feat(lsp): implement auto-provisioning for language servers

- Added LSP auto-provisioning functionality to automatically install and connect language servers.
- Introduced `shutdown_lsp` method to cleanly shut down LSP servers on application exit.
- Enhanced `AppStateRest` to spawn a background thread for provisioning language servers.
- Updated `builtin_agents` to include new LSP-related agents.
- Modified settings to include options for LSP auto-provisioning and supported languages.
- Updated file editing and writing tools to notify LSP servers of changes.
- Enhanced LSP tools to support auto-detection of servers based on file extensions.
- Added utility functions for managing known file extensions and resolving server names.
- Created a new `provisioner` module to handle the provisioning logic for various language servers.
This commit is contained in:
asepharyana
2026-07-12 14:47:01 +07:00
parent 48d2dc3ad6
commit e78813ecdb
12 changed files with 1179 additions and 43 deletions
+14 -3
View File
@@ -104,10 +104,21 @@ impl Tool for Edit {
} else {
content.len() - new_content.len()
};
if check_matches.is_empty() {
Ok(format!("edited {} ({} byte delta)", rel, bytes_diff as isize))
// Notify the LSP server of the on-disk change so diagnostics stay fresh.
// Never fail the edit because of this — LSP errors are surfaced as a
// trailing annotation on the success message instead.
let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() {
match lsp.did_change_file(&path) {
Ok(()) => String::new(),
Err(e) => format!(" (LSP: {})", e),
}
} else {
Ok(format!("edited {} ({} byte delta). Graduated checks matched: {}", rel, bytes_diff as isize, check_matches.join(", ")))
String::new()
};
if check_matches.is_empty() {
Ok(format!("edited {} ({} byte delta){}", rel, bytes_diff as isize, lsp_note))
} else {
Ok(format!("edited {} ({} byte delta). Graduated checks matched: {}{}", rel, bytes_diff as isize, check_matches.join(", "), lsp_note))
}
}
}
+14 -3
View File
@@ -64,10 +64,21 @@ impl Tool for Write {
}
fs::write(&path, &content)
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?;
if check_matches.is_empty() {
Ok(format!("wrote {} bytes to {}", content.len(), rel))
// Notify the LSP server of the on-disk change so diagnostics stay in
// sync. Never fails the write itself: a lock failure or LSP error is
// folded into the returned message instead of propagated as an Err.
let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() {
match lsp.did_change_file(&path) {
Ok(()) => String::new(),
Err(e) => format!(" (LSP: {})", e),
}
} else {
Ok(format!("wrote {} bytes to {}. Graduated checks matched: {}", content.len(), rel, check_matches.join(", ")))
String::new()
};
if check_matches.is_empty() {
Ok(format!("wrote {} bytes to {}{}", content.len(), rel, lsp_note))
} else {
Ok(format!("wrote {} bytes to {}{}. Graduated checks matched: {}", content.len(), rel, lsp_note, check_matches.join(", ")))
}
}
}
+133 -33
View File
@@ -12,7 +12,9 @@ impl Tool for LspConnect {
}
fn description(&self) -> &'static str {
"Connect to a Language Server Protocol (LSP) server for a programming language"
"Connect to a Language Server Protocol (LSP) server for a programming language. \
Known file extensions for the language are auto-registered, enabling other lsp_* \
tools to auto-detect this server when `server` is omitted."
}
fn parameters(&self) -> Value {
@@ -60,10 +62,18 @@ impl Tool for LspConnect {
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?;
manager.connect(name, command, &extra_args, language_id)?;
// Auto-register this server's known extensions so lsp_diagnostics /
// lsp_hover / lsp_completion / lsp_definition / lsp_references can
// auto-detect it later without an explicit `server` argument.
let known_exts = known_extensions_for(language_id);
if !known_exts.is_empty() {
manager.register_extensions(name, known_exts);
}
let client_arc = manager.get_client(name);
let caps = client_arc.map(|c| {
let caps = client_arc.and_then(|c| {
c.lock().ok().map(|guard| guard.server_capabilities().clone())
}).flatten().unwrap_or_default();
}).unwrap_or_default();
let caps_summary = serde_json::to_string_pretty(&caps)
.unwrap_or_else(|_| "{}".to_string());
@@ -83,7 +93,8 @@ impl Tool for LspDiagnostics {
}
fn description(&self) -> &'static str {
"Get diagnostics (errors, warnings, hints) for a file from an LSP server"
"Get diagnostics (errors, warnings, hints) for a file from an LSP server. \
`server` is optional — if omitted, the server is auto-detected from the file's extension."
}
fn parameters(&self) -> Value {
@@ -92,7 +103,7 @@ impl Tool for LspDiagnostics {
"properties": {
"server": {
"type": "string",
"description": "Name of the connected LSP server"
"description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
},
"path": {
"type": "string",
@@ -103,20 +114,19 @@ impl Tool for LspDiagnostics {
"description": "The full text content of the file"
}
},
"required": ["server", "path", "text"]
"required": ["path", "text"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let server_name = args.get("server")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: server"))?;
let rel_path = args.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let text = args.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: text"))?;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
@@ -178,7 +188,8 @@ impl Tool for LspHover {
}
fn description(&self) -> &'static str {
"Get hover information (type signature, documentation) at a cursor position in a file"
"Get hover information (type signature, documentation) at a cursor position in a file. \
`server` is optional — if omitted, the server is auto-detected from the file's extension."
}
fn parameters(&self) -> Value {
@@ -187,7 +198,7 @@ impl Tool for LspHover {
"properties": {
"server": {
"type": "string",
"description": "Name of the connected LSP server"
"description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
},
"path": {
"type": "string",
@@ -206,14 +217,11 @@ impl Tool for LspHover {
"description": "Language identifier (e.g. 'rust', 'typescript'). Optional if already set via lsp_connect."
}
},
"required": ["server", "path", "line", "column"]
"required": ["path", "line", "column"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let server_name = args.get("server")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: server"))?;
let rel_path = args.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
@@ -223,6 +231,8 @@ impl Tool for LspHover {
let column = args.get("column")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
@@ -311,7 +321,8 @@ impl Tool for LspCompletion {
}
fn description(&self) -> &'static str {
"Get code completion suggestions at a cursor position from an LSP server"
"Get code completion suggestions at a cursor position from an LSP server. \
`server` is optional — if omitted, the server is auto-detected from the file's extension."
}
fn parameters(&self) -> Value {
@@ -320,7 +331,7 @@ impl Tool for LspCompletion {
"properties": {
"server": {
"type": "string",
"description": "Name of the connected LSP server"
"description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
},
"path": {
"type": "string",
@@ -335,14 +346,11 @@ impl Tool for LspCompletion {
"description": "Column number (0-based)"
}
},
"required": ["server", "path", "line", "column"]
"required": ["path", "line", "column"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let server_name = args.get("server")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: server"))?;
let rel_path = args.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
@@ -352,6 +360,8 @@ impl Tool for LspCompletion {
let column = args.get("column")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
@@ -441,7 +451,8 @@ impl Tool for LspDefinition {
}
fn description(&self) -> &'static str {
"Go to definition: find the location where a symbol is defined"
"Go to definition: find the location where a symbol is defined. \
`server` is optional — if omitted, the server is auto-detected from the file's extension."
}
fn parameters(&self) -> Value {
@@ -450,7 +461,7 @@ impl Tool for LspDefinition {
"properties": {
"server": {
"type": "string",
"description": "Name of the connected LSP server"
"description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
},
"path": {
"type": "string",
@@ -465,14 +476,11 @@ impl Tool for LspDefinition {
"description": "Column number (0-based)"
}
},
"required": ["server", "path", "line", "column"]
"required": ["path", "line", "column"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let server_name = args.get("server")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: server"))?;
let rel_path = args.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
@@ -482,6 +490,8 @@ impl Tool for LspDefinition {
let column = args.get("column")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
@@ -547,7 +557,8 @@ impl Tool for LspReferences {
}
fn description(&self) -> &'static str {
"Find all references to a symbol at a cursor position"
"Find all references to a symbol at a cursor position. \
`server` is optional — if omitted, the server is auto-detected from the file's extension."
}
fn parameters(&self) -> Value {
@@ -556,7 +567,7 @@ impl Tool for LspReferences {
"properties": {
"server": {
"type": "string",
"description": "Name of the connected LSP server"
"description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
},
"path": {
"type": "string",
@@ -571,14 +582,11 @@ impl Tool for LspReferences {
"description": "Column number (0-based)"
}
},
"required": ["server", "path", "line", "column"]
"required": ["path", "line", "column"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let server_name = args.get("server")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: server"))?;
let rel_path = args.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
@@ -588,6 +596,8 @@ impl Tool for LspReferences {
let column = args.get("column")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
@@ -675,3 +685,93 @@ impl Tool for LspDisconnect {
}
}
}
/// Return the default file extensions associated with a language id.
///
/// Flow: pure `match` on `language_id` -> static slice of extension
/// strings (with leading dot). Returns an empty slice for unknown
/// languages, so callers can safely chain lookups without a special case.
///
/// Used by `lsp_connect` to auto-register extensions for a newly connected
/// server, and by `auto_detect_server` as a fallback when the manager's own
/// `extension_registry` has no entry yet.
fn known_extensions_for(language_id: &str) -> &[&'static str] {
match language_id {
"rust" => &[".rs"],
"typescript" => &[".ts", ".tsx", ".js", ".jsx"],
"go" => &[".go"],
"java" => &[".java"],
_ => &[],
}
}
/// Guess which connected LSP server should handle `path` based on its extension.
///
/// Flow: extract extension from `path` -> for each connected server, check
/// whether `known_extensions_for(server.language_id)` contains the extension
/// -> return the first match's name.
///
/// This is a fallback used only when the caller omits `server` and the file's
/// extension is not (yet) present in `LspManager::extension_registry` — e.g.
/// a server connected without an explicit `register_extensions` call. Returns
/// `None` if the path has no extension, the lock is poisoned, or no
/// connected server's language is known to use that extension.
fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option<String> {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str())?;
let dot_ext = format!(".{}", ext);
if let Ok(mgr) = ctx.lsp_manager.lock() {
for s in &mgr.servers {
let exts = known_extensions_for(&s.language_id);
if exts.contains(&dot_ext.as_str()) {
return Some(s.name.clone());
}
}
}
None
}
/// Resolve the LSP server name to use for a tool call: explicit `server`
/// argument if present, otherwise auto-detected from `path`'s extension.
///
/// Flow: `args["server"]` present -> use it as-is. Otherwise -> try
/// `LspManager::find_server_for_path`-style registry lookup by delegating to
/// `auto_detect_server`. If that also fails, build a helpful error message
/// listing the currently connected servers (via `LspManager::list_servers`)
/// so the caller knows whether to connect one first.
///
/// Return: `Ok(server_name)` on success. `Err` only when no `server` was
/// given and auto-detection could not resolve one — never fails just
/// because the caller provided an explicit (possibly wrong) server name,
/// since downstream `get_client`/`get_language_id` calls report that error.
fn resolve_server_name(ctx: &ToolCtx, args: &Value, path: &str) -> Result<String> {
if let Some(server) = args.get("server").and_then(|v| v.as_str()) {
return Ok(server.to_string());
}
if let Some(name) = auto_detect_server(ctx, path) {
return Ok(name);
}
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{}", e))
.unwrap_or_else(|| "<none>".to_string());
let available = ctx.lsp_manager.lock().ok()
.map(|mgr| {
mgr.list_servers()
.iter()
.map(|(name, lang, _)| format!("{} ({})", name, lang))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let available = if available.is_empty() { "none".to_string() } else { available };
Err(anyhow!(
"LSP server not found for extension '{}'. Use lsp_connect to connect one. Available servers: {}",
ext,
available
))
}