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 Homebrew { package: &'static str },
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ArchiveType {
32 Gzip,
34}
35
36#[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 pub requires_cmd: Option<&'static str>,
47}
48
49#[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#[must_use]
130pub fn get_entry(language: Language) -> Option<ServerEntry> {
131 get_entries(language).into_iter().next()
132}
133
134#[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#[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#[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#[must_use]
190pub fn find_in_path(binary_name: &str) -> Option<PathBuf> {
191 which::which(binary_name).ok()
192}
193
194#[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 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 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 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#[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#[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 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}