Skip to main content

aft/lsp/
registry.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use crate::config::{Config, UserServerDef};
6
7/// Resolve an LSP binary name to a full path.
8///
9/// Resolution order (mirrors `format::resolve_tool` for formatters/checkers):
10/// 1. `<project_root>/node_modules/.bin/<binary>` — project devDependency
11/// 2. Each path in `extra_paths` joined with `<binary>` — plugin-supplied
12///    auto-install cache locations such as
13///    `~/.cache/aft/lsp-packages/<pkg>/node_modules/.bin/`
14/// 3. PATH via [`which::which`]
15///
16/// On Windows, candidate directories are also probed with `.cmd`, `.exe`,
17/// and `.bat` extensions because npm-installed shims often use `.cmd`.
18/// `which::which` handles PATHEXT natively for the PATH fallback.
19pub fn resolve_lsp_binary(
20    binary: &str,
21    project_root: Option<&Path>,
22    extra_paths: &[PathBuf],
23) -> Option<PathBuf> {
24    // 1. Project-local node_modules/.bin
25    if let Some(root) = project_root {
26        let local_bin = root.join("node_modules").join(".bin");
27        if let Some(found) = probe_dir(&local_bin, binary) {
28            return Some(found);
29        }
30    }
31
32    // 2. Plugin-supplied extra paths (auto-install cache, etc.)
33    for dir in extra_paths {
34        if let Some(found) = probe_dir(dir, binary) {
35            return Some(found);
36        }
37    }
38
39    // 3. PATH fallback
40    which::which(binary).ok()
41}
42
43/// Check `dir/<binary>` and (on Windows) `dir/<binary>.cmd|.exe|.bat`.
44fn probe_dir(dir: &Path, binary: &str) -> Option<PathBuf> {
45    if !dir.is_dir() {
46        return None;
47    }
48
49    let direct = dir.join(binary);
50    if direct.is_file() {
51        return Some(direct);
52    }
53
54    if cfg!(windows) {
55        for ext in ["cmd", "exe", "bat"] {
56            let candidate = dir.join(format!("{binary}.{ext}"));
57            if candidate.is_file() {
58                return Some(candidate);
59            }
60        }
61    }
62
63    None
64}
65
66/// Unique identifier for a language server kind.
67///
68/// IDs match OpenCode's `lsp/server.ts` registry where possible so users can
69/// refer to the same names in `lsp.disabled` config across both projects.
70#[derive(Debug, Clone, PartialEq, Eq, Hash)]
71pub enum ServerKind {
72    // --- Built-in (existing, pre-v0.17.0) ---
73    TypeScript,
74    Python, // pyright
75    Rust,
76    Go,
77    Bash,
78    Yaml,
79    Ty, // experimental Astral Python LSP
80    // --- v0.17.0: PATH-only servers (Pattern A) ---
81    Clojure,
82    Dart,
83    ElixirLs,
84    FSharp,
85    Gleam,
86    Haskell,
87    Jdtls, // Java
88    Julia,
89    Nixd,
90    OcamlLsp,
91    PhpIntelephense,
92    RubyLsp,
93    SourceKit, // Swift
94    CSharp,
95    Razor,
96    // --- v0.17.0: Pattern C (PATH-first, GitHub-release auto-download in plugin) ---
97    Clangd,
98    LuaLs,
99    Zls,
100    Tinymist,
101    KotlinLs,
102    Texlab,
103    Oxlint,
104    TerraformLs,
105    // --- v0.17.0: Pattern B/D (npm auto-installable in plugin) ---
106    Vue,
107    Astro,
108    Prisma, // resolves the project's `prisma` CLI from node_modules; not auto-installed by AFT
109    Biome,
110    Svelte,
111    Dockerfile,
112    Custom(Arc<str>),
113}
114
115impl ServerKind {
116    pub fn id_str(&self) -> &str {
117        match self {
118            Self::TypeScript => "typescript",
119            Self::Python => "python",
120            Self::Rust => "rust",
121            Self::Go => "go",
122            Self::Bash => "bash",
123            Self::Yaml => "yaml",
124            Self::Ty => "ty",
125            // Pattern A
126            Self::Clojure => "clojure-lsp",
127            Self::Dart => "dart",
128            Self::ElixirLs => "elixir-ls",
129            Self::FSharp => "fsharp",
130            Self::Gleam => "gleam",
131            Self::Haskell => "haskell-language-server",
132            Self::Jdtls => "jdtls",
133            Self::Julia => "julials",
134            Self::Nixd => "nixd",
135            Self::OcamlLsp => "ocaml-lsp",
136            Self::PhpIntelephense => "php-intelephense",
137            Self::RubyLsp => "ruby-lsp",
138            Self::SourceKit => "sourcekit-lsp",
139            Self::CSharp => "csharp",
140            Self::Razor => "razor",
141            // Pattern C
142            Self::Clangd => "clangd",
143            Self::LuaLs => "lua-ls",
144            Self::Zls => "zls",
145            Self::Tinymist => "tinymist",
146            Self::KotlinLs => "kotlin-ls",
147            Self::Texlab => "texlab",
148            Self::Oxlint => "oxlint",
149            Self::TerraformLs => "terraform",
150            // Pattern B/D
151            Self::Vue => "vue",
152            Self::Astro => "astro",
153            Self::Prisma => "prisma",
154            Self::Biome => "biome",
155            Self::Svelte => "svelte",
156            Self::Dockerfile => "dockerfile",
157            Self::Custom(id) => id.as_ref(),
158        }
159    }
160}
161
162/// Definition of a language server.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct ServerDef {
165    pub kind: ServerKind,
166    /// Display name.
167    pub name: String,
168    /// File extensions this server handles.
169    pub extensions: Vec<String>,
170    /// Binary name to look up on PATH.
171    pub binary: String,
172    /// Arguments to pass when spawning.
173    pub args: Vec<String>,
174    /// Root marker files — presence indicates a workspace root.
175    pub root_markers: Vec<String>,
176    /// Extra environment variables for this server process.
177    pub env: HashMap<String, String>,
178    /// Optional JSON initializationOptions for the initialize request.
179    pub initialization_options: Option<serde_json::Value>,
180}
181
182impl ServerDef {
183    /// Check if this server handles a given file extension.
184    pub fn matches_extension(&self, ext: &str) -> bool {
185        self.extensions
186            .iter()
187            .any(|candidate| candidate.eq_ignore_ascii_case(ext))
188    }
189
190    /// Check if the server binary is available on PATH.
191    pub fn is_available(&self) -> bool {
192        which::which(&self.binary).is_ok()
193    }
194}
195
196/// Built-in server definitions.
197pub fn builtin_servers() -> Vec<ServerDef> {
198    vec![
199        builtin_server(
200            ServerKind::TypeScript,
201            "TypeScript Language Server",
202            &["ts", "tsx", "js", "jsx", "mjs", "cjs"],
203            "typescript-language-server",
204            &["--stdio"],
205            &["tsconfig.json", "jsconfig.json", "package.json"],
206        ),
207        builtin_server(
208            ServerKind::Python,
209            "Pyright",
210            &["py", "pyi"],
211            "pyright-langserver",
212            &["--stdio"],
213            &[
214                "pyproject.toml",
215                "setup.py",
216                "setup.cfg",
217                "pyrightconfig.json",
218                "requirements.txt",
219            ],
220        ),
221        builtin_server(
222            ServerKind::Rust,
223            "rust-analyzer",
224            &["rs"],
225            "rust-analyzer",
226            &[],
227            &["Cargo.toml"],
228        ),
229        // gopls requires opt-in for `textDocument/diagnostic` (LSP 3.17 pull)
230        // via the `pullDiagnostics` initializationOption. Without this the
231        // server still publishes via push but ignores pull requests.
232        // See https://github.com/golang/tools/blob/master/gopls/doc/settings.md
233        builtin_server_with_init(
234            ServerKind::Go,
235            "gopls",
236            &["go"],
237            "gopls",
238            &["serve"],
239            &["go.mod"],
240            serde_json::json!({ "pullDiagnostics": true }),
241        ),
242        builtin_server(
243            ServerKind::Bash,
244            "bash-language-server",
245            &["sh", "bash", "zsh"],
246            "bash-language-server",
247            &["start"],
248            &["package.json", ".git"],
249        ),
250        builtin_server(
251            ServerKind::Yaml,
252            "yaml-language-server",
253            &["yaml", "yml"],
254            "yaml-language-server",
255            &["--stdio"],
256            &["package.json", ".git"],
257        ),
258        builtin_server(
259            ServerKind::Ty,
260            "ty",
261            &["py", "pyi"],
262            "ty",
263            &["server"],
264            &[
265                "pyproject.toml",
266                "ty.toml",
267                "setup.py",
268                "setup.cfg",
269                "requirements.txt",
270                "Pipfile",
271                "pyrightconfig.json",
272            ],
273        ),
274        // ===== Pattern A: PATH-only servers =====
275        // These servers are not auto-installed by AFT (the toolchain itself
276        // ships the LSP, e.g. `dart`, `gleam`; or installation is highly
277        // platform-specific, e.g. `jdtls`). Users install via system package
278        // manager / language toolchain. AFT registers the def so users with
279        // the binary on PATH get LSP coverage.
280        builtin_server(
281            ServerKind::Clojure,
282            "clojure-lsp",
283            &["clj", "cljs", "cljc", "edn"],
284            "clojure-lsp",
285            &[],
286            &[
287                "deps.edn",
288                "project.clj",
289                "shadow-cljs.edn",
290                "bb.edn",
291                "build.boot",
292            ],
293        ),
294        builtin_server(
295            ServerKind::Dart,
296            "Dart Language Server",
297            &["dart"],
298            "dart",
299            &["language-server", "--lsp"],
300            &["pubspec.yaml", "analysis_options.yaml"],
301        ),
302        builtin_server(
303            ServerKind::ElixirLs,
304            "elixir-ls",
305            &["ex", "exs"],
306            "elixir-ls",
307            &[],
308            &["mix.exs", "mix.lock"],
309        ),
310        builtin_server(
311            ServerKind::FSharp,
312            "FSAutoComplete",
313            &["fs", "fsi", "fsx", "fsscript"],
314            "fsautocomplete",
315            &[],
316            &[".slnx", ".sln", ".fsproj", "global.json"],
317        ),
318        builtin_server(
319            ServerKind::Gleam,
320            "Gleam Language Server",
321            &["gleam"],
322            "gleam",
323            &["lsp"],
324            &["gleam.toml"],
325        ),
326        builtin_server(
327            ServerKind::Haskell,
328            "haskell-language-server",
329            &["hs", "lhs"],
330            "haskell-language-server-wrapper",
331            &["--lsp"],
332            &["stack.yaml", "cabal.project", "hie.yaml"],
333        ),
334        builtin_server(
335            ServerKind::Jdtls,
336            "Eclipse JDT Language Server",
337            &["java"],
338            "jdtls",
339            &[],
340            &["pom.xml", "build.gradle", "build.gradle.kts", ".project"],
341        ),
342        builtin_server(
343            ServerKind::Julia,
344            "Julia Language Server",
345            &["jl"],
346            "julia",
347            &[
348                "--startup-file=no",
349                "--history-file=no",
350                "-e",
351                "using LanguageServer; runserver()",
352            ],
353            &["Project.toml", "Manifest.toml"],
354        ),
355        builtin_server(
356            ServerKind::Nixd,
357            "nixd",
358            &["nix"],
359            "nixd",
360            &[],
361            &["flake.nix", "default.nix", "shell.nix"],
362        ),
363        builtin_server(
364            ServerKind::OcamlLsp,
365            "ocaml-lsp",
366            &["ml", "mli"],
367            "ocamllsp",
368            &[],
369            &["dune-project", "dune-workspace", ".merlin", "opam"],
370        ),
371        builtin_server(
372            ServerKind::PhpIntelephense,
373            "Intelephense",
374            &["php"],
375            "intelephense",
376            &["--stdio"],
377            &["composer.json", "composer.lock", ".php-version"],
378        ),
379        builtin_server(
380            ServerKind::RubyLsp,
381            "ruby-lsp",
382            &["rb", "rake", "gemspec", "ru"],
383            "ruby-lsp",
384            &[],
385            &["Gemfile"],
386        ),
387        builtin_server(
388            ServerKind::SourceKit,
389            "SourceKit-LSP",
390            &["swift"],
391            "sourcekit-lsp",
392            &[],
393            &["Package.swift"],
394        ),
395        builtin_server(
396            ServerKind::CSharp,
397            "Roslyn Language Server",
398            &["cs", "csx"],
399            "roslyn-language-server",
400            &[],
401            &[".slnx", ".sln", ".csproj", "global.json"],
402        ),
403        builtin_server(
404            ServerKind::Razor,
405            "rzls",
406            &["razor", "cshtml"],
407            "rzls",
408            &[],
409            &[".slnx", ".sln", ".csproj", "global.json"],
410        ),
411        // ===== Pattern C: PATH-first; plugin auto-downloads from GitHub releases =====
412        builtin_server(
413            ServerKind::Clangd,
414            "clangd",
415            &[
416                "c", "cpp", "cc", "cxx", "c++", "h", "hpp", "hh", "hxx", "h++",
417            ],
418            "clangd",
419            &[],
420            &["compile_commands.json", "compile_flags.txt", ".clangd"],
421        ),
422        builtin_server(
423            ServerKind::LuaLs,
424            "lua-language-server",
425            &["lua"],
426            "lua-language-server",
427            &[],
428            &[".luarc.json", ".luarc.jsonc", ".stylua.toml", "stylua.toml"],
429        ),
430        builtin_server(
431            ServerKind::Zls,
432            "zls",
433            &["zig", "zon"],
434            "zls",
435            &[],
436            &["build.zig"],
437        ),
438        builtin_server(
439            ServerKind::Tinymist,
440            "tinymist",
441            &["typ", "typc"],
442            "tinymist",
443            &[],
444            &["typst.toml"],
445        ),
446        builtin_server(
447            ServerKind::KotlinLs,
448            "kotlin-language-server",
449            &["kt", "kts"],
450            "kotlin-language-server",
451            &[],
452            &["settings.gradle", "settings.gradle.kts", "build.gradle"],
453        ),
454        builtin_server(
455            ServerKind::Texlab,
456            "texlab",
457            &["tex", "bib"],
458            "texlab",
459            &[],
460            &[".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"],
461        ),
462        builtin_server(
463            ServerKind::Oxlint,
464            "oxc-language-server",
465            // Same JS/TS family as TypeScript LS; coexists rather than replaces.
466            &[
467                "ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "vue", "astro", "svelte",
468            ],
469            "oxc-language-server",
470            &[],
471            &["package.json", ".oxlintrc.json", ".oxlintrc"],
472        ),
473        builtin_server(
474            ServerKind::TerraformLs,
475            "terraform-ls",
476            &["tf", "tfvars"],
477            "terraform-ls",
478            &["serve"],
479            &[".terraform.lock.hcl", "terraform.tfstate"],
480        ),
481        // ===== Pattern B/D: PATH-first; plugin auto-installs from npm =====
482        // Order matters slightly: vue/svelte/astro use TypeScript-family
483        // extensions when paired with their primary file extension. Each
484        // server only runs against its own primary extension here; agents
485        // run TypeScript LS for the rest.
486        builtin_server(
487            ServerKind::Vue,
488            "Vue Language Server",
489            &["vue"],
490            "vue-language-server",
491            &["--stdio"],
492            &[
493                "package-lock.json",
494                "bun.lockb",
495                "bun.lock",
496                "pnpm-lock.yaml",
497                "yarn.lock",
498            ],
499        ),
500        builtin_server(
501            ServerKind::Astro,
502            "Astro Language Server",
503            &["astro"],
504            "astro-ls",
505            &["--stdio"],
506            &[
507                "package-lock.json",
508                "bun.lockb",
509                "bun.lock",
510                "pnpm-lock.yaml",
511                "yarn.lock",
512            ],
513        ),
514        // Prisma's LSP runs via `prisma language-server` from the project's
515        // own `prisma` CLI (resolved through node_modules/.bin). AFT does NOT
516        // auto-install the prisma package — users get LSP coverage when their
517        // project has prisma as a devDependency.
518        builtin_server(
519            ServerKind::Prisma,
520            "Prisma Language Server",
521            &["prisma"],
522            "prisma",
523            &["language-server"],
524            &["schema.prisma", "package.json"],
525        ),
526        // Biome: lint+format LSP for the JS/TS family. Coexists with the
527        // TypeScript Language Server (different responsibilities). Disable
528        // via `lsp.disabled: ["biome"]` when not desired.
529        builtin_server(
530            ServerKind::Biome,
531            "Biome",
532            &[
533                "ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts", "json", "jsonc",
534            ],
535            "biome",
536            &["lsp-proxy"],
537            &["biome.json", "biome.jsonc"],
538        ),
539        builtin_server(
540            ServerKind::Svelte,
541            "Svelte Language Server",
542            &["svelte"],
543            "svelteserver",
544            &["--stdio"],
545            &[
546                "package-lock.json",
547                "bun.lockb",
548                "bun.lock",
549                "pnpm-lock.yaml",
550                "yarn.lock",
551            ],
552        ),
553        builtin_server(
554            ServerKind::Dockerfile,
555            "Dockerfile Language Server",
556            // OpenCode special-cases the literal "Dockerfile" name; AFT's
557            // extension-only matcher cannot. Users can `aft_outline`/edit
558            // Dockerfiles by extension `.dockerfile`. Plain `Dockerfile`
559            // files won't auto-trigger LSP — acknowledged limitation; can
560            // be revisited if users complain.
561            &["dockerfile"],
562            "docker-langserver",
563            &["--stdio"],
564            &["Dockerfile", "dockerfile", ".dockerignore"],
565        ),
566        // NOTE: ESLint LSP intentionally not registered — OpenCode resolves it
567        // through `Module.resolve("eslint", root)` and runs custom server-side
568        // logic. AFT does not implement that flow yet; users with ESLint can
569        // run `eslint --fix` via bash.
570    ]
571}
572
573/// Find all server definitions that handle a given file path.
574pub fn servers_for_file(path: &Path, config: &Config) -> Vec<ServerDef> {
575    let extension = path
576        .extension()
577        .and_then(|ext| ext.to_str())
578        .unwrap_or_default();
579
580    builtin_servers()
581        .into_iter()
582        .chain(config.lsp_servers.iter().filter_map(custom_server))
583        .filter(|server| !is_disabled(server, config))
584        .filter(|server| config.experimental_lsp_ty || server.kind != ServerKind::Ty)
585        .filter(|server| server.matches_extension(extension))
586        .collect()
587}
588
589fn builtin_server(
590    kind: ServerKind,
591    name: &str,
592    extensions: &[&str],
593    binary: &str,
594    args: &[&str],
595    root_markers: &[&str],
596) -> ServerDef {
597    ServerDef {
598        kind,
599        name: name.to_string(),
600        extensions: strings(extensions),
601        binary: binary.to_string(),
602        args: strings(args),
603        root_markers: strings(root_markers),
604        env: HashMap::new(),
605        initialization_options: None,
606    }
607}
608
609/// Builder variant of [`builtin_server`] that includes a default
610/// `initializationOptions` payload — used for servers that need server-specific
611/// settings to enable LSP features (e.g., gopls's `pullDiagnostics`).
612fn builtin_server_with_init(
613    kind: ServerKind,
614    name: &str,
615    extensions: &[&str],
616    binary: &str,
617    args: &[&str],
618    root_markers: &[&str],
619    initialization_options: serde_json::Value,
620) -> ServerDef {
621    let mut def = builtin_server(kind, name, extensions, binary, args, root_markers);
622    def.initialization_options = Some(initialization_options);
623    def
624}
625
626fn custom_server(server: &UserServerDef) -> Option<ServerDef> {
627    if server.disabled {
628        return None;
629    }
630
631    Some(ServerDef {
632        kind: ServerKind::Custom(Arc::from(server.id.as_str())),
633        name: server.id.clone(),
634        extensions: server.extensions.clone(),
635        binary: server.binary.clone(),
636        args: server.args.clone(),
637        root_markers: server.root_markers.clone(),
638        env: server.env.clone(),
639        initialization_options: server.initialization_options.clone(),
640    })
641}
642
643fn is_disabled(server: &ServerDef, config: &Config) -> bool {
644    config
645        .disabled_lsp
646        .contains(&server.kind.id_str().to_ascii_lowercase())
647}
648
649fn strings(values: &[&str]) -> Vec<String> {
650    values.iter().map(|value| (*value).to_string()).collect()
651}
652
653#[cfg(test)]
654mod tests {
655    use std::path::{Path, PathBuf};
656    use std::sync::Arc;
657
658    use crate::config::{Config, UserServerDef};
659
660    use super::{resolve_lsp_binary, servers_for_file, ServerKind};
661
662    fn matching_kinds(path: &str, config: &Config) -> Vec<ServerKind> {
663        servers_for_file(Path::new(path), config)
664            .into_iter()
665            .map(|server| server.kind)
666            .collect()
667    }
668
669    #[test]
670    fn test_servers_for_typescript_file() {
671        // TS files match TypeScript (primary) plus Biome / Oxlint / Eslint
672        // co-servers. The full set is asserted in `test_typescript_co_servers`.
673        let kinds = matching_kinds("/tmp/file.ts", &Config::default());
674        assert!(
675            kinds.contains(&ServerKind::TypeScript),
676            "expected TypeScript in {kinds:?}",
677        );
678    }
679
680    #[test]
681    fn test_typescript_co_servers() {
682        let kinds = matching_kinds("/tmp/file.ts", &Config::default());
683        assert!(kinds.contains(&ServerKind::TypeScript));
684        assert!(kinds.contains(&ServerKind::Biome));
685        assert!(kinds.contains(&ServerKind::Oxlint));
686    }
687
688    #[test]
689    fn test_typescript_co_servers_can_be_disabled() {
690        // `lsp.disabled` lets users opt out of co-servers individually.
691        let mut disabled = std::collections::HashSet::new();
692        disabled.insert("biome".to_string());
693        disabled.insert("oxlint".to_string());
694
695        let config = Config {
696            disabled_lsp: disabled,
697            ..Config::default()
698        };
699
700        assert_eq!(
701            matching_kinds("/tmp/file.ts", &config),
702            vec![ServerKind::TypeScript]
703        );
704    }
705
706    #[test]
707    fn test_servers_for_python_file() {
708        assert_eq!(
709            matching_kinds("/tmp/file.py", &Config::default()),
710            vec![ServerKind::Python]
711        );
712    }
713
714    #[test]
715    fn test_servers_for_rust_file() {
716        assert_eq!(
717            matching_kinds("/tmp/file.rs", &Config::default()),
718            vec![ServerKind::Rust]
719        );
720    }
721
722    #[test]
723    fn test_servers_for_go_file() {
724        assert_eq!(
725            matching_kinds("/tmp/file.go", &Config::default()),
726            vec![ServerKind::Go]
727        );
728    }
729
730    #[test]
731    fn test_servers_for_unknown_file() {
732        assert!(matching_kinds("/tmp/file.txt", &Config::default()).is_empty());
733    }
734
735    #[test]
736    fn test_tsx_matches_typescript() {
737        let kinds = matching_kinds("/tmp/file.tsx", &Config::default());
738        assert!(
739            kinds.contains(&ServerKind::TypeScript),
740            "expected TypeScript in {kinds:?}",
741        );
742    }
743
744    #[test]
745    fn test_case_insensitive_extension() {
746        let kinds = matching_kinds("/tmp/file.TS", &Config::default());
747        assert!(
748            kinds.contains(&ServerKind::TypeScript),
749            "expected TypeScript in {kinds:?}",
750        );
751    }
752
753    #[test]
754    fn test_bash_and_yaml_builtins() {
755        assert_eq!(
756            matching_kinds("/tmp/file.sh", &Config::default()),
757            vec![ServerKind::Bash]
758        );
759        assert_eq!(
760            matching_kinds("/tmp/file.yaml", &Config::default()),
761            vec![ServerKind::Yaml]
762        );
763    }
764
765    #[test]
766    fn test_ty_requires_experimental_flag() {
767        assert_eq!(
768            matching_kinds("/tmp/file.py", &Config::default()),
769            vec![ServerKind::Python]
770        );
771
772        let config = Config {
773            experimental_lsp_ty: true,
774            ..Config::default()
775        };
776        assert_eq!(
777            matching_kinds("/tmp/file.py", &config),
778            vec![ServerKind::Python, ServerKind::Ty]
779        );
780    }
781
782    #[test]
783    fn test_custom_server_matches_extension() {
784        // Use an extension that no built-in server claims so the custom
785        // server is the sole match.
786        let config = Config {
787            lsp_servers: vec![UserServerDef {
788                id: "my-custom-lsp".to_string(),
789                extensions: vec!["xyzcustom".to_string()],
790                binary: "my-custom-lsp".to_string(),
791                root_markers: vec!["custom.toml".to_string()],
792                ..UserServerDef::default()
793            }],
794            ..Config::default()
795        };
796
797        assert_eq!(
798            matching_kinds("/tmp/file.xyzcustom", &config),
799            vec![ServerKind::Custom(Arc::from("my-custom-lsp"))]
800        );
801    }
802
803    #[test]
804    fn test_custom_server_coexists_with_builtin_for_same_extension() {
805        // Both built-in tinymist and the user's custom override match
806        // the same extension. Custom appears after built-ins in the chain.
807        let config = Config {
808            lsp_servers: vec![UserServerDef {
809                id: "tinymist-fork".to_string(),
810                extensions: vec!["typ".to_string()],
811                binary: "tinymist-fork".to_string(),
812                root_markers: vec!["typst.toml".to_string()],
813                ..UserServerDef::default()
814            }],
815            ..Config::default()
816        };
817
818        let kinds = matching_kinds("/tmp/file.typ", &config);
819        assert!(kinds.contains(&ServerKind::Tinymist));
820        assert!(kinds.contains(&ServerKind::Custom(Arc::from("tinymist-fork"))));
821    }
822
823    #[test]
824    fn test_pattern_a_servers_register_for_their_extensions() {
825        let cases: &[(&str, ServerKind)] = &[
826            ("/tmp/a.clj", ServerKind::Clojure),
827            ("/tmp/a.dart", ServerKind::Dart),
828            ("/tmp/a.ex", ServerKind::ElixirLs),
829            ("/tmp/a.fs", ServerKind::FSharp),
830            ("/tmp/a.gleam", ServerKind::Gleam),
831            ("/tmp/a.hs", ServerKind::Haskell),
832            ("/tmp/A.java", ServerKind::Jdtls),
833            ("/tmp/a.jl", ServerKind::Julia),
834            ("/tmp/a.nix", ServerKind::Nixd),
835            ("/tmp/a.ml", ServerKind::OcamlLsp),
836            ("/tmp/a.php", ServerKind::PhpIntelephense),
837            ("/tmp/a.rb", ServerKind::RubyLsp),
838            ("/tmp/a.swift", ServerKind::SourceKit),
839            ("/tmp/a.cs", ServerKind::CSharp),
840            ("/tmp/a.razor", ServerKind::Razor),
841        ];
842
843        for (path, expected) in cases {
844            let kinds = matching_kinds(path, &Config::default());
845            assert!(
846                kinds.contains(expected),
847                "expected {expected:?} for {path}; got {kinds:?}",
848            );
849        }
850    }
851
852    #[test]
853    fn test_pattern_c_servers_register_for_their_extensions() {
854        let cases: &[(&str, ServerKind)] = &[
855            ("/tmp/a.c", ServerKind::Clangd),
856            ("/tmp/a.cpp", ServerKind::Clangd),
857            ("/tmp/a.h", ServerKind::Clangd),
858            ("/tmp/a.lua", ServerKind::LuaLs),
859            ("/tmp/a.zig", ServerKind::Zls),
860            ("/tmp/a.typ", ServerKind::Tinymist),
861            ("/tmp/a.kt", ServerKind::KotlinLs),
862            ("/tmp/a.tex", ServerKind::Texlab),
863            ("/tmp/a.tf", ServerKind::TerraformLs),
864        ];
865
866        for (path, expected) in cases {
867            let kinds = matching_kinds(path, &Config::default());
868            assert!(
869                kinds.contains(expected),
870                "expected {expected:?} for {path}; got {kinds:?}",
871            );
872        }
873    }
874
875    #[test]
876    fn test_pattern_b_d_servers_register_for_their_extensions() {
877        let cases: &[(&str, ServerKind)] = &[
878            ("/tmp/a.vue", ServerKind::Vue),
879            ("/tmp/a.astro", ServerKind::Astro),
880            ("/tmp/a.prisma", ServerKind::Prisma),
881            ("/tmp/a.svelte", ServerKind::Svelte),
882            ("/tmp/a.dockerfile", ServerKind::Dockerfile),
883        ];
884
885        for (path, expected) in cases {
886            let kinds = matching_kinds(path, &Config::default());
887            assert!(
888                kinds.contains(expected),
889                "expected {expected:?} for {path}; got {kinds:?}",
890            );
891        }
892    }
893
894    #[test]
895    fn test_lsp_disabled_filters_out_servers_by_id() {
896        let mut disabled = std::collections::HashSet::new();
897        disabled.insert("clangd".to_string());
898        disabled.insert("dart".to_string());
899        disabled.insert("rust".to_string());
900
901        let config = Config {
902            disabled_lsp: disabled,
903            ..Config::default()
904        };
905
906        // Disabled servers don't appear; non-disabled servers still match.
907        let c_kinds = matching_kinds("/tmp/a.c", &config);
908        assert!(!c_kinds.contains(&ServerKind::Clangd));
909
910        let dart_kinds = matching_kinds("/tmp/a.dart", &config);
911        assert!(!dart_kinds.contains(&ServerKind::Dart));
912
913        let rust_kinds = matching_kinds("/tmp/a.rs", &config);
914        assert!(!rust_kinds.contains(&ServerKind::Rust));
915
916        // Unrelated server still works.
917        let ts_kinds = matching_kinds("/tmp/a.ts", &config);
918        assert!(ts_kinds.contains(&ServerKind::TypeScript));
919    }
920
921    #[test]
922    fn test_server_kind_ids_are_unique() {
923        // Two server defs with the same `id_str()` would collide in
924        // `lsp.disabled` and `lsp.versions` config — protect against that.
925        use std::collections::HashSet;
926        let servers = super::builtin_servers();
927        let ids: Vec<String> = servers
928            .iter()
929            .map(|s| s.kind.id_str().to_string())
930            .collect();
931        let unique: HashSet<&String> = ids.iter().collect();
932        assert_eq!(
933            ids.len(),
934            unique.len(),
935            "duplicate server IDs in registry: {ids:?}",
936        );
937    }
938
939    /// Helper: write an executable file containing `#!/bin/sh\n` so it
940    /// passes both `is_file()` checks and is executable on Unix.
941    fn touch_exe(path: &Path) {
942        if let Some(parent) = path.parent() {
943            std::fs::create_dir_all(parent).unwrap();
944        }
945        std::fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap();
946        #[cfg(unix)]
947        {
948            use std::os::unix::fs::PermissionsExt;
949            let mut perms = std::fs::metadata(path).unwrap().permissions();
950            perms.set_mode(0o755);
951            std::fs::set_permissions(path, perms).unwrap();
952        }
953    }
954
955    #[test]
956    fn resolve_lsp_binary_prefers_project_node_modules() {
957        let tmp = tempfile::tempdir().unwrap();
958        let project = tmp.path();
959        let local_bin = project.join("node_modules").join(".bin");
960        touch_exe(&local_bin.join("typescript-language-server"));
961
962        let resolved = resolve_lsp_binary("typescript-language-server", Some(project), &[]);
963        assert_eq!(
964            resolved.as_deref(),
965            Some(local_bin.join("typescript-language-server").as_path())
966        );
967    }
968
969    #[test]
970    fn resolve_lsp_binary_falls_back_to_extra_paths() {
971        let tmp = tempfile::tempdir().unwrap();
972        let project = tmp.path().join("project");
973        std::fs::create_dir_all(&project).unwrap();
974
975        let extra_a = tmp.path().join("extra_a");
976        let extra_b = tmp.path().join("extra_b");
977        std::fs::create_dir_all(&extra_a).unwrap();
978        std::fs::create_dir_all(&extra_b).unwrap();
979        touch_exe(&extra_b.join("yaml-language-server"));
980
981        let resolved = resolve_lsp_binary(
982            "yaml-language-server",
983            Some(&project),
984            &[extra_a.clone(), extra_b.clone()],
985        );
986        assert_eq!(
987            resolved.as_deref(),
988            Some(extra_b.join("yaml-language-server").as_path())
989        );
990    }
991
992    #[test]
993    fn resolve_lsp_binary_extra_paths_search_in_order() {
994        let tmp = tempfile::tempdir().unwrap();
995        let extra_a = tmp.path().join("extra_a");
996        let extra_b = tmp.path().join("extra_b");
997        std::fs::create_dir_all(&extra_a).unwrap();
998        std::fs::create_dir_all(&extra_b).unwrap();
999        // Same binary in both — earlier path wins.
1000        touch_exe(&extra_a.join("bash-language-server"));
1001        touch_exe(&extra_b.join("bash-language-server"));
1002
1003        let resolved = resolve_lsp_binary(
1004            "bash-language-server",
1005            None,
1006            &[extra_a.clone(), extra_b.clone()],
1007        );
1008        assert_eq!(
1009            resolved.as_deref(),
1010            Some(extra_a.join("bash-language-server").as_path())
1011        );
1012    }
1013
1014    #[test]
1015    fn resolve_lsp_binary_project_root_wins_over_extra_paths() {
1016        let tmp = tempfile::tempdir().unwrap();
1017        let project = tmp.path().join("project");
1018        let local_bin = project.join("node_modules").join(".bin");
1019        touch_exe(&local_bin.join("pyright-langserver"));
1020
1021        let extra = tmp.path().join("extra");
1022        std::fs::create_dir_all(&extra).unwrap();
1023        touch_exe(&extra.join("pyright-langserver"));
1024
1025        let resolved = resolve_lsp_binary("pyright-langserver", Some(&project), &[extra.clone()]);
1026        assert_eq!(
1027            resolved.as_deref(),
1028            Some(local_bin.join("pyright-langserver").as_path())
1029        );
1030    }
1031
1032    #[test]
1033    fn resolve_lsp_binary_returns_none_for_missing_binary() {
1034        let tmp = tempfile::tempdir().unwrap();
1035        let project = tmp.path().join("project");
1036        std::fs::create_dir_all(&project).unwrap();
1037
1038        // Use a binary name that's almost certainly not on PATH.
1039        let resolved =
1040            resolve_lsp_binary("aft-test-nonexistent-binary-xyz123", Some(&project), &[]);
1041        assert!(resolved.is_none());
1042    }
1043
1044    #[test]
1045    fn resolve_lsp_binary_handles_missing_node_modules_gracefully() {
1046        // project_root is set but node_modules/.bin doesn't exist.
1047        // Should fall through to extra_paths and PATH without error.
1048        let tmp = tempfile::tempdir().unwrap();
1049        let project = tmp.path().join("project");
1050        std::fs::create_dir_all(&project).unwrap();
1051
1052        let extra = tmp.path().join("extra");
1053        std::fs::create_dir_all(&extra).unwrap();
1054        touch_exe(&extra.join("gopls"));
1055
1056        let resolved = resolve_lsp_binary("gopls", Some(&project), &[extra.clone()]);
1057        assert_eq!(resolved.as_deref(), Some(extra.join("gopls").as_path()));
1058    }
1059
1060    #[test]
1061    fn resolve_lsp_binary_skips_nonexistent_extra_path() {
1062        let tmp = tempfile::tempdir().unwrap();
1063        let missing = tmp.path().join("missing");
1064        let valid = tmp.path().join("valid");
1065        std::fs::create_dir_all(&valid).unwrap();
1066        touch_exe(&valid.join("clangd"));
1067
1068        let resolved = resolve_lsp_binary("clangd", None, &[missing, valid.clone()]);
1069
1070        assert_eq!(resolved.as_deref(), Some(valid.join("clangd").as_path()));
1071    }
1072
1073    #[test]
1074    fn resolve_lsp_binary_skips_file_extra_path() {
1075        let tmp = tempfile::tempdir().unwrap();
1076        let file = tmp.path().join("not-a-dir");
1077        let valid = tmp.path().join("valid");
1078        std::fs::write(&file, "not a directory").unwrap();
1079        std::fs::create_dir_all(&valid).unwrap();
1080        touch_exe(&valid.join("lua-language-server"));
1081
1082        let resolved = resolve_lsp_binary("lua-language-server", None, &[file, valid.clone()]);
1083
1084        assert_eq!(
1085            resolved.as_deref(),
1086            Some(valid.join("lua-language-server").as_path())
1087        );
1088    }
1089
1090    #[test]
1091    fn resolve_lsp_binary_skips_deleted_extra_path() {
1092        let tmp = tempfile::tempdir().unwrap();
1093        let deleted = tmp.path().join("deleted");
1094        let valid = tmp.path().join("valid");
1095        std::fs::create_dir_all(&deleted).unwrap();
1096        std::fs::remove_dir(&deleted).unwrap();
1097        std::fs::create_dir_all(&valid).unwrap();
1098        touch_exe(&valid.join("svelte-language-server"));
1099
1100        let resolved =
1101            resolve_lsp_binary("svelte-language-server", None, &[deleted, valid.clone()]);
1102
1103        assert_eq!(
1104            resolved.as_deref(),
1105            Some(valid.join("svelte-language-server").as_path())
1106        );
1107    }
1108
1109    // Avoid unused-import warning on platforms where probe_dir's Windows
1110    // branch is dead code.
1111    #[allow(dead_code)]
1112    fn _path_buf_used(_p: PathBuf) {}
1113}