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    /// Every configured server for this language has `enabled: false`.
78    /// This is a deliberate user opt-out, not a failure — callers
79    /// should stay silent (or log at debug level) rather than warning.
80    Disabled,
81    /// Server spawn failed (missing runtime, cooldown, or spawn error).
82    Failed,
83}
84
85/// Constants for restart behavior
86const MAX_RESTARTS_IN_WINDOW: usize = 5;
87const RESTART_WINDOW_SECS: u64 = 180; // 3 minutes
88const RESTART_BACKOFF_BASE_MS: u64 = 1000; // 1s, 2s, 4s, 8s...
89
90/// Outcome of consulting the spawn gate for a language.
91///
92/// The gate is the single throttle point for process spawns — every
93/// path that ultimately forks an LSP child (user activity via
94/// `try_spawn` → `force_spawn`, scheduled restarts via
95/// `process_pending_restarts`, crash recovery, manual restarts) goes
96/// through it. Previously, only `handle_server_crash` tracked restart
97/// attempts, which meant a fast-crashing server respawned on every
98/// edit via `force_spawn` and flooded the log (see #1612).
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100enum SpawnDecision {
101    /// A handle already exists — return the existing one, don't spawn.
102    Existing,
103    /// Spawn is permitted; the attempt has been recorded.
104    Allow,
105    /// A restart is already scheduled via exponential backoff — do
106    /// not double-spawn. The scheduled restart will fire later.
107    PendingBackoff,
108    /// The language hit the crash cap and is in cooldown until the
109    /// user manually re-enables it.
110    CooledDown,
111}
112
113/// Convert a directory path to an LSP `file://` URI without the `url` crate.
114fn path_to_uri(path: &Path) -> Option<Uri> {
115    let abs = if path.is_absolute() {
116        path.to_path_buf()
117    } else {
118        std::env::current_dir().ok()?.join(path)
119    };
120    // Percent-encode each path component for RFC 3986 compliance
121    let encoded: String = abs
122        .components()
123        .filter_map(|c| match c {
124            std::path::Component::RootDir => None, // handled by leading '/' in Normal
125            std::path::Component::Normal(s) => {
126                let s = s.to_str()?;
127                let mut out = String::with_capacity(s.len() + 1);
128                out.push('/');
129                for b in s.bytes() {
130                    if b.is_ascii_alphanumeric()
131                        || matches!(
132                            b,
133                            b'-' | b'.'
134                                | b'_'
135                                | b'~'
136                                | b'@'
137                                | b'!'
138                                | b'$'
139                                | b'&'
140                                | b'\''
141                                | b'('
142                                | b')'
143                                | b'+'
144                                | b','
145                                | b';'
146                                | b'='
147                        )
148                    {
149                        out.push(b as char);
150                    } else {
151                        out.push_str(&format!("%{:02X}", b));
152                    }
153                }
154                Some(out)
155            }
156            _ => None,
157        })
158        .collect();
159    format!("file://{}", encoded).parse().ok()
160}
161
162/// Detect workspace root by walking upward from a file looking for marker files/directories.
163///
164/// Returns the first directory containing any of the markers, or the file's parent
165/// directory if no marker is found.
166pub fn detect_workspace_root(file_path: &Path, root_markers: &[String]) -> std::path::PathBuf {
167    let file_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
168
169    if root_markers.is_empty() {
170        return file_dir;
171    }
172
173    let mut dir = Some(file_dir.as_path());
174    while let Some(d) = dir {
175        for marker in root_markers {
176            if d.join(marker).exists() {
177                return d.to_path_buf();
178            }
179        }
180        dir = d.parent();
181    }
182
183    file_dir
184}
185
186/// Summary of capabilities reported by an LSP server during initialization.
187///
188/// This is extracted from `ServerCapabilities` in the `initialize` response
189/// and stored per-server so that requests are only sent to servers that
190/// actually support them. Follows the LSP 3.17 specification.
191///
192#[derive(Debug, Clone, Default)]
193pub struct ServerCapabilitySummary {
194    /// Whether capabilities have been received from the server.
195    /// When false, `has_capability()` defers to the handle's readiness state.
196    pub initialized: bool,
197    pub hover: bool,
198    pub completion: bool,
199    pub completion_resolve: bool,
200    pub completion_trigger_characters: Vec<String>,
201    pub definition: bool,
202    pub implementation: bool,
203    pub references: bool,
204    pub document_formatting: bool,
205    pub document_range_formatting: bool,
206    pub rename: bool,
207    pub signature_help: bool,
208    pub inlay_hints: bool,
209    pub folding_ranges: bool,
210    pub semantic_tokens_full: bool,
211    pub semantic_tokens_full_delta: bool,
212    pub semantic_tokens_range: bool,
213    pub semantic_tokens_legend: Option<SemanticTokensLegend>,
214    pub document_highlight: bool,
215    pub code_action: bool,
216    pub code_action_resolve: bool,
217    pub document_symbols: bool,
218    pub workspace_symbols: bool,
219    pub diagnostics: bool,
220}
221
222impl ServerCapabilitySummary {
223    /// Apply a single dynamic capability registration
224    /// (`client/registerCapability`) or unregistration
225    /// (`client/unregisterCapability`) by toggling the matching capability
226    /// flag. `register == false` clears the flag (and any derived state such
227    /// as completion trigger characters or the semantic-tokens legend).
228    ///
229    /// Many servers advertise little or nothing in their `initialize` result
230    /// and register providers dynamically afterwards; without this the feature
231    /// stays gated off (`has_capability` → false) for the whole session.
232    ///
233    /// Returns `true` if `method` is one we recognize and gate a feature on,
234    /// so the caller knows whether to re-issue requests for already-open
235    /// buffers. Unknown methods (e.g. `workspace/didChangeWatchedFiles`, which
236    /// is handled separately) return `false`.
237    pub fn apply_dynamic_registration(
238        &mut self,
239        method: &str,
240        register_options: Option<&serde_json::Value>,
241        register: bool,
242    ) -> bool {
243        use lsp_types::SemanticTokensFullOptions;
244
245        match method {
246            "textDocument/hover" => self.hover = register,
247            "textDocument/completion" => {
248                self.completion = register;
249                if register {
250                    if let Some(opts) = register_options {
251                        if let Some(chars) =
252                            opts.get("triggerCharacters").and_then(|v| v.as_array())
253                        {
254                            self.completion_trigger_characters = chars
255                                .iter()
256                                .filter_map(|v| v.as_str().map(str::to_string))
257                                .collect();
258                        }
259                        if let Some(resolve) = opts
260                            .get("resolveProvider")
261                            .and_then(serde_json::Value::as_bool)
262                        {
263                            self.completion_resolve = resolve;
264                        }
265                    }
266                } else {
267                    self.completion_trigger_characters.clear();
268                    self.completion_resolve = false;
269                }
270            }
271            "textDocument/definition" => self.definition = register,
272            "textDocument/implementation" => self.implementation = register,
273            "textDocument/references" => self.references = register,
274            "textDocument/formatting" => self.document_formatting = register,
275            "textDocument/rangeFormatting" => self.document_range_formatting = register,
276            "textDocument/rename" => self.rename = register,
277            "textDocument/signatureHelp" => self.signature_help = register,
278            "textDocument/inlayHint" => self.inlay_hints = register,
279            "textDocument/foldingRange" => self.folding_ranges = register,
280            "textDocument/documentHighlight" => self.document_highlight = register,
281            "textDocument/codeAction" => {
282                self.code_action = register;
283                if register {
284                    if let Some(resolve) = register_options
285                        .and_then(|opts| opts.get("resolveProvider"))
286                        .and_then(serde_json::Value::as_bool)
287                    {
288                        self.code_action_resolve = resolve;
289                    }
290                } else {
291                    self.code_action_resolve = false;
292                }
293            }
294            "textDocument/documentSymbol" => self.document_symbols = register,
295            "workspace/symbol" => self.workspace_symbols = register,
296            "textDocument/diagnostic" => self.diagnostics = register,
297            "textDocument/semanticTokens" => {
298                if register {
299                    // Registration options carry the legend and full/range
300                    // flags. They are `SemanticTokensRegistrationOptions`, but
301                    // its extra fields (documentSelector, id) are ignored by
302                    // serde, so parsing the embedded `SemanticTokensOptions`
303                    // succeeds.
304                    match register_options.and_then(|opts| {
305                        serde_json::from_value::<lsp_types::SemanticTokensOptions>(opts.clone())
306                            .ok()
307                    }) {
308                        Some(opts) => {
309                            self.semantic_tokens_legend = Some(opts.legend);
310                            match opts.full {
311                                Some(SemanticTokensFullOptions::Bool(v)) => {
312                                    self.semantic_tokens_full = v;
313                                    self.semantic_tokens_full_delta = false;
314                                }
315                                Some(SemanticTokensFullOptions::Delta { delta }) => {
316                                    self.semantic_tokens_full = true;
317                                    self.semantic_tokens_full_delta = delta.unwrap_or(false);
318                                }
319                                None => {
320                                    self.semantic_tokens_full = false;
321                                    self.semantic_tokens_full_delta = false;
322                                }
323                            }
324                            self.semantic_tokens_range = opts.range.unwrap_or(false);
325                        }
326                        // No parseable options: assume full support so the
327                        // feature isn't silently dropped, but a legend is
328                        // required to decode tokens, so leave it as-is.
329                        None => self.semantic_tokens_full = true,
330                    }
331                } else {
332                    self.semantic_tokens_full = false;
333                    self.semantic_tokens_full_delta = false;
334                    self.semantic_tokens_range = false;
335                    self.semantic_tokens_legend = None;
336                }
337            }
338            _ => return false,
339        }
340        true
341    }
342}
343
344/// A named LSP handle with feature filter metadata and per-server capabilities.
345/// Wraps an LspHandle with the server's display name, feature routing filter,
346/// and the capabilities reported by this specific server during initialization.
347pub struct ServerHandle {
348    /// Display name for this server (e.g., "rust-analyzer", "eslint")
349    pub name: String,
350    /// The underlying LSP handle
351    pub handle: LspHandle,
352    /// Feature filter controlling which LSP features this server handles
353    pub feature_filter: FeatureFilter,
354    /// Capabilities reported by this server during initialization.
355    pub capabilities: ServerCapabilitySummary,
356}
357
358impl ServerHandle {
359    /// Check if this server has the actual capability for a feature.
360    ///
361    /// Checks the server's reported capabilities (from the `initialize` response).
362    /// Before initialization completes (capabilities not yet received), returns
363    /// `false` — the main loop must not route feature requests to servers whose
364    /// capabilities are unknown. Callers handle `None` from `handle_for_feature_mut`
365    /// by relying on existing retry mechanisms (render-cycle polling, timer retries,
366    /// or explicit re-requests from the `LspInitialized` handler).
367    pub fn has_capability(&self, feature: LspFeature) -> bool {
368        if !self.capabilities.initialized {
369            return false;
370        }
371        match feature {
372            LspFeature::Hover => self.capabilities.hover,
373            LspFeature::Completion => self.capabilities.completion,
374            LspFeature::Definition => self.capabilities.definition,
375            LspFeature::Implementation => self.capabilities.implementation,
376            LspFeature::References => self.capabilities.references,
377            LspFeature::Format => {
378                self.capabilities.document_formatting || self.capabilities.document_range_formatting
379            }
380            LspFeature::Rename => self.capabilities.rename,
381            LspFeature::SignatureHelp => self.capabilities.signature_help,
382            LspFeature::InlayHints => self.capabilities.inlay_hints,
383            LspFeature::FoldingRange => self.capabilities.folding_ranges,
384            LspFeature::SemanticTokens => {
385                self.capabilities.semantic_tokens_full || self.capabilities.semantic_tokens_range
386            }
387            LspFeature::DocumentHighlight => self.capabilities.document_highlight,
388            LspFeature::CodeAction => self.capabilities.code_action,
389            LspFeature::DocumentSymbols => self.capabilities.document_symbols,
390            LspFeature::WorkspaceSymbols => self.capabilities.workspace_symbols,
391            LspFeature::Diagnostics => self.capabilities.diagnostics,
392        }
393    }
394}
395
396/// Manager for multiple language servers (async version)
397pub struct LspManager {
398    /// Window that owns this manager. Set at construction time (one
399    /// `LspManager` per `Window`). Threaded into every async response
400    /// the manager's tasks emit so the editor's dispatcher can route
401    /// LSP responses back to the right window's pending-request maps
402    /// without a global registry.
403    window_id: fresh_core::WindowId,
404
405    /// All running LSP server handles. Each handle's `LanguageScope` determines
406    /// which languages it serves. Universal servers have `LanguageScope::all()`.
407    handles: Vec<ServerHandle>,
408
409    /// Configuration for each language (supports multiple servers per language)
410    config: HashMap<String, Vec<LspServerConfig>>,
411
412    /// Universal (global) LSP server configs — spawned once per project.
413    universal_configs: Vec<LspServerConfig>,
414
415    /// Default root URI for workspace (used if no per-language root is set)
416    root_uri: Option<Uri>,
417
418    /// Per-language root URIs (allows plugins to specify project roots)
419    per_language_root_uris: HashMap<String, Uri>,
420
421    /// Tokio runtime reference
422    runtime: Option<tokio::runtime::Handle>,
423
424    /// Async bridge for communication
425    async_bridge: Option<AsyncBridge>,
426
427    /// Long-running stdio spawner from the active authority. Used by
428    /// `force_spawn` and friends to route LSP child processes through
429    /// the right backend (local Command, `docker exec -i`, SSH). Set
430    /// by `set_long_running_spawner` from `Editor::set_boot_authority`
431    /// before any LSP spawn can happen.
432    long_running_spawner: Option<std::sync::Arc<dyn crate::services::remote::LongRunningSpawner>>,
433
434    /// Active authority's Workspace Trust handle. LSP servers load and execute
435    /// project-controlled code at startup (analyzers/source-generators for C#,
436    /// `build.rs`/proc-macros for Rust, `compile_commands` for clangd, …), so
437    /// auto-start is gated on this: an untrusted workspace doesn't auto-start
438    /// servers. `None` (tests / not yet wired) means "allow".
439    workspace_trust: Option<std::sync::Arc<crate::services::workspace_trust::WorkspaceTrust>>,
440
441    /// Active authority's host↔remote workspace mapping. When set
442    /// (devcontainer attach), [`Self::resolve_root_uri`] applies it to
443    /// the marker-walked workspace root so an in-container LSP sees
444    /// `file:///workspaces/proj` rather than the on-host temp path.
445    path_translation: Option<crate::services::authority::PathTranslation>,
446
447    /// Restart attempt timestamps per language (for tracking restart frequency)
448    restart_attempts: HashMap<String, Vec<Instant>>,
449
450    /// Languages currently in restart cooldown (gave up after too many restarts)
451    restart_cooldown: HashSet<String>,
452
453    /// Scheduled restart times (language -> when to restart)
454    pending_restarts: HashMap<String, Instant>,
455
456    /// Languages that have been manually started by the user
457    /// If a language is in this set, it will spawn even if auto_start=false in config
458    allowed_languages: HashSet<String>,
459
460    /// Languages that have been explicitly disabled/stopped by the user
461    /// These will not auto-restart until user manually restarts them
462    disabled_languages: HashSet<String>,
463
464    /// Master switch mirroring the top-level `lsp_enabled` config field.
465    /// When false, `try_spawn` refuses to auto-start any server (per-language
466    /// and universal alike). Manual starts (`allow_language` + `force_spawn`)
467    /// still work — an explicit user action overrides the global opt-out,
468    /// matching how manual start already overrides per-server `enabled=false`.
469    globally_enabled: bool,
470}
471
472impl LspManager {
473    /// Window that owns this manager.
474    pub fn window_id(&self) -> fresh_core::WindowId {
475        self.window_id
476    }
477
478    /// Create a new LSP manager owned by the given window.
479    pub fn new(window_id: fresh_core::WindowId, root_uri: Option<Uri>) -> Self {
480        Self {
481            window_id,
482            handles: Vec::new(),
483            config: HashMap::new(),
484            universal_configs: Vec::new(),
485            root_uri,
486            per_language_root_uris: HashMap::new(),
487            runtime: None,
488            async_bridge: None,
489            long_running_spawner: None,
490            workspace_trust: None,
491            path_translation: None,
492            restart_attempts: HashMap::new(),
493            restart_cooldown: HashSet::new(),
494            pending_restarts: HashMap::new(),
495            allowed_languages: HashSet::new(),
496            disabled_languages: HashSet::new(),
497            globally_enabled: true,
498        }
499    }
500
501    /// Mirror the top-level `lsp_enabled` config field. When false, no
502    /// server auto-starts for any language (see `try_spawn`).
503    pub fn set_globally_enabled(&mut self, enabled: bool) {
504        self.globally_enabled = enabled;
505    }
506
507    /// Wire the long-running spawner from the active `Authority`.
508    ///
509    /// Called from `Editor::set_boot_authority` so every LSP server
510    /// spawned after this point runs under the right backend — local
511    /// host, `docker exec -i` for containers, or SSH-tunneled.
512    /// Authority transitions destroy and rebuild the editor (and
513    /// therefore `LspManager`), so this is a one-shot wiring call per
514    /// editor instance.
515    pub fn set_long_running_spawner(
516        &mut self,
517        spawner: std::sync::Arc<dyn crate::services::remote::LongRunningSpawner>,
518    ) {
519        self.long_running_spawner = Some(spawner);
520    }
521
522    /// Install the active authority's Workspace Trust handle. Called from
523    /// `set_boot_authority` alongside the spawner setter so trust gating is in
524    /// place before any LSP auto-start.
525    pub fn set_workspace_trust(
526        &mut self,
527        trust: std::sync::Arc<crate::services::workspace_trust::WorkspaceTrust>,
528    ) {
529        self.workspace_trust = Some(trust);
530    }
531
532    /// Whether LSP servers may auto-start: only in a Trusted workspace (or
533    /// when trust isn't wired, e.g. tests). Untrusted workspaces don't
534    /// auto-start servers because starting one runs project-controlled code.
535    fn lsp_autostart_allowed(&self) -> bool {
536        use crate::services::workspace_trust::TrustLevel;
537        self.workspace_trust
538            .as_ref()
539            .map(|t| t.level() == TrustLevel::Trusted)
540            .unwrap_or(true)
541    }
542
543    /// Install the active authority's host↔remote path mapping. The
544    /// editor calls this from `set_boot_authority` alongside the
545    /// spawner setter so URI translation is in place before any LSP
546    /// spawns under the new authority.
547    pub fn set_path_translation(
548        &mut self,
549        translation: Option<crate::services::authority::PathTranslation>,
550    ) {
551        self.path_translation = translation;
552    }
553
554    /// Blocking variant of the authority-routed command probe used by
555    /// the LSP status popup (which runs on the main thread and needs a
556    /// synchronous answer). Blocks on the tokio runtime to drive the
557    /// async trait method; the local spawner resolves immediately via
558    /// `which::which`, the docker spawner runs a short
559    /// `docker exec <id> sh -c 'command -v <cmd>'`.
560    ///
561    /// Falls back to the module-level host probe when the spawner or
562    /// runtime hasn't been wired yet (e.g. during early editor boot
563    /// before `set_boot_authority` runs). The fallback is only
564    /// reachable in test harnesses and a vanishingly small window
565    /// around startup, so routing through the authority is the
566    /// effective behavior in production.
567    pub fn command_exists_via_authority(&self, command: &str) -> bool {
568        if command.is_empty() {
569            return false;
570        }
571        let (Some(runtime), Some(spawner)) =
572            (self.runtime.as_ref(), self.long_running_spawner.as_ref())
573        else {
574            return crate::services::lsp::command_exists(command);
575        };
576        runtime.block_on(spawner.command_exists(command))
577    }
578
579    /// Check if a language has been manually enabled (allowing spawn even if auto_start=false)
580    pub fn is_language_allowed(&self, language: &str) -> bool {
581        self.allowed_languages.contains(language)
582    }
583
584    /// Allow a language to spawn LSP server (used by manual start command)
585    pub fn allow_language(&mut self, language: &str) {
586        self.allowed_languages.insert(language.to_string());
587        tracing::info!("LSP language '{}' manually enabled", language);
588    }
589
590    /// Get the set of manually enabled languages
591    pub fn allowed_languages(&self) -> &HashSet<String> {
592        &self.allowed_languages
593    }
594
595    /// Get the configurations for a specific language (one or more servers).
596    pub fn get_configs(&self, language: &str) -> Option<&[LspServerConfig]> {
597        self.config.get(language).map(|v| v.as_slice())
598    }
599
600    /// Get the primary (first) configuration for a specific language.
601    pub fn get_config(&self, language: &str) -> Option<&LspServerConfig> {
602        self.config.get(language).and_then(|v| v.first())
603    }
604
605    /// Store capabilities on the specific server handle identified by server_name.
606    pub fn set_server_capabilities(
607        &mut self,
608        _language: &str,
609        server_name: &str,
610        mut capabilities: ServerCapabilitySummary,
611    ) {
612        capabilities.initialized = true;
613
614        if let Some(sh) = self.handles.iter_mut().find(|sh| sh.name == server_name) {
615            sh.capabilities = capabilities;
616        }
617    }
618
619    /// Apply dynamic capability (un)registrations to the named server's stored
620    /// capabilities. Each entry is `(method, register_options)`. Returns `true`
621    /// if any recognized feature flag changed, so the caller can re-issue
622    /// requests for buffers that opened before the registration arrived.
623    ///
624    /// Per the LSP spec a server only sends `client/registerCapability` after
625    /// it has received our `initialized` notification — i.e. after the
626    /// `initialize` result was processed and `set_server_capabilities` ran — so
627    /// these merge on top of the static summary rather than racing it.
628    pub fn apply_dynamic_capabilities(
629        &mut self,
630        server_name: &str,
631        register: bool,
632        registrations: &[(String, Option<serde_json::Value>)],
633    ) -> bool {
634        let Some(sh) = self.handles.iter_mut().find(|sh| sh.name == server_name) else {
635            return false;
636        };
637        let mut changed = false;
638        for (method, options) in registrations {
639            if sh
640                .capabilities
641                .apply_dynamic_registration(method, options.as_ref(), register)
642            {
643                changed = true;
644            }
645        }
646        changed
647    }
648
649    /// Get the semantic token legend for a language from the first eligible server.
650    pub fn semantic_tokens_legend(&self, language: &str) -> Option<&SemanticTokensLegend> {
651        self.get_handles(language).into_iter().find_map(|sh| {
652            if sh.feature_filter.allows(LspFeature::SemanticTokens)
653                && sh.has_capability(LspFeature::SemanticTokens)
654            {
655                sh.capabilities.semantic_tokens_legend.as_ref()
656            } else {
657                None
658            }
659        })
660    }
661
662    /// Check if any eligible server for the language supports full semantic tokens.
663    pub fn semantic_tokens_full_supported(&self, language: &str) -> bool {
664        self.get_handles(language).iter().any(|sh| {
665            sh.feature_filter.allows(LspFeature::SemanticTokens)
666                && sh.capabilities.semantic_tokens_full
667        })
668    }
669
670    /// Check if any eligible server for the language supports full semantic token deltas.
671    pub fn semantic_tokens_full_delta_supported(&self, language: &str) -> bool {
672        self.get_handles(language).iter().any(|sh| {
673            sh.feature_filter.allows(LspFeature::SemanticTokens)
674                && sh.capabilities.semantic_tokens_full_delta
675        })
676    }
677
678    /// Check if any eligible server for the language supports range semantic tokens.
679    pub fn semantic_tokens_range_supported(&self, language: &str) -> bool {
680        self.get_handles(language).iter().any(|sh| {
681            sh.feature_filter.allows(LspFeature::SemanticTokens)
682                && sh.capabilities.semantic_tokens_range
683        })
684    }
685
686    /// Check if any eligible server for the language supports folding ranges.
687    pub fn folding_ranges_supported(&self, language: &str) -> bool {
688        self.get_handles(language).iter().any(|sh| {
689            sh.feature_filter.allows(LspFeature::FoldingRange) && sh.capabilities.folding_ranges
690        })
691    }
692
693    /// Check if a character is a completion trigger for any running language server.
694    pub fn is_completion_trigger_char(&self, ch: char, language: &str) -> bool {
695        let ch_str = ch.to_string();
696        self.get_handles(language).iter().any(|sh| {
697            sh.feature_filter.allows(LspFeature::Completion)
698                && sh
699                    .capabilities
700                    .completion_trigger_characters
701                    .contains(&ch_str)
702        })
703    }
704
705    /// Try to spawn an LSP server, checking auto_start configuration
706    ///
707    /// This is the main entry point for spawning LSP servers on file open.
708    /// It returns:
709    /// - `LspSpawnResult::Spawned` if the server was spawned or already running
710    /// - `LspSpawnResult::NotAutoStart` if auto_start is false and not manually allowed
711    /// - `LspSpawnResult::NotConfigured` if no LSP server is configured for the language
712    /// - `LspSpawnResult::Disabled` if every configured server has `enabled: false`
713    /// - `LspSpawnResult::Failed` if spawn failed (missing runtime, cooldown, etc.)
714    ///
715    /// The `file_path` is used for workspace root detection via `root_markers`.
716    ///
717    /// IMPORTANT: Callers should only call this when there is at least one buffer
718    /// with a matching language. Do not call for languages with no open files.
719    pub fn try_spawn(&mut self, language: &str, file_path: Option<&Path>) -> LspSpawnResult {
720        // If handles already exist for this language, just ensure universals are running too
721        if self
722            .handles
723            .iter()
724            .any(|sh| sh.handle.scope().accepts(language))
725        {
726            self.ensure_universal_servers_running(file_path);
727            return LspSpawnResult::Spawned;
728        }
729
730        // Global master switch (top-level `lsp_enabled` config field): a
731        // deliberate user opt-out for every language, including universal
732        // servers. Checked before anything can spawn; manual starts go
733        // through `force_spawn` directly and are intentionally unaffected.
734        if !self.globally_enabled {
735            tracing::debug!(
736                "LSP for '{}' not auto-started: LSP is globally disabled (lsp_enabled=false)",
737                language
738            );
739            return LspSpawnResult::Disabled;
740        }
741
742        // Check if we have runtime and bridge
743        if self.runtime.is_none() || self.async_bridge.is_none() {
744            return LspSpawnResult::Failed;
745        }
746
747        // Workspace Trust gate: starting an LSP server loads/executes
748        // project-controlled code (C# analyzers, Rust build scripts/proc-macros,
749        // clangd's compile_commands, …). In an untrusted workspace, don't
750        // auto-start — unless the user has explicitly enabled this language
751        // (manual start is an explicit, informed action). The trust prompt on
752        // open lets the user enable everything by trusting the folder.
753        if !self.lsp_autostart_allowed() && !self.allowed_languages.contains(language) {
754            tracing::info!(
755                "LSP for '{}' not auto-started: workspace is not trusted \
756                 (trust the folder to enable language servers)",
757                language
758            );
759            return LspSpawnResult::NotAutoStart;
760        }
761
762        // Always try to start universal servers (they manage their own auto_start check)
763        self.ensure_universal_servers_running(file_path);
764
765        // Check if language is configured
766        let configs = match self.config.get(language) {
767            Some(configs) if !configs.is_empty() => configs,
768            _ => {
769                // No per-language config, but universal servers may be running
770                if self
771                    .handles
772                    .iter()
773                    .any(|sh| sh.handle.scope().is_universal())
774                {
775                    return LspSpawnResult::Spawned;
776                }
777                return LspSpawnResult::NotConfigured;
778            }
779        };
780
781        // Check if any per-language config is enabled
782        if !configs.iter().any(|c| c.enabled) {
783            if self
784                .handles
785                .iter()
786                .any(|sh| sh.handle.scope().is_universal())
787            {
788                return LspSpawnResult::Spawned;
789            }
790            return LspSpawnResult::Disabled;
791        }
792
793        // Check if auto_start is enabled (on any per-language config) or language was manually allowed
794        let any_auto_start = configs.iter().any(|c| c.auto_start && c.enabled);
795        if !any_auto_start && !self.allowed_languages.contains(language) {
796            if self
797                .handles
798                .iter()
799                .any(|sh| sh.handle.scope().is_universal())
800            {
801                return LspSpawnResult::Spawned;
802            }
803            return LspSpawnResult::NotAutoStart;
804        }
805
806        // Spawn per-language servers
807        let spawned = self.force_spawn(language, file_path).is_some();
808
809        if spawned
810            || self
811                .handles
812                .iter()
813                .any(|sh| sh.handle.scope().is_universal())
814        {
815            LspSpawnResult::Spawned
816        } else {
817            LspSpawnResult::Failed
818        }
819    }
820
821    /// Set the Tokio runtime and async bridge
822    ///
823    /// Must be called before spawning any servers
824    pub fn set_runtime(&mut self, runtime: tokio::runtime::Handle, async_bridge: AsyncBridge) {
825        self.runtime = Some(runtime);
826        self.async_bridge = Some(async_bridge);
827    }
828
829    /// Set configuration for a language (single server).
830    pub fn set_language_config(&mut self, language: String, config: LspServerConfig) {
831        self.config.insert(language, vec![config]);
832    }
833
834    /// Set configurations for a language (one or more servers).
835    pub fn set_language_configs(&mut self, language: String, configs: Vec<LspServerConfig>) {
836        self.config.insert(language, configs);
837    }
838
839    /// Append additional server configs to an existing language entry.
840    pub fn append_language_configs(&mut self, language: String, configs: Vec<LspServerConfig>) {
841        self.config.entry(language).or_default().extend(configs);
842    }
843
844    /// Set universal (global) LSP server configs.
845    ///
846    /// Universal servers are spawned once per project and shared across all
847    /// languages, rather than being duplicated into each language's config list.
848    pub fn set_universal_configs(&mut self, configs: Vec<LspServerConfig>) {
849        self.universal_configs = configs;
850    }
851
852    /// Return the list of currently configured language keys.
853    pub fn configured_languages(&self) -> Vec<String> {
854        self.config.keys().cloned().collect()
855    }
856
857    /// Set a new root URI for the workspace
858    ///
859    /// This should be called after shutting down all servers when switching projects.
860    /// Servers spawned after this will use the new root URI.
861    pub fn set_root_uri(&mut self, root_uri: Option<Uri>) {
862        self.root_uri = root_uri;
863    }
864
865    /// Set a language-specific root URI
866    ///
867    /// This allows plugins to specify project roots for specific languages.
868    /// For example, a C# plugin can set the root to the directory containing .csproj.
869    /// Returns true if an existing server was restarted with the new root.
870    pub fn set_language_root_uri(&mut self, language: &str, uri: Uri) -> bool {
871        tracing::info!("Setting root URI for {}: {}", language, uri.as_str());
872        self.per_language_root_uris
873            .insert(language.to_string(), uri.clone());
874
875        // If there's an existing server for this language, restart it with the new root
876        if self
877            .handles
878            .iter()
879            .any(|sh| sh.handle.scope().accepts(language))
880        {
881            tracing::info!(
882                "Restarting {} LSP server with new root: {}",
883                language,
884                uri.as_str()
885            );
886            self.shutdown_server(language);
887            // The server will be respawned on next request with the new root
888            return true;
889        }
890        false
891    }
892
893    /// Resolve the root URI for a language, using root_markers for detection.
894    ///
895    /// Priority:
896    /// 1. Plugin-set per-language root (per_language_root_uris)
897    /// 2. Walk upward from file_path using config's root_markers
898    /// 3. File's parent directory
899    pub fn resolve_root_uri(&self, language: &str, file_path: Option<&Path>) -> Option<Uri> {
900        // 1. Plugin-set root takes priority
901        if let Some(uri) = self.per_language_root_uris.get(language) {
902            return Some(uri.clone());
903        }
904
905        // 2. Use root_markers to detect workspace root from file path.
906        //    Walks the host filesystem; on a container authority that
907        //    yields a host path, which would confuse an in-container
908        //    LSP. Translate before encoding to a URI.
909        if let Some(path) = file_path {
910            let markers = self
911                .config
912                .get(language)
913                .and_then(|configs| configs.first())
914                .map(|c| c.root_markers.as_slice())
915                .unwrap_or(&[]);
916            let root = detect_workspace_root(path, markers);
917            let mapped = self
918                .path_translation
919                .as_ref()
920                .and_then(|t| t.host_to_remote(&root))
921                .unwrap_or(root);
922            if let Some(uri) = path_to_uri(&mapped) {
923                return Some(uri);
924            }
925        }
926
927        // 3. No file path available — use the global root_uri
928        self.root_uri.clone()
929    }
930
931    /// Get the effective root URI for a language (legacy, without file-based detection)
932    ///
933    /// Returns the language-specific root if set, otherwise the default root.
934    pub fn get_effective_root_uri(&self, language: &str) -> Option<Uri> {
935        self.resolve_root_uri(language, None)
936    }
937
938    /// Reset the manager for a new project
939    ///
940    /// This shuts down all servers and clears state, preparing for a fresh start.
941    /// The configuration is preserved but servers will need to be respawned.
942    pub fn reset_for_new_project(&mut self, new_root_uri: Option<Uri>) {
943        // Shutdown all servers
944        self.shutdown_all();
945
946        // Update root URI
947        self.root_uri = new_root_uri;
948
949        // Clear restart tracking state (fresh start)
950        self.restart_attempts.clear();
951        self.restart_cooldown.clear();
952        self.pending_restarts.clear();
953
954        // Keep allowed_languages and disabled_languages as user preferences
955        // Keep config as it's not project-specific
956
957        tracing::info!(
958            "LSP manager reset for new project: {:?}",
959            self.root_uri.as_ref().map(|u| u.as_str())
960        );
961    }
962
963    /// Get the primary (first) existing LSP handle for a language (no spawning).
964    /// Checks language-specific handles first, then universal handles.
965    pub fn get_handle(&self, language: &str) -> Option<&LspHandle> {
966        self.handles
967            .iter()
968            .find(|sh| sh.handle.scope().accepts(language))
969            .map(|sh| &sh.handle)
970    }
971
972    /// Get the primary (first) mutable existing LSP handle for a language (no spawning).
973    /// Checks language-specific handles first, then universal handles.
974    pub fn get_handle_mut(&mut self, language: &str) -> Option<&mut LspHandle> {
975        self.handles
976            .iter_mut()
977            .find(|sh| sh.handle.scope().accepts(language))
978            .map(|sh| &mut sh.handle)
979    }
980
981    /// Get all handles that accept a language (both language-specific and universal).
982    pub fn get_handles(&self, language: &str) -> Vec<&ServerHandle> {
983        self.handles
984            .iter()
985            .filter(|sh| sh.handle.scope().accepts(language))
986            .collect()
987    }
988
989    /// Get all mutable handles that accept a language (both language-specific and universal).
990    pub fn get_handles_mut(&mut self, language: &str) -> Vec<&mut ServerHandle> {
991        self.handles
992            .iter_mut()
993            .filter(|sh| sh.handle.scope().accepts(language))
994            .collect()
995    }
996
997    /// Get the language scope for a server by name.
998    ///
999    /// Returns `None` if the server is not found.
1000    pub fn server_scope(&self, server_name: &str) -> Option<&LanguageScope> {
1001        self.handles
1002            .iter()
1003            .find(|sh| sh.name == server_name)
1004            .map(|sh| sh.handle.scope())
1005    }
1006
1007    /// Check if any handles (language-specific or universal) exist for a language.
1008    pub fn has_handles(&self, language: &str) -> bool {
1009        self.handles
1010            .iter()
1011            .any(|sh| sh.handle.scope().accepts(language))
1012    }
1013
1014    /// Count all handles that accept a language.
1015    pub fn handle_count(&self, language: &str) -> usize {
1016        self.handles
1017            .iter()
1018            .filter(|sh| sh.handle.scope().accepts(language))
1019            .count()
1020    }
1021
1022    /// Check if a server with the given name exists.
1023    pub fn has_server_named(&self, server_name: &str) -> bool {
1024        self.handles.iter().any(|sh| sh.name == server_name)
1025    }
1026
1027    /// Get the first handle for a language that allows a given feature (for exclusive features).
1028    /// For capability-gated features (semantic tokens, folding ranges), this also checks
1029    /// that the server actually reported the capability during initialization.
1030    /// Checks per-language handles first, then universal handles.
1031    /// Returns `None` if no handle matches.
1032    pub fn handle_for_feature(&self, language: &str, feature: LspFeature) -> Option<&ServerHandle> {
1033        self.handles
1034            .iter()
1035            .filter(|sh| sh.handle.scope().accepts(language))
1036            .find(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
1037    }
1038
1039    /// Get the first mutable handle for a language that allows a given feature.
1040    /// For capability-gated features, this also checks the server's actual capabilities.
1041    /// Checks per-language handles first, then universal handles.
1042    pub fn handle_for_feature_mut(
1043        &mut self,
1044        language: &str,
1045        feature: LspFeature,
1046    ) -> Option<&mut ServerHandle> {
1047        self.handles
1048            .iter_mut()
1049            .filter(|sh| sh.handle.scope().accepts(language))
1050            .find(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
1051    }
1052
1053    /// Get all handles for a language that allow a given feature (for merged features).
1054    /// Like `handle_for_feature`, also checks per-server capabilities.
1055    /// Includes both per-language and universal handles.
1056    pub fn handles_for_feature(&self, language: &str, feature: LspFeature) -> Vec<&ServerHandle> {
1057        self.handles
1058            .iter()
1059            .filter(|sh| sh.handle.scope().accepts(language))
1060            .filter(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
1061            .collect()
1062    }
1063
1064    /// Get all mutable handles for a language that allow a given feature.
1065    /// Like `handle_for_feature_mut`, also checks per-server capabilities.
1066    /// Includes both per-language and universal handles.
1067    pub fn handles_for_feature_mut(
1068        &mut self,
1069        language: &str,
1070        feature: LspFeature,
1071    ) -> Vec<&mut ServerHandle> {
1072        self.handles
1073            .iter_mut()
1074            .filter(|sh| sh.handle.scope().accepts(language))
1075            .filter(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
1076            .collect()
1077    }
1078
1079    /// Consult the spawn throttle for `language` and, on `Allow`, record
1080    /// the attempt.
1081    ///
1082    /// This is the single source of truth for "are we allowed to spawn
1083    /// another LSP child right now?" — every path that actually spawns
1084    /// must call it.  The caller should propagate the decision (return
1085    /// None / refuse to spawn) on anything other than `Allow`.
1086    fn spawn_decision(&mut self, language: &str) -> SpawnDecision {
1087        if self
1088            .handles
1089            .iter()
1090            .any(|sh| sh.handle.scope().accepts(language))
1091        {
1092            return SpawnDecision::Existing;
1093        }
1094        if self.restart_cooldown.contains(language) {
1095            return SpawnDecision::CooledDown;
1096        }
1097        if self.pending_restarts.contains_key(language) {
1098            return SpawnDecision::PendingBackoff;
1099        }
1100
1101        let now = Instant::now();
1102        let window = Duration::from_secs(RESTART_WINDOW_SECS);
1103        let attempts = self
1104            .restart_attempts
1105            .entry(language.to_string())
1106            .or_default();
1107        attempts.retain(|t| now.duration_since(*t) < window);
1108
1109        if attempts.len() >= MAX_RESTARTS_IN_WINDOW {
1110            self.restart_cooldown.insert(language.to_string());
1111            tracing::warn!(
1112                "LSP server for {} has spawned {} times in {} minutes, entering cooldown",
1113                language,
1114                MAX_RESTARTS_IN_WINDOW,
1115                RESTART_WINDOW_SECS / 60
1116            );
1117            return SpawnDecision::CooledDown;
1118        }
1119
1120        attempts.push(now);
1121        SpawnDecision::Allow
1122    }
1123
1124    /// Force spawn LSP server(s) for a language.
1125    ///
1126    /// Spawns servers configured for the language, filtered as follows:
1127    /// - If the language is in `allowed_languages` (the user explicitly
1128    ///   started or approved this language via a manual command), spawns
1129    ///   every configured server regardless of its `enabled` / `auto_start`
1130    ///   flags. This is the "manual" path used by the command palette's
1131    ///   Start / Restart LSP commands and the LSP confirmation popup.
1132    /// - Otherwise (the auto-start path, reached via `try_spawn` on buffer
1133    ///   load or by crash recovery), spawns only servers that have both
1134    ///   `enabled=true` AND `auto_start=true`. Each config's own
1135    ///   `auto_start` flag is honoured individually, so configuring one
1136    ///   auto-start server alongside an opt-in manual server no longer
1137    ///   drags the manual one along for the ride.
1138    ///
1139    /// Returns a mutable reference to the primary (first) handle if any
1140    /// were spawned. The `file_path` is used for workspace root detection
1141    /// via `root_markers`.
1142    pub fn force_spawn(
1143        &mut self,
1144        language: &str,
1145        file_path: Option<&Path>,
1146    ) -> Option<&mut LspHandle> {
1147        tracing::debug!("force_spawn called for language: {}", language);
1148
1149        // Return existing handle if available
1150        if self
1151            .handles
1152            .iter()
1153            .any(|sh| sh.handle.scope().accepts(language))
1154        {
1155            tracing::debug!("force_spawn: returning existing handle for {}", language);
1156            return self
1157                .handles
1158                .iter_mut()
1159                .find(|sh| sh.handle.scope().accepts(language))
1160                .map(|sh| &mut sh.handle);
1161        }
1162
1163        // Check if language was explicitly disabled by user (via stop command)
1164        if self.disabled_languages.contains(language) {
1165            tracing::debug!(
1166                "LSP for {} is disabled, not spawning (use manual restart to re-enable)",
1167                language
1168            );
1169            return None;
1170        }
1171
1172        // Get configs for this language
1173        let configs = match self.config.get(language) {
1174            Some(configs) if !configs.is_empty() => configs.clone(),
1175            _ => {
1176                tracing::warn!(
1177                    "force_spawn: no config found for language '{}', available configs: {:?}",
1178                    language,
1179                    self.config.keys().collect::<Vec<_>>()
1180                );
1181                return None;
1182            }
1183        };
1184
1185        // Consult the spawn gate. This is the single point that enforces
1186        // the restart throttle across *all* spawn entry points — user
1187        // activity (try_spawn), scheduled backoff, manual restart. See
1188        // #1612: previously only handle_server_crash tracked attempts,
1189        // so a fast-crashing server got respawned on every edit.
1190        match self.spawn_decision(language) {
1191            SpawnDecision::Existing => {
1192                // Existing-handle case is already short-circuited above,
1193                // but handle it defensively.
1194                return self
1195                    .handles
1196                    .iter_mut()
1197                    .find(|sh| sh.handle.scope().accepts(language))
1198                    .map(|sh| &mut sh.handle);
1199            }
1200            SpawnDecision::CooledDown => {
1201                tracing::debug!(
1202                    "force_spawn: {} is in cooldown, refusing spawn (use Restart LSP command)",
1203                    language
1204                );
1205                return None;
1206            }
1207            SpawnDecision::PendingBackoff => {
1208                tracing::debug!(
1209                    "force_spawn: {} has a pending restart scheduled, not double-spawning",
1210                    language
1211                );
1212                return None;
1213            }
1214            SpawnDecision::Allow => {}
1215        }
1216
1217        // Check we have runtime, bridge, and the authority's spawner.
1218        // All three are wired at editor construction; a missing one is
1219        // a configuration error worth surfacing rather than silently
1220        // degrading to a host-only spawn.
1221        let runtime = match self.runtime.as_ref() {
1222            Some(r) => r.clone(),
1223            None => {
1224                tracing::error!("force_spawn: no tokio runtime available for {}", language);
1225                return None;
1226            }
1227        };
1228        let async_bridge = match self.async_bridge.as_ref() {
1229            Some(b) => b.clone(),
1230            None => {
1231                tracing::error!("force_spawn: no async bridge available for {}", language);
1232                return None;
1233            }
1234        };
1235        // Default to the local spawner when nothing's been wired. The
1236        // editor's `set_boot_authority` wires this as part of normal
1237        // construction; tests and other call sites that construct an
1238        // LspManager directly without going through Editor get the
1239        // pre-Phase-L behavior (host-only spawn) automatically. Passing
1240        // through the warning keeps the failure loud enough to catch
1241        // regressions in Editor-side wiring.
1242        let long_running_spawner = match self.long_running_spawner.as_ref() {
1243            Some(s) => s.clone(),
1244            None => {
1245                tracing::warn!(
1246                    "force_spawn: long-running spawner not wired for {} — \
1247                     falling back to host-local spawn (normal for tests \
1248                     that skip set_boot_authority)",
1249                    language
1250                );
1251                std::sync::Arc::new(crate::services::remote::LocalLongRunningSpawner::new(
1252                    std::sync::Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1253                    std::sync::Arc::new(
1254                        crate::services::workspace_trust::WorkspaceTrust::permissive(),
1255                    ),
1256                ))
1257            }
1258        };
1259
1260        let mut spawned_handles = Vec::new();
1261        let manually_allowed = self.allowed_languages.contains(language);
1262
1263        for config in &configs {
1264            if manually_allowed {
1265                // User explicitly started this language via command palette:
1266                // spawn every configured server, even if individually
1267                // disabled or marked not-auto-start.
1268            } else {
1269                // Auto-start path: only spawn servers that the user has
1270                // opted into both via `enabled=true` AND `auto_start=true`.
1271                // This honours each config's flags independently so that
1272                // e.g. configuring rust-auto (auto_start=true) alongside
1273                // rust-manual (auto_start=false) does not spawn both.
1274                if !config.enabled || !config.auto_start {
1275                    continue;
1276                }
1277            }
1278
1279            if config.command.is_empty() {
1280                tracing::warn!(
1281                    "force_spawn: LSP command is empty for {} server '{}'",
1282                    language,
1283                    config.display_name()
1284                );
1285                continue;
1286            }
1287
1288            let server_name = config.display_name();
1289            tracing::info!(
1290                "Spawning LSP server '{}' for language: {}",
1291                server_name,
1292                language
1293            );
1294
1295            match LspHandle::spawn(
1296                &runtime,
1297                &config.command,
1298                &config.args,
1299                config.env.clone(),
1300                LanguageScope::single(language),
1301                server_name.clone(),
1302                &async_bridge,
1303                config.process_limits.clone(),
1304                config.language_id_overrides.clone(),
1305                long_running_spawner.clone(),
1306            ) {
1307                Ok(handle) => {
1308                    let effective_root = self.resolve_root_uri(language, file_path);
1309                    if let Err(e) =
1310                        handle.initialize(effective_root, config.initialization_options.clone())
1311                    {
1312                        tracing::error!(
1313                            "Failed to send initialize command for {} ({}): {}",
1314                            language,
1315                            server_name,
1316                            e
1317                        );
1318                        continue;
1319                    }
1320
1321                    tracing::info!(
1322                        "LSP initialization started for {} ({}), will be ready asynchronously",
1323                        language,
1324                        server_name
1325                    );
1326
1327                    spawned_handles.push(ServerHandle {
1328                        name: server_name,
1329                        handle,
1330                        feature_filter: config.feature_filter(),
1331                        capabilities: ServerCapabilitySummary::default(),
1332                    });
1333                }
1334                Err(e) => {
1335                    tracing::error!(
1336                        "Failed to spawn LSP handle for {} ({}): {}",
1337                        language,
1338                        server_name,
1339                        e
1340                    );
1341                }
1342            }
1343        }
1344
1345        if spawned_handles.is_empty() {
1346            return None;
1347        }
1348
1349        self.handles.extend(spawned_handles);
1350        self.handles
1351            .iter_mut()
1352            .rev()
1353            .find(|sh| sh.handle.scope().accepts(language))
1354            .map(|sh| &mut sh.handle)
1355    }
1356
1357    /// Spawn universal LSP servers if they aren't already running.
1358    ///
1359    /// Called from `try_spawn` — universal servers are spawned once and shared
1360    /// across all languages. Only servers with `enabled=true` and
1361    /// `auto_start=true` are started automatically.
1362    fn ensure_universal_servers_running(&mut self, file_path: Option<&Path>) {
1363        // Global master switch also covers universal servers — without this,
1364        // a manually started language server would drag the universal
1365        // servers up via the handles-exist fast path in `try_spawn`.
1366        if !self.globally_enabled {
1367            return;
1368        }
1369        if self
1370            .handles
1371            .iter()
1372            .any(|sh| sh.handle.scope().is_universal())
1373            || self.universal_configs.is_empty()
1374        {
1375            return;
1376        }
1377
1378        let runtime = match self.runtime.as_ref() {
1379            Some(r) => r.clone(),
1380            None => return,
1381        };
1382        let async_bridge = match self.async_bridge.as_ref() {
1383            Some(b) => b.clone(),
1384            None => return,
1385        };
1386        let long_running_spawner =
1387            self.long_running_spawner
1388                .as_ref()
1389                .cloned()
1390                .unwrap_or_else(|| {
1391                    std::sync::Arc::new(crate::services::remote::LocalLongRunningSpawner::new(
1392                        std::sync::Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1393                        std::sync::Arc::new(
1394                            crate::services::workspace_trust::WorkspaceTrust::permissive(),
1395                        ),
1396                    ))
1397                });
1398
1399        let mut spawned = Vec::new();
1400        for config in &self.universal_configs {
1401            if !config.enabled || !config.auto_start {
1402                continue;
1403            }
1404            if config.command.is_empty() {
1405                continue;
1406            }
1407
1408            let server_name = config.display_name();
1409            tracing::info!("Spawning universal LSP server '{}'", server_name);
1410
1411            match LspHandle::spawn(
1412                &runtime,
1413                &config.command,
1414                &config.args,
1415                config.env.clone(),
1416                LanguageScope::all(),
1417                server_name.clone(),
1418                &async_bridge,
1419                config.process_limits.clone(),
1420                config.language_id_overrides.clone(),
1421                long_running_spawner.clone(),
1422            ) {
1423                Ok(handle) => {
1424                    let effective_root = file_path
1425                        .and_then(|p| {
1426                            let root = detect_workspace_root(p, &config.root_markers);
1427                            path_to_uri(&root)
1428                        })
1429                        .or_else(|| self.root_uri.clone());
1430                    if let Err(e) =
1431                        handle.initialize(effective_root, config.initialization_options.clone())
1432                    {
1433                        tracing::error!(
1434                            "Failed to initialize universal LSP server '{}': {}",
1435                            server_name,
1436                            e
1437                        );
1438                        continue;
1439                    }
1440                    tracing::info!(
1441                        "Universal LSP server '{}' initialization started",
1442                        server_name
1443                    );
1444                    spawned.push(ServerHandle {
1445                        name: server_name,
1446                        handle,
1447                        feature_filter: config.feature_filter(),
1448                        capabilities: ServerCapabilitySummary::default(),
1449                    });
1450                }
1451                Err(e) => {
1452                    tracing::error!(
1453                        "Failed to spawn universal LSP server '{}': {}",
1454                        server_name,
1455                        e
1456                    );
1457                }
1458            }
1459        }
1460
1461        self.handles.extend(spawned);
1462    }
1463
1464    /// Handle a server crash by scheduling a restart with exponential backoff
1465    ///
1466    /// Returns a message describing the action taken (for UI notification)
1467    pub fn handle_server_crash(&mut self, language: &str, server_name: &str) -> String {
1468        // Check if the crashed server is a universal handle
1469        if self
1470            .handles
1471            .iter()
1472            .any(|sh| sh.name == server_name && sh.handle.scope().is_universal())
1473        {
1474            // Drain all universal handles and shut them down
1475            let universals: Vec<ServerHandle> = {
1476                let mut drained = Vec::new();
1477                let mut i = 0;
1478                while i < self.handles.len() {
1479                    if self.handles[i].handle.scope().is_universal() {
1480                        drained.push(self.handles.remove(i));
1481                    } else {
1482                        i += 1;
1483                    }
1484                }
1485                drained
1486            };
1487            for sh in universals {
1488                fire_and_forget(sh.handle.shutdown());
1489            }
1490            // Universal servers will be re-spawned on next try_spawn call
1491            return "Universal LSP server crashed. It will restart on next file open.".to_string();
1492        }
1493
1494        // Remove all handles that accept this language (but not universal ones)
1495        {
1496            let mut i = 0;
1497            while i < self.handles.len() {
1498                if !self.handles[i].handle.scope().is_universal()
1499                    && self.handles[i].handle.scope().accepts(language)
1500                {
1501                    let sh = self.handles.remove(i);
1502                    fire_and_forget(sh.handle.shutdown());
1503                } else {
1504                    i += 1;
1505                }
1506            }
1507        }
1508
1509        // Check if server was explicitly disabled by user (via stop command)
1510        // Don't auto-restart disabled servers
1511        if self.disabled_languages.contains(language) {
1512            return format!(
1513                "LSP server for {} stopped. Use 'Restart LSP Server' command to start it again.",
1514                language
1515            );
1516        }
1517
1518        // Check if we're in cooldown
1519        if self.restart_cooldown.contains(language) {
1520            return format!(
1521                "LSP server for {} crashed. Too many restarts - use 'Restart LSP Server' command to retry.",
1522                language
1523            );
1524        }
1525
1526        // Attempt-counting and the cap are owned by `spawn_decision`
1527        // (called at every real spawn site). Here we only schedule the
1528        // next restart with exponential backoff; the gate will decide
1529        // whether it actually proceeds when the pending restart fires.
1530        let now = Instant::now();
1531        let attempt_number = self
1532            .restart_attempts
1533            .get(language)
1534            .map(|v| v.len())
1535            .unwrap_or(0);
1536
1537        let delay_ms = RESTART_BACKOFF_BASE_MS * (1 << attempt_number); // 1s, 2s, 4s, 8s
1538        let restart_time = now + Duration::from_millis(delay_ms);
1539
1540        self.pending_restarts
1541            .insert(language.to_string(), restart_time);
1542
1543        tracing::info!(
1544            "LSP server for {} crashed (attempt {}/{}), will restart in {}ms",
1545            language,
1546            attempt_number + 1,
1547            MAX_RESTARTS_IN_WINDOW,
1548            delay_ms
1549        );
1550
1551        format!(
1552            "LSP server for {} crashed (attempt {}/{}), restarting in {}s...",
1553            language,
1554            attempt_number + 1,
1555            MAX_RESTARTS_IN_WINDOW,
1556            delay_ms / 1000
1557        )
1558    }
1559
1560    /// Check and process any pending restarts that are due
1561    ///
1562    /// Returns list of (language, success, message) for each restart attempted
1563    pub fn process_pending_restarts(&mut self) -> Vec<(String, bool, String)> {
1564        let now = Instant::now();
1565        let mut results = Vec::new();
1566
1567        // Find restarts that are due
1568        let due_restarts: Vec<String> = self
1569            .pending_restarts
1570            .iter()
1571            .filter(|(_, time)| **time <= now)
1572            .map(|(lang, _)| lang.clone())
1573            .collect();
1574
1575        for language in due_restarts {
1576            self.pending_restarts.remove(&language);
1577
1578            // Attempt to spawn the server (bypassing auto_start for
1579            // crash recovery). The attempt is recorded by the spawn
1580            // gate inside force_spawn — no need to push here.
1581            if self.force_spawn(&language, None).is_some() {
1582                let message = format!("LSP server for {} restarted successfully", language);
1583                tracing::info!("{}", message);
1584                results.push((language, true, message));
1585            } else {
1586                let message = format!("Failed to restart LSP server for {}", language);
1587                tracing::error!("{}", message);
1588                results.push((language, false, message));
1589            }
1590        }
1591
1592        results
1593    }
1594
1595    /// Check if a language server is in restart cooldown
1596    pub fn is_in_cooldown(&self, language: &str) -> bool {
1597        self.restart_cooldown.contains(language)
1598    }
1599
1600    /// Check if a language server has a pending restart
1601    pub fn has_pending_restart(&self, language: &str) -> bool {
1602        self.pending_restarts.contains_key(language)
1603    }
1604
1605    /// Clear cooldown for a language and allow manual restart
1606    pub fn clear_cooldown(&mut self, language: &str) {
1607        self.restart_cooldown.remove(language);
1608        self.restart_attempts.remove(language);
1609        self.pending_restarts.remove(language);
1610        tracing::info!("Cleared restart cooldown for {}", language);
1611    }
1612
1613    /// Manually restart/start a language server (bypasses cooldown and auto_start check)
1614    ///
1615    /// This is used both to restart a crashed server and to manually start a server
1616    /// that has auto_start=false in its configuration.
1617    ///
1618    /// Returns (success, message) tuple
1619    pub fn manual_restart(&mut self, language: &str, file_path: Option<&Path>) -> (bool, String) {
1620        // Clear any existing state
1621        self.clear_cooldown(language);
1622
1623        // Re-enable the language (remove from disabled set)
1624        self.disabled_languages.remove(language);
1625
1626        // Add to allowed languages so it stays active even if auto_start=false
1627        self.allowed_languages.insert(language.to_string());
1628
1629        // Remove existing handles for this language (non-universal)
1630        {
1631            let mut i = 0;
1632            while i < self.handles.len() {
1633                if !self.handles[i].handle.scope().is_universal()
1634                    && self.handles[i].handle.scope().accepts(language)
1635                {
1636                    let sh = self.handles.remove(i);
1637                    fire_and_forget(sh.handle.shutdown());
1638                } else {
1639                    i += 1;
1640                }
1641            }
1642        }
1643
1644        // Spawn new server (bypassing auto_start for user-initiated restart)
1645        if self.force_spawn(language, file_path).is_some() {
1646            let message = format!("LSP server for {} started", language);
1647            tracing::info!("{}", message);
1648            (true, message)
1649        } else {
1650            let message = format!("Failed to start LSP server for {}", language);
1651            tracing::error!("{}", message);
1652            (false, message)
1653        }
1654    }
1655
1656    /// Restart a single server by name for a specific language.
1657    ///
1658    /// Shuts down just that server and re-spawns it from config.
1659    /// Returns (success, message) tuple.
1660    pub fn manual_restart_server(
1661        &mut self,
1662        language: &str,
1663        server_name: &str,
1664        file_path: Option<&Path>,
1665    ) -> (bool, String) {
1666        self.clear_cooldown(language);
1667        self.disabled_languages.remove(language);
1668        self.allowed_languages.insert(language.to_string());
1669
1670        // Find and shut down just the named server
1671        if let Some(idx) = self.handles.iter().position(|sh| sh.name == server_name) {
1672            let sh = self.handles.remove(idx);
1673            fire_and_forget(sh.handle.shutdown());
1674        }
1675
1676        // Find the matching config (check per-language first, then universal)
1677        let is_universal = self
1678            .universal_configs
1679            .iter()
1680            .any(|c| c.display_name() == server_name);
1681        let config = if is_universal {
1682            self.universal_configs
1683                .iter()
1684                .find(|c| c.display_name() == server_name)
1685                .cloned()
1686        } else {
1687            self.config
1688                .get(language)
1689                .and_then(|configs| configs.iter().find(|c| c.display_name() == server_name))
1690                .cloned()
1691        };
1692
1693        let Some(config) = config else {
1694            let message = format!(
1695                "No config found for server '{}' ({})",
1696                server_name, language
1697            );
1698            tracing::error!("{}", message);
1699            return (false, message);
1700        };
1701
1702        if config.command.is_empty() {
1703            let message = format!(
1704                "LSP command is empty for {} server '{}'",
1705                language, server_name
1706            );
1707            tracing::error!("{}", message);
1708            return (false, message);
1709        }
1710
1711        let runtime = match self.runtime.as_ref() {
1712            Some(r) => r.clone(),
1713            None => return (false, "No tokio runtime available".to_string()),
1714        };
1715        let async_bridge = match self.async_bridge.as_ref() {
1716            Some(b) => b.clone(),
1717            None => return (false, "No async bridge available".to_string()),
1718        };
1719        let long_running_spawner =
1720            self.long_running_spawner
1721                .as_ref()
1722                .cloned()
1723                .unwrap_or_else(|| {
1724                    std::sync::Arc::new(crate::services::remote::LocalLongRunningSpawner::new(
1725                        std::sync::Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1726                        std::sync::Arc::new(
1727                            crate::services::workspace_trust::WorkspaceTrust::permissive(),
1728                        ),
1729                    ))
1730                });
1731
1732        let scope = if is_universal {
1733            LanguageScope::all()
1734        } else {
1735            LanguageScope::single(language)
1736        };
1737
1738        match LspHandle::spawn(
1739            &runtime,
1740            &config.command,
1741            &config.args,
1742            config.env.clone(),
1743            scope,
1744            server_name.to_string(),
1745            &async_bridge,
1746            config.process_limits.clone(),
1747            config.language_id_overrides.clone(),
1748            long_running_spawner,
1749        ) {
1750            Ok(handle) => {
1751                let effective_root = if is_universal {
1752                    file_path
1753                        .and_then(|p| {
1754                            let root = detect_workspace_root(p, &config.root_markers);
1755                            path_to_uri(&root)
1756                        })
1757                        .or_else(|| self.root_uri.clone())
1758                } else {
1759                    self.resolve_root_uri(language, file_path)
1760                };
1761                if let Err(e) =
1762                    handle.initialize(effective_root, config.initialization_options.clone())
1763                {
1764                    let message = format!(
1765                        "Failed to initialize LSP server '{}' for {}: {}",
1766                        server_name, language, e
1767                    );
1768                    tracing::error!("{}", message);
1769                    return (false, message);
1770                }
1771
1772                let sh = ServerHandle {
1773                    name: server_name.to_string(),
1774                    handle,
1775                    feature_filter: config.feature_filter(),
1776                    capabilities: ServerCapabilitySummary::default(),
1777                };
1778
1779                self.handles.push(sh);
1780
1781                let message = format!("LSP server '{}' for {} started", server_name, language);
1782                tracing::info!("{}", message);
1783                (true, message)
1784            }
1785            Err(e) => {
1786                let message = format!(
1787                    "Failed to start LSP server '{}' for {}: {}",
1788                    server_name, language, e
1789                );
1790                tracing::error!("{}", message);
1791                (false, message)
1792            }
1793        }
1794    }
1795
1796    /// Get the number of recent restart attempts for a language
1797    pub fn restart_attempt_count(&self, language: &str) -> usize {
1798        let now = Instant::now();
1799        let window = Duration::from_secs(RESTART_WINDOW_SECS);
1800        self.restart_attempts
1801            .get(language)
1802            .map(|attempts| {
1803                attempts
1804                    .iter()
1805                    .filter(|t| now.duration_since(**t) < window)
1806                    .count()
1807            })
1808            .unwrap_or(0)
1809    }
1810
1811    /// Get a list of currently running LSP server language labels (deduplicated).
1812    pub fn running_servers(&self) -> Vec<String> {
1813        let mut labels: Vec<String> = self
1814            .handles
1815            .iter()
1816            .map(|sh| sh.handle.scope().label().to_string())
1817            .collect();
1818        labels.sort();
1819        labels.dedup();
1820        labels
1821    }
1822
1823    /// Get the names of all running servers for a given language
1824    pub fn server_names_for_language(&self, language: &str) -> Vec<String> {
1825        self.handles
1826            .iter()
1827            .filter(|sh| sh.handle.scope().accepts(language))
1828            .map(|sh| sh.name.clone())
1829            .collect()
1830    }
1831
1832    /// Check if any LSP server for a language is running and ready to serve requests
1833    pub fn is_server_ready(&self, language: &str) -> bool {
1834        self.handles
1835            .iter()
1836            .filter(|sh| sh.handle.scope().accepts(language))
1837            .any(|sh| sh.handle.state().can_send_requests())
1838    }
1839
1840    /// Shutdown a single server by name for a specific language.
1841    ///
1842    /// Returns true if the server was found and shut down.
1843    /// If this was the last server for the language, marks the language as disabled.
1844    pub fn shutdown_server_by_name(&mut self, language: &str, server_name: &str) -> bool {
1845        let Some(idx) = self.handles.iter().position(|sh| sh.name == server_name) else {
1846            tracing::warn!(
1847                "No running LSP server named '{}' found for {}",
1848                server_name,
1849                language
1850            );
1851            return false;
1852        };
1853
1854        let sh = self.handles.remove(idx);
1855        tracing::info!(
1856            "Shutting down LSP server '{}' for {} (disabled until manual restart)",
1857            sh.name,
1858            language
1859        );
1860        fire_and_forget(sh.handle.shutdown());
1861
1862        // If no more non-universal handles remain for this language, mark it disabled
1863        let has_remaining = self
1864            .handles
1865            .iter()
1866            .any(|sh| !sh.handle.scope().is_universal() && sh.handle.scope().accepts(language));
1867        if !has_remaining {
1868            self.disabled_languages.insert(language.to_string());
1869            self.pending_restarts.remove(language);
1870            self.restart_cooldown.remove(language);
1871            self.allowed_languages.remove(language);
1872        }
1873
1874        true
1875    }
1876
1877    /// Shutdown all servers for a specific language.
1878    ///
1879    /// This marks the language as disabled, preventing auto-restart until the user
1880    /// explicitly restarts it using the restart command.
1881    pub fn shutdown_server(&mut self, language: &str) -> bool {
1882        let mut found = false;
1883        let mut i = 0;
1884        while i < self.handles.len() {
1885            if !self.handles[i].handle.scope().is_universal()
1886                && self.handles[i].handle.scope().accepts(language)
1887            {
1888                let sh = self.handles.remove(i);
1889                tracing::info!(
1890                    "Shutting down LSP server '{}' for {} (disabled until manual restart)",
1891                    sh.name,
1892                    language
1893                );
1894                fire_and_forget(sh.handle.shutdown());
1895                found = true;
1896            } else {
1897                i += 1;
1898            }
1899        }
1900
1901        if found {
1902            self.disabled_languages.insert(language.to_string());
1903            self.pending_restarts.remove(language);
1904            self.restart_cooldown.remove(language);
1905            self.allowed_languages.remove(language);
1906        } else {
1907            tracing::warn!("No running LSP server found for {}", language);
1908        }
1909
1910        found
1911    }
1912
1913    /// Shutdown all language servers (including universal servers)
1914    pub fn shutdown_all(&mut self) {
1915        for sh in &self.handles {
1916            tracing::info!(
1917                "Shutting down LSP server '{}' ({})",
1918                sh.name,
1919                sh.handle.scope().label()
1920            );
1921            fire_and_forget(sh.handle.shutdown());
1922        }
1923        self.handles.clear();
1924    }
1925}
1926
1927impl Drop for LspManager {
1928    fn drop(&mut self) {
1929        self.shutdown_all();
1930    }
1931}
1932
1933/// Helper function to detect language from file path using the config's languages section.
1934///
1935/// Priority order matches `GrammarRegistry::find_by_path`:
1936/// 1. Exact filename match against `filenames` (highest priority)
1937/// 2. Glob pattern match against `filenames` entries containing wildcards
1938/// 3. File extension match against `extensions` (lowest config-based priority)
1939///
1940/// Kept separate from `find_by_path` because this returns the user's
1941/// config **key** (`[languages.mylang]` → `"mylang"`) rather than the
1942/// catalog entry's `language_id`, which is needed for LSP routing when a
1943/// user aliases an existing grammar.
1944pub fn detect_language(
1945    path: &std::path::Path,
1946    languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
1947) -> Option<String> {
1948    let detected = detect_language_by_config(path, languages);
1949
1950    // `.h` headers: the default config maps the extension to C, but in C++
1951    // projects the header is still C++ and must route to clangd in C++ mode.
1952    // If the detected language is `c`, the file is `.h`, and the surrounding
1953    // tree smells like C++ (sibling C++ sources or an ancestor
1954    // `compile_commands.json`), promote to `cpp` so the LSP binding is right.
1955    if detected.as_deref() == Some("c")
1956        && path.extension().and_then(|e| e.to_str()) == Some("h")
1957        && languages.contains_key("cpp")
1958        && header_in_cpp_tree(path)
1959    {
1960        return Some("cpp".to_string());
1961    }
1962
1963    detected
1964}
1965
1966/// Pure config/path-based language detection without filesystem probing.
1967fn detect_language_by_config(
1968    path: &std::path::Path,
1969    languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
1970) -> Option<String> {
1971    use crate::primitives::glob_match::{
1972        filename_glob_matches, is_glob_pattern, is_path_pattern, path_glob_matches,
1973    };
1974
1975    if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
1976        // 1. Exact filename match (highest priority)
1977        for (language_name, lang_config) in languages {
1978            if lang_config
1979                .filenames
1980                .iter()
1981                .any(|f| !is_glob_pattern(f) && f == filename)
1982            {
1983                return Some(language_name.clone());
1984            }
1985        }
1986
1987        // 2. Glob pattern match
1988        // Path patterns (containing `/`) match against the full path;
1989        // filename-only patterns match against just the filename.
1990        let path_str = path.to_str().unwrap_or("");
1991        for (language_name, lang_config) in languages {
1992            if lang_config.filenames.iter().any(|f| {
1993                if !is_glob_pattern(f) {
1994                    return false;
1995                }
1996                if is_path_pattern(f) {
1997                    path_glob_matches(f, path_str)
1998                } else {
1999                    filename_glob_matches(f, filename)
2000                }
2001            }) {
2002                return Some(language_name.clone());
2003            }
2004        }
2005    }
2006
2007    // 3. Extension match (lowest priority among config-based detection)
2008    if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
2009        for (language_name, lang_config) in languages {
2010            if lang_config.extensions.iter().any(|ext| ext == extension) {
2011                return Some(language_name.clone());
2012            }
2013        }
2014    }
2015
2016    None
2017}
2018
2019/// Filesystem probe: does this header sit inside something that looks like
2020/// a C++ project? Two signals, both conservative:
2021///
2022///   * The file's own directory contains any C++ source or C++-specific
2023///     header (`.cc`, `.cpp`, `.cxx`, `.C`, `.c++`, `.hpp`, `.hh`, `.hxx`).
2024///     Decisive — if the siblings are C++, the header is too.
2025///   * An ancestor up to 10 levels deep contains a `compile_commands.json`
2026///     whose content carries a C++ marker. The mere presence of the file
2027///     is not enough: CMake emits `compile_commands.json` for pure-C
2028///     builds as well, so we peek inside and only promote when the
2029///     payload mentions a C++-specific compiler, flag, or source
2030///     extension (`c++`, `.cpp`, `.cc`, `.cxx`, `.C` ). This still covers
2031///     the fmt / Chromium / LLVM / Qt-style layouts where the header
2032///     lives deep under `include/` while sources sit in `src/` at the
2033///     project root.
2034///
2035/// Bounded by depth (10), by a single shallow `read_dir` at the start,
2036/// and by a capped 1 MiB read of `compile_commands.json`, so the cost is
2037/// a handful of `stat`s plus at most one bounded read on file open.
2038/// Silent on any I/O error — if we can't see the filesystem we fall back
2039/// to the default config answer (C), which is the pre-fix behavior.
2040///
2041/// NOTE(remote-fs): Uses `std::fs` directly, matching the pre-existing
2042/// `detect_workspace_root` in this module. On SSH sessions the probe
2043/// sees the local filesystem, so the promotion silently becomes a no-op
2044/// (returns `false`, falls back to `c`). Fixing this requires threading
2045/// `&dyn FileSystem` through `detect_language` and
2046/// `DetectedLanguage::from_path` — a cross-cutting refactor that should
2047/// be done alongside the same fix for `detect_workspace_root`.
2048fn header_in_cpp_tree(path: &std::path::Path) -> bool {
2049    let Some(start_dir) = path.parent() else {
2050        return false;
2051    };
2052
2053    // 1. Sibling scan in the header's own directory.
2054    if let Ok(entries) = std::fs::read_dir(start_dir) {
2055        for entry in entries.flatten() {
2056            let p = entry.path();
2057            let Some(ext) = p.extension().and_then(|e| e.to_str()) else {
2058                continue;
2059            };
2060            if matches!(
2061                ext,
2062                "cc" | "cpp" | "cxx" | "C" | "c++" | "hpp" | "hh" | "hxx"
2063            ) {
2064                return true;
2065            }
2066        }
2067    }
2068
2069    // 2. Walk ancestors for compile_commands.json, and only promote if
2070    //    the file actually carries a C++ marker — CMake emits it for
2071    //    pure-C builds too.
2072    let mut current = Some(start_dir);
2073    let mut depth = 0u32;
2074    while let Some(dir) = current {
2075        let cc = dir.join("compile_commands.json");
2076        if cc.is_file() && compile_commands_has_cpp_marker(&cc) {
2077            return true;
2078        }
2079        if depth >= 10 {
2080            break;
2081        }
2082        depth += 1;
2083        current = dir.parent();
2084    }
2085
2086    false
2087}
2088
2089/// Returns true when `compile_commands.json` contains a C++ marker —
2090/// either the literal substring `c++` (covers `-std=c++17`, `clang++`,
2091/// `g++`, the `c++` compiler name) or a C++ source extension in a
2092/// context where it cannot be confused with an adjacent header path
2093/// (`.cpp`, `.cc`, `.cxx`). Reads at most 1 MiB so multi-megabyte
2094/// compile DBs from large monorepos don't block file open; a valid CMake
2095/// entry fits comfortably in that window.
2096fn compile_commands_has_cpp_marker(path: &std::path::Path) -> bool {
2097    use std::io::Read;
2098    const MAX_READ: u64 = 1_048_576;
2099
2100    let Ok(file) = std::fs::File::open(path) else {
2101        return false;
2102    };
2103    let mut buf = Vec::with_capacity(64 * 1024);
2104    if file.take(MAX_READ).read_to_end(&mut buf).is_err() {
2105        return false;
2106    }
2107    let Ok(text) = std::str::from_utf8(&buf) else {
2108        return false;
2109    };
2110
2111    // Strongest single marker: literal "c++" appears in -std=c++NN,
2112    // clang++, g++, and the "c++" compiler name — never in a pure-C
2113    // compilation invocation.
2114    if text.contains("c++") {
2115        return true;
2116    }
2117    // Secondary markers: any mention of a C++ source extension in the
2118    // compile DB implies at least one C++ translation unit in the tree.
2119    text.contains(".cpp") || text.contains(".cxx") || text.contains(".cc\"")
2120}
2121
2122#[cfg(test)]
2123mod tests {
2124    use super::*;
2125    use std::path::Path;
2126
2127    #[test]
2128    fn test_lsp_manager_new() {
2129        let root_uri: Option<Uri> = "file:///test".parse().ok();
2130        let manager = LspManager::new(fresh_core::WindowId(1), root_uri.clone());
2131
2132        // Manager should start with no handles
2133        assert_eq!(manager.handles.len(), 0);
2134        assert_eq!(manager.config.len(), 0);
2135        assert!(manager.root_uri.is_some());
2136        assert!(manager.runtime.is_none());
2137        assert!(manager.async_bridge.is_none());
2138    }
2139
2140    #[test]
2141    fn test_lsp_manager_set_language_config() {
2142        let mut manager = LspManager::new(fresh_core::WindowId(1), None);
2143
2144        let config = LspServerConfig {
2145            enabled: true,
2146            command: "rust-analyzer".to_string(),
2147            args: vec![],
2148            process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
2149            auto_start: false,
2150            initialization_options: None,
2151            env: Default::default(),
2152            language_id_overrides: Default::default(),
2153            name: None,
2154            only_features: None,
2155            except_features: None,
2156            root_markers: Default::default(),
2157        };
2158
2159        manager.set_language_config("rust".to_string(), config);
2160
2161        assert_eq!(manager.config.len(), 1);
2162        assert!(manager.config.contains_key("rust"));
2163        assert!(manager.config.get("rust").unwrap().first().unwrap().enabled);
2164    }
2165
2166    #[test]
2167    fn test_lsp_manager_force_spawn_no_runtime() {
2168        let mut manager = LspManager::new(fresh_core::WindowId(1), None);
2169
2170        // Add config for rust
2171        manager.set_language_config(
2172            "rust".to_string(),
2173            LspServerConfig {
2174                enabled: true,
2175                command: "rust-analyzer".to_string(),
2176                args: vec![],
2177                process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
2178                auto_start: false,
2179                initialization_options: None,
2180                env: Default::default(),
2181                language_id_overrides: Default::default(),
2182                name: None,
2183                only_features: None,
2184                except_features: None,
2185                root_markers: Default::default(),
2186            },
2187        );
2188
2189        // force_spawn should return None without runtime
2190        let result = manager.force_spawn("rust", None);
2191        assert!(result.is_none());
2192    }
2193
2194    #[test]
2195    fn test_lsp_manager_force_spawn_no_config() {
2196        let rt = tokio::runtime::Runtime::new().unwrap();
2197        let mut manager = LspManager::new(fresh_core::WindowId(1), None);
2198        let async_bridge = AsyncBridge::new();
2199
2200        manager.set_runtime(rt.handle().clone(), async_bridge);
2201
2202        // force_spawn should return None for unconfigured language
2203        let result = manager.force_spawn("rust", None);
2204        assert!(result.is_none());
2205    }
2206
2207    #[test]
2208    fn test_lsp_manager_force_spawn_disabled_language() {
2209        let rt = tokio::runtime::Runtime::new().unwrap();
2210        let mut manager = LspManager::new(fresh_core::WindowId(1), None);
2211        let async_bridge = AsyncBridge::new();
2212
2213        manager.set_runtime(rt.handle().clone(), async_bridge);
2214
2215        // Add disabled config (command is optional when disabled)
2216        manager.set_language_config(
2217            "rust".to_string(),
2218            LspServerConfig {
2219                enabled: false,
2220                command: String::new(), // command not required when disabled
2221                args: vec![],
2222                process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
2223                auto_start: false,
2224                initialization_options: None,
2225                env: Default::default(),
2226                language_id_overrides: Default::default(),
2227                name: None,
2228                only_features: None,
2229                except_features: None,
2230                root_markers: Default::default(),
2231            },
2232        );
2233
2234        // force_spawn should return None for disabled language
2235        let result = manager.force_spawn("rust", None);
2236        assert!(result.is_none());
2237    }
2238
2239    // try_spawn must distinguish "user disabled it" (enabled=false for
2240    // every configured server) from real spawn failures. Opening a file
2241    // of a deliberately-disabled language should return `Disabled`, not
2242    // `Failed`, so callers can log at debug level instead of warning on
2243    // every file open.
2244    #[test]
2245    fn test_lsp_manager_try_spawn_returns_disabled_when_all_configs_disabled() {
2246        let rt = tokio::runtime::Runtime::new().unwrap();
2247        let mut manager = LspManager::new(fresh_core::WindowId(1), None);
2248        let async_bridge = AsyncBridge::new();
2249        manager.set_runtime(rt.handle().clone(), async_bridge);
2250
2251        manager.set_language_config(
2252            "rust".to_string(),
2253            LspServerConfig {
2254                enabled: false,
2255                command: String::new(),
2256                args: vec![],
2257                process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
2258                auto_start: false,
2259                initialization_options: None,
2260                env: Default::default(),
2261                language_id_overrides: Default::default(),
2262                name: None,
2263                only_features: None,
2264                except_features: None,
2265                root_markers: Default::default(),
2266            },
2267        );
2268
2269        assert_eq!(manager.try_spawn("rust", None), LspSpawnResult::Disabled);
2270    }
2271
2272    // The global master switch (top-level `lsp_enabled=false` config field)
2273    // must block auto-start even for a server that is individually
2274    // enabled+auto_start. It's a deliberate user opt-out, so the result is
2275    // `Disabled` (callers stay silent), not `Failed`.
2276    #[test]
2277    fn test_lsp_manager_try_spawn_returns_disabled_when_globally_disabled() {
2278        let rt = tokio::runtime::Runtime::new().unwrap();
2279        let mut manager = LspManager::new(fresh_core::WindowId(1), None);
2280        let async_bridge = AsyncBridge::new();
2281        manager.set_runtime(rt.handle().clone(), async_bridge);
2282
2283        manager.set_language_config(
2284            "rust".to_string(),
2285            LspServerConfig {
2286                enabled: true,
2287                command: "rust-analyzer".to_string(),
2288                args: vec![],
2289                process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
2290                auto_start: true,
2291                initialization_options: None,
2292                env: Default::default(),
2293                language_id_overrides: Default::default(),
2294                name: None,
2295                only_features: None,
2296                except_features: None,
2297                root_markers: Default::default(),
2298            },
2299        );
2300
2301        manager.set_globally_enabled(false);
2302        assert_eq!(manager.try_spawn("rust", None), LspSpawnResult::Disabled);
2303
2304        // Flipping the switch back restores normal auto-start behaviour
2305        // (the spawn itself may fail in this barebones test setup, but it
2306        // must no longer short-circuit to Disabled).
2307        manager.set_globally_enabled(true);
2308        assert_ne!(manager.try_spawn("rust", None), LspSpawnResult::Disabled);
2309    }
2310
2311    #[test]
2312    fn test_lsp_manager_shutdown_all() {
2313        let mut manager = LspManager::new(fresh_core::WindowId(1), None);
2314
2315        // shutdown_all should not panic even with no handles
2316        manager.shutdown_all();
2317        assert_eq!(manager.handles.len(), 0);
2318    }
2319
2320    fn test_languages() -> std::collections::HashMap<String, crate::config::LanguageConfig> {
2321        let mut languages = std::collections::HashMap::new();
2322        languages.insert(
2323            "rust".to_string(),
2324            crate::config::LanguageConfig {
2325                extensions: vec!["rs".to_string()],
2326                filenames: vec![],
2327                grammar: "rust".to_string(),
2328                comment_prefix: Some("//".to_string()),
2329                auto_indent: true,
2330                auto_close: None,
2331                auto_surround: None,
2332                textmate_grammar: None,
2333                show_whitespace_tabs: false,
2334                line_wrap: None,
2335                wrap_column: None,
2336                page_view: None,
2337                page_width: None,
2338                use_tabs: None,
2339                tab_size: None,
2340                formatter: None,
2341                format_on_save: false,
2342                on_save: vec![],
2343                word_characters: None,
2344                indent: None,
2345            },
2346        );
2347        languages.insert(
2348            "javascript".to_string(),
2349            crate::config::LanguageConfig {
2350                extensions: vec!["js".to_string(), "jsx".to_string()],
2351                filenames: vec![],
2352                grammar: "javascript".to_string(),
2353                comment_prefix: Some("//".to_string()),
2354                auto_indent: true,
2355                auto_close: None,
2356                auto_surround: None,
2357                textmate_grammar: None,
2358                show_whitespace_tabs: false,
2359                line_wrap: None,
2360                wrap_column: None,
2361                page_view: None,
2362                page_width: None,
2363                use_tabs: None,
2364                tab_size: None,
2365                formatter: None,
2366                format_on_save: false,
2367                on_save: vec![],
2368                word_characters: None,
2369                indent: None,
2370            },
2371        );
2372        languages.insert(
2373            "csharp".to_string(),
2374            crate::config::LanguageConfig {
2375                extensions: vec!["cs".to_string()],
2376                filenames: vec![],
2377                grammar: "c_sharp".to_string(),
2378                comment_prefix: Some("//".to_string()),
2379                auto_indent: true,
2380                auto_close: None,
2381                auto_surround: None,
2382                textmate_grammar: None,
2383                show_whitespace_tabs: false,
2384                line_wrap: None,
2385                wrap_column: None,
2386                page_view: None,
2387                page_width: None,
2388                use_tabs: None,
2389                tab_size: None,
2390                formatter: None,
2391                format_on_save: false,
2392                on_save: vec![],
2393                word_characters: None,
2394                indent: None,
2395            },
2396        );
2397        languages
2398    }
2399
2400    #[test]
2401    fn test_detect_language_from_config() {
2402        let languages = test_languages();
2403
2404        // Test configured languages
2405        assert_eq!(
2406            detect_language(Path::new("main.rs"), &languages),
2407            Some("rust".to_string())
2408        );
2409        assert_eq!(
2410            detect_language(Path::new("index.js"), &languages),
2411            Some("javascript".to_string())
2412        );
2413        assert_eq!(
2414            detect_language(Path::new("App.jsx"), &languages),
2415            Some("javascript".to_string())
2416        );
2417        assert_eq!(
2418            detect_language(Path::new("Program.cs"), &languages),
2419            Some("csharp".to_string())
2420        );
2421
2422        // Test unconfigured extensions return None
2423        assert_eq!(detect_language(Path::new("main.py"), &languages), None);
2424        assert_eq!(detect_language(Path::new("file.xyz"), &languages), None);
2425        assert_eq!(detect_language(Path::new("file"), &languages), None);
2426    }
2427
2428    #[test]
2429    fn test_detect_language_no_extension() {
2430        let languages = test_languages();
2431        assert_eq!(detect_language(Path::new("README"), &languages), None);
2432        assert_eq!(detect_language(Path::new("Makefile"), &languages), None);
2433    }
2434
2435    #[test]
2436    fn test_detect_language_path_glob() {
2437        let mut languages = test_languages();
2438        languages.insert(
2439            "shell".to_string(),
2440            crate::config::LanguageConfig {
2441                extensions: vec!["sh".to_string()],
2442                filenames: vec!["/etc/**/rc.*".to_string(), "*rc".to_string()],
2443                grammar: "bash".to_string(),
2444                comment_prefix: Some("#".to_string()),
2445                auto_indent: true,
2446                auto_close: None,
2447                auto_surround: None,
2448                textmate_grammar: None,
2449                show_whitespace_tabs: false,
2450                line_wrap: None,
2451                wrap_column: None,
2452                page_view: None,
2453                page_width: None,
2454                use_tabs: None,
2455                tab_size: None,
2456                formatter: None,
2457                format_on_save: false,
2458                on_save: vec![],
2459                word_characters: None,
2460                indent: None,
2461            },
2462        );
2463
2464        // Path glob: /etc/**/rc.* should match
2465        assert_eq!(
2466            detect_language(Path::new("/etc/rc.conf"), &languages),
2467            Some("shell".to_string())
2468        );
2469        assert_eq!(
2470            detect_language(Path::new("/etc/init/rc.local"), &languages),
2471            Some("shell".to_string())
2472        );
2473        // Path glob should NOT match different root
2474        assert_eq!(detect_language(Path::new("/var/rc.conf"), &languages), None);
2475
2476        // Filename glob: *rc should still work
2477        assert_eq!(
2478            detect_language(Path::new("lfrc"), &languages),
2479            Some("shell".to_string())
2480        );
2481    }
2482
2483    #[test]
2484    fn test_detect_workspace_root_finds_marker_in_parent() {
2485        let tmp = tempfile::tempdir().unwrap();
2486        let project = tmp.path().join("myproject");
2487        let src = project.join("src");
2488        std::fs::create_dir_all(&src).unwrap();
2489        std::fs::write(project.join("Cargo.toml"), "").unwrap();
2490        let file = src.join("main.rs");
2491        std::fs::write(&file, "").unwrap();
2492
2493        let root = detect_workspace_root(&file, &["Cargo.toml".to_string(), ".git".to_string()]);
2494        assert_eq!(root, project);
2495    }
2496
2497    #[test]
2498    fn test_detect_workspace_root_finds_marker_two_levels_up() {
2499        let tmp = tempfile::tempdir().unwrap();
2500        let project = tmp.path().join("myproject");
2501        let deep = project.join("src").join("nested");
2502        std::fs::create_dir_all(&deep).unwrap();
2503        std::fs::write(project.join("Cargo.toml"), "").unwrap();
2504        let file = deep.join("lib.rs");
2505        std::fs::write(&file, "").unwrap();
2506
2507        let root = detect_workspace_root(&file, &["Cargo.toml".to_string()]);
2508        assert_eq!(root, project);
2509    }
2510
2511    #[test]
2512    fn test_detect_workspace_root_no_marker_returns_parent() {
2513        let tmp = tempfile::tempdir().unwrap();
2514        let dir = tmp.path().join("somedir");
2515        std::fs::create_dir_all(&dir).unwrap();
2516        let file = dir.join("file.txt");
2517        std::fs::write(&file, "").unwrap();
2518
2519        let root = detect_workspace_root(&file, &["nonexistent_marker".to_string()]);
2520        assert_eq!(root, dir);
2521    }
2522
2523    #[test]
2524    fn test_detect_workspace_root_empty_markers_returns_parent() {
2525        let tmp = tempfile::tempdir().unwrap();
2526        let dir = tmp.path().join("somedir");
2527        std::fs::create_dir_all(&dir).unwrap();
2528        let file = dir.join("file.txt");
2529        std::fs::write(&file, "").unwrap();
2530
2531        let root = detect_workspace_root(&file, &[]);
2532        assert_eq!(root, dir);
2533    }
2534
2535    #[test]
2536    fn test_detect_workspace_root_directory_marker() {
2537        let tmp = tempfile::tempdir().unwrap();
2538        let project = tmp.path().join("myproject");
2539        let src = project.join("src");
2540        std::fs::create_dir_all(&src).unwrap();
2541        std::fs::create_dir_all(project.join(".git")).unwrap();
2542        let file = src.join("main.rs");
2543        std::fs::write(&file, "").unwrap();
2544
2545        let root = detect_workspace_root(&file, &[".git".to_string()]);
2546        assert_eq!(root, project);
2547    }
2548
2549    /// Returns a languages map mirroring the default config's `c` + `cpp`
2550    /// entries: `.h` maps to `c`, and `.cpp/.cc/.cxx/.hpp/.hh/.hxx` map to
2551    /// `cpp`. Matches `config.rs:3010` and `:3040-3047` so the promotion
2552    /// logic is exercised under realistic config.
2553    fn c_cpp_languages() -> std::collections::HashMap<String, crate::config::LanguageConfig> {
2554        use crate::config::LanguageConfig;
2555        let mut languages = std::collections::HashMap::new();
2556        let base = LanguageConfig {
2557            extensions: vec![],
2558            filenames: vec![],
2559            grammar: String::new(),
2560            comment_prefix: Some("//".to_string()),
2561            auto_indent: true,
2562            auto_close: None,
2563            auto_surround: None,
2564            textmate_grammar: None,
2565            show_whitespace_tabs: false,
2566            line_wrap: None,
2567            wrap_column: None,
2568            page_view: None,
2569            page_width: None,
2570            use_tabs: None,
2571            tab_size: None,
2572            formatter: None,
2573            format_on_save: false,
2574            on_save: vec![],
2575            word_characters: None,
2576            indent: None,
2577        };
2578        languages.insert(
2579            "c".to_string(),
2580            LanguageConfig {
2581                extensions: vec!["c".to_string(), "h".to_string()],
2582                grammar: "c".to_string(),
2583                ..base.clone()
2584            },
2585        );
2586        languages.insert(
2587            "cpp".to_string(),
2588            LanguageConfig {
2589                extensions: vec![
2590                    "cpp".to_string(),
2591                    "cc".to_string(),
2592                    "cxx".to_string(),
2593                    "hpp".to_string(),
2594                    "hh".to_string(),
2595                    "hxx".to_string(),
2596                ],
2597                grammar: "cpp".to_string(),
2598                ..base
2599            },
2600        );
2601        languages
2602    }
2603
2604    #[test]
2605    fn test_detect_language_h_stays_c_without_cpp_signals() {
2606        // No filesystem context — plain `Path::new("foo.h")` doesn't exist,
2607        // so sibling scan + compile_commands walk both return false and the
2608        // default-config answer (`c`) survives.
2609        let languages = c_cpp_languages();
2610        assert_eq!(
2611            detect_language(Path::new("foo.h"), &languages),
2612            Some("c".to_string())
2613        );
2614    }
2615
2616    #[test]
2617    fn test_detect_language_h_promotes_to_cpp_with_sibling_cpp_source() {
2618        let tmp = tempfile::tempdir().unwrap();
2619        let project = tmp.path().join("proj");
2620        std::fs::create_dir_all(&project).unwrap();
2621        let header = project.join("widget.h");
2622        std::fs::write(&header, "").unwrap();
2623        // Sibling .cpp source — the decisive C++ signal.
2624        std::fs::write(project.join("widget.cpp"), "").unwrap();
2625
2626        let languages = c_cpp_languages();
2627        assert_eq!(
2628            detect_language(&header, &languages),
2629            Some("cpp".to_string())
2630        );
2631    }
2632
2633    #[test]
2634    fn test_detect_language_h_promotes_to_cpp_with_sibling_hpp() {
2635        let tmp = tempfile::tempdir().unwrap();
2636        let project = tmp.path().join("proj");
2637        std::fs::create_dir_all(&project).unwrap();
2638        let header = project.join("a.h");
2639        std::fs::write(&header, "").unwrap();
2640        // A `.hpp` sibling is also a C++-specific signal.
2641        std::fs::write(project.join("b.hpp"), "").unwrap();
2642
2643        let languages = c_cpp_languages();
2644        assert_eq!(
2645            detect_language(&header, &languages),
2646            Some("cpp".to_string())
2647        );
2648    }
2649
2650    #[test]
2651    fn test_detect_language_h_promotes_to_cpp_with_ancestor_compile_commands() {
2652        let tmp = tempfile::tempdir().unwrap();
2653        let project = tmp.path().join("proj");
2654        let include = project.join("include").join("fmt");
2655        std::fs::create_dir_all(&include).unwrap();
2656        // Compile DB two levels above the header — the fmt-style layout.
2657        // Realistic CMake output: the compile command references clang++
2658        // and a C++ source, which is the C++ marker we key on.
2659        std::fs::write(
2660            project.join("compile_commands.json"),
2661            r#"[{"directory":"/proj","command":"/usr/bin/clang++ -std=c++17 -c src/format.cc","file":"src/format.cc"}]"#,
2662        ).unwrap();
2663        let header = include.join("format.h");
2664        std::fs::write(&header, "").unwrap();
2665
2666        let languages = c_cpp_languages();
2667        assert_eq!(
2668            detect_language(&header, &languages),
2669            Some("cpp".to_string())
2670        );
2671    }
2672
2673    #[test]
2674    fn test_detect_language_h_stays_c_with_pure_c_compile_commands() {
2675        // A compile_commands.json generated for a pure-C project (gcc,
2676        // -std=c11, .c sources only) must NOT promote .h to cpp.
2677        let tmp = tempfile::tempdir().unwrap();
2678        let project = tmp.path().join("cproj");
2679        let include = project.join("include");
2680        std::fs::create_dir_all(&include).unwrap();
2681        std::fs::write(
2682            project.join("compile_commands.json"),
2683            r#"[{"directory":"/cproj","command":"/usr/bin/gcc -std=c11 -c src/lib.c","file":"src/lib.c"}]"#,
2684        )
2685        .unwrap();
2686        let header = include.join("lib.h");
2687        std::fs::write(&header, "").unwrap();
2688
2689        let languages = c_cpp_languages();
2690        assert_eq!(detect_language(&header, &languages), Some("c".to_string()));
2691    }
2692
2693    #[test]
2694    fn test_detect_language_h_stays_c_in_pure_c_tree() {
2695        let tmp = tempfile::tempdir().unwrap();
2696        let project = tmp.path().join("cproj");
2697        std::fs::create_dir_all(&project).unwrap();
2698        let header = project.join("lib.h");
2699        std::fs::write(&header, "").unwrap();
2700        // Only `.c` siblings — no C++ signal, no compile_commands.json.
2701        std::fs::write(project.join("lib.c"), "").unwrap();
2702
2703        let languages = c_cpp_languages();
2704        assert_eq!(detect_language(&header, &languages), Some("c".to_string()));
2705    }
2706
2707    #[test]
2708    fn test_detect_language_h_stays_c_with_empty_compile_commands() {
2709        // Empty / minimal compile_commands.json carries no C++ marker,
2710        // so we stay conservative and leave the header as C.
2711        let tmp = tempfile::tempdir().unwrap();
2712        let project = tmp.path().join("proj");
2713        std::fs::create_dir_all(&project).unwrap();
2714        std::fs::write(project.join("compile_commands.json"), "[]").unwrap();
2715        let header = project.join("foo.h");
2716        std::fs::write(&header, "").unwrap();
2717
2718        let languages = c_cpp_languages();
2719        assert_eq!(detect_language(&header, &languages), Some("c".to_string()));
2720    }
2721
2722    #[test]
2723    fn test_detect_language_h_promotes_on_cpp_std_flag_alone() {
2724        // `-std=c++20` with no other C++ extension is still conclusive.
2725        let tmp = tempfile::tempdir().unwrap();
2726        let project = tmp.path().join("proj");
2727        let include = project.join("include");
2728        std::fs::create_dir_all(&include).unwrap();
2729        std::fs::write(
2730            project.join("compile_commands.json"),
2731            // A contrived entry using the `.C` (capital) source extension
2732            // with the c++20 flag — tests that the "c++" substring alone
2733            // is sufficient even when our `.cpp/.cc/.cxx` scan would miss.
2734            r#"[{"directory":"/proj","command":"/usr/bin/clang -std=c++20 -c src/x.C","file":"src/x.C"}]"#,
2735        )
2736        .unwrap();
2737        let header = include.join("x.h");
2738        std::fs::write(&header, "").unwrap();
2739
2740        let languages = c_cpp_languages();
2741        assert_eq!(
2742            detect_language(&header, &languages),
2743            Some("cpp".to_string())
2744        );
2745    }
2746
2747    #[test]
2748    fn test_detect_language_c_source_never_promoted() {
2749        // `.c` files should stay `c` even in a C++ tree.
2750        let tmp = tempfile::tempdir().unwrap();
2751        let project = tmp.path().join("proj");
2752        std::fs::create_dir_all(&project).unwrap();
2753        let source = project.join("legacy.c");
2754        std::fs::write(&source, "").unwrap();
2755        std::fs::write(project.join("main.cpp"), "").unwrap();
2756
2757        let languages = c_cpp_languages();
2758        assert_eq!(detect_language(&source, &languages), Some("c".to_string()));
2759    }
2760
2761    #[test]
2762    fn test_detect_language_h_no_promotion_without_cpp_config() {
2763        // If the user hasn't configured `cpp`, we have nowhere to promote to
2764        // — stay with the base detection rather than inventing a language.
2765        let tmp = tempfile::tempdir().unwrap();
2766        let project = tmp.path().join("proj");
2767        std::fs::create_dir_all(&project).unwrap();
2768        let header = project.join("widget.h");
2769        std::fs::write(&header, "").unwrap();
2770        std::fs::write(project.join("widget.cpp"), "").unwrap();
2771
2772        let mut languages = c_cpp_languages();
2773        languages.remove("cpp");
2774        assert_eq!(detect_language(&header, &languages), Some("c".to_string()));
2775    }
2776
2777    #[test]
2778    fn test_path_to_uri_basic() {
2779        let uri = path_to_uri(Path::new("/tmp/test")).unwrap();
2780        assert_eq!(uri.as_str(), "file:///tmp/test");
2781    }
2782
2783    #[test]
2784    fn test_path_to_uri_with_spaces() {
2785        let uri = path_to_uri(Path::new("/tmp/my project/src")).unwrap();
2786        assert_eq!(uri.as_str(), "file:///tmp/my%20project/src");
2787    }
2788
2789    #[test]
2790    fn dynamic_registration_enables_then_disables_inlay_hints() {
2791        // A server that advertised no static inlayHintProvider but registers it
2792        // dynamically must end up with the capability enabled — and unregister
2793        // must turn it back off (sinelaw/fresh#2195 §1).
2794        let mut caps = ServerCapabilitySummary::default();
2795        assert!(!caps.inlay_hints);
2796
2797        let recognized = caps.apply_dynamic_registration("textDocument/inlayHint", None, true);
2798        assert!(
2799            recognized,
2800            "inlayHint must be a recognized capability method"
2801        );
2802        assert!(
2803            caps.inlay_hints,
2804            "dynamic registration must enable inlay hints"
2805        );
2806
2807        let recognized = caps.apply_dynamic_registration("textDocument/inlayHint", None, false);
2808        assert!(recognized);
2809        assert!(!caps.inlay_hints, "unregister must disable inlay hints");
2810    }
2811
2812    #[test]
2813    fn dynamic_registration_ignores_unknown_methods() {
2814        // Methods we don't gate a feature on (e.g. file watching, handled
2815        // elsewhere) must report "not recognized" so the caller doesn't
2816        // needlessly re-issue feature requests.
2817        let mut caps = ServerCapabilitySummary::default();
2818        let recognized =
2819            caps.apply_dynamic_registration("workspace/didChangeWatchedFiles", None, true);
2820        assert!(!recognized);
2821    }
2822
2823    #[test]
2824    fn dynamic_registration_parses_completion_options() {
2825        let mut caps = ServerCapabilitySummary::default();
2826        let opts = serde_json::json!({
2827            "triggerCharacters": [".", "::"],
2828            "resolveProvider": true,
2829        });
2830        let recognized =
2831            caps.apply_dynamic_registration("textDocument/completion", Some(&opts), true);
2832        assert!(recognized);
2833        assert!(caps.completion);
2834        assert!(caps.completion_resolve);
2835        assert_eq!(caps.completion_trigger_characters, vec![".", "::"]);
2836
2837        // Unregister clears the derived state too.
2838        caps.apply_dynamic_registration("textDocument/completion", None, false);
2839        assert!(!caps.completion);
2840        assert!(!caps.completion_resolve);
2841        assert!(caps.completion_trigger_characters.is_empty());
2842    }
2843
2844    #[test]
2845    fn dynamic_registration_parses_semantic_tokens_legend() {
2846        let mut caps = ServerCapabilitySummary::default();
2847        let opts = serde_json::json!({
2848            "legend": {
2849                "tokenTypes": ["namespace", "type"],
2850                "tokenModifiers": ["declaration"],
2851            },
2852            "full": { "delta": true },
2853            "range": true,
2854        });
2855        let recognized =
2856            caps.apply_dynamic_registration("textDocument/semanticTokens", Some(&opts), true);
2857        assert!(recognized);
2858        assert!(caps.semantic_tokens_full);
2859        assert!(caps.semantic_tokens_full_delta);
2860        assert!(caps.semantic_tokens_range);
2861        let legend = caps
2862            .semantic_tokens_legend
2863            .as_ref()
2864            .expect("legend must be parsed from registration options");
2865        assert_eq!(legend.token_types.len(), 2);
2866
2867        caps.apply_dynamic_registration("textDocument/semanticTokens", None, false);
2868        assert!(!caps.semantic_tokens_full);
2869        assert!(caps.semantic_tokens_legend.is_none());
2870    }
2871
2872    #[test]
2873    fn apply_dynamic_capabilities_reports_change_only_for_known_methods() {
2874        // The manager-level entry point returns whether any recognized feature
2875        // flag changed, which gates whether the dispatcher re-issues requests.
2876        let mut caps = ServerCapabilitySummary::default();
2877        let known = caps.apply_dynamic_registration("textDocument/hover", None, true);
2878        let unknown = caps.apply_dynamic_registration("some/unknownMethod", None, true);
2879        assert!(known);
2880        assert!(!unknown);
2881        assert!(caps.hover);
2882    }
2883}