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