1use std::collections::HashMap;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13#[serde(deny_unknown_fields)]
14pub struct ServerHeuristics {
15 #[serde(default)]
19 pub project_markers: Vec<String>,
20}
21
22impl ServerHeuristics {
23 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct LspServerConfig {
55 pub language_id: String,
57
58 pub command: String,
60
61 #[serde(default)]
63 pub args: Vec<String>,
64
65 #[serde(default)]
67 pub env: HashMap<String, String>,
68
69 #[serde(default)]
71 pub file_patterns: Vec<String>,
72
73 #[serde(default)]
75 pub initialization_options: Option<serde_json::Value>,
76
77 #[serde(default = "default_timeout")]
79 pub timeout_seconds: u64,
80
81 #[serde(default)]
84 pub heuristics: Option<ServerHeuristics>,
85}
86
87const fn default_timeout() -> u64 {
88 30
89}
90
91impl LspServerConfig {
92 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}