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::collections::HashMap;
10use std::path::Path;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU64, Ordering};
13use tokio::sync::RwLock;
14
15/// Global LSP managers keyed by workspace root.
16static 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
116/// LSP Tool for performing Language Server Protocol operations
117pub 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    /// Create with a specific workspace root
131    pub fn with_root(root_uri: String) -> Self {
132        Self {
133            root_uri: Some(root_uri),
134            lsp_settings: None,
135        }
136    }
137
138    /// Create with workspace root and config-driven LSP settings.
139    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    /// Shutdown all LSP clients, releasing resources.
153    #[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    /// Get or initialize the LSP manager
171    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                // Merge diagnostics from any applicable linter servers
404                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
427/// Format LSP result as tool output
428fn 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
578/// Returns `true` if the user has configured any LSP settings.
579fn 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}