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