mod connect; mod completion; mod definition; mod diagnostics; mod disconnect; mod hover; mod references; pub use connect::LspConnect; pub use completion::LspCompletion; pub use definition::LspDefinition; pub use diagnostics::LspDiagnostics; pub use disconnect::LspDisconnect; pub use hover::LspHover; pub use references::LspReferences; // --------------------------------------------------------------------------- // Shared helpers used by multiple per-tool files // --------------------------------------------------------------------------- use anyhow::{anyhow, Result}; use serde_json::Value; use crate::app::lsp::path_to_lsp_uri; use crate::tool::ToolCtx; /// 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"], _ => &[], } } /// Build the standard `server` + `path` + `line` + `column` parameter schema /// used by cursor-based LSP tools (definition, references, completion). /// /// When `with_language_id` is `true`, an optional `language_id` property is /// included (for tools like hover that pass it to `didOpen`). pub fn lsp_cursor_params(with_language_id: bool) -> serde_json::Value { let mut props = serde_json::json!({ "server": { "type": "string", "description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted." }, "path": { "type": "string", "description": "Path to the file (relative to workspace root)" }, "line": { "type": "integer", "description": "Line number (0-based)" }, "column": { "type": "integer", "description": "Column number (0-based)" } }); if with_language_id { if let Some(obj) = props.as_object_mut() { obj.insert( "language_id".to_string(), serde_json::json!({ "type": "string", "description": "Language identifier (e.g. 'rust', 'typescript'). Optional if already set via lsp_connect." }), ); } } serde_json::json!({ "type": "object", "properties": props, "required": ["path", "line", "column"] }) } /// 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 `language_id`. /// /// 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 { 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.language_id.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 /// 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 { 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_or_else(|| "".to_string(), |e| format!(".{e}")); let available = ctx .lsp_manager .lock() .ok() .map(|mgr| { mgr.list_servers() .iter() .map(|(lang, _)| lang.clone()) .collect::>() .join(", ") }) .unwrap_or_default(); let available = if available.is_empty() { "none".to_string() } else { available }; Err(anyhow!( "LSP server not found for extension '{ext}'. Use lsp_connect to connect one. Available servers: {available}" )) } /// Run a generic LSP query (hover, completion, goto-definition, references). /// /// Opens the file on the server via `didOpen`, invokes the query closure, /// then closes the file via `didClose`. Returns the query result along with /// the 0-based line and column for post-processing. /// /// When `text` is `Some`, the provided content is used instead of reading /// from disk (used by `LspDiagnostics` which receives the full text as an /// argument). fn run_lsp_query( ctx: &ToolCtx, args: &Value, text: Option<&str>, op: F, ) -> Result<(R, u32, u32)> where F: FnOnce(&mut crate::app::lsp::LspClient, &str, u32, u32) -> Result, { let rel_path = crate::tool::arg_str(args, "path")?; let line = args .get("line") .and_then(Value::as_i64) .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; let column = args .get("column") .and_then(Value::as_i64) .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; let server_name = resolve_server_name(ctx, args, &rel_path)?; let abs_path = crate::tool::resolve_path(&ctx.workspaces, &rel_path)?; let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let file_content = match text { Some(t) => t.to_string(), None => std::fs::read_to_string(&abs_path) .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?, }; let manager = ctx .lsp_manager .lock() .map_err(|e| anyhow!("LSP manager lock error: {e}"))?; let language_id = manager .get_language_id(&server_name) .unwrap_or_else(|| { args.get("language_id") .and_then(|v| v.as_str()) .unwrap_or("plaintext") .to_string() }); let client_arc = manager.get_client(&server_name).ok_or_else(|| { anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.") })?; drop(manager); let mut client = client_arc .lock() .map_err(|e| anyhow!("LSP client lock error: {e}"))?; client.did_open(&uri, &language_id, 1, &file_content)?; let result = op(&mut client, &uri, line, column); let _ = client.did_close(&uri); result.map(|r| (r, line, column)) }