diff --git a/testing/_diff.ts b/testing/_diff.ts index 7d659acd2621..ec632915402d 100644 --- a/testing/_diff.ts +++ b/testing/_diff.ts @@ -15,6 +15,7 @@ export enum DiffType { export interface DiffResult { type: DiffType; value: T; + details?: Array>; } const REMOVED = 1; @@ -226,3 +227,113 @@ export function diff(A: T[], B: T[]): Array> { ), ]; } + +/** + * Renders the differences between the actual and expected strings + * Partially inspired from https://github.com/kpdecker/jsdiff + * @param A Actual string + * @param B Expected string + */ +export function diffstr(A: string, B: string) { + function tokenize(string: string, { wordDiff = false } = {}): string[] { + if (wordDiff) { + // Split string on whitespace symbols + const tokens = string.split(/([^\S\r\n]+|[()[\]{}'"\r\n]|\b)/); + // Extended Latin character set + const words = + /^[a-zA-Z\u{C0}-\u{FF}\u{D8}-\u{F6}\u{F8}-\u{2C6}\u{2C8}-\u{2D7}\u{2DE}-\u{2FF}\u{1E00}-\u{1EFF}]+$/u; + + // Join boundary splits that we do not consider to be boundaries and merge empty strings surrounded by word chars + for (let i = 0; i < tokens.length - 1; i++) { + if ( + !tokens[i + 1] && tokens[i + 2] && words.test(tokens[i]) && + words.test(tokens[i + 2]) + ) { + tokens[i] += tokens[i + 2]; + tokens.splice(i + 1, 2); + i--; + } + } + return tokens.filter((token) => token); + } else { + // Split string on new lines symbols + const tokens = [], lines = string.split(/(\n|\r\n)/); + + // Ignore final empty token when text ends with a newline + if (!lines[lines.length - 1]) { + lines.pop(); + } + + // Merge the content and line separators into single tokens + for (let i = 0; i < lines.length; i++) { + if (i % 2) { + tokens[tokens.length - 1] += lines[i]; + } else { + tokens.push(lines[i]); + } + } + return tokens; + } + } + + // Create details by filtering revelant word-diff for current line + // and merge "space-diff" if surrounded by word-diff for cleaner displays + function createDetails( + line: DiffResult, + tokens: Array>, + ) { + return tokens.filter(({ type }) => + type === line.type || type === DiffType.common + ).map((result, i, t) => { + if ( + (result.type === DiffType.common) && (t[i - 1]) && + (t[i - 1]?.type === t[i + 1]?.type) + ) { + result.type = t[i - 1].type; + } + return result; + }); + } + + // Compute multi-line diff + const diffResult = diff(tokenize(`${A}\n`), tokenize(`${B}\n`)); + const added = [], removed = []; + for (const result of diffResult) { + if (result.type === DiffType.added) { + added.push(result); + } + if (result.type === DiffType.removed) { + removed.push(result); + } + } + + // Compute word-diff + const aLines = added.length < removed.length ? added : removed; + const bLines = aLines === removed ? added : removed; + for (const a of aLines) { + let tokens = [] as Array>, + b: undefined | DiffResult; + // Search another diff line with at least one common token + while (bLines.length) { + b = bLines.shift(); + tokens = diff( + tokenize(a.value, { wordDiff: true }), + tokenize(b?.value ?? "", { wordDiff: true }), + ); + if ( + tokens.some(({ type, value }) => + type === DiffType.common && value.trim().length + ) + ) { + break; + } + } + // Register word-diff details + a.details = createDetails(a, tokens); + if (b) { + b.details = createDetails(b, tokens); + } + } + + return diffResult; +} diff --git a/testing/_diff_test.ts b/testing/_diff_test.ts index ec40c191c2ea..638cb0c44f8c 100644 --- a/testing/_diff_test.ts +++ b/testing/_diff_test.ts @@ -1,5 +1,5 @@ // Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. -import { diff } from "./_diff.ts"; +import { diff, diffstr } from "./_diff.ts"; import { assertEquals } from "../testing/asserts.ts"; Deno.test({ @@ -109,3 +109,41 @@ Deno.test({ ]); }, }); + +Deno.test({ + name: '"a b c d" vs "a b x d e" (diffstr)', + fn(): void { + const diffResult = diffstr( + [..."abcd"].join("\n"), + [..."abxde"].join("\n"), + ); + assertEquals(diffResult, [ + { type: "common", value: "a\n" }, + { type: "common", value: "b\n" }, + { type: "added", value: "x\n" }, + { + type: "removed", + value: "c\n", + details: [{ type: "removed", value: "c" }, { + type: "common", + value: "\n", + }], + }, + { type: "common", value: "d\n" }, + { + type: "added", + value: "e\n", + details: [ + { + type: "added", + value: "e", + }, + { + type: "common", + value: "\n", + }, + ], + }, + ]); + }, +}); diff --git a/testing/asserts.ts b/testing/asserts.ts index edc91b89051a..291a5a6a5310 100644 --- a/testing/asserts.ts +++ b/testing/asserts.ts @@ -2,8 +2,17 @@ // This module is browser compatible. Do not rely on good formatting of values // for AssertionError messages in browsers. -import { bold, gray, green, red, stripColor, white } from "../fmt/colors.ts"; -import { diff, DiffResult, DiffType } from "./_diff.ts"; +import { + bgGreen, + bgRed, + bold, + gray, + green, + red, + stripColor, + white, +} from "../fmt/colors.ts"; +import { diff, DiffResult, diffstr, DiffType } from "./_diff.ts"; const CAN_NOT_DISPLAY = "[Cannot display]"; @@ -40,12 +49,16 @@ export function _format(v: unknown): string { * Colors the output of assertion diffs * @param diffType Difference type, either added or removed */ -function createColor(diffType: DiffType): (s: string) => string { +function createColor( + diffType: DiffType, + { background = false } = {}, +): (s: string) => string { switch (diffType) { case DiffType.added: - return (s: string): string => green(bold(s)); + return (s: string): string => + background ? bgGreen(white(s)) : green(bold(s)); case DiffType.removed: - return (s: string): string => red(bold(s)); + return (s: string): string => background ? bgRed(white(s)) : red(bold(s)); default: return white; } @@ -66,8 +79,11 @@ function createSign(diffType: DiffType): string { } } -function buildMessage(diffResult: ReadonlyArray>): string[] { - const messages: string[] = []; +function buildMessage( + diffResult: ReadonlyArray>, + { stringDiff = false } = {}, +): string[] { + const messages: string[] = [], diffMessages: string[] = []; messages.push(""); messages.push(""); messages.push( @@ -79,8 +95,14 @@ function buildMessage(diffResult: ReadonlyArray>): string[] { messages.push(""); diffResult.forEach((result: DiffResult): void => { const c = createColor(result.type); - messages.push(c(`${createSign(result.type)}${result.value}`)); + const line = result.details?.map((detail) => + detail.type !== DiffType.common + ? createColor(detail.type, { background: true })(detail.value) + : detail.value + ).join("") ?? result.value; + diffMessages.push(c(`${createSign(result.type)}${line}`)); }); + messages.push(...(stringDiff ? [diffMessages.join("")] : diffMessages)); messages.push(""); return messages; @@ -207,11 +229,12 @@ export function assertEquals( const actualString = _format(actual); const expectedString = _format(expected); try { - const diffResult = diff( - actualString.split("\n"), - expectedString.split("\n"), - ); - const diffMsg = buildMessage(diffResult).join("\n"); + const stringDiff = (typeof actual === "string") && + (typeof expected === "string"); + const diffResult = stringDiff + ? diffstr(actual as string, expected as string) + : diff(actualString.split("\n"), expectedString.split("\n")); + const diffMsg = buildMessage(diffResult, { stringDiff }).join("\n"); message = `Values are not equal:\n${diffMsg}`; } catch { message = `\n${red(CAN_NOT_DISPLAY)} + \n\n`; @@ -309,11 +332,12 @@ export function assertStrictEquals( }\n`; } else { try { - const diffResult = diff( - actualString.split("\n"), - expectedString.split("\n"), - ); - const diffMsg = buildMessage(diffResult).join("\n"); + const stringDiff = (typeof actual === "string") && + (typeof expected === "string"); + const diffResult = stringDiff + ? diffstr(actual as string, expected as string) + : diff(actualString.split("\n"), expectedString.split("\n")); + const diffMsg = buildMessage(diffResult, { stringDiff }).join("\n"); message = `Values are not strictly equal:\n${diffMsg}`; } catch { message = `\n${red(CAN_NOT_DISPLAY)} + \n\n`; diff --git a/testing/asserts_test.ts b/testing/asserts_test.ts index b2c70caa144b..4be8ae5ed3b3 100644 --- a/testing/asserts_test.ts +++ b/testing/asserts_test.ts @@ -927,6 +927,23 @@ Deno.test("assertEquals diff for differently ordered objects", () => { ); }); +Deno.test("assert diff formatting (strings)", () => { + assertThrows( + () => { + assertEquals([..."abcd"].join("\n"), [..."abxde"].join("\n")); + }, + undefined, + ` + a + b +${green("+ x")} +${red("- c")} + d +${green("+ e")} +`, + ); +}); + // Check that the diff formatter overrides some default behaviours of // `Deno.inspect()` which are problematic for diffing. Deno.test("assert diff formatting", () => {