Skip to main content

git_bot_feedback/
git_diff.rs

1#[cfg(feature = "pyo3")]
2use pyo3::prelude::*;
3
4use regex::Regex;
5use std::{collections::HashMap, ops::Range, path::Path};
6
7use crate::{FileDiffLines, FileFilter, LinesChangedOnly, error::DiffError};
8
9/// A struct to represent the header information of a diff hunk.
10#[cfg_attr(
11    feature = "pyo3",
12    pyclass(module = "git_bot_feedback", from_py_object, get_all, frozen)
13)]
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct DiffHunkHeader {
16    /// The starting line number of the old hunk.
17    pub old_start: u32,
18    /// The total number of lines in the old hunk.
19    pub old_lines: u32,
20    /// The starting line number of the new hunk.
21    pub new_start: u32,
22    /// The total number of lines in the new hunk.
23    pub new_lines: u32,
24}
25
26#[cfg(feature = "pyo3")]
27#[pymethods]
28impl DiffHunkHeader {
29    /// Create a new diff hunk header instance.
30    #[new]
31    #[pyo3(text_signature = "(old_start: int, old_lines: int, new_start: int, new_lines: int)")]
32    pub fn new_py(old_start: i64, old_lines: i64, new_start: i64, new_lines: i64) -> Self {
33        Self {
34            old_start: old_start.clamp(0, u32::MAX as i64) as u32,
35            old_lines: old_lines.clamp(0, u32::MAX as i64) as u32,
36            new_start: new_start.clamp(0, u32::MAX as i64) as u32,
37            new_lines: new_lines.clamp(0, u32::MAX as i64) as u32,
38        }
39    }
40}
41
42fn get_filename_from_front_matter(front_matter: &str) -> Result<Option<&str>, DiffError> {
43    let diff_file_name = Regex::new(r"(?m)^\+\+\+\sb?/(.*)$")?;
44    if let Some(captures) = diff_file_name.captures(front_matter)
45        && let Some(name) = captures.get(1)
46    {
47        return Ok(Some(name.as_str().trim_end_matches(['\r', '\n'])));
48    }
49    let diff_renamed_file = Regex::new(r"(?m)^rename to (.*)$")?;
50    if front_matter.starts_with("similarity")
51        && let Some(captures) = diff_renamed_file.captures(front_matter)
52        && let Some(name) = captures.get(1)
53    {
54        return Ok(Some(name.as_str().trim_end_matches(['\r', '\n'])));
55    }
56    let diff_binary_file = Regex::new(r"(?m)^Binary\sfiles\s")?;
57    let diff_mode_only = Regex::new(r"(?x)\Aold\ mode\ [0-7]{6}\r?\nnew\ mode\ [0-7]{6}\r?\n?\z")?;
58    if diff_mode_only.is_match(front_matter) || diff_binary_file.is_match(front_matter) {
59        return Ok(None);
60    }
61    Err(DiffError::MalformedDiffError(front_matter.to_string()))
62}
63
64/// A regex pattern used in multiple functions
65static HUNK_INFO_PATTERN: &str = r"(?m)@@\s\-\d+,?\d*\s\+(\d+),?(\d*)\s@@";
66
67/// Parses a single file's patch containing one or more hunks
68///
69/// Returns a 2-item tuple:
70///
71/// - the line numbers that contain additions
72/// - the ranges of lines that span each hunk
73fn parse_patch(patch: &str) -> Result<(Vec<u32>, Vec<Range<u32>>), DiffError> {
74    let mut diff_hunks = Vec::new();
75    let mut additions = Vec::new();
76
77    let hunk_info = Regex::new(HUNK_INFO_PATTERN)?;
78    let hunk_headers = hunk_info.captures_iter(patch).collect::<Vec<_>>();
79    if !hunk_headers.is_empty() {
80        // skip the first split because it is anything that precedes first hunk header
81        let hunks = hunk_info.split(patch).skip(1);
82        for (hunk, header) in hunks.zip(hunk_headers) {
83            // header.unwrap() is safe because the hunk_headers.iter() is parallel to hunk_info.split()
84            let [start_line, end_range] = header.extract().1.map(|v| v.parse::<u32>().unwrap_or(1));
85            let mut line_numb_in_diff = start_line;
86            diff_hunks.push(start_line..start_line + end_range);
87            for (line_index, line) in hunk.split('\n').enumerate() {
88                if line.starts_with('+') {
89                    additions.push(line_numb_in_diff);
90                }
91                if line_index > 0 && !line.starts_with('-') {
92                    line_numb_in_diff += 1;
93                }
94            }
95        }
96    }
97    Ok((additions, diff_hunks))
98}
99
100/// Parses a git `diff` string into a map of file names to their corresponding
101/// [`FileDiffLines`].
102///
103/// The `file_filter` is used to filter out files that are not of interest.
104/// The `lines_changed_only` parameter determines whether to include files
105/// based on their contents' changes.
106pub fn parse_diff(
107    diff: &str,
108    file_filter: &FileFilter,
109    lines_changed_only: &LinesChangedOnly,
110) -> Result<HashMap<String, FileDiffLines>, DiffError> {
111    let mut results = HashMap::new();
112    let diff_file_delimiter = Regex::new(r"(?m)^diff \-\-git a/.*$")?;
113    let hunk_info = Regex::new(HUNK_INFO_PATTERN)?;
114
115    let file_diffs = diff_file_delimiter.split(diff);
116    for file_diff in file_diffs {
117        if file_diff.is_empty() || file_diff.starts_with("deleted file") {
118            continue;
119        }
120        let hunk_start = if let Some(first_hunk) = hunk_info.find(file_diff) {
121            first_hunk.start()
122        } else {
123            file_diff.len()
124        };
125        let front_matter = &file_diff[..hunk_start];
126        if let Some(file_name) = get_filename_from_front_matter(front_matter.trim_start())? {
127            let file_name = file_name.strip_prefix('/').unwrap_or(file_name);
128            if file_filter.is_qualified(Path::new(file_name)) {
129                let (added_lines, diff_hunks) = parse_patch(&file_diff[hunk_start..])?;
130                if lines_changed_only
131                    .is_change_valid(!added_lines.is_empty(), !diff_hunks.is_empty())
132                {
133                    results
134                        .entry(file_name.to_string())
135                        .or_insert_with(|| FileDiffLines::with_info(added_lines, diff_hunks));
136                }
137            }
138        }
139    }
140    Ok(results)
141}
142
143// ******************* UNIT TESTS ***********************
144#[cfg(test)]
145mod test {
146    #![allow(clippy::unwrap_used)]
147
148    use super::parse_diff;
149    use crate::{FileFilter, LinesChangedOnly, error::DiffError};
150
151    const BAD_DIFF: &str = r#"{"message":"Resource not accessible by integration"}"#;
152
153    #[test]
154    fn bad_diff() {
155        let files = parse_diff(
156            BAD_DIFF,
157            &FileFilter::new(&[], &["rs"], None),
158            &LinesChangedOnly::Diff,
159        );
160        let e = files.unwrap_err();
161        assert!(matches!(e, DiffError::MalformedDiffError(_)));
162        assert!(e.to_string().ends_with(BAD_DIFF));
163    }
164
165    const RENAMED_DIFF: &str = r#"diff --git a/tests/demo/some source.cpp b/tests/demo/some source.c
166similarity index 100%
167rename from /tests/demo/some source.cpp
168rename to /tests/demo/some source.c
169diff --git a/some picture.png b/some picture.png
170new file mode 100644
171Binary files /dev/null and b/some picture.png differ
172"#;
173
174    #[test]
175    fn parse_renamed_diff() {
176        let files = parse_diff(
177            RENAMED_DIFF,
178            &FileFilter::new(&[], &["c"], None),
179            &LinesChangedOnly::Off,
180        )
181        .unwrap();
182        let git_file = files.get("tests/demo/some source.c").unwrap();
183        assert!(git_file.added_lines.is_empty());
184        assert!(git_file.diff_hunks.is_empty());
185    }
186
187    #[test]
188    fn parse_renamed_only_diff() {
189        let files = parse_diff(
190            RENAMED_DIFF,
191            &FileFilter::new(&[], &["c"], None),
192            &LinesChangedOnly::Diff,
193        )
194        .unwrap();
195        assert!(files.is_empty());
196    }
197
198    const RENAMED_DIFF_WITH_CHANGES: &str = r#"diff --git a/tests/demo/some source.cpp b/tests/demo/some source.c
199similarity index 99%
200rename from /tests/demo/some source.cpp
201rename to /tests/demo/some source.c
202@@ -3,7 +3,7 @@
203\n \n \n-#include "math.h"
204+#include <math.h>\n \n \n \n"#;
205
206    #[test]
207    fn parse_renamed_diff_with_patch() {
208        let files = parse_diff(
209            &String::from_iter([RENAMED_DIFF_WITH_CHANGES, TERSE_HEADERS]),
210            // ignore src/demo.cpp file (in TERSE_HEADERS) via glob (src/*);
211            // triggers code coverage of a `}` (region end)
212            &FileFilter::new(&["src/*"], &["c", "cpp"], None),
213            &LinesChangedOnly::On,
214        )
215        .unwrap();
216        eprintln!("files: {files:#?}");
217        let git_file = files.get("tests/demo/some source.c").unwrap();
218        assert!(!git_file.is_line_in_diff(&1));
219        assert!(git_file.is_line_in_diff(&4));
220    }
221
222    const TYPICAL_DIFF: &str = "diff --git a/path/for/Some file.cpp b/path/to/Some file.cpp\n\
223                            --- a/path/for/Some file.cpp\n\
224                            +++ b/path/to/Some file.cpp\n\
225                            @@ -3,7 +3,7 @@\n \n \n \n\
226                            -#include <some_lib/render/animation.hpp>\n\
227                            +#include <some_lib/render/animations.hpp>\n \n \n \n";
228
229    #[test]
230    fn parse_typical_diff() {
231        let files = parse_diff(
232            TYPICAL_DIFF,
233            &FileFilter::new(&[], &["cpp"], None),
234            &LinesChangedOnly::On,
235        )
236        .unwrap();
237        assert!(!files.is_empty());
238    }
239
240    const IGNORED_DIFF: &str = "diff --git a/some picture.png b/some picture.png\n\
241                new file mode 100644\n\
242                Binary files /dev/null and b/some picture.png differ\n\
243                diff --git a/script.sh b/script.sh\n\
244                old mode 100644\n\
245                new mode 100755\n";
246
247    #[test]
248    fn parse_ignored_diff() {
249        let files = parse_diff(
250            IGNORED_DIFF,
251            &FileFilter::new(&[], &["png"], None),
252            &LinesChangedOnly::Diff,
253        )
254        .unwrap();
255        assert!(files.is_empty());
256    }
257
258    const TERSE_HEADERS: &str = r#"diff --git a/src/demo.cpp b/src/demo.cpp
259--- a/src/demo.cpp
260+++ b/src/demo.cpp
261@@ -3 +3 @@
262-#include <stdio.h>
263+#include "stdio.h"
264@@ -4,0 +5,2 @@
265+auto main() -> int
266+{
267@@ -18 +17,2 @@ int main(){
268-    return 0;}
269+    return 0;
270+}"#;
271
272    #[test]
273    fn terse_hunk_header() {
274        let file_filter = FileFilter::new(&[], &["cpp"], None);
275        let files = parse_diff(TERSE_HEADERS, &file_filter, &LinesChangedOnly::Diff).unwrap();
276        let file_diff = files.get("src/demo.cpp").unwrap();
277        assert_eq!(file_diff.diff_hunks, vec![3..4, 5..7, 17..19]);
278    }
279}