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 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 ];
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
286const NODE_MODULES_TRAVERSE_DEPTH: usize = 8;
291
292fn 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
316pub 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
365pub fn lsp_binary_exists(command: &str) -> bool {
370 resolve_lsp_binary(command).is_some()
371}
372
373pub 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
382pub 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
403pub 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 #[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 assert!(
455 resolve_lsp_binary(&unique).is_none(),
456 "binary must be invisible without the hint dir"
457 );
458
459 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 #[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 #[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 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}