Skip to main content

codetether_agent/tool/
lsp.rs

1//! LSP tool: Language Server Protocol operations
2
3use crate::lsp::{LspActionResult, LspManager, detect_language_from_path};
4
5use super::{Tool, ToolResult};
6use anyhow::Result;
7use async_trait::async_trait;
8use serde_json::{Value, json};
9use std::path::Path;
10use std::sync::Arc;
11use tokio::sync::RwLock;
12
13/// Global LSP manager - lazily initialized
14static LSP_MANAGER: std::sync::OnceLock<Arc<RwLock<Option<Arc<LspManager>>>>> =
15    std::sync::OnceLock::new();
16
17/// LSP Tool for performing Language Server Protocol operations
18pub struct LspTool {
19    root_uri: Option<String>,
20}
21
22impl LspTool {
23    pub fn new() -> Self {
24        Self { root_uri: None }
25    }
26
27    /// Create with a specific workspace root
28    pub fn with_root(root_uri: String) -> Self {
29        Self {
30            root_uri: Some(root_uri),
31        }
32    }
33
34    /// Shutdown all LSP clients, releasing resources.
35    pub async fn shutdown_all(&self) {
36        let cell = LSP_MANAGER.get_or_init(|| Arc::new(RwLock::new(None)));
37        let guard = cell.read().await;
38        if let Some(manager) = guard.as_ref() {
39            manager.shutdown_all().await;
40        }
41    }
42
43    /// Get or initialize the LSP manager
44    async fn get_manager(&self) -> Arc<LspManager> {
45        let cell = LSP_MANAGER.get_or_init(|| Arc::new(RwLock::new(None)));
46        let mut guard = cell.write().await;
47
48        if guard.is_none() {
49            *guard = Some(Arc::new(LspManager::new(self.root_uri.clone())));
50        }
51
52        Arc::clone(guard.as_ref().unwrap())
53    }
54}
55
56impl Default for LspTool {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62#[async_trait]
63impl Tool for LspTool {
64    fn id(&self) -> &str {
65        "lsp"
66    }
67
68    fn name(&self) -> &str {
69        "LSP Tool"
70    }
71
72    fn description(&self) -> &str {
73        "Perform Language Server Protocol (LSP) operations such as go-to-definition, find-references, hover, document-symbol, workspace-symbol, and more. This tool enables AI agents to query language servers for code intelligence features. Supports rust-analyzer, typescript-language-server, pylsp, gopls, and clangd."
74    }
75
76    fn parameters(&self) -> Value {
77        json!({
78            "type": "object",
79            "properties": {
80                "action": {
81                    "type": "string",
82                    "description": "The LSP operation to perform",
83                    "enum": [
84                        "goToDefinition",
85                        "findReferences",
86                        "hover",
87                        "documentSymbol",
88                        "workspaceSymbol",
89                        "goToImplementation",
90                        "completion"
91                    ]
92                },
93                "file_path": {
94                    "type": "string",
95                    "description": "The absolute or relative path to the file"
96                },
97                "line": {
98                    "type": "integer",
99                    "description": "The line number (1-based, as shown in editors)",
100                    "minimum": 1
101                },
102                "column": {
103                    "type": "integer",
104                    "description": "The character offset/column (1-based, as shown in editors)",
105                    "minimum": 1
106                },
107                "query": {
108                    "type": "string",
109                    "description": "Search query for workspaceSymbol action"
110                },
111                "include_declaration": {
112                    "type": "boolean",
113                    "description": "For findReferences: include the declaration in results",
114                    "default": true
115                }
116            },
117            "required": ["action", "file_path"]
118        })
119    }
120
121    async fn execute(&self, args: Value) -> Result<ToolResult> {
122        let action = args["action"]
123            .as_str()
124            .ok_or_else(|| anyhow::anyhow!("action is required"))?;
125        let file_path = args["file_path"]
126            .as_str()
127            .ok_or_else(|| anyhow::anyhow!("file_path is required"))?;
128
129        let path = Path::new(file_path);
130
131        // Get the LSP manager and client
132        let manager = self.get_manager().await;
133
134        // For workspace symbol search, we don't need a file
135        if action == "workspaceSymbol" {
136            let query = args["query"].as_str().unwrap_or("");
137            let language = detect_language_from_path(file_path);
138
139            let client = if let Some(lang) = language {
140                manager.get_client(lang).await?
141            } else {
142                // Default to rust if we can't detect
143                manager.get_client("rust").await?
144            };
145
146            let result = client.workspace_symbols(query).await?;
147            return format_result(result);
148        }
149
150        // For other actions, we need a file and position
151        let line = args["line"]
152            .as_u64()
153            .ok_or_else(|| anyhow::anyhow!("line is required for action: {}", action))?
154            as u32;
155        let column = args["column"]
156            .as_u64()
157            .ok_or_else(|| anyhow::anyhow!("column is required for action: {}", action))?
158            as u32;
159
160        let client = manager.get_client_for_file(path).await?;
161
162        let result = match action {
163            "goToDefinition" => client.go_to_definition(path, line, column).await?,
164            "findReferences" => {
165                let include_decl = args["include_declaration"].as_bool().unwrap_or(true);
166                client
167                    .find_references(path, line, column, include_decl)
168                    .await?
169            }
170            "hover" => client.hover(path, line, column).await?,
171            "documentSymbol" => client.document_symbols(path).await?,
172            "goToImplementation" => client.go_to_implementation(path, line, column).await?,
173            "completion" => client.completion(path, line, column).await?,
174            _ => {
175                return Ok(ToolResult::error(format!("Unknown action: {}", action)));
176            }
177        };
178
179        format_result(result)
180    }
181}
182
183/// Format LSP result as tool output
184fn format_result(result: LspActionResult) -> Result<ToolResult> {
185    let output = match result {
186        LspActionResult::Definition { locations } => {
187            if locations.is_empty() {
188                "No definition found".to_string()
189            } else {
190                let mut out = format!("Found {} definition(s):\n\n", locations.len());
191                for loc in locations {
192                    let uri = loc.uri;
193                    let range = loc.range;
194                    out.push_str(&format!(
195                        "  {}:{}:{}\n",
196                        uri.trim_start_matches("file://"),
197                        range.start.line + 1,
198                        range.start.character + 1
199                    ));
200                }
201                out
202            }
203        }
204        LspActionResult::References { locations } => {
205            if locations.is_empty() {
206                "No references found".to_string()
207            } else {
208                let mut out = format!("Found {} reference(s):\n\n", locations.len());
209                for loc in locations {
210                    let uri = loc.uri;
211                    let range = loc.range;
212                    out.push_str(&format!(
213                        "  {}:{}:{}\n",
214                        uri.trim_start_matches("file://"),
215                        range.start.line + 1,
216                        range.start.character + 1
217                    ));
218                }
219                out
220            }
221        }
222        LspActionResult::Hover { contents, range } => {
223            let mut out = "Hover information:\n\n".to_string();
224            out.push_str(&contents);
225            if let Some(r) = range {
226                out.push_str(&format!(
227                    "\n\nRange: line {}-{}, col {}-{}",
228                    r.start.line + 1,
229                    r.end.line + 1,
230                    r.start.character + 1,
231                    r.end.character + 1
232                ));
233            }
234            out
235        }
236        LspActionResult::DocumentSymbols { symbols } => {
237            if symbols.is_empty() {
238                "No symbols found in document".to_string()
239            } else {
240                let mut out = format!("Document symbols ({}):\n\n", symbols.len());
241                for sym in symbols {
242                    out.push_str(&format!("  {} [{}]", sym.name, sym.kind));
243                    if let Some(detail) = sym.detail {
244                        out.push_str(&format!(" - {}", detail));
245                    }
246                    out.push('\n');
247                }
248                out
249            }
250        }
251        LspActionResult::WorkspaceSymbols { symbols } => {
252            if symbols.is_empty() {
253                "No symbols found matching query".to_string()
254            } else {
255                let mut out = format!("Workspace symbols ({}):\n\n", symbols.len());
256                for sym in symbols {
257                    out.push_str(&format!("  {} [{}]", sym.name, sym.kind));
258                    if let Some(uri) = sym.uri {
259                        out.push_str(&format!(" - {}", uri.trim_start_matches("file://")));
260                    }
261                    out.push('\n');
262                }
263                out
264            }
265        }
266        LspActionResult::Implementation { locations } => {
267            if locations.is_empty() {
268                "No implementations found".to_string()
269            } else {
270                let mut out = format!("Found {} implementation(s):\n\n", locations.len());
271                for loc in locations {
272                    let uri = loc.uri;
273                    let range = loc.range;
274                    out.push_str(&format!(
275                        "  {}:{}:{}\n",
276                        uri.trim_start_matches("file://"),
277                        range.start.line + 1,
278                        range.start.character + 1
279                    ));
280                }
281                out
282            }
283        }
284        LspActionResult::Completion { items } => {
285            if items.is_empty() {
286                "No completions available".to_string()
287            } else {
288                let mut out = format!("Completions ({}):\n\n", items.len());
289                for item in items {
290                    out.push_str(&format!("  {}", item.label));
291                    if let Some(kind) = item.kind {
292                        out.push_str(&format!(" [{}]", kind));
293                    }
294                    if let Some(detail) = item.detail {
295                        out.push_str(&format!(" - {}", detail));
296                    }
297                    out.push('\n');
298                }
299                out
300            }
301        }
302        LspActionResult::Error { message } => {
303            return Ok(ToolResult::error(message));
304        }
305    };
306
307    Ok(ToolResult::success(output))
308}