Skip to main content

codetether_agent/lsp/
client.rs

1//! LSP Client - manages language server connections and operations
2//!
3//! Provides high-level API for LSP operations:
4//! - Initialize/shutdown lifecycle
5//! - Document synchronization
6//! - Code intelligence (definition, references, hover, etc.)
7
8use super::transport::LspTransport;
9use super::types::*;
10use anyhow::Result;
11use lsp_types::{
12    ClientCapabilities, CompletionContext, CompletionParams, CompletionTriggerKind,
13    DocumentSymbolParams, HoverParams, Position, TextDocumentIdentifier, TextDocumentItem,
14    TextDocumentPositionParams,
15};
16use std::collections::HashMap;
17use std::path::Path;
18use std::sync::Arc;
19use tokio::sync::RwLock;
20use tracing::{debug, info, warn};
21
22/// LSP Client for a single language server
23pub struct LspClient {
24    transport: LspTransport,
25    config: LspConfig,
26    server_capabilities: RwLock<Option<lsp_types::ServerCapabilities>>,
27    /// Track open documents with their versions
28    open_documents: RwLock<HashMap<String, i32>>,
29}
30
31impl LspClient {
32    /// Create a new LSP client with the given configuration
33    pub async fn new(config: LspConfig) -> Result<Self> {
34        super::types::ensure_server_installed(&config).await?;
35
36        let transport =
37            LspTransport::spawn(&config.command, &config.args, config.timeout_ms).await?;
38
39        Ok(Self {
40            transport,
41            config,
42            server_capabilities: RwLock::new(None),
43            open_documents: RwLock::new(HashMap::new()),
44        })
45    }
46
47    /// Create an LSP client for a specific language
48    pub async fn for_language(language: &str, root_uri: Option<String>) -> Result<Self> {
49        let mut config = get_language_server_config(language)
50            .ok_or_else(|| anyhow::anyhow!("Unknown language: {}", language))?;
51        config.root_uri = root_uri;
52        Self::new(config).await
53    }
54
55    /// Initialize the language server
56    pub async fn initialize(&self) -> Result<()> {
57        let root_uri = self.config.root_uri.clone();
58
59        let params = InitializeParams {
60            process_id: Some(std::process::id() as i64),
61            client_info: ClientInfo {
62                name: "codetether".to_string(),
63                version: env!("CARGO_PKG_VERSION").to_string(),
64            },
65            locale: None,
66            root_path: None,
67            root_uri: root_uri.clone(),
68            initialization_options: self.config.initialization_options.clone(),
69            capabilities: ClientCapabilities::default(),
70            trace: None,
71            workspace_folders: None,
72        };
73
74        let response = self
75            .transport
76            .request("initialize", Some(serde_json::to_value(params)?))
77            .await?;
78
79        if let Some(error) = response.error {
80            return Err(anyhow::anyhow!("LSP initialize error: {}", error.message));
81        }
82
83        if let Some(result) = response.result {
84            let init_result: InitializeResult = serde_json::from_value(result)?;
85            *self.server_capabilities.write().await = Some(init_result.capabilities);
86            info!(server_info = ?init_result.server_info, "LSP server initialized");
87        }
88
89        self.transport.notify("initialized", None).await?;
90        self.transport.set_initialized(true);
91
92        Ok(())
93    }
94
95    /// Shutdown the language server
96    #[allow(dead_code)]
97    pub async fn shutdown(&self) -> Result<()> {
98        let response = self.transport.request("shutdown", None).await?;
99
100        if let Some(error) = response.error {
101            warn!("LSP shutdown error: {}", error.message);
102        }
103
104        self.transport.notify("exit", None).await?;
105        info!("LSP server shutdown complete");
106
107        Ok(())
108    }
109
110    /// Open a text document
111    pub async fn open_document(&self, path: &Path, content: &str) -> Result<()> {
112        let uri = path_to_uri(path);
113        let language_id = detect_language_from_path(path.to_string_lossy().as_ref())
114            .unwrap_or("plaintext")
115            .to_string();
116
117        let text_document = TextDocumentItem {
118            uri: parse_uri(&uri)?,
119            language_id,
120            version: 1,
121            text: content.to_string(),
122        };
123
124        let params = DidOpenTextDocumentParams { text_document };
125        self.transport
126            .notify("textDocument/didOpen", Some(serde_json::to_value(params)?))
127            .await?;
128
129        self.open_documents.write().await.insert(uri, 1);
130        debug!(path = %path.display(), "Opened document");
131
132        Ok(())
133    }
134
135    /// Close a text document
136    #[allow(dead_code)]
137    pub async fn close_document(&self, path: &Path) -> Result<()> {
138        let uri = path_to_uri(path);
139
140        let text_document = TextDocumentIdentifier {
141            uri: parse_uri(&uri)?,
142        };
143
144        let params = DidCloseTextDocumentParams { text_document };
145        self.transport
146            .notify("textDocument/didClose", Some(serde_json::to_value(params)?))
147            .await?;
148
149        self.open_documents.write().await.remove(&uri);
150        debug!(path = %path.display(), "Closed document");
151
152        Ok(())
153    }
154
155    /// Update a text document
156    pub async fn change_document(&self, path: &Path, content: &str) -> Result<()> {
157        let uri = path_to_uri(path);
158        let mut open_docs = self.open_documents.write().await;
159
160        let version = open_docs.entry(uri.clone()).or_insert(0);
161        *version += 1;
162
163        let text_document = VersionedTextDocumentIdentifier {
164            uri,
165            version: *version,
166        };
167
168        let content_changes = vec![super::types::TextDocumentContentChangeEvent {
169            range: None,
170            range_length: None,
171            text: content.to_string(),
172        }];
173
174        let params = DidChangeTextDocumentParams {
175            text_document,
176            content_changes,
177        };
178
179        self.transport
180            .notify(
181                "textDocument/didChange",
182                Some(serde_json::to_value(params)?),
183            )
184            .await?;
185
186        debug!(path = %path.display(), version = *version, "Changed document");
187
188        Ok(())
189    }
190
191    /// Go to definition
192    pub async fn go_to_definition(
193        &self,
194        path: &Path,
195        line: u32,
196        character: u32,
197    ) -> Result<LspActionResult> {
198        let uri = path_to_uri(path);
199        self.ensure_document_open(path).await?;
200
201        let params = serde_json::json!({
202            "textDocument": { "uri": uri },
203            "position": { "line": line.saturating_sub(1), "character": character.saturating_sub(1) },
204        });
205
206        let response = self
207            .transport
208            .request("textDocument/definition", Some(params))
209            .await?;
210
211        parse_location_response(response, "definition")
212    }
213
214    /// Find references
215    pub async fn find_references(
216        &self,
217        path: &Path,
218        line: u32,
219        character: u32,
220        include_declaration: bool,
221    ) -> Result<LspActionResult> {
222        let uri = path_to_uri(path);
223        self.ensure_document_open(path).await?;
224
225        let params = ReferenceParams {
226            text_document: TextDocumentIdentifier {
227                uri: parse_uri(&uri)?,
228            },
229            position: Position {
230                line: line.saturating_sub(1),
231                character: character.saturating_sub(1),
232            },
233            context: ReferenceContext {
234                include_declaration,
235            },
236        };
237
238        let response = self
239            .transport
240            .request(
241                "textDocument/references",
242                Some(serde_json::to_value(params)?),
243            )
244            .await?;
245
246        parse_location_response(response, "references")
247    }
248
249    /// Get hover information
250    pub async fn hover(&self, path: &Path, line: u32, character: u32) -> Result<LspActionResult> {
251        let uri = path_to_uri(path);
252        self.ensure_document_open(path).await?;
253
254        let params = HoverParams {
255            text_document_position_params: TextDocumentPositionParams {
256                text_document: TextDocumentIdentifier {
257                    uri: parse_uri(&uri)?,
258                },
259                position: Position {
260                    line: line.saturating_sub(1),
261                    character: character.saturating_sub(1),
262                },
263            },
264            work_done_progress_params: Default::default(),
265        };
266
267        let response = self
268            .transport
269            .request("textDocument/hover", Some(serde_json::to_value(params)?))
270            .await?;
271
272        parse_hover_response(response)
273    }
274
275    /// Get document symbols
276    pub async fn document_symbols(&self, path: &Path) -> Result<LspActionResult> {
277        let uri = path_to_uri(path);
278        self.ensure_document_open(path).await?;
279
280        let params = DocumentSymbolParams {
281            text_document: TextDocumentIdentifier {
282                uri: parse_uri(&uri)?,
283            },
284            work_done_progress_params: Default::default(),
285            partial_result_params: Default::default(),
286        };
287
288        let response = self
289            .transport
290            .request(
291                "textDocument/documentSymbol",
292                Some(serde_json::to_value(params)?),
293            )
294            .await?;
295
296        parse_document_symbols_response(response)
297    }
298
299    /// Search workspace symbols
300    pub async fn workspace_symbols(&self, query: &str) -> Result<LspActionResult> {
301        let params = WorkspaceSymbolParams {
302            query: query.to_string(),
303        };
304
305        let response = self
306            .transport
307            .request("workspace/symbol", Some(serde_json::to_value(params)?))
308            .await?;
309
310        parse_workspace_symbols_response(response)
311    }
312
313    /// Go to implementation
314    pub async fn go_to_implementation(
315        &self,
316        path: &Path,
317        line: u32,
318        character: u32,
319    ) -> Result<LspActionResult> {
320        let uri = path_to_uri(path);
321        self.ensure_document_open(path).await?;
322
323        let params = serde_json::json!({
324            "textDocument": { "uri": uri },
325            "position": { "line": line.saturating_sub(1), "character": character.saturating_sub(1) },
326        });
327
328        let response = self
329            .transport
330            .request("textDocument/implementation", Some(params))
331            .await?;
332
333        parse_location_response(response, "implementation")
334    }
335
336    /// Get code completions
337    pub async fn completion(
338        &self,
339        path: &Path,
340        line: u32,
341        character: u32,
342    ) -> Result<LspActionResult> {
343        let uri = path_to_uri(path);
344        self.ensure_document_open(path).await?;
345
346        let params = CompletionParams {
347            text_document_position: TextDocumentPositionParams {
348                text_document: TextDocumentIdentifier {
349                    uri: parse_uri(&uri)?,
350                },
351                position: Position {
352                    line: line.saturating_sub(1),
353                    character: character.saturating_sub(1),
354                },
355            },
356            work_done_progress_params: Default::default(),
357            partial_result_params: Default::default(),
358            context: Some(CompletionContext {
359                trigger_kind: CompletionTriggerKind::INVOKED,
360                trigger_character: None,
361            }),
362        };
363
364        let response = self
365            .transport
366            .request(
367                "textDocument/completion",
368                Some(serde_json::to_value(params)?),
369            )
370            .await?;
371
372        parse_completion_response(response)
373    }
374
375    /// Return the most recent LSP diagnostics for a file after ensuring the document is open.
376    ///
377    /// This always syncs the current on-disk content to the server via a
378    /// `textDocument/didChange` (or `didOpen` the first time), then waits for
379    /// a fresh `publishDiagnostics` from the server. Without this, edits made
380    /// by file-writing tools would be invisible to the LSP's in-memory buffer
381    /// and callers would see stale pre-edit diagnostics.
382    pub async fn diagnostics(&self, path: &Path) -> Result<LspActionResult> {
383        let uri = path_to_uri(path);
384
385        // Always re-read the file from disk and push it into the LSP session
386        // so diagnostics reflect the latest contents, not the version the
387        // server cached when the doc was first opened.
388        let disk_content = tokio::fs::read_to_string(path).await.unwrap_or_default();
389        let already_open = self.open_documents.read().await.contains_key(&uri);
390
391        let baseline_seq = self.transport.diagnostics_publish_seq();
392        self.transport.invalidate_diagnostics(&uri).await;
393
394        if already_open {
395            if let Err(e) = self.change_document(path, &disk_content).await {
396                debug!(path = %path.display(), error = %e, "didChange failed; falling back to cached snapshot");
397            }
398        } else if let Err(e) = self.open_document(path, &disk_content).await {
399            debug!(path = %path.display(), error = %e, "didOpen failed; falling back to cached snapshot");
400        }
401
402        // Wait up to ~1.5s for a fresh publication. rust-analyzer/eslint
403        // typically republish within a few hundred ms of didChange.
404        let _ = self
405            .transport
406            .wait_for_publish_after(baseline_seq, std::time::Duration::from_millis(1500))
407            .await;
408
409        let snapshot = self.transport.diagnostics_snapshot().await;
410        let diagnostics = snapshot
411            .get(&uri)
412            .cloned()
413            .unwrap_or_default()
414            .into_iter()
415            .map(|diagnostic| DiagnosticInfo::from((uri.clone(), diagnostic)))
416            .collect();
417
418        Ok(LspActionResult::Diagnostics { diagnostics })
419    }
420
421    /// Ensure a document is open (open it if not already)
422    async fn ensure_document_open(&self, path: &Path) -> Result<()> {
423        let uri = path_to_uri(path);
424        if !self.open_documents.read().await.contains_key(&uri) {
425            let content = tokio::fs::read_to_string(path).await?;
426            self.open_document(path, &content).await?;
427        }
428        Ok(())
429    }
430
431    /// Get the server capabilities
432    #[allow(dead_code)]
433    pub async fn capabilities(&self) -> Option<lsp_types::ServerCapabilities> {
434        self.server_capabilities.read().await.clone()
435    }
436
437    /// Check if this client handles the given file extension
438    #[allow(dead_code)]
439    pub fn handles_file(&self, path: &Path) -> bool {
440        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
441        self.config.file_extensions.iter().any(|fe| fe == ext)
442    }
443
444    /// Check if this client handles a language by name
445    #[allow(dead_code)]
446    pub fn handles_language(&self, language: &str) -> bool {
447        let extensions = match language {
448            "rust" => &["rs"][..],
449            "typescript" => &["ts", "tsx"],
450            "javascript" => &["js", "jsx"],
451            "python" => &["py"],
452            "go" => &["go"],
453            "c" => &["c", "h"],
454            "cpp" => &["cpp", "cc", "cxx", "hpp", "h"],
455            _ => &[],
456        };
457
458        extensions
459            .iter()
460            .any(|ext| self.config.file_extensions.iter().any(|fe| fe == *ext))
461    }
462}
463
464/// Convert a file path to a file:// URI
465fn path_to_uri(path: &Path) -> String {
466    let absolute = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
467    format!("file://{}", absolute.display())
468}
469
470/// Parse a string URI into an lsp_types::Uri
471fn parse_uri(uri_str: &str) -> Result<lsp_types::Uri> {
472    uri_str
473        .parse()
474        .map_err(|e| anyhow::anyhow!("Invalid URI: {e}"))
475}
476
477/// Parse a location response (definition, references, implementation)
478fn parse_location_response(response: JsonRpcResponse, _operation: &str) -> Result<LspActionResult> {
479    if let Some(error) = response.error {
480        return Ok(LspActionResult::Error {
481            message: error.message,
482        });
483    }
484
485    let Some(result) = response.result else {
486        return Ok(LspActionResult::Definition { locations: vec![] });
487    };
488
489    if let Ok(loc) = serde_json::from_value::<lsp_types::Location>(result.clone()) {
490        return Ok(LspActionResult::Definition {
491            locations: vec![LocationInfo::from(loc)],
492        });
493    }
494
495    if let Ok(locs) = serde_json::from_value::<Vec<lsp_types::Location>>(result.clone()) {
496        return Ok(LspActionResult::Definition {
497            locations: locs.into_iter().map(LocationInfo::from).collect(),
498        });
499    }
500
501    if let Ok(links) = serde_json::from_value::<Vec<lsp_types::LocationLink>>(result) {
502        return Ok(LspActionResult::Definition {
503            locations: links
504                .into_iter()
505                .map(|link| LocationInfo {
506                    uri: link.target_uri.to_string(),
507                    range: RangeInfo::from(link.target_selection_range),
508                })
509                .collect(),
510        });
511    }
512
513    Ok(LspActionResult::Definition { locations: vec![] })
514}
515
516/// Parse a hover response
517fn parse_hover_response(response: JsonRpcResponse) -> Result<LspActionResult> {
518    if let Some(error) = response.error {
519        return Ok(LspActionResult::Error {
520            message: error.message,
521        });
522    }
523
524    let Some(result) = response.result else {
525        return Ok(LspActionResult::Hover {
526            contents: String::new(),
527            range: None,
528        });
529    };
530
531    if result.is_null() {
532        return Ok(LspActionResult::Hover {
533            contents: "No hover information available".to_string(),
534            range: None,
535        });
536    }
537
538    let hover: lsp_types::Hover = serde_json::from_value(result)?;
539
540    let contents = match hover.contents {
541        lsp_types::HoverContents::Scalar(markup) => match markup {
542            lsp_types::MarkedString::String(s) => s,
543            lsp_types::MarkedString::LanguageString(ls) => ls.value,
544        },
545        lsp_types::HoverContents::Array(markups) => markups
546            .into_iter()
547            .map(|m| match m {
548                lsp_types::MarkedString::String(s) => s,
549                lsp_types::MarkedString::LanguageString(ls) => ls.value,
550            })
551            .collect::<Vec<_>>()
552            .join("\n\n"),
553        lsp_types::HoverContents::Markup(markup) => markup.value,
554    };
555
556    Ok(LspActionResult::Hover {
557        contents,
558        range: hover.range.map(RangeInfo::from),
559    })
560}
561
562/// Parse a document symbols response
563fn parse_document_symbols_response(response: JsonRpcResponse) -> Result<LspActionResult> {
564    if let Some(error) = response.error {
565        return Ok(LspActionResult::Error {
566            message: error.message,
567        });
568    }
569
570    let Some(result) = response.result else {
571        return Ok(LspActionResult::DocumentSymbols { symbols: vec![] });
572    };
573
574    if result.is_null() {
575        return Ok(LspActionResult::DocumentSymbols { symbols: vec![] });
576    }
577
578    if let Ok(symbols) = serde_json::from_value::<Vec<lsp_types::DocumentSymbol>>(result.clone()) {
579        return Ok(LspActionResult::DocumentSymbols {
580            symbols: symbols.into_iter().map(SymbolInfo::from).collect(),
581        });
582    }
583
584    if let Ok(symbols) = serde_json::from_value::<Vec<lsp_types::SymbolInformation>>(result) {
585        return Ok(LspActionResult::DocumentSymbols {
586            symbols: symbols.into_iter().map(SymbolInfo::from).collect(),
587        });
588    }
589
590    Ok(LspActionResult::DocumentSymbols { symbols: vec![] })
591}
592
593/// Parse a workspace symbols response
594fn parse_workspace_symbols_response(response: JsonRpcResponse) -> Result<LspActionResult> {
595    if let Some(error) = response.error {
596        return Ok(LspActionResult::Error {
597            message: error.message,
598        });
599    }
600
601    let Some(result) = response.result else {
602        return Ok(LspActionResult::WorkspaceSymbols { symbols: vec![] });
603    };
604
605    if result.is_null() {
606        return Ok(LspActionResult::WorkspaceSymbols { symbols: vec![] });
607    }
608
609    if let Ok(symbols) = serde_json::from_value::<Vec<lsp_types::SymbolInformation>>(result.clone())
610    {
611        return Ok(LspActionResult::WorkspaceSymbols {
612            symbols: symbols.into_iter().map(SymbolInfo::from).collect(),
613        });
614    }
615
616    if let Ok(symbols) = serde_json::from_value::<Vec<lsp_types::WorkspaceSymbol>>(result) {
617        return Ok(LspActionResult::WorkspaceSymbols {
618            symbols: symbols
619                .into_iter()
620                .map(|s| {
621                    let (uri, range) = match s.location {
622                        lsp_types::OneOf::Left(loc) => {
623                            (loc.uri.to_string(), Some(RangeInfo::from(loc.range)))
624                        }
625                        lsp_types::OneOf::Right(wl) => (wl.uri.to_string(), None),
626                    };
627                    SymbolInfo {
628                        name: s.name,
629                        kind: format!("{:?}", s.kind),
630                        detail: None,
631                        uri: Some(uri),
632                        range,
633                        container_name: s.container_name,
634                    }
635                })
636                .collect(),
637        });
638    }
639
640    Ok(LspActionResult::WorkspaceSymbols { symbols: vec![] })
641}
642
643/// Parse a completion response
644fn parse_completion_response(response: JsonRpcResponse) -> Result<LspActionResult> {
645    if let Some(error) = response.error {
646        return Ok(LspActionResult::Error {
647            message: error.message,
648        });
649    }
650
651    let Some(result) = response.result else {
652        return Ok(LspActionResult::Completion { items: vec![] });
653    };
654
655    if result.is_null() {
656        return Ok(LspActionResult::Completion { items: vec![] });
657    }
658
659    if let Ok(list) = serde_json::from_value::<lsp_types::CompletionList>(result.clone()) {
660        return Ok(LspActionResult::Completion {
661            items: list
662                .items
663                .into_iter()
664                .map(CompletionItemInfo::from)
665                .collect(),
666        });
667    }
668
669    if let Ok(items) = serde_json::from_value::<Vec<lsp_types::CompletionItem>>(result) {
670        return Ok(LspActionResult::Completion {
671            items: items.into_iter().map(CompletionItemInfo::from).collect(),
672        });
673    }
674
675    Ok(LspActionResult::Completion { items: vec![] })
676}
677
678/// LSP Manager - manages multiple language server connections
679pub struct LspManager {
680    clients: RwLock<HashMap<String, Arc<LspClient>>>,
681    /// Linter clients keyed by linter name (e.g. "eslint", "ruff").
682    /// These are only queried for diagnostics, not completions/definitions.
683    linter_clients: RwLock<HashMap<String, Arc<LspClient>>>,
684    root_uri: Option<String>,
685    /// User-supplied LSP settings from config.
686    lsp_settings: Option<crate::config::LspSettings>,
687}
688
689impl LspManager {
690    /// Create a new LSP manager
691    pub fn new(root_uri: Option<String>) -> Self {
692        Self {
693            clients: RwLock::new(HashMap::new()),
694            linter_clients: RwLock::new(HashMap::new()),
695            root_uri,
696            lsp_settings: None,
697        }
698    }
699
700    /// Create a new LSP manager with config-driven settings.
701    pub fn with_config(root_uri: Option<String>, settings: crate::config::LspSettings) -> Self {
702        Self {
703            clients: RwLock::new(HashMap::new()),
704            linter_clients: RwLock::new(HashMap::new()),
705            root_uri,
706            lsp_settings: Some(settings),
707        }
708    }
709
710    /// Get or create a client for the given language
711    pub async fn get_client(&self, language: &str) -> Result<Arc<LspClient>> {
712        {
713            let clients = self.clients.read().await;
714            if let Some(client) = clients.get(language) {
715                return Ok(Arc::clone(client));
716            }
717        }
718
719        let client = if let Some(settings) = &self.lsp_settings {
720            if let Some(entry) = settings.servers.get(language) {
721                let config = LspConfig::from_server_entry(entry, self.root_uri.clone());
722                LspClient::new(config).await?
723            } else {
724                LspClient::for_language(language, self.root_uri.clone()).await?
725            }
726        } else {
727            LspClient::for_language(language, self.root_uri.clone()).await?
728        };
729        client.initialize().await?;
730
731        let client = Arc::new(client);
732        self.clients
733            .write()
734            .await
735            .insert(language.to_string(), Arc::clone(&client));
736
737        Ok(client)
738    }
739
740    /// Get a client for a file path (detects language from extension)
741    pub async fn get_client_for_file(&self, path: &Path) -> Result<Arc<LspClient>> {
742        let language = detect_language_from_path(path.to_string_lossy().as_ref())
743            .ok_or_else(|| anyhow::anyhow!("Unknown language for file: {}", path.display()))?;
744        self.get_client(language).await
745    }
746
747    /// Check if any registered client handles the given file.
748    #[allow(dead_code)]
749    pub async fn handles_file(&self, path: &Path) -> bool {
750        let clients = self.clients.read().await;
751        clients.values().any(|c| c.handles_file(path))
752    }
753
754    /// Get capabilities for a specific language server.
755    #[allow(dead_code)]
756    pub async fn capabilities_for(&self, language: &str) -> Option<lsp_types::ServerCapabilities> {
757        let clients = self.clients.read().await;
758        if let Some(client) = clients.get(language) {
759            client.capabilities().await
760        } else {
761            None
762        }
763    }
764
765    /// Close a document across all relevant clients.
766    #[allow(dead_code)]
767    pub async fn close_document(&self, path: &Path) -> Result<()> {
768        if let Ok(client) = self.get_client_for_file(path).await {
769            client.close_document(path).await?;
770        }
771        Ok(())
772    }
773
774    /// Notify clients of a document change.
775    #[allow(dead_code)]
776    pub async fn change_document(&self, path: &Path, content: &str) -> Result<()> {
777        if let Ok(client) = self.get_client_for_file(path).await {
778            client.change_document(path, content).await?;
779        }
780        Ok(())
781    }
782
783    /// Shutdown all clients
784    #[allow(dead_code)]
785    pub async fn shutdown_all(&self) {
786        let clients = self.clients.read().await;
787        for (lang, client) in clients.iter() {
788            if let Err(e) = client.shutdown().await {
789                warn!("Failed to shutdown {} language server: {}", lang, e);
790            }
791        }
792        let linters = self.linter_clients.read().await;
793        for (name, client) in linters.iter() {
794            if let Err(e) = client.shutdown().await {
795                warn!("Failed to shutdown {} linter server: {}", name, e);
796            }
797        }
798    }
799
800    /// Get or start a linter client by name (e.g. "eslint", "ruff").
801    /// Returns `None` if the linter is not configured or its binary is missing.
802    pub async fn get_linter_client(&self, name: &str) -> Result<Option<Arc<LspClient>>> {
803        // Already running?
804        {
805            let linters = self.linter_clients.read().await;
806            if let Some(client) = linters.get(name) {
807                return Ok(Some(Arc::clone(client)));
808            }
809        }
810
811        // Resolve config
812        let lsp_config = if let Some(settings) = &self.lsp_settings {
813            if let Some(entry) = settings.linters.get(name) {
814                if !entry.enabled {
815                    return Ok(None);
816                }
817                LspConfig::from_linter_entry(name, entry, self.root_uri.clone())
818            } else if !settings.disable_builtin_linters {
819                // Not explicitly configured — try built-in
820                if let Some(mut cfg) = get_linter_server_config(name) {
821                    cfg.root_uri = self.root_uri.clone();
822                    Some(cfg)
823                } else {
824                    None
825                }
826            } else {
827                None
828            }
829        } else {
830            // No config provided — use built-in defaults
831            if let Some(mut cfg) = get_linter_server_config(name) {
832                cfg.root_uri = self.root_uri.clone();
833                Some(cfg)
834            } else {
835                None
836            }
837        };
838
839        let Some(config) = lsp_config else {
840            return Ok(None);
841        };
842
843        // Try to start; if the binary is missing, return None instead of hard error
844        let client = match LspClient::new(config).await {
845            Ok(c) => c,
846            Err(e) => {
847                debug!(linter = name, error = %e, "Linter server not available");
848                return Ok(None);
849            }
850        };
851        if let Err(e) = client.initialize().await {
852            warn!(linter = name, error = %e, "Linter server failed to initialize");
853            return Ok(None);
854        }
855
856        let client = Arc::new(client);
857        self.linter_clients
858            .write()
859            .await
860            .insert(name.to_string(), Arc::clone(&client));
861        info!(linter = name, "Linter server started");
862        Ok(Some(client))
863    }
864
865    /// Collect diagnostics from all applicable linter servers for a file.
866    /// Returns an empty vec if no linters match the file extension.
867    pub async fn linter_diagnostics(&self, path: &Path) -> Vec<DiagnosticInfo> {
868        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
869
870        // Determine which linters apply based on file extension
871        let linter_names: Vec<String> = if let Some(settings) = &self.lsp_settings {
872            settings
873                .linters
874                .iter()
875                .filter(|(_, entry)| {
876                    entry.enabled
877                        && (entry.file_extensions.iter().any(|e| e == ext)
878                            || entry.file_extensions.is_empty())
879                })
880                .map(|(name, _)| name.clone())
881                .collect()
882        } else {
883            // Auto-detect: try known linters whose extensions match
884            let mut names = Vec::new();
885            for candidate in &["eslint", "biome", "ruff", "stylelint"] {
886                if linter_extensions(candidate).contains(&ext) {
887                    names.push((*candidate).to_string());
888                }
889            }
890            names
891        };
892
893        let mut all_diagnostics = Vec::new();
894        for name in &linter_names {
895            match self.get_linter_client(name).await {
896                Ok(Some(client)) => match client.diagnostics(path).await {
897                    Ok(LspActionResult::Diagnostics { diagnostics }) => {
898                        all_diagnostics.extend(diagnostics);
899                    }
900                    Ok(_) => {}
901                    Err(e) => {
902                        debug!(linter = %name, error = %e, "Linter diagnostics failed");
903                    }
904                },
905                Ok(None) => {}
906                Err(e) => {
907                    debug!(linter = %name, error = %e, "Failed to get linter client");
908                }
909            }
910        }
911        all_diagnostics
912    }
913}
914
915impl Default for LspManager {
916    fn default() -> Self {
917        Self::new(None)
918    }
919}