1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, OnceLock};
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", "Cargo.lock"],
228 ),
229 builtin_server_with_init(
234 ServerKind::Go,
235 "gopls",
236 &["go"],
237 "gopls",
238 &["serve"],
239 &["go.mod", "go.sum"],
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
595pub fn is_config_file_path(path: &Path) -> bool {
599 const IGNORED_COMPONENTS: &[&str] = &[
600 "node_modules",
601 "target",
602 "vendor",
603 ".git",
604 "dist",
605 "build",
606 ".next",
607 ".nuxt",
608 "__pycache__",
609 ];
610
611 if path.components().any(|component| {
612 component
613 .as_os_str()
614 .to_str()
615 .is_some_and(|name| IGNORED_COMPONENTS.contains(&name))
616 }) {
617 return false;
618 }
619
620 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
621 return false;
622 };
623
624 const LOCKFILE_NAMES: &[&str] = &[
631 "package-lock.json",
632 "yarn.lock",
633 "pnpm-lock.yaml",
634 "Cargo.lock",
635 "Gemfile.lock",
636 "poetry.lock",
637 "go.sum",
638 "bun.lock",
639 "bun.lockb",
640 ];
641 if LOCKFILE_NAMES.contains(&file_name) {
642 return false;
643 }
644
645 builtin_config_file_names().contains(file_name)
646 || (file_name.starts_with("tsconfig.") && file_name.ends_with(".json"))
647}
648
649pub fn is_config_file_path_with_custom(path: &Path, extra_markers: &[String]) -> bool {
653 if is_config_file_path(path) {
654 return true;
655 }
656 if extra_markers.is_empty() {
657 return false;
658 }
659 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
660 return false;
661 };
662 extra_markers.iter().any(|m| m == file_name)
663}
664
665fn builtin_config_file_names() -> &'static HashSet<String> {
666 static NAMES: OnceLock<HashSet<String>> = OnceLock::new();
667 NAMES.get_or_init(|| {
668 builtin_servers()
669 .into_iter()
670 .flat_map(|server| server.root_markers)
671 .collect()
672 })
673}
674
675fn builtin_server(
676 kind: ServerKind,
677 name: &str,
678 extensions: &[&str],
679 binary: &str,
680 args: &[&str],
681 root_markers: &[&str],
682) -> ServerDef {
683 ServerDef {
684 kind,
685 name: name.to_string(),
686 extensions: strings(extensions),
687 binary: binary.to_string(),
688 args: strings(args),
689 root_markers: strings(root_markers),
690 env: HashMap::new(),
691 initialization_options: None,
692 }
693}
694
695fn builtin_server_with_init(
699 kind: ServerKind,
700 name: &str,
701 extensions: &[&str],
702 binary: &str,
703 args: &[&str],
704 root_markers: &[&str],
705 initialization_options: serde_json::Value,
706) -> ServerDef {
707 let mut def = builtin_server(kind, name, extensions, binary, args, root_markers);
708 def.initialization_options = Some(initialization_options);
709 def
710}
711
712fn custom_server(server: &UserServerDef) -> Option<ServerDef> {
713 if server.disabled {
714 return None;
715 }
716
717 Some(ServerDef {
718 kind: ServerKind::Custom(Arc::from(server.id.as_str())),
719 name: server.id.clone(),
720 extensions: server.extensions.clone(),
721 binary: server.binary.clone(),
722 args: server.args.clone(),
723 root_markers: server.root_markers.clone(),
724 env: server.env.clone(),
725 initialization_options: server.initialization_options.clone(),
726 })
727}
728
729fn is_disabled(server: &ServerDef, config: &Config) -> bool {
730 config
731 .disabled_lsp
732 .contains(&server.kind.id_str().to_ascii_lowercase())
733}
734
735fn strings(values: &[&str]) -> Vec<String> {
736 values.iter().map(|value| (*value).to_string()).collect()
737}
738
739#[cfg(test)]
740mod tests {
741 use std::path::{Path, PathBuf};
742 use std::sync::Arc;
743
744 use crate::config::{Config, UserServerDef};
745
746 use super::{is_config_file_path, resolve_lsp_binary, servers_for_file, ServerKind};
747
748 fn matching_kinds(path: &str, config: &Config) -> Vec<ServerKind> {
749 servers_for_file(Path::new(path), config)
750 .into_iter()
751 .map(|server| server.kind)
752 .collect()
753 }
754
755 #[test]
756 fn test_servers_for_typescript_file() {
757 let kinds = matching_kinds("/tmp/file.ts", &Config::default());
760 assert!(
761 kinds.contains(&ServerKind::TypeScript),
762 "expected TypeScript in {kinds:?}",
763 );
764 }
765
766 #[test]
767 fn test_is_config_file_path_recognizes_project_graph_configs() {
768 for path in [
770 "/repo/package.json",
771 "/repo/tsconfig.json",
772 "/repo/tsconfig.build.json",
773 "/repo/jsconfig.json",
774 "/repo/pyproject.toml",
775 "/repo/pyrightconfig.json",
776 "/repo/Cargo.toml",
777 "/repo/go.mod",
778 "/repo/biome.json",
779 ] {
780 assert!(
781 is_config_file_path(Path::new(path)),
782 "expected config: {path}"
783 );
784 }
785
786 for path in [
791 "/repo/Cargo.lock",
792 "/repo/go.sum",
793 "/repo/bun.lock",
794 "/repo/bun.lockb",
795 "/repo/package-lock.json",
796 "/repo/yarn.lock",
797 "/repo/pnpm-lock.yaml",
798 ] {
799 assert!(
800 !is_config_file_path(Path::new(path)),
801 "lockfile should be excluded from config-file detection: {path}"
802 );
803 }
804
805 for path in [
807 "/repo/tsconfig-json",
808 "/repo/tsconfig.build.ts",
809 "/repo/cargo.toml",
810 "/repo/src/package.json.ts",
811 ] {
812 assert!(
813 !is_config_file_path(Path::new(path)),
814 "expected non-config: {path}"
815 );
816 }
817 }
818
819 #[test]
820 fn test_typescript_co_servers() {
821 let kinds = matching_kinds("/tmp/file.ts", &Config::default());
822 assert!(kinds.contains(&ServerKind::TypeScript));
823 assert!(kinds.contains(&ServerKind::Biome));
824 assert!(kinds.contains(&ServerKind::Oxlint));
825 }
826
827 #[test]
828 fn test_typescript_co_servers_can_be_disabled() {
829 let mut disabled = std::collections::HashSet::new();
831 disabled.insert("biome".to_string());
832 disabled.insert("oxlint".to_string());
833
834 let config = Config {
835 disabled_lsp: disabled,
836 ..Config::default()
837 };
838
839 assert_eq!(
840 matching_kinds("/tmp/file.ts", &config),
841 vec![ServerKind::TypeScript]
842 );
843 }
844
845 #[test]
846 fn test_servers_for_python_file() {
847 assert_eq!(
848 matching_kinds("/tmp/file.py", &Config::default()),
849 vec![ServerKind::Python]
850 );
851 }
852
853 #[test]
854 fn test_servers_for_rust_file() {
855 assert_eq!(
856 matching_kinds("/tmp/file.rs", &Config::default()),
857 vec![ServerKind::Rust]
858 );
859 }
860
861 #[test]
862 fn test_servers_for_go_file() {
863 assert_eq!(
864 matching_kinds("/tmp/file.go", &Config::default()),
865 vec![ServerKind::Go]
866 );
867 }
868
869 #[test]
870 fn test_servers_for_unknown_file() {
871 assert!(matching_kinds("/tmp/file.txt", &Config::default()).is_empty());
872 }
873
874 #[test]
875 fn test_oxlint_root_markers_exclude_package_json() {
876 let oxlint = super::builtin_servers()
883 .into_iter()
884 .find(|s| s.kind == ServerKind::Oxlint)
885 .expect("Oxlint server must be registered");
886
887 assert!(
888 !oxlint.root_markers.iter().any(|m| m == "package.json"),
889 "package.json must not be a root marker for oxlint (got {:?})",
890 oxlint.root_markers,
891 );
892 assert!(
893 oxlint.root_markers.iter().any(|m| m == ".oxlintrc.json")
894 || oxlint.root_markers.iter().any(|m| m == ".oxlintrc"),
895 "expected an oxlint config file in root markers (got {:?})",
896 oxlint.root_markers,
897 );
898 }
899
900 #[test]
901 fn test_tsx_matches_typescript() {
902 let kinds = matching_kinds("/tmp/file.tsx", &Config::default());
903 assert!(
904 kinds.contains(&ServerKind::TypeScript),
905 "expected TypeScript in {kinds:?}",
906 );
907 }
908
909 #[test]
910 fn test_case_insensitive_extension() {
911 let kinds = matching_kinds("/tmp/file.TS", &Config::default());
912 assert!(
913 kinds.contains(&ServerKind::TypeScript),
914 "expected TypeScript in {kinds:?}",
915 );
916 }
917
918 #[test]
919 fn test_bash_and_yaml_builtins() {
920 assert_eq!(
921 matching_kinds("/tmp/file.sh", &Config::default()),
922 vec![ServerKind::Bash]
923 );
924 assert_eq!(
925 matching_kinds("/tmp/file.yaml", &Config::default()),
926 vec![ServerKind::Yaml]
927 );
928 }
929
930 #[test]
931 fn test_ty_requires_experimental_flag() {
932 assert_eq!(
933 matching_kinds("/tmp/file.py", &Config::default()),
934 vec![ServerKind::Python]
935 );
936
937 let config = Config {
938 experimental_lsp_ty: true,
939 ..Config::default()
940 };
941 assert_eq!(
942 matching_kinds("/tmp/file.py", &config),
943 vec![ServerKind::Python, ServerKind::Ty]
944 );
945 }
946
947 #[test]
948 fn test_custom_server_matches_extension() {
949 let config = Config {
952 lsp_servers: vec![UserServerDef {
953 id: "my-custom-lsp".to_string(),
954 extensions: vec!["xyzcustom".to_string()],
955 binary: "my-custom-lsp".to_string(),
956 root_markers: vec!["custom.toml".to_string()],
957 ..UserServerDef::default()
958 }],
959 ..Config::default()
960 };
961
962 assert_eq!(
963 matching_kinds("/tmp/file.xyzcustom", &config),
964 vec![ServerKind::Custom(Arc::from("my-custom-lsp"))]
965 );
966 }
967
968 #[test]
969 fn test_custom_server_coexists_with_builtin_for_same_extension() {
970 let config = Config {
973 lsp_servers: vec![UserServerDef {
974 id: "tinymist-fork".to_string(),
975 extensions: vec!["typ".to_string()],
976 binary: "tinymist-fork".to_string(),
977 root_markers: vec!["typst.toml".to_string()],
978 ..UserServerDef::default()
979 }],
980 ..Config::default()
981 };
982
983 let kinds = matching_kinds("/tmp/file.typ", &config);
984 assert!(kinds.contains(&ServerKind::Tinymist));
985 assert!(kinds.contains(&ServerKind::Custom(Arc::from("tinymist-fork"))));
986 }
987
988 #[test]
989 fn test_pattern_a_servers_register_for_their_extensions() {
990 let cases: &[(&str, ServerKind)] = &[
991 ("/tmp/a.clj", ServerKind::Clojure),
992 ("/tmp/a.dart", ServerKind::Dart),
993 ("/tmp/a.ex", ServerKind::ElixirLs),
994 ("/tmp/a.fs", ServerKind::FSharp),
995 ("/tmp/a.gleam", ServerKind::Gleam),
996 ("/tmp/a.hs", ServerKind::Haskell),
997 ("/tmp/A.java", ServerKind::Jdtls),
998 ("/tmp/a.jl", ServerKind::Julia),
999 ("/tmp/a.nix", ServerKind::Nixd),
1000 ("/tmp/a.ml", ServerKind::OcamlLsp),
1001 ("/tmp/a.php", ServerKind::PhpIntelephense),
1002 ("/tmp/a.rb", ServerKind::RubyLsp),
1003 ("/tmp/a.swift", ServerKind::SourceKit),
1004 ("/tmp/a.cs", ServerKind::CSharp),
1005 ("/tmp/a.razor", ServerKind::Razor),
1006 ];
1007
1008 for (path, expected) in cases {
1009 let kinds = matching_kinds(path, &Config::default());
1010 assert!(
1011 kinds.contains(expected),
1012 "expected {expected:?} for {path}; got {kinds:?}",
1013 );
1014 }
1015 }
1016
1017 #[test]
1018 fn test_pattern_c_servers_register_for_their_extensions() {
1019 let cases: &[(&str, ServerKind)] = &[
1020 ("/tmp/a.c", ServerKind::Clangd),
1021 ("/tmp/a.cpp", ServerKind::Clangd),
1022 ("/tmp/a.h", ServerKind::Clangd),
1023 ("/tmp/a.lua", ServerKind::LuaLs),
1024 ("/tmp/a.zig", ServerKind::Zls),
1025 ("/tmp/a.typ", ServerKind::Tinymist),
1026 ("/tmp/a.kt", ServerKind::KotlinLs),
1027 ("/tmp/a.tex", ServerKind::Texlab),
1028 ("/tmp/a.tf", ServerKind::TerraformLs),
1029 ];
1030
1031 for (path, expected) in cases {
1032 let kinds = matching_kinds(path, &Config::default());
1033 assert!(
1034 kinds.contains(expected),
1035 "expected {expected:?} for {path}; got {kinds:?}",
1036 );
1037 }
1038 }
1039
1040 #[test]
1041 fn test_pattern_b_d_servers_register_for_their_extensions() {
1042 let cases: &[(&str, ServerKind)] = &[
1043 ("/tmp/a.vue", ServerKind::Vue),
1044 ("/tmp/a.astro", ServerKind::Astro),
1045 ("/tmp/a.prisma", ServerKind::Prisma),
1046 ("/tmp/a.svelte", ServerKind::Svelte),
1047 ("/tmp/a.dockerfile", ServerKind::Dockerfile),
1048 ];
1049
1050 for (path, expected) in cases {
1051 let kinds = matching_kinds(path, &Config::default());
1052 assert!(
1053 kinds.contains(expected),
1054 "expected {expected:?} for {path}; got {kinds:?}",
1055 );
1056 }
1057 }
1058
1059 #[test]
1060 fn test_lsp_disabled_filters_out_servers_by_id() {
1061 let mut disabled = std::collections::HashSet::new();
1062 disabled.insert("clangd".to_string());
1063 disabled.insert("dart".to_string());
1064 disabled.insert("rust".to_string());
1065
1066 let config = Config {
1067 disabled_lsp: disabled,
1068 ..Config::default()
1069 };
1070
1071 let c_kinds = matching_kinds("/tmp/a.c", &config);
1073 assert!(!c_kinds.contains(&ServerKind::Clangd));
1074
1075 let dart_kinds = matching_kinds("/tmp/a.dart", &config);
1076 assert!(!dart_kinds.contains(&ServerKind::Dart));
1077
1078 let rust_kinds = matching_kinds("/tmp/a.rs", &config);
1079 assert!(!rust_kinds.contains(&ServerKind::Rust));
1080
1081 let ts_kinds = matching_kinds("/tmp/a.ts", &config);
1083 assert!(ts_kinds.contains(&ServerKind::TypeScript));
1084 }
1085
1086 #[test]
1087 fn test_server_kind_ids_are_unique() {
1088 use std::collections::HashSet;
1091 let servers = super::builtin_servers();
1092 let ids: Vec<String> = servers
1093 .iter()
1094 .map(|s| s.kind.id_str().to_string())
1095 .collect();
1096 let unique: HashSet<&String> = ids.iter().collect();
1097 assert_eq!(
1098 ids.len(),
1099 unique.len(),
1100 "duplicate server IDs in registry: {ids:?}",
1101 );
1102 }
1103
1104 fn touch_exe(path: &Path) {
1107 if let Some(parent) = path.parent() {
1108 std::fs::create_dir_all(parent).unwrap();
1109 }
1110 std::fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap();
1111 #[cfg(unix)]
1112 {
1113 use std::os::unix::fs::PermissionsExt;
1114 let mut perms = std::fs::metadata(path).unwrap().permissions();
1115 perms.set_mode(0o755);
1116 std::fs::set_permissions(path, perms).unwrap();
1117 }
1118 }
1119
1120 #[test]
1121 fn resolve_lsp_binary_prefers_project_node_modules() {
1122 let tmp = tempfile::tempdir().unwrap();
1123 let project = tmp.path();
1124 let local_bin = project.join("node_modules").join(".bin");
1125 touch_exe(&local_bin.join("typescript-language-server"));
1126
1127 let resolved = resolve_lsp_binary("typescript-language-server", Some(project), &[]);
1128 assert_eq!(
1129 resolved.as_deref(),
1130 Some(local_bin.join("typescript-language-server").as_path())
1131 );
1132 }
1133
1134 #[test]
1135 fn resolve_lsp_binary_falls_back_to_extra_paths() {
1136 let tmp = tempfile::tempdir().unwrap();
1137 let project = tmp.path().join("project");
1138 std::fs::create_dir_all(&project).unwrap();
1139
1140 let extra_a = tmp.path().join("extra_a");
1141 let extra_b = tmp.path().join("extra_b");
1142 std::fs::create_dir_all(&extra_a).unwrap();
1143 std::fs::create_dir_all(&extra_b).unwrap();
1144 touch_exe(&extra_b.join("yaml-language-server"));
1145
1146 let resolved = resolve_lsp_binary(
1147 "yaml-language-server",
1148 Some(&project),
1149 &[extra_a.clone(), extra_b.clone()],
1150 );
1151 assert_eq!(
1152 resolved.as_deref(),
1153 Some(extra_b.join("yaml-language-server").as_path())
1154 );
1155 }
1156
1157 #[test]
1158 fn resolve_lsp_binary_extra_paths_search_in_order() {
1159 let tmp = tempfile::tempdir().unwrap();
1160 let extra_a = tmp.path().join("extra_a");
1161 let extra_b = tmp.path().join("extra_b");
1162 std::fs::create_dir_all(&extra_a).unwrap();
1163 std::fs::create_dir_all(&extra_b).unwrap();
1164 touch_exe(&extra_a.join("bash-language-server"));
1166 touch_exe(&extra_b.join("bash-language-server"));
1167
1168 let resolved = resolve_lsp_binary(
1169 "bash-language-server",
1170 None,
1171 &[extra_a.clone(), extra_b.clone()],
1172 );
1173 assert_eq!(
1174 resolved.as_deref(),
1175 Some(extra_a.join("bash-language-server").as_path())
1176 );
1177 }
1178
1179 #[test]
1180 fn resolve_lsp_binary_project_root_wins_over_extra_paths() {
1181 let tmp = tempfile::tempdir().unwrap();
1182 let project = tmp.path().join("project");
1183 let local_bin = project.join("node_modules").join(".bin");
1184 touch_exe(&local_bin.join("pyright-langserver"));
1185
1186 let extra = tmp.path().join("extra");
1187 std::fs::create_dir_all(&extra).unwrap();
1188 touch_exe(&extra.join("pyright-langserver"));
1189
1190 let resolved = resolve_lsp_binary(
1191 "pyright-langserver",
1192 Some(&project),
1193 std::slice::from_ref(&extra),
1194 );
1195 assert_eq!(
1196 resolved.as_deref(),
1197 Some(local_bin.join("pyright-langserver").as_path())
1198 );
1199 }
1200
1201 #[test]
1202 fn resolve_lsp_binary_returns_none_for_missing_binary() {
1203 let tmp = tempfile::tempdir().unwrap();
1204 let project = tmp.path().join("project");
1205 std::fs::create_dir_all(&project).unwrap();
1206
1207 let resolved =
1209 resolve_lsp_binary("aft-test-nonexistent-binary-xyz123", Some(&project), &[]);
1210 assert!(resolved.is_none());
1211 }
1212
1213 #[test]
1214 fn resolve_lsp_binary_handles_missing_node_modules_gracefully() {
1215 let tmp = tempfile::tempdir().unwrap();
1218 let project = tmp.path().join("project");
1219 std::fs::create_dir_all(&project).unwrap();
1220
1221 let extra = tmp.path().join("extra");
1222 std::fs::create_dir_all(&extra).unwrap();
1223 touch_exe(&extra.join("gopls"));
1224
1225 let resolved = resolve_lsp_binary("gopls", Some(&project), std::slice::from_ref(&extra));
1226 assert_eq!(resolved.as_deref(), Some(extra.join("gopls").as_path()));
1227 }
1228
1229 #[test]
1230 fn resolve_lsp_binary_skips_nonexistent_extra_path() {
1231 let tmp = tempfile::tempdir().unwrap();
1232 let missing = tmp.path().join("missing");
1233 let valid = tmp.path().join("valid");
1234 std::fs::create_dir_all(&valid).unwrap();
1235 touch_exe(&valid.join("clangd"));
1236
1237 let resolved = resolve_lsp_binary("clangd", None, &[missing, valid.clone()]);
1238
1239 assert_eq!(resolved.as_deref(), Some(valid.join("clangd").as_path()));
1240 }
1241
1242 #[test]
1243 fn resolve_lsp_binary_skips_file_extra_path() {
1244 let tmp = tempfile::tempdir().unwrap();
1245 let file = tmp.path().join("not-a-dir");
1246 let valid = tmp.path().join("valid");
1247 std::fs::write(&file, "not a directory").unwrap();
1248 std::fs::create_dir_all(&valid).unwrap();
1249 touch_exe(&valid.join("lua-language-server"));
1250
1251 let resolved = resolve_lsp_binary("lua-language-server", None, &[file, valid.clone()]);
1252
1253 assert_eq!(
1254 resolved.as_deref(),
1255 Some(valid.join("lua-language-server").as_path())
1256 );
1257 }
1258
1259 #[test]
1260 fn resolve_lsp_binary_skips_deleted_extra_path() {
1261 let tmp = tempfile::tempdir().unwrap();
1262 let deleted = tmp.path().join("deleted");
1263 let valid = tmp.path().join("valid");
1264 std::fs::create_dir_all(&deleted).unwrap();
1265 std::fs::remove_dir(&deleted).unwrap();
1266 std::fs::create_dir_all(&valid).unwrap();
1267 touch_exe(&valid.join("svelte-language-server"));
1268
1269 let resolved =
1270 resolve_lsp_binary("svelte-language-server", None, &[deleted, valid.clone()]);
1271
1272 assert_eq!(
1273 resolved.as_deref(),
1274 Some(valid.join("svelte-language-server").as_path())
1275 );
1276 }
1277
1278 #[allow(dead_code)]
1281 fn _path_buf_used(_p: PathBuf) {}
1282}