Skip to main content

cpp_linter/
common_fs.rs

1//! A module to hold all common file system functionality.
2
3use 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/// A structure to represent a file's path and line changes.
25#[derive(Debug, Clone)]
26pub struct FileObj {
27    /// The path to the file.
28    pub name: PathBuf,
29
30    /// The list of lines with additions.
31    pub added_lines: Vec<u32>,
32
33    /// The list of ranges that span only lines with additions.
34    pub added_ranges: Vec<RangeInclusive<u32>>,
35
36    /// The list of ranges that span the lines present in diff chunks.
37    pub diff_chunks: Vec<RangeInclusive<u32>>,
38
39    /// The collection of clang-format advice for this file.
40    pub format_advice: Option<FormatAdvice>,
41
42    /// The collection of clang-format advice for this file.
43    pub tidy_advice: Option<TidyAdvice>,
44
45    /// A path to a cached file with all/any patches applied.
46    pub(crate) patched_path: Option<PathBuf>,
47}
48
49impl FileObj {
50    /// Instantiate a rudimentary object with only file name information.
51    ///
52    /// To instantiate an object with line information, use [`FileObj::from`].
53    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    /// Instantiate an object with file name and changed lines information.
66    pub fn from(
67        name: PathBuf,
68        added_lines: Vec<u32>,
69        diff_chunks: Vec<RangeInclusive<u32>>,
70    ) -> Self {
71        // filter out any line numbers that are 0 since line numbers are always 1-indexed in diffs
72        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    /// A helper function to consolidate a [Vec<u32>] of line numbers into a
89    /// [Vec<RangeInclusive<u32>>] in which each range describes the beginning and
90    /// ending of a group of consecutive line numbers.
91    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, // return empty vector if no lines
97        };
98        // lines.len() cannot be 0 at this point
99        let last_index = lines.len() - 1;
100        if last_index == 0 {
101            // Single element case: push range and return
102            ranges.push(RangeInclusive::new(range_start, range_start));
103            return ranges;
104        }
105        for (index, number) in line_iter {
106            // use let chain to avoid repeated lookup of lines[index - 1].
107            // should always yield some value since we entered the for loop at index 1.
108            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    /// Get the list of line ranges to consider based on the given
122    /// [`LinesChangedOnly`] configuration.
123    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    /// Is the range from `start_line` to `end_line` contained in a single item of
132    /// [`FileObj::diff_chunks`]?
133    pub fn is_hunk_in_diff(&self, hunk: &Hunk) -> Option<(u32, u32)> {
134        let (start_line, end_line) = if !hunk.before.is_empty() {
135            // if old hunk's total lines is > 0
136            let start = hunk.before.start.saturating_add(1); // convert to 1-based line numbers
137            (start, hunk.before.start + hunk.before.len() as u32)
138        } else {
139            // old hunk's total lines is 0, meaning changes were only added
140            let start = hunk.after.start.saturating_add(1); // convert to 1-based line numbers
141            // make old hunk's range span 1 line
142            (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    /// Similar to [`FileObj::is_hunk_in_diff()`] but looks for a single line instead of
153    /// an entire [`DiffHunk`].
154    ///
155    /// This is a private function because it is only used in
156    /// [`FileObj::make_suggestions_from_patch()`].
157    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    /// If the file has a cached fixes, then append them to a unified patched file.
167    ///
168    /// This is the alternative to [`FileObj::make_suggestions_from_patch()`] when
169    /// a PR review is not being posted. Both function have to create a patch by
170    /// reading the original file and patched file (in cache), but
171    /// [`FileObj::make_suggestions_from_patch()`] does more with the diff than this function.
172    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    /// write fixes to a unified patch file in the cache directory.
188    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    /// Create a list of [`Suggestion`](struct@crate::clang_tools::Suggestion) from a
217    /// generated diff and store them in the given
218    /// [`ReviewComments`](struct@crate::clang_tools::ReviewComments).
219    ///
220    /// The suggestions will also include diagnostics from clang-tidy that
221    /// did not have a fix applied in the patch.
222    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            // now check for clang-tidy warnings with no fixes applied
244            let file_ext = self
245                .name
246                .extension()
247                .unwrap_or_default()
248                .to_str()
249                .unwrap_or_default();
250            // Count of clang-tidy diagnostics that had no fixes applied
251            let mut total = 0;
252            for note in &advice.notes {
253                if note.fixed_lines.is_empty() && self.is_line_in_diff(&note.line) {
254                    // notification had no suggestion applied in `patched`
255                    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    /// Create a bunch of suggestions from a [`FileObj`]'s advice's generated `patched` buffer.
300    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/// Make the given `path` absolute.
386///
387/// This function will canonicalize the path and remove any `\\?\` prefix
388/// returned by Windows API, which is really only designed for other Windows API.
389///
390/// This function can error if the given path fails to be canonicalized.
391/// This may happen if the path does not exist or a non-final path
392/// component is not a directory.
393#[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    // *********************** tests for FileObj::get_ranges()
411
412    #[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        // Write original file with 3 lines
474        let original_content = "line1\nline2\nline3\n";
475        fs::write(repo_root.path().join(&file_name), original_content).unwrap();
476
477        // Patched file has line2 removed
478        let patched_content = "line1\nline3\n";
479        let patched_file = NamedTempFile::new().unwrap();
480        fs::write(patched_file.path(), patched_content).unwrap();
481
482        // line2 is 1-indexed line 2; diff_chunks must contain it
483        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}