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, HashSet};
14use std::path::Path;
15use std::time::{Duration, Instant};
16
17/// Result of attempting to spawn an LSP server
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum LspSpawnResult {
20    /// Server was spawned or already running
21    Spawned,
22    /// Server is not configured for auto-start
23    /// The server can still be started manually via command palette
24    NotAutoStart,
25    /// No LSP server is configured for this language
26    NotConfigured,
27    /// Server spawn failed or is disabled
28    Failed,
29}
30
31/// Constants for restart behavior
32const MAX_RESTARTS_IN_WINDOW: usize = 5;
33const RESTART_WINDOW_SECS: u64 = 180; // 3 minutes
34const RESTART_BACKOFF_BASE_MS: u64 = 1000; // 1s, 2s, 4s, 8s...
35
36/// Convert a directory path to an LSP `file://` URI without the `url` crate.
37fn path_to_uri(path: &Path) -> Option<Uri> {
38    let abs = if path.is_absolute() {
39        path.to_path_buf()
40    } else {
41        std::env::current_dir().ok()?.join(path)
42    };
43    // Percent-encode each path component for RFC 3986 compliance
44    let encoded: String = abs
45        .components()
46        .filter_map(|c| match c {
47            std::path::Component::RootDir => None, // handled by leading '/' in Normal
48            std::path::Component::Normal(s) => {
49                let s = s.to_str()?;
50                let mut out = String::with_capacity(s.len() + 1);
51                out.push('/');
52                for b in s.bytes() {
53                    if b.is_ascii_alphanumeric()
54                        || matches!(
55                            b,
56                            b'-' | b'.'
57                                | b'_'
58                                | b'~'
59                                | b'@'
60                                | b'!'
61                                | b'$'
62                                | b'&'
63                                | b'\''
64                                | b'('
65                                | b')'
66                                | b'+'
67                                | b','
68                                | b';'
69                                | b'='
70                        )
71                    {
72                        out.push(b as char);
73                    } else {
74                        out.push_str(&format!("%{:02X}", b));
75                    }
76                }
77                Some(out)
78            }
79            _ => None,
80        })
81        .collect();
82    format!("file://{}", encoded).parse().ok()
83}
84
85/// Detect workspace root by walking upward from a file looking for marker files/directories.
86///
87/// Returns the first directory containing any of the markers, or the file's parent
88/// directory if no marker is found.
89pub fn detect_workspace_root(file_path: &Path, root_markers: &[String]) -> std::path::PathBuf {
90    let file_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
91
92    if root_markers.is_empty() {
93        return file_dir;
94    }
95
96    let mut dir = Some(file_dir.as_path());
97    while let Some(d) = dir {
98        for marker in root_markers {
99            if d.join(marker).exists() {
100                return d.to_path_buf();
101            }
102        }
103        dir = d.parent();
104    }
105
106    file_dir
107}
108
109/// Summary of capabilities reported by an LSP server during initialization.
110///
111/// This is extracted from `ServerCapabilities` in the `initialize` response
112/// and stored per-server so that requests are only sent to servers that
113/// actually support them. Follows the LSP 3.17 specification.
114///
115#[derive(Debug, Clone, Default)]
116pub struct ServerCapabilitySummary {
117    /// Whether capabilities have been received from the server.
118    /// When false, `has_capability()` defers to the handle's readiness state.
119    pub initialized: bool,
120    pub hover: bool,
121    pub completion: bool,
122    pub completion_resolve: bool,
123    pub completion_trigger_characters: Vec<String>,
124    pub definition: bool,
125    pub references: bool,
126    pub document_formatting: bool,
127    pub document_range_formatting: bool,
128    pub rename: bool,
129    pub signature_help: bool,
130    pub inlay_hints: bool,
131    pub folding_ranges: bool,
132    pub semantic_tokens_full: bool,
133    pub semantic_tokens_full_delta: bool,
134    pub semantic_tokens_range: bool,
135    pub semantic_tokens_legend: Option<SemanticTokensLegend>,
136    pub document_highlight: bool,
137    pub code_action: bool,
138    pub code_action_resolve: bool,
139    pub document_symbols: bool,
140    pub workspace_symbols: bool,
141    pub diagnostics: bool,
142}
143
144/// A named LSP handle with feature filter metadata and per-server capabilities.
145/// Wraps an LspHandle with the server's display name, feature routing filter,
146/// and the capabilities reported by this specific server during initialization.
147pub struct ServerHandle {
148    /// Display name for this server (e.g., "rust-analyzer", "eslint")
149    pub name: String,
150    /// The underlying LSP handle
151    pub handle: LspHandle,
152    /// Feature filter controlling which LSP features this server handles
153    pub feature_filter: FeatureFilter,
154    /// Capabilities reported by this server during initialization.
155    pub capabilities: ServerCapabilitySummary,
156}
157
158impl ServerHandle {
159    /// Check if this server has the actual capability for a feature.
160    ///
161    /// Checks the server's reported capabilities (from the `initialize` response).
162    /// Before initialization completes (capabilities not yet received), returns
163    /// `false` — the main loop must not route feature requests to servers whose
164    /// capabilities are unknown. Callers handle `None` from `handle_for_feature_mut`
165    /// by relying on existing retry mechanisms (render-cycle polling, timer retries,
166    /// or explicit re-requests from the `LspInitialized` handler).
167    pub fn has_capability(&self, feature: LspFeature) -> bool {
168        if !self.capabilities.initialized {
169            return false;
170        }
171        match feature {
172            LspFeature::Hover => self.capabilities.hover,
173            LspFeature::Completion => self.capabilities.completion,
174            LspFeature::Definition => self.capabilities.definition,
175            LspFeature::References => self.capabilities.references,
176            LspFeature::Format => {
177                self.capabilities.document_formatting || self.capabilities.document_range_formatting
178            }
179            LspFeature::Rename => self.capabilities.rename,
180            LspFeature::SignatureHelp => self.capabilities.signature_help,
181            LspFeature::InlayHints => self.capabilities.inlay_hints,
182            LspFeature::FoldingRange => self.capabilities.folding_ranges,
183            LspFeature::SemanticTokens => {
184                self.capabilities.semantic_tokens_full || self.capabilities.semantic_tokens_range
185            }
186            LspFeature::DocumentHighlight => self.capabilities.document_highlight,
187            LspFeature::CodeAction => self.capabilities.code_action,
188            LspFeature::DocumentSymbols => self.capabilities.document_symbols,
189            LspFeature::WorkspaceSymbols => self.capabilities.workspace_symbols,
190            LspFeature::Diagnostics => self.capabilities.diagnostics,
191        }
192    }
193}
194
195/// Manager for multiple language servers (async version)
196pub struct LspManager {
197    /// Map from language ID to LSP handles (supports multiple servers per language)
198    handles: HashMap<String, Vec<ServerHandle>>,
199
200    /// Configuration for each language (supports multiple servers per language)
201    config: HashMap<String, Vec<LspServerConfig>>,
202
203    /// Default root URI for workspace (used if no per-language root is set)
204    root_uri: Option<Uri>,
205
206    /// Per-language root URIs (allows plugins to specify project roots)
207    per_language_root_uris: HashMap<String, Uri>,
208
209    /// Tokio runtime reference
210    runtime: Option<tokio::runtime::Handle>,
211
212    /// Async bridge for communication
213    async_bridge: Option<AsyncBridge>,
214
215    /// Restart attempt timestamps per language (for tracking restart frequency)
216    restart_attempts: HashMap<String, Vec<Instant>>,
217
218    /// Languages currently in restart cooldown (gave up after too many restarts)
219    restart_cooldown: HashSet<String>,
220
221    /// Scheduled restart times (language -> when to restart)
222    pending_restarts: HashMap<String, Instant>,
223
224    /// Languages that have been manually started by the user
225    /// If a language is in this set, it will spawn even if auto_start=false in config
226    allowed_languages: HashSet<String>,
227
228    /// Languages that have been explicitly disabled/stopped by the user
229    /// These will not auto-restart until user manually restarts them
230    disabled_languages: HashSet<String>,
231}
232
233impl LspManager {
234    /// Create a new LSP manager
235    pub fn new(root_uri: Option<Uri>) -> Self {
236        Self {
237            handles: HashMap::new(),
238            config: HashMap::new(),
239            root_uri,
240            per_language_root_uris: HashMap::new(),
241            runtime: None,
242            async_bridge: None,
243            restart_attempts: HashMap::new(),
244            restart_cooldown: HashSet::new(),
245            pending_restarts: HashMap::new(),
246            allowed_languages: HashSet::new(),
247            disabled_languages: HashSet::new(),
248        }
249    }
250
251    /// Check if a language has been manually enabled (allowing spawn even if auto_start=false)
252    pub fn is_language_allowed(&self, language: &str) -> bool {
253        self.allowed_languages.contains(language)
254    }
255
256    /// Allow a language to spawn LSP server (used by manual start command)
257    pub fn allow_language(&mut self, language: &str) {
258        self.allowed_languages.insert(language.to_string());
259        tracing::info!("LSP language '{}' manually enabled", language);
260    }
261
262    /// Get the set of manually enabled languages
263    pub fn allowed_languages(&self) -> &HashSet<String> {
264        &self.allowed_languages
265    }
266
267    /// Get the configurations for a specific language (one or more servers).
268    pub fn get_configs(&self, language: &str) -> Option<&[LspServerConfig]> {
269        self.config.get(language).map(|v| v.as_slice())
270    }
271
272    /// Get the primary (first) configuration for a specific language.
273    pub fn get_config(&self, language: &str) -> Option<&LspServerConfig> {
274        self.config.get(language).and_then(|v| v.first())
275    }
276
277    /// Store capabilities on the specific server handle identified by server_name.
278    pub fn set_server_capabilities(
279        &mut self,
280        language: &str,
281        server_name: &str,
282        mut capabilities: ServerCapabilitySummary,
283    ) {
284        capabilities.initialized = true;
285        if let Some(handles) = self.handles.get_mut(language) {
286            if let Some(sh) = handles.iter_mut().find(|sh| sh.name == server_name) {
287                sh.capabilities = capabilities;
288            }
289        }
290    }
291
292    /// Get the semantic token legend for a language from the first eligible server.
293    pub fn semantic_tokens_legend(&self, language: &str) -> Option<&SemanticTokensLegend> {
294        self.handles.get(language)?.iter().find_map(|sh| {
295            if sh.feature_filter.allows(LspFeature::SemanticTokens)
296                && sh.has_capability(LspFeature::SemanticTokens)
297            {
298                sh.capabilities.semantic_tokens_legend.as_ref()
299            } else {
300                None
301            }
302        })
303    }
304
305    /// Check if any eligible server for the language supports full semantic tokens.
306    pub fn semantic_tokens_full_supported(&self, language: &str) -> bool {
307        self.handles.get(language).is_some_and(|handles| {
308            handles.iter().any(|sh| {
309                sh.feature_filter.allows(LspFeature::SemanticTokens)
310                    && sh.capabilities.semantic_tokens_full
311            })
312        })
313    }
314
315    /// Check if any eligible server for the language supports full semantic token deltas.
316    pub fn semantic_tokens_full_delta_supported(&self, language: &str) -> bool {
317        self.handles.get(language).is_some_and(|handles| {
318            handles.iter().any(|sh| {
319                sh.feature_filter.allows(LspFeature::SemanticTokens)
320                    && sh.capabilities.semantic_tokens_full_delta
321            })
322        })
323    }
324
325    /// Check if any eligible server for the language supports range semantic tokens.
326    pub fn semantic_tokens_range_supported(&self, language: &str) -> bool {
327        self.handles.get(language).is_some_and(|handles| {
328            handles.iter().any(|sh| {
329                sh.feature_filter.allows(LspFeature::SemanticTokens)
330                    && sh.capabilities.semantic_tokens_range
331            })
332        })
333    }
334
335    /// Check if any eligible server for the language supports folding ranges.
336    pub fn folding_ranges_supported(&self, language: &str) -> bool {
337        self.handles.get(language).is_some_and(|handles| {
338            handles.iter().any(|sh| {
339                sh.feature_filter.allows(LspFeature::FoldingRange) && sh.capabilities.folding_ranges
340            })
341        })
342    }
343
344    /// Check if a character is a completion trigger for any running language server.
345    pub fn is_completion_trigger_char(&self, ch: char, language: &str) -> bool {
346        let ch_str = ch.to_string();
347        self.handles.get(language).is_some_and(|handles| {
348            handles.iter().any(|sh| {
349                sh.feature_filter.allows(LspFeature::Completion)
350                    && sh
351                        .capabilities
352                        .completion_trigger_characters
353                        .contains(&ch_str)
354            })
355        })
356    }
357
358    /// Try to spawn an LSP server, checking auto_start configuration
359    ///
360    /// This is the main entry point for spawning LSP servers on file open.
361    /// It returns:
362    /// - `LspSpawnResult::Spawned` if the server was spawned or already running
363    /// - `LspSpawnResult::NotAutoStart` if auto_start is false and not manually allowed
364    /// - `LspSpawnResult::NotConfigured` if no LSP server is configured for the language
365    /// - `LspSpawnResult::Failed` if spawn failed or language is disabled
366    ///
367    /// The `file_path` is used for workspace root detection via `root_markers`.
368    ///
369    /// IMPORTANT: Callers should only call this when there is at least one buffer
370    /// with a matching language. Do not call for languages with no open files.
371    pub fn try_spawn(&mut self, language: &str, file_path: Option<&Path>) -> LspSpawnResult {
372        // If handles already exist for this language, return success
373        if self.handles.get(language).is_some_and(|v| !v.is_empty()) {
374            return LspSpawnResult::Spawned;
375        }
376
377        // Check if language is configured
378        let configs = match self.config.get(language) {
379            Some(configs) if !configs.is_empty() => configs,
380            _ => return LspSpawnResult::NotConfigured,
381        };
382
383        // Check if any config is enabled
384        if !configs.iter().any(|c| c.enabled) {
385            return LspSpawnResult::Failed;
386        }
387
388        // Check if we have runtime and bridge
389        if self.runtime.is_none() || self.async_bridge.is_none() {
390            return LspSpawnResult::Failed;
391        }
392
393        // Check if auto_start is enabled (on any config) or language was manually allowed
394        let any_auto_start = configs.iter().any(|c| c.auto_start && c.enabled);
395        if !any_auto_start && !self.allowed_languages.contains(language) {
396            return LspSpawnResult::NotAutoStart;
397        }
398
399        // Spawn all enabled servers for this language
400        if self.force_spawn(language, file_path).is_some() {
401            LspSpawnResult::Spawned
402        } else {
403            LspSpawnResult::Failed
404        }
405    }
406
407    /// Set the Tokio runtime and async bridge
408    ///
409    /// Must be called before spawning any servers
410    pub fn set_runtime(&mut self, runtime: tokio::runtime::Handle, async_bridge: AsyncBridge) {
411        self.runtime = Some(runtime);
412        self.async_bridge = Some(async_bridge);
413    }
414
415    /// Set configuration for a language (single server).
416    pub fn set_language_config(&mut self, language: String, config: LspServerConfig) {
417        self.config.insert(language, vec![config]);
418    }
419
420    /// Set configurations for a language (one or more servers).
421    pub fn set_language_configs(&mut self, language: String, configs: Vec<LspServerConfig>) {
422        self.config.insert(language, configs);
423    }
424
425    /// Set a new root URI for the workspace
426    ///
427    /// This should be called after shutting down all servers when switching projects.
428    /// Servers spawned after this will use the new root URI.
429    pub fn set_root_uri(&mut self, root_uri: Option<Uri>) {
430        self.root_uri = root_uri;
431    }
432
433    /// Set a language-specific root URI
434    ///
435    /// This allows plugins to specify project roots for specific languages.
436    /// For example, a C# plugin can set the root to the directory containing .csproj.
437    /// Returns true if an existing server was restarted with the new root.
438    pub fn set_language_root_uri(&mut self, language: &str, uri: Uri) -> bool {
439        tracing::info!("Setting root URI for {}: {}", language, uri.as_str());
440        self.per_language_root_uris
441            .insert(language.to_string(), uri.clone());
442
443        // If there's an existing server for this language, restart it with the new root
444        if self.handles.contains_key(language) {
445            tracing::info!(
446                "Restarting {} LSP server with new root: {}",
447                language,
448                uri.as_str()
449            );
450            self.shutdown_server(language);
451            // The server will be respawned on next request with the new root
452            return true;
453        }
454        false
455    }
456
457    /// Resolve the root URI for a language, using root_markers for detection.
458    ///
459    /// Priority:
460    /// 1. Plugin-set per-language root (per_language_root_uris)
461    /// 2. Walk upward from file_path using config's root_markers
462    /// 3. File's parent directory
463    pub fn resolve_root_uri(&self, language: &str, file_path: Option<&Path>) -> Option<Uri> {
464        // 1. Plugin-set root takes priority
465        if let Some(uri) = self.per_language_root_uris.get(language) {
466            return Some(uri.clone());
467        }
468
469        // 2. Use root_markers to detect workspace root from file path
470        if let Some(path) = file_path {
471            let markers = self
472                .config
473                .get(language)
474                .and_then(|configs| configs.first())
475                .map(|c| c.root_markers.as_slice())
476                .unwrap_or(&[]);
477            let root = detect_workspace_root(path, markers);
478            if let Some(uri) = path_to_uri(&root) {
479                return Some(uri);
480            }
481        }
482
483        // 3. No file path available — use the global root_uri
484        self.root_uri.clone()
485    }
486
487    /// Get the effective root URI for a language (legacy, without file-based detection)
488    ///
489    /// Returns the language-specific root if set, otherwise the default root.
490    pub fn get_effective_root_uri(&self, language: &str) -> Option<Uri> {
491        self.resolve_root_uri(language, None)
492    }
493
494    /// Reset the manager for a new project
495    ///
496    /// This shuts down all servers and clears state, preparing for a fresh start.
497    /// The configuration is preserved but servers will need to be respawned.
498    pub fn reset_for_new_project(&mut self, new_root_uri: Option<Uri>) {
499        // Shutdown all servers
500        self.shutdown_all();
501
502        // Update root URI
503        self.root_uri = new_root_uri;
504
505        // Clear restart tracking state (fresh start)
506        self.restart_attempts.clear();
507        self.restart_cooldown.clear();
508        self.pending_restarts.clear();
509
510        // Keep allowed_languages and disabled_languages as user preferences
511        // Keep config as it's not project-specific
512
513        tracing::info!(
514            "LSP manager reset for new project: {:?}",
515            self.root_uri.as_ref().map(|u| u.as_str())
516        );
517    }
518
519    /// Get the primary (first) existing LSP handle for a language (no spawning).
520    pub fn get_handle(&self, language: &str) -> Option<&LspHandle> {
521        self.handles
522            .get(language)
523            .and_then(|v| v.first())
524            .map(|sh| &sh.handle)
525    }
526
527    /// Get the primary (first) mutable existing LSP handle for a language (no spawning).
528    pub fn get_handle_mut(&mut self, language: &str) -> Option<&mut LspHandle> {
529        self.handles
530            .get_mut(language)
531            .and_then(|v| v.first_mut())
532            .map(|sh| &mut sh.handle)
533    }
534
535    /// Get all handles for a language (for broadcasting operations like didOpen/didChange).
536    pub fn get_handles(&self, language: &str) -> &[ServerHandle] {
537        self.handles
538            .get(language)
539            .map(|v| v.as_slice())
540            .unwrap_or(&[])
541    }
542
543    /// Check if a server with the given name exists (across all languages).
544    pub fn has_server_named(&self, server_name: &str) -> bool {
545        self.handles
546            .values()
547            .any(|handles| handles.iter().any(|sh| sh.name == server_name))
548    }
549
550    /// Get all mutable handles for a language.
551    pub fn get_handles_mut(&mut self, language: &str) -> &mut [ServerHandle] {
552        self.handles
553            .get_mut(language)
554            .map(|v| v.as_mut_slice())
555            .unwrap_or(&mut [])
556    }
557
558    /// Get the first handle for a language that allows a given feature (for exclusive features).
559    /// For capability-gated features (semantic tokens, folding ranges), this also checks
560    /// that the server actually reported the capability during initialization.
561    /// Returns `None` if no handle matches.
562    pub fn handle_for_feature(&self, language: &str, feature: LspFeature) -> Option<&ServerHandle> {
563        self.handles
564            .get(language)?
565            .iter()
566            .find(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
567    }
568
569    /// Get the first mutable handle for a language that allows a given feature.
570    /// For capability-gated features, this also checks the server's actual capabilities.
571    pub fn handle_for_feature_mut(
572        &mut self,
573        language: &str,
574        feature: LspFeature,
575    ) -> Option<&mut ServerHandle> {
576        self.handles
577            .get_mut(language)?
578            .iter_mut()
579            .find(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
580    }
581
582    /// Get all handles for a language that allow a given feature (for merged features).
583    /// Like `handle_for_feature`, also checks per-server capabilities.
584    pub fn handles_for_feature(&self, language: &str, feature: LspFeature) -> Vec<&ServerHandle> {
585        self.handles
586            .get(language)
587            .map(|v| {
588                v.iter()
589                    .filter(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
590                    .collect()
591            })
592            .unwrap_or_default()
593    }
594
595    /// Get all mutable handles for a language that allow a given feature.
596    /// Like `handle_for_feature_mut`, also checks per-server capabilities.
597    pub fn handles_for_feature_mut(
598        &mut self,
599        language: &str,
600        feature: LspFeature,
601    ) -> Vec<&mut ServerHandle> {
602        self.handles
603            .get_mut(language)
604            .map(|v| {
605                v.iter_mut()
606                    .filter(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
607                    .collect()
608            })
609            .unwrap_or_default()
610    }
611
612    /// Force spawn LSP server(s) for a language, bypassing auto_start checks.
613    ///
614    /// Spawns all enabled servers configured for the language.
615    /// Returns a mutable reference to the primary (first) handle if any were spawned.
616    ///
617    /// The `file_path` is used for workspace root detection via `root_markers`.
618    pub fn force_spawn(
619        &mut self,
620        language: &str,
621        file_path: Option<&Path>,
622    ) -> Option<&mut LspHandle> {
623        tracing::debug!("force_spawn called for language: {}", language);
624
625        // Return existing handle if available
626        if self.handles.get(language).is_some_and(|v| !v.is_empty()) {
627            tracing::debug!("force_spawn: returning existing handle for {}", language);
628            return self
629                .handles
630                .get_mut(language)
631                .and_then(|v| v.first_mut())
632                .map(|sh| &mut sh.handle);
633        }
634
635        // Check if language was explicitly disabled by user (via stop command)
636        if self.disabled_languages.contains(language) {
637            tracing::debug!(
638                "LSP for {} is disabled, not spawning (use manual restart to re-enable)",
639                language
640            );
641            return None;
642        }
643
644        // Get configs for this language
645        let configs = match self.config.get(language) {
646            Some(configs) if !configs.is_empty() => configs.clone(),
647            _ => {
648                tracing::warn!(
649                    "force_spawn: no config found for language '{}', available configs: {:?}",
650                    language,
651                    self.config.keys().collect::<Vec<_>>()
652                );
653                return None;
654            }
655        };
656
657        // Check we have runtime and bridge
658        let runtime = match self.runtime.as_ref() {
659            Some(r) => r.clone(),
660            None => {
661                tracing::error!("force_spawn: no tokio runtime available for {}", language);
662                return None;
663            }
664        };
665        let async_bridge = match self.async_bridge.as_ref() {
666            Some(b) => b.clone(),
667            None => {
668                tracing::error!("force_spawn: no async bridge available for {}", language);
669                return None;
670            }
671        };
672
673        let mut spawned_handles = Vec::new();
674
675        for config in &configs {
676            // Skip disabled configs unless the user explicitly started this language
677            // via the command palette (allowed_languages).
678            if !config.enabled && !self.allowed_languages.contains(language) {
679                continue;
680            }
681
682            if config.command.is_empty() {
683                tracing::warn!(
684                    "force_spawn: LSP command is empty for {} server '{}'",
685                    language,
686                    config.display_name()
687                );
688                continue;
689            }
690
691            let server_name = config.display_name();
692            tracing::info!(
693                "Spawning LSP server '{}' for language: {}",
694                server_name,
695                language
696            );
697
698            match LspHandle::spawn(
699                &runtime,
700                &config.command,
701                &config.args,
702                config.env.clone(),
703                language.to_string(),
704                server_name.clone(),
705                &async_bridge,
706                config.process_limits.clone(),
707                config.language_id_overrides.clone(),
708            ) {
709                Ok(handle) => {
710                    let effective_root = self.resolve_root_uri(language, file_path);
711                    if let Err(e) =
712                        handle.initialize(effective_root, config.initialization_options.clone())
713                    {
714                        tracing::error!(
715                            "Failed to send initialize command for {} ({}): {}",
716                            language,
717                            server_name,
718                            e
719                        );
720                        continue;
721                    }
722
723                    tracing::info!(
724                        "LSP initialization started for {} ({}), will be ready asynchronously",
725                        language,
726                        server_name
727                    );
728
729                    spawned_handles.push(ServerHandle {
730                        name: server_name,
731                        handle,
732                        feature_filter: config.feature_filter(),
733                        capabilities: ServerCapabilitySummary::default(),
734                    });
735                }
736                Err(e) => {
737                    tracing::error!(
738                        "Failed to spawn LSP handle for {} ({}): {}",
739                        language,
740                        server_name,
741                        e
742                    );
743                }
744            }
745        }
746
747        if spawned_handles.is_empty() {
748            return None;
749        }
750
751        self.handles.insert(language.to_string(), spawned_handles);
752        self.handles
753            .get_mut(language)
754            .and_then(|v| v.first_mut())
755            .map(|sh| &mut sh.handle)
756    }
757
758    /// Handle a server crash by scheduling a restart with exponential backoff
759    ///
760    /// Returns a message describing the action taken (for UI notification)
761    #[allow(clippy::let_underscore_must_use)] // shutdown() is best-effort cleanup of a crashed server
762    pub fn handle_server_crash(&mut self, language: &str) -> String {
763        // Remove all handles for this language
764        if let Some(handles) = self.handles.remove(language) {
765            for sh in handles {
766                let _ = sh.handle.shutdown(); // Best-effort cleanup
767            }
768        }
769
770        // Check if server was explicitly disabled by user (via stop command)
771        // Don't auto-restart disabled servers
772        if self.disabled_languages.contains(language) {
773            return format!(
774                "LSP server for {} stopped. Use 'Restart LSP Server' command to start it again.",
775                language
776            );
777        }
778
779        // Check if we're in cooldown
780        if self.restart_cooldown.contains(language) {
781            return format!(
782                "LSP server for {} crashed. Too many restarts - use 'Restart LSP Server' command to retry.",
783                language
784            );
785        }
786
787        // Clean up old restart attempts outside the window
788        let now = Instant::now();
789        let window = Duration::from_secs(RESTART_WINDOW_SECS);
790        let attempts = self
791            .restart_attempts
792            .entry(language.to_string())
793            .or_default();
794        attempts.retain(|t| now.duration_since(*t) < window);
795
796        // Check if we've exceeded max restarts
797        if attempts.len() >= MAX_RESTARTS_IN_WINDOW {
798            self.restart_cooldown.insert(language.to_string());
799            tracing::warn!(
800                "LSP server for {} has crashed {} times in {} minutes, entering cooldown",
801                language,
802                MAX_RESTARTS_IN_WINDOW,
803                RESTART_WINDOW_SECS / 60
804            );
805            return format!(
806                "LSP server for {} has crashed too many times ({} in {} min). Use 'Restart LSP Server' command to manually restart.",
807                language,
808                MAX_RESTARTS_IN_WINDOW,
809                RESTART_WINDOW_SECS / 60
810            );
811        }
812
813        // Calculate exponential backoff delay
814        let attempt_number = attempts.len();
815        let delay_ms = RESTART_BACKOFF_BASE_MS * (1 << attempt_number); // 1s, 2s, 4s, 8s
816        let restart_time = now + Duration::from_millis(delay_ms);
817
818        // Schedule the restart
819        self.pending_restarts
820            .insert(language.to_string(), restart_time);
821
822        tracing::info!(
823            "LSP server for {} crashed (attempt {}/{}), will restart in {}ms",
824            language,
825            attempt_number + 1,
826            MAX_RESTARTS_IN_WINDOW,
827            delay_ms
828        );
829
830        format!(
831            "LSP server for {} crashed (attempt {}/{}), restarting in {}s...",
832            language,
833            attempt_number + 1,
834            MAX_RESTARTS_IN_WINDOW,
835            delay_ms / 1000
836        )
837    }
838
839    /// Check and process any pending restarts that are due
840    ///
841    /// Returns list of (language, success, message) for each restart attempted
842    pub fn process_pending_restarts(&mut self) -> Vec<(String, bool, String)> {
843        let now = Instant::now();
844        let mut results = Vec::new();
845
846        // Find restarts that are due
847        let due_restarts: Vec<String> = self
848            .pending_restarts
849            .iter()
850            .filter(|(_, time)| **time <= now)
851            .map(|(lang, _)| lang.clone())
852            .collect();
853
854        for language in due_restarts {
855            self.pending_restarts.remove(&language);
856
857            // Record this restart attempt
858            self.restart_attempts
859                .entry(language.clone())
860                .or_default()
861                .push(now);
862
863            // Attempt to spawn the server (bypassing auto_start for crash recovery)
864            if self.force_spawn(&language, None).is_some() {
865                let message = format!("LSP server for {} restarted successfully", language);
866                tracing::info!("{}", message);
867                results.push((language, true, message));
868            } else {
869                let message = format!("Failed to restart LSP server for {}", language);
870                tracing::error!("{}", message);
871                results.push((language, false, message));
872            }
873        }
874
875        results
876    }
877
878    /// Check if a language server is in restart cooldown
879    pub fn is_in_cooldown(&self, language: &str) -> bool {
880        self.restart_cooldown.contains(language)
881    }
882
883    /// Check if a language server has a pending restart
884    pub fn has_pending_restart(&self, language: &str) -> bool {
885        self.pending_restarts.contains_key(language)
886    }
887
888    /// Clear cooldown for a language and allow manual restart
889    pub fn clear_cooldown(&mut self, language: &str) {
890        self.restart_cooldown.remove(language);
891        self.restart_attempts.remove(language);
892        self.pending_restarts.remove(language);
893        tracing::info!("Cleared restart cooldown for {}", language);
894    }
895
896    /// Manually restart/start a language server (bypasses cooldown and auto_start check)
897    ///
898    /// This is used both to restart a crashed server and to manually start a server
899    /// that has auto_start=false in its configuration.
900    ///
901    /// Returns (success, message) tuple
902    #[allow(clippy::let_underscore_must_use)] // shutdown() is best-effort cleanup before restart
903    pub fn manual_restart(&mut self, language: &str, file_path: Option<&Path>) -> (bool, String) {
904        // Clear any existing state
905        self.clear_cooldown(language);
906
907        // Re-enable the language (remove from disabled set)
908        self.disabled_languages.remove(language);
909
910        // Add to allowed languages so it stays active even if auto_start=false
911        self.allowed_languages.insert(language.to_string());
912
913        // Remove existing handles
914        if let Some(handles) = self.handles.remove(language) {
915            for sh in handles {
916                let _ = sh.handle.shutdown();
917            }
918        }
919
920        // Spawn new server (bypassing auto_start for user-initiated restart)
921        if self.force_spawn(language, file_path).is_some() {
922            let message = format!("LSP server for {} started", language);
923            tracing::info!("{}", message);
924            (true, message)
925        } else {
926            let message = format!("Failed to start LSP server for {}", language);
927            tracing::error!("{}", message);
928            (false, message)
929        }
930    }
931
932    /// Restart a single server by name for a specific language.
933    ///
934    /// Shuts down just that server and re-spawns it from config.
935    /// Returns (success, message) tuple.
936    #[allow(clippy::let_underscore_must_use)]
937    pub fn manual_restart_server(
938        &mut self,
939        language: &str,
940        server_name: &str,
941        file_path: Option<&Path>,
942    ) -> (bool, String) {
943        self.clear_cooldown(language);
944        self.disabled_languages.remove(language);
945        self.allowed_languages.insert(language.to_string());
946
947        // Find and shut down just the named server
948        if let Some(handles) = self.handles.get_mut(language) {
949            if let Some(idx) = handles.iter().position(|sh| sh.name == server_name) {
950                let sh = handles.remove(idx);
951                let _ = sh.handle.shutdown();
952            }
953        }
954
955        // Find the matching config and spawn just that server
956        let config = self
957            .config
958            .get(language)
959            .and_then(|configs| configs.iter().find(|c| c.display_name() == server_name))
960            .cloned();
961
962        let Some(config) = config else {
963            let message = format!(
964                "No config found for server '{}' ({})",
965                server_name, language
966            );
967            tracing::error!("{}", message);
968            return (false, message);
969        };
970
971        if config.command.is_empty() {
972            let message = format!(
973                "LSP command is empty for {} server '{}'",
974                language, server_name
975            );
976            tracing::error!("{}", message);
977            return (false, message);
978        }
979
980        let runtime = match self.runtime.as_ref() {
981            Some(r) => r.clone(),
982            None => return (false, "No tokio runtime available".to_string()),
983        };
984        let async_bridge = match self.async_bridge.as_ref() {
985            Some(b) => b.clone(),
986            None => return (false, "No async bridge available".to_string()),
987        };
988
989        match LspHandle::spawn(
990            &runtime,
991            &config.command,
992            &config.args,
993            config.env.clone(),
994            language.to_string(),
995            server_name.to_string(),
996            &async_bridge,
997            config.process_limits.clone(),
998            config.language_id_overrides.clone(),
999        ) {
1000            Ok(handle) => {
1001                let effective_root = self.resolve_root_uri(language, file_path);
1002                if let Err(e) =
1003                    handle.initialize(effective_root, config.initialization_options.clone())
1004                {
1005                    let message = format!(
1006                        "Failed to initialize LSP server '{}' for {}: {}",
1007                        server_name, language, e
1008                    );
1009                    tracing::error!("{}", message);
1010                    return (false, message);
1011                }
1012
1013                let sh = ServerHandle {
1014                    name: server_name.to_string(),
1015                    handle,
1016                    feature_filter: config.feature_filter(),
1017                    capabilities: ServerCapabilitySummary::default(),
1018                };
1019
1020                self.handles
1021                    .entry(language.to_string())
1022                    .or_default()
1023                    .push(sh);
1024
1025                let message = format!("LSP server '{}' for {} started", server_name, language);
1026                tracing::info!("{}", message);
1027                (true, message)
1028            }
1029            Err(e) => {
1030                let message = format!(
1031                    "Failed to start LSP server '{}' for {}: {}",
1032                    server_name, language, e
1033                );
1034                tracing::error!("{}", message);
1035                (false, message)
1036            }
1037        }
1038    }
1039
1040    /// Get the number of recent restart attempts for a language
1041    pub fn restart_attempt_count(&self, language: &str) -> usize {
1042        let now = Instant::now();
1043        let window = Duration::from_secs(RESTART_WINDOW_SECS);
1044        self.restart_attempts
1045            .get(language)
1046            .map(|attempts| {
1047                attempts
1048                    .iter()
1049                    .filter(|t| now.duration_since(**t) < window)
1050                    .count()
1051            })
1052            .unwrap_or(0)
1053    }
1054
1055    /// Get a list of currently running LSP server languages
1056    pub fn running_servers(&self) -> Vec<String> {
1057        self.handles.keys().cloned().collect()
1058    }
1059
1060    /// Get the names of all running servers for a given language
1061    pub fn server_names_for_language(&self, language: &str) -> Vec<String> {
1062        self.handles
1063            .get(language)
1064            .map(|handles| handles.iter().map(|sh| sh.name.clone()).collect())
1065            .unwrap_or_default()
1066    }
1067
1068    /// Check if any LSP server for a language is running and ready to serve requests
1069    pub fn is_server_ready(&self, language: &str) -> bool {
1070        self.handles
1071            .get(language)
1072            .map(|handles| {
1073                handles
1074                    .iter()
1075                    .any(|sh| sh.handle.state().can_send_requests())
1076            })
1077            .unwrap_or(false)
1078    }
1079
1080    /// Shutdown a single server by name for a specific language.
1081    ///
1082    /// Returns true if the server was found and shut down.
1083    /// If this was the last server for the language, marks the language as disabled.
1084    #[allow(clippy::let_underscore_must_use)]
1085    pub fn shutdown_server_by_name(&mut self, language: &str, server_name: &str) -> bool {
1086        let Some(handles) = self.handles.get_mut(language) else {
1087            tracing::warn!("No running LSP servers found for {}", language);
1088            return false;
1089        };
1090
1091        let pos = handles.iter().position(|sh| sh.name == server_name);
1092        let Some(idx) = pos else {
1093            tracing::warn!(
1094                "No running LSP server named '{}' found for {}",
1095                server_name,
1096                language
1097            );
1098            return false;
1099        };
1100
1101        let sh = handles.remove(idx);
1102        tracing::info!(
1103            "Shutting down LSP server '{}' for {} (disabled until manual restart)",
1104            sh.name,
1105            language
1106        );
1107        let _ = sh.handle.shutdown();
1108
1109        if handles.is_empty() {
1110            // Last server for this language — clean up like shutdown_server
1111            self.handles.remove(language);
1112            self.disabled_languages.insert(language.to_string());
1113            self.pending_restarts.remove(language);
1114            self.restart_cooldown.remove(language);
1115            self.allowed_languages.remove(language);
1116        }
1117
1118        true
1119    }
1120
1121    /// Shutdown all servers for a specific language.
1122    ///
1123    /// This marks the language as disabled, preventing auto-restart until the user
1124    /// explicitly restarts it using the restart command.
1125    #[allow(clippy::let_underscore_must_use)]
1126    pub fn shutdown_server(&mut self, language: &str) -> bool {
1127        if let Some(handles) = self.handles.remove(language) {
1128            for sh in &handles {
1129                tracing::info!(
1130                    "Shutting down LSP server '{}' for {} (disabled until manual restart)",
1131                    sh.name,
1132                    language
1133                );
1134                let _ = sh.handle.shutdown();
1135            }
1136            self.disabled_languages.insert(language.to_string());
1137            self.pending_restarts.remove(language);
1138            self.restart_cooldown.remove(language);
1139            self.allowed_languages.remove(language);
1140            !handles.is_empty()
1141        } else {
1142            tracing::warn!("No running LSP server found for {}", language);
1143            false
1144        }
1145    }
1146
1147    /// Shutdown all language servers
1148    #[allow(clippy::let_underscore_must_use)]
1149    pub fn shutdown_all(&mut self) {
1150        for (language, handles) in self.handles.iter() {
1151            for sh in handles {
1152                tracing::info!("Shutting down LSP server '{}' for {}", sh.name, language);
1153                let _ = sh.handle.shutdown();
1154            }
1155        }
1156        self.handles.clear();
1157    }
1158}
1159
1160impl Drop for LspManager {
1161    fn drop(&mut self) {
1162        self.shutdown_all();
1163    }
1164}
1165
1166/// Helper function to detect language from file path using the config's languages section.
1167///
1168/// Priority order matches the grammar registry (`find_syntax_for_file_with_languages`):
1169/// 1. Exact filename match against `filenames` (highest priority)
1170/// 2. Glob pattern match against `filenames` entries containing wildcards
1171/// 3. File extension match against `extensions` (lowest config-based priority)
1172pub fn detect_language(
1173    path: &std::path::Path,
1174    languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
1175) -> Option<String> {
1176    use crate::primitives::glob_match::{
1177        filename_glob_matches, is_glob_pattern, is_path_pattern, path_glob_matches,
1178    };
1179
1180    if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
1181        // 1. Exact filename match (highest priority)
1182        for (language_name, lang_config) in languages {
1183            if lang_config
1184                .filenames
1185                .iter()
1186                .any(|f| !is_glob_pattern(f) && f == filename)
1187            {
1188                return Some(language_name.clone());
1189            }
1190        }
1191
1192        // 2. Glob pattern match
1193        // Path patterns (containing `/`) match against the full path;
1194        // filename-only patterns match against just the filename.
1195        let path_str = path.to_str().unwrap_or("");
1196        for (language_name, lang_config) in languages {
1197            if lang_config.filenames.iter().any(|f| {
1198                if !is_glob_pattern(f) {
1199                    return false;
1200                }
1201                if is_path_pattern(f) {
1202                    path_glob_matches(f, path_str)
1203                } else {
1204                    filename_glob_matches(f, filename)
1205                }
1206            }) {
1207                return Some(language_name.clone());
1208            }
1209        }
1210    }
1211
1212    // 3. Extension match (lowest priority among config-based detection)
1213    if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
1214        for (language_name, lang_config) in languages {
1215            if lang_config.extensions.iter().any(|ext| ext == extension) {
1216                return Some(language_name.clone());
1217            }
1218        }
1219    }
1220
1221    None
1222}
1223
1224#[cfg(test)]
1225mod tests {
1226    use super::*;
1227    use std::path::Path;
1228
1229    #[test]
1230    fn test_lsp_manager_new() {
1231        let root_uri: Option<Uri> = "file:///test".parse().ok();
1232        let manager = LspManager::new(root_uri.clone());
1233
1234        // Manager should start with no handles
1235        assert_eq!(manager.handles.len(), 0);
1236        assert_eq!(manager.config.len(), 0);
1237        assert!(manager.root_uri.is_some());
1238        assert!(manager.runtime.is_none());
1239        assert!(manager.async_bridge.is_none());
1240    }
1241
1242    #[test]
1243    fn test_lsp_manager_set_language_config() {
1244        let mut manager = LspManager::new(None);
1245
1246        let config = LspServerConfig {
1247            enabled: true,
1248            command: "rust-analyzer".to_string(),
1249            args: vec![],
1250            process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
1251            auto_start: false,
1252            initialization_options: None,
1253            env: Default::default(),
1254            language_id_overrides: Default::default(),
1255            name: None,
1256            only_features: None,
1257            except_features: None,
1258            root_markers: Default::default(),
1259        };
1260
1261        manager.set_language_config("rust".to_string(), config);
1262
1263        assert_eq!(manager.config.len(), 1);
1264        assert!(manager.config.contains_key("rust"));
1265        assert!(manager.config.get("rust").unwrap().first().unwrap().enabled);
1266    }
1267
1268    #[test]
1269    fn test_lsp_manager_force_spawn_no_runtime() {
1270        let mut manager = LspManager::new(None);
1271
1272        // Add config for rust
1273        manager.set_language_config(
1274            "rust".to_string(),
1275            LspServerConfig {
1276                enabled: true,
1277                command: "rust-analyzer".to_string(),
1278                args: vec![],
1279                process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
1280                auto_start: false,
1281                initialization_options: None,
1282                env: Default::default(),
1283                language_id_overrides: Default::default(),
1284                name: None,
1285                only_features: None,
1286                except_features: None,
1287                root_markers: Default::default(),
1288            },
1289        );
1290
1291        // force_spawn should return None without runtime
1292        let result = manager.force_spawn("rust", None);
1293        assert!(result.is_none());
1294    }
1295
1296    #[test]
1297    fn test_lsp_manager_force_spawn_no_config() {
1298        let rt = tokio::runtime::Runtime::new().unwrap();
1299        let mut manager = LspManager::new(None);
1300        let async_bridge = AsyncBridge::new();
1301
1302        manager.set_runtime(rt.handle().clone(), async_bridge);
1303
1304        // force_spawn should return None for unconfigured language
1305        let result = manager.force_spawn("rust", None);
1306        assert!(result.is_none());
1307    }
1308
1309    #[test]
1310    fn test_lsp_manager_force_spawn_disabled_language() {
1311        let rt = tokio::runtime::Runtime::new().unwrap();
1312        let mut manager = LspManager::new(None);
1313        let async_bridge = AsyncBridge::new();
1314
1315        manager.set_runtime(rt.handle().clone(), async_bridge);
1316
1317        // Add disabled config (command is optional when disabled)
1318        manager.set_language_config(
1319            "rust".to_string(),
1320            LspServerConfig {
1321                enabled: false,
1322                command: String::new(), // command not required when disabled
1323                args: vec![],
1324                process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
1325                auto_start: false,
1326                initialization_options: None,
1327                env: Default::default(),
1328                language_id_overrides: Default::default(),
1329                name: None,
1330                only_features: None,
1331                except_features: None,
1332                root_markers: Default::default(),
1333            },
1334        );
1335
1336        // force_spawn should return None for disabled language
1337        let result = manager.force_spawn("rust", None);
1338        assert!(result.is_none());
1339    }
1340
1341    #[test]
1342    fn test_lsp_manager_shutdown_all() {
1343        let mut manager = LspManager::new(None);
1344
1345        // shutdown_all should not panic even with no handles
1346        manager.shutdown_all();
1347        assert_eq!(manager.handles.len(), 0);
1348    }
1349
1350    fn test_languages() -> std::collections::HashMap<String, crate::config::LanguageConfig> {
1351        let mut languages = std::collections::HashMap::new();
1352        languages.insert(
1353            "rust".to_string(),
1354            crate::config::LanguageConfig {
1355                extensions: vec!["rs".to_string()],
1356                filenames: vec![],
1357                grammar: "rust".to_string(),
1358                comment_prefix: Some("//".to_string()),
1359                auto_indent: true,
1360                auto_close: None,
1361                auto_surround: None,
1362                highlighter: crate::config::HighlighterPreference::Auto,
1363                textmate_grammar: None,
1364                show_whitespace_tabs: false,
1365                line_wrap: None,
1366                wrap_column: None,
1367                page_view: None,
1368                page_width: None,
1369                use_tabs: None,
1370                tab_size: None,
1371                formatter: None,
1372                format_on_save: false,
1373                on_save: vec![],
1374                word_characters: None,
1375            },
1376        );
1377        languages.insert(
1378            "javascript".to_string(),
1379            crate::config::LanguageConfig {
1380                extensions: vec!["js".to_string(), "jsx".to_string()],
1381                filenames: vec![],
1382                grammar: "javascript".to_string(),
1383                comment_prefix: Some("//".to_string()),
1384                auto_indent: true,
1385                auto_close: None,
1386                auto_surround: None,
1387                highlighter: crate::config::HighlighterPreference::Auto,
1388                textmate_grammar: None,
1389                show_whitespace_tabs: false,
1390                line_wrap: None,
1391                wrap_column: None,
1392                page_view: None,
1393                page_width: None,
1394                use_tabs: None,
1395                tab_size: None,
1396                formatter: None,
1397                format_on_save: false,
1398                on_save: vec![],
1399                word_characters: None,
1400            },
1401        );
1402        languages.insert(
1403            "csharp".to_string(),
1404            crate::config::LanguageConfig {
1405                extensions: vec!["cs".to_string()],
1406                filenames: vec![],
1407                grammar: "c_sharp".to_string(),
1408                comment_prefix: Some("//".to_string()),
1409                auto_indent: true,
1410                auto_close: None,
1411                auto_surround: None,
1412                highlighter: crate::config::HighlighterPreference::Auto,
1413                textmate_grammar: None,
1414                show_whitespace_tabs: false,
1415                line_wrap: None,
1416                wrap_column: None,
1417                page_view: None,
1418                page_width: None,
1419                use_tabs: None,
1420                tab_size: None,
1421                formatter: None,
1422                format_on_save: false,
1423                on_save: vec![],
1424                word_characters: None,
1425            },
1426        );
1427        languages
1428    }
1429
1430    #[test]
1431    fn test_detect_language_from_config() {
1432        let languages = test_languages();
1433
1434        // Test configured languages
1435        assert_eq!(
1436            detect_language(Path::new("main.rs"), &languages),
1437            Some("rust".to_string())
1438        );
1439        assert_eq!(
1440            detect_language(Path::new("index.js"), &languages),
1441            Some("javascript".to_string())
1442        );
1443        assert_eq!(
1444            detect_language(Path::new("App.jsx"), &languages),
1445            Some("javascript".to_string())
1446        );
1447        assert_eq!(
1448            detect_language(Path::new("Program.cs"), &languages),
1449            Some("csharp".to_string())
1450        );
1451
1452        // Test unconfigured extensions return None
1453        assert_eq!(detect_language(Path::new("main.py"), &languages), None);
1454        assert_eq!(detect_language(Path::new("file.xyz"), &languages), None);
1455        assert_eq!(detect_language(Path::new("file"), &languages), None);
1456    }
1457
1458    #[test]
1459    fn test_detect_language_no_extension() {
1460        let languages = test_languages();
1461        assert_eq!(detect_language(Path::new("README"), &languages), None);
1462        assert_eq!(detect_language(Path::new("Makefile"), &languages), None);
1463    }
1464
1465    #[test]
1466    fn test_detect_language_path_glob() {
1467        let mut languages = test_languages();
1468        languages.insert(
1469            "shell".to_string(),
1470            crate::config::LanguageConfig {
1471                extensions: vec!["sh".to_string()],
1472                filenames: vec!["/etc/**/rc.*".to_string(), "*rc".to_string()],
1473                grammar: "bash".to_string(),
1474                comment_prefix: Some("#".to_string()),
1475                auto_indent: true,
1476                auto_close: None,
1477                auto_surround: None,
1478                highlighter: crate::config::HighlighterPreference::Auto,
1479                textmate_grammar: None,
1480                show_whitespace_tabs: false,
1481                line_wrap: None,
1482                wrap_column: None,
1483                page_view: None,
1484                page_width: None,
1485                use_tabs: None,
1486                tab_size: None,
1487                formatter: None,
1488                format_on_save: false,
1489                on_save: vec![],
1490                word_characters: None,
1491            },
1492        );
1493
1494        // Path glob: /etc/**/rc.* should match
1495        assert_eq!(
1496            detect_language(Path::new("/etc/rc.conf"), &languages),
1497            Some("shell".to_string())
1498        );
1499        assert_eq!(
1500            detect_language(Path::new("/etc/init/rc.local"), &languages),
1501            Some("shell".to_string())
1502        );
1503        // Path glob should NOT match different root
1504        assert_eq!(detect_language(Path::new("/var/rc.conf"), &languages), None);
1505
1506        // Filename glob: *rc should still work
1507        assert_eq!(
1508            detect_language(Path::new("lfrc"), &languages),
1509            Some("shell".to_string())
1510        );
1511    }
1512
1513    #[test]
1514    fn test_detect_workspace_root_finds_marker_in_parent() {
1515        let tmp = tempfile::tempdir().unwrap();
1516        let project = tmp.path().join("myproject");
1517        let src = project.join("src");
1518        std::fs::create_dir_all(&src).unwrap();
1519        std::fs::write(project.join("Cargo.toml"), "").unwrap();
1520        let file = src.join("main.rs");
1521        std::fs::write(&file, "").unwrap();
1522
1523        let root = detect_workspace_root(&file, &["Cargo.toml".to_string(), ".git".to_string()]);
1524        assert_eq!(root, project);
1525    }
1526
1527    #[test]
1528    fn test_detect_workspace_root_finds_marker_two_levels_up() {
1529        let tmp = tempfile::tempdir().unwrap();
1530        let project = tmp.path().join("myproject");
1531        let deep = project.join("src").join("nested");
1532        std::fs::create_dir_all(&deep).unwrap();
1533        std::fs::write(project.join("Cargo.toml"), "").unwrap();
1534        let file = deep.join("lib.rs");
1535        std::fs::write(&file, "").unwrap();
1536
1537        let root = detect_workspace_root(&file, &["Cargo.toml".to_string()]);
1538        assert_eq!(root, project);
1539    }
1540
1541    #[test]
1542    fn test_detect_workspace_root_no_marker_returns_parent() {
1543        let tmp = tempfile::tempdir().unwrap();
1544        let dir = tmp.path().join("somedir");
1545        std::fs::create_dir_all(&dir).unwrap();
1546        let file = dir.join("file.txt");
1547        std::fs::write(&file, "").unwrap();
1548
1549        let root = detect_workspace_root(&file, &["nonexistent_marker".to_string()]);
1550        assert_eq!(root, dir);
1551    }
1552
1553    #[test]
1554    fn test_detect_workspace_root_empty_markers_returns_parent() {
1555        let tmp = tempfile::tempdir().unwrap();
1556        let dir = tmp.path().join("somedir");
1557        std::fs::create_dir_all(&dir).unwrap();
1558        let file = dir.join("file.txt");
1559        std::fs::write(&file, "").unwrap();
1560
1561        let root = detect_workspace_root(&file, &[]);
1562        assert_eq!(root, dir);
1563    }
1564
1565    #[test]
1566    fn test_detect_workspace_root_directory_marker() {
1567        let tmp = tempfile::tempdir().unwrap();
1568        let project = tmp.path().join("myproject");
1569        let src = project.join("src");
1570        std::fs::create_dir_all(&src).unwrap();
1571        std::fs::create_dir_all(project.join(".git")).unwrap();
1572        let file = src.join("main.rs");
1573        std::fs::write(&file, "").unwrap();
1574
1575        let root = detect_workspace_root(&file, &[".git".to_string()]);
1576        assert_eq!(root, project);
1577    }
1578
1579    #[test]
1580    fn test_path_to_uri_basic() {
1581        let uri = path_to_uri(Path::new("/tmp/test")).unwrap();
1582        assert_eq!(uri.as_str(), "file:///tmp/test");
1583    }
1584
1585    #[test]
1586    fn test_path_to_uri_with_spaces() {
1587        let uri = path_to_uri(Path::new("/tmp/my project/src")).unwrap();
1588        assert_eq!(uri.as_str(), "file:///tmp/my%20project/src");
1589    }
1590}