Skip to main content

codelens_engine/lsp/
registry.rs

1use serde::Serialize;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, Serialize)]
5pub struct LspRecipe {
6    pub language: &'static str,
7    pub extensions: &'static [&'static str],
8    pub server_name: &'static str,
9    pub install_command: &'static str,
10    pub binary_name: &'static str,
11    pub args: &'static [&'static str],
12    pub package_manager: &'static str,
13}
14
15pub const LSP_RECIPES: &[LspRecipe] = &[
16    LspRecipe {
17        language: "python",
18        extensions: &["py"],
19        server_name: "pyright",
20        install_command: "npm install -g pyright",
21        binary_name: "pyright-langserver",
22        args: &["--stdio"],
23        package_manager: "npm",
24    },
25    LspRecipe {
26        language: "typescript",
27        extensions: &["ts", "tsx", "js", "jsx", "mjs", "cjs"],
28        server_name: "typescript-language-server",
29        install_command: "npm install -g typescript-language-server typescript",
30        binary_name: "typescript-language-server",
31        args: &["--stdio"],
32        package_manager: "npm",
33    },
34    LspRecipe {
35        language: "rust",
36        extensions: &["rs"],
37        server_name: "rust-analyzer",
38        install_command: "rustup component add rust-analyzer",
39        binary_name: "rust-analyzer",
40        args: &[],
41        package_manager: "rustup",
42    },
43    LspRecipe {
44        language: "go",
45        extensions: &["go"],
46        server_name: "gopls",
47        install_command: "go install golang.org/x/tools/gopls@latest",
48        binary_name: "gopls",
49        args: &["serve"],
50        package_manager: "go",
51    },
52    LspRecipe {
53        language: "java",
54        extensions: &["java"],
55        server_name: "jdtls",
56        install_command: "brew install jdtls",
57        binary_name: "jdtls",
58        args: &[],
59        package_manager: "brew",
60    },
61    LspRecipe {
62        language: "kotlin",
63        extensions: &["kt", "kts"],
64        server_name: "kotlin-language-server",
65        install_command: "brew install kotlin-language-server",
66        binary_name: "kotlin-language-server",
67        args: &[],
68        package_manager: "brew",
69    },
70    LspRecipe {
71        language: "c_cpp",
72        extensions: &["c", "h", "cpp", "cc", "cxx", "hpp"],
73        server_name: "clangd",
74        install_command: "brew install llvm",
75        binary_name: "clangd",
76        args: &["--background-index"],
77        package_manager: "brew",
78    },
79    LspRecipe {
80        language: "ruby",
81        extensions: &["rb"],
82        server_name: "solargraph",
83        install_command: "gem install solargraph",
84        binary_name: "solargraph",
85        args: &["stdio"],
86        package_manager: "gem",
87    },
88    LspRecipe {
89        language: "php",
90        extensions: &["php"],
91        server_name: "intelephense",
92        install_command: "npm install -g intelephense",
93        binary_name: "intelephense",
94        args: &["--stdio"],
95        package_manager: "npm",
96    },
97    LspRecipe {
98        language: "scala",
99        extensions: &["scala", "sc"],
100        server_name: "metals",
101        install_command: "cs install metals",
102        binary_name: "metals",
103        args: &[],
104        package_manager: "coursier",
105    },
106    LspRecipe {
107        language: "swift",
108        extensions: &["swift"],
109        server_name: "sourcekit-lsp",
110        install_command: "xcode-select --install",
111        binary_name: "sourcekit-lsp",
112        args: &[],
113        package_manager: "xcode",
114    },
115    LspRecipe {
116        language: "csharp",
117        extensions: &["cs"],
118        server_name: "omnisharp",
119        install_command: "dotnet tool install -g csharp-ls",
120        binary_name: "csharp-ls",
121        args: &[],
122        package_manager: "dotnet",
123    },
124    LspRecipe {
125        language: "dart",
126        extensions: &["dart"],
127        server_name: "dart-language-server",
128        install_command: "dart pub global activate dart_language_server",
129        binary_name: "dart",
130        args: &["language-server", "--protocol=lsp"],
131        package_manager: "dart",
132    },
133    // Phase 6a: new languages
134    LspRecipe {
135        language: "lua",
136        extensions: &["lua"],
137        server_name: "lua-language-server",
138        install_command: "brew install lua-language-server",
139        binary_name: "lua-language-server",
140        args: &[],
141        package_manager: "brew",
142    },
143    LspRecipe {
144        language: "zig",
145        extensions: &["zig"],
146        server_name: "zls",
147        install_command: "brew install zls",
148        binary_name: "zls",
149        args: &[],
150        package_manager: "brew",
151    },
152    LspRecipe {
153        language: "elixir",
154        extensions: &["ex", "exs"],
155        server_name: "next-ls",
156        install_command: "mix escript.install hex next_ls",
157        binary_name: "nextls",
158        args: &["--stdio"],
159        package_manager: "mix",
160    },
161    LspRecipe {
162        language: "haskell",
163        extensions: &["hs"],
164        server_name: "haskell-language-server",
165        install_command: "ghcup install hls",
166        binary_name: "haskell-language-server-wrapper",
167        args: &["--lsp"],
168        package_manager: "ghcup",
169    },
170    LspRecipe {
171        language: "ocaml",
172        extensions: &["ml", "mli"],
173        server_name: "ocamllsp",
174        install_command: "opam install ocaml-lsp-server",
175        binary_name: "ocamllsp",
176        args: &[],
177        package_manager: "opam",
178    },
179    LspRecipe {
180        language: "erlang",
181        extensions: &["erl", "hrl"],
182        server_name: "erlang_ls",
183        install_command: "brew install erlang_ls",
184        binary_name: "erlang_ls",
185        args: &[],
186        package_manager: "brew",
187    },
188    LspRecipe {
189        language: "r",
190        extensions: &["r", "R"],
191        server_name: "languageserver",
192        install_command: "R -e 'install.packages(\"languageserver\")'",
193        binary_name: "R",
194        args: &["--slave", "-e", "languageserver::run()"],
195        package_manager: "R",
196    },
197    LspRecipe {
198        language: "shellscript",
199        extensions: &["sh", "bash"],
200        server_name: "bash-language-server",
201        install_command: "npm install -g bash-language-server",
202        binary_name: "bash-language-server",
203        args: &["start"],
204        package_manager: "npm",
205    },
206    LspRecipe {
207        language: "julia",
208        extensions: &["jl"],
209        server_name: "julia-lsp",
210        install_command: "julia -e 'using Pkg; Pkg.add(\"LanguageServer\")'",
211        binary_name: "julia",
212        args: &["--project=@.", "-e", "using LanguageServer; runserver()"],
213        package_manager: "julia",
214    },
215    // Perl deferred until tree-sitter 0.26 upgrade
216];
217
218fn command_candidates(command: &str) -> Vec<String> {
219    #[cfg(windows)]
220    let mut candidates = vec![command.to_owned()];
221    #[cfg(not(windows))]
222    let candidates = vec![command.to_owned()];
223    #[cfg(windows)]
224    if Path::new(command).extension().is_none() {
225        let pathext = std::env::var_os("PATHEXT")
226            .and_then(|value| value.into_string().ok())
227            .unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".to_owned());
228        for ext in pathext.split(';').filter(|ext| !ext.is_empty()) {
229            let normalized = if ext.starts_with('.') {
230                ext.to_owned()
231            } else {
232                format!(".{ext}")
233            };
234            let candidate = format!("{command}{normalized}");
235            if !candidates
236                .iter()
237                .any(|existing| existing.eq_ignore_ascii_case(&candidate))
238            {
239                candidates.push(candidate);
240            }
241        }
242    }
243    candidates
244}
245
246fn resolve_in_dir(dir: &Path, command: &str) -> Option<PathBuf> {
247    command_candidates(command)
248        .into_iter()
249        .map(|candidate| dir.join(candidate))
250        .find(|candidate| candidate.is_file())
251}
252
253fn fallback_search_dirs() -> Vec<PathBuf> {
254    let mut dirs = Vec::new();
255    #[cfg(not(windows))]
256    {
257        let home = std::env::var("HOME").unwrap_or_default();
258        for dir in [
259            "/opt/homebrew/bin".to_owned(),
260            "/usr/local/bin".to_owned(),
261            format!("{home}/.cargo/bin"),
262            format!("{home}/.fnm/aliases/default/bin"),
263            format!("{home}/.nvm/versions/node/current/bin"),
264        ] {
265            if !dir.is_empty() {
266                dirs.push(PathBuf::from(dir));
267            }
268        }
269    }
270    #[cfg(windows)]
271    {
272        let user_profile = std::env::var("USERPROFILE").unwrap_or_default();
273        let app_data = std::env::var("APPDATA").unwrap_or_default();
274        for dir in [
275            format!("{user_profile}\\.cargo\\bin"),
276            format!("{app_data}\\npm"),
277        ] {
278            if !dir.is_empty() {
279                dirs.push(PathBuf::from(dir));
280            }
281        }
282    }
283    dirs
284}
285
286/// Maximum number of parent directories to traverse when looking for a
287/// `node_modules/.bin/<command>` shim. The Next.js / pnpm monorepo layout
288/// rarely nests workspaces deeper than a few levels, so 8 is a generous
289/// upper bound that still avoids walking out of the project tree.
290const NODE_MODULES_TRAVERSE_DEPTH: usize = 8;
291
292/// Walk `start` and up to `depth` parents, returning the path of the first
293/// `node_modules/.bin/<command>` shim that exists. Used by the LSP resolver
294/// so a Next.js / TS project that installs `typescript-language-server`
295/// only as a devDependency does not have to globally install it.
296fn find_in_node_modules_bin(start: &Path, command: &str, depth: usize) -> Option<PathBuf> {
297    let mut current = Some(start);
298    let mut steps = 0;
299    while let Some(dir) = current {
300        if steps > depth {
301            break;
302        }
303        if let Some(path) = resolve_in_dir(&dir.join("node_modules").join(".bin"), command) {
304            return Some(path);
305        }
306        current = dir.parent();
307        steps += 1;
308    }
309    None
310}
311
312pub(crate) fn resolve_lsp_binary(command: &str) -> Option<PathBuf> {
313    resolve_lsp_binary_with_hint(command, None)
314}
315
316/// Resolve an LSP binary with an optional `hint_dir`. When `hint_dir` is
317/// `Some`, the resolver also walks up from that directory looking for a
318/// `node_modules/.bin/<command>` shim before reporting the binary as
319/// missing. This unblocks Node / TS projects that install LSP servers
320/// as devDependencies (the Next.js standard pattern), where the global
321/// PATH does not see the binary but the per-project shim does.
322pub fn resolve_lsp_binary_with_hint(command: &str, hint_dir: Option<&Path>) -> Option<PathBuf> {
323    let command_path = Path::new(command);
324    if command_path.components().count() > 1 {
325        return if command_path.is_file() {
326            Some(command_path.to_path_buf())
327        } else if let Some(parent) = command_path.parent() {
328            resolve_in_dir(parent, command_path.file_name()?.to_str()?)
329        } else {
330            None
331        };
332    }
333
334    if let Some(path_dirs) = std::env::var_os("PATH") {
335        for dir in std::env::split_paths(&path_dirs) {
336            if let Some(path) = resolve_in_dir(&dir, command) {
337                return Some(path);
338            }
339        }
340    }
341
342    for dir in fallback_search_dirs() {
343        if let Some(path) = resolve_in_dir(&dir, command) {
344            return Some(path);
345        }
346    }
347
348    if let Some(extra) = std::env::var_os("CODELENS_LSP_PATH_EXTRA") {
349        for dir in std::env::split_paths(&extra) {
350            if let Some(path) = resolve_in_dir(&dir, command) {
351                return Some(path);
352            }
353        }
354    }
355
356    if let Some(start) = hint_dir
357        && let Some(path) = find_in_node_modules_bin(start, command, NODE_MODULES_TRAVERSE_DEPTH)
358    {
359        return Some(path);
360    }
361
362    None
363}
364
365/// Return `true` when the given LSP binary is resolvable either via the
366/// current `PATH` or via a conservative allow-list of common install
367/// locations. This keeps runtime capability reporting and `check_lsp_status`
368/// aligned even when the daemon inherits a minimal launchd/systemd PATH.
369pub fn lsp_binary_exists(command: &str) -> bool {
370    resolve_lsp_binary(command).is_some()
371}
372
373/// Like [`lsp_binary_exists`] but also walks up from `hint_dir` looking
374/// for `node_modules/.bin/<command>` shims. Callers that have a concrete
375/// project root or file path should prefer this — it lets capability
376/// reporting return `installed = true` for Node / TS projects that ship
377/// the LSP only as a devDependency.
378pub fn lsp_binary_exists_with_hint(command: &str, hint_dir: Option<&Path>) -> bool {
379    resolve_lsp_binary_with_hint(command, hint_dir).is_some()
380}
381
382/// Check which LSP servers are installed and which are missing.
383pub fn check_lsp_status() -> Vec<LspStatus> {
384    LSP_RECIPES
385        .iter()
386        .map(|recipe| LspStatus {
387            language: recipe.language,
388            server_name: recipe.server_name,
389            installed: lsp_binary_exists(recipe.binary_name),
390            install_command: recipe.install_command,
391        })
392        .collect()
393}
394
395#[derive(Debug, Clone, Serialize)]
396pub struct LspStatus {
397    pub language: &'static str,
398    pub server_name: &'static str,
399    pub installed: bool,
400    pub install_command: &'static str,
401}
402
403/// Get the recipe for a file extension.
404pub fn get_lsp_recipe(extension: &str) -> Option<&'static LspRecipe> {
405    let ext = extension.trim_start_matches('.').to_ascii_lowercase();
406    LSP_RECIPES
407        .iter()
408        .find(|r| r.extensions.contains(&ext.as_str()))
409}
410
411pub fn default_lsp_command_for_extension(extension: &str) -> Option<&'static str> {
412    get_lsp_recipe(extension).map(|recipe| recipe.binary_name)
413}
414
415pub fn default_lsp_command_for_path(file_path: &str) -> Option<&'static str> {
416    Path::new(file_path)
417        .extension()
418        .and_then(|ext| ext.to_str())
419        .and_then(default_lsp_command_for_extension)
420}
421
422pub fn default_lsp_args_for_command(command: &str) -> Option<&'static [&'static str]> {
423    LSP_RECIPES
424        .iter()
425        .find(|recipe| recipe.binary_name == command)
426        .map(|recipe| recipe.args)
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    /// Issue #215: a Next.js / TS project that installs
434    /// `typescript-language-server` only as a devDependency must still
435    /// be reachable via `node_modules/.bin/<command>`. The hint
436    /// directory points the resolver at the project root so the shim
437    /// is found even when the daemon's PATH does not include it.
438    #[test]
439    fn resolves_lsp_via_node_modules_bin_when_hint_dir_provided() {
440        let tempdir = tempfile::tempdir().expect("tempdir");
441        let bin_dir = tempdir.path().join("node_modules").join(".bin");
442        std::fs::create_dir_all(&bin_dir).expect("mkdir node_modules/.bin");
443        let unique = format!(
444            "phantom-lsp-{}",
445            std::time::SystemTime::now()
446                .duration_since(std::time::UNIX_EPOCH)
447                .map(|d| d.as_nanos())
448                .unwrap_or(0)
449        );
450        let shim = bin_dir.join(&unique);
451        std::fs::write(&shim, b"#!/bin/sh\nexit 0\n").expect("write shim");
452
453        // Without the hint, resolution fails — the binary is not on PATH.
454        assert!(
455            resolve_lsp_binary(&unique).is_none(),
456            "binary must be invisible without the hint dir"
457        );
458
459        // With the hint, the project-local shim is discovered.
460        let resolved = resolve_lsp_binary_with_hint(&unique, Some(tempdir.path()))
461            .expect("hint dir resolves the project-local shim");
462        assert_eq!(resolved, shim);
463        assert!(lsp_binary_exists_with_hint(&unique, Some(tempdir.path())));
464    }
465
466    /// The resolver walks up to `NODE_MODULES_TRAVERSE_DEPTH` parents,
467    /// so a hint pointing at `<repo>/apps/web/src/lib/...` still finds
468    /// `<repo>/node_modules/.bin/<command>` (npm/pnpm hoisting layout).
469    #[test]
470    fn resolves_lsp_via_node_modules_bin_in_parent_directory() {
471        let tempdir = tempfile::tempdir().expect("tempdir");
472        let nested = tempdir.path().join("apps/web/src/lib");
473        std::fs::create_dir_all(&nested).expect("mkdir nested");
474        let bin_dir = tempdir.path().join("node_modules").join(".bin");
475        std::fs::create_dir_all(&bin_dir).expect("mkdir node_modules/.bin");
476        let unique = format!(
477            "phantom-lsp-parent-{}",
478            std::time::SystemTime::now()
479                .duration_since(std::time::UNIX_EPOCH)
480                .map(|d| d.as_nanos())
481                .unwrap_or(0)
482        );
483        let shim = bin_dir.join(&unique);
484        std::fs::write(&shim, b"#!/bin/sh\nexit 0\n").expect("write shim");
485
486        let resolved = resolve_lsp_binary_with_hint(&unique, Some(&nested))
487            .expect("parent traversal must surface hoisted shim");
488        assert_eq!(resolved, shim);
489    }
490
491    /// `resolve_lsp_binary_with_hint(_, None)` must behave exactly like
492    /// `resolve_lsp_binary` — passing `None` does not enable any new
493    /// fallback path (and so does not regress callers that have no
494    /// project context).
495    #[test]
496    fn hint_none_does_not_enable_node_modules_fallback() {
497        let tempdir = tempfile::tempdir().expect("tempdir");
498        let bin_dir = tempdir.path().join("node_modules").join(".bin");
499        std::fs::create_dir_all(&bin_dir).expect("mkdir node_modules/.bin");
500        let unique = format!(
501            "phantom-lsp-no-hint-{}",
502            std::time::SystemTime::now()
503                .duration_since(std::time::UNIX_EPOCH)
504                .map(|d| d.as_nanos())
505                .unwrap_or(0)
506        );
507        std::fs::write(bin_dir.join(&unique), b"#!/bin/sh\nexit 0\n").expect("write shim");
508
509        // No hint → resolver only consults PATH + standard fallback dirs.
510        // Since `tempdir` is not on PATH and is not a fallback dir, the
511        // binary remains undiscovered.
512        assert!(resolve_lsp_binary_with_hint(&unique, None).is_none());
513    }
514
515    #[test]
516    fn recipe_accepts_leading_dot_for_extensions() {
517        let recipe = get_lsp_recipe(".tsx").expect("tsx recipe");
518        assert_eq!(recipe.language, "typescript");
519        assert_eq!(recipe.binary_name, "typescript-language-server");
520    }
521}