1use std::collections::HashMap;
4use std::path::Path;
5
6use ignore::WalkBuilder;
7use serde::{Deserialize, Serialize};
8
9pub const DEFAULT_HEURISTICS_MAX_DEPTH: usize = 10;
11
12const 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39#[serde(deny_unknown_fields)]
40pub struct ServerHeuristics {
41 #[serde(default)]
49 pub project_markers: Vec<String>,
50}
51
52impl ServerHeuristics {
53 #[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 #[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 #[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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct LspServerConfig {
150 pub language_id: String,
152
153 pub command: String,
155
156 #[serde(default)]
158 pub args: Vec<String>,
159
160 #[serde(default)]
162 pub env: HashMap<String, String>,
163
164 #[serde(default)]
166 pub file_patterns: Vec<String>,
167
168 #[serde(default)]
170 pub initialization_options: Option<serde_json::Value>,
171
172 #[serde(default = "default_timeout")]
174 pub timeout_seconds: u64,
175
176 #[serde(default)]
179 pub heuristics: Option<ServerHeuristics>,
180}
181
182const fn default_timeout() -> u64 {
183 30
184}
185
186impl LspServerConfig {
187 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
588 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 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 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 assert!(!heuristics.is_applicable_recursive(tmp.path(), Some(3)));
630 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 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 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 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 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 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 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 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 assert!(!config.should_spawn(tmp.path(), Some(2)));
755 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}