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:
+14
-3
@@ -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
@@ -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
@@ -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
|
||||
))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user