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(¤t_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, ¤t_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, ¤t_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(¤t_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(¤t_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 ¤t_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}