Skip to main content

spring_lsp/
config.rs

1//! 服务器配置管理
2//!
3//! 本模块提供 spring-lsp 服务器的配置管理功能,支持:
4//! - 从配置文件读取用户配置
5//! - 自定义补全触发字符
6//! - 诊断过滤配置
7//! - 自定义 Schema URL
8//! - 日志级别配置
9//!
10//! ## 配置文件
11//!
12//! spring-lsp 支持从以下位置读取配置文件(按优先级排序):
13//! 1. 工作空间根目录下的 `.spring-lsp.toml`
14//! 2. 用户主目录下的 `.config/spring-lsp/config.toml`
15//! 3. 环境变量配置
16//! 4. 默认配置
17//!
18//! ## 配置文件格式
19//!
20//! ```toml
21//! # 日志配置
22//! [logging]
23//! level = "info"  # trace, debug, info, warn, error
24//! verbose = false
25//! log_file = "/tmp/spring-lsp.log"  # 可选
26//!
27//! # 补全配置
28//! [completion]
29//! trigger_characters = ["[", ".", "$", "{", "#", "("]
30//!
31//! # 诊断配置
32//! [diagnostics]
33//! # 禁用特定类型的诊断
34//! disabled = ["deprecated_warning", "restful_style"]
35//!
36//! # Schema 配置
37//! [schema]
38//! url = "https://spring-rs.github.io/config-schema.json"
39//! # 或使用本地文件
40//! # url = "file:///path/to/schema.json"
41//! ```
42//!
43//! ## 环境变量
44//!
45//! 环境变量会覆盖配置文件中的设置:
46//! - `SPRING_LSP_LOG_LEVEL`: 日志级别
47//! - `SPRING_LSP_VERBOSE`: 启用详细日志
48//! - `SPRING_LSP_LOG_FILE`: 日志文件路径
49//! - `SPRING_LSP_SCHEMA_URL`: Schema URL
50
51use serde::{Deserialize, Serialize};
52use std::collections::HashSet;
53use std::env;
54use std::fs;
55use std::path::{Path, PathBuf};
56
57/// 服务器配置
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59#[serde(default)]
60pub struct ServerConfig {
61    /// 日志配置
62    pub logging: LoggingConfig,
63    /// 补全配置
64    pub completion: CompletionConfig,
65    /// 诊断配置
66    pub diagnostics: DiagnosticsConfig,
67    /// Schema 配置
68    pub schema: SchemaConfig,
69}
70
71impl ServerConfig {
72    /// 从配置文件和环境变量加载配置
73    ///
74    /// 配置加载顺序(后面的会覆盖前面的):
75    /// 1. 默认配置
76    /// 2. 用户主目录配置文件
77    /// 3. 工作空间配置文件
78    /// 4. 环境变量
79    pub fn load(workspace_root: Option<&Path>) -> Self {
80        let mut config = Self::default();
81
82        // 1. 尝试加载用户主目录配置
83        if let Some(user_config_path) = Self::user_config_path() {
84            if let Ok(user_config) = Self::load_from_file(&user_config_path) {
85                config = config.merge(user_config);
86            }
87        }
88
89        // 2. 尝试加载工作空间配置
90        if let Some(workspace_root) = workspace_root {
91            let workspace_config_path = workspace_root.join(".spring-lsp.toml");
92            if let Ok(workspace_config) = Self::load_from_file(&workspace_config_path) {
93                config = config.merge(workspace_config);
94            }
95        }
96
97        // 3. 应用环境变量覆盖
98        config = config.apply_env_overrides();
99
100        config
101    }
102
103    /// 从文件加载配置
104    fn load_from_file(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
105        let content = fs::read_to_string(path)?;
106        let config: Self = toml::from_str(&content)?;
107        tracing::debug!("Loaded configuration from: {}", path.display());
108        Ok(config)
109    }
110
111    /// 获取用户配置文件路径
112    fn user_config_path() -> Option<PathBuf> {
113        dirs::config_dir().map(|dir| dir.join("spring-lsp").join("config.toml"))
114    }
115
116    /// 合并另一个配置(other 的值会覆盖 self 的值)
117    pub fn merge(mut self, other: Self) -> Self {
118        self.logging = self.logging.merge(other.logging);
119        self.completion = self.completion.merge(other.completion);
120        self.diagnostics = self.diagnostics.merge(other.diagnostics);
121        self.schema = self.schema.merge(other.schema);
122        self
123    }
124
125    /// 应用环境变量覆盖
126    fn apply_env_overrides(mut self) -> Self {
127        self.logging = self.logging.apply_env_overrides();
128        self.schema = self.schema.apply_env_overrides();
129        self
130    }
131
132    /// 验证配置
133    pub fn validate(&self) -> Result<(), String> {
134        self.logging.validate()?;
135        self.completion.validate()?;
136        self.schema.validate()?;
137        Ok(())
138    }
139}
140
141/// 日志配置
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(default)]
144pub struct LoggingConfig {
145    /// 日志级别:trace, debug, info, warn, error
146    pub level: String,
147    /// 是否启用详细模式
148    pub verbose: bool,
149    /// 日志文件路径(可选)
150    pub log_file: Option<PathBuf>,
151}
152
153impl Default for LoggingConfig {
154    fn default() -> Self {
155        Self {
156            level: "info".to_string(),
157            verbose: false,
158            log_file: None,
159        }
160    }
161}
162
163impl LoggingConfig {
164    pub fn merge(self, other: Self) -> Self {
165        Self {
166            level: other.level,
167            verbose: other.verbose,
168            log_file: other.log_file.or(self.log_file),
169        }
170    }
171
172    fn apply_env_overrides(mut self) -> Self {
173        if let Ok(level) = env::var("SPRING_LSP_LOG_LEVEL") {
174            self.level = level.to_lowercase();
175        }
176        if let Ok(verbose) = env::var("SPRING_LSP_VERBOSE") {
177            self.verbose = verbose == "1" || verbose.to_lowercase() == "true";
178        }
179        if let Ok(log_file) = env::var("SPRING_LSP_LOG_FILE") {
180            self.log_file = Some(PathBuf::from(log_file));
181        }
182        self
183    }
184
185    pub fn validate(&self) -> Result<(), String> {
186        match self.level.as_str() {
187            "trace" | "debug" | "info" | "warn" | "error" => Ok(()),
188            _ => Err(format!(
189                "Invalid log level: {}. Valid levels are: trace, debug, info, warn, error",
190                self.level
191            )),
192        }
193    }
194}
195
196/// 补全配置
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(default)]
199pub struct CompletionConfig {
200    /// 触发补全的字符列表
201    pub trigger_characters: Vec<String>,
202}
203
204impl Default for CompletionConfig {
205    fn default() -> Self {
206        Self {
207            trigger_characters: vec![
208                "[".to_string(), // TOML 配置节
209                ".".to_string(), // 嵌套配置项
210                "$".to_string(), // 环境变量
211                "{".to_string(), // 环境变量插值
212                "#".to_string(), // 宏属性
213                "(".to_string(), // 宏参数
214            ],
215        }
216    }
217}
218
219impl CompletionConfig {
220    pub fn merge(self, other: Self) -> Self {
221        Self {
222            trigger_characters: if other.trigger_characters.is_empty() {
223                self.trigger_characters
224            } else {
225                other.trigger_characters
226            },
227        }
228    }
229
230    pub fn validate(&self) -> Result<(), String> {
231        if self.trigger_characters.is_empty() {
232            return Err("Trigger characters list cannot be empty".to_string());
233        }
234        Ok(())
235    }
236}
237
238/// 诊断配置
239#[derive(Debug, Clone, Serialize, Deserialize, Default)]
240#[serde(default)]
241pub struct DiagnosticsConfig {
242    /// 禁用的诊断类型列表
243    pub disabled: HashSet<String>,
244}
245
246impl DiagnosticsConfig {
247    pub fn merge(self, other: Self) -> Self {
248        Self {
249            disabled: if other.disabled.is_empty() {
250                self.disabled
251            } else {
252                other.disabled
253            },
254        }
255    }
256
257    /// 检查诊断类型是否被禁用
258    pub fn is_disabled(&self, diagnostic_type: &str) -> bool {
259        self.disabled.contains(diagnostic_type)
260    }
261}
262
263/// Schema 配置
264#[derive(Debug, Clone, Serialize, Deserialize)]
265#[serde(default)]
266pub struct SchemaConfig {
267    /// Schema URL(HTTP URL 或 file:// URL)
268    pub url: String,
269}
270
271impl Default for SchemaConfig {
272    fn default() -> Self {
273        Self {
274            url: "https://spring-rs.github.io/config-schema.json".to_string(),
275        }
276    }
277}
278
279impl SchemaConfig {
280    pub fn merge(self, other: Self) -> Self {
281        Self { url: other.url }
282    }
283
284    fn apply_env_overrides(mut self) -> Self {
285        if let Ok(url) = env::var("SPRING_LSP_SCHEMA_URL") {
286            self.url = url;
287        }
288        self
289    }
290
291    pub fn validate(&self) -> Result<(), String> {
292        if self.url.is_empty() {
293            return Err("Schema URL cannot be empty".to_string());
294        }
295
296        // 验证 URL 格式
297        if !self.url.starts_with("http://")
298            && !self.url.starts_with("https://")
299            && !self.url.starts_with("file://")
300        {
301            return Err(format!(
302                "Invalid Schema URL: {}. Must start with http://, https://, or file://",
303                self.url
304            ));
305        }
306
307        Ok(())
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use std::env;
315
316    #[test]
317    fn test_default_config() {
318        let config = ServerConfig::default();
319        assert_eq!(config.logging.level, "info");
320        assert!(!config.logging.verbose);
321        assert!(config.logging.log_file.is_none());
322        assert_eq!(config.completion.trigger_characters.len(), 6);
323        assert!(config.diagnostics.disabled.is_empty());
324        assert_eq!(
325            config.schema.url,
326            "https://spring-rs.github.io/config-schema.json"
327        );
328    }
329
330    #[test]
331    fn test_logging_config_validation() {
332        let valid_config = LoggingConfig {
333            level: "debug".to_string(),
334            verbose: false,
335            log_file: None,
336        };
337        assert!(valid_config.validate().is_ok());
338
339        let invalid_config = LoggingConfig {
340            level: "invalid".to_string(),
341            verbose: false,
342            log_file: None,
343        };
344        assert!(invalid_config.validate().is_err());
345    }
346
347    #[test]
348    fn test_completion_config_validation() {
349        let valid_config = CompletionConfig {
350            trigger_characters: vec!["[".to_string()],
351        };
352        assert!(valid_config.validate().is_ok());
353
354        let invalid_config = CompletionConfig {
355            trigger_characters: vec![],
356        };
357        assert!(invalid_config.validate().is_err());
358    }
359
360    #[test]
361    fn test_schema_config_validation() {
362        let valid_http = SchemaConfig {
363            url: "https://example.com/schema.json".to_string(),
364        };
365        assert!(valid_http.validate().is_ok());
366
367        let valid_file = SchemaConfig {
368            url: "file:///path/to/schema.json".to_string(),
369        };
370        assert!(valid_file.validate().is_ok());
371
372        let invalid_empty = SchemaConfig {
373            url: "".to_string(),
374        };
375        assert!(invalid_empty.validate().is_err());
376
377        let invalid_protocol = SchemaConfig {
378            url: "ftp://example.com/schema.json".to_string(),
379        };
380        assert!(invalid_protocol.validate().is_err());
381    }
382
383    #[test]
384    fn test_diagnostics_is_disabled() {
385        let mut config = DiagnosticsConfig::default();
386        assert!(!config.is_disabled("deprecated_warning"));
387
388        config.disabled.insert("deprecated_warning".to_string());
389        assert!(config.is_disabled("deprecated_warning"));
390        assert!(!config.is_disabled("type_error"));
391    }
392
393    #[test]
394    fn test_config_merge() {
395        let base = ServerConfig {
396            logging: LoggingConfig {
397                level: "info".to_string(),
398                verbose: false,
399                log_file: None,
400            },
401            completion: CompletionConfig {
402                trigger_characters: vec!["[".to_string()],
403            },
404            diagnostics: DiagnosticsConfig {
405                disabled: HashSet::new(),
406            },
407            schema: SchemaConfig {
408                url: "https://default.com/schema.json".to_string(),
409            },
410        };
411
412        let override_config = ServerConfig {
413            logging: LoggingConfig {
414                level: "debug".to_string(),
415                verbose: true,
416                log_file: Some(PathBuf::from("/tmp/test.log")),
417            },
418            completion: CompletionConfig {
419                trigger_characters: vec!["[".to_string(), ".".to_string()],
420            },
421            diagnostics: DiagnosticsConfig {
422                disabled: {
423                    let mut set = HashSet::new();
424                    set.insert("deprecated_warning".to_string());
425                    set
426                },
427            },
428            schema: SchemaConfig {
429                url: "https://custom.com/schema.json".to_string(),
430            },
431        };
432
433        let merged = base.merge(override_config);
434
435        assert_eq!(merged.logging.level, "debug");
436        assert!(merged.logging.verbose);
437        assert_eq!(
438            merged.logging.log_file,
439            Some(PathBuf::from("/tmp/test.log"))
440        );
441        assert_eq!(merged.completion.trigger_characters.len(), 2);
442        assert!(merged.diagnostics.is_disabled("deprecated_warning"));
443        assert_eq!(merged.schema.url, "https://custom.com/schema.json");
444    }
445
446    #[test]
447    fn test_env_overrides() {
448        // 保存原始环境变量
449        let original_level = env::var("SPRING_LSP_LOG_LEVEL").ok();
450        let original_verbose = env::var("SPRING_LSP_VERBOSE").ok();
451        let original_schema = env::var("SPRING_LSP_SCHEMA_URL").ok();
452
453        // 设置测试环境变量
454        env::set_var("SPRING_LSP_LOG_LEVEL", "trace");
455        env::set_var("SPRING_LSP_VERBOSE", "true");
456        env::set_var("SPRING_LSP_SCHEMA_URL", "https://test.com/schema.json");
457
458        let config = ServerConfig::default().apply_env_overrides();
459
460        assert_eq!(config.logging.level, "trace");
461        assert!(config.logging.verbose);
462        assert_eq!(config.schema.url, "https://test.com/schema.json");
463
464        // 恢复原始环境变量
465        match original_level {
466            Some(v) => env::set_var("SPRING_LSP_LOG_LEVEL", v),
467            None => env::remove_var("SPRING_LSP_LOG_LEVEL"),
468        }
469        match original_verbose {
470            Some(v) => env::set_var("SPRING_LSP_VERBOSE", v),
471            None => env::remove_var("SPRING_LSP_VERBOSE"),
472        }
473        match original_schema {
474            Some(v) => env::set_var("SPRING_LSP_SCHEMA_URL", v),
475            None => env::remove_var("SPRING_LSP_SCHEMA_URL"),
476        }
477    }
478
479    #[test]
480    fn test_load_from_toml() {
481        let toml_content = r#"
482[logging]
483level = "debug"
484verbose = true
485log_file = "/tmp/spring-lsp.log"
486
487[completion]
488trigger_characters = ["[", ".", "$"]
489
490[diagnostics]
491disabled = ["deprecated_warning", "restful_style"]
492
493[schema]
494url = "https://custom.com/schema.json"
495"#;
496
497        let config: ServerConfig = toml::from_str(toml_content).unwrap();
498
499        assert_eq!(config.logging.level, "debug");
500        assert!(config.logging.verbose);
501        assert_eq!(
502            config.logging.log_file,
503            Some(PathBuf::from("/tmp/spring-lsp.log"))
504        );
505        assert_eq!(config.completion.trigger_characters.len(), 3);
506        assert!(config.diagnostics.is_disabled("deprecated_warning"));
507        assert!(config.diagnostics.is_disabled("restful_style"));
508        assert_eq!(config.schema.url, "https://custom.com/schema.json");
509    }
510
511    #[test]
512    fn test_partial_toml_config() {
513        // 测试部分配置(其他使用默认值)
514        let toml_content = r#"
515[logging]
516level = "warn"
517
518[schema]
519url = "file:///local/schema.json"
520"#;
521
522        let config: ServerConfig = toml::from_str(toml_content).unwrap();
523
524        assert_eq!(config.logging.level, "warn");
525        assert!(!config.logging.verbose); // 默认值
526        assert_eq!(config.completion.trigger_characters.len(), 6); // 默认值
527        assert!(config.diagnostics.disabled.is_empty()); // 默认值
528        assert_eq!(config.schema.url, "file:///local/schema.json");
529    }
530
531    #[test]
532    fn test_config_validation() {
533        let valid_config = ServerConfig::default();
534        assert!(valid_config.validate().is_ok());
535
536        let invalid_config = ServerConfig {
537            logging: LoggingConfig {
538                level: "invalid".to_string(),
539                verbose: false,
540                log_file: None,
541            },
542            ..Default::default()
543        };
544        assert!(invalid_config.validate().is_err());
545    }
546}