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}