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        return Ok((path, entry));
25    }
26
27    // 2. None found — download the preferred (first) entry
28    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
36/// Download and install an LSP server binary.
37///
38/// # Errors
39/// Returns an error if the download or installation fails.
40pub 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
57/// Download a standalone binary from a GitHub release.
58async 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    // Download with curl
69    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    // Decompress
89    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            // gunzip removes .tmp extension → file is now without .tmp
104            // Actually gunzip strips the .gz, but our file doesn't end in .gz
105            // gunzip -f on a non-.gz file renames to remove the extension
106            let decompressed = dir.join(format!(".{}", entry.binary_name));
107            if decompressed.exists() {
108                std::fs::rename(&decompressed, &target)?;
109            } else if tmp.exists() {
110                // gunzip may have decompressed in-place
111                std::fs::rename(&tmp, &target)?;
112            }
113        }
114    }
115
116    // Make executable
117    #[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    // Verify it can run
125    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
147/// Install an npm package to `~/.krait/servers/npm/`.
148async fn download_npm(
149    entry: &ServerEntry,
150    dir: &Path,
151    package: &str,
152    extra_packages: &[&str],
153) -> anyhow::Result<PathBuf> {
154    // Check if node is available
155    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
213/// Install a Go binary via `go install`.
214async 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
260/// Check if a command exists in PATH.
261fn command_exists(name: &str) -> bool {
262    which::which(name).is_ok()
263}
264
265/// Remove all managed server binaries from `~/.krait/servers/`.
266///
267/// Makes all files writable first (Go module cache uses read-only permissions).
268///
269/// # Errors
270/// Returns an error if the directory cannot be removed.
271pub 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 everything writable before removal (Go module cache is read-only by default).
279    make_writable_recursive(&dir);
280    std::fs::remove_dir_all(&dir).context("failed to remove servers directory")?;
281    Ok(size)
282}
283
284/// Recursively make all files and directories writable.
285fn 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        // We don't actually create it in this test (side effect),
338        // just verify the path is valid
339        assert!(dir.to_str().is_some());
340        assert!(dir.ends_with("servers"));
341    }
342
343    #[test]
344    fn clean_empty_is_ok() {
345        // Skip if servers are actually installed — we don't want to wipe them.
346        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        // This test doesn't download — it just checks if RA is in PATH
361        if which::which("rust-analyzer").is_err() {
362            return; // skip if not installed
363        }
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}