Skip to main content

krait/lsp/
registry.rs

1use std::path::PathBuf;
2
3use crate::detect::Language;
4
5/// How to acquire an LSP server binary.
6#[derive(Debug, Clone)]
7pub enum InstallMethod {
8    /// Download a standalone binary from a GitHub release.
9    GithubRelease {
10        repo: &'static str,
11        /// Asset filename template. Placeholders: `{arch}`, `{platform}`.
12        asset_pattern: &'static str,
13        archive: ArchiveType,
14    },
15    /// Install via npm to `~/.krait/servers/npm/`.
16    /// Requires `node` in PATH.
17    Npm {
18        package: &'static str,
19        extra_packages: &'static [&'static str],
20    },
21    /// Install via `go install` to `~/.krait/servers/go/bin/`.
22    /// Requires `go` in PATH.
23    GoInstall { module: &'static str },
24    /// Install via Homebrew (`brew install <package>`).
25    /// Requires `brew` in PATH.
26    Homebrew { package: &'static str },
27}
28
29/// Archive format for downloaded files.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ArchiveType {
32    /// Single file compressed with gzip (`.gz`).
33    Gzip,
34}
35
36/// Full metadata for an LSP server.
37#[derive(Debug, Clone)]
38pub struct ServerEntry {
39    pub language: Language,
40    pub binary_name: &'static str,
41    pub args: &'static [&'static str],
42    pub install_method: InstallMethod,
43    pub install_advice: &'static str,
44    /// Optional runtime command that must be in PATH for the server to function.
45    /// e.g. gopls requires `go` to be installed even though gopls itself is a binary.
46    pub requires_cmd: Option<&'static str>,
47}
48
49/// Get all server entries for a language, in preference order.
50///
51/// The first entry is the preferred server. Callers should try each in order
52/// and use the first one found, or auto-install the preferred (first) one.
53#[must_use]
54pub fn get_entries(language: Language) -> Vec<ServerEntry> {
55    match language {
56        Language::Rust => vec![ServerEntry {
57            language,
58            binary_name: "rust-analyzer",
59            args: &[],
60            install_method: InstallMethod::GithubRelease {
61                repo: "rust-lang/rust-analyzer",
62                asset_pattern: "rust-analyzer-{arch}-{platform}.gz",
63                archive: ArchiveType::Gzip,
64            },
65            install_advice: "Install: `rustup component add rust-analyzer`",
66            requires_cmd: None,
67        }],
68        Language::TypeScript | Language::JavaScript => vec![
69            ServerEntry {
70                language,
71                binary_name: "vtsls",
72                args: &["--stdio"],
73                install_method: InstallMethod::Npm {
74                    package: "@vtsls/language-server",
75                    extra_packages: &["typescript"],
76                },
77                install_advice: "Install: `npm install -g @vtsls/language-server typescript`",
78                requires_cmd: None,
79            },
80            ServerEntry {
81                language,
82                binary_name: "typescript-language-server",
83                args: &["--stdio"],
84                install_method: InstallMethod::Npm {
85                    package: "typescript-language-server",
86                    extra_packages: &["typescript"],
87                },
88                install_advice:
89                    "Install: `npm install -g typescript-language-server typescript`",
90                requires_cmd: None,
91            },
92        ],
93        Language::Go => vec![
94            ServerEntry {
95                language,
96                binary_name: "gopls",
97                args: &["serve"],
98                install_method: InstallMethod::GoInstall {
99                    module: "golang.org/x/tools/gopls@latest",
100                },
101                install_advice: "Install: `go install golang.org/x/tools/gopls@latest`",
102                requires_cmd: Some("go"),
103            },
104            ServerEntry {
105                language,
106                binary_name: "gopls",
107                args: &["serve"],
108                install_method: InstallMethod::Homebrew { package: "gopls" },
109                install_advice: "Install: `brew install gopls` (also requires `go` in PATH)",
110                requires_cmd: Some("go"),
111            },
112        ],
113        Language::Cpp => vec![ServerEntry {
114            language,
115            binary_name: "clangd",
116            args: &[],
117            install_method: InstallMethod::GithubRelease {
118                repo: "clangd/clangd",
119                asset_pattern: "clangd-{platform}-{arch}.zip",
120                archive: ArchiveType::Gzip,
121            },
122            install_advice: "Install: `brew install llvm` (includes clangd) or download from https://github.com/clangd/clangd/releases",
123            requires_cmd: None,
124        }],
125    }
126}
127
128/// Get the preferred (first) server entry for a language.
129#[must_use]
130pub fn get_entry(language: Language) -> Option<ServerEntry> {
131    get_entries(language).into_iter().next()
132}
133
134/// Detect the current platform for download URL resolution.
135/// Returns `(platform, arch)` matching rust-analyzer's naming convention.
136#[must_use]
137pub fn detect_platform() -> (&'static str, &'static str) {
138    let platform = if cfg!(target_os = "macos") {
139        "apple-darwin"
140    } else if cfg!(target_os = "linux") {
141        "unknown-linux-gnu"
142    } else {
143        "unknown"
144    };
145
146    let arch = if cfg!(target_arch = "aarch64") {
147        "aarch64"
148    } else if cfg!(target_arch = "x86_64") {
149        "x86_64"
150    } else {
151        "unknown"
152    };
153
154    (platform, arch)
155}
156
157/// Resolve the download URL for a GitHub release asset.
158/// Returns `None` if the install method is not a GitHub release.
159#[must_use]
160pub fn resolve_download_url(entry: &ServerEntry) -> Option<String> {
161    match &entry.install_method {
162        InstallMethod::GithubRelease {
163            repo,
164            asset_pattern,
165            ..
166        } => {
167            let (platform, arch) = detect_platform();
168            let asset = asset_pattern
169                .replace("{arch}", arch)
170                .replace("{platform}", platform);
171            Some(format!(
172                "https://github.com/{repo}/releases/latest/download/{asset}"
173            ))
174        }
175        _ => None,
176    }
177}
178
179/// Global directory for managed LSP server binaries.
180#[must_use]
181pub fn servers_dir() -> PathBuf {
182    dirs::home_dir()
183        .unwrap_or_else(|| PathBuf::from("/tmp"))
184        .join(".krait")
185        .join("servers")
186}
187
188/// Check if a binary exists in PATH.
189#[must_use]
190pub fn find_in_path(binary_name: &str) -> Option<PathBuf> {
191    which::which(binary_name).ok()
192}
193
194/// Check if a managed binary exists in `~/.krait/servers/` or tool-specific locations.
195#[must_use]
196pub fn find_managed(binary_name: &str) -> Option<PathBuf> {
197    let path = servers_dir().join(binary_name);
198    if path.exists() && path.is_file() {
199        return Some(path);
200    }
201
202    // npm bin directory
203    let npm_path = servers_dir()
204        .join("npm")
205        .join("node_modules")
206        .join(".bin")
207        .join(binary_name);
208    if npm_path.exists() {
209        return Some(npm_path);
210    }
211
212    // go bin directory
213    let go_path = servers_dir().join("go").join("bin").join(binary_name);
214    if go_path.exists() {
215        return Some(go_path);
216    }
217
218    // go install default output (~/$GOPATH/bin, falls back to ~/go/bin)
219    if let Some(home) = dirs::home_dir() {
220        let gopath =
221            std::env::var("GOPATH").map_or_else(|_| home.join("go"), std::path::PathBuf::from);
222        let go_default = gopath.join("bin").join(binary_name);
223        if go_default.exists() {
224            return Some(go_default);
225        }
226    }
227
228    None
229}
230
231/// Find the server binary for a specific entry — checks PATH first, then managed directory.
232#[must_use]
233pub fn find_server(entry: &ServerEntry) -> Option<PathBuf> {
234    find_in_path(entry.binary_name).or_else(|| find_managed(entry.binary_name))
235}
236
237/// Resolve the best available server for a language.
238///
239/// Tries each entry in preference order (e.g., vtsls before typescript-language-server).
240/// Returns the first entry whose binary is found, along with its path.
241/// If none found, returns `None`.
242#[must_use]
243pub fn resolve_server(language: Language) -> Option<(ServerEntry, PathBuf)> {
244    for entry in get_entries(language) {
245        if let Some(path) = find_server(&entry) {
246            return Some((entry, path));
247        }
248    }
249    None
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn registry_has_entry_for_all_languages() {
258        assert!(get_entry(Language::Rust).is_some());
259        assert!(get_entry(Language::TypeScript).is_some());
260        assert!(get_entry(Language::JavaScript).is_some());
261        assert!(get_entry(Language::Go).is_some());
262        assert!(get_entry(Language::Cpp).is_some());
263    }
264
265    #[test]
266    fn platform_detection_returns_valid_tuple() {
267        let (platform, arch) = detect_platform();
268        assert!(
269            ["apple-darwin", "unknown-linux-gnu", "unknown"].contains(&platform),
270            "unexpected platform: {platform}"
271        );
272        assert!(
273            ["aarch64", "x86_64", "unknown"].contains(&arch),
274            "unexpected arch: {arch}"
275        );
276    }
277
278    #[test]
279    fn download_url_resolves_for_rust_analyzer() {
280        let entry = get_entry(Language::Rust).unwrap();
281        let url = resolve_download_url(&entry).unwrap();
282        assert!(url.starts_with("https://github.com/rust-lang/rust-analyzer/releases/"));
283        assert!(url.contains("rust-analyzer-"));
284        assert!(url.contains(".gz"), "URL should contain .gz: {url}");
285    }
286
287    #[test]
288    fn download_url_none_for_npm_packages() {
289        let entry = get_entry(Language::TypeScript).unwrap();
290        assert!(resolve_download_url(&entry).is_none());
291    }
292
293    #[test]
294    fn typescript_and_javascript_share_entry() {
295        let ts = get_entry(Language::TypeScript).unwrap();
296        let js = get_entry(Language::JavaScript).unwrap();
297        assert_eq!(ts.binary_name, js.binary_name);
298        assert_eq!(ts.binary_name, "vtsls");
299    }
300
301    #[test]
302    fn typescript_entries_have_vtsls_preferred() {
303        let entries = get_entries(Language::TypeScript);
304        assert_eq!(entries.len(), 2);
305        assert_eq!(entries[0].binary_name, "vtsls");
306        assert_eq!(entries[1].binary_name, "typescript-language-server");
307    }
308
309    #[test]
310    fn resolve_server_returns_none_when_nothing_installed() {
311        // This is a best-effort test — if neither vtsls nor ts-lang-server
312        // is installed, it returns None. If one is, it returns it.
313        let result = resolve_server(Language::TypeScript);
314        if let Some((entry, path)) = result {
315            assert!(path.exists());
316            assert!(
317                entry.binary_name == "vtsls" || entry.binary_name == "typescript-language-server"
318            );
319        }
320    }
321
322    #[test]
323    fn servers_dir_is_under_home() {
324        let dir = servers_dir();
325        let home = dirs::home_dir().unwrap();
326        assert!(
327            dir.starts_with(&home),
328            "servers_dir {dir:?} not under home {home:?}"
329        );
330        assert!(dir.ends_with("servers"));
331    }
332
333    #[test]
334    fn find_managed_returns_none_for_missing() {
335        assert!(find_managed("definitely-not-a-real-binary-xyz").is_none());
336    }
337
338    #[test]
339    fn rust_entry_has_github_release_method() {
340        let entry = get_entry(Language::Rust).unwrap();
341        assert!(matches!(
342            entry.install_method,
343            InstallMethod::GithubRelease { .. }
344        ));
345    }
346
347    #[test]
348    fn vtsls_entry_has_npm_method() {
349        let entry = get_entry(Language::TypeScript).unwrap();
350        assert!(matches!(entry.install_method, InstallMethod::Npm { .. }));
351        if let InstallMethod::Npm { package, .. } = entry.install_method {
352            assert_eq!(package, "@vtsls/language-server");
353        }
354    }
355
356    #[test]
357    fn go_entry_has_go_install_method() {
358        let entry = get_entry(Language::Go).unwrap();
359        assert!(matches!(
360            entry.install_method,
361            InstallMethod::GoInstall { .. }
362        ));
363    }
364}