Files
zesdex/crates/zesdex-backend/src/tool/lsp/mod.rs
T
asepharyanaandClaude Opus 4.8 7d8487cefb refactor: consolidate #![allow(clippy::cast_*)] to crate roots
Add the cast-allow block to zesdex-entities/src/lib.rs and
zesdex-utils/src/lib.rs (which lacked it), then remove from
65 sub-files across all 8 crates. Build and all 223 tests
continue to pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 03:18:27 +07:00

230 lines
7.9 KiB
Rust

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<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.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<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_or_else(|| "<none>".to_string(), |e| format!(".{e}"));
let available = ctx
.lsp_manager
.lock()
.ok()
.map(|mgr| {
mgr.list_servers()
.iter()
.map(|(lang, _)| lang.clone())
.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 '{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<F, R>(
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<R>,
{
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))
}