feat: full LSP (Language Server Protocol) integration

Implement complete LSP support for code intelligence tools:

- lsp_transport.rs: JSON-RPC 2.0 transport over stdio with Content-Length
  framing, async request/response handling, and graceful shutdown

- lsp_process.rs: LSP process manager with initialize handshake, and methods
  for hover, goto_definition, references, document_symbols, completion, format

- lsp_discovery.rs: Auto-discovery of installed LSP servers (rust-analyzer,
  clangd, gopls, pyright, typescript-language-server, etc.) with PATH lookup

- lsp_client.rs: Rewired LspRegistry to use real LSP processes instead of
  placeholder JSON, with lazy-start on first dispatch call

- config.rs: Added LspServerConfig for user-configured LSP servers

- config_validate.rs: Validation for lsp config section

- main.rs: CLI integration with server discovery at startup, /lsp slash
  command for status/start/stop/restart, and graceful shutdown on exit

- commands/src/lib.rs: Added SlashCommand::Lsp variant

The LSP tool is now available to the agent for hover, definition, references,
symbols, completion, and diagnostics queries. Servers are auto-discovered at
REPL startup and lazily started on first use.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
TheArchitectit 2026-04-26 22:32:06 -05:00
parent 050d505b9b
commit e72368eaa5
10 changed files with 2381 additions and 37 deletions

View File

