Skip to main content

cmake_tidy_config/
lib.rs

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
222/// Load configuration by discovering a supported config file in `directory`.
223///
224/// # Errors
225///
226/// Returns an error if a discovered configuration file cannot be read or parsed.
227pub 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
251/// Load configuration from an explicit file path.
252///
253/// # Errors
254///
255/// Returns an error if the file cannot be read or parsed.
256pub 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}