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