1use std::path::PathBuf;
2
3use crate::detect::Language;
4
5#[derive(Debug, Clone)]
7pub enum InstallMethod {
8 GithubRelease {
10 repo: &'static str,
11 asset_pattern: &'static str,
13 archive: ArchiveType,
14 },
15 Npm {
18 package: &'static str,
19 extra_packages: &'static [&'static str],
20 },
21 GoInstall { module: &'static str },
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ArchiveType {
29 Gzip,
31}
32
33#[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#[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#[must_use]
110pub fn get_entry(language: Language) -> Option<ServerEntry> {
111 get_entries(language).into_iter().next()
112}
113
114#[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#[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#[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#[must_use]
170pub fn find_in_path(binary_name: &str) -> Option<PathBuf> {
171 which::which(binary_name).ok()
172}
173
174#[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 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 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 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#[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#[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 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}