Skip to main content

oxicode_hashline/
diff_preview.rs

1//! Re-number a line-level diff between two file versions into a compact
2//! current-file preview.
3//!
4//! Removed lines are omitted from the preview; added and kept (context) lines
5//! are anchored to their post-edit positions so a follow-up edit can reuse
6//! visible concrete lines directly. Long contiguous added runs are summarized
7//! with a `…` marker instead of echoing every inserted line, and long runs of
8//! unchanged lines are trimmed to a configurable context window around each
9//! change.
10//!
11//! Ported from omp `packages/hashline/src/diff-preview.ts`, adapted to take the
12//! before/after text directly (the crate's [`CompactDiffPreview`] type) rather
13//! than a pre-formatted `<sign><lineNum>|<content>` diff string.
14
15use crate::types::{CompactDiffOptions, CompactDiffPreview};
16
17/// Marker substituted for long elided added runs (and for any literal `...` /
18/// `+…` content lines, matching omp).
19const PREVIEW_ELISION_MARKER: &str = "…";
20/// Blank row separating non-contiguous regions of a numbered diff.
21const PREVIEW_GAP_ROW: &str = "";
22
23/// `true` for separator lines (elision marker or blank gap row).
24fn is_preview_separator(line: &str) -> bool {
25    line == PREVIEW_ELISION_MARKER || line == PREVIEW_GAP_ROW
26}
27
28/// Normalize omp's raw elision spellings (`...`, `…`, `+…`) to the single
29/// marker, then append with separator de-stacking: separators never stack
30/// (removed lines between two separators would otherwise leave them adjacent),
31/// and a leading separator is dropped outright.
32fn append_preview_line(output: &mut Vec<String>, line: &str) {
33    let normalized: &str = match line {
34        "..." | "…" | "+…" => PREVIEW_ELISION_MARKER,
35        _ => line,
36    };
37    if is_preview_separator(normalized)
38        && (output.is_empty()
39            || output
40                .last()
41                .map(|l| is_preview_separator(l))
42                .unwrap_or(false))
43    {
44        return;
45    }
46    output.push(normalized.to_string());
47}
48
49/// Append an accumulated added run, collapsing it to its edges + an elision
50/// marker when it is longer than `edge * 2 + 1` lines.
51fn append_added_run(output: &mut Vec<String>, run: &[String], edge: usize) {
52    if run.is_empty() {
53        return;
54    }
55    let edge = edge.max(1);
56    let collapse_threshold = edge * 2 + 1;
57    if run.len() <= collapse_threshold {
58        for text in run {
59            append_preview_line(output, text);
60        }
61        return;
62    }
63    for text in &run[..edge] {
64        append_preview_line(output, text);
65    }
66    append_preview_line(output, PREVIEW_ELISION_MARKER);
67    for text in &run[run.len() - edge..] {
68        append_preview_line(output, text);
69    }
70}
71
72/// Flush a pending added run into `output`.
73fn flush(output: &mut Vec<String>, run: &mut Vec<String>, edge: usize) {
74    append_added_run(output, run, edge);
75    run.clear();
76}
77
78/// A single LCS step.
79#[derive(Debug)]
80enum Op {
81    /// Line present in both versions.
82    Keep { post: u32, content: String },
83    /// Line removed from the before version.
84    Remove,
85    /// Line inserted in the after version.
86    Insert { post: u32, content: String },
87}
88
89impl Op {
90    fn is_keep(&self) -> bool {
91        matches!(self, Op::Keep { .. })
92    }
93}
94
95/// Produce the LCS-based edit script between `before` and `after`, with each
96/// keep/insert op tagged by its 1-indexed post-edit line number.
97fn diff_ops(before: &[&str], after: &[&str]) -> Vec<Op> {
98    let m = before.len();
99    let n = after.len();
100
101    // dp[i][j] = LCS length of before[i..] and after[j..].
102    let mut dp = vec![vec![0u32; n + 1]; m + 1];
103    for i in (0..m).rev() {
104        for j in (0..n).rev() {
105            dp[i][j] = if before[i] == after[j] {
106                dp[i + 1][j + 1] + 1
107            } else {
108                dp[i + 1][j].max(dp[i][j + 1])
109            };
110        }
111    }
112
113    let mut ops = Vec::with_capacity(m + n);
114    let (mut i, mut j, mut post) = (0usize, 0usize, 1u32);
115    while i < m && j < n {
116        if before[i] == after[j] {
117            ops.push(Op::Keep {
118                post,
119                content: before[i].to_string(),
120            });
121            i += 1;
122            j += 1;
123            post += 1;
124        } else if dp[i + 1][j] >= dp[i][j + 1] {
125            ops.push(Op::Remove);
126            i += 1;
127        } else {
128            ops.push(Op::Insert {
129                post,
130                content: after[j].to_string(),
131            });
132            j += 1;
133            post += 1;
134        }
135    }
136    while i < m {
137        ops.push(Op::Remove);
138        i += 1;
139    }
140    while j < n {
141        ops.push(Op::Insert {
142            post,
143            content: after[j].to_string(),
144        });
145        j += 1;
146        post += 1;
147    }
148    ops
149}
150
151/// Build a compact before/after diff preview. Added and context (unchanged)
152/// lines are numbered at their post-edit positions; unchanged lines far from any
153/// change are trimmed to [`CompactDiffOptions::max_unchanged_context`] lines on
154/// each side; long added runs are collapsed with a `…` marker.
155pub fn build_compact_diff_preview(
156    before: &str,
157    after: &str,
158    opts: &CompactDiffOptions,
159) -> CompactDiffPreview {
160    let ctx = opts.max_unchanged_context.max(1);
161    let before_lines: Vec<&str> = before.split('\n').collect();
162    let after_lines: Vec<&str> = after.split('\n').collect();
163    let ops = diff_ops(&before_lines, &after_lines);
164
165    // Decide which keep ops survive trimming: keep up to `ctx` unchanged lines
166    // on each side of every change (skipping over adjacent changes).
167    let n = ops.len();
168    let mut keep_visible = vec![false; n];
169    for center in 0..n {
170        if ops[center].is_keep() {
171            continue;
172        }
173        let mut c = 0usize;
174        let mut k = center;
175        while k > 0 && c < ctx {
176            k -= 1;
177            if ops[k].is_keep() {
178                keep_visible[k] = true;
179                c += 1;
180            }
181        }
182        c = 0;
183        let mut k = center + 1;
184        while k < n && c < ctx {
185            if ops[k].is_keep() {
186                keep_visible[k] = true;
187                c += 1;
188            }
189            k += 1;
190        }
191    }
192
193    let mut output: Vec<String> = Vec::new();
194    let mut added_run: Vec<String> = Vec::new();
195    for (idx, op) in ops.iter().enumerate() {
196        match op {
197            Op::Keep { post, content } => {
198                if !keep_visible[idx] {
199                    continue;
200                }
201                flush(&mut output, &mut added_run, ctx);
202                append_preview_line(&mut output, &format!("{post}:{content}"));
203            }
204            Op::Remove => {
205                flush(&mut output, &mut added_run, ctx);
206            }
207            Op::Insert { post, content } => {
208                added_run.push(format!("{post}:{content}"));
209            }
210        }
211    }
212    flush(&mut output, &mut added_run, ctx);
213
214    // Strip trailing separators.
215    while output
216        .last()
217        .map(|l| is_preview_separator(l))
218        .unwrap_or(false)
219    {
220        output.pop();
221    }
222
223    CompactDiffPreview { lines: output }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    fn preview(before: &str, after: &str) -> Vec<String> {
231        build_compact_diff_preview(before, after, &CompactDiffOptions::default()).lines
232    }
233
234    #[test]
235    fn identical_input_is_empty() {
236        assert!(preview("a\nb\nc", "a\nb\nc").is_empty());
237    }
238
239    #[test]
240    fn pure_insert_numbers_at_post_edit_positions() {
241        let lines = preview("a\nb\nc", "x\na\nb\nc");
242        assert!(lines.iter().any(|l| l == "1:x"), "insert x at post line 1");
243        // The inserted line is adjacent to kept context.
244        assert!(lines.iter().any(|l| l.starts_with("2:a")));
245    }
246
247    #[test]
248    fn replace_shows_insert_and_surrounding_context() {
249        let lines = preview("a\nb\nc", "a\nB\nc");
250        // `b` is gone; `B` inserted at post line 2.
251        assert!(lines.iter().any(|l| l == "2:B"));
252        assert!(lines.iter().any(|l| l == "1:a"));
253        assert!(lines.iter().any(|l| l == "3:c"));
254        // The removed `b` never appears as content.
255        assert!(!lines.iter().any(|l| l.ends_with(":b")));
256    }
257
258    #[test]
259    fn trailing_insert_is_emitted() {
260        let lines = preview("a", "a\nb");
261        assert!(lines.iter().any(|l| l == "2:b"));
262    }
263
264    #[test]
265    fn long_added_run_collapses_with_marker() {
266        // 10 inserted lines with default ctx=3 -> edge=3, threshold=7 -> first 3 + … + last 3.
267        let after: Vec<&str> = (0..10).map(|_| "x").collect();
268        let lines = preview("", &after.join("\n"));
269        assert!(lines.iter().any(|l| l == "…"), "elision marker present");
270        // No more than 2*edge + 1 content lines.
271        assert!(lines.len() <= 7);
272        // First and last inserted lines appear.
273        assert!(lines.first().map(|l| l == "1:x").unwrap_or(false));
274    }
275
276    #[test]
277    fn unchanged_lines_far_from_change_are_trimmed() {
278        // A change at line 1; the long tail must be trimmed away.
279        let before: String = "head\n".to_string() + &"tail\n".repeat(50);
280        let after: String = "CHANGED\n".to_string() + &"tail\n".repeat(50);
281        let lines = preview(&before, &after);
282        assert!(lines.iter().any(|l| l == "1:CHANGED"));
283        // The preview is far smaller than 51 lines.
284        assert!(lines.len() < 20, "trimmed: got {} lines", lines.len());
285    }
286
287    #[test]
288    fn context_window_respects_option() {
289        // ctx=1: only the immediately adjacent kept line on each side.
290        let opts = CompactDiffOptions {
291            max_unchanged_context: 1,
292        };
293        let lines = build_compact_diff_preview("a\nb\nc\nd\ne", "a\nb\nX\nd\ne", &opts).lines;
294        // Change at position 3 (c -> X); ctx=1 keeps b (pre) and d (post).
295        assert!(lines.iter().any(|l| l == "2:b"));
296        assert!(lines.iter().any(|l| l == "3:X"));
297        assert!(lines.iter().any(|l| l == "4:d"));
298        // `a` and `e` are outside the 1-line window.
299        assert!(!lines.iter().any(|l| l == "1:a"));
300        assert!(!lines.iter().any(|l| l == "5:e"));
301    }
302
303    #[test]
304    fn options_min_clamps_to_one() {
305        let opts = CompactDiffOptions {
306            max_unchanged_context: 0,
307        };
308        // ctx=0 must be clamped to >=1 and still produce output, not panic.
309        let lines = build_compact_diff_preview("a\nb", "a\nB", &opts).lines;
310        assert!(lines.iter().any(|l| l == "2:B"));
311    }
312
313    #[test]
314    fn elision_marker_is_unique_and_interior() {
315        // A long added run collapses to a single interior … marker, never
316        // stacked and never left leading/trailing.
317        let after: Vec<&str> = (0..10).map(|_| "x").collect();
318        let lines = preview("", &after.join("\n"));
319        let markers: Vec<&String> = lines.iter().filter(|l| **l == "…").collect();
320        assert_eq!(
321            markers.len(),
322            1,
323            "exactly one elision marker, never stacked"
324        );
325        assert_ne!(
326            lines.first().map(String::as_str),
327            Some("…"),
328            "no leading marker"
329        );
330        assert_ne!(
331            lines.last().map(String::as_str),
332            Some("…"),
333            "no trailing marker"
334        );
335    }
336}