Skip to main content

fc_diff/
lib.rs

1//! fc-diff — line diffing, Myers' O(ND) algorithm.
2//!
3//! # Why this is in Rust and what that buys
4//!
5//! A diff is the one place in this system where the cost is superlinear in the
6//! input. Myers is O(ND) rather than the O(N*M) a dynamic-programming table
7//! would need, but on a 20,000-line file that is still ~400M comparisons in
8//! JavaScript's worst case. In Rust the same walk is memory-safe, allocation-free
9//! on the hot path, and roughly an order of magnitude faster — which is what
10//! makes live diffing while a user types viable at all.
11//!
12//! # The invariants, and why they are the whole contract
13//!
14//! A diff that is subtly wrong is worse than no diff: a reviewer cannot tell a
15//! missing line from a mis-attributed one. Two properties are therefore asserted
16//! on every test case rather than described:
17//!
18//!   1. **Reconstruction.** Dropping the `Add` lines reproduces the original;
19//!      dropping the `Remove` lines reproduces the revision. This single property
20//!      catches every possible backtracking off-by-one at once.
21//!   2. **Text preservation.** No line's text is altered, only its attribution.
22//!
23//! # Line numbers are 1-based
24//!
25//! What a human reads, and what `git blame` prints. The algorithm works in
26//! 0-based indices internally; the conversion is part of the public contract.
27//! `None` means the line does not exist on that side, and callers must render
28//! nothing rather than 0 — inventing a line number is how a reviewer is misled
29//! about where a change landed.
30
31// `no_std` on wasm, for size rather than portability. std's panic runtime
32// formats every message it could print -- `Debug` for ranges, char escaping,
33// integer padding -- and links all of it the moment any bounds check can fail,
34// which is most of this module's bytes and none of its behaviour. With
35// `panic = "abort"` a panic was already a trap, so the handler below traps
36// without formatting anything first.
37#![cfg_attr(all(target_arch = "wasm32", feature = "bare", not(test)), no_std)]
38
39extern crate alloc;
40
41use alloc::string::{String, ToString};
42use alloc::vec;
43use alloc::vec::Vec;
44use core::cmp::min;
45
46#[cfg(all(target_arch = "wasm32", feature = "bare", not(test)))]
47#[panic_handler]
48fn panic(_: &core::panic::PanicInfo) -> ! {
49    core::arch::wasm32::unreachable()
50}
51
52#[cfg(all(target_arch = "wasm32", feature = "bare", not(test)))]
53#[global_allocator]
54static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc;
55
56/// How a line relates to the other side of the diff.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum DiffKind {
59    /// Present in both inputs, unchanged.
60    Equal,
61    /// Present only in the revision.
62    Add,
63    /// Present only in the original.
64    Remove,
65}
66
67impl DiffKind {
68    /// The wire encoding: a single character, so the JS side can rebuild the
69    /// label without a table lookup.
70    pub fn as_str(self) -> &'static str {
71        match self {
72            DiffKind::Equal => "equal",
73            DiffKind::Add => "add",
74            DiffKind::Remove => "remove",
75        }
76    }
77}
78
79/// One line of output. `old_line`/`new_line` are 1-based, or `None` when the
80/// line does not exist on that side.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct DiffLine {
83    pub kind: DiffKind,
84    pub text: String,
85    pub old_line: Option<u32>,
86    pub new_line: Option<u32>,
87}
88
89/// Counts for a diff header.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct DiffStats {
92    pub added: u32,
93    pub removed: u32,
94    pub identical: bool,
95}
96
97/// One step of the greedy walk, before backtracking recovers the edit script.
98#[derive(Debug, Clone, Copy)]
99struct Step {
100    kind: DiffKind,
101    a_index: usize,
102    b_index: usize,
103}
104
105/// Split text into lines, without a phantom trailing empty line.
106///
107/// An empty input has *no* lines, not one empty line. Treating it as one makes
108/// "created a file" render as "emptied an existing file", which is a
109/// materially different (and wrong) story to a reviewer.
110///
111/// `\r\n`, a lone `\r` and `\n` all end a line. Lines are borrowed slices
112/// found by scanning bytes rather than built with `replace` and `split`: the
113/// terminators are ASCII, so every cut lands on a character boundary, and the
114/// substring search behind `replace` brought panic paths whose message
115/// formatting was a quarter of the WASM module.
116pub fn split_lines(text: &str) -> Vec<&str> {
117    let bytes = text.as_bytes();
118    let mut lines = Vec::new();
119    let mut start = 0;
120    let mut i = 0;
121    while i < bytes.len() {
122        let end = i;
123        match bytes[i] {
124            b'\n' => i += 1,
125            b'\r' if bytes.get(i + 1) == Some(&b'\n') => i += 2,
126            b'\r' => i += 1,
127            _ => {
128                i += 1;
129                continue;
130            }
131        }
132        lines.push(text.get(start..end).unwrap_or(""));
133        start = i;
134    }
135    // A final terminator ends the last line rather than starting an empty one.
136    if start < bytes.len() {
137        lines.push(text.get(start..).unwrap_or(""));
138    }
139    lines
140}
141
142/// Compute a line diff between two texts.
143pub fn diff_lines(before: &str, after: &str) -> Vec<DiffLine> {
144    let a = split_lines(before);
145    let b = split_lines(after);
146
147    let steps = myers(&a, &b);
148
149    // Attach 1-based line numbers as the steps are emitted, so a removed line
150    // reports only its old number and an added line only its new one.
151    let mut out = Vec::with_capacity(steps.len());
152    let mut old_no: u32 = 1;
153    let mut new_no: u32 = 1;
154    for step in steps {
155        let (old_line, new_line) = match step.kind {
156            DiffKind::Equal => {
157                let pair = (Some(old_no), Some(new_no));
158                old_no += 1;
159                new_no += 1;
160                pair
161            }
162            DiffKind::Remove => {
163                let pair = (Some(old_no), None);
164                old_no += 1;
165                pair
166            }
167            DiffKind::Add => {
168                let pair = (None, Some(new_no));
169                new_no += 1;
170                pair
171            }
172        };
173        out.push(DiffLine {
174            kind: step.kind,
175            text: step_text(&a, &b, &step).to_string(),
176            old_line,
177            new_line,
178        });
179    }
180    out
181}
182
183fn step_text<'a>(a: &[&'a str], b: &[&'a str], step: &Step) -> &'a str {
184    match step.kind {
185        DiffKind::Equal | DiffKind::Remove => a[step.a_index],
186        DiffKind::Add => b[step.b_index],
187    }
188}
189
190/// Counts for a diff header.
191pub fn diff_stats(lines: &[DiffLine]) -> DiffStats {
192    let mut added = 0;
193    let mut removed = 0;
194    for line in lines {
195        match line.kind {
196            DiffKind::Add => added += 1,
197            DiffKind::Remove => removed += 1,
198            DiffKind::Equal => {}
199        }
200    }
201    DiffStats {
202        added,
203        removed,
204        identical: added == 0 && removed == 0,
205    }
206}
207
208/// Myers' greedy walk.
209///
210/// `v[k]` holds, for each diagonal `k`, the furthest `x` reached. Each step
211/// extends the walk down `k` (a deletion) or up (an insertion) and takes
212/// whichever reaches further — the greedy choice that makes this O(ND) rather
213/// than O(N*M). The per-step trace is what `backtrack` needs to recover the
214/// edit script.
215fn myers(a: &[&str], b: &[&str]) -> Vec<Step> {
216    let n = a.len();
217    let m = b.len();
218    if n == 0 {
219        return (0..m)
220            .map(|j| Step { kind: DiffKind::Add, a_index: 0, b_index: j })
221            .collect();
222    }
223    if m == 0 {
224        return (0..n)
225            .map(|i| Step { kind: DiffKind::Remove, a_index: i, b_index: 0 })
226            .collect();
227    }
228
229    let max = n + m;
230    let offset = max as i32;
231    // Indexed by diagonal k, stored at k + offset so negative k is a valid index.
232    let mut v: Vec<i32> = vec![0i32; 2 * max + 1];
233    let mut trace: Vec<Vec<i32>> = Vec::with_capacity(min(max, 4096));
234
235    for d in 0..=max as i32 {
236        trace.push(v.clone());
237        let mut k = -d;
238        while k <= d {
239            let idx = (k + offset) as usize;
240            let down = (k + 1 + offset) as usize;
241            let right = (k - 1 + offset) as usize;
242            let mut x = if k == -d || (k != d && v[right] < v[down]) {
243                v[down]
244            } else {
245                v[right] + 1
246            };
247            let mut y = x - k;
248            while (x as usize) < n && (y as usize) < m && a[x as usize] == b[y as usize] {
249                x += 1;
250                y += 1;
251            }
252            v[idx] = x;
253            if x as usize >= n && y as usize >= m {
254                /*
255                 * The endpoint must come from the live `v`, not from `trace[d]`.
256                 *
257                 * `trace[d]` was pushed *before* this step ran, so it holds the
258                 * state after step d-1. Reading the start position from it walks
259                 * the path back from the wrong place: equal lines get
260                 * mis-attributed to inserts and deletes, and the reconstruction
261                 * invariant fails silently. This was a real bug in the JS twin.
262                 */
263                return backtrack(&trace, d, offset, x, y);
264            }
265            k += 2;
266        }
267    }
268    // Unreachable: d == n + m always reaches the corner, and both degenerate
269    // cases are handled by the guards above.
270    Vec::new()
271}
272
273/// Walk the recorded trace backwards to recover the edit script.
274///
275/// A diagonal step can cover several equal lines, so the inner loop walks back
276/// along the diagonal first and only then attributes the single remaining step
277/// to an insert or delete. Getting that order wrong is what drops unchanged
278/// lines from the output.
279fn backtrack(trace: &[Vec<i32>], d: i32, offset: i32, end_x: i32, end_y: i32) -> Vec<Step> {
280    let mut steps = Vec::new();
281    let mut x = end_x;
282    let mut y = end_y;
283
284    /*
285     * Mirrors the JS twin step for step, because a divergence here is the worst
286     * bug this system can have: users get correct results on one path and wrong
287     * results on the other with no way to tell.
288     *
289     * Two details are load-bearing and were wrong in the first Rust draft:
290     *   - the state read is `trace[step]`, not `trace[step - 1]`;
291     *   - the coordinates are decremented when the step is pushed, so `x`/`y`
292     *     always describe the next position to compare, not the last consumed.
293     * Getting the second wrong duplicates the boundary step, which showed up as
294     * a spurious leading `add` and shifted every line number after it.
295     */
296    for step in (1..=d).rev() {
297        let v = &trace[step as usize];
298        let k = x - y;
299        // Which diagonal the previous step came from: k+1 means an insertion
300        // (the walk moved down in b), k-1 a deletion. `k == -step` has only one
301        // predecessor, so it short-circuits.
302        let prev_k = if k == -step
303            || (k != step && v[(k - 1 + offset) as usize] < v[(k + 1 + offset) as usize])
304        {
305            k + 1
306        } else {
307            k - 1
308        };
309        let prev_x = v[(prev_k + offset) as usize];
310        let prev_y = prev_x - prev_k;
311
312        // Every diagonal position between prev and current is an equal line.
313        while x > prev_x && y > prev_y {
314            steps.push(Step {
315                kind: DiffKind::Equal,
316                a_index: (x - 1) as usize,
317                b_index: (y - 1) as usize,
318            });
319            x -= 1;
320            y -= 1;
321        }
322        if x == prev_x {
323            steps.push(Step { kind: DiffKind::Add, a_index: x as usize, b_index: (y - 1) as usize });
324            y -= 1;
325        } else {
326            steps.push(Step {
327                kind: DiffKind::Remove,
328                a_index: (x - 1) as usize,
329                b_index: y as usize,
330            });
331            x -= 1;
332        }
333    }
334
335    // Anything left at the origin is an equal line.
336    while x > 0 && y > 0 {
337        steps.push(Step {
338            kind: DiffKind::Equal,
339            a_index: (x - 1) as usize,
340            b_index: (y - 1) as usize,
341        });
342        x -= 1;
343        y -= 1;
344    }
345
346    steps.reverse();
347    steps
348}
349
350// ---------------------------------------------------------------------------
351// wasm-bindgen boundary
352// ---------------------------------------------------------------------------
353//
354// Primitives in, primitives out, and nothing else. This crate's Cargo.toml
355// states the rule it exists to demonstrate: `js-sys` adds ~302 KB the instant it
356// is linked and LTO cannot remove it, which is why every other module in the
357// workspace is ~10x over budget. `fc-diff` returns a flat `Float64Array`-free
358// shape instead -- three parallel primitive arrays and one length.
359//
360// A `Vec<JsValue>` of diff lines would be far more natural to write and is
361// exactly the thing that costs 302 KB. The layout is chosen so the whole result
362// crosses in three typed arrays:
363//
364//   kinds[i]  0 = equal, 1 = add, 2 = remove
365//   old[i]    1-based line number in the original, or -1 when the line is new
366//   new[i]    1-based line number in the revision, or -1 when the line is removed
367//   text_at   the line's own text, fetched by index only when the caller needs it
368//
369// -1 rather than 0 for "absent" is deliberate: line numbers are 1-based, so 0
370// would be a value no real line can take, and the JS side can compare against
371// `>= 1` without a null check.
372//
373// `text_at` is a method rather than a fourth array because the text is
374// variable-length and the only consumer is a renderer walking the rows. The
375// three numeric arrays are what a diff summary or a test needs, and those are
376// the paths that must not pay for strings.
377
378#[cfg(feature = "bindgen")]
379use wasm_bindgen::prelude::*;
380
381/// Wire encoding for `DiffKind`, kept next to the boundary that depends on it.
382const KIND_EQUAL: i32 = 0;
383const KIND_ADD: i32 = 1;
384const KIND_REMOVE: i32 = 2;
385
386/// Line count without materialising the text array.
387///
388/// Splitting is cheap and the count is what a caller needs to size the other
389/// three arrays, so this is the one call that does the work up front.
390#[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = lineCount))]
391pub fn js_line_count(text: &str) -> usize {
392    split_lines(text).len()
393}
394
395/// The per-line kinds, as a flat `Int32Array` of length `lineCount`.
396#[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = diffKinds))]
397pub fn js_diff_kinds(before: &str, after: &str) -> Vec<i32> {
398    diff_lines(before, after)
399        .iter()
400        .map(|l| match l.kind {
401            DiffKind::Equal => KIND_EQUAL,
402            DiffKind::Add => KIND_ADD,
403            DiffKind::Remove => KIND_REMOVE,
404        })
405        .collect()
406}
407
408/// 1-based line numbers in the original; -1 where the line is new.
409#[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = diffOldLines))]
410pub fn js_diff_old_lines(before: &str, after: &str) -> Vec<i32> {
411    diff_lines(before, after)
412        .iter()
413        .map(|l| l.old_line.map_or(-1, |n| n as i32))
414        .collect()
415}
416
417/// 1-based line numbers in the revision; -1 where the line was removed.
418#[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = diffNewLines))]
419pub fn js_diff_new_lines(before: &str, after: &str) -> Vec<i32> {
420    diff_lines(before, after)
421        .iter()
422        .map(|l| l.new_line.map_or(-1, |n| n as i32))
423        .collect()
424}
425
426/// The text of line `index`, or `""` when out of range.
427///
428/// Out-of-range returns empty rather than panicking: a JS caller indexing past
429/// the end should see no text, not take the whole module down with a trap.
430#[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = diffTextAt))]
431pub fn js_diff_text_at(before: &str, after: &str, index: usize) -> String {
432    diff_lines(before, after)
433        .get(index)
434        .map(|l| l.text.clone())
435        .unwrap_or_default()
436}
437
438/// `[added, removed]` as a flat two-element array.
439#[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = diffStats))]
440pub fn js_diff_stats(before: &str, after: &str) -> Vec<i32> {
441    let stats = diff_stats(&diff_lines(before, after));
442    vec![stats.added as i32, stats.removed as i32]
443}
444
445/// Whether the two inputs are identical after line splitting.
446#[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = diffIdentical))]
447pub fn js_diff_identical(before: &str, after: &str) -> bool {
448    diff_stats(&diff_lines(before, after)).identical
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    /// The invariant that makes a diff trustworthy, and the reason the JS twin
456    /// carries the same test: dropping the added lines must reproduce the
457    /// original, and dropping the removed lines must reproduce the revision.
458    /// This one property catches every possible backtracking off-by-one.
459    fn reconstruct(lines: &[DiffLine]) -> (String, String) {
460        let before = lines
461            .iter()
462            .filter(|l| l.kind != DiffKind::Add)
463            .map(|l| l.text.as_str())
464            .collect::<Vec<_>>()
465            .join("\n");
466        let after = lines
467            .iter()
468            .filter(|l| l.kind != DiffKind::Remove)
469            .map(|l| l.text.as_str())
470            .collect::<Vec<_>>()
471            .join("\n");
472        (before, after)
473    }
474
475    fn cases() -> Vec<(&'static str, &'static str, &'static str)> {
476        vec![
477            ("single change", "a\nb\nc", "a\nx\nc"),
478            ("from empty", "", "new"),
479            ("to empty", "gone", ""),
480            ("both empty", "", ""),
481            ("identical", "same", "same"),
482            ("middle edit", "a\nb\nc\nd\ne\nf\ng\nh", "a\nb\nc\nX\nY\ne\nf\ng\nh"),
483            ("reversed", "1\n2\n3\n4\n5", "5\n4\n3\n2\n1"),
484            ("blank lines removed", "a\n\n\nb", "a\nb"),
485            ("duplicated", "x", "x\nx\nx"),
486            ("full replace", "l1\nl2", "totally\ndifferent\ncontent\nhere"),
487        ]
488    }
489
490    #[test]
491    fn reconstructs_both_sides() {
492        for (name, before, after) in cases() {
493            let lines = diff_lines(before, after);
494            let (rebuilt_before, rebuilt_after) = reconstruct(&lines);
495            assert_eq!(rebuilt_before, split_lines(before).join("\n"), "{name}: before");
496            assert_eq!(rebuilt_after, split_lines(after).join("\n"), "{name}: after");
497        }
498    }
499
500    #[test]
501    fn empty_input_is_not_one_empty_line() {
502        // "" .split('\n') is [""], so an empty file used to diff as a removal --
503        // making "created a file" render as "emptied an existing one".
504        assert!(split_lines("").is_empty());
505        let stats = diff_stats(&diff_lines("", "new"));
506        assert_eq!(stats.added, 1);
507        assert_eq!(stats.removed, 0);
508    }
509
510    #[test]
511    fn trailing_newline_is_a_terminator() {
512        assert_eq!(split_lines("a\n").len(), 1);
513    }
514
515    #[test]
516    fn every_terminator_ends_one_line() {
517        assert_eq!(split_lines("a\r\nb\rc\nd"), ["a", "b", "c", "d"]);
518        // A lone \r before \r\n is its own terminator, not part of the pair.
519        assert_eq!(split_lines("a\r\r\nb"), ["a", "", "b"]);
520        assert_eq!(split_lines("\n"), [""]);
521        assert_eq!(split_lines("a\n\n"), ["a", ""]);
522        assert_eq!(split_lines("é\r😀"), ["é", "😀"]);
523    }
524
525    #[test]
526    fn normalizes_crlf() {
527        let lines = diff_lines("a\r\nb", "a\r\nc");
528        assert!(!lines.iter().any(|l| l.text.contains('\r')));
529        assert_eq!(
530            lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join(","),
531            "a,b,c"
532        );
533    }
534
535    #[test]
536    fn reports_accurate_counts() {
537        let stats = diff_stats(&diff_lines("a\nb\nc", "a\nc\nd\ne"));
538        assert_eq!(stats.added, 2);
539        assert_eq!(stats.removed, 1);
540        assert!(!stats.identical);
541        assert!(diff_stats(&diff_lines("a", "a")).identical);
542    }
543
544    #[test]
545    fn line_numbers_are_one_based_and_absent_on_the_missing_side() {
546        let lines = diff_lines("a", "a\nb");
547        let added = lines.iter().find(|l| l.kind == DiffKind::Add).unwrap();
548        assert_eq!(added.old_line, None, "never invent a number for a line that was never there");
549        assert_eq!(added.new_line, Some(2));
550    }
551
552    #[test]
553    fn handles_a_large_file_with_a_small_edit() {
554        // The reason for Myers over a DP table: O(ND), not O(N*M) in time and
555        // memory. A 20k-line file with one changed line must stay fast.
556        let big: String = (0..20_000).map(|i| format!("line {i}\n")).collect();
557        let edited = big.replacen("line 10000", "line 10000 CHANGED", 1);
558        let stats = diff_stats(&diff_lines(&big, &edited));
559        assert_eq!(stats.added, 1);
560        assert_eq!(stats.removed, 1);
561    }
562}