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    #[allow(dead_code)]
157    pub async fn change_document(&self, path: &Path, content: &str) -> Result<()> {
158        let uri = path_to_uri(path);
159        let mut open_docs = self.open_documents.write().await;
160
161        let version = open_docs.entry(uri.clone()).or_insert(0);
162        *version += 1;
163
164        let text_document = VersionedTextDocumentIdentifier {
165            uri,
166            version: *version,
167        };
168
169        let content_changes = vec![super::types::TextDocumentContentChangeEvent {
170            range: None,
171            range_length: None,
172            text: content.to_string(),
173        }];
174
175        let params = DidChangeTextDocumentParams {
176            text_document,
177            content_changes,
178        };
179
180        self.transport
181            .notify(
182                "textDocument/didChange",
183                Some(serde_json::to_value(params)?),
184            )
185            .await?;
186
187        debug!(path = %path.display(), version = *version, "Changed document");
188
189        Ok(())
190    }
191
192    /// Go to definition
193    pub async fn go_to_definition(
194        &self,
195        path: &Path,
196        line: u32,
197        character: u32,
198    ) -> Result<LspActionResult> {
199        let uri = path_to_uri(path);
200        self.ensure_document_open(path).await?;
201
202        let params = serde_json::json!({
203            "textDocument": { "uri": uri },
204            "position": { "line": line.saturating_sub(1), "character": character.saturating_sub(1) },
205        });
206
207        let response = self
208            .transport
209            .request("textDocument/definition", Some(params))
210            .await?;
211
212        parse_location_response(response, "definition")
213    }
214
215    /// Find references
216    pub async fn find_references(
217        &self,
218        path: &Path,
219        line: u32,
220        character: u32,
221        include_declaration: bool,
222    ) -> Result<LspActionResult> {
223        let uri = path_to_uri(path);
224        self.ensure_document_open(path).await?;
225
226        let params = ReferenceParams {
227            text_document: TextDocumentIdentifier {
228                uri: parse_uri(&uri)?,
229            },
230            position: Position {
231                line: line.saturating_sub(1),
232                character: character.saturating_sub(1),
233            },
234            context: ReferenceContext {
235                include_declaration,
236            },
237        };
238
239        let response = self
240            .transport
241            .request(
242                "textDocument/references",
243                Some(serde_json::to_value(params)?),
244            )
245            .await?;
246
247        parse_location_response(response, "references")
248    }
249
250    /// Get hover information
251    pub async fn hover(&self, path: &Path, line: u32, character: u32) -> Result<LspActionResult> {
252        let uri = path_to_uri(path);
253        self.ensure_document_open(path).await?;
254
255        let params = HoverParams {
256            text_document_position_params: TextDocumentPositionParams {
257                text_document: TextDocumentIdentifier {
258                    uri: parse_uri(&uri)?,
259                },
260                position: Position {
261                    line: line.saturating_sub(1),
262                    character: character.saturating_sub(1),
263                },
264            },
265            work_done_progress_params: Default::default(),
266        };
267
268        let response = self
269            .transport
270            .request("textDocument/hover", Some(serde_json::to_value(params)?))
271            .await?;
272
273        parse_hover_response(response)
274    }
275
276    /// Get document symbols
277    pub async fn document_symbols(&self, path: &Path) -> Result<LspActionResult> {
278        let uri = path_to_uri(path);
279        self.ensure_document_open(path).await?;
280
281        let params = DocumentSymbolParams {
282            text_document: TextDocumentIdentifier {
283                uri: parse_uri(&uri)?,
284            },
285            work_done_progress_params: Default::default(),
286            partial_result_params: Default::default(),
287        };
288
289        let response = self
290            .transport
291            .request(
292                "textDocument/documentSymbol",
293                Some(serde_json::to_value(params)?),
294            )
295            .await?;
296
297        parse_document_symbols_response(response)
298    }
299
300    /// Search workspace symbols
301    pub async fn workspace_symbols(&self, query: &str) -> Result<LspActionResult> {
302        let params = WorkspaceSymbolParams {
303            query: query.to_string(),
304        };
305
306        let response = self
307            .transport
308            .request("workspace/symbol", Some(serde_json::to_value(params)?))
309            .await?;
310
311        parse_workspace_symbols_response(response)
312    }
313
314    /// Go to implementation
315    pub async fn go_to_implementation(
316        &self,
317        path: &Path,
318        line: u32,
319        character: u32,
320    ) -> Result<LspActionResult> {
321        let uri = path_to_uri(path);
322        self.ensure_document_open(path).await?;
323
324        let params = serde_json::json!({
325            "textDocument": { "uri": uri },
326            "position": { "line": line.saturating_sub(1), "character": character.saturating_sub(1) },
327        });
328
329        let response = self
330            .transport
331            .request("textDocument/implementation", Some(params))
332            .await?;
333
334        parse_location_response(response, "implementation")
335    }
336
337    /// Get code completions
338    pub async fn completion(
339        &self,
340        path: &Path,
341        line: u32,
342        character: u32,
343    ) -> Result<LspActionResult> {
344        let uri = path_to_uri(path);
345        self.ensure_document_open(path).await?;
346
347        let params = CompletionParams {
348            text_document_position: TextDocumentPositionParams {
349                text_document: TextDocumentIdentifier {
350                    uri: parse_uri(&uri)?,
351                },
352                position: Position {
353                    line: line.saturating_sub(1),
354                    character: character.saturating_sub(1),
355                },
356            },
357            work_done_progress_params: Default::default(),
358            partial_result_params: Default::default(),
359            context: Some(CompletionContext {
360                trigger_kind: CompletionTriggerKind::INVOKED,
361                trigger_character: None,
362            }),
363        };
364
365        let response = self
366            .transport
367            .request(
368                "textDocument/completion",
369                Some(serde_json::to_value(params)?),
370            )
371            .await?;
372
373        parse_completion_response(response)
374    }
375
376    /// Return the most recent LSP diagnostics for a file after ensuring the document is open.
377    pub async fn diagnostics(&self, path: &Path) -> Result<LspActionResult> {
378        self.ensure_document_open(path).await?;
379        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
380
381        let uri = path_to_uri(path);
382        let snapshot = self.transport.diagnostics_snapshot().await;
383        let diagnostics = snapshot
384            .get(&uri)
385            .cloned()
386            .unwrap_or_default()
387            .into_iter()
388            .map(|diagnostic| DiagnosticInfo::from((uri.clone(), diagnostic)))
389            .collect();
390
391        Ok(LspActionResult::Diagnostics { diagnostics })
392    }
393
394    /// Ensure a document is open (open it if not already)
395    async fn ensure_document_open(&self, path: &Path) -> Result<()> {
396        let uri = path_to_uri(path);
397        if !self.open_documents.read().await.contains_key(&uri) {
398            let content = tokio::fs::read_to_string(path).await?;
399            self.open_document(path, &content).await?;
400        }
401        Ok(())
402    }
403
404    /// Get the server capabilities
405    #[allow(dead_code)]
406    pub async fn capabilities(&self) -> Option<lsp_types::ServerCapabilities> {
407        self.server_capabilities.read().await.clone()
408    }
409
410    /// Check if this client handles the given file extension
411    #[allow(dead_code)]
412    pub fn handles_file(&self, path: &Path) -> bool {
413        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
414        self.config.file_extensions.iter().any(|fe| fe == ext)
415    }
416
417    /// Check if this client handles a language by name
418    #[allow(dead_code)]
419    pub fn handles_language(&self, language: &str) -> bool {
420        let extensions = match language {
421            "rust" => &["rs"][..],
422            "typescript" => &["ts", "tsx"],
423            "javascript" => &["js", "jsx"],
424            "python" => &["py"],
425            "go" => &["go"],
426            "c" => &["c", "h"],
427            "cpp" => &["cpp", "cc", "cxx", "hpp", "h"],
428            _ => &[],
429        };
430
431        extensions
432            .iter()
433            .any(|ext| self.config.file_extensions.iter().any(|fe| fe == *ext))
434    }
435}
436
437/// Convert a file path to a file:// URI
438fn path_to_uri(path: &Path) -> String {
439    let absolute = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
440    format!("file://{}", absolute.display())
441}
442
443/// Parse a string URI into an lsp_types::Uri
444fn parse_uri(uri_str: &str) -> Result<lsp_types::Uri> {
445    uri_str
446        .parse()
447        .map_err(|e| anyhow::anyhow!("Invalid URI: {e}"))
448}
449
450/// Parse a location response (definition, references, implementation)
451fn parse_location_response(response: JsonRpcResponse, _operation: &str) -> Result<LspActionResult> {
452    if let Some(error) = response.error {
453        return Ok(LspActionResult::Error {
454            message: error.message,
455        });
456    }
457
458    let Some(result) = response.result else {
459        return Ok(LspActionResult::Definition { locations: vec![] });
460    };
461
462    if let Ok(loc) = serde_json::from_value::<lsp_types::Location>(result.clone()) {
463        return Ok(LspActionResult::Definition {
464            locations: vec![LocationInfo::from(loc)],
465        });
466    }
467
468    if let Ok(locs) = serde_json::from_value::<Vec<lsp_types::Location>>(result.clone()) {
469        return Ok(LspActionResult::Definition {
470            locations: locs.into_iter().map(LocationInfo::from).collect(),
471        });
472    }
473
474    if let Ok(links) = serde_json::from_value::<Vec<lsp_types::LocationLink>>(result) {
475        return Ok(LspActionResult::Definition {
476            locations: links
477                .into_iter()
478                .map(|link| LocationInfo {
479                    uri: link.target_uri.to_string(),
480                    range: RangeInfo::from(link.target_selection_range),
481                })
482                .collect(),
483        });
484    }
485
486    Ok(LspActionResult::Definition { locations: vec![] })
487}
488
489/// Parse a hover response
490fn parse_hover_response(response: JsonRpcResponse) -> Result<LspActionResult> {
491    if let Some(error) = response.error {
492        return Ok(LspActionResult::Error {
493            message: error.message,
494        });
495    }
496
497    let Some(result) = response.result else {
498        return Ok(LspActionResult::Hover {
499            contents: String::new(),
500            range: None,
501        });
502    };
503
504    if result.is_null() {
505        return Ok(LspActionResult::Hover {
506            contents: "No hover information available".to_string(),
507            range: None,
508        });
509    }
510
511    let hover: lsp_types::Hover = serde_json::from_value(result)?;
512
513    let contents = match hover.contents {
514        lsp_types::HoverContents::Scalar(markup) => match markup {
515            lsp_types::MarkedString::String(s) => s,
516            lsp_types::MarkedString::LanguageString(ls) => ls.value,
517        },
518        lsp_types::HoverContents::Array(markups) => markups
519            .into_iter()
520            .map(|m| match m {
521                lsp_types::MarkedString::String(s) => s,
522                lsp_types::MarkedString::LanguageString(ls) => ls.value,
523            })
524            .collect::<Vec<_>>()
525            .join("\n\n"),
526        lsp_types::HoverContents::Markup(markup) => markup.value,
527    };
528
529    Ok(LspActionResult::Hover {
530        contents,
531        range: hover.range.map(RangeInfo::from),
532    })
533}
534
535/// Parse a document symbols response
536fn parse_document_symbols_response(response: JsonRpcResponse) -> Result<LspActionResult> {
537    if let Some(error) = response.error {
538        return Ok(LspActionResult::Error {
539            message: error.message,
540        });
541    }
542
543    let Some(result) = response.result else {
544        return Ok(LspActionResult::DocumentSymbols { symbols: vec![] });
545    };
546
547    if result.is_null() {
548        return Ok(LspActionResult::DocumentSymbols { symbols: vec![] });
549    }
550
551    if let Ok(symbols) = serde_json::from_value::<Vec<lsp_types::DocumentSymbol>>(result.clone()) {
552        return Ok(LspActionResult::DocumentSymbols {
553            symbols: symbols.into_iter().map(SymbolInfo::from).collect(),
554        });
555    }
556
557    if let Ok(symbols) = serde_json::from_value::<Vec<lsp_types::SymbolInformation>>(result) {
558        return Ok(LspActionResult::DocumentSymbols {
559            symbols: symbols.into_iter().map(SymbolInfo::from).collect(),
560        });
561    }
562
563    Ok(LspActionResult::DocumentSymbols { symbols: vec![] })
564}
565
566/// Parse a workspace symbols response
567fn parse_workspace_symbols_response(response: JsonRpcResponse) -> Result<LspActionResult> {
568    if let Some(error) = response.error {
569        return Ok(LspActionResult::Error {
570            message: error.message,
571        });
572    }
573
574    let Some(result) = response.result else {
575        return Ok(LspActionResult::WorkspaceSymbols { symbols: vec![] });
576    };
577
578    if result.is_null() {
579        return Ok(LspActionResult::WorkspaceSymbols { symbols: vec![] });
580    }
581
582    if let Ok(symbols) = serde_json::from_value::<Vec<lsp_types::SymbolInformation>>(result.clone())
583    {
584        return Ok(LspActionResult::WorkspaceSymbols {
585            symbols: symbols.into_iter().map(SymbolInfo::from).collect(),
586        });
587    }
588
589    if let Ok(symbols) = serde_json::from_value::<Vec<lsp_types::WorkspaceSymbol>>(result) {
590        return Ok(LspActionResult::WorkspaceSymbols {
591            symbols: symbols
592                .into_iter()
593                .map(|s| {
594                    let (uri, range) = match s.location {
595                        lsp_types::OneOf::Left(loc) => {
596                            (loc.uri.to_string(), Some(RangeInfo::from(loc.range)))
597                        }
598                        lsp_types::OneOf::Right(wl) => (wl.uri.to_string(), None),
599                    };
600                    SymbolInfo {
601                        name: s.name,
602                        kind: format!("{:?}", s.kind),
603                        detail: None,
604                        uri: Some(uri),
605                        range,
606                        container_name: s.container_name,
607                    }
608                })
609                .collect(),
610        });
611    }
612
613    Ok(LspActionResult::WorkspaceSymbols { symbols: vec![] })
614}
615
616/// Parse a completion response
617fn parse_completion_response(response: JsonRpcResponse) -> Result<LspActionResult> {
618    if let Some(error) = response.error {
619        return Ok(LspActionResult::Error {
620            message: error.message,
621        });
622    }
623
624    let Some(result) = response.result else {
625        return Ok(LspActionResult::Completion { items: vec![] });
626    };
627
628    if result.is_null() {
629        return Ok(LspActionResult::Completion { items: vec![] });
630    }
631
632    if let Ok(list) = serde_json::from_value::<lsp_types::CompletionList>(result.clone()) {
633        return Ok(LspActionResult::Completion {
634            items: list
635                .items
636                .into_iter()
637                .map(CompletionItemInfo::from)
638                .collect(),
639        });
640    }
641
642    if let Ok(items) = serde_json::from_value::<Vec<lsp_types::CompletionItem>>(result) {
643        return Ok(LspActionResult::Completion {
644            items: items.into_iter().map(CompletionItemInfo::from).collect(),
645        });
646    }
647
648    Ok(LspActionResult::Completion { items: vec![] })
649}
650
651/// LSP Manager - manages multiple language server connections
652pub struct LspManager {
653    clients: RwLock<HashMap<String, Arc<LspClient>>>,
654    /// Linter clients keyed by linter name (e.g. "eslint", "ruff").
655    /// These are only queried for diagnostics, not completions/definitions.
656    linter_clients: RwLock<HashMap<String, Arc<LspClient>>>,
657    root_uri: Option<String>,
658    /// User-supplied LSP settings from config.
659    lsp_settings: Option<crate::config::LspSettings>,
660}
661
662impl LspManager {
663    /// Create a new LSP manager
664    pub fn new(root_uri: Option<String>) -> Self {
665        Self {
666            clients: RwLock::new(HashMap::new()),
667            linter_clients: RwLock::new(HashMap::new()),
668            root_uri,
669            lsp_settings: None,
670        }
671    }
672
673    /// Create a new LSP manager with config-driven settings.
674    pub fn with_config(root_uri: Option<String>, settings: crate::config::LspSettings) -> Self {
675        Self {
676            clients: RwLock::new(HashMap::new()),
677            linter_clients: RwLock::new(HashMap::new()),
678            root_uri,
679            lsp_settings: Some(settings),
680        }
681    }
682
683    /// Get or create a client for the given language
684    pub async fn get_client(&self, language: &str) -> Result<Arc<LspClient>> {
685        {
686            let clients = self.clients.read().await;
687            if let Some(client) = clients.get(language) {
688                return Ok(Arc::clone(client));
689            }
690        }
691
692        let client = if let Some(settings) = &self.lsp_settings {
693            if let Some(entry) = settings.servers.get(language) {
694                let config = LspConfig::from_server_entry(entry, self.root_uri.clone());
695                LspClient::new(config).await?
696            } else {
697                LspClient::for_language(language, self.root_uri.clone()).await?
698            }
699        } else {
700            LspClient::for_language(language, self.root_uri.clone()).await?
701        };
702        client.initialize().await?;
703
704        let client = Arc::new(client);
705        self.clients
706            .write()
707            .await
708            .insert(language.to_string(), Arc::clone(&client));
709
710        Ok(client)
711    }
712
713    /// Get a client for a file path (detects language from extension)
714    pub async fn get_client_for_file(&self, path: &Path) -> Result<Arc<LspClient>> {
715        let language = detect_language_from_path(path.to_string_lossy().as_ref())
716            .ok_or_else(|| anyhow::anyhow!("Unknown language for file: {}", path.display()))?;
717        self.get_client(language).await
718    }
719
720    /// Check if any registered client handles the given file.
721    #[allow(dead_code)]
722    pub async fn handles_file(&self, path: &Path) -> bool {
723        let clients = self.clients.read().await;
724        clients.values().any(|c| c.handles_file(path))
725    }
726
727    /// Get capabilities for a specific language server.
728    #[allow(dead_code)]
729    pub async fn capabilities_for(&self, language: &str) -> Option<lsp_types::ServerCapabilities> {
730        let clients = self.clients.read().await;
731        if let Some(client) = clients.get(language) {
732            client.capabilities().await
733        } else {
734            None
735        }
736    }
737
738    /// Close a document across all relevant clients.
739    #[allow(dead_code)]
740    pub async fn close_document(&self, path: &Path) -> Result<()> {
741        if let Ok(client) = self.get_client_for_file(path).await {
742            client.close_document(path).await?;
743        }
744        Ok(())
745    }
746
747    /// Notify clients of a document change.
748    #[allow(dead_code)]
749    pub async fn change_document(&self, path: &Path, content: &str) -> Result<()> {
750        if let Ok(client) = self.get_client_for_file(path).await {
751            client.change_document(path, content).await?;
752        }
753        Ok(())
754    }
755
756    /// Shutdown all clients
757    #[allow(dead_code)]
758    pub async fn shutdown_all(&self) {
759        let clients = self.clients.read().await;
760        for (lang, client) in clients.iter() {
761            if let Err(e) = client.shutdown().await {
762                warn!("Failed to shutdown {} language server: {}", lang, e);
763            }
764        }
765        let linters = self.linter_clients.read().await;
766        for (name, client) in linters.iter() {
767            if let Err(e) = client.shutdown().await {
768                warn!("Failed to shutdown {} linter server: {}", name, e);
769            }
770        }
771    }
772
773    /// Get or start a linter client by name (e.g. "eslint", "ruff").
774    /// Returns `None` if the linter is not configured or its binary is missing.
775    pub async fn get_linter_client(&self, name: &str) -> Result<Option<Arc<LspClient>>> {
776        // Already running?
777        {
778            let linters = self.linter_clients.read().await;
779            if let Some(client) = linters.get(name) {
780                return Ok(Some(Arc::clone(client)));
781            }
782        }
783
784        // Resolve config
785        let lsp_config = if let Some(settings) = &self.lsp_settings {
786            if let Some(entry) = settings.linters.get(name) {
787                if !entry.enabled {
788                    return Ok(None);
789                }
790                LspConfig::from_linter_entry(name, entry, self.root_uri.clone())
791            } else if !settings.disable_builtin_linters {
792                // Not explicitly configured — try built-in
793                if let Some(mut cfg) = get_linter_server_config(name) {
794                    cfg.root_uri = self.root_uri.clone();
795                    Some(cfg)
796                } else {
797                    None
798                }
799            } else {
800                None
801            }
802        } else {
803            // No config provided — use built-in defaults
804            if let Some(mut cfg) = get_linter_server_config(name) {
805                cfg.root_uri = self.root_uri.clone();
806                Some(cfg)
807            } else {
808                None
809            }
810        };
811
812        let Some(config) = lsp_config else {
813            return Ok(None);
814        };
815
816        // Try to start; if the binary is missing, return None instead of hard error
817        let client = match LspClient::new(config).await {
818            Ok(c) => c,
819            Err(e) => {
820                debug!(linter = name, error = %e, "Linter server not available");
821                return Ok(None);
822            }
823        };
824        if let Err(e) = client.initialize().await {
825            warn!(linter = name, error = %e, "Linter server failed to initialize");
826            return Ok(None);
827        }
828
829        let client = Arc::new(client);
830        self.linter_clients
831            .write()
832            .await
833            .insert(name.to_string(), Arc::clone(&client));
834        info!(linter = name, "Linter server started");
835        Ok(Some(client))
836    }
837
838    /// Collect diagnostics from all applicable linter servers for a file.
839    /// Returns an empty vec if no linters match the file extension.
840    pub async fn linter_diagnostics(&self, path: &Path) -> Vec<DiagnosticInfo> {
841        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
842
843        // Determine which linters apply based on file extension
844        let linter_names: Vec<String> = if let Some(settings) = &self.lsp_settings {
845            settings
846                .linters
847                .iter()
848                .filter(|(_, entry)| {
849                    entry.enabled
850                        && (entry.file_extensions.iter().any(|e| e == ext)
851                            || entry.file_extensions.is_empty())
852                })
853                .map(|(name, _)| name.clone())
854                .collect()
855        } else {
856            // Auto-detect: try known linters whose extensions match
857            let mut names = Vec::new();
858            for candidate in &["eslint", "biome", "ruff", "stylelint"] {
859                if linter_extensions(candidate).contains(&ext) {
860                    names.push((*candidate).to_string());
861                }
862            }
863            names
864        };
865
866        let mut all_diagnostics = Vec::new();
867        for name in &linter_names {
868            match self.get_linter_client(name).await {
869                Ok(Some(client)) => match client.diagnostics(path).await {
870                    Ok(LspActionResult::Diagnostics { diagnostics }) => {
871                        all_diagnostics.extend(diagnostics);
872                    }
873                    Ok(_) => {}
874                    Err(e) => {
875                        debug!(linter = %name, error = %e, "Linter diagnostics failed");
876                    }
877                },
878                Ok(None) => {}
879                Err(e) => {
880                    debug!(linter = %name, error = %e, "Failed to get linter client");
881                }
882            }
883        }
884        all_diagnostics
885    }
886}
887
888impl Default for LspManager {
889    fn default() -> Self {
890        Self::new(None)
891    }
892}