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 return Ok((path, entry));
25 }
26
27 let entry =
29 get_entry(language).with_context(|| format!("no LSP server configured for {language}"))?;
30
31 info!("{} not found, downloading...", entry.binary_name);
32 let path = download_server(&entry).await?;
33 Ok((path, entry))
34}
35
36pub async fn download_server(entry: &ServerEntry) -> anyhow::Result<PathBuf> {
41 let dir = servers_dir();
42 std::fs::create_dir_all(&dir)
43 .with_context(|| format!("failed to create servers directory: {}", dir.display()))?;
44
45 match &entry.install_method {
46 InstallMethod::GithubRelease { archive, .. } => {
47 download_github_release(entry, &dir, *archive).await
48 }
49 InstallMethod::Npm {
50 package,
51 extra_packages,
52 } => download_npm(entry, &dir, package, extra_packages).await,
53 InstallMethod::GoInstall { module } => download_go(entry, &dir, module).await,
54 }
55}
56
57async fn download_github_release(
59 entry: &ServerEntry,
60 dir: &Path,
61 archive: ArchiveType,
62) -> anyhow::Result<PathBuf> {
63 let url = resolve_download_url(entry).context("cannot resolve download URL for this server")?;
64
65 let target = dir.join(entry.binary_name);
66 let tmp = dir.join(format!(".{}.tmp", entry.binary_name));
67
68 let download_status = tokio::process::Command::new("curl")
70 .args(["-fsSL", "-o"])
71 .arg(&tmp)
72 .arg(&url)
73 .stdout(std::process::Stdio::null())
74 .stderr(std::process::Stdio::piped())
75 .status()
76 .await
77 .context("failed to run curl — is curl installed?")?;
78
79 if !download_status.success() {
80 let _ = std::fs::remove_file(&tmp);
81 bail!(
82 "failed to download {} from {url}\n {}",
83 entry.binary_name,
84 entry.install_advice
85 );
86 }
87
88 match archive {
90 ArchiveType::Gzip => {
91 let gunzip_status = tokio::process::Command::new("gunzip")
92 .args(["-f"])
93 .arg(&tmp)
94 .status()
95 .await
96 .context("failed to run gunzip")?;
97
98 if !gunzip_status.success() {
99 let _ = std::fs::remove_file(&tmp);
100 bail!("failed to decompress {}", entry.binary_name);
101 }
102
103 let decompressed = dir.join(format!(".{}", entry.binary_name));
107 if decompressed.exists() {
108 std::fs::rename(&decompressed, &target)?;
109 } else if tmp.exists() {
110 std::fs::rename(&tmp, &target)?;
112 }
113 }
114 }
115
116 #[cfg(unix)]
118 {
119 use std::os::unix::fs::PermissionsExt;
120 let perms = std::fs::Permissions::from_mode(0o755);
121 std::fs::set_permissions(&target, perms).context("failed to make binary executable")?;
122 }
123
124 let verify = tokio::process::Command::new(&target)
126 .arg("--version")
127 .stdout(std::process::Stdio::null())
128 .stderr(std::process::Stdio::null())
129 .status()
130 .await;
131
132 match verify {
133 Ok(status) if status.success() => {
134 info!("installed {} to {}", entry.binary_name, target.display());
135 }
136 _ => {
137 warn!(
138 "{} downloaded but --version check failed (may still work)",
139 entry.binary_name
140 );
141 }
142 }
143
144 Ok(target)
145}
146
147async fn download_npm(
149 entry: &ServerEntry,
150 dir: &Path,
151 package: &str,
152 extra_packages: &[&str],
153) -> anyhow::Result<PathBuf> {
154 if !command_exists("node") {
156 bail!(
157 "Node.js is required for {} but not found in PATH.\n {}",
158 entry.binary_name,
159 entry.install_advice
160 );
161 }
162
163 let npm_dir = dir.join("npm");
164 std::fs::create_dir_all(&npm_dir)?;
165
166 let mut args = vec!["install", "--prefix"];
167 let npm_dir_str = npm_dir
168 .to_str()
169 .context("npm directory path is not valid UTF-8")?;
170 args.push(npm_dir_str);
171 args.push(package);
172 for pkg in extra_packages {
173 args.push(pkg);
174 }
175
176 let status = tokio::process::Command::new("npm")
177 .args(&args)
178 .stdout(std::process::Stdio::null())
179 .stderr(std::process::Stdio::piped())
180 .status()
181 .await
182 .context("failed to run npm — is npm installed?")?;
183
184 if !status.success() {
185 bail!(
186 "npm install failed for {}.\n {}",
187 package,
188 entry.install_advice
189 );
190 }
191
192 let bin_path = npm_dir
193 .join("node_modules")
194 .join(".bin")
195 .join(entry.binary_name);
196
197 if !bin_path.exists() {
198 bail!(
199 "{} not found after npm install at {}",
200 entry.binary_name,
201 bin_path.display()
202 );
203 }
204
205 info!(
206 "installed {} via npm to {}",
207 entry.binary_name,
208 bin_path.display()
209 );
210 Ok(bin_path)
211}
212
213async fn download_go(entry: &ServerEntry, dir: &Path, module: &str) -> anyhow::Result<PathBuf> {
215 if !command_exists("go") {
216 bail!(
217 "Go is required for {} but not found in PATH.\n {}",
218 entry.binary_name,
219 entry.install_advice
220 );
221 }
222
223 let go_dir = dir.join("go");
224 std::fs::create_dir_all(&go_dir)?;
225
226 let status = tokio::process::Command::new("go")
227 .args(["install", module])
228 .env("GOPATH", &go_dir)
229 .stdout(std::process::Stdio::null())
230 .stderr(std::process::Stdio::piped())
231 .status()
232 .await
233 .context("failed to run go install")?;
234
235 if !status.success() {
236 bail!(
237 "go install failed for {}.\n {}",
238 module,
239 entry.install_advice
240 );
241 }
242
243 let bin_path = go_dir.join("bin").join(entry.binary_name);
244 if !bin_path.exists() {
245 bail!(
246 "{} not found after go install at {}",
247 entry.binary_name,
248 bin_path.display()
249 );
250 }
251
252 info!(
253 "installed {} via go install to {}",
254 entry.binary_name,
255 bin_path.display()
256 );
257 Ok(bin_path)
258}
259
260fn command_exists(name: &str) -> bool {
262 which::which(name).is_ok()
263}
264
265pub fn clean_servers() -> anyhow::Result<u64> {
272 let dir = servers_dir();
273 if !dir.exists() {
274 return Ok(0);
275 }
276
277 let size = dir_size(&dir);
278 make_writable_recursive(&dir);
280 std::fs::remove_dir_all(&dir).context("failed to remove servers directory")?;
281 Ok(size)
282}
283
284fn make_writable_recursive(path: &Path) {
286 #[cfg(unix)]
287 {
288 use std::os::unix::fs::PermissionsExt;
289 if let Ok(meta) = std::fs::metadata(path) {
290 let mut perms = meta.permissions();
291 let mode = perms.mode() | 0o200;
292 perms.set_mode(mode);
293 let _ = std::fs::set_permissions(path, perms);
294 }
295 }
296
297 if path.is_dir() {
298 if let Ok(entries) = std::fs::read_dir(path) {
299 for entry in entries.filter_map(Result::ok) {
300 make_writable_recursive(&entry.path());
301 }
302 }
303 #[cfg(unix)]
304 {
305 use std::os::unix::fs::PermissionsExt;
306 if let Ok(meta) = std::fs::metadata(path) {
307 let mut perms = meta.permissions();
308 perms.set_mode(perms.mode() | 0o700);
309 let _ = std::fs::set_permissions(path, perms);
310 }
311 }
312 }
313}
314
315fn dir_size(path: &Path) -> u64 {
316 std::fs::read_dir(path).ok().map_or(0, |entries| {
317 entries
318 .filter_map(Result::ok)
319 .map(|e| {
320 if e.path().is_dir() {
321 dir_size(&e.path())
322 } else {
323 e.metadata().map_or(0, |m| m.len())
324 }
325 })
326 .sum()
327 })
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn servers_dir_creates_if_missing() {
336 let dir = servers_dir();
337 assert!(dir.to_str().is_some());
340 assert!(dir.ends_with("servers"));
341 }
342
343 #[test]
344 fn clean_empty_is_ok() {
345 let dir = servers_dir();
347 if dir.exists()
348 && std::fs::read_dir(&dir)
349 .map(|mut e| e.next().is_some())
350 .unwrap_or(false)
351 {
352 return;
353 }
354 let result = clean_servers();
355 assert!(result.is_ok());
356 }
357
358 #[tokio::test]
359 async fn ensure_server_finds_rust_analyzer_if_installed() {
360 if which::which("rust-analyzer").is_err() {
362 return; }
364 let (path, entry) = ensure_server(Language::Rust).await.unwrap();
365 assert!(path.exists());
366 assert_eq!(entry.binary_name, "rust-analyzer");
367 }
368
369 #[test]
370 fn command_exists_finds_curl() {
371 assert!(command_exists("curl"));
372 }
373
374 #[test]
375 fn command_exists_rejects_missing() {
376 assert!(!command_exists("definitely-not-a-real-binary-xyz-123"));
377 }
378}