1use 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::collections::HashMap;
10use std::path::Path;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU64, Ordering};
13use tokio::sync::RwLock;
14
15static LSP_MANAGERS: std::sync::OnceLock<Arc<RwLock<HashMap<String, (u64, Arc<LspManager>)>>>> =
17 std::sync::OnceLock::new();
18static LSP_MANAGER_ACCESS: AtomicU64 = AtomicU64::new(0);
19const MAX_LSP_MANAGERS: usize = 8;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22enum LspOperation {
23 GoToDefinition,
24 FindReferences,
25 Hover,
26 DocumentSymbol,
27 WorkspaceSymbol,
28 GoToImplementation,
29 Completion,
30 Diagnostics,
31}
32
33impl LspOperation {
34 fn parse(action: &str) -> Option<Self> {
35 match action {
36 "goToDefinition" | "go-to-definition" | "go_to_definition" => {
37 Some(Self::GoToDefinition)
38 }
39 "findReferences" | "find-references" | "find_references" => Some(Self::FindReferences),
40 "hover" => Some(Self::Hover),
41 "documentSymbol" | "document-symbol" | "document_symbol" => Some(Self::DocumentSymbol),
42 "workspaceSymbol" | "workspace-symbol" | "workspace_symbol" => {
43 Some(Self::WorkspaceSymbol)
44 }
45 "goToImplementation" | "go-to-implementation" | "go_to_implementation" => {
46 Some(Self::GoToImplementation)
47 }
48 "completion" => Some(Self::Completion),
49 "diagnostics" => Some(Self::Diagnostics),
50 _ => None,
51 }
52 }
53
54 fn requires_position(self) -> bool {
55 match self {
56 Self::GoToDefinition
57 | Self::FindReferences
58 | Self::Hover
59 | Self::GoToImplementation
60 | Self::Completion => true,
61 Self::DocumentSymbol | Self::WorkspaceSymbol | Self::Diagnostics => false,
62 }
63 }
64
65 fn canonical_name(self) -> &'static str {
66 match self {
67 Self::GoToDefinition => "goToDefinition",
68 Self::FindReferences => "findReferences",
69 Self::Hover => "hover",
70 Self::DocumentSymbol => "documentSymbol",
71 Self::WorkspaceSymbol => "workspaceSymbol",
72 Self::GoToImplementation => "goToImplementation",
73 Self::Completion => "completion",
74 Self::Diagnostics => "diagnostics",
75 }
76 }
77}
78
79fn get_file_path_arg(args: &Value) -> Option<&str> {
80 args["file_path"].as_str().or_else(|| args["path"].as_str())
81}
82
83fn action_from_command(command: &str) -> Option<&'static str> {
84 match command {
85 "textDocument/definition" => Some("goToDefinition"),
86 "textDocument/references" => Some("findReferences"),
87 "textDocument/hover" => Some("hover"),
88 "textDocument/documentSymbol" => Some("documentSymbol"),
89 "workspace/symbol" => Some("workspaceSymbol"),
90 "textDocument/implementation" => Some("goToImplementation"),
91 "textDocument/completion" => Some("completion"),
92 _ => None,
93 }
94}
95
96fn resolve_action_raw(args: &Value) -> Result<String> {
97 if let Some(action) = args["action"].as_str() {
98 return Ok(action.to_string());
99 }
100
101 if let Some(command) = args["command"].as_str() {
102 if let Some(mapped) = action_from_command(command) {
103 return Ok(mapped.to_string());
104 }
105
106 return Err(anyhow::anyhow!(
107 "Unsupported lsp command: {command}. Use action with one of: \
108 goToDefinition, findReferences, hover, documentSymbol, workspaceSymbol, \
109 goToImplementation, completion, diagnostics"
110 ));
111 }
112
113 Err(anyhow::anyhow!("action is required"))
114}
115
116pub struct LspTool {
118 root_uri: Option<String>,
119 lsp_settings: Option<crate::config::LspSettings>,
120}
121
122impl LspTool {
123 pub fn new() -> Self {
124 Self {
125 root_uri: None,
126 lsp_settings: None,
127 }
128 }
129
130 pub fn with_root(root_uri: String) -> Self {
132 Self {
133 root_uri: Some(root_uri),
134 lsp_settings: None,
135 }
136 }
137
138 pub fn with_config(root_uri: Option<String>, settings: crate::config::LspSettings) -> Self {
140 Self {
141 root_uri,
142 lsp_settings: Some(settings),
143 }
144 }
145
146 fn manager_key(&self) -> String {
147 self.root_uri
148 .clone()
149 .unwrap_or_else(|| "__default__".to_string())
150 }
151
152 #[allow(dead_code)]
154 pub async fn shutdown_all(&self) {
155 let cell = LSP_MANAGERS.get_or_init(|| Arc::new(RwLock::new(HashMap::new())));
156 let managers = {
157 let mut guard = cell.write().await;
158 let managers = guard
159 .values()
160 .map(|(_, manager)| Arc::clone(manager))
161 .collect::<Vec<_>>();
162 guard.clear();
163 managers
164 };
165 for manager in managers {
166 manager.shutdown_all().await;
167 }
168 }
169
170 pub async fn get_manager(&self) -> Arc<LspManager> {
172 let access = LSP_MANAGER_ACCESS.fetch_add(1, Ordering::Relaxed);
173 let key = self.manager_key();
174 let cell = LSP_MANAGERS.get_or_init(|| Arc::new(RwLock::new(HashMap::new())));
175
176 {
177 let mut guard = cell.write().await;
178 if let Some((last_access, manager)) = guard.get_mut(&key) {
179 *last_access = access;
180 return Arc::clone(manager);
181 }
182 }
183
184 let manager = if let Some(settings) = &self.lsp_settings {
185 Arc::new(LspManager::with_config(
186 self.root_uri.clone(),
187 settings.clone(),
188 ))
189 } else {
190 match crate::config::Config::load().await {
191 Ok(config) if has_lsp_settings(&config.lsp) => {
192 Arc::new(LspManager::with_config(self.root_uri.clone(), config.lsp))
193 }
194 _ => Arc::new(LspManager::new(self.root_uri.clone())),
195 }
196 };
197
198 let evicted_manager = {
199 let mut guard = cell.write().await;
200 if let Some((last_access, existing_manager)) = guard.get_mut(&key) {
201 *last_access = access;
202 return Arc::clone(existing_manager);
203 }
204
205 let evicted_manager = if guard.len() >= MAX_LSP_MANAGERS {
206 let evicted_key = guard
207 .iter()
208 .min_by_key(|(_, (last_access, _))| *last_access)
209 .map(|(evicted_key, _)| evicted_key.clone());
210 evicted_key
211 .and_then(|evicted_key| guard.remove(&evicted_key))
212 .map(|(_, evicted_manager)| evicted_manager)
213 } else {
214 None
215 };
216
217 guard.insert(key, (access, Arc::clone(&manager)));
218 evicted_manager
219 };
220
221 if let Some(evicted_manager) = evicted_manager
222 && Arc::strong_count(&evicted_manager) == 1
223 {
224 evicted_manager.shutdown_all().await;
225 }
226
227 manager
228 }
229}
230
231impl Default for LspTool {
232 fn default() -> Self {
233 Self::new()
234 }
235}
236
237#[async_trait]
238impl Tool for LspTool {
239 fn id(&self) -> &str {
240 "lsp"
241 }
242
243 fn name(&self) -> &str {
244 "LSP Tool"
245 }
246
247 fn description(&self) -> &str {
248 "Perform Language Server Protocol (LSP) operations such as go-to-definition, find-references, hover, document-symbol, workspace-symbol, diagnostics, 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."
249 }
250
251 fn parameters(&self) -> Value {
252 json!({
253 "type": "object",
254 "properties": {
255 "action": {
256 "type": "string",
257 "description": "The LSP operation to perform",
258 "enum": [
259 "goToDefinition",
260 "go-to-definition",
261 "go_to_definition",
262 "findReferences",
263 "find-references",
264 "find_references",
265 "hover",
266 "documentSymbol",
267 "document-symbol",
268 "document_symbol",
269 "workspaceSymbol",
270 "workspace-symbol",
271 "workspace_symbol",
272 "goToImplementation",
273 "go-to-implementation",
274 "go_to_implementation",
275 "completion",
276 "diagnostics"
277 ]
278 },
279 "file_path": {
280 "type": "string",
281 "description": "The absolute or relative path to the file"
282 },
283 "path": {
284 "type": "string",
285 "description": "Alias for file_path"
286 },
287 "line": {
288 "type": "integer",
289 "description": "The line number (1-based, as shown in editors)",
290 "minimum": 1
291 },
292 "column": {
293 "type": "integer",
294 "description": "The character offset/column (1-based, as shown in editors)",
295 "minimum": 1
296 },
297 "query": {
298 "type": "string",
299 "description": "Search query for workspaceSymbol action"
300 },
301 "include_declaration": {
302 "type": "boolean",
303 "description": "For findReferences: include the declaration in results",
304 "default": true
305 }
306 },
307 "required": ["action"]
308 })
309 }
310
311 async fn execute(&self, args: Value) -> Result<ToolResult> {
312 let action_raw = resolve_action_raw(&args)?;
313 let action = LspOperation::parse(&action_raw)
314 .ok_or_else(|| anyhow::anyhow!("Unknown action: {}", action_raw))?;
315
316 let manager = self.get_manager().await;
317
318 if action == LspOperation::WorkspaceSymbol {
319 let query = args["query"].as_str().unwrap_or("");
320 let language = get_file_path_arg(&args).and_then(detect_language_from_path);
321
322 let client = if let Some(lang) = language {
323 manager.get_client(lang).await?
324 } else {
325 manager.get_client("rust").await?
326 };
327
328 let result = client.workspace_symbols(query).await?;
329 return format_result(result);
330 }
331
332 let file_path = get_file_path_arg(&args).ok_or_else(|| {
333 anyhow::anyhow!(
334 "file_path is required (or use path) for action: {}",
335 action_raw
336 )
337 })?;
338 let path = Path::new(file_path);
339
340 let client = manager.get_client_for_file(path).await?;
341
342 let line = args["line"].as_u64().map(|l| l as u32);
343 let column = args["column"].as_u64().map(|c| c as u32);
344
345 if action.requires_position() && (line.is_none() || column.is_none()) {
346 return Ok(ToolResult::error(format!(
347 "line and column are required for action: {}",
348 action.canonical_name()
349 )));
350 }
351
352 let result = match action {
353 LspOperation::GoToDefinition => {
354 client
355 .go_to_definition(
356 path,
357 line.expect("line required"),
358 column.expect("column required"),
359 )
360 .await?
361 }
362 LspOperation::FindReferences => {
363 let include_decl = args["include_declaration"].as_bool().unwrap_or(true);
364 client
365 .find_references(
366 path,
367 line.expect("line required"),
368 column.expect("column required"),
369 include_decl,
370 )
371 .await?
372 }
373 LspOperation::Hover => {
374 client
375 .hover(
376 path,
377 line.expect("line required"),
378 column.expect("column required"),
379 )
380 .await?
381 }
382 LspOperation::DocumentSymbol => client.document_symbols(path).await?,
383 LspOperation::GoToImplementation => {
384 client
385 .go_to_implementation(
386 path,
387 line.expect("line required"),
388 column.expect("column required"),
389 )
390 .await?
391 }
392 LspOperation::Completion => {
393 client
394 .completion(
395 path,
396 line.expect("line required"),
397 column.expect("column required"),
398 )
399 .await?
400 }
401 LspOperation::Diagnostics => {
402 let mut result = client.diagnostics(path).await?;
403 let linter_diags = manager.linter_diagnostics(path).await;
405 if !linter_diags.is_empty() {
406 if let LspActionResult::Diagnostics {
407 ref mut diagnostics,
408 } = result
409 {
410 diagnostics.extend(linter_diags);
411 }
412 }
413 result
414 }
415 LspOperation::WorkspaceSymbol => {
416 return Ok(ToolResult::error(format!(
417 "Action {} is handled separately",
418 action.canonical_name()
419 )));
420 }
421 };
422
423 format_result(result)
424 }
425}
426
427fn format_result(result: LspActionResult) -> Result<ToolResult> {
429 let output = match result {
430 LspActionResult::Definition { locations } => {
431 if locations.is_empty() {
432 "No definition found".to_string()
433 } else {
434 let mut out = format!("Found {} definition(s):\n\n", locations.len());
435 for loc in locations {
436 let uri = loc.uri;
437 let range = loc.range;
438 out.push_str(&format!(
439 " {}:{}:{}\n",
440 uri.trim_start_matches("file://"),
441 range.start.line + 1,
442 range.start.character + 1
443 ));
444 }
445 out
446 }
447 }
448 LspActionResult::References { locations } => {
449 if locations.is_empty() {
450 "No references found".to_string()
451 } else {
452 let mut out = format!("Found {} reference(s):\n\n", locations.len());
453 for loc in locations {
454 let uri = loc.uri;
455 let range = loc.range;
456 out.push_str(&format!(
457 " {}:{}:{}\n",
458 uri.trim_start_matches("file://"),
459 range.start.line + 1,
460 range.start.character + 1
461 ));
462 }
463 out
464 }
465 }
466 LspActionResult::Hover { contents, range } => {
467 let mut out = "Hover information:\n\n".to_string();
468 out.push_str(&contents);
469 if let Some(r) = range {
470 out.push_str(&format!(
471 "\n\nRange: line {}-{}, col {}-{}",
472 r.start.line + 1,
473 r.end.line + 1,
474 r.start.character + 1,
475 r.end.character + 1
476 ));
477 }
478 out
479 }
480 LspActionResult::DocumentSymbols { symbols } => {
481 if symbols.is_empty() {
482 "No symbols found in document".to_string()
483 } else {
484 let mut out = format!("Document symbols ({}):\n\n", symbols.len());
485 for sym in symbols {
486 out.push_str(&format!(" {} [{}]", sym.name, sym.kind));
487 if let Some(detail) = sym.detail {
488 out.push_str(&format!(" - {}", detail));
489 }
490 out.push('\n');
491 }
492 out
493 }
494 }
495 LspActionResult::WorkspaceSymbols { symbols } => {
496 if symbols.is_empty() {
497 "No symbols found matching query".to_string()
498 } else {
499 let mut out = format!("Workspace symbols ({}):\n\n", symbols.len());
500 for sym in symbols {
501 out.push_str(&format!(" {} [{}]", sym.name, sym.kind));
502 if let Some(uri) = sym.uri {
503 out.push_str(&format!(" - {}", uri.trim_start_matches("file://")));
504 }
505 out.push('\n');
506 }
507 out
508 }
509 }
510 LspActionResult::Implementation { locations } => {
511 if locations.is_empty() {
512 "No implementations found".to_string()
513 } else {
514 let mut out = format!("Found {} implementation(s):\n\n", locations.len());
515 for loc in locations {
516 let uri = loc.uri;
517 let range = loc.range;
518 out.push_str(&format!(
519 " {}:{}:{}\n",
520 uri.trim_start_matches("file://"),
521 range.start.line + 1,
522 range.start.character + 1
523 ));
524 }
525 out
526 }
527 }
528 LspActionResult::Completion { items } => {
529 if items.is_empty() {
530 "No completions available".to_string()
531 } else {
532 let mut out = format!("Completions ({}):\n\n", items.len());
533 for item in items {
534 out.push_str(&format!(" {}", item.label));
535 if let Some(kind) = item.kind {
536 out.push_str(&format!(" [{}]", kind));
537 }
538 if let Some(detail) = item.detail {
539 out.push_str(&format!(" - {}", detail));
540 }
541 out.push('\n');
542 }
543 out
544 }
545 }
546 LspActionResult::Diagnostics { diagnostics } => {
547 if diagnostics.is_empty() {
548 "No diagnostics found".to_string()
549 } else {
550 let mut out = format!("Diagnostics ({})\n\n", diagnostics.len());
551 for diagnostic in diagnostics {
552 out.push_str(&format!(
553 " [{}] {}:{}:{}",
554 diagnostic.severity.as_deref().unwrap_or("unknown"),
555 diagnostic.uri.trim_start_matches("file://"),
556 diagnostic.range.start.line + 1,
557 diagnostic.range.start.character + 1,
558 ));
559 if let Some(source) = diagnostic.source {
560 out.push_str(&format!(" [{source}]"));
561 }
562 if let Some(code) = diagnostic.code {
563 out.push_str(&format!(" ({code})"));
564 }
565 out.push_str(&format!("\n {}\n", diagnostic.message));
566 }
567 out
568 }
569 }
570 LspActionResult::Error { message } => {
571 return Ok(ToolResult::error(message));
572 }
573 };
574
575 Ok(ToolResult::success(output))
576}
577
578fn has_lsp_settings(settings: &crate::config::LspSettings) -> bool {
580 !settings.servers.is_empty() || !settings.linters.is_empty() || settings.disable_builtin_linters
581}
582
583#[cfg(test)]
584mod tests {
585 use super::{LspOperation, get_file_path_arg, resolve_action_raw};
586 use serde_json::json;
587
588 #[test]
589 fn parses_lsp_action_aliases() {
590 assert_eq!(
591 LspOperation::parse("document-symbol"),
592 Some(LspOperation::DocumentSymbol)
593 );
594 assert_eq!(
595 LspOperation::parse("workspace_symbol"),
596 Some(LspOperation::WorkspaceSymbol)
597 );
598 assert_eq!(
599 LspOperation::parse("goToImplementation"),
600 Some(LspOperation::GoToImplementation)
601 );
602 assert_eq!(
603 LspOperation::parse("diagnostics"),
604 Some(LspOperation::Diagnostics)
605 );
606 assert_eq!(LspOperation::parse("unknown"), None);
607 }
608
609 #[test]
610 fn accepts_path_alias_for_file_path() {
611 let args_with_file_path = json!({
612 "action": "documentSymbol",
613 "file_path": "src/main.rs"
614 });
615 assert_eq!(get_file_path_arg(&args_with_file_path), Some("src/main.rs"));
616
617 let args_with_path = json!({
618 "action": "document-symbol",
619 "path": "src/lib.rs"
620 });
621 assert_eq!(get_file_path_arg(&args_with_path), Some("src/lib.rs"));
622 }
623
624 #[test]
625 fn maps_command_aliases_to_actions() {
626 let args = json!({
627 "command": "textDocument/hover",
628 "file_path": "src/lib.rs",
629 "line": 1,
630 "column": 1
631 });
632 let action = resolve_action_raw(&args).expect("command should map");
633 assert_eq!(action, "hover");
634 }
635
636 #[test]
637 fn rejects_unsupported_command_with_helpful_error() {
638 let args = json!({
639 "command": "workspace/diagnostics"
640 });
641 let err = resolve_action_raw(&args).expect_err("unsupported command should error");
642 assert!(err.to_string().contains("Unsupported lsp command"));
643 }
644}