Skip to main content

fresh/services/lsp/
manager.rs

1//! LSP Manager - manages multiple language servers using async I/O
2//!
3//! This module provides a manager for multiple LSP servers that:
4//! - Spawns one server per language
5//! - Uses async LspHandle for non-blocking I/O
6//! - Routes requests to appropriate servers
7//! - Configured via config.json
8
9use crate::services::async_bridge::AsyncBridge;
10use crate::services::lsp::async_handler::LspHandle;
11use crate::types::{FeatureFilter, LspFeature, LspServerConfig};
12use lsp_types::{SemanticTokensLegend, Uri};
13use std::collections::HashMap;
14use std::collections::HashSet;
15use std::path::Path;
16use std::time::{Duration, Instant};
17
18/// Consume and discard a `Result` from a fire-and-forget operation.
19///
20/// Use for best-effort cleanup where failure is expected and non-actionable,
21/// e.g. shutting down an LSP server that may have already exited.
22fn fire_and_forget<E: std::fmt::Debug>(result: Result<(), E>) {
23    if let Err(e) = result {
24        tracing::trace!(error = ?e, "fire-and-forget operation failed");
25    }
26}
27
28/// Which languages an LSP server handles.
29///
30/// Empty means the server is universal (accepts all languages).
31/// Non-empty lists only the accepted languages.
32#[derive(Debug, Clone)]
33pub struct LanguageScope(Vec<String>);
34
35impl LanguageScope {
36    /// Universal scope — accepts all languages.
37    pub fn all() -> Self {
38        Self(Vec::new())
39    }
40
41    /// Scope for a single language.
42    pub fn single(language: impl Into<String>) -> Self {
43        Self(vec![language.into()])
44    }
45
46    /// Whether this scope accepts documents of the given language.
47    pub fn accepts(&self, language: &str) -> bool {
48        self.0.is_empty() || self.0.iter().any(|l| l == language)
49    }
50
51    /// Whether this is a universal scope (accepts all languages).
52    pub fn is_universal(&self) -> bool {
53        self.0.is_empty()
54    }
55
56    /// The language list. Empty means all.
57    pub fn languages(&self) -> &[String] {
58        &self.0
59    }
60
61    /// A display label for logging and status messages.
62    pub fn label(&self) -> &str {
63        self.0.first().map(|s| s.as_str()).unwrap_or("universal")
64    }
65}
66
67/// Result of attempting to spawn an LSP server
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum LspSpawnResult {
70    /// Server was spawned or already running
71    Spawned,
72    /// Server is not configured for auto-start
73    /// The server can still be started manually via command palette
74    NotAutoStart,
75    /// No LSP server is configured for this language
76    NotConfigured,
77    /// Server spawn failed or is disabled
78    Failed,
79}
80
81/// Constants for restart behavior
82const MAX_RESTARTS_IN_WINDOW: usize = 5;
83const RESTART_WINDOW_SECS: u64 = 180; // 3 minutes
84const RESTART_BACKOFF_BASE_MS: u64 = 1000; // 1s, 2s, 4s, 8s...
85
86/// Convert a directory path to an LSP `file://` URI without the `url` crate.
87fn path_to_uri(path: &Path) -> Option<Uri> {
88    let abs = if path.is_absolute() {
89        path.to_path_buf()
90    } else {
91        std::env::current_dir().ok()?.join(path)
92    };
93    // Percent-encode each path component for RFC 3986 compliance
94    let encoded: String = abs
95        .components()
96        .filter_map(|c| match c {
97            std::path::Component::RootDir => None, // handled by leading '/' in Normal
98            std::path::Component::Normal(s) => {
99                let s = s.to_str()?;
100                let mut out = String::with_capacity(s.len() + 1);
101                out.push('/');
102                for b in s.bytes() {
103                    if b.is_ascii_alphanumeric()
104                        || matches!(
105                            b,
106                            b'-' | b'.'
107                                | b'_'
108                                | b'~'
109                                | b'@'
110                                | b'!'
111                                | b'$'
112                                | b'&'
113                                | b'\''
114                                | b'('
115                                | b')'
116                                | b'+'
117                                | b','
118                                | b';'
119                                | b'='
120                        )
121                    {
122                        out.push(b as char);
123                    } else {
124                        out.push_str(&format!("%{:02X}", b));
125                    }
126                }
127                Some(out)
128            }
129            _ => None,
130        })
131        .collect();
132    format!("file://{}", encoded).parse().ok()
133}
134
135/// Detect workspace root by walking upward from a file looking for marker files/directories.
136///
137/// Returns the first directory containing any of the markers, or the file's parent
138/// directory if no marker is found.
139pub fn detect_workspace_root(file_path: &Path, root_markers: &[String]) -> std::path::PathBuf {
140    let file_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
141
142    if root_markers.is_empty() {
143        return file_dir;
144    }
145
146    let mut dir = Some(file_dir.as_path());
147    while let Some(d) = dir {
148        for marker in root_markers {
149            if d.join(marker).exists() {
150                return d.to_path_buf();
151            }
152        }
153        dir = d.parent();
154    }
155
156    file_dir
157}
158
159/// Summary of capabilities reported by an LSP server during initialization.
160///
161/// This is extracted from `ServerCapabilities` in the `initialize` response
162/// and stored per-server so that requests are only sent to servers that
163/// actually support them. Follows the LSP 3.17 specification.
164///
165#[derive(Debug, Clone, Default)]
166pub struct ServerCapabilitySummary {
167    /// Whether capabilities have been received from the server.
168    /// When false, `has_capability()` defers to the handle's readiness state.
169    pub initialized: bool,
170    pub hover: bool,
171    pub completion: bool,
172    pub completion_resolve: bool,
173    pub completion_trigger_characters: Vec<String>,
174    pub definition: bool,
175    pub references: bool,
176    pub document_formatting: bool,
177    pub document_range_formatting: bool,
178    pub rename: bool,
179    pub signature_help: bool,
180    pub inlay_hints: bool,
181    pub folding_ranges: bool,
182    pub semantic_tokens_full: bool,
183    pub semantic_tokens_full_delta: bool,
184    pub semantic_tokens_range: bool,
185    pub semantic_tokens_legend: Option<SemanticTokensLegend>,
186    pub document_highlight: bool,
187    pub code_action: bool,
188    pub code_action_resolve: bool,
189    pub document_symbols: bool,
190    pub workspace_symbols: bool,
191    pub diagnostics: bool,
192}
193
194/// A named LSP handle with feature filter metadata and per-server capabilities.
195/// Wraps an LspHandle with the server's display name, feature routing filter,
196/// and the capabilities reported by this specific server during initialization.
197pub struct ServerHandle {
198    /// Display name for this server (e.g., "rust-analyzer", "eslint")
199    pub name: String,
200    /// The underlying LSP handle
201    pub handle: LspHandle,
202    /// Feature filter controlling which LSP features this server handles
203    pub feature_filter: FeatureFilter,
204    /// Capabilities reported by this server during initialization.
205    pub capabilities: ServerCapabilitySummary,
206}
207
208impl ServerHandle {
209    /// Check if this server has the actual capability for a feature.
210    ///
211    /// Checks the server's reported capabilities (from the `initialize` response).
212    /// Before initialization completes (capabilities not yet received), returns
213    /// `false` — the main loop must not route feature requests to servers whose
214    /// capabilities are unknown. Callers handle `None` from `handle_for_feature_mut`
215    /// by relying on existing retry mechanisms (render-cycle polling, timer retries,
216    /// or explicit re-requests from the `LspInitialized` handler).
217    pub fn has_capability(&self, feature: LspFeature) -> bool {
218        if !self.capabilities.initialized {
219            return false;
220        }
221        match feature {
222            LspFeature::Hover => self.capabilities.hover,
223            LspFeature::Completion => self.capabilities.completion,
224            LspFeature::Definition => self.capabilities.definition,
225            LspFeature::References => self.capabilities.references,
226            LspFeature::Format => {
227                self.capabilities.document_formatting || self.capabilities.document_range_formatting
228            }
229            LspFeature::Rename => self.capabilities.rename,
230            LspFeature::SignatureHelp => self.capabilities.signature_help,
231            LspFeature::InlayHints => self.capabilities.inlay_hints,
232            LspFeature::FoldingRange => self.capabilities.folding_ranges,
233            LspFeature::SemanticTokens => {
234                self.capabilities.semantic_tokens_full || self.capabilities.semantic_tokens_range
235            }
236            LspFeature::DocumentHighlight => self.capabilities.document_highlight,
237            LspFeature::CodeAction => self.capabilities.code_action,
238            LspFeature::DocumentSymbols => self.capabilities.document_symbols,
239            LspFeature::WorkspaceSymbols => self.capabilities.workspace_symbols,
240            LspFeature::Diagnostics => self.capabilities.diagnostics,
241        }
242    }
243}
244
245/// Manager for multiple language servers (async version)
246pub struct LspManager {
247    /// All running LSP server handles. Each handle's `LanguageScope` determines
248    /// which languages it serves. Universal servers have `LanguageScope::all()`.
249    handles: Vec<ServerHandle>,
250
251    /// Configuration for each language (supports multiple servers per language)
252    config: HashMap<String, Vec<LspServerConfig>>,
253
254    /// Universal (global) LSP server configs — spawned once per project.
255    universal_configs: Vec<LspServerConfig>,
256
257    /// Default root URI for workspace (used if no per-language root is set)
258    root_uri: Option<Uri>,
259
260    /// Per-language root URIs (allows plugins to specify project roots)
261    per_language_root_uris: HashMap<String, Uri>,
262
263    /// Tokio runtime reference
264    runtime: Option<tokio::runtime::Handle>,
265
266    /// Async bridge for communication
267    async_bridge: Option<AsyncBridge>,
268
269    /// Restart attempt timestamps per language (for tracking restart frequency)
270    restart_attempts: HashMap<String, Vec<Instant>>,
271
272    /// Languages currently in restart cooldown (gave up after too many restarts)
273    restart_cooldown: HashSet<String>,
274
275    /// Scheduled restart times (language -> when to restart)
276    pending_restarts: HashMap<String, Instant>,
277
278    /// Languages that have been manually started by the user
279    /// If a language is in this set, it will spawn even if auto_start=false in config
280    allowed_languages: HashSet<String>,
281
282    /// Languages that have been explicitly disabled/stopped by the user
283    /// These will not auto-restart until user manually restarts them
284    disabled_languages: HashSet<String>,
285}
286
287impl LspManager {
288    /// Create a new LSP manager
289    pub fn new(root_uri: Option<Uri>) -> Self {
290        Self {
291            handles: Vec::new(),
292            config: HashMap::new(),
293            universal_configs: Vec::new(),
294            root_uri,
295            per_language_root_uris: HashMap::new(),
296            runtime: None,
297            async_bridge: None,
298            restart_attempts: HashMap::new(),
299            restart_cooldown: HashSet::new(),
300            pending_restarts: HashMap::new(),
301            allowed_languages: HashSet::new(),
302            disabled_languages: HashSet::new(),
303        }
304    }
305
306    /// Check if a language has been manually enabled (allowing spawn even if auto_start=false)
307    pub fn is_language_allowed(&self, language: &str) -> bool {
308        self.allowed_languages.contains(language)
309    }
310
311    /// Allow a language to spawn LSP server (used by manual start command)
312    pub fn allow_language(&mut self, language: &str) {
313        self.allowed_languages.insert(language.to_string());
314        tracing::info!("LSP language '{}' manually enabled", language);
315    }
316
317    /// Get the set of manually enabled languages
318    pub fn allowed_languages(&self) -> &HashSet<String> {
319        &self.allowed_languages
320    }
321
322    /// Get the configurations for a specific language (one or more servers).
323    pub fn get_configs(&self, language: &str) -> Option<&[LspServerConfig]> {
324        self.config.get(language).map(|v| v.as_slice())
325    }
326
327    /// Get the primary (first) configuration for a specific language.
328    pub fn get_config(&self, language: &str) -> Option<&LspServerConfig> {
329        self.config.get(language).and_then(|v| v.first())
330    }
331
332    /// Store capabilities on the specific server handle identified by server_name.
333    pub fn set_server_capabilities(
334        &mut self,
335        _language: &str,
336        server_name: &str,
337        mut capabilities: ServerCapabilitySummary,
338    ) {
339        capabilities.initialized = true;
340
341        if let Some(sh) = self.handles.iter_mut().find(|sh| sh.name == server_name) {
342            sh.capabilities = capabilities;
343        }
344    }
345
346    /// Get the semantic token legend for a language from the first eligible server.
347    pub fn semantic_tokens_legend(&self, language: &str) -> Option<&SemanticTokensLegend> {
348        self.get_handles(language).into_iter().find_map(|sh| {
349            if sh.feature_filter.allows(LspFeature::SemanticTokens)
350                && sh.has_capability(LspFeature::SemanticTokens)
351            {
352                sh.capabilities.semantic_tokens_legend.as_ref()
353            } else {
354                None
355            }
356        })
357    }
358
359    /// Check if any eligible server for the language supports full semantic tokens.
360    pub fn semantic_tokens_full_supported(&self, language: &str) -> bool {
361        self.get_handles(language).iter().any(|sh| {
362            sh.feature_filter.allows(LspFeature::SemanticTokens)
363                && sh.capabilities.semantic_tokens_full
364        })
365    }
366
367    /// Check if any eligible server for the language supports full semantic token deltas.
368    pub fn semantic_tokens_full_delta_supported(&self, language: &str) -> bool {
369        self.get_handles(language).iter().any(|sh| {
370            sh.feature_filter.allows(LspFeature::SemanticTokens)
371                && sh.capabilities.semantic_tokens_full_delta
372        })
373    }
374
375    /// Check if any eligible server for the language supports range semantic tokens.
376    pub fn semantic_tokens_range_supported(&self, language: &str) -> bool {
377        self.get_handles(language).iter().any(|sh| {
378            sh.feature_filter.allows(LspFeature::SemanticTokens)
379                && sh.capabilities.semantic_tokens_range
380        })
381    }
382
383    /// Check if any eligible server for the language supports folding ranges.
384    pub fn folding_ranges_supported(&self, language: &str) -> bool {
385        self.get_handles(language).iter().any(|sh| {
386            sh.feature_filter.allows(LspFeature::FoldingRange) && sh.capabilities.folding_ranges
387        })
388    }
389
390    /// Check if a character is a completion trigger for any running language server.
391    pub fn is_completion_trigger_char(&self, ch: char, language: &str) -> bool {
392        let ch_str = ch.to_string();
393        self.get_handles(language).iter().any(|sh| {
394            sh.feature_filter.allows(LspFeature::Completion)
395                && sh
396                    .capabilities
397                    .completion_trigger_characters
398                    .contains(&ch_str)
399        })
400    }
401
402    /// Try to spawn an LSP server, checking auto_start configuration
403    ///
404    /// This is the main entry point for spawning LSP servers on file open.
405    /// It returns:
406    /// - `LspSpawnResult::Spawned` if the server was spawned or already running
407    /// - `LspSpawnResult::NotAutoStart` if auto_start is false and not manually allowed
408    /// - `LspSpawnResult::NotConfigured` if no LSP server is configured for the language
409    /// - `LspSpawnResult::Failed` if spawn failed or language is disabled
410    ///
411    /// The `file_path` is used for workspace root detection via `root_markers`.
412    ///
413    /// IMPORTANT: Callers should only call this when there is at least one buffer
414    /// with a matching language. Do not call for languages with no open files.
415    pub fn try_spawn(&mut self, language: &str, file_path: Option<&Path>) -> LspSpawnResult {
416        // If handles already exist for this language, just ensure universals are running too
417        if self
418            .handles
419            .iter()
420            .any(|sh| sh.handle.scope().accepts(language))
421        {
422            self.ensure_universal_servers_running(file_path);
423            return LspSpawnResult::Spawned;
424        }
425
426        // Check if we have runtime and bridge
427        if self.runtime.is_none() || self.async_bridge.is_none() {
428            return LspSpawnResult::Failed;
429        }
430
431        // Always try to start universal servers (they manage their own auto_start check)
432        self.ensure_universal_servers_running(file_path);
433
434        // Check if language is configured
435        let configs = match self.config.get(language) {
436            Some(configs) if !configs.is_empty() => configs,
437            _ => {
438                // No per-language config, but universal servers may be running
439                if self
440                    .handles
441                    .iter()
442                    .any(|sh| sh.handle.scope().is_universal())
443                {
444                    return LspSpawnResult::Spawned;
445                }
446                return LspSpawnResult::NotConfigured;
447            }
448        };
449
450        // Check if any per-language config is enabled
451        if !configs.iter().any(|c| c.enabled) {
452            if self
453                .handles
454                .iter()
455                .any(|sh| sh.handle.scope().is_universal())
456            {
457                return LspSpawnResult::Spawned;
458            }
459            return LspSpawnResult::Failed;
460        }
461
462        // Check if auto_start is enabled (on any per-language config) or language was manually allowed
463        let any_auto_start = configs.iter().any(|c| c.auto_start && c.enabled);
464        if !any_auto_start && !self.allowed_languages.contains(language) {
465            if self
466                .handles
467                .iter()
468                .any(|sh| sh.handle.scope().is_universal())
469            {
470                return LspSpawnResult::Spawned;
471            }
472            return LspSpawnResult::NotAutoStart;
473        }
474
475        // Spawn per-language servers
476        let spawned = self.force_spawn(language, file_path).is_some();
477
478        if spawned
479            || self
480                .handles
481                .iter()
482                .any(|sh| sh.handle.scope().is_universal())
483        {
484            LspSpawnResult::Spawned
485        } else {
486            LspSpawnResult::Failed
487        }
488    }
489
490    /// Set the Tokio runtime and async bridge
491    ///
492    /// Must be called before spawning any servers
493    pub fn set_runtime(&mut self, runtime: tokio::runtime::Handle, async_bridge: AsyncBridge) {
494        self.runtime = Some(runtime);
495        self.async_bridge = Some(async_bridge);
496    }
497
498    /// Set configuration for a language (single server).
499    pub fn set_language_config(&mut self, language: String, config: LspServerConfig) {
500        self.config.insert(language, vec![config]);
501    }
502
503    /// Set configurations for a language (one or more servers).
504    pub fn set_language_configs(&mut self, language: String, configs: Vec<LspServerConfig>) {
505        self.config.insert(language, configs);
506    }
507
508    /// Append additional server configs to an existing language entry.
509    pub fn append_language_configs(&mut self, language: String, configs: Vec<LspServerConfig>) {
510        self.config.entry(language).or_default().extend(configs);
511    }
512
513    /// Set universal (global) LSP server configs.
514    ///
515    /// Universal servers are spawned once per project and shared across all
516    /// languages, rather than being duplicated into each language's config list.
517    pub fn set_universal_configs(&mut self, configs: Vec<LspServerConfig>) {
518        self.universal_configs = configs;
519    }
520
521    /// Return the list of currently configured language keys.
522    pub fn configured_languages(&self) -> Vec<String> {
523        self.config.keys().cloned().collect()
524    }
525
526    /// Set a new root URI for the workspace
527    ///
528    /// This should be called after shutting down all servers when switching projects.
529    /// Servers spawned after this will use the new root URI.
530    pub fn set_root_uri(&mut self, root_uri: Option<Uri>) {
531        self.root_uri = root_uri;
532    }
533
534    /// Set a language-specific root URI
535    ///
536    /// This allows plugins to specify project roots for specific languages.
537    /// For example, a C# plugin can set the root to the directory containing .csproj.
538    /// Returns true if an existing server was restarted with the new root.
539    pub fn set_language_root_uri(&mut self, language: &str, uri: Uri) -> bool {
540        tracing::info!("Setting root URI for {}: {}", language, uri.as_str());
541        self.per_language_root_uris
542            .insert(language.to_string(), uri.clone());
543
544        // If there's an existing server for this language, restart it with the new root
545        if self
546            .handles
547            .iter()
548            .any(|sh| sh.handle.scope().accepts(language))
549        {
550            tracing::info!(
551                "Restarting {} LSP server with new root: {}",
552                language,
553                uri.as_str()
554            );
555            self.shutdown_server(language);
556            // The server will be respawned on next request with the new root
557            return true;
558        }
559        false
560    }
561
562    /// Resolve the root URI for a language, using root_markers for detection.
563    ///
564    /// Priority:
565    /// 1. Plugin-set per-language root (per_language_root_uris)
566    /// 2. Walk upward from file_path using config's root_markers
567    /// 3. File's parent directory
568    pub fn resolve_root_uri(&self, language: &str, file_path: Option<&Path>) -> Option<Uri> {
569        // 1. Plugin-set root takes priority
570        if let Some(uri) = self.per_language_root_uris.get(language) {
571            return Some(uri.clone());
572        }
573
574        // 2. Use root_markers to detect workspace root from file path
575        if let Some(path) = file_path {
576            let markers = self
577                .config
578                .get(language)
579                .and_then(|configs| configs.first())
580                .map(|c| c.root_markers.as_slice())
581                .unwrap_or(&[]);
582            let root = detect_workspace_root(path, markers);
583            if let Some(uri) = path_to_uri(&root) {
584                return Some(uri);
585            }
586        }
587
588        // 3. No file path available — use the global root_uri
589        self.root_uri.clone()
590    }
591
592    /// Get the effective root URI for a language (legacy, without file-based detection)
593    ///
594    /// Returns the language-specific root if set, otherwise the default root.
595    pub fn get_effective_root_uri(&self, language: &str) -> Option<Uri> {
596        self.resolve_root_uri(language, None)
597    }
598
599    /// Reset the manager for a new project
600    ///
601    /// This shuts down all servers and clears state, preparing for a fresh start.
602    /// The configuration is preserved but servers will need to be respawned.
603    pub fn reset_for_new_project(&mut self, new_root_uri: Option<Uri>) {
604        // Shutdown all servers
605        self.shutdown_all();
606
607        // Update root URI
608        self.root_uri = new_root_uri;
609
610        // Clear restart tracking state (fresh start)
611        self.restart_attempts.clear();
612        self.restart_cooldown.clear();
613        self.pending_restarts.clear();
614
615        // Keep allowed_languages and disabled_languages as user preferences
616        // Keep config as it's not project-specific
617
618        tracing::info!(
619            "LSP manager reset for new project: {:?}",
620            self.root_uri.as_ref().map(|u| u.as_str())
621        );
622    }
623
624    /// Get the primary (first) existing LSP handle for a language (no spawning).
625    /// Checks language-specific handles first, then universal handles.
626    pub fn get_handle(&self, language: &str) -> Option<&LspHandle> {
627        self.handles
628            .iter()
629            .find(|sh| sh.handle.scope().accepts(language))
630            .map(|sh| &sh.handle)
631    }
632
633    /// Get the primary (first) mutable existing LSP handle for a language (no spawning).
634    /// Checks language-specific handles first, then universal handles.
635    pub fn get_handle_mut(&mut self, language: &str) -> Option<&mut LspHandle> {
636        self.handles
637            .iter_mut()
638            .find(|sh| sh.handle.scope().accepts(language))
639            .map(|sh| &mut sh.handle)
640    }
641
642    /// Get all handles that accept a language (both language-specific and universal).
643    pub fn get_handles(&self, language: &str) -> Vec<&ServerHandle> {
644        self.handles
645            .iter()
646            .filter(|sh| sh.handle.scope().accepts(language))
647            .collect()
648    }
649
650    /// Get all mutable handles that accept a language (both language-specific and universal).
651    pub fn get_handles_mut(&mut self, language: &str) -> Vec<&mut ServerHandle> {
652        self.handles
653            .iter_mut()
654            .filter(|sh| sh.handle.scope().accepts(language))
655            .collect()
656    }
657
658    /// Get the language scope for a server by name.
659    ///
660    /// Returns `None` if the server is not found.
661    pub fn server_scope(&self, server_name: &str) -> Option<&LanguageScope> {
662        self.handles
663            .iter()
664            .find(|sh| sh.name == server_name)
665            .map(|sh| sh.handle.scope())
666    }
667
668    /// Check if any handles (language-specific or universal) exist for a language.
669    pub fn has_handles(&self, language: &str) -> bool {
670        self.handles
671            .iter()
672            .any(|sh| sh.handle.scope().accepts(language))
673    }
674
675    /// Count all handles that accept a language.
676    pub fn handle_count(&self, language: &str) -> usize {
677        self.handles
678            .iter()
679            .filter(|sh| sh.handle.scope().accepts(language))
680            .count()
681    }
682
683    /// Check if a server with the given name exists.
684    pub fn has_server_named(&self, server_name: &str) -> bool {
685        self.handles.iter().any(|sh| sh.name == server_name)
686    }
687
688    /// Get the first handle for a language that allows a given feature (for exclusive features).
689    /// For capability-gated features (semantic tokens, folding ranges), this also checks
690    /// that the server actually reported the capability during initialization.
691    /// Checks per-language handles first, then universal handles.
692    /// Returns `None` if no handle matches.
693    pub fn handle_for_feature(&self, language: &str, feature: LspFeature) -> Option<&ServerHandle> {
694        self.handles
695            .iter()
696            .filter(|sh| sh.handle.scope().accepts(language))
697            .find(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
698    }
699
700    /// Get the first mutable handle for a language that allows a given feature.
701    /// For capability-gated features, this also checks the server's actual capabilities.
702    /// Checks per-language handles first, then universal handles.
703    pub fn handle_for_feature_mut(
704        &mut self,
705        language: &str,
706        feature: LspFeature,
707    ) -> Option<&mut ServerHandle> {
708        self.handles
709            .iter_mut()
710            .filter(|sh| sh.handle.scope().accepts(language))
711            .find(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
712    }
713
714    /// Get all handles for a language that allow a given feature (for merged features).
715    /// Like `handle_for_feature`, also checks per-server capabilities.
716    /// Includes both per-language and universal handles.
717    pub fn handles_for_feature(&self, language: &str, feature: LspFeature) -> Vec<&ServerHandle> {
718        self.handles
719            .iter()
720            .filter(|sh| sh.handle.scope().accepts(language))
721            .filter(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
722            .collect()
723    }
724
725    /// Get all mutable handles for a language that allow a given feature.
726    /// Like `handle_for_feature_mut`, also checks per-server capabilities.
727    /// Includes both per-language and universal handles.
728    pub fn handles_for_feature_mut(
729        &mut self,
730        language: &str,
731        feature: LspFeature,
732    ) -> Vec<&mut ServerHandle> {
733        self.handles
734            .iter_mut()
735            .filter(|sh| sh.handle.scope().accepts(language))
736            .filter(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
737            .collect()
738    }
739
740    /// Force spawn LSP server(s) for a language.
741    ///
742    /// Spawns servers configured for the language, filtered as follows:
743    /// - If the language is in `allowed_languages` (the user explicitly
744    ///   started or approved this language via a manual command), spawns
745    ///   every configured server regardless of its `enabled` / `auto_start`
746    ///   flags. This is the "manual" path used by the command palette's
747    ///   Start / Restart LSP commands and the LSP confirmation popup.
748    /// - Otherwise (the auto-start path, reached via `try_spawn` on buffer
749    ///   load or by crash recovery), spawns only servers that have both
750    ///   `enabled=true` AND `auto_start=true`. Each config's own
751    ///   `auto_start` flag is honoured individually, so configuring one
752    ///   auto-start server alongside an opt-in manual server no longer
753    ///   drags the manual one along for the ride.
754    ///
755    /// Returns a mutable reference to the primary (first) handle if any
756    /// were spawned. The `file_path` is used for workspace root detection
757    /// via `root_markers`.
758    pub fn force_spawn(
759        &mut self,
760        language: &str,
761        file_path: Option<&Path>,
762    ) -> Option<&mut LspHandle> {
763        tracing::debug!("force_spawn called for language: {}", language);
764
765        // Return existing handle if available
766        if self
767            .handles
768            .iter()
769            .any(|sh| sh.handle.scope().accepts(language))
770        {
771            tracing::debug!("force_spawn: returning existing handle for {}", language);
772            return self
773                .handles
774                .iter_mut()
775                .find(|sh| sh.handle.scope().accepts(language))
776                .map(|sh| &mut sh.handle);
777        }
778
779        // Check if language was explicitly disabled by user (via stop command)
780        if self.disabled_languages.contains(language) {
781            tracing::debug!(
782                "LSP for {} is disabled, not spawning (use manual restart to re-enable)",
783                language
784            );
785            return None;
786        }
787
788        // Get configs for this language
789        let configs = match self.config.get(language) {
790            Some(configs) if !configs.is_empty() => configs.clone(),
791            _ => {
792                tracing::warn!(
793                    "force_spawn: no config found for language '{}', available configs: {:?}",
794                    language,
795                    self.config.keys().collect::<Vec<_>>()
796                );
797                return None;
798            }
799        };
800
801        // Check we have runtime and bridge
802        let runtime = match self.runtime.as_ref() {
803            Some(r) => r.clone(),
804            None => {
805                tracing::error!("force_spawn: no tokio runtime available for {}", language);
806                return None;
807            }
808        };
809        let async_bridge = match self.async_bridge.as_ref() {
810            Some(b) => b.clone(),
811            None => {
812                tracing::error!("force_spawn: no async bridge available for {}", language);
813                return None;
814            }
815        };
816
817        let mut spawned_handles = Vec::new();
818        let manually_allowed = self.allowed_languages.contains(language);
819
820        for config in &configs {
821            if manually_allowed {
822                // User explicitly started this language via command palette:
823                // spawn every configured server, even if individually
824                // disabled or marked not-auto-start.
825            } else {
826                // Auto-start path: only spawn servers that the user has
827                // opted into both via `enabled=true` AND `auto_start=true`.
828                // This honours each config's flags independently so that
829                // e.g. configuring rust-auto (auto_start=true) alongside
830                // rust-manual (auto_start=false) does not spawn both.
831                if !config.enabled || !config.auto_start {
832                    continue;
833                }
834            }
835
836            if config.command.is_empty() {
837                tracing::warn!(
838                    "force_spawn: LSP command is empty for {} server '{}'",
839                    language,
840                    config.display_name()
841                );
842                continue;
843            }
844
845            let server_name = config.display_name();
846            tracing::info!(
847                "Spawning LSP server '{}' for language: {}",
848                server_name,
849                language
850            );
851
852            match LspHandle::spawn(
853                &runtime,
854                &config.command,
855                &config.args,
856                config.env.clone(),
857                LanguageScope::single(language),
858                server_name.clone(),
859                &async_bridge,
860                config.process_limits.clone(),
861                config.language_id_overrides.clone(),
862            ) {
863                Ok(handle) => {
864                    let effective_root = self.resolve_root_uri(language, file_path);
865                    if let Err(e) =
866                        handle.initialize(effective_root, config.initialization_options.clone())
867                    {
868                        tracing::error!(
869                            "Failed to send initialize command for {} ({}): {}",
870                            language,
871                            server_name,
872                            e
873                        );
874                        continue;
875                    }
876
877                    tracing::info!(
878                        "LSP initialization started for {} ({}), will be ready asynchronously",
879                        language,
880                        server_name
881                    );
882
883                    spawned_handles.push(ServerHandle {
884                        name: server_name,
885                        handle,
886                        feature_filter: config.feature_filter(),
887                        capabilities: ServerCapabilitySummary::default(),
888                    });
889                }
890                Err(e) => {
891                    tracing::error!(
892                        "Failed to spawn LSP handle for {} ({}): {}",
893                        language,
894                        server_name,
895                        e
896                    );
897                }
898            }
899        }
900
901        if spawned_handles.is_empty() {
902            return None;
903        }
904
905        self.handles.extend(spawned_handles);
906        self.handles
907            .iter_mut()
908            .rev()
909            .find(|sh| sh.handle.scope().accepts(language))
910            .map(|sh| &mut sh.handle)
911    }
912
913    /// Spawn universal LSP servers if they aren't already running.
914    ///
915    /// Called from `try_spawn` — universal servers are spawned once and shared
916    /// across all languages. Only servers with `enabled=true` and
917    /// `auto_start=true` are started automatically.
918    fn ensure_universal_servers_running(&mut self, file_path: Option<&Path>) {
919        if self
920            .handles
921            .iter()
922            .any(|sh| sh.handle.scope().is_universal())
923            || self.universal_configs.is_empty()
924        {
925            return;
926        }
927
928        let runtime = match self.runtime.as_ref() {
929            Some(r) => r.clone(),
930            None => return,
931        };
932        let async_bridge = match self.async_bridge.as_ref() {
933            Some(b) => b.clone(),
934            None => return,
935        };
936
937        let mut spawned = Vec::new();
938        for config in &self.universal_configs {
939            if !config.enabled || !config.auto_start {
940                continue;
941            }
942            if config.command.is_empty() {
943                continue;
944            }
945
946            let server_name = config.display_name();
947            tracing::info!("Spawning universal LSP server '{}'", server_name);
948
949            match LspHandle::spawn(
950                &runtime,
951                &config.command,
952                &config.args,
953                config.env.clone(),
954                LanguageScope::all(),
955                server_name.clone(),
956                &async_bridge,
957                config.process_limits.clone(),
958                config.language_id_overrides.clone(),
959            ) {
960                Ok(handle) => {
961                    let effective_root = file_path
962                        .map(|p| {
963                            let root = detect_workspace_root(p, &config.root_markers);
964                            path_to_uri(&root)
965                        })
966                        .flatten()
967                        .or_else(|| self.root_uri.clone());
968                    if let Err(e) =
969                        handle.initialize(effective_root, config.initialization_options.clone())
970                    {
971                        tracing::error!(
972                            "Failed to initialize universal LSP server '{}': {}",
973                            server_name,
974                            e
975                        );
976                        continue;
977                    }
978                    tracing::info!(
979                        "Universal LSP server '{}' initialization started",
980                        server_name
981                    );
982                    spawned.push(ServerHandle {
983                        name: server_name,
984                        handle,
985                        feature_filter: config.feature_filter(),
986                        capabilities: ServerCapabilitySummary::default(),
987                    });
988                }
989                Err(e) => {
990                    tracing::error!(
991                        "Failed to spawn universal LSP server '{}': {}",
992                        server_name,
993                        e
994                    );
995                }
996            }
997        }
998
999        self.handles.extend(spawned);
1000    }
1001
1002    /// Handle a server crash by scheduling a restart with exponential backoff
1003    ///
1004    /// Returns a message describing the action taken (for UI notification)
1005    pub fn handle_server_crash(&mut self, language: &str, server_name: &str) -> String {
1006        // Check if the crashed server is a universal handle
1007        if self
1008            .handles
1009            .iter()
1010            .any(|sh| sh.name == server_name && sh.handle.scope().is_universal())
1011        {
1012            // Drain all universal handles and shut them down
1013            let universals: Vec<ServerHandle> = {
1014                let mut drained = Vec::new();
1015                let mut i = 0;
1016                while i < self.handles.len() {
1017                    if self.handles[i].handle.scope().is_universal() {
1018                        drained.push(self.handles.remove(i));
1019                    } else {
1020                        i += 1;
1021                    }
1022                }
1023                drained
1024            };
1025            for sh in universals {
1026                fire_and_forget(sh.handle.shutdown());
1027            }
1028            // Universal servers will be re-spawned on next try_spawn call
1029            return "Universal LSP server crashed. It will restart on next file open.".to_string();
1030        }
1031
1032        // Remove all handles that accept this language (but not universal ones)
1033        {
1034            let mut i = 0;
1035            while i < self.handles.len() {
1036                if !self.handles[i].handle.scope().is_universal()
1037                    && self.handles[i].handle.scope().accepts(language)
1038                {
1039                    let sh = self.handles.remove(i);
1040                    fire_and_forget(sh.handle.shutdown());
1041                } else {
1042                    i += 1;
1043                }
1044            }
1045        }
1046
1047        // Check if server was explicitly disabled by user (via stop command)
1048        // Don't auto-restart disabled servers
1049        if self.disabled_languages.contains(language) {
1050            return format!(
1051                "LSP server for {} stopped. Use 'Restart LSP Server' command to start it again.",
1052                language
1053            );
1054        }
1055
1056        // Check if we're in cooldown
1057        if self.restart_cooldown.contains(language) {
1058            return format!(
1059                "LSP server for {} crashed. Too many restarts - use 'Restart LSP Server' command to retry.",
1060                language
1061            );
1062        }
1063
1064        // Clean up old restart attempts outside the window
1065        let now = Instant::now();
1066        let window = Duration::from_secs(RESTART_WINDOW_SECS);
1067        let attempts = self
1068            .restart_attempts
1069            .entry(language.to_string())
1070            .or_default();
1071        attempts.retain(|t| now.duration_since(*t) < window);
1072
1073        // Check if we've exceeded max restarts
1074        if attempts.len() >= MAX_RESTARTS_IN_WINDOW {
1075            self.restart_cooldown.insert(language.to_string());
1076            tracing::warn!(
1077                "LSP server for {} has crashed {} times in {} minutes, entering cooldown",
1078                language,
1079                MAX_RESTARTS_IN_WINDOW,
1080                RESTART_WINDOW_SECS / 60
1081            );
1082            return format!(
1083                "LSP server for {} has crashed too many times ({} in {} min). Use 'Restart LSP Server' command to manually restart.",
1084                language,
1085                MAX_RESTARTS_IN_WINDOW,
1086                RESTART_WINDOW_SECS / 60
1087            );
1088        }
1089
1090        // Calculate exponential backoff delay
1091        let attempt_number = attempts.len();
1092        let delay_ms = RESTART_BACKOFF_BASE_MS * (1 << attempt_number); // 1s, 2s, 4s, 8s
1093        let restart_time = now + Duration::from_millis(delay_ms);
1094
1095        // Schedule the restart
1096        self.pending_restarts
1097            .insert(language.to_string(), restart_time);
1098
1099        tracing::info!(
1100            "LSP server for {} crashed (attempt {}/{}), will restart in {}ms",
1101            language,
1102            attempt_number + 1,
1103            MAX_RESTARTS_IN_WINDOW,
1104            delay_ms
1105        );
1106
1107        format!(
1108            "LSP server for {} crashed (attempt {}/{}), restarting in {}s...",
1109            language,
1110            attempt_number + 1,
1111            MAX_RESTARTS_IN_WINDOW,
1112            delay_ms / 1000
1113        )
1114    }
1115
1116    /// Check and process any pending restarts that are due
1117    ///
1118    /// Returns list of (language, success, message) for each restart attempted
1119    pub fn process_pending_restarts(&mut self) -> Vec<(String, bool, String)> {
1120        let now = Instant::now();
1121        let mut results = Vec::new();
1122
1123        // Find restarts that are due
1124        let due_restarts: Vec<String> = self
1125            .pending_restarts
1126            .iter()
1127            .filter(|(_, time)| **time <= now)
1128            .map(|(lang, _)| lang.clone())
1129            .collect();
1130
1131        for language in due_restarts {
1132            self.pending_restarts.remove(&language);
1133
1134            // Record this restart attempt
1135            self.restart_attempts
1136                .entry(language.clone())
1137                .or_default()
1138                .push(now);
1139
1140            // Attempt to spawn the server (bypassing auto_start for crash recovery)
1141            if self.force_spawn(&language, None).is_some() {
1142                let message = format!("LSP server for {} restarted successfully", language);
1143                tracing::info!("{}", message);
1144                results.push((language, true, message));
1145            } else {
1146                let message = format!("Failed to restart LSP server for {}", language);
1147                tracing::error!("{}", message);
1148                results.push((language, false, message));
1149            }
1150        }
1151
1152        results
1153    }
1154
1155    /// Check if a language server is in restart cooldown
1156    pub fn is_in_cooldown(&self, language: &str) -> bool {
1157        self.restart_cooldown.contains(language)
1158    }
1159
1160    /// Check if a language server has a pending restart
1161    pub fn has_pending_restart(&self, language: &str) -> bool {
1162        self.pending_restarts.contains_key(language)
1163    }
1164
1165    /// Clear cooldown for a language and allow manual restart
1166    pub fn clear_cooldown(&mut self, language: &str) {
1167        self.restart_cooldown.remove(language);
1168        self.restart_attempts.remove(language);
1169        self.pending_restarts.remove(language);
1170        tracing::info!("Cleared restart cooldown for {}", language);
1171    }
1172
1173    /// Manually restart/start a language server (bypasses cooldown and auto_start check)
1174    ///
1175    /// This is used both to restart a crashed server and to manually start a server
1176    /// that has auto_start=false in its configuration.
1177    ///
1178    /// Returns (success, message) tuple
1179    pub fn manual_restart(&mut self, language: &str, file_path: Option<&Path>) -> (bool, String) {
1180        // Clear any existing state
1181        self.clear_cooldown(language);
1182
1183        // Re-enable the language (remove from disabled set)
1184        self.disabled_languages.remove(language);
1185
1186        // Add to allowed languages so it stays active even if auto_start=false
1187        self.allowed_languages.insert(language.to_string());
1188
1189        // Remove existing handles for this language (non-universal)
1190        {
1191            let mut i = 0;
1192            while i < self.handles.len() {
1193                if !self.handles[i].handle.scope().is_universal()
1194                    && self.handles[i].handle.scope().accepts(language)
1195                {
1196                    let sh = self.handles.remove(i);
1197                    fire_and_forget(sh.handle.shutdown());
1198                } else {
1199                    i += 1;
1200                }
1201            }
1202        }
1203
1204        // Spawn new server (bypassing auto_start for user-initiated restart)
1205        if self.force_spawn(language, file_path).is_some() {
1206            let message = format!("LSP server for {} started", language);
1207            tracing::info!("{}", message);
1208            (true, message)
1209        } else {
1210            let message = format!("Failed to start LSP server for {}", language);
1211            tracing::error!("{}", message);
1212            (false, message)
1213        }
1214    }
1215
1216    /// Restart a single server by name for a specific language.
1217    ///
1218    /// Shuts down just that server and re-spawns it from config.
1219    /// Returns (success, message) tuple.
1220    pub fn manual_restart_server(
1221        &mut self,
1222        language: &str,
1223        server_name: &str,
1224        file_path: Option<&Path>,
1225    ) -> (bool, String) {
1226        self.clear_cooldown(language);
1227        self.disabled_languages.remove(language);
1228        self.allowed_languages.insert(language.to_string());
1229
1230        // Find and shut down just the named server
1231        if let Some(idx) = self.handles.iter().position(|sh| sh.name == server_name) {
1232            let sh = self.handles.remove(idx);
1233            fire_and_forget(sh.handle.shutdown());
1234        }
1235
1236        // Find the matching config (check per-language first, then universal)
1237        let is_universal = self
1238            .universal_configs
1239            .iter()
1240            .any(|c| c.display_name() == server_name);
1241        let config = if is_universal {
1242            self.universal_configs
1243                .iter()
1244                .find(|c| c.display_name() == server_name)
1245                .cloned()
1246        } else {
1247            self.config
1248                .get(language)
1249                .and_then(|configs| configs.iter().find(|c| c.display_name() == server_name))
1250                .cloned()
1251        };
1252
1253        let Some(config) = config else {
1254            let message = format!(
1255                "No config found for server '{}' ({})",
1256                server_name, language
1257            );
1258            tracing::error!("{}", message);
1259            return (false, message);
1260        };
1261
1262        if config.command.is_empty() {
1263            let message = format!(
1264                "LSP command is empty for {} server '{}'",
1265                language, server_name
1266            );
1267            tracing::error!("{}", message);
1268            return (false, message);
1269        }
1270
1271        let runtime = match self.runtime.as_ref() {
1272            Some(r) => r.clone(),
1273            None => return (false, "No tokio runtime available".to_string()),
1274        };
1275        let async_bridge = match self.async_bridge.as_ref() {
1276            Some(b) => b.clone(),
1277            None => return (false, "No async bridge available".to_string()),
1278        };
1279
1280        let scope = if is_universal {
1281            LanguageScope::all()
1282        } else {
1283            LanguageScope::single(language)
1284        };
1285
1286        match LspHandle::spawn(
1287            &runtime,
1288            &config.command,
1289            &config.args,
1290            config.env.clone(),
1291            scope,
1292            server_name.to_string(),
1293            &async_bridge,
1294            config.process_limits.clone(),
1295            config.language_id_overrides.clone(),
1296        ) {
1297            Ok(handle) => {
1298                let effective_root = if is_universal {
1299                    file_path
1300                        .map(|p| {
1301                            let root = detect_workspace_root(p, &config.root_markers);
1302                            path_to_uri(&root)
1303                        })
1304                        .flatten()
1305                        .or_else(|| self.root_uri.clone())
1306                } else {
1307                    self.resolve_root_uri(language, file_path)
1308                };
1309                if let Err(e) =
1310                    handle.initialize(effective_root, config.initialization_options.clone())
1311                {
1312                    let message = format!(
1313                        "Failed to initialize LSP server '{}' for {}: {}",
1314                        server_name, language, e
1315                    );
1316                    tracing::error!("{}", message);
1317                    return (false, message);
1318                }
1319
1320                let sh = ServerHandle {
1321                    name: server_name.to_string(),
1322                    handle,
1323                    feature_filter: config.feature_filter(),
1324                    capabilities: ServerCapabilitySummary::default(),
1325                };
1326
1327                self.handles.push(sh);
1328
1329                let message = format!("LSP server '{}' for {} started", server_name, language);
1330                tracing::info!("{}", message);
1331                (true, message)
1332            }
1333            Err(e) => {
1334                let message = format!(
1335                    "Failed to start LSP server '{}' for {}: {}",
1336                    server_name, language, e
1337                );
1338                tracing::error!("{}", message);
1339                (false, message)
1340            }
1341        }
1342    }
1343
1344    /// Get the number of recent restart attempts for a language
1345    pub fn restart_attempt_count(&self, language: &str) -> usize {
1346        let now = Instant::now();
1347        let window = Duration::from_secs(RESTART_WINDOW_SECS);
1348        self.restart_attempts
1349            .get(language)
1350            .map(|attempts| {
1351                attempts
1352                    .iter()
1353                    .filter(|t| now.duration_since(**t) < window)
1354                    .count()
1355            })
1356            .unwrap_or(0)
1357    }
1358
1359    /// Get a list of currently running LSP server language labels (deduplicated).
1360    pub fn running_servers(&self) -> Vec<String> {
1361        let mut labels: Vec<String> = self
1362            .handles
1363            .iter()
1364            .map(|sh| sh.handle.scope().label().to_string())
1365            .collect();
1366        labels.sort();
1367        labels.dedup();
1368        labels
1369    }
1370
1371    /// Get the names of all running servers for a given language
1372    pub fn server_names_for_language(&self, language: &str) -> Vec<String> {
1373        self.handles
1374            .iter()
1375            .filter(|sh| sh.handle.scope().accepts(language))
1376            .map(|sh| sh.name.clone())
1377            .collect()
1378    }
1379
1380    /// Check if any LSP server for a language is running and ready to serve requests
1381    pub fn is_server_ready(&self, language: &str) -> bool {
1382        self.handles
1383            .iter()
1384            .filter(|sh| sh.handle.scope().accepts(language))
1385            .any(|sh| sh.handle.state().can_send_requests())
1386    }
1387
1388    /// Shutdown a single server by name for a specific language.
1389    ///
1390    /// Returns true if the server was found and shut down.
1391    /// If this was the last server for the language, marks the language as disabled.
1392    pub fn shutdown_server_by_name(&mut self, language: &str, server_name: &str) -> bool {
1393        let Some(idx) = self.handles.iter().position(|sh| sh.name == server_name) else {
1394            tracing::warn!(
1395                "No running LSP server named '{}' found for {}",
1396                server_name,
1397                language
1398            );
1399            return false;
1400        };
1401
1402        let sh = self.handles.remove(idx);
1403        tracing::info!(
1404            "Shutting down LSP server '{}' for {} (disabled until manual restart)",
1405            sh.name,
1406            language
1407        );
1408        fire_and_forget(sh.handle.shutdown());
1409
1410        // If no more non-universal handles remain for this language, mark it disabled
1411        let has_remaining = self
1412            .handles
1413            .iter()
1414            .any(|sh| !sh.handle.scope().is_universal() && sh.handle.scope().accepts(language));
1415        if !has_remaining {
1416            self.disabled_languages.insert(language.to_string());
1417            self.pending_restarts.remove(language);
1418            self.restart_cooldown.remove(language);
1419            self.allowed_languages.remove(language);
1420        }
1421
1422        true
1423    }
1424
1425    /// Shutdown all servers for a specific language.
1426    ///
1427    /// This marks the language as disabled, preventing auto-restart until the user
1428    /// explicitly restarts it using the restart command.
1429    pub fn shutdown_server(&mut self, language: &str) -> bool {
1430        let mut found = false;
1431        let mut i = 0;
1432        while i < self.handles.len() {
1433            if !self.handles[i].handle.scope().is_universal()
1434                && self.handles[i].handle.scope().accepts(language)
1435            {
1436                let sh = self.handles.remove(i);
1437                tracing::info!(
1438                    "Shutting down LSP server '{}' for {} (disabled until manual restart)",
1439                    sh.name,
1440                    language
1441                );
1442                fire_and_forget(sh.handle.shutdown());
1443                found = true;
1444            } else {
1445                i += 1;
1446            }
1447        }
1448
1449        if found {
1450            self.disabled_languages.insert(language.to_string());
1451            self.pending_restarts.remove(language);
1452            self.restart_cooldown.remove(language);
1453            self.allowed_languages.remove(language);
1454        } else {
1455            tracing::warn!("No running LSP server found for {}", language);
1456        }
1457
1458        found
1459    }
1460
1461    /// Shutdown all language servers (including universal servers)
1462    pub fn shutdown_all(&mut self) {
1463        for sh in &self.handles {
1464            tracing::info!(
1465                "Shutting down LSP server '{}' ({})",
1466                sh.name,
1467                sh.handle.scope().label()
1468            );
1469            fire_and_forget(sh.handle.shutdown());
1470        }
1471        self.handles.clear();
1472    }
1473}
1474
1475impl Drop for LspManager {
1476    fn drop(&mut self) {
1477        self.shutdown_all();
1478    }
1479}
1480
1481/// Helper function to detect language from file path using the config's languages section.
1482///
1483/// Priority order matches `GrammarRegistry::find_by_path`:
1484/// 1. Exact filename match against `filenames` (highest priority)
1485/// 2. Glob pattern match against `filenames` entries containing wildcards
1486/// 3. File extension match against `extensions` (lowest config-based priority)
1487///
1488/// Kept separate from `find_by_path` because this returns the user's
1489/// config **key** (`[languages.mylang]` → `"mylang"`) rather than the
1490/// catalog entry's `language_id`, which is needed for LSP routing when a
1491/// user aliases an existing grammar.
1492pub fn detect_language(
1493    path: &std::path::Path,
1494    languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
1495) -> Option<String> {
1496    let detected = detect_language_by_config(path, languages);
1497
1498    // `.h` headers: the default config maps the extension to C, but in C++
1499    // projects the header is still C++ and must route to clangd in C++ mode.
1500    // If the detected language is `c`, the file is `.h`, and the surrounding
1501    // tree smells like C++ (sibling C++ sources or an ancestor
1502    // `compile_commands.json`), promote to `cpp` so the LSP binding is right.
1503    if detected.as_deref() == Some("c")
1504        && path.extension().and_then(|e| e.to_str()) == Some("h")
1505        && languages.contains_key("cpp")
1506        && header_in_cpp_tree(path)
1507    {
1508        return Some("cpp".to_string());
1509    }
1510
1511    detected
1512}
1513
1514/// Pure config/path-based language detection without filesystem probing.
1515fn detect_language_by_config(
1516    path: &std::path::Path,
1517    languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
1518) -> Option<String> {
1519    use crate::primitives::glob_match::{
1520        filename_glob_matches, is_glob_pattern, is_path_pattern, path_glob_matches,
1521    };
1522
1523    if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
1524        // 1. Exact filename match (highest priority)
1525        for (language_name, lang_config) in languages {
1526            if lang_config
1527                .filenames
1528                .iter()
1529                .any(|f| !is_glob_pattern(f) && f == filename)
1530            {
1531                return Some(language_name.clone());
1532            }
1533        }
1534
1535        // 2. Glob pattern match
1536        // Path patterns (containing `/`) match against the full path;
1537        // filename-only patterns match against just the filename.
1538        let path_str = path.to_str().unwrap_or("");
1539        for (language_name, lang_config) in languages {
1540            if lang_config.filenames.iter().any(|f| {
1541                if !is_glob_pattern(f) {
1542                    return false;
1543                }
1544                if is_path_pattern(f) {
1545                    path_glob_matches(f, path_str)
1546                } else {
1547                    filename_glob_matches(f, filename)
1548                }
1549            }) {
1550                return Some(language_name.clone());
1551            }
1552        }
1553    }
1554
1555    // 3. Extension match (lowest priority among config-based detection)
1556    if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
1557        for (language_name, lang_config) in languages {
1558            if lang_config.extensions.iter().any(|ext| ext == extension) {
1559                return Some(language_name.clone());
1560            }
1561        }
1562    }
1563
1564    None
1565}
1566
1567/// Filesystem probe: does this header sit inside something that looks like
1568/// a C++ project? Two signals, both conservative:
1569///
1570///   * The file's own directory contains any C++ source or C++-specific
1571///     header (`.cc`, `.cpp`, `.cxx`, `.C`, `.c++`, `.hpp`, `.hh`, `.hxx`).
1572///     Decisive — if the siblings are C++, the header is too.
1573///   * An ancestor up to 10 levels deep contains a `compile_commands.json`
1574///     whose content carries a C++ marker. The mere presence of the file
1575///     is not enough: CMake emits `compile_commands.json` for pure-C
1576///     builds as well, so we peek inside and only promote when the
1577///     payload mentions a C++-specific compiler, flag, or source
1578///     extension (`c++`, `.cpp`, `.cc`, `.cxx`, `.C` ). This still covers
1579///     the fmt / Chromium / LLVM / Qt-style layouts where the header
1580///     lives deep under `include/` while sources sit in `src/` at the
1581///     project root.
1582///
1583/// Bounded by depth (10), by a single shallow `read_dir` at the start,
1584/// and by a capped 1 MiB read of `compile_commands.json`, so the cost is
1585/// a handful of `stat`s plus at most one bounded read on file open.
1586/// Silent on any I/O error — if we can't see the filesystem we fall back
1587/// to the default config answer (C), which is the pre-fix behavior.
1588///
1589/// NOTE(remote-fs): Uses `std::fs` directly, matching the pre-existing
1590/// `detect_workspace_root` in this module. On SSH sessions the probe
1591/// sees the local filesystem, so the promotion silently becomes a no-op
1592/// (returns `false`, falls back to `c`). Fixing this requires threading
1593/// `&dyn FileSystem` through `detect_language` and
1594/// `DetectedLanguage::from_path` — a cross-cutting refactor that should
1595/// be done alongside the same fix for `detect_workspace_root`.
1596fn header_in_cpp_tree(path: &std::path::Path) -> bool {
1597    let Some(start_dir) = path.parent() else {
1598        return false;
1599    };
1600
1601    // 1. Sibling scan in the header's own directory.
1602    if let Ok(entries) = std::fs::read_dir(start_dir) {
1603        for entry in entries.flatten() {
1604            let p = entry.path();
1605            let Some(ext) = p.extension().and_then(|e| e.to_str()) else {
1606                continue;
1607            };
1608            if matches!(
1609                ext,
1610                "cc" | "cpp" | "cxx" | "C" | "c++" | "hpp" | "hh" | "hxx"
1611            ) {
1612                return true;
1613            }
1614        }
1615    }
1616
1617    // 2. Walk ancestors for compile_commands.json, and only promote if
1618    //    the file actually carries a C++ marker — CMake emits it for
1619    //    pure-C builds too.
1620    let mut current = Some(start_dir);
1621    let mut depth = 0u32;
1622    while let Some(dir) = current {
1623        let cc = dir.join("compile_commands.json");
1624        if cc.is_file() && compile_commands_has_cpp_marker(&cc) {
1625            return true;
1626        }
1627        if depth >= 10 {
1628            break;
1629        }
1630        depth += 1;
1631        current = dir.parent();
1632    }
1633
1634    false
1635}
1636
1637/// Returns true when `compile_commands.json` contains a C++ marker —
1638/// either the literal substring `c++` (covers `-std=c++17`, `clang++`,
1639/// `g++`, the `c++` compiler name) or a C++ source extension in a
1640/// context where it cannot be confused with an adjacent header path
1641/// (`.cpp`, `.cc`, `.cxx`). Reads at most 1 MiB so multi-megabyte
1642/// compile DBs from large monorepos don't block file open; a valid CMake
1643/// entry fits comfortably in that window.
1644fn compile_commands_has_cpp_marker(path: &std::path::Path) -> bool {
1645    use std::io::Read;
1646    const MAX_READ: u64 = 1_048_576;
1647
1648    let Ok(file) = std::fs::File::open(path) else {
1649        return false;
1650    };
1651    let mut buf = Vec::with_capacity(64 * 1024);
1652    if file.take(MAX_READ).read_to_end(&mut buf).is_err() {
1653        return false;
1654    }
1655    let Ok(text) = std::str::from_utf8(&buf) else {
1656        return false;
1657    };
1658
1659    // Strongest single marker: literal "c++" appears in -std=c++NN,
1660    // clang++, g++, and the "c++" compiler name — never in a pure-C
1661    // compilation invocation.
1662    if text.contains("c++") {
1663        return true;
1664    }
1665    // Secondary markers: any mention of a C++ source extension in the
1666    // compile DB implies at least one C++ translation unit in the tree.
1667    text.contains(".cpp") || text.contains(".cxx") || text.contains(".cc\"")
1668}
1669
1670#[cfg(test)]
1671mod tests {
1672    use super::*;
1673    use std::path::Path;
1674
1675    #[test]
1676    fn test_lsp_manager_new() {
1677        let root_uri: Option<Uri> = "file:///test".parse().ok();
1678        let manager = LspManager::new(root_uri.clone());
1679
1680        // Manager should start with no handles
1681        assert_eq!(manager.handles.len(), 0);
1682        assert_eq!(manager.config.len(), 0);
1683        assert!(manager.root_uri.is_some());
1684        assert!(manager.runtime.is_none());
1685        assert!(manager.async_bridge.is_none());
1686    }
1687
1688    #[test]
1689    fn test_lsp_manager_set_language_config() {
1690        let mut manager = LspManager::new(None);
1691
1692        let config = LspServerConfig {
1693            enabled: true,
1694            command: "rust-analyzer".to_string(),
1695            args: vec![],
1696            process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
1697            auto_start: false,
1698            initialization_options: None,
1699            env: Default::default(),
1700            language_id_overrides: Default::default(),
1701            name: None,
1702            only_features: None,
1703            except_features: None,
1704            root_markers: Default::default(),
1705        };
1706
1707        manager.set_language_config("rust".to_string(), config);
1708
1709        assert_eq!(manager.config.len(), 1);
1710        assert!(manager.config.contains_key("rust"));
1711        assert!(manager.config.get("rust").unwrap().first().unwrap().enabled);
1712    }
1713
1714    #[test]
1715    fn test_lsp_manager_force_spawn_no_runtime() {
1716        let mut manager = LspManager::new(None);
1717
1718        // Add config for rust
1719        manager.set_language_config(
1720            "rust".to_string(),
1721            LspServerConfig {
1722                enabled: true,
1723                command: "rust-analyzer".to_string(),
1724                args: vec![],
1725                process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
1726                auto_start: false,
1727                initialization_options: None,
1728                env: Default::default(),
1729                language_id_overrides: Default::default(),
1730                name: None,
1731                only_features: None,
1732                except_features: None,
1733                root_markers: Default::default(),
1734            },
1735        );
1736
1737        // force_spawn should return None without runtime
1738        let result = manager.force_spawn("rust", None);
1739        assert!(result.is_none());
1740    }
1741
1742    #[test]
1743    fn test_lsp_manager_force_spawn_no_config() {
1744        let rt = tokio::runtime::Runtime::new().unwrap();
1745        let mut manager = LspManager::new(None);
1746        let async_bridge = AsyncBridge::new();
1747
1748        manager.set_runtime(rt.handle().clone(), async_bridge);
1749
1750        // force_spawn should return None for unconfigured language
1751        let result = manager.force_spawn("rust", None);
1752        assert!(result.is_none());
1753    }
1754
1755    #[test]
1756    fn test_lsp_manager_force_spawn_disabled_language() {
1757        let rt = tokio::runtime::Runtime::new().unwrap();
1758        let mut manager = LspManager::new(None);
1759        let async_bridge = AsyncBridge::new();
1760
1761        manager.set_runtime(rt.handle().clone(), async_bridge);
1762
1763        // Add disabled config (command is optional when disabled)
1764        manager.set_language_config(
1765            "rust".to_string(),
1766            LspServerConfig {
1767                enabled: false,
1768                command: String::new(), // command not required when disabled
1769                args: vec![],
1770                process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
1771                auto_start: false,
1772                initialization_options: None,
1773                env: Default::default(),
1774                language_id_overrides: Default::default(),
1775                name: None,
1776                only_features: None,
1777                except_features: None,
1778                root_markers: Default::default(),
1779            },
1780        );
1781
1782        // force_spawn should return None for disabled language
1783        let result = manager.force_spawn("rust", None);
1784        assert!(result.is_none());
1785    }
1786
1787    #[test]
1788    fn test_lsp_manager_shutdown_all() {
1789        let mut manager = LspManager::new(None);
1790
1791        // shutdown_all should not panic even with no handles
1792        manager.shutdown_all();
1793        assert_eq!(manager.handles.len(), 0);
1794    }
1795
1796    fn test_languages() -> std::collections::HashMap<String, crate::config::LanguageConfig> {
1797        let mut languages = std::collections::HashMap::new();
1798        languages.insert(
1799            "rust".to_string(),
1800            crate::config::LanguageConfig {
1801                extensions: vec!["rs".to_string()],
1802                filenames: vec![],
1803                grammar: "rust".to_string(),
1804                comment_prefix: Some("//".to_string()),
1805                auto_indent: true,
1806                auto_close: None,
1807                auto_surround: None,
1808                textmate_grammar: None,
1809                show_whitespace_tabs: false,
1810                line_wrap: None,
1811                wrap_column: None,
1812                page_view: None,
1813                page_width: None,
1814                use_tabs: None,
1815                tab_size: None,
1816                formatter: None,
1817                format_on_save: false,
1818                on_save: vec![],
1819                word_characters: None,
1820            },
1821        );
1822        languages.insert(
1823            "javascript".to_string(),
1824            crate::config::LanguageConfig {
1825                extensions: vec!["js".to_string(), "jsx".to_string()],
1826                filenames: vec![],
1827                grammar: "javascript".to_string(),
1828                comment_prefix: Some("//".to_string()),
1829                auto_indent: true,
1830                auto_close: None,
1831                auto_surround: None,
1832                textmate_grammar: None,
1833                show_whitespace_tabs: false,
1834                line_wrap: None,
1835                wrap_column: None,
1836                page_view: None,
1837                page_width: None,
1838                use_tabs: None,
1839                tab_size: None,
1840                formatter: None,
1841                format_on_save: false,
1842                on_save: vec![],
1843                word_characters: None,
1844            },
1845        );
1846        languages.insert(
1847            "csharp".to_string(),
1848            crate::config::LanguageConfig {
1849                extensions: vec!["cs".to_string()],
1850                filenames: vec![],
1851                grammar: "c_sharp".to_string(),
1852                comment_prefix: Some("//".to_string()),
1853                auto_indent: true,
1854                auto_close: None,
1855                auto_surround: None,
1856                textmate_grammar: None,
1857                show_whitespace_tabs: false,
1858                line_wrap: None,
1859                wrap_column: None,
1860                page_view: None,
1861                page_width: None,
1862                use_tabs: None,
1863                tab_size: None,
1864                formatter: None,
1865                format_on_save: false,
1866                on_save: vec![],
1867                word_characters: None,
1868            },
1869        );
1870        languages
1871    }
1872
1873    #[test]
1874    fn test_detect_language_from_config() {
1875        let languages = test_languages();
1876
1877        // Test configured languages
1878        assert_eq!(
1879            detect_language(Path::new("main.rs"), &languages),
1880            Some("rust".to_string())
1881        );
1882        assert_eq!(
1883            detect_language(Path::new("index.js"), &languages),
1884            Some("javascript".to_string())
1885        );
1886        assert_eq!(
1887            detect_language(Path::new("App.jsx"), &languages),
1888            Some("javascript".to_string())
1889        );
1890        assert_eq!(
1891            detect_language(Path::new("Program.cs"), &languages),
1892            Some("csharp".to_string())
1893        );
1894
1895        // Test unconfigured extensions return None
1896        assert_eq!(detect_language(Path::new("main.py"), &languages), None);
1897        assert_eq!(detect_language(Path::new("file.xyz"), &languages), None);
1898        assert_eq!(detect_language(Path::new("file"), &languages), None);
1899    }
1900
1901    #[test]
1902    fn test_detect_language_no_extension() {
1903        let languages = test_languages();
1904        assert_eq!(detect_language(Path::new("README"), &languages), None);
1905        assert_eq!(detect_language(Path::new("Makefile"), &languages), None);
1906    }
1907
1908    #[test]
1909    fn test_detect_language_path_glob() {
1910        let mut languages = test_languages();
1911        languages.insert(
1912            "shell".to_string(),
1913            crate::config::LanguageConfig {
1914                extensions: vec!["sh".to_string()],
1915                filenames: vec!["/etc/**/rc.*".to_string(), "*rc".to_string()],
1916                grammar: "bash".to_string(),
1917                comment_prefix: Some("#".to_string()),
1918                auto_indent: true,
1919                auto_close: None,
1920                auto_surround: None,
1921                textmate_grammar: None,
1922                show_whitespace_tabs: false,
1923                line_wrap: None,
1924                wrap_column: None,
1925                page_view: None,
1926                page_width: None,
1927                use_tabs: None,
1928                tab_size: None,
1929                formatter: None,
1930                format_on_save: false,
1931                on_save: vec![],
1932                word_characters: None,
1933            },
1934        );
1935
1936        // Path glob: /etc/**/rc.* should match
1937        assert_eq!(
1938            detect_language(Path::new("/etc/rc.conf"), &languages),
1939            Some("shell".to_string())
1940        );
1941        assert_eq!(
1942            detect_language(Path::new("/etc/init/rc.local"), &languages),
1943            Some("shell".to_string())
1944        );
1945        // Path glob should NOT match different root
1946        assert_eq!(detect_language(Path::new("/var/rc.conf"), &languages), None);
1947
1948        // Filename glob: *rc should still work
1949        assert_eq!(
1950            detect_language(Path::new("lfrc"), &languages),
1951            Some("shell".to_string())
1952        );
1953    }
1954
1955    #[test]
1956    fn test_detect_workspace_root_finds_marker_in_parent() {
1957        let tmp = tempfile::tempdir().unwrap();
1958        let project = tmp.path().join("myproject");
1959        let src = project.join("src");
1960        std::fs::create_dir_all(&src).unwrap();
1961        std::fs::write(project.join("Cargo.toml"), "").unwrap();
1962        let file = src.join("main.rs");
1963        std::fs::write(&file, "").unwrap();
1964
1965        let root = detect_workspace_root(&file, &["Cargo.toml".to_string(), ".git".to_string()]);
1966        assert_eq!(root, project);
1967    }
1968
1969    #[test]
1970    fn test_detect_workspace_root_finds_marker_two_levels_up() {
1971        let tmp = tempfile::tempdir().unwrap();
1972        let project = tmp.path().join("myproject");
1973        let deep = project.join("src").join("nested");
1974        std::fs::create_dir_all(&deep).unwrap();
1975        std::fs::write(project.join("Cargo.toml"), "").unwrap();
1976        let file = deep.join("lib.rs");
1977        std::fs::write(&file, "").unwrap();
1978
1979        let root = detect_workspace_root(&file, &["Cargo.toml".to_string()]);
1980        assert_eq!(root, project);
1981    }
1982
1983    #[test]
1984    fn test_detect_workspace_root_no_marker_returns_parent() {
1985        let tmp = tempfile::tempdir().unwrap();
1986        let dir = tmp.path().join("somedir");
1987        std::fs::create_dir_all(&dir).unwrap();
1988        let file = dir.join("file.txt");
1989        std::fs::write(&file, "").unwrap();
1990
1991        let root = detect_workspace_root(&file, &["nonexistent_marker".to_string()]);
1992        assert_eq!(root, dir);
1993    }
1994
1995    #[test]
1996    fn test_detect_workspace_root_empty_markers_returns_parent() {
1997        let tmp = tempfile::tempdir().unwrap();
1998        let dir = tmp.path().join("somedir");
1999        std::fs::create_dir_all(&dir).unwrap();
2000        let file = dir.join("file.txt");
2001        std::fs::write(&file, "").unwrap();
2002
2003        let root = detect_workspace_root(&file, &[]);
2004        assert_eq!(root, dir);
2005    }
2006
2007    #[test]
2008    fn test_detect_workspace_root_directory_marker() {
2009        let tmp = tempfile::tempdir().unwrap();
2010        let project = tmp.path().join("myproject");
2011        let src = project.join("src");
2012        std::fs::create_dir_all(&src).unwrap();
2013        std::fs::create_dir_all(project.join(".git")).unwrap();
2014        let file = src.join("main.rs");
2015        std::fs::write(&file, "").unwrap();
2016
2017        let root = detect_workspace_root(&file, &[".git".to_string()]);
2018        assert_eq!(root, project);
2019    }
2020
2021    /// Returns a languages map mirroring the default config's `c` + `cpp`
2022    /// entries: `.h` maps to `c`, and `.cpp/.cc/.cxx/.hpp/.hh/.hxx` map to
2023    /// `cpp`. Matches `config.rs:3010` and `:3040-3047` so the promotion
2024    /// logic is exercised under realistic config.
2025    fn c_cpp_languages() -> std::collections::HashMap<String, crate::config::LanguageConfig> {
2026        use crate::config::LanguageConfig;
2027        let mut languages = std::collections::HashMap::new();
2028        let base = LanguageConfig {
2029            extensions: vec![],
2030            filenames: vec![],
2031            grammar: String::new(),
2032            comment_prefix: Some("//".to_string()),
2033            auto_indent: true,
2034            auto_close: None,
2035            auto_surround: None,
2036            textmate_grammar: None,
2037            show_whitespace_tabs: false,
2038            line_wrap: None,
2039            wrap_column: None,
2040            page_view: None,
2041            page_width: None,
2042            use_tabs: None,
2043            tab_size: None,
2044            formatter: None,
2045            format_on_save: false,
2046            on_save: vec![],
2047            word_characters: None,
2048        };
2049        languages.insert(
2050            "c".to_string(),
2051            LanguageConfig {
2052                extensions: vec!["c".to_string(), "h".to_string()],
2053                grammar: "c".to_string(),
2054                ..base.clone()
2055            },
2056        );
2057        languages.insert(
2058            "cpp".to_string(),
2059            LanguageConfig {
2060                extensions: vec![
2061                    "cpp".to_string(),
2062                    "cc".to_string(),
2063                    "cxx".to_string(),
2064                    "hpp".to_string(),
2065                    "hh".to_string(),
2066                    "hxx".to_string(),
2067                ],
2068                grammar: "cpp".to_string(),
2069                ..base
2070            },
2071        );
2072        languages
2073    }
2074
2075    #[test]
2076    fn test_detect_language_h_stays_c_without_cpp_signals() {
2077        // No filesystem context — plain `Path::new("foo.h")` doesn't exist,
2078        // so sibling scan + compile_commands walk both return false and the
2079        // default-config answer (`c`) survives.
2080        let languages = c_cpp_languages();
2081        assert_eq!(
2082            detect_language(Path::new("foo.h"), &languages),
2083            Some("c".to_string())
2084        );
2085    }
2086
2087    #[test]
2088    fn test_detect_language_h_promotes_to_cpp_with_sibling_cpp_source() {
2089        let tmp = tempfile::tempdir().unwrap();
2090        let project = tmp.path().join("proj");
2091        std::fs::create_dir_all(&project).unwrap();
2092        let header = project.join("widget.h");
2093        std::fs::write(&header, "").unwrap();
2094        // Sibling .cpp source — the decisive C++ signal.
2095        std::fs::write(project.join("widget.cpp"), "").unwrap();
2096
2097        let languages = c_cpp_languages();
2098        assert_eq!(
2099            detect_language(&header, &languages),
2100            Some("cpp".to_string())
2101        );
2102    }
2103
2104    #[test]
2105    fn test_detect_language_h_promotes_to_cpp_with_sibling_hpp() {
2106        let tmp = tempfile::tempdir().unwrap();
2107        let project = tmp.path().join("proj");
2108        std::fs::create_dir_all(&project).unwrap();
2109        let header = project.join("a.h");
2110        std::fs::write(&header, "").unwrap();
2111        // A `.hpp` sibling is also a C++-specific signal.
2112        std::fs::write(project.join("b.hpp"), "").unwrap();
2113
2114        let languages = c_cpp_languages();
2115        assert_eq!(
2116            detect_language(&header, &languages),
2117            Some("cpp".to_string())
2118        );
2119    }
2120
2121    #[test]
2122    fn test_detect_language_h_promotes_to_cpp_with_ancestor_compile_commands() {
2123        let tmp = tempfile::tempdir().unwrap();
2124        let project = tmp.path().join("proj");
2125        let include = project.join("include").join("fmt");
2126        std::fs::create_dir_all(&include).unwrap();
2127        // Compile DB two levels above the header — the fmt-style layout.
2128        // Realistic CMake output: the compile command references clang++
2129        // and a C++ source, which is the C++ marker we key on.
2130        std::fs::write(
2131            project.join("compile_commands.json"),
2132            r#"[{"directory":"/proj","command":"/usr/bin/clang++ -std=c++17 -c src/format.cc","file":"src/format.cc"}]"#,
2133        ).unwrap();
2134        let header = include.join("format.h");
2135        std::fs::write(&header, "").unwrap();
2136
2137        let languages = c_cpp_languages();
2138        assert_eq!(
2139            detect_language(&header, &languages),
2140            Some("cpp".to_string())
2141        );
2142    }
2143
2144    #[test]
2145    fn test_detect_language_h_stays_c_with_pure_c_compile_commands() {
2146        // A compile_commands.json generated for a pure-C project (gcc,
2147        // -std=c11, .c sources only) must NOT promote .h to cpp.
2148        let tmp = tempfile::tempdir().unwrap();
2149        let project = tmp.path().join("cproj");
2150        let include = project.join("include");
2151        std::fs::create_dir_all(&include).unwrap();
2152        std::fs::write(
2153            project.join("compile_commands.json"),
2154            r#"[{"directory":"/cproj","command":"/usr/bin/gcc -std=c11 -c src/lib.c","file":"src/lib.c"}]"#,
2155        )
2156        .unwrap();
2157        let header = include.join("lib.h");
2158        std::fs::write(&header, "").unwrap();
2159
2160        let languages = c_cpp_languages();
2161        assert_eq!(detect_language(&header, &languages), Some("c".to_string()));
2162    }
2163
2164    #[test]
2165    fn test_detect_language_h_stays_c_in_pure_c_tree() {
2166        let tmp = tempfile::tempdir().unwrap();
2167        let project = tmp.path().join("cproj");
2168        std::fs::create_dir_all(&project).unwrap();
2169        let header = project.join("lib.h");
2170        std::fs::write(&header, "").unwrap();
2171        // Only `.c` siblings — no C++ signal, no compile_commands.json.
2172        std::fs::write(project.join("lib.c"), "").unwrap();
2173
2174        let languages = c_cpp_languages();
2175        assert_eq!(detect_language(&header, &languages), Some("c".to_string()));
2176    }
2177
2178    #[test]
2179    fn test_detect_language_h_stays_c_with_empty_compile_commands() {
2180        // Empty / minimal compile_commands.json carries no C++ marker,
2181        // so we stay conservative and leave the header as C.
2182        let tmp = tempfile::tempdir().unwrap();
2183        let project = tmp.path().join("proj");
2184        std::fs::create_dir_all(&project).unwrap();
2185        std::fs::write(project.join("compile_commands.json"), "[]").unwrap();
2186        let header = project.join("foo.h");
2187        std::fs::write(&header, "").unwrap();
2188
2189        let languages = c_cpp_languages();
2190        assert_eq!(detect_language(&header, &languages), Some("c".to_string()));
2191    }
2192
2193    #[test]
2194    fn test_detect_language_h_promotes_on_cpp_std_flag_alone() {
2195        // `-std=c++20` with no other C++ extension is still conclusive.
2196        let tmp = tempfile::tempdir().unwrap();
2197        let project = tmp.path().join("proj");
2198        let include = project.join("include");
2199        std::fs::create_dir_all(&include).unwrap();
2200        std::fs::write(
2201            project.join("compile_commands.json"),
2202            // A contrived entry using the `.C` (capital) source extension
2203            // with the c++20 flag — tests that the "c++" substring alone
2204            // is sufficient even when our `.cpp/.cc/.cxx` scan would miss.
2205            r#"[{"directory":"/proj","command":"/usr/bin/clang -std=c++20 -c src/x.C","file":"src/x.C"}]"#,
2206        )
2207        .unwrap();
2208        let header = include.join("x.h");
2209        std::fs::write(&header, "").unwrap();
2210
2211        let languages = c_cpp_languages();
2212        assert_eq!(
2213            detect_language(&header, &languages),
2214            Some("cpp".to_string())
2215        );
2216    }
2217
2218    #[test]
2219    fn test_detect_language_c_source_never_promoted() {
2220        // `.c` files should stay `c` even in a C++ tree.
2221        let tmp = tempfile::tempdir().unwrap();
2222        let project = tmp.path().join("proj");
2223        std::fs::create_dir_all(&project).unwrap();
2224        let source = project.join("legacy.c");
2225        std::fs::write(&source, "").unwrap();
2226        std::fs::write(project.join("main.cpp"), "").unwrap();
2227
2228        let languages = c_cpp_languages();
2229        assert_eq!(detect_language(&source, &languages), Some("c".to_string()));
2230    }
2231
2232    #[test]
2233    fn test_detect_language_h_no_promotion_without_cpp_config() {
2234        // If the user hasn't configured `cpp`, we have nowhere to promote to
2235        // — stay with the base detection rather than inventing a language.
2236        let tmp = tempfile::tempdir().unwrap();
2237        let project = tmp.path().join("proj");
2238        std::fs::create_dir_all(&project).unwrap();
2239        let header = project.join("widget.h");
2240        std::fs::write(&header, "").unwrap();
2241        std::fs::write(project.join("widget.cpp"), "").unwrap();
2242
2243        let mut languages = c_cpp_languages();
2244        languages.remove("cpp");
2245        assert_eq!(detect_language(&header, &languages), Some("c".to_string()));
2246    }
2247
2248    #[test]
2249    fn test_path_to_uri_basic() {
2250        let uri = path_to_uri(Path::new("/tmp/test")).unwrap();
2251        assert_eq!(uri.as_str(), "file:///tmp/test");
2252    }
2253
2254    #[test]
2255    fn test_path_to_uri_with_spaces() {
2256        let uri = path_to_uri(Path::new("/tmp/my project/src")).unwrap();
2257        assert_eq!(uri.as_str(), "file:///tmp/my%20project/src");
2258    }
2259}