1use std::borrow::Cow;
4use std::collections::BTreeMap;
5use std::ffi::OsStr;
6use std::ops::ControlFlow;
7use std::path::{Path, PathBuf};
8
9use ignore::WalkBuilder;
10use serde::{Deserialize, Serialize};
11
12use super::bounded_secs::TimeoutSecs;
13use super::command_stem::CommandStem;
14use super::host_os::HostOs;
15use super::language_id::LanguageId;
16#[cfg(test)]
17use super::limits::DEFAULT_HEURISTICS_MAX_DEPTH;
18use super::limits::SearchDepth;
19use super::patterns::{FilePattern, ProjectMarker};
20use super::routing::{ServerId, ToolSet};
21use super::server_args::ServerArgs;
22use super::server_env::{EnvKey, EnvValue, InvalidServerEnv, ServerEnv};
23use super::settings::LspSettings;
24use super::text_newtype::{contains_nul, impl_text_newtype};
25use crate::bridge::IndexingPolicy;
26use crate::error::{ChainedError, ConfigError, spelled_enum};
27
28const EXCLUDED_DIRECTORIES: &[&str] = &[
31 "node_modules",
32 "target",
33 ".git",
34 "__pycache__",
35 ".venv",
36 "venv",
37 ".tox",
38 ".mypy_cache",
39 ".pytest_cache",
40 "build",
41 "dist",
42 ".cargo",
43 ".rustup",
44 "vendor",
45 "coverage",
46 ".next",
47 ".nuxt",
48];
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55#[serde(deny_unknown_fields)]
56pub struct ServerHeuristics {
57 #[serde(default)]
65 pub project_markers: Vec<ProjectMarker>,
66}
67
68impl ServerHeuristics {
69 #[must_use]
71 pub fn with_markers(markers: impl IntoIterator<Item = ProjectMarker>) -> Self {
72 Self {
73 project_markers: markers.into_iter().collect(),
74 }
75 }
76}
77
78fn walk_names(
82 workspace_root: &Path,
83 max_depth: SearchDepth,
84 mut visit: impl FnMut(&str) -> ControlFlow<()>,
85) -> ControlFlow<()> {
86 let mut builder = WalkBuilder::new(workspace_root);
87 builder
91 .standard_filters(false)
92 .max_depth(Some(max_depth.get()))
93 .hidden(false)
94 .git_ignore(true)
95 .git_global(false)
96 .git_exclude(false)
97 .follow_links(false)
98 .filter_entry(|entry| {
99 if entry.file_type().is_some_and(|ft| ft.is_dir())
101 && let Some(name) = entry.file_name().to_str()
102 && EXCLUDED_DIRECTORIES.contains(&name)
103 {
104 return false;
105 }
106 true
107 });
108
109 for entry in builder.build().flatten() {
110 if let Some(name) = entry.path().file_name().and_then(OsStr::to_str) {
111 visit(name)?;
112 }
113 }
114 ControlFlow::Continue(())
115}
116
117#[derive(Debug, Clone, Default)]
123pub struct MarkerScan {
124 found: Vec<ProjectMarker>,
125}
126
127impl MarkerScan {
128 #[must_use]
132 pub fn collect<'a>(
133 roots: &[PathBuf],
134 servers: impl IntoIterator<Item = &'a LspServerConfig>,
135 max_depth: SearchDepth,
136 ) -> Self {
137 let mut missing: Vec<&ProjectMarker> = Vec::new();
138 for marker in servers
139 .into_iter()
140 .filter_map(|server| server.heuristics.as_ref())
141 .flat_map(|heuristics| &heuristics.project_markers)
142 {
143 if !missing.contains(&marker) {
144 missing.push(marker);
145 }
146 }
147 let mut found = Vec::new();
148 for root in roots {
149 if missing.is_empty() {
150 break;
151 }
152 missing.retain(|marker| {
153 let present = root.join(marker.as_str()).exists();
154 if present {
155 found.push((*marker).clone());
156 }
157 !present
158 });
159 if missing.is_empty() {
160 break;
161 }
162 let all_found = walk_names(root, max_depth, |name| {
163 if let Some(index) = missing.iter().position(|marker| *marker == name) {
164 found.push(missing.swap_remove(index).clone());
165 }
166 if missing.is_empty() {
167 ControlFlow::Break(())
168 } else {
169 ControlFlow::Continue(())
170 }
171 })
172 .is_break();
173 if all_found {
174 break;
175 }
176 }
177 Self { found }
178 }
179
180 #[must_use]
183 pub fn applies_to(&self, server: &LspServerConfig) -> bool {
184 server.heuristics.as_ref().is_none_or(|heuristics| {
185 heuristics.project_markers.is_empty()
186 || heuristics
187 .project_markers
188 .iter()
189 .any(|marker| self.found.contains(marker))
190 })
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
209#[serde(rename_all = "snake_case")]
210pub enum ServerSelection {
211 #[default]
213 Explicit,
214 Auto,
219}
220
221impl ServerSelection {
222 #[expect(
225 clippy::trivially_copy_pass_by_ref,
226 reason = "serde skip_serializing_if passes a reference"
227 )]
228 #[must_use]
229 pub(crate) const fn is_explicit(&self) -> bool {
230 matches!(self, Self::Explicit)
231 }
232}
233
234#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
236#[non_exhaustive]
237pub enum InvalidServerCommand {
238 #[error("command cannot be blank")]
240 Blank,
241 #[error("command cannot contain NUL")]
244 ContainsNul,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
262#[serde(try_from = "String", into = "String")]
263pub struct ServerCommand(Cow<'static, str>);
264
265impl ServerCommand {
266 #[must_use]
276 pub const fn from_static(text: &'static str) -> Self {
277 assert!(
278 text.is_ascii() && !text.trim_ascii().is_empty() && !contains_nul(text),
279 "command must be ASCII and not blank, and hold no NUL"
280 );
281 Self(Cow::Borrowed(text))
282 }
283
284 pub fn new(text: impl Into<String>) -> Result<Self, InvalidServerCommand> {
301 let text = text.into();
302 if text.trim().is_empty() {
303 return Err(InvalidServerCommand::Blank);
304 }
305 if contains_nul(&text) {
306 return Err(InvalidServerCommand::ContainsNul);
307 }
308 Ok(Self(Cow::Owned(text)))
309 }
310}
311
312impl_text_newtype!(ServerCommand, InvalidServerCommand);
313
314impl AsRef<OsStr> for ServerCommand {
315 fn as_ref(&self) -> &OsStr {
316 OsStr::new(self.as_str())
317 }
318}
319
320impl AsRef<Path> for ServerCommand {
321 fn as_ref(&self) -> &Path {
322 Path::new(self.as_str())
323 }
324}
325
326#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
328#[error("selection = \"auto\" is only valid for typescript-language-server entries")]
329pub struct InvalidAutoSelection;
330
331#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct LaunchCommand {
357 command: ServerCommand,
358 selection: ServerSelection,
359}
360
361impl LaunchCommand {
362 #[must_use]
364 pub const fn explicit(command: ServerCommand) -> Self {
365 Self {
366 command,
367 selection: ServerSelection::Explicit,
368 }
369 }
370
371 pub fn auto(command: ServerCommand) -> Result<Self, InvalidAutoSelection> {
377 Self::new(command, ServerSelection::Auto)
378 }
379
380 pub fn new(
387 command: ServerCommand,
388 selection: ServerSelection,
389 ) -> Result<Self, InvalidAutoSelection> {
390 if selection == ServerSelection::Auto
391 && !BuiltinServer::TypescriptLanguageServer.matches_command(command.as_str())
392 {
393 return Err(InvalidAutoSelection);
394 }
395 Ok(Self { command, selection })
396 }
397
398 const fn typescript() -> Self {
400 Self {
401 command: ServerCommand::from_static(BuiltinServer::TypescriptLanguageServer.as_str()),
402 selection: ServerSelection::Auto,
403 }
404 }
405
406 pub fn retarget(self, command: ServerCommand) -> Result<Self, InvalidAutoSelection> {
414 Self::new(command, self.selection)
415 }
416
417 #[must_use]
419 pub const fn server_command(&self) -> &ServerCommand {
420 &self.command
421 }
422
423 #[must_use]
425 pub const fn selection(&self) -> ServerSelection {
426 self.selection
427 }
428
429 #[must_use]
431 pub const fn is_explicit(&self) -> bool {
432 self.selection.is_explicit()
433 }
434
435 #[must_use]
437 pub fn as_str(&self) -> &str {
438 self.command.as_str()
439 }
440
441 fn into_parts(self) -> (ServerCommand, ServerSelection) {
442 (self.command, self.selection)
443 }
444}
445
446impl From<ServerCommand> for LaunchCommand {
447 fn from(command: ServerCommand) -> Self {
448 Self::explicit(command)
449 }
450}
451
452impl std::fmt::Display for LaunchCommand {
453 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454 std::fmt::Display::fmt(&self.command, f)
455 }
456}
457
458impl AsRef<OsStr> for LaunchCommand {
459 fn as_ref(&self) -> &OsStr {
460 self.command.as_ref()
461 }
462}
463
464impl AsRef<Path> for LaunchCommand {
465 fn as_ref(&self) -> &Path {
466 self.command.as_ref()
467 }
468}
469
470impl PartialEq<str> for LaunchCommand {
471 fn eq(&self, other: &str) -> bool {
472 self.command == *other
473 }
474}
475
476impl PartialEq<&str> for LaunchCommand {
477 fn eq(&self, other: &&str) -> bool {
478 self.command == *other
479 }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
489#[serde(try_from = "RawLspServerConfig", into = "RawLspServerConfig")]
490pub struct LspServerConfig {
491 pub language_id: LanguageId,
493
494 pub command: LaunchCommand,
496
497 pub args: ServerArgs,
499
500 pub env: ServerEnv,
502
503 pub file_patterns: Vec<FilePattern>,
505
506 pub initialization_options: Option<serde_json::Value>,
518
519 pub settings: Option<LspSettings>,
524
525 pub timeout_seconds: TimeoutSecs,
531
532 pub request_timeout_seconds: TimeoutSecs,
544
545 pub heuristics: Option<ServerHeuristics>,
548
549 pub name: Option<ServerId>,
557
558 pub handles: Option<ToolSet>,
564
565 pub indexing: IndexingPolicy,
572}
573
574#[derive(Serialize, Deserialize)]
580#[serde(deny_unknown_fields)]
581struct RawLspServerConfig {
582 language_id: LanguageId,
583 command: ServerCommand,
584 #[serde(default)]
585 args: Vec<String>,
586 #[serde(default)]
587 env: BTreeMap<EnvKey, String>,
588 #[serde(default)]
589 file_patterns: Vec<String>,
590 #[serde(default)]
591 initialization_options: Option<serde_json::Value>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
593 settings: Option<LspSettings>,
594 #[serde(default)]
595 timeout_seconds: TimeoutSecs,
596 #[serde(default)]
597 request_timeout_seconds: TimeoutSecs,
598 #[serde(default)]
599 heuristics: Option<ServerHeuristics>,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
601 name: Option<ServerId>,
602 #[serde(default, skip_serializing_if = "Option::is_none")]
603 handles: Option<ToolSet>,
604 #[serde(default, skip_serializing_if = "IndexingPolicy::is_auto")]
605 indexing: IndexingPolicy,
606 #[serde(default, skip_serializing_if = "ServerSelection::is_explicit")]
607 selection: ServerSelection,
608}
609
610impl From<LspServerConfig> for RawLspServerConfig {
611 fn from(config: LspServerConfig) -> Self {
612 let LspServerConfig {
613 language_id,
614 command,
615 args,
616 env,
617 file_patterns,
618 initialization_options,
619 settings,
620 timeout_seconds,
621 request_timeout_seconds,
622 heuristics,
623 name,
624 handles,
625 indexing,
626 } = config;
627 let (command, selection) = command.into_parts();
628 Self {
629 language_id,
630 command,
631 args: args.into(),
632 env: env.into(),
633 file_patterns: file_patterns.into_iter().map(String::from).collect(),
634 initialization_options,
635 settings,
636 timeout_seconds,
637 request_timeout_seconds,
638 heuristics,
639 name,
640 handles,
641 indexing,
642 selection,
643 }
644 }
645}
646
647impl TryFrom<RawLspServerConfig> for LspServerConfig {
648 type Error = ChainedError<ConfigError>;
649
650 fn try_from(raw: RawLspServerConfig) -> Result<Self, Self::Error> {
651 let RawLspServerConfig {
652 language_id,
653 command,
654 args,
655 env,
656 file_patterns,
657 initialization_options,
658 settings,
659 timeout_seconds,
660 request_timeout_seconds,
661 heuristics,
662 name,
663 handles,
664 indexing,
665 selection,
666 } = raw;
667 let entry = entry_id(name.as_ref(), &language_id);
668 let command = LaunchCommand::new(command, selection).map_err(|InvalidAutoSelection| {
669 ConfigError::SelectionAutoOnNonTypescript {
670 language: language_id.clone(),
671 }
672 })?;
673 let env = env
674 .into_iter()
675 .map(|(key, value)| match EnvValue::new(value) {
676 Ok(value) => Ok((key, value)),
677 Err(error) => Err(ConfigError::InvalidEnvValue {
678 server: entry.clone(),
679 key,
680 error,
681 }),
682 })
683 .collect::<Result<Vec<_>, _>>()?;
684 let env = ServerEnv::from_entries(env, HostOs::CURRENT).map_err(|error| match error {
685 InvalidServerEnv::Duplicate(error) => ConfigError::DuplicateEnvKey {
686 server: entry.clone(),
687 error,
688 },
689 InvalidServerEnv::TooLarge(error) => ConfigError::EnvTooLarge {
690 server: entry.clone(),
691 error,
692 },
693 })?;
694 let args = ServerArgs::try_from(args).map_err(|error| ConfigError::ArgContainsNul {
695 server: entry.clone(),
696 error,
697 })?;
698 let file_patterns = file_patterns
699 .iter()
700 .map(|pattern| {
701 FilePattern::parse(pattern).map_err(|pattern| ConfigError::UnsupportedFilePattern {
702 server: entry.clone(),
703 pattern,
704 })
705 })
706 .collect::<Result<_, _>>()?;
707 Ok(Self {
708 language_id,
709 command,
710 args,
711 env,
712 file_patterns,
713 initialization_options,
714 settings,
715 timeout_seconds,
716 request_timeout_seconds,
717 heuristics,
718 name,
719 handles,
720 indexing,
721 })
722 }
723}
724
725spelled_enum! {
726 BuiltinServer {
732 RustAnalyzer => "rust-analyzer",
734 Pyright => "pyright-langserver",
736 TypescriptLanguageServer => "typescript-language-server",
738 Gopls => "gopls",
740 Clangd => "clangd",
742 Zls => "zls",
744 }
745}
746
747impl BuiltinServer {
748 #[must_use]
751 pub const fn workspace_code(self) -> &'static str {
752 match self {
753 Self::RustAnalyzer => "Cargo build scripts and procedural macros",
754 Self::Pyright => {
755 "the project's Python environment, plugins and configured interpreters"
756 }
757 Self::TypescriptLanguageServer => {
758 "the workspace's tsserver and tsconfig plugins, unless pinned"
759 }
760 Self::Gopls => "the Go toolchain, including toolchain downloads requested by go.mod",
761 Self::Clangd => "commands from compile_commands.json and .clangd configuration",
762 Self::Zls => "build.zig through its build runner",
763 }
764 }
765
766 #[must_use]
768 pub const fn install_hint(self) -> &'static str {
769 match self {
770 Self::RustAnalyzer => "rustup component add rust-analyzer",
771 Self::Pyright => "npm install -g pyright",
772 Self::TypescriptLanguageServer => {
773 "npm install -g typescript-language-server typescript@6"
774 }
775 Self::Gopls => "go install golang.org/x/tools/gopls@latest",
776 Self::Clangd => {
777 "install clangd via your package manager (e.g. apt install clangd, brew install llvm) or see https://clangd.llvm.org/installation"
778 }
779 Self::Zls => {
780 "install a zls matching your Zig version: https://zigtools.org/zls/install/"
781 }
782 }
783 }
784
785 #[must_use]
791 pub const fn early_exit_hint(self) -> Option<&'static str> {
792 match self {
793 Self::RustAnalyzer => Some(
794 "the `rust-analyzer` found on PATH may be a rustup proxy without the component \
795 installed -- run `rustup component add rust-analyzer`",
796 ),
797 Self::Pyright
798 | Self::TypescriptLanguageServer
799 | Self::Gopls
800 | Self::Clangd
801 | Self::Zls => None,
802 }
803 }
804
805 #[must_use]
808 pub const fn is_npm_package(self) -> bool {
809 matches!(self, Self::Pyright | Self::TypescriptLanguageServer)
810 }
811
812 #[must_use]
828 pub fn matches_command(self, command: &str) -> bool {
829 CommandStem::of(command).is(self.as_str())
830 }
831
832 #[must_use]
843 pub fn from_command(command: &str) -> Option<Self> {
844 Self::ALL.into_iter().find(|b| b.as_str() == command)
845 }
846}
847
848impl LspServerConfig {
849 #[must_use]
855 pub fn id(&self) -> ServerId {
856 entry_id(self.name.as_ref(), &self.language_id)
857 }
858
859 fn builtin(
862 language_id: LanguageId,
863 server: BuiltinServer,
864 args: &'static [&'static str],
865 file_patterns: &[&'static str],
866 markers: impl IntoIterator<Item = &'static str>,
867 ) -> Self {
868 Self {
869 language_id,
870 command: ServerCommand::from_static(server.as_str()).into(),
871 args: ServerArgs::from_static(args),
872 env: crate::config::ServerEnv::default(),
873 file_patterns: file_patterns
874 .iter()
875 .map(|pattern| FilePattern::from_static(pattern))
876 .collect(),
877 initialization_options: None,
878 settings: None,
879 timeout_seconds: TimeoutSecs::DEFAULT,
880 request_timeout_seconds: TimeoutSecs::DEFAULT,
881 heuristics: Some(ServerHeuristics::with_markers(
882 markers.into_iter().map(ProjectMarker::from_static),
883 )),
884 name: None,
885 handles: None,
886 indexing: IndexingPolicy::Auto,
887 }
888 }
889
890 #[must_use]
892 pub fn rust_analyzer() -> Self {
893 Self::builtin(
894 const { LanguageId::from_static("rust") },
895 BuiltinServer::RustAnalyzer,
896 &[],
897 &["**/*.rs"],
898 ["Cargo.toml", "rust-toolchain.toml"],
899 )
900 }
901
902 #[must_use]
904 pub fn pyright() -> Self {
905 Self::builtin(
906 const { LanguageId::from_static("python") },
907 BuiltinServer::Pyright,
908 &["--stdio"],
909 &["**/*.py"],
910 [
911 "pyproject.toml",
912 "setup.py",
913 "requirements.txt",
914 "pyrightconfig.json",
915 ],
916 )
917 }
918
919 #[must_use]
927 pub fn typescript() -> Self {
928 Self {
929 command: LaunchCommand::typescript(),
930 ..Self::builtin(
931 const { LanguageId::from_static("typescript") },
932 BuiltinServer::TypescriptLanguageServer,
933 &["--stdio"],
934 &["**/*.ts", "**/*.tsx"],
935 ["package.json", "tsconfig.json", "jsconfig.json"],
936 )
937 }
938 }
939
940 #[must_use]
942 pub fn gopls() -> Self {
943 Self::builtin(
944 const { LanguageId::from_static("go") },
945 BuiltinServer::Gopls,
946 &["serve"],
947 &["**/*.go"],
948 ["go.mod", "go.sum"],
949 )
950 }
951
952 #[must_use]
954 pub fn clangd() -> Self {
955 Self::builtin(
956 const { LanguageId::from_static("cpp") },
957 BuiltinServer::Clangd,
958 &[],
959 &["**/*.c", "**/*.cpp", "**/*.h", "**/*.hpp"],
960 [
961 "CMakeLists.txt",
962 "compile_commands.json",
963 "Makefile",
964 ".clangd",
965 ],
966 )
967 }
968
969 #[must_use]
971 pub fn zls() -> Self {
972 Self::builtin(
973 const { LanguageId::from_static("zig") },
974 BuiltinServer::Zls,
975 &[],
976 &["**/*.zig"],
977 ["build.zig", "build.zig.zon"],
978 )
979 }
980}
981
982fn entry_id(name: Option<&ServerId>, language: &LanguageId) -> ServerId {
984 name.cloned()
985 .unwrap_or_else(|| ServerId::from(language.clone()))
986}
987
988#[cfg(test)]
989mod tests {
990 use tempfile::TempDir;
991
992 use super::*;
993 use crate::config::ToolRouter;
994
995 fn heuristics_with<const N: usize>(markers: [&'static str; N]) -> ServerHeuristics {
996 ServerHeuristics::with_markers(markers.map(ProjectMarker::from_static))
997 }
998
999 #[test]
1000 fn test_server_command_rejects_blank_and_round_trips() {
1001 assert_eq!(ServerCommand::new(""), Err(InvalidServerCommand::Blank));
1002 assert_eq!(ServerCommand::new(" \t"), Err(InvalidServerCommand::Blank));
1003 assert_eq!(
1004 ServerCommand::new("a\0b"),
1005 Err(InvalidServerCommand::ContainsNul)
1006 );
1007 let command: ServerCommand = serde_json::from_str("\"gopls\"").unwrap();
1008 assert_eq!(command, "gopls");
1009 assert_eq!(serde_json::to_string(&command).unwrap(), "\"gopls\"");
1010 assert!(serde_json::from_str::<ServerCommand>("\" \"").is_err());
1011 }
1012
1013 #[test]
1014 #[should_panic(expected = "command must be ASCII and not blank")]
1015 fn test_server_command_from_static_panics_on_blank() {
1016 let _ = ServerCommand::from_static(" ");
1017 }
1018
1019 #[test]
1020 #[should_panic(expected = "command must be ASCII and not blank")]
1021 fn test_server_command_from_static_panics_on_non_ascii_blank() {
1022 let _ = ServerCommand::from_static("\u{a0}");
1023 }
1024
1025 #[test]
1026 fn test_matches_command_accepts_stem_and_rejects_other_servers() {
1027 let tsls = BuiltinServer::TypescriptLanguageServer;
1028 for command in [
1029 "typescript-language-server",
1030 "/usr/local/bin/typescript-language-server",
1031 "typescript-language-server.cmd",
1032 ] {
1033 assert!(tsls.matches_command(command), "{command}");
1034 }
1035 for command in ["tsc", "", "rust-analyzer", "typescript-language-server-x"] {
1036 assert!(!tsls.matches_command(command), "{command}");
1037 }
1038 }
1039
1040 #[test]
1041 fn test_selection_serde_and_defaults() {
1042 assert!(ServerSelection::default().is_explicit());
1043 assert_eq!(
1044 serde_json::from_str::<ServerSelection>("\"auto\"").unwrap(),
1045 ServerSelection::Auto
1046 );
1047 assert!(serde_json::from_str::<ServerSelection>("\"native\"").is_err());
1048 assert_eq!(
1049 LspServerConfig::typescript().command.selection(),
1050 ServerSelection::Auto
1051 );
1052 assert_eq!(
1053 LspServerConfig::rust_analyzer().command.selection(),
1054 ServerSelection::Explicit
1055 );
1056 }
1057
1058 #[test]
1059 fn test_default_typescript_command_is_a_valid_auto_selection() {
1060 let command = LspServerConfig::typescript().command;
1061 assert_eq!(
1062 LaunchCommand::auto(command.server_command().clone()),
1063 Ok(command)
1064 );
1065 }
1066
1067 #[test]
1068 fn test_retarget_keeps_the_selection_and_rechecks_auto() {
1069 let tsls = |text: &str| ServerCommand::new(text).unwrap();
1070 let auto = LaunchCommand::auto(tsls("typescript-language-server")).unwrap();
1071
1072 let moved = auto
1073 .clone()
1074 .retarget(tsls("/opt/bin/typescript-language-server"))
1075 .unwrap();
1076 assert_eq!(moved.selection(), ServerSelection::Auto);
1077 assert_eq!(moved, "/opt/bin/typescript-language-server");
1078 assert_eq!(
1079 auto.retarget(tsls("rust-analyzer")),
1080 Err(InvalidAutoSelection)
1081 );
1082
1083 let explicit = LaunchCommand::explicit(tsls("rust-analyzer"));
1084 assert!(explicit.retarget(tsls("anything")).is_ok());
1085 }
1086
1087 #[test]
1088 fn test_selection_key_is_written_only_for_auto() {
1089 let typescript = toml::to_string(&LspServerConfig::typescript()).unwrap();
1090 assert!(typescript.contains("selection = \"auto\""), "{typescript}");
1091 let rust = toml::to_string(&LspServerConfig::rust_analyzer()).unwrap();
1092 assert!(!rust.contains("selection"), "{rust}");
1093 }
1094
1095 #[test]
1096 fn test_early_exit_hint_only_for_rust_analyzer() {
1097 for builtin in BuiltinServer::ALL {
1098 assert_eq!(
1099 builtin.early_exit_hint().is_some(),
1100 builtin == BuiltinServer::RustAnalyzer,
1101 "{builtin:?}"
1102 );
1103 }
1104 }
1105
1106 #[test]
1107 fn test_rust_analyzer_defaults() {
1108 let config = LspServerConfig::rust_analyzer();
1109
1110 assert_eq!(config.language_id, "rust");
1111 assert_eq!(config.command, "rust-analyzer");
1112 assert_eq!(config.args.len(), 0);
1113 assert!(config.env.is_empty());
1114 assert_eq!(config.file_patterns, ["**/*.rs"]);
1115 assert!(config.initialization_options.is_none());
1116 assert_eq!(config.timeout_seconds, TimeoutSecs::DEFAULT);
1117 }
1118
1119 #[test]
1120 fn test_pyright_defaults() {
1121 let config = LspServerConfig::pyright();
1122
1123 assert_eq!(config.language_id, "python");
1124 assert_eq!(config.command, "pyright-langserver");
1125 assert_eq!(config.args, vec!["--stdio"]);
1126 assert!(config.env.is_empty());
1127 assert_eq!(config.file_patterns, ["**/*.py"]);
1128 assert!(config.initialization_options.is_none());
1129 assert_eq!(config.timeout_seconds, TimeoutSecs::DEFAULT);
1130 }
1131
1132 #[test]
1133 fn test_typescript_defaults() {
1134 let config = LspServerConfig::typescript();
1135
1136 assert_eq!(config.language_id, "typescript");
1137 assert_eq!(config.command, "typescript-language-server");
1138 assert_eq!(config.args, vec!["--stdio"]);
1139 assert!(config.env.is_empty());
1140 assert_eq!(config.file_patterns, ["**/*.ts", "**/*.tsx"]);
1141 assert!(config.initialization_options.is_none());
1142 assert_eq!(config.timeout_seconds, TimeoutSecs::DEFAULT);
1143 }
1144
1145 #[test]
1146 fn test_custom_config() {
1147 let mut env = ServerEnv::default();
1148 env.insert(
1149 EnvKey::from_static("RUST_LOG"),
1150 EnvValue::from_static("debug"),
1151 )
1152 .unwrap();
1153
1154 let config = LspServerConfig {
1155 language_id: LanguageId::from_static("custom"),
1156 command: ServerCommand::from_static("custom-lsp").into(),
1157 args: crate::config::ServerArgs::try_from(vec!["--flag".to_string()]).unwrap(),
1158 env: env.clone(),
1159 file_patterns: vec![FilePattern::from_static("**/*.custom")],
1160 initialization_options: Some(serde_json::json!({"key": "value"})),
1161 settings: None,
1162 timeout_seconds: TimeoutSecs::new(60).unwrap(),
1163 request_timeout_seconds: TimeoutSecs::new(45).unwrap(),
1164 heuristics: None,
1165 name: None,
1166 handles: None,
1167 indexing: crate::bridge::IndexingPolicy::Auto,
1168 };
1169
1170 assert_eq!(config.language_id, "custom");
1171 assert_eq!(config.command, "custom-lsp");
1172 assert_eq!(config.args, vec!["--flag"]);
1173 assert_eq!(config.env.get("RUST_LOG"), Some("debug"));
1174 assert_eq!(config.file_patterns, ["**/*.custom"]);
1175 assert!(config.initialization_options.is_some());
1176 assert_eq!(config.timeout_seconds.get(), 60);
1177 }
1178
1179 #[test]
1180 fn test_serde_roundtrip() {
1181 let original = LspServerConfig::rust_analyzer();
1182
1183 let serialized = serde_json::to_string(&original).unwrap();
1184 let deserialized: LspServerConfig = serde_json::from_str(&serialized).unwrap();
1185
1186 assert_eq!(deserialized.language_id, original.language_id);
1187 assert_eq!(deserialized.command, original.command);
1188 assert_eq!(deserialized.args, original.args);
1189 assert_eq!(deserialized.timeout_seconds, original.timeout_seconds);
1190 assert_eq!(
1191 deserialized.request_timeout_seconds,
1192 original.request_timeout_seconds
1193 );
1194 }
1195
1196 #[test]
1197 fn test_indexing_defaults_to_auto_when_omitted() {
1198 assert_eq!(
1199 LspServerConfig::rust_analyzer().indexing,
1200 crate::bridge::IndexingPolicy::Auto
1201 );
1202 }
1203
1204 #[test]
1207 fn test_indexing_disabled_round_trips_through_toml() {
1208 let toml_str = r#"
1209 language_id = "rust"
1210 command = "rust-analyzer"
1211 indexing = "disabled"
1212 "#;
1213 let config: LspServerConfig = toml::from_str(toml_str).unwrap();
1214 assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Disabled);
1215
1216 let serialized = toml::to_string(&config).unwrap();
1217 assert!(serialized.contains(r#"indexing = "disabled""#));
1218 let round_tripped: LspServerConfig = toml::from_str(&serialized).unwrap();
1219 assert_eq!(
1220 round_tripped.indexing,
1221 crate::bridge::IndexingPolicy::Disabled
1222 );
1223 }
1224
1225 #[test]
1228 fn test_nul_in_env_value_is_rejected_at_load() {
1229 let toml_str =
1230 "language_id = \"rust\"\ncommand = \"rust-analyzer\"\n[env]\nA = \"x\\u0000y\"\n";
1231 let error = toml::from_str::<LspServerConfig>(toml_str).unwrap_err();
1232
1233 assert!(
1234 error.to_string().contains(
1235 "lsp_servers entry 'rust': environment variable 'A': environment variable value cannot contain NUL"
1236 ),
1237 "{error}"
1238 );
1239 }
1240
1241 #[test]
1244 fn test_nul_in_arg_is_rejected_at_load() {
1245 let toml_str =
1246 "language_id = \"rust\"\ncommand = \"rust-analyzer\"\nargs = [\"ok\", \"a\\u0000b\"]\n";
1247 let error = toml::from_str::<LspServerConfig>(toml_str).unwrap_err();
1248
1249 assert!(
1250 error
1251 .to_string()
1252 .contains("lsp_servers entry 'rust': args[1] cannot contain NUL"),
1253 "{error}"
1254 );
1255 }
1256
1257 #[test]
1259 fn test_oversized_env_is_rejected_at_load() {
1260 let toml_str = format!(
1261 "language_id = \"rust\"\ncommand = \"rust-analyzer\"\n[env]\nA = \"{}\"\n",
1262 "x".repeat(1 << 20)
1263 );
1264 let error = toml::from_str::<LspServerConfig>(&toml_str).unwrap_err();
1265
1266 assert!(
1267 error
1268 .to_string()
1269 .contains("lsp_servers entry 'rust': environment variable 'A' is"),
1270 "{error}"
1271 );
1272 }
1273
1274 #[test]
1277 fn test_indexing_omitted_from_toml_defaults_to_auto() {
1278 let toml_str = r#"
1279 language_id = "rust"
1280 command = "rust-analyzer"
1281 "#;
1282 let config: LspServerConfig = toml::from_str(toml_str).unwrap();
1283 assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Auto);
1284 }
1285
1286 #[test]
1293 fn test_indexing_auto_is_omitted_from_serialized_toml() {
1294 let config = LspServerConfig::rust_analyzer();
1295 assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Auto);
1296
1297 let serialized = toml::to_string(&config).unwrap();
1298 assert!(
1299 !serialized.contains("indexing"),
1300 "the default IndexingPolicy::Auto must be omitted, not serialized as \
1301 indexing = \"auto\""
1302 );
1303 }
1304
1305 #[test]
1306 fn test_clone() {
1307 let config = LspServerConfig::rust_analyzer();
1308 let cloned = config.clone();
1309
1310 assert_eq!(cloned.language_id, config.language_id);
1311 assert_eq!(cloned.command, config.command);
1312 assert_eq!(cloned.timeout_seconds, config.timeout_seconds);
1313 }
1314
1315 #[test]
1316 fn test_empty_env() {
1317 let config = LspServerConfig::rust_analyzer();
1318 assert!(config.env.is_empty());
1319 }
1320
1321 #[test]
1322 fn test_multiple_file_patterns() {
1323 let config = LspServerConfig::typescript();
1324 assert_eq!(config.file_patterns.len(), 2);
1325 assert!(
1326 config
1327 .file_patterns
1328 .contains(&FilePattern::from_static("**/*.ts"))
1329 );
1330 assert!(
1331 config
1332 .file_patterns
1333 .contains(&FilePattern::from_static("**/*.tsx"))
1334 );
1335 }
1336
1337 #[test]
1338 fn test_initialization_options_none_by_default() {
1339 let configs = vec![
1340 LspServerConfig::rust_analyzer(),
1341 LspServerConfig::pyright(),
1342 LspServerConfig::typescript(),
1343 ];
1344
1345 for config in configs {
1346 assert!(config.initialization_options.is_none());
1347 }
1348 }
1349
1350 #[test]
1352 fn test_heuristics_empty_always_applicable() {
1353 let heuristics = ServerHeuristics::default();
1354 let tmp = TempDir::new().unwrap();
1355 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1356 }
1357
1358 #[test]
1359 fn test_heuristics_marker_present() {
1360 let tmp = TempDir::new().unwrap();
1361 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1362
1363 let heuristics = heuristics_with(["Cargo.toml"]);
1364 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1365 }
1366
1367 #[test]
1368 fn test_heuristics_marker_absent() {
1369 let tmp = TempDir::new().unwrap();
1370 let heuristics = heuristics_with(["Cargo.toml"]);
1371 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1372 }
1373
1374 #[test]
1375 fn test_heuristics_any_marker_matches() {
1376 let tmp = TempDir::new().unwrap();
1377 std::fs::write(tmp.path().join("setup.py"), "").unwrap();
1378
1379 let heuristics = heuristics_with(["pyproject.toml", "setup.py", "requirements.txt"]);
1380 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1381 }
1382
1383 #[test]
1384 fn test_should_spawn_without_heuristics() {
1385 let config = LspServerConfig {
1386 language_id: LanguageId::from_static("test"),
1387 command: ServerCommand::from_static("test-lsp").into(),
1388 args: crate::config::ServerArgs::default(),
1389 env: crate::config::ServerEnv::default(),
1390 file_patterns: vec![],
1391 initialization_options: None,
1392 settings: None,
1393 timeout_seconds: TimeoutSecs::new(30).unwrap(),
1394 request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
1395 heuristics: None,
1396 name: None,
1397 handles: None,
1398 indexing: crate::bridge::IndexingPolicy::Auto,
1399 };
1400
1401 let tmp = TempDir::new().unwrap();
1402 assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1403 }
1404
1405 #[test]
1406 fn test_should_spawn_with_heuristics() {
1407 let tmp = TempDir::new().unwrap();
1408 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1409
1410 let config = LspServerConfig::rust_analyzer();
1411 assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1412 }
1413
1414 #[test]
1415 fn test_should_not_spawn_without_markers() {
1416 let tmp = TempDir::new().unwrap();
1417 let config = LspServerConfig::rust_analyzer();
1418 assert!(!spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1419 }
1420
1421 #[test]
1422 fn test_heuristics_serde_roundtrip() {
1423 let heuristics = heuristics_with(["Cargo.toml", "rust-toolchain.toml"]);
1424 let json = serde_json::to_string(&heuristics).unwrap();
1425 let deserialized: ServerHeuristics = serde_json::from_str(&json).unwrap();
1426 assert_eq!(deserialized.project_markers, heuristics.project_markers);
1427 }
1428
1429 #[test]
1430 fn test_default_rust_analyzer_heuristics() {
1431 let config = LspServerConfig::rust_analyzer();
1432 assert!(config.heuristics.is_some());
1433 let markers = &config.heuristics.unwrap().project_markers;
1434 assert!(markers.contains(&ProjectMarker::from_static("Cargo.toml")));
1435 }
1436
1437 #[test]
1438 fn test_gopls_defaults() {
1439 let config = LspServerConfig::gopls();
1440
1441 assert_eq!(config.language_id, "go");
1442 assert_eq!(config.command, "gopls");
1443 assert_eq!(config.args, vec!["serve"]);
1444 assert!(config.heuristics.is_some());
1445 let markers = &config.heuristics.unwrap().project_markers;
1446 assert!(markers.contains(&ProjectMarker::from_static("go.mod")));
1447 assert!(markers.contains(&ProjectMarker::from_static("go.sum")));
1448 }
1449
1450 #[test]
1451 fn test_clangd_defaults() {
1452 let config = LspServerConfig::clangd();
1453
1454 assert_eq!(config.language_id, "cpp");
1455 assert_eq!(config.command, "clangd");
1456 assert_eq!(config.args.len(), 0);
1457 assert!(config.heuristics.is_some());
1458 let markers = &config.heuristics.unwrap().project_markers;
1459 assert!(markers.contains(&ProjectMarker::from_static("CMakeLists.txt")));
1460 assert!(markers.contains(&ProjectMarker::from_static("compile_commands.json")));
1461 }
1462
1463 #[test]
1464 fn test_zls_defaults() {
1465 let config = LspServerConfig::zls();
1466
1467 assert_eq!(config.language_id, "zig");
1468 assert_eq!(config.command, "zls");
1469 assert_eq!(config.args.len(), 0);
1470 assert!(config.heuristics.is_some());
1471 let markers = &config.heuristics.unwrap().project_markers;
1472 assert!(markers.contains(&ProjectMarker::from_static("build.zig")));
1473 assert!(markers.contains(&ProjectMarker::from_static("build.zig.zon")));
1474 }
1475
1476 #[test]
1478 fn test_recursive_empty_markers_always_applicable() {
1479 let heuristics = ServerHeuristics::default();
1480 let tmp = TempDir::new().unwrap();
1481 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1482 }
1483
1484 #[test]
1485 fn test_recursive_marker_at_root() {
1486 let tmp = TempDir::new().unwrap();
1487 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1488
1489 let heuristics = heuristics_with(["Cargo.toml"]);
1490 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1491 }
1492
1493 #[test]
1494 fn test_recursive_nested_python_project() {
1495 let tmp = TempDir::new().unwrap();
1496 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1498 let python_dir = tmp.path().join("python");
1500 std::fs::create_dir(&python_dir).unwrap();
1501 std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
1502
1503 let heuristics = heuristics_with(["pyproject.toml", "setup.py"]);
1504 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1505 }
1506
1507 #[test]
1508 fn test_recursive_deeply_nested_marker() {
1509 let tmp = TempDir::new().unwrap();
1510 let deep_path = tmp.path().join("level1").join("level2").join("level3");
1512 std::fs::create_dir_all(&deep_path).unwrap();
1513 std::fs::write(deep_path.join("go.mod"), "").unwrap();
1514
1515 let heuristics = heuristics_with(["go.mod"]);
1516 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1517 }
1518
1519 #[test]
1520 fn test_recursive_no_marker_found() {
1521 let tmp = TempDir::new().unwrap();
1522 std::fs::create_dir(tmp.path().join("src")).unwrap();
1523 std::fs::write(tmp.path().join("src").join("main.rs"), "").unwrap();
1524
1525 let heuristics = heuristics_with(["Cargo.toml"]);
1526 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1527 }
1528
1529 #[test]
1530 fn test_recursive_max_depth_respected() {
1531 let tmp = TempDir::new().unwrap();
1532 let deep_path = tmp.path().join("a").join("b").join("c").join("d").join("e");
1534 std::fs::create_dir_all(&deep_path).unwrap();
1535 std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
1536
1537 let heuristics = heuristics_with(["Cargo.toml"]);
1538 assert!(!applies(
1540 &heuristics,
1541 tmp.path(),
1542 SearchDepth::new(3).unwrap()
1543 ));
1544 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1546 }
1547
1548 #[test]
1549 fn test_recursive_excludes_node_modules() {
1550 let tmp = TempDir::new().unwrap();
1551 let node_modules = tmp.path().join("node_modules").join("some-package");
1553 std::fs::create_dir_all(&node_modules).unwrap();
1554 std::fs::write(node_modules.join("package.json"), "").unwrap();
1555
1556 let heuristics = heuristics_with(["package.json"]);
1557 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1558 }
1559
1560 #[test]
1561 fn test_recursive_excludes_target_directory() {
1562 let tmp = TempDir::new().unwrap();
1563 let target = tmp.path().join("target").join("debug");
1565 std::fs::create_dir_all(&target).unwrap();
1566 std::fs::write(target.join("Cargo.toml"), "").unwrap();
1567
1568 let heuristics = heuristics_with(["Cargo.toml"]);
1569 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1570 }
1571
1572 #[test]
1573 fn test_recursive_excludes_git_directory() {
1574 let tmp = TempDir::new().unwrap();
1575 let git_dir = tmp.path().join(".git").join("hooks");
1576 std::fs::create_dir_all(&git_dir).unwrap();
1577 std::fs::write(git_dir.join("Cargo.toml"), "").unwrap();
1578
1579 let heuristics = heuristics_with(["Cargo.toml"]);
1580 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1581 }
1582
1583 #[test]
1584 fn test_recursive_excludes_pycache() {
1585 let tmp = TempDir::new().unwrap();
1586 let pycache = tmp.path().join("__pycache__");
1587 std::fs::create_dir_all(&pycache).unwrap();
1588 std::fs::write(pycache.join("pyproject.toml"), "").unwrap();
1589
1590 let heuristics = heuristics_with(["pyproject.toml"]);
1591 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1592 }
1593
1594 #[test]
1595 fn test_recursive_excludes_venv() {
1596 let tmp = TempDir::new().unwrap();
1597 let venv = tmp.path().join(".venv").join("lib");
1598 std::fs::create_dir_all(&venv).unwrap();
1599 std::fs::write(venv.join("setup.py"), "").unwrap();
1600
1601 let heuristics = heuristics_with(["setup.py"]);
1602 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1603 }
1604
1605 #[test]
1610 fn test_recursive_respects_gitignore_in_marker_search() {
1611 let tmp = TempDir::new().unwrap();
1612 std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
1615 std::fs::write(tmp.path().join(".gitignore"), "ignored/\n").unwrap();
1616
1617 let ignored_dir = tmp.path().join("ignored");
1618 std::fs::create_dir_all(&ignored_dir).unwrap();
1619 std::fs::write(ignored_dir.join("Cargo.toml"), "").unwrap();
1620
1621 let heuristics = heuristics_with(["Cargo.toml"]);
1622 assert!(
1623 !applies(&heuristics, tmp.path(), SearchDepth::DEFAULT),
1624 "marker inside a gitignored directory must not make the server applicable"
1625 );
1626 }
1627
1628 #[test]
1629 fn test_recursive_finds_marker_outside_excluded() {
1630 let tmp = TempDir::new().unwrap();
1631 let node_modules = tmp.path().join("node_modules");
1633 std::fs::create_dir_all(&node_modules).unwrap();
1634 std::fs::write(node_modules.join("package.json"), "").unwrap();
1635 let src = tmp.path().join("src");
1637 std::fs::create_dir_all(&src).unwrap();
1638 std::fs::write(src.join("package.json"), "").unwrap();
1639
1640 let heuristics = heuristics_with(["package.json"]);
1641 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1642 }
1643
1644 #[test]
1645 fn test_recursive_monorepo_structure() {
1646 let tmp = TempDir::new().unwrap();
1647 let rust_pkg = tmp.path().join("packages").join("rust-lib");
1649 let python_pkg = tmp.path().join("packages").join("python-bindings");
1650 let ts_pkg = tmp.path().join("packages").join("typescript-client");
1651
1652 std::fs::create_dir_all(&rust_pkg).unwrap();
1653 std::fs::create_dir_all(&python_pkg).unwrap();
1654 std::fs::create_dir_all(&ts_pkg).unwrap();
1655
1656 std::fs::write(rust_pkg.join("Cargo.toml"), "").unwrap();
1657 std::fs::write(python_pkg.join("pyproject.toml"), "").unwrap();
1658 std::fs::write(ts_pkg.join("package.json"), "").unwrap();
1659
1660 let rust_heuristics = heuristics_with(["Cargo.toml"]);
1662 let python_heuristics = heuristics_with(["pyproject.toml"]);
1663 let ts_heuristics = heuristics_with(["package.json"]);
1664
1665 assert!(applies(&rust_heuristics, tmp.path(), SearchDepth::DEFAULT));
1666 assert!(applies(
1667 &python_heuristics,
1668 tmp.path(),
1669 SearchDepth::DEFAULT
1670 ));
1671 assert!(applies(&ts_heuristics, tmp.path(), SearchDepth::DEFAULT));
1672 }
1673
1674 #[test]
1675 fn test_should_spawn_recursive() {
1676 let tmp = TempDir::new().unwrap();
1677 let python_dir = tmp.path().join("bindings").join("python");
1679 std::fs::create_dir_all(&python_dir).unwrap();
1680 std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
1681
1682 let config = LspServerConfig::pyright();
1683 assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1684 }
1685
1686 #[test]
1687 fn test_should_spawn_with_custom_max_depth() {
1688 let tmp = TempDir::new().unwrap();
1689 let deep_path = tmp.path().join("a").join("b").join("c").join("d");
1690 std::fs::create_dir_all(&deep_path).unwrap();
1691 std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
1692
1693 let config = LspServerConfig::rust_analyzer();
1694 assert!(!spawns(&config, tmp.path(), SearchDepth::new(2).unwrap()));
1696 assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1698 }
1699
1700 fn applies(heuristics: &ServerHeuristics, root: &Path, depth: SearchDepth) -> bool {
1701 let server = LspServerConfig {
1702 heuristics: Some(heuristics.clone()),
1703 ..LspServerConfig::rust_analyzer()
1704 };
1705 spawns(&server, root, depth)
1706 }
1707
1708 fn spawns(server: &LspServerConfig, root: &Path, depth: SearchDepth) -> bool {
1709 scan(&[root], std::slice::from_ref(server), depth).applies_to(server)
1710 }
1711
1712 fn scan(roots: &[&Path], servers: &[LspServerConfig], depth: SearchDepth) -> MarkerScan {
1713 let roots: Vec<PathBuf> = roots.iter().map(|root| root.to_path_buf()).collect();
1714 MarkerScan::collect(&roots, servers, depth)
1715 }
1716
1717 fn all_builtins() -> Vec<LspServerConfig> {
1718 vec![
1719 LspServerConfig::rust_analyzer(),
1720 LspServerConfig::pyright(),
1721 LspServerConfig::typescript(),
1722 LspServerConfig::gopls(),
1723 LspServerConfig::clangd(),
1724 LspServerConfig::zls(),
1725 ]
1726 }
1727
1728 #[test]
1729 fn test_marker_scan_finds_the_markers_of_every_server_in_one_pass() {
1730 let tmp = TempDir::new().unwrap();
1731 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1732 let python = tmp.path().join("bindings").join("python");
1733 std::fs::create_dir_all(&python).unwrap();
1734 std::fs::write(python.join("pyproject.toml"), "").unwrap();
1735 let servers = all_builtins();
1736
1737 let found = scan(&[tmp.path()], &servers, SearchDepth::DEFAULT);
1738
1739 let applying: Vec<_> = servers
1740 .iter()
1741 .filter(|server| found.applies_to(server))
1742 .map(|server| server.language_id.as_str())
1743 .collect();
1744 assert_eq!(applying, ["rust", "python"]);
1745 }
1746
1747 #[test]
1748 fn test_marker_scan_skips_excluded_directories_and_markers_below_the_depth() {
1749 let tmp = TempDir::new().unwrap();
1750 for (dir, file) in [
1751 ("a/b", "go.mod"),
1752 ("c", "package.json"),
1753 ("node_modules/x", "Cargo.toml"),
1754 ("d/e/f/g/h/i/j/k/l/m/n", "build.zig"),
1755 ] {
1756 std::fs::create_dir_all(tmp.path().join(dir)).unwrap();
1757 std::fs::write(tmp.path().join(dir).join(file), "").unwrap();
1758 }
1759 let servers = all_builtins();
1760 let found = scan(&[tmp.path()], &servers, SearchDepth::DEFAULT);
1761
1762 let applying: Vec<_> = servers
1763 .iter()
1764 .filter(|server| found.applies_to(server))
1765 .map(|server| server.language_id.as_str())
1766 .collect();
1767 assert_eq!(applying, ["typescript", "go"]);
1768 }
1769
1770 #[test]
1771 fn test_marker_scan_unions_the_roots() {
1772 let (first, second) = (TempDir::new().unwrap(), TempDir::new().unwrap());
1773 std::fs::write(first.path().join("Cargo.toml"), "").unwrap();
1774 let nested = second.path().join("svc");
1775 std::fs::create_dir_all(&nested).unwrap();
1776 std::fs::write(nested.join("go.mod"), "").unwrap();
1777 let servers = all_builtins();
1778
1779 let found = scan(
1780 &[first.path(), second.path()],
1781 &servers,
1782 SearchDepth::DEFAULT,
1783 );
1784
1785 assert!(found.applies_to(&LspServerConfig::rust_analyzer()));
1786 assert!(found.applies_to(&LspServerConfig::gopls()));
1787 assert!(!found.applies_to(&LspServerConfig::zls()));
1788 }
1789
1790 #[test]
1791 fn test_marker_scan_honours_the_depth_limit() {
1792 let tmp = TempDir::new().unwrap();
1793 let deep = tmp.path().join("a").join("b").join("c").join("d");
1794 std::fs::create_dir_all(&deep).unwrap();
1795 std::fs::write(deep.join("Cargo.toml"), "").unwrap();
1796 let servers = [LspServerConfig::rust_analyzer()];
1797
1798 let shallow = scan(&[tmp.path()], &servers, SearchDepth::new(2).unwrap());
1799 let default = scan(&[tmp.path()], &servers, SearchDepth::DEFAULT);
1800
1801 assert!(!shallow.applies_to(&servers[0]));
1802 assert!(default.applies_to(&servers[0]));
1803 }
1804
1805 #[test]
1806 fn test_marker_scan_applies_a_server_without_markers_everywhere() {
1807 let tmp = TempDir::new().unwrap();
1808 let mut no_markers = LspServerConfig::rust_analyzer();
1809 no_markers.heuristics = None;
1810 let mut empty_markers = LspServerConfig::rust_analyzer();
1811 empty_markers.heuristics = Some(ServerHeuristics::default());
1812
1813 let found = scan(&[tmp.path()], &[], SearchDepth::DEFAULT);
1814
1815 assert!(found.applies_to(&no_markers));
1816 assert!(found.applies_to(&empty_markers));
1817 assert!(!found.applies_to(&LspServerConfig::rust_analyzer()));
1818 }
1819
1820 #[test]
1821 fn test_default_heuristics_max_depth() {
1822 assert_eq!(DEFAULT_HEURISTICS_MAX_DEPTH, 10);
1823 }
1824
1825 #[test]
1826 fn test_excluded_directories_constant() {
1827 assert!(EXCLUDED_DIRECTORIES.contains(&"node_modules"));
1828 assert!(EXCLUDED_DIRECTORIES.contains(&"target"));
1829 assert!(EXCLUDED_DIRECTORIES.contains(&".git"));
1830 assert!(EXCLUDED_DIRECTORIES.contains(&"__pycache__"));
1831 assert!(EXCLUDED_DIRECTORIES.contains(&".venv"));
1832 }
1833
1834 const NATIVE_ENTRY: &str = r#"[[lsp_servers]]
1835language_id = "typescript"
1836command = "/home/me/ts7/node_modules/.bin/tsc"
1837args = ["--lsp", "--stdio"]
1838file_patterns = ["**/*.ts", "**/*.tsx"]"#;
1839
1840 fn repo_file(relative: &str) -> String {
1841 let manifest = std::env::var_os("CARGO_MANIFEST_DIR").map_or_else(
1842 || std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")),
1843 Into::into,
1844 );
1845 std::fs::read_to_string(manifest.join("../..").join(relative))
1846 .unwrap()
1847 .replace("\r\n", "\n")
1848 }
1849
1850 fn native_entries(extra: &str) -> Vec<LspServerConfig> {
1851 let text = format!("{NATIVE_ENTRY}\n{extra}");
1852 toml::from_str::<crate::config::ServerConfig>(&text)
1853 .unwrap()
1854 .lsp_servers
1855 }
1856
1857 #[test]
1858 fn test_docs_agree_with_typescript_install_hint() {
1859 let hint = BuiltinServer::TypescriptLanguageServer.install_hint();
1860 let pin = hint.split_whitespace().last().unwrap();
1861 assert!(pin.starts_with("typescript@"), "{hint}");
1862
1863 let mut checked = 0;
1864 for file in [
1865 "README.md",
1866 "book/src/getting-started/installation.md",
1867 "book/src/guide/language-servers.md",
1868 "book/src/advanced/typescript.md",
1869 ] {
1870 for line in repo_file(file).lines() {
1871 if !(line.contains("npm install -g") && line.contains("typescript")) {
1872 continue;
1873 }
1874 checked += 1;
1875 assert!(
1876 line.split(|c: char| !(c.is_alphanumeric() || matches!(c, '@' | '.' | '-')))
1877 .any(|word| word == pin),
1878 "{file}: {line}"
1879 );
1880 if line.contains("typescript-language-server") {
1881 assert!(line.contains(hint), "{file}: {line}");
1882 }
1883 }
1884 }
1885 assert!(checked >= 4, "only {checked} install lines found");
1886 }
1887
1888 #[test]
1889 fn test_documented_native_entry_is_in_the_book() {
1890 assert!(repo_file("book/src/advanced/typescript.md").contains(NATIVE_ENTRY));
1891 }
1892
1893 #[test]
1894 fn test_native_entry_replacing_default_typescript_entry_routes() {
1895 let router = ToolRouter::from_configs(&native_entries("")).unwrap();
1896 assert!(router.has_language("typescript"));
1897 }
1898
1899 #[test]
1900 fn test_native_entry_appended_to_default_is_rejected() {
1901 let mut configs = vec![LspServerConfig::typescript()];
1902 configs.extend(native_entries(""));
1903 let err = ToolRouter::from_configs(&configs).unwrap_err().to_string();
1904 assert!(err.contains("duplicate server id"), "{err}");
1905 }
1906
1907 #[test]
1908 fn test_named_native_entry_appended_to_default_is_rejected() {
1909 let mut named = native_entries("");
1910 named[0].name = Some(ServerId::from_static("native-ts"));
1911 let mut configs = vec![LspServerConfig::typescript()];
1912 configs.extend(named);
1913 let err = ToolRouter::from_configs(&configs).unwrap_err().to_string();
1914 assert!(err.contains("two catch-all servers"), "{err}");
1915 }
1916}