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 resolved_servers(config)
587 .into_iter()
588 .filter(|server| !is_disabled(server, config))
589 .filter(|server| config.experimental_lsp_ty || server.kind != ServerKind::Ty)
590 .filter(|server| server.matches_extension(extension))
591 .collect()
592}
593
594fn resolved_servers(config: &Config) -> Vec<ServerDef> {
606 let mut servers = builtin_servers();
607 for user in &config.lsp_servers {
608 if user.disabled {
609 servers.retain(|s| s.kind.id_str() != user.id);
613 continue;
614 }
615 if let Some(position) = servers.iter().position(|s| s.kind.id_str() == user.id) {
616 let builtin = &servers[position];
621 let merged = ServerDef {
622 kind: builtin.kind.clone(),
623 name: builtin.name.clone(),
624 extensions: if user.extensions.is_empty() {
625 builtin.extensions.clone()
626 } else {
627 user.extensions.clone()
628 },
629 binary: if user.binary.is_empty() {
630 builtin.binary.clone()
631 } else {
632 user.binary.clone()
633 },
634 args: if user.args.is_empty() {
635 builtin.args.clone()
636 } else {
637 user.args.clone()
638 },
639 root_markers: if user.root_markers.is_empty() {
640 builtin.root_markers.clone()
641 } else {
642 user.root_markers.clone()
643 },
644 env: if user.env.is_empty() {
645 builtin.env.clone()
646 } else {
647 user.env.clone()
648 },
649 initialization_options: user
650 .initialization_options
651 .clone()
652 .or_else(|| builtin.initialization_options.clone()),
653 };
654 servers[position] = merged;
655 } else if let Some(def) = custom_server(user) {
656 servers.push(def);
657 }
658 }
659 servers
660}
661
662pub fn is_config_file_path(path: &Path) -> bool {
666 const IGNORED_COMPONENTS: &[&str] = &[
667 "node_modules",
668 "target",
669 "vendor",
670 ".git",
671 "dist",
672 "build",
673 ".next",
674 ".nuxt",
675 "__pycache__",
676 ];
677
678 if path.components().any(|component| {
679 component
680 .as_os_str()
681 .to_str()
682 .is_some_and(|name| IGNORED_COMPONENTS.contains(&name))
683 }) {
684 return false;
685 }
686
687 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
688 return false;
689 };
690
691 const LOCKFILE_NAMES: &[&str] = &[
698 "package-lock.json",
699 "yarn.lock",
700 "pnpm-lock.yaml",
701 "Cargo.lock",
702 "Gemfile.lock",
703 "poetry.lock",
704 "go.sum",
705 "bun.lock",
706 "bun.lockb",
707 ];
708 if LOCKFILE_NAMES.contains(&file_name) {
709 return false;
710 }
711
712 builtin_config_file_names().contains(file_name)
713 || (file_name.starts_with("tsconfig.") && file_name.ends_with(".json"))
714}
715
716pub fn is_config_file_path_with_custom(path: &Path, extra_markers: &[String]) -> bool {
720 if is_config_file_path(path) {
721 return true;
722 }
723 if extra_markers.is_empty() {
724 return false;
725 }
726 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
727 return false;
728 };
729 extra_markers.iter().any(|m| m == file_name)
730}
731
732fn builtin_config_file_names() -> &'static HashSet<String> {
733 static NAMES: OnceLock<HashSet<String>> = OnceLock::new();
734 NAMES.get_or_init(|| {
735 builtin_servers()
736 .into_iter()
737 .flat_map(|server| server.root_markers)
738 .collect()
739 })
740}
741
742fn builtin_server(
743 kind: ServerKind,
744 name: &str,
745 extensions: &[&str],
746 binary: &str,
747 args: &[&str],
748 root_markers: &[&str],
749) -> ServerDef {
750 ServerDef {
751 kind,
752 name: name.to_string(),
753 extensions: strings(extensions),
754 binary: binary.to_string(),
755 args: strings(args),
756 root_markers: strings(root_markers),
757 env: HashMap::new(),
758 initialization_options: None,
759 }
760}
761
762fn builtin_server_with_init(
766 kind: ServerKind,
767 name: &str,
768 extensions: &[&str],
769 binary: &str,
770 args: &[&str],
771 root_markers: &[&str],
772 initialization_options: serde_json::Value,
773) -> ServerDef {
774 let mut def = builtin_server(kind, name, extensions, binary, args, root_markers);
775 def.initialization_options = Some(initialization_options);
776 def
777}
778
779fn custom_server(server: &UserServerDef) -> Option<ServerDef> {
780 if server.disabled {
781 return None;
782 }
783
784 Some(ServerDef {
785 kind: ServerKind::Custom(Arc::from(server.id.as_str())),
786 name: server.id.clone(),
787 extensions: server.extensions.clone(),
788 binary: server.binary.clone(),
789 args: server.args.clone(),
790 root_markers: server.root_markers.clone(),
791 env: server.env.clone(),
792 initialization_options: server.initialization_options.clone(),
793 })
794}
795
796fn is_disabled(server: &ServerDef, config: &Config) -> bool {
797 config
798 .disabled_lsp
799 .contains(&server.kind.id_str().to_ascii_lowercase())
800}
801
802fn strings(values: &[&str]) -> Vec<String> {
803 values.iter().map(|value| (*value).to_string()).collect()
804}
805
806#[cfg(test)]
807mod tests {
808 use std::path::{Path, PathBuf};
809 use std::sync::Arc;
810
811 use crate::config::{Config, UserServerDef};
812
813 use super::{is_config_file_path, resolve_lsp_binary, servers_for_file, ServerKind};
814
815 fn matching_kinds(path: &str, config: &Config) -> Vec<ServerKind> {
816 servers_for_file(Path::new(path), config)
817 .into_iter()
818 .map(|server| server.kind)
819 .collect()
820 }
821
822 #[test]
823 fn test_servers_for_typescript_file() {
824 let kinds = matching_kinds("/tmp/file.ts", &Config::default());
827 assert!(
828 kinds.contains(&ServerKind::TypeScript),
829 "expected TypeScript in {kinds:?}",
830 );
831 }
832
833 #[test]
834 fn test_is_config_file_path_recognizes_project_graph_configs() {
835 for path in [
837 "/repo/package.json",
838 "/repo/tsconfig.json",
839 "/repo/tsconfig.build.json",
840 "/repo/jsconfig.json",
841 "/repo/pyproject.toml",
842 "/repo/pyrightconfig.json",
843 "/repo/Cargo.toml",
844 "/repo/go.mod",
845 "/repo/biome.json",
846 ] {
847 assert!(
848 is_config_file_path(Path::new(path)),
849 "expected config: {path}"
850 );
851 }
852
853 for path in [
858 "/repo/Cargo.lock",
859 "/repo/go.sum",
860 "/repo/bun.lock",
861 "/repo/bun.lockb",
862 "/repo/package-lock.json",
863 "/repo/yarn.lock",
864 "/repo/pnpm-lock.yaml",
865 ] {
866 assert!(
867 !is_config_file_path(Path::new(path)),
868 "lockfile should be excluded from config-file detection: {path}"
869 );
870 }
871
872 for path in [
874 "/repo/tsconfig-json",
875 "/repo/tsconfig.build.ts",
876 "/repo/cargo.toml",
877 "/repo/src/package.json.ts",
878 ] {
879 assert!(
880 !is_config_file_path(Path::new(path)),
881 "expected non-config: {path}"
882 );
883 }
884 }
885
886 #[test]
887 fn test_typescript_co_servers() {
888 let kinds = matching_kinds("/tmp/file.ts", &Config::default());
889 assert!(kinds.contains(&ServerKind::TypeScript));
890 assert!(kinds.contains(&ServerKind::Biome));
891 assert!(kinds.contains(&ServerKind::Oxlint));
892 }
893
894 #[test]
895 fn test_typescript_co_servers_can_be_disabled() {
896 let mut disabled = std::collections::HashSet::new();
898 disabled.insert("biome".to_string());
899 disabled.insert("oxlint".to_string());
900
901 let config = Config {
902 disabled_lsp: disabled,
903 ..Config::default()
904 };
905
906 assert_eq!(
907 matching_kinds("/tmp/file.ts", &config),
908 vec![ServerKind::TypeScript]
909 );
910 }
911
912 #[test]
913 fn test_servers_for_python_file() {
914 assert_eq!(
915 matching_kinds("/tmp/file.py", &Config::default()),
916 vec![ServerKind::Python]
917 );
918 }
919
920 #[test]
921 fn test_servers_for_rust_file() {
922 assert_eq!(
923 matching_kinds("/tmp/file.rs", &Config::default()),
924 vec![ServerKind::Rust]
925 );
926 }
927
928 #[test]
929 fn test_servers_for_go_file() {
930 assert_eq!(
931 matching_kinds("/tmp/file.go", &Config::default()),
932 vec![ServerKind::Go]
933 );
934 }
935
936 #[test]
937 fn test_servers_for_unknown_file() {
938 assert!(matching_kinds("/tmp/file.txt", &Config::default()).is_empty());
939 }
940
941 #[test]
942 fn test_oxlint_root_markers_exclude_package_json() {
943 let oxlint = super::builtin_servers()
950 .into_iter()
951 .find(|s| s.kind == ServerKind::Oxlint)
952 .expect("Oxlint server must be registered");
953
954 assert!(
955 !oxlint.root_markers.iter().any(|m| m == "package.json"),
956 "package.json must not be a root marker for oxlint (got {:?})",
957 oxlint.root_markers,
958 );
959 assert!(
960 oxlint.root_markers.iter().any(|m| m == ".oxlintrc.json")
961 || oxlint.root_markers.iter().any(|m| m == ".oxlintrc"),
962 "expected an oxlint config file in root markers (got {:?})",
963 oxlint.root_markers,
964 );
965 }
966
967 #[test]
968 fn test_tsx_matches_typescript() {
969 let kinds = matching_kinds("/tmp/file.tsx", &Config::default());
970 assert!(
971 kinds.contains(&ServerKind::TypeScript),
972 "expected TypeScript in {kinds:?}",
973 );
974 }
975
976 #[test]
977 fn test_case_insensitive_extension() {
978 let kinds = matching_kinds("/tmp/file.TS", &Config::default());
979 assert!(
980 kinds.contains(&ServerKind::TypeScript),
981 "expected TypeScript in {kinds:?}",
982 );
983 }
984
985 #[test]
986 fn test_bash_and_yaml_builtins() {
987 assert_eq!(
988 matching_kinds("/tmp/file.sh", &Config::default()),
989 vec![ServerKind::Bash]
990 );
991 assert_eq!(
992 matching_kinds("/tmp/file.yaml", &Config::default()),
993 vec![ServerKind::Yaml]
994 );
995 }
996
997 #[test]
998 fn test_ty_requires_experimental_flag() {
999 assert_eq!(
1000 matching_kinds("/tmp/file.py", &Config::default()),
1001 vec![ServerKind::Python]
1002 );
1003
1004 let config = Config {
1005 experimental_lsp_ty: true,
1006 ..Config::default()
1007 };
1008 assert_eq!(
1009 matching_kinds("/tmp/file.py", &config),
1010 vec![ServerKind::Python, ServerKind::Ty]
1011 );
1012 }
1013
1014 #[test]
1015 fn test_custom_server_matches_extension() {
1016 let config = Config {
1019 lsp_servers: vec![UserServerDef {
1020 id: "my-custom-lsp".to_string(),
1021 extensions: vec!["xyzcustom".to_string()],
1022 binary: "my-custom-lsp".to_string(),
1023 root_markers: vec!["custom.toml".to_string()],
1024 ..UserServerDef::default()
1025 }],
1026 ..Config::default()
1027 };
1028
1029 assert_eq!(
1030 matching_kinds("/tmp/file.xyzcustom", &config),
1031 vec![ServerKind::Custom(Arc::from("my-custom-lsp"))]
1032 );
1033 }
1034
1035 #[test]
1036 fn test_custom_server_coexists_with_builtin_for_same_extension() {
1037 let config = Config {
1040 lsp_servers: vec![UserServerDef {
1041 id: "tinymist-fork".to_string(),
1042 extensions: vec!["typ".to_string()],
1043 binary: "tinymist-fork".to_string(),
1044 root_markers: vec!["typst.toml".to_string()],
1045 ..UserServerDef::default()
1046 }],
1047 ..Config::default()
1048 };
1049
1050 let kinds = matching_kinds("/tmp/file.typ", &config);
1051 assert!(kinds.contains(&ServerKind::Tinymist));
1052 assert!(kinds.contains(&ServerKind::Custom(Arc::from("tinymist-fork"))));
1053 }
1054
1055 #[test]
1056 fn test_pattern_a_servers_register_for_their_extensions() {
1057 let cases: &[(&str, ServerKind)] = &[
1058 ("/tmp/a.clj", ServerKind::Clojure),
1059 ("/tmp/a.dart", ServerKind::Dart),
1060 ("/tmp/a.ex", ServerKind::ElixirLs),
1061 ("/tmp/a.fs", ServerKind::FSharp),
1062 ("/tmp/a.gleam", ServerKind::Gleam),
1063 ("/tmp/a.hs", ServerKind::Haskell),
1064 ("/tmp/A.java", ServerKind::Jdtls),
1065 ("/tmp/a.jl", ServerKind::Julia),
1066 ("/tmp/a.nix", ServerKind::Nixd),
1067 ("/tmp/a.ml", ServerKind::OcamlLsp),
1068 ("/tmp/a.php", ServerKind::PhpIntelephense),
1069 ("/tmp/a.rb", ServerKind::RubyLsp),
1070 ("/tmp/a.swift", ServerKind::SourceKit),
1071 ("/tmp/a.cs", ServerKind::CSharp),
1072 ("/tmp/a.razor", ServerKind::Razor),
1073 ];
1074
1075 for (path, expected) in cases {
1076 let kinds = matching_kinds(path, &Config::default());
1077 assert!(
1078 kinds.contains(expected),
1079 "expected {expected:?} for {path}; got {kinds:?}",
1080 );
1081 }
1082 }
1083
1084 #[test]
1085 fn test_pattern_c_servers_register_for_their_extensions() {
1086 let cases: &[(&str, ServerKind)] = &[
1087 ("/tmp/a.c", ServerKind::Clangd),
1088 ("/tmp/a.cpp", ServerKind::Clangd),
1089 ("/tmp/a.h", ServerKind::Clangd),
1090 ("/tmp/a.lua", ServerKind::LuaLs),
1091 ("/tmp/a.zig", ServerKind::Zls),
1092 ("/tmp/a.typ", ServerKind::Tinymist),
1093 ("/tmp/a.kt", ServerKind::KotlinLs),
1094 ("/tmp/a.tex", ServerKind::Texlab),
1095 ("/tmp/a.tf", ServerKind::TerraformLs),
1096 ];
1097
1098 for (path, expected) in cases {
1099 let kinds = matching_kinds(path, &Config::default());
1100 assert!(
1101 kinds.contains(expected),
1102 "expected {expected:?} for {path}; got {kinds:?}",
1103 );
1104 }
1105 }
1106
1107 #[test]
1108 fn test_pattern_b_d_servers_register_for_their_extensions() {
1109 let cases: &[(&str, ServerKind)] = &[
1110 ("/tmp/a.vue", ServerKind::Vue),
1111 ("/tmp/a.astro", ServerKind::Astro),
1112 ("/tmp/a.prisma", ServerKind::Prisma),
1113 ("/tmp/a.svelte", ServerKind::Svelte),
1114 ("/tmp/a.dockerfile", ServerKind::Dockerfile),
1115 ];
1116
1117 for (path, expected) in cases {
1118 let kinds = matching_kinds(path, &Config::default());
1119 assert!(
1120 kinds.contains(expected),
1121 "expected {expected:?} for {path}; got {kinds:?}",
1122 );
1123 }
1124 }
1125
1126 #[test]
1127 fn test_lsp_disabled_filters_out_servers_by_id() {
1128 let mut disabled = std::collections::HashSet::new();
1129 disabled.insert("clangd".to_string());
1130 disabled.insert("dart".to_string());
1131 disabled.insert("rust".to_string());
1132
1133 let config = Config {
1134 disabled_lsp: disabled,
1135 ..Config::default()
1136 };
1137
1138 let c_kinds = matching_kinds("/tmp/a.c", &config);
1140 assert!(!c_kinds.contains(&ServerKind::Clangd));
1141
1142 let dart_kinds = matching_kinds("/tmp/a.dart", &config);
1143 assert!(!dart_kinds.contains(&ServerKind::Dart));
1144
1145 let rust_kinds = matching_kinds("/tmp/a.rs", &config);
1146 assert!(!rust_kinds.contains(&ServerKind::Rust));
1147
1148 let ts_kinds = matching_kinds("/tmp/a.ts", &config);
1150 assert!(ts_kinds.contains(&ServerKind::TypeScript));
1151 }
1152
1153 #[test]
1154 fn test_server_kind_ids_are_unique() {
1155 use std::collections::HashSet;
1158 let servers = super::builtin_servers();
1159 let ids: Vec<String> = servers
1160 .iter()
1161 .map(|s| s.kind.id_str().to_string())
1162 .collect();
1163 let unique: HashSet<&String> = ids.iter().collect();
1164 assert_eq!(
1165 ids.len(),
1166 unique.len(),
1167 "duplicate server IDs in registry: {ids:?}",
1168 );
1169 }
1170
1171 #[test]
1172 fn user_override_with_matching_id_replaces_builtin_not_appended() {
1173 let config = Config {
1176 lsp_servers: vec![UserServerDef {
1177 id: "clangd".to_string(),
1178 args: vec!["--query-driver=/path/to/arm-none-eabi-*".to_string()],
1179 ..UserServerDef::default()
1180 }],
1181 ..Config::default()
1182 };
1183
1184 let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1185 let clangd_entries: Vec<_> = cpp_servers
1186 .iter()
1187 .filter(|s| s.kind.id_str() == "clangd")
1188 .collect();
1189 assert_eq!(
1190 clangd_entries.len(),
1191 1,
1192 "expected exactly one clangd server after user override; got {} ({:?})",
1193 clangd_entries.len(),
1194 cpp_servers.iter().map(|s| &s.kind).collect::<Vec<_>>()
1195 );
1196
1197 let clangd = clangd_entries[0];
1199 assert_eq!(clangd.args, vec!["--query-driver=/path/to/arm-none-eabi-*"],);
1200
1201 assert!(
1204 !clangd.extensions.is_empty(),
1205 "extensions should inherit from built-in clangd, got empty",
1206 );
1207 assert!(
1208 !clangd.root_markers.is_empty(),
1209 "root_markers should inherit from built-in clangd, got empty",
1210 );
1211 }
1212
1213 #[test]
1214 fn user_override_preserves_builtin_kind_not_custom() {
1215 let config = Config {
1220 lsp_servers: vec![UserServerDef {
1221 id: "clangd".to_string(),
1222 root_markers: vec![".clangd".to_string()],
1223 ..UserServerDef::default()
1224 }],
1225 ..Config::default()
1226 };
1227
1228 let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1229 let clangd = cpp_servers
1230 .iter()
1231 .find(|s| s.kind.id_str() == "clangd")
1232 .expect("clangd entry");
1233 assert!(
1234 matches!(clangd.kind, ServerKind::Clangd),
1235 "merged server must keep ServerKind::Clangd, got {:?}",
1236 clangd.kind,
1237 );
1238 }
1239
1240 #[test]
1241 fn user_override_with_non_matching_id_is_appended_as_custom() {
1242 let config = Config {
1252 lsp_servers: vec![UserServerDef {
1253 id: "custom-clangd".to_string(),
1254 extensions: vec!["c".to_string(), "cpp".to_string()],
1255 binary: "clangd".to_string(),
1256 ..UserServerDef::default()
1257 }],
1258 ..Config::default()
1259 };
1260
1261 let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1262 let kinds: Vec<&ServerKind> = cpp_servers.iter().map(|s| &s.kind).collect();
1263 assert!(
1264 kinds.iter().any(|k| matches!(k, ServerKind::Clangd)),
1265 "built-in clangd should still be present alongside custom-clangd; got {kinds:?}",
1266 );
1267 assert!(
1268 kinds
1269 .iter()
1270 .any(|k| matches!(k, ServerKind::Custom(id) if id.as_ref() == "custom-clangd")),
1271 "custom-clangd should be appended as Custom; got {kinds:?}",
1272 );
1273 }
1274
1275 #[test]
1276 fn user_override_with_disabled_true_drops_builtin() {
1277 let config = Config {
1280 lsp_servers: vec![UserServerDef {
1281 id: "clangd".to_string(),
1282 disabled: true,
1283 ..UserServerDef::default()
1284 }],
1285 ..Config::default()
1286 };
1287
1288 let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1289 assert!(
1290 !cpp_servers.iter().any(|s| s.kind.id_str() == "clangd"),
1291 "disabled user override should drop the built-in; got {:?}",
1292 cpp_servers.iter().map(|s| &s.kind).collect::<Vec<_>>(),
1293 );
1294 }
1295
1296 fn touch_exe(path: &Path) {
1299 if let Some(parent) = path.parent() {
1300 std::fs::create_dir_all(parent).unwrap();
1301 }
1302 std::fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap();
1303 #[cfg(unix)]
1304 {
1305 use std::os::unix::fs::PermissionsExt;
1306 let mut perms = std::fs::metadata(path).unwrap().permissions();
1307 perms.set_mode(0o755);
1308 std::fs::set_permissions(path, perms).unwrap();
1309 }
1310 }
1311
1312 #[test]
1313 fn resolve_lsp_binary_prefers_project_node_modules() {
1314 let tmp = tempfile::tempdir().unwrap();
1315 let project = tmp.path();
1316 let local_bin = project.join("node_modules").join(".bin");
1317 touch_exe(&local_bin.join("typescript-language-server"));
1318
1319 let resolved = resolve_lsp_binary("typescript-language-server", Some(project), &[]);
1320 assert_eq!(
1321 resolved.as_deref(),
1322 Some(local_bin.join("typescript-language-server").as_path())
1323 );
1324 }
1325
1326 #[test]
1327 fn resolve_lsp_binary_falls_back_to_extra_paths() {
1328 let tmp = tempfile::tempdir().unwrap();
1329 let project = tmp.path().join("project");
1330 std::fs::create_dir_all(&project).unwrap();
1331
1332 let extra_a = tmp.path().join("extra_a");
1333 let extra_b = tmp.path().join("extra_b");
1334 std::fs::create_dir_all(&extra_a).unwrap();
1335 std::fs::create_dir_all(&extra_b).unwrap();
1336 touch_exe(&extra_b.join("yaml-language-server"));
1337
1338 let resolved = resolve_lsp_binary(
1339 "yaml-language-server",
1340 Some(&project),
1341 &[extra_a.clone(), extra_b.clone()],
1342 );
1343 assert_eq!(
1344 resolved.as_deref(),
1345 Some(extra_b.join("yaml-language-server").as_path())
1346 );
1347 }
1348
1349 #[test]
1350 fn resolve_lsp_binary_extra_paths_search_in_order() {
1351 let tmp = tempfile::tempdir().unwrap();
1352 let extra_a = tmp.path().join("extra_a");
1353 let extra_b = tmp.path().join("extra_b");
1354 std::fs::create_dir_all(&extra_a).unwrap();
1355 std::fs::create_dir_all(&extra_b).unwrap();
1356 touch_exe(&extra_a.join("bash-language-server"));
1358 touch_exe(&extra_b.join("bash-language-server"));
1359
1360 let resolved = resolve_lsp_binary(
1361 "bash-language-server",
1362 None,
1363 &[extra_a.clone(), extra_b.clone()],
1364 );
1365 assert_eq!(
1366 resolved.as_deref(),
1367 Some(extra_a.join("bash-language-server").as_path())
1368 );
1369 }
1370
1371 #[test]
1372 fn resolve_lsp_binary_project_root_wins_over_extra_paths() {
1373 let tmp = tempfile::tempdir().unwrap();
1374 let project = tmp.path().join("project");
1375 let local_bin = project.join("node_modules").join(".bin");
1376 touch_exe(&local_bin.join("pyright-langserver"));
1377
1378 let extra = tmp.path().join("extra");
1379 std::fs::create_dir_all(&extra).unwrap();
1380 touch_exe(&extra.join("pyright-langserver"));
1381
1382 let resolved = resolve_lsp_binary(
1383 "pyright-langserver",
1384 Some(&project),
1385 std::slice::from_ref(&extra),
1386 );
1387 assert_eq!(
1388 resolved.as_deref(),
1389 Some(local_bin.join("pyright-langserver").as_path())
1390 );
1391 }
1392
1393 #[test]
1394 fn resolve_lsp_binary_returns_none_for_missing_binary() {
1395 let tmp = tempfile::tempdir().unwrap();
1396 let project = tmp.path().join("project");
1397 std::fs::create_dir_all(&project).unwrap();
1398
1399 let resolved =
1401 resolve_lsp_binary("aft-test-nonexistent-binary-xyz123", Some(&project), &[]);
1402 assert!(resolved.is_none());
1403 }
1404
1405 #[test]
1406 fn resolve_lsp_binary_handles_missing_node_modules_gracefully() {
1407 let tmp = tempfile::tempdir().unwrap();
1410 let project = tmp.path().join("project");
1411 std::fs::create_dir_all(&project).unwrap();
1412
1413 let extra = tmp.path().join("extra");
1414 std::fs::create_dir_all(&extra).unwrap();
1415 touch_exe(&extra.join("gopls"));
1416
1417 let resolved = resolve_lsp_binary("gopls", Some(&project), std::slice::from_ref(&extra));
1418 assert_eq!(resolved.as_deref(), Some(extra.join("gopls").as_path()));
1419 }
1420
1421 #[test]
1422 fn resolve_lsp_binary_skips_nonexistent_extra_path() {
1423 let tmp = tempfile::tempdir().unwrap();
1424 let missing = tmp.path().join("missing");
1425 let valid = tmp.path().join("valid");
1426 std::fs::create_dir_all(&valid).unwrap();
1427 touch_exe(&valid.join("clangd"));
1428
1429 let resolved = resolve_lsp_binary("clangd", None, &[missing, valid.clone()]);
1430
1431 assert_eq!(resolved.as_deref(), Some(valid.join("clangd").as_path()));
1432 }
1433
1434 #[test]
1435 fn resolve_lsp_binary_skips_file_extra_path() {
1436 let tmp = tempfile::tempdir().unwrap();
1437 let file = tmp.path().join("not-a-dir");
1438 let valid = tmp.path().join("valid");
1439 std::fs::write(&file, "not a directory").unwrap();
1440 std::fs::create_dir_all(&valid).unwrap();
1441 touch_exe(&valid.join("lua-language-server"));
1442
1443 let resolved = resolve_lsp_binary("lua-language-server", None, &[file, valid.clone()]);
1444
1445 assert_eq!(
1446 resolved.as_deref(),
1447 Some(valid.join("lua-language-server").as_path())
1448 );
1449 }
1450
1451 #[test]
1452 fn resolve_lsp_binary_skips_deleted_extra_path() {
1453 let tmp = tempfile::tempdir().unwrap();
1454 let deleted = tmp.path().join("deleted");
1455 let valid = tmp.path().join("valid");
1456 std::fs::create_dir_all(&deleted).unwrap();
1457 std::fs::remove_dir(&deleted).unwrap();
1458 std::fs::create_dir_all(&valid).unwrap();
1459 touch_exe(&valid.join("svelte-language-server"));
1460
1461 let resolved =
1462 resolve_lsp_binary("svelte-language-server", None, &[deleted, valid.clone()]);
1463
1464 assert_eq!(
1465 resolved.as_deref(),
1466 Some(valid.join("svelte-language-server").as_path())
1467 );
1468 }
1469
1470 #[allow(dead_code)]
1473 fn _path_buf_used(_p: PathBuf) {}
1474}