Skip to main content

docgen_diff/
line_diff.rs

1//! Line-level hunk diff — a faithful port of `line-diff.ts`.
2//!
3//! The originals roll their own LCS with a load-bearing tie-break
4//! (`lcs[i+1][j] >= lcs[i][j+1]` ⇒ prefer *removed*); we port it directly so
5//! the emitted op stream matches byte-for-byte. Hunks expand `context_lines`
6//! around each change and merge when adjacent.
7
8use crate::types::{DocDiffHunk, DocDiffLine, DocDiffLineKind};
9
10/// A diff op carrying the cursor positions before it was emitted, used to
11/// compute hunk start lines when a hunk begins on a one-sided change.
12#[derive(Debug, Clone)]
13struct DiffOp {
14    kind: DocDiffLineKind,
15    old_line: Option<u32>,
16    new_line: Option<u32>,
17    text: String,
18    old_before: usize,
19    new_before: usize,
20}
21
22/// Build line-level hunks between two texts, with `context_lines` of context
23/// around each change. Returns an empty vec when the texts are identical.
24pub fn build_line_hunks(old_text: &str, new_text: &str, context_lines: usize) -> Vec<DocDiffHunk> {
25    if old_text == new_text {
26        return vec![];
27    }
28
29    let old_lines = split_lines(old_text);
30    let new_lines = split_lines(new_text);
31    let ops = build_diff_ops(&old_lines, &new_lines);
32    let ranges = build_hunk_ranges(&ops, context_lines);
33
34    ranges
35        .into_iter()
36        .map(|(start, end)| build_hunk(&ops[start..=end]))
37        .collect()
38}
39
40/// Convenience matching the TS default of 3 context lines.
41pub fn build_line_hunks_default(old_text: &str, new_text: &str) -> Vec<DocDiffHunk> {
42    build_line_hunks(old_text, new_text, 3)
43}
44
45fn split_lines(text: &str) -> Vec<String> {
46    if text.is_empty() {
47        return vec![];
48    }
49
50    let mut lines: Vec<String> = text
51        .split('\n')
52        .map(|line| line.strip_suffix('\r').unwrap_or(line).to_string())
53        .collect();
54    if lines.last().map(|l| l.is_empty()).unwrap_or(false) {
55        lines.pop();
56    }
57
58    lines
59}
60
61fn build_diff_ops(old_lines: &[String], new_lines: &[String]) -> Vec<DiffOp> {
62    let lcs = build_lcs_table(old_lines, new_lines);
63    let mut ops = Vec::new();
64    let mut old_index = 0usize;
65    let mut new_index = 0usize;
66
67    while old_index < old_lines.len() || new_index < new_lines.len() {
68        let old_before = old_index;
69        let new_before = new_index;
70
71        if old_index < old_lines.len()
72            && new_index < new_lines.len()
73            && old_lines[old_index] == new_lines[new_index]
74        {
75            ops.push(DiffOp {
76                kind: DocDiffLineKind::Context,
77                old_line: Some((old_index + 1) as u32),
78                new_line: Some((new_index + 1) as u32),
79                text: old_lines[old_index].clone(),
80                old_before,
81                new_before,
82            });
83            old_index += 1;
84            new_index += 1;
85        } else if old_index < old_lines.len()
86            && (new_index == new_lines.len()
87                || lcs[old_index + 1][new_index] >= lcs[old_index][new_index + 1])
88        {
89            ops.push(DiffOp {
90                kind: DocDiffLineKind::Removed,
91                old_line: Some((old_index + 1) as u32),
92                new_line: None,
93                text: old_lines[old_index].clone(),
94                old_before,
95                new_before,
96            });
97            old_index += 1;
98        } else {
99            ops.push(DiffOp {
100                kind: DocDiffLineKind::Added,
101                old_line: None,
102                new_line: Some((new_index + 1) as u32),
103                text: new_lines[new_index].clone(),
104                old_before,
105                new_before,
106            });
107            new_index += 1;
108        }
109    }
110
111    ops
112}
113
114fn build_lcs_table(old_lines: &[String], new_lines: &[String]) -> Vec<Vec<usize>> {
115    let mut table = vec![vec![0usize; new_lines.len() + 1]; old_lines.len() + 1];
116
117    for old_index in (0..old_lines.len()).rev() {
118        for new_index in (0..new_lines.len()).rev() {
119            table[old_index][new_index] = if old_lines[old_index] == new_lines[new_index] {
120                table[old_index + 1][new_index + 1] + 1
121            } else {
122                table[old_index + 1][new_index].max(table[old_index][new_index + 1])
123            };
124        }
125    }
126
127    table
128}
129
130fn build_hunk_ranges(ops: &[DiffOp], context_lines: usize) -> Vec<(usize, usize)> {
131    let mut ranges: Vec<(usize, usize)> = Vec::new();
132
133    for (index, op) in ops.iter().enumerate() {
134        if op.kind == DocDiffLineKind::Context {
135            continue;
136        }
137
138        let start = index.saturating_sub(context_lines);
139        let end = (index + context_lines).min(ops.len() - 1);
140
141        if let Some(previous) = ranges.last_mut() {
142            if start <= previous.1 + 1 {
143                previous.1 = previous.1.max(end);
144                continue;
145            }
146        }
147        ranges.push((start, end));
148    }
149
150    ranges
151}
152
153fn build_hunk(lines: &[DiffOp]) -> DocDiffHunk {
154    let old_lines = lines
155        .iter()
156        .filter(|line| line.kind != DocDiffLineKind::Added)
157        .count() as u32;
158    let new_lines = lines
159        .iter()
160        .filter(|line| line.kind != DocDiffLineKind::Removed)
161        .count() as u32;
162
163    DocDiffHunk {
164        old_start: hunk_start(lines, Side::Old),
165        old_lines,
166        new_start: hunk_start(lines, Side::New),
167        new_lines,
168        lines: lines
169            .iter()
170            .map(|line| DocDiffLine {
171                kind: line.kind,
172                old_line: line.old_line,
173                new_line: line.new_line,
174                text: line.text.clone(),
175            })
176            .collect(),
177    }
178}
179
180enum Side {
181    Old,
182    New,
183}
184
185fn hunk_start(lines: &[DiffOp], side: Side) -> u32 {
186    let first_line = lines.iter().find_map(|line| match side {
187        Side::Old => line.old_line,
188        Side::New => line.new_line,
189    });
190
191    if let Some(value) = first_line {
192        return value;
193    }
194
195    match side {
196        Side::Old => lines[0].old_before as u32,
197        Side::New => lines[0].new_before as u32,
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use DocDiffLineKind::*;
205
206    fn line(
207        kind: DocDiffLineKind,
208        old_line: Option<u32>,
209        new_line: Option<u32>,
210        text: &str,
211    ) -> DocDiffLine {
212        DocDiffLine {
213            kind,
214            old_line,
215            new_line,
216            text: text.into(),
217        }
218    }
219
220    #[test]
221    fn identical_returns_no_hunks() {
222        assert!(build_line_hunks_default("alpha\nbeta\ngamma", "alpha\nbeta\ngamma").is_empty());
223    }
224
225    #[test]
226    fn replacement_marks_context_removed_added_context() {
227        let h = build_line_hunks_default("alpha\nbeta\ngamma", "alpha\ndelta\ngamma");
228        assert_eq!(
229            h,
230            vec![DocDiffHunk {
231                old_start: 1,
232                old_lines: 3,
233                new_start: 1,
234                new_lines: 3,
235                lines: vec![
236                    line(Context, Some(1), Some(1), "alpha"),
237                    line(Removed, Some(2), None, "beta"),
238                    line(Added, None, Some(2), "delta"),
239                    line(Context, Some(3), Some(3), "gamma"),
240                ],
241            }]
242        );
243    }
244
245    #[test]
246    fn distant_edits_split_into_two_hunks_with_context_one() {
247        let h = build_line_hunks("a\nb\nc\nd\ne\nf\ng", "a\nB\nc\nd\ne\nF\ng", 1);
248        assert_eq!(
249            h,
250            vec![
251                DocDiffHunk {
252                    old_start: 1,
253                    old_lines: 3,
254                    new_start: 1,
255                    new_lines: 3,
256                    lines: vec![
257                        line(Context, Some(1), Some(1), "a"),
258                        line(Removed, Some(2), None, "b"),
259                        line(Added, None, Some(2), "B"),
260                        line(Context, Some(3), Some(3), "c"),
261                    ],
262                },
263                DocDiffHunk {
264                    old_start: 5,
265                    old_lines: 3,
266                    new_start: 5,
267                    new_lines: 3,
268                    lines: vec![
269                        line(Context, Some(5), Some(5), "e"),
270                        line(Removed, Some(6), None, "f"),
271                        line(Added, None, Some(6), "F"),
272                        line(Context, Some(7), Some(7), "g"),
273                    ],
274                },
275            ]
276        );
277    }
278}