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