1mod server;
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12pub use server::LspServerConfig;
13
14use crate::error::{Error, Result};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct LanguageExtensionMapping {
22 pub extensions: Vec<String>,
24 pub language_id: String,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct ServerConfig {
32 #[serde(default)]
34 pub workspace: WorkspaceConfig,
35
36 #[serde(default)]
38 pub lsp_servers: Vec<LspServerConfig>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct WorkspaceConfig {
45 #[serde(default)]
47 pub roots: Vec<PathBuf>,
48
49 #[serde(default = "default_position_encodings")]
52 pub position_encodings: Vec<String>,
53
54 #[serde(default)]
57 pub language_extensions: Vec<LanguageExtensionMapping>,
58}
59
60impl Default for WorkspaceConfig {
61 fn default() -> Self {
62 Self {
63 roots: Vec::new(),
64 position_encodings: default_position_encodings(),
65 language_extensions: default_language_extensions(),
66 }
67 }
68}
69
70impl WorkspaceConfig {
71 #[must_use]
78 pub fn build_extension_map(&self) -> HashMap<String, String> {
79 let mut map = HashMap::new();
80 for mapping in &self.language_extensions {
81 for ext in &mapping.extensions {
82 map.insert(ext.clone(), mapping.language_id.clone());
83 }
84 }
85 map
86 }
87
88 #[must_use]
98 pub fn get_language_for_extension(&self, extension: &str) -> Option<String> {
99 for mapping in &self.language_extensions {
100 if mapping.extensions.contains(&extension.to_string()) {
101 return Some(mapping.language_id.clone());
102 }
103 }
104 None
105 }
106}
107
108fn default_position_encodings() -> Vec<String> {
109 vec!["utf-8".to_string(), "utf-16".to_string()]
110}
111
112#[allow(clippy::too_many_lines)]
117fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
118 vec![
119 LanguageExtensionMapping {
120 extensions: vec!["rs".to_string()],
121 language_id: "rust".to_string(),
122 },
123 LanguageExtensionMapping {
124 extensions: vec!["py".to_string(), "pyw".to_string(), "pyi".to_string()],
125 language_id: "python".to_string(),
126 },
127 LanguageExtensionMapping {
128 extensions: vec!["js".to_string(), "mjs".to_string(), "cjs".to_string()],
129 language_id: "javascript".to_string(),
130 },
131 LanguageExtensionMapping {
132 extensions: vec!["ts".to_string(), "mts".to_string(), "cts".to_string()],
133 language_id: "typescript".to_string(),
134 },
135 LanguageExtensionMapping {
136 extensions: vec!["tsx".to_string()],
137 language_id: "typescriptreact".to_string(),
138 },
139 LanguageExtensionMapping {
140 extensions: vec!["jsx".to_string()],
141 language_id: "javascriptreact".to_string(),
142 },
143 LanguageExtensionMapping {
144 extensions: vec!["go".to_string()],
145 language_id: "go".to_string(),
146 },
147 LanguageExtensionMapping {
148 extensions: vec!["c".to_string(), "h".to_string()],
149 language_id: "c".to_string(),
150 },
151 LanguageExtensionMapping {
152 extensions: vec![
153 "cpp".to_string(),
154 "cc".to_string(),
155 "cxx".to_string(),
156 "hpp".to_string(),
157 "hh".to_string(),
158 "hxx".to_string(),
159 ],
160 language_id: "cpp".to_string(),
161 },
162 LanguageExtensionMapping {
163 extensions: vec!["java".to_string()],
164 language_id: "java".to_string(),
165 },
166 LanguageExtensionMapping {
167 extensions: vec!["rb".to_string()],
168 language_id: "ruby".to_string(),
169 },
170 LanguageExtensionMapping {
171 extensions: vec!["php".to_string()],
172 language_id: "php".to_string(),
173 },
174 LanguageExtensionMapping {
175 extensions: vec!["swift".to_string()],
176 language_id: "swift".to_string(),
177 },
178 LanguageExtensionMapping {
179 extensions: vec!["kt".to_string(), "kts".to_string()],
180 language_id: "kotlin".to_string(),
181 },
182 LanguageExtensionMapping {
183 extensions: vec!["scala".to_string(), "sc".to_string()],
184 language_id: "scala".to_string(),
185 },
186 LanguageExtensionMapping {
187 extensions: vec!["zig".to_string()],
188 language_id: "zig".to_string(),
189 },
190 LanguageExtensionMapping {
191 extensions: vec!["lua".to_string()],
192 language_id: "lua".to_string(),
193 },
194 LanguageExtensionMapping {
195 extensions: vec!["sh".to_string(), "bash".to_string(), "zsh".to_string()],
196 language_id: "shellscript".to_string(),
197 },
198 LanguageExtensionMapping {
199 extensions: vec!["json".to_string()],
200 language_id: "json".to_string(),
201 },
202 LanguageExtensionMapping {
203 extensions: vec!["toml".to_string()],
204 language_id: "toml".to_string(),
205 },
206 LanguageExtensionMapping {
207 extensions: vec!["yaml".to_string(), "yml".to_string()],
208 language_id: "yaml".to_string(),
209 },
210 LanguageExtensionMapping {
211 extensions: vec!["xml".to_string()],
212 language_id: "xml".to_string(),
213 },
214 LanguageExtensionMapping {
215 extensions: vec!["html".to_string(), "htm".to_string()],
216 language_id: "html".to_string(),
217 },
218 LanguageExtensionMapping {
219 extensions: vec!["css".to_string()],
220 language_id: "css".to_string(),
221 },
222 LanguageExtensionMapping {
223 extensions: vec!["scss".to_string()],
224 language_id: "scss".to_string(),
225 },
226 LanguageExtensionMapping {
227 extensions: vec!["less".to_string()],
228 language_id: "less".to_string(),
229 },
230 LanguageExtensionMapping {
231 extensions: vec!["md".to_string(), "markdown".to_string()],
232 language_id: "markdown".to_string(),
233 },
234 LanguageExtensionMapping {
235 extensions: vec!["cs".to_string()],
236 language_id: "csharp".to_string(),
237 },
238 LanguageExtensionMapping {
239 extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
240 language_id: "fsharp".to_string(),
241 },
242 LanguageExtensionMapping {
243 extensions: vec!["r".to_string(), "R".to_string()],
244 language_id: "r".to_string(),
245 },
246 ]
247}
248
249impl ServerConfig {
250 pub fn load() -> Result<Self> {
266 if let Ok(path) = std::env::var("MCPLS_CONFIG") {
267 return Self::load_from(Path::new(&path));
268 }
269
270 let local_config = PathBuf::from("mcpls.toml");
271 if local_config.exists() {
272 return Self::load_from(&local_config);
273 }
274
275 if let Some(config_dir) = dirs::config_dir() {
276 let user_config = config_dir.join("mcpls").join("mcpls.toml");
277 if user_config.exists() {
278 return Self::load_from(&user_config);
279 }
280
281 if let Err(e) = Self::create_default_config_file(&user_config) {
283 tracing::warn!(
284 "Failed to create default config at {}: {}. Using in-memory defaults.",
285 user_config.display(),
286 e
287 );
288 } else {
289 tracing::info!("Created default config at {}", user_config.display());
290 }
291 }
292
293 Ok(Self::default())
295 }
296
297 pub fn load_from(path: &Path) -> Result<Self> {
303 let content = std::fs::read_to_string(path).map_err(|e| {
304 if e.kind() == std::io::ErrorKind::NotFound {
305 Error::ConfigNotFound(path.to_path_buf())
306 } else {
307 Error::Io(e)
308 }
309 })?;
310
311 let config: Self = toml::from_str(&content)?;
312 config.validate()?;
313 Ok(config)
314 }
315
316 fn create_default_config_file(path: &Path) -> Result<()> {
324 if let Some(parent) = path.parent() {
325 std::fs::create_dir_all(parent)?;
326 }
327
328 let default_config = Self::default();
329 let toml_content = toml::to_string_pretty(&default_config)?;
330 std::fs::write(path, toml_content)?;
331
332 Ok(())
333 }
334
335 fn validate(&self) -> Result<()> {
337 for server in &self.lsp_servers {
338 if server.language_id.is_empty() {
339 return Err(Error::InvalidConfig(
340 "language_id cannot be empty".to_string(),
341 ));
342 }
343 if server.command.is_empty() {
344 return Err(Error::InvalidConfig(format!(
345 "command cannot be empty for language '{}'",
346 server.language_id
347 )));
348 }
349 }
350 Ok(())
351 }
352}
353
354impl Default for ServerConfig {
355 fn default() -> Self {
356 Self {
357 workspace: WorkspaceConfig::default(),
358 lsp_servers: vec![LspServerConfig::rust_analyzer()],
359 }
360 }
361}
362
363#[cfg(test)]
364#[allow(clippy::unwrap_used)]
365mod tests {
366 use std::fs;
367
368 use tempfile::TempDir;
369
370 use super::*;
371
372 #[test]
373 fn test_default_config() {
374 let config = ServerConfig::default();
375 assert_eq!(config.lsp_servers.len(), 1);
376 assert_eq!(config.lsp_servers[0].language_id, "rust");
377 assert_eq!(config.workspace.position_encodings, vec!["utf-8", "utf-16"]);
378 }
379
380 #[test]
381 fn test_default_position_encodings() {
382 let encodings = default_position_encodings();
383 assert_eq!(encodings, vec!["utf-8", "utf-16"]);
384 }
385
386 #[test]
387 fn test_load_from_valid_toml() {
388 let tmp_dir = TempDir::new().unwrap();
389 let config_path = tmp_dir.path().join("config.toml");
390
391 let toml_content = r#"
392 [workspace]
393 roots = ["/tmp/workspace"]
394 position_encodings = ["utf-8"]
395
396 [[lsp_servers]]
397 language_id = "rust"
398 command = "rust-analyzer"
399 timeout_seconds = 30
400 "#;
401
402 fs::write(&config_path, toml_content).unwrap();
403
404 let config = ServerConfig::load_from(&config_path).unwrap();
405 assert_eq!(
406 config.workspace.roots,
407 vec![PathBuf::from("/tmp/workspace")]
408 );
409 assert_eq!(config.workspace.position_encodings, vec!["utf-8"]);
410 assert_eq!(config.lsp_servers.len(), 1);
411 assert_eq!(config.lsp_servers[0].language_id, "rust");
412 }
413
414 #[test]
415 fn test_load_from_nonexistent_file() {
416 let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
417 assert!(result.is_err());
418
419 if let Err(Error::ConfigNotFound(path)) = result {
420 assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
421 } else {
422 panic!("Expected ConfigNotFound error");
423 }
424 }
425
426 #[test]
427 fn test_load_from_invalid_toml() {
428 let tmp_dir = TempDir::new().unwrap();
429 let config_path = tmp_dir.path().join("invalid.toml");
430
431 fs::write(&config_path, "invalid toml content {{}").unwrap();
432
433 let result = ServerConfig::load_from(&config_path);
434 assert!(result.is_err());
435 }
436
437 #[test]
438 fn test_validate_empty_language_id() {
439 let tmp_dir = TempDir::new().unwrap();
440 let config_path = tmp_dir.path().join("config.toml");
441
442 let toml_content = r#"
443 [[lsp_servers]]
444 language_id = ""
445 command = "test"
446 "#;
447
448 fs::write(&config_path, toml_content).unwrap();
449
450 let result = ServerConfig::load_from(&config_path);
451 assert!(result.is_err());
452
453 if let Err(Error::InvalidConfig(msg)) = result {
454 assert!(msg.contains("language_id cannot be empty"));
455 } else {
456 panic!("Expected InvalidConfig error");
457 }
458 }
459
460 #[test]
461 fn test_validate_empty_command() {
462 let tmp_dir = TempDir::new().unwrap();
463 let config_path = tmp_dir.path().join("config.toml");
464
465 let toml_content = r#"
466 [[lsp_servers]]
467 language_id = "rust"
468 command = ""
469 "#;
470
471 fs::write(&config_path, toml_content).unwrap();
472
473 let result = ServerConfig::load_from(&config_path);
474 assert!(result.is_err());
475
476 if let Err(Error::InvalidConfig(msg)) = result {
477 assert!(msg.contains("command cannot be empty"));
478 } else {
479 panic!("Expected InvalidConfig error");
480 }
481 }
482
483 #[test]
484 fn test_workspace_config_defaults() {
485 let workspace = WorkspaceConfig::default();
486 assert!(workspace.roots.is_empty());
487 assert_eq!(workspace.position_encodings, vec!["utf-8", "utf-16"]);
488 assert!(!workspace.language_extensions.is_empty());
489 assert_eq!(workspace.language_extensions.len(), 30);
490 }
491
492 #[test]
493 fn test_load_multiple_servers() {
494 let tmp_dir = TempDir::new().unwrap();
495 let config_path = tmp_dir.path().join("multi.toml");
496
497 let toml_content = r#"
498 [[lsp_servers]]
499 language_id = "rust"
500 command = "rust-analyzer"
501
502 [[lsp_servers]]
503 language_id = "python"
504 command = "pyright-langserver"
505 args = ["--stdio"]
506 "#;
507
508 fs::write(&config_path, toml_content).unwrap();
509
510 let config = ServerConfig::load_from(&config_path).unwrap();
511 assert_eq!(config.lsp_servers.len(), 2);
512 assert_eq!(config.lsp_servers[0].language_id, "rust");
513 assert_eq!(config.lsp_servers[1].language_id, "python");
514 assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
515 }
516
517 #[test]
518 fn test_deny_unknown_fields() {
519 let tmp_dir = TempDir::new().unwrap();
520 let config_path = tmp_dir.path().join("unknown.toml");
521
522 let toml_content = r#"
523 unknown_field = "value"
524
525 [workspace]
526 roots = []
527 "#;
528
529 fs::write(&config_path, toml_content).unwrap();
530
531 let result = ServerConfig::load_from(&config_path);
532 assert!(result.is_err(), "Should reject unknown fields");
533 }
534
535 #[test]
536 fn test_empty_config_file() {
537 let tmp_dir = TempDir::new().unwrap();
538 let config_path = tmp_dir.path().join("empty.toml");
539
540 fs::write(&config_path, "").unwrap();
541
542 let config = ServerConfig::load_from(&config_path).unwrap();
543 assert!(config.workspace.roots.is_empty());
544 assert!(config.lsp_servers.is_empty());
545 }
546
547 #[test]
548 fn test_config_with_initialization_options() {
549 let tmp_dir = TempDir::new().unwrap();
550 let config_path = tmp_dir.path().join("init_opts.toml");
551
552 let toml_content = r#"
553 [[lsp_servers]]
554 language_id = "rust"
555 command = "rust-analyzer"
556
557 [lsp_servers.initialization_options]
558 cargo = { allFeatures = true }
559 "#;
560
561 fs::write(&config_path, toml_content).unwrap();
562
563 let config = ServerConfig::load_from(&config_path).unwrap();
564 assert!(config.lsp_servers[0].initialization_options.is_some());
565 }
566
567 #[test]
568 fn test_language_extensions_in_config() {
569 let tmp_dir = TempDir::new().unwrap();
570 let config_path = tmp_dir.path().join("extensions.toml");
571
572 let toml_content = r#"
573 [[workspace.language_extensions]]
574 extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
575 language_id = "cpp"
576
577 [[workspace.language_extensions]]
578 extensions = ["nu"]
579 language_id = "nushell"
580
581 [[workspace.language_extensions]]
582 extensions = ["py", "pyw", "pyi"]
583 language_id = "python"
584 "#;
585
586 fs::write(&config_path, toml_content).unwrap();
587
588 let config = ServerConfig::load_from(&config_path).unwrap();
589 assert_eq!(config.workspace.language_extensions.len(), 3);
590
591 assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
593 assert_eq!(
594 config.workspace.language_extensions[0].extensions,
595 vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
596 );
597
598 assert_eq!(
600 config.workspace.language_extensions[1].language_id,
601 "nushell"
602 );
603 assert_eq!(
604 config.workspace.language_extensions[1].extensions,
605 vec!["nu"]
606 );
607 }
608
609 #[test]
610 fn test_build_extension_map() {
611 let workspace = WorkspaceConfig {
612 roots: vec![],
613 position_encodings: vec![],
614 language_extensions: vec![
615 LanguageExtensionMapping {
616 extensions: vec!["cpp".to_string(), "cc".to_string(), "cxx".to_string()],
617 language_id: "cpp".to_string(),
618 },
619 LanguageExtensionMapping {
620 extensions: vec!["nu".to_string()],
621 language_id: "nushell".to_string(),
622 },
623 ],
624 };
625
626 let map = workspace.build_extension_map();
627 assert_eq!(map.get("cpp"), Some(&"cpp".to_string()));
628 assert_eq!(map.get("cc"), Some(&"cpp".to_string()));
629 assert_eq!(map.get("cxx"), Some(&"cpp".to_string()));
630 assert_eq!(map.get("nu"), Some(&"nushell".to_string()));
631 assert_eq!(map.get("unknown"), None);
632 }
633
634 #[test]
635 fn test_get_language_for_extension() {
636 let workspace = WorkspaceConfig {
637 roots: vec![],
638 position_encodings: vec![],
639 language_extensions: vec![
640 LanguageExtensionMapping {
641 extensions: vec!["hpp".to_string(), "hh".to_string()],
642 language_id: "cpp".to_string(),
643 },
644 LanguageExtensionMapping {
645 extensions: vec!["py".to_string()],
646 language_id: "python".to_string(),
647 },
648 ],
649 };
650
651 assert_eq!(
652 workspace.get_language_for_extension("hpp"),
653 Some("cpp".to_string())
654 );
655 assert_eq!(
656 workspace.get_language_for_extension("hh"),
657 Some("cpp".to_string())
658 );
659 assert_eq!(
660 workspace.get_language_for_extension("py"),
661 Some("python".to_string())
662 );
663 assert_eq!(workspace.get_language_for_extension("unknown"), None);
664 }
665
666 #[test]
667 fn test_default_language_extensions() {
668 let workspace = WorkspaceConfig::default();
669 let map = workspace.build_extension_map();
670 assert!(!map.is_empty());
671 assert_eq!(
672 workspace.get_language_for_extension("rs"),
673 Some("rust".to_string())
674 );
675 assert_eq!(
676 workspace.get_language_for_extension("py"),
677 Some("python".to_string())
678 );
679 assert_eq!(
680 workspace.get_language_for_extension("cpp"),
681 Some("cpp".to_string())
682 );
683 }
684
685 #[test]
686 fn test_create_default_config_file() {
687 let tmp_dir = TempDir::new().unwrap();
688 let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
689
690 ServerConfig::create_default_config_file(&config_path).unwrap();
691
692 assert!(config_path.exists());
693
694 let loaded_config = ServerConfig::load_from(&config_path).unwrap();
695 assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
696 assert_eq!(loaded_config.lsp_servers.len(), 1);
697 assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
698 }
699
700 #[test]
701 fn test_load_returns_default_config() {
702 let config = ServerConfig::default();
704 assert_eq!(config.workspace.language_extensions.len(), 30);
705 assert_eq!(config.lsp_servers.len(), 1);
706 assert_eq!(config.lsp_servers[0].language_id, "rust");
707 }
708
709 #[test]
710 fn test_load_does_not_overwrite_existing_config() {
711 let original_dir = std::env::current_dir().unwrap();
713
714 let tmp_dir = TempDir::new().unwrap();
715 let config_path = tmp_dir.path().join("mcpls.toml");
716
717 let custom_toml = r#"
718 [workspace]
719 roots = ["/custom/path"]
720
721 [[lsp_servers]]
722 language_id = "python"
723 command = "pyright-langserver"
724 "#;
725
726 fs::write(&config_path, custom_toml).unwrap();
727
728 std::env::set_current_dir(tmp_dir.path()).unwrap();
729 let config = ServerConfig::load().unwrap();
730
731 assert_eq!(config.workspace.roots, vec![PathBuf::from("/custom/path")]);
732 assert_eq!(config.lsp_servers.len(), 1);
733 assert_eq!(config.lsp_servers[0].language_id, "python");
734
735 std::env::set_current_dir(original_dir).unwrap();
737 }
738
739 #[test]
740 fn test_config_file_creation_with_proper_structure() {
741 let tmp_dir = TempDir::new().unwrap();
742 let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
743
744 ServerConfig::create_default_config_file(&config_path).unwrap();
745
746 let content = fs::read_to_string(&config_path).unwrap();
747
748 assert!(content.contains("[workspace]"));
749 assert!(content.contains("[[workspace.language_extensions]]"));
750 assert!(content.contains("[[lsp_servers]]"));
751 assert!(content.contains("language_id = \"rust\""));
752 assert!(content.contains("extensions = [\"rs\"]"));
753 }
754}