1use std::{
4 fmt::Debug,
5 fs,
6 io::Write,
7 num::NonZeroU32,
8 ops::RangeInclusive,
9 path::{Path, PathBuf},
10};
11
12use gix_imara_diff::{
13 BasicLineDiffPrinter, Diff, Hunk, InternedInput, UnifiedDiffConfig, UnifiedDiffPrinter,
14};
15
16use crate::{
17 clang_tools::{
18 ReviewComments, Suggestion, clang_format::FormatAdvice, clang_tidy::TidyAdvice, make_patch,
19 },
20 cli::{ClangParams, LinesChangedOnly},
21 error::FileObjError,
22};
23
24#[derive(Debug, Clone)]
26pub struct FileObj {
27 pub name: PathBuf,
29
30 pub added_lines: Vec<u32>,
32
33 pub added_ranges: Vec<RangeInclusive<u32>>,
35
36 pub diff_chunks: Vec<RangeInclusive<u32>>,
38
39 pub format_advice: Option<FormatAdvice>,
41
42 pub tidy_advice: Option<TidyAdvice>,
44
45 pub(crate) patched_path: Option<PathBuf>,
47}
48
49impl FileObj {
50 pub fn new(name: PathBuf) -> Self {
54 FileObj {
55 name,
56 added_lines: Vec::<u32>::new(),
57 added_ranges: Vec::<RangeInclusive<u32>>::new(),
58 diff_chunks: Vec::<RangeInclusive<u32>>::new(),
59 format_advice: None,
60 tidy_advice: None,
61 patched_path: None,
62 }
63 }
64
65 pub fn from(
67 name: PathBuf,
68 added_lines: Vec<u32>,
69 diff_chunks: Vec<RangeInclusive<u32>>,
70 ) -> Self {
71 let added_lines: Vec<NonZeroU32> = added_lines
73 .into_iter()
74 .filter_map(NonZeroU32::new)
75 .collect();
76 let added_ranges = FileObj::consolidate_numbers_to_ranges(&added_lines);
77 FileObj {
78 name,
79 added_lines: added_lines.into_iter().map(|v| v.get()).collect(),
80 added_ranges,
81 diff_chunks,
82 format_advice: None,
83 tidy_advice: None,
84 patched_path: None,
85 }
86 }
87
88 fn consolidate_numbers_to_ranges(lines: &[NonZeroU32]) -> Vec<RangeInclusive<u32>> {
92 let mut ranges: Vec<RangeInclusive<u32>> = Vec::new();
93 let mut line_iter = lines.iter().enumerate();
94 let mut range_start = match line_iter.next() {
95 Some((_, number)) => number.get(),
96 None => return ranges, };
98 let last_index = lines.len() - 1;
100 if last_index == 0 {
101 ranges.push(RangeInclusive::new(range_start, range_start));
103 return ranges;
104 }
105 for (index, number) in line_iter {
106 if let Some(prev_line) = lines.get(index - 1)
109 && number.get() - 1 != prev_line.get()
110 {
111 ranges.push(RangeInclusive::new(range_start, prev_line.get()));
112 range_start = number.get();
113 }
114 if index == last_index {
115 ranges.push(RangeInclusive::new(range_start, number.get()));
116 }
117 }
118 ranges
119 }
120
121 pub fn get_ranges(&self, lines_changed_only: &LinesChangedOnly) -> Vec<RangeInclusive<u32>> {
124 match lines_changed_only {
125 LinesChangedOnly::Diff => self.diff_chunks.to_vec(),
126 LinesChangedOnly::On => self.added_ranges.to_vec(),
127 _ => Vec::new(),
128 }
129 }
130
131 pub fn is_hunk_in_diff(&self, hunk: &Hunk) -> Option<(u32, u32)> {
134 let (start_line, end_line) = if !hunk.before.is_empty() {
135 let start = hunk.before.start.saturating_add(1); (start, hunk.before.start + hunk.before.len() as u32)
138 } else {
139 let start = hunk.after.start.saturating_add(1); (start, start)
143 };
144 for range in &self.diff_chunks {
145 if range.contains(&start_line) && range.contains(&end_line) {
146 return Some((start_line, end_line));
147 }
148 }
149 None
150 }
151
152 fn is_line_in_diff(&self, line: &u32) -> bool {
158 for range in &self.diff_chunks {
159 if range.contains(line) {
160 return true;
161 }
162 }
163 false
164 }
165
166 pub fn maybe_append_patch(&self, repo_root: &Path) -> Result<(), FileObjError> {
173 let patched = match &self.patched_path {
174 Some(patched_path) if patched_path.exists() => {
175 fs::read_to_string(patched_path).map_err(FileObjError::ReadFile)?
176 }
177 _ => return Ok(()),
178 };
179 let original_content =
180 fs::read_to_string(repo_root.join(&self.name)).map_err(FileObjError::ReadFile)?;
181 let (diff, input) = make_patch(patched.as_str(), &original_content);
182 let file_name = self.name.to_string_lossy().replace("\\", "/");
183 Self::append_patch(&file_name, &input, &diff, repo_root)?;
184 Ok(())
185 }
186
187 fn append_patch(
189 file_name: &str,
190 input: &InternedInput<&str>,
191 diff: &Diff,
192 repo_root: &Path,
193 ) -> Result<(), FileObjError> {
194 let printer = BasicLineDiffPrinter(&input.interner);
195 let diff_config = UnifiedDiffConfig::default();
196 let unified_diff = diff.unified_diff(&printer, diff_config, input).to_string();
197 if !unified_diff.is_empty() {
198 let patch_path_parent = repo_root.join(ClangParams::CACHE_DIR);
199 fs::create_dir_all(&patch_path_parent).map_err(FileObjError::MkDirFailed)?;
200 let patch_file_path = patch_path_parent.join(ClangParams::AUTO_FIX_PATCH);
201 let mut patch_file = fs::OpenOptions::new()
202 .append(true)
203 .create(true)
204 .truncate(false)
205 .open(&patch_file_path)
206 .map_err(FileObjError::OpenPatchFileFailed)?;
207 patch_file
208 .write_all(
209 format!("--- a/{file_name}\n+++ b/{file_name}\n{unified_diff}").as_bytes(),
210 )
211 .map_err(FileObjError::WritePatchFailed)?;
212 }
213 Ok(())
214 }
215
216 pub fn make_suggestions_from_patch(
223 &self,
224 review_comments: &mut ReviewComments,
225 summary_only: bool,
226 repo_root: &Path,
227 ) -> Result<(), FileObjError> {
228 let patched = match &self.patched_path {
229 Some(patched_path) if patched_path.exists() => {
230 fs::read_to_string(patched_path).map_err(FileObjError::ReadFile)?
231 }
232 _ => return Ok(()),
233 };
234 let original_content =
235 fs::read_to_string(repo_root.join(&self.name)).map_err(FileObjError::ReadFile)?;
236 let (diff, input) = make_patch(patched.as_str(), &original_content);
237 let file_name = self.name.to_string_lossy().replace("\\", "/");
238 Self::append_patch(&file_name, &input, &diff, repo_root)?;
239
240 self.get_suggestions(review_comments, &diff, &input, summary_only)
241 .map_err(FileObjError::DisplayStringFailed)?;
242 if let Some(advice) = &self.tidy_advice {
243 let file_ext = self
245 .name
246 .extension()
247 .unwrap_or_default()
248 .to_str()
249 .unwrap_or_default();
250 let mut total = 0;
252 for note in &advice.notes {
253 if note.fixed_lines.is_empty() && self.is_line_in_diff(¬e.line) {
254 total += 1;
256 if summary_only {
257 continue;
258 }
259 let mut suggestion = format!(
260 "### clang-tidy diagnostic\n**{file_name}:{}:{}** {}: [{}]\n\n> {}\n",
261 note.line,
262 note.cols,
263 note.severity,
264 note.diagnostic_link(),
265 note.rationale
266 );
267 if !note.suggestion.is_empty() {
268 suggestion.push_str(
269 format!("\n```{file_ext}\n{}\n```\n", note.suggestion.join("\n"))
270 .as_str(),
271 );
272 }
273 let mut is_merged = false;
274 for s in &mut review_comments.comments {
275 if s.path == file_name
276 && s.line_end >= note.line
277 && s.line_start <= note.line
278 {
279 s.suggestion.push_str(suggestion.as_str());
280 is_merged = true;
281 break;
282 }
283 }
284 if !is_merged {
285 review_comments.comments.push(Suggestion {
286 line_start: note.line,
287 line_end: note.line,
288 suggestion,
289 path: file_name.to_owned(),
290 });
291 }
292 }
293 }
294 review_comments.tool_total += total;
295 }
296 Ok(())
297 }
298
299 fn get_suggestions(
301 &self,
302 review_comments: &mut ReviewComments,
303 diff: &Diff,
304 input: &InternedInput<&str>,
305 summary_only: bool,
306 ) -> Result<(), std::fmt::Error> {
307 let file_name = self
308 .name
309 .to_string_lossy()
310 .replace("\\", "/")
311 .trim_start_matches("./")
312 .to_owned();
313 let mut config = UnifiedDiffConfig::default();
314 config.context_len(0);
315 let printer = BasicLineDiffPrinter(&input.interner);
316 let mut patch_buff = String::new();
317 let mut hunks_in_patch = 0u32;
318 for hunk in diff.hunks() {
319 hunks_in_patch += 1;
320 let hunk_range = self.is_hunk_in_diff(&hunk);
321 match hunk_range {
322 Some((start_line, end_line)) if !summary_only => {
323 let mut suggestion = String::new();
324 let suggestion_help = self
325 .tidy_advice
326 .as_ref()
327 .map(|t| t.get_suggestion_help(start_line, end_line))
328 .unwrap_or_default();
329 if hunk.is_pure_removal() {
330 suggestion.push_str(
331 format!(
332 "Please remove the line(s)\n- {}",
333 hunk.before
334 .map(|l| l.saturating_add(1).to_string())
335 .collect::<Vec<String>>()
336 .join("\n- ")
337 )
338 .as_str(),
339 );
340 } else {
341 suggestion.push_str("```suggestion\n");
342 for token in
343 &input.after[hunk.after.start as usize..hunk.after.end as usize]
344 {
345 let line = &input.interner[*token];
346 suggestion.push_str(line);
347 }
348 suggestion.push_str("```\n");
349 }
350 let comment = Suggestion {
351 line_start: start_line,
352 line_end: end_line,
353 suggestion: format!("{suggestion_help}\n{suggestion}"),
354 path: file_name.clone(),
355 };
356 if !review_comments.is_comment_in_suggestions(&comment) {
357 review_comments.comments.push(comment);
358 }
359 }
360 _ => {
361 printer.display_header(
362 &mut patch_buff,
363 hunk.before.start,
364 hunk.after.start,
365 hunk.before.len() as u32,
366 hunk.after.len() as u32,
367 )?;
368 printer.display_hunk(
369 &mut patch_buff,
370 &input.before[hunk.before.start as usize..hunk.before.end as usize],
371 &input.after[hunk.after.start as usize..hunk.after.end as usize],
372 )?;
373 }
374 }
375 }
376 if !patch_buff.is_empty() {
377 let patch_buf = format!("--- a/{file_name}\n+++ b/{file_name}\n{patch_buff}");
378 review_comments.full_patch.push_str(patch_buf.as_str());
379 }
380 review_comments.tool_total += hunks_in_patch;
381 Ok(())
382 }
383}
384
385#[cfg(any(test, feature = "bin"))]
394pub(crate) fn mk_path_abs<P: AsRef<Path>>(path: P) -> Result<PathBuf, std::io::Error> {
395 let abs_path = path.as_ref().canonicalize()?;
396 let abs_path_str = abs_path.to_string_lossy();
397 Ok(PathBuf::from(abs_path_str.trim_start_matches(r"\\?\")))
398}
399
400#[cfg(test)]
401mod test {
402 #![allow(clippy::unwrap_used)]
403 use std::{fs, path::PathBuf};
404
405 use tempfile::{NamedTempFile, TempDir};
406
407 use super::FileObj;
408 use crate::{clang_tools::ReviewComments, cli::LinesChangedOnly};
409
410 #[test]
413 fn get_ranges_none() {
414 let file_obj = FileObj::new(PathBuf::from("tests/demo/demo.cpp"));
415 let ranges = file_obj.get_ranges(&LinesChangedOnly::Off);
416 assert!(ranges.is_empty());
417 }
418
419 #[test]
420 fn get_ranges_diff() {
421 let diff_chunks = vec![1..=10];
422 let added_lines = vec![4, 5, 9];
423 let file_obj = FileObj::from(
424 PathBuf::from("tests/demo/demo.cpp"),
425 added_lines,
426 diff_chunks.clone(),
427 );
428 let ranges = file_obj.get_ranges(&LinesChangedOnly::Diff);
429 assert_eq!(ranges, diff_chunks);
430 }
431
432 #[test]
433 fn get_ranges_added() {
434 let diff_chunks = vec![1..=10];
435 let added_lines = vec![4, 5, 9];
436 let file_obj = FileObj::from(
437 PathBuf::from("tests/demo/demo.cpp"),
438 added_lines,
439 diff_chunks,
440 );
441 let ranges = file_obj.get_ranges(&LinesChangedOnly::On);
442 assert_eq!(ranges, vec![4..=5, 9..=9]);
443 }
444
445 #[test]
446 fn get_ranges_single_added_line() {
447 let added_lines = vec![5];
448 let file_obj = FileObj::from(PathBuf::from("tests/demo/demo.cpp"), added_lines, vec![]);
449 let ranges = file_obj.get_ranges(&LinesChangedOnly::On);
450 assert_eq!(ranges, vec![5..=5]);
451 }
452
453 #[test]
454 fn line_not_in_diff() {
455 let file_obj = FileObj::new(PathBuf::from("tests/demo/demo.cpp"));
456 assert!(!file_obj.is_line_in_diff(&42));
457 }
458
459 #[test]
460 fn canonical_path() {
461 let file_obj = FileObj::new(PathBuf::from("tests/demo/demo.cpp"));
462 let canonical_path = super::mk_path_abs(&file_obj.name).unwrap();
463 assert!(canonical_path.is_file());
464 println!("Canonical path: {}", canonical_path.display());
465 assert!(!canonical_path.to_string_lossy().starts_with(r"\\?\"));
466 }
467
468 #[test]
469 fn pure_removal_suggestion() {
470 let repo_root = TempDir::new().unwrap();
471 let file_name = PathBuf::from("test_file.cpp");
472
473 let original_content = "line1\nline2\nline3\n";
475 fs::write(repo_root.path().join(&file_name), original_content).unwrap();
476
477 let patched_content = "line1\nline3\n";
479 let patched_file = NamedTempFile::new().unwrap();
480 fs::write(patched_file.path(), patched_content).unwrap();
481
482 let mut file_obj = FileObj::from(file_name, vec![2], vec![2..=2]);
484 file_obj.patched_path = Some(patched_file.path().to_path_buf());
485
486 let mut review_comments = ReviewComments::default();
487 file_obj
488 .make_suggestions_from_patch(&mut review_comments, false, repo_root.path())
489 .unwrap();
490
491 assert_eq!(review_comments.comments.len(), 1);
492 let suggestion = &review_comments.comments[0].suggestion;
493 assert!(
494 suggestion.contains("Please remove the line(s)\n- 2"),
495 "unexpected suggestion: {suggestion}"
496 );
497 }
498}