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 &["package.json", ".oxlintrc.json", ".oxlintrc"],
472 ),
473 builtin_server(
474 ServerKind::TerraformLs,
475 "terraform-ls",
476 &["tf", "tfvars"],
477 "terraform-ls",
478 &["serve"],
479 &[".terraform.lock.hcl", "terraform.tfstate"],
480 ),
481 builtin_server(
487 ServerKind::Vue,
488 "Vue Language Server",
489 &["vue"],
490 "vue-language-server",
491 &["--stdio"],
492 &[
493 "package-lock.json",
494 "bun.lockb",
495 "bun.lock",
496 "pnpm-lock.yaml",
497 "yarn.lock",
498 ],
499 ),
500 builtin_server(
501 ServerKind::Astro,
502 "Astro Language Server",
503 &["astro"],
504 "astro-ls",
505 &["--stdio"],
506 &[
507 "package-lock.json",
508 "bun.lockb",
509 "bun.lock",
510 "pnpm-lock.yaml",
511 "yarn.lock",
512 ],
513 ),
514 builtin_server(
519 ServerKind::Prisma,
520 "Prisma Language Server",
521 &["prisma"],
522 "prisma",
523 &["language-server"],
524 &["schema.prisma", "package.json"],
525 ),
526 builtin_server(
530 ServerKind::Biome,
531 "Biome",
532 &[
533 "ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "json", "jsonc",
534 ],
535 "biome",
536 &["lsp-proxy"],
537 &["biome.json", "biome.jsonc"],
538 ),
539 builtin_server(
540 ServerKind::Svelte,
541 "Svelte Language Server",
542 &["svelte"],
543 "svelteserver",
544 &["--stdio"],
545 &[
546 "package-lock.json",
547 "bun.lockb",
548 "bun.lock",
549 "pnpm-lock.yaml",
550 "yarn.lock",
551 ],
552 ),
553 builtin_server(
554 ServerKind::Dockerfile,
555 "Dockerfile Language Server",
556 &["dockerfile"],
562 "docker-langserver",
563 &["--stdio"],
564 &["Dockerfile", "dockerfile", ".dockerignore"],
565 ),
566 ]
571}
572
573pub fn servers_for_file(path: &Path, config: &Config) -> Vec<ServerDef> {
575 let extension = path
576 .extension()
577 .and_then(|ext| ext.to_str())
578 .unwrap_or_default();
579
580 builtin_servers()
581 .into_iter()
582 .chain(config.lsp_servers.iter().filter_map(custom_server))
583 .filter(|server| !is_disabled(server, config))
584 .filter(|server| config.experimental_lsp_ty || server.kind != ServerKind::Ty)
585 .filter(|server| server.matches_extension(extension))
586 .collect()
587}
588
589fn builtin_server(
590 kind: ServerKind,
591 name: &str,
592 extensions: &[&str],
593 binary: &str,
594 args: &[&str],
595 root_markers: &[&str],
596) -> ServerDef {
597 ServerDef {
598 kind,
599 name: name.to_string(),
600 extensions: strings(extensions),
601 binary: binary.to_string(),
602 args: strings(args),
603 root_markers: strings(root_markers),
604 env: HashMap::new(),
605 initialization_options: None,
606 }
607}
608
609fn builtin_server_with_init(
613 kind: ServerKind,
614 name: &str,
615 extensions: &[&str],
616 binary: &str,
617 args: &[&str],
618 root_markers: &[&str],
619 initialization_options: serde_json::Value,
620) -> ServerDef {
621 let mut def = builtin_server(kind, name, extensions, binary, args, root_markers);
622 def.initialization_options = Some(initialization_options);
623 def
624}
625
626fn custom_server(server: &UserServerDef) -> Option<ServerDef> {
627 if server.disabled {
628 return None;
629 }
630
631 Some(ServerDef {
632 kind: ServerKind::Custom(Arc::from(server.id.as_str())),
633 name: server.id.clone(),
634 extensions: server.extensions.clone(),
635 binary: server.binary.clone(),
636 args: server.args.clone(),
637 root_markers: server.root_markers.clone(),
638 env: server.env.clone(),
639 initialization_options: server.initialization_options.clone(),
640 })
641}
642
643fn is_disabled(server: &ServerDef, config: &Config) -> bool {
644 config
645 .disabled_lsp
646 .contains(&server.kind.id_str().to_ascii_lowercase())
647}
648
649fn strings(values: &[&str]) -> Vec<String> {
650 values.iter().map(|value| (*value).to_string()).collect()
651}
652
653#[cfg(test)]
654mod tests {
655 use std::path::{Path, PathBuf};
656 use std::sync::Arc;
657
658 use crate::config::{Config, UserServerDef};
659
660 use super::{resolve_lsp_binary, servers_for_file, ServerKind};
661
662 fn matching_kinds(path: &str, config: &Config) -> Vec<ServerKind> {
663 servers_for_file(Path::new(path), config)
664 .into_iter()
665 .map(|server| server.kind)
666 .collect()
667 }
668
669 #[test]
670 fn test_servers_for_typescript_file() {
671 let kinds = matching_kinds("/tmp/file.ts", &Config::default());
674 assert!(
675 kinds.contains(&ServerKind::TypeScript),
676 "expected TypeScript in {kinds:?}",
677 );
678 }
679
680 #[test]
681 fn test_typescript_co_servers() {
682 let kinds = matching_kinds("/tmp/file.ts", &Config::default());
683 assert!(kinds.contains(&ServerKind::TypeScript));
684 assert!(kinds.contains(&ServerKind::Biome));
685 assert!(kinds.contains(&ServerKind::Oxlint));
686 }
687
688 #[test]
689 fn test_typescript_co_servers_can_be_disabled() {
690 let mut disabled = std::collections::HashSet::new();
692 disabled.insert("biome".to_string());
693 disabled.insert("oxlint".to_string());
694
695 let config = Config {
696 disabled_lsp: disabled,
697 ..Config::default()
698 };
699
700 assert_eq!(
701 matching_kinds("/tmp/file.ts", &config),
702 vec![ServerKind::TypeScript]
703 );
704 }
705
706 #[test]
707 fn test_servers_for_python_file() {
708 assert_eq!(
709 matching_kinds("/tmp/file.py", &Config::default()),
710 vec![ServerKind::Python]
711 );
712 }
713
714 #[test]
715 fn test_servers_for_rust_file() {
716 assert_eq!(
717 matching_kinds("/tmp/file.rs", &Config::default()),
718 vec![ServerKind::Rust]
719 );
720 }
721
722 #[test]
723 fn test_servers_for_go_file() {
724 assert_eq!(
725 matching_kinds("/tmp/file.go", &Config::default()),
726 vec![ServerKind::Go]
727 );
728 }
729
730 #[test]
731 fn test_servers_for_unknown_file() {
732 assert!(matching_kinds("/tmp/file.txt", &Config::default()).is_empty());
733 }
734
735 #[test]
736 fn test_tsx_matches_typescript() {
737 let kinds = matching_kinds("/tmp/file.tsx", &Config::default());
738 assert!(
739 kinds.contains(&ServerKind::TypeScript),
740 "expected TypeScript in {kinds:?}",
741 );
742 }
743
744 #[test]
745 fn test_case_insensitive_extension() {
746 let kinds = matching_kinds("/tmp/file.TS", &Config::default());
747 assert!(
748 kinds.contains(&ServerKind::TypeScript),
749 "expected TypeScript in {kinds:?}",
750 );
751 }
752
753 #[test]
754 fn test_bash_and_yaml_builtins() {
755 assert_eq!(
756 matching_kinds("/tmp/file.sh", &Config::default()),
757 vec![ServerKind::Bash]
758 );
759 assert_eq!(
760 matching_kinds("/tmp/file.yaml", &Config::default()),
761 vec![ServerKind::Yaml]
762 );
763 }
764
765 #[test]
766 fn test_ty_requires_experimental_flag() {
767 assert_eq!(
768 matching_kinds("/tmp/file.py", &Config::default()),
769 vec![ServerKind::Python]
770 );
771
772 let config = Config {
773 experimental_lsp_ty: true,
774 ..Config::default()
775 };
776 assert_eq!(
777 matching_kinds("/tmp/file.py", &config),
778 vec![ServerKind::Python, ServerKind::Ty]
779 );
780 }
781
782 #[test]
783 fn test_custom_server_matches_extension() {
784 let config = Config {
787 lsp_servers: vec![UserServerDef {
788 id: "my-custom-lsp".to_string(),
789 extensions: vec!["xyzcustom".to_string()],
790 binary: "my-custom-lsp".to_string(),
791 root_markers: vec!["custom.toml".to_string()],
792 ..UserServerDef::default()
793 }],
794 ..Config::default()
795 };
796
797 assert_eq!(
798 matching_kinds("/tmp/file.xyzcustom", &config),
799 vec![ServerKind::Custom(Arc::from("my-custom-lsp"))]
800 );
801 }
802
803 #[test]
804 fn test_custom_server_coexists_with_builtin_for_same_extension() {
805 let config = Config {
808 lsp_servers: vec![UserServerDef {
809 id: "tinymist-fork".to_string(),
810 extensions: vec!["typ".to_string()],
811 binary: "tinymist-fork".to_string(),
812 root_markers: vec!["typst.toml".to_string()],
813 ..UserServerDef::default()
814 }],
815 ..Config::default()
816 };
817
818 let kinds = matching_kinds("/tmp/file.typ", &config);
819 assert!(kinds.contains(&ServerKind::Tinymist));
820 assert!(kinds.contains(&ServerKind::Custom(Arc::from("tinymist-fork"))));
821 }
822
823 #[test]
824 fn test_pattern_a_servers_register_for_their_extensions() {
825 let cases: &[(&str, ServerKind)] = &[
826 ("/tmp/a.clj", ServerKind::Clojure),
827 ("/tmp/a.dart", ServerKind::Dart),
828 ("/tmp/a.ex", ServerKind::ElixirLs),
829 ("/tmp/a.fs", ServerKind::FSharp),
830 ("/tmp/a.gleam", ServerKind::Gleam),
831 ("/tmp/a.hs", ServerKind::Haskell),
832 ("/tmp/A.java", ServerKind::Jdtls),
833 ("/tmp/a.jl", ServerKind::Julia),
834 ("/tmp/a.nix", ServerKind::Nixd),
835 ("/tmp/a.ml", ServerKind::OcamlLsp),
836 ("/tmp/a.php", ServerKind::PhpIntelephense),
837 ("/tmp/a.rb", ServerKind::RubyLsp),
838 ("/tmp/a.swift", ServerKind::SourceKit),
839 ("/tmp/a.cs", ServerKind::CSharp),
840 ("/tmp/a.razor", ServerKind::Razor),
841 ];
842
843 for (path, expected) in cases {
844 let kinds = matching_kinds(path, &Config::default());
845 assert!(
846 kinds.contains(expected),
847 "expected {expected:?} for {path}; got {kinds:?}",
848 );
849 }
850 }
851
852 #[test]
853 fn test_pattern_c_servers_register_for_their_extensions() {
854 let cases: &[(&str, ServerKind)] = &[
855 ("/tmp/a.c", ServerKind::Clangd),
856 ("/tmp/a.cpp", ServerKind::Clangd),
857 ("/tmp/a.h", ServerKind::Clangd),
858 ("/tmp/a.lua", ServerKind::LuaLs),
859 ("/tmp/a.zig", ServerKind::Zls),
860 ("/tmp/a.typ", ServerKind::Tinymist),
861 ("/tmp/a.kt", ServerKind::KotlinLs),
862 ("/tmp/a.tex", ServerKind::Texlab),
863 ("/tmp/a.tf", ServerKind::TerraformLs),
864 ];
865
866 for (path, expected) in cases {
867 let kinds = matching_kinds(path, &Config::default());
868 assert!(
869 kinds.contains(expected),
870 "expected {expected:?} for {path}; got {kinds:?}",
871 );
872 }
873 }
874
875 #[test]
876 fn test_pattern_b_d_servers_register_for_their_extensions() {
877 let cases: &[(&str, ServerKind)] = &[
878 ("/tmp/a.vue", ServerKind::Vue),
879 ("/tmp/a.astro", ServerKind::Astro),
880 ("/tmp/a.prisma", ServerKind::Prisma),
881 ("/tmp/a.svelte", ServerKind::Svelte),
882 ("/tmp/a.dockerfile", ServerKind::Dockerfile),
883 ];
884
885 for (path, expected) in cases {
886 let kinds = matching_kinds(path, &Config::default());
887 assert!(
888 kinds.contains(expected),
889 "expected {expected:?} for {path}; got {kinds:?}",
890 );
891 }
892 }
893
894 #[test]
895 fn test_lsp_disabled_filters_out_servers_by_id() {
896 let mut disabled = std::collections::HashSet::new();
897 disabled.insert("clangd".to_string());
898 disabled.insert("dart".to_string());
899 disabled.insert("rust".to_string());
900
901 let config = Config {
902 disabled_lsp: disabled,
903 ..Config::default()
904 };
905
906 let c_kinds = matching_kinds("/tmp/a.c", &config);
908 assert!(!c_kinds.contains(&ServerKind::Clangd));
909
910 let dart_kinds = matching_kinds("/tmp/a.dart", &config);
911 assert!(!dart_kinds.contains(&ServerKind::Dart));
912
913 let rust_kinds = matching_kinds("/tmp/a.rs", &config);
914 assert!(!rust_kinds.contains(&ServerKind::Rust));
915
916 let ts_kinds = matching_kinds("/tmp/a.ts", &config);
918 assert!(ts_kinds.contains(&ServerKind::TypeScript));
919 }
920
921 #[test]
922 fn test_server_kind_ids_are_unique() {
923 use std::collections::HashSet;
926 let servers = super::builtin_servers();
927 let ids: Vec<String> = servers
928 .iter()
929 .map(|s| s.kind.id_str().to_string())
930 .collect();
931 let unique: HashSet<&String> = ids.iter().collect();
932 assert_eq!(
933 ids.len(),
934 unique.len(),
935 "duplicate server IDs in registry: {ids:?}",
936 );
937 }
938
939 fn touch_exe(path: &Path) {
942 if let Some(parent) = path.parent() {
943 std::fs::create_dir_all(parent).unwrap();
944 }
945 std::fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap();
946 #[cfg(unix)]
947 {
948 use std::os::unix::fs::PermissionsExt;
949 let mut perms = std::fs::metadata(path).unwrap().permissions();
950 perms.set_mode(0o755);
951 std::fs::set_permissions(path, perms).unwrap();
952 }
953 }
954
955 #[test]
956 fn resolve_lsp_binary_prefers_project_node_modules() {
957 let tmp = tempfile::tempdir().unwrap();
958 let project = tmp.path();
959 let local_bin = project.join("node_modules").join(".bin");
960 touch_exe(&local_bin.join("typescript-language-server"));
961
962 let resolved = resolve_lsp_binary("typescript-language-server", Some(project), &[]);
963 assert_eq!(
964 resolved.as_deref(),
965 Some(local_bin.join("typescript-language-server").as_path())
966 );
967 }
968
969 #[test]
970 fn resolve_lsp_binary_falls_back_to_extra_paths() {
971 let tmp = tempfile::tempdir().unwrap();
972 let project = tmp.path().join("project");
973 std::fs::create_dir_all(&project).unwrap();
974
975 let extra_a = tmp.path().join("extra_a");
976 let extra_b = tmp.path().join("extra_b");
977 std::fs::create_dir_all(&extra_a).unwrap();
978 std::fs::create_dir_all(&extra_b).unwrap();
979 touch_exe(&extra_b.join("yaml-language-server"));
980
981 let resolved = resolve_lsp_binary(
982 "yaml-language-server",
983 Some(&project),
984 &[extra_a.clone(), extra_b.clone()],
985 );
986 assert_eq!(
987 resolved.as_deref(),
988 Some(extra_b.join("yaml-language-server").as_path())
989 );
990 }
991
992 #[test]
993 fn resolve_lsp_binary_extra_paths_search_in_order() {
994 let tmp = tempfile::tempdir().unwrap();
995 let extra_a = tmp.path().join("extra_a");
996 let extra_b = tmp.path().join("extra_b");
997 std::fs::create_dir_all(&extra_a).unwrap();
998 std::fs::create_dir_all(&extra_b).unwrap();
999 touch_exe(&extra_a.join("bash-language-server"));
1001 touch_exe(&extra_b.join("bash-language-server"));
1002
1003 let resolved = resolve_lsp_binary(
1004 "bash-language-server",
1005 None,
1006 &[extra_a.clone(), extra_b.clone()],
1007 );
1008 assert_eq!(
1009 resolved.as_deref(),
1010 Some(extra_a.join("bash-language-server").as_path())
1011 );
1012 }
1013
1014 #[test]
1015 fn resolve_lsp_binary_project_root_wins_over_extra_paths() {
1016 let tmp = tempfile::tempdir().unwrap();
1017 let project = tmp.path().join("project");
1018 let local_bin = project.join("node_modules").join(".bin");
1019 touch_exe(&local_bin.join("pyright-langserver"));
1020
1021 let extra = tmp.path().join("extra");
1022 std::fs::create_dir_all(&extra).unwrap();
1023 touch_exe(&extra.join("pyright-langserver"));
1024
1025 let resolved = resolve_lsp_binary("pyright-langserver", Some(&project), &[extra.clone()]);
1026 assert_eq!(
1027 resolved.as_deref(),
1028 Some(local_bin.join("pyright-langserver").as_path())
1029 );
1030 }
1031
1032 #[test]
1033 fn resolve_lsp_binary_returns_none_for_missing_binary() {
1034 let tmp = tempfile::tempdir().unwrap();
1035 let project = tmp.path().join("project");
1036 std::fs::create_dir_all(&project).unwrap();
1037
1038 let resolved =
1040 resolve_lsp_binary("aft-test-nonexistent-binary-xyz123", Some(&project), &[]);
1041 assert!(resolved.is_none());
1042 }
1043
1044 #[test]
1045 fn resolve_lsp_binary_handles_missing_node_modules_gracefully() {
1046 let tmp = tempfile::tempdir().unwrap();
1049 let project = tmp.path().join("project");
1050 std::fs::create_dir_all(&project).unwrap();
1051
1052 let extra = tmp.path().join("extra");
1053 std::fs::create_dir_all(&extra).unwrap();
1054 touch_exe(&extra.join("gopls"));
1055
1056 let resolved = resolve_lsp_binary("gopls", Some(&project), &[extra.clone()]);
1057 assert_eq!(resolved.as_deref(), Some(extra.join("gopls").as_path()));
1058 }
1059
1060 #[test]
1061 fn resolve_lsp_binary_skips_nonexistent_extra_path() {
1062 let tmp = tempfile::tempdir().unwrap();
1063 let missing = tmp.path().join("missing");
1064 let valid = tmp.path().join("valid");
1065 std::fs::create_dir_all(&valid).unwrap();
1066 touch_exe(&valid.join("clangd"));
1067
1068 let resolved = resolve_lsp_binary("clangd", None, &[missing, valid.clone()]);
1069
1070 assert_eq!(resolved.as_deref(), Some(valid.join("clangd").as_path()));
1071 }
1072
1073 #[test]
1074 fn resolve_lsp_binary_skips_file_extra_path() {
1075 let tmp = tempfile::tempdir().unwrap();
1076 let file = tmp.path().join("not-a-dir");
1077 let valid = tmp.path().join("valid");
1078 std::fs::write(&file, "not a directory").unwrap();
1079 std::fs::create_dir_all(&valid).unwrap();
1080 touch_exe(&valid.join("lua-language-server"));
1081
1082 let resolved = resolve_lsp_binary("lua-language-server", None, &[file, valid.clone()]);
1083
1084 assert_eq!(
1085 resolved.as_deref(),
1086 Some(valid.join("lua-language-server").as_path())
1087 );
1088 }
1089
1090 #[test]
1091 fn resolve_lsp_binary_skips_deleted_extra_path() {
1092 let tmp = tempfile::tempdir().unwrap();
1093 let deleted = tmp.path().join("deleted");
1094 let valid = tmp.path().join("valid");
1095 std::fs::create_dir_all(&deleted).unwrap();
1096 std::fs::remove_dir(&deleted).unwrap();
1097 std::fs::create_dir_all(&valid).unwrap();
1098 touch_exe(&valid.join("svelte-language-server"));
1099
1100 let resolved =
1101 resolve_lsp_binary("svelte-language-server", None, &[deleted, valid.clone()]);
1102
1103 assert_eq!(
1104 resolved.as_deref(),
1105 Some(valid.join("svelte-language-server").as_path())
1106 );
1107 }
1108
1109 #[allow(dead_code)]
1112 fn _path_buf_used(_p: PathBuf) {}
1113}