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;