Skip to main content

krait/lsp/
install.rs

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
12/// Ensure the LSP server binary is available. Tries all servers for the language
13/// in preference order (e.g., vtsls before typescript-language-server), then
14/// auto-installs the preferred one if none found.
15///
16/// Returns `(binary_path, server_entry)` so callers know which server was resolved.
17///
18/// # Errors
19/// Returns an error if the server cannot be found or downloaded.
20pub async fn ensure_server(language: Language) -> anyhow::Result<(PathBuf, ServerEntry)> {
21    // 1. Try all entries in preference order (e.g., vtsls → typescript-language-server)
22    if let Some((entry, path)) = resolve_server(language) {
23        debug!("found {}: {}", entry.binary_name, path.display());
24        // Check runtime prerequisite (e.g., gopls needs `go` in PATH to analyze modules).
25        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    // 2. None found — download the preferred (first) entry
37    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
45/// Download and install an LSP server binary.
46///
47/// # Errors
48/// Returns an error if the download or installation fails.
49pub 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
67/// Download a standalone binary from a GitHub release.
68async 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    // Download with curl
79    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    // Decompress
99    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            // gunzip removes .tmp extension → file is now without .tmp
114            // Actually gunzip strips the .gz, but our file doesn't end in .gz
115            // gunzip -f on a non-.gz file renames to remove the extension
116            let decompressed = dir.join(format!(".{}", entry.binary_name));
117            if decompressed.exists() {
118                std::fs::rename(&decompressed, &target)?;
119            } else if tmp.exists() {
120                // gunzip may have decompressed in-place
121                std::fs::rename(&tmp, &target)?;
122            }
123        }
124    }
125
126    // Make executable
127    #[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    // Verify it can run
135    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
157/// Install an npm package to `~/.krait/servers/npm/`.
158async fn download_npm(
159    entry: &ServerEntry,
160    dir: &Path,
161    package: &str,
162    extra_packages: &[&str],
163) -> anyhow::Result<PathBuf> {
164    // Check if node is available
165    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
223/// Install a Go binary via `go install`.
224async 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
270/// Install a binary via Homebrew (`brew install <package>`).
271///
272/// Homebrew installs to its prefix (e.g. `/opt/homebrew/bin` on Apple Silicon),
273/// which is already in PATH. So we just run `brew install` and then re-resolve
274/// the binary from PATH.
275async 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    // Homebrew puts binaries in its prefix/bin, which should be in PATH
301    which::which(entry.binary_name)
302        .with_context(|| format!("{} not found in PATH after brew install", entry.binary_name))
303}
304
305/// Check if a command exists in PATH.
306fn command_exists(name: &str) -> bool {
307    which::which(name).is_ok()
308}
309
310/// Remove all managed server binaries from `~/.krait/servers/`.
311///
312/// Makes all files writable first (Go module cache uses read-only permissions).
313///
314/// # Errors
315/// Returns an error if the directory cannot be removed.
316pub 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 everything writable before removal (Go module cache is read-only by default).
324    make_writable_recursive(&dir);
325    std::fs::remove_dir_all(&dir).context("failed to remove servers directory")?;
326    Ok(size)
327}
328
329/// Recursively make all files and directories writable.
330fn 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        // We don't actually create it in this test (side effect),
383        // just verify the path is valid
384        assert!(dir.to_str().is_some());
385        assert!(dir.ends_with("servers"));
386    }
387
388    #[test]
389    fn clean_empty_is_ok() {
390        // Skip if servers are actually installed — we don't want to wipe them.
391        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        // This test doesn't download — it just checks if RA is in PATH
406        if which::which("rust-analyzer").is_err() {
407            return; // skip if not installed
408        }
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}