Skip to main content

mcpls_core/config/
server.rs

1//! LSP server configuration types.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8/// Heuristics for determining if an LSP server should be spawned.
9///
10/// Used to prevent spawning servers in projects where they are not applicable
11/// (e.g., rust-analyzer in a Python-only project).
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13#[serde(deny_unknown_fields)]
14pub struct ServerHeuristics {
15    /// Files or directories that indicate this server is applicable.
16    /// The server will spawn if ANY of these markers exist in the workspace root.
17    /// If empty, the server will always attempt to spawn.
18    #[serde(default)]
19    pub project_markers: Vec<String>,
20}
21
22impl ServerHeuristics {
23    /// Create heuristics with the given project markers.
24    #[must_use]
25    pub fn with_markers<I, S>(markers: I) -> Self
26    where
27        I: IntoIterator<Item = S>,
28        S: Into<String>,
29    {
30        Self {
31            project_markers: markers.into_iter().map(Into::into).collect(),
32        }
33    }
34
35    /// Check if any marker exists at the given workspace root.
36    ///
37    /// Returns `true` if:
38    /// - No markers are defined (empty = always applicable)
39    /// - At least one marker file/directory exists
40    #[must_use]
41    pub fn is_applicable(&self, workspace_root: &Path) -> bool {
42        if self.project_markers.is_empty() {
43            return true;
44        }
45        self.project_markers
46            .iter()
47            .any(|marker| workspace_root.join(marker).exists())
48    }
49}
50
51/// Configuration for a single LSP server.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct LspServerConfig {
55    /// Language identifier (e.g., "rust", "python", "typescript").
56    pub language_id: String,
57
58    /// Command to start the LSP server.
59    pub command: String,
60
61    /// Arguments to pass to the LSP server command.
62    #[serde(default)]
63    pub args: Vec<String>,
64
65    /// Environment variables for the LSP server process.
66    #[serde(default)]
67    pub env: HashMap<String, String>,
68
69    /// File patterns this server handles (glob patterns).
70    #[serde(default)]
71    pub file_patterns: Vec<String>,
72
73    /// LSP initialization options (server-specific).
74    #[serde(default)]
75    pub initialization_options: Option<serde_json::Value>,
76
77    /// Request timeout in seconds.
78    #[serde(default = "default_timeout")]
79    pub timeout_seconds: u64,
80
81    /// Heuristics for determining if this server should be spawned.
82    /// If not specified, the server will always attempt to spawn.
83    #[serde(default)]
84    pub heuristics: Option<ServerHeuristics>,
85}
86
87const fn default_timeout() -> u64 {
88    30
89}
90
91impl LspServerConfig {
92    /// Check if this server should be spawned for the given workspace.
93    #[must_use]
94    pub fn should_spawn(&self, workspace_root: &Path) -> bool {
95        self.heuristics
96            .as_ref()
97            .is_none_or(|h| h.is_applicable(workspace_root))
98    }
99
100    /// Create a default configuration for rust-analyzer.
101    #[must_use]
102    pub fn rust_analyzer() -> Self {
103        Self {
104            language_id: "rust".to_string(),
105            command: "rust-analyzer".to_string(),
106            args: vec![],
107            env: HashMap::new(),
108            file_patterns: vec!["**/*.rs".to_string()],
109            initialization_options: None,
110            timeout_seconds: default_timeout(),
111            heuristics: Some(ServerHeuristics::with_markers([
112                "Cargo.toml",
113                "rust-toolchain.toml",
114            ])),
115        }
116    }
117
118    /// Create a default configuration for pyright.
119    #[must_use]
120    pub fn pyright() -> Self {
121        Self {
122            language_id: "python".to_string(),
123            command: "pyright-langserver".to_string(),
124            args: vec!["--stdio".to_string()],
125            env: HashMap::new(),
126            file_patterns: vec!["**/*.py".to_string()],
127            initialization_options: None,
128            timeout_seconds: default_timeout(),
129            heuristics: Some(ServerHeuristics::with_markers([
130                "pyproject.toml",
131                "setup.py",
132                "requirements.txt",
133                "pyrightconfig.json",
134            ])),
135        }
136    }
137
138    /// Create a default configuration for TypeScript language server.
139    #[must_use]
140    pub fn typescript() -> Self {
141        Self {
142            language_id: "typescript".to_string(),
143            command: "typescript-language-server".to_string(),
144            args: vec!["--stdio".to_string()],
145            env: HashMap::new(),
146            file_patterns: vec!["**/*.ts".to_string(), "**/*.tsx".to_string()],
147            initialization_options: None,
148            timeout_seconds: default_timeout(),
149            heuristics: Some(ServerHeuristics::with_markers([
150                "package.json",
151                "tsconfig.json",
152                "jsconfig.json",
153            ])),
154        }
155    }
156
157    /// Create a default configuration for gopls.
158    #[must_use]
159    pub fn gopls() -> Self {
160        Self {
161            language_id: "go".to_string(),
162            command: "gopls".to_string(),
163            args: vec!["serve".to_string()],
164            env: HashMap::new(),
165            file_patterns: vec!["**/*.go".to_string()],
166            initialization_options: None,
167            timeout_seconds: default_timeout(),
168            heuristics: Some(ServerHeuristics::with_markers(["go.mod", "go.sum"])),
169        }
170    }
171
172    /// Create a default configuration for clangd.
173    #[must_use]
174    pub fn clangd() -> Self {
175        Self {
176            language_id: "cpp".to_string(),
177            command: "clangd".to_string(),
178            args: vec![],
179            env: HashMap::new(),
180            file_patterns: vec![
181                "**/*.c".to_string(),
182                "**/*.cpp".to_string(),
183                "**/*.h".to_string(),
184                "**/*.hpp".to_string(),
185            ],
186            initialization_options: None,
187            timeout_seconds: default_timeout(),
188            heuristics: Some(ServerHeuristics::with_markers([
189                "CMakeLists.txt",
190                "compile_commands.json",
191                "Makefile",
192                ".clangd",
193            ])),
194        }
195    }
196
197    /// Create a default configuration for zls.
198    #[must_use]
199    pub fn zls() -> Self {
200        Self {
201            language_id: "zig".to_string(),
202            command: "zls".to_string(),
203            args: vec![],
204            env: HashMap::new(),
205            file_patterns: vec!["**/*.zig".to_string()],
206            initialization_options: None,
207            timeout_seconds: default_timeout(),
208            heuristics: Some(ServerHeuristics::with_markers([
209                "build.zig",
210                "build.zig.zon",
211            ])),
212        }
213    }
214}
215
216#[cfg(test)]
217#[allow(clippy::unwrap_used)]
218mod tests {
219    use tempfile::TempDir;
220
221    use super::*;
222
223    #[test]
224    fn test_rust_analyzer_defaults() {
225        let config = LspServerConfig::rust_analyzer();
226
227        assert_eq!(config.language_id, "rust");
228        assert_eq!(config.command, "rust-analyzer");
229        assert!(config.args.is_empty());
230        assert!(config.env.is_empty());
231        assert_eq!(config.file_patterns, vec!["**/*.rs"]);
232        assert!(config.initialization_options.is_none());
233        assert_eq!(config.timeout_seconds, 30);
234    }
235
236    #[test]
237    fn test_pyright_defaults() {
238        let config = LspServerConfig::pyright();
239
240        assert_eq!(config.language_id, "python");
241        assert_eq!(config.command, "pyright-langserver");
242        assert_eq!(config.args, vec!["--stdio"]);
243        assert!(config.env.is_empty());
244        assert_eq!(config.file_patterns, vec!["**/*.py"]);
245        assert!(config.initialization_options.is_none());
246        assert_eq!(config.timeout_seconds, 30);
247    }
248
249    #[test]
250    fn test_typescript_defaults() {
251        let config = LspServerConfig::typescript();
252
253        assert_eq!(config.language_id, "typescript");
254        assert_eq!(config.command, "typescript-language-server");
255        assert_eq!(config.args, vec!["--stdio"]);
256        assert!(config.env.is_empty());
257        assert_eq!(config.file_patterns, vec!["**/*.ts", "**/*.tsx"]);
258        assert!(config.initialization_options.is_none());
259        assert_eq!(config.timeout_seconds, 30);
260    }
261
262    #[test]
263    fn test_default_timeout() {
264        assert_eq!(default_timeout(), 30);
265    }
266
267    #[test]
268    fn test_custom_config() {
269        let mut env = HashMap::new();
270        env.insert("RUST_LOG".to_string(), "debug".to_string());
271
272        let config = LspServerConfig {
273            language_id: "custom".to_string(),
274            command: "custom-lsp".to_string(),
275            args: vec!["--flag".to_string()],
276            env: env.clone(),
277            file_patterns: vec!["**/*.custom".to_string()],
278            initialization_options: Some(serde_json::json!({"key": "value"})),
279            timeout_seconds: 60,
280            heuristics: None,
281        };
282
283        assert_eq!(config.language_id, "custom");
284        assert_eq!(config.command, "custom-lsp");
285        assert_eq!(config.args, vec!["--flag"]);
286        assert_eq!(config.env.get("RUST_LOG"), Some(&"debug".to_string()));
287        assert_eq!(config.file_patterns, vec!["**/*.custom"]);
288        assert!(config.initialization_options.is_some());
289        assert_eq!(config.timeout_seconds, 60);
290    }
291
292    #[test]
293    fn test_serde_roundtrip() {
294        let original = LspServerConfig::rust_analyzer();
295
296        let serialized = serde_json::to_string(&original).unwrap();
297        let deserialized: LspServerConfig = serde_json::from_str(&serialized).unwrap();
298
299        assert_eq!(deserialized.language_id, original.language_id);
300        assert_eq!(deserialized.command, original.command);
301        assert_eq!(deserialized.args, original.args);
302        assert_eq!(deserialized.timeout_seconds, original.timeout_seconds);
303    }
304
305    #[test]
306    fn test_clone() {
307        let config = LspServerConfig::rust_analyzer();
308        let cloned = config.clone();
309
310        assert_eq!(cloned.language_id, config.language_id);
311        assert_eq!(cloned.command, config.command);
312        assert_eq!(cloned.timeout_seconds, config.timeout_seconds);
313    }
314
315    #[test]
316    fn test_empty_env() {
317        let config = LspServerConfig::rust_analyzer();
318        assert!(config.env.is_empty());
319    }
320
321    #[test]
322    fn test_multiple_file_patterns() {
323        let config = LspServerConfig::typescript();
324        assert_eq!(config.file_patterns.len(), 2);
325        assert!(config.file_patterns.contains(&"**/*.ts".to_string()));
326        assert!(config.file_patterns.contains(&"**/*.tsx".to_string()));
327    }
328
329    #[test]
330    fn test_initialization_options_none_by_default() {
331        let configs = vec![
332            LspServerConfig::rust_analyzer(),
333            LspServerConfig::pyright(),
334            LspServerConfig::typescript(),
335        ];
336
337        for config in configs {
338            assert!(config.initialization_options.is_none());
339        }
340    }
341
342    // Heuristics tests
343    #[test]
344    fn test_heuristics_empty_always_applicable() {
345        let heuristics = ServerHeuristics::default();
346        let tmp = TempDir::new().unwrap();
347        assert!(heuristics.is_applicable(tmp.path()));
348    }
349
350    #[test]
351    fn test_heuristics_marker_present() {
352        let tmp = TempDir::new().unwrap();
353        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
354
355        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
356        assert!(heuristics.is_applicable(tmp.path()));
357    }
358
359    #[test]
360    fn test_heuristics_marker_absent() {
361        let tmp = TempDir::new().unwrap();
362        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
363        assert!(!heuristics.is_applicable(tmp.path()));
364    }
365
366    #[test]
367    fn test_heuristics_any_marker_matches() {
368        let tmp = TempDir::new().unwrap();
369        std::fs::write(tmp.path().join("setup.py"), "").unwrap();
370
371        let heuristics =
372            ServerHeuristics::with_markers(["pyproject.toml", "setup.py", "requirements.txt"]);
373        assert!(heuristics.is_applicable(tmp.path()));
374    }
375
376    #[test]
377    fn test_should_spawn_without_heuristics() {
378        let config = LspServerConfig {
379            language_id: "test".to_string(),
380            command: "test-lsp".to_string(),
381            args: vec![],
382            env: HashMap::new(),
383            file_patterns: vec![],
384            initialization_options: None,
385            timeout_seconds: 30,
386            heuristics: None,
387        };
388
389        let tmp = TempDir::new().unwrap();
390        assert!(config.should_spawn(tmp.path()));
391    }
392
393    #[test]
394    fn test_should_spawn_with_heuristics() {
395        let tmp = TempDir::new().unwrap();
396        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
397
398        let config = LspServerConfig::rust_analyzer();
399        assert!(config.should_spawn(tmp.path()));
400    }
401
402    #[test]
403    fn test_should_not_spawn_without_markers() {
404        let tmp = TempDir::new().unwrap();
405        let config = LspServerConfig::rust_analyzer();
406        assert!(!config.should_spawn(tmp.path()));
407    }
408
409    #[test]
410    fn test_heuristics_serde_roundtrip() {
411        let heuristics = ServerHeuristics::with_markers(["Cargo.toml", "rust-toolchain.toml"]);
412        let json = serde_json::to_string(&heuristics).unwrap();
413        let deserialized: ServerHeuristics = serde_json::from_str(&json).unwrap();
414        assert_eq!(deserialized.project_markers, heuristics.project_markers);
415    }
416
417    #[test]
418    fn test_default_rust_analyzer_heuristics() {
419        let config = LspServerConfig::rust_analyzer();
420        assert!(config.heuristics.is_some());
421        let markers = &config.heuristics.unwrap().project_markers;
422        assert!(markers.contains(&"Cargo.toml".to_string()));
423    }
424
425    #[test]
426    fn test_gopls_defaults() {
427        let config = LspServerConfig::gopls();
428
429        assert_eq!(config.language_id, "go");
430        assert_eq!(config.command, "gopls");
431        assert_eq!(config.args, vec!["serve"]);
432        assert!(config.heuristics.is_some());
433        let markers = &config.heuristics.unwrap().project_markers;
434        assert!(markers.contains(&"go.mod".to_string()));
435        assert!(markers.contains(&"go.sum".to_string()));
436    }
437
438    #[test]
439    fn test_clangd_defaults() {
440        let config = LspServerConfig::clangd();
441
442        assert_eq!(config.language_id, "cpp");
443        assert_eq!(config.command, "clangd");
444        assert!(config.args.is_empty());
445        assert!(config.heuristics.is_some());
446        let markers = &config.heuristics.unwrap().project_markers;
447        assert!(markers.contains(&"CMakeLists.txt".to_string()));
448        assert!(markers.contains(&"compile_commands.json".to_string()));
449    }
450
451    #[test]
452    fn test_zls_defaults() {
453        let config = LspServerConfig::zls();
454
455        assert_eq!(config.language_id, "zig");
456        assert_eq!(config.command, "zls");
457        assert!(config.args.is_empty());
458        assert!(config.heuristics.is_some());
459        let markers = &config.heuristics.unwrap().project_markers;
460        assert!(markers.contains(&"build.zig".to_string()));
461        assert!(markers.contains(&"build.zig.zon".to_string()));
462    }
463}