|
| 1 | +import { IS_NODEJS, getType, pluralize, stringify } from "./util.js"; |
| 2 | +import { stripFormatting } from "./format-console.js"; |
| 3 | + |
| 4 | +// Dual Node/browser import. Kept version in the CDN URL in sync with package.json. |
| 5 | +// `diffWordsWithSpace` keeps whitespace as part of token boundaries so the |
| 6 | +// reconstructed string matches the input; plain `diffWords` collapses it. |
| 7 | +const { diffChars, diffLines, diffWordsWithSpace: diffWords } = await import( |
| 8 | + IS_NODEJS |
| 9 | + ? "diff" |
| 10 | + : "https://cdn.jsdelivr.net/npm/diff@8.0.4/lib/index.es6.js" |
| 11 | +); |
| 12 | + |
| 13 | +/** @typedef {{value: string, added?: boolean, removed?: boolean}} Change */ |
| 14 | + |
| 15 | +/** Unchanged lines kept around each change (diff context). */ |
| 16 | +const CONTEXT = 2; |
| 17 | + |
| 18 | +/** Longer single-line text switches to two-line word-diff layout. */ |
| 19 | +const INLINE_MAX = 40; |
| 20 | + |
| 21 | +/** Per-side color, change-action key, and output label for diff formatting. */ |
| 22 | +const sides = { |
| 23 | + actual: { color: "red", action: "removed", label: " Actual: " }, |
| 24 | + expected: { color: "green", action: "added", label: " Expected: " }, |
| 25 | +}; |
| 26 | + |
| 27 | +export function formatDiff (actual, expected, unmapped = {}) { |
| 28 | + let actualType = getType(actual); |
| 29 | + let expectedType = getType(expected); |
| 30 | + |
| 31 | + if (actualType !== expectedType) { |
| 32 | + return typeMismatch(actual, expected, actualType, expectedType, unmapped); |
| 33 | + } |
| 34 | + |
| 35 | + let actualString = stringify(actual); |
| 36 | + let expectedString = stringify(expected); |
| 37 | + |
| 38 | + if (actualString === expectedString) { |
| 39 | + let actualLabel = sides.actual.label; |
| 40 | + let expectedLabel = sides.expected.label; |
| 41 | + |
| 42 | + return [ |
| 43 | + `${ actualLabel }${ actualString } <dim>(${ actualType })</dim>`, |
| 44 | + `${ expectedLabel }${ expectedString } <dim>(${ expectedType })</dim>`, |
| 45 | + ` <dim>(values are stringified identically but not equal)</dim>`, |
| 46 | + ].join("\n"); |
| 47 | + } |
| 48 | + |
| 49 | + if (actualString.includes("\n") || expectedString.includes("\n")) { |
| 50 | + return lineDiff(actualString, expectedString, unmapped); |
| 51 | + } |
| 52 | + |
| 53 | + let long = |
| 54 | + Math.max(actualString.length, expectedString.length) > INLINE_MAX |
| 55 | + && actualString.includes(" ") && expectedString.includes(" "); |
| 56 | + |
| 57 | + return sideBySide(actualString, expectedString, unmapped, !long); |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * Format a user-supplied value as a dim "unmapped" annotation. Four styles |
| 62 | + * match the four layouts that carry unmapped hints. |
| 63 | + */ |
| 64 | +function formatUnmapped (value, style = "inline") { |
| 65 | + value = stripFormatting(stringify(value)); |
| 66 | + let ret = ` <dim>(${value} unmapped)</dim>`; |
| 67 | + |
| 68 | + if (style === "gutter") { |
| 69 | + ret = ` <dim>${value} unmapped</dim>`; |
| 70 | + } |
| 71 | + else if (style === "actual") { |
| 72 | + ret = ` <dim>Actual unmapped: ${value}</dim>`; |
| 73 | + } |
| 74 | + else if (style === "expected") { |
| 75 | + ret = ` <dim>Expected unmapped: ${value}</dim>`; |
| 76 | + } |
| 77 | + |
| 78 | + return ret; |
| 79 | +} |
| 80 | + |
| 81 | +function typeMismatch (actual, expected, actualType, expectedType, unmapped) { |
| 82 | + let values = { actual, expected }; |
| 83 | + let lines = [`Got ${ actualType }, expected ${ expectedType }`]; |
| 84 | + |
| 85 | + for (let side in sides) { |
| 86 | + let { label } = sides[side]; |
| 87 | + let line = label + stringify(values[side]); |
| 88 | + |
| 89 | + if (side in unmapped) { |
| 90 | + line += formatUnmapped(unmapped[side]); |
| 91 | + } |
| 92 | + |
| 93 | + lines.push(line); |
| 94 | + } |
| 95 | + |
| 96 | + return lines.join("\n"); |
| 97 | +} |
| 98 | + |
| 99 | +/** |
| 100 | + * Inline (`Got X, expected Y`) or two-line (` Actual:` / ` Expected:`) layout, |
| 101 | + * driven by char-diff or word-diff depending on `inline`. |
| 102 | + */ |
| 103 | +function sideBySide (actualString, expectedString, unmapped, inline) { |
| 104 | + let changes = (inline ? diffChars : diffWords)(actualString, expectedString); |
| 105 | + let actual = colorize(changes, "actual"); |
| 106 | + let expected = colorize(changes, "expected"); |
| 107 | + |
| 108 | + if (inline) { |
| 109 | + let left = `Got ${ actual }`; |
| 110 | + if ("actual" in unmapped) { |
| 111 | + left += formatUnmapped(unmapped.actual); |
| 112 | + } |
| 113 | + |
| 114 | + let right = `expected ${ expected }`; |
| 115 | + if ("expected" in unmapped) { |
| 116 | + right += formatUnmapped(unmapped.expected); |
| 117 | + } |
| 118 | + |
| 119 | + return `${ left }, ${ right }`; |
| 120 | + } |
| 121 | + |
| 122 | + let formatted = { actual, expected }; |
| 123 | + let lines = []; |
| 124 | + |
| 125 | + for (let side in sides) { |
| 126 | + let { label } = sides[side]; |
| 127 | + |
| 128 | + lines.push(label + formatted[side]); |
| 129 | + if (side in unmapped) { |
| 130 | + lines.push(formatUnmapped(unmapped[side], "gutter")); |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + return "\n" + lines.join("\n"); |
| 135 | +} |
| 136 | + |
| 137 | +/** |
| 138 | + * Format one side of a change array. Whitespace-only runs get `<bg>` so |
| 139 | + * whitespace-only diffs stay visible. |
| 140 | + * |
| 141 | + * Without `prefix`: per-token `<c color>` wrap, common parts uncolored. |
| 142 | + * With `prefix`: one outer `<c color>` wraps the whole line with `prefix` in front, |
| 143 | + * tokens inside use plain `<b>`. Use this when a line is already committed to one side. |
| 144 | + */ |
| 145 | +function colorize (changes, side, prefix) { |
| 146 | + let { color, action } = sides[side]; |
| 147 | + let ret = ""; |
| 148 | + |
| 149 | + for (let change of changes) { |
| 150 | + if ((change.added || change.removed) && !change[action]) { |
| 151 | + continue; |
| 152 | + } |
| 153 | + |
| 154 | + if (!change[action]) { |
| 155 | + ret += change.value; |
| 156 | + continue; |
| 157 | + } |
| 158 | + |
| 159 | + for (let part of change.value.split(/(\s+)/)) { |
| 160 | + if (!part) { |
| 161 | + continue; |
| 162 | + } |
| 163 | + if (/^\s+$/.test(part)) { |
| 164 | + ret += `<bg ${ color }>${ part }</bg>`; |
| 165 | + } |
| 166 | + else { |
| 167 | + ret += prefix ? `<b>${ part }</b>` : `<c ${ color }><b>${ part }</b></c>`; |
| 168 | + } |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + return prefix ? `<c ${ color }>${ prefix } ${ ret }</c>` : ret; |
| 173 | +} |
| 174 | + |
| 175 | +function lineDiff (actualString, expectedString, unmapped) { |
| 176 | + // Flatten diffLines output to one entry per line. Each chunk ends with a |
| 177 | + // trailing `\n`; splitting produces a spurious empty tail we drop. |
| 178 | + let entries = []; |
| 179 | + for (let change of diffLines(actualString, expectedString)) { |
| 180 | + let prefix = change.added ? "+" : change.removed ? "-" : " "; |
| 181 | + let side = change.added ? "expected" : change.removed ? "actual" : "common"; |
| 182 | + let texts = change.value.split("\n"); |
| 183 | + if (texts.at(-1) === "") { |
| 184 | + texts.pop(); |
| 185 | + } |
| 186 | + for (let text of texts) { |
| 187 | + entries.push({ prefix, text, side }); |
| 188 | + } |
| 189 | + } |
| 190 | + |
| 191 | + let hunks = extractHunks(entries); |
| 192 | + let lines = [" Actual ↔ Expected:"]; |
| 193 | + |
| 194 | + for (let hunk of hunks) { |
| 195 | + if (hunk.elidedBefore > 0) { |
| 196 | + lines.push(elision(hunk.elidedBefore)); |
| 197 | + } |
| 198 | + |
| 199 | + for (let i = 0; i < hunk.lines.length; i++) { |
| 200 | + let entry = hunk.lines[i]; |
| 201 | + let prev = hunk.lines[i - 1]; |
| 202 | + let next = hunk.lines[i + 1]; |
| 203 | + let after = hunk.lines[i + 2]; |
| 204 | + |
| 205 | + // Pair only a lone `-` with a lone `+`. Multi-line blocks stay plain: |
| 206 | + // token-level highlighting across arbitrary alignments would mislead. |
| 207 | + let isolatedPair = |
| 208 | + entry.prefix === "-" && next?.prefix === "+" |
| 209 | + && (!prev || prev.prefix !== "-") |
| 210 | + && (!after || after.prefix !== "+"); |
| 211 | + |
| 212 | + if (isolatedPair) { |
| 213 | + let changes = diffWords(entry.text, next.text); |
| 214 | + lines.push(colorize(changes, "actual", "-")); |
| 215 | + lines.push(colorize(changes, "expected", "+")); |
| 216 | + i++; |
| 217 | + } |
| 218 | + else if (entry.side === "common") { |
| 219 | + lines.push(` ${ entry.text }`); |
| 220 | + } |
| 221 | + else { |
| 222 | + let { color } = sides[entry.side]; |
| 223 | + lines.push(`<c ${ color }>${ entry.prefix } ${ entry.text }</c>`); |
| 224 | + } |
| 225 | + } |
| 226 | + } |
| 227 | + |
| 228 | + let lastHunk = hunks.at(-1); |
| 229 | + if (lastHunk?.elidedAfter > 0) { |
| 230 | + lines.push(elision(lastHunk.elidedAfter)); |
| 231 | + } |
| 232 | + |
| 233 | + for (let [side, value] of Object.entries(unmapped)) { |
| 234 | + lines.push(formatUnmapped(value, side)); |
| 235 | + } |
| 236 | + |
| 237 | + return "\n" + lines.join("\n"); |
| 238 | +} |
| 239 | + |
| 240 | +function elision (count) { |
| 241 | + return ` <dim>… ${ count } matching ${ pluralize(count, "line", "lines") } …</dim>`; |
| 242 | +} |
| 243 | + |
| 244 | +/** |
| 245 | + * Group changed entries with up to `CONTEXT` common lines on each side. |
| 246 | + * Overlapping windows merge; gaps ≤ `2 * CONTEXT + 1` stay in place (an |
| 247 | + * elision marker would take as many rows as the lines it hides). |
| 248 | + */ |
| 249 | +function extractHunks (entries) { |
| 250 | + let last = entries.length - 1; |
| 251 | + let merged = []; |
| 252 | + for (let i = 0; i < entries.length; i++) { |
| 253 | + if (entries[i].side === "common") { |
| 254 | + continue; |
| 255 | + } |
| 256 | + let start = Math.max(0, i - CONTEXT); |
| 257 | + let end = Math.min(last, i + CONTEXT); |
| 258 | + let prev = merged.at(-1); |
| 259 | + if (prev && start - prev[1] <= 1) { |
| 260 | + // Windows overlap or touch — fold into the previous hunk. |
| 261 | + prev[1] = Math.max(prev[1], end); |
| 262 | + } |
| 263 | + else { |
| 264 | + merged.push([start, end]); |
| 265 | + } |
| 266 | + } |
| 267 | + if (merged.length === 0) { |
| 268 | + return []; |
| 269 | + } |
| 270 | + |
| 271 | + let hunks = merged.map(([start, end], i) => { |
| 272 | + let prevEnd = i === 0 ? -1 : merged[i - 1][1]; |
| 273 | + let elidedBefore = start - prevEnd - 1; |
| 274 | + // Leading common lines that fit in the context budget get shown — |
| 275 | + // the marker would take as many rows as the lines it would hide. |
| 276 | + if (i === 0 && elidedBefore <= CONTEXT) { |
| 277 | + start = 0; |
| 278 | + elidedBefore = 0; |
| 279 | + } |
| 280 | + return { lines: entries.slice(start, end + 1), elidedBefore, elidedAfter: 0 }; |
| 281 | + }); |
| 282 | + |
| 283 | + let [lastStart, lastEnd] = merged.at(-1); |
| 284 | + let trailing = last - lastEnd; |
| 285 | + if (trailing > CONTEXT) { |
| 286 | + hunks.at(-1).elidedAfter = trailing; |
| 287 | + } |
| 288 | + else { |
| 289 | + hunks.at(-1).lines = entries.slice(lastStart); |
| 290 | + } |
| 291 | + |
| 292 | + return hunks; |
| 293 | +} |
0 commit comments