Skip to main content

hematite/tools/
lsp_tools.rs

1use crate::agent::lsp::manager::LspManager;
2use serde_json::{json, Value};
3use std::fmt::Write as _;
4use std::path::Path;
5use std::sync::Arc;
6use tokio::sync::Mutex;
7
8fn adjust_position(root: &Path, path: &str, line: u32, character: u32) -> u32 {
9    if character > 0 {
10        return character;
11    }
12
13    let abs_path = root.join(path);
14    if let Ok(content) = std::fs::read_to_string(&abs_path) {
15        let lines: Vec<&str> = content.lines().collect();
16        if let Some(l) = lines.get(line as usize) {
17            if let Some(first) = l.find(|c: char| !c.is_whitespace()) {
18                return first as u32;
19            }
20        }
21    }
22    character
23}
24
25pub async fn lsp_definitions(
26    lsp: Arc<Mutex<LspManager>>,
27    path: String,
28    line: u32,
29    character: u32,
30) -> Result<String, String> {
31    let mut manager = lsp.lock().await;
32    if manager.get_client_for_path(&path).is_none() {
33        let _ = manager.start_servers().await;
34    }
35    let _ = manager.ensure_opened(&path).await;
36    let client = manager
37        .get_client_for_path(&path)
38        .ok_or_else(|| "No Language Server active for this file type.".to_string())?;
39
40    let uri = manager.resolve_uri(&path);
41    let character = adjust_position(&manager.workspace_root, &path, line, character);
42    let params = json!({
43        "textDocument": { "uri": uri },
44        "position": { "line": line, "character": character }
45    });
46
47    let mut result = client
48        .call("textDocument/definition", params.clone())
49        .await?;
50
51    // Index Recovery: Try line-1 if line N is empty (handling 1-indexed slips)
52    if result.is_null() && line > 0 {
53        let mut fallback_params = params.clone();
54        fallback_params["position"]["line"] = json!(line - 1);
55        let fallback_char = adjust_position(&manager.workspace_root, &path, line - 1, 0);
56        fallback_params["position"]["character"] = json!(fallback_char);
57
58        if let Ok(res) = client
59            .call("textDocument/definition", fallback_params)
60            .await
61        {
62            if !res.is_null() && res.get("uri").is_some() {
63                result = res;
64            }
65        }
66    }
67
68    format_location_result(result)
69}
70
71pub async fn lsp_references(
72    lsp: Arc<Mutex<LspManager>>,
73    path: String,
74    line: u32,
75    character: u32,
76) -> Result<String, String> {
77    let mut manager = lsp.lock().await;
78    if manager.get_client_for_path(&path).is_none() {
79        let _ = manager.start_servers().await;
80    }
81    let _ = manager.ensure_opened(&path).await;
82    let client = manager
83        .get_client_for_path(&path)
84        .ok_or_else(|| "No Language Server active for this file type.".to_string())?;
85
86    let uri = manager.resolve_uri(&path);
87    let character = adjust_position(&manager.workspace_root, &path, line, character);
88    let params = json!({
89        "textDocument": { "uri": uri },
90        "position": { "line": line, "character": character },
91        "context": { "includeDeclaration": true }
92    });
93
94    let result = client.call("textDocument/references", params).await?;
95    format_location_result(result)
96}
97
98pub async fn lsp_hover(
99    lsp: Arc<Mutex<LspManager>>,
100    path: String,
101    line: u32,
102    character: u32,
103) -> Result<String, String> {
104    let mut manager = lsp.lock().await;
105    if manager.get_client_for_path(&path).is_none() {
106        let _ = manager.start_servers().await;
107    }
108    let _ = manager.ensure_opened(&path).await;
109    let client = manager
110        .get_client_for_path(&path)
111        .ok_or_else(|| "No Language Server active for this file type.".to_string())?;
112
113    let uri = manager.resolve_uri(&path);
114    let character = adjust_position(&manager.workspace_root, &path, line, character);
115    let params = json!({
116        "textDocument": { "uri": uri },
117        "position": { "line": line, "character": character }
118    });
119
120    let mut result = client.call("textDocument/hover", params.clone()).await?;
121
122    // Index Recovery: If line N returns nothing, try line N-1 (handling 1-indexed slips)
123    if result.is_null() && line > 0 {
124        let mut fallback_params = params.clone();
125        fallback_params["position"]["line"] = json!(line - 1);
126        // Also re-adjust character for the new line
127        let fallback_char = adjust_position(&manager.workspace_root, &path, line - 1, 0);
128        fallback_params["position"]["character"] = json!(fallback_char);
129
130        if let Ok(res) = client.call("textDocument/hover", fallback_params).await {
131            if !res.is_null() {
132                result = res;
133            }
134        }
135    }
136
137    if result.is_null() {
138        return Ok("No hover information available.".to_string());
139    }
140
141    let contents = result.get("contents").ok_or("Invalid hover response")?;
142    // Handle both String and MarkupContent/Array
143    if let Some(s) = contents.as_str() {
144        Ok(s.to_string())
145    } else if let Some(obj) = contents.get("value") {
146        Ok(obj.as_str().unwrap_or("").to_string())
147    } else {
148        Ok(serde_json::to_string_pretty(contents).unwrap_or_default())
149    }
150}
151
152fn format_location_result(res: Value) -> Result<String, String> {
153    if res.is_null() {
154        return Ok("No results found.".to_string());
155    }
156
157    let mut output = String::new();
158    if let Some(arr) = res.as_array() {
159        for (i, loc) in arr.iter().enumerate() {
160            if i > 0 {
161                output.push('\n');
162            }
163            output.push_str(&format_location(loc));
164        }
165    } else {
166        output.push_str(&format_location(&res));
167    }
168
169    Ok(output)
170}
171
172fn format_location(loc: &Value) -> String {
173    let uri = loc.get("uri").and_then(|v| v.as_str()).unwrap_or("unknown");
174    let range = loc.get("range");
175    let start = range.and_then(|r| r.get("start"));
176    let line = start
177        .and_then(|s| s.get("line").and_then(|v| v.as_u64()))
178        .unwrap_or(0);
179    let col = start
180        .and_then(|s| s.get("character").and_then(|v| v.as_u64()))
181        .unwrap_or(0);
182
183    format!("{}:{}:{}", uri.replace("file:///", ""), line, col)
184}
185
186pub async fn lsp_search_symbol(
187    lsp: Arc<Mutex<LspManager>>,
188    query: String,
189) -> Result<String, String> {
190    let mut manager = lsp.lock().await;
191    // Default to rust if nothing started yet for simple queries
192    if manager.clients.is_empty() {
193        let _ = manager.start_servers().await;
194    }
195
196    let client = manager
197        .get_client("rust")
198        .ok_or_else(|| "No Language Server active for workspace symbol search.".to_string())?;
199
200    let params = json!({
201        "query": query
202    });
203
204    let result = client.call("workspace/symbol", params).await?;
205    if result.is_null() {
206        return Ok("No symbols found matching your query.".to_string());
207    }
208
209    let mut output = Vec::new();
210    if let Some(arr) = result.as_array() {
211        output.reserve(arr.len());
212        for sym in arr {
213            let name = sym
214                .get("name")
215                .and_then(|v| v.as_str())
216                .unwrap_or("unknown");
217            let location = sym.get("location");
218            if let Some(loc) = location {
219                let formatted = format_location(loc);
220                output.push(format!("{} -> {}", name, formatted));
221            }
222        }
223    }
224
225    if output.is_empty() {
226        Ok("No symbols found matching your query.".to_string())
227    } else {
228        Ok(output.join("\n"))
229    }
230}
231
232pub async fn lsp_rename_symbol(
233    lsp: Arc<Mutex<LspManager>>,
234    path: String,
235    line: u32,
236    character: u32,
237    new_name: String,
238) -> Result<String, String> {
239    let mut manager = lsp.lock().await;
240    let _ = manager.ensure_opened(&path).await;
241    let client = manager
242        .get_client_for_path(&path)
243        .ok_or_else(|| "No LSP client for this file.".to_string())?;
244
245    let uri = manager.resolve_uri(&path);
246    let character = adjust_position(&manager.workspace_root, &path, line, character);
247    let params = json!({
248        "textDocument": { "uri": uri },
249        "position": { "line": line, "character": character },
250        "newName": new_name
251    });
252
253    let result = client.call("textDocument/rename", params).await?;
254    if result.is_null() {
255        return Ok("Rename failed or no changes returned.".to_string());
256    }
257
258    Ok(format!(
259        "Rename successful. Workspace edit changes: \n{}",
260        serde_json::to_string_pretty(&result).unwrap_or_default()
261    ))
262}
263
264pub async fn lsp_get_diagnostics(
265    lsp: Arc<Mutex<LspManager>>,
266    path: String,
267) -> Result<String, String> {
268    let manager = lsp.lock().await;
269    let client = manager
270        .get_client_for_path(&path)
271        .ok_or_else(|| "No LSP client for this file.".to_string())?;
272
273    let uri = manager.resolve_uri(&path);
274    let all_diags = client.diagnostics.lock().await;
275
276    match all_diags.get(&uri) {
277        Some(Value::Array(indices)) if !indices.is_empty() => {
278            let mut out = format!("Diagnostics for {}:\n", path);
279            for diag in indices {
280                let msg = diag
281                    .get("message")
282                    .and_then(|v| v.as_str())
283                    .unwrap_or("unknown error");
284                let severity = diag.get("severity").and_then(|v| v.as_u64()).unwrap_or(1);
285                let range = diag.get("range");
286                let start_line = range
287                    .and_then(|r| r.get("start"))
288                    .and_then(|s| s.get("line"))
289                    .and_then(|v| v.as_u64())
290                    .unwrap_or(0);
291
292                let sev_label = match severity {
293                    1 => "[ERROR]",
294                    2 => "[WARNING]",
295                    3 => "[INFO]",
296                    _ => "[HINT]",
297                };
298                let _ = writeln!(out, "{} Line {}: {}", sev_label, start_line + 1, msg);
299            }
300            Ok(out)
301        }
302        _ => Ok(format!(
303            "No diagnostics (errors/warnings) found for {}.",
304            path
305        )),
306    }
307}
308
309pub fn get_lsp_definitions() -> Vec<Value> {
310    vec![
311        json!({
312            "name": "lsp_definitions",
313            "description": "Find the definition of a symbol at a specific file and position (line/char). \
314                             Requires /lsp to be active. Much more precise than grep for code navigation.",
315            "parameters": {
316                "type": "object",
317                "properties": {
318                    "path": { "type": "string", "description": "Relative path to the file" },
319                    "line": { "type": "integer", "description": "0-indexed line number" },
320                    "character": { "type": "integer", "description": "0-indexed character offset" }
321                },
322                "required": ["path", "line", "character"]
323            }
324        }),
325        json!({
326            "name": "lsp_references",
327            "description": "Find all references to a symbol at a specific file and position. \
328                             Use this to find where a function or struct is used across the project.",
329            "parameters": {
330                "type": "object",
331                "properties": {
332                    "path": { "type": "string", "description": "Relative path to the file" },
333                    "line": { "type": "integer", "description": "0-indexed line number" },
334                    "character": { "type": "integer", "description": "0-indexed character offset" }
335                },
336                "required": ["path", "line", "character"]
337            }
338        }),
339        json!({
340            "name": "lsp_hover",
341            "description": "Get type information, documentation, and metadata for a symbol at a specific position. \
342                             Like hovering your mouse over a symbol in an IDE.",
343            "parameters": {
344                "type": "object",
345                "properties": {
346                    "path": { "type": "string", "description": "Relative path to the file" },
347                    "line": { "type": "integer", "description": "0-indexed line number" },
348                    "character": { "type": "integer", "description": "0-indexed character offset" }
349                },
350                "required": ["path", "line", "character"]
351            }
352        }),
353        json!({
354            "name": "lsp_search_symbol",
355            "description": "Find the location (file/line) of any function, struct, or variable in the entire project workspace. \
356                             This is the fastest 'Golden Path' for navigating to a symbol by name.",
357            "parameters": {
358                "type": "object",
359                "properties": {
360                    "query": { "type": "string", "description": "The name of the symbol to find (e.g. 'initialize_mcp')" }
361                },
362                "required": ["query"]
363            }
364        }),
365        json!({
366            "name": "lsp_rename_symbol",
367            "description": "Rename a symbol reliably across the whole project using the Language Server. \
368                             This handles all variable/function name changes safely.",
369            "parameters": {
370                "type": "object",
371                "properties": {
372                    "path": { "type": "string", "description": "Relative path to the file containing the symbol" },
373                    "line": { "type": "integer", "description": "0-indexed line number" },
374                    "character": { "type": "integer", "description": "0-indexed character offset" },
375                    "new_name": { "type": "string", "description": "The new name for the symbol" }
376                },
377                "required": ["path", "line", "character", "new_name"]
378            }
379        }),
380        json!({
381            "name": "lsp_get_diagnostics",
382            "description": "Get current compiler/linter errors and warnings for a file. \
383                             Use this to verify your changes fixed a bug or to find where your code is broken.",
384            "parameters": {
385                "type": "object",
386                "properties": {
387                    "path": { "type": "string", "description": "Relative path to the file" }
388                },
389                "required": ["path"]
390            }
391        }),
392    ]
393}