Skip to main content

hematite/tools/
lsp_tools.rs

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