1use std::path::{Path, PathBuf};
2
3use anyhow::{bail, Context};
4use tracing::{debug, info, warn};
5
6use super::registry::{
7 get_entry, resolve_download_url, resolve_server, servers_dir, ArchiveType, InstallMethod,
8 ServerEntry,
9};
10use crate::detect::Language;
11
12pub async fn ensure_server(language: Language) -> anyhow::Result<(PathBuf, ServerEntry)> {
21 if let Some((entry, path)) = resolve_server(language) {
23 debug!("found {}: {}", entry.binary_name, path.display());
24 if let Some(cmd) = entry.requires_cmd {
26 if !command_exists(cmd) {
27 anyhow::bail!(
28 "{} is installed but requires `{cmd}` in PATH to function.\n Install Go: https://go.dev/dl/",
29 entry.binary_name
30 );
31 }
32 }
33 return Ok((path, entry));
34 }
35
36 let entry =
38 get_entry(language).with_context(|| format!("no LSP server configured for {language}"))?;
39
40 info!("{} not found, downloading...", entry.binary_name);
41 let path = download_server(&entry).await?;
42 Ok((path, entry))
43}
44
45pub async fn download_server(entry: &ServerEntry) -> anyhow::Result<PathBuf> {
50 let dir = servers_dir();
51 std::fs::create_dir_all(&dir)
52 .with_context(|| format!("failed to create servers directory: {}", dir.display()))?;
53
54 match &entry.install_method {
55 InstallMethod::GithubRelease { archive, .. } => {
56 download_github_release(entry, &dir, *archive).await
57 }
58 InstallMethod::Npm {
59 package,
60 extra_packages,
61 } => download_npm(entry, &dir, package, extra_packages).await,
62 InstallMethod::GoInstall { module } => download_go(entry, &dir, module).await,
63 InstallMethod::Homebrew { package } => download_homebrew(entry, package).await,
64 }
65}
66
67async fn download_github_release(
69 entry: &ServerEntry,
70 dir: &Path,
71 archive: ArchiveType,
72) -> anyhow::Result<PathBuf> {
73 let url = resolve_download_url(entry).context("cannot resolve download URL for this server")?;
74
75 let target = dir.join(entry.binary_name);
76 let tmp = dir.join(format!(".{}.tmp", entry.binary_name));
77
78 let download_status = tokio::process::Command::new("curl")
80 .args(["-fsSL", "-o"])
81 .arg(&tmp)
82 .arg(&url)
83 .stdout(std::process::Stdio::null())
84 .stderr(std::process::Stdio::piped())
85 .status()
86 .await
87 .context("failed to run curl — is curl installed?")?;
88
89 if !download_status.success() {
90 let _ = std::fs::remove_file(&tmp);
91 bail!(
92 "failed to download {} from {url}\n {}",
93 entry.binary_name,
94 entry.install_advice
95 );
96 }
97
98 match archive {
100 ArchiveType::Gzip => {
101 let gunzip_status = tokio::process::Command::new("gunzip")
102 .args(["-f"])
103 .arg(&tmp)
104 .status()
105 .await
106 .context("failed to run gunzip")?;
107
108 if !gunzip_status.success() {
109 let _ = std::fs::remove_file(&tmp);
110 bail!("failed to decompress {}", entry.binary_name);
111 }
112
113 let decompressed = dir.join(format!(".{}", entry.binary_name));
117 if decompressed.exists() {
118 std::fs::rename(&decompressed, &target)?;
119 } else if tmp.exists() {
120 std::fs::rename(&tmp, &target)?;
122 }
123 }
124 }
125
126 #[cfg(unix)]
128 {
129 use std::os::unix::fs::PermissionsExt;
130 let perms = std::fs::Permissions::from_mode(0o755);
131 std::fs::set_permissions(&target, perms).context("failed to make binary executable")?;
132 }
133
134 let verify = tokio::process::Command::new(&target)
136 .arg("--version")
137 .stdout(std::process::Stdio::null())
138 .stderr(std::process::Stdio::null())
139 .status()
140 .await;
141
142 match verify {
143 Ok(status) if status.success() => {
144 info!("installed {} to {}", entry.binary_name, target.display());
145 }
146 _ => {
147 warn!(
148 "{} downloaded but --version check failed (may still work)",
149 entry.binary_name
150 );
151 }
152 }
153
154 Ok(target)
155}
156
157async fn download_npm(
159 entry: &ServerEntry,
160 dir: &Path,
161 package: &str,
162 extra_packages: &[&str],
163) -> anyhow::Result<PathBuf> {
164 if !command_exists("node") {
166 bail!(
167 "Node.js is required for {} but not found in PATH.\n {}",
168 entry.binary_name,
169 entry.install_advice
170 );
171 }
172
173 let npm_dir = dir.join("npm");
174 std::fs::create_dir_all(&npm_dir)?;
175
176 let mut args = vec!["install", "--prefix"];
177 let npm_dir_str = npm_dir
178 .to_str()
179 .context("npm directory path is not valid UTF-8")?;
180 args.push(npm_dir_str);
181 args.push(package);
182 for pkg in extra_packages {
183 args.push(pkg);
184 }
185
186 let status = tokio::process::Command::new("npm")
187 .args(&args)
188 .stdout(std::process::Stdio::null())
189 .stderr(std::process::Stdio::piped())
190 .status()
191 .await
192 .context("failed to run npm — is npm installed?")?;
193
194 if !status.success() {
195 bail!(
196 "npm install failed for {}.\n {}",
197 package,
198 entry.install_advice
199 );
200 }
201
202 let bin_path = npm_dir
203 .join("node_modules")
204 .join(".bin")
205 .join(entry.binary_name);
206
207 if !bin_path.exists() {
208 bail!(
209 "{} not found after npm install at {}",
210 entry.binary_name,
211 bin_path.display()
212 );
213 }
214
215 info!(
216 "installed {} via npm to {}",
217 entry.binary_name,
218 bin_path.display()
219 );
220 Ok(bin_path)
221}
222
223async fn download_go(entry: &ServerEntry, dir: &Path, module: &str) -> anyhow::Result<PathBuf> {
225 if !command_exists("go") {
226 bail!(
227 "Go is required for {} but not found in PATH.\n {}",
228 entry.binary_name,
229 entry.install_advice
230 );
231 }
232
233 let go_dir = dir.join("go");
234 std::fs::create_dir_all(&go_dir)?;
235
236 let status = tokio::process::Command::new("go")
237 .args(["install", module])
238 .env("GOPATH", &go_dir)
239 .stdout(std::process::Stdio::null())
240 .stderr(std::process::Stdio::piped())
241 .status()
242 .await
243 .context("failed to run go install")?;
244
245 if !status.success() {
246 bail!(
247 "go install failed for {}.\n {}",
248 module,
249 entry.install_advice
250 );
251 }
252
253 let bin_path = go_dir.join("bin").join(entry.binary_name);
254 if !bin_path.exists() {
255 bail!(
256 "{} not found after go install at {}",
257 entry.binary_name,
258 bin_path.display()
259 );
260 }
261
262 info!(
263 "installed {} via go install to {}",
264 entry.binary_name,
265 bin_path.display()
266 );
267 Ok(bin_path)
268}
269
270async fn download_homebrew(entry: &ServerEntry, package: &str) -> anyhow::Result<PathBuf> {
276 if !command_exists("brew") {
277 bail!(
278 "Homebrew is required for {} but not found in PATH.\n {}",
279 entry.binary_name,
280 entry.install_advice
281 );
282 }
283
284 let status = tokio::process::Command::new("brew")
285 .args(["install", package])
286 .stdout(std::process::Stdio::null())
287 .stderr(std::process::Stdio::piped())
288 .status()
289 .await
290 .context("failed to run brew install")?;
291
292 if !status.success() {
293 bail!(
294 "brew install failed for {}.\n {}",
295 package,
296 entry.install_advice
297 );
298 }
299
300 which::which(entry.binary_name)
302 .with_context(|| format!("{} not found in PATH after brew install", entry.binary_name))
303}
304
305fn command_exists(name: &str) -> bool {
307 which::which(name).is_ok()
308}
309
310pub fn clean_servers() -> anyhow::Result<u64> {
317 let dir = servers_dir();
318 if !dir.exists() {
319 return Ok(0);
320 }
321
322 let size = dir_size(&dir);
323 make_writable_recursive(&dir);
325 std::fs::remove_dir_all(&dir).context("failed to remove servers directory")?;
326 Ok(size)
327}
328
329fn make_writable_recursive(path: &Path) {
331 #[cfg(unix)]
332 {
333 use std::os::unix::fs::PermissionsExt;
334 if let Ok(meta) = std::fs::metadata(path) {
335 let mut perms = meta.permissions();
336 let mode = perms.mode() | 0o200;
337 perms.set_mode(mode);
338 let _ = std::fs::set_permissions(path, perms);
339 }
340 }
341
342 if path.is_dir() {
343 if let Ok(entries) = std::fs::read_dir(path) {
344 for entry in entries.filter_map(Result::ok) {
345 make_writable_recursive(&entry.path());
346 }
347 }
348 #[cfg(unix)]
349 {
350 use std::os::unix::fs::PermissionsExt;
351 if let Ok(meta) = std::fs::metadata(path) {
352 let mut perms = meta.permissions();
353 perms.set_mode(perms.mode() | 0o700);
354 let _ = std::fs::set_permissions(path, perms);
355 }
356 }
357 }
358}
359
360fn dir_size(path: &Path) -> u64 {
361 std::fs::read_dir(path).ok().map_or(0, |entries| {
362 entries
363 .filter_map(Result::ok)
364 .map(|e| {
365 if e.path().is_dir() {
366 dir_size(&e.path())
367 } else {
368 e.metadata().map_or(0, |m| m.len())
369 }
370 })
371 .sum()
372 })
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn servers_dir_creates_if_missing() {
381 let dir = servers_dir();
382 assert!(dir.to_str().is_some());
385 assert!(dir.ends_with("servers"));
386 }
387
388 #[test]
389 fn clean_empty_is_ok() {
390 let dir = servers_dir();
392 if dir.exists()
393 && std::fs::read_dir(&dir)
394 .map(|mut e| e.next().is_some())
395 .unwrap_or(false)
396 {
397 return;
398 }
399 let result = clean_servers();
400 assert!(result.is_ok());
401 }
402
403 #[tokio::test]
404 async fn ensure_server_finds_rust_analyzer_if_installed() {
405 if which::which("rust-analyzer").is_err() {
407 return; }
409 let (path, entry) = ensure_server(Language::Rust).await.unwrap();
410 assert!(path.exists());
411 assert_eq!(entry.binary_name, "rust-analyzer");
412 }
413
414 #[test]
415 fn command_exists_finds_curl() {
416 assert!(command_exists("curl"));
417 }
418
419 #[test]
420 fn command_exists_rejects_missing() {
421 assert!(!command_exists("definitely-not-a-real-binary-xyz-123"));
422 }
423}