Skip to main content

git_policy_cli/
standards_review_patch.rs

1//! What:
2//!  Reading one file's changed new-side line spans out of a `git show --patch` text, so the review needs no
3//!  `git diff -U0` start per excerpted file.
4//! Why:
5//!  The script runs `git diff -U0 <sha>^ <sha> -- <name>` for each of up to eight files. A hunk of the three-line-context
6//!  patch is a list of change runs, and each run is one zero-context hunk, so the same spans follow from the patch the
7//!  review already holds. The spans are on the new side because the excerpt is cut from the new-side file.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! for (const match of git('diff', '-U0', `${sha}^`, sha, '--', name).matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? /gm)) { ... }
12//! ```
13
14/// A diff section's opening line prefix.
15const SECTION_PREFIX: &str = "diff --git ";
16
17/// A hunk header's opening characters.
18const HUNK_PREFIX: &str = "@@ ";
19
20/// What:
21///  One run of changed lines on the new side of a file: the first new line the change touches and the last.
22///  A pure deletion touches the new line that now stands where the removed lines were.
23/// Why:
24///   The excerpt window is the union of these spans over the file's runs, cut from the new-side file the commit
25///   produced. The script applied the old-side numbers to the new-side file, which shifts the window after an earlier
26///   insertion; this port uses the new side on both ends.
27///
28/// In TS you'd write (pseudocode):
29/// ```ts
30/// type NewSpan = { first: number; last: number };
31/// ```
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct NewSpan {
34    /// First changed new line.
35    pub first: u64,
36    /// Last changed new line.
37    pub last: u64,
38}
39
40/// What:
41///  Whether `header` and the extended lines after it describe `name` as the section's new path: either both sides
42///  name it, or a `rename to` line does.
43/// Why:
44///   A renamed file is excerpted under its new name, like any other changed file.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// header === `diff --git a/${name} b/${name}` || extended.includes(`rename to ${name}`)
49/// ```
50fn section_is_for(header: &str, extended: &[&str], name: &str) -> bool {
51    let rename: String = format!("rename to {name}");
52    return header == format!("{SECTION_PREFIX}a/{name} b/{name}") || extended.iter().any(|line| return *line == rename);
53}
54
55/// What:
56///  Parse the new side of `@@ -a[,b] +c[,d] @@` into the first new line the hunk consumes.
57/// Why:
58///   A zero new length means the hunk sits after line `c`, so the first new line it reaches is `c + 1`.
59///
60/// In TS you'd write (pseudocode):
61/// ```ts
62/// const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? /.exec(line);
63/// ```
64fn parse_new_start(line: &str) -> Option<u64> {
65    let mut sides = line.strip_prefix("@@ -")?.split(' ');
66    let _old = sides.next()?;
67    let new: &str = sides.next()?.strip_prefix('+')?;
68    let (start_text, length_text) = new.split_once(',').unwrap_or((new, "1"));
69    let start: u64 = start_text.parse::<u64>().ok()?;
70    let length: u64 = length_text.parse::<u64>().ok()?;
71    return Some(if length == 0 { start + 1 } else { start });
72}
73
74/// What:
75///  The new-side spans of `name` in `patch_lines`, in file order; empty when the patch holds no section for the path
76///  or a section without hunks (a binary file, a mode change or a pure rename).
77/// Why:
78///   These replace the script's per-file `git diff -U0` start, and `standards_review_patch_tests.rs` checks them
79///   against that command's new-side headers.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// function newSpans({ patchLines, name }): { first: number; last: number }[];
84/// ```
85pub fn new_spans(patch_lines: &[&str], name: &str) -> Vec<NewSpan> {
86    let mut spans: Vec<NewSpan> = Vec::new();
87    let mut index: usize = 0;
88    while index < patch_lines.len() {
89        let header: &str = patch_lines[index];
90        index += 1;
91        if !header.starts_with(SECTION_PREFIX) {
92            continue;
93        }
94        let first_hunk: usize = patch_lines[index..]
95            .iter()
96            .position(|line| return line.starts_with(HUNK_PREFIX) || line.starts_with(SECTION_PREFIX))
97            .map_or(patch_lines.len(), |offset| return index + offset);
98        let matches: bool = section_is_for(header, &patch_lines[index..first_hunk], name);
99        index = first_hunk;
100        if matches {
101            read_hunks(patch_lines, &mut index, &mut spans);
102            return spans;
103        }
104    }
105    return spans;
106}
107
108/// What:
109///  Walk the hunks that start at `index` until the next section, appending one span per run of changed lines.
110/// Why:
111///   Context lines separate runs, and each run is what a zero-context diff would print as one hunk.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// function readHunks({ patchLines, index, spans }): void;
116/// ```
117fn read_hunks(patch_lines: &[&str], index: &mut usize, spans: &mut Vec<NewSpan>) {
118    let mut new_line: u64 = 0;
119    let mut open: Option<NewSpan> = None;
120    while *index < patch_lines.len() {
121        let line: &str = patch_lines[*index];
122        if line.starts_with(SECTION_PREFIX) {
123            break;
124        }
125        *index += 1;
126        if line.starts_with(HUNK_PREFIX) {
127            close_run(&mut open, spans);
128            new_line = parse_new_start(line).unwrap_or(new_line);
129            continue;
130        }
131        match line.as_bytes().first() {
132            Some(b'-') => {
133                open = Some(open.unwrap_or(NewSpan { first: new_line, last: new_line }));
134            }
135            Some(b'+') => {
136                let run: NewSpan = open.unwrap_or(NewSpan { first: new_line, last: new_line });
137                open = Some(NewSpan { first: run.first, last: new_line });
138                new_line += 1;
139            }
140            Some(b' ') => {
141                close_run(&mut open, spans);
142                new_line += 1;
143            }
144            _ => {}
145        }
146    }
147    close_run(&mut open, spans);
148}
149
150/// What:
151///  Move an open run, if any, into the finished spans.
152/// Why:
153///   Both a context line and the end of a hunk finish a run.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// if (open !== undefined) { spans.push(open); open = undefined; }
158/// ```
159fn close_run(open: &mut Option<NewSpan>, spans: &mut Vec<NewSpan>) {
160    if let Some(run) = open.take() {
161        spans.push(run);
162    }
163}
164
165/// Controls stay out of the release executable.
166#[cfg(test)]
167#[path = "standards_review_patch_tests.rs"]
168mod tests;