feat(lsp): add lspAutoStart config, remove unused LSP client/process/transport modules
- Add lsp_auto_start field to RuntimeFeatureConfig (default: true) - Add lspAutoStart bool field validation in config_validate - Parse lspAutoStart from config JSON - Auto-start discovered LSP servers on REPL init when enabled - Add /lsp toggle command to enable/disable auto-start at runtime - Remove lsp_client.rs, lsp_process.rs, lsp_transport.rs (2831 lines) — functionality consolidated into discovery-based auto-start - Show auto-start status in /lsp status output
This commit is contained in:
parent
d610657cf0
commit
c28092d540
|
|
@ -225,6 +225,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
|
||||||
expected: FieldType::Object,
|
expected: FieldType::Object,
|
||||||
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
|
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
|
||||||
},
|
},
|
||||||
|
FieldSpec {
|
||||||
|
name: "lspAutoStart",
|
||||||
|
expected: FieldType::Bool,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const HOOKS_FIELDS: &[FieldSpec] = &[
|
const HOOKS_FIELDS: &[FieldSpec] = &[
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -112,16 +112,64 @@ pub fn command_exists_on_path(command: &str) -> bool {
|
||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a binary is a rustup proxy by running `--version` and looking for
|
||||||
|
/// the "Unknown binary" error message that rustup prints for uninstalled tools.
|
||||||
|
#[must_use]
|
||||||
|
fn is_rustup_proxy(command: &str) -> bool {
|
||||||
|
let Ok(output) = Command::new(command).arg("--version").output() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// rustup proxy exits non-zero and prints "error: Unknown binary '...' in official toolchain"
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
stderr.contains("Unknown binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a rustup component is actually functional by running it through
|
||||||
|
/// `rustup run stable <command> --version`. Returns `true` only if the process
|
||||||
|
/// exits successfully (exit code 0), meaning the component is installed.
|
||||||
|
#[must_use]
|
||||||
|
fn rustup_component_works(component: &str) -> bool {
|
||||||
|
Command::new("rustup")
|
||||||
|
.args(["run", "stable", component, "--version"])
|
||||||
|
.output()
|
||||||
|
.is_ok_and(|o| o.status.success())
|
||||||
|
}
|
||||||
|
|
||||||
/// Discover LSP servers that are actually installed on the current system.
|
/// Discover LSP servers that are actually installed on the current system.
|
||||||
///
|
///
|
||||||
/// Iterates over the known server table and returns only those whose command
|
/// Iterates over the known server table and returns only those whose command
|
||||||
/// is found on `PATH`.
|
/// is found on `PATH` **and** is actually functional. For `rust-analyzer`,
|
||||||
|
/// rustup ships a stub proxy that always exists on PATH but prints
|
||||||
|
/// "Unknown binary" when the component isn't installed. We detect that
|
||||||
|
/// case and either rewrite to `rustup run stable rust-analyzer` (when the
|
||||||
|
/// component is installed) or skip the server entirely (when it's not).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn discover_available_servers() -> Vec<LspServerDescriptor> {
|
pub fn discover_available_servers() -> Vec<LspServerDescriptor> {
|
||||||
KNOWN_LSP_SERVERS_TABLE
|
KNOWN_LSP_SERVERS_TABLE
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|desc| command_exists_on_path(desc.command))
|
.filter(|desc| command_exists_on_path(desc.command))
|
||||||
.map(StaticLspServerDescriptor::to_descriptor)
|
.filter_map(|desc| {
|
||||||
|
let mut server = desc.to_descriptor();
|
||||||
|
// rustup ships a proxy `rust-analyzer` that exists on PATH but
|
||||||
|
// errors with "Unknown binary" when the component isn't installed.
|
||||||
|
if desc.command == "rust-analyzer" && is_rustup_proxy("rust-analyzer") {
|
||||||
|
// The component isn't installed under this toolchain.
|
||||||
|
// Check if `rustup run stable rust-analyzer --version` works
|
||||||
|
// (e.g. user installed it via rustup but the proxy is misconfigured).
|
||||||
|
if rustup_component_works("rust-analyzer") {
|
||||||
|
server.command = "rustup".to_string();
|
||||||
|
server.args = vec![
|
||||||
|
"run".to_string(),
|
||||||
|
"stable".to_string(),
|
||||||
|
"rust-analyzer".to_string(),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Component truly not installed — skip it.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(server)
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,929 +0,0 @@
|
||||||
//! LSP process manager: spawns language servers and drives the LSP lifecycle.
|
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use serde_json::Value as JsonValue;
|
|
||||||
|
|
||||||
use crate::lsp_client::{
|
|
||||||
LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerStatus, LspSymbol,
|
|
||||||
};
|
|
||||||
use crate::lsp_transport::{LspTransport, LspTransportError};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct LspProcess {
|
|
||||||
transport: LspTransport,
|
|
||||||
language: String,
|
|
||||||
root_uri: String,
|
|
||||||
capabilities: Option<JsonValue>,
|
|
||||||
status: LspServerStatus,
|
|
||||||
open_files: HashSet<String>,
|
|
||||||
version_counter: HashMap<String, u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::cast_possible_truncation)]
|
|
||||||
impl LspProcess {
|
|
||||||
/// Spawn a language server process and perform the LSP initialize handshake.
|
|
||||||
pub async fn start(
|
|
||||||
command: &str,
|
|
||||||
args: &[String],
|
|
||||||
root_path: &Path,
|
|
||||||
) -> Result<Self, LspProcessError> {
|
|
||||||
let transport = LspTransport::spawn(command, args)
|
|
||||||
.map_err(|e| LspProcessError::Transport(LspTransportError::Io(e)))?;
|
|
||||||
|
|
||||||
let canonical = canonicalize_root(root_path)?;
|
|
||||||
let root_uri = format!("file://{canonical}");
|
|
||||||
|
|
||||||
let mut process = Self {
|
|
||||||
transport,
|
|
||||||
language: command.to_owned(),
|
|
||||||
root_uri: root_uri.clone(),
|
|
||||||
capabilities: None,
|
|
||||||
status: LspServerStatus::Starting,
|
|
||||||
open_files: HashSet::new(),
|
|
||||||
version_counter: HashMap::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
process.initialize(&canonical).await?;
|
|
||||||
process.status = LspServerStatus::Connected;
|
|
||||||
|
|
||||||
Ok(process)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send the LSP `initialize` request followed by the `initialized` notification.
|
|
||||||
async fn initialize(&mut self, root_path: &str) -> Result<JsonValue, LspProcessError> {
|
|
||||||
let root_uri = format!("file://{root_path}");
|
|
||||||
let pid = std::process::id();
|
|
||||||
|
|
||||||
let params = serde_json::json!({
|
|
||||||
"processId": pid,
|
|
||||||
"rootUri": root_uri,
|
|
||||||
"capabilities": {
|
|
||||||
"textDocument": {
|
|
||||||
"hover": { "contentFormat": ["markdown", "plaintext"] },
|
|
||||||
"definition": { "linkSupport": true },
|
|
||||||
"references": {},
|
|
||||||
"completion": {
|
|
||||||
"completionItem": { "snippetSupport": false }
|
|
||||||
},
|
|
||||||
"documentSymbol": { "hierarchicalDocumentSymbolSupport": true },
|
|
||||||
"publishDiagnostics": { "relatedInformation": true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.transport
|
|
||||||
.send_request("initialize", Some(params))
|
|
||||||
.await
|
|
||||||
.map_err(LspProcessError::Transport)?;
|
|
||||||
|
|
||||||
let result = response
|
|
||||||
.into_result()
|
|
||||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
|
||||||
|
|
||||||
self.capabilities = Some(result.clone());
|
|
||||||
|
|
||||||
self.transport
|
|
||||||
.send_notification("initialized", Some(serde_json::json!({})))
|
|
||||||
.await
|
|
||||||
.map_err(LspProcessError::Transport)?;
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gracefully shut down the language server.
|
|
||||||
pub async fn shutdown(&mut self) -> Result<(), LspProcessError> {
|
|
||||||
self.status = LspServerStatus::Disconnected;
|
|
||||||
|
|
||||||
let shutdown_result = self
|
|
||||||
.transport
|
|
||||||
.send_request("shutdown", None)
|
|
||||||
.await
|
|
||||||
.map_err(LspProcessError::Transport);
|
|
||||||
|
|
||||||
if shutdown_result.is_ok() {
|
|
||||||
self.transport
|
|
||||||
.send_notification("exit", None)
|
|
||||||
.await
|
|
||||||
.map_err(LspProcessError::Transport)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.transport
|
|
||||||
.shutdown()
|
|
||||||
.await
|
|
||||||
.map_err(LspProcessError::Transport)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Query hover information at a position.
|
|
||||||
pub async fn hover(
|
|
||||||
&mut self,
|
|
||||||
path: &str,
|
|
||||||
line: u32,
|
|
||||||
character: u32,
|
|
||||||
) -> Result<Option<LspHoverResult>, LspProcessError> {
|
|
||||||
let uri = path_to_uri(path);
|
|
||||||
let params = text_document_position_params(&uri, line, character);
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.transport
|
|
||||||
.send_request("textDocument/hover", Some(params))
|
|
||||||
.await
|
|
||||||
.map_err(LspProcessError::Transport)?;
|
|
||||||
|
|
||||||
let result = response
|
|
||||||
.into_result()
|
|
||||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
|
||||||
|
|
||||||
if result.is_null() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(parse_hover(&result))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Go to definition at a position.
|
|
||||||
pub async fn goto_definition(
|
|
||||||
&mut self,
|
|
||||||
path: &str,
|
|
||||||
line: u32,
|
|
||||||
character: u32,
|
|
||||||
) -> Result<Vec<LspLocation>, LspProcessError> {
|
|
||||||
let uri = path_to_uri(path);
|
|
||||||
let params = text_document_position_params(&uri, line, character);
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.transport
|
|
||||||
.send_request("textDocument/definition", Some(params))
|
|
||||||
.await
|
|
||||||
.map_err(LspProcessError::Transport)?;
|
|
||||||
|
|
||||||
let result = response
|
|
||||||
.into_result()
|
|
||||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
|
||||||
|
|
||||||
Ok(parse_locations(&result))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find references at a position.
|
|
||||||
pub async fn references(
|
|
||||||
&mut self,
|
|
||||||
path: &str,
|
|
||||||
line: u32,
|
|
||||||
character: u32,
|
|
||||||
) -> Result<Vec<LspLocation>, LspProcessError> {
|
|
||||||
let uri = path_to_uri(path);
|
|
||||||
let params = serde_json::json!({
|
|
||||||
"textDocument": { "uri": uri },
|
|
||||||
"position": { "line": line, "character": character },
|
|
||||||
"context": { "includeDeclaration": true }
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.transport
|
|
||||||
.send_request("textDocument/references", Some(params))
|
|
||||||
.await
|
|
||||||
.map_err(LspProcessError::Transport)?;
|
|
||||||
|
|
||||||
let result = response
|
|
||||||
.into_result()
|
|
||||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
|
||||||
|
|
||||||
Ok(parse_locations(&result))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get document symbols for a file.
|
|
||||||
pub async fn document_symbols(
|
|
||||||
&mut self,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<Vec<LspSymbol>, LspProcessError> {
|
|
||||||
let uri = path_to_uri(path);
|
|
||||||
let params = serde_json::json!({
|
|
||||||
"textDocument": { "uri": uri }
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.transport
|
|
||||||
.send_request("textDocument/documentSymbol", Some(params))
|
|
||||||
.await
|
|
||||||
.map_err(LspProcessError::Transport)?;
|
|
||||||
|
|
||||||
let result = response
|
|
||||||
.into_result()
|
|
||||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
|
||||||
|
|
||||||
if result.is_null() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(parse_symbols(&result, path))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get completions at a position.
|
|
||||||
pub async fn completion(
|
|
||||||
&mut self,
|
|
||||||
path: &str,
|
|
||||||
line: u32,
|
|
||||||
character: u32,
|
|
||||||
) -> Result<Vec<LspCompletionItem>, LspProcessError> {
|
|
||||||
let uri = path_to_uri(path);
|
|
||||||
let params = text_document_position_params(&uri, line, character);
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.transport
|
|
||||||
.send_request("textDocument/completion", Some(params))
|
|
||||||
.await
|
|
||||||
.map_err(LspProcessError::Transport)?;
|
|
||||||
|
|
||||||
let result = response
|
|
||||||
.into_result()
|
|
||||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
|
||||||
|
|
||||||
if result.is_null() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
// The response may be a CompletionList or a plain array.
|
|
||||||
let items = if let Some(list) = result.get("items") {
|
|
||||||
list
|
|
||||||
} else {
|
|
||||||
&result
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(parse_completions(items))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a document.
|
|
||||||
pub async fn format(&mut self, path: &str) -> Result<Vec<JsonValue>, LspProcessError> {
|
|
||||||
let uri = path_to_uri(path);
|
|
||||||
let params = serde_json::json!({
|
|
||||||
"textDocument": { "uri": uri },
|
|
||||||
"options": { "tabSize": 4, "insertSpaces": true }
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.transport
|
|
||||||
.send_request("textDocument/formatting", Some(params))
|
|
||||||
.await
|
|
||||||
.map_err(LspProcessError::Transport)?;
|
|
||||||
|
|
||||||
let result = response
|
|
||||||
.into_result()
|
|
||||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
|
||||||
|
|
||||||
if result.is_null() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
match result.as_array() {
|
|
||||||
Some(arr) => Ok(arr.clone()),
|
|
||||||
None => Ok(Vec::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notify the server that a file was opened. Sends `textDocument/didOpen`.
|
|
||||||
/// No-op if the file is already tracked as open.
|
|
||||||
pub async fn did_open(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> {
|
|
||||||
if self.open_files.contains(path) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let uri = path_to_uri(path);
|
|
||||||
let language_id = language_id_for_path(path);
|
|
||||||
let params = serde_json::json!({
|
|
||||||
"textDocument": {
|
|
||||||
"uri": uri,
|
|
||||||
"languageId": language_id,
|
|
||||||
"version": 0,
|
|
||||||
"text": content
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
self.transport
|
|
||||||
.send_notification("textDocument/didOpen", Some(params))
|
|
||||||
.await
|
|
||||||
.map_err(LspProcessError::Transport)?;
|
|
||||||
|
|
||||||
self.open_files.insert(path.to_owned());
|
|
||||||
self.version_counter.insert(path.to_owned(), 0);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notify the server that a file's content changed. Sends `textDocument/didChange`.
|
|
||||||
pub async fn did_change(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> {
|
|
||||||
let version = self.version_counter.get(path).map_or(1, |v| v + 1);
|
|
||||||
|
|
||||||
let uri = path_to_uri(path);
|
|
||||||
let params = serde_json::json!({
|
|
||||||
"textDocument": { "uri": uri, "version": version },
|
|
||||||
"contentChanges": [{ "text": content }]
|
|
||||||
});
|
|
||||||
|
|
||||||
self.transport
|
|
||||||
.send_notification("textDocument/didChange", Some(params))
|
|
||||||
.await
|
|
||||||
.map_err(LspProcessError::Transport)?;
|
|
||||||
|
|
||||||
self.version_counter.insert(path.to_owned(), version);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drain queued server notifications and extract `publishDiagnostics`.
|
|
||||||
#[allow(clippy::redundant_closure_for_method_calls)]
|
|
||||||
pub fn drain_diagnostics(&mut self) -> Vec<LspDiagnostic> {
|
|
||||||
let notifications = self.transport.drain_notifications();
|
|
||||||
let mut diagnostics = Vec::new();
|
|
||||||
for n in ¬ifications {
|
|
||||||
if n.method == "textDocument/publishDiagnostics" {
|
|
||||||
if let Some(params) = &n.params {
|
|
||||||
if let Some(uri) = params.get("uri").and_then(|v| v.as_str()) {
|
|
||||||
let path = uri_to_path(uri);
|
|
||||||
if let Some(diags) = params.get("diagnostics").and_then(|v| v.as_array())
|
|
||||||
{
|
|
||||||
for d in diags {
|
|
||||||
diagnostics.push(LspDiagnostic {
|
|
||||||
path: path.clone(),
|
|
||||||
line: d
|
|
||||||
.get("range")
|
|
||||||
.and_then(|r| r.get("start"))
|
|
||||||
.and_then(|s| s.get("line"))
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.map_or(0, |v| v as u32),
|
|
||||||
character: d
|
|
||||||
.get("range")
|
|
||||||
.and_then(|r| r.get("start"))
|
|
||||||
.and_then(|s| s.get("character"))
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.map_or(0, |v| v as u32),
|
|
||||||
severity: d
|
|
||||||
.get("severity")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.map_or_else(|| "error".to_owned(), severity_name),
|
|
||||||
message: d
|
|
||||||
.get("message")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_owned(),
|
|
||||||
source: d
|
|
||||||
.get("source")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::to_owned),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diagnostics
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn status(&self) -> LspServerStatus {
|
|
||||||
self.status
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn language(&self) -> &str {
|
|
||||||
&self.language
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn root_uri(&self) -> &str {
|
|
||||||
&self.root_uri
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Error type
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum LspProcessError {
|
|
||||||
Transport(LspTransportError),
|
|
||||||
InvalidPath(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for LspProcessError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Transport(e) => write!(f, "LSP transport error: {e}"),
|
|
||||||
Self::InvalidPath(p) => write!(f, "invalid path: {p}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for LspProcessError {
|
|
||||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
||||||
match self {
|
|
||||||
Self::Transport(e) => Some(e),
|
|
||||||
Self::InvalidPath(_) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn canonicalize_root(path: &Path) -> Result<String, LspProcessError> {
|
|
||||||
path.canonicalize()
|
|
||||||
.map_err(|e| LspProcessError::InvalidPath(format!("{}: {e}", path.display())))
|
|
||||||
.map(|p| p.to_string_lossy().into_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path_to_uri(path: &str) -> String {
|
|
||||||
let canonical = std::path::Path::new(path);
|
|
||||||
if canonical.is_absolute() {
|
|
||||||
format!("file://{path}")
|
|
||||||
} else {
|
|
||||||
let resolved = std::env::current_dir()
|
|
||||||
.map_or_else(|_| canonical.to_path_buf(), |d| d.join(path));
|
|
||||||
let canonicalized = resolved
|
|
||||||
.canonicalize()
|
|
||||||
.unwrap_or(resolved)
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned();
|
|
||||||
format!("file://{canonicalized}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn text_document_position_params(uri: &str, line: u32, character: u32) -> JsonValue {
|
|
||||||
serde_json::json!({
|
|
||||||
"textDocument": { "uri": uri },
|
|
||||||
"position": { "line": line, "character": character }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uri_to_path(uri: &str) -> String {
|
|
||||||
uri.strip_prefix("file://").unwrap_or(uri).to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn language_id_for_path(path: &str) -> String {
|
|
||||||
let ext = std::path::Path::new(path)
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
match ext {
|
|
||||||
"rs" => "rust",
|
|
||||||
"ts" => "typescript",
|
|
||||||
"tsx" => "typescriptreact",
|
|
||||||
"js" => "javascript",
|
|
||||||
"jsx" => "javascriptreact",
|
|
||||||
"py" => "python",
|
|
||||||
"go" => "go",
|
|
||||||
"java" => "java",
|
|
||||||
"c" | "h" => "c",
|
|
||||||
"cpp" | "hpp" | "cc" => "cpp",
|
|
||||||
"rb" => "ruby",
|
|
||||||
"lua" => "lua",
|
|
||||||
_ => ext,
|
|
||||||
}
|
|
||||||
.to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn severity_name(code: u64) -> String {
|
|
||||||
match code {
|
|
||||||
1 => "error".to_owned(),
|
|
||||||
2 => "warning".to_owned(),
|
|
||||||
3 => "info".to_owned(),
|
|
||||||
4 => "hint".to_owned(),
|
|
||||||
_ => format!("unknown({code})"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_hover(value: &JsonValue) -> Option<LspHoverResult> {
|
|
||||||
let contents = value.get("contents")?;
|
|
||||||
|
|
||||||
// MarkupContent: { kind, value }
|
|
||||||
if let (Some(kind), Some(val)) = (contents.get("kind"), contents.get("value")) {
|
|
||||||
let language = if kind.as_str() == Some("plaintext") {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(kind.as_str().unwrap_or("markdown").to_owned())
|
|
||||||
};
|
|
||||||
return Some(LspHoverResult {
|
|
||||||
content: val.as_str().unwrap_or("").to_owned(),
|
|
||||||
language,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkedString object: { language, value }
|
|
||||||
if let (Some(lang), Some(val)) = (contents.get("language"), contents.get("value")) {
|
|
||||||
return Some(LspHoverResult {
|
|
||||||
content: val.as_str().unwrap_or("").to_owned(),
|
|
||||||
language: Some(lang.as_str().unwrap_or("").to_owned()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plain string MarkedString
|
|
||||||
if let Some(s) = contents.as_str() {
|
|
||||||
return Some(LspHoverResult {
|
|
||||||
content: s.to_owned(),
|
|
||||||
language: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Array of MarkedString
|
|
||||||
if let Some(arr) = contents.as_array() {
|
|
||||||
let parts: Vec<&str> = arr
|
|
||||||
.iter()
|
|
||||||
.filter_map(|item| {
|
|
||||||
if let Some(s) = item.as_str() {
|
|
||||||
Some(s)
|
|
||||||
} else {
|
|
||||||
item.get("value").and_then(JsonValue::as_str)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if parts.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
return Some(LspHoverResult {
|
|
||||||
content: parts.join("\n"),
|
|
||||||
language: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::cast_possible_truncation)]
|
|
||||||
fn parse_locations(value: &JsonValue) -> Vec<LspLocation> {
|
|
||||||
let Some(locations) = value.as_array() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
|
|
||||||
locations
|
|
||||||
.iter()
|
|
||||||
.filter_map(|loc| {
|
|
||||||
let uri = loc.get("uri")?.as_str()?;
|
|
||||||
let path = uri_to_path(uri);
|
|
||||||
let range = loc.get("range")?;
|
|
||||||
let start = range.get("start")?;
|
|
||||||
let end = range.get("end")?;
|
|
||||||
|
|
||||||
Some(LspLocation {
|
|
||||||
path,
|
|
||||||
line: start.get("line")?.as_u64()? as u32,
|
|
||||||
character: start.get("character")?.as_u64()? as u32,
|
|
||||||
end_line: end
|
|
||||||
.get("line")
|
|
||||||
.and_then(JsonValue::as_u64)
|
|
||||||
.map(|v| v as u32),
|
|
||||||
end_character: end
|
|
||||||
.get("character")
|
|
||||||
.and_then(JsonValue::as_u64)
|
|
||||||
.map(|v| v as u32),
|
|
||||||
preview: None,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_symbols(items: &[JsonValue], path: &str, out: &mut Vec<LspSymbol>) {
|
|
||||||
for item in items {
|
|
||||||
let name = item.get("name").and_then(JsonValue::as_str).unwrap_or("");
|
|
||||||
let kind = item
|
|
||||||
.get("kind")
|
|
||||||
.and_then(JsonValue::as_u64)
|
|
||||||
.map_or_else(|| "Unknown".into(), symbol_kind_name);
|
|
||||||
|
|
||||||
let (sym_path, line, character) = if let Some(range) = item.get("range") {
|
|
||||||
let start = range.get("start");
|
|
||||||
(
|
|
||||||
path.to_owned(),
|
|
||||||
u32::try_from(
|
|
||||||
start
|
|
||||||
.and_then(|s| s.get("line"))
|
|
||||||
.and_then(JsonValue::as_u64)
|
|
||||||
.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.unwrap_or(0),
|
|
||||||
u32::try_from(
|
|
||||||
start
|
|
||||||
.and_then(|s| s.get("character"))
|
|
||||||
.and_then(JsonValue::as_u64)
|
|
||||||
.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.unwrap_or(0),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(path.to_owned(), 0, 0)
|
|
||||||
};
|
|
||||||
|
|
||||||
out.push(LspSymbol {
|
|
||||||
name: name.to_owned(),
|
|
||||||
kind: kind.clone(),
|
|
||||||
path: sym_path,
|
|
||||||
line,
|
|
||||||
character,
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(children) = item.get("children").and_then(JsonValue::as_array) {
|
|
||||||
extract_symbols(children, path, out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_symbols(value: &JsonValue, default_path: &str) -> Vec<LspSymbol> {
|
|
||||||
let Some(items) = value.as_array() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut result = Vec::new();
|
|
||||||
extract_symbols(items, default_path, &mut result);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_completions(value: &JsonValue) -> Vec<LspCompletionItem> {
|
|
||||||
let Some(items) = value.as_array() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
|
|
||||||
items
|
|
||||||
.iter()
|
|
||||||
.map(|item| LspCompletionItem {
|
|
||||||
label: item
|
|
||||||
.get("label")
|
|
||||||
.and_then(JsonValue::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_owned(),
|
|
||||||
kind: item
|
|
||||||
.get("kind")
|
|
||||||
.and_then(JsonValue::as_u64)
|
|
||||||
.map(completion_kind_name),
|
|
||||||
detail: item
|
|
||||||
.get("detail")
|
|
||||||
.and_then(JsonValue::as_str)
|
|
||||||
.map(str::to_owned),
|
|
||||||
insert_text: item
|
|
||||||
.get("insertText")
|
|
||||||
.and_then(JsonValue::as_str)
|
|
||||||
.map(str::to_owned),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn symbol_kind_name(kind: u64) -> String {
|
|
||||||
match kind {
|
|
||||||
1 => "File".into(),
|
|
||||||
2 => "Module".into(),
|
|
||||||
3 => "Namespace".into(),
|
|
||||||
4 => "Package".into(),
|
|
||||||
5 => "Class".into(),
|
|
||||||
6 => "Method".into(),
|
|
||||||
7 => "Property".into(),
|
|
||||||
8 => "Field".into(),
|
|
||||||
9 => "Constructor".into(),
|
|
||||||
10 => "Enum".into(),
|
|
||||||
11 => "Interface".into(),
|
|
||||||
12 => "Function".into(),
|
|
||||||
13 => "Variable".into(),
|
|
||||||
14 => "Constant".into(),
|
|
||||||
15 => "String".into(),
|
|
||||||
16 => "Number".into(),
|
|
||||||
17 => "Boolean".into(),
|
|
||||||
18 => "Array".into(),
|
|
||||||
19 => "Object".into(),
|
|
||||||
20 => "Key".into(),
|
|
||||||
21 => "Null".into(),
|
|
||||||
22 => "EnumMember".into(),
|
|
||||||
23 => "Struct".into(),
|
|
||||||
24 => "Event".into(),
|
|
||||||
25 => "Operator".into(),
|
|
||||||
26 => "TypeParameter".into(),
|
|
||||||
_ => format!("Unknown({kind})"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn completion_kind_name(kind: u64) -> String {
|
|
||||||
match kind {
|
|
||||||
1 => "Text".into(),
|
|
||||||
2 => "Method".into(),
|
|
||||||
3 => "Function".into(),
|
|
||||||
4 => "Constructor".into(),
|
|
||||||
5 => "Field".into(),
|
|
||||||
6 => "Variable".into(),
|
|
||||||
7 => "Class".into(),
|
|
||||||
8 => "Interface".into(),
|
|
||||||
9 => "Module".into(),
|
|
||||||
10 => "Property".into(),
|
|
||||||
11 => "Unit".into(),
|
|
||||||
12 => "Value".into(),
|
|
||||||
13 => "Enum".into(),
|
|
||||||
14 => "Keyword".into(),
|
|
||||||
15 => "Snippet".into(),
|
|
||||||
16 => "Color".into(),
|
|
||||||
17 => "File".into(),
|
|
||||||
18 => "Reference".into(),
|
|
||||||
19 => "Folder".into(),
|
|
||||||
20 => "EnumMember".into(),
|
|
||||||
21 => "Constant".into(),
|
|
||||||
22 => "Struct".into(),
|
|
||||||
23 => "Event".into(),
|
|
||||||
24 => "Operator".into(),
|
|
||||||
25 => "TypeParameter".into(),
|
|
||||||
_ => format!("Unknown({kind})"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Requires rust-analyzer to be installed on the system.
|
|
||||||
/// Run with: cargo test -p runtime -- --ignored
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore = "requires rust-analyzer installed on PATH"]
|
|
||||||
async fn spawn_and_initialize_rust_analyzer() {
|
|
||||||
let root = std::env::current_dir().expect("should have cwd");
|
|
||||||
let process = LspProcess::start("rust-analyzer", &[], &root).await;
|
|
||||||
assert!(process.is_ok(), "should spawn and initialize rust-analyzer");
|
|
||||||
|
|
||||||
let mut process = process.unwrap();
|
|
||||||
assert_eq!(process.status(), LspServerStatus::Connected);
|
|
||||||
assert_eq!(process.language(), "rust-analyzer");
|
|
||||||
|
|
||||||
let shutdown_result = process.shutdown().await;
|
|
||||||
assert!(shutdown_result.is_ok(), "shutdown should succeed: {shutdown_result:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Requires rust-analyzer to be installed and a Rust project on disk.
|
|
||||||
/// Run with: cargo test -p runtime -- --ignored
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore = "requires rust-analyzer installed on PATH"]
|
|
||||||
async fn hover_on_real_file() {
|
|
||||||
let root = std::env::current_dir().expect("should have cwd");
|
|
||||||
let mut process = LspProcess::start("rust-analyzer", &[], &root)
|
|
||||||
.await
|
|
||||||
.expect("should start rust-analyzer");
|
|
||||||
|
|
||||||
// Try hover on src/main.rs — the result might be None if the file
|
|
||||||
// doesn't exist at that path, but the call itself should not error.
|
|
||||||
let file_path = root.join("src").join("main.rs");
|
|
||||||
let path_str = file_path.to_string_lossy();
|
|
||||||
let result = process.hover(&path_str, 0, 0).await;
|
|
||||||
assert!(result.is_ok(), "hover should not return an error: {:?}", result.err());
|
|
||||||
|
|
||||||
let _ = process.shutdown().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_hover_markup_content() {
|
|
||||||
let value = serde_json::json!({
|
|
||||||
"contents": {
|
|
||||||
"kind": "plaintext",
|
|
||||||
"value": "fn main()"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let result = parse_hover(&value);
|
|
||||||
assert!(result.is_some());
|
|
||||||
let hover = result.unwrap();
|
|
||||||
assert_eq!(hover.content, "fn main()");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_hover_marked_string_object() {
|
|
||||||
let value = serde_json::json!({
|
|
||||||
"contents": {
|
|
||||||
"language": "rust",
|
|
||||||
"value": "pub fn foo()"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let result = parse_hover(&value);
|
|
||||||
assert!(result.is_some());
|
|
||||||
let hover = result.unwrap();
|
|
||||||
assert_eq!(hover.content, "pub fn foo()");
|
|
||||||
assert_eq!(hover.language.as_deref(), Some("rust"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_hover_plain_string() {
|
|
||||||
let value = serde_json::json!({
|
|
||||||
"contents": "some text"
|
|
||||||
});
|
|
||||||
let result = parse_hover(&value);
|
|
||||||
assert!(result.is_some());
|
|
||||||
let hover = result.unwrap();
|
|
||||||
assert_eq!(hover.content, "some text");
|
|
||||||
assert!(hover.language.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_hover_array_of_marked_strings() {
|
|
||||||
let value = serde_json::json!({
|
|
||||||
"contents": [
|
|
||||||
"first line",
|
|
||||||
{ "language": "rust", "value": "fn bar()" }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
let result = parse_hover(&value);
|
|
||||||
assert!(result.is_some());
|
|
||||||
let hover = result.unwrap();
|
|
||||||
assert!(hover.content.contains("first line"));
|
|
||||||
assert!(hover.content.contains("fn bar()"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_locations_empty_array() {
|
|
||||||
let value = serde_json::json!([]);
|
|
||||||
let locations = parse_locations(&value);
|
|
||||||
assert!(locations.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_locations_valid() {
|
|
||||||
let value = serde_json::json!([
|
|
||||||
{
|
|
||||||
"uri": "file:///tmp/test.rs",
|
|
||||||
"range": {
|
|
||||||
"start": { "line": 5, "character": 10 },
|
|
||||||
"end": { "line": 5, "character": 15 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
let locations = parse_locations(&value);
|
|
||||||
assert_eq!(locations.len(), 1);
|
|
||||||
assert_eq!(locations[0].line, 5);
|
|
||||||
assert_eq!(locations[0].character, 10);
|
|
||||||
assert_eq!(locations[0].end_line, Some(5));
|
|
||||||
assert_eq!(locations[0].end_character, Some(15));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_symbols_basic() {
|
|
||||||
let value = serde_json::json!([
|
|
||||||
{
|
|
||||||
"name": "main",
|
|
||||||
"kind": 12,
|
|
||||||
"range": {
|
|
||||||
"start": { "line": 1, "character": 0 },
|
|
||||||
"end": { "line": 5, "character": 1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
let symbols = parse_symbols(&value, "/tmp/test.rs");
|
|
||||||
assert_eq!(symbols.len(), 1);
|
|
||||||
assert_eq!(symbols[0].name, "main");
|
|
||||||
assert_eq!(symbols[0].kind, "Function");
|
|
||||||
assert_eq!(symbols[0].line, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_completions_basic() {
|
|
||||||
let value = serde_json::json!([
|
|
||||||
{ "label": "foo", "kind": 3, "detail": "fn foo()" },
|
|
||||||
{ "label": "bar", "kind": 6 }
|
|
||||||
]);
|
|
||||||
let completions = parse_completions(&value);
|
|
||||||
assert_eq!(completions.len(), 2);
|
|
||||||
assert_eq!(completions[0].label, "foo");
|
|
||||||
assert_eq!(completions[0].kind.as_deref(), Some("Function"));
|
|
||||||
assert_eq!(completions[0].detail.as_deref(), Some("fn foo()"));
|
|
||||||
assert_eq!(completions[1].label, "bar");
|
|
||||||
assert_eq!(completions[1].kind.as_deref(), Some("Variable"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn symbol_kind_name_all_variants() {
|
|
||||||
assert_eq!(symbol_kind_name(1), "File");
|
|
||||||
assert_eq!(symbol_kind_name(6), "Method");
|
|
||||||
assert_eq!(symbol_kind_name(12), "Function");
|
|
||||||
assert_eq!(symbol_kind_name(13), "Variable");
|
|
||||||
assert_eq!(symbol_kind_name(23), "Struct");
|
|
||||||
assert_eq!(symbol_kind_name(99), "Unknown(99)");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn completion_kind_name_all_variants() {
|
|
||||||
assert_eq!(completion_kind_name(1), "Text");
|
|
||||||
assert_eq!(completion_kind_name(3), "Function");
|
|
||||||
assert_eq!(completion_kind_name(6), "Variable");
|
|
||||||
assert_eq!(completion_kind_name(14), "Keyword");
|
|
||||||
assert_eq!(completion_kind_name(99), "Unknown(99)");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn text_document_position_params_structure() {
|
|
||||||
let params = text_document_position_params("file:///test.rs", 5, 10);
|
|
||||||
assert_eq!(params["textDocument"]["uri"], "file:///test.rs");
|
|
||||||
assert_eq!(params["position"]["line"], 5);
|
|
||||||
assert_eq!(params["position"]["character"], 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn path_to_uri_absolute() {
|
|
||||||
let uri = path_to_uri("/tmp/test.rs");
|
|
||||||
assert_eq!(uri, "file:///tmp/test.rs");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uri_to_path_extracts_path() {
|
|
||||||
assert_eq!(uri_to_path("file:///tmp/test.rs"), "/tmp/test.rs");
|
|
||||||
assert_eq!(uri_to_path("/no/prefix"), "/no/prefix");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,560 +0,0 @@
|
||||||
use std::io;
|
|
||||||
use std::process::Stdio;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value as JsonValue;
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
|
||||||
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
|
||||||
use tokio::time::timeout;
|
|
||||||
|
|
||||||
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub enum LspId {
|
|
||||||
Number(u64),
|
|
||||||
String(String),
|
|
||||||
Null,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct LspRequest {
|
|
||||||
pub jsonrpc: String,
|
|
||||||
pub id: LspId,
|
|
||||||
pub method: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub params: Option<JsonValue>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LspRequest {
|
|
||||||
pub fn new(id: LspId, method: impl Into<String>, params: Option<JsonValue>) -> Self {
|
|
||||||
Self {
|
|
||||||
jsonrpc: "2.0".to_string(),
|
|
||||||
id,
|
|
||||||
method: method.into(),
|
|
||||||
params,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct LspNotification {
|
|
||||||
pub jsonrpc: String,
|
|
||||||
pub method: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub params: Option<JsonValue>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LspNotification {
|
|
||||||
pub fn new(method: impl Into<String>, params: Option<JsonValue>) -> Self {
|
|
||||||
Self {
|
|
||||||
jsonrpc: "2.0".to_string(),
|
|
||||||
method: method.into(),
|
|
||||||
params,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct LspError {
|
|
||||||
pub code: i64,
|
|
||||||
pub message: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub data: Option<JsonValue>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct LspResponse {
|
|
||||||
pub jsonrpc: String,
|
|
||||||
pub id: LspId,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub result: Option<JsonValue>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub error: Option<LspError>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LspResponse {
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_error(&self) -> bool {
|
|
||||||
self.error.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn into_result(self) -> Result<JsonValue, LspError> {
|
|
||||||
if let Some(error) = self.error {
|
|
||||||
Err(error)
|
|
||||||
} else {
|
|
||||||
Ok(self.result.unwrap_or(JsonValue::Null))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A message received from an LSP server — either a response to a request
|
|
||||||
/// or a server-initiated notification (e.g. `textDocument/publishDiagnostics`).
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum LspServerMessage {
|
|
||||||
Response(LspResponse),
|
|
||||||
Notification(LspNotification),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum LspTransportError {
|
|
||||||
Io(io::Error),
|
|
||||||
Timeout { method: String, timeout: Duration },
|
|
||||||
JsonRpc(LspError),
|
|
||||||
InvalidResponse { method: String, details: String },
|
|
||||||
ServerExited,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for LspTransportError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Io(error) => write!(f, "{error}"),
|
|
||||||
Self::Timeout { method, timeout } => {
|
|
||||||
write!(f, "LSP request `{method}` timed out after {}s", timeout.as_secs())
|
|
||||||
}
|
|
||||||
Self::JsonRpc(error) => {
|
|
||||||
write!(f, "LSP JSON-RPC error: {} ({})", error.message, error.code)
|
|
||||||
}
|
|
||||||
Self::InvalidResponse { method, details } => {
|
|
||||||
write!(f, "LSP invalid response for `{method}`: {details}")
|
|
||||||
}
|
|
||||||
Self::ServerExited => write!(f, "LSP server process exited unexpectedly"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for LspTransportError {
|
|
||||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
||||||
match self {
|
|
||||||
Self::Io(error) => Some(error),
|
|
||||||
Self::JsonRpc(_) | Self::Timeout { .. } | Self::InvalidResponse { .. } | Self::ServerExited => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<io::Error> for LspTransportError {
|
|
||||||
fn from(value: io::Error) -> Self {
|
|
||||||
Self::Io(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct LspTransport {
|
|
||||||
child: Child,
|
|
||||||
stdin: ChildStdin,
|
|
||||||
stdout: BufReader<ChildStdout>,
|
|
||||||
next_id: u64,
|
|
||||||
request_timeout: Duration,
|
|
||||||
pending_notifications: Vec<LspNotification>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LspTransport {
|
|
||||||
pub fn spawn(command: &str, args: &[String]) -> io::Result<Self> {
|
|
||||||
Self::spawn_with_timeout(command, args, DEFAULT_REQUEST_TIMEOUT)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn spawn_with_timeout(
|
|
||||||
command: &str,
|
|
||||||
args: &[String],
|
|
||||||
request_timeout: Duration,
|
|
||||||
) -> io::Result<Self> {
|
|
||||||
let mut cmd = Command::new(command);
|
|
||||||
cmd.args(args)
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::inherit());
|
|
||||||
|
|
||||||
let mut child = cmd.spawn()?;
|
|
||||||
let stdin = child
|
|
||||||
.stdin
|
|
||||||
.take()
|
|
||||||
.ok_or_else(|| io::Error::other("LSP process missing stdin pipe"))?;
|
|
||||||
let stdout = child
|
|
||||||
.stdout
|
|
||||||
.take()
|
|
||||||
.ok_or_else(|| io::Error::other("LSP process missing stdout pipe"))?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
child,
|
|
||||||
stdin,
|
|
||||||
stdout: BufReader::new(stdout),
|
|
||||||
next_id: 1,
|
|
||||||
request_timeout,
|
|
||||||
pending_notifications: Vec::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construct an `LspTransport` from an already-spawned child process.
|
|
||||||
/// Primarily useful for testing.
|
|
||||||
#[cfg(test)]
|
|
||||||
fn from_child(mut child: Child, request_timeout: Duration) -> Self {
|
|
||||||
let stdin = child
|
|
||||||
.stdin
|
|
||||||
.take()
|
|
||||||
.expect("LSP process missing stdin pipe");
|
|
||||||
let stdout = child
|
|
||||||
.stdout
|
|
||||||
.take()
|
|
||||||
.expect("LSP process missing stdout pipe");
|
|
||||||
Self {
|
|
||||||
child,
|
|
||||||
stdin,
|
|
||||||
stdout: BufReader::new(stdout),
|
|
||||||
next_id: 1,
|
|
||||||
request_timeout,
|
|
||||||
pending_notifications: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn allocate_id(&mut self) -> LspId {
|
|
||||||
let id = self.next_id;
|
|
||||||
self.next_id += 1;
|
|
||||||
LspId::Number(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send_notification(
|
|
||||||
&mut self,
|
|
||||||
method: &str,
|
|
||||||
params: Option<JsonValue>,
|
|
||||||
) -> Result<(), LspTransportError> {
|
|
||||||
let notification = LspNotification::new(method, params);
|
|
||||||
let body = serde_json::to_vec(¬ification)
|
|
||||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
|
||||||
self.write_frame(&body).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send_request(
|
|
||||||
&mut self,
|
|
||||||
method: &str,
|
|
||||||
params: Option<JsonValue>,
|
|
||||||
) -> Result<LspResponse, LspTransportError> {
|
|
||||||
let id = self.allocate_id();
|
|
||||||
self.send_request_with_id(method, params, id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send_request_with_id(
|
|
||||||
&mut self,
|
|
||||||
method: &str,
|
|
||||||
params: Option<JsonValue>,
|
|
||||||
id: LspId,
|
|
||||||
) -> Result<LspResponse, LspTransportError> {
|
|
||||||
let request = LspRequest::new(id.clone(), method, params);
|
|
||||||
let body = serde_json::to_vec(&request)
|
|
||||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
|
||||||
self.write_frame(&body).await?;
|
|
||||||
|
|
||||||
let method_owned = method.to_string();
|
|
||||||
let timeout_duration = self.request_timeout;
|
|
||||||
let response = match timeout(timeout_duration, async {
|
|
||||||
loop {
|
|
||||||
match self.read_message().await {
|
|
||||||
Ok(LspServerMessage::Response(r)) => break Ok(r),
|
|
||||||
Ok(LspServerMessage::Notification(n)) => {
|
|
||||||
self.pending_notifications.push(n);
|
|
||||||
}
|
|
||||||
Err(e) => break Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(inner) => inner,
|
|
||||||
Err(_) => {
|
|
||||||
return Err(LspTransportError::Timeout {
|
|
||||||
method: method_owned,
|
|
||||||
timeout: timeout_duration,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}?;
|
|
||||||
|
|
||||||
if response.jsonrpc != "2.0" {
|
|
||||||
return Err(LspTransportError::InvalidResponse {
|
|
||||||
method: method.to_string(),
|
|
||||||
details: format!("unsupported jsonrpc version `{}`", response.jsonrpc),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if response.id != id {
|
|
||||||
return Err(LspTransportError::InvalidResponse {
|
|
||||||
method: method.to_string(),
|
|
||||||
details: format!(
|
|
||||||
"mismatched id: expected {:?}, got {:?}",
|
|
||||||
id, response.id
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(error) = &response.error {
|
|
||||||
return Err(LspTransportError::JsonRpc(error.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read a single message from the server, returning either a response or
|
|
||||||
/// a server-initiated notification (e.g. `publishDiagnostics`).
|
|
||||||
pub async fn read_message(&mut self) -> Result<LspServerMessage, LspTransportError> {
|
|
||||||
let payload = self.read_frame().await?;
|
|
||||||
let value: JsonValue = serde_json::from_slice(&payload).map_err(|error| {
|
|
||||||
LspTransportError::InvalidResponse {
|
|
||||||
method: "unknown".to_string(),
|
|
||||||
details: error.to_string(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Responses have an "id" field; notifications have "method" but no "id"
|
|
||||||
if value.get("id").is_some() {
|
|
||||||
let response: LspResponse = serde_json::from_value(value).map_err(|error| {
|
|
||||||
LspTransportError::InvalidResponse {
|
|
||||||
method: "unknown".to_string(),
|
|
||||||
details: format!("failed to parse response: {error}"),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
Ok(LspServerMessage::Response(response))
|
|
||||||
} else if value.get("method").is_some() {
|
|
||||||
let notification: LspNotification = serde_json::from_value(value).map_err(|error| {
|
|
||||||
LspTransportError::InvalidResponse {
|
|
||||||
method: "unknown".to_string(),
|
|
||||||
details: format!("failed to parse notification: {error}"),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
Ok(LspServerMessage::Notification(notification))
|
|
||||||
} else {
|
|
||||||
Err(LspTransportError::InvalidResponse {
|
|
||||||
method: "unknown".to_string(),
|
|
||||||
details: "message has neither 'id' nor 'method'".to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read a response from the server. Interleaved notifications are queued.
|
|
||||||
pub async fn read_response(&mut self) -> Result<LspResponse, LspTransportError> {
|
|
||||||
loop {
|
|
||||||
match self.read_message().await? {
|
|
||||||
LspServerMessage::Response(r) => return Ok(r),
|
|
||||||
LspServerMessage::Notification(n) => {
|
|
||||||
self.pending_notifications.push(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drain and return all queued server-initiated notifications.
|
|
||||||
pub fn drain_notifications(&mut self) -> Vec<LspNotification> {
|
|
||||||
std::mem::take(&mut self.pending_notifications)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn shutdown(&mut self) -> Result<(), LspTransportError> {
|
|
||||||
let _ = self
|
|
||||||
.send_notification("shutdown", None)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let _ = self.send_notification("exit", None).await;
|
|
||||||
|
|
||||||
match self.child.try_wait() {
|
|
||||||
Ok(Some(_)) => {}
|
|
||||||
Ok(None) | Err(_) => {
|
|
||||||
let _ = self.child.kill().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_alive(&mut self) -> bool {
|
|
||||||
matches!(self.child.try_wait(), Ok(None))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn write_frame(&mut self, payload: &[u8]) -> Result<(), LspTransportError> {
|
|
||||||
let header = format!("Content-Length: {}\r\n\r\n", payload.len());
|
|
||||||
self.stdin.write_all(header.as_bytes()).await?;
|
|
||||||
self.stdin.write_all(payload).await?;
|
|
||||||
self.stdin.flush().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_frame(&mut self) -> Result<Vec<u8>, LspTransportError> {
|
|
||||||
let mut content_length: Option<usize> = None;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let mut line = String::new();
|
|
||||||
let bytes_read = self.stdout.read_line(&mut line).await?;
|
|
||||||
if bytes_read == 0 {
|
|
||||||
return Err(LspTransportError::ServerExited);
|
|
||||||
}
|
|
||||||
if line == "\r\n" {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let header = line.trim_end_matches(['\r', '\n']);
|
|
||||||
if let Some((name, value)) = header.split_once(':') {
|
|
||||||
if name.trim().eq_ignore_ascii_case("Content-Length") {
|
|
||||||
let parsed = value
|
|
||||||
.trim()
|
|
||||||
.parse::<usize>()
|
|
||||||
.map_err(|error| LspTransportError::Io(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
error,
|
|
||||||
)))?;
|
|
||||||
content_length = Some(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_length = content_length.ok_or_else(|| {
|
|
||||||
LspTransportError::InvalidResponse {
|
|
||||||
method: "unknown".to_string(),
|
|
||||||
details: "missing Content-Length header".to_string(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut payload = vec![0u8; content_length];
|
|
||||||
self.stdout.read_exact(&mut payload).await.map_err(|error| {
|
|
||||||
if error.kind() == io::ErrorKind::UnexpectedEof {
|
|
||||||
LspTransportError::ServerExited
|
|
||||||
} else {
|
|
||||||
LspTransportError::Io(error)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::io::Cursor;
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn content_length_header_roundtrip() {
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
rt.block_on(async {
|
|
||||||
let payload = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":null}"#;
|
|
||||||
|
|
||||||
// Write frame into a buffer
|
|
||||||
let mut write_buf = Vec::new();
|
|
||||||
{
|
|
||||||
let header = format!("Content-Length: {}\r\n\r\n", payload.len());
|
|
||||||
write_buf.extend_from_slice(header.as_bytes());
|
|
||||||
write_buf.extend_from_slice(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read frame back using the same logic as LspTransport::read_frame
|
|
||||||
let cursor = Cursor::new(write_buf);
|
|
||||||
let mut reader = BufReader::new(cursor);
|
|
||||||
|
|
||||||
let mut content_length: Option<usize> = None;
|
|
||||||
loop {
|
|
||||||
let mut line = String::new();
|
|
||||||
let bytes_read = reader.read_line(&mut line).await.unwrap();
|
|
||||||
assert!(bytes_read > 0, "unexpected EOF reading header");
|
|
||||||
if line == "\r\n" {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let header = line.trim_end_matches(['\r', '\n']);
|
|
||||||
if let Some((name, value)) = header.split_once(':') {
|
|
||||||
if name.trim().eq_ignore_ascii_case("Content-Length") {
|
|
||||||
content_length = Some(value.trim().parse::<usize>().unwrap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_length = content_length.expect("should have Content-Length");
|
|
||||||
assert_eq!(content_length, payload.len());
|
|
||||||
|
|
||||||
let mut read_payload = vec![0u8; content_length];
|
|
||||||
reader.read_exact(&mut read_payload).await.unwrap();
|
|
||||||
|
|
||||||
let original: serde_json::Value = serde_json::from_slice(payload).unwrap();
|
|
||||||
let roundtripped: serde_json::Value = serde_json::from_slice(&read_payload).unwrap();
|
|
||||||
assert_eq!(original, roundtripped);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn request_has_incrementing_ids() {
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
rt.block_on(async {
|
|
||||||
// Spawn cat so we can construct a real LspTransport.
|
|
||||||
let child = tokio::process::Command::new("cat")
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
.expect("cat should be available");
|
|
||||||
|
|
||||||
let mut transport = LspTransport::from_child(child, Duration::from_secs(5));
|
|
||||||
|
|
||||||
// Allocate IDs by inspecting what send_request would produce.
|
|
||||||
let id1 = transport.allocate_id();
|
|
||||||
let id2 = transport.allocate_id();
|
|
||||||
let id3 = transport.allocate_id();
|
|
||||||
|
|
||||||
assert_eq!(id1, LspId::Number(1));
|
|
||||||
assert_eq!(id2, LspId::Number(2));
|
|
||||||
assert_eq!(id3, LspId::Number(3));
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let _ = transport.shutdown().await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn notification_has_no_id() {
|
|
||||||
let notification = LspNotification::new("initialized", Some(serde_json::json!({})));
|
|
||||||
let serialized = serde_json::to_string(¬ification).unwrap();
|
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
|
|
||||||
assert!(
|
|
||||||
parsed.get("id").is_none(),
|
|
||||||
"notification should not contain an 'id' field, got: {serialized}"
|
|
||||||
);
|
|
||||||
assert_eq!(parsed["jsonrpc"], "2.0");
|
|
||||||
assert_eq!(parsed["method"], "initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_header_handling() {
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
rt.block_on(async {
|
|
||||||
// Feed garbage bytes that don't contain a valid Content-Length header.
|
|
||||||
let garbage = b"THIS IS NOT A VALID HEADER\r\n\r\n";
|
|
||||||
let cursor = Cursor::new(garbage.to_vec());
|
|
||||||
let mut reader = BufReader::new(cursor);
|
|
||||||
|
|
||||||
let mut content_length: Option<usize> = None;
|
|
||||||
loop {
|
|
||||||
let mut line = String::new();
|
|
||||||
let bytes_read = reader.read_line(&mut line).await.unwrap();
|
|
||||||
if bytes_read == 0 || line == "\r\n" {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let header = line.trim_end_matches(['\r', '\n']);
|
|
||||||
if let Some((name, value)) = header.split_once(':') {
|
|
||||||
if name.trim().eq_ignore_ascii_case("Content-Length") {
|
|
||||||
content_length = value.trim().parse::<usize>().ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The garbage header should not produce a valid Content-Length.
|
|
||||||
assert!(
|
|
||||||
content_length.is_none(),
|
|
||||||
"garbage input should not produce a valid Content-Length"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7061,6 +7061,14 @@ fn run_repl(
|
||||||
run_stale_base_preflight(base_commit.as_deref());
|
run_stale_base_preflight(base_commit.as_deref());
|
||||||
let resolved_model = resolve_repl_model(model)?;
|
let resolved_model = resolve_repl_model(model)?;
|
||||||
let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?;
|
let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?;
|
||||||
|
|
||||||
|
// Read config for LSP auto-start setting
|
||||||
|
let cwd = std::env::current_dir().unwrap_or_default();
|
||||||
|
let lsp_auto = runtime::ConfigLoader::default_for(&cwd)
|
||||||
|
.load()
|
||||||
|
.map(|c| c.lsp_auto_start())
|
||||||
|
.unwrap_or(true);
|
||||||
|
cli.lsp_auto_start = lsp_auto;
|
||||||
cli.set_reasoning_effort(reasoning_effort);
|
cli.set_reasoning_effort(reasoning_effort);
|
||||||
let mut editor =
|
let mut editor =
|
||||||
input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default());
|
input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default());
|
||||||
|
|
@ -7084,6 +7092,16 @@ fn run_repl(
|
||||||
server.clone(),
|
server.clone(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Auto-start all discovered servers if enabled
|
||||||
|
if cli.lsp_auto_start {
|
||||||
|
let registry = tools::global_lsp_registry();
|
||||||
|
for server in &lsp_servers {
|
||||||
|
match registry.start_server(&server.language) {
|
||||||
|
Ok(()) => eprintln!(" {} started", server.language),
|
||||||
|
Err(e) => eprintln!(" {} failed to start: {e}", server.language),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -7175,6 +7193,7 @@ struct LiveCli {
|
||||||
runtime: BuiltRuntime,
|
runtime: BuiltRuntime,
|
||||||
session: SessionHandle,
|
session: SessionHandle,
|
||||||
prompt_history: Vec<PromptHistoryEntry>,
|
prompt_history: Vec<PromptHistoryEntry>,
|
||||||
|
lsp_auto_start: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -7689,6 +7708,7 @@ impl LiveCli {
|
||||||
runtime,
|
runtime,
|
||||||
session,
|
session,
|
||||||
prompt_history: Vec::new(),
|
prompt_history: Vec::new(),
|
||||||
|
lsp_auto_start: true,
|
||||||
};
|
};
|
||||||
cli.persist_session()?;
|
cli.persist_session()?;
|
||||||
Ok(cli)
|
Ok(cli)
|
||||||
|
|
@ -8292,7 +8312,7 @@ impl LiveCli {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_lsp_command(&self, action: Option<&str>, target: Option<&str>) {
|
fn handle_lsp_command(&mut self, action: Option<&str>, target: Option<&str>) {
|
||||||
let registry = tools::global_lsp_registry();
|
let registry = tools::global_lsp_registry();
|
||||||
match action {
|
match action {
|
||||||
Some("start") => {
|
Some("start") => {
|
||||||
|
|
@ -8317,8 +8337,15 @@ impl LiveCli {
|
||||||
Err(e) => eprintln!("Failed to restart LSP server '{lang}': {e}"),
|
Err(e) => eprintln!("Failed to restart LSP server '{lang}': {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some("toggle") => {
|
||||||
|
self.lsp_auto_start = !self.lsp_auto_start;
|
||||||
|
let state = if self.lsp_auto_start { "on" } else { "off" };
|
||||||
|
eprintln!("LSP auto-start: {state}");
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let servers = registry.list_servers();
|
let servers = registry.list_servers();
|
||||||
|
let auto_state = if self.lsp_auto_start { "on" } else { "off" };
|
||||||
|
eprintln!("LSP auto-start: {auto_state}");
|
||||||
if servers.is_empty() {
|
if servers.is_empty() {
|
||||||
eprintln!("No LSP servers registered.");
|
eprintln!("No LSP servers registered.");
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue