1mod coverage_excluded;
2
3use std::fmt;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6
7use glob::Pattern;
8use serde::Deserialize;
9use thiserror::Error;
10
11const CONFIG_FILENAMES: [&str; 3] = ["cmake-tidy.toml", ".cmake-tidy.toml", "pyproject.toml"];
12
13#[derive(Debug, Clone, PartialEq, Eq, Default)]
14pub struct Configuration {
15 pub source: Option<PathBuf>,
16 pub main: MainConfiguration,
17 pub lint: LintConfiguration,
18 pub format: FormatConfiguration,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Default)]
22pub struct MainConfiguration {
23 pub exclude: Vec<PathBuf>,
24 pub fix: bool,
25}
26
27impl MainConfiguration {
28 #[must_use]
29 pub fn is_path_excluded(&self, path: &Path) -> bool {
30 self.exclude.iter().any(|excluded| {
31 if excluded.is_absolute() {
32 path.starts_with(excluded)
33 } else {
34 path.starts_with(excluded)
35 || path
36 .strip_prefix(Path::new("."))
37 .is_ok_and(|relative_path| relative_path.starts_with(excluded))
38 }
39 })
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct LintConfiguration {
45 pub select: Vec<RuleSelector>,
46 pub ignore: Vec<RuleSelector>,
47 pub per_file_ignores: Vec<PerFileIgnore>,
48 pub function_name_case: NameCase,
49}
50
51impl Default for LintConfiguration {
52 fn default() -> Self {
53 Self {
54 select: vec![RuleSelector::prefix("E"), RuleSelector::prefix("W")],
55 ignore: Vec::new(),
56 per_file_ignores: Vec::new(),
57 function_name_case: NameCase::default(),
58 }
59 }
60}
61
62impl LintConfiguration {
63 #[must_use]
64 pub fn is_rule_enabled(&self, code: &str) -> bool {
65 let selected = self.select.iter().any(|selector| selector.matches(code));
66 let ignored = self.ignore.iter().any(|selector| selector.matches(code));
67 selected && !ignored
68 }
69
70 #[must_use]
71 pub fn is_rule_enabled_for_path(&self, path: &Path, code: &str) -> bool {
72 self.is_rule_enabled(code)
73 && !self
74 .per_file_ignores
75 .iter()
76 .any(|ignore| ignore.matches(path, code))
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct PerFileIgnore {
82 pub pattern: String,
83 pub selectors: Vec<RuleSelector>,
84}
85
86impl PerFileIgnore {
87 #[must_use]
88 pub fn matches(&self, path: &Path, code: &str) -> bool {
89 let normalized = path.to_string_lossy().replace('\\', "/");
90 Pattern::new(&self.pattern).is_ok_and(|pattern| pattern.matches(&normalized))
91 && self.selectors.iter().any(|selector| selector.matches(code))
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
96#[serde(rename_all = "kebab-case")]
97pub enum NameCase {
98 #[default]
99 Lower,
100 Upper,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct FormatConfiguration {
105 pub final_newline: bool,
106 pub max_blank_lines: usize,
107 pub space_before_paren: bool,
108}
109
110impl Default for FormatConfiguration {
111 fn default() -> Self {
112 Self {
113 final_newline: true,
114 max_blank_lines: 1,
115 space_before_paren: false,
116 }
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum RuleSelector {
122 All,
123 Prefix(String),
124}
125
126impl RuleSelector {
127 #[must_use]
128 pub fn prefix(prefix: impl Into<String>) -> Self {
129 Self::Prefix(prefix.into())
130 }
131
132 #[must_use]
133 pub fn matches(&self, code: &str) -> bool {
134 match self {
135 Self::All => true,
136 Self::Prefix(prefix) => code.starts_with(prefix),
137 }
138 }
139}
140
141impl fmt::Display for RuleSelector {
142 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143 match self {
144 Self::All => formatter.write_str("ALL"),
145 Self::Prefix(prefix) => formatter.write_str(prefix),
146 }
147 }
148}
149
150impl FromStr for RuleSelector {
151 type Err = ConfigError;
152
153 fn from_str(value: &str) -> Result<Self, Self::Err> {
154 if value == "ALL" {
155 return Ok(Self::All);
156 }
157
158 if value.is_empty()
159 || !value
160 .chars()
161 .all(|character| character.is_ascii_uppercase() || character.is_ascii_digit())
162 {
163 return Err(ConfigError::InvalidRuleSelector(value.to_owned()));
164 }
165
166 Ok(Self::Prefix(value.to_owned()))
167 }
168}
169
170impl<'de> Deserialize<'de> for RuleSelector {
171 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
172 where
173 D: serde::Deserializer<'de>,
174 {
175 let value = String::deserialize(deserializer)?;
176 Self::from_str(&value).map_err(serde::de::Error::custom)
177 }
178}
179
180#[derive(Debug, Error)]
181pub enum ConfigError {
182 #[error("failed to read configuration file `{path}`")]
183 ReadFile {
184 path: PathBuf,
185 #[source]
186 source: std::io::Error,
187 },
188 #[error("failed to parse configuration file `{path}`")]
189 ParseToml {
190 path: PathBuf,
191 #[source]
192 source: toml::de::Error,
193 },
194 #[error("`pyproject.toml` does not contain a `[tool.cmake-tidy]` section")]
195 MissingPyprojectSection,
196 #[error("invalid rule selector `{0}`")]
197 InvalidRuleSelector(String),
198 #[error("invalid per-file-ignore pattern `{pattern}`")]
199 InvalidPerFileIgnorePattern {
200 pattern: String,
201 #[source]
202 source: glob::PatternError,
203 },
204}
205
206#[must_use]
207pub fn find_configuration(directory: &Path) -> Option<PathBuf> {
208 for filename in CONFIG_FILENAMES {
209 let path = directory.join(filename);
210 if !path.is_file() {
211 continue;
212 }
213
214 if filename != "pyproject.toml" || pyproject_has_section(&path).ok().flatten().is_some() {
215 return Some(path);
216 }
217 }
218
219 None
220}
221
222pub fn load_configuration(directory: &Path) -> Result<Configuration, ConfigError> {
228 for filename in CONFIG_FILENAMES {
229 let path = directory.join(filename);
230 if !path.is_file() {
231 continue;
232 }
233
234 if filename == "pyproject.toml" {
235 if pyproject_has_section(&path)?.is_none() {
236 continue;
237 }
238 let content = read_file(&path)?;
239 let raw = parse_pyproject(&content, &path)?;
240 return normalize_configuration(raw, Some(path));
241 }
242
243 let content = read_file(&path)?;
244 let raw = parse_standard_file(&content, &path)?;
245 return normalize_configuration(raw, Some(path));
246 }
247
248 Ok(Configuration::default())
249}
250
251pub fn load_configuration_from_file(path: &Path) -> Result<Configuration, ConfigError> {
257 let content = read_file(path)?;
258 let raw = if path
259 .file_name()
260 .is_some_and(|filename| filename == "pyproject.toml")
261 {
262 parse_pyproject(&content, path)?
263 } else {
264 parse_standard_file(&content, path)?
265 };
266
267 normalize_configuration(raw, Some(path.to_path_buf()))
268}
269
270fn read_file(path: &Path) -> Result<String, ConfigError> {
271 coverage_excluded::read_file(path)
272}
273
274fn parse_standard_file(content: &str, path: &Path) -> Result<RawConfiguration, ConfigError> {
275 toml::from_str(content).map_err(|source| ConfigError::ParseToml {
276 path: path.to_path_buf(),
277 source,
278 })
279}
280
281fn parse_pyproject(content: &str, path: &Path) -> Result<RawConfiguration, ConfigError> {
282 let pyproject =
283 toml::from_str::<Pyproject>(content).map_err(|source| ConfigError::ParseToml {
284 path: path.to_path_buf(),
285 source,
286 })?;
287
288 pyproject
289 .tool
290 .and_then(|tool| tool.cmake_tidy)
291 .ok_or(ConfigError::MissingPyprojectSection)
292}
293
294fn pyproject_has_section(path: &Path) -> Result<Option<RawConfiguration>, ConfigError> {
295 let content = read_file(path)?;
296 match parse_pyproject(&content, path) {
297 Ok(configuration) => Ok(Some(configuration)),
298 Err(ConfigError::MissingPyprojectSection) => Ok(None),
299 Err(error) => Err(error),
300 }
301}
302
303fn normalize_configuration(
304 raw: RawConfiguration,
305 source: Option<PathBuf>,
306) -> Result<Configuration, ConfigError> {
307 let per_file_ignores = raw
308 .lint
309 .per_file_ignores
310 .unwrap_or_default()
311 .into_iter()
312 .map(|(pattern, selectors)| {
313 Pattern::new(&pattern).map_err(|source| ConfigError::InvalidPerFileIgnorePattern {
314 pattern: pattern.clone(),
315 source,
316 })?;
317 Ok(PerFileIgnore { pattern, selectors })
318 })
319 .collect::<Result<Vec<_>, ConfigError>>()?;
320
321 Ok(Configuration {
322 source,
323 main: MainConfiguration {
324 exclude: raw.exclude.unwrap_or_default(),
325 fix: raw.fix.unwrap_or_default(),
326 },
327 lint: LintConfiguration {
328 select: raw
329 .lint
330 .select
331 .unwrap_or_else(|| LintConfiguration::default().select),
332 ignore: raw.lint.ignore.unwrap_or_default(),
333 per_file_ignores,
334 function_name_case: raw.lint.function_name_case.unwrap_or_default(),
335 },
336 format: raw.format.into(),
337 })
338}
339
340#[derive(Debug, Deserialize, Default)]
341struct RawConfiguration {
342 exclude: Option<Vec<PathBuf>>,
343 fix: Option<bool>,
344 #[serde(default)]
345 lint: RawLintConfiguration,
346 #[serde(default)]
347 format: RawFormatConfiguration,
348}
349
350#[derive(Debug, Deserialize, Default)]
351#[serde(rename_all = "kebab-case")]
352struct RawLintConfiguration {
353 select: Option<Vec<RuleSelector>>,
354 ignore: Option<Vec<RuleSelector>>,
355 per_file_ignores: Option<std::collections::BTreeMap<String, Vec<RuleSelector>>>,
356 function_name_case: Option<NameCase>,
357}
358
359#[derive(Debug, Deserialize, Default)]
360#[serde(rename_all = "kebab-case")]
361struct RawFormatConfiguration {
362 final_newline: Option<bool>,
363 max_blank_lines: Option<usize>,
364 space_before_paren: Option<bool>,
365}
366
367impl From<RawFormatConfiguration> for FormatConfiguration {
368 fn from(value: RawFormatConfiguration) -> Self {
369 let defaults = Self::default();
370 Self {
371 final_newline: value.final_newline.unwrap_or(defaults.final_newline),
372 max_blank_lines: value.max_blank_lines.unwrap_or(defaults.max_blank_lines),
373 space_before_paren: value
374 .space_before_paren
375 .unwrap_or(defaults.space_before_paren),
376 }
377 }
378}
379
380#[derive(Debug, Deserialize)]
381struct Pyproject {
382 tool: Option<PyprojectTool>,
383}
384
385#[derive(Debug, Deserialize)]
386struct PyprojectTool {
387 #[serde(rename = "cmake-tidy")]
388 cmake_tidy: Option<RawConfiguration>,
389}
390
391#[cfg(test)]
392mod tests {
393 use std::fs;
394 use std::path::{Path, PathBuf};
395 use std::sync::atomic::{AtomicU64, Ordering};
396 use std::time::{SystemTime, UNIX_EPOCH};
397
398 use super::{
399 ConfigError, LintConfiguration, NameCase, RuleSelector, find_configuration,
400 load_configuration, load_configuration_from_file,
401 };
402
403 static NEXT_TEMP_DIR: AtomicU64 = AtomicU64::new(0);
404
405 #[test]
406 fn defaults_select_error_and_warning_rules() {
407 let config = load_configuration(&unique_temp_dir()).expect("default config should load");
408 assert!(config.lint.is_rule_enabled("E001"));
409 assert!(config.lint.is_rule_enabled("W302"));
410 assert!(!config.lint.is_rule_enabled("B900"));
411 }
412
413 #[test]
414 fn parses_standard_toml_configuration() {
415 let directory = create_temp_dir();
416 write_file(
417 &directory.join("cmake-tidy.toml"),
418 "exclude = [\"build\", \"generated/output.cmake\"]\n[lint]\nselect = [\"ALL\"]\nignore = [\"W2\"]\n",
419 );
420
421 let config = load_configuration(&directory).expect("config should parse");
422 assert_eq!(config.source, Some(directory.join("cmake-tidy.toml")));
423 assert_eq!(
424 config.main.exclude,
425 vec![
426 PathBuf::from("build"),
427 PathBuf::from("generated/output.cmake")
428 ]
429 );
430 assert!(config.lint.is_rule_enabled("E001"));
431 assert!(!config.lint.is_rule_enabled("W201"));
432 assert!(config.lint.is_rule_enabled("W301"));
433 assert!(
434 config
435 .lint
436 .is_rule_enabled_for_path(Path::new("src/CMakeLists.txt"), "W301")
437 );
438 assert_eq!(config.lint.function_name_case, NameCase::Lower);
439 assert!(!config.main.fix);
440 }
441
442 #[test]
443 fn parses_hidden_toml_configuration() {
444 let directory = create_temp_dir();
445 write_file(
446 &directory.join(".cmake-tidy.toml"),
447 "fix = true\n[lint]\nselect = [\"W301\"]\nfunction-name-case = \"upper\"\n\n[lint.per-file-ignores]\n\"tests/**\" = [\"W301\"]\n\n[format]\nmax-blank-lines = 2\n",
448 );
449
450 let config = load_configuration(&directory).expect("hidden config should parse");
451 assert_eq!(config.source, Some(directory.join(".cmake-tidy.toml")));
452 assert!(config.lint.is_rule_enabled("W301"));
453 assert!(!config.lint.is_rule_enabled("W302"));
454 assert_eq!(config.format.max_blank_lines, 2);
455 assert!(!config.format.space_before_paren);
456 assert!(config.main.fix);
457 assert_eq!(config.lint.function_name_case, NameCase::Upper);
458 assert!(
459 !config
460 .lint
461 .is_rule_enabled_for_path(Path::new("tests/example/CMakeLists.txt"), "W301")
462 );
463 }
464
465 #[test]
466 fn parses_pyproject_configuration() {
467 let directory = create_temp_dir();
468 write_file(
469 &directory.join("pyproject.toml"),
470 "[tool.cmake-tidy]\nexclude = [\"third_party\"]\nfix = true\n\n[tool.cmake-tidy.lint]\nselect = [\"W3\"]\nignore = [\"W302\"]\nfunction-name-case = \"upper\"\n\n[tool.cmake-tidy.lint.per-file-ignores]\n\"examples/**\" = [\"W301\"]\n\n[tool.cmake-tidy.format]\nfinal-newline = false\nspace-before-paren = true\n",
471 );
472
473 let config = load_configuration(&directory).expect("pyproject config should parse");
474 assert_eq!(config.source, Some(directory.join("pyproject.toml")));
475 assert_eq!(config.main.exclude, vec![PathBuf::from("third_party")]);
476 assert!(config.main.fix);
477 assert!(config.lint.is_rule_enabled("W301"));
478 assert!(!config.lint.is_rule_enabled("W302"));
479 assert!(!config.lint.is_rule_enabled("E001"));
480 assert_eq!(config.lint.function_name_case, NameCase::Upper);
481 assert!(
482 !config
483 .lint
484 .is_rule_enabled_for_path(Path::new("examples/CMakeLists.txt"), "W301")
485 );
486 assert!(!config.format.final_newline);
487 assert!(config.format.space_before_paren);
488 }
489
490 #[test]
491 fn discovers_configuration_in_precedence_order() {
492 let directory = create_temp_dir();
493 write_file(
494 &directory.join("pyproject.toml"),
495 "[tool.cmake-tidy.lint]\nselect = [\"ALL\"]\n",
496 );
497 write_file(
498 &directory.join(".cmake-tidy.toml"),
499 "[lint]\nselect = [\"W\"]\n",
500 );
501 write_file(
502 &directory.join("cmake-tidy.toml"),
503 "[lint]\nselect = [\"E\"]\n",
504 );
505
506 assert_eq!(
507 find_configuration(&directory),
508 Some(directory.join("cmake-tidy.toml"))
509 );
510
511 let config = load_configuration(&directory).expect("preferred config should parse");
512 assert!(config.lint.is_rule_enabled("E001"));
513 assert!(!config.lint.is_rule_enabled("W301"));
514 }
515
516 #[test]
517 fn explicit_pyproject_without_section_errors() {
518 let directory = create_temp_dir();
519 let path = directory.join("pyproject.toml");
520 write_file(&path, "[tool.other]\nvalue = true\n");
521
522 let error = load_configuration_from_file(&path)
523 .expect_err("pyproject should require tool.cmake-tidy");
524 assert!(matches!(error, ConfigError::MissingPyprojectSection));
525 }
526
527 #[test]
528 fn pyproject_without_tool_section_is_ignored_during_discovery() {
529 let directory = create_temp_dir();
530 write_file(
531 &directory.join("pyproject.toml"),
532 "[tool.other]\nvalue = true\n",
533 );
534
535 let config =
536 load_configuration(&directory).expect("missing tool section should be ignored");
537 assert_eq!(config.source, None);
538 assert!(config.lint.is_rule_enabled("E001"));
539 }
540
541 #[test]
542 fn invalid_selector_is_rejected() {
543 let directory = create_temp_dir();
544 write_file(
545 &directory.join("cmake-tidy.toml"),
546 "[lint]\nselect = [\"e\"]\n",
547 );
548
549 let error = load_configuration(&directory).expect_err("invalid selector should fail");
550 assert!(matches!(error, ConfigError::ParseToml { .. }));
551 }
552
553 #[test]
554 fn ignore_overrides_selected_rules() {
555 let lint = LintConfiguration {
556 select: vec![RuleSelector::All],
557 ignore: vec![RuleSelector::prefix("W2")],
558 per_file_ignores: Vec::new(),
559 function_name_case: NameCase::Lower,
560 };
561
562 assert!(lint.is_rule_enabled("E001"));
563 assert!(!lint.is_rule_enabled("W201"));
564 }
565
566 #[test]
567 fn invalid_per_file_ignore_pattern_is_rejected() {
568 let directory = create_temp_dir();
569 write_file(
570 &directory.join("cmake-tidy.toml"),
571 "[lint.per-file-ignores]\n\"[\" = [\"W301\"]\n",
572 );
573
574 let error = load_configuration(&directory).expect_err("invalid pattern should fail");
575 assert!(matches!(
576 error,
577 ConfigError::InvalidPerFileIgnorePattern { .. }
578 ));
579 }
580
581 #[test]
582 fn excludes_match_relative_prefixes() {
583 let directory = create_temp_dir();
584 write_file(
585 &directory.join("cmake-tidy.toml"),
586 "exclude = [\"build\", \"vendor/generated.cmake\"]\n",
587 );
588
589 let config = load_configuration(&directory).expect("exclude config should parse");
590 assert!(
591 config
592 .main
593 .is_path_excluded(Path::new("build/CMakeLists.txt"))
594 );
595 assert!(
596 config
597 .main
598 .is_path_excluded(Path::new("./build/output/config.cmake"))
599 );
600 assert!(
601 config
602 .main
603 .is_path_excluded(Path::new("vendor/generated.cmake"))
604 );
605 assert!(
606 !config
607 .main
608 .is_path_excluded(Path::new("src/CMakeLists.txt"))
609 );
610 }
611
612 fn create_temp_dir() -> PathBuf {
613 let directory = unique_temp_dir();
614 if directory.exists() {
615 fs::remove_dir_all(&directory).expect("stale temporary directory should be removable");
616 }
617 fs::create_dir_all(&directory).expect("temporary directory should be created");
618 directory
619 }
620
621 fn write_file(path: &Path, content: &str) {
622 fs::write(path, content).expect("temporary file should be written");
623 }
624
625 fn unique_temp_dir() -> PathBuf {
626 let timestamp = SystemTime::now()
627 .duration_since(UNIX_EPOCH)
628 .expect("system clock should be after UNIX_EPOCH")
629 .as_nanos();
630 let sequence = NEXT_TEMP_DIR.fetch_add(1, Ordering::Relaxed);
631 std::env::temp_dir().join(format!(
632 "cmake-tidy-config-{}-{timestamp}-{sequence}",
633 std::process::id(),
634 ))
635 }
636}