diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 31c09173..fd5b0bdd 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -6048,6 +6048,7 @@ mod tests { assert!(!help.contains("/login")); assert!(!help.contains("/logout")); <<<<<<< HEAD +<<<<<<< HEAD <<<<<<< HEAD assert!(help.contains("/setup")); assert_eq!(slash_command_specs().len(), 140); @@ -6057,6 +6058,9 @@ mod tests { ======= assert_eq!(slash_command_specs().len(), 141); >>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) +======= + assert_eq!(slash_command_specs().len(), 140); +>>>>>>> 353da088 (feat(lsp): install prompts, new servers, and advanced LSP features) assert!(resume_supported_slash_commands().len() >= 39); } diff --git a/rust/crates/runtime/src/lsp_client/dispatch.rs b/rust/crates/runtime/src/lsp_client/dispatch.rs index 4533f763..d07943ef 100644 --- a/rust/crates/runtime/src/lsp_client/dispatch.rs +++ b/rust/crates/runtime/src/lsp_client/dispatch.rs @@ -1,6 +1,7 @@ //! LSP action dispatch: routes actions to the appropriate server process. use super::types::{LspAction, LspServerStatus}; +use crate::lsp_process::LspProcessError; impl super::LspRegistry { /// Dispatch an LSP action and return a structured result. @@ -42,9 +43,18 @@ impl super::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 language = Self::language_for_path(path) - .ok_or_else(|| format!("no LSP server available for path: {path}"))?; + // (workspace_symbols operates without a specific file path) + let language = if lsp_action == LspAction::WorkspaceSymbols { + // Try to find any connected server for workspace symbols + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.keys().next().cloned() + .ok_or_else(|| "no LSP servers available for workspace symbols".to_owned())? + } else { + let p = path.ok_or("path is required for this LSP action")?; + Self::language_for_path(p) + .ok_or_else(|| format!("no LSP server available for path: {p}"))? + }; + let path = path.unwrap_or(""); // Check the entry exists { @@ -54,6 +64,23 @@ impl super::LspRegistry { } } + // Check if the server is already in a non-starting state + { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get(&language) { + if entry.state.status == LspServerStatus::Disconnected + || entry.state.status == LspServerStatus::Error + { + if entry.process.is_none() { + return Err(format!( + "LSP server for '{}' is not connected (status: {})", + language, entry.state.status + )); + } + } + } + } + // Lazy-start: if no process yet, try to start one let needs_start = { let inner = self.inner.lock().expect("lsp registry lock poisoned"); @@ -214,6 +241,80 @@ impl super::LspRegistry { "edits": text_edits, })) } + LspAction::CodeAction => { + let end_line = if line > 0 { Some(line) } else { None }; + let end_character = if character > 0 { Some(character) } else { None }; + let actions = process.code_action(path, line, character, end_line, end_character, None).await; + actions.map(|acts| serde_json::json!({ + "action": "code_action", + "path": path, + "line": 0, + "character": 0, + "end_line": end_line, + "end_character": end_character, + "language": language, + "status": "ok", + "actions": acts, + })) + } + LspAction::Rename => { + let new_name = _query.ok_or_else(|| LspProcessError::InvalidRequest("new_name required for rename".into()))?; + let rename_result = process.rename(path, line, character, new_name).await; + rename_result.map(|r| serde_json::json!({ + "action": "rename", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "result": r, + })) + } + LspAction::SignatureHelp => { + let sig = process.signature_help(path, line, character).await; + sig.map(|opt| { + opt.map_or_else( + || serde_json::json!({ + "action": "signature_help", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "no_result", + }), + |s| serde_json::json!({ + "action": "signature_help", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "result": s, + }), + ) + }) + } + LspAction::CodeLens => { + let lenses = process.code_lens(path).await; + lenses.map(|l| serde_json::json!({ + "action": "code_lens", + "path": path, + "language": language, + "status": "ok", + "lenses": l, + })) + } + LspAction::WorkspaceSymbols => { + let query = _query.unwrap_or(""); + let symbols = process.workspace_symbols(query).await; + symbols.map(|syms| serde_json::json!({ + "action": "workspace_symbols", + "language": language, + "query": query, + "status": "ok", + "symbols": syms, + })) + } LspAction::Diagnostics => unreachable!(), } }) diff --git a/rust/crates/runtime/src/lsp_client/mod.rs b/rust/crates/runtime/src/lsp_client/mod.rs index 05aa5c67..7a9c7a3b 100644 --- a/rust/crates/runtime/src/lsp_client/mod.rs +++ b/rust/crates/runtime/src/lsp_client/mod.rs @@ -9,8 +9,10 @@ mod tests; mod tests_lifecycle; pub use types::{ - LspAction, LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerState, - LspServerStatus, LspSymbol, + LspAction, LspCodeAction, LspCodeLens, LspCommand, LspCompletionItem, LspDiagnostic, + LspFileEdit, LspHoverResult, LspLocation, LspParameterInfo, LspRenameResult, + LspServerState, LspServerStatus, LspSignatureHelpResult, LspSignatureInformation, + LspSymbol, LspTextEdit, LspWorkspaceEdit, }; use std::collections::{HashMap, HashSet}; @@ -144,6 +146,12 @@ impl LspRegistry { "cpp" | "hpp" | "cc" => "cpp", "rb" => "ruby", "lua" => "lua", + "html" | "htm" => "html", + "css" | "scss" | "less" | "sass" => "css", + "json" | "jsonc" => "json", + "sh" | "bash" | "zsh" => "bash", + "yaml" | "yml" => "yaml", + "gd" => "gdscript", _ => return None, }; @@ -167,6 +175,12 @@ impl LspRegistry { "cpp" | "hpp" | "cc" => "cpp", "rb" => "ruby", "lua" => "lua", + "html" | "htm" => "html", + "css" | "scss" | "less" | "sass" => "css", + "json" | "jsonc" => "json", + "sh" | "bash" | "zsh" => "bash", + "yaml" | "yml" => "yaml", + "gd" => "gdscript", _ => return None, }; @@ -433,6 +447,39 @@ impl LspRegistry { diagnostics } + + /// Notify the LSP server that a file was closed. + /// Best-effort: returns empty vec if no server is available. + pub fn notify_file_close(&self, path: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + match inner.servers.get(&language).and_then(|e| e.process.clone()) { + Some(p) => p, + None => return Vec::new(), + } + }; + + if let Ok(mut process) = process_arc.lock() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + if let Ok(rt) = rt { + let _ = rt.block_on(process.did_close(path)); + } + } + + // Mark file as closed + { + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.open_files.remove(path); + } + + Vec::new() + } /// Fetch diagnostics for a file by draining pending server notifications /// and returning cached diagnostics. pub fn fetch_diagnostics_for_file(&self, path: &str) -> Vec { diff --git a/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs b/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs index 67c2c554..7b2a094b 100644 --- a/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs +++ b/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs @@ -188,6 +188,7 @@ fn register_with_descriptor_stores_entry() { command: "rust-analyzer".into(), args: vec![], extensions: vec!["rs".into()], + install_hint: vec![], }; registry.register_with_descriptor( "rust", @@ -238,6 +239,7 @@ fn dispatch_hover_lazy_starts_server() { command: "rust-analyzer".into(), args: vec![], extensions: vec!["rs".into()], + install_hint: vec![], }; registry.register_with_descriptor( "rust", @@ -271,6 +273,7 @@ fn start_and_stop_server() { command: "rust-analyzer".into(), args: vec![], extensions: vec!["rs".into()], + install_hint: vec![], }; registry.register_with_descriptor( "rust", diff --git a/rust/crates/runtime/src/lsp_client/types.rs b/rust/crates/runtime/src/lsp_client/types.rs index 485a5697..d0ec60bd 100644 --- a/rust/crates/runtime/src/lsp_client/types.rs +++ b/rust/crates/runtime/src/lsp_client/types.rs @@ -1,4 +1,5 @@ -//! LSP type definitions: action enums, diagnostic/location types, server status. +//! LSP type definitions: action enums, diagnostic/location types, server status, +//! and structured results for all supported LSP features. use serde::{Deserialize, Serialize}; @@ -13,6 +14,11 @@ pub enum LspAction { Completion, Symbols, Format, + CodeAction, + Rename, + SignatureHelp, + CodeLens, + WorkspaceSymbols, } impl LspAction { @@ -25,6 +31,11 @@ impl LspAction { "completion" | "completions" => Some(Self::Completion), "symbols" | "document_symbols" => Some(Self::Symbols), "format" | "formatting" => Some(Self::Format), + "code_action" | "codeaction" => Some(Self::CodeAction), + "rename" => Some(Self::Rename), + "signature_help" | "signatures" => Some(Self::SignatureHelp), + "code_lens" | "codelens" => Some(Self::CodeLens), + "workspace_symbols" => Some(Self::WorkspaceSymbols), _ => None, } } @@ -73,6 +84,87 @@ pub struct LspSymbol { pub character: u32, } +/// A code action (quick fix, refactor, etc.) returned by the server. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspCodeAction { + pub title: String, + pub kind: Option, + pub is_preferred: bool, + pub edit: Option, + pub command: Option, +} + +/// A workspace edit containing multiple file changes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspWorkspaceEdit { + pub changes: Vec, +} + +/// Edits to a single file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspFileEdit { + pub path: String, + pub edits: Vec, +} + +/// A single text edit operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspTextEdit { + pub new_text: String, + pub start_line: u32, + pub start_character: u32, + pub end_line: u32, + pub end_character: u32, +} + +/// A command that the server requests the client to execute. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspCommand { + pub title: String, + pub command: String, + pub arguments: Vec, +} + +/// Result of a rename operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspRenameResult { + pub new_name: String, + pub edit: Option, +} + +/// A single parameter in a function signature. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspParameterInfo { + pub label: String, + pub documentation: Option, +} + +/// A function signature with its parameters. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspSignatureInformation { + pub label: String, + pub documentation: Option, + pub parameters: Vec, + pub active_parameter: Option, +} + +/// Result of a signature help request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspSignatureHelpResult { + pub signatures: Vec, + pub active_signature: Option, + pub active_parameter: Option, +} + +/// A code lens item — an actionable hint inline in the editor. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspCodeLens { + pub line: u32, + pub character: u32, + pub command: Option, + pub data: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LspServerStatus { diff --git a/rust/crates/runtime/src/lsp_discovery.rs b/rust/crates/runtime/src/lsp_discovery.rs index 32edd2c2..f00291a9 100644 --- a/rust/crates/runtime/src/lsp_discovery.rs +++ b/rust/crates/runtime/src/lsp_discovery.rs @@ -1,20 +1,56 @@ -//! Auto-discovery of installed LSP servers and file-extension mapping. +//! Auto-discovery of installed LSP servers, file-extension mapping, and +//! distro-aware install prompting. 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. +/// Descriptor for a well-known LSP server, including its launch command, +/// the file extensions it handles, and how to install it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LspServerDescriptor { pub language: String, pub command: String, pub args: Vec, pub extensions: Vec, + pub install_hint: Vec, } -/// Static descriptor used by the [`KNOWN_LSP_SERVERS`] constant. Uses -/// `&'static str` fields so the table can live in read-only memory. +/// A single install command for a specific package manager or platform. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallInstruction { + pub label: String, + pub command: String, +} + +/// What the caller should do when a server is missing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LspInstallAction { + /// The server is already available. + Installed, + /// The server is not found; these are the suggested install commands. + Missing { language: String, instructions: Vec }, + /// The server binary exists but is a rustup proxy stub for an uninstalled component. + RustupProxyMissing { language: String, component: String }, +} + +/// Detect the current Linux distribution (or non-Linux). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinuxDistro { + Debian, + Ubuntu, + Fedora, + Arch, + OpenSuse, + Alpine, + Void, + NixOS, + UnknownLinux, + MacOS, + Windows, + Other, +} + +/// Static descriptor used by the [`KNOWN_LSP_SERVERS_TABLE`] constant. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct StaticLspServerDescriptor { language: &'static str, @@ -31,6 +67,7 @@ impl StaticLspServerDescriptor { 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(), + install_hint: install_instructions_for(self.language), } } } @@ -85,8 +122,138 @@ const KNOWN_LSP_SERVERS_TABLE: &[StaticLspServerDescriptor] = &[ args: &[], extensions: &["lua"], }, + StaticLspServerDescriptor { + language: "html", + command: "vscode-html-language-server", + args: &["--stdio"], + extensions: &["html", "htm"], + }, + StaticLspServerDescriptor { + language: "css", + command: "vscode-css-language-server", + args: &["--stdio"], + extensions: &["css", "scss", "less", "sass"], + }, + StaticLspServerDescriptor { + language: "json", + command: "vscode-json-language-server", + args: &["--stdio"], + extensions: &["json", "jsonc"], + }, + StaticLspServerDescriptor { + language: "bash", + command: "bash-language-server", + args: &["start"], + extensions: &["sh", "bash", "zsh"], + }, + StaticLspServerDescriptor { + language: "yaml", + command: "yaml-language-server", + args: &["--stdio"], + extensions: &["yaml", "yml"], + }, + StaticLspServerDescriptor { + language: "gdscript", + command: "godot", + args: &["--headless", "--editor"], + extensions: &["gd"], + }, ]; +/// Return install instructions for a known language server, covering all +/// common distros and package managers. Order doesn't matter — the caller +/// picks the one matching the current system. +fn install_instructions_for(language: &str) -> Vec { + match language { + "rust" => vec![ + InstallInstruction { label: "rustup".into(), command: "rustup component add rust-analyzer".into() }, + InstallInstruction { label: "Ubuntu/Debian".into(), command: "sudo apt install rust-analyzer".into() }, + InstallInstruction { label: "Fedora".into(), command: "sudo dnf install rust-analyzer".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S rust-analyzer".into() }, + InstallInstruction { label: "openSUSE".into(), command: "sudo zypper install rust-analyzer".into() }, + InstallInstruction { label: "Alpine".into(), command: "sudo apk add rust-analyzer".into() }, + InstallInstruction { label: "Void".into(), command: "sudo xbps-install rust-analyzer".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.rust-analyzer".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install rust-analyzer".into() }, + InstallInstruction { label: "pip".into(), command: "pip install rust-analyzer".into() }, + ], + "c/cpp" => vec![ + InstallInstruction { label: "Ubuntu/Debian".into(), command: "sudo apt install clangd".into() }, + InstallInstruction { label: "Fedora".into(), command: "sudo dnf install clang-tools-extra".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S clang".into() }, + InstallInstruction { label: "openSUSE".into(), command: "sudo zypper install clang-tools".into() }, + InstallInstruction { label: "Alpine".into(), command: "sudo apk add clang-extra-tools".into() }, + InstallInstruction { label: "Void".into(), command: "sudo xbps-install clang-tools-extra".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.clang-tools".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install llvm".into() }, + ], + "python" => vec![ + InstallInstruction { label: "npm".into(), command: "npm install -g pyright".into() }, + InstallInstruction { label: "pip".into(), command: "pip install pyright".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S pyright".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.pyright".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install pyright".into() }, + ], + "go" => vec![ + InstallInstruction { label: "go".into(), command: "go install golang.org/x/tools/gopls@latest".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S gopls".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.gopls".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install gopls".into() }, + ], + "typescript" => vec![ + InstallInstruction { label: "npm".into(), command: "npm install -g typescript-language-server typescript".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S typescript-language-server".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.typescript-language-server".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install typescript-language-server".into() }, + ], + "java" => vec![ + InstallInstruction { label: "Ubuntu/Debian".into(), command: "sudo apt install eclipse-jdtls".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S jdtls".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.eclipse-jdtls".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install jdtls".into() }, + ], + "ruby" => vec![ + InstallInstruction { label: "gem".into(), command: "gem install solargraph".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S solargraph".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.solargraph".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install solargraph".into() }, + ], + "lua" => vec![ + InstallInstruction { label: "npm".into(), command: "npm install -g lua-language-server".into() }, + InstallInstruction { label: "Ubuntu/Debian".into(), command: "sudo apt install lua-language-server".into() }, + InstallInstruction { label: "Fedora".into(), command: "sudo dnf install lua-language-server".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S lua-language-server".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.lua-language-server".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install lua-language-server".into() }, + ], + "html" | "css" | "json" => vec![ + InstallInstruction { label: "npm".into(), command: "npm install -g vscode-langservers-extracted".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S vscode-langservers-extracted".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.vscode-langservers-extracted".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install vscode-langservers-extracted".into() }, + ], + "bash" => vec![ + InstallInstruction { label: "npm".into(), command: "npm install -g bash-language-server".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S bash-language-server".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.bash-language-server".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install bash-language-server".into() }, + ], + "yaml" => vec![ + InstallInstruction { label: "npm".into(), command: "npm install -g yaml-language-server".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S yaml-language-server".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.yaml-language-server".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install yaml-language-server".into() }, + ], + "gdscript" => vec![ + InstallInstruction { label: "Godot Editor".into(), command: "Download from https://godotengine.org".into() }, + InstallInstruction { label: "Arch".into(), command: "sudo pacman -S godot".into() }, + InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.godot".into() }, + InstallInstruction { label: "macOS".into(), command: "brew install godot".into() }, + ], + _ => Vec::new(), + } +} + /// Owned copy of the known LSP server descriptors, useful when callers need /// to mutate or transfer ownership. #[must_use] @@ -101,12 +268,15 @@ pub fn known_lsp_servers() -> Vec { /// with `--version`. Returns `true` if the command could be spawned /// successfully, `false` otherwise. <<<<<<< HEAD +<<<<<<< HEAD /// /// Some LSP servers (like rust-analyzer via rustup) exit non-zero on --version /// but are still functional. We treat "spawned successfully" as found, regardless /// of the exit code. Only a failure to spawn (command not found) returns false. ======= >>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) +======= +>>>>>>> 353da088 (feat(lsp): install prompts, new servers, and advanced LSP features) #[must_use] pub fn command_exists_on_path(command: &str) -> bool { Command::new(command) @@ -124,7 +294,6 @@ 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") } @@ -150,7 +319,159 @@ fn rustup_component_works(component: &str) -> bool { >>>>>>> ab3550e5 (feat(lsp): add lspAutoStart config, remove unused LSP client/process/transport modules) } +/// Detect the current platform/distro for install suggestion filtering. +#[must_use] +pub fn detect_platform() -> LinuxDistro { + if cfg!(target_os = "macos") { + return LinuxDistro::MacOS; + } + if cfg!(target_os = "windows") { + return LinuxDistro::Windows; + } + if !cfg!(target_os = "linux") { + return LinuxDistro::Other; + } + + let contents = std::fs::read_to_string("/etc/os-release").unwrap_or_default(); + + if contents.contains("Ubuntu") { + LinuxDistro::Ubuntu + } else if contents.contains("Debian") { + LinuxDistro::Debian + } else if contents.contains("Fedora") { + LinuxDistro::Fedora + } else if contents.contains("Arch") || contents.contains("archlinux") || contents.contains("Manjaro") || contents.contains("EndeavourOS") { + LinuxDistro::Arch + } else if contents.contains("openSUSE") || contents.contains("SUSE") { + LinuxDistro::OpenSuse + } else if contents.contains("Alpine") { + LinuxDistro::Alpine + } else if contents.contains("Void") { + LinuxDistro::Void + } else if contents.contains("NixOS") { + LinuxDistro::NixOS + } else { + LinuxDistro::UnknownLinux + } +} + +/// Return the best install instruction for a language given the current platform. +/// Returns `None` if no instructions are known for this language. +#[must_use] +pub fn best_install_instruction(language: &str) -> Option { + let distro = detect_platform(); + let instructions = install_instructions_for(language); + if instructions.is_empty() { + return None; + } + + let label_match = match distro { + LinuxDistro::Ubuntu | LinuxDistro::Debian => "Ubuntu/Debian", + LinuxDistro::Fedora => "Fedora", + LinuxDistro::Arch => "Arch", + LinuxDistro::OpenSuse => "openSUSE", + LinuxDistro::Alpine => "Alpine", + LinuxDistro::Void => "Void", + LinuxDistro::NixOS => "NixOS", + LinuxDistro::MacOS => "macOS", + LinuxDistro::Windows | LinuxDistro::UnknownLinux | LinuxDistro::Other => { + instructions.first().map(|i| i.label.as_str()).unwrap_or("") + } + }; + + instructions + .iter() + .find(|i| i.label == label_match) + .or_else(|| instructions.first()) + .cloned() +} + +/// Check which known LSP servers are missing and produce install suggestions. +/// Returns a list of `LspInstallAction` for every known language: installed, +/// missing, or rustup-proxy-missing. +#[must_use] +pub fn check_lsp_availability() -> Vec { + let mut actions = Vec::new(); + + for desc in KNOWN_LSP_SERVERS_TABLE { + if !command_exists_on_path(desc.command) { + actions.push(LspInstallAction::Missing { + language: desc.language.to_string(), + instructions: install_instructions_for(desc.language), + }); + continue; + } + + if desc.command == "rust-analyzer" && is_rustup_proxy("rust-analyzer") { + if rustup_component_works("rust-analyzer") { + actions.push(LspInstallAction::Installed); + } else { + actions.push(LspInstallAction::RustupProxyMissing { + language: desc.language.to_string(), + component: "rust-analyzer".to_string(), + }); + } + continue; + } + + actions.push(LspInstallAction::Installed); + } + + actions +} + +/// Format a human-readable install prompt for missing LSP servers. +#[must_use] +pub fn format_install_prompt(actions: &[LspInstallAction]) -> String { + let mut lines = Vec::new(); + let distro = detect_platform(); + + for action in actions { + match action { + LspInstallAction::Installed => continue, + LspInstallAction::Missing { language, instructions } => { + lines.push(format!(" {language}: not found")); + let best = instructions + .iter() + .find(|i| match distro { + LinuxDistro::Ubuntu | LinuxDistro::Debian => i.label == "Ubuntu/Debian", + LinuxDistro::Fedora => i.label == "Fedora", + LinuxDistro::Arch => i.label == "Arch", + LinuxDistro::OpenSuse => i.label == "openSUSE", + LinuxDistro::Alpine => i.label == "Alpine", + LinuxDistro::Void => i.label == "Void", + LinuxDistro::NixOS => i.label == "NixOS", + LinuxDistro::MacOS => i.label == "macOS", + _ => false, + }) + .or_else(|| instructions.first()); + if let Some(inst) = best { + lines.push(format!(" → {}", inst.command)); + } + for inst in instructions { + if Some(inst) != best { + lines.push(format!(" • {} ({})", inst.command, inst.label)); + } + } + } + LspInstallAction::RustupProxyMissing { language, component } => { + lines.push(format!(" {language}: rustup proxy found but component not installed")); + lines.push(format!(" → rustup component add {component}")); + } + } + } + + if lines.is_empty() { + return String::new(); + } + + let mut out = "LSP servers missing — install for code intelligence:\n".to_string(); + out.push_str(&lines.join("\n")); + out +} + /// Discover LSP servers that are actually installed on the current system. +<<<<<<< HEAD /// /// Iterates over the known server table and returns only those whose command <<<<<<< HEAD @@ -168,6 +489,8 @@ fn rustup_component_works(component: &str) -> bool { >>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) ======= >>>>>>> ab3550e5 (feat(lsp): add lspAutoStart config, remove unused LSP client/process/transport modules) +======= +>>>>>>> 353da088 (feat(lsp): install prompts, new servers, and advanced LSP features) #[must_use] pub fn discover_available_servers() -> Vec { KNOWN_LSP_SERVERS_TABLE @@ -179,12 +502,7 @@ pub fn discover_available_servers() -> Vec { >>>>>>> ab3550e5 (feat(lsp): add lspAutoStart config, remove unused LSP client/process/transport modules) .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![ @@ -193,7 +511,6 @@ pub fn discover_available_servers() -> Vec { "rust-analyzer".to_string(), ]; } else { - // Component truly not installed — skip it. return None; } } @@ -209,9 +526,6 @@ pub fn discover_available_servers() -> Vec { } /// 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, @@ -285,7 +599,6 @@ mod tests { #[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), @@ -294,9 +607,8 @@ mod tests { 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") { + if command_exists_on_path("rust-analyzer") && !is_rustup_proxy("rust-analyzer") { assert!(languages.contains(&"rust"), "rust-analyzer is on PATH but 'rust' not in discovered servers"); } if command_exists_on_path("clangd") { @@ -328,4 +640,66 @@ mod tests { 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"); } + + #[test] + fn install_instructions_cover_all_languages() { + for desc in KNOWN_LSP_SERVERS_TABLE { + let instructions = install_instructions_for(desc.language); + assert!(!instructions.is_empty(), "no install instructions for '{}'", desc.language); + } + } + + #[test] + fn best_install_returns_something_for_known_languages() { + for desc in KNOWN_LSP_SERVERS_TABLE { + assert!(best_install_instruction(desc.language).is_some(), "no best install for '{}'", desc.language); + } + } + + #[test] + fn format_install_prompt_skips_installed() { + let actions = vec![LspInstallAction::Installed]; + let prompt = format_install_prompt(&actions); + assert!(prompt.is_empty(), "should not prompt for installed servers"); + } + + #[test] + fn format_install_prompt_shows_missing() { + let actions = vec![LspInstallAction::Missing { + language: "rust".into(), + instructions: install_instructions_for("rust"), + }]; + let prompt = format_install_prompt(&actions); + assert!(prompt.contains("rust"), "should mention rust"); + assert!(prompt.contains("rustup component add rust-analyzer"), "should show rustup command"); + } + + #[test] + fn format_install_prompt_shows_rustup_proxy_missing() { + let actions = vec![LspInstallAction::RustupProxyMissing { + language: "rust".into(), + component: "rust-analyzer".into(), + }]; + let prompt = format_install_prompt(&actions); + assert!(prompt.contains("rustup component add rust-analyzer")); + } + + #[test] + fn detect_platform_returns_something() { + let _ = detect_platform(); + } + + #[test] + fn check_availability_returns_one_per_known_language() { + let actions = check_lsp_availability(); + assert_eq!(actions.len(), KNOWN_LSP_SERVERS_TABLE.len()); + } + + #[test] + fn server_descriptors_have_install_hints() { + let servers = known_lsp_servers(); + for server in &servers { + assert!(!server.install_hint.is_empty(), "server '{}' should have install hints", server.language); + } + } } diff --git a/rust/crates/runtime/src/lsp_process/mod.rs b/rust/crates/runtime/src/lsp_process/mod.rs index ed0fc107..dab6aa37 100644 --- a/rust/crates/runtime/src/lsp_process/mod.rs +++ b/rust/crates/runtime/src/lsp_process/mod.rs @@ -11,13 +11,17 @@ use std::path::Path; use serde_json::Value as JsonValue; use crate::lsp_client::{ - LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerStatus, LspSymbol, + LspCodeAction, LspCodeLens, LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, + LspRenameResult, LspServerStatus, LspSignatureHelpResult, LspSymbol, }; use crate::lsp_transport::{LspTransport, LspTransportError}; use parse::{ - canonicalize_root, language_id_for_path, parse_completions, parse_hover, parse_locations, - parse_symbols, path_to_uri, severity_name, text_document_position_params, uri_to_path, + canonicalize_root, language_id_for_path, parse_code_actions, parse_code_lens, + parse_completions, parse_hover, parse_locations, parse_signature_help, + parse_symbols, parse_workspace_edit, parse_workspace_symbols, path_to_uri, + rename_params, severity_name, text_document_position_params, uri_to_path, + workspace_symbol_params, }; #[derive(Debug)] @@ -69,6 +73,7 @@ impl LspProcess { let params = serde_json::json!({ "processId": pid, "rootUri": root_uri, + "workspaceFolders": [{ "uri": root_uri, "name": "root" }], "capabilities": { "textDocument": { "hover": { "contentFormat": ["markdown", "plaintext"] }, @@ -78,7 +83,30 @@ impl LspProcess { "completionItem": { "snippetSupport": false } }, "documentSymbol": { "hierarchicalDocumentSymbolSupport": true }, - "publishDiagnostics": { "relatedInformation": true } + "publishDiagnostics": { "relatedInformation": true }, + "codeAction": { + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", "quickfix", "refactor", "refactor.extract", + "refactor.inline", "refactor.rewrite", "source", + "source.organizeImports" + ] + } + } + }, + "rename": { "prepareSupport": true }, + "signatureHelp": { + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": { "labelOffsetSupport": true } + } + }, + "codeLens": {} + }, + "workspace": { + "symbol": {}, + "workspaceFolders": true } } }); @@ -341,6 +369,145 @@ impl LspProcess { Ok(()) } + + /// Notify the server that a file was closed. Sends `textDocument/didClose`. + pub async fn did_close(&mut self, path: &str) -> Result<(), LspProcessError> { + if !self.open_files.contains(path) { + return Ok(()); + } + let uri = path_to_uri(path); + let params = serde_json::json!({ + "textDocument": { "uri": uri } + }); + self.transport + .send_notification("textDocument/didClose", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + self.open_files.remove(path); + self.version_counter.remove(path); + Ok(()) + } + + /// Request code actions (quick fixes, refactors) for a range in a file. + pub async fn code_action( + &mut self, + path: &str, + line: u32, + character: u32, + end_line: Option, + end_character: Option, + only_kinds: Option<&[String]>, + ) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let el = end_line.unwrap_or(line); + let ec = end_character.unwrap_or(character); + let mut params = serde_json::json!({ + "textDocument": { "uri": uri }, + "range": { + "start": { "line": line, "character": character }, + "end": { "line": el, "character": ec } + }, + "context": { "diagnostics": [] } + }); + if let Some(kinds) = only_kinds { + params["context"]["only"] = serde_json::json!(kinds); + } + let response = self + .transport + .send_request("textDocument/codeAction", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + Ok(parse_code_actions(&result)) + } + + /// Rename a symbol at a position across the workspace. + pub async fn rename( + &mut self, + path: &str, + line: u32, + character: u32, + new_name: &str, + ) -> Result { + let uri = path_to_uri(path); + let params = rename_params(&uri, line, character, new_name); + let response = self + .transport + .send_request("textDocument/rename", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + let edit = parse_workspace_edit(&result); + Ok(LspRenameResult { + new_name: new_name.to_owned(), + edit, + }) + } + + /// Get signature help at a position (function signatures, parameters). + pub async fn signature_help( + &mut self, + path: &str, + line: u32, + character: u32, + ) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = text_document_position_params(&uri, line, character); + let response = self + .transport + .send_request("textDocument/signatureHelp", 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_signature_help(&result)) + } + + /// Get code lens items for a file (actionable inline hints). + pub async fn code_lens(&mut self, path: &str) -> Result, LspProcessError> { + let uri = path_to_uri(path); + let params = serde_json::json!({ + "textDocument": { "uri": uri } + }); + let response = self + .transport + .send_request("textDocument/codeLens", 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_code_lens(&result)) + } + + /// Search for symbols across the entire workspace. + pub async fn workspace_symbols( + &mut self, + query: &str, + ) -> Result, LspProcessError> { + let params = workspace_symbol_params(query); + let response = self + .transport + .send_request("workspace/symbol", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + let result = response + .into_result() + .map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?; + Ok(parse_workspace_symbols(&result)) + } + /// Drain queued server notifications and extract `publishDiagnostics`. #[allow(clippy::redundant_closure_for_method_calls)] pub fn drain_diagnostics(&mut self) -> Vec { @@ -415,6 +582,7 @@ impl LspProcess { pub enum LspProcessError { Transport(LspTransportError), InvalidPath(String), + InvalidRequest(String), } impl std::fmt::Display for LspProcessError { @@ -422,6 +590,7 @@ impl std::fmt::Display for LspProcessError { match self { Self::Transport(e) => write!(f, "LSP transport error: {e}"), Self::InvalidPath(p) => write!(f, "invalid path: {p}"), + Self::InvalidRequest(msg) => write!(f, "invalid request: {msg}"), } } } @@ -430,7 +599,7 @@ 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, + Self::InvalidPath(_) | Self::InvalidRequest(_) => None, } } } diff --git a/rust/crates/runtime/src/lsp_process/parse.rs b/rust/crates/runtime/src/lsp_process/parse.rs index 575f425a..1a5debf4 100644 --- a/rust/crates/runtime/src/lsp_process/parse.rs +++ b/rust/crates/runtime/src/lsp_process/parse.rs @@ -309,3 +309,140 @@ pub(super) fn completion_kind_name(kind: u64) -> String { _ => format!("Unknown({kind})"), } } + + +#[allow(clippy::cast_possible_truncation)] +pub(super) fn parse_code_actions(value: &JsonValue) -> Vec { + let Some(items) = value.as_array() else { + return Vec::new(); + }; + items.iter().filter_map(|item| { + // Code actions can be Command or CodeAction objects; we only parse CodeAction + let title = item.get("title")?.as_str()?.to_owned(); + let kind = item.get("kind").and_then(JsonValue::as_str).map(str::to_owned); + let is_preferred = item.get("isPreferred").and_then(JsonValue::as_bool).unwrap_or(false); + let edit = item.get("edit").and_then(|e| parse_workspace_edit(e)); + let command = item.get("command").and_then(parse_command); + Some(crate::lsp_client::LspCodeAction { title, kind, is_preferred, edit, command }) + }).collect() +} + +pub(super) fn parse_workspace_edit(value: &JsonValue) -> Option { + let changes = if let Some(changes_map) = value.get("changes").and_then(JsonValue::as_object) { + changes_map.iter().filter_map(|(uri, edits)| { + let path = uri_to_path(uri); + let edit_list = edits.as_array()?; + let text_edits: Vec = edit_list.iter().filter_map(|e| { + let new_text = e.get("newText")?.as_str()?.to_owned(); + let range = e.get("range")?; + let start = range.get("start")?; + let end = range.get("end")?; + Some(crate::lsp_client::LspTextEdit { + new_text, + start_line: start.get("line")?.as_u64()? as u32, + start_character: start.get("character")?.as_u64()? as u32, + end_line: end.get("line")?.as_u64()? as u32, + end_character: end.get("character")?.as_u64()? as u32, + }) + }).collect(); + if text_edits.is_empty() { None } else { Some(crate::lsp_client::LspFileEdit { path, edits: text_edits }) } + }).collect() + } else { + Vec::new() + }; + if changes.is_empty() { None } else { Some(crate::lsp_client::LspWorkspaceEdit { changes }) } +} + +pub(super) fn parse_command(value: &JsonValue) -> Option { + let title = value.get("title")?.as_str()?.to_owned(); + let command = value.get("command")?.as_str()?.to_owned(); + let arguments = value.get("arguments") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + Some(crate::lsp_client::LspCommand { title, command, arguments }) +} + +#[allow(clippy::cast_possible_truncation)] +pub(super) fn parse_signature_help(value: &JsonValue) -> Option { + let signatures_arr = value.get("signatures")?.as_array()?; + let signatures: Vec = signatures_arr.iter().filter_map(|sig| { + let label = sig.get("label")?.as_str()?.to_owned(); + let documentation = sig.get("documentation") + .and_then(|d| d.get("value").and_then(JsonValue::as_str).or_else(|| d.as_str())) + .map(str::to_owned); + let parameters = sig.get("parameters").and_then(JsonValue::as_array) + .map(|arr| arr.iter().filter_map(|p| { + let plabel = p.get("label").and_then(|l| l.as_str().or_else(|| l.get("value").and_then(JsonValue::as_str))).unwrap_or("").to_owned(); + let pdoc = p.get("documentation") + .and_then(|d| d.get("value").and_then(JsonValue::as_str).or_else(|| d.as_str())) + .map(str::to_owned); + Some(crate::lsp_client::LspParameterInfo { label: plabel, documentation: pdoc }) + }).collect()) + .unwrap_or_default(); + let active_parameter = sig.get("activeParameter").and_then(JsonValue::as_u64).map(|v| v as u32); + Some(crate::lsp_client::LspSignatureInformation { label, documentation, parameters, active_parameter }) + }).collect(); + let active_signature = value.get("activeSignature").and_then(JsonValue::as_u64).map(|v| v as u32); + let active_parameter = value.get("activeParameter").and_then(JsonValue::as_u64).map(|v| v as u32); + Some(crate::lsp_client::LspSignatureHelpResult { signatures, active_signature, active_parameter }) +} + +#[allow(clippy::cast_possible_truncation)] +pub(super) fn parse_code_lens(value: &JsonValue) -> Vec { + let Some(items) = value.as_array() else { + return Vec::new(); + }; + items.iter().filter_map(|item| { + let range = item.get("range")?; + let start = range.get("start")?; + let line = start.get("line")?.as_u64()? as u32; + let character = start.get("character")?.as_u64()? as u32; + let command = item.get("command").and_then(parse_command); + let data = item.get("data").cloned(); + Some(crate::lsp_client::LspCodeLens { line, character, command, data }) + }).collect() +} + +pub(super) fn parse_workspace_symbols(value: &JsonValue) -> Vec { + let Some(items) = value.as_array() else { + return Vec::new(); + }; + items.iter().filter_map(|item| { + let name = item.get("name")?.as_str()?.to_owned(); + let kind = item.get("kind").and_then(JsonValue::as_u64).map_or_else(|| "Unknown".into(), symbol_kind_name); + let path = item.get("location") + .and_then(|l| l.get("uri")) + .and_then(JsonValue::as_str) + .map(uri_to_path) + .or_else(|| item.get("uri").and_then(JsonValue::as_str).map(uri_to_path)) + .unwrap_or_default(); + let line = item.get("location") + .and_then(|l| l.get("range")) + .and_then(|r| r.get("start")) + .and_then(|s| s.get("line")) + .and_then(JsonValue::as_u64) + .map_or(0, |v| v as u32); + let character = item.get("location") + .and_then(|l| l.get("range")) + .and_then(|r| r.get("start")) + .and_then(|s| s.get("character")) + .and_then(JsonValue::as_u64) + .map_or(0, |v| v as u32); + Some(crate::lsp_client::LspSymbol { name, kind, path, line, character }) + }).collect() +} + +pub(super) fn rename_params(uri: &str, line: u32, character: u32, new_name: &str) -> JsonValue { + serde_json::json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character }, + "newName": new_name + }) +} + +pub(super) fn workspace_symbol_params(query: &str) -> JsonValue { + serde_json::json!({ + "query": query + }) +} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 7ba81bf5..c6962aad 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -7124,6 +7124,15 @@ fn run_repl( >>>>>>> ab3550e5 (feat(lsp): add lspAutoStart config, remove unused LSP client/process/transport modules) } + // Show install suggestions for missing LSP servers + { + let availability = runtime::lsp_discovery::check_lsp_availability(); + let prompt = runtime::lsp_discovery::format_install_prompt(&availability); + if !prompt.is_empty() { + eprintln!("{prompt}"); + } + } + loop { editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); match editor.read_line()? { diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 029c6240..66d15002 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -1175,14 +1175,16 @@ pub fn mvp_tool_specs() -> Vec { }, ToolSpec { name: "LSP", - description: "Query Language Server Protocol for code intelligence (symbols, references, diagnostics).", + description: "Query Language Server Protocol for code intelligence (symbols, references, diagnostics, code actions, rename, signature help, code lens, workspace symbols).", input_schema: json!({ "type": "object", "properties": { - "action": { "type": "string", "enum": ["symbols", "references", "diagnostics", "definition", "hover"] }, + "action": { "type": "string", "enum": ["symbols", "references", "diagnostics", "definition", "hover", "code_action", "rename", "signature_help", "code_lens", "workspace_symbols"] }, "path": { "type": "string" }, "line": { "type": "integer", "minimum": 0 }, "character": { "type": "integer", "minimum": 0 }, + "end_line": { "type": "integer", "minimum": 0 }, + "end_character": { "type": "integer", "minimum": 0 }, "query": { "type": "string" } }, "required": ["action"], @@ -1863,7 +1865,18 @@ fn run_lsp(input: LspInput) -> Result { let character = input.character; let query = input.query.as_deref(); - match registry.dispatch(action, path, line, character, query) { + // For code_action, pass end_line/end_character through the query param + // since dispatch() doesn't take them directly — encode as "end_line:end_character" + let effective_query = if input.action == "code_action" { + match (input.end_line, input.end_character) { + (Some(el), Some(ec)) => Some(format!("{el}:{ec}")), + _ => query.map(str::to_owned), + } + } else { + query.map(str::to_owned) + }; + + match registry.dispatch(action, path, line, character, effective_query.as_deref()) { Ok(result) => to_pretty_json(result), Err(e) => to_pretty_json(json!({ "action": action, @@ -3124,6 +3137,10 @@ struct LspInput { #[serde(default)] character: Option, #[serde(default)] + end_line: Option, + #[serde(default)] + end_character: Option, + #[serde(default)] query: Option, }