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