Skip to main content

cmake_tidy_check/
lib.rs

1use std::fmt;
2use std::str::FromStr;
3
4use cmake_tidy_ast::{CommandInvocation, File, Statement, TextRange};
5use cmake_tidy_config::{NameCase, RuleSelector};
6use cmake_tidy_lexer::{Token, TokenKind};
7use cmake_tidy_parser::{ParseError, parse_file};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub struct CheckOptions {
11    pub project_root: bool,
12    pub function_name_case: NameCase,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct CheckResult {
17    pub diagnostics: Vec<Diagnostic>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Diagnostic {
22    pub code: RuleCode,
23    pub message: String,
24    pub range: TextRange,
25    pub fix: Option<Edit>,
26}
27
28impl Diagnostic {
29    #[must_use]
30    pub fn new(code: RuleCode, message: impl Into<String>, range: TextRange) -> Self {
31        Self {
32            code,
33            message: message.into(),
34            range,
35            fix: None,
36        }
37    }
38
39    #[must_use]
40    pub fn with_fix(mut self, fix: Edit) -> Self {
41        self.fix = Some(fix);
42        self
43    }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Edit {
48    pub range: TextRange,
49    pub replacement: String,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
53pub enum RuleCode {
54    E001,
55    N001,
56    W201,
57    W202,
58    W203,
59    W301,
60    W302,
61}
62
63impl fmt::Display for RuleCode {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        let code = match self {
66            Self::E001 => "E001",
67            Self::N001 => "N001",
68            Self::W201 => "W201",
69            Self::W202 => "W202",
70            Self::W203 => "W203",
71            Self::W301 => "W301",
72            Self::W302 => "W302",
73        };
74
75        formatter.write_str(code)
76    }
77}
78
79#[must_use]
80pub fn check_source(source: &str, options: &CheckOptions) -> CheckResult {
81    let parsed = parse_file(source);
82    let mut diagnostics = Vec::new();
83
84    diagnostics.extend(parsed.errors.iter().map(parse_error_diagnostic));
85    diagnostics.extend(check_file(&parsed.syntax, *options));
86    let noqa = NoqaDirectives::from_tokens(source, &parsed.tokens);
87    diagnostics.retain(|diagnostic| !noqa.suppresses(diagnostic));
88    diagnostics.sort_by_key(|diagnostic| {
89        (
90            diagnostic.range.start,
91            diagnostic.range.end,
92            diagnostic.code,
93        )
94    });
95
96    CheckResult { diagnostics }
97}
98
99fn parse_error_diagnostic(error: &ParseError) -> Diagnostic {
100    Diagnostic::new(RuleCode::E001, error.message.clone(), error.range)
101}
102
103fn check_file(file: &File, options: CheckOptions) -> Vec<Diagnostic> {
104    let commands = file
105        .items
106        .iter()
107        .map(|statement| match statement {
108            Statement::Command(command) => command,
109        })
110        .collect::<Vec<_>>();
111
112    let mut diagnostics = Vec::new();
113    check_function_name_case(&commands, options.function_name_case, &mut diagnostics);
114    check_empty_project_calls(&commands, &mut diagnostics);
115
116    if options.project_root {
117        check_project_root_commands(file, &commands, &mut diagnostics);
118    }
119
120    diagnostics
121}
122
123fn check_function_name_case(
124    commands: &[&CommandInvocation],
125    naming: NameCase,
126    diagnostics: &mut Vec<Diagnostic>,
127) {
128    for command in commands {
129        let expected = match naming {
130            NameCase::Lower => command.name.text.to_ascii_lowercase(),
131            NameCase::Upper => command.name.text.to_ascii_uppercase(),
132        };
133
134        if command.name.text == expected {
135            continue;
136        }
137
138        let description = match naming {
139            NameCase::Lower => "lowercase",
140            NameCase::Upper => "uppercase",
141        };
142        diagnostics.push(
143            Diagnostic::new(
144                RuleCode::N001,
145                format!("function names should use {description} style"),
146                command.name.range,
147            )
148            .with_fix(Edit {
149                range: command.name.range,
150                replacement: expected,
151            }),
152        );
153    }
154}
155
156#[must_use]
157pub fn apply_fixes(source: &str, diagnostics: &[Diagnostic]) -> Option<String> {
158    let mut edits = diagnostics
159        .iter()
160        .filter_map(|diagnostic| diagnostic.fix.as_ref())
161        .collect::<Vec<_>>();
162    if edits.is_empty() {
163        return None;
164    }
165
166    edits.sort_by_key(|edit| (edit.range.start, edit.range.end));
167
168    let mut output = String::with_capacity(source.len());
169    let mut offset = 0;
170
171    for edit in edits {
172        if edit.range.start < offset {
173            continue;
174        }
175        output.push_str(&source[offset..edit.range.start]);
176        output.push_str(&edit.replacement);
177        offset = edit.range.end;
178    }
179
180    output.push_str(&source[offset..]);
181    (output != source).then_some(output)
182}
183
184fn check_empty_project_calls(commands: &[&CommandInvocation], diagnostics: &mut Vec<Diagnostic>) {
185    for command in commands {
186        if command.name.text.eq_ignore_ascii_case("project") && command.arguments.is_empty() {
187            diagnostics.push(Diagnostic::new(
188                RuleCode::W203,
189                "`project()` should declare at least a project name",
190                command.name.range,
191            ));
192        }
193    }
194}
195
196fn check_project_root_commands(
197    file: &File,
198    commands: &[&CommandInvocation],
199    diagnostics: &mut Vec<Diagnostic>,
200) {
201    let cmake_minimum_required = find_commands(commands, "cmake_minimum_required");
202    let project_commands = find_commands(commands, "project");
203
204    if cmake_minimum_required.is_empty() {
205        diagnostics.push(Diagnostic::new(
206            RuleCode::W301,
207            "missing `cmake_minimum_required()` in the project root `CMakeLists.txt`",
208            file.range,
209        ));
210    } else {
211        for duplicate in &cmake_minimum_required[1..] {
212            diagnostics.push(Diagnostic::new(
213                RuleCode::W201,
214                "duplicate `cmake_minimum_required()` declaration",
215                duplicate.name.range,
216            ));
217        }
218    }
219
220    if project_commands.is_empty() {
221        diagnostics.push(Diagnostic::new(
222            RuleCode::W302,
223            "missing `project()` in the project root `CMakeLists.txt`",
224            file.range,
225        ));
226    } else {
227        for duplicate in &project_commands[1..] {
228            diagnostics.push(Diagnostic::new(
229                RuleCode::W202,
230                "duplicate `project()` declaration",
231                duplicate.name.range,
232            ));
233        }
234    }
235}
236
237fn find_commands<'a>(commands: &[&'a CommandInvocation], name: &str) -> Vec<&'a CommandInvocation> {
238    commands
239        .iter()
240        .copied()
241        .filter(|command| command.name.text.eq_ignore_ascii_case(name))
242        .collect()
243}
244
245#[derive(Debug)]
246struct NoqaDirectives {
247    file: Option<Directive>,
248    line_index: LineIndex,
249    line_directives: Vec<(usize, Directive)>,
250}
251
252impl NoqaDirectives {
253    fn from_tokens(source: &str, tokens: &[Token]) -> Self {
254        let line_index = LineIndex::new(source);
255        let mut directives = Self {
256            file: leading_file_directive(tokens),
257            line_index,
258            line_directives: Vec::new(),
259        };
260
261        for token in tokens {
262            let TokenKind::Comment(comment) = &token.kind else {
263                continue;
264            };
265
266            if let Some(directive) = parse_line_directive(comment) {
267                directives.line_directives.push((
268                    directives.line_index.line_number(token.range.start),
269                    directive,
270                ));
271            }
272        }
273
274        directives
275    }
276
277    fn suppresses(&self, diagnostic: &Diagnostic) -> bool {
278        let code = diagnostic.code.to_string();
279
280        if self
281            .file
282            .as_ref()
283            .is_some_and(|directive| directive.matches(&code))
284        {
285            return true;
286        }
287
288        let line = self.line_index.line_number(diagnostic.range.start);
289        self.line_directives
290            .iter()
291            .any(|(directive_line, directive)| *directive_line == line && directive.matches(&code))
292    }
293}
294
295#[derive(Debug)]
296struct Directive {
297    selectors: Vec<RuleSelector>,
298}
299
300impl Directive {
301    fn matches(&self, code: &str) -> bool {
302        self.selectors.iter().any(|selector| selector.matches(code))
303    }
304}
305
306fn leading_file_directive(tokens: &[Token]) -> Option<Directive> {
307    for token in tokens {
308        match &token.kind {
309            TokenKind::Comment(comment) => {
310                if let Some(directive) = parse_file_directive(comment) {
311                    return Some(directive);
312                }
313            }
314            TokenKind::Whitespace(_) | TokenKind::Newline => {}
315            _ => break,
316        }
317    }
318
319    None
320}
321
322fn parse_file_directive(comment: &str) -> Option<Directive> {
323    let body = comment.strip_prefix('#')?.trim();
324    parse_noqa_directive(body)
325}
326
327fn parse_line_directive(comment: &str) -> Option<Directive> {
328    let body = comment.strip_prefix('#')?.trim();
329    parse_noqa_directive(body)
330}
331
332fn parse_noqa_directive(value: &str) -> Option<Directive> {
333    let directive = value.strip_prefix("noqa")?.trim();
334    if directive.is_empty() {
335        return Some(Directive {
336            selectors: vec![RuleSelector::All],
337        });
338    }
339
340    let selectors = directive
341        .strip_prefix(':')?
342        .split(',')
343        .map(str::trim)
344        .map(RuleSelector::from_str)
345        .collect::<Result<Vec<_>, _>>()
346        .ok()?;
347    if selectors.is_empty() {
348        return None;
349    }
350
351    Some(Directive { selectors })
352}
353
354#[derive(Debug)]
355struct LineIndex {
356    line_starts: Vec<usize>,
357}
358
359impl LineIndex {
360    fn new(source: &str) -> Self {
361        let mut line_starts = vec![0];
362        for (index, character) in source.char_indices() {
363            if character == '\n' {
364                line_starts.push(index + 1);
365            }
366        }
367
368        Self { line_starts }
369    }
370
371    fn line_number(&self, offset: usize) -> usize {
372        match self.line_starts.binary_search(&offset) {
373            Ok(index) => index + 1,
374            Err(index) => index,
375        }
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use cmake_tidy_config::NameCase;
382
383    use super::{CheckOptions, RuleCode, apply_fixes, check_source};
384
385    fn diagnostic_codes(source: &str, options: CheckOptions) -> Vec<RuleCode> {
386        check_source(source, &options)
387            .diagnostics
388            .into_iter()
389            .map(|diagnostic| diagnostic.code)
390            .collect()
391    }
392
393    #[test]
394    fn reports_parse_errors() {
395        let codes = diagnostic_codes(
396            "project(example",
397            CheckOptions {
398                project_root: true,
399                function_name_case: NameCase::Lower,
400            },
401        );
402        assert_eq!(codes.len(), 2);
403        assert!(codes.contains(&RuleCode::E001));
404        assert!(codes.contains(&RuleCode::W301));
405    }
406
407    #[test]
408    fn reports_missing_root_project_commands() {
409        let codes = diagnostic_codes(
410            "add_library(example STATIC main.cpp)\n",
411            CheckOptions {
412                project_root: true,
413                function_name_case: NameCase::Lower,
414            },
415        );
416        assert_eq!(codes, vec![RuleCode::W301, RuleCode::W302]);
417    }
418
419    #[test]
420    fn reports_duplicate_root_commands() {
421        let codes = diagnostic_codes(
422            "cmake_minimum_required(VERSION 3.30)\ncmake_minimum_required(VERSION 3.31)\nproject(example)\nproject(example-again)\n",
423            CheckOptions {
424                project_root: true,
425                function_name_case: NameCase::Lower,
426            },
427        );
428        assert_eq!(codes, vec![RuleCode::W201, RuleCode::W202]);
429    }
430
431    #[test]
432    fn reports_empty_project_calls() {
433        let codes = diagnostic_codes(
434            "cmake_minimum_required(VERSION 3.30)\nproject()\n",
435            CheckOptions {
436                project_root: true,
437                function_name_case: NameCase::Lower,
438            },
439        );
440        assert_eq!(codes, vec![RuleCode::W203]);
441    }
442
443    #[test]
444    fn skips_root_only_rules_for_non_root_files() {
445        let codes = diagnostic_codes(
446            "add_subdirectory(src)\n",
447            CheckOptions {
448                project_root: false,
449                function_name_case: NameCase::Lower,
450            },
451        );
452        assert!(codes.is_empty());
453    }
454
455    #[test]
456    fn line_noqa_suppresses_matching_rule() {
457        let codes = diagnostic_codes(
458            "project() # noqa: W203\nproject(example)\n",
459            CheckOptions {
460                project_root: true,
461                function_name_case: NameCase::Lower,
462            },
463        );
464        assert!(!codes.contains(&RuleCode::W203));
465        assert!(codes.contains(&RuleCode::W202));
466        assert!(codes.contains(&RuleCode::W301));
467    }
468
469    #[test]
470    fn file_noqa_suppresses_all_rules() {
471        let codes = diagnostic_codes(
472            "# noqa\nproject()\nproject(example)\n",
473            CheckOptions {
474                project_root: true,
475                function_name_case: NameCase::Lower,
476            },
477        );
478        assert!(codes.is_empty());
479    }
480
481    #[test]
482    fn file_noqa_can_target_specific_rules() {
483        let codes = diagnostic_codes(
484            "# noqa: W301,W202\nproject()\nproject(example)\n",
485            CheckOptions {
486                project_root: true,
487                function_name_case: NameCase::Lower,
488            },
489        );
490        assert_eq!(codes, vec![RuleCode::W203]);
491    }
492
493    #[test]
494    fn reports_lowercase_naming_rule() {
495        let codes = diagnostic_codes(
496            "ADD_LIBRARY(example STATIC main.cpp)\n",
497            CheckOptions {
498                project_root: false,
499                function_name_case: NameCase::Lower,
500            },
501        );
502        assert_eq!(codes, vec![RuleCode::N001]);
503    }
504
505    #[test]
506    fn reports_uppercase_naming_rule() {
507        let codes = diagnostic_codes(
508            "add_library(example STATIC main.cpp)\n",
509            CheckOptions {
510                project_root: false,
511                function_name_case: NameCase::Upper,
512            },
513        );
514        assert_eq!(codes, vec![RuleCode::N001]);
515    }
516
517    #[test]
518    fn applies_naming_fixes() {
519        let result = check_source(
520            "ADD_LIBRARY(example STATIC main.cpp)\n",
521            &CheckOptions {
522                project_root: false,
523                function_name_case: NameCase::Lower,
524            },
525        );
526        let fixed = apply_fixes(
527            "ADD_LIBRARY(example STATIC main.cpp)\n",
528            &result.diagnostics,
529        )
530        .expect("naming fix should produce an edit");
531        assert_eq!(fixed, "add_library(example STATIC main.cpp)\n");
532    }
533}