Skip to main content

aft/lsp/
registry.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, OnceLock};
4
5use crate::config::{Config, UserServerDef};
6
7/// Resolve an LSP binary name to a full path.
8///
9/// Resolution order (mirrors `format::resolve_tool` for formatters/checkers):
10/// 1. `<project_root>/node_modules/.bin/<binary>` — project devDependency
11/// 2. Each path in `extra_paths` joined with `<binary>` — plugin-supplied
12///    auto-install cache locations such as
13///    `~/.cache/aft/lsp-packages/<pkg>/node_modules/.bin/`
14/// 3. PATH via [`which::which`]
15///
16/// On Windows, candidate directories are also probed with `.cmd`, `.exe`,
17/// and `.bat` extensions because npm-installed shims often use `.cmd`.
18/// `which::which` handles PATHEXT natively for the PATH fallback.
19pub fn resolve_lsp_binary(
20    binary: &str,
21    project_root: Option<&Path>,
22    extra_paths: &[PathBuf],
23) -> Option<PathBuf> {
24    // 1. Project-local node_modules/.bin
25    if let Some(root) = project_root {
26        let local_bin = root.join("node_modules").join(".bin");
27        if let Some(found) = probe_dir(&local_bin, binary) {
28            return Some(found);
29        }
30    }
31
32    // 2. Plugin-supplied extra paths (auto-install cache, etc.)
33    for dir in extra_paths {
34        if let Some(found) = probe_dir(dir, binary) {
35            return Some(found);
36        }
37    }
38
39    // 3. PATH fallback
40    which::which(binary).ok()
41}
42
43/// Check `dir/<binary>` and (on Windows) `dir/<binary>.cmd|.exe|.bat`.
44fn probe_dir(dir: &Path, binary: &str) -> Option<PathBuf> {
45    if !dir.is_dir() {
46        return None;
47    }
48
49    if cfg!(windows) {
50        // npm creates both an extensionless POSIX shell shim and a `.cmd`
51        // wrapper under node_modules/.bin. The extensionless shim exists on
52        // Windows too but is not a Win32 executable, so prefer Windows-native
53        // wrappers before falling back to the direct path.
54        for ext in ["cmd", "exe", "bat"] {
55            let candidate = dir.join(format!("{binary}.{ext}"));
56            if candidate.is_file() {
57                return Some(candidate);
58            }
59        }
60    }
61
62    let direct = dir.join(binary);
63    if direct.is_file() {
64        return Some(direct);
65    }
66
67    None
68}
69
70/// Unique identifier for a language server kind.
71///
72/// IDs match OpenCode's `lsp/server.ts` registry where possible so users can
73/// refer to the same names in `lsp.disabled` config across both projects.
74#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75pub enum ServerKind {
76    // --- Built-in (existing, pre-v0.17.0) ---
77    TypeScript,
78    Python, // pyright
79    Rust,
80    Go,
81    Bash,
82    Yaml,
83    Ty, // experimental Astral Python LSP
84    // --- v0.17.0: PATH-only servers (Pattern A) ---
85    Clojure,
86    Dart,
87    ElixirLs,
88    FSharp,
89    Gleam,
90    Haskell,
91    Jdtls, // Java
92    Julia,
93    Nixd,
94    OcamlLsp,
95    PhpIntelephense,
96    RubyLsp,
97    SourceKit, // Swift
98    CSharp,
99    Razor,
100    // --- v0.17.0: Pattern C (PATH-first, GitHub-release auto-download in plugin) ---
101    Clangd,
102    LuaLs,
103    Zls,
104    Tinymist,
105    KotlinLs,
106    Texlab,
107    Oxlint,
108    TerraformLs,
109    // --- v0.17.0: Pattern B/D (npm auto-installable in plugin) ---
110    Vue,
111    Astro,
112    Prisma, // resolves the project's `prisma` CLI from node_modules; not auto-installed by AFT
113    Biome,
114    Svelte,
115    Dockerfile,
116    Custom(Arc<str>),
117}
118
119impl ServerKind {
120    pub fn id_str(&self) -> &str {
121        match self {
122            Self::TypeScript => "typescript",
123            Self::Python => "python",
124            Self::Rust => "rust",
125            Self::Go => "go",
126            Self::Bash => "bash",
127            Self::Yaml => "yaml",
128            Self::Ty => "ty",
129            // Pattern A
130            Self::Clojure => "clojure-lsp",
131            Self::Dart => "dart",
132            Self::ElixirLs => "elixir-ls",
133            Self::FSharp => "fsharp",
134            Self::Gleam => "gleam",
135            Self::Haskell => "haskell-language-server",
136            Self::Jdtls => "jdtls",
137            Self::Julia => "julials",
138            Self::Nixd => "nixd",
139            Self::OcamlLsp => "ocaml-lsp",
140            Self::PhpIntelephense => "php-intelephense",
141            Self::RubyLsp => "ruby-lsp",
142            Self::SourceKit => "sourcekit-lsp",
143            Self::CSharp => "csharp",
144            Self::Razor => "razor",
145            // Pattern C
146            Self::Clangd => "clangd",
147            Self::LuaLs => "lua-ls",
148            Self::Zls => "zls",
149            Self::Tinymist => "tinymist",
150            Self::KotlinLs => "kotlin-ls",
151            Self::Texlab => "texlab",
152            Self::Oxlint => "oxlint",
153            Self::TerraformLs => "terraform",
154            // Pattern B/D
155            Self::Vue => "vue",
156            Self::Astro => "astro",
157            Self::Prisma => "prisma",
158            Self::Biome => "biome",
159            Self::Svelte => "svelte",
160            Self::Dockerfile => "dockerfile",
161            Self::Custom(id) => id.as_ref(),
162        }
163    }
164}
165
166/// Definition of a language server.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ServerDef {
169    pub kind: ServerKind,
170    /// Display name.
171    pub name: String,
172    /// File extensions this server handles.
173    pub extensions: Vec<String>,
174    /// Binary name to look up on PATH.
175    pub binary: String,
176    /// Arguments to pass when spawning.
177    pub args: Vec<String>,
178    /// Root marker files — presence indicates a workspace root.
179    pub root_markers: Vec<String>,
180    /// Extra environment variables for this server process.
181    pub env: HashMap<String, String>,
182    /// Optional JSON initializationOptions for the initialize request.
183    pub initialization_options: Option<serde_json::Value>,
184}
185
186impl ServerDef {
187    /// Check if this server handles a given file extension.
188    pub fn matches_extension(&self, ext: &str) -> bool {
189        self.extensions
190            .iter()
191            .any(|candidate| candidate.eq_ignore_ascii_case(ext))
192    }
193
194    /// Check if the server binary is available on PATH.
195    pub fn is_available(&self) -> bool {
196        which::which(&self.binary).is_ok()
197    }
198}
199
200/// Built-in server definitions.
201pub fn builtin_servers() -> Vec<ServerDef> {
202    vec![
203        builtin_server(
204            ServerKind::TypeScript,
205            "TypeScript Language Server",
206            &["ts", "tsx", "js", "jsx", "mjs", "cjs"],
207            "typescript-language-server",
208            &["--stdio"],
209            &["tsconfig.json", "jsconfig.json", "package.json"],
210        ),
211        builtin_server(
212            ServerKind::Python,
213            "Pyright",
214            &["py", "pyi"],
215            "pyright-langserver",
216            &["--stdio"],
217            &[
218                "pyproject.toml",
219                "setup.py",
220                "setup.cfg",
221                "pyrightconfig.json",
222                "requirements.txt",
223            ],
224        ),
225        builtin_server(
226            ServerKind::Rust,
227            "rust-analyzer",
228            &["rs"],
229            "rust-analyzer",
230            &[],
231            &["Cargo.toml", "Cargo.lock"],
232        ),
233        // gopls requires opt-in for `textDocument/diagnostic` (LSP 3.17 pull)
234        // via the `pullDiagnostics` initializationOption. Without this the
235        // server still publishes via push but ignores pull requests.
236        // See https://github.com/golang/tools/blob/master/gopls/doc/settings.md
237        builtin_server_with_init(
238            ServerKind::Go,
239            "gopls",
240            &["go"],
241            "gopls",
242            &["serve"],
243            &["go.mod", "go.sum"],
244            serde_json::json!({ "pullDiagnostics": true }),
245        ),
246        builtin_server(
247            ServerKind::Bash,
248            "bash-language-server",
249            &["sh", "bash", "zsh"],
250            "bash-language-server",
251            &["start"],
252            &["package.json", ".git"],
253        ),
254        builtin_server(
255            ServerKind::Yaml,
256            "yaml-language-server",
257            &["yaml", "yml"],
258            "yaml-language-server",
259            &["--stdio"],
260            &["package.json", ".git"],
261        ),
262        builtin_server(
263            ServerKind::Ty,
264            "ty",
265            &["py", "pyi"],
266            "ty",
267            &["server"],
268            &[
269                "pyproject.toml",
270                "ty.toml",
271                "setup.py",
272                "setup.cfg",
273                "requirements.txt",
274                "Pipfile",
275                "pyrightconfig.json",
276            ],
277        ),
278        // ===== Pattern A: PATH-only servers =====
279        // These servers are not auto-installed by AFT (the toolchain itself
280        // ships the LSP, e.g. `dart`, `gleam`; or installation is highly
281        // platform-specific, e.g. `jdtls`). Users install via system package
282        // manager / language toolchain. AFT registers the def so users with
283        // the binary on PATH get LSP coverage.
284        builtin_server(
285            ServerKind::Clojure,
286            "clojure-lsp",
287            &["clj", "cljs", "cljc", "edn"],
288            "clojure-lsp",
289            &[],
290            &[
291                "deps.edn",
292                "project.clj",
293                "shadow-cljs.edn",
294                "bb.edn",
295                "build.boot",
296            ],
297        ),
298        builtin_server(
299            ServerKind::Dart,
300            "Dart Language Server",
301            &["dart"],
302            "dart",
303            &["language-server", "--lsp"],
304            &["pubspec.yaml", "analysis_options.yaml"],
305        ),
306        builtin_server(
307            ServerKind::ElixirLs,
308            "elixir-ls",
309            &["ex", "exs"],
310            "elixir-ls",
311            &[],
312            &["mix.exs", "mix.lock"],
313        ),
314        builtin_server(
315            ServerKind::FSharp,
316            "FSAutoComplete",
317            &["fs", "fsi", "fsx", "fsscript"],
318            "fsautocomplete",
319            &[],
320            &[".slnx", ".sln", ".fsproj", "global.json"],
321        ),
322        builtin_server(
323            ServerKind::Gleam,
324            "Gleam Language Server",
325            &["gleam"],
326            "gleam",
327            &["lsp"],
328            &["gleam.toml"],
329        ),
330        builtin_server(
331            ServerKind::Haskell,
332            "haskell-language-server",
333            &["hs", "lhs"],
334            "haskell-language-server-wrapper",
335            &["--lsp"],
336            &["stack.yaml", "cabal.project", "hie.yaml"],
337        ),
338        builtin_server(
339            ServerKind::Jdtls,
340            "Eclipse JDT Language Server",
341            &["java"],
342            "jdtls",
343            &[],
344            &["pom.xml", "build.gradle", "build.gradle.kts", ".project"],
345        ),
346        builtin_server(
347            ServerKind::Julia,
348            "Julia Language Server",
349            &["jl"],
350            "julia",
351            &[
352                "--startup-file=no",
353                "--history-file=no",
354                "-e",
355                "using LanguageServer; runserver()",
356            ],
357            &["Project.toml", "Manifest.toml"],
358        ),
359        builtin_server(
360            ServerKind::Nixd,
361            "nixd",
362            &["nix"],
363            "nixd",
364            &[],
365            &["flake.nix", "default.nix", "shell.nix"],
366        ),
367        builtin_server(
368            ServerKind::OcamlLsp,
369            "ocaml-lsp",
370            &["ml", "mli"],
371            "ocamllsp",
372            &[],
373            &["dune-project", "dune-workspace", ".merlin", "opam"],
374        ),
375        builtin_server(
376            ServerKind::PhpIntelephense,
377            "Intelephense",
378            &["php"],
379            "intelephense",
380            &["--stdio"],
381            &["composer.json", "composer.lock", ".php-version"],
382        ),
383        builtin_server(
384            ServerKind::RubyLsp,
385            "ruby-lsp",
386            &["rb", "rake", "gemspec", "ru"],
387            "ruby-lsp",
388            &[],
389            &["Gemfile"],
390        ),
391        builtin_server(
392            ServerKind::SourceKit,
393            "SourceKit-LSP",
394            &["swift"],
395            "sourcekit-lsp",
396            &[],
397            &["Package.swift"],
398        ),
399        builtin_server(
400            ServerKind::CSharp,
401            "Roslyn Language Server",
402            &["cs", "csx"],
403            "roslyn-language-server",
404            &[],
405            &[".slnx", ".sln", ".csproj", "global.json"],
406        ),
407        builtin_server(
408            ServerKind::Razor,
409            "rzls",
410            &["razor", "cshtml"],
411            "rzls",
412            &[],
413            &[".slnx", ".sln", ".csproj", "global.json"],
414        ),
415        // ===== Pattern C: PATH-first; plugin auto-downloads from GitHub releases =====
416        builtin_server(
417            ServerKind::Clangd,
418            "clangd",
419            &[
420                "c", "cpp", "cc", "cxx", "c++", "h", "hpp", "hh", "hxx", "h++",
421            ],
422            "clangd",
423            &[],
424            &["compile_commands.json", "compile_flags.txt", ".clangd"],
425        ),
426        builtin_server(
427            ServerKind::LuaLs,
428            "lua-language-server",
429            &["lua"],
430            "lua-language-server",
431            &[],
432            &[".luarc.json", ".luarc.jsonc", ".stylua.toml", "stylua.toml"],
433        ),
434        builtin_server(
435            ServerKind::Zls,
436            "zls",
437            &["zig", "zon"],
438            "zls",
439            &[],
440            &["build.zig"],
441        ),
442        builtin_server(
443            ServerKind::Tinymist,
444            "tinymist",
445            &["typ", "typc"],
446            "tinymist",
447            &[],
448            &["typst.toml"],
449        ),
450        builtin_server(
451            ServerKind::KotlinLs,
452            "kotlin-language-server",
453            &["kt", "kts"],
454            "kotlin-language-server",
455            &[],
456            &["settings.gradle", "settings.gradle.kts", "build.gradle"],
457        ),
458        builtin_server(
459            ServerKind::Texlab,
460            "texlab",
461            &["tex", "bib"],
462            "texlab",
463            &[],
464            &[".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"],
465        ),
466        builtin_server(
467            ServerKind::Oxlint,
468            "oxc-language-server",
469            // Same JS/TS family as TypeScript LS; coexists rather than replaces.
470            &[
471                "ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "vue", "astro", "svelte",
472            ],
473            "oxc-language-server",
474            &[],
475            // Only trigger on actual oxlint config files. We previously also
476            // matched `package.json`, but that fired oxc on every JS/TS project
477            // whether they used oxlint or not, producing a persistent warning
478            // for the (overwhelmingly common) case where the user never opted
479            // into oxlint. Users who run oxlint will have one of these config
480            // files; everyone else gets silence.
481            &[".oxlintrc.json", ".oxlintrc"],
482        ),
483        builtin_server(
484            ServerKind::TerraformLs,
485            "terraform-ls",
486            &["tf", "tfvars"],
487            "terraform-ls",
488            &["serve"],
489            &[".terraform.lock.hcl", "terraform.tfstate"],
490        ),
491        // ===== Pattern B/D: PATH-first; plugin auto-installs from npm =====
492        // Order matters slightly: vue/svelte/astro use TypeScript-family
493        // extensions when paired with their primary file extension. Each
494        // server only runs against its own primary extension here; agents
495        // run TypeScript LS for the rest.
496        builtin_server(
497            ServerKind::Vue,
498            "Vue Language Server",
499            &["vue"],
500            "vue-language-server",
501            &["--stdio"],
502            &[
503                "package-lock.json",
504                "bun.lockb",
505                "bun.lock",
506                "pnpm-lock.yaml",
507                "yarn.lock",
508            ],
509        ),
510        builtin_server(
511            ServerKind::Astro,
512            "Astro Language Server",
513            &["astro"],
514            "astro-ls",
515            &["--stdio"],
516            &[
517                "package-lock.json",
518                "bun.lockb",
519                "bun.lock",
520                "pnpm-lock.yaml",
521                "yarn.lock",
522            ],
523        ),
524        // Prisma's LSP runs via `prisma language-server` from the project's
525        // own `prisma` CLI (resolved through node_modules/.bin). AFT does NOT
526        // auto-install the prisma package — users get LSP coverage when their
527        // project has prisma as a devDependency.
528        builtin_server(
529            ServerKind::Prisma,
530            "Prisma Language Server",
531            &["prisma"],
532            "prisma",
533            &["language-server"],
534            &["schema.prisma", "package.json"],
535        ),
536        // Biome: lint+format LSP for the JS/TS family. Coexists with the
537        // TypeScript Language Server (different responsibilities). Disable
538        // via `lsp.disabled: ["biome"]` when not desired.
539        builtin_server(
540            ServerKind::Biome,
541            "Biome",
542            &[
543                "ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "json", "jsonc",
544            ],
545            "biome",
546            &["lsp-proxy"],
547            &["biome.json", "biome.jsonc"],
548        ),
549        builtin_server(
550            ServerKind::Svelte,
551            "Svelte Language Server",
552            &["svelte"],
553            "svelteserver",
554            &["--stdio"],
555            &[
556                "package-lock.json",
557                "bun.lockb",
558                "bun.lock",
559                "pnpm-lock.yaml",
560                "yarn.lock",
561            ],
562        ),
563        builtin_server(
564            ServerKind::Dockerfile,
565            "Dockerfile Language Server",
566            // OpenCode special-cases the literal "Dockerfile" name; AFT's
567            // extension-only matcher cannot. Users can `aft_outline`/edit
568            // Dockerfiles by extension `.dockerfile`. Plain `Dockerfile`
569            // files won't auto-trigger LSP — acknowledged limitation; can
570            // be revisited if users complain.
571            &["dockerfile"],
572            "docker-langserver",
573            &["--stdio"],
574            &["Dockerfile", "dockerfile", ".dockerignore"],
575        ),
576        // NOTE: ESLint LSP intentionally not registered — OpenCode resolves it
577        // through `Module.resolve("eslint", root)` and runs custom server-side
578        // logic. AFT does not implement that flow yet; users with ESLint can
579        // run `eslint --fix` via bash.
580    ]
581}
582
583/// Find all server definitions that handle a given file path.
584pub fn servers_for_file(path: &Path, config: &Config) -> Vec<ServerDef> {
585    let extension = path
586        .extension()
587        .and_then(|ext| ext.to_str())
588        .unwrap_or_default();
589
590    resolved_servers(config)
591        .into_iter()
592        .filter(|server| !is_disabled(server, config))
593        .filter(|server| config.experimental_lsp_ty || server.kind != ServerKind::Ty)
594        .filter(|server| server.matches_extension(extension))
595        .collect()
596}
597
598/// Resolve the full server set after applying user overrides.
599///
600/// When a user-defined server's `id` matches a built-in server's `id_str()`
601/// (e.g. `lsp.servers.clangd`), the user entry REPLACES the built-in entry
602/// rather than registering alongside it. Fields the user left at their
603/// default value (empty array, empty string, empty map, None) are inherited
604/// from the built-in so users only have to specify what they actually want
605/// to override.
606///
607/// User-defined servers whose `id` does not match any built-in are appended
608/// as `ServerKind::Custom(id)` with no merging — they're standalone.
609fn resolved_servers(config: &Config) -> Vec<ServerDef> {
610    let mut servers = builtin_servers();
611    for user in &config.lsp_servers {
612        if user.disabled {
613            // Disabled user override means "drop the matching built-in if any"
614            // — equivalent to adding the id to `lsp.disabled`. We don't include
615            // it in the result regardless of whether it matched.
616            servers.retain(|s| s.kind.id_str() != user.id);
617            continue;
618        }
619        if let Some(position) = servers.iter().position(|s| s.kind.id_str() == user.id) {
620            // Replace the built-in with a merged ServerDef. Keep the built-in
621            // `kind` so callers that match on enum variants (e.g. cap probing
622            // for `ServerKind::Go`) continue to work. Inherit any field the
623            // user left at its default value.
624            let builtin = &servers[position];
625            let merged = ServerDef {
626                kind: builtin.kind.clone(),
627                name: builtin.name.clone(),
628                extensions: if user.extensions.is_empty() {
629                    builtin.extensions.clone()
630                } else {
631                    user.extensions.clone()
632                },
633                binary: if user.binary.is_empty() {
634                    builtin.binary.clone()
635                } else {
636                    user.binary.clone()
637                },
638                args: if user.args.is_empty() {
639                    builtin.args.clone()
640                } else {
641                    user.args.clone()
642                },
643                root_markers: if user.root_markers.is_empty() {
644                    builtin.root_markers.clone()
645                } else {
646                    user.root_markers.clone()
647                },
648                env: if user.env.is_empty() {
649                    builtin.env.clone()
650                } else {
651                    user.env.clone()
652                },
653                initialization_options: user
654                    .initialization_options
655                    .clone()
656                    .or_else(|| builtin.initialization_options.clone()),
657            };
658            servers[position] = merged;
659        } else if let Some(def) = custom_server(user) {
660            servers.push(def);
661        }
662    }
663    servers
664}
665
666/// Returns true when `path` is a project configuration file whose changes can
667/// affect an LSP server's workspace/project graph, even if the edited file
668/// itself is not a source file handled by that server.
669pub fn is_config_file_path(path: &Path) -> bool {
670    const IGNORED_COMPONENTS: &[&str] = &[
671        "node_modules",
672        "target",
673        "vendor",
674        ".git",
675        "dist",
676        "build",
677        ".next",
678        ".nuxt",
679        "__pycache__",
680    ];
681
682    if path.components().any(|component| {
683        component
684            .as_os_str()
685            .to_str()
686            .is_some_and(|name| IGNORED_COMPONENTS.contains(&name))
687    }) {
688        return false;
689    }
690
691    let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
692        return false;
693    };
694
695    // Lockfiles appear in root_markers for workspace-detection but should NOT
696    // trigger didChangeWatchedFiles notifications — they are regenerated by
697    // package managers constantly and notifying LSP servers on every install
698    // creates unnecessary churn without affecting language analysis.
699    // Intentional: this list is checked BEFORE builtin_config_file_names so a
700    // file that is both a root_marker and a lockfile is excluded.
701    const LOCKFILE_NAMES: &[&str] = &[
702        "package-lock.json",
703        "yarn.lock",
704        "pnpm-lock.yaml",
705        "Cargo.lock",
706        "Gemfile.lock",
707        "poetry.lock",
708        "go.sum",
709        "bun.lock",
710        "bun.lockb",
711    ];
712    if LOCKFILE_NAMES.contains(&file_name) {
713        return false;
714    }
715
716    builtin_config_file_names().contains(file_name)
717        || (file_name.starts_with("tsconfig.") && file_name.ends_with(".json"))
718}
719
720/// Extended variant that also considers root_markers from user-configured
721/// custom LSP servers (#25). Call this from contexts where Config is available.
722/// Falls back to `is_config_file_path` when `extra_markers` is empty.
723pub fn is_config_file_path_with_custom(path: &Path, extra_markers: &[String]) -> bool {
724    if is_config_file_path(path) {
725        return true;
726    }
727    if extra_markers.is_empty() {
728        return false;
729    }
730    let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
731        return false;
732    };
733    extra_markers.iter().any(|m| m == file_name)
734}
735
736fn builtin_config_file_names() -> &'static HashSet<String> {
737    static NAMES: OnceLock<HashSet<String>> = OnceLock::new();
738    NAMES.get_or_init(|| {
739        builtin_servers()
740            .into_iter()
741            .flat_map(|server| server.root_markers)
742            .collect()
743    })
744}
745
746fn builtin_server(
747    kind: ServerKind,
748    name: &str,
749    extensions: &[&str],
750    binary: &str,
751    args: &[&str],
752    root_markers: &[&str],
753) -> ServerDef {
754    ServerDef {
755        kind,
756        name: name.to_string(),
757        extensions: strings(extensions),
758        binary: binary.to_string(),
759        args: strings(args),
760        root_markers: strings(root_markers),
761        env: HashMap::new(),
762        initialization_options: None,
763    }
764}
765
766/// Builder variant of [`builtin_server`] that includes a default
767/// `initializationOptions` payload — used for servers that need server-specific
768/// settings to enable LSP features (e.g., gopls's `pullDiagnostics`).
769fn builtin_server_with_init(
770    kind: ServerKind,
771    name: &str,
772    extensions: &[&str],
773    binary: &str,
774    args: &[&str],
775    root_markers: &[&str],
776    initialization_options: serde_json::Value,
777) -> ServerDef {
778    let mut def = builtin_server(kind, name, extensions, binary, args, root_markers);
779    def.initialization_options = Some(initialization_options);
780    def
781}
782
783fn custom_server(server: &UserServerDef) -> Option<ServerDef> {
784    if server.disabled {
785        return None;
786    }
787
788    Some(ServerDef {
789        kind: ServerKind::Custom(Arc::from(server.id.as_str())),
790        name: server.id.clone(),
791        extensions: server.extensions.clone(),
792        binary: server.binary.clone(),
793        args: server.args.clone(),
794        root_markers: server.root_markers.clone(),
795        env: server.env.clone(),
796        initialization_options: server.initialization_options.clone(),
797    })
798}
799
800fn is_disabled(server: &ServerDef, config: &Config) -> bool {
801    config
802        .disabled_lsp
803        .contains(&server.kind.id_str().to_ascii_lowercase())
804}
805
806fn strings(values: &[&str]) -> Vec<String> {
807    values.iter().map(|value| (*value).to_string()).collect()
808}
809
810#[cfg(test)]
811mod tests {
812    use std::path::{Path, PathBuf};
813    use std::sync::Arc;
814
815    use crate::config::{Config, UserServerDef};
816
817    use super::{is_config_file_path, resolve_lsp_binary, servers_for_file, ServerKind};
818
819    fn matching_kinds(path: &str, config: &Config) -> Vec<ServerKind> {
820        servers_for_file(Path::new(path), config)
821            .into_iter()
822            .map(|server| server.kind)
823            .collect()
824    }
825
826    #[test]
827    fn test_servers_for_typescript_file() {
828        // TS files match TypeScript (primary) plus Biome / Oxlint / Eslint
829        // co-servers. The full set is asserted in `test_typescript_co_servers`.
830        let kinds = matching_kinds("/tmp/file.ts", &Config::default());
831        assert!(
832            kinds.contains(&ServerKind::TypeScript),
833            "expected TypeScript in {kinds:?}",
834        );
835    }
836
837    #[test]
838    fn test_is_config_file_path_recognizes_project_graph_configs() {
839        // These ARE config files that should trigger didChangeWatchedFiles.
840        for path in [
841            "/repo/package.json",
842            "/repo/tsconfig.json",
843            "/repo/tsconfig.build.json",
844            "/repo/jsconfig.json",
845            "/repo/pyproject.toml",
846            "/repo/pyrightconfig.json",
847            "/repo/Cargo.toml",
848            "/repo/go.mod",
849            "/repo/biome.json",
850        ] {
851            assert!(
852                is_config_file_path(Path::new(path)),
853                "expected config: {path}"
854            );
855        }
856
857        // Lockfiles are excluded even though they appear in root_markers —
858        // they change on every package install and triggering LSP re-analysis
859        // on each install creates unnecessary churn. See the LOCKFILE_NAMES
860        // list in is_config_file_path().
861        for path in [
862            "/repo/Cargo.lock",
863            "/repo/go.sum",
864            "/repo/bun.lock",
865            "/repo/bun.lockb",
866            "/repo/package-lock.json",
867            "/repo/yarn.lock",
868            "/repo/pnpm-lock.yaml",
869        ] {
870            assert!(
871                !is_config_file_path(Path::new(path)),
872                "lockfile should be excluded from config-file detection: {path}"
873            );
874        }
875
876        // Non-config files
877        for path in [
878            "/repo/tsconfig-json",
879            "/repo/tsconfig.build.ts",
880            "/repo/cargo.toml",
881            "/repo/src/package.json.ts",
882        ] {
883            assert!(
884                !is_config_file_path(Path::new(path)),
885                "expected non-config: {path}"
886            );
887        }
888    }
889
890    #[test]
891    fn test_typescript_co_servers() {
892        let kinds = matching_kinds("/tmp/file.ts", &Config::default());
893        assert!(kinds.contains(&ServerKind::TypeScript));
894        assert!(kinds.contains(&ServerKind::Biome));
895        assert!(kinds.contains(&ServerKind::Oxlint));
896    }
897
898    #[test]
899    fn test_typescript_co_servers_can_be_disabled() {
900        // `lsp.disabled` lets users opt out of co-servers individually.
901        let mut disabled = std::collections::HashSet::new();
902        disabled.insert("biome".to_string());
903        disabled.insert("oxlint".to_string());
904
905        let config = Config {
906            disabled_lsp: disabled,
907            ..Config::default()
908        };
909
910        assert_eq!(
911            matching_kinds("/tmp/file.ts", &config),
912            vec![ServerKind::TypeScript]
913        );
914    }
915
916    #[test]
917    fn test_servers_for_python_file() {
918        assert_eq!(
919            matching_kinds("/tmp/file.py", &Config::default()),
920            vec![ServerKind::Python]
921        );
922    }
923
924    #[test]
925    fn test_servers_for_rust_file() {
926        assert_eq!(
927            matching_kinds("/tmp/file.rs", &Config::default()),
928            vec![ServerKind::Rust]
929        );
930    }
931
932    #[test]
933    fn test_servers_for_go_file() {
934        assert_eq!(
935            matching_kinds("/tmp/file.go", &Config::default()),
936            vec![ServerKind::Go]
937        );
938    }
939
940    #[test]
941    fn test_servers_for_unknown_file() {
942        assert!(matching_kinds("/tmp/file.txt", &Config::default()).is_empty());
943    }
944
945    #[test]
946    fn test_oxlint_root_markers_exclude_package_json() {
947        // Regression guard (v0.17.2): oxc-language-server previously listed
948        // `package.json` as a root marker, which fired oxc on every JS/TS
949        // project — including the overwhelming majority that don't use
950        // oxlint — producing a persistent "binary missing" warning whenever
951        // the binary wasn't installed. Root markers are now restricted to
952        // actual oxlint config files, mirroring user intent.
953        let oxlint = super::builtin_servers()
954            .into_iter()
955            .find(|s| s.kind == ServerKind::Oxlint)
956            .expect("Oxlint server must be registered");
957
958        assert!(
959            !oxlint.root_markers.iter().any(|m| m == "package.json"),
960            "package.json must not be a root marker for oxlint (got {:?})",
961            oxlint.root_markers,
962        );
963        assert!(
964            oxlint.root_markers.iter().any(|m| m == ".oxlintrc.json")
965                || oxlint.root_markers.iter().any(|m| m == ".oxlintrc"),
966            "expected an oxlint config file in root markers (got {:?})",
967            oxlint.root_markers,
968        );
969    }
970
971    #[test]
972    fn test_tsx_matches_typescript() {
973        let kinds = matching_kinds("/tmp/file.tsx", &Config::default());
974        assert!(
975            kinds.contains(&ServerKind::TypeScript),
976            "expected TypeScript in {kinds:?}",
977        );
978    }
979
980    #[test]
981    fn test_case_insensitive_extension() {
982        let kinds = matching_kinds("/tmp/file.TS", &Config::default());
983        assert!(
984            kinds.contains(&ServerKind::TypeScript),
985            "expected TypeScript in {kinds:?}",
986        );
987    }
988
989    #[test]
990    fn test_bash_and_yaml_builtins() {
991        assert_eq!(
992            matching_kinds("/tmp/file.sh", &Config::default()),
993            vec![ServerKind::Bash]
994        );
995        assert_eq!(
996            matching_kinds("/tmp/file.yaml", &Config::default()),
997            vec![ServerKind::Yaml]
998        );
999    }
1000
1001    #[test]
1002    fn test_ty_requires_experimental_flag() {
1003        assert_eq!(
1004            matching_kinds("/tmp/file.py", &Config::default()),
1005            vec![ServerKind::Python]
1006        );
1007
1008        let config = Config {
1009            experimental_lsp_ty: true,
1010            ..Config::default()
1011        };
1012        assert_eq!(
1013            matching_kinds("/tmp/file.py", &config),
1014            vec![ServerKind::Python, ServerKind::Ty]
1015        );
1016    }
1017
1018    #[test]
1019    fn test_custom_server_matches_extension() {
1020        // Use an extension that no built-in server claims so the custom
1021        // server is the sole match.
1022        let config = Config {
1023            lsp_servers: vec![UserServerDef {
1024                id: "my-custom-lsp".to_string(),
1025                extensions: vec!["xyzcustom".to_string()],
1026                binary: "my-custom-lsp".to_string(),
1027                root_markers: vec!["custom.toml".to_string()],
1028                ..UserServerDef::default()
1029            }],
1030            ..Config::default()
1031        };
1032
1033        assert_eq!(
1034            matching_kinds("/tmp/file.xyzcustom", &config),
1035            vec![ServerKind::Custom(Arc::from("my-custom-lsp"))]
1036        );
1037    }
1038
1039    #[test]
1040    fn test_custom_server_coexists_with_builtin_for_same_extension() {
1041        // Both built-in tinymist and the user's custom override match
1042        // the same extension. Custom appears after built-ins in the chain.
1043        let config = Config {
1044            lsp_servers: vec![UserServerDef {
1045                id: "tinymist-fork".to_string(),
1046                extensions: vec!["typ".to_string()],
1047                binary: "tinymist-fork".to_string(),
1048                root_markers: vec!["typst.toml".to_string()],
1049                ..UserServerDef::default()
1050            }],
1051            ..Config::default()
1052        };
1053
1054        let kinds = matching_kinds("/tmp/file.typ", &config);
1055        assert!(kinds.contains(&ServerKind::Tinymist));
1056        assert!(kinds.contains(&ServerKind::Custom(Arc::from("tinymist-fork"))));
1057    }
1058
1059    #[test]
1060    fn test_pattern_a_servers_register_for_their_extensions() {
1061        let cases: &[(&str, ServerKind)] = &[
1062            ("/tmp/a.clj", ServerKind::Clojure),
1063            ("/tmp/a.dart", ServerKind::Dart),
1064            ("/tmp/a.ex", ServerKind::ElixirLs),
1065            ("/tmp/a.fs", ServerKind::FSharp),
1066            ("/tmp/a.gleam", ServerKind::Gleam),
1067            ("/tmp/a.hs", ServerKind::Haskell),
1068            ("/tmp/A.java", ServerKind::Jdtls),
1069            ("/tmp/a.jl", ServerKind::Julia),
1070            ("/tmp/a.nix", ServerKind::Nixd),
1071            ("/tmp/a.ml", ServerKind::OcamlLsp),
1072            ("/tmp/a.php", ServerKind::PhpIntelephense),
1073            ("/tmp/a.rb", ServerKind::RubyLsp),
1074            ("/tmp/a.swift", ServerKind::SourceKit),
1075            ("/tmp/a.cs", ServerKind::CSharp),
1076            ("/tmp/a.razor", ServerKind::Razor),
1077        ];
1078
1079        for (path, expected) in cases {
1080            let kinds = matching_kinds(path, &Config::default());
1081            assert!(
1082                kinds.contains(expected),
1083                "expected {expected:?} for {path}; got {kinds:?}",
1084            );
1085        }
1086    }
1087
1088    #[test]
1089    fn test_pattern_c_servers_register_for_their_extensions() {
1090        let cases: &[(&str, ServerKind)] = &[
1091            ("/tmp/a.c", ServerKind::Clangd),
1092            ("/tmp/a.cpp", ServerKind::Clangd),
1093            ("/tmp/a.h", ServerKind::Clangd),
1094            ("/tmp/a.lua", ServerKind::LuaLs),
1095            ("/tmp/a.zig", ServerKind::Zls),
1096            ("/tmp/a.typ", ServerKind::Tinymist),
1097            ("/tmp/a.kt", ServerKind::KotlinLs),
1098            ("/tmp/a.tex", ServerKind::Texlab),
1099            ("/tmp/a.tf", ServerKind::TerraformLs),
1100        ];
1101
1102        for (path, expected) in cases {
1103            let kinds = matching_kinds(path, &Config::default());
1104            assert!(
1105                kinds.contains(expected),
1106                "expected {expected:?} for {path}; got {kinds:?}",
1107            );
1108        }
1109    }
1110
1111    #[test]
1112    fn test_pattern_b_d_servers_register_for_their_extensions() {
1113        let cases: &[(&str, ServerKind)] = &[
1114            ("/tmp/a.vue", ServerKind::Vue),
1115            ("/tmp/a.astro", ServerKind::Astro),
1116            ("/tmp/a.prisma", ServerKind::Prisma),
1117            ("/tmp/a.svelte", ServerKind::Svelte),
1118            ("/tmp/a.dockerfile", ServerKind::Dockerfile),
1119        ];
1120
1121        for (path, expected) in cases {
1122            let kinds = matching_kinds(path, &Config::default());
1123            assert!(
1124                kinds.contains(expected),
1125                "expected {expected:?} for {path}; got {kinds:?}",
1126            );
1127        }
1128    }
1129
1130    #[test]
1131    fn test_lsp_disabled_filters_out_servers_by_id() {
1132        let mut disabled = std::collections::HashSet::new();
1133        disabled.insert("clangd".to_string());
1134        disabled.insert("dart".to_string());
1135        disabled.insert("rust".to_string());
1136
1137        let config = Config {
1138            disabled_lsp: disabled,
1139            ..Config::default()
1140        };
1141
1142        // Disabled servers don't appear; non-disabled servers still match.
1143        let c_kinds = matching_kinds("/tmp/a.c", &config);
1144        assert!(!c_kinds.contains(&ServerKind::Clangd));
1145
1146        let dart_kinds = matching_kinds("/tmp/a.dart", &config);
1147        assert!(!dart_kinds.contains(&ServerKind::Dart));
1148
1149        let rust_kinds = matching_kinds("/tmp/a.rs", &config);
1150        assert!(!rust_kinds.contains(&ServerKind::Rust));
1151
1152        // Unrelated server still works.
1153        let ts_kinds = matching_kinds("/tmp/a.ts", &config);
1154        assert!(ts_kinds.contains(&ServerKind::TypeScript));
1155    }
1156
1157    #[test]
1158    fn test_server_kind_ids_are_unique() {
1159        // Two server defs with the same `id_str()` would collide in
1160        // `lsp.disabled` and `lsp.versions` config — protect against that.
1161        use std::collections::HashSet;
1162        let servers = super::builtin_servers();
1163        let ids: Vec<String> = servers
1164            .iter()
1165            .map(|s| s.kind.id_str().to_string())
1166            .collect();
1167        let unique: HashSet<&String> = ids.iter().collect();
1168        assert_eq!(
1169            ids.len(),
1170            unique.len(),
1171            "duplicate server IDs in registry: {ids:?}",
1172        );
1173    }
1174
1175    #[test]
1176    fn user_override_with_matching_id_replaces_builtin_not_appended() {
1177        // Issue #56: setting `lsp.servers.clangd = { args: [...] }` should
1178        // result in ONE clangd entry (the user-overridden one), not two.
1179        let config = Config {
1180            lsp_servers: vec![UserServerDef {
1181                id: "clangd".to_string(),
1182                args: vec!["--query-driver=/path/to/arm-none-eabi-*".to_string()],
1183                ..UserServerDef::default()
1184            }],
1185            ..Config::default()
1186        };
1187
1188        let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1189        let clangd_entries: Vec<_> = cpp_servers
1190            .iter()
1191            .filter(|s| s.kind.id_str() == "clangd")
1192            .collect();
1193        assert_eq!(
1194            clangd_entries.len(),
1195            1,
1196            "expected exactly one clangd server after user override; got {} ({:?})",
1197            clangd_entries.len(),
1198            cpp_servers.iter().map(|s| &s.kind).collect::<Vec<_>>()
1199        );
1200
1201        // Override fields take effect.
1202        let clangd = clangd_entries[0];
1203        assert_eq!(clangd.args, vec!["--query-driver=/path/to/arm-none-eabi-*"],);
1204
1205        // Fields the user left empty (extensions, root_markers) inherit from
1206        // the built-in — that's the whole point of the merge.
1207        assert!(
1208            !clangd.extensions.is_empty(),
1209            "extensions should inherit from built-in clangd, got empty",
1210        );
1211        assert!(
1212            !clangd.root_markers.is_empty(),
1213            "root_markers should inherit from built-in clangd, got empty",
1214        );
1215    }
1216
1217    #[test]
1218    fn user_override_preserves_builtin_kind_not_custom() {
1219        // The merged entry must keep the built-in ServerKind variant (e.g.
1220        // ServerKind::Clangd) so callers that match on the enum continue to
1221        // work — including `lsp.disabled` and any kind-specific capability
1222        // probing in the LSP manager.
1223        let config = Config {
1224            lsp_servers: vec![UserServerDef {
1225                id: "clangd".to_string(),
1226                root_markers: vec![".clangd".to_string()],
1227                ..UserServerDef::default()
1228            }],
1229            ..Config::default()
1230        };
1231
1232        let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1233        let clangd = cpp_servers
1234            .iter()
1235            .find(|s| s.kind.id_str() == "clangd")
1236            .expect("clangd entry");
1237        assert!(
1238            matches!(clangd.kind, ServerKind::Clangd),
1239            "merged server must keep ServerKind::Clangd, got {:?}",
1240            clangd.kind,
1241        );
1242    }
1243
1244    #[test]
1245    fn user_override_with_non_matching_id_is_appended_as_custom() {
1246        // Pre-existing behavior preserved: a user-defined id that doesn't
1247        // match any built-in is registered as a Custom server alongside the
1248        // built-ins. (This is the workaround issue #56 reporters were using
1249        // — it must keep working.)
1250        //
1251        // Extensions in `lsp.servers` are matched WITHOUT a leading dot
1252        // (the same convention as built-in servers — see `builtin_server()`
1253        // calls). Users writing `".cpp"` in their config would silently
1254        // never match; that's a separate UX gap not part of this fix.
1255        let config = Config {
1256            lsp_servers: vec![UserServerDef {
1257                id: "custom-clangd".to_string(),
1258                extensions: vec!["c".to_string(), "cpp".to_string()],
1259                binary: "clangd".to_string(),
1260                ..UserServerDef::default()
1261            }],
1262            ..Config::default()
1263        };
1264
1265        let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1266        let kinds: Vec<&ServerKind> = cpp_servers.iter().map(|s| &s.kind).collect();
1267        assert!(
1268            kinds.iter().any(|k| matches!(k, ServerKind::Clangd)),
1269            "built-in clangd should still be present alongside custom-clangd; got {kinds:?}",
1270        );
1271        assert!(
1272            kinds
1273                .iter()
1274                .any(|k| matches!(k, ServerKind::Custom(id) if id.as_ref() == "custom-clangd")),
1275            "custom-clangd should be appended as Custom; got {kinds:?}",
1276        );
1277    }
1278
1279    #[test]
1280    fn user_override_with_disabled_true_drops_builtin() {
1281        // `lsp.servers.clangd = { disabled: true }` should be equivalent to
1282        // adding `"clangd"` to `lsp.disabled`.
1283        let config = Config {
1284            lsp_servers: vec![UserServerDef {
1285                id: "clangd".to_string(),
1286                disabled: true,
1287                ..UserServerDef::default()
1288            }],
1289            ..Config::default()
1290        };
1291
1292        let cpp_servers = super::servers_for_file(Path::new("/tmp/a.cpp"), &config);
1293        assert!(
1294            !cpp_servers.iter().any(|s| s.kind.id_str() == "clangd"),
1295            "disabled user override should drop the built-in; got {:?}",
1296            cpp_servers.iter().map(|s| &s.kind).collect::<Vec<_>>(),
1297        );
1298    }
1299
1300    /// Helper: write an executable file containing `#!/bin/sh\n` so it
1301    /// passes both `is_file()` checks and is executable on Unix.
1302    fn touch_exe(path: &Path) {
1303        if let Some(parent) = path.parent() {
1304            std::fs::create_dir_all(parent).unwrap();
1305        }
1306        std::fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap();
1307        #[cfg(unix)]
1308        {
1309            use std::os::unix::fs::PermissionsExt;
1310            let mut perms = std::fs::metadata(path).unwrap().permissions();
1311            perms.set_mode(0o755);
1312            std::fs::set_permissions(path, perms).unwrap();
1313        }
1314    }
1315
1316    #[test]
1317    fn resolve_lsp_binary_prefers_project_node_modules() {
1318        let tmp = tempfile::tempdir().unwrap();
1319        let project = tmp.path();
1320        let local_bin = project.join("node_modules").join(".bin");
1321        touch_exe(&local_bin.join("typescript-language-server"));
1322
1323        let resolved = resolve_lsp_binary("typescript-language-server", Some(project), &[]);
1324        assert_eq!(
1325            resolved.as_deref(),
1326            Some(local_bin.join("typescript-language-server").as_path())
1327        );
1328    }
1329
1330    #[test]
1331    fn resolve_lsp_binary_falls_back_to_extra_paths() {
1332        let tmp = tempfile::tempdir().unwrap();
1333        let project = tmp.path().join("project");
1334        std::fs::create_dir_all(&project).unwrap();
1335
1336        let extra_a = tmp.path().join("extra_a");
1337        let extra_b = tmp.path().join("extra_b");
1338        std::fs::create_dir_all(&extra_a).unwrap();
1339        std::fs::create_dir_all(&extra_b).unwrap();
1340        touch_exe(&extra_b.join("yaml-language-server"));
1341
1342        let resolved = resolve_lsp_binary(
1343            "yaml-language-server",
1344            Some(&project),
1345            &[extra_a.clone(), extra_b.clone()],
1346        );
1347        assert_eq!(
1348            resolved.as_deref(),
1349            Some(extra_b.join("yaml-language-server").as_path())
1350        );
1351    }
1352
1353    #[test]
1354    fn resolve_lsp_binary_extra_paths_search_in_order() {
1355        let tmp = tempfile::tempdir().unwrap();
1356        let extra_a = tmp.path().join("extra_a");
1357        let extra_b = tmp.path().join("extra_b");
1358        std::fs::create_dir_all(&extra_a).unwrap();
1359        std::fs::create_dir_all(&extra_b).unwrap();
1360        // Same binary in both — earlier path wins.
1361        touch_exe(&extra_a.join("bash-language-server"));
1362        touch_exe(&extra_b.join("bash-language-server"));
1363
1364        let resolved = resolve_lsp_binary(
1365            "bash-language-server",
1366            None,
1367            &[extra_a.clone(), extra_b.clone()],
1368        );
1369        assert_eq!(
1370            resolved.as_deref(),
1371            Some(extra_a.join("bash-language-server").as_path())
1372        );
1373    }
1374
1375    #[test]
1376    fn resolve_lsp_binary_project_root_wins_over_extra_paths() {
1377        let tmp = tempfile::tempdir().unwrap();
1378        let project = tmp.path().join("project");
1379        let local_bin = project.join("node_modules").join(".bin");
1380        touch_exe(&local_bin.join("pyright-langserver"));
1381
1382        let extra = tmp.path().join("extra");
1383        std::fs::create_dir_all(&extra).unwrap();
1384        touch_exe(&extra.join("pyright-langserver"));
1385
1386        let resolved = resolve_lsp_binary(
1387            "pyright-langserver",
1388            Some(&project),
1389            std::slice::from_ref(&extra),
1390        );
1391        assert_eq!(
1392            resolved.as_deref(),
1393            Some(local_bin.join("pyright-langserver").as_path())
1394        );
1395    }
1396
1397    #[test]
1398    fn resolve_lsp_binary_returns_none_for_missing_binary() {
1399        let tmp = tempfile::tempdir().unwrap();
1400        let project = tmp.path().join("project");
1401        std::fs::create_dir_all(&project).unwrap();
1402
1403        // Use a binary name that's almost certainly not on PATH.
1404        let resolved =
1405            resolve_lsp_binary("aft-test-nonexistent-binary-xyz123", Some(&project), &[]);
1406        assert!(resolved.is_none());
1407    }
1408
1409    #[test]
1410    fn resolve_lsp_binary_handles_missing_node_modules_gracefully() {
1411        // project_root is set but node_modules/.bin doesn't exist.
1412        // Should fall through to extra_paths and PATH without error.
1413        let tmp = tempfile::tempdir().unwrap();
1414        let project = tmp.path().join("project");
1415        std::fs::create_dir_all(&project).unwrap();
1416
1417        let extra = tmp.path().join("extra");
1418        std::fs::create_dir_all(&extra).unwrap();
1419        touch_exe(&extra.join("gopls"));
1420
1421        let resolved = resolve_lsp_binary("gopls", Some(&project), std::slice::from_ref(&extra));
1422        assert_eq!(resolved.as_deref(), Some(extra.join("gopls").as_path()));
1423    }
1424
1425    #[test]
1426    fn resolve_lsp_binary_skips_nonexistent_extra_path() {
1427        let tmp = tempfile::tempdir().unwrap();
1428        let missing = tmp.path().join("missing");
1429        let valid = tmp.path().join("valid");
1430        std::fs::create_dir_all(&valid).unwrap();
1431        touch_exe(&valid.join("clangd"));
1432
1433        let resolved = resolve_lsp_binary("clangd", None, &[missing, valid.clone()]);
1434
1435        assert_eq!(resolved.as_deref(), Some(valid.join("clangd").as_path()));
1436    }
1437
1438    #[test]
1439    fn resolve_lsp_binary_skips_file_extra_path() {
1440        let tmp = tempfile::tempdir().unwrap();
1441        let file = tmp.path().join("not-a-dir");
1442        let valid = tmp.path().join("valid");
1443        std::fs::write(&file, "not a directory").unwrap();
1444        std::fs::create_dir_all(&valid).unwrap();
1445        touch_exe(&valid.join("lua-language-server"));
1446
1447        let resolved = resolve_lsp_binary("lua-language-server", None, &[file, valid.clone()]);
1448
1449        assert_eq!(
1450            resolved.as_deref(),
1451            Some(valid.join("lua-language-server").as_path())
1452        );
1453    }
1454
1455    #[test]
1456    fn resolve_lsp_binary_skips_deleted_extra_path() {
1457        let tmp = tempfile::tempdir().unwrap();
1458        let deleted = tmp.path().join("deleted");
1459        let valid = tmp.path().join("valid");
1460        std::fs::create_dir_all(&deleted).unwrap();
1461        std::fs::remove_dir(&deleted).unwrap();
1462        std::fs::create_dir_all(&valid).unwrap();
1463        touch_exe(&valid.join("svelte-language-server"));
1464
1465        let resolved =
1466            resolve_lsp_binary("svelte-language-server", None, &[deleted, valid.clone()]);
1467
1468        assert_eq!(
1469            resolved.as_deref(),
1470            Some(valid.join("svelte-language-server").as_path())
1471        );
1472    }
1473
1474    // Avoid unused-import warning on platforms where probe_dir's Windows
1475    // branch is dead code.
1476    #[allow(dead_code)]
1477    fn _path_buf_used(_p: PathBuf) {}
1478}