Skip to main content

nodejs/stdlib/
assert_diff.rs

1//! The structural diff Node prints inside an `AssertionError` message.
2//!
3//! A failing `deepStrictEqual`/`strictEqual` in Node does not read `{ a: 1 } !==
4//! { a: 2 }`; it renders both operands with `util.inspect` and prints a
5//! line-oriented diff of the two renderings, marking lines only in `actual` with
6//! `+` and lines only in `expected` with `-`:
7//!
8//! ```text
9//! Expected values to be strictly deep-equal:
10//! + actual - expected
11//!
12//!   {
13//!     a: 1,
14//! +   b: 2
15//! -   b: 3
16//!   }
17//! ```
18//!
19//! This is a port of `lib/internal/assert/myers_diff.js` and the `createErrDiff`
20//! half of `lib/internal/assert/assertion_error.js`. The diff itself is Myers'
21//! O(ND) algorithm; the printer is the part with all the observable detail
22//! (which lines are context, when a long identical run collapses to `...`, and
23//! the `... Skipped lines` note that then appears in the header).
24//!
25//! Colors are never emitted: the harness compares captured stdout, which Node
26//! itself renders uncolored when stderr is not a TTY.
27
28use crate::host::with_host;
29use fusevm::Value;
30
31/// One line of the merged diff. `Nop` lines are present in both renderings.
32#[derive(Clone, Copy, PartialEq, Eq)]
33enum Op {
34    Delete,
35    Nop,
36    Insert,
37}
38
39/// How many consecutive unchanged lines are printed before the run collapses.
40/// Node's `kNopLinesToCollapse`.
41const NOP_LINES_TO_COLLAPSE: usize = 5;
42
43/// Node's `kMaxShortStringLength`: below this combined width the two operands
44/// are printed as `actual !== expected` on one line instead of diffed.
45const MAX_SHORT_STRING_LENGTH: usize = 12;
46
47/// Render a value the way `assert` does, which is NOT the way `console.log`
48/// does: every group broken onto its own line (`compact: false`), keys in sorted
49/// order, no depth or array-length limit. The options matter to the diff rather
50/// than to taste — one property per line is what gives the differ something to
51/// match line-by-line, and sorting means two objects built with the same
52/// properties in a different insertion order diff as equal instead of as a
53/// wholesale rewrite.
54///
55/// `customInspect: false` matters more than it sounds: it is what makes a
56/// Buffer in a failing assertion print its bytes as a list rather than as the
57/// opaque `<Buffer ff fe …>` summary, so the diff can point at the byte that
58/// differs. Node's `showProxy: false` is already this renderer's behavior.
59fn inspect_value(v: &Value) -> String {
60    crate::host::set_inspect_compact(0);
61    crate::host::set_inspect_sorted(true);
62    crate::host::set_inspect_max_depth(1000);
63    crate::host::set_inspect_max_array_length(usize::MAX);
64    crate::host::set_inspect_custom(false);
65    let s = with_host(|h| h.inspect(v));
66    crate::host::set_inspect_compact(3);
67    crate::host::set_inspect_sorted(false);
68    crate::host::set_inspect_max_depth(2);
69    crate::host::set_inspect_max_array_length(crate::host::DEFAULT_MAX_ARRAY_LENGTH);
70    crate::host::set_inspect_custom(true);
71    s
72}
73
74/// Whether two rendered lines count as the same line.
75///
76/// `check_comma_disparity` makes a line equal to itself-plus-a-trailing-comma.
77/// Without it, appending a property to an object would report the previous last
78/// property as changed too, purely because it gained a separator: diffing
79/// `{a: 1}` against `{a: 1, b: 2}` would mark `a: 1` -> `a: 1,` as a
80/// substitution rather than leaving it as context.
81fn lines_equal(actual: &str, expected: &str, check_comma_disparity: bool) -> bool {
82    if actual == expected {
83        return true;
84    }
85    if check_comma_disparity {
86        return expected.strip_suffix(',').is_some_and(|e| e == actual)
87            || actual.strip_suffix(',').is_some_and(|a| a == expected);
88    }
89    false
90}
91
92/// Myers' shortest-edit-script over the two line lists, returned in REVERSE
93/// order (the printer walks it back to front, as Node's does).
94///
95/// The `v` array is indexed by diagonal. Node reads `v[offset - 1]` and
96/// `v[offset + 1]` out of an `Int32Array`, where an out-of-range read yields
97/// `undefined` and every comparison against it is false; each such read here is
98/// instead guarded by the branch that already made it unreachable, so no
99/// sentinel value is needed.
100fn myers_diff(
101    actual: &[&str],
102    expected: &[&str],
103    check_comma_disparity: bool,
104) -> Vec<(Op, String)> {
105    let actual_len = actual.len() as i64;
106    let expected_len = expected.len() as i64;
107    let max = actual_len + expected_len;
108    // Both sides empty: no edit script, and the loop below would index `v[max]`
109    // of a one-element array looking for a match it already has.
110    if max == 0 {
111        return Vec::new();
112    }
113    let mut v = vec![0i64; (2 * max + 1) as usize];
114    let mut trace: Vec<Vec<i64>> = Vec::new();
115
116    for diff_level in 0..=max {
117        trace.push(v.clone());
118        let mut diagonal = -diff_level;
119        while diagonal <= diff_level {
120            let offset = (diagonal + max) as usize;
121            // `diagonal == -diff_level` short-circuits before `v[offset - 1]` is
122            // read, which is the only case in which `offset` can be 0.
123            let mut x = if diagonal == -diff_level
124                || (diagonal != diff_level && v[offset - 1] < v[offset + 1])
125            {
126                v[offset + 1]
127            } else {
128                v[offset - 1] + 1
129            };
130            let mut y = x - diagonal;
131
132            while x < actual_len
133                && y < expected_len
134                && lines_equal(
135                    actual[x as usize],
136                    expected[y as usize],
137                    check_comma_disparity,
138                )
139            {
140                x += 1;
141                y += 1;
142            }
143
144            v[offset] = x;
145
146            if x >= actual_len && y >= expected_len {
147                return backtrack(&trace, actual, expected, check_comma_disparity);
148            }
149            diagonal += 2;
150        }
151    }
152    Vec::new()
153}
154
155/// Walk the recorded per-level diagonals back to the origin, emitting the edit
156/// script. Returned reversed, exactly as Node returns it.
157fn backtrack(
158    trace: &[Vec<i64>],
159    actual: &[&str],
160    expected: &[&str],
161    check_comma_disparity: bool,
162) -> Vec<(Op, String)> {
163    let actual_len = actual.len() as i64;
164    let expected_len = expected.len() as i64;
165    let max = actual_len + expected_len;
166
167    let mut x = actual_len;
168    let mut y = expected_len;
169    let mut result: Vec<(Op, String)> = Vec::new();
170
171    for diff_level in (0..trace.len() as i64).rev() {
172        let v = &trace[diff_level as usize];
173        let diagonal = x - y;
174        let offset = (diagonal + max) as usize;
175
176        let prev_diagonal = if diagonal == -diff_level
177            || (diagonal != diff_level && v[offset - 1] < v[offset + 1])
178        {
179            diagonal + 1
180        } else {
181            diagonal - 1
182        };
183
184        // Node reads this out of bounds on the last level and gets `undefined`,
185        // which makes both `x > prevX` tests below false. `None` reproduces that
186        // without inventing a numeric sentinel that could compare as a real
187        // position.
188        let idx = prev_diagonal + max;
189        let prev = if idx >= 0 && (idx as usize) < v.len() {
190            let prev_x = v[idx as usize];
191            Some((prev_x, prev_x - prev_diagonal))
192        } else {
193            None
194        };
195
196        if let Some((prev_x, prev_y)) = prev {
197            while x > prev_x && y > prev_y {
198                let actual_item = actual[(x - 1) as usize];
199                // With comma disparity on, a context line is printed in the
200                // EXPECTED side's spelling when the actual side is the one
201                // missing the trailing comma — otherwise the last property of a
202                // shortened object would print without the separator the
203                // surrounding lines still carry.
204                let value = if check_comma_disparity && !actual_item.ends_with(',') {
205                    expected[(y - 1) as usize]
206                } else {
207                    actual_item
208                };
209                result.push((Op::Nop, value.to_string()));
210                x -= 1;
211                y -= 1;
212            }
213        }
214
215        if diff_level > 0 {
216            if prev.is_some_and(|(prev_x, _)| x > prev_x) {
217                x -= 1;
218                result.push((Op::Insert, actual[x as usize].to_string()));
219            } else {
220                y -= 1;
221                result.push((Op::Delete, expected[y as usize].to_string()));
222            }
223        }
224    }
225    result
226}
227
228/// Render the edit script. Returns the message body and whether any unchanged
229/// run was collapsed (which the caller reports as `... Skipped lines`).
230///
231/// Unchanged lines are printed only while fewer than `NOP_LINES_TO_COLLAPSE` of
232/// them have accumulated since the last change. When the run then ends, its LAST
233/// one or two lines are printed after the fact so that a change keeps its
234/// leading context, and a longer run is introduced by a bare `...`.
235fn print_myers_diff(diff: &[(Op, String)]) -> (String, bool) {
236    let mut message = String::new();
237    let mut skipped = false;
238    let mut nop_count = 0usize;
239
240    for idx in (0..diff.len()).rev() {
241        let (operation, value) = (&diff[idx].0, &diff[idx].1);
242        let previous_operation = if idx + 1 < diff.len() {
243            Some(diff[idx + 1].0)
244        } else {
245            None
246        };
247
248        // A run of unchanged lines has just ended. Re-emit its tail as leading
249        // context for the change about to be printed — but only if that is more
250        // than one line, since collapsing a single line saves nothing.
251        if previous_operation == Some(Op::Nop) && *operation != Op::Nop {
252            if nop_count == NOP_LINES_TO_COLLAPSE + 1 {
253                message.push_str(&format!("  {}\n", diff[idx + 1].1));
254            } else if nop_count == NOP_LINES_TO_COLLAPSE + 2 {
255                message.push_str(&format!("  {}\n", diff[idx + 2].1));
256                message.push_str(&format!("  {}\n", diff[idx + 1].1));
257            } else if nop_count >= NOP_LINES_TO_COLLAPSE + 3 {
258                message.push_str("...\n");
259                message.push_str(&format!("  {}\n", diff[idx + 1].1));
260                skipped = true;
261            }
262            nop_count = 0;
263        }
264
265        match operation {
266            Op::Insert => message.push_str(&format!("+ {value}\n")),
267            Op::Delete => message.push_str(&format!("- {value}\n")),
268            Op::Nop => {
269                if nop_count < NOP_LINES_TO_COLLAPSE {
270                    message.push_str(&format!("  {value}\n"));
271                }
272                nop_count += 1;
273            }
274        }
275    }
276
277    (message.trim_end().to_string(), skipped)
278}
279
280/// Node's `kReadableOperator`: the heading each comparison writes above its diff.
281fn readable_operator(op: &str) -> &'static str {
282    match op {
283        "deepStrictEqual" => "Expected values to be strictly deep-equal:",
284        "partialDeepStrictEqual" => "Expected values to be partially and strictly deep-equal:",
285        "strictEqual" => "Expected values to be strictly equal:",
286        "strictEqualObject" => "Expected \"actual\" to be reference-equal to \"expected\":",
287        "notIdentical" => "Values have same structure but are not reference-equal:",
288        _ => "Expected values to be strictly deep-equal:",
289    }
290}
291
292/// Whether a value is an object for the purposes below — `typeof v === 'object'
293/// && v !== null`, so a function is NOT one.
294fn is_object(v: &Value) -> bool {
295    with_host(|h| h.type_of(v) == "object" && !h.is_null(v))
296}
297
298/// Node's `checkOperator`: `strictEqual` between two distinct objects reports
299/// that they are not REFERENCE-equal, since their contents may well match.
300fn check_operator(actual: &Value, expected: &Value, operator: &str) -> String {
301    if operator == "strictEqual" && is_object(actual) && is_object(expected) {
302        return "strictEqualObject".to_string();
303    }
304    operator.to_string()
305}
306
307/// Node's `getStackedDiff`: the two operands stacked on their own `+`/`-` lines,
308/// with a caret under the first differing character when both sides are short
309/// enough to line up.
310fn stacked_diff(actual: &str, expected: &str) -> String {
311    let mut message = format!("\n+ {actual}\n- {expected}");
312    // Node compares against the terminal width only when stderr is a TTY, and
313    // otherwise against 80. Assert messages are captured, not printed, in every
314    // context this runtime is compared in, so 80 is the constant here.
315    let strings_len = actual.chars().count() + expected.chars().count();
316    if strings_len > 80 {
317        return message;
318    }
319    let a: Vec<char> = actual.chars().collect();
320    let e: Vec<char> = expected.chars().collect();
321    let mut indicator_idx: Option<usize> = None;
322    for (i, ch) in a.iter().enumerate() {
323        if e.get(i) != Some(ch) {
324            // The first two characters are skipped because a difference there is
325            // already obvious; the bound is 3 rather than 2 to account for the
326            // opening quote a rendered string carries.
327            if i >= 3 {
328                indicator_idx = Some(i);
329            }
330            break;
331        }
332    }
333    if let Some(i) = indicator_idx {
334        message.push('\n');
335        message.push_str(&" ".repeat(i + 2));
336        message.push('^');
337    }
338    message
339}
340
341/// Node's `getSimpleDiff`: how two operands that each rendered to a SINGLE line
342/// are shown.
343fn simple_diff(
344    original_actual: &Value,
345    actual: &str,
346    original_expected: &Value,
347    expected: &str,
348) -> (String, Option<String>) {
349    let is_str = |v: &Value| with_host(|h| h.type_of(v) == "string");
350    let mut strings_len = actual.chars().count() + expected.chars().count();
351    // A rendered string carries quotes the comparison should not count.
352    if is_str(original_actual) {
353        strings_len = strings_len.saturating_sub(2);
354    }
355    if is_str(original_expected) {
356        strings_len = strings_len.saturating_sub(2);
357    }
358    let both_zero = with_host(|h| {
359        h.type_of(original_actual) == "number"
360            && h.type_of(original_expected) == "number"
361            && h.to_number(original_actual) == 0.0
362            && h.to_number(original_expected) == 0.0
363    });
364    // `0` against `-0` renders as `0` and `-0`, which is short enough for the
365    // one-line form — but that form would read `0 !== -0` with no indication of
366    // which is which, so Node stacks it instead.
367    if strings_len <= MAX_SHORT_STRING_LENGTH && !both_zero {
368        return (format!("{actual} !== {expected}"), Some(String::new()));
369    }
370    (stacked_diff(actual, expected), None)
371}
372
373/// Build the whole `AssertionError` message for the comparisons that carry a
374/// diff (`strictEqual`, `deepStrictEqual`, `partialDeepStrictEqual`).
375///
376/// A custom message replaces only the HEADING; Node still appends the diff, so
377/// `assert.deepStrictEqual(a, b, 'boom')` reports `boom` followed by the same
378/// `+ actual - expected` body a generated message would carry.
379pub fn create_err_diff(
380    actual: &Value,
381    expected: &Value,
382    operator: &str,
383    custom_message: Option<&str>,
384) -> String {
385    let mut operator = check_operator(actual, expected, operator);
386    let mut skipped = false;
387    let message;
388    let inspected_actual = inspect_value(actual);
389    let inspected_expected = inspect_value(expected);
390    let split_actual: Vec<&str> = inspected_actual.split('\n').collect();
391    let split_expected: Vec<&str> = inspected_expected.split('\n').collect();
392    let mut header = "+ actual - expected".to_string();
393
394    // Node's `isSimpleDiff`: a diff needs something to align, so it applies only
395    // once at least one side rendered to more than one line AND both sides are
396    // objects.
397    let show_simple = if split_actual.len() > 1 || split_expected.len() > 1 {
398        false
399    } else {
400        !is_object(actual) || !is_object(expected)
401    };
402
403    if show_simple {
404        let (m, h) = simple_diff(actual, split_actual[0], expected, split_expected[0]);
405        message = m;
406        if let Some(h) = h {
407            header = h;
408        }
409    } else if inspected_actual == inspected_expected {
410        // Structurally identical but not the same reference — there is no diff
411        // to draw, so Node prints the one shared rendering under a heading that
412        // says exactly that.
413        operator = "notIdentical".to_string();
414        if split_actual.len() > 50 {
415            message = format!("{}\n...}}", split_actual[..50].join("\n"));
416            skipped = true;
417        } else {
418            message = split_actual.join("\n");
419        }
420        header = String::new();
421    } else {
422        // Comma disparity is only meaningful between two rendered containers;
423        // between primitives a trailing comma is part of the value.
424        let check_comma_disparity = is_object(actual);
425        let diff = myers_diff(&split_actual, &split_expected, check_comma_disparity);
426        let (body, was_skipped) = print_myers_diff(&diff);
427        message = format!("\n{body}");
428        if was_skipped {
429            skipped = true;
430        }
431    }
432
433    let heading = custom_message
434        .map(str::to_string)
435        .unwrap_or_else(|| readable_operator(&operator).to_string());
436    let skipped_message = if skipped { "\n... Skipped lines" } else { "" };
437    format!("{heading}\n{header}{skipped_message}\n{message}\n")
438}
439
440/// Render a value for the assert messages that show ONE operand rather than a
441/// diff (`notDeepStrictEqual`, `deepEqual`, `notDeepEqual`). These use the same
442/// expanded, sorted rendering as the diff body, not `console.log`'s.
443pub fn inspect_operand(v: &Value) -> String {
444    inspect_value(v)
445}