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:
TheArchitectit 2026-04-28 08:11:30 -05:00
parent 562ee61466
commit 7cdccc3ca5
10 changed files with 985 additions and 32 deletions

View File

@ -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);
}

View File

@ -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!(),
}
})

View File

@ -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> {

View File

@ -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",

View File

@ -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 {

View File

@ -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);
}
}
}

View File

@ -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,
}
}
}

View File

@ -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
})
}

View File

@ -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()? {

View File

@ -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>,
}