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