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