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