cpp_linter/common_fs/
mod.rs

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