1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct LspServerConfig {
6 pub command: String,
7 pub args: Vec<String>,
8}
9
10#[derive(Debug, Clone)]
11pub struct LspServerInfo {
12 pub language: &'static str,
13 pub binary: &'static str,
14 pub install_hint: &'static str,
15}
16
17pub const KNOWN_SERVERS: &[LspServerInfo] = &[
18 LspServerInfo {
19 language: "rust",
20 binary: "rust-analyzer",
21 install_hint: "rustup component add rust-analyzer",
22 },
23 LspServerInfo {
24 language: "typescript",
25 binary: "typescript-language-server",
26 install_hint: "npm install -g typescript (≤ 6: plus typescript-language-server)",
29 },
30 LspServerInfo {
31 language: "python",
32 binary: "pylsp",
33 install_hint: "pip install python-lsp-server",
34 },
35 LspServerInfo {
36 language: "go",
37 binary: "gopls",
38 install_hint: "go install golang.org/x/tools/gopls@latest",
39 },
40];
41
42pub fn default_servers() -> HashMap<&'static str, LspServerConfig> {
43 let mut m = HashMap::new();
44 m.insert(
45 "rust",
46 LspServerConfig {
47 command: "rust-analyzer".into(),
48 args: vec![],
49 },
50 );
51 m.insert(
52 "typescript",
53 LspServerConfig {
54 command: "typescript-language-server".into(),
55 args: vec!["--stdio".into()],
56 },
57 );
58 m.insert(
59 "javascript",
60 LspServerConfig {
61 command: "typescript-language-server".into(),
62 args: vec!["--stdio".into()],
63 },
64 );
65 m.insert(
66 "python",
67 LspServerConfig {
68 command: "pylsp".into(),
69 args: vec![],
70 },
71 );
72 m.insert(
73 "go",
74 LspServerConfig {
75 command: "gopls".into(),
76 args: vec!["serve".into()],
77 },
78 );
79 m
80}
81
82pub fn language_for_extension(ext: &str) -> Option<&'static str> {
83 match ext {
84 "rs" => Some("rust"),
85 "ts" | "tsx" => Some("typescript"),
86 "js" | "jsx" | "mjs" | "cjs" => Some("javascript"),
87 "py" | "pyi" => Some("python"),
88 "go" => Some("go"),
89 "java" => Some("java"),
90 "kt" | "kts" => Some("kotlin"),
91 "rb" => Some("ruby"),
92 "c" | "h" => Some("c"),
93 "cpp" | "cxx" | "cc" | "hpp" => Some("cpp"),
94 "cs" => Some("csharp"),
95 _ => None,
96 }
97}
98
99pub fn find_binary_in_path(binary: &str) -> Option<PathBuf> {
100 let path_var = std::env::var("PATH").ok()?;
101 std::env::split_paths(&path_var).find_map(|dir| executable_in(&dir, binary))
102}
103
104fn executable_in(dir: &Path, binary: &str) -> Option<PathBuf> {
108 let names: &[String] = if cfg!(windows) {
109 &[
110 format!("{binary}.exe"),
111 format!("{binary}.cmd"),
112 binary.to_string(),
113 ]
114 } else {
115 &[binary.to_string()]
116 };
117 names.iter().map(|n| dir.join(n)).find(|p| p.is_file())
118}
119
120pub fn find_runnable_server(binary: &str) -> Option<PathBuf> {
125 find_binary_in_path(binary).and_then(runnable)
126}
127
128fn runnable(path: PathBuf) -> Option<PathBuf> {
129 let is_rustup_proxy = std::fs::read_link(&path).is_ok_and(|target| {
130 target
131 .file_stem()
132 .is_some_and(|stem| stem.eq_ignore_ascii_case("rustup"))
133 });
134 if !is_rustup_proxy {
135 return Some(path);
136 }
137 std::process::Command::new(&path)
140 .arg("--version")
141 .env("RUSTUP_AUTO_INSTALL", "0")
142 .current_dir(std::env::temp_dir())
143 .stdin(std::process::Stdio::null())
144 .stdout(std::process::Stdio::null())
145 .stderr(std::process::Stdio::null())
146 .status()
147 .is_ok_and(|s| s.success())
148 .then_some(path)
149}
150
151#[derive(Debug, Clone, PartialEq)]
153pub struct ResolvedServer {
154 pub binary: PathBuf,
156 pub config: LspServerConfig,
158 pub init_options: Option<serde_json::Value>,
160}
161
162impl ResolvedServer {
163 pub fn binary_name(&self) -> String {
166 self.binary.file_stem().map_or_else(
167 || self.binary.to_string_lossy().into_owned(),
168 |n| n.to_string_lossy().into_owned(),
169 )
170 }
171
172 pub fn binary_path(&self) -> &Path {
174 &self.binary
175 }
176
177 fn new(binary: PathBuf, config: LspServerConfig) -> Self {
178 Self {
179 binary,
180 config,
181 init_options: None,
182 }
183 }
184}
185
186const TS_LANGUAGE_SERVER: &str = "typescript-language-server";
187
188pub fn resolve_server(language: &str, project_root: Option<&Path>) -> Option<ResolvedServer> {
193 if matches!(language, "typescript" | "javascript") {
194 return resolve_typescript(project_root, &find_binary_in_path);
195 }
196 let mut config = default_servers().remove(language)?;
197 let binary = find_runnable_server(&config.command)?;
198 config.command = binary.to_string_lossy().into_owned();
199 Some(ResolvedServer::new(binary, config))
200}
201
202pub fn configured_server(language: &str, command: &str, project_root: &Path) -> ResolvedServer {
205 let binary = PathBuf::from(command);
206 let stem = binary
207 .file_stem()
208 .map(|s| s.to_string_lossy().to_ascii_lowercase());
209 let plain = |args: &[&str]| LspServerConfig {
210 command: command.to_string(),
211 args: args.iter().map(|a| (*a).to_string()).collect(),
212 };
213 let (config, init_options) = match (language, stem.as_deref()) {
214 ("typescript" | "javascript", Some("tsc")) => (
215 npm_launch(
216 &find_binary_in_path,
217 &binary,
218 "typescript",
219 "tsc",
220 &["--lsp", "--stdio"],
221 )
222 .unwrap_or_else(|| plain(&["--lsp", "--stdio"])),
223 None,
224 ),
225 ("typescript" | "javascript", Some(TS_LANGUAGE_SERVER)) => (
226 npm_launch(
227 &find_binary_in_path,
228 &binary,
229 TS_LANGUAGE_SERVER,
230 TS_LANGUAGE_SERVER,
231 &["--stdio"],
232 )
233 .unwrap_or_else(|| plain(&["--stdio"])),
234 project_typescript(project_root)
235 .is_none()
236 .then(|| tsserver_options(&binary))
237 .flatten(),
238 ),
239 ("typescript" | "javascript", _) => (plain(&["--stdio"]), None),
240 ("go", _) => (plain(&["serve"]), None),
241 _ => (plain(&[]), None),
242 };
243 ResolvedServer {
244 binary,
245 config,
246 init_options,
247 }
248}
249
250fn resolve_typescript(
256 project_root: Option<&Path>,
257 which: &dyn Fn(&str) -> Option<PathBuf>,
258) -> Option<ResolvedServer> {
259 let tls = |init_options| {
260 let binary = which(TS_LANGUAGE_SERVER)?;
261 Some(ResolvedServer {
262 config: npm_launch(
263 which,
264 &binary,
265 TS_LANGUAGE_SERVER,
266 TS_LANGUAGE_SERVER,
267 &["--stdio"],
268 )?,
269 binary,
270 init_options,
271 })
272 };
273 if let Some(node_modules) = project_root.and_then(project_typescript) {
274 return if typescript_major(&node_modules.join("typescript"))? >= 7 {
275 native_tsc(which, executable_in(&node_modules.join(".bin"), "tsc")?)
276 } else {
277 tls(None)
279 };
280 }
281 if let Some(options) = which(TS_LANGUAGE_SERVER).and_then(|p| tsserver_options(&p)) {
284 return tls(Some(options));
285 }
286 let tsc = which("tsc")?;
288 if typescript_major(&npm_package_of(&tsc, "typescript")?)? >= 7 {
289 native_tsc(which, tsc)
290 } else {
291 None
292 }
293}
294
295fn native_tsc(which: &dyn Fn(&str) -> Option<PathBuf>, tsc: PathBuf) -> Option<ResolvedServer> {
296 let config = npm_launch(which, &tsc, "typescript", "tsc", &["--lsp", "--stdio"])?;
297 Some(ResolvedServer::new(tsc, config))
298}
299
300fn npm_launch(
306 which: &dyn Fn(&str) -> Option<PathBuf>,
307 bin: &Path,
308 package: &str,
309 bin_name: &str,
310 args: &[&str],
311) -> Option<LspServerConfig> {
312 let args = args.iter().map(|a| (*a).to_string());
313 let is_shim = bin
314 .extension()
315 .is_some_and(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"));
316 if !is_shim {
317 return bin.is_file().then(|| LspServerConfig {
318 command: bin.to_string_lossy().into_owned(),
319 args: args.collect(),
320 });
321 }
322 let entry = npm_bin_entry(&npm_package_of(bin, package)?, bin_name)?;
323 let node = which("node")?;
324 Some(LspServerConfig {
325 command: node.to_string_lossy().into_owned(),
326 args: std::iter::once(entry.to_string_lossy().into_owned())
327 .chain(args)
328 .collect(),
329 })
330}
331
332fn npm_bin_entry(package: &Path, bin_name: &str) -> Option<PathBuf> {
335 let manifest = std::fs::read_to_string(package.join("package.json")).ok()?;
336 let manifest: serde_json::Value = serde_json::from_str(&manifest).ok()?;
337 let relative = match manifest.get("bin")? {
338 serde_json::Value::String(only) => only.as_str(),
339 bins => bins.get(bin_name)?.as_str()?,
340 };
341 let entry = package.join(relative);
342 (entry.is_file()
344 && !Path::new(relative).components().any(|c| {
345 matches!(
346 c,
347 std::path::Component::ParentDir
348 | std::path::Component::RootDir
349 | std::path::Component::Prefix(_)
350 )
351 }))
352 .then_some(entry)
353}
354
355fn project_typescript(project_root: &Path) -> Option<PathBuf> {
357 project_root
358 .ancestors()
359 .map(|dir| dir.join("node_modules"))
360 .find(|nm| typescript_major(&nm.join("typescript")).is_some())
361}
362
363fn typescript_major(package_dir: &Path) -> Option<u32> {
365 let manifest = std::fs::read_to_string(package_dir.join("package.json")).ok()?;
366 let manifest: serde_json::Value = serde_json::from_str(&manifest).ok()?;
367 if manifest.get("name")?.as_str()? != "typescript" {
368 return None;
369 }
370 manifest
371 .get("version")?
372 .as_str()?
373 .split('.')
374 .next()?
375 .parse()
376 .ok()
377}
378
379fn npm_package_of(bin: &Path, name: &str) -> Option<PathBuf> {
384 let is_package = |dir: &Path| {
385 dir.file_name().is_some_and(|n| n == name)
386 && dir
387 .parent()
388 .and_then(Path::file_name)
389 .is_some_and(|n| n == "node_modules")
390 };
391 if let Some(dir) = std::fs::canonicalize(bin).ok().and_then(|real| {
392 real.ancestors()
393 .find(|d| is_package(d))
394 .map(Path::to_path_buf)
395 }) {
396 return Some(dir);
397 }
398 let dir = bin.parent()?;
399 let shim_target = if dir.file_name().is_some_and(|n| n == ".bin") {
400 dir.parent()?.join(name)
401 } else {
402 dir.join("node_modules").join(name)
403 };
404 shim_target
405 .join("package.json")
406 .is_file()
407 .then_some(shim_target)
408}
409
410fn tsserver_options(server: &Path) -> Option<serde_json::Value> {
414 let package = npm_package_of(server, TS_LANGUAGE_SERVER)?;
415 let tsserver = [
416 package.join("node_modules"),
417 package.parent()?.to_path_buf(),
418 ]
419 .into_iter()
420 .map(|nm| nm.join("typescript/lib/tsserver.js"))
421 .find(|p| p.is_file())?;
422 Some(serde_json::json!({ "tsserver": { "path": tsserver } }))
423}
424
425pub fn install_hint_for_language(language: &str) -> &'static str {
426 for info in KNOWN_SERVERS {
427 if info.language == language {
428 return info.install_hint;
429 }
430 }
431 "No install instructions available for this language server."
432}
433
434pub fn binary_for_language(language: &str) -> Option<&'static str> {
435 for info in KNOWN_SERVERS {
436 if info.language == language {
437 return Some(info.binary);
438 }
439 }
440 None
441}
442
443pub fn check_server_available(
445 language: &str,
446 project_root: &Path,
447) -> Result<ResolvedServer, String> {
448 let servers = default_servers();
449 let config = servers
450 .get(language)
451 .ok_or_else(|| format!("No LSP server configured for '{language}'"))?;
452
453 resolve_server(language, Some(project_root)).ok_or_else(|| {
454 let hint = install_hint_for_language(language);
455 format!(
456 "Language server '{}' not found in PATH (or not installed behind its rustup proxy).\n\
457 \n\
458 LSP-backed symbol operations require an external language server for '{}' files.\n\
459 Install it with:\n\
460 \n\
461 \x20 {}\n\
462 \n\
463 Then retry. This is optional — ctx_search and ctx_graph work without it.",
464 config.command, language, hint
465 )
466 })
467}
468
469#[cfg(test)]
470mod npm_shim_tests {
471 use std::path::Path;
472
473 fn write(path: &Path, content: &str) {
474 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
475 std::fs::write(path, content).unwrap();
476 }
477
478 #[test]
483 fn windows_style_shims_start_the_package_entry_with_node() {
484 let dir = tempfile::tempdir().unwrap();
485 let prefix = dir.path();
486 let nm = prefix.join("node_modules");
487 let node = prefix.join("node.exe");
488 write(&node, "");
489 write(&prefix.join("tsc.cmd"), "@node tsc %*");
490 write(
491 &nm.join("typescript/package.json"),
492 r#"{"name":"typescript","version":"7.0.2","bin":{"tsc":"./bin/tsc"}}"#,
493 );
494 write(&nm.join("typescript/bin/tsc"), "");
495 let project_dir = tempfile::tempdir().unwrap();
497 let project = project_dir.path();
498 let which = |name: &str| {
499 if name == "node" {
500 return Some(node.clone());
501 }
502 let shim = prefix.join(format!("{name}.cmd"));
503 shim.is_file().then_some(shim)
504 };
505 let entry = |pkg: &str, rel: &str| nm.join(pkg).join(rel).to_string_lossy().into_owned();
506
507 let native = super::resolve_typescript(Some(project), &which).unwrap();
508 assert_eq!(native.binary, prefix.join("tsc.cmd"));
509 assert_eq!(native.binary_name(), "tsc");
510 assert_eq!(Path::new(&native.config.command), node);
511 assert_eq!(
512 native.config.args,
513 [
514 entry("typescript", "./bin/tsc"),
515 "--lsp".into(),
516 "--stdio".into()
517 ]
518 );
519
520 write(
522 &nm.join("typescript/package.json"),
523 r#"{"name":"typescript","version":"5.9.3"}"#,
524 );
525 write(&nm.join("typescript/lib/tsserver.js"), "");
526 write(&prefix.join("typescript-language-server.cmd"), "");
527 write(
528 &nm.join("typescript-language-server/package.json"),
529 r#"{"bin":"lib/cli.mjs"}"#,
530 );
531 write(&nm.join("typescript-language-server/lib/cli.mjs"), "");
532 let tls = super::resolve_typescript(Some(project), &which).unwrap();
533 assert_eq!(tls.binary_name(), "typescript-language-server");
534 assert_eq!(
535 tls.config.args,
536 [
537 entry("typescript-language-server", "lib/cli.mjs"),
538 "--stdio".into()
539 ]
540 );
541 assert_eq!(
542 tls.init_options.unwrap()["tsserver"]["path"],
543 nm.join("typescript/lib/tsserver.js").to_str().unwrap()
544 );
545
546 write(
548 &nm.join("typescript-language-server/package.json"),
549 r#"{"bin":"../typescript/lib/tsserver.js"}"#,
550 );
551 assert_eq!(super::resolve_typescript(Some(project), &which), None);
552 }
553
554 #[test]
556 fn configured_servers_get_their_kind_specific_arguments() {
557 let root = Path::new("/nonexistent-project");
558 let args = |lang: &str, cmd: &str| super::configured_server(lang, cmd, root).config.args;
559 assert_eq!(args("typescript", "/opt/ts/bin/tsc"), ["--lsp", "--stdio"]);
560 assert_eq!(
561 args("typescript", "/opt/typescript-language-server"),
562 ["--stdio"]
563 );
564 assert_eq!(args("go", "/opt/gopls"), ["serve"]);
565 {
566 let found = &args("rust", "/opt/rust-analyzer");
567 assert!(found.is_empty(), "{found:?}");
568 }
569 }
570}
571
572#[cfg(all(test, unix))]
573mod tests {
574 use super::runnable;
575 use std::os::unix::fs::{PermissionsExt, symlink};
576
577 #[test]
580 fn rustup_proxy_counts_only_when_it_runs() {
581 let dir = tempfile::tempdir().unwrap();
582 let rustup = dir.path().join("rustup");
583 let proxy = dir.path().join("fake-analyzer");
584 symlink(&rustup, &proxy).unwrap();
585
586 let mut verdicts = Vec::new();
587 for exit_code in [1, 0] {
588 std::fs::write(&rustup, format!("#!/bin/sh\nexit {exit_code}\n")).unwrap();
589 std::fs::set_permissions(&rustup, std::fs::Permissions::from_mode(0o755)).unwrap();
590 verdicts.push(runnable(proxy.clone()).is_some());
591 }
592 assert_eq!(verdicts, vec![false, true]);
593 }
594
595 #[test]
599 fn typescript_server_follows_the_typescript_version() {
600 use std::path::{Path, PathBuf};
601 fn write(path: &Path, content: &str) {
602 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
603 std::fs::write(path, content).unwrap();
604 }
605 fn typescript(pkg: &Path, version: &str) {
606 write(
607 &pkg.join("package.json"),
608 &format!(r#"{{"name":"typescript","version":"{version}"}}"#),
609 );
610 }
611 let dir = tempfile::tempdir().unwrap();
612 let (global, bin) = (dir.path().join("lib/node_modules"), dir.path().join("bin"));
613 write(&global.join("typescript-language-server/lib/cli.mjs"), "");
615 typescript(&global.join("typescript"), "5.9.3");
616 write(&global.join("typescript/lib/tsserver.js"), "");
617 write(&global.join("typescript/bin/tsc"), "");
618 std::fs::create_dir_all(&bin).unwrap();
619 symlink(
620 global.join("typescript-language-server/lib/cli.mjs"),
621 bin.join("typescript-language-server"),
622 )
623 .unwrap();
624 symlink(global.join("typescript/bin/tsc"), bin.join("tsc")).unwrap();
625 let which = |name: &str| Some(bin.join(name)).filter(|p| p.exists());
626 let resolve = |root: &Path| super::resolve_typescript(Some(root), &which);
627 let command = |r: Option<super::ResolvedServer>| r.map(|r| r.binary_name());
628
629 let bare = dir.path().join("bare");
630 std::fs::create_dir_all(&bare).unwrap();
631 let tls = resolve(&bare).unwrap();
632 assert_eq!(tls.binary_name(), "typescript-language-server");
633 assert_eq!(
634 tls.init_options.unwrap()["tsserver"]["path"],
635 std::fs::canonicalize(global.join("typescript/lib/tsserver.js"))
636 .unwrap()
637 .to_str()
638 .unwrap()
639 );
640
641 let ts7 = dir.path().join("ts7");
644 typescript(&ts7.join("node_modules/typescript"), "7.0.2");
645 write(&ts7.join("node_modules/.bin/tsc"), "");
646 let native = resolve(&ts7.join("packages/app")).unwrap();
647 assert_eq!(native.config.args, ["--lsp", "--stdio"]);
648 assert!(Path::new(&native.config.command).starts_with(&ts7));
649 let ts5 = dir.path().join("ts5");
650 typescript(&ts5.join("node_modules/typescript"), "5.4.0");
651 assert_eq!(resolve(&ts5).unwrap().init_options, None);
652
653 std::fs::remove_file(bin.join("typescript-language-server")).unwrap();
655 assert_eq!(command(resolve(&bare)), None, "tsc 5 is no server");
656 typescript(&global.join("typescript"), "7.0.2");
657 let global_native = resolve(&bare).unwrap();
658 assert_eq!(
659 PathBuf::from(&global_native.config.command),
660 bin.join("tsc")
661 );
662 }
663}