Skip to main content

cmake_tidy/
format.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result, bail};
4use cmake_tidy_config::load_configuration;
5use cmake_tidy_format::format_source_with_options;
6
7use crate::coverage_excluded;
8
9pub fn run(paths: &[PathBuf]) -> Result<bool> {
10    let current_directory = std::env::current_dir().context("failed to read current directory")?;
11    let configuration = load_configuration(&current_directory).with_context(|| {
12        format!(
13            "failed to load configuration from {}",
14            current_directory.display()
15        )
16    })?;
17    let targets = discover_targets(paths, &current_directory, &configuration.main)?;
18    if targets.is_empty() {
19        bail!("no CMake files found");
20    }
21
22    let mut changed_any = false;
23
24    for path in targets {
25        let source = coverage_excluded::read_cmake_file(&path)?;
26        let result = format_source_with_options(&source, &configuration.format);
27        if !result.changed {
28            continue;
29        }
30
31        coverage_excluded::write_formatted_file(&path, result.output)?;
32        changed_any = true;
33    }
34
35    Ok(changed_any)
36}
37
38fn discover_targets(
39    paths: &[PathBuf],
40    current_directory: &Path,
41    main: &cmake_tidy_config::MainConfiguration,
42) -> Result<Vec<PathBuf>> {
43    let mut targets = Vec::new();
44    for path in paths {
45        collect_targets(path, current_directory, main, &mut targets)?;
46    }
47
48    targets.sort();
49    targets.dedup();
50    Ok(targets)
51}
52
53fn collect_targets(
54    path: &Path,
55    current_directory: &Path,
56    main: &cmake_tidy_config::MainConfiguration,
57    targets: &mut Vec<PathBuf>,
58) -> Result<()> {
59    let metadata = coverage_excluded::read_metadata(path)?;
60
61    if metadata.is_file() {
62        if is_cmake_file(path) && !is_excluded(path, current_directory, main) {
63            targets.push(path.to_path_buf());
64        }
65        return Ok(());
66    }
67
68    for entry in coverage_excluded::read_directory(path)? {
69        let entry = coverage_excluded::read_directory_entry(entry, path)?;
70        let entry_path = entry.path();
71
72        if entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
73            collect_targets(&entry_path, current_directory, main, targets)?;
74        } else if is_cmake_file(&entry_path) && !is_excluded(&entry_path, current_directory, main) {
75            targets.push(entry_path);
76        }
77    }
78
79    Ok(())
80}
81
82fn is_cmake_file(path: &Path) -> bool {
83    path.file_name()
84        .is_some_and(|file_name| file_name == "CMakeLists.txt")
85        || path
86            .extension()
87            .is_some_and(|extension| extension == "cmake")
88}
89
90fn is_excluded(
91    path: &Path,
92    current_directory: &Path,
93    main: &cmake_tidy_config::MainConfiguration,
94) -> bool {
95    if main.is_path_excluded(path) {
96        return true;
97    }
98
99    if path
100        .strip_prefix(current_directory)
101        .is_ok_and(|relative| main.is_path_excluded(relative))
102    {
103        return true;
104    }
105
106    if let (Ok(canonical_path), Ok(canonical_current_directory)) = (
107        std::fs::canonicalize(path),
108        std::fs::canonicalize(current_directory),
109    ) && canonical_path
110        .strip_prefix(&canonical_current_directory)
111        .is_ok_and(|relative| main.is_path_excluded(relative))
112    {
113        return true;
114    }
115
116    false
117}
118
119#[cfg(test)]
120mod tests {
121    use std::fs;
122    use std::path::PathBuf;
123    use std::sync::atomic::{AtomicU64, Ordering};
124    use std::time::{SystemTime, UNIX_EPOCH};
125
126    use anyhow::{Context, Result};
127    use cmake_tidy_config::MainConfiguration;
128
129    use super::{discover_targets, is_cmake_file, is_excluded};
130
131    static NEXT_TEMP_DIR: AtomicU64 = AtomicU64::new(0);
132
133    #[test]
134    fn discovers_direct_cmake_file_inputs() -> Result<()> {
135        let temp_dir = unique_temp_dir()?;
136        fs::create_dir_all(&temp_dir)
137            .with_context(|| format!("failed to create {}", temp_dir.display()))?;
138        let cmake_file = temp_dir.join("tooling.cmake");
139        fs::write(&cmake_file, "message(STATUS hi)\n")
140            .with_context(|| format!("failed to write {}", cmake_file.display()))?;
141
142        let targets = discover_targets(
143            std::slice::from_ref(&cmake_file),
144            &temp_dir,
145            &MainConfiguration::default(),
146        )?;
147
148        assert_eq!(targets, vec![cmake_file]);
149
150        fs::remove_dir_all(&temp_dir)
151            .with_context(|| format!("failed to remove {}", temp_dir.display()))?;
152        Ok(())
153    }
154
155    #[test]
156    fn deduplicates_targets_when_directory_and_file_overlap() -> Result<()> {
157        let temp_dir = unique_temp_dir()?;
158        fs::create_dir_all(&temp_dir)
159            .with_context(|| format!("failed to create {}", temp_dir.display()))?;
160        let cmakelists = temp_dir.join("CMakeLists.txt");
161        fs::write(&cmakelists, "project(example)\n")
162            .with_context(|| format!("failed to write {}", cmakelists.display()))?;
163
164        let targets = discover_targets(
165            &[temp_dir.clone(), cmakelists.clone()],
166            &temp_dir,
167            &MainConfiguration::default(),
168        )?;
169
170        assert_eq!(targets, vec![cmakelists]);
171
172        fs::remove_dir_all(&temp_dir)
173            .with_context(|| format!("failed to remove {}", temp_dir.display()))?;
174        Ok(())
175    }
176
177    #[test]
178    fn recognizes_cmake_file_names() {
179        assert!(is_cmake_file(std::path::Path::new("CMakeLists.txt")));
180        assert!(is_cmake_file(std::path::Path::new("tooling.cmake")));
181        assert!(!is_cmake_file(std::path::Path::new("notes.txt")));
182    }
183
184    #[test]
185    fn discovers_no_targets_for_excluded_file_input() -> Result<()> {
186        let temp_dir = unique_temp_dir()?;
187        fs::create_dir_all(&temp_dir)
188            .with_context(|| format!("failed to create {}", temp_dir.display()))?;
189        let excluded_dir = temp_dir.join("generated");
190        fs::create_dir_all(&excluded_dir)
191            .with_context(|| format!("failed to create {}", excluded_dir.display()))?;
192        let cmake_file = excluded_dir.join("tooling.cmake");
193        fs::write(&cmake_file, "message(STATUS hi)\n")
194            .with_context(|| format!("failed to write {}", cmake_file.display()))?;
195
196        let targets = discover_targets(
197            std::slice::from_ref(&cmake_file),
198            &temp_dir,
199            &MainConfiguration {
200                exclude: vec![PathBuf::from("generated")],
201                ..MainConfiguration::default()
202            },
203        )?;
204
205        assert!(targets.is_empty());
206
207        fs::remove_dir_all(&temp_dir)
208            .with_context(|| format!("failed to remove {}", temp_dir.display()))?;
209        Ok(())
210    }
211
212    #[test]
213    fn discover_targets_errors_for_missing_path() {
214        let temp_dir = std::env::temp_dir();
215        let missing = temp_dir.join("cmake-tidy-format-missing-input");
216        let error = discover_targets(
217            std::slice::from_ref(&missing),
218            &temp_dir,
219            &MainConfiguration::default(),
220        )
221        .expect_err("missing path should error");
222
223        assert!(error.to_string().contains("failed to read file metadata"));
224    }
225
226    #[test]
227    fn excludes_match_relative_paths() {
228        let current_directory = std::path::Path::new("/workspace");
229        let path = current_directory.join("generated").join("tooling.cmake");
230        let configuration = MainConfiguration {
231            exclude: vec![PathBuf::from("generated")],
232            ..MainConfiguration::default()
233        };
234
235        assert!(is_excluded(&path, current_directory, &configuration));
236        assert!(!is_excluded(
237            &current_directory.join("src").join("tooling.cmake"),
238            current_directory,
239            &configuration,
240        ));
241    }
242
243    fn unique_temp_dir() -> Result<PathBuf> {
244        let timestamp = SystemTime::now()
245            .duration_since(UNIX_EPOCH)
246            .context("system clock is before UNIX_EPOCH")?
247            .as_nanos();
248        let sequence = NEXT_TEMP_DIR.fetch_add(1, Ordering::Relaxed);
249        Ok(std::env::temp_dir().join(format!(
250            "cmake-tidy-format-unit-{}-{timestamp}-{sequence}",
251            std::process::id(),
252        )))
253    }
254}