@ -1042,6 +1042,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "lsp",
aliases: &[],
summary: "Show or manage LSP server status",
argument_hint: Some("[start|stop|restart <language>]"),
resume_supported: true,
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
@ -1188,6 +1195,10 @@ pub enum SlashCommand {
History {
count: Option<String>,
},
Lsp {
action: Option<String>,
target: Option<String>,
},
Unknown(String),
Team {
action: Option<String>,
@ -1290,7 +1301,11 @@ impl SlashCommand {
Self::Tag { .. } => "/tag",
Self::OutputStyle { .. } => "/output-style",
Self::AddDir { .. } => "/add-dir",
<<<<<<< HEAD
Self::Team { .. } => "/team",
=======
Self::Lsp { .. } => "/lsp",
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
Self::Sandbox => "/sandbox",
Self::Mcp { .. } => "/mcp",
Self::Export { .. } => "/export",
@ -1506,6 +1521,10 @@ pub fn validate_slash_command_input(
"tag" => SlashCommand::Tag { label: remainder },
"output-style" => SlashCommand::OutputStyle { style: remainder },
"add-dir" => SlashCommand::AddDir { path: remainder },
"lsp" => SlashCommand::Lsp {
action: args.first().map(|s| (*s).to_string()),
target: args.get(1).map(|s| (*s).to_string()),
},
"history" => SlashCommand::History {
count: optional_single_arg(command, &args, "[count]")?,
},
@ -5393,7 +5412,11 @@ pub fn handle_slash_command(
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
| SlashCommand::History { .. }
<<<<<<< HEAD
| SlashCommand::Team { .. }
=======
| SlashCommand::Lsp { .. }
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
| SlashCommand::Setup
| SlashCommand::Unknown(_) => None,
}
@ -6011,8 +6034,12 @@ mod tests {
assert!(help.contains("aliases: /skill"));
assert!(!help.contains("/login"));
assert!(!help.contains("/logout"));
<<<<<<< HEAD
assert!(help.contains("/setup"));
assert_eq!(slash_command_specs().len(), 140);
=======
assert_eq!(slash_command_specs().len(), 141);
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
assert!(resume_supported_slash_commands().len() >= 39);
}

View File

@ -125,6 +125,7 @@ pub struct RuntimePluginConfig {
max_output_tokens: Option<u32>,
}
<<<<<<< HEAD
/// API timeout and retry configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiTimeoutConfig {
@ -144,6 +145,14 @@ impl Default for ApiTimeoutConfig {
max_retries: 8,
}
}
=======
/// Per-language LSP server configuration supplied by the user in settings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LspServerConfig {
pub command: String,
pub args: Vec<String>,
pub enabled: bool,
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
}
/// Structured feature configuration consumed by runtime subsystems.
@ -163,6 +172,7 @@ pub struct RuntimeFeatureConfig {
api_timeout: ApiTimeoutConfig,
rules_import: RulesImportConfig,
provider: RuntimeProviderConfig,
lsp: BTreeMap<String, LspServerConfig>,
}
/// Controls which external AI coding framework rules are imported into the system prompt.
@ -496,6 +506,7 @@ impl ConfigLoader {
build_runtime_config(merged, loaded_entries, mcp)
}
<<<<<<< HEAD
/// Like [`load`] but also returns the list of validation warnings collected during
/// loading, without emitting them to stderr. Callers that want to surface warnings
/// through a structured channel (e.g. the JSON config envelope) should use this.
@ -652,6 +663,24 @@ impl ConfigLoader {
load_error.get_or_insert_with(|| error.to_string());
None
}
=======
let feature_config = RuntimeFeatureConfig {
hooks: parse_optional_hooks_config(&merged_value)?,
plugins: parse_optional_plugin_config(&merged_value)?,
mcp: McpConfigCollection {
servers: mcp_servers,
},
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
model: parse_optional_model(&merged_value),
aliases: parse_optional_aliases(&merged_value)?,
permission_mode: parse_optional_permission_mode(&merged_value)?,
permission_rules: parse_optional_permission_rules(&merged_value)?,
sandbox: parse_optional_sandbox_config(&merged_value)?,
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
provider: parse_optional_provider_config(&merged_value)?,
lsp: parse_optional_lsp_config(&merged_value)?,
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
};
ConfigInspection {
@ -920,6 +949,7 @@ impl RuntimeConfig {
&self.feature_config.provider
}
<<<<<<< HEAD
/// Merge config-level default trusted roots with per-call roots.
///
/// Config roots are defaults and are kept first; per-call roots extend the
@ -929,6 +959,11 @@ impl RuntimeConfig {
#[must_use]
pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec<String> {
merge_trusted_roots(self.trusted_roots(), per_call_roots)
=======
#[must_use]
pub fn lsp(&self) -> &BTreeMap<String, LspServerConfig> {
&self.feature_config.lsp
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
}
}
@ -1012,6 +1047,7 @@ impl RuntimeFeatureConfig {
&self.rules_import
}
<<<<<<< HEAD
/// Merge this config's default trusted roots with per-call roots.
#[must_use]
pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec<String> {
@ -1027,6 +1063,12 @@ fn merge_trusted_roots(config_roots: &[String], per_call_roots: &[String]) -> Ve
}
}
merged
=======
#[must_use]
pub fn lsp(&self) -> &BTreeMap<String, LspServerConfig> {
&self.lsp
}
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
}
impl ProviderFallbackConfig {
@ -2247,6 +2289,34 @@ fn expand_config_value(value: &str) -> String {
result
}
fn parse_optional_lsp_config(
root: &JsonValue,
) -> Result<BTreeMap<String, LspServerConfig>, ConfigError> {
let Some(lsp_value) = root.as_object().and_then(|object| object.get("lsp")) else {
return Ok(BTreeMap::new());
};
let lsp_object = expect_object(lsp_value, "merged settings.lsp")?;
let mut result = BTreeMap::new();
for (language, value) in lsp_object {
let entry = expect_object(value, &format!("merged settings.lsp.{language}"))?;
let command = expect_string(entry, "command", &format!("merged settings.lsp.{language}"))?
.to_string();
let args = optional_string_array(entry, "args", &format!("merged settings.lsp.{language}"))?
.unwrap_or_default();
let enabled = optional_bool(entry, "enabled", &format!("merged settings.lsp.{language}"))?
.unwrap_or(true);
result.insert(
language.clone(),
LspServerConfig {
command,
args,
enabled,
},
);
}
Ok(result)
}
fn parse_mcp_server_config(
server_name: &str,
value: &JsonValue,

View File

@ -213,12 +213,17 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
expected: FieldType::Object,
},
FieldSpec {
<<<<<<< HEAD
name: "rulesImport",
expected: FieldType::RulesImport,
},
FieldSpec {
name: "subagentModel",
expected: FieldType::String,
=======
name: "lsp",
expected: FieldType::Object,
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
},
];
@ -356,6 +361,21 @@ const PROVIDER_FIELDS: &[FieldSpec] = &[
},
];
const LSP_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "command",
expected: FieldType::String,
},
FieldSpec {
name: "args",
expected: FieldType::StringArray,
},
FieldSpec {
name: "enabled",
expected: FieldType::Bool,
},
];
const DEPRECATED_FIELDS: &[DeprecatedField] = &[
DeprecatedField {
name: "permissionMode",
@ -556,6 +576,31 @@ pub fn validate_config_file(
));
}
// Validate lsp map: each value must be an object with LSP_FIELDS.
if let Some(lsp) = object.get("lsp").and_then(JsonValue::as_object) {
for (server_name, server_value) in lsp {
if let Some(server_obj) = server_value.as_object() {
result.merge(validate_object_keys(
server_obj,
LSP_FIELDS,
&format!("lsp.{server_name}"),
source,
&path_display,
));
} else {
result.errors.push(ConfigDiagnostic {
path: path_display.clone(),
field: format!("lsp.{server_name}"),
line: find_key_line(source, server_name),
kind: DiagnosticKind::WrongType {
expected: "an object",
got: json_type_label(server_value),
},
});
}
}
}
result
}
@ -1015,4 +1060,122 @@ mod tests {
r#"/test/settings.json: field "permissionMode" is deprecated (line 3). Use "permissions.defaultMode" instead"#
);
}
#[test]
fn validates_lsp_config_valid() {
// given
let source = r#"{"lsp": {"rust": {"command": "rust-analyzer", "args": [], "enabled": true}, "python": {"command": "pyright-langserver", "args": ["--stdio"], "enabled": false}}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert!(result.is_ok());
}
#[test]
fn validates_lsp_config_unknown_field() {
// given
let source = r#"{"lsp": {"rust": {"command": "rust-analyzer", "port": 8080}}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "lsp.rust.port");
assert!(matches!(
result.errors[0].kind,
DiagnosticKind::UnknownKey { .. }
));
}
#[test]
fn validates_lsp_config_wrong_type_for_command() {
// given
let source = r#"{"lsp": {"rust": {"command": 123}}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "lsp.rust.command");
assert!(matches!(
result.errors[0].kind,
DiagnosticKind::WrongType {
expected: "a string",
got: "a number"
}
));
}
#[test]
fn validates_lsp_config_wrong_type_for_args() {
// given
let source = r#"{"lsp": {"rust": {"command": "rust-analyzer", "args": "wrong"}}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "lsp.rust.args");
assert!(matches!(
result.errors[0].kind,
DiagnosticKind::WrongType { .. }
));
}
#[test]
fn validates_lsp_config_wrong_type_for_enabled() {
// given
let source = r#"{"lsp": {"rust": {"command": "rust-analyzer", "enabled": "yes"}}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "lsp.rust.enabled");
assert!(matches!(
result.errors[0].kind,
DiagnosticKind::WrongType {
expected: "a boolean",
got: "a string"
}
));
}
#[test]
fn validates_lsp_server_must_be_object() {
// given
let source = r#"{"lsp": {"rust": "not-an-object"}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "lsp.rust");
assert!(matches!(
result.errors[0].kind,
DiagnosticKind::WrongType {
expected: "an object",
got: "a string"
}
));
}
}

