1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use crate::config::{Config, UserServerDef};
6
7pub fn resolve_lsp_binary(
20 binary: &str,
21 project_root: Option<&Path>,
22 extra_paths: &[PathBuf],
23) -> Option<PathBuf> {
24 if let Some(root) = project_root {
26 let local_bin = root.join("node_modules").join(".bin");
27 if let Some(found) = probe_dir(&local_bin, binary) {
28 return Some(found);
29 }
30 }
31
32 for dir in extra_paths {
34 if let Some(found) = probe_dir(dir, binary) {
35 return Some(found);
36 }
37 }
38
39 which::which(binary).ok()
41}
42
43fn probe_dir(dir: &Path, binary: &str) -> Option<PathBuf> {
45 if !dir.is_dir() {
46 return None;
47 }
48
49 let direct = dir.join(binary);
50 if direct.is_file() {
51 return Some(direct);
52 }
53
54 if cfg!(windows) {
55 for ext in ["cmd", "exe", "bat"] {
56 let candidate = dir.join(format!("{binary}.{ext}"));
57 if candidate.is_file() {
58 return Some(candidate);
59 }
60 }
61 }
62
63 None
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Hash)]
71pub enum ServerKind {
72 TypeScript,
74 Python, Rust,
76 Go,
77 Bash,
78 Yaml,
79 Ty, Clojure,
82 Dart,
83 ElixirLs,
84 FSharp,
85 Gleam,
86 Haskell,
87 Jdtls, Julia,
89 Nixd,
90 OcamlLsp,
91 PhpIntelephense,
92 RubyLsp,
93 SourceKit, CSharp,
95 Razor,
96 Clangd,
98 LuaLs,
99 Zls,
100 Tinymist,
101 KotlinLs,
102 Texlab,
103 Oxlint,
104 TerraformLs,
105 Vue,
107 Astro,
108 Prisma, Biome,
110 Svelte,
111 Dockerfile,
112 Custom(Arc<str>),
113}
114
115impl ServerKind {
116 pub fn id_str(&self) -> &str {
117 match self {
118 Self::TypeScript => "typescript",
119 Self::Python => "python",
120 Self::Rust => "rust",
121 Self::Go => "go",
122 Self::Bash => "bash",
123 Self::Yaml => "yaml",
124 Self::Ty => "ty",
125 Self::Clojure => "clojure-lsp",
127 Self::Dart => "dart",
128 Self::ElixirLs => "elixir-ls",
129 Self::FSharp => "fsharp",
130 Self::Gleam => "gleam",
131 Self::Haskell => "haskell-language-server",
132 Self::Jdtls => "jdtls",
133 Self::Julia => "julials",
134 Self::Nixd => "nixd",
135 Self::OcamlLsp => "ocaml-lsp",
136 Self::PhpIntelephense => "php-intelephense",
137 Self::RubyLsp => "ruby-lsp",
138 Self::SourceKit => "sourcekit-lsp",
139 Self::CSharp => "csharp",
140 Self::Razor => "razor",
141 Self::Clangd => "clangd",
143 Self::LuaLs => "lua-ls",
144 Self::Zls => "zls",
145 Self::Tinymist => "tinymist",
146 Self::KotlinLs => "kotlin-ls",
147 Self::Texlab => "texlab",
148 Self::Oxlint => "oxlint",
149 Self::TerraformLs => "terraform",
150 Self::Vue => "vue",
152 Self::Astro => "astro",
153 Self::Prisma => "prisma",
154 Self::Biome => "biome",
155 Self::Svelte => "svelte",
156 Self::Dockerfile => "dockerfile",
157 Self::Custom(id) => id.as_ref(),
158 }
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct ServerDef {
165 pub kind: ServerKind,
166 pub name: String,
168 pub extensions: Vec<String>,
170 pub binary: String,
172 pub args: Vec<String>,
174 pub root_markers: Vec<String>,
176 pub env: HashMap<String, String>,
178 pub initialization_options: Option<serde_json::Value>,
180}
181
182impl ServerDef {
183 pub fn matches_extension(&self, ext: &str) -> bool {
185 self.extensions
186 .iter()
187 .any(|candidate| candidate.eq_ignore_ascii_case(ext))
188 }
189
190 pub fn is_available(&self) -> bool {
192 which::which(&self.binary).is_ok()
193 }
194}
195
196pub fn builtin_servers() -> Vec<ServerDef> {
198 vec![
199 builtin_server(
200 ServerKind::TypeScript,
201 "TypeScript Language Server",
202 &["ts", "tsx", "js", "jsx", "mjs", "cjs"],
203 "typescript-language-server",
204 &["--stdio"],
205 &["tsconfig.json", "jsconfig.json", "package.json"],
206 ),
207 builtin_server(
208 ServerKind::Python,
209 "Pyright",
210 &["py", "pyi"],
211 "pyright-langserver",
212 &["--stdio"],
213 &[
214 "pyproject.toml",
215 "setup.py",
216 "setup.cfg",
217 "pyrightconfig.json",
218 "requirements.txt",
219 ],
220 ),
221 builtin_server(
222 ServerKind::Rust,
223 "rust-analyzer",
224 &["rs"],
225 "rust-analyzer",
226 &[],
227 &["Cargo.toml"],
228 ),
229 builtin_server_with_init(
234 ServerKind::Go,
235 "gopls",
236 &["go"],
237 "gopls",
238 &["serve"],
239 &["go.mod"],
240 serde_json::json!({ "pullDiagnostics": true }),
241 ),
242 builtin_server(
243 ServerKind::Bash,
244 "bash-language-server",
245 &["sh", "bash", "zsh"],
246 "bash-language-server",
247 &["start"],
248 &["package.json", ".git"],
249 ),
250 builtin_server(
251 ServerKind::Yaml,
252 "yaml-language-server",
253 &["yaml", "yml"],
254 "yaml-language-server",
255 &["--stdio"],
256 &["package.json", ".git"],
257 ),
258 builtin_server(
259 ServerKind::Ty,
260 "ty",
261 &["py", "pyi"],
262 "ty",
263 &["server"],
264 &[
265 "pyproject.toml",
266 "ty.toml",
267 "setup.py",
268 "setup.cfg",
269 "requirements.txt",
270 "Pipfile",
271 "pyrightconfig.json",
272 ],
273 ),
274 builtin_server(
281 ServerKind::Clojure,
282 "clojure-lsp",
283 &["clj", "cljs", "cljc", "edn"],
284 "clojure-lsp",
285 &[],
286 &[
287 "deps.edn",
288 "project.clj",
289 "shadow-cljs.edn",
290 "bb.edn",
291 "build.boot",
292 ],
293 ),
294 builtin_server(
295 ServerKind::Dart,
296 "Dart Language Server",
297 &["dart"],
298 "dart",
299 &["language-server", "--lsp"],
300 &["pubspec.yaml", "analysis_options.yaml"],
301 ),
302 builtin_server(
303 ServerKind::ElixirLs,
304 "elixir-ls",
305 &["ex", "exs"],
306 "elixir-ls",
307 &[],
308 &["mix.exs", "mix.lock"],
309 ),
310 builtin_server(
311 ServerKind::FSharp,
312 "FSAutoComplete",
313 &["fs", "fsi", "fsx", "fsscript"],
314 "fsautocomplete",
315 &[],
316 &[".slnx", ".sln", ".fsproj", "global.json"],
317 ),
318 builtin_server(
319 ServerKind::Gleam,
320 "Gleam Language Server",
321 &["gleam"],
322 "gleam",
323 &["lsp"],
324 &["gleam.toml"],
325 ),
326 builtin_server(
327 ServerKind::Haskell,
328 "haskell-language-server",
329 &["hs", "lhs"],
330 "haskell-language-server-wrapper",
331 &["--lsp"],
332 &["stack.yaml", "cabal.project", "hie.yaml"],
333 ),
334 builtin_server(
335 ServerKind::Jdtls,
336 "Eclipse JDT Language Server",
337 &["java"],
338 "jdtls",
339 &[],
340 &["pom.xml", "build.gradle", "build.gradle.kts", ".project"],
341 ),
342 builtin_server(
343 ServerKind::Julia,
344 "Julia Language Server",
345 &["jl"],
346 "julia",
347 &[
348 "--startup-file=no",
349 "--history-file=no",
350 "-e",
351 "using LanguageServer; runserver()",
352 ],
353 &["Project.toml", "Manifest.toml"],
354 ),
355 builtin_server(
356 ServerKind::Nixd,
357 "nixd",
358 &["nix"],
359 "nixd",
360 &[],
361 &["flake.nix", "default.nix", "shell.nix"],
362 ),
363 builtin_server(
364 ServerKind::OcamlLsp,
365 "ocaml-lsp",
366 &["ml", "mli"],
367 "ocamllsp",
368 &[],
369 &["dune-project", "dune-workspace", ".merlin", "opam"],
370 ),
371 builtin_server(
372 ServerKind::PhpIntelephense,
373 "Intelephense",
374 &["php"],
375 "intelephense",
376 &["--stdio"],
377 &["composer.json", "composer.lock", ".php-version"],
378 ),
379 builtin_server(
380 ServerKind::RubyLsp,
381 "ruby-lsp",
382 &["rb", "rake", "gemspec", "ru"],
383 "ruby-lsp",
384 &[],
385 &["Gemfile"],
386 ),
387 builtin_server(
388 ServerKind::SourceKit,
389 "SourceKit-LSP",
390 &["swift"],
391 "sourcekit-lsp",
392 &[],
393 &["Package.swift"],
394 ),
395 builtin_server(
396 ServerKind::CSharp,
397 "Roslyn Language Server",
398 &["cs", "csx"],
399 "roslyn-language-server",
400 &[],
401 &[".slnx", ".sln", ".csproj", "global.json"],
402 ),
403 builtin_server(
404 ServerKind::Razor,
405 "rzls",
406 &["razor", "cshtml"],
407 "rzls",
408 &[],
409 &[".slnx", ".sln", ".csproj", "global.json"],
410 ),
411 builtin_server(
413 ServerKind::Clangd,
414 "clangd",
415 &[
416 "c", "cpp", "cc", "cxx", "c++", "h", "hpp", "hh", "hxx", "h++",
417 ],
418 "clangd",
419 &[],
420 &["compile_commands.json", "compile_flags.txt", ".clangd"],
421 ),
422 builtin_server(
423 ServerKind::LuaLs,
424 "lua-language-server",
425 &["lua"],
426 "lua-language-server",
427 &[],
428 &[".luarc.json", ".luarc.jsonc", ".stylua.toml", "stylua.toml"],
429 ),
430 builtin_server(
431 ServerKind::Zls,
432 "zls",
433 &["zig", "zon"],
434 "zls",
435 &[],
436 &["build.zig"],
437 ),
438 builtin_server(
439 ServerKind::Tinymist,
440 "tinymist",
441 &["typ", "typc"],
442 "tinymist",
443 &[],
444 &["typst.toml"],
445 ),
446 builtin_server(
447 ServerKind::KotlinLs,
448 "kotlin-language-server",
449 &["kt", "kts"],
450 "kotlin-language-server",
451 &[],
452 &["settings.gradle", "settings.gradle.kts", "build.gradle"],
453 ),
454 builtin_server(
455 ServerKind::Texlab,
456 "texlab",
457 &["tex", "bib"],
458 "texlab",
459 &[],
460 &[".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"],
461 ),
462 builtin_server(
463 ServerKind::Oxlint,
464 "oxc-language-server",
465 &[
467 "ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "vue", "astro", "svelte",
468 ],
469 "oxc-language-server",
470 &[],
471 &[".oxlintrc.json", ".oxlintrc"],
478 ),
479 builtin_server(
480 ServerKind::TerraformLs,
481 "terraform-ls",
482 &["tf", "tfvars"],
483 "terraform-ls",
484 &["serve"],
485 &[".terraform.lock.hcl", "terraform.tfstate"],
486 ),
487 builtin_server(
493 ServerKind::Vue,
494 "Vue Language Server",
495 &["vue"],
496 "vue-language-server",
497 &["--stdio"],
498 &[
499 "package-lock.json",
500 "bun.lockb",
501 "bun.lock",
502 "pnpm-lock.yaml",
503 "yarn.lock",
504 ],
505 ),
506 builtin_server(
507 ServerKind::Astro,
508 "Astro Language Server",
509 &["astro"],
510 "astro-ls",
511 &["--stdio"],
512 &[
513 "package-lock.json",
514 "bun.lockb",
515 "bun.lock",
516 "pnpm-lock.yaml",
517 "yarn.lock",
518 ],
519 ),
520 builtin_server(
525 ServerKind::Prisma,
526 "Prisma Language Server",
527 &["prisma"],
528 "prisma",
529 &["language-server"],
530 &["schema.prisma", "package.json"],
531 ),
532 builtin_server(
536 ServerKind::Biome,
537 "Biome",
538 &[
539 "ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "json", "jsonc",
540 ],
541 "biome",
542 &["lsp-proxy"],
543 &["biome.json", "biome.jsonc"],
544 ),
545 builtin_server(
546 ServerKind::Svelte,
547 "Svelte Language Server",
548 &["svelte"],
549 "svelteserver",
550 &["--stdio"],
551 &[
552 "package-lock.json",
553 "bun.lockb",
554 "bun.lock",
555 "pnpm-lock.yaml",
556 "yarn.lock",
557 ],
558 ),
559 builtin_server(
560 ServerKind::Dockerfile,
561 "Dockerfile Language Server",
562 &["dockerfile"],
568 "docker-langserver",
569 &["--stdio"],
570 &["Dockerfile", "dockerfile", ".dockerignore"],
571 ),
572 ]
577}
578
579pub fn servers_for_file(path: &Path, config: &Config) -> Vec<ServerDef> {
581 let extension = path
582 .extension()
583 .and_then(|ext| ext.to_str())
584 .unwrap_or_default();
585
586 builtin_servers()
587 .into_iter()
588 .chain(config.lsp_servers.iter().filter_map(custom_server))
589 .filter(|server| !is_disabled(server, config))
590 .filter(|server| config.experimental_lsp_ty || server.kind != ServerKind::Ty)
591 .filter(|server| server.matches_extension(extension))
592 .collect()
593}
594
595fn builtin_server(
596 kind: ServerKind,
597 name: &str,
598 extensions: &[&str],
599 binary: &str,
600 args: &[&str],
601 root_markers: &[&str],
602) -> ServerDef {
603 ServerDef {
604 kind,
605 name: name.to_string(),
606 extensions: strings(extensions),
607 binary: binary.to_string(),
608 args: strings(args),
609 root_markers: strings(root_markers),
610 env: HashMap::new(),
611 initialization_options: None,
612 }
613}
614
615fn builtin_server_with_init(
619 kind: ServerKind,
620 name: &str,
621 extensions: &[&str],
622 binary: &str,
623 args: &[&str],
624 root_markers: &[&str],
625 initialization_options: serde_json::Value,
626) -> ServerDef {
627 let mut def = builtin_server(kind, name, extensions, binary, args, root_markers);
628 def.initialization_options = Some(initialization_options);
629 def
630}
631
632fn custom_server(server: &UserServerDef) -> Option<ServerDef> {
633 if server.disabled {
634 return None;
635 }
636
637 Some(ServerDef {
638 kind: ServerKind::Custom(Arc::from(server.id.as_str())),
639 name: server.id.clone(),
640 extensions: server.extensions.clone(),
641 binary: server.binary.clone(),
642 args: server.args.clone(),
643 root_markers: server.root_markers.clone(),
644 env: server.env.clone(),
645 initialization_options: server.initialization_options.clone(),
646 })
647}
648
649fn is_disabled(server: &ServerDef, config: &Config) -> bool {
650 config
651 .disabled_lsp
652 .contains(&server.kind.id_str().to_ascii_lowercase())
653}
654
655fn strings(values: &[&str]) -> Vec<String> {
656 values.iter().map(|value| (*value).to_string()).collect()
657}
658
659#[cfg(test)]
660mod tests {
661 use std::path::{Path, PathBuf};
662 use std::sync::Arc;
663
664 use crate::config::{Config, UserServerDef};
665
666 use super::{resolve_lsp_binary, servers_for_file, ServerKind};
667
668 fn matching_kinds(path: &str, config: &Config) -> Vec<ServerKind> {
669 servers_for_file(Path::new(path), config)
670 .into_iter()
671 .map(|server| server.kind)
672 .collect()
673 }
674
675 #[test]
676 fn test_servers_for_typescript_file() {
677 let kinds = matching_kinds("/tmp/file.ts", &Config::default());
680 assert!(
681 kinds.contains(&ServerKind::TypeScript),
682 "expected TypeScript in {kinds:?}",
683 );
684 }
685
686 #[test]
687 fn test_typescript_co_servers() {
688 let kinds = matching_kinds("/tmp/file.ts", &Config::default());
689 assert!(kinds.contains(&ServerKind::TypeScript));
690 assert!(kinds.contains(&ServerKind::Biome));
691 assert!(kinds.contains(&ServerKind::Oxlint));
692 }
693
694 #[test]
695 fn test_typescript_co_servers_can_be_disabled() {
696 let mut disabled = std::collections::HashSet::new();
698 disabled.insert("biome".to_string());
699 disabled.insert("oxlint".to_string());
700
701 let config = Config {
702 disabled_lsp: disabled,
703 ..Config::default()
704 };
705
706 assert_eq!(
707 matching_kinds("/tmp/file.ts", &config),
708 vec![ServerKind::TypeScript]
709 );
710 }
711
712 #[test]
713 fn test_servers_for_python_file() {
714 assert_eq!(
715 matching_kinds("/tmp/file.py", &Config::default()),
716 vec![ServerKind::Python]
717 );
718 }
719
720 #[test]
721 fn test_servers_for_rust_file() {
722 assert_eq!(
723 matching_kinds("/tmp/file.rs", &Config::default()),
724 vec![ServerKind::Rust]
725 );
726 }
727
728 #[test]
729 fn test_servers_for_go_file() {
730 assert_eq!(
731 matching_kinds("/tmp/file.go", &Config::default()),
732 vec![ServerKind::Go]
733 );
734 }
735
736 #[test]
737 fn test_servers_for_unknown_file() {
738 assert!(matching_kinds("/tmp/file.txt", &Config::default()).is_empty());
739 }
740
741 #[test]
742 fn test_oxlint_root_markers_exclude_package_json() {
743 let oxlint = super::builtin_servers()
750 .into_iter()
751 .find(|s| s.kind == ServerKind::Oxlint)
752 .expect("Oxlint server must be registered");
753
754 assert!(
755 !oxlint.root_markers.iter().any(|m| m == "package.json"),
756 "package.json must not be a root marker for oxlint (got {:?})",
757 oxlint.root_markers,
758 );
759 assert!(
760 oxlint.root_markers.iter().any(|m| m == ".oxlintrc.json")
761 || oxlint.root_markers.iter().any(|m| m == ".oxlintrc"),
762 "expected an oxlint config file in root markers (got {:?})",
763 oxlint.root_markers,
764 );
765 }
766
767 #[test]
768 fn test_tsx_matches_typescript() {
769 let kinds = matching_kinds("/tmp/file.tsx", &Config::default());
770 assert!(
771 kinds.contains(&ServerKind::TypeScript),
772 "expected TypeScript in {kinds:?}",
773 );
774 }
775
776 #[test]
777 fn test_case_insensitive_extension() {
778 let kinds = matching_kinds("/tmp/file.TS", &Config::default());
779 assert!(
780 kinds.contains(&ServerKind::TypeScript),
781 "expected TypeScript in {kinds:?}",
782 );
783 }
784
785 #[test]
786 fn test_bash_and_yaml_builtins() {
787 assert_eq!(
788 matching_kinds("/tmp/file.sh", &Config::default()),
789 vec![ServerKind::Bash]
790 );
791 assert_eq!(
792 matching_kinds("/tmp/file.yaml", &Config::default()),
793 vec![ServerKind::Yaml]
794 );
795 }
796
797 #[test]
798 fn test_ty_requires_experimental_flag() {
799 assert_eq!(
800 matching_kinds("/tmp/file.py", &Config::default()),
801 vec![ServerKind::Python]
802 );
803
804 let config = Config {
805 experimental_lsp_ty: true,
806 ..Config::default()
807 };
808 assert_eq!(
809 matching_kinds("/tmp/file.py", &config),
810 vec![ServerKind::Python, ServerKind::Ty]
811 );
812 }
813
814 #[test]
815 fn test_custom_server_matches_extension() {
816 let config = Config {
819 lsp_servers: vec![UserServerDef {
820 id: "my-custom-lsp".to_string(),
821 extensions: vec!["xyzcustom".to_string()],
822 binary: "my-custom-lsp".to_string(),
823 root_markers: vec!["custom.toml".to_string()],
824 ..UserServerDef::default()
825 }],
826 ..Config::default()
827 };
828
829 assert_eq!(
830 matching_kinds("/tmp/file.xyzcustom", &config),
831 vec![ServerKind::Custom(Arc::from("my-custom-lsp"))]
832 );
833 }
834
835 #[test]
836 fn test_custom_server_coexists_with_builtin_for_same_extension() {
837 let config = Config {
840 lsp_servers: vec![UserServerDef {
841 id: "tinymist-fork".to_string(),
842 extensions: vec!["typ".to_string()],
843 binary: "tinymist-fork".to_string(),
844 root_markers: vec!["typst.toml".to_string()],
845 ..UserServerDef::default()
846 }],
847 ..Config::default()
848 };
849
850 let kinds = matching_kinds("/tmp/file.typ", &config);
851 assert!(kinds.contains(&ServerKind::Tinymist));
852 assert!(kinds.contains(&ServerKind::Custom(Arc::from("tinymist-fork"))));
853 }
854
855 #[test]
856 fn test_pattern_a_servers_register_for_their_extensions() {
857 let cases: &[(&str, ServerKind)] = &[
858 ("/tmp/a.clj", ServerKind::Clojure),
859 ("/tmp/a.dart", ServerKind::Dart),
860 ("/tmp/a.ex", ServerKind::ElixirLs),
861 ("/tmp/a.fs", ServerKind::FSharp),
862 ("/tmp/a.gleam", ServerKind::Gleam),
863 ("/tmp/a.hs", ServerKind::Haskell),
864 ("/tmp/A.java", ServerKind::Jdtls),
865 ("/tmp/a.jl", ServerKind::Julia),
866 ("/tmp/a.nix", ServerKind::Nixd),
867 ("/tmp/a.ml", ServerKind::OcamlLsp),
868 ("/tmp/a.php", ServerKind::PhpIntelephense),
869 ("/tmp/a.rb", ServerKind::RubyLsp),
870 ("/tmp/a.swift", ServerKind::SourceKit),
871 ("/tmp/a.cs", ServerKind::CSharp),
872 ("/tmp/a.razor", ServerKind::Razor),
873 ];
874
875 for (path, expected) in cases {
876 let kinds = matching_kinds(path, &Config::default());
877 assert!(
878 kinds.contains(expected),
879 "expected {expected:?} for {path}; got {kinds:?}",
880 );
881 }
882 }
883
884 #[test]
885 fn test_pattern_c_servers_register_for_their_extensions() {
886 let cases: &[(&str, ServerKind)] = &[
887 ("/tmp/a.c", ServerKind::Clangd),
888 ("/tmp/a.cpp", ServerKind::Clangd),
889 ("/tmp/a.h", ServerKind::Clangd),
890 ("/tmp/a.lua", ServerKind::LuaLs),
891 ("/tmp/a.zig", ServerKind::Zls),
892 ("/tmp/a.typ", ServerKind::Tinymist),
893 ("/tmp/a.kt", ServerKind::KotlinLs),
894 ("/tmp/a.tex", ServerKind::Texlab),
895 ("/tmp/a.tf", ServerKind::TerraformLs),
896 ];
897
898 for (path, expected) in cases {
899 let kinds = matching_kinds(path, &Config::default());
900 assert!(
901 kinds.contains(expected),
902 "expected {expected:?} for {path}; got {kinds:?}",
903 );
904 }
905 }
906
907 #[test]
908 fn test_pattern_b_d_servers_register_for_their_extensions() {
909 let cases: &[(&str, ServerKind)] = &[
910 ("/tmp/a.vue", ServerKind::Vue),
911 ("/tmp/a.astro", ServerKind::Astro),
912 ("/tmp/a.prisma", ServerKind::Prisma),
913 ("/tmp/a.svelte", ServerKind::Svelte),
914 ("/tmp/a.dockerfile", ServerKind::Dockerfile),
915 ];
916
917 for (path, expected) in cases {
918 let kinds = matching_kinds(path, &Config::default());
919 assert!(
920 kinds.contains(expected),
921 "expected {expected:?} for {path}; got {kinds:?}",
922 );
923 }
924 }
925
926 #[test]
927 fn test_lsp_disabled_filters_out_servers_by_id() {
928 let mut disabled = std::collections::HashSet::new();
929 disabled.insert("clangd".to_string());
930 disabled.insert("dart".to_string());
931 disabled.insert("rust".to_string());
932
933 let config = Config {
934 disabled_lsp: disabled,
935 ..Config::default()
936 };
937
938 let c_kinds = matching_kinds("/tmp/a.c", &config);
940 assert!(!c_kinds.contains(&ServerKind::Clangd));
941
942 let dart_kinds = matching_kinds("/tmp/a.dart", &config);
943 assert!(!dart_kinds.contains(&ServerKind::Dart));
944
945 let rust_kinds = matching_kinds("/tmp/a.rs", &config);
946 assert!(!rust_kinds.contains(&ServerKind::Rust));
947
948 let ts_kinds = matching_kinds("/tmp/a.ts", &config);
950 assert!(ts_kinds.contains(&ServerKind::TypeScript));
951 }
952
953 #[test]
954 fn test_server_kind_ids_are_unique() {
955 use std::collections::HashSet;
958 let servers = super::builtin_servers();
959 let ids: Vec<String> = servers
960 .iter()
961 .map(|s| s.kind.id_str().to_string())
962 .collect();
963 let unique: HashSet<&String> = ids.iter().collect();
964 assert_eq!(
965 ids.len(),
966 unique.len(),
967 "duplicate server IDs in registry: {ids:?}",
968 );
969 }
970
971 fn touch_exe(path: &Path) {
974 if let Some(parent) = path.parent() {
975 std::fs::create_dir_all(parent).unwrap();
976 }
977 std::fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap();
978 #[cfg(unix)]
979 {
980 use std::os::unix::fs::PermissionsExt;
981 let mut perms = std::fs::metadata(path).unwrap().permissions();
982 perms.set_mode(0o755);
983 std::fs::set_permissions(path, perms).unwrap();
984 }
985 }
986
987 #[test]
988 fn resolve_lsp_binary_prefers_project_node_modules() {
989 let tmp = tempfile::tempdir().unwrap();
990 let project = tmp.path();
991 let local_bin = project.join("node_modules").join(".bin");
992 touch_exe(&local_bin.join("typescript-language-server"));
993
994 let resolved = resolve_lsp_binary("typescript-language-server", Some(project), &[]);
995 assert_eq!(
996 resolved.as_deref(),
997 Some(local_bin.join("typescript-language-server").as_path())
998 );
999 }
1000
1001 #[test]
1002 fn resolve_lsp_binary_falls_back_to_extra_paths() {
1003 let tmp = tempfile::tempdir().unwrap();
1004 let project = tmp.path().join("project");
1005 std::fs::create_dir_all(&project).unwrap();
1006
1007 let extra_a = tmp.path().join("extra_a");
1008 let extra_b = tmp.path().join("extra_b");
1009 std::fs::create_dir_all(&extra_a).unwrap();
1010 std::fs::create_dir_all(&extra_b).unwrap();
1011 touch_exe(&extra_b.join("yaml-language-server"));
1012
1013 let resolved = resolve_lsp_binary(
1014 "yaml-language-server",
1015 Some(&project),
1016 &[extra_a.clone(), extra_b.clone()],
1017 );
1018 assert_eq!(
1019 resolved.as_deref(),
1020 Some(extra_b.join("yaml-language-server").as_path())
1021 );
1022 }
1023
1024 #[test]
1025 fn resolve_lsp_binary_extra_paths_search_in_order() {
1026 let tmp = tempfile::tempdir().unwrap();
1027 let extra_a = tmp.path().join("extra_a");
1028 let extra_b = tmp.path().join("extra_b");
1029 std::fs::create_dir_all(&extra_a).unwrap();
1030 std::fs::create_dir_all(&extra_b).unwrap();
1031 touch_exe(&extra_a.join("bash-language-server"));
1033 touch_exe(&extra_b.join("bash-language-server"));
1034
1035 let resolved = resolve_lsp_binary(
1036 "bash-language-server",
1037 None,
1038 &[extra_a.clone(), extra_b.clone()],
1039 );
1040 assert_eq!(
1041 resolved.as_deref(),
1042 Some(extra_a.join("bash-language-server").as_path())
1043 );
1044 }
1045
1046 #[test]
1047 fn resolve_lsp_binary_project_root_wins_over_extra_paths() {
1048 let tmp = tempfile::tempdir().unwrap();
1049 let project = tmp.path().join("project");
1050 let local_bin = project.join("node_modules").join(".bin");
1051 touch_exe(&local_bin.join("pyright-langserver"));
1052
1053 let extra = tmp.path().join("extra");
1054 std::fs::create_dir_all(&extra).unwrap();
1055 touch_exe(&extra.join("pyright-langserver"));
1056
1057 let resolved = resolve_lsp_binary("pyright-langserver", Some(&project), &[extra.clone()]);
1058 assert_eq!(
1059 resolved.as_deref(),
1060 Some(local_bin.join("pyright-langserver").as_path())
1061 );
1062 }
1063
1064 #[test]
1065 fn resolve_lsp_binary_returns_none_for_missing_binary() {
1066 let tmp = tempfile::tempdir().unwrap();
1067 let project = tmp.path().join("project");
1068 std::fs::create_dir_all(&project).unwrap();
1069
1070 let resolved =
1072 resolve_lsp_binary("aft-test-nonexistent-binary-xyz123", Some(&project), &[]);
1073 assert!(resolved.is_none());
1074 }
1075
1076 #[test]
1077 fn resolve_lsp_binary_handles_missing_node_modules_gracefully() {
1078 let tmp = tempfile::tempdir().unwrap();
1081 let project = tmp.path().join("project");
1082 std::fs::create_dir_all(&project).unwrap();
1083
1084 let extra = tmp.path().join("extra");
1085 std::fs::create_dir_all(&extra).unwrap();
1086 touch_exe(&extra.join("gopls"));
1087
1088 let resolved = resolve_lsp_binary("gopls", Some(&project), &[extra.clone()]);
1089 assert_eq!(resolved.as_deref(), Some(extra.join("gopls").as_path()));
1090 }
1091
1092 #[test]
1093 fn resolve_lsp_binary_skips_nonexistent_extra_path() {
1094 let tmp = tempfile::tempdir().unwrap();
1095 let missing = tmp.path().join("missing");
1096 let valid = tmp.path().join("valid");
1097 std::fs::create_dir_all(&valid).unwrap();
1098 touch_exe(&valid.join("clangd"));
1099
1100 let resolved = resolve_lsp_binary("clangd", None, &[missing, valid.clone()]);
1101
1102 assert_eq!(resolved.as_deref(), Some(valid.join("clangd").as_path()));
1103 }
1104
1105 #[test]
1106 fn resolve_lsp_binary_skips_file_extra_path() {
1107 let tmp = tempfile::tempdir().unwrap();
1108 let file = tmp.path().join("not-a-dir");
1109 let valid = tmp.path().join("valid");
1110 std::fs::write(&file, "not a directory").unwrap();
1111 std::fs::create_dir_all(&valid).unwrap();
1112 touch_exe(&valid.join("lua-language-server"));
1113
1114 let resolved = resolve_lsp_binary("lua-language-server", None, &[file, valid.clone()]);
1115
1116 assert_eq!(
1117 resolved.as_deref(),
1118 Some(valid.join("lua-language-server").as_path())
1119 );
1120 }
1121
1122 #[test]
1123 fn resolve_lsp_binary_skips_deleted_extra_path() {
1124 let tmp = tempfile::tempdir().unwrap();
1125 let deleted = tmp.path().join("deleted");
1126 let valid = tmp.path().join("valid");
1127 std::fs::create_dir_all(&deleted).unwrap();
1128 std::fs::remove_dir(&deleted).unwrap();
1129 std::fs::create_dir_all(&valid).unwrap();
1130 touch_exe(&valid.join("svelte-language-server"));
1131
1132 let resolved =
1133 resolve_lsp_binary("svelte-language-server", None, &[deleted, valid.clone()]);
1134
1135 assert_eq!(
1136 resolved.as_deref(),
1137 Some(valid.join("svelte-language-server").as_path())
1138 );
1139 }
1140
1141 #[allow(dead_code)]
1144 fn _path_buf_used(_p: PathBuf) {}
1145}