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;
137            (start, start + hunk.before.len() as u32 - 1)
138        } else {
139            // old hunk's total lines is 0, meaning changes were only added
140            let start = hunk.after.start;
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 mut diff_config = UnifiedDiffConfig::default();
196        diff_config.context_len(0);
197        let unified_diff = diff.unified_diff(&printer, diff_config, input).to_string();
198        if !unified_diff.is_empty() {
199            let patch_path_parent = repo_root.join(ClangParams::CACHE_DIR);
200            fs::create_dir_all(&patch_path_parent).map_err(FileObjError::MkDirFailed)?;
201            let patch_file_path = patch_path_parent.join(ClangParams::AUTO_FIX_PATCH);
202            let mut patch_file = fs::OpenOptions::new()
203                .append(true)
204                .create(true)
205                .truncate(false)
206                .open(&patch_file_path)
207                .map_err(FileObjError::OpenPatchFileFailed)?;
208            patch_file
209                .write_all(
210                    format!("--- a/{file_name}\n+++ b/{file_name}\n{unified_diff}",).as_bytes(),
211                )
212                .map_err(FileObjError::WritePatchFailed)?;
213        }
214        Ok(())
215    }
216
217    /// Create a list of [`Suggestion`](struct@crate::clang_tools::Suggestion) from a
218    /// generated diff and store them in the given
219    /// [`ReviewComments`](struct@crate::clang_tools::ReviewComments).
220    ///
221    /// The suggestions will also include diagnostics from clang-tidy that
222    /// did not have a fix applied in the patch.
223    pub fn make_suggestions_from_patch(
224        &self,
225        review_comments: &mut ReviewComments,
226        summary_only: bool,
227        repo_root: &Path,
228    ) -> Result<(), FileObjError> {
229        let patched = match &self.patched_path {
230            Some(patched_path) if patched_path.exists() => {
231                fs::read_to_string(patched_path).map_err(FileObjError::ReadFile)?
232            }
233            _ => return Ok(()),
234        };
235        let original_content =
236            fs::read_to_string(repo_root.join(&self.name)).map_err(FileObjError::ReadFile)?;
237        let (diff, input) = make_patch(patched.as_str(), &original_content);
238        let file_name = self.name.to_string_lossy().replace("\\", "/");
239        Self::append_patch(&file_name, &input, &diff, repo_root)?;
240
241        self.get_suggestions(review_comments, &diff, &input, summary_only)
242            .map_err(FileObjError::DisplayStringFailed)?;
243        if let Some(advice) = &self.tidy_advice {
244            // now check for clang-tidy warnings with no fixes applied
245            let file_ext = self
246                .name
247                .extension()
248                .unwrap_or_default()
249                .to_str()
250                .unwrap_or_default();
251            // Count of clang-tidy diagnostics that had no fixes applied
252            let mut total = 0;
253            for note in &advice.notes {
254                if note.fixed_lines.is_empty() && self.is_line_in_diff(&note.line) {
255                    // notification had no suggestion applied in `patched`
256                    total += 1;
257                    if summary_only {
258                        continue;
259                    }
260                    let mut suggestion = format!(
261                        "### clang-tidy diagnostic\n**{file_name}:{}:{}** {}: [{}]\n\n> {}\n",
262                        &note.line,
263                        &note.cols,
264                        &note.severity,
265                        note.diagnostic_link(),
266                        &note.rationale
267                    );
268                    if !note.suggestion.is_empty() {
269                        suggestion.push_str(
270                            format!("\n```{file_ext}\n{}\n```\n", &note.suggestion.join("\n"))
271                                .as_str(),
272                        );
273                    }
274                    let mut is_merged = false;
275                    for s in &mut review_comments.comments {
276                        if s.path == file_name
277                            && s.line_end >= note.line
278                            && s.line_start <= note.line
279                        {
280                            s.suggestion.push_str(suggestion.as_str());
281                            is_merged = true;
282                            break;
283                        }
284                    }
285                    if !is_merged {
286                        review_comments.comments.push(Suggestion {
287                            line_start: note.line,
288                            line_end: note.line,
289                            suggestion,
290                            path: file_name.to_owned(),
291                        });
292                    }
293                }
294            }
295            review_comments.tool_total += total;
296        }
297        Ok(())
298    }
299
300    /// Create a bunch of suggestions from a [`FileObj`]'s advice's generated `patched` buffer.
301    fn get_suggestions(
302        &self,
303        review_comments: &mut ReviewComments,
304        diff: &Diff,
305        input: &InternedInput<&str>,
306        summary_only: bool,
307    ) -> Result<(), std::fmt::Error> {
308        let file_name = self
309            .name
310            .to_string_lossy()
311            .replace("\\", "/")
312            .trim_start_matches("./")
313            .to_owned();
314        let mut config = UnifiedDiffConfig::default();
315        config.context_len(0);
316        let printer = BasicLineDiffPrinter(&input.interner);
317        let mut patch_buff = String::new();
318        let mut hunks_in_patch = 0u32;
319        for hunk in diff.hunks() {
320            hunks_in_patch += 1;
321            let hunk_range = self.is_hunk_in_diff(&hunk);
322            match hunk_range {
323                Some((start_line, end_line)) if !summary_only => {
324                    let mut suggestion = String::new();
325                    let suggestion_help = self
326                        .tidy_advice
327                        .as_ref()
328                        .map(|t| t.get_suggestion_help(start_line, end_line))
329                        .unwrap_or_default();
330                    if hunk.is_pure_removal() {
331                        suggestion.push_str(
332                            format!(
333                                "Please remove the line(s)\n- {}",
334                                hunk.before
335                                    .map(|l| l.to_string())
336                                    .collect::<Vec<String>>()
337                                    .join("\n- ")
338                            )
339                            .as_str(),
340                        );
341                    } else {
342                        suggestion.push_str("```suggestion\n");
343                        for token in
344                            &input.after[hunk.after.start as usize..hunk.after.end as usize]
345                        {
346                            let line = &input.interner[*token];
347                            suggestion.push_str(line);
348                        }
349                        suggestion.push_str("```\n");
350                    }
351                    let comment = Suggestion {
352                        line_start: start_line,
353                        line_end: end_line,
354                        suggestion: format!("{suggestion_help}\n{suggestion}"),
355                        path: file_name.clone(),
356                    };
357                    if !review_comments.is_comment_in_suggestions(&comment) {
358                        review_comments.comments.push(comment);
359                    }
360                }
361                _ => {
362                    printer.display_header(
363                        &mut patch_buff,
364                        hunk.before.start,
365                        hunk.after.start,
366                        hunk.before.len() as u32,
367                        hunk.after.len() as u32,
368                    )?;
369                    printer.display_hunk(
370                        &mut patch_buff,
371                        &input.before[hunk.before.start as usize..hunk.before.end as usize],
372                        &input.after[hunk.after.start as usize..hunk.after.end as usize],
373                    )?;
374                }
375            }
376        }
377        if !patch_buff.is_empty() {
378            let patch_buf = format!("--- a/{file_name}\n+++ b/{file_name}\n{patch_buff}");
379            review_comments.full_patch.push_str(patch_buf.as_str());
380        }
381        review_comments.tool_total += hunks_in_patch;
382        Ok(())
383    }
384}
385
386#[cfg(test)]
387mod test {
388    use std::path::PathBuf;
389
390    use super::FileObj;
391    use crate::cli::LinesChangedOnly;
392
393    // *********************** tests for FileObj::get_ranges()
394
395    #[test]
396    fn get_ranges_none() {
397        let file_obj = FileObj::new(PathBuf::from("tests/demo/demo.cpp"));
398        let ranges = file_obj.get_ranges(&LinesChangedOnly::Off);
399        assert!(ranges.is_empty());
400    }
401
402    #[test]
403    fn get_ranges_diff() {
404        let diff_chunks = vec![1..=10];
405        let added_lines = vec![4, 5, 9];
406        let file_obj = FileObj::from(
407            PathBuf::from("tests/demo/demo.cpp"),
408            added_lines,
409            diff_chunks.clone(),
410        );
411        let ranges = file_obj.get_ranges(&LinesChangedOnly::Diff);
412        assert_eq!(ranges, diff_chunks);
413    }
414
415    #[test]
416    fn get_ranges_added() {
417        let diff_chunks = vec![1..=10];
418        let added_lines = vec![4, 5, 9];
419        let file_obj = FileObj::from(
420            PathBuf::from("tests/demo/demo.cpp"),
421            added_lines,
422            diff_chunks,
423        );
424        let ranges = file_obj.get_ranges(&LinesChangedOnly::On);
425        assert_eq!(ranges, vec![4..=5, 9..=9]);
426    }
427
428    #[test]
429    fn get_ranges_single_added_line() {
430        let added_lines = vec![5];
431        let file_obj = FileObj::from(PathBuf::from("tests/demo/demo.cpp"), added_lines, vec![]);
432        let ranges = file_obj.get_ranges(&LinesChangedOnly::On);
433        assert_eq!(ranges, vec![5..=5]);
434    }
435
436    #[test]
437    fn line_not_in_diff() {
438        let file_obj = FileObj::new(PathBuf::from("tests/demo/demo.cpp"));
439        assert!(!file_obj.is_line_in_diff(&42));
440    }
441}