View File

@ -21,6 +21,9 @@ mod hooks;
mod json;
mod lane_events;
pub mod lsp_client;
pub mod lsp_discovery;
pub mod lsp_process;
pub mod lsp_transport;
mod mcp;
mod mcp_client;
pub mod mcp_lifecycle_hardened;
@ -65,6 +68,7 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use config::{
<<<<<<< HEAD
clear_user_provider_settings, default_config_home, save_user_provider_settings,
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
@ -74,11 +78,24 @@ pub use config::{
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
=======
clear_user_provider_settings, save_user_provider_settings, ConfigEntry, ConfigError,
ConfigLoader, ConfigSource, LspServerConfig, McpConfigCollection, McpManagedProxyServerConfig,
McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig,
McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig,
RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig,
ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
};
pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
DiagnosticKind, ValidationResult,
};
pub use lsp_discovery::{
command_exists_on_path, discover_available_servers, find_server_for_file,
known_lsp_servers, LspServerDescriptor,
};
pub use conversation::{
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,

View File

@ -2,10 +2,14 @@
//! LSP (Language Server Protocol) client registry for tool dispatch.
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use crate::lsp_discovery::{discover_available_servers, LspServerDescriptor};
use crate::lsp_process::LspProcess;
/// Supported LSP actions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@ -106,6 +110,44 @@ pub struct LspServerState {
pub diagnostics: Vec<LspDiagnostic>,
}
/// Entry in the LSP registry combining process handle, descriptor, and state.
struct LspServerEntry {
/// The running LSP process, if started. Wrapped in Arc<Mutex<>> for thread-safe async access.
process: Option<Arc<Mutex<LspProcess>>>,
/// The server descriptor for lazy-start on first use.
descriptor: Option<LspServerDescriptor>,
/// The server state metadata (status, capabilities, diagnostics).
state: LspServerState,
}
impl std::fmt::Debug for LspServerEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LspServerEntry")
.field("process", &self.process.is_some())
.field("descriptor", &self.descriptor)
.field("state", &self.state)
.finish()
}
}
impl LspServerEntry {
fn new(state: LspServerState) -> Self {
Self {
process: None,
descriptor: None,
state,
}
}
fn with_descriptor(state: LspServerState, descriptor: LspServerDescriptor) -> Self {
Self {
process: None,
descriptor: Some(descriptor),
state,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct LspRegistry {
inner: Arc<Mutex<RegistryInner>>,
@ -113,7 +155,7 @@ pub struct LspRegistry {
#[derive(Debug, Default)]
struct RegistryInner {
servers: HashMap<String, LspServerState>,
servers: HashMap<String, LspServerEntry>,
}
impl LspRegistry {
@ -122,6 +164,8 @@ impl LspRegistry {
Self::default()
}
/// Register an LSP server with metadata but without starting the process.
/// The server can be started later via `start_server()` or lazily on first `dispatch()`.
pub fn register(
&self,
language: &str,
@ -129,22 +173,46 @@ impl LspRegistry {
root_path: Option<&str>,
capabilities: Vec<String>,
) {
let state = LspServerState {
language: language.to_owned(),
status,
root_path: root_path.map(str::to_owned),
capabilities,
diagnostics: Vec::new(),
};
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
inner
.servers
.insert(language.to_owned(), LspServerEntry::new(state));
}
/// Register an LSP server with a descriptor for lazy-start.
/// The descriptor provides the command and args to start the server when needed.
pub fn register_with_descriptor(
&self,
language: &str,
status: LspServerStatus,
root_path: Option<&str>,
capabilities: Vec<String>,
descriptor: LspServerDescriptor,
) {
let state = LspServerState {
language: language.to_owned(),
status,
root_path: root_path.map(str::to_owned),
capabilities,
diagnostics: Vec::new(),
};
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.insert(
language.to_owned(),
LspServerState {
language: language.to_owned(),
status,
root_path: root_path.map(str::to_owned),
capabilities,
diagnostics: Vec::new(),
},
LspServerEntry::with_descriptor(state, descriptor),
);
}
pub fn get(&self, language: &str) -> Option<LspServerState> {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.get(language).cloned()
inner.servers.get(language).map(|entry| entry.state.clone())
}
/// Find the appropriate server for a file path based on extension.
@ -171,10 +239,33 @@ impl LspRegistry {
self.get(language)
}
/// Get the language name for a file path based on extension.
fn language_for_path(path: &str) -> Option<String> {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())?;
let language = match ext {
"rs" => "rust",
"ts" | "tsx" => "typescript",
"js" | "jsx" => "javascript",
"py" => "python",
"go" => "go",
"java" => "java",
"c" | "h" => "c",
"cpp" | "hpp" | "cc" => "cpp",
"rb" => "ruby",
"lua" => "lua",
_ => return None,
};
Some(language.to_owned())
}
/// List all registered servers.
pub fn list_servers(&self) -> Vec<LspServerState> {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.values().cloned().collect()
inner.servers.values().map(|entry| entry.state.clone()).collect()
}
/// Add diagnostics to a server.
@ -184,11 +275,11 @@ impl LspRegistry {
diagnostics: Vec<LspDiagnostic>,
) -> Result<(), String> {
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
let server = inner
let entry = inner
.servers
.get_mut(language)
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
server.diagnostics.extend(diagnostics);
entry.state.diagnostics.extend(diagnostics);
Ok(())
}
@ -198,7 +289,7 @@ impl LspRegistry {
inner
.servers
.values()
.flat_map(|s| &s.diagnostics)
.flat_map(|entry| &entry.state.diagnostics)
.filter(|d| d.path == path)
.cloned()
.collect()
@ -207,18 +298,18 @@ impl LspRegistry {
/// Clear diagnostics for a language server.
pub fn clear_diagnostics(&self, language: &str) -> Result<(), String> {
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
let server = inner
let entry = inner
.servers
.get_mut(language)
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
server.diagnostics.clear();
entry.state.diagnostics.clear();
Ok(())
}
/// Disconnect a server.
pub fn disconnect(&self, language: &str) -> Option<LspServerState> {
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.remove(language)
inner.servers.remove(language).map(|entry| entry.state)
}
#[must_use]
@ -232,7 +323,105 @@ impl LspRegistry {
self.len() == 0
}
/// Start an LSP server process for the given language.
/// If the process is already running, this is a no-op.
/// If a descriptor is available, it is used to start the process.
/// If no descriptor is available, the discovery system is consulted.
pub fn start_server(&self, language: &str) -> Result<(), String> {
// Check if already running
{
let inner = self.inner.lock().expect("lsp registry lock poisoned");
if let Some(entry) = inner.servers.get(language) {
if entry.process.is_some() {
return Ok(());
}
}
}
// Try to get the descriptor
let descriptor = {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
if let Some(entry) = inner.servers.get(language) {
entry.descriptor.clone()
} else {
None
}
};
// If no descriptor, try discovery
let descriptor = if let Some(d) = descriptor { d } else {
let available = discover_available_servers();
available
.into_iter()
.find(|d| d.language == language)
.ok_or_else(|| {
format!("no LSP server descriptor found for language: {language}")
})?
};
let root_path = {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner
.servers
.get(language)
.and_then(|entry| entry.state.root_path.clone())
.unwrap_or_else(|| {
std::env::current_dir()
.map_or_else(|_| ".".to_owned(), |p| p.to_string_lossy().into_owned())
})
};
let process = {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("failed to create tokio runtime: {e}"))?;
rt.block_on(LspProcess::start(
&descriptor.command,
&descriptor.args,
Path::new(&root_path),
))
.map_err(|e| format!("failed to start LSP server for '{language}': {e}"))?
};
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
if let Some(entry) = inner.servers.get_mut(language) {
entry.process = Some(Arc::new(Mutex::new(process)));
entry.state.status = LspServerStatus::Connected;
}
Ok(())
}
/// Stop a running LSP server process.
pub fn stop_server(&self, language: &str) -> Result<(), String> {
let process_arc = {
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
let entry = inner
.servers
.get_mut(language)
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
entry.state.status = LspServerStatus::Disconnected;
entry.process.take()
};
if let Some(process_arc) = process_arc {
let mut process = process_arc
.lock()
.map_err(|_| "lsp process lock poisoned")?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("failed to create tokio runtime: {e}"))?;
rt.block_on(process.shutdown())
.map_err(|e| format!("LSP shutdown error: {e}"))?;
}
Ok(())
}
/// Dispatch an LSP action and return a structured result.
#[allow(clippy::too_many_lines)]
pub fn dispatch(
&self,
action: &str,
@ -244,7 +433,7 @@ impl LspRegistry {
let lsp_action =
LspAction::from_str(action).ok_or_else(|| format!("unknown LSP action: {action}"))?;
// For diagnostics, we can check existing cached diagnostics
// For diagnostics, we check existing cached diagnostics
if lsp_action == LspAction::Diagnostics {
if let Some(path) = path {
let diags = self.get_diagnostics(path);
@ -260,7 +449,7 @@ impl LspRegistry {
let all_diags: Vec<_> = inner
.servers
.values()
.flat_map(|s| &s.diagnostics)
.flat_map(|entry| &entry.state.diagnostics)
.collect();
return Ok(serde_json::json!({
"action": "diagnostics",
@ -271,28 +460,183 @@ impl LspRegistry {
// For other actions, we need a connected server for the given file
let path = path.ok_or("path is required for this LSP action")?;
let server = self
.find_server_for_path(path)
let language = Self::language_for_path(path)
.ok_or_else(|| format!("no LSP server available for path: {path}"))?;
if server.status != LspServerStatus::Connected {
return Err(format!(
"LSP server for '{}' is not connected (status: {})",
server.language, server.status
));
// Check the entry exists
{
let inner = self.inner.lock().expect("lsp registry lock poisoned");
if !inner.servers.contains_key(&language) {
return Err(format!("no LSP server available for path: {path}"));
}
}
// Return structured placeholder — actual LSP JSON-RPC calls would
// go through the real LSP process here.
Ok(serde_json::json!({
"action": action,
"path": path,
"line": line,
"character": character,
"language": server.language,
"status": "dispatched",
"message": format!("LSP {} dispatched to {} server", action, server.language)
}))
// Lazy-start: if no process yet, try to start one
let needs_start = {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner
.servers
.get(&language)
.is_none_or(|entry| entry.process.is_none())
};
if needs_start {
if let Err(e) = self.start_server(&language) {
// Check the status after failed start — if still not Connected,
// return a proper error. This preserves the existing behavior
// for Disconnected/Error status servers.
let inner = self.inner.lock().expect("lsp registry lock poisoned");
if let Some(entry) = inner.servers.get(&language) {
if entry.state.status != LspServerStatus::Connected {
return Err(format!(
"LSP server for '{}' is not connected (status: {}): {}",
language, entry.state.status, e
));
}
}
// If somehow still marked Connected but start failed, return error JSON
return Ok(serde_json::json!({
"action": action,
"path": path,
"line": line,
"character": character,
"language": language,
"status": "error",
"error": e
}));
}
}
// Check the server status
{
let inner = self.inner.lock().expect("lsp registry lock poisoned");
if let Some(entry) = inner.servers.get(&language) {
if entry.state.status != LspServerStatus::Connected {
return Err(format!(
"LSP server for '{}' is not connected (status: {})",
language, entry.state.status
));
}
}
}
// Get the process handle (clone the Arc)
let process_arc = {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner
.servers
.get(&language)
.and_then(|entry| entry.process.clone())
.ok_or_else(|| format!("no LSP process available for language: {language}"))?
};
// Dispatch to the real LSP process
let result = {
let mut process = process_arc
.lock()
.map_err(|_| "lsp process lock poisoned".to_owned())?;
// Create a minimal tokio runtime for async LSP calls
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("failed to create tokio runtime: {e}"))?;
rt.block_on(async {
let line = line.unwrap_or(0);
let character = character.unwrap_or(0);
match lsp_action {
LspAction::Hover => {
let hover = process.hover(path, line, character).await;
hover.map(|opt| {
opt.map_or_else(
|| serde_json::json!({
"action": "hover",
"path": path,
"line": line,
"character": character,
"language": language,
"status": "no_result",
}),
|h| serde_json::json!({
"action": "hover",
"path": path,
"line": line,
"character": character,
"language": language,
"status": "ok",
"result": h,
}),
)
})
}
LspAction::Definition => {
let locations = process.goto_definition(path, line, character).await;
locations.map(|locs| serde_json::json!({
"action": "definition",
"path": path,
"line": line,
"character": character,
"language": language,
"status": "ok",
"locations": locs,
}))
}
LspAction::References => {
let locations = process.references(path, line, character).await;
locations.map(|locs| serde_json::json!({
"action": "references",
"path": path,
"line": line,
"character": character,
"language": language,
"status": "ok",
"locations": locs,
}))
}
LspAction::Completion => {
let items = process.completion(path, line, character).await;
items.map(|completions| serde_json::json!({
"action": "completion",
"path": path,
"line": line,
"character": character,
"language": language,
"status": "ok",
"items": completions,
}))
}
LspAction::Symbols => {
let symbols = process.document_symbols(path).await;
symbols.map(|syms| serde_json::json!({
"action": "symbols",
"path": path,
"line": line,
"character": character,
"language": language,
"status": "ok",
"symbols": syms,
}))
}
LspAction::Format => {
let edits = process.format(path).await;
edits.map(|text_edits| serde_json::json!({
"action": "format",
"path": path,
"line": line,
"character": character,
"language": language,
"status": "ok",
"edits": text_edits,
}))
}
LspAction::Diagnostics => unreachable!(),
}
})
};
result.map_err(|e| format!("LSP {action} failed for '{language}': {e}"))
}
}
@ -744,4 +1088,117 @@ mod tests {
let error = result.expect_err("missing language should fail");
assert!(error.contains("LSP server not found for language: missing"));
}
#[test]
fn register_with_descriptor_stores_entry() {
let registry = LspRegistry::new();
let descriptor = LspServerDescriptor {
language: "rust".into(),
command: "rust-analyzer".into(),
args: vec![],
extensions: vec!["rs".into()],
};
registry.register_with_descriptor(
"rust",
LspServerStatus::Connected,
Some("/project"),
vec!["hover".into()],
descriptor,
);
let server = registry.get("rust").expect("should exist after register_with_descriptor");
assert_eq!(server.language, "rust");
assert_eq!(server.status, LspServerStatus::Connected);
assert_eq!(server.root_path.as_deref(), Some("/project"));
assert_eq!(server.capabilities, vec!["hover"]);
}
#[test]
fn stop_server_on_nonexistent_errors() {
let registry = LspRegistry::new();
let result = registry.stop_server("missing");
assert!(result.is_err(), "stopping a nonexistent server should error");
let error = result.unwrap_err();
assert!(error.contains("missing"), "error message should reference 'missing', got: {error}");
}
/// This test requires rust-analyzer to be installed on the system.
/// Run with: cargo test -p runtime -- --ignored
#[test]
#[ignore = "requires rust-analyzer installed on PATH"]
fn start_server_without_descriptor_falls_back_to_discovery() {
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Starting, None, vec![]);
let result = registry.start_server("rust");
assert!(result.is_ok(), "start_server should discover and start rust-analyzer: {result:?}");
let server = registry.get("rust").expect("rust should be registered");
assert_eq!(server.status, LspServerStatus::Connected);
let _ = registry.stop_server("rust");
}
/// This test requires rust-analyzer to be installed on the system.
/// Run with: cargo test -p runtime -- --ignored
#[test]
#[ignore = "requires rust-analyzer installed on PATH"]
fn dispatch_hover_lazy_starts_server() {
let registry = LspRegistry::new();
let descriptor = crate::lsp_discovery::LspServerDescriptor {
language: "rust".into(),
command: "rust-analyzer".into(),
args: vec![],
extensions: vec!["rs".into()],
};
registry.register_with_descriptor(
"rust",
LspServerStatus::Starting,
None,
vec![],
descriptor,
);
// dispatch should trigger start_server because process is None
let result = registry.dispatch("hover", Some("src/main.rs"), Some(0), Some(0), None);
// Result may be Ok or Err depending on whether rust-analyzer can actually
// respond for this path, but it should not fail with "not connected"
// (which would indicate the lazy-start didn't kick in).
if let Err(e) = &result {
assert!(
!e.contains("not connected"),
"dispatch should have lazily started the server, got: {e}"
);
}
let _ = registry.stop_server("rust");
}
/// This test requires rust-analyzer to be installed on the system.
/// Run with: cargo test -p runtime -- --ignored
#[test]
#[ignore = "requires rust-analyzer installed on PATH"]
fn start_and_stop_server() {
let registry = LspRegistry::new();
let descriptor = crate::lsp_discovery::LspServerDescriptor {
language: "rust".into(),
command: "rust-analyzer".into(),
args: vec![],
extensions: vec!["rs".into()],
};
registry.register_with_descriptor(
"rust",
LspServerStatus::Starting,
None,
vec![],
descriptor,
);
let start_result = registry.start_server("rust");
assert!(start_result.is_ok(), "start_server should succeed: {start_result:?}");
let server = registry.get("rust").expect("rust should exist");
assert_eq!(server.status, LspServerStatus::Connected);
let stop_result = registry.stop_server("rust");
assert!(stop_result.is_ok(), "stop_server should succeed: {stop_result:?}");
let server = registry.get("rust").expect("rust should still be in registry");
assert_eq!(server.status, LspServerStatus::Disconnected);
}
}

View File

@ -0,0 +1,245 @@
//! Auto-discovery of installed LSP servers and file-extension mapping.
use std::path::Path;
use std::process::Command;
/// Descriptor for a well-known LSP server, including its launch command and
/// the file extensions it handles.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LspServerDescriptor {
pub language: String,
pub command: String,
pub args: Vec<String>,
pub extensions: Vec<String>,
}
/// Static descriptor used by the [`KNOWN_LSP_SERVERS`] constant. Uses
/// `&'static str` fields so the table can live in read-only memory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct StaticLspServerDescriptor {
language: &'static str,
command: &'static str,
args: &'static [&'static str],
extensions: &'static [&'static str],
}
impl StaticLspServerDescriptor {
#[allow(clippy::wrong_self_convention)]
fn to_descriptor(&self) -> LspServerDescriptor {
LspServerDescriptor {
language: self.language.to_string(),
command: self.command.to_string(),
args: self.args.iter().map(|s| (*s).to_string()).collect(),
extensions: self.extensions.iter().map(|s| (*s).to_string()).collect(),
}
}
}
/// Known LSP servers with their default commands, args, and file extensions.
const KNOWN_LSP_SERVERS_TABLE: &[StaticLspServerDescriptor] = &[
StaticLspServerDescriptor {
language: "rust",
command: "rust-analyzer",
args: &[],
extensions: &["rs"],
},
StaticLspServerDescriptor {
language: "c/cpp",
command: "clangd",
args: &[],
extensions: &["c", "h", "cpp", "hpp"],
},
StaticLspServerDescriptor {
language: "python",
command: "pyright-langserver",
args: &["--stdio"],
extensions: &["py"],
},
StaticLspServerDescriptor {
language: "go",
command: "gopls",
args: &[],
extensions: &["go"],
},
StaticLspServerDescriptor {
language: "typescript",
command: "typescript-language-server",
args: &["--stdio"],
extensions: &["ts", "tsx", "js", "jsx"],
},
StaticLspServerDescriptor {
language: "java",
command: "jdtls",
args: &[],
extensions: &["java"],
},
StaticLspServerDescriptor {
language: "ruby",
command: "solargraph",
args: &["stdio"],
extensions: &["rb"],
},
StaticLspServerDescriptor {
language: "lua",
command: "lua-language-server",
args: &[],
extensions: &["lua"],
},
];
/// Owned copy of the known LSP server descriptors, useful when callers need
/// to mutate or transfer ownership.
#[must_use]
pub fn known_lsp_servers() -> Vec<LspServerDescriptor> {
KNOWN_LSP_SERVERS_TABLE
.iter()
.map(StaticLspServerDescriptor::to_descriptor)
.collect()
}
/// Check whether a command exists on the user's PATH by attempting to run it
/// with `--version`. Returns `true` if the command could be spawned
/// successfully, `false` otherwise.
#[must_use]
pub fn command_exists_on_path(command: &str) -> bool {
Command::new(command)
.arg("--version")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
/// Discover LSP servers that are actually installed on the current system.
///
/// Iterates over the known server table and returns only those whose command
/// is found on `PATH`.
#[must_use]
pub fn discover_available_servers() -> Vec<LspServerDescriptor> {
KNOWN_LSP_SERVERS_TABLE
.iter()
.filter(|desc| command_exists_on_path(desc.command))
.map(StaticLspServerDescriptor::to_descriptor)
.collect()
}
/// Find the best-matching LSP server descriptor for a given file path.
///
/// Matches on the file extension. If multiple servers share the same
/// extension, the first match wins.
#[must_use]
pub fn find_server_for_file<'a>(
path: &Path,
servers: &'a [LspServerDescriptor],
) -> Option<&'a LspServerDescriptor> {
let ext = path.extension().and_then(|e| e.to_str())?;
servers
.iter()
.find(|desc| desc.extensions.iter().any(|e| e == ext))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn known_servers_contains_expected_languages() {
let languages: Vec<&str> = KNOWN_LSP_SERVERS_TABLE
.iter()
.map(|s| s.language)
.collect();
assert!(languages.contains(&"rust"));
assert!(languages.contains(&"c/cpp"));
assert!(languages.contains(&"python"));
assert!(languages.contains(&"go"));
assert!(languages.contains(&"typescript"));
assert!(languages.contains(&"java"));
assert!(languages.contains(&"ruby"));
assert!(languages.contains(&"lua"));
}
#[test]
fn find_server_for_rust_file() {
let servers = known_lsp_servers();
let result = find_server_for_file(PathBuf::from("src/main.rs").as_path(), &servers);
assert!(result.is_some());
assert_eq!(result.unwrap().language, "rust");
}
#[test]
fn find_server_for_python_file() {
let servers = known_lsp_servers();
let result = find_server_for_file(PathBuf::from("app.py").as_path(), &servers);
assert!(result.is_some());
assert_eq!(result.unwrap().language, "python");
}
#[test]
fn find_server_for_typescript_file() {
let servers = known_lsp_servers();
let result = find_server_for_file(PathBuf::from("index.tsx").as_path(), &servers);
assert!(result.is_some());
assert_eq!(result.unwrap().language, "typescript");
}
#[test]
fn find_server_for_unknown_extension_returns_none() {
let servers = known_lsp_servers();
let result = find_server_for_file(PathBuf::from("data.xyz").as_path(), &servers);
assert!(result.is_none());
}
#[test]
fn find_server_for_file_without_extension_returns_none() {
let servers = known_lsp_servers();
let result = find_server_for_file(PathBuf::from("Makefile").as_path(), &servers);
assert!(result.is_none());
}
#[test]
fn discover_returns_only_installed_servers() {
let available = discover_available_servers();
// Every returned server must have a command that actually exists on PATH.
for server in &available {
assert!(
command_exists_on_path(&server.command),
"discover_available_servers returned '{}' but command '{}' is not on PATH",
server.language,
server.command,
);
}
// If rust-analyzer or clangd are on this system, they should appear.
let languages: Vec<&str> = available.iter().map(|s| s.language.as_str()).collect();
if command_exists_on_path("rust-analyzer") {
assert!(languages.contains(&"rust"), "rust-analyzer is on PATH but 'rust' not in discovered servers");
}
if command_exists_on_path("clangd") {
assert!(languages.contains(&"c/cpp"), "clangd is on PATH but 'c/cpp' not in discovered servers");
}
}
#[test]
fn find_server_for_rs_file() {
let servers = known_lsp_servers();
let result = find_server_for_file(Path::new("src/main.rs"), &servers);
assert!(result.is_some());
assert_eq!(result.unwrap().language, "rust");
}
#[test]
fn find_server_for_unknown_extension() {
let servers = known_lsp_servers();
let result = find_server_for_file(Path::new("README.md"), &servers);
assert!(result.is_none());
}
#[test]
fn descriptor_has_correct_args() {
let servers = known_lsp_servers();
let rust = servers.iter().find(|s| s.language == "rust").expect("rust server should exist");
assert!(rust.args.is_empty(), "rust-analyzer should have no args");
let ts = servers.iter().find(|s| s.language == "typescript").expect("typescript server should exist");
assert_eq!(ts.args, vec!["--stdio"], "typescript-language-server should have --stdio arg");
}
}

View File

@ -0,0 +1,794 @@
//! LSP process manager: spawns language servers and drives the LSP lifecycle.
use std::path::Path;
use serde_json::Value as JsonValue;
use crate::lsp_client::{
LspCompletionItem, 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,
}
#[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,
};
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()),
}
}
#[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 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");
}
}

