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