1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, OnceLock};
4
5use crate::config::{Config, UserServerDef};
6use crate::lsp::roots::{
7 find_rust_workspace_root, find_workspace_root, find_workspace_root_within,
8};
9
10pub fn resolve_lsp_binary(
23 binary: &str,
24 project_root: Option<&Path>,
25 extra_paths: &[PathBuf],
26) -> Option<PathBuf> {
27 if let Some(root) = project_root {
29 let local_bin = root.join("node_modules").join(".bin");
30 if let Some(found) = probe_dir(&local_bin, binary) {
31 return Some(found);
32 }
33 }
34
35 for dir in extra_paths {
37 if let Some(found) = probe_dir(dir, binary) {
38 return Some(found);
39 }
40 }
41
42 which::which(binary).ok()
44}
45
46pub fn resolve_server_binary(
49 server: &ServerDef,
50 workspace_root: Option<&Path>,
51 config: &Config,
52) -> Option<PathBuf> {
53 let python_family = matches!(server.kind, ServerKind::Python | ServerKind::Ty);
54
55 if python_family {
56 if let Some(root) = workspace_root.or(config.project_root.as_deref()) {
57 if let Some(found) = probe_project_virtualenv(root, &server.binary) {
58 return Some(found);
59 }
60 }
61 if let Some(root) = workspace_root {
62 if config.project_root.as_deref() != Some(root) {
63 if let Some(found) =
64 probe_dir(&root.join("node_modules").join(".bin"), &server.binary)
65 {
66 return Some(found);
67 }
68 }
69 }
70 }
71
72 let project_root = if python_family {
76 config.project_root.as_deref().or(workspace_root)
77 } else {
78 config.project_root.as_deref()
79 };
80 resolve_lsp_binary(&server.binary, project_root, &config.lsp_paths_extra)
81}
82
83fn probe_project_virtualenv(root: &Path, binary: &str) -> Option<PathBuf> {
84 [root.join(".venv"), root.join("venv")]
85 .into_iter()
86 .find_map(|virtualenv| {
87 let bin_dir = if cfg!(windows) {
88 virtualenv.join("Scripts")
89 } else {
90 virtualenv.join("bin")
91 };
92 probe_dir(&bin_dir, binary)
93 })
94}
95
96fn probe_dir(dir: &Path, binary: &str) -> Option<PathBuf> {
98 if !dir.is_dir() {
99 return None;
100 }
101
102 if cfg!(windows) {
103 for ext in ["cmd", "exe", "bat"] {
108 let candidate = dir.join(format!("{binary}.{ext}"));
109 if candidate.is_file() {
110 return Some(candidate);
111 }
112 }
113 }
114
115 let direct = dir.join(binary);
116 if direct.is_file() {
117 return Some(direct);
118 }
119
120 None
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Hash)]
128pub enum ServerKind {
129 TypeScript,
131 Python, Rust,
133 Go,
134 Bash,
135 Yaml,
136 Ty, Clojure,
139 Dart,
140 ElixirLs,
141 FSharp,
142 Gleam,
143 Haskell,
144 Jdtls, Julia,
146 Nixd,
147 OcamlLsp,
148 PhpIntelephense,
149 RubyLsp,
150 SourceKit, CSharp,
152 Razor,
153 Clangd,
155 LuaLs,
156 Zls,
157 Tinymist,
158 KotlinLs,
159 Texlab,
160 Oxlint,
161 TerraformLs,
162 Vue,
164 Astro,
165 Prisma, Biome,
167 Svelte,
168 Dockerfile,
169 Custom(Arc<str>),
170}
171
172impl ServerKind {
173 pub fn id_str(&self) -> &str {
174 match self {
175 Self::TypeScript => "typescript",
176 Self::Python => "python",
177 Self::Rust => "rust",
178 Self::Go => "go",
179 Self::Bash => "bash",
180 Self::Yaml => "yaml",
181 Self::Ty => "ty",
182 Self::Clojure => "clojure-lsp",
184 Self::Dart => "dart",
185 Self::ElixirLs => "elixir-ls",
186 Self::FSharp => "fsharp",
187 Self::Gleam => "gleam",
188 Self::Haskell => "haskell-language-server",
189 Self::Jdtls => "jdtls",
190 Self::Julia => "julials",
191 Self::Nixd => "nixd",
192 Self::OcamlLsp => "ocaml-lsp",
193 Self::PhpIntelephense => "php-intelephense",
194 Self::RubyLsp => "ruby-lsp",
195 Self::SourceKit => "sourcekit-lsp",
196 Self::CSharp => "csharp",
197 Self::Razor => "razor",
198 Self::Clangd => "clangd",
200 Self::LuaLs => "lua-ls",
201 Self::Zls => "zls",
202 Self::Tinymist => "tinymist",
203 Self::KotlinLs => "kotlin-ls",
204 Self::Texlab => "texlab",
205 Self::Oxlint => "oxlint",
206 Self::TerraformLs => "terraform",
207 Self::Vue => "vue",
209 Self::Astro => "astro",
210 Self::Prisma => "prisma",
211 Self::Biome => "biome",
212 Self::Svelte => "svelte",
213 Self::Dockerfile => "dockerfile",
214 Self::Custom(id) => id.as_ref(),
215 }
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct ServerDef {
222 pub kind: ServerKind,
223 pub name: String,
225 pub extensions: Vec<String>,
227 pub binary: String,
229 pub args: Vec<String>,
231 pub root_markers: Vec<String>,
233 pub priority_root_markers: Vec<String>,
241 pub env: HashMap<String, String>,
243 pub initialization_options: Option<serde_json::Value>,
245}
246
247impl ServerDef {
248 pub fn workspace_root_for_file(&self, file_path: &Path) -> Option<PathBuf> {
250 self.workspace_root_for_file_with_project_root(file_path, None)
251 }
252
253 pub fn workspace_root_for_file_with_project_root(
260 &self,
261 file_path: &Path,
262 project_root: Option<&Path>,
263 ) -> Option<PathBuf> {
264 if self.kind == ServerKind::Rust {
265 if let Some(root) = find_rust_workspace_root(file_path, project_root) {
266 return Some(root);
267 }
268 }
269
270 let bounded_to_project = matches!(
271 self.kind,
272 ServerKind::Rust | ServerKind::Python | ServerKind::Ty
273 );
274 for marker in &self.priority_root_markers {
275 let root = if bounded_to_project {
276 find_workspace_root_within(file_path, &[marker.as_str()], project_root)
277 } else {
278 find_workspace_root(file_path, &[marker.as_str()])
279 };
280 if let Some(root) = root {
281 return Some(root);
282 }
283 }
284
285 if bounded_to_project {
286 find_workspace_root_within(file_path, &self.root_markers, project_root)
287 } else {
288 find_workspace_root(file_path, &self.root_markers)
289 }
290 }
291
292 pub fn matches_extension(&self, ext: &str) -> bool {
294 self.extensions
295 .iter()
296 .any(|candidate| candidate.eq_ignore_ascii_case(ext))
297 }
298
299 pub fn is_available(&self) -> bool {
301 which::which(&self.binary).is_ok()
302 }
303}
304
305pub fn builtin_servers() -> Vec<ServerDef> {
307 vec![
308 builtin_server(
309 ServerKind::TypeScript,
310 "TypeScript Language Server",
311 &["ts", "tsx", "js", "jsx", "mjs", "cjs"],
312 "typescript-language-server",
313 &["--stdio"],
314 &["tsconfig.json", "jsconfig.json", "package.json"],
315 ),
316 builtin_server_with_priority_roots(
317 ServerKind::Python,
318 "Pyright",
319 &["py", "pyi"],
320 "pyright-langserver",
321 &["--stdio"],
322 &[
323 "pyrightconfig.json",
324 "pyproject.toml",
325 "setup.py",
326 "setup.cfg",
327 "requirements.txt",
328 ],
329 &["pyrightconfig.json", "pyproject.toml"],
330 ),
331 builtin_server(
332 ServerKind::Rust,
333 "rust-analyzer",
334 &["rs"],
335 "rust-analyzer",
336 &[],
337 &["Cargo.toml", "Cargo.lock"],
338 ),
339 builtin_server_with_init(
344 ServerKind::Go,
345 "gopls",
346 &["go"],
347 "gopls",
348 &["serve"],
349 &["go.mod", "go.sum"],
350 serde_json::json!({ "pullDiagnostics": true }),
351 ),
352 builtin_server(
353 ServerKind::Bash,
354 "bash-language-server",
355 &["sh", "bash", "zsh"],
356 "bash-language-server",
357 &["start"],
358 &["package.json", ".git"],
359 ),
360 builtin_server(
361 ServerKind::Yaml,
362 "yaml-language-server",
363 &["yaml", "yml"],
364 "yaml-language-server",
365 &["--stdio"],
366 &["package.json", ".git"],
367 ),
368 builtin_server(
369 ServerKind::Ty,
370 "ty",
371 &["py", "pyi"],
372 "ty",
373 &["server"],
374 &[
375 "pyproject.toml",
376 "ty.toml",
377 "setup.py",
378 "setup.cfg",
379 "requirements.txt",
380 "Pipfile",
381 "pyrightconfig.json",
382 ],
383 ),
384 builtin_server(
391 ServerKind::Clojure,
392 "clojure-lsp",
393 &["clj", "cljs", "cljc", "edn"],
394 "clojure-lsp",
395 &[],
396 &[
397 "deps.edn",
398 "project.clj",
399 "shadow-cljs.edn",
400 "bb.edn",
401 "build.boot",
402 ],
403 ),
404 builtin_server(
405 ServerKind::Dart,
406 "Dart Language Server",
407 &["dart"],
408 "dart",
409 &["language-server", "--lsp"],
410 &["pubspec.yaml", "analysis_options.yaml"],
411 ),
412 builtin_server(
413 ServerKind::ElixirLs,
414 "elixir-ls",
415 &["ex", "exs"],
416 "elixir-ls",
417 &[],
418 &["mix.exs", "mix.lock"],
419 ),
420 builtin_server(
421 ServerKind::FSharp,
422 "FSAutoComplete",
423 &["fs", "fsi", "fsx", "fsscript"],
424 "fsautocomplete",
425 &[],
426 &[".slnx", ".sln", ".fsproj", "global.json"],
427 ),
428 builtin_server(
429 ServerKind::Gleam,
430 "Gleam Language Server",
431 &["gleam"],
432 "gleam",
433 &["lsp"],
434 &["gleam.toml"],
435 ),
436 builtin_server(
437 ServerKind::Haskell,
438 "haskell-language-server",
439 &["hs", "lhs"],
440 "haskell-language-server-wrapper",
441 &["--lsp"],
442 &["stack.yaml", "cabal.project", "hie.yaml"],
443 ),
444 builtin_server(
445 ServerKind::Jdtls,
446 "Eclipse JDT Language Server",
447 &["java"],
448 "jdtls",
449 &[],
450 &["pom.xml", "build.gradle", "build.gradle.kts", ".project"],
451 ),
452 builtin_server(
453 ServerKind::Julia,
454 "Julia Language Server",
455 &["jl"],
456 "julia",
457 &[
458 "--startup-file=no",
459 "--history-file=no",
460 "-e",
461 "using LanguageServer; runserver()",
462 ],
463 &["Project.toml", "Manifest.toml"],
464 ),
465 builtin_server(
466 ServerKind::Nixd,
467 "nixd",
468 &["nix"],
469 "nixd",
470 &[],
471 &["flake.nix", "default.nix", "shell.nix"],
472 ),
473 builtin_server(
474 ServerKind::OcamlLsp,
475 "ocaml-lsp",
476 &["ml", "mli"],
477 "ocamllsp",
478 &[],
479 &["dune-project", "dune-workspace", ".merlin", "opam"],
480 ),
481 builtin_server(
482 ServerKind::PhpIntelephense,
483 "Intelephense",
484 &["php"],
485 "intelephense",
486 &["--stdio"],
487 &["composer.json", "composer.lock", ".php-version"],
488 ),
489 builtin_server(
490 ServerKind::RubyLsp,
491 "ruby-lsp",
492 &["rb", "rake", "gemspec", "ru"],
493 "ruby-lsp",
494 &[],
495 &["Gemfile"],
496 ),
497 builtin_server(
498 ServerKind::SourceKit,
499 "SourceKit-LSP",
500 &["swift"],
501 "sourcekit-lsp",
502 &[],
503 &["Package.swift"],
504 ),
505 builtin_server(
506 ServerKind::CSharp,
507 "Roslyn Language Server",
508 &["cs", "csx"],
509 "roslyn-language-server",
510 &[],
511 &[".slnx", ".sln", ".csproj", "global.json"],
512 ),
513 builtin_server(
514 ServerKind::Razor,
515 "rzls",
516 &["razor", "cshtml"],
517 "rzls",
518 &[],
519 &[".slnx", ".sln", ".csproj", "global.json"],
520 ),
521 builtin_server(
523 ServerKind::Clangd,
524 "clangd",
525 &[
526 "c", "cpp", "cc", "cxx", "c++", "h", "hpp", "hh", "hxx", "h++",
527 ],
528 "clangd",
529 &[],
530 &["compile_commands.json", "compile_flags.txt", ".clangd"],
531 ),
532 builtin_server(
533 ServerKind::LuaLs,
534 "lua-language-server",
535 &["lua"],
536 "lua-language-server",
537 &[],
538 &[".luarc.json", ".luarc.jsonc", ".stylua.toml", "stylua.toml"],
539 ),
540 builtin_server(
541 ServerKind::Zls,
542 "zls",
543 &["zig", "zon"],
544 "zls",
545 &[],
546 &["build.zig"],
547 ),
548 builtin_server(
549 ServerKind::Tinymist,
550 "tinymist",
551 &["typ", "typc"],
552 "tinymist",
553 &[],
554 &["typst.toml"],
555 ),
556 builtin_server(
557 ServerKind::KotlinLs,
558 "kotlin-language-server",
559 &["kt", "kts"],
560 "kotlin-language-server",
561 &[],
562 &["settings.gradle", "settings.gradle.kts", "build.gradle"],
563 ),
564 builtin_server(
565 ServerKind::Texlab,
566 "texlab",
567 &["tex", "bib"],
568 "texlab",
569 &[],
570 &[".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"],
571 ),
572 builtin_server(
573 ServerKind::Oxlint,
574 "oxc-language-server",
575 &[
577 "ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "vue", "astro", "svelte",
578 ],
579 "oxc-language-server",
580 &[],
581 &[".oxlintrc.json", ".oxlintrc"],
588 ),
589 builtin_server(
590 ServerKind::TerraformLs,
591 "terraform-ls",
592 &["tf", "tfvars"],
593 "terraform-ls",
594 &["serve"],
595 &[".terraform.lock.hcl", "terraform.tfstate"],
596 ),
597 builtin_server(
603 ServerKind::Vue,
604 "Vue Language Server",
605 &["vue"],
606 "vue-language-server",
607 &["--stdio"],
608 &[
609 "package-lock.json",
610 "bun.lockb",
611 "bun.lock",
612 "pnpm-lock.yaml",
613 "yarn.lock",
614 ],
615 ),
616 builtin_server(
617 ServerKind::Astro,
618 "Astro Language Server",
619 &["astro"],
620 "astro-ls",
621 &["--stdio"],
622 &[
623 "astro.config.js",
624 "astro.config.mjs",
625 "astro.config.ts",
626 "astro.config.cjs",
627 "package.json",
628 "package-lock.json",
629 "bun.lockb",
630 "bun.lock",
631 "pnpm-lock.yaml",
632 "yarn.lock",
633 ],
634 ),
635 builtin_server(
640 ServerKind::Prisma,
641 "Prisma Language Server",
642 &["prisma"],
643 "prisma",
644 &["language-server"],
645 &["schema.prisma", "package.json"],
646 ),
647 builtin_server(
651 ServerKind::Biome,
652 "Biome",
653 &[
654 "ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "json", "jsonc",
655 ],
656 "biome",
657 &["lsp-proxy"],
658 &["biome.json", "biome.jsonc"],
659 ),
660 builtin_server(
661 ServerKind::Svelte,
662 "Svelte Language Server",
663 &["svelte"],
664 "svelteserver",
665 &["--stdio"],
666 &[
667 "package-lock.json",
668 "bun.lockb",
669 "bun.lock",
670 "pnpm-lock.yaml",
671 "yarn.lock",
672 ],
673 ),
674 builtin_server(
675 ServerKind::Dockerfile,
676 "Dockerfile Language Server",
677 &["dockerfile"],
683 "docker-langserver",
684 &["--stdio"],
685 &["Dockerfile", "dockerfile", ".dockerignore"],
686 ),
687 ]
692}
693
694pub fn servers_for_file(path: &Path, config: &Config) -> Vec<ServerDef> {
696 let extension = path
697 .extension()
698 .and_then(|ext| ext.to_str())
699 .unwrap_or_default();
700
701 resolved_servers(config)
702 .into_iter()
703 .filter(|server| !is_disabled(server, config))
704 .filter(|server| server.matches_extension(extension))
705 .filter(|server| config.experimental_lsp_ty || server.kind != ServerKind::Ty)
706 .collect()
707}
708
709fn resolved_servers(config: &Config) -> Vec<ServerDef> {
721 let mut servers = builtin_servers();
722 for user in &config.lsp_servers {
723 if user.disabled {
724 servers.retain(|s| s.kind.id_str() != user.id);
728 continue;
729 }
730 if let Some(position) = servers.iter().position(|s| s.kind.id_str() == user.id) {
731 let builtin = &servers[position];
736 let merged = ServerDef {
737 kind: builtin.kind.clone(),
738 name: builtin.name.clone(),
739 extensions: if user.extensions.is_empty() {
740 builtin.extensions.clone()
741 } else {
742 user.extensions.clone()
743 },
744 binary: if user.binary.is_empty() {
745 builtin.binary.clone()
746 } else {
747 user.binary.clone()
748 },
749 args: if user.args.is_empty() {
750 builtin.args.clone()
751 } else {
752 user.args.clone()
753 },
754 root_markers: if user.root_markers.is_empty() {
755 builtin.root_markers.clone()
756 } else {
757 user.root_markers.clone()
758 },
759 priority_root_markers: if user.root_markers.is_empty() {
760 builtin.priority_root_markers.clone()
761 } else {
762 Vec::new()
763 },
764 env: if user.env.is_empty() {
765 builtin.env.clone()
766 } else {
767 user.env.clone()
768 },
769 initialization_options: user
770 .initialization_options
771 .clone()
772 .or_else(|| builtin.initialization_options.clone()),
773 };
774 servers[position] = merged;
775 } else if let Some(def) = custom_server(user) {
776 servers.push(def);
777 }
778 }
779 servers
780}
781
782pub fn is_config_file_path(path: &Path) -> bool {
786 const IGNORED_COMPONENTS: &[&str] = &[
787 "node_modules",
788 "target",
789 "vendor",
790 ".git",
791 "dist",
792 "build",
793 ".next",
794 ".nuxt",
795 "__pycache__",
796 ];
797
798 if path.components().any(|component| {
799 component
800 .as_os_str()
801 .to_str()
802 .is_some_and(|name| IGNORED_COMPONENTS.contains(&name))
803 }) {
804 return false;
805 }
806
807 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
808 return false;
809 };
810
811 const LOCKFILE_NAMES: &[&str] = &[
818 "package-lock.json",
819 "yarn.lock",
820 "pnpm-lock.yaml",
821 "Cargo.lock",
822 "Gemfile.lock",
823 "poetry.lock",
824 "go.sum",
825 "bun.lock",
826 "bun.lockb",
827 ];
828 if LOCKFILE_NAMES.contains(&file_name) {
829 return false;
830 }
831
832 builtin_config_file_names().contains(file_name)
833 || (file_name.starts_with("tsconfig.") && file_name.ends_with(".json"))
834}
835
836pub fn is_config_file_path_with_custom(path: &Path, extra_markers: &[String]) -> bool {
840 if is_config_file_path(path) {
841 return true;
842 }
843 if extra_markers.is_empty() {
844 return false;
845 }
846 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
847 return false;
848 };
849 extra_markers.iter().any(|m| m == file_name)
850}
851
852fn builtin_config_file_names() -> &'static HashSet<String> {
853 static NAMES: OnceLock<HashSet<String>> = OnceLock::new();
854 NAMES.get_or_init(|| {
855 builtin_servers()
856 .into_iter()
857 .flat_map(|server| server.root_markers)
858 .collect()
859 })
860}
861
862fn builtin_server(
863 kind: ServerKind,
864 name: &str,
865 extensions: &[&str],
866 binary: &str,
867 args: &[&str],
868 root_markers: &[&str],
869) -> ServerDef {
870 ServerDef {
871 kind,
872 name: name.to_string(),
873 extensions: strings(extensions),
874 binary: binary.to_string(),
875 args: strings(args),
876 root_markers: strings(root_markers),
877 priority_root_markers: Vec::new(),
878 env: HashMap::new(),
879 initialization_options: None,
880 }
881}
882
883fn builtin_server_with_priority_roots(
886 kind: ServerKind,
887 name: &str,
888 extensions: &[&str],
889 binary: &str,
890 args: &[&str],
891 root_markers: &[&str],
892 priority_root_markers: &[&str],
893) -> ServerDef {
894 let mut def = builtin_server(kind, name, extensions, binary, args, root_markers);
895 def.priority_root_markers = strings(priority_root_markers);
896 def
897}
898
899fn builtin_server_with_init(
900 kind: ServerKind,
901 name: &str,
902 extensions: &[&str],
903 binary: &str,
904 args: &[&str],
905 root_markers: &[&str],
906 initialization_options: serde_json::Value,
907) -> ServerDef {
908 let mut def = builtin_server(kind, name, extensions, binary, args, root_markers);
909 def.initialization_options = Some(initialization_options);
910 def
911}
912
913fn custom_server(server: &UserServerDef) -> Option<ServerDef> {
914 if server.disabled {
915 return None;
916 }
917
918 Some(ServerDef {
919 kind: ServerKind::Custom(Arc::from(server.id.as_str())),
920 name: server.id.clone(),
921 extensions: server.extensions.clone(),
922 binary: server.binary.clone(),
923 args: server.args.clone(),
924 root_markers: server.root_markers.clone(),
925 priority_root_markers: Vec::new(),
926 env: server.env.clone(),
927 initialization_options: server.initialization_options.clone(),
928 })
929}
930
931fn is_disabled(server: &ServerDef, config: &Config) -> bool {
932 config
933 .disabled_lsp
934 .contains(&server.kind.id_str().to_ascii_lowercase())
935}
936
937fn strings(values: &[&str]) -> Vec<String> {
938 values.iter().map(|value| (*value).to_string()).collect()
939}
940
941#[cfg(test)]
942mod tests {
943 use std::path::{Path, PathBuf};
944 use std::sync::Arc;
945
946 use super::{
947 builtin_servers, is_config_file_path, resolve_lsp_binary, resolve_server_binary,
948 servers_for_file, ServerKind,
949 };
950 use crate::config::{Config, UserServerDef};
951
952 fn matching_kinds(path: &str, config: &Config) -> Vec<ServerKind> {
953 servers_for_file(Path::new(path), config)
954 .into_iter()
955 .map(|server| server.kind)
956 .collect()
957 }
958
959 #[test]
960 fn test_servers_for_typescript_file() {
961 let kinds = matching_kinds("/tmp/file.ts", &Config::default());
964 assert!(
965 kinds.contains(&ServerKind::TypeScript),
966 "expected TypeScript in {kinds:?}",
967 );
968 }
969
970 #[test]
971 fn test_is_config_file_path_recognizes_project_graph_configs() {
972 for path in [
974 "/repo/package.json",
975 "/repo/tsconfig.json",
976 "/repo/tsconfig.build.json",
977 "/repo/jsconfig.json",
978 "/repo/pyproject.toml",
979 "/repo/pyrightconfig.json",
980 "/repo/Cargo.toml",
981 "/repo/go.mod",
982 "/repo/biome.json",
983 ] {
984 assert!(
985 is_config_file_path(Path::new(path)),
986 "expected config: {path}"
987 );
988 }
989
990 for path in [
995 "/repo/Cargo.lock",
996 "/repo/go.sum",
997 "/repo/bun.lock",
998 "/repo/bun.lockb",
999 "/repo/package-lock.json",
1000 "/repo/yarn.lock",
1001 "/repo/pnpm-lock.yaml",
1002 ] {
1003 assert!(
1004 !is_config_file_path(Path::new(path)),
1005 "lockfile should be excluded from config-file detection: {path}"
1006 );
1007 }
1008
1009 for path in [
1011 "/repo/tsconfig-json",
1012 "/repo/tsconfig.build.ts",
1013 "/repo/cargo.toml",
1014 "/repo/src/package.json.ts",
1015 ] {
1016 assert!(
1017 !is_config_file_path(Path::new(path)),
1018 "expected non-config: {path}"
1019 );
1020 }
1021 }
1022
1023 #[test]
1024 fn test_typescript_co_servers() {
1025 let kinds = matching_kinds("/tmp/file.ts", &Config::default());
1026 assert!(kinds.contains(&ServerKind::TypeScript));
1027 assert!(kinds.contains(&ServerKind::Biome));
1028 assert!(kinds.contains(&ServerKind::Oxlint));
1029 }
1030
1031 #[test]
1032 fn test_typescript_co_servers_can_be_disabled() {
1033 let mut disabled = std::collections::HashSet::new();
1035 disabled.insert("biome".to_string());
1036 disabled.insert("oxlint".to_string());
1037
1038 let config = Config {
1039 disabled_lsp: disabled,
1040 ..Config::default()
1041 };
1042
1043 assert_eq!(
1044 matching_kinds("/tmp/file.ts", &config),
1045 vec![ServerKind::TypeScript]
1046 );
1047 }
1048
1049 #[test]
1050 fn test_servers_for_python_file() {
1051 assert_eq!(
1052 matching_kinds("/tmp/file.py", &Config::default()),
1053 vec![ServerKind::Python]
1054 );
1055 }
1056
1057 #[test]
1058 fn test_servers_for_rust_file() {
1059 assert_eq!(
1060 matching_kinds("/tmp/file.rs", &Config::default()),
1061 vec![ServerKind::Rust]
1062 );
1063 }
1064
1065 #[test]
1066 fn test_servers_for_go_file() {
1067 assert_eq!(
1068 matching_kinds("/tmp/file.go", &Config::default()),
1069 vec![ServerKind::Go]
1070 );
1071 }
1072
1073 #[test]
1074 fn test_servers_for_unknown_file() {
1075 assert!(matching_kinds("/tmp/file.txt", &Config::default()).is_empty());
1076 }
1077
1078 #[test]
1079 fn test_oxlint_root_markers_exclude_package_json() {
1080 let oxlint = super::builtin_servers()
1087 .into_iter()
1088 .find(|s| s.kind == ServerKind::Oxlint)
1089 .expect("Oxlint server must be registered");
1090
1091 assert!(
1092 !oxlint.root_markers.iter().any(|m| m == "package.json"),
1093 "package.json must not be a root marker for oxlint (got {:?})",
1094 oxlint.root_markers,
1095 );
1096 assert!(
1097 oxlint.root_markers.iter().any(|m| m == ".oxlintrc.json")
1098 || oxlint.root_markers.iter().any(|m| m == ".oxlintrc"),
1099 "expected an oxlint config file in root markers (got {:?})",
1100 oxlint.root_markers,
1101 );
1102 }
1103
1104 #[test]
1105 fn test_tsx_matches_typescript() {
1106 let kinds = matching_kinds("/tmp/file.tsx", &Config::default());
1107 assert!(
1108 kinds.contains(&ServerKind::TypeScript),
1109 "expected TypeScript in {kinds:?}",
1110 );
1111 }
1112
1113 #[test]
1114 fn test_case_insensitive_extension() {
1115 let kinds = matching_kinds("/tmp/file.TS", &Config::default());
1116 assert!(
1117 kinds.contains(&ServerKind::TypeScript),
1118 "expected TypeScript in {kinds:?}",
1119 );
1120 }
1121
1122 #[test]
1123 fn test_bash_and_yaml_builtins() {
1124 assert_eq!(
1125 matching_kinds("/tmp/file.sh", &Config::default()),
1126 vec![ServerKind::Bash]
1127 );
1128 assert_eq!(
1129 matching_kinds("/tmp/file.yaml", &Config::default()),
1130 vec![ServerKind::Yaml]
1131 );
1132 }
1133
1134 #[test]
1135 fn test_ty_requires_experimental_flag() {
1136 assert_eq!(
1137 matching_kinds("/tmp/file.py", &Config::default()),
1138 vec![ServerKind::Python]
1139 );
1140
1141 let config = Config {
1142 experimental_lsp_ty: true,
1143 ..Config::default()
1144 };
1145 assert_eq!(
1146 matching_kinds("/tmp/file.py", &config),
1147 vec![ServerKind::Python, ServerKind::Ty]
1148 );
1149 }
1150
1151 #[test]
1152 fn test_custom_server_matches_extension() {
1153 let config = Config {
1156 lsp_servers: vec![UserServerDef {
1157 id: "my-custom-lsp".to_string(),
1158 extensions: vec!["xyzcustom".to_string()],
1159 binary: "my-custom-lsp".to_string(),
1160 root_markers: vec!["custom.toml".to_string()],
1161 ..UserServerDef::default()
1162 }],
1163 ..Config::default()
1164 };
1165
1166 assert_eq!(
1167 matching_kinds("/tmp/file.xyzcustom", &config),
1168 vec![ServerKind::Custom(Arc::from("my-custom-lsp"))]
1169 );
1170 }
1171
1172 #[test]
1173 fn test_custom_server_coexists_with_builtin_for_same_extension() {
1174 let config = Config {
1177 lsp_servers: vec![UserServerDef {
1178 id: "tinymist-fork".to_string(),
1179 extensions: vec!["typ".to_string()],
1180 binary: "tinymist-fork".to_string(),
1181 root_markers: vec!["typst.toml".to_string()],
1182 ..UserServerDef::default()
1183 }],
1184 ..Config::default()
1185 };
1186
1187 let kinds = matching_kinds("/tmp/file.typ", &config);
1188 assert!(kinds.contains(&ServerKind::Tinymist));
1189 assert!(kinds.contains(&ServerKind::Custom(Arc::from("tinymist-fork"))));
1190 }
1191
1192 #[test]
1193 fn test_pattern_a_servers_register_for_their_extensions() {
1194 let cases: &[(&str, ServerKind)] = &[
1195 ("/tmp/a.clj", ServerKind::Clojure),
1196 ("/tmp/a.dart", ServerKind::Dart),
1197 ("/tmp/a.ex", ServerKind::ElixirLs),
1198 ("/tmp/a.fs", ServerKind::FSharp),
1199 ("/tmp/a.gleam", ServerKind::Gleam),
1200 ("/tmp/a.hs", ServerKind::Haskell),
1201 ("/tmp/A.java", ServerKind::Jdtls),
1202 ("/tmp/a.jl", ServerKind::Julia),
1203 ("/tmp/a.nix", ServerKind::Nixd),
1204 ("/tmp/a.ml", ServerKind::OcamlLsp),
1205 ("/tmp/a.php", ServerKind::PhpIntelephense),
1206 ("/tmp/a.rb", ServerKind::RubyLsp),
1207 ("/tmp/a.swift", ServerKind::SourceKit),
1208 ("/tmp/a.cs", ServerKind::CSharp),
1209 ("/tmp/a.razor", ServerKind::Razor),
1210 ];
1211
1212 for (path, expected) in cases {
1213 let kinds = matching_kinds(path, &Config::default());
1214 assert!(
1215 kinds.contains(expected),
1216 "expected {expected:?} for {path}; got {kinds:?}",
1217 );
1218 }
1219 }
1220
1221 #[test]
1222 fn test_pattern_c_servers_register_for_their_extensions() {
1223 let cases: &[(&str, ServerKind)] = &[
1224 ("/tmp/a.c", ServerKind::Clangd),
1225 ("/tmp/a.cpp", ServerKind::Clangd),
1226 ("/tmp/a.h", ServerKind::Clangd),
1227 ("/tmp/a.lua", ServerKind::LuaLs),
1228 ("/tmp/a.zig", ServerKind::Zls),
1229 ("/tmp/a.typ", ServerKind::Tinymist),
1230 ("/tmp/a.kt", ServerKind::KotlinLs),
1231 ("/tmp/a.tex", ServerKind::Texlab),
1232 ("/tmp/a.tf", ServerKind::TerraformLs),
1233 ];
1234
1235 for (path, expected) in cases {
1236 let kinds = matching_kinds(path, &Config::default());
1237 assert!(
1238 kinds.contains(expected),
1239 "expected {expected:?} for {path}; got {kinds:?}",
1240 );
1241 }
1242 }
1243
1244 #[test]
1245 fn test_pattern_b_d_servers_register_for_their_extensions() {
1246 let cases: &[(&str, ServerKind)] = &[
1247 ("/tmp/a.vue", ServerKind::Vue),
1248 ("/tmp/a.astro", ServerKind::Astro),
1249 ("/tmp/a.prisma", ServerKind::Prisma),
1250 ("/tmp/a.svelte", ServerKind::Svelte),
1251 ("/tmp/a.dockerfile", ServerKind::Dockerfile),
1252 ];
1253
1254 for (path, expected) in cases {
1255 let kinds = matching_kinds(path, &Config::default());
1256 assert!(
1257 kinds.contains(expected),
1258 "expected {expected:?} for {path}; got {kinds:?}",
1259 );
1260 }
1261 }
1262
1263 #[test]
1264 fn test_lsp_disabled_filters_out_servers_by_id() {
1265 let mut disabled = std::collections::HashSet::new();
1266 disabled.insert("clangd".to_string());
1267 disabled.insert("dart".to_string());
1268 disabled.insert("rust".to_string());
1269
1270 let config = Config {
1271 disabled_lsp: disabled,
1272 ..Config::default()
1273 };
1274
1275 let c_kinds = matching_kinds("/tmp/a.c", &config);
1277 assert!(!c_kinds.contains(&ServerKind::Clangd));
1278
1279 let dart_kinds = matching_kinds("/tmp/a.dart", &config);
1280 assert!(!dart_kinds.contains(&ServerKind::Dart));
1281
1282 let rust_kinds = matching_kinds("/tmp/a.rs", &config);
1283 assert!(!rust_kinds.contains(&ServerKind::Rust));
1284
1285 let ts_kinds = matching_kinds("/tmp/a.ts", &config);
1287 assert!(ts_kinds.contains(&ServerKind::TypeScript));
1288 }
1289
1290 #[test]
1291 fn test_server_kind_ids_are_unique() {
1292 use std::collections::HashSet;
1295 let servers = super::builtin_servers();
1296 let ids: Vec<String> = servers
1297 .iter()
1298 .map(|s| s.kind.id_str().to_string())
1299 .collect();
1300 let unique: HashSet<&String> = ids.iter().collect();
1301 assert_eq!(
1302 ids.len(),
1303 unique.len(),
1304 "duplicate server IDs in registry: {ids:?}",
1305 );
1306 }
1307
1308 #[test]
1309 fn user_override_with_matching_id_replaces_builtin_not_appended() {
1310 let config = Config {
1313 lsp_servers: vec![UserServerDef {
1314 id: "clangd".to_string(),
1315 args: vec!["--query-driver=/path/to/arm-none-eabi-*".to_string()],
1316 ..UserServerDef::default()
1317 }],
1318 ..Config::default()
1319 };
1320
1321 let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1322 let clangd_entries: Vec<_> = cpp_servers
1323 .iter()
1324 .filter(|s| s.kind.id_str() == "clangd")
1325 .collect();
1326 assert_eq!(
1327 clangd_entries.len(),
1328 1,
1329 "expected exactly one clangd server after user override; got {} ({:?})",
1330 clangd_entries.len(),
1331 cpp_servers.iter().map(|s| &s.kind).collect::<Vec<_>>()
1332 );
1333
1334 let clangd = clangd_entries[0];
1336 assert_eq!(clangd.args, vec!["--query-driver=/path/to/arm-none-eabi-*"],);
1337
1338 assert!(
1341 !clangd.extensions.is_empty(),
1342 "extensions should inherit from built-in clangd, got empty",
1343 );
1344 assert!(
1345 !clangd.root_markers.is_empty(),
1346 "root_markers should inherit from built-in clangd, got empty",
1347 );
1348 }
1349
1350 #[test]
1351 fn user_override_preserves_builtin_kind_not_custom() {
1352 let config = Config {
1357 lsp_servers: vec![UserServerDef {
1358 id: "clangd".to_string(),
1359 root_markers: vec![".clangd".to_string()],
1360 ..UserServerDef::default()
1361 }],
1362 ..Config::default()
1363 };
1364
1365 let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1366 let clangd = cpp_servers
1367 .iter()
1368 .find(|s| s.kind.id_str() == "clangd")
1369 .expect("clangd entry");
1370 assert!(
1371 matches!(clangd.kind, ServerKind::Clangd),
1372 "merged server must keep ServerKind::Clangd, got {:?}",
1373 clangd.kind,
1374 );
1375 }
1376
1377 #[test]
1378 fn user_override_with_non_matching_id_is_appended_as_custom() {
1379 let config = Config {
1389 lsp_servers: vec![UserServerDef {
1390 id: "custom-clangd".to_string(),
1391 extensions: vec!["c".to_string(), "cpp".to_string()],
1392 binary: "clangd".to_string(),
1393 ..UserServerDef::default()
1394 }],
1395 ..Config::default()
1396 };
1397
1398 let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1399 let kinds: Vec<&ServerKind> = cpp_servers.iter().map(|s| &s.kind).collect();
1400 assert!(
1401 kinds.iter().any(|k| matches!(k, ServerKind::Clangd)),
1402 "built-in clangd should still be present alongside custom-clangd; got {kinds:?}",
1403 );
1404 assert!(
1405 kinds
1406 .iter()
1407 .any(|k| matches!(k, ServerKind::Custom(id) if id.as_ref() == "custom-clangd")),
1408 "custom-clangd should be appended as Custom; got {kinds:?}",
1409 );
1410 }
1411
1412 #[test]
1413 fn user_override_with_disabled_true_drops_builtin() {
1414 let config = Config {
1417 lsp_servers: vec![UserServerDef {
1418 id: "clangd".to_string(),
1419 disabled: true,
1420 ..UserServerDef::default()
1421 }],
1422 ..Config::default()
1423 };
1424
1425 let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1426 assert!(
1427 !cpp_servers.iter().any(|s| s.kind.id_str() == "clangd"),
1428 "disabled user override should drop the built-in; got {:?}",
1429 cpp_servers.iter().map(|s| &s.kind).collect::<Vec<_>>(),
1430 );
1431 }
1432
1433 fn touch_exe(path: &Path) {
1436 if let Some(parent) = path.parent() {
1437 std::fs::create_dir_all(parent).unwrap();
1438 }
1439 std::fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap();
1440 #[cfg(unix)]
1441 {
1442 use std::os::unix::fs::PermissionsExt;
1443 let mut perms = std::fs::metadata(path).unwrap().permissions();
1444 perms.set_mode(0o755);
1445 std::fs::set_permissions(path, perms).unwrap();
1446 }
1447 }
1448
1449 #[test]
1450 fn resolve_lsp_binary_prefers_project_node_modules() {
1451 let tmp = tempfile::tempdir().unwrap();
1452 let project = tmp.path();
1453 let local_bin = project.join("node_modules").join(".bin");
1454 touch_exe(&local_bin.join("typescript-language-server"));
1455
1456 let resolved = resolve_lsp_binary("typescript-language-server", Some(project), &[]);
1457 assert_eq!(
1458 resolved.as_deref(),
1459 Some(local_bin.join("typescript-language-server").as_path())
1460 );
1461 }
1462
1463 #[test]
1464 fn resolve_lsp_binary_falls_back_to_extra_paths() {
1465 let tmp = tempfile::tempdir().unwrap();
1466 let project = tmp.path().join("project");
1467 std::fs::create_dir_all(&project).unwrap();
1468
1469 let extra_a = tmp.path().join("extra_a");
1470 let extra_b = tmp.path().join("extra_b");
1471 std::fs::create_dir_all(&extra_a).unwrap();
1472 std::fs::create_dir_all(&extra_b).unwrap();
1473 touch_exe(&extra_b.join("yaml-language-server"));
1474
1475 let resolved = resolve_lsp_binary(
1476 "yaml-language-server",
1477 Some(&project),
1478 &[extra_a.clone(), extra_b.clone()],
1479 );
1480 assert_eq!(
1481 resolved.as_deref(),
1482 Some(extra_b.join("yaml-language-server").as_path())
1483 );
1484 }
1485
1486 #[test]
1487 fn resolve_lsp_binary_extra_paths_search_in_order() {
1488 let tmp = tempfile::tempdir().unwrap();
1489 let extra_a = tmp.path().join("extra_a");
1490 let extra_b = tmp.path().join("extra_b");
1491 std::fs::create_dir_all(&extra_a).unwrap();
1492 std::fs::create_dir_all(&extra_b).unwrap();
1493 touch_exe(&extra_a.join("bash-language-server"));
1495 touch_exe(&extra_b.join("bash-language-server"));
1496
1497 let resolved = resolve_lsp_binary(
1498 "bash-language-server",
1499 None,
1500 &[extra_a.clone(), extra_b.clone()],
1501 );
1502 assert_eq!(
1503 resolved.as_deref(),
1504 Some(extra_a.join("bash-language-server").as_path())
1505 );
1506 }
1507
1508 #[test]
1509 fn resolve_lsp_binary_project_root_wins_over_extra_paths() {
1510 let tmp = tempfile::tempdir().unwrap();
1511 let project = tmp.path().join("project");
1512 let local_bin = project.join("node_modules").join(".bin");
1513 touch_exe(&local_bin.join("pyright-langserver"));
1514
1515 let extra = tmp.path().join("extra");
1516 std::fs::create_dir_all(&extra).unwrap();
1517 touch_exe(&extra.join("pyright-langserver"));
1518
1519 let resolved = resolve_lsp_binary(
1520 "pyright-langserver",
1521 Some(&project),
1522 std::slice::from_ref(&extra),
1523 );
1524 assert_eq!(
1525 resolved.as_deref(),
1526 Some(local_bin.join("pyright-langserver").as_path())
1527 );
1528 }
1529
1530 #[test]
1531 fn generic_resolver_does_not_probe_project_virtualenv() {
1532 let tmp = tempfile::tempdir().unwrap();
1533 let project = tmp.path().join("project");
1534 let extra = tmp.path().join("extra");
1535 let virtualenv_bin = if cfg!(windows) {
1536 project.join(".venv").join("Scripts")
1537 } else {
1538 project.join(".venv").join("bin")
1539 };
1540 touch_exe(&virtualenv_bin.join("typescript-language-server"));
1541 touch_exe(&extra.join("typescript-language-server"));
1542
1543 let resolved = resolve_lsp_binary(
1544 "typescript-language-server",
1545 Some(&project),
1546 std::slice::from_ref(&extra),
1547 );
1548
1549 assert_eq!(
1550 resolved.as_deref(),
1551 Some(extra.join("typescript-language-server").as_path())
1552 );
1553 }
1554
1555 #[test]
1556 fn python_resolver_prefers_nested_project_virtualenv() {
1557 let tmp = tempfile::tempdir().unwrap();
1558 let repository = tmp.path();
1559 let backend = repository.join("backend");
1560 let virtualenv_bin = if cfg!(windows) {
1561 backend.join(".venv").join("Scripts")
1562 } else {
1563 backend.join(".venv").join("bin")
1564 };
1565 touch_exe(&virtualenv_bin.join("pyright-langserver"));
1566 let cache = repository.join("cache");
1567 touch_exe(&cache.join("pyright-langserver"));
1568 let config = Config {
1569 project_root: Some(repository.to_path_buf()),
1570 lsp_paths_extra: vec![cache],
1571 ..Config::default()
1572 };
1573 let server = builtin_servers()
1574 .into_iter()
1575 .find(|server| server.kind == ServerKind::Python)
1576 .unwrap();
1577
1578 let resolved = resolve_server_binary(&server, Some(&backend), &config);
1579
1580 assert_eq!(
1581 resolved.as_deref(),
1582 Some(virtualenv_bin.join("pyright-langserver").as_path())
1583 );
1584 }
1585
1586 #[test]
1587 fn python_resolver_falls_back_to_project_root_node_modules() {
1588 let tmp = tempfile::tempdir().unwrap();
1589 let project = tmp.path();
1590 let workspace = project.join("backend");
1591 let project_bin = project.join("node_modules").join(".bin");
1592 let hoisted = if cfg!(windows) {
1593 project_bin.join("pyright-langserver.cmd")
1594 } else {
1595 project_bin.join("pyright-langserver")
1596 };
1597 touch_exe(&hoisted);
1598 let server = builtin_servers()
1599 .into_iter()
1600 .find(|server| server.kind == ServerKind::Python)
1601 .unwrap();
1602 let config = Config {
1603 project_root: Some(project.to_path_buf()),
1604 ..Config::default()
1605 };
1606
1607 assert_eq!(
1608 resolve_server_binary(&server, Some(&workspace), &config),
1609 Some(hoisted)
1610 );
1611 }
1612
1613 #[test]
1614 fn python_resolver_prefers_workspace_node_modules_over_project_root() {
1615 let tmp = tempfile::tempdir().unwrap();
1616 let project = tmp.path();
1617 let workspace = project.join("backend");
1618 let workspace_bin = workspace.join("node_modules").join(".bin");
1619 let project_bin = project.join("node_modules").join(".bin");
1620 let binary_name = if cfg!(windows) {
1621 "pyright-langserver.cmd"
1622 } else {
1623 "pyright-langserver"
1624 };
1625 let nested = workspace_bin.join(binary_name);
1626 touch_exe(&nested);
1627 touch_exe(&project_bin.join(binary_name));
1628 let server = builtin_servers()
1629 .into_iter()
1630 .find(|server| server.kind == ServerKind::Python)
1631 .unwrap();
1632 let config = Config {
1633 project_root: Some(project.to_path_buf()),
1634 ..Config::default()
1635 };
1636
1637 assert_eq!(
1638 resolve_server_binary(&server, Some(&workspace), &config),
1639 Some(nested)
1640 );
1641 }
1642
1643 #[test]
1644 fn ty_resolver_prefers_nested_project_virtualenv() {
1645 let tmp = tempfile::tempdir().unwrap();
1646 let repository = tmp.path();
1647 let backend = repository.join("backend");
1648 let virtualenv_bin = if cfg!(windows) {
1649 backend.join(".venv").join("Scripts")
1650 } else {
1651 backend.join(".venv").join("bin")
1652 };
1653 touch_exe(&virtualenv_bin.join("ty"));
1654 let config = Config {
1655 project_root: Some(repository.to_path_buf()),
1656 ..Config::default()
1657 };
1658 let server = builtin_servers()
1659 .into_iter()
1660 .find(|server| server.kind == ServerKind::Ty)
1661 .unwrap();
1662
1663 let resolved = resolve_server_binary(&server, Some(&backend), &config);
1664
1665 assert_eq!(
1666 resolved.as_deref(),
1667 Some(virtualenv_bin.join("ty").as_path())
1668 );
1669 }
1670
1671 #[test]
1672 fn non_python_resolver_keeps_configured_project_root() {
1673 let tmp = tempfile::tempdir().unwrap();
1674 let repository = tmp.path().join("repository");
1675 let backend = repository.join("backend");
1676 let repository_bin = repository.join("node_modules").join(".bin");
1677 let backend_bin = backend.join("node_modules").join(".bin");
1678 touch_exe(&repository_bin.join("typescript-language-server"));
1679 touch_exe(&backend_bin.join("typescript-language-server"));
1680 let config = Config {
1681 project_root: Some(repository.clone()),
1682 ..Config::default()
1683 };
1684 let server = builtin_servers()
1685 .into_iter()
1686 .find(|server| server.kind == ServerKind::TypeScript)
1687 .unwrap();
1688
1689 let resolved = resolve_server_binary(&server, Some(&backend), &config);
1690
1691 assert_eq!(
1692 resolved.as_deref(),
1693 Some(repository_bin.join("typescript-language-server").as_path())
1694 );
1695 }
1696
1697 #[test]
1698 fn non_python_without_project_root_ignores_workspace_node_modules() {
1699 let tmp = tempfile::tempdir().unwrap();
1700 let workspace = tmp.path().join("backend");
1701 let workspace_bin = workspace.join("node_modules").join(".bin");
1702 touch_exe(&workspace_bin.join("typescript-language-server"));
1703 let config = Config::default();
1704 let server = builtin_servers()
1705 .into_iter()
1706 .find(|server| server.kind == ServerKind::TypeScript)
1707 .unwrap();
1708
1709 let resolved = resolve_server_binary(&server, Some(&workspace), &config);
1713 assert!(
1714 resolved.map_or(true, |path| !path.starts_with(&workspace)),
1715 "generic resolution must not adopt the workspace root"
1716 );
1717 }
1718
1719 #[test]
1720 fn python_auto_stays_on_pyright_when_local_ty_exists() {
1721 let tmp = tempfile::tempdir().unwrap();
1722 let project = tmp.path().join("project");
1723 let source = project.join("main.py");
1724 std::fs::create_dir_all(&project).unwrap();
1725 std::fs::write(project.join("pyproject.toml"), "[project]\nname = 'demo'\n").unwrap();
1726 let virtualenv_bin = if cfg!(windows) {
1727 project.join(".venv").join("Scripts")
1728 } else {
1729 project.join(".venv").join("bin")
1730 };
1731 touch_exe(&virtualenv_bin.join("ty"));
1732 let config = Config {
1733 project_root: Some(project.clone()),
1734 ..Config::default()
1735 };
1736
1737 let servers = servers_for_file(&source, &config);
1738
1739 assert_eq!(servers.len(), 1);
1740 assert_eq!(servers[0].kind, ServerKind::Python);
1741 assert_eq!(servers[0].binary, "pyright-langserver");
1742 }
1743
1744 #[test]
1745 fn python_workspace_root_does_not_escape_configured_project() {
1746 let tmp = tempfile::tempdir().unwrap();
1747 let ancestor = tmp.path();
1748 let project = ancestor.join("project");
1749 let source = project.join("src").join("main.py");
1750 std::fs::create_dir_all(source.parent().unwrap()).unwrap();
1751 std::fs::write(&source, "print('ok')\n").unwrap();
1752 std::fs::write(
1753 ancestor.join("pyproject.toml"),
1754 "[project]\nname = 'outside'\n",
1755 )
1756 .unwrap();
1757 let outside_python = if cfg!(windows) {
1758 ancestor.join(".venv").join("Scripts").join("python.exe")
1759 } else {
1760 ancestor.join(".venv").join("bin").join("python")
1761 };
1762 touch_exe(&outside_python);
1763
1764 for kind in [ServerKind::Python, ServerKind::Ty] {
1765 let server = builtin_servers()
1766 .into_iter()
1767 .find(|server| server.kind == kind)
1768 .unwrap();
1769 assert!(
1770 server
1771 .workspace_root_for_file_with_project_root(&source, Some(&project))
1772 .is_none(),
1773 "{kind:?} must not select a marker above project_root"
1774 );
1775 }
1776 }
1777
1778 #[test]
1779 fn resolve_lsp_binary_returns_none_for_missing_binary() {
1780 let tmp = tempfile::tempdir().unwrap();
1781 let project = tmp.path().join("project");
1782 std::fs::create_dir_all(&project).unwrap();
1783
1784 let resolved =
1786 resolve_lsp_binary("aft-test-nonexistent-binary-xyz123", Some(&project), &[]);
1787 assert!(resolved.is_none());
1788 }
1789
1790 #[test]
1791 fn resolve_lsp_binary_handles_missing_node_modules_gracefully() {
1792 let tmp = tempfile::tempdir().unwrap();
1795 let project = tmp.path().join("project");
1796 std::fs::create_dir_all(&project).unwrap();
1797
1798 let extra = tmp.path().join("extra");
1799 std::fs::create_dir_all(&extra).unwrap();
1800 touch_exe(&extra.join("gopls"));
1801
1802 let resolved = resolve_lsp_binary("gopls", Some(&project), std::slice::from_ref(&extra));
1803 assert_eq!(resolved.as_deref(), Some(extra.join("gopls").as_path()));
1804 }
1805
1806 #[test]
1807 fn resolve_lsp_binary_skips_nonexistent_extra_path() {
1808 let tmp = tempfile::tempdir().unwrap();
1809 let missing = tmp.path().join("missing");
1810 let valid = tmp.path().join("valid");
1811 std::fs::create_dir_all(&valid).unwrap();
1812 touch_exe(&valid.join("clangd"));
1813
1814 let resolved = resolve_lsp_binary("clangd", None, &[missing, valid.clone()]);
1815
1816 assert_eq!(resolved.as_deref(), Some(valid.join("clangd").as_path()));
1817 }
1818
1819 #[test]
1820 fn resolve_lsp_binary_skips_file_extra_path() {
1821 let tmp = tempfile::tempdir().unwrap();
1822 let file = tmp.path().join("not-a-dir");
1823 let valid = tmp.path().join("valid");
1824 std::fs::write(&file, "not a directory").unwrap();
1825 std::fs::create_dir_all(&valid).unwrap();
1826 touch_exe(&valid.join("lua-language-server"));
1827
1828 let resolved = resolve_lsp_binary("lua-language-server", None, &[file, valid.clone()]);
1829
1830 assert_eq!(
1831 resolved.as_deref(),
1832 Some(valid.join("lua-language-server").as_path())
1833 );
1834 }
1835
1836 #[test]
1837 fn resolve_lsp_binary_skips_deleted_extra_path() {
1838 let tmp = tempfile::tempdir().unwrap();
1839 let deleted = tmp.path().join("deleted");
1840 let valid = tmp.path().join("valid");
1841 std::fs::create_dir_all(&deleted).unwrap();
1842 std::fs::remove_dir(&deleted).unwrap();
1843 std::fs::create_dir_all(&valid).unwrap();
1844 touch_exe(&valid.join("svelte-language-server"));
1845
1846 let resolved =
1847 resolve_lsp_binary("svelte-language-server", None, &[deleted, valid.clone()]);
1848
1849 assert_eq!(
1850 resolved.as_deref(),
1851 Some(valid.join("svelte-language-server").as_path())
1852 );
1853 }
1854
1855 #[allow(dead_code)]
1858 fn _path_buf_used(_p: PathBuf) {}
1859}