1use 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
12pub const DEFAULT_HEURISTICS_MAX_DEPTH: usize = 10;
14
15const 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42#[serde(deny_unknown_fields)]
43pub struct ServerHeuristics {
44 #[serde(default)]
52 pub project_markers: Vec<String>,
53}
54
55impl ServerHeuristics {
56 #[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 #[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 #[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 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 fn find_any_marker_recursive(&self, workspace_root: &Path, max_depth: usize) -> bool {
113 let mut builder = WalkBuilder::new(workspace_root);
114 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct LspServerConfig {
155 pub language_id: String,
157
158 pub command: String,
160
161 #[serde(default)]
163 pub args: Vec<String>,
164
165 #[serde(default)]
167 pub env: HashMap<String, String>,
168
169 #[serde(default)]
171 pub file_patterns: Vec<String>,
172
173 #[serde(default)]
175 pub initialization_options: Option<serde_json::Value>,
176
177 #[serde(default = "default_timeout")]
183 pub timeout_seconds: u64,
184
185 #[serde(default = "default_request_timeout")]
197 pub request_timeout_seconds: u64,
198
199 #[serde(default)]
202 pub heuristics: Option<ServerHeuristics>,
203
204 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub name: Option<String>,
213
214 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub handles: Option<Vec<ToolKind>>,
221
222 #[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
240pub const MAX_TIMEOUT_SECONDS: u64 = 900;
262
263impl LspServerConfig {
264 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
746 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 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 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 assert!(!heuristics.is_applicable_recursive(tmp.path(), Some(3)));
788 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 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 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 #[test]
854 fn test_recursive_respects_gitignore_in_marker_search() {
855 let tmp = TempDir::new().unwrap();
856 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 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 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 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 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 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 assert!(!config.should_spawn(tmp.path(), Some(2)));
936 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}