1mod server;
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12pub use server::{DEFAULT_HEURISTICS_MAX_DEPTH, LspServerConfig, ServerHeuristics};
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 #[serde(default = "default_heuristics_max_depth")]
63 pub heuristics_max_depth: usize,
64}
65
66impl Default for WorkspaceConfig {
67 fn default() -> Self {
68 Self {
69 roots: Vec::new(),
70 position_encodings: default_position_encodings(),
71 language_extensions: default_language_extensions(),
72 heuristics_max_depth: default_heuristics_max_depth(),
73 }
74 }
75}
76
77const fn default_heuristics_max_depth() -> usize {
78 DEFAULT_HEURISTICS_MAX_DEPTH
79}
80
81impl WorkspaceConfig {
82 #[must_use]
89 pub fn build_extension_map(&self) -> HashMap<String, String> {
90 let mut map = HashMap::new();
91 for mapping in &self.language_extensions {
92 for ext in &mapping.extensions {
93 map.insert(ext.clone(), mapping.language_id.clone());
94 }
95 }
96 map
97 }
98
99 #[must_use]
109 pub fn get_language_for_extension(&self, extension: &str) -> Option<String> {
110 for mapping in &self.language_extensions {
111 if mapping.extensions.contains(&extension.to_string()) {
112 return Some(mapping.language_id.clone());
113 }
114 }
115 None
116 }
117}
118
119fn default_position_encodings() -> Vec<String> {
120 vec!["utf-8".to_string(), "utf-16".to_string()]
121}
122
123#[allow(clippy::too_many_lines)]
128fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
129 vec![
130 LanguageExtensionMapping {
131 extensions: vec!["rs".to_string()],
132 language_id: "rust".to_string(),
133 },
134 LanguageExtensionMapping {
135 extensions: vec!["py".to_string(), "pyw".to_string(), "pyi".to_string()],
136 language_id: "python".to_string(),
137 },
138 LanguageExtensionMapping {
139 extensions: vec!["js".to_string(), "mjs".to_string(), "cjs".to_string()],
140 language_id: "javascript".to_string(),
141 },
142 LanguageExtensionMapping {
143 extensions: vec!["ts".to_string(), "mts".to_string(), "cts".to_string()],
144 language_id: "typescript".to_string(),
145 },
146 LanguageExtensionMapping {
147 extensions: vec!["tsx".to_string()],
148 language_id: "typescriptreact".to_string(),
149 },
150 LanguageExtensionMapping {
151 extensions: vec!["jsx".to_string()],
152 language_id: "javascriptreact".to_string(),
153 },
154 LanguageExtensionMapping {
155 extensions: vec!["go".to_string()],
156 language_id: "go".to_string(),
157 },
158 LanguageExtensionMapping {
159 extensions: vec!["c".to_string(), "h".to_string()],
160 language_id: "c".to_string(),
161 },
162 LanguageExtensionMapping {
163 extensions: vec![
164 "cpp".to_string(),
165 "cc".to_string(),
166 "cxx".to_string(),
167 "hpp".to_string(),
168 "hh".to_string(),
169 "hxx".to_string(),
170 ],
171 language_id: "cpp".to_string(),
172 },
173 LanguageExtensionMapping {
174 extensions: vec!["java".to_string()],
175 language_id: "java".to_string(),
176 },
177 LanguageExtensionMapping {
178 extensions: vec!["rb".to_string()],
179 language_id: "ruby".to_string(),
180 },
181 LanguageExtensionMapping {
182 extensions: vec!["php".to_string()],
183 language_id: "php".to_string(),
184 },
185 LanguageExtensionMapping {
186 extensions: vec!["swift".to_string()],
187 language_id: "swift".to_string(),
188 },
189 LanguageExtensionMapping {
190 extensions: vec!["kt".to_string(), "kts".to_string()],
191 language_id: "kotlin".to_string(),
192 },
193 LanguageExtensionMapping {
194 extensions: vec!["scala".to_string(), "sc".to_string()],
195 language_id: "scala".to_string(),
196 },
197 LanguageExtensionMapping {
198 extensions: vec!["zig".to_string()],
199 language_id: "zig".to_string(),
200 },
201 LanguageExtensionMapping {
202 extensions: vec!["lua".to_string()],
203 language_id: "lua".to_string(),
204 },
205 LanguageExtensionMapping {
206 extensions: vec!["sh".to_string(), "bash".to_string(), "zsh".to_string()],
207 language_id: "shellscript".to_string(),
208 },
209 LanguageExtensionMapping {
210 extensions: vec!["json".to_string()],
211 language_id: "json".to_string(),
212 },
213 LanguageExtensionMapping {
214 extensions: vec!["toml".to_string()],
215 language_id: "toml".to_string(),
216 },
217 LanguageExtensionMapping {
218 extensions: vec!["yaml".to_string(), "yml".to_string()],
219 language_id: "yaml".to_string(),
220 },
221 LanguageExtensionMapping {
222 extensions: vec!["xml".to_string()],
223 language_id: "xml".to_string(),
224 },
225 LanguageExtensionMapping {
226 extensions: vec!["html".to_string(), "htm".to_string()],
227 language_id: "html".to_string(),
228 },
229 LanguageExtensionMapping {
230 extensions: vec!["css".to_string()],
231 language_id: "css".to_string(),
232 },
233 LanguageExtensionMapping {
234 extensions: vec!["scss".to_string()],
235 language_id: "scss".to_string(),
236 },
237 LanguageExtensionMapping {
238 extensions: vec!["less".to_string()],
239 language_id: "less".to_string(),
240 },
241 LanguageExtensionMapping {
242 extensions: vec!["md".to_string(), "markdown".to_string()],
243 language_id: "markdown".to_string(),
244 },
245 LanguageExtensionMapping {
246 extensions: vec!["cs".to_string()],
247 language_id: "csharp".to_string(),
248 },
249 LanguageExtensionMapping {
250 extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
251 language_id: "fsharp".to_string(),
252 },
253 LanguageExtensionMapping {
254 extensions: vec!["r".to_string(), "R".to_string()],
255 language_id: "r".to_string(),
256 },
257 ]
258}
259
260impl ServerConfig {
261 pub fn load() -> Result<Self> {
277 if let Ok(path) = std::env::var("MCPLS_CONFIG") {
278 return Self::load_from(Path::new(&path));
279 }
280
281 let local_config = PathBuf::from("mcpls.toml");
282 if local_config.exists() {
283 return Self::load_from(&local_config);
284 }
285
286 if let Some(config_dir) = dirs::config_dir() {
287 let user_config = config_dir.join("mcpls").join("mcpls.toml");
288 if user_config.exists() {
289 return Self::load_from(&user_config);
290 }
291
292 if let Err(e) = Self::create_default_config_file(&user_config) {
294 tracing::warn!(
295 "Failed to create default config at {}: {}. Using in-memory defaults.",
296 user_config.display(),
297 e
298 );
299 } else {
300 tracing::info!("Created default config at {}", user_config.display());
301 }
302 }
303
304 Ok(Self::default())
306 }
307
308 pub fn load_from(path: &Path) -> Result<Self> {
314 let content = std::fs::read_to_string(path).map_err(|e| {
315 if e.kind() == std::io::ErrorKind::NotFound {
316 Error::ConfigNotFound(path.to_path_buf())
317 } else {
318 Error::Io(e)
319 }
320 })?;
321
322 let config: Self = toml::from_str(&content)?;
323 config.validate()?;
324 Ok(config)
325 }
326
327 fn create_default_config_file(path: &Path) -> Result<()> {
335 if let Some(parent) = path.parent() {
336 std::fs::create_dir_all(parent)?;
337 }
338
339 let default_config = Self::default();
340 let toml_content = toml::to_string_pretty(&default_config)?;
341 std::fs::write(path, toml_content)?;
342
343 Ok(())
344 }
345
346 fn validate(&self) -> Result<()> {
348 for server in &self.lsp_servers {
349 if server.language_id.is_empty() {
350 return Err(Error::InvalidConfig(
351 "language_id cannot be empty".to_string(),
352 ));
353 }
354 if server.command.is_empty() {
355 return Err(Error::InvalidConfig(format!(
356 "command cannot be empty for language '{}'",
357 server.language_id
358 )));
359 }
360 }
361 Ok(())
362 }
363}
364
365impl Default for ServerConfig {
366 fn default() -> Self {
367 Self {
368 workspace: WorkspaceConfig::default(),
369 lsp_servers: vec![
370 LspServerConfig::rust_analyzer(),
371 LspServerConfig::pyright(),
372 LspServerConfig::typescript(),
373 LspServerConfig::gopls(),
374 LspServerConfig::clangd(),
375 LspServerConfig::zls(),
376 ],
377 }
378 }
379}
380
381#[cfg(test)]
382#[allow(clippy::unwrap_used)]
383mod tests {
384 use std::fs;
385
386 use tempfile::TempDir;
387
388 use super::*;
389
390 #[test]
391 fn test_default_config() {
392 let config = ServerConfig::default();
393 assert_eq!(config.lsp_servers.len(), 6);
394 assert_eq!(config.lsp_servers[0].language_id, "rust");
395 assert_eq!(config.lsp_servers[1].language_id, "python");
396 assert_eq!(config.lsp_servers[2].language_id, "typescript");
397 assert_eq!(config.lsp_servers[3].language_id, "go");
398 assert_eq!(config.lsp_servers[4].language_id, "cpp");
399 assert_eq!(config.lsp_servers[5].language_id, "zig");
400 assert_eq!(config.workspace.position_encodings, vec!["utf-8", "utf-16"]);
401 }
402
403 #[test]
404 fn test_default_position_encodings() {
405 let encodings = default_position_encodings();
406 assert_eq!(encodings, vec!["utf-8", "utf-16"]);
407 }
408
409 #[test]
410 fn test_load_from_valid_toml() {
411 let tmp_dir = TempDir::new().unwrap();
412 let config_path = tmp_dir.path().join("config.toml");
413
414 let toml_content = r#"
415 [workspace]
416 roots = ["/tmp/workspace"]
417 position_encodings = ["utf-8"]
418
419 [[lsp_servers]]
420 language_id = "rust"
421 command = "rust-analyzer"
422 timeout_seconds = 30
423 "#;
424
425 fs::write(&config_path, toml_content).unwrap();
426
427 let config = ServerConfig::load_from(&config_path).unwrap();
428 assert_eq!(
429 config.workspace.roots,
430 vec![PathBuf::from("/tmp/workspace")]
431 );
432 assert_eq!(config.workspace.position_encodings, vec!["utf-8"]);
433 assert_eq!(config.lsp_servers.len(), 1);
434 assert_eq!(config.lsp_servers[0].language_id, "rust");
435 }
436
437 #[test]
438 fn test_load_from_nonexistent_file() {
439 let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
440 assert!(result.is_err());
441
442 if let Err(Error::ConfigNotFound(path)) = result {
443 assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
444 } else {
445 panic!("Expected ConfigNotFound error");
446 }
447 }
448
449 #[test]
450 fn test_load_from_invalid_toml() {
451 let tmp_dir = TempDir::new().unwrap();
452 let config_path = tmp_dir.path().join("invalid.toml");
453
454 fs::write(&config_path, "invalid toml content {{}").unwrap();
455
456 let result = ServerConfig::load_from(&config_path);
457 assert!(result.is_err());
458 }
459
460 #[test]
461 fn test_validate_empty_language_id() {
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 = ""
468 command = "test"
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("language_id cannot be empty"));
478 } else {
479 panic!("Expected InvalidConfig error");
480 }
481 }
482
483 #[test]
484 fn test_validate_empty_command() {
485 let tmp_dir = TempDir::new().unwrap();
486 let config_path = tmp_dir.path().join("config.toml");
487
488 let toml_content = r#"
489 [[lsp_servers]]
490 language_id = "rust"
491 command = ""
492 "#;
493
494 fs::write(&config_path, toml_content).unwrap();
495
496 let result = ServerConfig::load_from(&config_path);
497 assert!(result.is_err());
498
499 if let Err(Error::InvalidConfig(msg)) = result {
500 assert!(msg.contains("command cannot be empty"));
501 } else {
502 panic!("Expected InvalidConfig error");
503 }
504 }
505
506 #[test]
507 fn test_workspace_config_defaults() {
508 let workspace = WorkspaceConfig::default();
509 assert!(workspace.roots.is_empty());
510 assert_eq!(workspace.position_encodings, vec!["utf-8", "utf-16"]);
511 assert!(!workspace.language_extensions.is_empty());
512 assert_eq!(workspace.language_extensions.len(), 30);
513 assert_eq!(workspace.heuristics_max_depth, DEFAULT_HEURISTICS_MAX_DEPTH);
514 }
515
516 #[test]
517 fn test_load_multiple_servers() {
518 let tmp_dir = TempDir::new().unwrap();
519 let config_path = tmp_dir.path().join("multi.toml");
520
521 let toml_content = r#"
522 [[lsp_servers]]
523 language_id = "rust"
524 command = "rust-analyzer"
525
526 [[lsp_servers]]
527 language_id = "python"
528 command = "pyright-langserver"
529 args = ["--stdio"]
530 "#;
531
532 fs::write(&config_path, toml_content).unwrap();
533
534 let config = ServerConfig::load_from(&config_path).unwrap();
535 assert_eq!(config.lsp_servers.len(), 2);
536 assert_eq!(config.lsp_servers[0].language_id, "rust");
537 assert_eq!(config.lsp_servers[1].language_id, "python");
538 assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
539 }
540
541 #[test]
542 fn test_deny_unknown_fields() {
543 let tmp_dir = TempDir::new().unwrap();
544 let config_path = tmp_dir.path().join("unknown.toml");
545
546 let toml_content = r#"
547 unknown_field = "value"
548
549 [workspace]
550 roots = []
551 "#;
552
553 fs::write(&config_path, toml_content).unwrap();
554
555 let result = ServerConfig::load_from(&config_path);
556 assert!(result.is_err(), "Should reject unknown fields");
557 }
558
559 #[test]
560 fn test_empty_config_file() {
561 let tmp_dir = TempDir::new().unwrap();
562 let config_path = tmp_dir.path().join("empty.toml");
563
564 fs::write(&config_path, "").unwrap();
565
566 let config = ServerConfig::load_from(&config_path).unwrap();
567 assert!(config.workspace.roots.is_empty());
568 assert!(config.lsp_servers.is_empty());
569 }
570
571 #[test]
572 fn test_config_with_initialization_options() {
573 let tmp_dir = TempDir::new().unwrap();
574 let config_path = tmp_dir.path().join("init_opts.toml");
575
576 let toml_content = r#"
577 [[lsp_servers]]
578 language_id = "rust"
579 command = "rust-analyzer"
580
581 [lsp_servers.initialization_options]
582 cargo = { allFeatures = true }
583 "#;
584
585 fs::write(&config_path, toml_content).unwrap();
586
587 let config = ServerConfig::load_from(&config_path).unwrap();
588 assert!(config.lsp_servers[0].initialization_options.is_some());
589 }
590
591 #[test]
592 fn test_language_extensions_in_config() {
593 let tmp_dir = TempDir::new().unwrap();
594 let config_path = tmp_dir.path().join("extensions.toml");
595
596 let toml_content = r#"
597 [[workspace.language_extensions]]
598 extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
599 language_id = "cpp"
600
601 [[workspace.language_extensions]]
602 extensions = ["nu"]
603 language_id = "nushell"
604
605 [[workspace.language_extensions]]
606 extensions = ["py", "pyw", "pyi"]
607 language_id = "python"
608 "#;
609
610 fs::write(&config_path, toml_content).unwrap();
611
612 let config = ServerConfig::load_from(&config_path).unwrap();
613 assert_eq!(config.workspace.language_extensions.len(), 3);
614
615 assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
617 assert_eq!(
618 config.workspace.language_extensions[0].extensions,
619 vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
620 );
621
622 assert_eq!(
624 config.workspace.language_extensions[1].language_id,
625 "nushell"
626 );
627 assert_eq!(
628 config.workspace.language_extensions[1].extensions,
629 vec!["nu"]
630 );
631 }
632
633 #[test]
634 fn test_build_extension_map() {
635 let workspace = WorkspaceConfig {
636 roots: vec![],
637 position_encodings: vec![],
638 language_extensions: vec![
639 LanguageExtensionMapping {
640 extensions: vec!["cpp".to_string(), "cc".to_string(), "cxx".to_string()],
641 language_id: "cpp".to_string(),
642 },
643 LanguageExtensionMapping {
644 extensions: vec!["nu".to_string()],
645 language_id: "nushell".to_string(),
646 },
647 ],
648 heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
649 };
650
651 let map = workspace.build_extension_map();
652 assert_eq!(map.get("cpp"), Some(&"cpp".to_string()));
653 assert_eq!(map.get("cc"), Some(&"cpp".to_string()));
654 assert_eq!(map.get("cxx"), Some(&"cpp".to_string()));
655 assert_eq!(map.get("nu"), Some(&"nushell".to_string()));
656 assert_eq!(map.get("unknown"), None);
657 }
658
659 #[test]
660 fn test_get_language_for_extension() {
661 let workspace = WorkspaceConfig {
662 roots: vec![],
663 position_encodings: vec![],
664 language_extensions: vec![
665 LanguageExtensionMapping {
666 extensions: vec!["hpp".to_string(), "hh".to_string()],
667 language_id: "cpp".to_string(),
668 },
669 LanguageExtensionMapping {
670 extensions: vec!["py".to_string()],
671 language_id: "python".to_string(),
672 },
673 ],
674 heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
675 };
676
677 assert_eq!(
678 workspace.get_language_for_extension("hpp"),
679 Some("cpp".to_string())
680 );
681 assert_eq!(
682 workspace.get_language_for_extension("hh"),
683 Some("cpp".to_string())
684 );
685 assert_eq!(
686 workspace.get_language_for_extension("py"),
687 Some("python".to_string())
688 );
689 assert_eq!(workspace.get_language_for_extension("unknown"), None);
690 }
691
692 #[test]
693 fn test_default_language_extensions() {
694 let workspace = WorkspaceConfig::default();
695 let map = workspace.build_extension_map();
696 assert!(!map.is_empty());
697 assert_eq!(
698 workspace.get_language_for_extension("rs"),
699 Some("rust".to_string())
700 );
701 assert_eq!(
702 workspace.get_language_for_extension("py"),
703 Some("python".to_string())
704 );
705 assert_eq!(
706 workspace.get_language_for_extension("cpp"),
707 Some("cpp".to_string())
708 );
709 }
710
711 #[test]
712 fn test_create_default_config_file() {
713 let tmp_dir = TempDir::new().unwrap();
714 let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
715
716 ServerConfig::create_default_config_file(&config_path).unwrap();
717
718 assert!(config_path.exists());
719
720 let loaded_config = ServerConfig::load_from(&config_path).unwrap();
721 assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
722 assert_eq!(loaded_config.lsp_servers.len(), 6);
723 assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
724 }
725
726 #[test]
727 fn test_load_returns_default_config() {
728 let config = ServerConfig::default();
730 assert_eq!(config.workspace.language_extensions.len(), 30);
731 assert_eq!(config.lsp_servers.len(), 6);
732 assert_eq!(config.lsp_servers[0].language_id, "rust");
733 }
734
735 #[test]
736 fn test_load_does_not_overwrite_existing_config() {
737 let original_dir = std::env::current_dir().unwrap();
739
740 let tmp_dir = TempDir::new().unwrap();
741 let config_path = tmp_dir.path().join("mcpls.toml");
742
743 let custom_toml = r#"
744 [workspace]
745 roots = ["/custom/path"]
746
747 [[lsp_servers]]
748 language_id = "python"
749 command = "pyright-langserver"
750 "#;
751
752 fs::write(&config_path, custom_toml).unwrap();
753
754 std::env::set_current_dir(tmp_dir.path()).unwrap();
755 let config = ServerConfig::load().unwrap();
756
757 assert_eq!(config.workspace.roots, vec![PathBuf::from("/custom/path")]);
758 assert_eq!(config.lsp_servers.len(), 1);
759 assert_eq!(config.lsp_servers[0].language_id, "python");
760
761 std::env::set_current_dir(original_dir).unwrap();
763 }
764
765 #[test]
766 fn test_config_file_creation_with_proper_structure() {
767 let tmp_dir = TempDir::new().unwrap();
768 let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
769
770 ServerConfig::create_default_config_file(&config_path).unwrap();
771
772 let content = fs::read_to_string(&config_path).unwrap();
773
774 assert!(content.contains("[workspace]"));
775 assert!(content.contains("[[workspace.language_extensions]]"));
776 assert!(content.contains("[[lsp_servers]]"));
777 assert!(content.contains("language_id = \"rust\""));
778 assert!(content.contains("extensions = [\"rs\"]"));
779 }
780
781 #[test]
782 fn test_heuristics_max_depth_default() {
783 let config = WorkspaceConfig::default();
784 assert_eq!(config.heuristics_max_depth, 10);
785 }
786
787 #[test]
788 fn test_heuristics_max_depth_from_config() {
789 let tmp_dir = TempDir::new().unwrap();
790 let config_path = tmp_dir.path().join("depth.toml");
791
792 let toml_content = r"
793 [workspace]
794 heuristics_max_depth = 5
795 ";
796
797 fs::write(&config_path, toml_content).unwrap();
798
799 let config = ServerConfig::load_from(&config_path).unwrap();
800 assert_eq!(config.workspace.heuristics_max_depth, 5);
801 }
802
803 #[test]
804 fn test_heuristics_max_depth_uses_default_when_not_specified() {
805 let tmp_dir = TempDir::new().unwrap();
806 let config_path = tmp_dir.path().join("no_depth.toml");
807
808 let toml_content = r"
809 [workspace]
810 roots = []
811 ";
812
813 fs::write(&config_path, toml_content).unwrap();
814
815 let config = ServerConfig::load_from(&config_path).unwrap();
816 assert_eq!(
817 config.workspace.heuristics_max_depth,
818 DEFAULT_HEURISTICS_MAX_DEPTH
819 );
820 }
821}