Skip to main content

cpp_linter/clang_tools/
clang_format.rs

1//! This module holds functionality specific to running clang-format and parsing it's
2//! output.
3
4use std::{
5    fs,
6    ops::RangeInclusive,
7    process::Command,
8    sync::{Arc, Mutex, MutexGuard},
9};
10
11use gix_imara_diff::Diff;
12use log::Level;
13
14// project-specific crates/modules
15use crate::{
16    clang_tools::make_patch, cli::ClangParams, common_fs::FileObj, error::ClangCaptureError,
17};
18
19/// A struct to hold clang-format advice for a single file.
20#[derive(Debug, Clone, PartialEq, Eq, Default)]
21pub struct FormatAdvice {
22    /// A list of line ranges that clang-format wants to replace.
23    pub replacements: Vec<RangeInclusive<u32>>,
24}
25
26/// Get a string that summarizes the given `--style`
27pub fn summarize_style(style: &str) -> String {
28    let mut char_iter = style.chars();
29    if ["google", "chromium", "microsoft", "mozilla", "webkit"].contains(&style)
30        && let Some(first_char) = char_iter.next()
31    {
32        // capitalize the first letter
33        first_char.to_ascii_uppercase().to_string() + char_iter.as_str()
34    } else if style == "llvm" || style == "gnu" {
35        style.to_ascii_uppercase()
36    } else {
37        String::from("Custom")
38    }
39}
40
41/// Get a total count of clang-format advice from the given list of [FileObj]s.
42pub fn tally_format_advice(files: &[Arc<Mutex<FileObj>>]) -> Result<u64, String> {
43    let mut total = 0;
44    for file in files {
45        let file = file.lock().map_err(|e| e.to_string())?;
46        if let Some(advice) = &file.format_advice
47            && !advice.replacements.is_empty()
48        {
49            total += 1;
50        }
51    }
52    Ok(total)
53}
54
55/// Run clang-format for a specific `file`, then parse and return it's XML output.
56pub fn run_clang_format(
57    file: &mut MutexGuard<FileObj>,
58    clang_params: &ClangParams,
59) -> Result<Vec<(log::Level, String)>, ClangCaptureError> {
60    let cmd_path = clang_params
61        .clang_format_command
62        .as_ref()
63        .ok_or(ClangCaptureError::ToolPathUnknown("clang-format"))?;
64    let mut cmd = Command::new(cmd_path);
65    cmd.current_dir(&clang_params.repo_root);
66    let mut logs = vec![];
67    cmd.args(["--style", &clang_params.style]);
68    let ranges = file.get_ranges(&clang_params.lines_changed_only);
69    for range in &ranges {
70        cmd.arg(format!("--lines={}:{}", range.start(), range.end()));
71    }
72    let cache_path = clang_params.get_cache_path();
73    let file_name = file.name.to_string_lossy().to_string();
74    cmd.arg(file.name.to_path_buf().as_os_str());
75    logs.push((
76        Level::Info,
77        format!(
78            "Getting format fixes with \"{} {}\"",
79            cmd.get_program().to_string_lossy(),
80            cmd.get_args()
81                .map(|a| a.to_string_lossy())
82                .collect::<Vec<_>>()
83                .join(" ")
84        ),
85    ));
86    let output = cmd
87        .output()
88        .map_err(|e| ClangCaptureError::FailedToRunCommand {
89            task: format!("get fixes from clang-format {file_name}"),
90            source: e,
91        })?;
92
93    if !output.stderr.is_empty() || !output.status.success() {
94        logs.push((
95            log::Level::Debug,
96            format!(
97                "clang-format raised the follow errors:\n{}",
98                String::from_utf8_lossy(&output.stderr)
99            ),
100        ));
101    }
102
103    // use a diff between patched and original contents to get format results
104    let original_contents =
105        fs::read_to_string(clang_params.repo_root.join(&file.name)).map_err(|e| {
106            ClangCaptureError::ReadFileFailed {
107                file_name: file_name.clone(),
108                source: e,
109            }
110        })?;
111    let patched_contents = String::from_utf8(output.stdout.to_vec()).map_err(|e| {
112        ClangCaptureError::NonUtf8Output {
113            task: "clang-format".to_string(),
114            source: e,
115        }
116    })?;
117    let (diff, _) = make_patch(&patched_contents, &original_contents);
118    let format_advice = FormatAdvice {
119        replacements: diff
120            .hunks()
121            .filter_map(|hunk| {
122                let altered_start = hunk.after.start.saturating_add(1); // convert to 1-based line numbers
123                let og_start = hunk.before.start.saturating_add(1); // convert to 1-based line numbers
124                let og_end = hunk.before.end; // exclusive end is inclusive for 1-based line numbers
125                let replacement = if hunk.is_pure_insertion() {
126                    RangeInclusive::new(altered_start, altered_start)
127                } else {
128                    RangeInclusive::new(og_start, og_end)
129                };
130                if ranges.is_empty() {
131                    Some(replacement)
132                } else {
133                    // only include replacements that fall within the specified line ranges
134                    if ranges.iter().any(|range| {
135                        range.contains(replacement.start()) && range.contains(replacement.end())
136                    }) {
137                        Some(replacement)
138                    } else {
139                        None
140                    }
141                }
142            })
143            .collect(),
144    };
145
146    // if a clang-tidy patched file exists in cache,
147    // get the diff between it and the original file,
148    // then format both clang-tidy fixes and any other changes by clang-format fixes.
149    if let Some(patched_path) = &file.patched_path
150        && patched_path.exists()
151    {
152        let mut cmd = Command::new(cmd_path);
153        cmd.current_dir(&cache_path);
154        // edit the clang-tody patched file in-place (`-i`)
155        cmd.args(["--style", &clang_params.style, "-i"]);
156        // if ranges is empty, then we're just formatting the entire file.
157        if !ranges.is_empty() {
158            let tidy_patch_contents = fs::read_to_string(patched_path).map_err(|e| {
159                ClangCaptureError::ReadFileFailed {
160                    file_name: patched_path.to_string_lossy().to_string(),
161                    source: e,
162                }
163            })?;
164            let (tidy_diff, _) = make_patch(&tidy_patch_contents, &original_contents);
165            let joint_ranges = three_way_diff(&ranges, tidy_diff);
166            for range in &joint_ranges {
167                cmd.arg(format!("--lines={}:{}", range.start(), range.end()).as_str());
168            }
169        }
170        cmd.arg(&file_name);
171        let output = cmd
172            .output()
173            .map_err(|e| ClangCaptureError::FailedToRunCommand {
174                task: format!("apply clang-format to clang-tidy fixes ({file_name})"),
175                source: e,
176            })?;
177        if !output.stderr.is_empty() || !output.status.success() {
178            logs.push((
179                log::Level::Debug,
180                format!(
181                    "clang-format raised the follow errors about clang-tidy fixes:\n{}",
182                    String::from_utf8_lossy(&output.stderr)
183                ),
184            ));
185        }
186    } else {
187        // clang-tidy was not run on this file,
188        // so just use the clang-format fixes as the patched content.
189        let cache_format_fixes = cache_path.join(&file.name);
190        fs::create_dir_all(
191            cache_format_fixes
192                .parent()
193                .ok_or(ClangCaptureError::UnknownCacheParentPath)?,
194        )
195        .map_err(ClangCaptureError::MkDirFailed)?;
196        fs::write(&cache_format_fixes, &output.stdout).map_err(|e| {
197            ClangCaptureError::WriteFileFailed {
198                file_name: cache_format_fixes.to_string_lossy().to_string(),
199                source: e,
200            }
201        })?;
202        file.patched_path = Some(cache_format_fixes);
203    }
204
205    file.format_advice = Some(format_advice);
206    Ok(logs)
207}
208
209/// Essentially does a three way diff without the original source that generated the given `ranges` (simplified hunks).
210///
211/// The returned list of ranges are lines that need formatting in the clang-tidy patched file,
212/// provided by the `tidy_diff`. The given `ranges` are the line numbers in the original file
213/// that clang-tidy patched.
214fn three_way_diff(ranges: &[RangeInclusive<u32>], tidy_diff: Diff) -> Vec<RangeInclusive<u32>> {
215    // We're concerned about the formatting cases:
216    //
217    // 1. changes that clang-tidy made: `tidy_diff.hunks().after`
218    // 2. changes in the current CI event's diff (`ranges`)
219    //    that clang-tidy did not touch (`tidy_diff.hunks().before`)
220    // 3. changes that do not overlap clang-tidy fixes: `ranges` - `tidy_diff.hunks().before`
221    // 4. changes that overlap with clang-tidy fixes. This one is complex because
222    //    - tidy fixes can prefix an og range
223    //    - tidy fixes can suffix an og range
224    //    - tidy fixes can be contained within an og range
225    //    - multiple tidy fixes can (in order) suffix, be contained within, and prefix an og range
226    let mut joint_ranges = vec![];
227    let mut tidy_iter = tidy_diff.hunks().peekable();
228    let mut line_shift = 0i32;
229
230    /// Prevent pure removals from causing invalid inclusive ranges.
231    fn maybe_push_range(joint_ranges: &mut Vec<RangeInclusive<u32>>, start: u32, end: u32) {
232        if start <= end {
233            joint_ranges.push(RangeInclusive::new(start, end));
234        }
235    }
236
237    for og_range in ranges {
238        let og_start = *og_range.start();
239        let og_end = *og_range.end();
240
241        // track the start and end of a merged range that gets pushed into joint_ranges.
242        let mut merged_start = (og_start as i32 + line_shift) as u32;
243        let mut merged_end = (og_end as i32 + line_shift) as u32;
244
245        while let Some(tidy_hunk) = tidy_iter.peek() {
246            // alias for readability and prevent some repeated calculations
247            let before_start = tidy_hunk.before.start.saturating_add(1); // convert to 1-based line numbers
248            let before_end = tidy_hunk.before.end; // exclusive end is inclusive for 1-based line numbers
249            let after_start = tidy_hunk.after.start.saturating_add(1); // convert to 1-based line numbers
250            let after_end = tidy_hunk.after.end; // exclusive end is inclusive for 1-based line numbers
251            let delta = tidy_hunk.after.len() as i32 - tidy_hunk.before.len() as i32;
252
253            // The tidy hunk is a pure removal that encompasses the og range.
254            if tidy_hunk.is_pure_removal() && before_start <= og_start && before_end >= og_end {
255                // Skip the og range and tidy hunk entirely.
256                // The line shift must still be adjusted for the pure removal though
257                line_shift += delta;
258                merged_end = 0; // causes invalid inclusive range which does not get pushed.
259                tidy_iter.next(); // skip this tidy hunk
260                break; // skip og range and iterate to the next one.
261            }
262
263            // tidy hunk is before the og range.
264            if before_end < og_start {
265                maybe_push_range(&mut joint_ranges, after_start, after_end);
266                line_shift += delta;
267                tidy_iter.next();
268                continue;
269            }
270
271            // tidy hunk is after the og range.
272            if before_start > og_end {
273                // handle the og range before iterating the next tidy hunk
274                break;
275            }
276
277            // tidy hunk overlaps with the og range in some way (case 4).
278            if (before_start..=before_end).contains(&og_start) {
279                merged_start = after_start;
280            }
281
282            // commit the line shift now that the tidy hunk start is checked.
283            line_shift += delta;
284
285            // tidy hunk suffixes the og range.
286            if (before_start..=before_end).contains(&og_end) {
287                merged_end = after_end;
288                tidy_iter.next(); // this tidy hunk is handled.
289                break; // break from loop to push the merged range into joint_ranges.
290            }
291
292            // tidy hunk is contained within the og range.
293            // so adjust the og range end accordingly and continue iterating tidy hunks
294            merged_end = (og_end as i32 + line_shift) as u32;
295            tidy_iter.next();
296        }
297
298        maybe_push_range(&mut joint_ranges, merged_start, merged_end);
299    }
300
301    // handle any remaining tidy hunks that are after all og ranges.
302    for tidy_hunk in tidy_iter {
303        maybe_push_range(
304            &mut joint_ranges,
305            tidy_hunk.after.start.saturating_add(1), // convert to 1-based line numbers
306            tidy_hunk.after.end, // exclusive end is inclusive for 1-based line numbers
307        );
308    }
309
310    joint_ranges
311}
312
313#[cfg(test)]
314mod tests {
315    #![allow(clippy::unwrap_used)]
316
317    use std::ops::RangeInclusive;
318
319    use super::{summarize_style, three_way_diff};
320    use crate::clang_tools::make_patch;
321
322    fn formalize_style(style: &str, expected: &str) {
323        assert_eq!(summarize_style(style), expected);
324    }
325
326    #[test]
327    fn formalize_llvm_style() {
328        formalize_style("llvm", "LLVM");
329    }
330
331    #[test]
332    fn formalize_google_style() {
333        formalize_style("google", "Google");
334    }
335
336    #[test]
337    fn formalize_custom_style() {
338        formalize_style("file", "Custom");
339    }
340
341    #[test]
342    fn three_way_diff_mixed() {
343        const OG_SRC: &str =
344            "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12";
345        // The first hunk line2-3->StringA-B (hunk 2..4 prefixes og range[3..=5]).
346        // The second hunk line8-11->StringC-D\nline11\n (hunk 8..12 suffixes og range[7..=9]).
347        const TIDY_SRC: &str =
348            "line1\nStringA\nStringB\nline4\nline5\nline6\nline7\nline8\nStringC\nStringD\nline11";
349        let (tidy_diff, _input) = make_patch(TIDY_SRC, OG_SRC);
350        #[cfg(feature = "bin")]
351        print_diff(OG_SRC, TIDY_SRC, &tidy_diff, &_input);
352        let ranges = vec![RangeInclusive::new(3, 5), RangeInclusive::new(7, 9)];
353        println!("tidy diff: {tidy_diff:#?}\ncompared to og ranges: {ranges:?}");
354        let joint_ranges = three_way_diff(&ranges, tidy_diff);
355        println!("joint ranges: {joint_ranges:#?}");
356        assert_eq!(joint_ranges, vec![2..=5, 7..=11]);
357    }
358
359    #[test]
360    fn three_way_diff_separated() {
361        const OG_SRC: &str =
362            "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11";
363        // TIDY_SRC removes "line3" which decrements offsets in ranges[5,8] and removes ranges[3,3].
364        // TIDY_SRC appends StringE, which handles remaining tidy hunks after done iterating ranges
365        const TIDY_SRC: &str =
366            "line1\nline2\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nStringE";
367        let (tidy_diff, _input) = make_patch(TIDY_SRC, OG_SRC);
368        #[cfg(feature = "bin")]
369        print_diff(OG_SRC, TIDY_SRC, &tidy_diff, &_input);
370        let ranges = vec![3..=3, 5..=8];
371        println!("tidy diff: {tidy_diff:#?}\ncompared to og ranges: {ranges:?}");
372        let joint_ranges = three_way_diff(&ranges, tidy_diff);
373        println!("joint ranges: {joint_ranges:#?}");
374        assert_eq!(joint_ranges, vec![4..=7, 10..=11]);
375    }
376
377    #[cfg(feature = "bin")]
378    fn print_diff(
379        og: &str,
380        altered: &str,
381        diff: &gix_imara_diff::Diff,
382        input: &gix_imara_diff::InternedInput<&str>,
383    ) {
384        use clap::builder::styling::{AnsiColor, Color, Style};
385        use gix_imara_diff::{BasicLineDiffPrinter, UnifiedDiffConfig};
386
387        println!("---\nOG SRC:");
388        for (i, l) in og.lines().enumerate() {
389            println!("{i:>2}|{l}");
390        }
391        println!("---\nALTERED SRC:");
392        for (i, l) in altered.lines().enumerate() {
393            println!("{i:>2}|{l}");
394        }
395        let printer = BasicLineDiffPrinter(&input.interner);
396        let mut config = UnifiedDiffConfig::default();
397        config.context_len(0);
398        let unified = diff.unified_diff(&printer, config, input).to_string();
399        for l in unified.lines() {
400            let style = if l.starts_with('+') {
401                Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green)))
402            } else if l.starts_with('-') {
403                Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red)))
404            } else {
405                Style::new()
406            };
407            println!("{style}{l}{style:#}");
408        }
409    }
410}