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