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