Skip to content

Commit edd66db

Browse files
authored
feat: merge review suggestions from both clang tools (#358)
This is a culmination of - [x] #354 - [x] #347 As this builds on the plan discussed in cpp-linter/cpp-linter#82, it it the last step needed before providing a patch that consumers can push to auto-fix lints. ## Intended flow of data 1. run clang-tidy before running clang-format 2. after running clang-tidy save the patched file to cache 3. after running clang-format: - check for clang-tidy patch in cache. - if cached patch is present, run clang-format on the clang-tidy patch. This step includes formatting lines that clang-tidy changed and lines that clang-format changed. - if cached patch is not present (clang-tidy was not run on the file), then cache the clang-format fixes instead. - `--lines-changed-only` is respected, but this may need tweaking in the future. 4. when creating a PR review, - only add hunks to the patch that are not present in the review comments. - I added a parameter to also calculate (and display) how many review comments were reused in previous PR reviews. - PR review summaries are worded better if the env var `CPP_LINTER_PR_REVIEW_SUMMARY_ONLY = 1`. If PR review is a summary only, then the patch will include all fixes that would have been posted in the diff. --- Adjusted tests accordingly. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved formatting/tidy patch handling to generate more accurate review diffs and line-range suggestions. * Refined how review summaries are produced (including “summary-only” behavior and reused-review messaging). * Improved review comment metadata when line ranges are identical. * **Tests** * Expanded diff and three-way diff coverage. * Updated review-body expectation logic to match the new summary rules. * **Chores** * Updated documentation dependency. * Refined caching and output processing for more reliable review generation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent d45a232 commit edd66db

10 files changed

Lines changed: 477 additions & 258 deletions

File tree

cpp-linter/src/clang_tools/clang_format.rs

Lines changed: 213 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,23 @@
44
use std::{
55
fs,
66
ops::RangeInclusive,
7-
path::PathBuf,
87
process::Command,
98
sync::{Arc, Mutex, MutexGuard},
109
};
1110

12-
use gix_imara_diff::{Diff, InternedInput};
11+
use gix_imara_diff::Diff;
1312
use log::Level;
1413

1514
// project-specific crates/modules
16-
use super::{CACHE_DIR, MakeSuggestions};
17-
use crate::{cli::ClangParams, common_fs::FileObj, error::ClangCaptureError};
15+
use crate::{
16+
clang_tools::make_patch, cli::ClangParams, common_fs::FileObj, error::ClangCaptureError,
17+
};
1818

1919
/// A struct to hold clang-format advice for a single file.
2020
#[derive(Debug, Clone, PartialEq, Eq, Default)]
2121
pub struct FormatAdvice {
2222
/// A list of line ranges that clang-format wants to replace.
2323
pub replacements: Vec<RangeInclusive<u32>>,
24-
25-
/// A path to a cached file containing the full contents of the file after applying clang-format fixes.
26-
pub patched: PathBuf,
27-
}
28-
29-
impl MakeSuggestions for FormatAdvice {
30-
fn get_suggestion_help(&self, _start_line: u32, _end_line: u32) -> String {
31-
String::from("### clang-format suggestions\n")
32-
}
33-
34-
fn get_tool_name(&self) -> String {
35-
"clang-format".to_string()
36-
}
3724
}
3825

3926
/// Get a string that summarizes the given `--style`
@@ -82,14 +69,7 @@ pub fn run_clang_format(
8269
for range in &ranges {
8370
cmd.arg(format!("--lines={}:{}", range.start(), range.end()));
8471
}
85-
let cache_path = clang_params.repo_root.join(CACHE_DIR).join("patches");
86-
let cache_format_fixes = cache_path.join(file.name.with_added_extension("format"));
87-
fs::create_dir_all(
88-
cache_format_fixes
89-
.parent()
90-
.ok_or(ClangCaptureError::UnknownCacheParentPath)?,
91-
)
92-
.map_err(ClangCaptureError::MkDirFailed)?;
72+
let cache_path = clang_params.get_cache_path();
9373
let file_name = file.name.to_string_lossy().to_string();
9474
cmd.arg(file.name.to_path_buf().as_os_str());
9575
logs.push((
@@ -109,12 +89,6 @@ pub fn run_clang_format(
10989
task: format!("get fixes from clang-format {file_name}"),
11090
source: e,
11191
})?;
112-
fs::write(&cache_format_fixes, &output.stdout).map_err(|e| {
113-
ClangCaptureError::WriteFileFailed {
114-
file_name: cache_format_fixes.to_string_lossy().to_string(),
115-
source: e,
116-
}
117-
})?;
11892

11993
if !output.stderr.is_empty() || !output.status.success() {
12094
logs.push((
@@ -140,9 +114,7 @@ pub fn run_clang_format(
140114
source: e,
141115
}
142116
})?;
143-
let input = InternedInput::new(original_contents.as_str(), patched_contents.as_str());
144-
let mut diff = Diff::compute(gix_imara_diff::Algorithm::Histogram, &input);
145-
diff.postprocess_lines(&input);
117+
let (diff, _) = make_patch(&patched_contents, &original_contents);
146118
let format_advice = FormatAdvice {
147119
replacements: diff
148120
.hunks()
@@ -166,17 +138,184 @@ pub fn run_clang_format(
166138
}
167139
})
168140
.collect(),
169-
patched: cache_format_fixes,
170141
};
142+
143+
// if a clang-tidy patched file exists in cache,
144+
// get the diff between it and the original file,
145+
// then format both clang-tidy fixes and any other changes by clang-format fixes.
146+
if let Some(patched_path) = &file.patched_path
147+
&& patched_path.exists()
148+
{
149+
let mut cmd = Command::new(cmd_path);
150+
cmd.current_dir(&cache_path);
151+
// edit the clang-tody patched file in-place (`-i`)
152+
cmd.args(["--style", &clang_params.style, "-i"]);
153+
// if ranges is empty, then we're just formatting the entire file.
154+
if !ranges.is_empty() {
155+
let tidy_patch_contents = fs::read_to_string(patched_path).map_err(|e| {
156+
ClangCaptureError::ReadFileFailed {
157+
file_name: patched_path.to_string_lossy().to_string(),
158+
source: e,
159+
}
160+
})?;
161+
let (tidy_diff, _) = make_patch(&tidy_patch_contents, &original_contents);
162+
let joint_ranges = three_way_diff(&ranges, tidy_diff);
163+
for range in &joint_ranges {
164+
cmd.arg(format!("--lines={}:{}", range.start(), range.end()).as_str());
165+
}
166+
}
167+
cmd.arg(&file_name);
168+
let output = cmd
169+
.output()
170+
.map_err(|e| ClangCaptureError::FailedToRunCommand {
171+
task: format!("apply clang-format to clang-tidy fixes ({file_name})"),
172+
source: e,
173+
})?;
174+
if !output.stderr.is_empty() || !output.status.success() {
175+
logs.push((
176+
log::Level::Debug,
177+
format!(
178+
"clang-format raised the follow errors about clang-tidy fixes:\n{}",
179+
String::from_utf8_lossy(&output.stderr)
180+
),
181+
));
182+
}
183+
} else {
184+
// clang-tidy was not run on this file,
185+
// so just use the clang-format fixes as the patched content.
186+
let cache_format_fixes = cache_path.join(&file.name);
187+
fs::create_dir_all(
188+
cache_format_fixes
189+
.parent()
190+
.ok_or(ClangCaptureError::UnknownCacheParentPath)?,
191+
)
192+
.map_err(ClangCaptureError::MkDirFailed)?;
193+
fs::write(&cache_format_fixes, &output.stdout).map_err(|e| {
194+
ClangCaptureError::WriteFileFailed {
195+
file_name: cache_format_fixes.to_string_lossy().to_string(),
196+
source: e,
197+
}
198+
})?;
199+
file.patched_path = Some(cache_format_fixes);
200+
}
201+
171202
file.format_advice = Some(format_advice);
172203
Ok(logs)
173204
}
174205

206+
/// Essentially does a three way diff without the original source that generated the given `ranges` (simplified hunks).
207+
///
208+
/// The returned list of ranges are lines that need formatting in the clang-tidy patched file,
209+
/// provided by the `tidy_diff`. The given `ranges` are the line numbers in the original file
210+
/// that clang-tidy patched.
211+
fn three_way_diff(ranges: &[RangeInclusive<u32>], tidy_diff: Diff) -> Vec<RangeInclusive<u32>> {
212+
// We're concerned about the formatting cases:
213+
//
214+
// 1. changes that clang-tidy made: `tidy_diff.hunks().after`
215+
// 2. changes in the current CI event's diff (`ranges`)
216+
// that clang-tidy did not touch (`tidy_diff.hunks().before`)
217+
// 3. changes that do not overlap clang-tidy fixes: `ranges` - `tidy_diff.hunks().before`
218+
// 4. changes that overlap with clang-tidy fixes. This one is complex because
219+
// - tidy fixes can prefix an og range
220+
// - tidy fixes can suffix an og range
221+
// - tidy fixes can be contained within an og range
222+
// - multiple tidy fixes can (in order) suffix, be contained within, and prefix an og range
223+
let mut joint_ranges = vec![];
224+
let mut tidy_iter = tidy_diff.hunks().peekable();
225+
let mut line_shift = 0i32;
226+
227+
/// Prevent pure removals from causing invalid inclusive ranges.
228+
fn maybe_push_range(joint_ranges: &mut Vec<RangeInclusive<u32>>, start: u32, end: u32) {
229+
if start <= end {
230+
joint_ranges.push(RangeInclusive::new(start, end));
231+
}
232+
}
233+
234+
for og_range in ranges {
235+
let og_start = *og_range.start();
236+
let og_end = *og_range.end();
237+
238+
// track the start and end of a merged range that gets pushed into joint_ranges.
239+
let mut merged_start = (og_start as i32 + line_shift) as u32;
240+
let mut merged_end = (og_end as i32 + line_shift) as u32;
241+
242+
while let Some(tidy_hunk) = tidy_iter.peek() {
243+
// alias for readability and prevent some repeated calculations
244+
let before_start = tidy_hunk.before.start;
245+
let before_end = tidy_hunk.before.end.saturating_sub(1);
246+
let after_start = tidy_hunk.after.start;
247+
let after_end = tidy_hunk.after.end.saturating_sub(1);
248+
let delta = tidy_hunk.after.len() as i32 - tidy_hunk.before.len() as i32;
249+
250+
// The tidy hunk is a pure removal that exactly matches the og range.
251+
if tidy_hunk.is_pure_removal() && before_start == og_start && before_end == og_end {
252+
// Skip the og range and tidy hunk entirely.
253+
// The line shift must still be adjusted for the pure removal though
254+
line_shift += delta;
255+
merged_end = 0; // causes invalid inclusive range which does not get pushed.
256+
tidy_iter.next(); // skip this tidy hunk
257+
break; // skip og range and iterate to the next one.
258+
}
259+
260+
// tidy hunk is before the og range.
261+
if before_end < og_start {
262+
maybe_push_range(&mut joint_ranges, after_start, after_end);
263+
line_shift += delta;
264+
tidy_iter.next();
265+
continue;
266+
}
267+
268+
// tidy hunk is after the og range.
269+
if before_start > og_end {
270+
// handle the og range before iterating the next tidy hunk
271+
break;
272+
}
273+
274+
// tidy hunk overlaps with the og range in some way (case 4).
275+
if tidy_hunk.before.contains(&og_start) {
276+
merged_start = after_start;
277+
}
278+
279+
// commit the line shift now that the tidy hunk start is checked.
280+
line_shift += delta;
281+
282+
// tidy hunk suffixes the og range.
283+
if tidy_hunk.before.contains(&og_end) {
284+
merged_end = after_end;
285+
tidy_iter.next(); // this tidy hunk is handled.
286+
break; // break from loop to push the merged range into joint_ranges.
287+
}
288+
289+
// tidy hunk is contained within the og range.
290+
// so adjust the og range end accordingly and continue iterating tidy hunks
291+
merged_end = (og_end as i32 + line_shift) as u32;
292+
tidy_iter.next();
293+
}
294+
295+
maybe_push_range(&mut joint_ranges, merged_start, merged_end);
296+
}
297+
298+
// handle any remaining tidy hunks that are after all og ranges.
299+
for tidy_hunk in tidy_iter {
300+
maybe_push_range(
301+
&mut joint_ranges,
302+
tidy_hunk.after.start,
303+
tidy_hunk.after.end.saturating_sub(1),
304+
);
305+
}
306+
307+
joint_ranges
308+
}
309+
175310
#[cfg(test)]
176311
mod tests {
177312
#![allow(clippy::unwrap_used)]
178313

179-
use super::summarize_style;
314+
use std::ops::RangeInclusive;
315+
316+
use gix_imara_diff::{Diff, InternedInput};
317+
318+
use super::{summarize_style, three_way_diff};
180319

181320
fn formalize_style(style: &str, expected: &str) {
182321
assert_eq!(summarize_style(style), expected);
@@ -196,4 +335,42 @@ mod tests {
196335
fn formalize_custom_style() {
197336
formalize_style("file", "Custom");
198337
}
338+
339+
#[test]
340+
fn three_way_diff_mixed() {
341+
const OG_SRC: &str =
342+
"line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11";
343+
// TIDY_SRC replaces line3->StringA (hunk before=2..3) and
344+
// line8+line9+line10->StringB+StringC (hunk before=7..10), then appends StringE.
345+
// The second hunk's before=7..10 contains og_end=9 but not og_start=6,
346+
// which exercises the "tidy hunk suffixes og range" branch.
347+
const TIDY_SRC: &str =
348+
"line1\nline2\nStringA\nline4\nline5\nline6\nline7\nStringB\nStringC\nline11\nStringE";
349+
let input = InternedInput::new(OG_SRC, TIDY_SRC);
350+
let mut tidy_diff = Diff::compute(gix_imara_diff::Algorithm::Histogram, &input);
351+
tidy_diff.postprocess_lines(&input);
352+
let ranges = vec![RangeInclusive::new(2, 4), RangeInclusive::new(6, 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..=4, 6..=10]);
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" (index 2) which decrements offsets in ranges[5,8] and removes ranges[2,2].
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 input = InternedInput::new(OG_SRC, TIDY_SRC);
368+
let mut tidy_diff = Diff::compute(gix_imara_diff::Algorithm::Histogram, &input);
369+
tidy_diff.postprocess_lines(&input);
370+
let ranges = vec![2..=2, 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, 9..=10]);
375+
}
199376
}

cpp-linter/src/clang_tools/clang_tidy.rs

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@ use regex::Regex;
1515
use serde::Deserialize;
1616

1717
// project-specific modules/crates
18-
use super::MakeSuggestions;
19-
use crate::{
20-
clang_tools::CACHE_DIR, cli::ClangParams, common_fs::FileObj, error::ClangCaptureError,
21-
};
18+
use crate::{cli::ClangParams, common_fs::FileObj, error::ClangCaptureError};
2219

2320
/// Used to deserialize a json compilation database's translation unit.
2421
///
@@ -104,13 +101,10 @@ impl TidyNotification {
104101
pub struct TidyAdvice {
105102
/// A list of notifications parsed from clang-tidy stdout.
106103
pub notes: Vec<TidyNotification>,
107-
108-
/// A path to the cached contents of the file after applying clang-tidy fixes.
109-
pub patched: PathBuf,
110104
}
111105

112-
impl MakeSuggestions for TidyAdvice {
113-
fn get_suggestion_help(&self, start_line: u32, end_line: u32) -> String {
106+
impl TidyAdvice {
107+
pub(crate) fn get_suggestion_help(&self, start_line: u32, end_line: u32) -> String {
114108
let mut diagnostics = vec![];
115109
for note in &self.notes {
116110
for fixed_line in &note.fixed_lines {
@@ -133,10 +127,6 @@ impl MakeSuggestions for TidyAdvice {
133127
diagnostics.join("")
134128
)
135129
}
136-
137-
fn get_tool_name(&self) -> String {
138-
"clang-tidy".to_string()
139-
}
140130
}
141131

142132
/// A regex pattern to capture the clang-tidy note header.
@@ -336,10 +326,8 @@ pub fn run_clang_tidy(
336326
.join(" ")
337327
),
338328
));
339-
let cache_patch_path = clang_params
340-
.repo_root
341-
.join(CACHE_DIR)
342-
.join(file.name.with_added_extension("tidy"));
329+
let cache_path = clang_params.get_cache_path();
330+
let cache_patch_path = cache_path.join(&file.name);
343331
fs::create_dir_all(
344332
cache_patch_path
345333
.parent()
@@ -403,10 +391,8 @@ pub fn run_clang_tidy(
403391
&clang_params.repo_root,
404392
)?;
405393

406-
let tidy_advice = TidyAdvice {
407-
notes,
408-
patched: cache_patch_path.to_path_buf(),
409-
};
394+
let tidy_advice = TidyAdvice { notes };
395+
file.patched_path = Some(cache_patch_path.to_path_buf());
410396
file.tidy_advice = Some(tidy_advice);
411397
Ok(logs)
412398
}
@@ -539,6 +525,7 @@ mod test {
539525
repo_root: tmp_workspace.path().to_path_buf(),
540526
};
541527
let mut file_lock = arc_file.lock().unwrap();
528+
fs::create_dir_all(clang_params.get_cache_path()).unwrap();
542529
let logs = run_clang_tidy(&mut file_lock, &clang_params)
543530
.unwrap()
544531
.into_iter()

0 commit comments

Comments
 (0)