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_env::{EnvKey, ServerEnv};
22use super::settings::LspSettings;
23use super::text_newtype::impl_text_newtype;
24use crate::bridge::IndexingPolicy;
25use crate::error::ConfigError;
26
27const EXCLUDED_DIRECTORIES: &[&str] = &[
30 "node_modules",
31 "target",
32 ".git",
33 "__pycache__",
34 ".venv",
35 "venv",
36 ".tox",
37 ".mypy_cache",
38 ".pytest_cache",
39 "build",
40 "dist",
41 ".cargo",
42 ".rustup",
43 "vendor",
44 "coverage",
45 ".next",
46 ".nuxt",
47];
48
49#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54#[serde(deny_unknown_fields)]
55pub struct ServerHeuristics {
56 #[serde(default)]
64 pub project_markers: Vec<ProjectMarker>,
65}
66
67impl ServerHeuristics {
68 #[must_use]
70 pub fn with_markers(markers: impl IntoIterator<Item = ProjectMarker>) -> Self {
71 Self {
72 project_markers: markers.into_iter().collect(),
73 }
74 }
75}
76
77fn walk_names(
81 workspace_root: &Path,
82 max_depth: SearchDepth,
83 mut visit: impl FnMut(&str) -> ControlFlow<()>,
84) -> ControlFlow<()> {
85 let mut builder = WalkBuilder::new(workspace_root);
86 builder
90 .standard_filters(false)
91 .max_depth(Some(max_depth.get()))
92 .hidden(false)
93 .git_ignore(true)
94 .git_global(false)
95 .git_exclude(false)
96 .follow_links(false)
97 .filter_entry(|entry| {
98 if entry.file_type().is_some_and(|ft| ft.is_dir())
100 && let Some(name) = entry.file_name().to_str()
101 && EXCLUDED_DIRECTORIES.contains(&name)
102 {
103 return false;
104 }
105 true
106 });
107
108 for entry in builder.build().flatten() {
109 if let Some(name) = entry.path().file_name().and_then(OsStr::to_str) {
110 visit(name)?;
111 }
112 }
113 ControlFlow::Continue(())
114}
115
116#[derive(Debug, Clone, Default)]
122pub struct MarkerScan {
123 found: Vec<ProjectMarker>,
124}
125
126impl MarkerScan {
127 #[must_use]
131 pub fn collect<'a>(
132 roots: &[PathBuf],
133 servers: impl IntoIterator<Item = &'a LspServerConfig>,
134 max_depth: SearchDepth,
135 ) -> Self {
136 let mut missing: Vec<&ProjectMarker> = Vec::new();
137 for marker in servers
138 .into_iter()
139 .filter_map(|server| server.heuristics.as_ref())
140 .flat_map(|heuristics| &heuristics.project_markers)
141 {
142 if !missing.contains(&marker) {
143 missing.push(marker);
144 }
145 }
146 let mut found = Vec::new();
147 for root in roots {
148 if missing.is_empty() {
149 break;
150 }
151 missing.retain(|marker| {
152 let present = root.join(marker.as_str()).exists();
153 if present {
154 found.push((*marker).clone());
155 }
156 !present
157 });
158 if missing.is_empty() {
159 break;
160 }
161 let all_found = walk_names(root, max_depth, |name| {
162 if let Some(index) = missing.iter().position(|marker| *marker == name) {
163 found.push(missing.swap_remove(index).clone());
164 }
165 if missing.is_empty() {
166 ControlFlow::Break(())
167 } else {
168 ControlFlow::Continue(())
169 }
170 })
171 .is_break();
172 if all_found {
173 break;
174 }
175 }
176 Self { found }
177 }
178
179 #[must_use]
182 pub fn applies_to(&self, server: &LspServerConfig) -> bool {
183 server.heuristics.as_ref().is_none_or(|heuristics| {
184 heuristics.project_markers.is_empty()
185 || heuristics
186 .project_markers
187 .iter()
188 .any(|marker| self.found.contains(marker))
189 })
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum ServerSelection {
210 #[default]
212 Explicit,
213 Auto,
218}
219
220impl ServerSelection {
221 #[expect(
224 clippy::trivially_copy_pass_by_ref,
225 reason = "serde skip_serializing_if passes a reference"
226 )]
227 #[must_use]
228 pub(crate) const fn is_explicit(&self) -> bool {
229 matches!(self, Self::Explicit)
230 }
231}
232
233#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
235#[error("command cannot be blank")]
236pub struct InvalidServerCommand;
237
238#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
252#[serde(try_from = "String", into = "String")]
253pub struct ServerCommand(Cow<'static, str>);
254
255impl_text_newtype!(ServerCommand, InvalidServerCommand, non_blank, "command");
256
257impl AsRef<OsStr> for ServerCommand {
258 fn as_ref(&self) -> &OsStr {
259 OsStr::new(self.as_str())
260 }
261}
262
263impl AsRef<Path> for ServerCommand {
264 fn as_ref(&self) -> &Path {
265 Path::new(self.as_str())
266 }
267}
268
269#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
271#[error("selection = \"auto\" is only valid for typescript-language-server entries")]
272pub struct InvalidAutoSelection;
273
274#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct LaunchCommand {
300 command: ServerCommand,
301 selection: ServerSelection,
302}
303
304impl LaunchCommand {
305 #[must_use]
307 pub const fn explicit(command: ServerCommand) -> Self {
308 Self {
309 command,
310 selection: ServerSelection::Explicit,
311 }
312 }
313
314 pub fn auto(command: ServerCommand) -> Result<Self, InvalidAutoSelection> {
320 Self::new(command, ServerSelection::Auto)
321 }
322
323 pub fn new(
330 command: ServerCommand,
331 selection: ServerSelection,
332 ) -> Result<Self, InvalidAutoSelection> {
333 if selection == ServerSelection::Auto
334 && !BuiltinServer::TypescriptLanguageServer.matches_command(command.as_str())
335 {
336 return Err(InvalidAutoSelection);
337 }
338 Ok(Self { command, selection })
339 }
340
341 const fn typescript() -> Self {
343 Self {
344 command: ServerCommand::from_static(BuiltinServer::TypescriptLanguageServer.command()),
345 selection: ServerSelection::Auto,
346 }
347 }
348
349 pub fn retarget(self, command: ServerCommand) -> Result<Self, InvalidAutoSelection> {
357 Self::new(command, self.selection)
358 }
359
360 #[must_use]
362 pub const fn server_command(&self) -> &ServerCommand {
363 &self.command
364 }
365
366 #[must_use]
368 pub const fn selection(&self) -> ServerSelection {
369 self.selection
370 }
371
372 #[must_use]
374 pub const fn is_explicit(&self) -> bool {
375 self.selection.is_explicit()
376 }
377
378 #[must_use]
380 pub fn as_str(&self) -> &str {
381 self.command.as_str()
382 }
383
384 fn into_parts(self) -> (ServerCommand, ServerSelection) {
385 (self.command, self.selection)
386 }
387}
388
389impl From<ServerCommand> for LaunchCommand {
390 fn from(command: ServerCommand) -> Self {
391 Self::explicit(command)
392 }
393}
394
395impl std::fmt::Display for LaunchCommand {
396 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397 std::fmt::Display::fmt(&self.command, f)
398 }
399}
400
401impl AsRef<OsStr> for LaunchCommand {
402 fn as_ref(&self) -> &OsStr {
403 self.command.as_ref()
404 }
405}
406
407impl AsRef<Path> for LaunchCommand {
408 fn as_ref(&self) -> &Path {
409 self.command.as_ref()
410 }
411}
412
413impl PartialEq<str> for LaunchCommand {
414 fn eq(&self, other: &str) -> bool {
415 self.command == *other
416 }
417}
418
419impl PartialEq<&str> for LaunchCommand {
420 fn eq(&self, other: &&str) -> bool {
421 self.command == *other
422 }
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
431#[serde(try_from = "RawLspServerConfig", into = "RawLspServerConfig")]
432pub struct LspServerConfig {
433 pub language_id: LanguageId,
435
436 pub command: LaunchCommand,
438
439 pub args: Vec<String>,
441
442 pub env: ServerEnv,
444
445 pub file_patterns: Vec<FilePattern>,
447
448 pub initialization_options: Option<serde_json::Value>,
460
461 pub settings: Option<LspSettings>,
466
467 pub timeout_seconds: TimeoutSecs,
473
474 pub request_timeout_seconds: TimeoutSecs,
486
487 pub heuristics: Option<ServerHeuristics>,
490
491 pub name: Option<ServerId>,
499
500 pub handles: Option<ToolSet>,
506
507 pub indexing: IndexingPolicy,
514}
515
516#[derive(Serialize, Deserialize)]
522#[serde(deny_unknown_fields)]
523struct RawLspServerConfig {
524 language_id: LanguageId,
525 command: ServerCommand,
526 #[serde(default)]
527 args: Vec<String>,
528 #[serde(default)]
529 env: BTreeMap<EnvKey, String>,
530 #[serde(default)]
531 file_patterns: Vec<String>,
532 #[serde(default)]
533 initialization_options: Option<serde_json::Value>,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
535 settings: Option<LspSettings>,
536 #[serde(default)]
537 timeout_seconds: TimeoutSecs,
538 #[serde(default)]
539 request_timeout_seconds: TimeoutSecs,
540 #[serde(default)]
541 heuristics: Option<ServerHeuristics>,
542 #[serde(default, skip_serializing_if = "Option::is_none")]
543 name: Option<ServerId>,
544 #[serde(default, skip_serializing_if = "Option::is_none")]
545 handles: Option<ToolSet>,
546 #[serde(default, skip_serializing_if = "IndexingPolicy::is_auto")]
547 indexing: IndexingPolicy,
548 #[serde(default, skip_serializing_if = "ServerSelection::is_explicit")]
549 selection: ServerSelection,
550}
551
552impl From<LspServerConfig> for RawLspServerConfig {
553 fn from(config: LspServerConfig) -> Self {
554 let LspServerConfig {
555 language_id,
556 command,
557 args,
558 env,
559 file_patterns,
560 initialization_options,
561 settings,
562 timeout_seconds,
563 request_timeout_seconds,
564 heuristics,
565 name,
566 handles,
567 indexing,
568 } = config;
569 let (command, selection) = command.into_parts();
570 Self {
571 language_id,
572 command,
573 args,
574 env: env.into(),
575 file_patterns: file_patterns.into_iter().map(String::from).collect(),
576 initialization_options,
577 settings,
578 timeout_seconds,
579 request_timeout_seconds,
580 heuristics,
581 name,
582 handles,
583 indexing,
584 selection,
585 }
586 }
587}
588
589impl TryFrom<RawLspServerConfig> for LspServerConfig {
590 type Error = ConfigError;
591
592 fn try_from(raw: RawLspServerConfig) -> Result<Self, Self::Error> {
593 let RawLspServerConfig {
594 language_id,
595 command,
596 args,
597 env,
598 file_patterns,
599 initialization_options,
600 settings,
601 timeout_seconds,
602 request_timeout_seconds,
603 heuristics,
604 name,
605 handles,
606 indexing,
607 selection,
608 } = raw;
609 let command = LaunchCommand::new(command, selection).map_err(|InvalidAutoSelection| {
610 ConfigError::SelectionAutoOnNonTypescript {
611 language: language_id.clone(),
612 }
613 })?;
614 let env = ServerEnv::from_entries(env, HostOs::CURRENT).map_err(|error| {
615 ConfigError::DuplicateEnvKey {
616 server: entry_id(name.as_ref(), &language_id),
617 error,
618 }
619 })?;
620 let file_patterns = file_patterns
621 .iter()
622 .map(|pattern| {
623 FilePattern::parse(pattern).map_err(|pattern| ConfigError::UnsupportedFilePattern {
624 server: entry_id(name.as_ref(), &language_id),
625 pattern,
626 })
627 })
628 .collect::<Result<_, _>>()?;
629 Ok(Self {
630 language_id,
631 command,
632 args,
633 env,
634 file_patterns,
635 initialization_options,
636 settings,
637 timeout_seconds,
638 request_timeout_seconds,
639 heuristics,
640 name,
641 handles,
642 indexing,
643 })
644 }
645}
646
647#[derive(Debug, Clone, Copy, PartialEq, Eq)]
652pub enum BuiltinServer {
653 RustAnalyzer,
655 Pyright,
657 TypescriptLanguageServer,
659 Gopls,
661 Clangd,
663 Zls,
665}
666
667impl BuiltinServer {
668 pub const ALL: [Self; 6] = [
670 Self::RustAnalyzer,
671 Self::Pyright,
672 Self::TypescriptLanguageServer,
673 Self::Gopls,
674 Self::Clangd,
675 Self::Zls,
676 ];
677
678 #[must_use]
680 pub const fn command(self) -> &'static str {
681 match self {
682 Self::RustAnalyzer => "rust-analyzer",
683 Self::Pyright => "pyright-langserver",
684 Self::TypescriptLanguageServer => "typescript-language-server",
685 Self::Gopls => "gopls",
686 Self::Clangd => "clangd",
687 Self::Zls => "zls",
688 }
689 }
690
691 #[must_use]
694 pub const fn workspace_code(self) -> &'static str {
695 match self {
696 Self::RustAnalyzer => "Cargo build scripts and procedural macros",
697 Self::Pyright => {
698 "the project's Python environment, plugins and configured interpreters"
699 }
700 Self::TypescriptLanguageServer => {
701 "the workspace's tsserver and tsconfig plugins, unless pinned"
702 }
703 Self::Gopls => "the Go toolchain, including toolchain downloads requested by go.mod",
704 Self::Clangd => "commands from compile_commands.json and .clangd configuration",
705 Self::Zls => "build.zig through its build runner",
706 }
707 }
708
709 #[must_use]
711 pub const fn install_hint(self) -> &'static str {
712 match self {
713 Self::RustAnalyzer => "rustup component add rust-analyzer",
714 Self::Pyright => "npm install -g pyright",
715 Self::TypescriptLanguageServer => {
716 "npm install -g typescript-language-server typescript@6"
717 }
718 Self::Gopls => "go install golang.org/x/tools/gopls@latest",
719 Self::Clangd => {
720 "install clangd via your package manager (e.g. apt install clangd, brew install llvm) or see https://clangd.llvm.org/installation"
721 }
722 Self::Zls => {
723 "install a zls matching your Zig version: https://zigtools.org/zls/install/"
724 }
725 }
726 }
727
728 #[must_use]
734 pub const fn early_exit_hint(self) -> Option<&'static str> {
735 match self {
736 Self::RustAnalyzer => Some(
737 "the `rust-analyzer` found on PATH may be a rustup proxy without the component \
738 installed -- run `rustup component add rust-analyzer`",
739 ),
740 Self::Pyright
741 | Self::TypescriptLanguageServer
742 | Self::Gopls
743 | Self::Clangd
744 | Self::Zls => None,
745 }
746 }
747
748 #[must_use]
751 pub const fn is_npm_package(self) -> bool {
752 matches!(self, Self::Pyright | Self::TypescriptLanguageServer)
753 }
754
755 #[must_use]
771 pub fn matches_command(self, command: &str) -> bool {
772 CommandStem::of(command).is(self.command())
773 }
774
775 #[must_use]
786 pub fn from_command(command: &str) -> Option<Self> {
787 Self::ALL.into_iter().find(|b| b.command() == command)
788 }
789}
790
791impl LspServerConfig {
792 #[must_use]
798 pub fn id(&self) -> ServerId {
799 entry_id(self.name.as_ref(), &self.language_id)
800 }
801
802 fn builtin(
805 language_id: LanguageId,
806 server: BuiltinServer,
807 args: &[&str],
808 file_patterns: &[&'static str],
809 markers: impl IntoIterator<Item = &'static str>,
810 ) -> Self {
811 Self {
812 language_id,
813 command: ServerCommand::from_static(server.command()).into(),
814 args: args.iter().map(ToString::to_string).collect(),
815 env: crate::config::ServerEnv::default(),
816 file_patterns: file_patterns
817 .iter()
818 .map(|pattern| FilePattern::from_static(pattern))
819 .collect(),
820 initialization_options: None,
821 settings: None,
822 timeout_seconds: TimeoutSecs::DEFAULT,
823 request_timeout_seconds: TimeoutSecs::DEFAULT,
824 heuristics: Some(ServerHeuristics::with_markers(
825 markers.into_iter().map(ProjectMarker::from_static),
826 )),
827 name: None,
828 handles: None,
829 indexing: IndexingPolicy::Auto,
830 }
831 }
832
833 #[must_use]
835 pub fn rust_analyzer() -> Self {
836 Self::builtin(
837 const { LanguageId::from_static("rust") },
838 BuiltinServer::RustAnalyzer,
839 &[],
840 &["**/*.rs"],
841 ["Cargo.toml", "rust-toolchain.toml"],
842 )
843 }
844
845 #[must_use]
847 pub fn pyright() -> Self {
848 Self::builtin(
849 const { LanguageId::from_static("python") },
850 BuiltinServer::Pyright,
851 &["--stdio"],
852 &["**/*.py"],
853 [
854 "pyproject.toml",
855 "setup.py",
856 "requirements.txt",
857 "pyrightconfig.json",
858 ],
859 )
860 }
861
862 #[must_use]
870 pub fn typescript() -> Self {
871 Self {
872 command: LaunchCommand::typescript(),
873 ..Self::builtin(
874 const { LanguageId::from_static("typescript") },
875 BuiltinServer::TypescriptLanguageServer,
876 &["--stdio"],
877 &["**/*.ts", "**/*.tsx"],
878 ["package.json", "tsconfig.json", "jsconfig.json"],
879 )
880 }
881 }
882
883 #[must_use]
885 pub fn gopls() -> Self {
886 Self::builtin(
887 const { LanguageId::from_static("go") },
888 BuiltinServer::Gopls,
889 &["serve"],
890 &["**/*.go"],
891 ["go.mod", "go.sum"],
892 )
893 }
894
895 #[must_use]
897 pub fn clangd() -> Self {
898 Self::builtin(
899 const { LanguageId::from_static("cpp") },
900 BuiltinServer::Clangd,
901 &[],
902 &["**/*.c", "**/*.cpp", "**/*.h", "**/*.hpp"],
903 [
904 "CMakeLists.txt",
905 "compile_commands.json",
906 "Makefile",
907 ".clangd",
908 ],
909 )
910 }
911
912 #[must_use]
914 pub fn zls() -> Self {
915 Self::builtin(
916 const { LanguageId::from_static("zig") },
917 BuiltinServer::Zls,
918 &[],
919 &["**/*.zig"],
920 ["build.zig", "build.zig.zon"],
921 )
922 }
923}
924
925fn entry_id(name: Option<&ServerId>, language: &LanguageId) -> ServerId {
927 name.cloned()
928 .unwrap_or_else(|| ServerId::from(language.clone()))
929}
930
931#[cfg(test)]
932mod tests {
933 use tempfile::TempDir;
934
935 use super::*;
936 use crate::config::ToolRouter;
937
938 fn heuristics_with<const N: usize>(markers: [&'static str; N]) -> ServerHeuristics {
939 ServerHeuristics::with_markers(markers.map(ProjectMarker::from_static))
940 }
941
942 #[test]
943 fn test_server_command_rejects_blank_and_round_trips() {
944 assert_eq!(ServerCommand::new(""), Err(InvalidServerCommand));
945 assert_eq!(ServerCommand::new(" \t"), Err(InvalidServerCommand));
946 let command: ServerCommand = serde_json::from_str("\"gopls\"").unwrap();
947 assert_eq!(command, "gopls");
948 assert_eq!(serde_json::to_string(&command).unwrap(), "\"gopls\"");
949 assert!(serde_json::from_str::<ServerCommand>("\" \"").is_err());
950 }
951
952 #[test]
953 #[should_panic(expected = "command must be ASCII and not blank")]
954 fn test_server_command_from_static_panics_on_blank() {
955 let _ = ServerCommand::from_static(" ");
956 }
957
958 #[test]
959 #[should_panic(expected = "command must be ASCII and not blank")]
960 fn test_server_command_from_static_panics_on_non_ascii_blank() {
961 let _ = ServerCommand::from_static("\u{a0}");
962 }
963
964 #[test]
965 fn test_matches_command_accepts_stem_and_rejects_other_servers() {
966 let tsls = BuiltinServer::TypescriptLanguageServer;
967 for command in [
968 "typescript-language-server",
969 "/usr/local/bin/typescript-language-server",
970 "typescript-language-server.cmd",
971 ] {
972 assert!(tsls.matches_command(command), "{command}");
973 }
974 for command in ["tsc", "", "rust-analyzer", "typescript-language-server-x"] {
975 assert!(!tsls.matches_command(command), "{command}");
976 }
977 }
978
979 #[test]
980 fn test_selection_serde_and_defaults() {
981 assert!(ServerSelection::default().is_explicit());
982 assert_eq!(
983 serde_json::from_str::<ServerSelection>("\"auto\"").unwrap(),
984 ServerSelection::Auto
985 );
986 assert!(serde_json::from_str::<ServerSelection>("\"native\"").is_err());
987 assert_eq!(
988 LspServerConfig::typescript().command.selection(),
989 ServerSelection::Auto
990 );
991 assert_eq!(
992 LspServerConfig::rust_analyzer().command.selection(),
993 ServerSelection::Explicit
994 );
995 }
996
997 #[test]
998 fn test_default_typescript_command_is_a_valid_auto_selection() {
999 let command = LspServerConfig::typescript().command;
1000 assert_eq!(
1001 LaunchCommand::auto(command.server_command().clone()),
1002 Ok(command)
1003 );
1004 }
1005
1006 #[test]
1007 fn test_retarget_keeps_the_selection_and_rechecks_auto() {
1008 let tsls = |text: &str| ServerCommand::new(text).unwrap();
1009 let auto = LaunchCommand::auto(tsls("typescript-language-server")).unwrap();
1010
1011 let moved = auto
1012 .clone()
1013 .retarget(tsls("/opt/bin/typescript-language-server"))
1014 .unwrap();
1015 assert_eq!(moved.selection(), ServerSelection::Auto);
1016 assert_eq!(moved, "/opt/bin/typescript-language-server");
1017 assert_eq!(
1018 auto.retarget(tsls("rust-analyzer")),
1019 Err(InvalidAutoSelection)
1020 );
1021
1022 let explicit = LaunchCommand::explicit(tsls("rust-analyzer"));
1023 assert!(explicit.retarget(tsls("anything")).is_ok());
1024 }
1025
1026 #[test]
1027 fn test_selection_key_is_written_only_for_auto() {
1028 let typescript = toml::to_string(&LspServerConfig::typescript()).unwrap();
1029 assert!(typescript.contains("selection = \"auto\""), "{typescript}");
1030 let rust = toml::to_string(&LspServerConfig::rust_analyzer()).unwrap();
1031 assert!(!rust.contains("selection"), "{rust}");
1032 }
1033
1034 #[test]
1035 fn test_early_exit_hint_only_for_rust_analyzer() {
1036 for builtin in BuiltinServer::ALL {
1037 assert_eq!(
1038 builtin.early_exit_hint().is_some(),
1039 builtin == BuiltinServer::RustAnalyzer,
1040 "{builtin:?}"
1041 );
1042 }
1043 }
1044
1045 #[test]
1046 fn test_rust_analyzer_defaults() {
1047 let config = LspServerConfig::rust_analyzer();
1048
1049 assert_eq!(config.language_id, "rust");
1050 assert_eq!(config.command, "rust-analyzer");
1051 assert_eq!(config.args.len(), 0);
1052 assert!(config.env.is_empty());
1053 assert_eq!(config.file_patterns, ["**/*.rs"]);
1054 assert!(config.initialization_options.is_none());
1055 assert_eq!(config.timeout_seconds, TimeoutSecs::DEFAULT);
1056 }
1057
1058 #[test]
1059 fn test_pyright_defaults() {
1060 let config = LspServerConfig::pyright();
1061
1062 assert_eq!(config.language_id, "python");
1063 assert_eq!(config.command, "pyright-langserver");
1064 assert_eq!(config.args, vec!["--stdio"]);
1065 assert!(config.env.is_empty());
1066 assert_eq!(config.file_patterns, ["**/*.py"]);
1067 assert!(config.initialization_options.is_none());
1068 assert_eq!(config.timeout_seconds, TimeoutSecs::DEFAULT);
1069 }
1070
1071 #[test]
1072 fn test_typescript_defaults() {
1073 let config = LspServerConfig::typescript();
1074
1075 assert_eq!(config.language_id, "typescript");
1076 assert_eq!(config.command, "typescript-language-server");
1077 assert_eq!(config.args, vec!["--stdio"]);
1078 assert!(config.env.is_empty());
1079 assert_eq!(config.file_patterns, ["**/*.ts", "**/*.tsx"]);
1080 assert!(config.initialization_options.is_none());
1081 assert_eq!(config.timeout_seconds, TimeoutSecs::DEFAULT);
1082 }
1083
1084 #[test]
1085 fn test_custom_config() {
1086 let mut env = ServerEnv::default();
1087 env.insert(EnvKey::from_static("RUST_LOG"), "debug".to_string());
1088
1089 let config = LspServerConfig {
1090 language_id: LanguageId::from_static("custom"),
1091 command: ServerCommand::from_static("custom-lsp").into(),
1092 args: vec!["--flag".to_string()],
1093 env: env.clone(),
1094 file_patterns: vec![FilePattern::from_static("**/*.custom")],
1095 initialization_options: Some(serde_json::json!({"key": "value"})),
1096 settings: None,
1097 timeout_seconds: TimeoutSecs::new(60).unwrap(),
1098 request_timeout_seconds: TimeoutSecs::new(45).unwrap(),
1099 heuristics: None,
1100 name: None,
1101 handles: None,
1102 indexing: crate::bridge::IndexingPolicy::Auto,
1103 };
1104
1105 assert_eq!(config.language_id, "custom");
1106 assert_eq!(config.command, "custom-lsp");
1107 assert_eq!(config.args, vec!["--flag"]);
1108 assert_eq!(config.env.get("RUST_LOG"), Some("debug"));
1109 assert_eq!(config.file_patterns, ["**/*.custom"]);
1110 assert!(config.initialization_options.is_some());
1111 assert_eq!(config.timeout_seconds.get(), 60);
1112 }
1113
1114 #[test]
1115 fn test_serde_roundtrip() {
1116 let original = LspServerConfig::rust_analyzer();
1117
1118 let serialized = serde_json::to_string(&original).unwrap();
1119 let deserialized: LspServerConfig = serde_json::from_str(&serialized).unwrap();
1120
1121 assert_eq!(deserialized.language_id, original.language_id);
1122 assert_eq!(deserialized.command, original.command);
1123 assert_eq!(deserialized.args, original.args);
1124 assert_eq!(deserialized.timeout_seconds, original.timeout_seconds);
1125 assert_eq!(
1126 deserialized.request_timeout_seconds,
1127 original.request_timeout_seconds
1128 );
1129 }
1130
1131 #[test]
1132 fn test_indexing_defaults_to_auto_when_omitted() {
1133 assert_eq!(
1134 LspServerConfig::rust_analyzer().indexing,
1135 crate::bridge::IndexingPolicy::Auto
1136 );
1137 }
1138
1139 #[test]
1142 fn test_indexing_disabled_round_trips_through_toml() {
1143 let toml_str = r#"
1144 language_id = "rust"
1145 command = "rust-analyzer"
1146 indexing = "disabled"
1147 "#;
1148 let config: LspServerConfig = toml::from_str(toml_str).unwrap();
1149 assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Disabled);
1150
1151 let serialized = toml::to_string(&config).unwrap();
1152 assert!(serialized.contains(r#"indexing = "disabled""#));
1153 let round_tripped: LspServerConfig = toml::from_str(&serialized).unwrap();
1154 assert_eq!(
1155 round_tripped.indexing,
1156 crate::bridge::IndexingPolicy::Disabled
1157 );
1158 }
1159
1160 #[test]
1163 fn test_indexing_omitted_from_toml_defaults_to_auto() {
1164 let toml_str = r#"
1165 language_id = "rust"
1166 command = "rust-analyzer"
1167 "#;
1168 let config: LspServerConfig = toml::from_str(toml_str).unwrap();
1169 assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Auto);
1170 }
1171
1172 #[test]
1179 fn test_indexing_auto_is_omitted_from_serialized_toml() {
1180 let config = LspServerConfig::rust_analyzer();
1181 assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Auto);
1182
1183 let serialized = toml::to_string(&config).unwrap();
1184 assert!(
1185 !serialized.contains("indexing"),
1186 "the default IndexingPolicy::Auto must be omitted, not serialized as \
1187 indexing = \"auto\""
1188 );
1189 }
1190
1191 #[test]
1192 fn test_clone() {
1193 let config = LspServerConfig::rust_analyzer();
1194 let cloned = config.clone();
1195
1196 assert_eq!(cloned.language_id, config.language_id);
1197 assert_eq!(cloned.command, config.command);
1198 assert_eq!(cloned.timeout_seconds, config.timeout_seconds);
1199 }
1200
1201 #[test]
1202 fn test_empty_env() {
1203 let config = LspServerConfig::rust_analyzer();
1204 assert!(config.env.is_empty());
1205 }
1206
1207 #[test]
1208 fn test_multiple_file_patterns() {
1209 let config = LspServerConfig::typescript();
1210 assert_eq!(config.file_patterns.len(), 2);
1211 assert!(
1212 config
1213 .file_patterns
1214 .contains(&FilePattern::from_static("**/*.ts"))
1215 );
1216 assert!(
1217 config
1218 .file_patterns
1219 .contains(&FilePattern::from_static("**/*.tsx"))
1220 );
1221 }
1222
1223 #[test]
1224 fn test_initialization_options_none_by_default() {
1225 let configs = vec![
1226 LspServerConfig::rust_analyzer(),
1227 LspServerConfig::pyright(),
1228 LspServerConfig::typescript(),
1229 ];
1230
1231 for config in configs {
1232 assert!(config.initialization_options.is_none());
1233 }
1234 }
1235
1236 #[test]
1238 fn test_heuristics_empty_always_applicable() {
1239 let heuristics = ServerHeuristics::default();
1240 let tmp = TempDir::new().unwrap();
1241 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1242 }
1243
1244 #[test]
1245 fn test_heuristics_marker_present() {
1246 let tmp = TempDir::new().unwrap();
1247 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1248
1249 let heuristics = heuristics_with(["Cargo.toml"]);
1250 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1251 }
1252
1253 #[test]
1254 fn test_heuristics_marker_absent() {
1255 let tmp = TempDir::new().unwrap();
1256 let heuristics = heuristics_with(["Cargo.toml"]);
1257 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1258 }
1259
1260 #[test]
1261 fn test_heuristics_any_marker_matches() {
1262 let tmp = TempDir::new().unwrap();
1263 std::fs::write(tmp.path().join("setup.py"), "").unwrap();
1264
1265 let heuristics = heuristics_with(["pyproject.toml", "setup.py", "requirements.txt"]);
1266 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1267 }
1268
1269 #[test]
1270 fn test_should_spawn_without_heuristics() {
1271 let config = LspServerConfig {
1272 language_id: LanguageId::from_static("test"),
1273 command: ServerCommand::from_static("test-lsp").into(),
1274 args: vec![],
1275 env: crate::config::ServerEnv::default(),
1276 file_patterns: vec![],
1277 initialization_options: None,
1278 settings: None,
1279 timeout_seconds: TimeoutSecs::new(30).unwrap(),
1280 request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
1281 heuristics: None,
1282 name: None,
1283 handles: None,
1284 indexing: crate::bridge::IndexingPolicy::Auto,
1285 };
1286
1287 let tmp = TempDir::new().unwrap();
1288 assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1289 }
1290
1291 #[test]
1292 fn test_should_spawn_with_heuristics() {
1293 let tmp = TempDir::new().unwrap();
1294 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1295
1296 let config = LspServerConfig::rust_analyzer();
1297 assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1298 }
1299
1300 #[test]
1301 fn test_should_not_spawn_without_markers() {
1302 let tmp = TempDir::new().unwrap();
1303 let config = LspServerConfig::rust_analyzer();
1304 assert!(!spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1305 }
1306
1307 #[test]
1308 fn test_heuristics_serde_roundtrip() {
1309 let heuristics = heuristics_with(["Cargo.toml", "rust-toolchain.toml"]);
1310 let json = serde_json::to_string(&heuristics).unwrap();
1311 let deserialized: ServerHeuristics = serde_json::from_str(&json).unwrap();
1312 assert_eq!(deserialized.project_markers, heuristics.project_markers);
1313 }
1314
1315 #[test]
1316 fn test_default_rust_analyzer_heuristics() {
1317 let config = LspServerConfig::rust_analyzer();
1318 assert!(config.heuristics.is_some());
1319 let markers = &config.heuristics.unwrap().project_markers;
1320 assert!(markers.contains(&ProjectMarker::from_static("Cargo.toml")));
1321 }
1322
1323 #[test]
1324 fn test_gopls_defaults() {
1325 let config = LspServerConfig::gopls();
1326
1327 assert_eq!(config.language_id, "go");
1328 assert_eq!(config.command, "gopls");
1329 assert_eq!(config.args, vec!["serve"]);
1330 assert!(config.heuristics.is_some());
1331 let markers = &config.heuristics.unwrap().project_markers;
1332 assert!(markers.contains(&ProjectMarker::from_static("go.mod")));
1333 assert!(markers.contains(&ProjectMarker::from_static("go.sum")));
1334 }
1335
1336 #[test]
1337 fn test_clangd_defaults() {
1338 let config = LspServerConfig::clangd();
1339
1340 assert_eq!(config.language_id, "cpp");
1341 assert_eq!(config.command, "clangd");
1342 assert_eq!(config.args.len(), 0);
1343 assert!(config.heuristics.is_some());
1344 let markers = &config.heuristics.unwrap().project_markers;
1345 assert!(markers.contains(&ProjectMarker::from_static("CMakeLists.txt")));
1346 assert!(markers.contains(&ProjectMarker::from_static("compile_commands.json")));
1347 }
1348
1349 #[test]
1350 fn test_zls_defaults() {
1351 let config = LspServerConfig::zls();
1352
1353 assert_eq!(config.language_id, "zig");
1354 assert_eq!(config.command, "zls");
1355 assert_eq!(config.args.len(), 0);
1356 assert!(config.heuristics.is_some());
1357 let markers = &config.heuristics.unwrap().project_markers;
1358 assert!(markers.contains(&ProjectMarker::from_static("build.zig")));
1359 assert!(markers.contains(&ProjectMarker::from_static("build.zig.zon")));
1360 }
1361
1362 #[test]
1364 fn test_recursive_empty_markers_always_applicable() {
1365 let heuristics = ServerHeuristics::default();
1366 let tmp = TempDir::new().unwrap();
1367 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1368 }
1369
1370 #[test]
1371 fn test_recursive_marker_at_root() {
1372 let tmp = TempDir::new().unwrap();
1373 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1374
1375 let heuristics = heuristics_with(["Cargo.toml"]);
1376 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1377 }
1378
1379 #[test]
1380 fn test_recursive_nested_python_project() {
1381 let tmp = TempDir::new().unwrap();
1382 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1384 let python_dir = tmp.path().join("python");
1386 std::fs::create_dir(&python_dir).unwrap();
1387 std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
1388
1389 let heuristics = heuristics_with(["pyproject.toml", "setup.py"]);
1390 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1391 }
1392
1393 #[test]
1394 fn test_recursive_deeply_nested_marker() {
1395 let tmp = TempDir::new().unwrap();
1396 let deep_path = tmp.path().join("level1").join("level2").join("level3");
1398 std::fs::create_dir_all(&deep_path).unwrap();
1399 std::fs::write(deep_path.join("go.mod"), "").unwrap();
1400
1401 let heuristics = heuristics_with(["go.mod"]);
1402 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1403 }
1404
1405 #[test]
1406 fn test_recursive_no_marker_found() {
1407 let tmp = TempDir::new().unwrap();
1408 std::fs::create_dir(tmp.path().join("src")).unwrap();
1409 std::fs::write(tmp.path().join("src").join("main.rs"), "").unwrap();
1410
1411 let heuristics = heuristics_with(["Cargo.toml"]);
1412 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1413 }
1414
1415 #[test]
1416 fn test_recursive_max_depth_respected() {
1417 let tmp = TempDir::new().unwrap();
1418 let deep_path = tmp.path().join("a").join("b").join("c").join("d").join("e");
1420 std::fs::create_dir_all(&deep_path).unwrap();
1421 std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
1422
1423 let heuristics = heuristics_with(["Cargo.toml"]);
1424 assert!(!applies(
1426 &heuristics,
1427 tmp.path(),
1428 SearchDepth::new(3).unwrap()
1429 ));
1430 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1432 }
1433
1434 #[test]
1435 fn test_recursive_excludes_node_modules() {
1436 let tmp = TempDir::new().unwrap();
1437 let node_modules = tmp.path().join("node_modules").join("some-package");
1439 std::fs::create_dir_all(&node_modules).unwrap();
1440 std::fs::write(node_modules.join("package.json"), "").unwrap();
1441
1442 let heuristics = heuristics_with(["package.json"]);
1443 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1444 }
1445
1446 #[test]
1447 fn test_recursive_excludes_target_directory() {
1448 let tmp = TempDir::new().unwrap();
1449 let target = tmp.path().join("target").join("debug");
1451 std::fs::create_dir_all(&target).unwrap();
1452 std::fs::write(target.join("Cargo.toml"), "").unwrap();
1453
1454 let heuristics = heuristics_with(["Cargo.toml"]);
1455 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1456 }
1457
1458 #[test]
1459 fn test_recursive_excludes_git_directory() {
1460 let tmp = TempDir::new().unwrap();
1461 let git_dir = tmp.path().join(".git").join("hooks");
1462 std::fs::create_dir_all(&git_dir).unwrap();
1463 std::fs::write(git_dir.join("Cargo.toml"), "").unwrap();
1464
1465 let heuristics = heuristics_with(["Cargo.toml"]);
1466 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1467 }
1468
1469 #[test]
1470 fn test_recursive_excludes_pycache() {
1471 let tmp = TempDir::new().unwrap();
1472 let pycache = tmp.path().join("__pycache__");
1473 std::fs::create_dir_all(&pycache).unwrap();
1474 std::fs::write(pycache.join("pyproject.toml"), "").unwrap();
1475
1476 let heuristics = heuristics_with(["pyproject.toml"]);
1477 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1478 }
1479
1480 #[test]
1481 fn test_recursive_excludes_venv() {
1482 let tmp = TempDir::new().unwrap();
1483 let venv = tmp.path().join(".venv").join("lib");
1484 std::fs::create_dir_all(&venv).unwrap();
1485 std::fs::write(venv.join("setup.py"), "").unwrap();
1486
1487 let heuristics = heuristics_with(["setup.py"]);
1488 assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1489 }
1490
1491 #[test]
1496 fn test_recursive_respects_gitignore_in_marker_search() {
1497 let tmp = TempDir::new().unwrap();
1498 std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
1501 std::fs::write(tmp.path().join(".gitignore"), "ignored/\n").unwrap();
1502
1503 let ignored_dir = tmp.path().join("ignored");
1504 std::fs::create_dir_all(&ignored_dir).unwrap();
1505 std::fs::write(ignored_dir.join("Cargo.toml"), "").unwrap();
1506
1507 let heuristics = heuristics_with(["Cargo.toml"]);
1508 assert!(
1509 !applies(&heuristics, tmp.path(), SearchDepth::DEFAULT),
1510 "marker inside a gitignored directory must not make the server applicable"
1511 );
1512 }
1513
1514 #[test]
1515 fn test_recursive_finds_marker_outside_excluded() {
1516 let tmp = TempDir::new().unwrap();
1517 let node_modules = tmp.path().join("node_modules");
1519 std::fs::create_dir_all(&node_modules).unwrap();
1520 std::fs::write(node_modules.join("package.json"), "").unwrap();
1521 let src = tmp.path().join("src");
1523 std::fs::create_dir_all(&src).unwrap();
1524 std::fs::write(src.join("package.json"), "").unwrap();
1525
1526 let heuristics = heuristics_with(["package.json"]);
1527 assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1528 }
1529
1530 #[test]
1531 fn test_recursive_monorepo_structure() {
1532 let tmp = TempDir::new().unwrap();
1533 let rust_pkg = tmp.path().join("packages").join("rust-lib");
1535 let python_pkg = tmp.path().join("packages").join("python-bindings");
1536 let ts_pkg = tmp.path().join("packages").join("typescript-client");
1537
1538 std::fs::create_dir_all(&rust_pkg).unwrap();
1539 std::fs::create_dir_all(&python_pkg).unwrap();
1540 std::fs::create_dir_all(&ts_pkg).unwrap();
1541
1542 std::fs::write(rust_pkg.join("Cargo.toml"), "").unwrap();
1543 std::fs::write(python_pkg.join("pyproject.toml"), "").unwrap();
1544 std::fs::write(ts_pkg.join("package.json"), "").unwrap();
1545
1546 let rust_heuristics = heuristics_with(["Cargo.toml"]);
1548 let python_heuristics = heuristics_with(["pyproject.toml"]);
1549 let ts_heuristics = heuristics_with(["package.json"]);
1550
1551 assert!(applies(&rust_heuristics, tmp.path(), SearchDepth::DEFAULT));
1552 assert!(applies(
1553 &python_heuristics,
1554 tmp.path(),
1555 SearchDepth::DEFAULT
1556 ));
1557 assert!(applies(&ts_heuristics, tmp.path(), SearchDepth::DEFAULT));
1558 }
1559
1560 #[test]
1561 fn test_should_spawn_recursive() {
1562 let tmp = TempDir::new().unwrap();
1563 let python_dir = tmp.path().join("bindings").join("python");
1565 std::fs::create_dir_all(&python_dir).unwrap();
1566 std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
1567
1568 let config = LspServerConfig::pyright();
1569 assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1570 }
1571
1572 #[test]
1573 fn test_should_spawn_with_custom_max_depth() {
1574 let tmp = TempDir::new().unwrap();
1575 let deep_path = tmp.path().join("a").join("b").join("c").join("d");
1576 std::fs::create_dir_all(&deep_path).unwrap();
1577 std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
1578
1579 let config = LspServerConfig::rust_analyzer();
1580 assert!(!spawns(&config, tmp.path(), SearchDepth::new(2).unwrap()));
1582 assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1584 }
1585
1586 fn applies(heuristics: &ServerHeuristics, root: &Path, depth: SearchDepth) -> bool {
1587 let server = LspServerConfig {
1588 heuristics: Some(heuristics.clone()),
1589 ..LspServerConfig::rust_analyzer()
1590 };
1591 spawns(&server, root, depth)
1592 }
1593
1594 fn spawns(server: &LspServerConfig, root: &Path, depth: SearchDepth) -> bool {
1595 scan(&[root], std::slice::from_ref(server), depth).applies_to(server)
1596 }
1597
1598 fn scan(roots: &[&Path], servers: &[LspServerConfig], depth: SearchDepth) -> MarkerScan {
1599 let roots: Vec<PathBuf> = roots.iter().map(|root| root.to_path_buf()).collect();
1600 MarkerScan::collect(&roots, servers, depth)
1601 }
1602
1603 fn all_builtins() -> Vec<LspServerConfig> {
1604 vec![
1605 LspServerConfig::rust_analyzer(),
1606 LspServerConfig::pyright(),
1607 LspServerConfig::typescript(),
1608 LspServerConfig::gopls(),
1609 LspServerConfig::clangd(),
1610 LspServerConfig::zls(),
1611 ]
1612 }
1613
1614 #[test]
1615 fn test_marker_scan_finds_the_markers_of_every_server_in_one_pass() {
1616 let tmp = TempDir::new().unwrap();
1617 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1618 let python = tmp.path().join("bindings").join("python");
1619 std::fs::create_dir_all(&python).unwrap();
1620 std::fs::write(python.join("pyproject.toml"), "").unwrap();
1621 let servers = all_builtins();
1622
1623 let found = scan(&[tmp.path()], &servers, SearchDepth::DEFAULT);
1624
1625 let applying: Vec<_> = servers
1626 .iter()
1627 .filter(|server| found.applies_to(server))
1628 .map(|server| server.language_id.as_str())
1629 .collect();
1630 assert_eq!(applying, ["rust", "python"]);
1631 }
1632
1633 #[test]
1634 fn test_marker_scan_skips_excluded_directories_and_markers_below_the_depth() {
1635 let tmp = TempDir::new().unwrap();
1636 for (dir, file) in [
1637 ("a/b", "go.mod"),
1638 ("c", "package.json"),
1639 ("node_modules/x", "Cargo.toml"),
1640 ("d/e/f/g/h/i/j/k/l/m/n", "build.zig"),
1641 ] {
1642 std::fs::create_dir_all(tmp.path().join(dir)).unwrap();
1643 std::fs::write(tmp.path().join(dir).join(file), "").unwrap();
1644 }
1645 let servers = all_builtins();
1646 let found = scan(&[tmp.path()], &servers, SearchDepth::DEFAULT);
1647
1648 let applying: Vec<_> = servers
1649 .iter()
1650 .filter(|server| found.applies_to(server))
1651 .map(|server| server.language_id.as_str())
1652 .collect();
1653 assert_eq!(applying, ["typescript", "go"]);
1654 }
1655
1656 #[test]
1657 fn test_marker_scan_unions_the_roots() {
1658 let (first, second) = (TempDir::new().unwrap(), TempDir::new().unwrap());
1659 std::fs::write(first.path().join("Cargo.toml"), "").unwrap();
1660 let nested = second.path().join("svc");
1661 std::fs::create_dir_all(&nested).unwrap();
1662 std::fs::write(nested.join("go.mod"), "").unwrap();
1663 let servers = all_builtins();
1664
1665 let found = scan(
1666 &[first.path(), second.path()],
1667 &servers,
1668 SearchDepth::DEFAULT,
1669 );
1670
1671 assert!(found.applies_to(&LspServerConfig::rust_analyzer()));
1672 assert!(found.applies_to(&LspServerConfig::gopls()));
1673 assert!(!found.applies_to(&LspServerConfig::zls()));
1674 }
1675
1676 #[test]
1677 fn test_marker_scan_honours_the_depth_limit() {
1678 let tmp = TempDir::new().unwrap();
1679 let deep = tmp.path().join("a").join("b").join("c").join("d");
1680 std::fs::create_dir_all(&deep).unwrap();
1681 std::fs::write(deep.join("Cargo.toml"), "").unwrap();
1682 let servers = [LspServerConfig::rust_analyzer()];
1683
1684 let shallow = scan(&[tmp.path()], &servers, SearchDepth::new(2).unwrap());
1685 let default = scan(&[tmp.path()], &servers, SearchDepth::DEFAULT);
1686
1687 assert!(!shallow.applies_to(&servers[0]));
1688 assert!(default.applies_to(&servers[0]));
1689 }
1690
1691 #[test]
1692 fn test_marker_scan_applies_a_server_without_markers_everywhere() {
1693 let tmp = TempDir::new().unwrap();
1694 let mut no_markers = LspServerConfig::rust_analyzer();
1695 no_markers.heuristics = None;
1696 let mut empty_markers = LspServerConfig::rust_analyzer();
1697 empty_markers.heuristics = Some(ServerHeuristics::default());
1698
1699 let found = scan(&[tmp.path()], &[], SearchDepth::DEFAULT);
1700
1701 assert!(found.applies_to(&no_markers));
1702 assert!(found.applies_to(&empty_markers));
1703 assert!(!found.applies_to(&LspServerConfig::rust_analyzer()));
1704 }
1705
1706 #[test]
1707 fn test_default_heuristics_max_depth() {
1708 assert_eq!(DEFAULT_HEURISTICS_MAX_DEPTH, 10);
1709 }
1710
1711 #[test]
1712 fn test_excluded_directories_constant() {
1713 assert!(EXCLUDED_DIRECTORIES.contains(&"node_modules"));
1714 assert!(EXCLUDED_DIRECTORIES.contains(&"target"));
1715 assert!(EXCLUDED_DIRECTORIES.contains(&".git"));
1716 assert!(EXCLUDED_DIRECTORIES.contains(&"__pycache__"));
1717 assert!(EXCLUDED_DIRECTORIES.contains(&".venv"));
1718 }
1719
1720 const NATIVE_ENTRY: &str = r#"[[lsp_servers]]
1721language_id = "typescript"
1722command = "/home/me/ts7/node_modules/.bin/tsc"
1723args = ["--lsp", "--stdio"]
1724file_patterns = ["**/*.ts", "**/*.tsx"]"#;
1725
1726 fn repo_file(relative: &str) -> String {
1727 let manifest = std::env::var_os("CARGO_MANIFEST_DIR").map_or_else(
1728 || std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")),
1729 Into::into,
1730 );
1731 std::fs::read_to_string(manifest.join("../..").join(relative))
1732 .unwrap()
1733 .replace("\r\n", "\n")
1734 }
1735
1736 fn native_entries(extra: &str) -> Vec<LspServerConfig> {
1737 let text = format!("{NATIVE_ENTRY}\n{extra}");
1738 toml::from_str::<crate::config::ServerConfig>(&text)
1739 .unwrap()
1740 .lsp_servers
1741 }
1742
1743 #[test]
1744 fn test_docs_agree_with_typescript_install_hint() {
1745 let hint = BuiltinServer::TypescriptLanguageServer.install_hint();
1746 let pin = hint.split_whitespace().last().unwrap();
1747 assert!(pin.starts_with("typescript@"), "{hint}");
1748
1749 let mut checked = 0;
1750 for file in [
1751 "README.md",
1752 "book/src/getting-started/installation.md",
1753 "book/src/guide/language-servers.md",
1754 "book/src/advanced/typescript.md",
1755 ] {
1756 for line in repo_file(file).lines() {
1757 if !(line.contains("npm install -g") && line.contains("typescript")) {
1758 continue;
1759 }
1760 checked += 1;
1761 assert!(
1762 line.split(|c: char| !(c.is_alphanumeric() || matches!(c, '@' | '.' | '-')))
1763 .any(|word| word == pin),
1764 "{file}: {line}"
1765 );
1766 if line.contains("typescript-language-server") {
1767 assert!(line.contains(hint), "{file}: {line}");
1768 }
1769 }
1770 }
1771 assert!(checked >= 4, "only {checked} install lines found");
1772 }
1773
1774 #[test]
1775 fn test_documented_native_entry_is_in_the_book() {
1776 assert!(repo_file("book/src/advanced/typescript.md").contains(NATIVE_ENTRY));
1777 }
1778
1779 #[test]
1780 fn test_native_entry_replacing_default_typescript_entry_routes() {
1781 let router = ToolRouter::from_configs(&native_entries("")).unwrap();
1782 assert!(router.has_language("typescript"));
1783 }
1784
1785 #[test]
1786 fn test_native_entry_appended_to_default_is_rejected() {
1787 let mut configs = vec![LspServerConfig::typescript()];
1788 configs.extend(native_entries(""));
1789 let err = ToolRouter::from_configs(&configs).unwrap_err().to_string();
1790 assert!(err.contains("duplicate server id"), "{err}");
1791 }
1792
1793 #[test]
1794 fn test_named_native_entry_appended_to_default_is_rejected() {
1795 let mut named = native_entries("");
1796 named[0].name = Some(ServerId::from_static("native-ts"));
1797 let mut configs = vec![LspServerConfig::typescript()];
1798 configs.extend(named);
1799 let err = ToolRouter::from_configs(&configs).unwrap_err().to_string();
1800 assert!(err.contains("two catch-all servers"), "{err}");
1801 }
1802}