1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10use std::process::Command;
11use tracing::debug;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ServerConfig {
16 pub language_id: String,
18 pub command: String,
20 pub args: Vec<String>,
22 pub file_extensions: Vec<String>,
24}
25
26pub struct ServerRegistry {
31 configs: HashMap<String, ServerConfig>,
32}
33
34impl ServerRegistry {
35 pub fn new() -> Self {
37 Self {
38 configs: HashMap::new(),
39 }
40 }
41
42 pub fn with_defaults() -> Self {
44 let mut registry = Self::new();
45
46 let defaults = vec![
47 ServerConfig {
48 language_id: "rust".into(),
49 command: "rust-analyzer".into(),
50 args: vec![],
51 file_extensions: vec!["rs".into()],
52 },
53 ServerConfig {
54 language_id: "python".into(),
55 command: "pyright-langserver".into(),
56 args: vec!["--stdio".into()],
57 file_extensions: vec!["py".into()],
58 },
59 ServerConfig {
60 language_id: "typescript".into(),
61 command: "typescript-language-server".into(),
62 args: vec!["--stdio".into()],
63 file_extensions: vec!["ts".into(), "tsx".into()],
64 },
65 ServerConfig {
66 language_id: "javascript".into(),
67 command: "typescript-language-server".into(),
68 args: vec!["--stdio".into()],
69 file_extensions: vec!["js".into(), "jsx".into()],
70 },
71 ServerConfig {
72 language_id: "go".into(),
73 command: "gopls".into(),
74 args: vec!["serve".into()],
75 file_extensions: vec!["go".into()],
76 },
77 ServerConfig {
78 language_id: "cpp".into(),
79 command: "clangd".into(),
80 args: vec![],
81 file_extensions: vec![
82 "c".into(),
83 "cpp".into(),
84 "h".into(),
85 "hpp".into(),
86 "cc".into(),
87 ],
88 },
89 ServerConfig {
90 language_id: "java".into(),
91 command: "jdtls".into(),
92 args: vec![],
93 file_extensions: vec!["java".into()],
94 },
95 ServerConfig {
96 language_id: "yaml".into(),
97 command: "yaml-language-server".into(),
98 args: vec!["--stdio".into()],
99 file_extensions: vec!["yaml".into(), "yml".into()],
100 },
101 ServerConfig {
102 language_id: "json".into(),
103 command: "vscode-json-language-server".into(),
104 args: vec!["--stdio".into()],
105 file_extensions: vec!["json".into()],
106 },
107 ServerConfig {
108 language_id: "toml".into(),
109 command: "taplo".into(),
110 args: vec!["lsp".into(), "stdio".into()],
111 file_extensions: vec!["toml".into()],
112 },
113 ];
114
115 for config in defaults {
116 registry.configs.insert(config.language_id.clone(), config);
117 }
118
119 registry
120 }
121
122 pub fn register(&mut self, config: ServerConfig) {
126 debug!(language_id = %config.language_id, command = %config.command, "Registering language server config");
127 self.configs.insert(config.language_id.clone(), config);
128 }
129
130 pub fn get(&self, language_id: &str) -> Option<&ServerConfig> {
132 self.configs.get(language_id)
133 }
134
135 pub fn detect_language(file_path: &Path) -> Option<String> {
140 let ext = file_path.extension()?.to_str()?;
141 let language = match ext {
142 "rs" => "rust",
143 "py" => "python",
144 "ts" | "tsx" => "typescript",
145 "js" | "jsx" => "javascript",
146 "go" => "go",
147 "c" | "h" => "c",
148 "cpp" | "hpp" | "cc" | "cxx" => "cpp",
149 "java" => "java",
150 "yaml" | "yml" => "yaml",
151 "json" => "json",
152 "toml" => "toml",
153 "rb" => "ruby",
154 "sh" | "bash" => "bash",
155 _ => return None,
156 };
157 Some(language.to_string())
158 }
159
160 pub fn find_config_for_file(&self, file_path: &Path) -> Option<&ServerConfig> {
165 let language = Self::detect_language(file_path)?;
166 self.get(&language)
167 }
168
169 pub fn is_server_available(config: &ServerConfig) -> bool {
171 Command::new("which")
172 .arg(&config.command)
173 .stdout(std::process::Stdio::null())
174 .stderr(std::process::Stdio::null())
175 .status()
176 .map(|status| status.success())
177 .unwrap_or(false)
178 }
179
180 pub fn list_languages(&self) -> Vec<String> {
182 let mut languages: Vec<String> = self.configs.keys().cloned().collect();
183 languages.sort();
184 languages
185 }
186
187 pub fn list_available_servers(&self) -> Vec<&ServerConfig> {
189 self.configs
190 .values()
191 .filter(|config| Self::is_server_available(config))
192 .collect()
193 }
194}
195
196impl Default for ServerRegistry {
197 fn default() -> Self {
198 Self::new()
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use std::path::PathBuf;
206
207 #[test]
208 fn test_new_creates_empty() {
209 let registry = ServerRegistry::new();
210 assert!(registry.configs.is_empty());
211 assert!(registry.list_languages().is_empty());
212 }
213
214 #[test]
215 fn test_with_defaults_has_configs() {
216 let registry = ServerRegistry::with_defaults();
217 assert!(registry.get("rust").is_some());
218 assert!(registry.get("python").is_some());
219 assert!(registry.get("typescript").is_some());
220 assert!(registry.get("javascript").is_some());
221 assert!(registry.get("go").is_some());
222 assert!(registry.get("cpp").is_some());
223 assert!(registry.get("java").is_some());
224 assert!(registry.get("yaml").is_some());
225 assert!(registry.get("json").is_some());
226 assert!(registry.get("toml").is_some());
227 }
228
229 #[test]
230 fn test_register_custom() {
231 let mut registry = ServerRegistry::new();
232 registry.register(ServerConfig {
233 language_id: "ruby".into(),
234 command: "solargraph".into(),
235 args: vec!["stdio".into()],
236 file_extensions: vec!["rb".into()],
237 });
238
239 let config = registry.get("ruby").expect("ruby config should exist");
240 assert_eq!(config.command, "solargraph");
241 assert_eq!(config.args, vec!["stdio"]);
242 assert_eq!(config.file_extensions, vec!["rb"]);
243 }
244
245 #[test]
246 fn test_register_override() {
247 let mut registry = ServerRegistry::with_defaults();
248 let original = registry.get("rust").unwrap();
249 assert_eq!(original.command, "rust-analyzer");
250
251 registry.register(ServerConfig {
252 language_id: "rust".into(),
253 command: "custom-rust-ls".into(),
254 args: vec!["--custom".into()],
255 file_extensions: vec!["rs".into()],
256 });
257
258 let overridden = registry.get("rust").unwrap();
259 assert_eq!(overridden.command, "custom-rust-ls");
260 assert_eq!(overridden.args, vec!["--custom"]);
261 }
262
263 #[test]
264 fn test_get_known_language() {
265 let registry = ServerRegistry::with_defaults();
266 let config = registry.get("rust").expect("should find rust config");
267 assert_eq!(config.command, "rust-analyzer");
268 assert_eq!(config.language_id, "rust");
269 assert!(config.file_extensions.contains(&"rs".to_string()));
270 }
271
272 #[test]
273 fn test_get_unknown_language() {
274 let registry = ServerRegistry::with_defaults();
275 assert!(registry.get("fortran").is_none());
276 }
277
278 #[test]
279 fn test_detect_language_rs() {
280 let path = PathBuf::from("main.rs");
281 assert_eq!(ServerRegistry::detect_language(&path), Some("rust".into()));
282 }
283
284 #[test]
285 fn test_detect_language_py() {
286 let path = PathBuf::from("script.py");
287 assert_eq!(
288 ServerRegistry::detect_language(&path),
289 Some("python".into())
290 );
291 }
292
293 #[test]
294 fn test_detect_language_ts() {
295 let path = PathBuf::from("app.ts");
296 assert_eq!(
297 ServerRegistry::detect_language(&path),
298 Some("typescript".into())
299 );
300 }
301
302 #[test]
303 fn test_detect_language_tsx() {
304 let path = PathBuf::from("App.tsx");
305 assert_eq!(
306 ServerRegistry::detect_language(&path),
307 Some("typescript".into())
308 );
309 }
310
311 #[test]
312 fn test_detect_language_go() {
313 let path = PathBuf::from("main.go");
314 assert_eq!(ServerRegistry::detect_language(&path), Some("go".into()));
315 }
316
317 #[test]
318 fn test_detect_language_unknown() {
319 let path = PathBuf::from("file.xyz");
320 assert_eq!(ServerRegistry::detect_language(&path), None);
321 }
322
323 #[test]
324 fn test_detect_language_no_extension() {
325 let path = PathBuf::from("Makefile");
326 assert_eq!(ServerRegistry::detect_language(&path), None);
327 }
328
329 #[test]
330 fn test_find_config_for_file() {
331 let registry = ServerRegistry::with_defaults();
332 let path = PathBuf::from("main.rs");
333 let config = registry
334 .find_config_for_file(&path)
335 .expect("should find config for .rs file");
336 assert_eq!(config.language_id, "rust");
337 assert_eq!(config.command, "rust-analyzer");
338 }
339
340 #[test]
341 fn test_find_config_for_unknown_file() {
342 let registry = ServerRegistry::with_defaults();
343 let path = PathBuf::from("data.xyz");
344 assert!(registry.find_config_for_file(&path).is_none());
345 }
346
347 #[test]
348 fn test_list_languages() {
349 let registry = ServerRegistry::with_defaults();
350 let languages = registry.list_languages();
351
352 let mut sorted = languages.clone();
354 sorted.sort();
355 assert_eq!(languages, sorted);
356
357 assert!(languages.contains(&"rust".to_string()));
359 assert!(languages.contains(&"python".to_string()));
360 assert!(languages.contains(&"go".to_string()));
361 assert!(languages.contains(&"typescript".to_string()));
362 assert!(languages.contains(&"javascript".to_string()));
363 assert!(languages.contains(&"cpp".to_string()));
364 assert!(languages.contains(&"java".to_string()));
365 assert!(languages.contains(&"yaml".to_string()));
366 assert!(languages.contains(&"json".to_string()));
367 assert!(languages.contains(&"toml".to_string()));
368 }
369
370 #[test]
371 fn test_server_config_serde() {
372 let config = ServerConfig {
373 language_id: "rust".into(),
374 command: "rust-analyzer".into(),
375 args: vec!["--log-file".into(), "/tmp/ra.log".into()],
376 file_extensions: vec!["rs".into()],
377 };
378
379 let json = serde_json::to_string(&config).expect("serialization should succeed");
380 let deserialized: ServerConfig =
381 serde_json::from_str(&json).expect("deserialization should succeed");
382
383 assert_eq!(deserialized.language_id, config.language_id);
384 assert_eq!(deserialized.command, config.command);
385 assert_eq!(deserialized.args, config.args);
386 assert_eq!(deserialized.file_extensions, config.file_extensions);
387 }
388}