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
9/// Default max depth for recursive marker search.
10pub const DEFAULT_HEURISTICS_MAX_DEPTH: usize = 10;
11
12/// Directories excluded from recursive marker search.
13/// These are well-known directories that should never contain project markers.
14const EXCLUDED_DIRECTORIES: &[&str] = &[
15    "node_modules",
16    "target",
17    ".git",
18    "__pycache__",
19    ".venv",
20    "venv",
21    ".tox",
22    ".mypy_cache",
23    ".pytest_cache",
24    "build",
25    "dist",
26    ".cargo",
27    ".rustup",
28    "vendor",
29    "coverage",
30    ".next",
31    ".nuxt",
32];
33
34/// Heuristics for determining if an LSP server should be spawned.
35///
36/// Used to prevent spawning servers in projects where they are not applicable
37/// (e.g., rust-analyzer in a Python-only project).
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39#[serde(deny_unknown_fields)]
40pub struct ServerHeuristics {
41    /// Files or directories that indicate this server is applicable.
42    ///
43    /// The server will spawn if ANY of these markers exist anywhere in the workspace tree
44    /// (searched recursively up to `heuristics_max_depth`). Well-known directories like
45    /// `node_modules`, `target`, `.git` are excluded from the search.
46    ///
47    /// If empty, the server will always attempt to spawn.
48    #[serde(default)]
49    pub project_markers: Vec<String>,
50}
51
52impl ServerHeuristics {
53    /// Create heuristics with the given project markers.
54    #[must_use]
55    pub fn with_markers<I, S>(markers: I) -> Self
56    where
57        I: IntoIterator<Item = S>,
58        S: Into<String>,
59    {
60        Self {
61            project_markers: markers.into_iter().map(Into::into).collect(),
62        }
63    }
64
65    /// Check if any marker exists at the given workspace root.
66    ///
67    /// Returns `true` if:
68    /// - No markers are defined (empty = always applicable)
69    /// - At least one marker file/directory exists
70    #[must_use]
71    pub fn is_applicable(&self, workspace_root: &Path) -> bool {
72        if self.project_markers.is_empty() {
73            return true;
74        }
75        self.project_markers
76            .iter()
77            .any(|marker| workspace_root.join(marker).exists())
78    }
79
80    /// Check if any marker exists anywhere in the workspace tree.
81    ///
82    /// Recursively searches the workspace for project markers, excluding
83    /// well-known directories like `node_modules`, `target`, `.git`, etc.
84    ///
85    /// # Arguments
86    ///
87    /// * `workspace_root` - Root directory to search from
88    /// * `max_depth` - Maximum recursion depth (default: 10)
89    ///
90    /// # Returns
91    ///
92    /// `true` if any marker is found, `false` otherwise.
93    #[must_use]
94    pub fn is_applicable_recursive(&self, workspace_root: &Path, max_depth: Option<usize>) -> bool {
95        if self.project_markers.is_empty() {
96            return true;
97        }
98
99        // First check the root level (fast path)
100        if self.is_applicable(workspace_root) {
101            return true;
102        }
103
104        let depth = max_depth.unwrap_or(DEFAULT_HEURISTICS_MAX_DEPTH);
105        self.find_any_marker_recursive(workspace_root, depth)
106    }
107
108    /// Search recursively for any marker file.
109    fn find_any_marker_recursive(&self, workspace_root: &Path, max_depth: usize) -> bool {
110        let mut builder = WalkBuilder::new(workspace_root);
111        builder
112            .max_depth(Some(max_depth))
113            .hidden(false)
114            .git_ignore(true)
115            .git_global(false)
116            .git_exclude(false)
117            .follow_links(false)
118            .standard_filters(false)
119            .filter_entry(|entry| {
120                // Skip excluded directories entirely (prevents descending into them)
121                if entry.file_type().is_some_and(|ft| ft.is_dir())
122                    && let Some(name) = entry.file_name().to_str()
123                    && EXCLUDED_DIRECTORIES.contains(&name)
124                {
125                    return false;
126                }
127                true
128            });
129
130        for entry in builder.build().flatten() {
131            let path = entry.path();
132
133            // Check if this entry matches any marker
134            if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
135                && self.project_markers.iter().any(|m| m == file_name)
136            {
137                return true;
138            }
139        }
140
141        false
142    }
143}
144
145/// Configuration for a single LSP server.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct LspServerConfig {
149    /// Language identifier (e.g., "rust", "python", "typescript").
150    pub language_id: String,
151
152    /// Command to start the LSP server.
153    pub command: String,
154
155    /// Arguments to pass to the LSP server command.
156    #[serde(default)]
157    pub args: Vec<String>,
158
159    /// Environment variables for the LSP server process.
160    #[serde(default)]
161    pub env: HashMap<String, String>,
162
163    /// File patterns this server handles (glob patterns).
164    #[serde(default)]
165    pub file_patterns: Vec<String>,
166
167    /// LSP initialization options (server-specific).
168    #[serde(default)]
169    pub initialization_options: Option<serde_json::Value>,
170
171    /// Request timeout in seconds.
172    #[serde(default = "default_timeout")]
173    pub timeout_seconds: u64,
174
175    /// Heuristics for determining if this server should be spawned.
176    /// If not specified, the server will always attempt to spawn.
177    #[serde(default)]
178    pub heuristics: Option<ServerHeuristics>,
179}
180
181const fn default_timeout() -> u64 {
182    30
183}
184
185impl LspServerConfig {
186    /// Check if this server should be spawned for the given workspace.
187    ///
188    /// Uses recursive marker search to detect nested projects.
189    ///
190    /// # Arguments
191    ///
192    /// * `workspace_root` - Root directory of the workspace
193    /// * `max_depth` - Maximum depth for recursive search (default: 10)
194    #[must_use]
195    pub fn should_spawn(&self, workspace_root: &Path, max_depth: Option<usize>) -> bool {
196        self.heuristics
197            .as_ref()
198            .is_none_or(|h| h.is_applicable_recursive(workspace_root, max_depth))
199    }
200
201    /// Create a default configuration for rust-analyzer.
202    #[must_use]
203    pub fn rust_analyzer() -> Self {
204        Self {
205            language_id: "rust".to_string(),
206            command: "rust-analyzer".to_string(),
207            args: vec![],
208            env: HashMap::new(),
209            file_patterns: vec!["**/*.rs".to_string()],
210            initialization_options: None,
211            timeout_seconds: default_timeout(),
212            heuristics: Some(ServerHeuristics::with_markers([
213                "Cargo.toml",
214                "rust-toolchain.toml",
215            ])),
216        }
217    }
218
219    /// Create a default configuration for pyright.
220    #[must_use]
221    pub fn pyright() -> Self {
222        Self {
223            language_id: "python".to_string(),
224            command: "pyright-langserver".to_string(),
225            args: vec!["--stdio".to_string()],
226            env: HashMap::new(),
227            file_patterns: vec!["**/*.py".to_string()],
228            initialization_options: None,
229            timeout_seconds: default_timeout(),
230            heuristics: Some(ServerHeuristics::with_markers([
231                "pyproject.toml",
232                "setup.py",
233                "requirements.txt",
234                "pyrightconfig.json",
235            ])),
236        }
237    }
238
239    /// Create a default configuration for TypeScript language server.
240    #[must_use]
241    pub fn typescript() -> Self {
242        Self {
243            language_id: "typescript".to_string(),
244            command: "typescript-language-server".to_string(),
245            args: vec!["--stdio".to_string()],
246            env: HashMap::new(),
247            file_patterns: vec!["**/*.ts".to_string(), "**/*.tsx".to_string()],
248            initialization_options: None,
249            timeout_seconds: default_timeout(),
250            heuristics: Some(ServerHeuristics::with_markers([
251                "package.json",
252                "tsconfig.json",
253                "jsconfig.json",
254            ])),
255        }
256    }
257
258    /// Create a default configuration for gopls.
259    #[must_use]
260    pub fn gopls() -> Self {
261        Self {
262            language_id: "go".to_string(),
263            command: "gopls".to_string(),
264            args: vec!["serve".to_string()],
265            env: HashMap::new(),
266            file_patterns: vec!["**/*.go".to_string()],
267            initialization_options: None,
268            timeout_seconds: default_timeout(),
269            heuristics: Some(ServerHeuristics::with_markers(["go.mod", "go.sum"])),
270        }
271    }
272
273    /// Create a default configuration for clangd.
274    #[must_use]
275    pub fn clangd() -> Self {
276        Self {
277            language_id: "cpp".to_string(),
278            command: "clangd".to_string(),
279            args: vec![],
280            env: HashMap::new(),
281            file_patterns: vec![
282                "**/*.c".to_string(),
283                "**/*.cpp".to_string(),
284                "**/*.h".to_string(),
285                "**/*.hpp".to_string(),
286            ],
287            initialization_options: None,
288            timeout_seconds: default_timeout(),
289            heuristics: Some(ServerHeuristics::with_markers([
290                "CMakeLists.txt",
291                "compile_commands.json",
292                "Makefile",
293                ".clangd",
294            ])),
295        }
296    }
297
298    /// Create a default configuration for zls.
299    #[must_use]
300    pub fn zls() -> Self {
301        Self {
302            language_id: "zig".to_string(),
303            command: "zls".to_string(),
304            args: vec![],
305            env: HashMap::new(),
306            file_patterns: vec!["**/*.zig".to_string()],
307            initialization_options: None,
308            timeout_seconds: default_timeout(),
309            heuristics: Some(ServerHeuristics::with_markers([
310                "build.zig",
311                "build.zig.zon",
312            ])),
313        }
314    }
315}
316
317#[cfg(test)]
318#[allow(clippy::unwrap_used)]
319mod tests {
320    use tempfile::TempDir;
321
322    use super::*;
323
324    #[test]
325    fn test_rust_analyzer_defaults() {
326        let config = LspServerConfig::rust_analyzer();
327
328        assert_eq!(config.language_id, "rust");
329        assert_eq!(config.command, "rust-analyzer");
330        assert!(config.args.is_empty());
331        assert!(config.env.is_empty());
332        assert_eq!(config.file_patterns, vec!["**/*.rs"]);
333        assert!(config.initialization_options.is_none());
334        assert_eq!(config.timeout_seconds, 30);
335    }
336
337    #[test]
338    fn test_pyright_defaults() {
339        let config = LspServerConfig::pyright();
340
341        assert_eq!(config.language_id, "python");
342        assert_eq!(config.command, "pyright-langserver");
343        assert_eq!(config.args, vec!["--stdio"]);
344        assert!(config.env.is_empty());
345        assert_eq!(config.file_patterns, vec!["**/*.py"]);
346        assert!(config.initialization_options.is_none());
347        assert_eq!(config.timeout_seconds, 30);
348    }
349
350    #[test]
351    fn test_typescript_defaults() {
352        let config = LspServerConfig::typescript();
353
354        assert_eq!(config.language_id, "typescript");
355        assert_eq!(config.command, "typescript-language-server");
356        assert_eq!(config.args, vec!["--stdio"]);
357        assert!(config.env.is_empty());
358        assert_eq!(config.file_patterns, vec!["**/*.ts", "**/*.tsx"]);
359        assert!(config.initialization_options.is_none());
360        assert_eq!(config.timeout_seconds, 30);
361    }
362
363    #[test]
364    fn test_default_timeout() {
365        assert_eq!(default_timeout(), 30);
366    }
367
368    #[test]
369    fn test_custom_config() {
370        let mut env = HashMap::new();
371        env.insert("RUST_LOG".to_string(), "debug".to_string());
372
373        let config = LspServerConfig {
374            language_id: "custom".to_string(),
375            command: "custom-lsp".to_string(),
376            args: vec!["--flag".to_string()],
377            env: env.clone(),
378            file_patterns: vec!["**/*.custom".to_string()],
379            initialization_options: Some(serde_json::json!({"key": "value"})),
380            timeout_seconds: 60,
381            heuristics: None,
382        };
383
384        assert_eq!(config.language_id, "custom");
385        assert_eq!(config.command, "custom-lsp");
386        assert_eq!(config.args, vec!["--flag"]);
387        assert_eq!(config.env.get("RUST_LOG"), Some(&"debug".to_string()));
388        assert_eq!(config.file_patterns, vec!["**/*.custom"]);
389        assert!(config.initialization_options.is_some());
390        assert_eq!(config.timeout_seconds, 60);
391    }
392
393    #[test]
394    fn test_serde_roundtrip() {
395        let original = LspServerConfig::rust_analyzer();
396
397        let serialized = serde_json::to_string(&original).unwrap();
398        let deserialized: LspServerConfig = serde_json::from_str(&serialized).unwrap();
399
400        assert_eq!(deserialized.language_id, original.language_id);
401        assert_eq!(deserialized.command, original.command);
402        assert_eq!(deserialized.args, original.args);
403        assert_eq!(deserialized.timeout_seconds, original.timeout_seconds);
404    }
405
406    #[test]
407    fn test_clone() {
408        let config = LspServerConfig::rust_analyzer();
409        let cloned = config.clone();
410
411        assert_eq!(cloned.language_id, config.language_id);
412        assert_eq!(cloned.command, config.command);
413        assert_eq!(cloned.timeout_seconds, config.timeout_seconds);
414    }
415
416    #[test]
417    fn test_empty_env() {
418        let config = LspServerConfig::rust_analyzer();
419        assert!(config.env.is_empty());
420    }
421
422    #[test]
423    fn test_multiple_file_patterns() {
424        let config = LspServerConfig::typescript();
425        assert_eq!(config.file_patterns.len(), 2);
426        assert!(config.file_patterns.contains(&"**/*.ts".to_string()));
427        assert!(config.file_patterns.contains(&"**/*.tsx".to_string()));
428    }
429
430    #[test]
431    fn test_initialization_options_none_by_default() {
432        let configs = vec![
433            LspServerConfig::rust_analyzer(),
434            LspServerConfig::pyright(),
435            LspServerConfig::typescript(),
436        ];
437
438        for config in configs {
439            assert!(config.initialization_options.is_none());
440        }
441    }
442
443    // Heuristics tests
444    #[test]
445    fn test_heuristics_empty_always_applicable() {
446        let heuristics = ServerHeuristics::default();
447        let tmp = TempDir::new().unwrap();
448        assert!(heuristics.is_applicable(tmp.path()));
449    }
450
451    #[test]
452    fn test_heuristics_marker_present() {
453        let tmp = TempDir::new().unwrap();
454        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
455
456        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
457        assert!(heuristics.is_applicable(tmp.path()));
458    }
459
460    #[test]
461    fn test_heuristics_marker_absent() {
462        let tmp = TempDir::new().unwrap();
463        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
464        assert!(!heuristics.is_applicable(tmp.path()));
465    }
466
467    #[test]
468    fn test_heuristics_any_marker_matches() {
469        let tmp = TempDir::new().unwrap();
470        std::fs::write(tmp.path().join("setup.py"), "").unwrap();
471
472        let heuristics =
473            ServerHeuristics::with_markers(["pyproject.toml", "setup.py", "requirements.txt"]);
474        assert!(heuristics.is_applicable(tmp.path()));
475    }
476
477    #[test]
478    fn test_should_spawn_without_heuristics() {
479        let config = LspServerConfig {
480            language_id: "test".to_string(),
481            command: "test-lsp".to_string(),
482            args: vec![],
483            env: HashMap::new(),
484            file_patterns: vec![],
485            initialization_options: None,
486            timeout_seconds: 30,
487            heuristics: None,
488        };
489
490        let tmp = TempDir::new().unwrap();
491        assert!(config.should_spawn(tmp.path(), None));
492    }
493
494    #[test]
495    fn test_should_spawn_with_heuristics() {
496        let tmp = TempDir::new().unwrap();
497        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
498
499        let config = LspServerConfig::rust_analyzer();
500        assert!(config.should_spawn(tmp.path(), None));
501    }
502
503    #[test]
504    fn test_should_not_spawn_without_markers() {
505        let tmp = TempDir::new().unwrap();
506        let config = LspServerConfig::rust_analyzer();
507        assert!(!config.should_spawn(tmp.path(), None));
508    }
509
510    #[test]
511    fn test_heuristics_serde_roundtrip() {
512        let heuristics = ServerHeuristics::with_markers(["Cargo.toml", "rust-toolchain.toml"]);
513        let json = serde_json::to_string(&heuristics).unwrap();
514        let deserialized: ServerHeuristics = serde_json::from_str(&json).unwrap();
515        assert_eq!(deserialized.project_markers, heuristics.project_markers);
516    }
517
518    #[test]
519    fn test_default_rust_analyzer_heuristics() {
520        let config = LspServerConfig::rust_analyzer();
521        assert!(config.heuristics.is_some());
522        let markers = &config.heuristics.unwrap().project_markers;
523        assert!(markers.contains(&"Cargo.toml".to_string()));
524    }
525
526    #[test]
527    fn test_gopls_defaults() {
528        let config = LspServerConfig::gopls();
529
530        assert_eq!(config.language_id, "go");
531        assert_eq!(config.command, "gopls");
532        assert_eq!(config.args, vec!["serve"]);
533        assert!(config.heuristics.is_some());
534        let markers = &config.heuristics.unwrap().project_markers;
535        assert!(markers.contains(&"go.mod".to_string()));
536        assert!(markers.contains(&"go.sum".to_string()));
537    }
538
539    #[test]
540    fn test_clangd_defaults() {
541        let config = LspServerConfig::clangd();
542
543        assert_eq!(config.language_id, "cpp");
544        assert_eq!(config.command, "clangd");
545        assert!(config.args.is_empty());
546        assert!(config.heuristics.is_some());
547        let markers = &config.heuristics.unwrap().project_markers;
548        assert!(markers.contains(&"CMakeLists.txt".to_string()));
549        assert!(markers.contains(&"compile_commands.json".to_string()));
550    }
551
552    #[test]
553    fn test_zls_defaults() {
554        let config = LspServerConfig::zls();
555
556        assert_eq!(config.language_id, "zig");
557        assert_eq!(config.command, "zls");
558        assert!(config.args.is_empty());
559        assert!(config.heuristics.is_some());
560        let markers = &config.heuristics.unwrap().project_markers;
561        assert!(markers.contains(&"build.zig".to_string()));
562        assert!(markers.contains(&"build.zig.zon".to_string()));
563    }
564
565    // Recursive scanning tests
566    #[test]
567    fn test_recursive_empty_markers_always_applicable() {
568        let heuristics = ServerHeuristics::default();
569        let tmp = TempDir::new().unwrap();
570        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
571    }
572
573    #[test]
574    fn test_recursive_marker_at_root() {
575        let tmp = TempDir::new().unwrap();
576        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
577
578        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
579        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
580    }
581
582    #[test]
583    fn test_recursive_nested_python_project() {
584        let tmp = TempDir::new().unwrap();
585        // Create Rust project at root
586        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
587        // Create nested Python project
588        let python_dir = tmp.path().join("python");
589        std::fs::create_dir(&python_dir).unwrap();
590        std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
591
592        let heuristics = ServerHeuristics::with_markers(["pyproject.toml", "setup.py"]);
593        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
594    }
595
596    #[test]
597    fn test_recursive_deeply_nested_marker() {
598        let tmp = TempDir::new().unwrap();
599        // Create a deeply nested structure
600        let deep_path = tmp.path().join("level1").join("level2").join("level3");
601        std::fs::create_dir_all(&deep_path).unwrap();
602        std::fs::write(deep_path.join("go.mod"), "").unwrap();
603
604        let heuristics = ServerHeuristics::with_markers(["go.mod"]);
605        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
606    }
607
608    #[test]
609    fn test_recursive_no_marker_found() {
610        let tmp = TempDir::new().unwrap();
611        std::fs::create_dir(tmp.path().join("src")).unwrap();
612        std::fs::write(tmp.path().join("src").join("main.rs"), "").unwrap();
613
614        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
615        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
616    }
617
618    #[test]
619    fn test_recursive_max_depth_respected() {
620        let tmp = TempDir::new().unwrap();
621        // Create marker at depth 5
622        let deep_path = tmp.path().join("a").join("b").join("c").join("d").join("e");
623        std::fs::create_dir_all(&deep_path).unwrap();
624        std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
625
626        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
627        // With max_depth=3, should not find marker at depth 5
628        assert!(!heuristics.is_applicable_recursive(tmp.path(), Some(3)));
629        // With max_depth=10 (default), should find it
630        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
631    }
632
633    #[test]
634    fn test_recursive_excludes_node_modules() {
635        let tmp = TempDir::new().unwrap();
636        // Create package.json inside node_modules (should be ignored)
637        let node_modules = tmp.path().join("node_modules").join("some-package");
638        std::fs::create_dir_all(&node_modules).unwrap();
639        std::fs::write(node_modules.join("package.json"), "").unwrap();
640
641        let heuristics = ServerHeuristics::with_markers(["package.json"]);
642        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
643    }
644
645    #[test]
646    fn test_recursive_excludes_target_directory() {
647        let tmp = TempDir::new().unwrap();
648        // Create Cargo.toml inside target (should be ignored)
649        let target = tmp.path().join("target").join("debug");
650        std::fs::create_dir_all(&target).unwrap();
651        std::fs::write(target.join("Cargo.toml"), "").unwrap();
652
653        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
654        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
655    }
656
657    #[test]
658    fn test_recursive_excludes_git_directory() {
659        let tmp = TempDir::new().unwrap();
660        let git_dir = tmp.path().join(".git").join("hooks");
661        std::fs::create_dir_all(&git_dir).unwrap();
662        std::fs::write(git_dir.join("Cargo.toml"), "").unwrap();
663
664        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
665        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
666    }
667
668    #[test]
669    fn test_recursive_excludes_pycache() {
670        let tmp = TempDir::new().unwrap();
671        let pycache = tmp.path().join("__pycache__");
672        std::fs::create_dir_all(&pycache).unwrap();
673        std::fs::write(pycache.join("pyproject.toml"), "").unwrap();
674
675        let heuristics = ServerHeuristics::with_markers(["pyproject.toml"]);
676        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
677    }
678
679    #[test]
680    fn test_recursive_excludes_venv() {
681        let tmp = TempDir::new().unwrap();
682        let venv = tmp.path().join(".venv").join("lib");
683        std::fs::create_dir_all(&venv).unwrap();
684        std::fs::write(venv.join("setup.py"), "").unwrap();
685
686        let heuristics = ServerHeuristics::with_markers(["setup.py"]);
687        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
688    }
689
690    #[test]
691    fn test_recursive_finds_marker_outside_excluded() {
692        let tmp = TempDir::new().unwrap();
693        // Create excluded dir with marker
694        let node_modules = tmp.path().join("node_modules");
695        std::fs::create_dir_all(&node_modules).unwrap();
696        std::fs::write(node_modules.join("package.json"), "").unwrap();
697        // Create valid marker in src
698        let src = tmp.path().join("src");
699        std::fs::create_dir_all(&src).unwrap();
700        std::fs::write(src.join("package.json"), "").unwrap();
701
702        let heuristics = ServerHeuristics::with_markers(["package.json"]);
703        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
704    }
705
706    #[test]
707    fn test_recursive_monorepo_structure() {
708        let tmp = TempDir::new().unwrap();
709        // Create monorepo with multiple language projects
710        let rust_pkg = tmp.path().join("packages").join("rust-lib");
711        let python_pkg = tmp.path().join("packages").join("python-bindings");
712        let ts_pkg = tmp.path().join("packages").join("typescript-client");
713
714        std::fs::create_dir_all(&rust_pkg).unwrap();
715        std::fs::create_dir_all(&python_pkg).unwrap();
716        std::fs::create_dir_all(&ts_pkg).unwrap();
717
718        std::fs::write(rust_pkg.join("Cargo.toml"), "").unwrap();
719        std::fs::write(python_pkg.join("pyproject.toml"), "").unwrap();
720        std::fs::write(ts_pkg.join("package.json"), "").unwrap();
721
722        // All should be detected
723        let rust_heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
724        let python_heuristics = ServerHeuristics::with_markers(["pyproject.toml"]);
725        let ts_heuristics = ServerHeuristics::with_markers(["package.json"]);
726
727        assert!(rust_heuristics.is_applicable_recursive(tmp.path(), None));
728        assert!(python_heuristics.is_applicable_recursive(tmp.path(), None));
729        assert!(ts_heuristics.is_applicable_recursive(tmp.path(), None));
730    }
731
732    #[test]
733    fn test_should_spawn_recursive() {
734        let tmp = TempDir::new().unwrap();
735        // Create nested Python project in Rust workspace
736        let python_dir = tmp.path().join("bindings").join("python");
737        std::fs::create_dir_all(&python_dir).unwrap();
738        std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
739
740        let config = LspServerConfig::pyright();
741        assert!(config.should_spawn(tmp.path(), None));
742    }
743
744    #[test]
745    fn test_should_spawn_with_custom_max_depth() {
746        let tmp = TempDir::new().unwrap();
747        let deep_path = tmp.path().join("a").join("b").join("c").join("d");
748        std::fs::create_dir_all(&deep_path).unwrap();
749        std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
750
751        let config = LspServerConfig::rust_analyzer();
752        // Shallow depth should not find it
753        assert!(!config.should_spawn(tmp.path(), Some(2)));
754        // Default depth should find it
755        assert!(config.should_spawn(tmp.path(), None));
756    }
757
758    #[test]
759    fn test_default_heuristics_max_depth() {
760        assert_eq!(DEFAULT_HEURISTICS_MAX_DEPTH, 10);
761    }
762
763    #[test]
764    fn test_excluded_directories_constant() {
765        assert!(EXCLUDED_DIRECTORIES.contains(&"node_modules"));
766        assert!(EXCLUDED_DIRECTORIES.contains(&"target"));
767        assert!(EXCLUDED_DIRECTORIES.contains(&".git"));
768        assert!(EXCLUDED_DIRECTORIES.contains(&"__pycache__"));
769        assert!(EXCLUDED_DIRECTORIES.contains(&".venv"));
770    }
771}