mcpls_core/config/
mod.rs

1//! Configuration types and loading.
2//!
3//! This module provides configuration structures for MCPLS,
4//! including LSP server definitions and workspace settings.
5
6mod 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/// Maps file extensions to LSP language identifiers.
17///
18/// Used to detect the language ID for files based on their extension.
19/// Extensions are mapped to language IDs like "rust", "python", "cpp", etc.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct LanguageExtensionMapping {
22    /// Array of extensions and their corresponding language ID.
23    pub extensions: Vec<String>,
24    /// Language ID to report to the LSP server.
25    pub language_id: String,
26}
27
28/// Main configuration for the MCPLS server.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct ServerConfig {
32    /// Workspace configuration.
33    #[serde(default)]
34    pub workspace: WorkspaceConfig,
35
36    /// LSP server configurations.
37    #[serde(default)]
38    pub lsp_servers: Vec<LspServerConfig>,
39}
40
41/// Workspace-level configuration.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct WorkspaceConfig {
45    /// Root directories for the workspace.
46    #[serde(default)]
47    pub roots: Vec<PathBuf>,
48
49    /// Position encoding preference order.
50    /// Valid values: "utf-8", "utf-16", "utf-32"
51    #[serde(default = "default_position_encodings")]
52    pub position_encodings: Vec<String>,
53
54    /// File extension to language ID mappings.
55    /// Allows users to customize which file extensions map to which language servers.
56    #[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    /// Build a map of file extensions to language IDs from the configuration.
72    ///
73    /// # Returns
74    ///
75    /// A `HashMap` where keys are file extensions (without the dot) and values
76    /// are the corresponding language IDs to report to LSP servers.
77    #[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    /// Get the language ID for a file extension.
89    ///
90    /// # Arguments
91    ///
92    /// * `extension` - The file extension (without the dot)
93    ///
94    /// # Returns
95    ///
96    /// The language ID if found, `None` otherwise.
97    #[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/// Build default language extension mappings.
113///
114/// Returns all built-in language extensions that MCPLS recognizes by default.
115/// These mappings are used when no custom configuration is provided.
116#[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    /// Load configuration from the default path.
251    ///
252    /// Default paths checked in order:
253    /// 1. `$MCPLS_CONFIG` environment variable
254    /// 2. `./mcpls.toml` (current directory)
255    /// 3. `~/.config/mcpls/mcpls.toml` (Linux/macOS)
256    /// 4. `%APPDATA%\mcpls\mcpls.toml` (Windows)
257    ///
258    /// If no configuration file exists, creates a default configuration file
259    /// in the user's config directory with all default language extensions.
260    ///
261    /// # Errors
262    ///
263    /// Returns an error if parsing an existing config fails.
264    /// If config creation fails, returns default config with graceful degradation.
265    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            // No config found - create default config file
282            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        // Return default configuration
294        Ok(Self::default())
295    }
296
297    /// Load configuration from a specific path.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error if the file doesn't exist or parsing fails.
302    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    /// Create a default configuration file with all built-in extensions.
317    ///
318    /// Creates the parent directory if it doesn't exist.
319    ///
320    /// # Errors
321    ///
322    /// Returns an error if directory or file creation fails.
323    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    /// Validate the configuration.
336    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        // Check C++ extensions
592        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        // Check Nushell extension
599        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        // When called directly, default() should return config with all language extensions
703        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        // Save original directory to restore it after the test
712        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        // Restore original directory to avoid affecting other tests
736        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}