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