Skip to main content

mcpls_core/config/
server.rs

1//! LSP server configuration types.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use ignore::WalkBuilder;
7use serde::{Deserialize, Serialize};
8
9use super::routing::{ServerId, ToolKind};
10use crate::bridge::IndexingPolicy;
11
12/// Default max depth for recursive marker search.
13pub const DEFAULT_HEURISTICS_MAX_DEPTH: usize = 10;
14
15/// Directories excluded from recursive marker search.
16/// These are well-known directories that should never contain project markers.
17const EXCLUDED_DIRECTORIES: &[&str] = &[
18    "node_modules",
19    "target",
20    ".git",
21    "__pycache__",
22    ".venv",
23    "venv",
24    ".tox",
25    ".mypy_cache",
26    ".pytest_cache",
27    "build",
28    "dist",
29    ".cargo",
30    ".rustup",
31    "vendor",
32    "coverage",
33    ".next",
34    ".nuxt",
35];
36
37/// Heuristics for determining if an LSP server should be spawned.
38///
39/// Used to prevent spawning servers in projects where they are not applicable
40/// (e.g., rust-analyzer in a Python-only project).
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42#[serde(deny_unknown_fields)]
43pub struct ServerHeuristics {
44    /// Files or directories that indicate this server is applicable.
45    ///
46    /// The server will spawn if ANY of these markers exist anywhere in the workspace tree
47    /// (searched recursively up to `heuristics_max_depth`). Well-known directories like
48    /// `node_modules`, `target`, `.git` are excluded from the search.
49    ///
50    /// If empty, the server will always attempt to spawn.
51    #[serde(default)]
52    pub project_markers: Vec<String>,
53}
54
55impl ServerHeuristics {
56    /// Create heuristics with the given project markers.
57    #[must_use]
58    pub fn with_markers<I, S>(markers: I) -> Self
59    where
60        I: IntoIterator<Item = S>,
61        S: Into<String>,
62    {
63        Self {
64            project_markers: markers.into_iter().map(Into::into).collect(),
65        }
66    }
67
68    /// Check if any marker exists at the given workspace root.
69    ///
70    /// Returns `true` if:
71    /// - No markers are defined (empty = always applicable)
72    /// - At least one marker file/directory exists
73    #[must_use]
74    pub fn is_applicable(&self, workspace_root: &Path) -> bool {
75        if self.project_markers.is_empty() {
76            return true;
77        }
78        self.project_markers
79            .iter()
80            .any(|marker| workspace_root.join(marker).exists())
81    }
82
83    /// Check if any marker exists anywhere in the workspace tree.
84    ///
85    /// Recursively searches the workspace for project markers, excluding
86    /// well-known directories like `node_modules`, `target`, `.git`, etc.
87    ///
88    /// # Arguments
89    ///
90    /// * `workspace_root` - Root directory to search from
91    /// * `max_depth` - Maximum recursion depth (default: 10)
92    ///
93    /// # Returns
94    ///
95    /// `true` if any marker is found, `false` otherwise.
96    #[must_use]
97    pub fn is_applicable_recursive(&self, workspace_root: &Path, max_depth: Option<usize>) -> bool {
98        if self.project_markers.is_empty() {
99            return true;
100        }
101
102        // First check the root level (fast path)
103        if self.is_applicable(workspace_root) {
104            return true;
105        }
106
107        let depth = max_depth.unwrap_or(DEFAULT_HEURISTICS_MAX_DEPTH);
108        self.find_any_marker_recursive(workspace_root, depth)
109    }
110
111    /// Search recursively for any marker file.
112    fn find_any_marker_recursive(&self, workspace_root: &Path, max_depth: usize) -> bool {
113        let mut builder = WalkBuilder::new(workspace_root);
114        // `standard_filters(false)` (bulk setter, last-write-wins) must run first or it
115        // undoes the overrides below; `.git_ignore(true)` itself is then a no-op outside
116        // an actual git repo (`require_git` defaults true).
117        builder
118            .standard_filters(false)
119            .max_depth(Some(max_depth))
120            .hidden(false)
121            .git_ignore(true)
122            .git_global(false)
123            .git_exclude(false)
124            .follow_links(false)
125            .filter_entry(|entry| {
126                // Skip excluded directories entirely (prevents descending into them)
127                if entry.file_type().is_some_and(|ft| ft.is_dir())
128                    && let Some(name) = entry.file_name().to_str()
129                    && EXCLUDED_DIRECTORIES.contains(&name)
130                {
131                    return false;
132                }
133                true
134            });
135
136        for entry in builder.build().flatten() {
137            let path = entry.path();
138
139            // Check if this entry matches any marker
140            if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
141                && self.project_markers.iter().any(|m| m == file_name)
142            {
143                return true;
144            }
145        }
146
147        false
148    }
149}
150
151/// Configuration for a single LSP server.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct LspServerConfig {
155    /// Language identifier (e.g., "rust", "python", "typescript").
156    pub language_id: String,
157
158    /// Command to start the LSP server.
159    pub command: String,
160
161    /// Arguments to pass to the LSP server command.
162    #[serde(default)]
163    pub args: Vec<String>,
164
165    /// Environment variables for the LSP server process.
166    #[serde(default)]
167    pub env: HashMap<String, String>,
168
169    /// File patterns this server handles (glob patterns).
170    #[serde(default)]
171    pub file_patterns: Vec<String>,
172
173    /// LSP initialization options (server-specific).
174    #[serde(default)]
175    pub initialization_options: Option<serde_json::Value>,
176
177    /// Handshake timeout in seconds: bounds the `initialize` request during
178    /// server startup. Does not affect individual tool-call requests sent
179    /// after initialization; see [`Self::request_timeout_seconds`] for that.
180    /// The LSP server's `shutdown` request during teardown uses a separate,
181    /// fixed 5-second timeout that is not configurable by this field.
182    #[serde(default = "default_timeout")]
183    pub timeout_seconds: u64,
184
185    /// Per-request timeout in seconds, applied to each LSP request issued
186    /// while translating an MCP tool call (hover, definition, references, etc.).
187    ///
188    /// This bounds a single request attempt, not a whole tool call: on a
189    /// `-32801` (`ContentModified`) or `-32802` (`ServerCancelled`) response,
190    /// [`crate::lsp::LspClient::request`] retries up to 4 attempts with
191    /// backoff (one shared budget across both codes), so the worst-case
192    /// latency for one tool call is `4 * request_timeout_seconds + 3.5`
193    /// seconds. Completion requests are further capped at 10 seconds
194    /// regardless of this value; see
195    /// [`crate::lsp::LspClient::completion_timeout`].
196    #[serde(default = "default_request_timeout")]
197    pub request_timeout_seconds: u64,
198
199    /// Heuristics for determining if this server should be spawned.
200    /// If not specified, the server will always attempt to spawn.
201    #[serde(default)]
202    pub heuristics: Option<ServerHeuristics>,
203
204    /// Human-readable server identity used as the routing key.
205    ///
206    /// Defaults to `language_id` when omitted (see [`Self::id`]). Must be
207    /// unique across all applicable servers in a workspace, regardless of
208    /// language: this is what lets two servers share one `language_id`
209    /// (e.g. pyright and pylsp both for `python`) without one silently
210    /// overwriting the other in the maps keyed by [`ServerId`].
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub name: Option<String>,
213
214    /// Tools this server handles.
215    ///
216    /// `None` means this server is a catch-all: it serves every tool not
217    /// explicitly claimed by another server for the same language.
218    /// `Some(list)` restricts the server to exactly those tools.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub handles: Option<Vec<ToolKind>>,
221
222    /// Workspace-indexing readiness policy for this server (P4 escape
223    /// hatch). Defaults to [`IndexingPolicy::Auto`]: readiness is tracked
224    /// normally from whatever signals the server sends (rust-analyzer's
225    /// `experimental/serverStatus`, or a generic `$/progress` sequence).
226    /// Set to `"disabled"` for a server whose signal shape doesn't fit this
227    /// tracker's assumptions -- see [`IndexingPolicy::Disabled`].
228    #[serde(default, skip_serializing_if = "IndexingPolicy::is_auto")]
229    pub indexing: IndexingPolicy,
230}
231
232const fn default_timeout() -> u64 {
233    30
234}
235
236const fn default_request_timeout() -> u64 {
237    30
238}
239
240/// Maximum allowed value, in seconds, for both [`LspServerConfig::timeout_seconds`]
241/// and [`LspServerConfig::request_timeout_seconds`].
242///
243/// Both fields are passed straight into `Duration::from_secs` — `timeout_seconds`
244/// in the `initialize` handshake (`lsp::lifecycle::LspServer::initialize`),
245/// `request_timeout_seconds` in [`crate::lsp::LspClient::request_timeout`].
246/// tokio's `timeout`/`sleep` fall back to `Instant::far_future()` for
247/// astronomically large durations instead of panicking, so an unbounded value
248/// on either field (misconfiguration or typo) would silently disable the
249/// timeout rather than fail with a diagnosable error. One shared constant
250/// bounds both, since the underlying defect and fix are identical for each.
251///
252/// Set to 900 (15 minutes), not a rounder 3600 (1 hour): [`LspClient::request`]
253/// retries a request up to 4 times total on a `-32802` (`ServerCancelled`) or
254/// `-32801` (`ContentModified`) response (one shared budget across both
255/// codes), so the worst-case latency for a single call bounded by this value
256/// is `4 * 900 + 3.5s` ≈ 1 hour, not 4 hours — this constant bounds one
257/// attempt, so it is chosen such that the actually-experienced worst case
258/// (the retried total) stays within about an hour.
259///
260/// [`LspClient::request`]: crate::lsp::LspClient::request
261pub const MAX_TIMEOUT_SECONDS: u64 = 900;
262
263impl LspServerConfig {
264    /// Check if this server should be spawned for the given workspace.
265    ///
266    /// Uses recursive marker search to detect nested projects.
267    ///
268    /// # Arguments
269    ///
270    /// * `workspace_root` - Root directory of the workspace
271    /// * `max_depth` - Maximum depth for recursive search (default: 10)
272    #[must_use]
273    pub fn should_spawn(&self, workspace_root: &Path, max_depth: Option<usize>) -> bool {
274        self.heuristics
275            .as_ref()
276            .is_none_or(|h| h.is_applicable_recursive(workspace_root, max_depth))
277    }
278
279    /// The routing identity of this server: `name` if set, otherwise `language_id`.
280    ///
281    /// This is the key used across `Translator`'s client/server maps, so two
282    /// servers for the same language must set distinct `name`s or they
283    /// collide (see `ToolRouter::from_configs` for the enforcement).
284    #[must_use]
285    pub fn id(&self) -> ServerId {
286        self.name
287            .clone()
288            .map_or_else(|| ServerId::from(self.language_id.clone()), ServerId::from)
289    }
290
291    /// Build a built-in server config, filling in every field not passed as
292    /// a parameter.
293    fn builtin(
294        language_id: &str,
295        command: &str,
296        args: &[&str],
297        file_patterns: &[&str],
298        markers: impl IntoIterator<Item = &'static str>,
299    ) -> Self {
300        Self {
301            language_id: language_id.to_string(),
302            command: command.to_string(),
303            args: args.iter().map(ToString::to_string).collect(),
304            env: HashMap::new(),
305            file_patterns: file_patterns.iter().map(ToString::to_string).collect(),
306            initialization_options: None,
307            timeout_seconds: default_timeout(),
308            request_timeout_seconds: default_request_timeout(),
309            heuristics: Some(ServerHeuristics::with_markers(markers)),
310            name: None,
311            handles: None,
312            indexing: IndexingPolicy::Auto,
313        }
314    }
315
316    /// Create a default configuration for rust-analyzer.
317    #[must_use]
318    pub fn rust_analyzer() -> Self {
319        Self::builtin(
320            "rust",
321            "rust-analyzer",
322            &[],
323            &["**/*.rs"],
324            ["Cargo.toml", "rust-toolchain.toml"],
325        )
326    }
327
328    /// Create a default configuration for pyright.
329    #[must_use]
330    pub fn pyright() -> Self {
331        Self::builtin(
332            "python",
333            "pyright-langserver",
334            &["--stdio"],
335            &["**/*.py"],
336            [
337                "pyproject.toml",
338                "setup.py",
339                "requirements.txt",
340                "pyrightconfig.json",
341            ],
342        )
343    }
344
345    /// Create a default configuration for TypeScript language server.
346    #[must_use]
347    pub fn typescript() -> Self {
348        Self::builtin(
349            "typescript",
350            "typescript-language-server",
351            &["--stdio"],
352            &["**/*.ts", "**/*.tsx"],
353            ["package.json", "tsconfig.json", "jsconfig.json"],
354        )
355    }
356
357    /// Create a default configuration for gopls.
358    #[must_use]
359    pub fn gopls() -> Self {
360        Self::builtin(
361            "go",
362            "gopls",
363            &["serve"],
364            &["**/*.go"],
365            ["go.mod", "go.sum"],
366        )
367    }
368
369    /// Create a default configuration for clangd.
370    #[must_use]
371    pub fn clangd() -> Self {
372        Self::builtin(
373            "cpp",
374            "clangd",
375            &[],
376            &["**/*.c", "**/*.cpp", "**/*.h", "**/*.hpp"],
377            [
378                "CMakeLists.txt",
379                "compile_commands.json",
380                "Makefile",
381                ".clangd",
382            ],
383        )
384    }
385
386    /// Create a default configuration for zls.
387    #[must_use]
388    pub fn zls() -> Self {
389        Self::builtin(
390            "zig",
391            "zls",
392            &[],
393            &["**/*.zig"],
394            ["build.zig", "build.zig.zon"],
395        )
396    }
397}
398
399#[cfg(test)]
400#[allow(clippy::unwrap_used)]
401mod tests {
402    use tempfile::TempDir;
403
404    use super::*;
405
406    #[test]
407    fn test_rust_analyzer_defaults() {
408        let config = LspServerConfig::rust_analyzer();
409
410        assert_eq!(config.language_id, "rust");
411        assert_eq!(config.command, "rust-analyzer");
412        assert!(config.args.is_empty());
413        assert!(config.env.is_empty());
414        assert_eq!(config.file_patterns, vec!["**/*.rs"]);
415        assert!(config.initialization_options.is_none());
416        assert_eq!(config.timeout_seconds, 30);
417    }
418
419    #[test]
420    fn test_pyright_defaults() {
421        let config = LspServerConfig::pyright();
422
423        assert_eq!(config.language_id, "python");
424        assert_eq!(config.command, "pyright-langserver");
425        assert_eq!(config.args, vec!["--stdio"]);
426        assert!(config.env.is_empty());
427        assert_eq!(config.file_patterns, vec!["**/*.py"]);
428        assert!(config.initialization_options.is_none());
429        assert_eq!(config.timeout_seconds, 30);
430    }
431
432    #[test]
433    fn test_typescript_defaults() {
434        let config = LspServerConfig::typescript();
435
436        assert_eq!(config.language_id, "typescript");
437        assert_eq!(config.command, "typescript-language-server");
438        assert_eq!(config.args, vec!["--stdio"]);
439        assert!(config.env.is_empty());
440        assert_eq!(config.file_patterns, vec!["**/*.ts", "**/*.tsx"]);
441        assert!(config.initialization_options.is_none());
442        assert_eq!(config.timeout_seconds, 30);
443    }
444
445    #[test]
446    fn test_default_timeout() {
447        assert_eq!(default_timeout(), 30);
448    }
449
450    #[test]
451    fn test_custom_config() {
452        let mut env = HashMap::new();
453        env.insert("RUST_LOG".to_string(), "debug".to_string());
454
455        let config = LspServerConfig {
456            language_id: "custom".to_string(),
457            command: "custom-lsp".to_string(),
458            args: vec!["--flag".to_string()],
459            env: env.clone(),
460            file_patterns: vec!["**/*.custom".to_string()],
461            initialization_options: Some(serde_json::json!({"key": "value"})),
462            timeout_seconds: 60,
463            request_timeout_seconds: 45,
464            heuristics: None,
465            name: None,
466            handles: None,
467            indexing: crate::bridge::IndexingPolicy::Auto,
468        };
469
470        assert_eq!(config.language_id, "custom");
471        assert_eq!(config.command, "custom-lsp");
472        assert_eq!(config.args, vec!["--flag"]);
473        assert_eq!(config.env.get("RUST_LOG"), Some(&"debug".to_string()));
474        assert_eq!(config.file_patterns, vec!["**/*.custom"]);
475        assert!(config.initialization_options.is_some());
476        assert_eq!(config.timeout_seconds, 60);
477    }
478
479    #[test]
480    fn test_serde_roundtrip() {
481        let original = LspServerConfig::rust_analyzer();
482
483        let serialized = serde_json::to_string(&original).unwrap();
484        let deserialized: LspServerConfig = serde_json::from_str(&serialized).unwrap();
485
486        assert_eq!(deserialized.language_id, original.language_id);
487        assert_eq!(deserialized.command, original.command);
488        assert_eq!(deserialized.args, original.args);
489        assert_eq!(deserialized.timeout_seconds, original.timeout_seconds);
490        assert_eq!(
491            deserialized.request_timeout_seconds,
492            original.request_timeout_seconds
493        );
494    }
495
496    #[test]
497    fn test_default_request_timeout() {
498        assert_eq!(default_request_timeout(), 30);
499    }
500
501    #[test]
502    fn test_indexing_defaults_to_auto_when_omitted() {
503        assert_eq!(
504            LspServerConfig::rust_analyzer().indexing,
505            crate::bridge::IndexingPolicy::Auto
506        );
507    }
508
509    /// P4: `[[lsp_servers]] indexing = "disabled"` must round-trip through
510    /// TOML, the format `[[lsp_servers]]` entries are actually configured in.
511    #[test]
512    fn test_indexing_disabled_round_trips_through_toml() {
513        let toml_str = r#"
514            language_id = "rust"
515            command = "rust-analyzer"
516            indexing = "disabled"
517        "#;
518        let config: LspServerConfig = toml::from_str(toml_str).unwrap();
519        assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Disabled);
520
521        let serialized = toml::to_string(&config).unwrap();
522        assert!(serialized.contains(r#"indexing = "disabled""#));
523        let round_tripped: LspServerConfig = toml::from_str(&serialized).unwrap();
524        assert_eq!(
525            round_tripped.indexing,
526            crate::bridge::IndexingPolicy::Disabled
527        );
528    }
529
530    /// A `[[lsp_servers]]` entry that omits `indexing` entirely must default
531    /// to `Auto`, not fail `deny_unknown_fields`/require the field.
532    #[test]
533    fn test_indexing_omitted_from_toml_defaults_to_auto() {
534        let toml_str = r#"
535            language_id = "rust"
536            command = "rust-analyzer"
537        "#;
538        let config: LspServerConfig = toml::from_str(toml_str).unwrap();
539        assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Auto);
540    }
541
542    /// M4: a config at the default `IndexingPolicy::Auto` must not write
543    /// `indexing = "auto"` into the serialized TOML -- every other optional
544    /// field on this struct is skipped at its default, and generating
545    /// ~30 builtin server entries each carrying a redundant `indexing =
546    /// "auto"` line would be a visible regression to `mcpls.toml`'s
547    /// generated default.
548    #[test]
549    fn test_indexing_auto_is_omitted_from_serialized_toml() {
550        let config = LspServerConfig::rust_analyzer();
551        assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Auto);
552
553        let serialized = toml::to_string(&config).unwrap();
554        assert!(
555            !serialized.contains("indexing"),
556            "the default IndexingPolicy::Auto must be omitted, not serialized as \
557             indexing = \"auto\""
558        );
559    }
560
561    #[test]
562    fn test_clone() {
563        let config = LspServerConfig::rust_analyzer();
564        let cloned = config.clone();
565
566        assert_eq!(cloned.language_id, config.language_id);
567        assert_eq!(cloned.command, config.command);
568        assert_eq!(cloned.timeout_seconds, config.timeout_seconds);
569    }
570
571    #[test]
572    fn test_empty_env() {
573        let config = LspServerConfig::rust_analyzer();
574        assert!(config.env.is_empty());
575    }
576
577    #[test]
578    fn test_multiple_file_patterns() {
579        let config = LspServerConfig::typescript();
580        assert_eq!(config.file_patterns.len(), 2);
581        assert!(config.file_patterns.contains(&"**/*.ts".to_string()));
582        assert!(config.file_patterns.contains(&"**/*.tsx".to_string()));
583    }
584
585    #[test]
586    fn test_initialization_options_none_by_default() {
587        let configs = vec![
588            LspServerConfig::rust_analyzer(),
589            LspServerConfig::pyright(),
590            LspServerConfig::typescript(),
591        ];
592
593        for config in configs {
594            assert!(config.initialization_options.is_none());
595        }
596    }
597
598    // Heuristics tests
599    #[test]
600    fn test_heuristics_empty_always_applicable() {
601        let heuristics = ServerHeuristics::default();
602        let tmp = TempDir::new().unwrap();
603        assert!(heuristics.is_applicable(tmp.path()));
604    }
605
606    #[test]
607    fn test_heuristics_marker_present() {
608        let tmp = TempDir::new().unwrap();
609        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
610
611        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
612        assert!(heuristics.is_applicable(tmp.path()));
613    }
614
615    #[test]
616    fn test_heuristics_marker_absent() {
617        let tmp = TempDir::new().unwrap();
618        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
619        assert!(!heuristics.is_applicable(tmp.path()));
620    }
621
622    #[test]
623    fn test_heuristics_any_marker_matches() {
624        let tmp = TempDir::new().unwrap();
625        std::fs::write(tmp.path().join("setup.py"), "").unwrap();
626
627        let heuristics =
628            ServerHeuristics::with_markers(["pyproject.toml", "setup.py", "requirements.txt"]);
629        assert!(heuristics.is_applicable(tmp.path()));
630    }
631
632    #[test]
633    fn test_should_spawn_without_heuristics() {
634        let config = LspServerConfig {
635            language_id: "test".to_string(),
636            command: "test-lsp".to_string(),
637            args: vec![],
638            env: HashMap::new(),
639            file_patterns: vec![],
640            initialization_options: None,
641            timeout_seconds: 30,
642            request_timeout_seconds: 30,
643            heuristics: None,
644            name: None,
645            handles: None,
646            indexing: crate::bridge::IndexingPolicy::Auto,
647        };
648
649        let tmp = TempDir::new().unwrap();
650        assert!(config.should_spawn(tmp.path(), None));
651    }
652
653    #[test]
654    fn test_should_spawn_with_heuristics() {
655        let tmp = TempDir::new().unwrap();
656        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
657
658        let config = LspServerConfig::rust_analyzer();
659        assert!(config.should_spawn(tmp.path(), None));
660    }
661
662    #[test]
663    fn test_should_not_spawn_without_markers() {
664        let tmp = TempDir::new().unwrap();
665        let config = LspServerConfig::rust_analyzer();
666        assert!(!config.should_spawn(tmp.path(), None));
667    }
668
669    #[test]
670    fn test_heuristics_serde_roundtrip() {
671        let heuristics = ServerHeuristics::with_markers(["Cargo.toml", "rust-toolchain.toml"]);
672        let json = serde_json::to_string(&heuristics).unwrap();
673        let deserialized: ServerHeuristics = serde_json::from_str(&json).unwrap();
674        assert_eq!(deserialized.project_markers, heuristics.project_markers);
675    }
676
677    #[test]
678    fn test_default_rust_analyzer_heuristics() {
679        let config = LspServerConfig::rust_analyzer();
680        assert!(config.heuristics.is_some());
681        let markers = &config.heuristics.unwrap().project_markers;
682        assert!(markers.contains(&"Cargo.toml".to_string()));
683    }
684
685    #[test]
686    fn test_gopls_defaults() {
687        let config = LspServerConfig::gopls();
688
689        assert_eq!(config.language_id, "go");
690        assert_eq!(config.command, "gopls");
691        assert_eq!(config.args, vec!["serve"]);
692        assert!(config.heuristics.is_some());
693        let markers = &config.heuristics.unwrap().project_markers;
694        assert!(markers.contains(&"go.mod".to_string()));
695        assert!(markers.contains(&"go.sum".to_string()));
696    }
697
698    #[test]
699    fn test_clangd_defaults() {
700        let config = LspServerConfig::clangd();
701
702        assert_eq!(config.language_id, "cpp");
703        assert_eq!(config.command, "clangd");
704        assert!(config.args.is_empty());
705        assert!(config.heuristics.is_some());
706        let markers = &config.heuristics.unwrap().project_markers;
707        assert!(markers.contains(&"CMakeLists.txt".to_string()));
708        assert!(markers.contains(&"compile_commands.json".to_string()));
709    }
710
711    #[test]
712    fn test_zls_defaults() {
713        let config = LspServerConfig::zls();
714
715        assert_eq!(config.language_id, "zig");
716        assert_eq!(config.command, "zls");
717        assert!(config.args.is_empty());
718        assert!(config.heuristics.is_some());
719        let markers = &config.heuristics.unwrap().project_markers;
720        assert!(markers.contains(&"build.zig".to_string()));
721        assert!(markers.contains(&"build.zig.zon".to_string()));
722    }
723
724    // Recursive scanning tests
725    #[test]
726    fn test_recursive_empty_markers_always_applicable() {
727        let heuristics = ServerHeuristics::default();
728        let tmp = TempDir::new().unwrap();
729        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
730    }
731
732    #[test]
733    fn test_recursive_marker_at_root() {
734        let tmp = TempDir::new().unwrap();
735        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
736
737        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
738        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
739    }
740
741    #[test]
742    fn test_recursive_nested_python_project() {
743        let tmp = TempDir::new().unwrap();
744        // Create Rust project at root
745        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
746        // Create nested Python project
747        let python_dir = tmp.path().join("python");
748        std::fs::create_dir(&python_dir).unwrap();
749        std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
750
751        let heuristics = ServerHeuristics::with_markers(["pyproject.toml", "setup.py"]);
752        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
753    }
754
755    #[test]
756    fn test_recursive_deeply_nested_marker() {
757        let tmp = TempDir::new().unwrap();
758        // Create a deeply nested structure
759        let deep_path = tmp.path().join("level1").join("level2").join("level3");
760        std::fs::create_dir_all(&deep_path).unwrap();
761        std::fs::write(deep_path.join("go.mod"), "").unwrap();
762
763        let heuristics = ServerHeuristics::with_markers(["go.mod"]);
764        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
765    }
766
767    #[test]
768    fn test_recursive_no_marker_found() {
769        let tmp = TempDir::new().unwrap();
770        std::fs::create_dir(tmp.path().join("src")).unwrap();
771        std::fs::write(tmp.path().join("src").join("main.rs"), "").unwrap();
772
773        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
774        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
775    }
776
777    #[test]
778    fn test_recursive_max_depth_respected() {
779        let tmp = TempDir::new().unwrap();
780        // Create marker at depth 5
781        let deep_path = tmp.path().join("a").join("b").join("c").join("d").join("e");
782        std::fs::create_dir_all(&deep_path).unwrap();
783        std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
784
785        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
786        // With max_depth=3, should not find marker at depth 5
787        assert!(!heuristics.is_applicable_recursive(tmp.path(), Some(3)));
788        // With max_depth=10 (default), should find it
789        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
790    }
791
792    #[test]
793    fn test_recursive_excludes_node_modules() {
794        let tmp = TempDir::new().unwrap();
795        // Create package.json inside node_modules (should be ignored)
796        let node_modules = tmp.path().join("node_modules").join("some-package");
797        std::fs::create_dir_all(&node_modules).unwrap();
798        std::fs::write(node_modules.join("package.json"), "").unwrap();
799
800        let heuristics = ServerHeuristics::with_markers(["package.json"]);
801        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
802    }
803
804    #[test]
805    fn test_recursive_excludes_target_directory() {
806        let tmp = TempDir::new().unwrap();
807        // Create Cargo.toml inside target (should be ignored)
808        let target = tmp.path().join("target").join("debug");
809        std::fs::create_dir_all(&target).unwrap();
810        std::fs::write(target.join("Cargo.toml"), "").unwrap();
811
812        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
813        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
814    }
815
816    #[test]
817    fn test_recursive_excludes_git_directory() {
818        let tmp = TempDir::new().unwrap();
819        let git_dir = tmp.path().join(".git").join("hooks");
820        std::fs::create_dir_all(&git_dir).unwrap();
821        std::fs::write(git_dir.join("Cargo.toml"), "").unwrap();
822
823        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
824        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
825    }
826
827    #[test]
828    fn test_recursive_excludes_pycache() {
829        let tmp = TempDir::new().unwrap();
830        let pycache = tmp.path().join("__pycache__");
831        std::fs::create_dir_all(&pycache).unwrap();
832        std::fs::write(pycache.join("pyproject.toml"), "").unwrap();
833
834        let heuristics = ServerHeuristics::with_markers(["pyproject.toml"]);
835        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
836    }
837
838    #[test]
839    fn test_recursive_excludes_venv() {
840        let tmp = TempDir::new().unwrap();
841        let venv = tmp.path().join(".venv").join("lib");
842        std::fs::create_dir_all(&venv).unwrap();
843        std::fs::write(venv.join("setup.py"), "").unwrap();
844
845        let heuristics = ServerHeuristics::with_markers(["setup.py"]);
846        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
847    }
848
849    /// #476: `standard_filters(false)` is a bulk setter that resets
850    /// `.git_ignore` (among others); it must run before the individual
851    /// overrides, or it silently negates the `.git_ignore(true)` set
852    /// afterward and the walk descends into gitignored directories.
853    #[test]
854    fn test_recursive_respects_gitignore_in_marker_search() {
855        let tmp = TempDir::new().unwrap();
856        // A bare `.git` directory is enough for the `ignore` crate to treat
857        // this fixture as a git repository and start honoring `.gitignore`.
858        std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
859        std::fs::write(tmp.path().join(".gitignore"), "ignored/\n").unwrap();
860
861        let ignored_dir = tmp.path().join("ignored");
862        std::fs::create_dir_all(&ignored_dir).unwrap();
863        std::fs::write(ignored_dir.join("Cargo.toml"), "").unwrap();
864
865        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
866        assert!(
867            !heuristics.is_applicable_recursive(tmp.path(), None),
868            "marker inside a gitignored directory must not make the server applicable"
869        );
870    }
871
872    #[test]
873    fn test_recursive_finds_marker_outside_excluded() {
874        let tmp = TempDir::new().unwrap();
875        // Create excluded dir with marker
876        let node_modules = tmp.path().join("node_modules");
877        std::fs::create_dir_all(&node_modules).unwrap();
878        std::fs::write(node_modules.join("package.json"), "").unwrap();
879        // Create valid marker in src
880        let src = tmp.path().join("src");
881        std::fs::create_dir_all(&src).unwrap();
882        std::fs::write(src.join("package.json"), "").unwrap();
883
884        let heuristics = ServerHeuristics::with_markers(["package.json"]);
885        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
886    }
887
888    #[test]
889    fn test_recursive_monorepo_structure() {
890        let tmp = TempDir::new().unwrap();
891        // Create monorepo with multiple language projects
892        let rust_pkg = tmp.path().join("packages").join("rust-lib");
893        let python_pkg = tmp.path().join("packages").join("python-bindings");
894        let ts_pkg = tmp.path().join("packages").join("typescript-client");
895
896        std::fs::create_dir_all(&rust_pkg).unwrap();
897        std::fs::create_dir_all(&python_pkg).unwrap();
898        std::fs::create_dir_all(&ts_pkg).unwrap();
899
900        std::fs::write(rust_pkg.join("Cargo.toml"), "").unwrap();
901        std::fs::write(python_pkg.join("pyproject.toml"), "").unwrap();
902        std::fs::write(ts_pkg.join("package.json"), "").unwrap();
903
904        // All should be detected
905        let rust_heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
906        let python_heuristics = ServerHeuristics::with_markers(["pyproject.toml"]);
907        let ts_heuristics = ServerHeuristics::with_markers(["package.json"]);
908
909        assert!(rust_heuristics.is_applicable_recursive(tmp.path(), None));
910        assert!(python_heuristics.is_applicable_recursive(tmp.path(), None));
911        assert!(ts_heuristics.is_applicable_recursive(tmp.path(), None));
912    }
913
914    #[test]
915    fn test_should_spawn_recursive() {
916        let tmp = TempDir::new().unwrap();
917        // Create nested Python project in Rust workspace
918        let python_dir = tmp.path().join("bindings").join("python");
919        std::fs::create_dir_all(&python_dir).unwrap();
920        std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
921
922        let config = LspServerConfig::pyright();
923        assert!(config.should_spawn(tmp.path(), None));
924    }
925
926    #[test]
927    fn test_should_spawn_with_custom_max_depth() {
928        let tmp = TempDir::new().unwrap();
929        let deep_path = tmp.path().join("a").join("b").join("c").join("d");
930        std::fs::create_dir_all(&deep_path).unwrap();
931        std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
932
933        let config = LspServerConfig::rust_analyzer();
934        // Shallow depth should not find it
935        assert!(!config.should_spawn(tmp.path(), Some(2)));
936        // Default depth should find it
937        assert!(config.should_spawn(tmp.path(), None));
938    }
939
940    #[test]
941    fn test_default_heuristics_max_depth() {
942        assert_eq!(DEFAULT_HEURISTICS_MAX_DEPTH, 10);
943    }
944
945    #[test]
946    fn test_excluded_directories_constant() {
947        assert!(EXCLUDED_DIRECTORIES.contains(&"node_modules"));
948        assert!(EXCLUDED_DIRECTORIES.contains(&"target"));
949        assert!(EXCLUDED_DIRECTORIES.contains(&".git"));
950        assert!(EXCLUDED_DIRECTORIES.contains(&"__pycache__"));
951        assert!(EXCLUDED_DIRECTORIES.contains(&".venv"));
952    }
953}