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 if cfg!(windows) {
50 for ext in ["cmd", "exe", "bat"] {
55 let candidate = dir.join(format!("{binary}.{ext}"));
56 if candidate.is_file() {
57 return Some(candidate);
58 }
59 }
60 }
61
62 let direct = dir.join(binary);
63 if direct.is_file() {
64 return Some(direct);
65 }
66
67 None
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75pub enum ServerKind {
76 TypeScript,
78 Python, Rust,
80 Go,
81 Bash,
82 Yaml,
83 Ty, Clojure,
86 Dart,
87 ElixirLs,
88 FSharp,
89 Gleam,
90 Haskell,
91 Jdtls, Julia,
93 Nixd,
94 OcamlLsp,
95 PhpIntelephense,
96 RubyLsp,
97 SourceKit, CSharp,
99 Razor,
100 Clangd,
102 LuaLs,
103 Zls,
104 Tinymist,
105 KotlinLs,
106 Texlab,
107 Oxlint,
108 TerraformLs,
109 Vue,
111 Astro,
112 Prisma, Biome,
114 Svelte,
115 Dockerfile,
116 Custom(Arc<str>),
117}
118
119impl ServerKind {
120 pub fn id_str(&self) -> &str {
121 match self {
122 Self::TypeScript => "typescript",
123 Self::Python => "python",
124 Self::Rust => "rust",
125 Self::Go => "go",
126 Self::Bash => "bash",
127 Self::Yaml => "yaml",
128 Self::Ty => "ty",
129 Self::Clojure => "clojure-lsp",
131 Self::Dart => "dart",
132 Self::ElixirLs => "elixir-ls",
133 Self::FSharp => "fsharp",
134 Self::Gleam => "gleam",
135 Self::Haskell => "haskell-language-server",
136 Self::Jdtls => "jdtls",
137 Self::Julia => "julials",
138 Self::Nixd => "nixd",
139 Self::OcamlLsp => "ocaml-lsp",
140 Self::PhpIntelephense => "php-intelephense",
141 Self::RubyLsp => "ruby-lsp",
142 Self::SourceKit => "sourcekit-lsp",
143 Self::CSharp => "csharp",
144 Self::Razor => "razor",
145 Self::Clangd => "clangd",
147 Self::LuaLs => "lua-ls",
148 Self::Zls => "zls",
149 Self::Tinymist => "tinymist",
150 Self::KotlinLs => "kotlin-ls",
151 Self::Texlab => "texlab",
152 Self::Oxlint => "oxlint",
153 Self::TerraformLs => "terraform",
154 Self::Vue => "vue",
156 Self::Astro => "astro",
157 Self::Prisma => "prisma",
158 Self::Biome => "biome",
159 Self::Svelte => "svelte",
160 Self::Dockerfile => "dockerfile",
161 Self::Custom(id) => id.as_ref(),
162 }
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ServerDef {
169 pub kind: ServerKind,
170 pub name: String,
172 pub extensions: Vec<String>,
174 pub binary: String,
176 pub args: Vec<String>,
178 pub root_markers: Vec<String>,
180 pub env: HashMap<String, String>,
182 pub initialization_options: Option<serde_json::Value>,
184}
185
186impl ServerDef {
187 pub fn matches_extension(&self, ext: &str) -> bool {
189 self.extensions
190 .iter()
191 .any(|candidate| candidate.eq_ignore_ascii_case(ext))
192 }
193
194 pub fn is_available(&self) -> bool {
196 which::which(&self.binary).is_ok()
197 }
198}
199
200pub fn builtin_servers() -> Vec<ServerDef> {
202 vec![
203 builtin_server(
204 ServerKind::TypeScript,
205 "TypeScript Language Server",
206 &["ts", "tsx", "js", "jsx", "mjs", "cjs"],
207 "typescript-language-server",
208 &["--stdio"],
209 &["tsconfig.json", "jsconfig.json", "package.json"],
210 ),
211 builtin_server(
212 ServerKind::Python,
213 "Pyright",
214 &["py", "pyi"],
215 "pyright-langserver",
216 &["--stdio"],
217 &[
218 "pyproject.toml",
219 "setup.py",
220 "setup.cfg",
221 "pyrightconfig.json",
222 "requirements.txt",
223 ],
224 ),
225 builtin_server(
226 ServerKind::Rust,
227 "rust-analyzer",
228 &["rs"],
229 "rust-analyzer",
230 &[],
231 &["Cargo.toml", "Cargo.lock"],
232 ),
233 builtin_server_with_init(
238 ServerKind::Go,
239 "gopls",
240 &["go"],
241 "gopls",
242 &["serve"],
243 &["go.mod", "go.sum"],
244 serde_json::json!({ "pullDiagnostics": true }),
245 ),
246 builtin_server(
247 ServerKind::Bash,
248 "bash-language-server",
249 &["sh", "bash", "zsh"],
250 "bash-language-server",
251 &["start"],
252 &["package.json", ".git"],
253 ),
254 builtin_server(
255 ServerKind::Yaml,
256 "yaml-language-server",
257 &["yaml", "yml"],
258 "yaml-language-server",
259 &["--stdio"],
260 &["package.json", ".git"],
261 ),
262 builtin_server(
263 ServerKind::Ty,
264 "ty",
265 &["py", "pyi"],
266 "ty",
267 &["server"],
268 &[
269 "pyproject.toml",
270 "ty.toml",
271 "setup.py",
272 "setup.cfg",
273 "requirements.txt",
274 "Pipfile",
275 "pyrightconfig.json",
276 ],
277 ),
278 builtin_server(
285 ServerKind::Clojure,
286 "clojure-lsp",
287 &["clj", "cljs", "cljc", "edn"],
288 "clojure-lsp",
289 &[],
290 &[
291 "deps.edn",
292 "project.clj",
293 "shadow-cljs.edn",
294 "bb.edn",
295 "build.boot",
296 ],
297 ),
298 builtin_server(
299 ServerKind::Dart,
300 "Dart Language Server",
301 &["dart"],
302 "dart",
303 &["language-server", "--lsp"],
304 &["pubspec.yaml", "analysis_options.yaml"],
305 ),
306 builtin_server(
307 ServerKind::ElixirLs,
308 "elixir-ls",
309 &["ex", "exs"],
310 "elixir-ls",
311 &[],
312 &["mix.exs", "mix.lock"],
313 ),
314 builtin_server(
315 ServerKind::FSharp,
316 "FSAutoComplete",
317 &["fs", "fsi", "fsx", "fsscript"],
318 "fsautocomplete",
319 &[],
320 &[".slnx", ".sln", ".fsproj", "global.json"],
321 ),
322 builtin_server(
323 ServerKind::Gleam,
324 "Gleam Language Server",
325 &["gleam"],
326 "gleam",
327 &["lsp"],
328 &["gleam.toml"],
329 ),
330 builtin_server(
331 ServerKind::Haskell,
332 "haskell-language-server",
333 &["hs", "lhs"],
334 "haskell-language-server-wrapper",
335 &["--lsp"],
336 &["stack.yaml", "cabal.project", "hie.yaml"],
337 ),
338 builtin_server(
339 ServerKind::Jdtls,
340 "Eclipse JDT Language Server",
341 &["java"],
342 "jdtls",
343 &[],
344 &["pom.xml", "build.gradle", "build.gradle.kts", ".project"],
345 ),
346 builtin_server(
347 ServerKind::Julia,
348 "Julia Language Server",
349 &["jl"],
350 "julia",
351 &[
352 "--startup-file=no",
353 "--history-file=no",
354 "-e",
355 "using LanguageServer; runserver()",
356 ],
357 &["Project.toml", "Manifest.toml"],
358 ),
359 builtin_server(
360 ServerKind::Nixd,
361 "nixd",
362 &["nix"],
363 "nixd",
364 &[],
365 &["flake.nix", "default.nix", "shell.nix"],
366 ),
367 builtin_server(
368 ServerKind::OcamlLsp,
369 "ocaml-lsp",
370 &["ml", "mli"],
371 "ocamllsp",
372 &[],
373 &["dune-project", "dune-workspace", ".merlin", "opam"],
374 ),
375 builtin_server(
376 ServerKind::PhpIntelephense,
377 "Intelephense",
378 &["php"],
379 "intelephense",
380 &["--stdio"],
381 &["composer.json", "composer.lock", ".php-version"],
382 ),
383 builtin_server(
384 ServerKind::RubyLsp,
385 "ruby-lsp",
386 &["rb", "rake", "gemspec", "ru"],
387 "ruby-lsp",
388 &[],
389 &["Gemfile"],
390 ),
391 builtin_server(
392 ServerKind::SourceKit,
393 "SourceKit-LSP",
394 &["swift"],
395 "sourcekit-lsp",
396 &[],
397 &["Package.swift"],
398 ),
399 builtin_server(
400 ServerKind::CSharp,
401 "Roslyn Language Server",
402 &["cs", "csx"],
403 "roslyn-language-server",
404 &[],
405 &[".slnx", ".sln", ".csproj", "global.json"],
406 ),
407 builtin_server(
408 ServerKind::Razor,
409 "rzls",
410 &["razor", "cshtml"],
411 "rzls",
412 &[],
413 &[".slnx", ".sln", ".csproj", "global.json"],
414 ),
415 builtin_server(
417 ServerKind::Clangd,
418 "clangd",
419 &[
420 "c", "cpp", "cc", "cxx", "c++", "h", "hpp", "hh", "hxx", "h++",
421 ],
422 "clangd",
423 &[],
424 &["compile_commands.json", "compile_flags.txt", ".clangd"],
425 ),
426 builtin_server(
427 ServerKind::LuaLs,
428 "lua-language-server",
429 &["lua"],
430 "lua-language-server",
431 &[],
432 &[".luarc.json", ".luarc.jsonc", ".stylua.toml", "stylua.toml"],
433 ),
434 builtin_server(
435 ServerKind::Zls,
436 "zls",
437 &["zig", "zon"],
438 "zls",
439 &[],
440 &["build.zig"],
441 ),
442 builtin_server(
443 ServerKind::Tinymist,
444 "tinymist",
445 &["typ", "typc"],
446 "tinymist",
447 &[],
448 &["typst.toml"],
449 ),
450 builtin_server(
451 ServerKind::KotlinLs,
452 "kotlin-language-server",
453 &["kt", "kts"],
454 "kotlin-language-server",
455 &[],
456 &["settings.gradle", "settings.gradle.kts", "build.gradle"],
457 ),
458 builtin_server(
459 ServerKind::Texlab,
460 "texlab",
461 &["tex", "bib"],
462 "texlab",
463 &[],
464 &[".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"],
465 ),
466 builtin_server(
467 ServerKind::Oxlint,
468 "oxc-language-server",
469 &[
471 "ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "vue", "astro", "svelte",
472 ],
473 "oxc-language-server",
474 &[],
475 &[".oxlintrc.json", ".oxlintrc"],
482 ),
483 builtin_server(
484 ServerKind::TerraformLs,
485 "terraform-ls",
486 &["tf", "tfvars"],
487 "terraform-ls",
488 &["serve"],
489 &[".terraform.lock.hcl", "terraform.tfstate"],
490 ),
491 builtin_server(
497 ServerKind::Vue,
498 "Vue Language Server",
499 &["vue"],
500 "vue-language-server",
501 &["--stdio"],
502 &[
503 "package-lock.json",
504 "bun.lockb",
505 "bun.lock",
506 "pnpm-lock.yaml",
507 "yarn.lock",
508 ],
509 ),
510 builtin_server(
511 ServerKind::Astro,
512 "Astro Language Server",
513 &["astro"],
514 "astro-ls",
515 &["--stdio"],
516 &[
517 "package-lock.json",
518 "bun.lockb",
519 "bun.lock",
520 "pnpm-lock.yaml",
521 "yarn.lock",
522 ],
523 ),
524 builtin_server(
529 ServerKind::Prisma,
530 "Prisma Language Server",
531 &["prisma"],
532 "prisma",
533 &["language-server"],
534 &["schema.prisma", "package.json"],
535 ),
536 builtin_server(
540 ServerKind::Biome,
541 "Biome",
542 &[
543 "ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "json", "jsonc",
544 ],
545 "biome",
546 &["lsp-proxy"],
547 &["biome.json", "biome.jsonc"],
548 ),
549 builtin_server(
550 ServerKind::Svelte,
551 "Svelte Language Server",
552 &["svelte"],
553 "svelteserver",
554 &["--stdio"],
555 &[
556 "package-lock.json",
557 "bun.lockb",
558 "bun.lock",
559 "pnpm-lock.yaml",
560 "yarn.lock",
561 ],
562 ),
563 builtin_server(
564 ServerKind::Dockerfile,
565 "Dockerfile Language Server",
566 &["dockerfile"],
572 "docker-langserver",
573 &["--stdio"],
574 &["Dockerfile", "dockerfile", ".dockerignore"],
575 ),
576 ]
581}
582
583pub fn servers_for_file(path: &Path, config: &Config) -> Vec<ServerDef> {
585 let extension = path
586 .extension()
587 .and_then(|ext| ext.to_str())
588 .unwrap_or_default();
589
590 resolved_servers(config)
591 .into_iter()
592 .filter(|server| !is_disabled(server, config))
593 .filter(|server| config.experimental_lsp_ty || server.kind != ServerKind::Ty)
594 .filter(|server| server.matches_extension(extension))
595 .collect()
596}
597
598fn resolved_servers(config: &Config) -> Vec<ServerDef> {
610 let mut servers = builtin_servers();
611 for user in &config.lsp_servers {
612 if user.disabled {
613 servers.retain(|s| s.kind.id_str() != user.id);
617 continue;
618 }
619 if let Some(position) = servers.iter().position(|s| s.kind.id_str() == user.id) {
620 let builtin = &servers[position];
625 let merged = ServerDef {
626 kind: builtin.kind.clone(),
627 name: builtin.name.clone(),
628 extensions: if user.extensions.is_empty() {
629 builtin.extensions.clone()
630 } else {
631 user.extensions.clone()
632 },
633 binary: if user.binary.is_empty() {
634 builtin.binary.clone()
635 } else {
636 user.binary.clone()
637 },
638 args: if user.args.is_empty() {
639 builtin.args.clone()
640 } else {
641 user.args.clone()
642 },
643 root_markers: if user.root_markers.is_empty() {
644 builtin.root_markers.clone()
645 } else {
646 user.root_markers.clone()
647 },
648 env: if user.env.is_empty() {
649 builtin.env.clone()
650 } else {
651 user.env.clone()
652 },
653 initialization_options: user
654 .initialization_options
655 .clone()
656 .or_else(|| builtin.initialization_options.clone()),
657 };
658 servers[position] = merged;
659 } else if let Some(def) = custom_server(user) {
660 servers.push(def);
661 }
662 }
663 servers
664}
665
666pub fn is_config_file_path(path: &Path) -> bool {
670 const IGNORED_COMPONENTS: &[&str] = &[
671 "node_modules",
672 "target",
673 "vendor",
674 ".git",
675 "dist",
676 "build",
677 ".next",
678 ".nuxt",
679 "__pycache__",
680 ];
681
682 if path.components().any(|component| {
683 component
684 .as_os_str()
685 .to_str()
686 .is_some_and(|name| IGNORED_COMPONENTS.contains(&name))
687 }) {
688 return false;
689 }
690
691 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
692 return false;
693 };
694
695 const LOCKFILE_NAMES: &[&str] = &[
702 "package-lock.json",
703 "yarn.lock",
704 "pnpm-lock.yaml",
705 "Cargo.lock",
706 "Gemfile.lock",
707 "poetry.lock",
708 "go.sum",
709 "bun.lock",
710 "bun.lockb",
711 ];
712 if LOCKFILE_NAMES.contains(&file_name) {
713 return false;
714 }
715
716 builtin_config_file_names().contains(file_name)
717 || (file_name.starts_with("tsconfig.") && file_name.ends_with(".json"))
718}
719
720pub fn is_config_file_path_with_custom(path: &Path, extra_markers: &[String]) -> bool {
724 if is_config_file_path(path) {
725 return true;
726 }
727 if extra_markers.is_empty() {
728 return false;
729 }
730 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
731 return false;
732 };
733 extra_markers.iter().any(|m| m == file_name)
734}
735
736fn builtin_config_file_names() -> &'static HashSet<String> {
737 static NAMES: OnceLock<HashSet<String>> = OnceLock::new();
738 NAMES.get_or_init(|| {
739 builtin_servers()
740 .into_iter()
741 .flat_map(|server| server.root_markers)
742 .collect()
743 })
744}
745
746fn builtin_server(
747 kind: ServerKind,
748 name: &str,
749 extensions: &[&str],
750 binary: &str,
751 args: &[&str],
752 root_markers: &[&str],
753) -> ServerDef {
754 ServerDef {
755 kind,
756 name: name.to_string(),
757 extensions: strings(extensions),
758 binary: binary.to_string(),
759 args: strings(args),
760 root_markers: strings(root_markers),
761 env: HashMap::new(),
762 initialization_options: None,
763 }
764}
765
766fn builtin_server_with_init(
770 kind: ServerKind,
771 name: &str,
772 extensions: &[&str],
773 binary: &str,
774 args: &[&str],
775 root_markers: &[&str],
776 initialization_options: serde_json::Value,
777) -> ServerDef {
778 let mut def = builtin_server(kind, name, extensions, binary, args, root_markers);
779 def.initialization_options = Some(initialization_options);
780 def
781}
782
783fn custom_server(server: &UserServerDef) -> Option<ServerDef> {
784 if server.disabled {
785 return None;
786 }
787
788 Some(ServerDef {
789 kind: ServerKind::Custom(Arc::from(server.id.as_str())),
790 name: server.id.clone(),
791 extensions: server.extensions.clone(),
792 binary: server.binary.clone(),
793 args: server.args.clone(),
794 root_markers: server.root_markers.clone(),
795 env: server.env.clone(),
796 initialization_options: server.initialization_options.clone(),
797 })
798}
799
800fn is_disabled(server: &ServerDef, config: &Config) -> bool {
801 config
802 .disabled_lsp
803 .contains(&server.kind.id_str().to_ascii_lowercase())
804}
805
806fn strings(values: &[&str]) -> Vec<String> {
807 values.iter().map(|value| (*value).to_string()).collect()
808}
809
810#[cfg(test)]
811mod tests {
812 use std::path::{Path, PathBuf};
813 use std::sync::Arc;
814
815 use crate::config::{Config, UserServerDef};
816
817 use super::{is_config_file_path, resolve_lsp_binary, servers_for_file, ServerKind};
818
819 fn matching_kinds(path: &str, config: &Config) -> Vec<ServerKind> {
820 servers_for_file(Path::new(path), config)
821 .into_iter()
822 .map(|server| server.kind)
823 .collect()
824 }
825
826 #[test]
827 fn test_servers_for_typescript_file() {
828 let kinds = matching_kinds("/tmp/file.ts", &Config::default());
831 assert!(
832 kinds.contains(&ServerKind::TypeScript),
833 "expected TypeScript in {kinds:?}",
834 );
835 }
836
837 #[test]
838 fn test_is_config_file_path_recognizes_project_graph_configs() {
839 for path in [
841 "/repo/package.json",
842 "/repo/tsconfig.json",
843 "/repo/tsconfig.build.json",
844 "/repo/jsconfig.json",
845 "/repo/pyproject.toml",
846 "/repo/pyrightconfig.json",
847 "/repo/Cargo.toml",
848 "/repo/go.mod",
849 "/repo/biome.json",
850 ] {
851 assert!(
852 is_config_file_path(Path::new(path)),
853 "expected config: {path}"
854 );
855 }
856
857 for path in [
862 "/repo/Cargo.lock",
863 "/repo/go.sum",
864 "/repo/bun.lock",
865 "/repo/bun.lockb",
866 "/repo/package-lock.json",
867 "/repo/yarn.lock",
868 "/repo/pnpm-lock.yaml",
869 ] {
870 assert!(
871 !is_config_file_path(Path::new(path)),
872 "lockfile should be excluded from config-file detection: {path}"
873 );
874 }
875
876 for path in [
878 "/repo/tsconfig-json",
879 "/repo/tsconfig.build.ts",
880 "/repo/cargo.toml",
881 "/repo/src/package.json.ts",
882 ] {
883 assert!(
884 !is_config_file_path(Path::new(path)),
885 "expected non-config: {path}"
886 );
887 }
888 }
889
890 #[test]
891 fn test_typescript_co_servers() {
892 let kinds = matching_kinds("/tmp/file.ts", &Config::default());
893 assert!(kinds.contains(&ServerKind::TypeScript));
894 assert!(kinds.contains(&ServerKind::Biome));
895 assert!(kinds.contains(&ServerKind::Oxlint));
896 }
897
898 #[test]
899 fn test_typescript_co_servers_can_be_disabled() {
900 let mut disabled = std::collections::HashSet::new();
902 disabled.insert("biome".to_string());
903 disabled.insert("oxlint".to_string());
904
905 let config = Config {
906 disabled_lsp: disabled,
907 ..Config::default()
908 };
909
910 assert_eq!(
911 matching_kinds("/tmp/file.ts", &config),
912 vec![ServerKind::TypeScript]
913 );
914 }
915
916 #[test]
917 fn test_servers_for_python_file() {
918 assert_eq!(
919 matching_kinds("/tmp/file.py", &Config::default()),
920 vec![ServerKind::Python]
921 );
922 }
923
924 #[test]
925 fn test_servers_for_rust_file() {
926 assert_eq!(
927 matching_kinds("/tmp/file.rs", &Config::default()),
928 vec![ServerKind::Rust]
929 );
930 }
931
932 #[test]
933 fn test_servers_for_go_file() {
934 assert_eq!(
935 matching_kinds("/tmp/file.go", &Config::default()),
936 vec![ServerKind::Go]
937 );
938 }
939
940 #[test]
941 fn test_servers_for_unknown_file() {
942 assert!(matching_kinds("/tmp/file.txt", &Config::default()).is_empty());
943 }
944
945 #[test]
946 fn test_oxlint_root_markers_exclude_package_json() {
947 let oxlint = super::builtin_servers()
954 .into_iter()
955 .find(|s| s.kind == ServerKind::Oxlint)
956 .expect("Oxlint server must be registered");
957
958 assert!(
959 !oxlint.root_markers.iter().any(|m| m == "package.json"),
960 "package.json must not be a root marker for oxlint (got {:?})",
961 oxlint.root_markers,
962 );
963 assert!(
964 oxlint.root_markers.iter().any(|m| m == ".oxlintrc.json")
965 || oxlint.root_markers.iter().any(|m| m == ".oxlintrc"),
966 "expected an oxlint config file in root markers (got {:?})",
967 oxlint.root_markers,
968 );
969 }
970
971 #[test]
972 fn test_tsx_matches_typescript() {
973 let kinds = matching_kinds("/tmp/file.tsx", &Config::default());
974 assert!(
975 kinds.contains(&ServerKind::TypeScript),
976 "expected TypeScript in {kinds:?}",
977 );
978 }
979
980 #[test]
981 fn test_case_insensitive_extension() {
982 let kinds = matching_kinds("/tmp/file.TS", &Config::default());
983 assert!(
984 kinds.contains(&ServerKind::TypeScript),
985 "expected TypeScript in {kinds:?}",
986 );
987 }
988
989 #[test]
990 fn test_bash_and_yaml_builtins() {
991 assert_eq!(
992 matching_kinds("/tmp/file.sh", &Config::default()),
993 vec![ServerKind::Bash]
994 );
995 assert_eq!(
996 matching_kinds("/tmp/file.yaml", &Config::default()),
997 vec![ServerKind::Yaml]
998 );
999 }
1000
1001 #[test]
1002 fn test_ty_requires_experimental_flag() {
1003 assert_eq!(
1004 matching_kinds("/tmp/file.py", &Config::default()),
1005 vec![ServerKind::Python]
1006 );
1007
1008 let config = Config {
1009 experimental_lsp_ty: true,
1010 ..Config::default()
1011 };
1012 assert_eq!(
1013 matching_kinds("/tmp/file.py", &config),
1014 vec![ServerKind::Python, ServerKind::Ty]
1015 );
1016 }
1017
1018 #[test]
1019 fn test_custom_server_matches_extension() {
1020 let config = Config {
1023 lsp_servers: vec![UserServerDef {
1024 id: "my-custom-lsp".to_string(),
1025 extensions: vec!["xyzcustom".to_string()],
1026 binary: "my-custom-lsp".to_string(),
1027 root_markers: vec!["custom.toml".to_string()],
1028 ..UserServerDef::default()
1029 }],
1030 ..Config::default()
1031 };
1032
1033 assert_eq!(
1034 matching_kinds("/tmp/file.xyzcustom", &config),
1035 vec![ServerKind::Custom(Arc::from("my-custom-lsp"))]
1036 );
1037 }
1038
1039 #[test]
1040 fn test_custom_server_coexists_with_builtin_for_same_extension() {
1041 let config = Config {
1044 lsp_servers: vec![UserServerDef {
1045 id: "tinymist-fork".to_string(),
1046 extensions: vec!["typ".to_string()],
1047 binary: "tinymist-fork".to_string(),
1048 root_markers: vec!["typst.toml".to_string()],
1049 ..UserServerDef::default()
1050 }],
1051 ..Config::default()
1052 };
1053
1054 let kinds = matching_kinds("/tmp/file.typ", &config);
1055 assert!(kinds.contains(&ServerKind::Tinymist));
1056 assert!(kinds.contains(&ServerKind::Custom(Arc::from("tinymist-fork"))));
1057 }
1058
1059 #[test]
1060 fn test_pattern_a_servers_register_for_their_extensions() {
1061 let cases: &[(&str, ServerKind)] = &[
1062 ("/tmp/a.clj", ServerKind::Clojure),
1063 ("/tmp/a.dart", ServerKind::Dart),
1064 ("/tmp/a.ex", ServerKind::ElixirLs),
1065 ("/tmp/a.fs", ServerKind::FSharp),
1066 ("/tmp/a.gleam", ServerKind::Gleam),
1067 ("/tmp/a.hs", ServerKind::Haskell),
1068 ("/tmp/A.java", ServerKind::Jdtls),
1069 ("/tmp/a.jl", ServerKind::Julia),
1070 ("/tmp/a.nix", ServerKind::Nixd),
1071 ("/tmp/a.ml", ServerKind::OcamlLsp),
1072 ("/tmp/a.php", ServerKind::PhpIntelephense),
1073 ("/tmp/a.rb", ServerKind::RubyLsp),
1074 ("/tmp/a.swift", ServerKind::SourceKit),
1075 ("/tmp/a.cs", ServerKind::CSharp),
1076 ("/tmp/a.razor", ServerKind::Razor),
1077 ];
1078
1079 for (path, expected) in cases {
1080 let kinds = matching_kinds(path, &Config::default());
1081 assert!(
1082 kinds.contains(expected),
1083 "expected {expected:?} for {path}; got {kinds:?}",
1084 );
1085 }
1086 }
1087
1088 #[test]
1089 fn test_pattern_c_servers_register_for_their_extensions() {
1090 let cases: &[(&str, ServerKind)] = &[
1091 ("/tmp/a.c", ServerKind::Clangd),
1092 ("/tmp/a.cpp", ServerKind::Clangd),
1093 ("/tmp/a.h", ServerKind::Clangd),
1094 ("/tmp/a.lua", ServerKind::LuaLs),
1095 ("/tmp/a.zig", ServerKind::Zls),
1096 ("/tmp/a.typ", ServerKind::Tinymist),
1097 ("/tmp/a.kt", ServerKind::KotlinLs),
1098 ("/tmp/a.tex", ServerKind::Texlab),
1099 ("/tmp/a.tf", ServerKind::TerraformLs),
1100 ];
1101
1102 for (path, expected) in cases {
1103 let kinds = matching_kinds(path, &Config::default());
1104 assert!(
1105 kinds.contains(expected),
1106 "expected {expected:?} for {path}; got {kinds:?}",
1107 );
1108 }
1109 }
1110
1111 #[test]
1112 fn test_pattern_b_d_servers_register_for_their_extensions() {
1113 let cases: &[(&str, ServerKind)] = &[
1114 ("/tmp/a.vue", ServerKind::Vue),
1115 ("/tmp/a.astro", ServerKind::Astro),
1116 ("/tmp/a.prisma", ServerKind::Prisma),
1117 ("/tmp/a.svelte", ServerKind::Svelte),
1118 ("/tmp/a.dockerfile", ServerKind::Dockerfile),
1119 ];
1120
1121 for (path, expected) in cases {
1122 let kinds = matching_kinds(path, &Config::default());
1123 assert!(
1124 kinds.contains(expected),
1125 "expected {expected:?} for {path}; got {kinds:?}",
1126 );
1127 }
1128 }
1129
1130 #[test]
1131 fn test_lsp_disabled_filters_out_servers_by_id() {
1132 let mut disabled = std::collections::HashSet::new();
1133 disabled.insert("clangd".to_string());
1134 disabled.insert("dart".to_string());
1135 disabled.insert("rust".to_string());
1136
1137 let config = Config {
1138 disabled_lsp: disabled,
1139 ..Config::default()
1140 };
1141
1142 let c_kinds = matching_kinds("/tmp/a.c", &config);
1144 assert!(!c_kinds.contains(&ServerKind::Clangd));
1145
1146 let dart_kinds = matching_kinds("/tmp/a.dart", &config);
1147 assert!(!dart_kinds.contains(&ServerKind::Dart));
1148
1149 let rust_kinds = matching_kinds("/tmp/a.rs", &config);
1150 assert!(!rust_kinds.contains(&ServerKind::Rust));
1151
1152 let ts_kinds = matching_kinds("/tmp/a.ts", &config);
1154 assert!(ts_kinds.contains(&ServerKind::TypeScript));
1155 }
1156
1157 #[test]
1158 fn test_server_kind_ids_are_unique() {
1159 use std::collections::HashSet;
1162 let servers = super::builtin_servers();
1163 let ids: Vec<String> = servers
1164 .iter()
1165 .map(|s| s.kind.id_str().to_string())
1166 .collect();
1167 let unique: HashSet<&String> = ids.iter().collect();
1168 assert_eq!(
1169 ids.len(),
1170 unique.len(),
1171 "duplicate server IDs in registry: {ids:?}",
1172 );
1173 }
1174
1175 #[test]
1176 fn user_override_with_matching_id_replaces_builtin_not_appended() {
1177 let config = Config {
1180 lsp_servers: vec![UserServerDef {
1181 id: "clangd".to_string(),
1182 args: vec!["--query-driver=/path/to/arm-none-eabi-*".to_string()],
1183 ..UserServerDef::default()
1184 }],
1185 ..Config::default()
1186 };
1187
1188 let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1189 let clangd_entries: Vec<_> = cpp_servers
1190 .iter()
1191 .filter(|s| s.kind.id_str() == "clangd")
1192 .collect();
1193 assert_eq!(
1194 clangd_entries.len(),
1195 1,
1196 "expected exactly one clangd server after user override; got {} ({:?})",
1197 clangd_entries.len(),
1198 cpp_servers.iter().map(|s| &s.kind).collect::<Vec<_>>()
1199 );
1200
1201 let clangd = clangd_entries[0];
1203 assert_eq!(clangd.args, vec!["--query-driver=/path/to/arm-none-eabi-*"],);
1204
1205 assert!(
1208 !clangd.extensions.is_empty(),
1209 "extensions should inherit from built-in clangd, got empty",
1210 );
1211 assert!(
1212 !clangd.root_markers.is_empty(),
1213 "root_markers should inherit from built-in clangd, got empty",
1214 );
1215 }
1216
1217 #[test]
1218 fn user_override_preserves_builtin_kind_not_custom() {
1219 let config = Config {
1224 lsp_servers: vec![UserServerDef {
1225 id: "clangd".to_string(),
1226 root_markers: vec![".clangd".to_string()],
1227 ..UserServerDef::default()
1228 }],
1229 ..Config::default()
1230 };
1231
1232 let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1233 let clangd = cpp_servers
1234 .iter()
1235 .find(|s| s.kind.id_str() == "clangd")
1236 .expect("clangd entry");
1237 assert!(
1238 matches!(clangd.kind, ServerKind::Clangd),
1239 "merged server must keep ServerKind::Clangd, got {:?}",
1240 clangd.kind,
1241 );
1242 }
1243
1244 #[test]
1245 fn user_override_with_non_matching_id_is_appended_as_custom() {
1246 let config = Config {
1256 lsp_servers: vec![UserServerDef {
1257 id: "custom-clangd".to_string(),
1258 extensions: vec!["c".to_string(), "cpp".to_string()],
1259 binary: "clangd".to_string(),
1260 ..UserServerDef::default()
1261 }],
1262 ..Config::default()
1263 };
1264
1265 let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1266 let kinds: Vec<&ServerKind> = cpp_servers.iter().map(|s| &s.kind).collect();
1267 assert!(
1268 kinds.iter().any(|k| matches!(k, ServerKind::Clangd)),
1269 "built-in clangd should still be present alongside custom-clangd; got {kinds:?}",
1270 );
1271 assert!(
1272 kinds
1273 .iter()
1274 .any(|k| matches!(k, ServerKind::Custom(id) if id.as_ref() == "custom-clangd")),
1275 "custom-clangd should be appended as Custom; got {kinds:?}",
1276 );
1277 }
1278
1279 #[test]
1280 fn user_override_with_disabled_true_drops_builtin() {
1281 let config = Config {
1284 lsp_servers: vec![UserServerDef {
1285 id: "clangd".to_string(),
1286 disabled: true,
1287 ..UserServerDef::default()
1288 }],
1289 ..Config::default()
1290 };
1291
1292 let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1293 assert!(
1294 !cpp_servers.iter().any(|s| s.kind.id_str() == "clangd"),
1295 "disabled user override should drop the built-in; got {:?}",
1296 cpp_servers.iter().map(|s| &s.kind).collect::<Vec<_>>(),
1297 );
1298 }
1299
1300 fn touch_exe(path: &Path) {
1303 if let Some(parent) = path.parent() {
1304 std::fs::create_dir_all(parent).unwrap();
1305 }
1306 std::fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap();
1307 #[cfg(unix)]
1308 {
1309 use std::os::unix::fs::PermissionsExt;
1310 let mut perms = std::fs::metadata(path).unwrap().permissions();
1311 perms.set_mode(0o755);
1312 std::fs::set_permissions(path, perms).unwrap();
1313 }
1314 }
1315
1316 #[test]
1317 fn resolve_lsp_binary_prefers_project_node_modules() {
1318 let tmp = tempfile::tempdir().unwrap();
1319 let project = tmp.path();
1320 let local_bin = project.join("node_modules").join(".bin");
1321 touch_exe(&local_bin.join("typescript-language-server"));
1322
1323 let resolved = resolve_lsp_binary("typescript-language-server", Some(project), &[]);
1324 assert_eq!(
1325 resolved.as_deref(),
1326 Some(local_bin.join("typescript-language-server").as_path())
1327 );
1328 }
1329
1330 #[test]
1331 fn resolve_lsp_binary_falls_back_to_extra_paths() {
1332 let tmp = tempfile::tempdir().unwrap();
1333 let project = tmp.path().join("project");
1334 std::fs::create_dir_all(&project).unwrap();
1335
1336 let extra_a = tmp.path().join("extra_a");
1337 let extra_b = tmp.path().join("extra_b");
1338 std::fs::create_dir_all(&extra_a).unwrap();
1339 std::fs::create_dir_all(&extra_b).unwrap();
1340 touch_exe(&extra_b.join("yaml-language-server"));
1341
1342 let resolved = resolve_lsp_binary(
1343 "yaml-language-server",
1344 Some(&project),
1345 &[extra_a.clone(), extra_b.clone()],
1346 );
1347 assert_eq!(
1348 resolved.as_deref(),
1349 Some(extra_b.join("yaml-language-server").as_path())
1350 );
1351 }
1352
1353 #[test]
1354 fn resolve_lsp_binary_extra_paths_search_in_order() {
1355 let tmp = tempfile::tempdir().unwrap();
1356 let extra_a = tmp.path().join("extra_a");
1357 let extra_b = tmp.path().join("extra_b");
1358 std::fs::create_dir_all(&extra_a).unwrap();
1359 std::fs::create_dir_all(&extra_b).unwrap();
1360 touch_exe(&extra_a.join("bash-language-server"));
1362 touch_exe(&extra_b.join("bash-language-server"));
1363
1364 let resolved = resolve_lsp_binary(
1365 "bash-language-server",
1366 None,
1367 &[extra_a.clone(), extra_b.clone()],
1368 );
1369 assert_eq!(
1370 resolved.as_deref(),
1371 Some(extra_a.join("bash-language-server").as_path())
1372 );
1373 }
1374
1375 #[test]
1376 fn resolve_lsp_binary_project_root_wins_over_extra_paths() {
1377 let tmp = tempfile::tempdir().unwrap();
1378 let project = tmp.path().join("project");
1379 let local_bin = project.join("node_modules").join(".bin");
1380 touch_exe(&local_bin.join("pyright-langserver"));
1381
1382 let extra = tmp.path().join("extra");
1383 std::fs::create_dir_all(&extra).unwrap();
1384 touch_exe(&extra.join("pyright-langserver"));
1385
1386 let resolved = resolve_lsp_binary(
1387 "pyright-langserver",
1388 Some(&project),
1389 std::slice::from_ref(&extra),
1390 );
1391 assert_eq!(
1392 resolved.as_deref(),
1393 Some(local_bin.join("pyright-langserver").as_path())
1394 );
1395 }
1396
1397 #[test]
1398 fn resolve_lsp_binary_returns_none_for_missing_binary() {
1399 let tmp = tempfile::tempdir().unwrap();
1400 let project = tmp.path().join("project");
1401 std::fs::create_dir_all(&project).unwrap();
1402
1403 let resolved =
1405 resolve_lsp_binary("aft-test-nonexistent-binary-xyz123", Some(&project), &[]);
1406 assert!(resolved.is_none());
1407 }
1408
1409 #[test]
1410 fn resolve_lsp_binary_handles_missing_node_modules_gracefully() {
1411 let tmp = tempfile::tempdir().unwrap();
1414 let project = tmp.path().join("project");
1415 std::fs::create_dir_all(&project).unwrap();
1416
1417 let extra = tmp.path().join("extra");
1418 std::fs::create_dir_all(&extra).unwrap();
1419 touch_exe(&extra.join("gopls"));
1420
1421 let resolved = resolve_lsp_binary("gopls", Some(&project), std::slice::from_ref(&extra));
1422 assert_eq!(resolved.as_deref(), Some(extra.join("gopls").as_path()));
1423 }
1424
1425 #[test]
1426 fn resolve_lsp_binary_skips_nonexistent_extra_path() {
1427 let tmp = tempfile::tempdir().unwrap();
1428 let missing = tmp.path().join("missing");
1429 let valid = tmp.path().join("valid");
1430 std::fs::create_dir_all(&valid).unwrap();
1431 touch_exe(&valid.join("clangd"));
1432
1433 let resolved = resolve_lsp_binary("clangd", None, &[missing, valid.clone()]);
1434
1435 assert_eq!(resolved.as_deref(), Some(valid.join("clangd").as_path()));
1436 }
1437
1438 #[test]
1439 fn resolve_lsp_binary_skips_file_extra_path() {
1440 let tmp = tempfile::tempdir().unwrap();
1441 let file = tmp.path().join("not-a-dir");
1442 let valid = tmp.path().join("valid");
1443 std::fs::write(&file, "not a directory").unwrap();
1444 std::fs::create_dir_all(&valid).unwrap();
1445 touch_exe(&valid.join("lua-language-server"));
1446
1447 let resolved = resolve_lsp_binary("lua-language-server", None, &[file, valid.clone()]);
1448
1449 assert_eq!(
1450 resolved.as_deref(),
1451 Some(valid.join("lua-language-server").as_path())
1452 );
1453 }
1454
1455 #[test]
1456 fn resolve_lsp_binary_skips_deleted_extra_path() {
1457 let tmp = tempfile::tempdir().unwrap();
1458 let deleted = tmp.path().join("deleted");
1459 let valid = tmp.path().join("valid");
1460 std::fs::create_dir_all(&deleted).unwrap();
1461 std::fs::remove_dir(&deleted).unwrap();
1462 std::fs::create_dir_all(&valid).unwrap();
1463 touch_exe(&valid.join("svelte-language-server"));
1464
1465 let resolved =
1466 resolve_lsp_binary("svelte-language-server", None, &[deleted, valid.clone()]);
1467
1468 assert_eq!(
1469 resolved.as_deref(),
1470 Some(valid.join("svelte-language-server").as_path())
1471 );
1472 }
1473
1474 #[allow(dead_code)]
1477 fn _path_buf_used(_p: PathBuf) {}
1478}