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