feat(lsp): install prompts, new servers, and advanced LSP features
- Add distro-aware install prompt system: detects Ubuntu/Debian/Fedora/
Arch/openSUSE/Alpine/Void/NixOS/macOS and suggests the right install
command for each missing LSP server at startup
- Add 6 new language servers: HTML, CSS, JSON, Bash, YAML, GDScript
- Add didClose notifications for proper file lifecycle
- Add code_action support (quick fixes, refactors)
- Add rename support (workspace-wide symbol renaming)
- Add signature_help (function signatures + parameter hints)
- Add code_lens (inline actionable hints)
- Add workspace_symbols (project-wide symbol search)
- Add workspaceFolders support in initialize handshake
- Advertise full capability set (code actions, rename, signatures,
code lens, workspace symbols) to LSP servers
- Fix panic in lsp_discovery test when rust-analyzer is a rustup
proxy stub for an uninstalled component
💘 Generated with Crush
Assisted-by: GLM 5.1 FP8 via Crush <crush@charm.land>
This commit is contained in:
parent
562ee61466
commit
7cdccc3ca5
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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!(),
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<LspDiagnostic> {
|
||||
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<LspDiagnostic> {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub is_preferred: bool,
|
||||
pub edit: Option<LspWorkspaceEdit>,
|
||||
pub command: Option<LspCommand>,
|
||||
}
|
||||
|
||||
/// A workspace edit containing multiple file changes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LspWorkspaceEdit {
|
||||
pub changes: Vec<LspFileEdit>,
|
||||
}
|
||||
|
||||
/// Edits to a single file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LspFileEdit {
|
||||
pub path: String,
|
||||
pub edits: Vec<LspTextEdit>,
|
||||
}
|
||||
|
||||
/// 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<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Result of a rename operation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LspRenameResult {
|
||||
pub new_name: String,
|
||||
pub edit: Option<LspWorkspaceEdit>,
|
||||
}
|
||||
|
||||
/// A single parameter in a function signature.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LspParameterInfo {
|
||||
pub label: String,
|
||||
pub documentation: Option<String>,
|
||||
}
|
||||
|
||||
/// A function signature with its parameters.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LspSignatureInformation {
|
||||
pub label: String,
|
||||
pub documentation: Option<String>,
|
||||
pub parameters: Vec<LspParameterInfo>,
|
||||
pub active_parameter: Option<u32>,
|
||||
}
|
||||
|
||||
/// Result of a signature help request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LspSignatureHelpResult {
|
||||
pub signatures: Vec<LspSignatureInformation>,
|
||||
pub active_signature: Option<u32>,
|
||||
pub active_parameter: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<LspCommand>,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LspServerStatus {
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub extensions: Vec<String>,
|
||||
pub install_hint: Vec<InstallInstruction>,
|
||||
}
|
||||
|
||||
/// 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<InstallInstruction> },
|
||||
/// 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<InstallInstruction> {
|
||||
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<LspServerDescriptor> {
|
|||
/// 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<InstallInstruction> {
|
||||
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<LspInstallAction> {
|
||||
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<LspServerDescriptor> {
|
||||
KNOWN_LSP_SERVERS_TABLE
|
||||
|
|
@ -179,12 +502,7 @@ pub fn discover_available_servers() -> Vec<LspServerDescriptor> {
|
|||
>>>>>>> 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<LspServerDescriptor> {
|
|||
"rust-analyzer".to_string(),
|
||||
];
|
||||
} else {
|
||||
// Component truly not installed — skip it.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
|
@ -209,9 +526,6 @@ pub fn discover_available_servers() -> Vec<LspServerDescriptor> {
|
|||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u32>,
|
||||
end_character: Option<u32>,
|
||||
only_kinds: Option<&[String]>,
|
||||
) -> Result<Vec<LspCodeAction>, 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<LspRenameResult, LspProcessError> {
|
||||
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<Option<LspSignatureHelpResult>, 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<Vec<LspCodeLens>, 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<Vec<LspSymbol>, 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<LspDiagnostic> {
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<crate::lsp_client::LspCodeAction> {
|
||||
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<crate::lsp_client::LspWorkspaceEdit> {
|
||||
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<crate::lsp_client::LspTextEdit> = 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<crate::lsp_client::LspCommand> {
|
||||
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<crate::lsp_client::LspSignatureHelpResult> {
|
||||
let signatures_arr = value.get("signatures")?.as_array()?;
|
||||
let signatures: Vec<crate::lsp_client::LspSignatureInformation> = 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<crate::lsp_client::LspCodeLens> {
|
||||
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<crate::lsp_client::LspSymbol> {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()? {
|
||||
|
|
|
|||
|
|
@ -1175,14 +1175,16 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||
},
|
||||
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<String, String> {
|
|||
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<u32>,
|
||||
#[serde(default)]
|
||||
end_line: Option<u32>,
|
||||
#[serde(default)]
|
||||
end_character: Option<u32>,
|
||||
#[serde(default)]
|
||||
query: Option<String>,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue