Skip to main content

harn_vm/
text_diff.rs

1//! The workspace's single line-diff owner.
2//!
3//! Every unified-diff renderer in the codebase — orchestration run-record
4//! diffs, the `ast.dry_run` preview, and the `harn package publish` index
5//! preview — routes through [`render_line_diff`] so the algorithm choice and
6//! context radius live in exactly one place. Callers supply their own file
7//! header (`--- a/… / +++ b/…`, `/dev/null`, etc.); this module owns the
8//! `@@` hunk body and the `+`/`-` line counts.
9
10use similar::{Algorithm, ChangeTag, TextDiff};
11
12/// Line-diff algorithm shared by every renderer.
13///
14/// Histogram is anchor-based: it produces more stable, human-readable hunks
15/// on real source than plain Myers, at equal correctness and generally lower
16/// cost. Every consumer here renders diffs for people to read (run
17/// comparisons, edit previews, index-change previews), so hunk quality is
18/// worth more than matching Myers' particular edit script.
19const ALGORITHM: Algorithm = Algorithm::Histogram;
20
21/// Unchanged lines kept on each side of a hunk. Matches `git diff`'s default.
22pub(crate) const DEFAULT_CONTEXT: usize = 3;
23
24/// A rendered line diff: the unified-diff hunk body plus raw `+`/`-` counts.
25///
26/// `body` carries `@@` hunk headers, up to [`DEFAULT_CONTEXT`] context lines per side,
27/// and `\ No newline at end of file` markers, but no file header — the caller
28/// prepends that. It is empty exactly when the inputs are identical. A line
29/// modified in place counts toward both `lines_added` and `lines_removed`.
30pub struct LineDiff {
31    pub body: String,
32    pub lines_added: usize,
33    pub lines_removed: usize,
34    pub old_lines: usize,
35    pub new_lines: usize,
36    pub changes: Vec<LineChange>,
37}
38
39/// One line in an expanded edit script.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum LineChangeKind {
42    Equal,
43    Delete,
44    Insert,
45}
46
47impl LineChangeKind {
48    /// Stable wire spelling used by `std/diff`.
49    pub const fn as_str(self) -> &'static str {
50        match self {
51            Self::Equal => "equal",
52            Self::Delete => "delete",
53            Self::Insert => "insert",
54        }
55    }
56}
57
58/// Expanded line operation with one-based coordinates on both sides.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct LineChange {
61    pub kind: LineChangeKind,
62    pub line: String,
63    pub old_line: usize,
64    pub new_line: usize,
65}
66
67/// Work requested from the shared line-diff engine.
68#[derive(Debug, Clone, Copy)]
69pub struct LineDiffOptions {
70    pub context: usize,
71    pub include_body: bool,
72    pub include_changes: bool,
73}
74
75impl Default for LineDiffOptions {
76    fn default() -> Self {
77        Self {
78            context: DEFAULT_CONTEXT,
79            include_body: true,
80            include_changes: false,
81        }
82    }
83}
84
85/// Diff `before` against `after` line by line.
86///
87/// `before` and `after` are compared with trailing terminators intact, so a file that
88/// only gains or loses its final newline still diffs as a change (and earns
89/// the `\ No newline at end of file` marker) rather than collapsing to a no-op.
90pub fn render_line_diff(before: &str, after: &str) -> LineDiff {
91    compute_line_diff(before, after, LineDiffOptions::default())
92}
93
94/// Compute one line diff and project only the representations a caller needs.
95pub fn compute_line_diff(before: &str, after: &str, options: LineDiffOptions) -> LineDiff {
96    let diff = TextDiff::configure()
97        .algorithm(ALGORITHM)
98        .diff_lines(before, after);
99    let body = if options.include_body {
100        diff.unified_diff()
101            .context_radius(options.context)
102            .to_string()
103    } else {
104        String::new()
105    };
106
107    let mut lines_added = 0;
108    let mut lines_removed = 0;
109    let mut old_line = 1;
110    let mut new_line = 1;
111    let mut changes = if options.include_changes {
112        Vec::with_capacity(diff.old_len().max(diff.new_len()))
113    } else {
114        Vec::new()
115    };
116    for change in diff.iter_all_changes() {
117        let kind = match change.tag() {
118            ChangeTag::Insert => {
119                lines_added += 1;
120                LineChangeKind::Insert
121            }
122            ChangeTag::Delete => {
123                lines_removed += 1;
124                LineChangeKind::Delete
125            }
126            ChangeTag::Equal => LineChangeKind::Equal,
127        };
128        if options.include_changes {
129            changes.push(LineChange {
130                kind,
131                line: line_without_terminator(change.value()),
132                old_line,
133                new_line,
134            });
135        }
136        match kind {
137            LineChangeKind::Equal => {
138                old_line += 1;
139                new_line += 1;
140            }
141            LineChangeKind::Delete => old_line += 1,
142            LineChangeKind::Insert => new_line += 1,
143        }
144    }
145
146    LineDiff {
147        body,
148        lines_added,
149        lines_removed,
150        old_lines: diff.old_len(),
151        new_lines: diff.new_len(),
152        changes,
153    }
154}
155
156fn line_without_terminator(value: &str) -> String {
157    let value = value.strip_suffix('\n').unwrap_or(value);
158    value.strip_suffix('\r').unwrap_or(value).to_owned()
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn identical_inputs_yield_empty_body() {
167        let diff = render_line_diff("a\nb\nc\n", "a\nb\nc\n");
168        assert_eq!(diff.body, "");
169        assert_eq!(diff.lines_added, 0);
170        assert_eq!(diff.lines_removed, 0);
171    }
172
173    #[test]
174    fn single_change_emits_bounded_hunk() {
175        let diff = render_line_diff("a\nb\nc\n", "a\nB\nc\n");
176        assert!(diff.body.starts_with("@@ -"));
177        assert!(diff.body.contains("-b\n"));
178        assert!(diff.body.contains("+B\n"));
179        assert_eq!(diff.lines_added, 1);
180        assert_eq!(diff.lines_removed, 1);
181    }
182
183    #[test]
184    fn expanded_changes_keep_one_based_coordinates() {
185        let diff = compute_line_diff(
186            "a\nb\nc\n",
187            "a\nB\nc\n",
188            LineDiffOptions {
189                include_body: false,
190                include_changes: true,
191                ..LineDiffOptions::default()
192            },
193        );
194        assert_eq!(diff.old_lines, 3);
195        assert_eq!(diff.new_lines, 3);
196        assert_eq!(diff.changes[1].kind, LineChangeKind::Delete);
197        assert_eq!(diff.changes[1].line, "b");
198        assert_eq!((diff.changes[1].old_line, diff.changes[1].new_line), (2, 2));
199        assert_eq!(diff.changes[2].kind, LineChangeKind::Insert);
200        assert_eq!(diff.changes[2].line, "B");
201        assert_eq!((diff.changes[2].old_line, diff.changes[2].new_line), (3, 2));
202    }
203
204    #[test]
205    fn context_stays_bounded_on_large_inputs() {
206        let before: String = (0..1000).map(|i| format!("line {i}\n")).collect();
207        let mut after_lines: Vec<String> = (0..1000).map(|i| format!("line {i}")).collect();
208        after_lines[500] = "CHANGED".to_string();
209        let after = after_lines
210            .iter()
211            .map(|l| format!("{l}\n"))
212            .collect::<String>();
213        let diff = render_line_diff(&before, &after);
214        // One hunk with 3 lines of context each side — not all 1000 lines.
215        assert_eq!(diff.body.matches("@@ -").count(), 1);
216        assert!(diff.body.lines().count() < 12);
217        assert!(!diff.body.contains("line 100\n"));
218    }
219
220    #[test]
221    fn trailing_newline_change_is_not_collapsed() {
222        let diff = render_line_diff("a\nb", "a\nb\n");
223        assert!(diff.body.contains("\\ No newline at end of file"));
224    }
225}