use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use crate::config::{Config, UserServerDef};
pub fn resolve_lsp_binary(
binary: &str,
project_root: Option<&Path>,
extra_paths: &[PathBuf],
) -> Option<PathBuf> {
if let Some(root) = project_root {
let local_bin = root.join("node_modules").join(".bin");
if let Some(found) = probe_dir(&local_bin, binary) {
return Some(found);
}
}
for dir in extra_paths {
if let Some(found) = probe_dir(dir, binary) {
return Some(found);
}
}
which::which(binary).ok()
}
fn probe_dir(dir: &Path, binary: &str) -> Option<PathBuf> {
if !dir.is_dir() {
return None;
}
let direct = dir.join(binary);
if direct.is_file() {
return Some(direct);
}
if cfg!(windows) {
for ext in ["cmd", "exe", "bat"] {
let candidate = dir.join(format!("{binary}.{ext}"));
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ServerKind {
TypeScript,
Python, Rust,
Go,
Bash,
Yaml,
Ty, Clojure,
Dart,
ElixirLs,
FSharp,
Gleam,
Haskell,
Jdtls, Julia,
Nixd,
OcamlLsp,
PhpIntelephense,
RubyLsp,
SourceKit, CSharp,
Razor,
Clangd,
LuaLs,
Zls,
Tinymist,
KotlinLs,
Texlab,
Oxlint,
TerraformLs,
Vue,
Astro,
Prisma, Biome,
Svelte,
Dockerfile,
Custom(Arc<str>),
}
impl ServerKind {
pub fn id_str(&self) -> &str {
match self {
Self::TypeScript => "typescript",
Self::Python => "python",
Self::Rust => "rust",
Self::Go => "go",
Self::Bash => "bash",
Self::Yaml => "yaml",
Self::Ty => "ty",
Self::Clojure => "clojure-lsp",
Self::Dart => "dart",
Self::ElixirLs => "elixir-ls",
Self::FSharp => "fsharp",
Self::Gleam => "gleam",
Self::Haskell => "haskell-language-server",
Self::Jdtls => "jdtls",
Self::Julia => "julials",
Self::Nixd => "nixd",
Self::OcamlLsp => "ocaml-lsp",
Self::PhpIntelephense => "php-intelephense",
Self::RubyLsp => "ruby-lsp",
Self::SourceKit => "sourcekit-lsp",
Self::CSharp => "csharp",
Self::Razor => "razor",
Self::Clangd => "clangd",
Self::LuaLs => "lua-ls",
Self::Zls => "zls",
Self::Tinymist => "tinymist",
Self::KotlinLs => "kotlin-ls",
Self::Texlab => "texlab",
Self::Oxlint => "oxlint",
Self::TerraformLs => "terraform",
Self::Vue => "vue",
Self::Astro => "astro",
Self::Prisma => "prisma",
Self::Biome => "biome",
Self::Svelte => "svelte",
Self::Dockerfile => "dockerfile",
Self::Custom(id) => id.as_ref(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerDef {
pub kind: ServerKind,
pub name: String,
pub extensions: Vec<String>,
pub binary: String,
pub args: Vec<String>,
pub root_markers: Vec<String>,
pub env: HashMap<String, String>,
pub initialization_options: Option<serde_json::Value>,
}
impl ServerDef {
pub fn matches_extension(&self, ext: &str) -> bool {
self.extensions
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(ext))
}
pub fn is_available(&self) -> bool {
which::which(&self.binary).is_ok()
}
}
pub fn builtin_servers() -> Vec<ServerDef> {
vec![
builtin_server(
ServerKind::TypeScript,
"TypeScript Language Server",
&["ts", "tsx", "js", "jsx", "mjs", "cjs"],
"typescript-language-server",
&["--stdio"],
&["tsconfig.json", "jsconfig.json", "package.json"],
),
builtin_server(
ServerKind::Python,
"Pyright",
&["py", "pyi"],
"pyright-langserver",
&["--stdio"],
&[
"pyproject.toml",
"setup.py",
"setup.cfg",
"pyrightconfig.json",
"requirements.txt",
],
),
builtin_server(
ServerKind::Rust,
"rust-analyzer",
&["rs"],
"rust-analyzer",
&[],
&["Cargo.toml", "Cargo.lock"],
),
builtin_server_with_init(
ServerKind::Go,
"gopls",
&["go"],
"gopls",
&["serve"],
&["go.mod", "go.sum"],
serde_json::json!({ "pullDiagnostics": true }),
),
builtin_server(
ServerKind::Bash,
"bash-language-server",
&["sh", "bash", "zsh"],
"bash-language-server",
&["start"],
&["package.json", ".git"],
),
builtin_server(
ServerKind::Yaml,
"yaml-language-server",
&["yaml", "yml"],
"yaml-language-server",
&["--stdio"],
&["package.json", ".git"],
),
builtin_server(
ServerKind::Ty,
"ty",
&["py", "pyi"],
"ty",
&["server"],
&[
"pyproject.toml",
"ty.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
"Pipfile",
"pyrightconfig.json",
],
),
builtin_server(
ServerKind::Clojure,
"clojure-lsp",
&["clj", "cljs", "cljc", "edn"],
"clojure-lsp",
&[],
&[
"deps.edn",
"project.clj",
"shadow-cljs.edn",
"bb.edn",
"build.boot",
],
),
builtin_server(
ServerKind::Dart,
"Dart Language Server",
&["dart"],
"dart",
&["language-server", "--lsp"],
&["pubspec.yaml", "analysis_options.yaml"],
),
builtin_server(
ServerKind::ElixirLs,
"elixir-ls",
&["ex", "exs"],
"elixir-ls",
&[],
&["mix.exs", "mix.lock"],
),
builtin_server(
ServerKind::FSharp,
"FSAutoComplete",
&["fs", "fsi", "fsx", "fsscript"],
"fsautocomplete",
&[],
&[".slnx", ".sln", ".fsproj", "global.json"],
),
builtin_server(
ServerKind::Gleam,
"Gleam Language Server",
&["gleam"],
"gleam",
&["lsp"],
&["gleam.toml"],
),
builtin_server(
ServerKind::Haskell,
"haskell-language-server",
&["hs", "lhs"],
"haskell-language-server-wrapper",
&["--lsp"],
&["stack.yaml", "cabal.project", "hie.yaml"],
),
builtin_server(
ServerKind::Jdtls,
"Eclipse JDT Language Server",
&["java"],
"jdtls",
&[],
&["pom.xml", "build.gradle", "build.gradle.kts", ".project"],
),
builtin_server(
ServerKind::Julia,
"Julia Language Server",
&["jl"],
"julia",
&[
"--startup-file=no",
"--history-file=no",
"-e",
"using LanguageServer; runserver()",
],
&["Project.toml", "Manifest.toml"],
),
builtin_server(
ServerKind::Nixd,
"nixd",
&["nix"],
"nixd",
&[],
&["flake.nix", "default.nix", "shell.nix"],
),
builtin_server(
ServerKind::OcamlLsp,
"ocaml-lsp",
&["ml", "mli"],
"ocamllsp",
&[],
&["dune-project", "dune-workspace", ".merlin", "opam"],
),
builtin_server(
ServerKind::PhpIntelephense,
"Intelephense",
&["php"],
"intelephense",
&["--stdio"],
&["composer.json", "composer.lock", ".php-version"],
),
builtin_server(
ServerKind::RubyLsp,
"ruby-lsp",
&["rb", "rake", "gemspec", "ru"],
"ruby-lsp",
&[],
&["Gemfile"],
),
builtin_server(
ServerKind::SourceKit,
"SourceKit-LSP",
&["swift"],
"sourcekit-lsp",
&[],
&["Package.swift"],
),
builtin_server(
ServerKind::CSharp,
"Roslyn Language Server",
&["cs", "csx"],
"roslyn-language-server",
&[],
&[".slnx", ".sln", ".csproj", "global.json"],
),
builtin_server(
ServerKind::Razor,
"rzls",
&["razor", "cshtml"],
"rzls",
&[],
&[".slnx", ".sln", ".csproj", "global.json"],
),
builtin_server(
ServerKind::Clangd,
"clangd",
&[
"c", "cpp", "cc", "cxx", "c++", "h", "hpp", "hh", "hxx", "h++",
],
"clangd",
&[],
&["compile_commands.json", "compile_flags.txt", ".clangd"],
),
builtin_server(
ServerKind::LuaLs,
"lua-language-server",
&["lua"],
"lua-language-server",
&[],
&[".luarc.json", ".luarc.jsonc", ".stylua.toml", "stylua.toml"],
),
builtin_server(
ServerKind::Zls,
"zls",
&["zig", "zon"],
"zls",
&[],
&["build.zig"],
),
builtin_server(
ServerKind::Tinymist,
"tinymist",
&["typ", "typc"],
"tinymist",
&[],
&["typst.toml"],
),
builtin_server(
ServerKind::KotlinLs,
"kotlin-language-server",
&["kt", "kts"],
"kotlin-language-server",
&[],
&["settings.gradle", "settings.gradle.kts", "build.gradle"],
),
builtin_server(
ServerKind::Texlab,
"texlab",
&["tex", "bib"],
"texlab",
&[],
&[".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"],
),
builtin_server(
ServerKind::Oxlint,
"oxc-language-server",
&[
"ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "vue", "astro", "svelte",
],
"oxc-language-server",
&[],
&[".oxlintrc.json", ".oxlintrc"],
),
builtin_server(
ServerKind::TerraformLs,
"terraform-ls",
&["tf", "tfvars"],
"terraform-ls",
&["serve"],
&[".terraform.lock.hcl", "terraform.tfstate"],
),
builtin_server(
ServerKind::Vue,
"Vue Language Server",
&["vue"],
"vue-language-server",
&["--stdio"],
&[
"package-lock.json",
"bun.lockb",
"bun.lock",
"pnpm-lock.yaml",
"yarn.lock",
],
),
builtin_server(
ServerKind::Astro,
"Astro Language Server",
&["astro"],
"astro-ls",
&["--stdio"],
&[
"package-lock.json",
"bun.lockb",
"bun.lock",
"pnpm-lock.yaml",
"yarn.lock",
],
),
builtin_server(
ServerKind::Prisma,
"Prisma Language Server",
&["prisma"],
"prisma",
&["language-server"],
&["schema.prisma", "package.json"],
),
builtin_server(
ServerKind::Biome,
"Biome",
&[
"ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "json", "jsonc",
],
"biome",
&["lsp-proxy"],
&["biome.json", "biome.jsonc"],
),
builtin_server(
ServerKind::Svelte,
"Svelte Language Server",
&["svelte"],
"svelteserver",
&["--stdio"],
&[
"package-lock.json",
"bun.lockb",
"bun.lock",
"pnpm-lock.yaml",
"yarn.lock",
],
),
builtin_server(
ServerKind::Dockerfile,
"Dockerfile Language Server",
&["dockerfile"],
"docker-langserver",
&["--stdio"],
&["Dockerfile", "dockerfile", ".dockerignore"],
),
]
}
pub fn servers_for_file(path: &Path, config: &Config) -> Vec<ServerDef> {
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default();
builtin_servers()
.into_iter()
.chain(config.lsp_servers.iter().filter_map(custom_server))
.filter(|server| !is_disabled(server, config))
.filter(|server| config.experimental_lsp_ty || server.kind != ServerKind::Ty)
.filter(|server| server.matches_extension(extension))
.collect()
}
pub fn is_config_file_path(path: &Path) -> bool {
const IGNORED_COMPONENTS: &[&str] = &[
"node_modules",
"target",
"vendor",
".git",
"dist",
"build",
".next",
".nuxt",
"__pycache__",
];
if path.components().any(|component| {
component
.as_os_str()
.to_str()
.is_some_and(|name| IGNORED_COMPONENTS.contains(&name))
}) {
return false;
}
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
const LOCKFILE_NAMES: &[&str] = &[
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"Cargo.lock",
"Gemfile.lock",
"poetry.lock",
"go.sum",
"bun.lock",
"bun.lockb",
];
if LOCKFILE_NAMES.contains(&file_name) {
return false;
}
builtin_config_file_names().contains(file_name)
|| (file_name.starts_with("tsconfig.") && file_name.ends_with(".json"))
}
pub fn is_config_file_path_with_custom(path: &Path, extra_markers: &[String]) -> bool {
if is_config_file_path(path) {
return true;
}
if extra_markers.is_empty() {
return false;
}
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
extra_markers.iter().any(|m| m == file_name)
}
fn builtin_config_file_names() -> &'static HashSet<String> {
static NAMES: OnceLock<HashSet<String>> = OnceLock::new();
NAMES.get_or_init(|| {
builtin_servers()
.into_iter()
.flat_map(|server| server.root_markers)
.collect()
})
}
fn builtin_server(
kind: ServerKind,
name: &str,
extensions: &[&str],
binary: &str,
args: &[&str],
root_markers: &[&str],
) -> ServerDef {
ServerDef {
kind,
name: name.to_string(),
extensions: strings(extensions),
binary: binary.to_string(),
args: strings(args),
root_markers: strings(root_markers),
env: HashMap::new(),
initialization_options: None,
}
}
fn builtin_server_with_init(
kind: ServerKind,
name: &str,
extensions: &[&str],
binary: &str,
args: &[&str],
root_markers: &[&str],
initialization_options: serde_json::Value,
) -> ServerDef {
let mut def = builtin_server(kind, name, extensions, binary, args, root_markers);
def.initialization_options = Some(initialization_options);
def
}
fn custom_server(server: &UserServerDef) -> Option<ServerDef> {
if server.disabled {
return None;
}
Some(ServerDef {
kind: ServerKind::Custom(Arc::from(server.id.as_str())),
name: server.id.clone(),
extensions: server.extensions.clone(),
binary: server.binary.clone(),
args: server.args.clone(),
root_markers: server.root_markers.clone(),
env: server.env.clone(),
initialization_options: server.initialization_options.clone(),
})
}
fn is_disabled(server: &ServerDef, config: &Config) -> bool {
config
.disabled_lsp
.contains(&server.kind.id_str().to_ascii_lowercase())
}
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::config::{Config, UserServerDef};
use super::{is_config_file_path, resolve_lsp_binary, servers_for_file, ServerKind};
fn matching_kinds(path: &str, config: &Config) -> Vec<ServerKind> {
servers_for_file(Path::new(path), config)
.into_iter()
.map(|server| server.kind)
.collect()
}
#[test]
fn test_servers_for_typescript_file() {
let kinds = matching_kinds("/tmp/file.ts", &Config::default());
assert!(
kinds.contains(&ServerKind::TypeScript),
"expected TypeScript in {kinds:?}",
);
}
#[test]
fn test_is_config_file_path_recognizes_project_graph_configs() {
for path in [
"/repo/package.json",
"/repo/tsconfig.json",
"/repo/tsconfig.build.json",
"/repo/jsconfig.json",
"/repo/pyproject.toml",
"/repo/pyrightconfig.json",
"/repo/Cargo.toml",
"/repo/go.mod",
"/repo/biome.json",
] {
assert!(
is_config_file_path(Path::new(path)),
"expected config: {path}"
);
}
for path in [
"/repo/Cargo.lock",
"/repo/go.sum",
"/repo/bun.lock",
"/repo/bun.lockb",
"/repo/package-lock.json",
"/repo/yarn.lock",
"/repo/pnpm-lock.yaml",
] {
assert!(
!is_config_file_path(Path::new(path)),
"lockfile should be excluded from config-file detection: {path}"
);
}
for path in [
"/repo/tsconfig-json",
"/repo/tsconfig.build.ts",
"/repo/cargo.toml",
"/repo/src/package.json.ts",
] {
assert!(
!is_config_file_path(Path::new(path)),
"expected non-config: {path}"
);
}
}
#[test]
fn test_typescript_co_servers() {
let kinds = matching_kinds("/tmp/file.ts", &Config::default());
assert!(kinds.contains(&ServerKind::TypeScript));
assert!(kinds.contains(&ServerKind::Biome));
assert!(kinds.contains(&ServerKind::Oxlint));
}
#[test]
fn test_typescript_co_servers_can_be_disabled() {
let mut disabled = std::collections::HashSet::new();
disabled.insert("biome".to_string());
disabled.insert("oxlint".to_string());
let config = Config {
disabled_lsp: disabled,
..Config::default()
};
assert_eq!(
matching_kinds("/tmp/file.ts", &config),
vec![ServerKind::TypeScript]
);
}
#[test]
fn test_servers_for_python_file() {
assert_eq!(
matching_kinds("/tmp/file.py", &Config::default()),
vec![ServerKind::Python]
);
}
#[test]
fn test_servers_for_rust_file() {
assert_eq!(
matching_kinds("/tmp/file.rs", &Config::default()),
vec![ServerKind::Rust]
);
}
#[test]
fn test_servers_for_go_file() {
assert_eq!(
matching_kinds("/tmp/file.go", &Config::default()),
vec![ServerKind::Go]
);
}
#[test]
fn test_servers_for_unknown_file() {
assert!(matching_kinds("/tmp/file.txt", &Config::default()).is_empty());
}
#[test]
fn test_oxlint_root_markers_exclude_package_json() {
let oxlint = super::builtin_servers()
.into_iter()
.find(|s| s.kind == ServerKind::Oxlint)
.expect("Oxlint server must be registered");
assert!(
!oxlint.root_markers.iter().any(|m| m == "package.json"),
"package.json must not be a root marker for oxlint (got {:?})",
oxlint.root_markers,
);
assert!(
oxlint.root_markers.iter().any(|m| m == ".oxlintrc.json")
|| oxlint.root_markers.iter().any(|m| m == ".oxlintrc"),
"expected an oxlint config file in root markers (got {:?})",
oxlint.root_markers,
);
}
#[test]
fn test_tsx_matches_typescript() {
let kinds = matching_kinds("/tmp/file.tsx", &Config::default());
assert!(
kinds.contains(&ServerKind::TypeScript),
"expected TypeScript in {kinds:?}",
);
}
#[test]
fn test_case_insensitive_extension() {
let kinds = matching_kinds("/tmp/file.TS", &Config::default());
assert!(
kinds.contains(&ServerKind::TypeScript),
"expected TypeScript in {kinds:?}",
);
}
#[test]
fn test_bash_and_yaml_builtins() {
assert_eq!(
matching_kinds("/tmp/file.sh", &Config::default()),
vec![ServerKind::Bash]
);
assert_eq!(
matching_kinds("/tmp/file.yaml", &Config::default()),
vec![ServerKind::Yaml]
);
}
#[test]
fn test_ty_requires_experimental_flag() {
assert_eq!(
matching_kinds("/tmp/file.py", &Config::default()),
vec![ServerKind::Python]
);
let config = Config {
experimental_lsp_ty: true,
..Config::default()
};
assert_eq!(
matching_kinds("/tmp/file.py", &config),
vec![ServerKind::Python, ServerKind::Ty]
);
}
#[test]
fn test_custom_server_matches_extension() {
let config = Config {
lsp_servers: vec![UserServerDef {
id: "my-custom-lsp".to_string(),
extensions: vec!["xyzcustom".to_string()],
binary: "my-custom-lsp".to_string(),
root_markers: vec!["custom.toml".to_string()],
..UserServerDef::default()
}],
..Config::default()
};
assert_eq!(
matching_kinds("/tmp/file.xyzcustom", &config),
vec![ServerKind::Custom(Arc::from("my-custom-lsp"))]
);
}
#[test]
fn test_custom_server_coexists_with_builtin_for_same_extension() {
let config = Config {
lsp_servers: vec![UserServerDef {
id: "tinymist-fork".to_string(),
extensions: vec!["typ".to_string()],
binary: "tinymist-fork".to_string(),
root_markers: vec!["typst.toml".to_string()],
..UserServerDef::default()
}],
..Config::default()
};
let kinds = matching_kinds("/tmp/file.typ", &config);
assert!(kinds.contains(&ServerKind::Tinymist));
assert!(kinds.contains(&ServerKind::Custom(Arc::from("tinymist-fork"))));
}
#[test]
fn test_pattern_a_servers_register_for_their_extensions() {
let cases: &[(&str, ServerKind)] = &[
("/tmp/a.clj", ServerKind::Clojure),
("/tmp/a.dart", ServerKind::Dart),
("/tmp/a.ex", ServerKind::ElixirLs),
("/tmp/a.fs", ServerKind::FSharp),
("/tmp/a.gleam", ServerKind::Gleam),
("/tmp/a.hs", ServerKind::Haskell),
("/tmp/A.java", ServerKind::Jdtls),
("/tmp/a.jl", ServerKind::Julia),
("/tmp/a.nix", ServerKind::Nixd),
("/tmp/a.ml", ServerKind::OcamlLsp),
("/tmp/a.php", ServerKind::PhpIntelephense),
("/tmp/a.rb", ServerKind::RubyLsp),
("/tmp/a.swift", ServerKind::SourceKit),
("/tmp/a.cs", ServerKind::CSharp),
("/tmp/a.razor", ServerKind::Razor),
];
for (path, expected) in cases {
let kinds = matching_kinds(path, &Config::default());
assert!(
kinds.contains(expected),
"expected {expected:?} for {path}; got {kinds:?}",
);
}
}
#[test]
fn test_pattern_c_servers_register_for_their_extensions() {
let cases: &[(&str, ServerKind)] = &[
("/tmp/a.c", ServerKind::Clangd),
("/tmp/a.cpp", ServerKind::Clangd),
("/tmp/a.h", ServerKind::Clangd),
("/tmp/a.lua", ServerKind::LuaLs),
("/tmp/a.zig", ServerKind::Zls),
("/tmp/a.typ", ServerKind::Tinymist),
("/tmp/a.kt", ServerKind::KotlinLs),
("/tmp/a.tex", ServerKind::Texlab),
("/tmp/a.tf", ServerKind::TerraformLs),
];
for (path, expected) in cases {
let kinds = matching_kinds(path, &Config::default());
assert!(
kinds.contains(expected),
"expected {expected:?} for {path}; got {kinds:?}",
);
}
}
#[test]
fn test_pattern_b_d_servers_register_for_their_extensions() {
let cases: &[(&str, ServerKind)] = &[
("/tmp/a.vue", ServerKind::Vue),
("/tmp/a.astro", ServerKind::Astro),
("/tmp/a.prisma", ServerKind::Prisma),
("/tmp/a.svelte", ServerKind::Svelte),
("/tmp/a.dockerfile", ServerKind::Dockerfile),
];
for (path, expected) in cases {
let kinds = matching_kinds(path, &Config::default());
assert!(
kinds.contains(expected),
"expected {expected:?} for {path}; got {kinds:?}",
);
}
}
#[test]
fn test_lsp_disabled_filters_out_servers_by_id() {
let mut disabled = std::collections::HashSet::new();
disabled.insert("clangd".to_string());
disabled.insert("dart".to_string());
disabled.insert("rust".to_string());
let config = Config {
disabled_lsp: disabled,
..Config::default()
};
let c_kinds = matching_kinds("/tmp/a.c", &config);
assert!(!c_kinds.contains(&ServerKind::Clangd));
let dart_kinds = matching_kinds("/tmp/a.dart", &config);
assert!(!dart_kinds.contains(&ServerKind::Dart));
let rust_kinds = matching_kinds("/tmp/a.rs", &config);
assert!(!rust_kinds.contains(&ServerKind::Rust));
let ts_kinds = matching_kinds("/tmp/a.ts", &config);
assert!(ts_kinds.contains(&ServerKind::TypeScript));
}
#[test]
fn test_server_kind_ids_are_unique() {
use std::collections::HashSet;
let servers = super::builtin_servers();
let ids: Vec<String> = servers
.iter()
.map(|s| s.kind.id_str().to_string())
.collect();
let unique: HashSet<&String> = ids.iter().collect();
assert_eq!(
ids.len(),
unique.len(),
"duplicate server IDs in registry: {ids:?}",
);
}
fn touch_exe(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}
}
#[test]
fn resolve_lsp_binary_prefers_project_node_modules() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path();
let local_bin = project.join("node_modules").join(".bin");
touch_exe(&local_bin.join("typescript-language-server"));
let resolved = resolve_lsp_binary("typescript-language-server", Some(project), &[]);
assert_eq!(
resolved.as_deref(),
Some(local_bin.join("typescript-language-server").as_path())
);
}
#[test]
fn resolve_lsp_binary_falls_back_to_extra_paths() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let extra_a = tmp.path().join("extra_a");
let extra_b = tmp.path().join("extra_b");
std::fs::create_dir_all(&extra_a).unwrap();
std::fs::create_dir_all(&extra_b).unwrap();
touch_exe(&extra_b.join("yaml-language-server"));
let resolved = resolve_lsp_binary(
"yaml-language-server",
Some(&project),
&[extra_a.clone(), extra_b.clone()],
);
assert_eq!(
resolved.as_deref(),
Some(extra_b.join("yaml-language-server").as_path())
);
}
#[test]
fn resolve_lsp_binary_extra_paths_search_in_order() {
let tmp = tempfile::tempdir().unwrap();
let extra_a = tmp.path().join("extra_a");
let extra_b = tmp.path().join("extra_b");
std::fs::create_dir_all(&extra_a).unwrap();
std::fs::create_dir_all(&extra_b).unwrap();
touch_exe(&extra_a.join("bash-language-server"));
touch_exe(&extra_b.join("bash-language-server"));
let resolved = resolve_lsp_binary(
"bash-language-server",
None,
&[extra_a.clone(), extra_b.clone()],
);
assert_eq!(
resolved.as_deref(),
Some(extra_a.join("bash-language-server").as_path())
);
}
#[test]
fn resolve_lsp_binary_project_root_wins_over_extra_paths() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let local_bin = project.join("node_modules").join(".bin");
touch_exe(&local_bin.join("pyright-langserver"));
let extra = tmp.path().join("extra");
std::fs::create_dir_all(&extra).unwrap();
touch_exe(&extra.join("pyright-langserver"));
let resolved = resolve_lsp_binary(
"pyright-langserver",
Some(&project),
std::slice::from_ref(&extra),
);
assert_eq!(
resolved.as_deref(),
Some(local_bin.join("pyright-langserver").as_path())
);
}
#[test]
fn resolve_lsp_binary_returns_none_for_missing_binary() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let resolved =
resolve_lsp_binary("aft-test-nonexistent-binary-xyz123", Some(&project), &[]);
assert!(resolved.is_none());
}
#[test]
fn resolve_lsp_binary_handles_missing_node_modules_gracefully() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let extra = tmp.path().join("extra");
std::fs::create_dir_all(&extra).unwrap();
touch_exe(&extra.join("gopls"));
let resolved = resolve_lsp_binary("gopls", Some(&project), std::slice::from_ref(&extra));
assert_eq!(resolved.as_deref(), Some(extra.join("gopls").as_path()));
}
#[test]
fn resolve_lsp_binary_skips_nonexistent_extra_path() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("missing");
let valid = tmp.path().join("valid");
std::fs::create_dir_all(&valid).unwrap();
touch_exe(&valid.join("clangd"));
let resolved = resolve_lsp_binary("clangd", None, &[missing, valid.clone()]);
assert_eq!(resolved.as_deref(), Some(valid.join("clangd").as_path()));
}
#[test]
fn resolve_lsp_binary_skips_file_extra_path() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("not-a-dir");
let valid = tmp.path().join("valid");
std::fs::write(&file, "not a directory").unwrap();
std::fs::create_dir_all(&valid).unwrap();
touch_exe(&valid.join("lua-language-server"));
let resolved = resolve_lsp_binary("lua-language-server", None, &[file, valid.clone()]);
assert_eq!(
resolved.as_deref(),
Some(valid.join("lua-language-server").as_path())
);
}
#[test]
fn resolve_lsp_binary_skips_deleted_extra_path() {
let tmp = tempfile::tempdir().unwrap();
let deleted = tmp.path().join("deleted");
let valid = tmp.path().join("valid");
std::fs::create_dir_all(&deleted).unwrap();
std::fs::remove_dir(&deleted).unwrap();
std::fs::create_dir_all(&valid).unwrap();
touch_exe(&valid.join("svelte-language-server"));
let resolved =
resolve_lsp_binary("svelte-language-server", None, &[deleted, valid.clone()]);
assert_eq!(
resolved.as_deref(),
Some(valid.join("svelte-language-server").as_path())
);
}
#[allow(dead_code)]
fn _path_buf_used(_p: PathBuf) {}
}