Skip to main content

cmake_tidy/
check.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result, bail};
4use cmake_tidy_check::{CheckOptions, Diagnostic, apply_fixes, check_source};
5use cmake_tidy_config::{LintConfiguration, MainConfiguration, RuleSelector, load_configuration};
6
7use crate::coverage_excluded;
8
9pub fn run(
10    paths: &[PathBuf],
11    select: Vec<RuleSelector>,
12    ignore: Vec<RuleSelector>,
13    fix: bool,
14) -> Result<bool> {
15    let current_directory = std::env::current_dir().context("failed to read current directory")?;
16    let configuration = load_configuration(&current_directory).with_context(|| {
17        format!(
18            "failed to load configuration from {}",
19            current_directory.display()
20        )
21    })?;
22    let lint = build_lint_configuration(&configuration.lint, select, ignore);
23    let fix_enabled = fix || configuration.main.fix;
24    let targets = discover_targets(paths, &current_directory, &configuration.main)?;
25    if targets.is_empty() {
26        bail!("no CMake files found");
27    }
28
29    let mut found_diagnostics = false;
30
31    for target in targets {
32        let source = coverage_excluded::read_cmake_file(&target.path)?;
33        let options = CheckOptions {
34            project_root: target.project_root,
35            function_name_case: configuration.lint.function_name_case,
36        };
37        let relative_path = relative_match_path(&target.path, &current_directory);
38        let mut diagnostics = filter_diagnostics(
39            check_source(&source, &options).diagnostics,
40            &lint,
41            &relative_path,
42        );
43
44        let current_source = if fix_enabled {
45            if let Some(fixed) = apply_fixes(&source, &diagnostics) {
46                coverage_excluded::write_fixed_file(&target.path, &fixed)?;
47                fixed
48            } else {
49                source
50            }
51        } else {
52            source
53        };
54
55        diagnostics = filter_diagnostics(
56            check_source(&current_source, &options).diagnostics,
57            &lint,
58            &relative_path,
59        );
60
61        if diagnostics.is_empty() {
62            continue;
63        }
64
65        let source_index = SourceIndex::new(&current_source);
66        for diagnostic in diagnostics {
67            found_diagnostics = true;
68            print_diagnostic(&target.path, &source_index, &diagnostic);
69        }
70    }
71
72    Ok(found_diagnostics)
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
76struct FileTarget {
77    path: PathBuf,
78    project_root: bool,
79}
80
81fn build_lint_configuration(
82    base: &LintConfiguration,
83    select: Vec<RuleSelector>,
84    ignore: Vec<RuleSelector>,
85) -> LintConfiguration {
86    let mut lint = base.clone();
87    if !select.is_empty() {
88        lint.select = select;
89    }
90    if !ignore.is_empty() {
91        lint.ignore = ignore;
92    }
93    lint
94}
95
96fn filter_diagnostics(
97    diagnostics: Vec<Diagnostic>,
98    lint: &LintConfiguration,
99    path: &Path,
100) -> Vec<Diagnostic> {
101    diagnostics
102        .into_iter()
103        .filter(|diagnostic| lint.is_rule_enabled_for_path(path, &diagnostic.code.to_string()))
104        .collect()
105}
106
107fn discover_targets(
108    paths: &[PathBuf],
109    current_directory: &Path,
110    main: &MainConfiguration,
111) -> Result<Vec<FileTarget>> {
112    let mut targets = Vec::new();
113
114    for path in paths {
115        collect_targets(path, path, current_directory, main, &mut targets)?;
116    }
117
118    targets.sort();
119    targets.dedup();
120    Ok(targets)
121}
122
123fn collect_targets(
124    input_path: &Path,
125    current_path: &Path,
126    current_directory: &Path,
127    main: &MainConfiguration,
128    targets: &mut Vec<FileTarget>,
129) -> Result<()> {
130    let metadata = coverage_excluded::read_metadata(current_path)?;
131
132    if metadata.is_file() {
133        if !is_excluded(current_path, current_directory, main) {
134            targets.push(FileTarget {
135                path: current_path.to_path_buf(),
136                project_root: current_path
137                    .file_name()
138                    .is_some_and(|file_name| file_name == "CMakeLists.txt"),
139            });
140        }
141        return Ok(());
142    }
143
144    let root_cmakelists = current_path.join("CMakeLists.txt");
145    if root_cmakelists.is_file() && !is_excluded(&root_cmakelists, current_directory, main) {
146        targets.push(FileTarget {
147            path: root_cmakelists,
148            project_root: true,
149        });
150    }
151
152    let entries = coverage_excluded::read_directory(current_path)?;
153
154    for entry in entries {
155        let entry = coverage_excluded::read_directory_entry(entry, current_path)?;
156        let entry_path = entry.path();
157
158        if entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
159            collect_targets(input_path, &entry_path, current_directory, main, targets)?;
160            continue;
161        }
162
163        let is_direct_root_file = current_path == input_path
164            && entry_path
165                .file_name()
166                .is_some_and(|file_name| file_name == "CMakeLists.txt");
167        if is_direct_root_file {
168            continue;
169        }
170
171        if is_cmake_file(&entry_path) && !is_excluded(&entry_path, current_directory, main) {
172            targets.push(FileTarget {
173                path: entry_path,
174                project_root: false,
175            });
176        }
177    }
178
179    Ok(())
180}
181
182fn is_cmake_file(path: &Path) -> bool {
183    path.file_name()
184        .is_some_and(|file_name| file_name == "CMakeLists.txt")
185        || path
186            .extension()
187            .is_some_and(|extension| extension == "cmake")
188}
189
190fn is_excluded(path: &Path, current_directory: &Path, main: &MainConfiguration) -> bool {
191    main.is_path_excluded(path)
192        || path
193            .strip_prefix(current_directory)
194            .is_ok_and(|relative| main.is_path_excluded(relative))
195}
196
197fn relative_match_path(path: &Path, current_directory: &Path) -> PathBuf {
198    if let Ok(relative) = path.strip_prefix(current_directory) {
199        return relative.to_path_buf();
200    }
201
202    if let (Ok(canonical_path), Ok(canonical_current_directory)) = (
203        std::fs::canonicalize(path),
204        std::fs::canonicalize(current_directory),
205    ) && let Ok(relative) = canonical_path.strip_prefix(&canonical_current_directory)
206    {
207        return relative.to_path_buf();
208    }
209
210    path.file_name()
211        .map_or_else(|| path.to_path_buf(), PathBuf::from)
212}
213
214fn print_diagnostic(path: &Path, source_index: &SourceIndex, diagnostic: &Diagnostic) {
215    let (line, column) = source_index.line_column(diagnostic.range.start);
216    println!(
217        "{}:{}:{}: {} {}",
218        path.display(),
219        line,
220        column,
221        diagnostic.code,
222        diagnostic.message
223    );
224}
225
226#[derive(Debug, Clone)]
227struct SourceIndex {
228    line_starts: Vec<usize>,
229    len: usize,
230}
231
232impl SourceIndex {
233    fn new(source: &str) -> Self {
234        let mut line_starts = vec![0];
235        for (index, character) in source.char_indices() {
236            if character == '\n' {
237                line_starts.push(index + 1);
238            }
239        }
240
241        Self {
242            line_starts,
243            len: source.len(),
244        }
245    }
246
247    fn line_column(&self, offset: usize) -> (usize, usize) {
248        let offset = offset.min(self.len);
249        let line_index = match self.line_starts.binary_search(&offset) {
250            Ok(index) => index,
251            Err(index) => index.saturating_sub(1),
252        };
253        let line_start = self.line_starts[line_index];
254        let column = offset - line_start + 1;
255        (line_index + 1, column)
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use std::fs;
262    use std::path::PathBuf;
263    use std::sync::atomic::{AtomicU64, Ordering};
264    use std::time::{SystemTime, UNIX_EPOCH};
265
266    use anyhow::{Context, Result};
267    use cmake_tidy_ast::TextRange;
268    use cmake_tidy_check::{Diagnostic, RuleCode};
269    use cmake_tidy_config::{LintConfiguration, MainConfiguration, RuleSelector};
270
271    use super::{
272        SourceIndex, build_lint_configuration, discover_targets, filter_diagnostics, is_excluded,
273        relative_match_path,
274    };
275
276    static NEXT_TEMP_DIR: AtomicU64 = AtomicU64::new(0);
277
278    #[test]
279    fn computes_one_based_locations() {
280        let source_index = SourceIndex::new("first()\nsecond()\n");
281        assert_eq!(source_index.line_column(TextRange::new(8, 8).start), (2, 1));
282    }
283
284    #[test]
285    fn discovers_root_and_nested_targets() -> Result<()> {
286        let temp_dir = unique_temp_dir()?;
287        let nested_dir = temp_dir.join("cmake");
288        fs::create_dir_all(&nested_dir)
289            .with_context(|| format!("failed to create {}", nested_dir.display()))?;
290
291        let root_file = temp_dir.join("CMakeLists.txt");
292        let nested_file = nested_dir.join("tooling.cmake");
293        fs::write(&root_file, "project(example)\n")
294            .with_context(|| format!("failed to write {}", root_file.display()))?;
295        fs::write(&nested_file, "message(STATUS hi)\n")
296            .with_context(|| format!("failed to write {}", nested_file.display()))?;
297
298        let targets = discover_targets(
299            std::slice::from_ref(&temp_dir),
300            &temp_dir,
301            &MainConfiguration::default(),
302        )?;
303
304        assert_eq!(targets.len(), 2);
305        assert!(
306            targets
307                .iter()
308                .any(|target| target.path == root_file && target.project_root)
309        );
310        assert!(
311            targets
312                .iter()
313                .any(|target| target.path == nested_file && !target.project_root)
314        );
315
316        fs::remove_dir_all(&temp_dir)
317            .with_context(|| format!("failed to remove {}", temp_dir.display()))?;
318        Ok(())
319    }
320
321    #[test]
322    fn relative_match_path_falls_back_to_file_name_for_missing_paths() {
323        let current_directory = std::path::Path::new("/tmp/workspace");
324        let path = std::path::Path::new("/totally/elsewhere/tooling.cmake");
325        assert_eq!(
326            relative_match_path(path, current_directory),
327            PathBuf::from("tooling.cmake")
328        );
329    }
330
331    #[test]
332    fn relative_match_path_returns_relative_path_when_under_current_directory() {
333        let current_directory = std::path::Path::new("/tmp/workspace");
334        let path = current_directory.join("cmake").join("tooling.cmake");
335        assert_eq!(
336            relative_match_path(&path, current_directory),
337            PathBuf::from("cmake").join("tooling.cmake")
338        );
339    }
340
341    #[test]
342    fn discovers_direct_cmakelists_file_as_project_root() -> Result<()> {
343        let temp_dir = unique_temp_dir()?;
344        fs::create_dir_all(&temp_dir)
345            .with_context(|| format!("failed to create {}", temp_dir.display()))?;
346        let root_file = temp_dir.join("CMakeLists.txt");
347        fs::write(&root_file, "project(example)\n")
348            .with_context(|| format!("failed to write {}", root_file.display()))?;
349
350        let targets = discover_targets(
351            std::slice::from_ref(&root_file),
352            &temp_dir,
353            &MainConfiguration::default(),
354        )?;
355
356        assert_eq!(targets.len(), 1);
357        assert_eq!(targets[0].path, root_file);
358        assert!(targets[0].project_root);
359
360        fs::remove_dir_all(&temp_dir)
361            .with_context(|| format!("failed to remove {}", temp_dir.display()))?;
362        Ok(())
363    }
364
365    #[test]
366    fn discovers_direct_cmake_module_as_non_root_file() -> Result<()> {
367        let temp_dir = unique_temp_dir()?;
368        fs::create_dir_all(&temp_dir)
369            .with_context(|| format!("failed to create {}", temp_dir.display()))?;
370        let module_file = temp_dir.join("tooling.cmake");
371        fs::write(&module_file, "message(STATUS hi)\n")
372            .with_context(|| format!("failed to write {}", module_file.display()))?;
373
374        let targets = discover_targets(
375            std::slice::from_ref(&module_file),
376            &temp_dir,
377            &MainConfiguration::default(),
378        )?;
379
380        assert_eq!(targets.len(), 1);
381        assert_eq!(targets[0].path, module_file);
382        assert!(!targets[0].project_root);
383
384        fs::remove_dir_all(&temp_dir)
385            .with_context(|| format!("failed to remove {}", temp_dir.display()))?;
386        Ok(())
387    }
388
389    #[test]
390    fn discover_targets_errors_for_missing_path() {
391        let temp_dir = std::env::temp_dir();
392        let missing = temp_dir.join("cmake-tidy-check-missing-input");
393        let error = discover_targets(
394            std::slice::from_ref(&missing),
395            &temp_dir,
396            &MainConfiguration::default(),
397        )
398        .expect_err("missing path should error");
399
400        assert!(error.to_string().contains("failed to read file metadata"));
401    }
402
403    #[test]
404    fn build_lint_configuration_overrides_select_and_ignore() {
405        let base = LintConfiguration::default();
406        let lint = build_lint_configuration(
407            &base,
408            vec![RuleSelector::prefix("N")],
409            vec![RuleSelector::prefix("N001")],
410        );
411
412        assert_eq!(lint.select, vec![RuleSelector::prefix("N")]);
413        assert_eq!(lint.ignore, vec![RuleSelector::prefix("N001")]);
414    }
415
416    #[test]
417    fn filter_diagnostics_respects_enabled_rules_for_path() {
418        let diagnostics = vec![
419            Diagnostic::new(RuleCode::W201, "duplicate", TextRange::new(0, 1)),
420            Diagnostic::new(RuleCode::W203, "empty", TextRange::new(1, 2)),
421        ];
422        let lint = LintConfiguration {
423            select: vec![RuleSelector::prefix("W")],
424            ignore: vec![RuleSelector::prefix("W201")],
425            ..LintConfiguration::default()
426        };
427
428        let filtered =
429            filter_diagnostics(diagnostics, &lint, std::path::Path::new("CMakeLists.txt"));
430        assert_eq!(filtered.len(), 1);
431        assert_eq!(filtered[0].code, RuleCode::W203);
432    }
433
434    #[test]
435    fn excludes_match_relative_paths() {
436        let current_directory = std::path::Path::new("/workspace");
437        let path = current_directory.join("generated").join("tooling.cmake");
438        let configuration = MainConfiguration {
439            exclude: vec![PathBuf::from("generated")],
440            ..MainConfiguration::default()
441        };
442
443        assert!(is_excluded(&path, current_directory, &configuration));
444        assert!(!is_excluded(
445            &current_directory.join("src").join("tooling.cmake"),
446            current_directory,
447            &configuration,
448        ));
449    }
450
451    fn unique_temp_dir() -> Result<PathBuf> {
452        let timestamp = SystemTime::now()
453            .duration_since(UNIX_EPOCH)
454            .context("system clock is before UNIX_EPOCH")?
455            .as_nanos();
456        let sequence = NEXT_TEMP_DIR.fetch_add(1, Ordering::Relaxed);
457        Ok(std::env::temp_dir().join(format!(
458            "cmake-tidy-check-unit-{}-{timestamp}-{sequence}",
459            std::process::id(),
460        )))
461    }
462}