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