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