Skip to main content

cpp_linter/common_fs/
mod.rs

1//! A module to hold all common file system functionality.
2
3use std::{
4    fmt::Debug,
5    fs,
6    ops::RangeInclusive,
7    path::{Path, PathBuf},
8};
9
10use crate::{
11    clang_tools::{
12        MakeSuggestions, ReviewComments, Suggestion, clang_format::FormatAdvice,
13        clang_tidy::TidyAdvice, make_patch,
14    },
15    cli::LinesChangedOnly,
16    error::FileObjError,
17};
18use git2::DiffHunk;
19
20/// A structure to represent a file's path and line changes.
21#[derive(Debug, Clone)]
22pub struct FileObj {
23    /// The path to the file.
24    pub name: PathBuf,
25
26    /// The list of lines with additions.
27    pub added_lines: Vec<u32>,
28
29    /// The list of ranges that span only lines with additions.
30    pub added_ranges: Vec<RangeInclusive<u32>>,
31
32    /// The list of ranges that span the lines present in diff chunks.
33    pub diff_chunks: Vec<RangeInclusive<u32>>,
34
35    /// The collection of clang-format advice for this file.
36    pub format_advice: Option<FormatAdvice>,
37
38    /// The collection of clang-format advice for this file.
39    pub tidy_advice: Option<TidyAdvice>,
40}
41
42impl FileObj {
43    /// Instantiate a rudimentary object with only file name information.
44    ///
45    /// To instantiate an object with line information, use [`FileObj::from`].
46    pub fn new(name: PathBuf) -> Self {
47        FileObj {
48            name,
49            added_lines: Vec::<u32>::new(),
50            added_ranges: Vec::<RangeInclusive<u32>>::new(),
51            diff_chunks: Vec::<RangeInclusive<u32>>::new(),
52            format_advice: None,
53            tidy_advice: None,
54        }
55    }
56
57    /// Instantiate an object with file name and changed lines information.
58    pub fn from(
59        name: PathBuf,
60        added_lines: Vec<u32>,
61        diff_chunks: Vec<RangeInclusive<u32>>,
62    ) -> Self {
63        let added_ranges = FileObj::consolidate_numbers_to_ranges(&added_lines);
64        FileObj {
65            name,
66            added_lines,
67            added_ranges,
68            diff_chunks,
69            format_advice: None,
70            tidy_advice: None,
71        }
72    }
73
74    /// A helper function to consolidate a [Vec<u32>] of line numbers into a
75    /// [Vec<RangeInclusive<u32>>] in which each range describes the beginning and
76    /// ending of a group of consecutive line numbers.
77    fn consolidate_numbers_to_ranges(lines: &[u32]) -> Vec<RangeInclusive<u32>> {
78        let mut range_start = None;
79        let mut ranges: Vec<RangeInclusive<u32>> = Vec::new();
80        for (index, number) in lines.iter().enumerate() {
81            if index == 0 {
82                range_start = Some(*number);
83            } else if number - 1 != lines[index - 1] {
84                ranges.push(RangeInclusive::new(range_start.unwrap(), lines[index - 1]));
85                range_start = Some(*number);
86            }
87            if index == lines.len() - 1 {
88                ranges.push(RangeInclusive::new(range_start.unwrap(), *number));
89            }
90        }
91        ranges
92    }
93
94    pub fn get_ranges(&self, lines_changed_only: &LinesChangedOnly) -> Vec<RangeInclusive<u32>> {
95        match lines_changed_only {
96            LinesChangedOnly::Diff => self.diff_chunks.to_vec(),
97            LinesChangedOnly::On => self.added_ranges.to_vec(),
98            _ => Vec::new(),
99        }
100    }
101
102    /// Is the range from `start_line` to `end_line` contained in a single item of
103    /// [`FileObj::diff_chunks`]?
104    pub fn is_hunk_in_diff(&self, hunk: &DiffHunk) -> Option<(u32, u32)> {
105        let (start_line, end_line) = if hunk.old_lines() > 0 {
106            // if old hunk's total lines is > 0
107            let start = hunk.old_start();
108            (start, start + hunk.old_lines() - 1)
109        } else {
110            // old hunk's total lines is 0, meaning changes were only added
111            let start = hunk.new_start();
112            // make old hunk's range span 1 line
113            (start, start)
114        };
115        for range in &self.diff_chunks {
116            if range.contains(&start_line) && range.contains(&end_line) {
117                return Some((start_line, end_line));
118            }
119        }
120        None
121    }
122
123    /// Similar to [`FileObj::is_hunk_in_diff()`] but looks for a single line instead of
124    /// an entire [`DiffHunk`].
125    ///
126    /// This is a private function because it is only used in
127    /// [`FileObj::make_suggestions_from_patch()`].
128    fn is_line_in_diff(&self, line: &u32) -> bool {
129        for range in &self.diff_chunks {
130            if range.contains(line) {
131                return true;
132            }
133        }
134        false
135    }
136
137    /// Create a list of [`Suggestion`](struct@crate::clang_tools::Suggestion) from a
138    /// generated [`Patch`](struct@git2::Patch) and store them in the given
139    /// [`ReviewComments`](struct@crate::clang_tools::ReviewComments).
140    ///
141    /// The suggestions will also include diagnostics from clang-tidy that
142    /// did not have a fix applied in the patch.
143    pub fn make_suggestions_from_patch(
144        &self,
145        review_comments: &mut ReviewComments,
146        summary_only: bool,
147    ) -> Result<(), FileObjError> {
148        let original_content = fs::read(&self.name).map_err(FileObjError::ReadFile)?;
149        let file_name = self.name.to_str().unwrap_or_default().replace("\\", "/");
150        let file_path = Path::new(&file_name);
151        if let Some(advice) = &self.format_advice
152            && let Some(patched) = &advice.patched
153        {
154            let mut patch = make_patch(file_path, patched, &original_content)
155                .map_err(|e| FileObjError::MakePatchFailed(file_name.clone(), e))?;
156            advice.get_suggestions(review_comments, self, &mut patch, summary_only)?;
157        }
158
159        if let Some(advice) = &self.tidy_advice {
160            if let Some(patched) = &advice.patched {
161                let mut patch = make_patch(file_path, patched, &original_content)
162                    .map_err(|e| FileObjError::MakePatchFailed(file_name.clone(), e))?;
163                advice.get_suggestions(review_comments, self, &mut patch, summary_only)?;
164            }
165
166            if summary_only {
167                return Ok(());
168            }
169
170            // now check for clang-tidy warnings with no fixes applied
171            let file_ext = self
172                .name
173                .extension()
174                .unwrap_or_default()
175                .to_str()
176                .unwrap_or_default();
177            // Count of clang-tidy diagnostics that had no fixes applied
178            let mut total = 0;
179            for note in &advice.notes {
180                if note.fixed_lines.is_empty() && self.is_line_in_diff(&note.line) {
181                    // notification had no suggestion applied in `patched`
182                    let mut suggestion = format!(
183                        "### clang-tidy diagnostic\n**{file_name}:{}:{}** {}: [{}]\n\n> {}\n",
184                        &note.line,
185                        &note.cols,
186                        &note.severity,
187                        note.diagnostic_link(),
188                        &note.rationale
189                    );
190                    if !note.suggestion.is_empty() {
191                        suggestion.push_str(
192                            format!("\n```{file_ext}\n{}\n```\n", &note.suggestion.join("\n"))
193                                .as_str(),
194                        );
195                    }
196                    total += 1;
197                    let mut is_merged = false;
198                    for s in &mut review_comments.comments {
199                        if s.path == file_name
200                            && s.line_end >= note.line
201                            && s.line_start <= note.line
202                        {
203                            s.suggestion.push_str(suggestion.as_str());
204                            is_merged = true;
205                            break;
206                        }
207                    }
208                    if !is_merged {
209                        review_comments.comments.push(Suggestion {
210                            line_start: note.line,
211                            line_end: note.line,
212                            suggestion,
213                            path: file_name.to_owned(),
214                        });
215                    }
216                }
217            }
218            review_comments.tool_total[1] =
219                Some(review_comments.tool_total[1].unwrap_or_default() + total);
220        }
221        Ok(())
222    }
223}
224
225/// Gets the line number for a given `offset` (of bytes) from the given
226/// buffer `contents`.
227///
228/// The `offset` given to this function is expected to originate from
229/// diagnostic information provided by clang-format. Any `offset` out of
230/// bounds is clamped to the given `contents` buffer's length.
231pub fn get_line_count_from_offset(contents: &[u8], offset: u32) -> u32 {
232    let offset = (offset as usize).min(contents.len());
233    let lines = contents[0..offset].split(|byte| byte == &b'\n');
234    lines.count() as u32
235}
236
237#[cfg(test)]
238mod test {
239    use std::{fs, path::PathBuf};
240
241    use super::{FileObj, get_line_count_from_offset};
242    use crate::cli::LinesChangedOnly;
243
244    // *********************** tests for translating byte offset into line/column
245
246    #[test]
247    fn translate_byte_offset() {
248        let contents = fs::read(PathBuf::from("tests/demo/demo.cpp")).unwrap();
249        let lines = get_line_count_from_offset(&contents, 144);
250        assert_eq!(lines, 13);
251    }
252
253    #[test]
254    fn get_line_count_edge_cases() {
255        // Empty content
256        assert_eq!(get_line_count_from_offset(&[], 0), 1);
257
258        // No newlines
259        assert_eq!(get_line_count_from_offset(b"abc", 3), 1);
260
261        // Consecutive newlines
262        assert_eq!(get_line_count_from_offset(b"a\n\nb", 3), 3);
263
264        // Offset beyond content length
265        assert_eq!(get_line_count_from_offset(b"a\nb\n", 10), 3);
266    }
267    // *********************** tests for FileObj::get_ranges()
268
269    #[test]
270    fn get_ranges_none() {
271        let file_obj = FileObj::new(PathBuf::from("tests/demo/demo.cpp"));
272        let ranges = file_obj.get_ranges(&LinesChangedOnly::Off);
273        assert!(ranges.is_empty());
274    }
275
276    #[test]
277    fn get_ranges_diff() {
278        let diff_chunks = vec![1..=10];
279        let added_lines = vec![4, 5, 9];
280        let file_obj = FileObj::from(
281            PathBuf::from("tests/demo/demo.cpp"),
282            added_lines,
283            diff_chunks.clone(),
284        );
285        let ranges = file_obj.get_ranges(&LinesChangedOnly::Diff);
286        assert_eq!(ranges, diff_chunks);
287    }
288
289    #[test]
290    fn get_ranges_added() {
291        let diff_chunks = vec![1..=10];
292        let added_lines = vec![4, 5, 9];
293        let file_obj = FileObj::from(
294            PathBuf::from("tests/demo/demo.cpp"),
295            added_lines,
296            diff_chunks,
297        );
298        let ranges = file_obj.get_ranges(&LinesChangedOnly::On);
299        assert_eq!(ranges, vec![4..=5, 9..=9]);
300    }
301
302    #[test]
303    fn line_not_in_diff() {
304        let file_obj = FileObj::new(PathBuf::from("tests/demo/demo.cpp"));
305        assert!(!file_obj.is_line_in_diff(&42));
306    }
307}