pytest_language_server/config/
mod.rs1use glob::Pattern;
6use serde::Deserialize;
7use std::path::Path;
8use tracing::{debug, warn};
9
10#[derive(Debug, Clone, Default)]
12pub struct Config {
13 pub exclude: Vec<Pattern>,
15
16 pub disabled_diagnostics: Vec<String>,
18
19 #[allow(dead_code)] pub fixture_paths: Vec<String>,
22
23 #[allow(dead_code)] pub skip_plugins: Vec<String>,
26}
27
28#[derive(Debug, Deserialize, Default)]
30struct RawConfig {
31 #[serde(default)]
32 exclude: Vec<String>,
33
34 #[serde(default)]
35 disabled_diagnostics: Vec<String>,
36
37 #[serde(default)]
38 fixture_paths: Vec<String>,
39
40 #[serde(default)]
41 skip_plugins: Vec<String>,
42}
43
44#[derive(Debug, Deserialize)]
46struct PyProjectToml {
47 tool: Option<Tool>,
48}
49
50#[derive(Debug, Deserialize)]
51struct Tool {
52 #[serde(rename = "pytest-language-server")]
53 pytest_language_server: Option<RawConfig>,
54}
55
56impl Config {
57 pub fn load(workspace_root: &Path) -> Self {
60 let pyproject_path = workspace_root.join("pyproject.toml");
61
62 if !pyproject_path.exists() {
63 debug!(
64 "No pyproject.toml found at {:?}, using defaults",
65 pyproject_path
66 );
67 return Self::default();
68 }
69
70 match std::fs::read_to_string(&pyproject_path) {
71 Ok(content) => Self::parse(&content, &pyproject_path),
72 Err(e) => {
73 warn!("Failed to read pyproject.toml: {}", e);
74 Self::default()
75 }
76 }
77 }
78
79 fn parse(content: &str, path: &Path) -> Self {
81 let parsed: PyProjectToml = match toml::from_str(content) {
82 Ok(p) => p,
83 Err(e) => {
84 warn!("Failed to parse pyproject.toml at {:?}: {}", path, e);
85 return Self::default();
86 }
87 };
88
89 let raw = parsed
90 .tool
91 .and_then(|t| t.pytest_language_server)
92 .unwrap_or_default();
93
94 Self::from_raw(raw, path)
95 }
96
97 fn from_raw(raw: RawConfig, path: &Path) -> Self {
99 let exclude: Vec<Pattern> = raw
101 .exclude
102 .into_iter()
103 .filter_map(|pattern| match Pattern::new(&pattern) {
104 Ok(p) => Some(p),
105 Err(e) => {
106 warn!("Invalid exclude pattern '{}' in {:?}: {}", pattern, path, e);
107 None
108 }
109 })
110 .collect();
111
112 let valid_diagnostics = [
114 "undeclared-fixture",
115 "scope-mismatch",
116 "circular-dependency",
117 ];
118 let disabled_diagnostics: Vec<String> = raw
119 .disabled_diagnostics
120 .into_iter()
121 .filter(|code| {
122 if valid_diagnostics.contains(&code.as_str()) {
123 true
124 } else {
125 warn!(
126 "Unknown diagnostic code '{}' in {:?}, valid codes are: {:?}",
127 code, path, valid_diagnostics
128 );
129 false
130 }
131 })
132 .collect();
133
134 debug!(
135 "Loaded config from {:?}: {} exclude patterns, {} disabled diagnostics",
136 path,
137 exclude.len(),
138 disabled_diagnostics.len()
139 );
140
141 Self {
142 exclude,
143 disabled_diagnostics,
144 fixture_paths: raw.fixture_paths,
145 skip_plugins: raw.skip_plugins,
146 }
147 }
148
149 pub fn is_diagnostic_disabled(&self, code: &str) -> bool {
151 self.disabled_diagnostics.iter().any(|d| d == code)
152 }
153
154 #[allow(dead_code)] pub fn should_exclude(&self, path: &Path) -> bool {
157 let path_str = path.to_string_lossy();
158 self.exclude
159 .iter()
160 .any(|pattern| pattern.matches(&path_str))
161 }
162
163 #[allow(dead_code)] pub fn should_skip_plugin(&self, plugin_name: &str) -> bool {
166 self.skip_plugins.iter().any(|p| p == plugin_name)
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn test_parse_empty_config() {
176 let content = r#"
177[project]
178name = "myproject"
179"#;
180 let config = Config::parse(content, Path::new("pyproject.toml"));
181 assert!(config.exclude.is_empty());
182 assert!(config.disabled_diagnostics.is_empty());
183 assert!(config.fixture_paths.is_empty());
184 assert!(config.skip_plugins.is_empty());
185 }
186
187 #[test]
188 fn test_parse_full_config() {
189 let content = r#"
190[project]
191name = "myproject"
192
193[tool.pytest-language-server]
194exclude = ["build", "dist/**", ".tox"]
195disabled_diagnostics = ["undeclared-fixture"]
196fixture_paths = ["fixtures/", "shared/fixtures/"]
197skip_plugins = ["pytest-xdist"]
198"#;
199 let config = Config::parse(content, Path::new("pyproject.toml"));
200 assert_eq!(config.exclude.len(), 3);
201 assert_eq!(config.disabled_diagnostics, vec!["undeclared-fixture"]);
202 assert_eq!(config.fixture_paths, vec!["fixtures/", "shared/fixtures/"]);
203 assert_eq!(config.skip_plugins, vec!["pytest-xdist"]);
204 }
205
206 #[test]
207 fn test_parse_partial_config() {
208 let content = r#"
209[tool.pytest-language-server]
210exclude = ["build"]
211"#;
212 let config = Config::parse(content, Path::new("pyproject.toml"));
213 assert_eq!(config.exclude.len(), 1);
214 assert!(config.disabled_diagnostics.is_empty());
215 }
216
217 #[test]
218 fn test_invalid_glob_pattern_skipped() {
219 let content = r#"
220[tool.pytest-language-server]
221exclude = ["valid", "[invalid", "also_valid"]
222"#;
223 let config = Config::parse(content, Path::new("pyproject.toml"));
224 assert_eq!(config.exclude.len(), 2);
226 }
227
228 #[test]
229 fn test_invalid_diagnostic_code_skipped() {
230 let content = r#"
231[tool.pytest-language-server]
232disabled_diagnostics = ["undeclared-fixture", "invalid-code", "scope-mismatch"]
233"#;
234 let config = Config::parse(content, Path::new("pyproject.toml"));
235 assert_eq!(config.disabled_diagnostics.len(), 2);
237 assert!(config
238 .disabled_diagnostics
239 .contains(&"undeclared-fixture".to_string()));
240 assert!(config
241 .disabled_diagnostics
242 .contains(&"scope-mismatch".to_string()));
243 }
244
245 #[test]
246 fn test_is_diagnostic_disabled() {
247 let content = r#"
248[tool.pytest-language-server]
249disabled_diagnostics = ["undeclared-fixture"]
250"#;
251 let config = Config::parse(content, Path::new("pyproject.toml"));
252 assert!(config.is_diagnostic_disabled("undeclared-fixture"));
253 assert!(!config.is_diagnostic_disabled("scope-mismatch"));
254 }
255
256 #[test]
257 fn test_should_exclude() {
258 let content = r#"
259[tool.pytest-language-server]
260exclude = ["build/**", "dist"]
261"#;
262 let config = Config::parse(content, Path::new("pyproject.toml"));
263 assert!(config.should_exclude(Path::new("build/output/file.py")));
264 assert!(config.should_exclude(Path::new("dist")));
265 assert!(!config.should_exclude(Path::new("src/main.py")));
266 }
267
268 #[test]
269 fn test_should_skip_plugin() {
270 let content = r#"
271[tool.pytest-language-server]
272skip_plugins = ["pytest-xdist", "pytest-cov"]
273"#;
274 let config = Config::parse(content, Path::new("pyproject.toml"));
275 assert!(config.should_skip_plugin("pytest-xdist"));
276 assert!(config.should_skip_plugin("pytest-cov"));
277 assert!(!config.should_skip_plugin("pytest-mock"));
278 }
279
280 #[test]
281 fn test_invalid_toml_returns_default() {
282 let content = "this is not valid toml [[[";
283 let config = Config::parse(content, Path::new("pyproject.toml"));
284 assert!(config.exclude.is_empty());
285 assert!(config.disabled_diagnostics.is_empty());
286 }
287
288 #[test]
289 fn test_default_config() {
290 let config = Config::default();
291 assert!(config.exclude.is_empty());
292 assert!(config.disabled_diagnostics.is_empty());
293 assert!(config.fixture_paths.is_empty());
294 assert!(config.skip_plugins.is_empty());
295 }
296}