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