View File

@ -0,0 +1,495 @@
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))
}
}
}
#[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,
}
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,
})
}
/// 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,
}
}
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(&notification)
.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 = timeout(timeout_duration, self.read_response())
.await
.map_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)
}
pub async fn read_response(&mut self) -> Result<LspResponse, LspTransportError> {
self.read_jsonrpc_message().await
}
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)
}
async fn read_jsonrpc_message<T: serde::de::DeserializeOwned>(
&mut self,
) -> Result<T, LspTransportError> {
let payload = self.read_frame().await?;
serde_json::from_slice(&payload).map_err(|error| LspTransportError::InvalidResponse {
method: "unknown".to_string(),
details: error.to_string(),
})
}
}
#[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(&notification).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"
);
});
}
}

View File

@ -6926,7 +6926,11 @@ fn run_resume_command(
| SlashCommand::Tag { .. }
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
<<<<<<< HEAD
| SlashCommand::Team { .. }
=======
| SlashCommand::Lsp { .. }
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
| SlashCommand::Setup => Err("unsupported resumed slash command".into()),
}
}
@ -7063,6 +7067,25 @@ fn run_repl(
println!("{}", cli.startup_banner());
println!("{}", format_connected_line(&cli.model));
// Discover and register LSP servers
let lsp_servers = runtime::lsp_discovery::discover_available_servers();
if !lsp_servers.is_empty() {
let names: Vec<String> = lsp_servers
.iter()
.map(|s| format!("{} ({})", s.language, s.command))
.collect();
eprintln!("LSP: {}", names.join(", "));
for server in &lsp_servers {
tools::global_lsp_registry().register_with_descriptor(
&server.language,
runtime::lsp_client::LspServerStatus::Starting,
None,
vec![],
server.clone(),
);
}
}
loop {
editor.set_completions(cli.repl_completion_candidates().unwrap_or_default());
match editor.read_line()? {
@ -7072,6 +7095,7 @@ fn run_repl(
continue;
}
if matches!(trimmed.as_str(), "/exit" | "/quit") {
cli.shutdown_lsp_servers();
cli.persist_session()?;
break;
}
@ -7114,6 +7138,7 @@ fn run_repl(
}
input::ReadOutcome::Cancel => {}
input::ReadOutcome::Exit => {
cli.shutdown_lsp_servers();
cli.persist_session()?;
break;
}
@ -8256,6 +8281,10 @@ impl LiveCli {
eprintln!("{cmd_name} is not yet implemented in this build.");
false
}
SlashCommand::Lsp { action, target } => {
self.handle_lsp_command(action.as_deref(), target.as_deref());
false
}
SlashCommand::Unknown(name) => {
eprintln!("{}", format_unknown_slash_command(&name));
false
@ -8263,6 +8292,53 @@ impl LiveCli {
})
}
fn handle_lsp_command(&self, action: Option<&str>, target: Option<&str>) {
let registry = tools::global_lsp_registry();
match action {
Some("start") => {
let lang = target.unwrap_or("unknown");
match registry.start_server(lang) {
Ok(()) => eprintln!("LSP server '{lang}' started."),
Err(e) => eprintln!("Failed to start LSP server '{lang}': {e}"),
}
}
Some("stop") => {
let lang = target.unwrap_or("unknown");
match registry.stop_server(lang) {
Ok(()) => eprintln!("LSP server '{lang}' stopped."),
Err(e) => eprintln!("Failed to stop LSP server '{lang}': {e}"),
}
}
Some("restart") => {
let lang = target.unwrap_or("unknown");
let _ = registry.stop_server(lang);
match registry.start_server(lang) {
Ok(()) => eprintln!("LSP server '{lang}' restarted."),
Err(e) => eprintln!("Failed to restart LSP server '{lang}': {e}"),
}
}
_ => {
let servers = registry.list_servers();
if servers.is_empty() {
eprintln!("No LSP servers registered.");
} else {
for s in &servers {
eprintln!(" {} [{}]", s.language, s.status);
}
}
}
}
}
fn shutdown_lsp_servers(&self) {
let registry = tools::global_lsp_registry();
for server in registry.list_servers() {
if server.status == runtime::lsp_client::LspServerStatus::Connected {
let _ = registry.stop_server(&server.language);
}
}
}
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
self.runtime.session().save_to_path(&self.session.path)?;
Ok(())

View File

@ -36,7 +36,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
/// Global task registry shared across tool invocations within a session.
fn global_lsp_registry() -> &'static LspRegistry {
pub fn global_lsp_registry() -> &'static LspRegistry {
use std::sync::OnceLock;
static REGISTRY: OnceLock<LspRegistry> = OnceLock::new();
REGISTRY.get_or_init(LspRegistry::new)