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}