Skip to main content

rustant_tools/lsp/
discovery.rs

1//! Language server discovery and configuration.
2//!
3//! Provides automatic detection of language servers based on file extensions,
4//! a registry of known server configurations, and utilities for checking
5//! server availability on the system PATH.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10use std::process::Command;
11use tracing::debug;
12
13/// Configuration for a single language server.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ServerConfig {
16    /// The programming language this server handles (e.g., "rust", "python").
17    pub language_id: String,
18    /// The command to start the language server.
19    pub command: String,
20    /// Command-line arguments.
21    pub args: Vec<String>,
22    /// File extensions this server handles.
23    pub file_extensions: Vec<String>,
24}
25
26/// Registry of language server configurations.
27///
28/// Holds a mapping from language ID to [`ServerConfig`], with built-in defaults
29/// for common languages and the ability to register custom or override configs.
30pub struct ServerRegistry {
31    configs: HashMap<String, ServerConfig>,
32}
33
34impl ServerRegistry {
35    /// Create an empty registry with no built-in configurations.
36    pub fn new() -> Self {
37        Self {
38            configs: HashMap::new(),
39        }
40    }
41
42    /// Create a registry pre-populated with built-in language server configurations.
43    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    /// Register or override a server configuration.
123    ///
124    /// If a config for the same `language_id` already exists, it will be replaced.
125    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    /// Get the server configuration for a given language ID.
131    pub fn get(&self, language_id: &str) -> Option<&ServerConfig> {
132        self.configs.get(language_id)
133    }
134
135    /// Detect the language from a file path's extension.
136    ///
137    /// Returns the canonical language identifier (e.g., `"rust"`, `"python"`) or
138    /// `None` if the extension is unrecognized or absent.
139    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    /// Find the appropriate server configuration for a file path.
161    ///
162    /// Detects the language from the file extension, then looks up the
163    /// corresponding config in the registry.
164    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    /// Check whether the server command is available on the system PATH.
170    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    /// Return a sorted list of all registered language IDs.
181    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    /// Return configurations for which the server binary is available on PATH.
188    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        // Should be sorted
353        let mut sorted = languages.clone();
354        sorted.sort();
355        assert_eq!(languages, sorted);
356
357        // Should contain all defaults
358        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}