From 9d32ca0956eb5407315163942fba3f79e06cd46f Mon Sep 17 00:00:00 2001 From: lionel-rowe Date: Wed, 30 Apr 2025 23:52:16 +0800 Subject: [PATCH 1/5] feat(assert): truncate big diffs --- assert/equals_test.ts | 89 +++++++++++++++++++++ internal/build_message.ts | 139 +++++++++++++++++++++++++++++++-- internal/build_message_test.ts | 129 +++++++++++++++++++++++++++--- internal/diff_str.ts | 4 +- internal/types.ts | 19 +++-- 5 files changed, 352 insertions(+), 28 deletions(-) diff --git a/assert/equals_test.ts b/assert/equals_test.ts index 2f9bd29b9908..9d7ca8d0de46 100644 --- a/assert/equals_test.ts +++ b/assert/equals_test.ts @@ -8,6 +8,7 @@ import { stripAnsiCode, yellow, } from "@std/internal/styles"; +import { dedent } from "@std/text/unstable-dedent"; function createHeader(): string[] { return [ @@ -228,3 +229,91 @@ Deno.test({ assertEquals(new Set(data), new Set(data)); }, }); + +Deno.test("assertEquals() truncates unchanged lines of large diffs", async (t) => { + const a = Array.from({ length: 10_000 }, (_, i) => i); + const b = [...a]; + b[5_000] = -1; + + await t.step("array", () => { + assertThrows( + () => assertEquals(a, b), + AssertionError, + dedent` + [ + ... 4998 unchanged lines ... + 4998, + 4999, + - 5000, + + -1, + 5001, + 5002, + ... 4997 unchanged lines ... + ] + `, + ); + }); + + await t.step("object", () => { + assertThrows( + () => + assertEquals( + Object.fromEntries(a.entries()), + Object.fromEntries(b.entries()), + ), + AssertionError, + dedent` + { + ... 4446 unchanged lines ... + "50": 50, + "500": 500, + - "5000": 5000, + + "5000": -1, + "5001": 5001, + "5002": 5002, + ... 5549 unchanged lines ... + } + `, + ); + }); + + await t.step("string", () => { + assertThrows( + () => assertEquals(a.join("\n"), b.join("\n")), + AssertionError, + dedent` + 0\\n + ... 4997 unchanged lines ... + 4998\\n + 4999\\n + - 5000\\n + + -1\\n + 5001\\n + 5002\\n + ... 4996 unchanged lines ... + 9999 + `, + ); + }); + + await t.step("Set", () => { + assertThrows( + () => assertEquals(new Set(a), new Set(b)), + AssertionError, + dedent` + Set(10000) { + + -1, + 0, + 1, + ... 4444 unchanged lines ... + 50, + 500, + - 5000, + 5001, + 5002, + ... 5549 unchanged lines ... + } + `, + ); + }); +}); diff --git a/internal/build_message.ts b/internal/build_message.ts index b45a8a9d5a09..92e842c199e7 100644 --- a/internal/build_message.ts +++ b/internal/build_message.ts @@ -2,7 +2,7 @@ // This module is browser compatible. import { bgGreen, bgRed, bold, gray, green, red, white } from "./styles.ts"; -import type { DiffResult, DiffType } from "./types.ts"; +import type { CommonDiffResult, DiffResult, DiffType } from "./types.ts"; /** * Colors the output of assertion diffs. @@ -36,6 +36,8 @@ export function createColor( return (s) => background ? bgGreen(white(s)) : green(bold(s)); case "removed": return (s) => background ? bgRed(white(s)) : red(bold(s)); + case "truncation": + return gray; default: return white; } @@ -73,10 +75,29 @@ export function createSign(diffType: DiffType): string { export interface BuildMessageOptions { /** * Whether to output the diff as a single string. - * * @default {false} */ stringDiff?: boolean; + /** + * Minimum number of total diff result lines to enable truncation. + * @default {100} + */ + minTruncationLength?: number; + /** + * Length of an individual common span in lines to trigger truncation. + * @default {10} + */ + truncationSpanLength?: number; + /** + * Length of context in lines either side of a truncated span in a truncated diff. + * @default {2} + */ + truncationContextLength?: number; + /** + * Length of context in lines to show at very start and end of a truncated diff. + * @default {1} + */ + truncationExtremityLength?: number; } /** @@ -110,6 +131,7 @@ export function buildMessage( diffResult: ReadonlyArray>, options: BuildMessageOptions = {}, ): string[] { + diffResult = consolidateDiff(diffResult, options); const { stringDiff = false } = options; const messages = [ "", @@ -122,13 +144,116 @@ export function buildMessage( ]; const diffMessages = diffResult.map((result) => { const color = createColor(result.type); - const line = result.details?.map((detail) => - detail.type !== "common" - ? createColor(detail.type, true)(detail.value) - : detail.value - ).join("") ?? result.value; + + const line = result.type === "added" || result.type === "removed" + ? result.details?.map((detail) => + detail.type !== "common" + ? createColor(detail.type, true)(detail.value) + : detail.value + ).join("") ?? result.value + : result.value; + return color(`${createSign(result.type)}${line}`); }); messages.push(...(stringDiff ? [diffMessages.join("")] : diffMessages), ""); return messages; } + +export function consolidateDiff( + diffResult: ReadonlyArray>, + options: BuildMessageOptions, +): ReadonlyArray> { + const { minTruncationLength = 100 } = options; + + if (diffResult.length < minTruncationLength) { + return diffResult; + } + + const messages: DiffResult[] = []; + const commons: CommonDiffResult[] = []; + + for (let i = 0; i < diffResult.length; ++i) { + const result = diffResult[i]!; + + if (result.type === "common") { + commons.push(result as typeof result & { type: typeof result.type }); + } else { + messages.push( + ...consolidateCommon( + commons, + commons.length === i ? "start" : "none", + options, + ), + ); + commons.length = 0; + messages.push(result); + } + } + + messages.push(...consolidateCommon(commons, "end", options)); + + return messages; +} + +export function consolidateCommon( + commons: ReadonlyArray>, + extremity: "start" | "end" | "none", + options: BuildMessageOptions, +): ReadonlyArray> { + const { + truncationSpanLength = 10, + truncationContextLength = 2, + truncationExtremityLength = 1, + stringDiff, + } = options; + const isStart = extremity === "start"; + const isEnd = extremity === "end"; + + const startTruncationLength = isStart + ? truncationExtremityLength + : truncationContextLength; + const endTruncationLength = isEnd + ? truncationExtremityLength + : truncationContextLength; + + if (commons.length <= truncationSpanLength) return commons; + + const [before, after] = [ + commons[startTruncationLength - 1]!.value, + commons[commons.length - endTruncationLength]!.value, + ]; + + const indent = isStart + ? getIndent(after) + : isEnd + ? getIndent(before) + : commonIndent(before, after); + + return [ + ...commons.slice(0, startTruncationLength), + { + type: "truncation", + value: `${indent}... ${ + commons.length - startTruncationLength - endTruncationLength + } unchanged lines ...${stringDiff ? "\n" : ""}`, + }, + ...commons.slice(-endTruncationLength), + ]; +} + +function commonIndent(line1: string, line2: string): string { + const [indent1, indent2] = [line1, line2].map(getIndent); + return !indent1 || !indent2 + ? "" + : indent1 === indent2 + ? indent1 + : indent1.startsWith(indent2) + ? indent1.slice(0, indent2.length) + : indent2.startsWith(indent1) + ? indent2.slice(0, indent1.length) + : ""; +} + +function getIndent(line: string): string { + return line.match(/^\s+/g)?.[0] ?? ""; +} diff --git a/internal/build_message_test.ts b/internal/build_message_test.ts index 0365006b25ae..82f7141c76e7 100644 --- a/internal/build_message_test.ts +++ b/internal/build_message_test.ts @@ -1,10 +1,16 @@ // Copyright 2018-2025 the Deno authors. MIT license. import { assertEquals } from "@std/assert"; import { bgGreen, bgRed, bold, gray, green, red, white } from "@std/fmt/colors"; -import { buildMessage, createColor, createSign } from "./build_message.ts"; +import { + buildMessage, + consolidateCommon, + consolidateDiff, + createColor, + createSign, +} from "./build_message.ts"; -Deno.test("buildMessage()", () => { - const messages = [ +Deno.test("buildMessage()", async (t) => { + const prelude = [ "", "", ` ${gray(bold("[Diff]"))} ${red(bold("Actual"))} / ${ @@ -13,14 +19,60 @@ Deno.test("buildMessage()", () => { "", "", ]; - assertEquals(buildMessage([]), [...messages, ""]); - assertEquals( - buildMessage([{ type: "added", value: "foo" }, { - type: "removed", - value: "bar", - }]), - [...messages, green(bold("+ foo")), red(bold("- bar")), ""], - ); + + await t.step("basic", () => { + assertEquals(buildMessage([]), [...prelude, ""]); + assertEquals( + buildMessage([ + { type: "added", value: "foo" }, + { type: "removed", value: "bar" }, + ]), + [...prelude, green(bold("+ foo")), red(bold("- bar")), ""], + ); + }); + + await t.step("truncated", () => { + assertEquals( + buildMessage([ + { type: "added", value: "foo" }, + { type: "common", value: "bar 1" }, + { type: "common", value: "bar 2" }, + { type: "common", value: "bar 3" }, + { type: "common", value: "bar 4" }, + { type: "common", value: "bar 5" }, + { type: "added", value: "foo" }, + { type: "common", value: "bar 1" }, + { type: "common", value: "bar 2" }, + { type: "common", value: "bar 3" }, + { type: "common", value: "bar 4" }, + { type: "common", value: "bar 5" }, + { type: "common", value: "bar 6" }, + { type: "removed", value: "baz" }, + { type: "common", value: "bar" }, + ], { + minTruncationLength: 0, + truncationSpanLength: 5, + }), + [ + ...prelude, + green(bold("+ foo")), + white(" bar 1"), + white(" bar 2"), + white(" bar 3"), + white(" bar 4"), + white(" bar 5"), + green(bold("+ foo")), + white(" bar 1"), + white(" bar 2"), + gray(" ... 2 unchanged lines ..."), + white(" bar 5"), + white(" bar 6"), + red(bold("- baz")), + white(" bar"), + "", + ], + ); + }); }); Deno.test("createColor()", () => { @@ -39,3 +91,58 @@ Deno.test("createSign()", () => { // deno-lint-ignore no-explicit-any assertEquals(createSign("unknown" as any), " "); }); + +Deno.test("consolidateDiff()", () => { + assertEquals( + consolidateDiff([ + { type: "added", value: "foo" }, + { type: "common", value: "[" }, + { type: "common", value: ' "bar-->",' }, + { type: "common", value: ' "bar",' }, + { type: "common", value: ' "bar",' }, + { type: "common", value: ' "<--bar",' }, + { type: "common", value: "]" }, + { type: "removed", value: "foo" }, + ], { + minTruncationLength: 0, + truncationSpanLength: 5, + }), + [ + { type: "added", value: "foo" }, + { type: "common", value: "[" }, + { type: "common", value: ' "bar-->",' }, + { type: "truncation", value: " ... 2 unchanged lines ..." }, + { type: "common", value: ' "<--bar",' }, + { type: "common", value: "]" }, + { type: "removed", value: "foo" }, + ], + ); +}); + +Deno.test("consolidateCommon()", () => { + assertEquals( + consolidateCommon( + [ + { type: "common", value: "[" }, + { type: "common", value: ' "bar-->",' }, + { type: "common", value: ' "bar",' }, + { type: "common", value: ' "bar",' }, + { type: "common", value: ' "bar",' }, + { type: "common", value: ' "<--bar",' }, + { type: "common", value: "]" }, + ], + "none", + { + minTruncationLength: 0, + truncationSpanLength: 5, + }, + ), + [ + { type: "common", value: "[" }, + { type: "common", value: ' "bar-->",' }, + { type: "truncation", value: " ... 3 unchanged lines ..." }, + { type: "common", value: ' "<--bar",' }, + { type: "common", value: "]" }, + ], + ); +}); diff --git a/internal/diff_str.ts b/internal/diff_str.ts index fcf56512a091..862103fe7113 100644 --- a/internal/diff_str.ts +++ b/internal/diff_str.ts @@ -1,7 +1,7 @@ // Copyright 2018-2025 the Deno authors. MIT license. // This module is browser compatible. -import type { DiffResult } from "./types.ts"; +import type { ChangedDiffResult, DiffResult } from "./types.ts"; import { diff } from "./diff.ts"; /** @@ -179,7 +179,7 @@ export function diffStr(A: string, B: string): DiffResult[] { const bLines = hasMoreRemovedLines ? removed : added; for (const a of aLines) { let tokens = [] as Array>; - let b: undefined | DiffResult; + let b: undefined | ChangedDiffResult; // Search another diff line with at least one common token while (bLines.length) { b = bLines.shift(); diff --git a/internal/types.ts b/internal/types.ts index 6e7c5c113061..d81400ba7237 100644 --- a/internal/types.ts +++ b/internal/types.ts @@ -2,18 +2,21 @@ // This module is browser compatible. /** Ways that lines in a diff can be different. */ -export type DiffType = "removed" | "common" | "added"; +export type DiffType = DiffResult["type"]; /** * Represents the result of a diff operation. - * * @typeParam T The type of the value in the diff result. */ -export interface DiffResult { - /** The type of the diff. */ - type: DiffType; - /** The value of the diff. */ +export type DiffResult = ChangedDiffResult | CommonDiffResult; + +export type CommonDiffResult = { + type: "common" | "truncation"; + value: T; +}; + +export type ChangedDiffResult = { + type: "removed" | "added"; value: T; - /** The details of the diff. */ details?: DiffResult[]; -} +}; From 8b8b41c676356f9522090abe2894866c2a808da5 Mon Sep 17 00:00:00 2001 From: lionel-rowe Date: Mon, 19 May 2025 17:09:01 +0800 Subject: [PATCH 2/5] Add BuildMessageTruncationOptions for testing --- assert/equals_test.ts | 150 +++++++++++++++++++++++++++++++-- internal/build_message.ts | 93 ++++++++++++++------ internal/build_message_test.ts | 83 ++++++++++-------- 3 files changed, 260 insertions(+), 66 deletions(-) diff --git a/assert/equals_test.ts b/assert/equals_test.ts index 9d7ca8d0de46..361d5f8ca103 100644 --- a/assert/equals_test.ts +++ b/assert/equals_test.ts @@ -241,14 +241,30 @@ Deno.test("assertEquals() truncates unchanged lines of large diffs", async (t) = AssertionError, dedent` [ - ... 4998 unchanged lines ... + ... 4990 unchanged lines ... + 4990, + 4991, + 4992, + 4993, + 4994, + 4995, + 4996, + 4997, 4998, 4999, - 5000, + -1, 5001, 5002, - ... 4997 unchanged lines ... + 5003, + 5004, + 5005, + 5006, + 5007, + 5008, + 5009, + 5010, + ... 4989 unchanged lines ... ] `, ); @@ -264,14 +280,30 @@ Deno.test("assertEquals() truncates unchanged lines of large diffs", async (t) = AssertionError, dedent` { - ... 4446 unchanged lines ... + ... 4438 unchanged lines ... + "4993": 4993, + "4994": 4994, + "4995": 4995, + "4996": 4996, + "4997": 4997, + "4998": 4998, + "4999": 4999, + "5": 5, "50": 50, "500": 500, - "5000": 5000, + "5000": -1, "5001": 5001, "5002": 5002, - ... 5549 unchanged lines ... + "5003": 5003, + "5004": 5004, + "5005": 5005, + "5006": 5006, + "5007": 5007, + "5008": 5008, + "5009": 5009, + "501": 501, + ... 5541 unchanged lines ... } `, ); @@ -283,14 +315,30 @@ Deno.test("assertEquals() truncates unchanged lines of large diffs", async (t) = AssertionError, dedent` 0\\n - ... 4997 unchanged lines ... + ... 4989 unchanged lines ... + 4990\\n + 4991\\n + 4992\\n + 4993\\n + 4994\\n + 4995\\n + 4996\\n + 4997\\n 4998\\n 4999\\n - 5000\\n + -1\\n 5001\\n 5002\\n - ... 4996 unchanged lines ... + 5003\\n + 5004\\n + 5005\\n + 5006\\n + 5007\\n + 5008\\n + 5009\\n + 5010\\n + ... 4988 unchanged lines ... 9999 `, ); @@ -305,15 +353,101 @@ Deno.test("assertEquals() truncates unchanged lines of large diffs", async (t) = + -1, 0, 1, - ... 4444 unchanged lines ... + 10, + 100, + 1000, + 1001, + 1002, + 1003, + 1004, + 1005, + ... 4428 unchanged lines ... + 4993, + 4994, + 4995, + 4996, + 4997, + 4998, + 4999, + 5, 50, 500, - 5000, 5001, 5002, - ... 5549 unchanged lines ... + 5003, + 5004, + 5005, + 5006, + 5007, + 5008, + 5009, + 501, + ... 5541 unchanged lines ... } `, ); }); + + await t.step("diff near start", () => { + const a = Array.from({ length: 10_000 }, (_, i) => i); + const b = [...a]; + b[3] = -1; + + assertThrows( + () => assertEquals(a, b), + AssertionError, + dedent` + [ + 0, + 1, + 2, + - 3, + + -1, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + ... 9986 unchanged lines ... + ] + `, + ); + }); + + await t.step("diff near end", () => { + const a = Array.from({ length: 10_000 }, (_, i) => i); + const b = [...a]; + b[9996] = -1; + + assertThrows( + () => assertEquals(a, b), + AssertionError, + dedent` + [ + ... 9986 unchanged lines ... + 9986, + 9987, + 9988, + 9989, + 9990, + 9991, + 9992, + 9993, + 9994, + 9995, + - 9996, + + -1, + 9997, + 9998, + 9999, + ] + `, + ); + }); }); diff --git a/internal/build_message.ts b/internal/build_message.ts index 92e842c199e7..3b5f71ba946f 100644 --- a/internal/build_message.ts +++ b/internal/build_message.ts @@ -71,33 +71,72 @@ export function createSign(diffType: DiffType): string { } } -/** Options for {@linkcode buildMessage}. */ -export interface BuildMessageOptions { - /** - * Whether to output the diff as a single string. - * @default {false} - */ - stringDiff?: boolean; +function getMinTruncationLength() { + const ENV_PERM_STATUS = globalThis.Deno?.permissions.querySync?.({ + name: "env", + variable: "DIFF_CONTEXT_LENGTH", + }) + .state ?? "granted"; + const specifiedTruncationContextLength = ENV_PERM_STATUS === "granted" + ? Deno.env.get("DIFF_CONTEXT_LENGTH") ?? null + : null; + + const truncationContextLength = parseInt( + specifiedTruncationContextLength ?? "10", + ); + return Number.isNaN(truncationContextLength) + ? 0 + : truncationContextLength < 1 + ? Infinity + : truncationContextLength; +} + +function resolveBuildMessageTruncationOptions(): BuildMessageTruncationOptions { + const truncationContextLength = getMinTruncationLength(); + const truncationSpanLength = truncationContextLength * 2; + const minTruncationLength = truncationContextLength * 10; + + return { + minTruncationLength, + truncationSpanLength, + truncationContextLength, + truncationExtremityLength: 1, + }; +} + +const buildMessageTruncationOptions = resolveBuildMessageTruncationOptions(); + +/** Currently, explicitly passing these options is only used for testing. */ +type BuildMessageTruncationOptions = { /** * Minimum number of total diff result lines to enable truncation. * @default {100} */ - minTruncationLength?: number; + minTruncationLength: number; /** * Length of an individual common span in lines to trigger truncation. - * @default {10} + * @default {20} */ - truncationSpanLength?: number; + truncationSpanLength: number; /** * Length of context in lines either side of a truncated span in a truncated diff. - * @default {2} + * @default {10} */ - truncationContextLength?: number; + truncationContextLength: number; /** * Length of context in lines to show at very start and end of a truncated diff. * @default {1} */ - truncationExtremityLength?: number; + truncationExtremityLength: number; +}; + +/** Options for {@linkcode buildMessage}. */ +export interface BuildMessageOptions { + /** + * Whether to output the diff as a single string. + * @default {false} + */ + stringDiff?: boolean; } /** @@ -130,8 +169,10 @@ export interface BuildMessageOptions { export function buildMessage( diffResult: ReadonlyArray>, options: BuildMessageOptions = {}, + truncationOptions?: BuildMessageTruncationOptions, ): string[] { - diffResult = consolidateDiff(diffResult, options); + truncationOptions ??= buildMessageTruncationOptions; + diffResult = truncateDiff(diffResult, options, truncationOptions); const { stringDiff = false } = options; const messages = [ "", @@ -159,13 +200,12 @@ export function buildMessage( return messages; } -export function consolidateDiff( +export function truncateDiff( diffResult: ReadonlyArray>, options: BuildMessageOptions, + truncationOptions: BuildMessageTruncationOptions, ): ReadonlyArray> { - const { minTruncationLength = 100 } = options; - - if (diffResult.length < minTruncationLength) { + if (diffResult.length < truncationOptions.minTruncationLength) { return diffResult; } @@ -183,6 +223,7 @@ export function consolidateDiff( commons, commons.length === i ? "start" : "none", options, + truncationOptions, ), ); commons.length = 0; @@ -190,7 +231,9 @@ export function consolidateDiff( } } - messages.push(...consolidateCommon(commons, "end", options)); + messages.push( + ...consolidateCommon(commons, "end", options, truncationOptions), + ); return messages; } @@ -199,13 +242,15 @@ export function consolidateCommon( commons: ReadonlyArray>, extremity: "start" | "end" | "none", options: BuildMessageOptions, + truncationOptions: BuildMessageTruncationOptions, ): ReadonlyArray> { + const { stringDiff } = options; const { - truncationSpanLength = 10, - truncationContextLength = 2, - truncationExtremityLength = 1, - stringDiff, - } = options; + truncationSpanLength, + truncationContextLength, + truncationExtremityLength, + } = truncationOptions; + const isStart = extremity === "start"; const isEnd = extremity === "end"; diff --git a/internal/build_message_test.ts b/internal/build_message_test.ts index 82f7141c76e7..c80c58331b4f 100644 --- a/internal/build_message_test.ts +++ b/internal/build_message_test.ts @@ -4,9 +4,9 @@ import { bgGreen, bgRed, bold, gray, green, red, white } from "@std/fmt/colors"; import { buildMessage, consolidateCommon, - consolidateDiff, createColor, createSign, + truncateDiff, } from "./build_message.ts"; Deno.test("buildMessage()", async (t) => { @@ -33,26 +33,32 @@ Deno.test("buildMessage()", async (t) => { await t.step("truncated", () => { assertEquals( - buildMessage([ - { type: "added", value: "foo" }, - { type: "common", value: "bar 1" }, - { type: "common", value: "bar 2" }, - { type: "common", value: "bar 3" }, - { type: "common", value: "bar 4" }, - { type: "common", value: "bar 5" }, - { type: "added", value: "foo" }, - { type: "common", value: "bar 1" }, - { type: "common", value: "bar 2" }, - { type: "common", value: "bar 3" }, - { type: "common", value: "bar 4" }, - { type: "common", value: "bar 5" }, - { type: "common", value: "bar 6" }, - { type: "removed", value: "baz" }, - { type: "common", value: "bar" }, - ], { - minTruncationLength: 0, - truncationSpanLength: 5, - }), + buildMessage( + [ + { type: "added", value: "foo" }, + { type: "common", value: "bar 1" }, + { type: "common", value: "bar 2" }, + { type: "common", value: "bar 3" }, + { type: "common", value: "bar 4" }, + { type: "common", value: "bar 5" }, + { type: "added", value: "foo" }, + { type: "common", value: "bar 1" }, + { type: "common", value: "bar 2" }, + { type: "common", value: "bar 3" }, + { type: "common", value: "bar 4" }, + { type: "common", value: "bar 5" }, + { type: "common", value: "bar 6" }, + { type: "removed", value: "baz" }, + { type: "common", value: "bar" }, + ], + {}, + { + minTruncationLength: 0, + truncationSpanLength: 5, + truncationContextLength: 2, + truncationExtremityLength: 1, + }, + ), [ ...prelude, green(bold("+ foo")), @@ -94,19 +100,25 @@ Deno.test("createSign()", () => { Deno.test("consolidateDiff()", () => { assertEquals( - consolidateDiff([ - { type: "added", value: "foo" }, - { type: "common", value: "[" }, - { type: "common", value: ' "bar-->",' }, - { type: "common", value: ' "bar",' }, - { type: "common", value: ' "bar",' }, - { type: "common", value: ' "<--bar",' }, - { type: "common", value: "]" }, - { type: "removed", value: "foo" }, - ], { - minTruncationLength: 0, - truncationSpanLength: 5, - }), + truncateDiff( + [ + { type: "added", value: "foo" }, + { type: "common", value: "[" }, + { type: "common", value: ' "bar-->",' }, + { type: "common", value: ' "bar",' }, + { type: "common", value: ' "bar",' }, + { type: "common", value: ' "<--bar",' }, + { type: "common", value: "]" }, + { type: "removed", value: "foo" }, + ], + {}, + { + minTruncationLength: 0, + truncationSpanLength: 5, + truncationContextLength: 2, + truncationExtremityLength: 1, + }, + ), [ { type: "added", value: "foo" }, { type: "common", value: "[" }, @@ -132,9 +144,12 @@ Deno.test("consolidateCommon()", () => { { type: "common", value: "]" }, ], "none", + {}, { minTruncationLength: 0, truncationSpanLength: 5, + truncationContextLength: 2, + truncationExtremityLength: 1, }, ), [ From 80cde6947f1ab55d02e3a91a52a366d1234c0145 Mon Sep 17 00:00:00 2001 From: lionel-rowe Date: Sun, 21 Sep 2025 01:45:50 +0800 Subject: [PATCH 3/5] Set DIFF_CONTEXT_LENGTH via env var --- assert/equals.ts | 7 + assert/equals_test.ts | 308 +++++++++++++---------- assert/strict_equals.ts | 7 + internal/_truncate_build_message.ts | 94 +++++++ internal/_truncate_build_message_test.ts | 89 +++++++ internal/build_message.ts | 206 ++++----------- internal/build_message_test.ts | 79 +----- internal/types.ts | 8 + 8 files changed, 418 insertions(+), 380 deletions(-) create mode 100644 internal/_truncate_build_message.ts create mode 100644 internal/_truncate_build_message_test.ts diff --git a/assert/equals.ts b/assert/equals.ts index f0e355590a5b..0a48cf74c3a7 100644 --- a/assert/equals.ts +++ b/assert/equals.ts @@ -7,6 +7,8 @@ import { diffStr } from "@std/internal/diff-str"; import { format } from "@std/internal/format"; import { AssertionError } from "./assertion_error.ts"; +// deno-lint-ignore no-unused-vars +import type { DIFF_CONTEXT_LENGTH } from "@std/internal/build-message"; /** * Make an assertion that `actual` and `expected` are equal, deeply. If not @@ -19,6 +21,11 @@ import { AssertionError } from "./assertion_error.ts"; * `Uint8Array` using the `Blob.bytes()` method and then compare their * contents. * + * The {@linkcode DIFF_CONTEXT_LENGTH} environment variable can be set to + * enable truncation of long diffs, in which case its value should be a + * positive integer representing the number of unchanged context lines to show + * around each changed part of the diff. By default, diffs are not truncated. + * * @example Usage * ```ts ignore * import { assertEquals } from "@std/assert"; diff --git a/assert/equals_test.ts b/assert/equals_test.ts index 361d5f8ca103..207b85f65643 100644 --- a/assert/equals_test.ts +++ b/assert/equals_test.ts @@ -1,5 +1,6 @@ // Copyright 2018-2025 the Deno authors. MIT license. import { assertEquals, AssertionError, assertThrows } from "./mod.ts"; +import { DIFF_CONTEXT_LENGTH } from "@std/internal/build-message"; import { bold, gray, @@ -9,6 +10,8 @@ import { yellow, } from "@std/internal/styles"; import { dedent } from "@std/text/unstable-dedent"; +import { stub } from "@std/testing/mock"; +import { disposableStack } from "../internal/_testing.ts"; function createHeader(): string[] { return [ @@ -230,173 +233,200 @@ Deno.test({ }, }); -Deno.test("assertEquals() truncates unchanged lines of large diffs", async (t) => { - const a = Array.from({ length: 10_000 }, (_, i) => i); +function assertDiffMessage(a: unknown, b: unknown, expected: string) { + const err = assertThrows(() => assertEquals(a, b), AssertionError); + // TODO(lionel-rowe): re-spell `fullExpectedMessage` indentation once https://github.com/denoland/std/issues/6830 + // is fixed + const fullExpectedMessage = dedent` + Values are not equal. + + + [Diff] Actual / Expected + + + ${expected} + `; + assertEquals( + // TODO(lionel-rowe): compare full messages without trimming once https://github.com/denoland/std/issues/6830 and + // https://github.com/denoland/std/issues/6831 are fixed + stripAnsiCode(err.message).trimEnd(), + fullExpectedMessage, + ); +} + +Deno.test(`assertEquals() truncates unchanged lines of large diffs when "${DIFF_CONTEXT_LENGTH}" is set`, async (t) => { + using stack = disposableStack(); + stack.use(stub(Deno.permissions, "querySync", (x) => { + if (x.name === "env") return { state: "granted" } as Deno.PermissionStatus; + throw new Error(`Unexpected permission descriptor: ${x.name}`); + })); + stack.use(stub(Deno.env, "get", (key) => { + if (key === DIFF_CONTEXT_LENGTH) return "10"; + throw new Error(`Unexpected env var key: ${key}`); + })); + + const a = Array.from({ length: 1000 }, (_, i) => i); const b = [...a]; - b[5_000] = -1; + b[500] = -1; await t.step("array", () => { - assertThrows( - () => assertEquals(a, b), - AssertionError, + assertDiffMessage( + a, + b, dedent` [ - ... 4990 unchanged lines ... - 4990, - 4991, - 4992, - 4993, - 4994, - 4995, - 4996, - 4997, - 4998, - 4999, - - 5000, + ... 490 unchanged lines ... + 490, + 491, + 492, + 493, + 494, + 495, + 496, + 497, + 498, + 499, + - 500, + -1, - 5001, - 5002, - 5003, - 5004, - 5005, - 5006, - 5007, - 5008, - 5009, - 5010, - ... 4989 unchanged lines ... + 501, + 502, + 503, + 504, + 505, + 506, + 507, + 508, + 509, + 510, + ... 489 unchanged lines ... ] `, ); }); await t.step("object", () => { - assertThrows( - () => - assertEquals( - Object.fromEntries(a.entries()), - Object.fromEntries(b.entries()), - ), - AssertionError, + assertDiffMessage( + Object.fromEntries(a.entries()), + Object.fromEntries(b.entries()), dedent` { - ... 4438 unchanged lines ... - "4993": 4993, - "4994": 4994, - "4995": 4995, - "4996": 4996, - "4997": 4997, - "4998": 4998, - "4999": 4999, + ... 437 unchanged lines ... + "492": 492, + "493": 493, + "494": 494, + "495": 495, + "496": 496, + "497": 497, + "498": 498, + "499": 499, "5": 5, "50": 50, - "500": 500, - - "5000": 5000, - + "5000": -1, - "5001": 5001, - "5002": 5002, - "5003": 5003, - "5004": 5004, - "5005": 5005, - "5006": 5006, - "5007": 5007, - "5008": 5008, - "5009": 5009, + - "500": 500, + + "500": -1, "501": 501, - ... 5541 unchanged lines ... + "502": 502, + "503": 503, + "504": 504, + "505": 505, + "506": 506, + "507": 507, + "508": 508, + "509": 509, + "51": 51, + ... 542 unchanged lines ... } `, ); }); await t.step("string", () => { - assertThrows( - () => assertEquals(a.join("\n"), b.join("\n")), - AssertionError, + assertDiffMessage( + a.join("\n"), + b.join("\n"), dedent` 0\\n - ... 4989 unchanged lines ... - 4990\\n - 4991\\n - 4992\\n - 4993\\n - 4994\\n - 4995\\n - 4996\\n - 4997\\n - 4998\\n - 4999\\n - - 5000\\n + ... 489 unchanged lines ... + 490\\n + 491\\n + 492\\n + 493\\n + 494\\n + 495\\n + 496\\n + 497\\n + 498\\n + 499\\n + - 500\\n + -1\\n - 5001\\n - 5002\\n - 5003\\n - 5004\\n - 5005\\n - 5006\\n - 5007\\n - 5008\\n - 5009\\n - 5010\\n - ... 4988 unchanged lines ... - 9999 + 501\\n + 502\\n + 503\\n + 504\\n + 505\\n + 506\\n + 507\\n + 508\\n + 509\\n + 510\\n + ... 488 unchanged lines ... + 999 `, ); }); await t.step("Set", () => { - assertThrows( - () => assertEquals(new Set(a), new Set(b)), - AssertionError, + assertDiffMessage( + new Set(a), + new Set(b), dedent` - Set(10000) { + Set(1000) { + -1, 0, 1, 10, 100, - 1000, - 1001, - 1002, - 1003, - 1004, - 1005, - ... 4428 unchanged lines ... - 4993, - 4994, - 4995, - 4996, - 4997, - 4998, - 4999, + 101, + 102, + 103, + 104, + 105, + 106, + ... 427 unchanged lines ... + 492, + 493, + 494, + 495, + 496, + 497, + 498, + 499, 5, 50, - 500, - - 5000, - 5001, - 5002, - 5003, - 5004, - 5005, - 5006, - 5007, - 5008, - 5009, + - 500, 501, - ... 5541 unchanged lines ... + 502, + 503, + 504, + 505, + 506, + 507, + 508, + 509, + 51, + ... 542 unchanged lines ... } `, ); }); await t.step("diff near start", () => { - const a = Array.from({ length: 10_000 }, (_, i) => i); + const a = Array.from({ length: 1000 }, (_, i) => i); const b = [...a]; b[3] = -1; - assertThrows( - () => assertEquals(a, b), - AssertionError, + assertDiffMessage( + a, + b, dedent` [ 0, @@ -414,38 +444,38 @@ Deno.test("assertEquals() truncates unchanged lines of large diffs", async (t) = 11, 12, 13, - ... 9986 unchanged lines ... + ... 986 unchanged lines ... ] `, ); }); await t.step("diff near end", () => { - const a = Array.from({ length: 10_000 }, (_, i) => i); + const a = Array.from({ length: 1000 }, (_, i) => i); const b = [...a]; - b[9996] = -1; + b[996] = -1; - assertThrows( - () => assertEquals(a, b), - AssertionError, + assertDiffMessage( + a, + b, dedent` [ - ... 9986 unchanged lines ... - 9986, - 9987, - 9988, - 9989, - 9990, - 9991, - 9992, - 9993, - 9994, - 9995, - - 9996, + ... 986 unchanged lines ... + 986, + 987, + 988, + 989, + 990, + 991, + 992, + 993, + 994, + 995, + - 996, + -1, - 9997, - 9998, - 9999, + 997, + 998, + 999, ] `, ); diff --git a/assert/strict_equals.ts b/assert/strict_equals.ts index 197a5c708d88..ceac09c4ad4a 100644 --- a/assert/strict_equals.ts +++ b/assert/strict_equals.ts @@ -6,11 +6,18 @@ import { diffStr } from "@std/internal/diff-str"; import { format } from "@std/internal/format"; import { red } from "@std/internal/styles"; import { AssertionError } from "./assertion_error.ts"; +// deno-lint-ignore no-unused-vars +import type { DIFF_CONTEXT_LENGTH } from "@std/internal/build-message"; /** * Make an assertion that `actual` and `expected` are strictly equal, using * {@linkcode Object.is} for equality comparison. If not, then throw. * + * The {@linkcode DIFF_CONTEXT_LENGTH} environment variable can be set to + * enable truncation of long diffs, in which case its value should be a + * positive integer representing the number of unchanged context lines to show + * around each changed part of the diff. By default, diffs are not truncated. + * * @example Usage * ```ts ignore * import { assertStrictEquals } from "@std/assert"; diff --git a/internal/_truncate_build_message.ts b/internal/_truncate_build_message.ts new file mode 100644 index 000000000000..f4d74807757e --- /dev/null +++ b/internal/_truncate_build_message.ts @@ -0,0 +1,94 @@ +// Copyright 2018-2025 the Deno authors. MIT license. +import type { CommonDiffResult, DiffResult } from "@std/internal/types"; + +export function truncateDiff( + diffResult: ReadonlyArray>, + stringDiff: boolean, + contextLength: number, +): ReadonlyArray> { + const messages: DiffResult[] = []; + const commons: CommonDiffResult[] = []; + + for (let i = 0; i < diffResult.length; ++i) { + const result = diffResult[i]!; + + if (result.type === "common") { + commons.push(result as typeof result & { type: typeof result.type }); + } else { + messages.push( + ...consolidateCommon( + commons, + commons.length === i ? "start" : "middle", + stringDiff, + contextLength, + ), + ); + commons.length = 0; + messages.push(result); + } + } + + messages.push( + ...consolidateCommon(commons, "end", stringDiff, contextLength), + ); + + return messages; +} + +export function consolidateCommon( + commons: ReadonlyArray>, + location: "start" | "middle" | "end", + stringDiff: boolean, + contextLength: number, +): ReadonlyArray> { + const spanLength = contextLength * 2 + 1; + const extremityLength = 1; + + const startTruncationLength = location === "start" + ? extremityLength + : contextLength; + const endTruncationLength = location === "end" + ? extremityLength + : contextLength; + + if (commons.length <= spanLength) return commons; + + const [before, after] = [ + commons[startTruncationLength - 1]!.value, + commons[commons.length - endTruncationLength]!.value, + ]; + + const indent = location === "start" + ? getIndent(after) + : location === "end" + ? getIndent(before) + : commonIndent(before, after); + + return [ + ...commons.slice(0, startTruncationLength), + { + type: "truncation", + value: `${indent}... ${ + commons.length - startTruncationLength - endTruncationLength + } unchanged lines ...${stringDiff ? "\n" : ""}`, + }, + ...commons.slice(-endTruncationLength), + ]; +} + +function commonIndent(line1: string, line2: string): string { + const [indent1, indent2] = [line1, line2].map(getIndent); + return !indent1 || !indent2 + ? "" + : indent1 === indent2 + ? indent1 + : indent1.startsWith(indent2) + ? indent1.slice(0, indent2.length) + : indent2.startsWith(indent1) + ? indent2.slice(0, indent1.length) + : ""; +} + +function getIndent(line: string): string { + return line.match(/^\s+/g)?.[0] ?? ""; +} diff --git a/internal/_truncate_build_message_test.ts b/internal/_truncate_build_message_test.ts new file mode 100644 index 000000000000..c996823f6aad --- /dev/null +++ b/internal/_truncate_build_message_test.ts @@ -0,0 +1,89 @@ +// Copyright 2018-2025 the Deno authors. MIT license. +import { assertEquals } from "@std/assert/equals"; +import { consolidateCommon, truncateDiff } from "./_truncate_build_message.ts"; +import type { CommonDiffResult } from "@std/internal/types"; + +Deno.test("consolidateDiff()", () => { + assertEquals( + truncateDiff( + [ + { type: "added", value: "foo" }, + { type: "common", value: "[" }, + { type: "common", value: ' "bar-->",' }, + { type: "common", value: ' "bar",' }, + { type: "common", value: ' "bar",' }, + { type: "common", value: ' "<--bar",' }, + { type: "common", value: "]" }, + { type: "removed", value: "foo" }, + ], + false, + 2, + ), + [ + { type: "added", value: "foo" }, + { type: "common", value: "[" }, + { type: "common", value: ' "bar-->",' }, + { type: "truncation", value: " ... 2 unchanged lines ..." }, + { type: "common", value: ' "<--bar",' }, + { type: "common", value: "]" }, + { type: "removed", value: "foo" }, + ], + ); +}); + +Deno.test("consolidateCommon()", async (t) => { + const commons = [ + { type: "common", value: "[" }, + { type: "common", value: ' "bar-->",' }, + { type: "common", value: ' "bar",' }, + { type: "common", value: ' "bar",' }, + { type: "common", value: ' "bar",' }, + { type: "common", value: ' "<--bar",' }, + { type: "common", value: "]" }, + ] as const; + + const contextLength = 2; + + const cases: { + location: "start" | "middle" | "end"; + expected: ReadonlyArray>; + }[] = [ + { + location: "start", + expected: [ + { type: "common", value: "[" }, + { type: "truncation", value: " ... 4 unchanged lines ..." }, + { type: "common", value: ' "<--bar",' }, + { type: "common", value: "]" }, + ], + }, + { + location: "middle", + expected: [ + { type: "common", value: "[" }, + { type: "common", value: ' "bar-->",' }, + { type: "truncation", value: " ... 3 unchanged lines ..." }, + { type: "common", value: ' "<--bar",' }, + { type: "common", value: "]" }, + ], + }, + { + location: "end", + expected: [ + { type: "common", value: "[" }, + { type: "common", value: ' "bar-->",' }, + { type: "truncation", value: " ... 4 unchanged lines ..." }, + { type: "common", value: "]" }, + ], + }, + ]; + + for (const { location: extremity, expected } of cases) { + await t.step(extremity, () => { + assertEquals( + consolidateCommon(commons, extremity, false, contextLength), + expected, + ); + }); + } +}); diff --git a/internal/build_message.ts b/internal/build_message.ts index 3b5f71ba946f..fbb40df68b55 100644 --- a/internal/build_message.ts +++ b/internal/build_message.ts @@ -1,8 +1,9 @@ // Copyright 2018-2025 the Deno authors. MIT license. // This module is browser compatible. +import { truncateDiff } from "./_truncate_build_message.ts"; import { bgGreen, bgRed, bold, gray, green, red, white } from "./styles.ts"; -import type { CommonDiffResult, DiffResult, DiffType } from "./types.ts"; +import type { DiffResult, DiffType } from "./types.ts"; /** * Colors the output of assertion diffs. @@ -71,65 +72,39 @@ export function createSign(diffType: DiffType): string { } } -function getMinTruncationLength() { - const ENV_PERM_STATUS = globalThis.Deno?.permissions.querySync?.({ - name: "env", - variable: "DIFF_CONTEXT_LENGTH", - }) - .state ?? "granted"; - const specifiedTruncationContextLength = ENV_PERM_STATUS === "granted" - ? Deno.env.get("DIFF_CONTEXT_LENGTH") ?? null +/** The environment variable used for setting diff context length. */ +export const DIFF_CONTEXT_LENGTH = "DIFF_CONTEXT_LENGTH"; +function getTruncationEnvVar() { + // deno-lint-ignore no-explicit-any + const { Deno, process } = globalThis as any; + + if (typeof Deno === "object") { + const permissionStatus = Deno.permissions.querySync({ + name: "env", + variable: DIFF_CONTEXT_LENGTH, + }).state ?? "granted"; + + return permissionStatus === "granted" + ? Deno.env.get(DIFF_CONTEXT_LENGTH) ?? null + : null; + } + const nodeEnv = process?.getBuiltinModule?.("node:process")?.env as + | Partial> + | undefined; + return typeof nodeEnv === "object" + ? nodeEnv[DIFF_CONTEXT_LENGTH] ?? null : null; - - const truncationContextLength = parseInt( - specifiedTruncationContextLength ?? "10", - ); - return Number.isNaN(truncationContextLength) - ? 0 - : truncationContextLength < 1 - ? Infinity - : truncationContextLength; } -function resolveBuildMessageTruncationOptions(): BuildMessageTruncationOptions { - const truncationContextLength = getMinTruncationLength(); - const truncationSpanLength = truncationContextLength * 2; - const minTruncationLength = truncationContextLength * 10; - - return { - minTruncationLength, - truncationSpanLength, - truncationContextLength, - truncationExtremityLength: 1, - }; +function getTruncationContextLengthFromEnv() { + const envVar = getTruncationEnvVar(); + if (envVar == null) return null; + const truncationContextLength = parseInt(envVar); + return Number.isFinite(truncationContextLength) && truncationContextLength > 0 + ? truncationContextLength + : null; } -const buildMessageTruncationOptions = resolveBuildMessageTruncationOptions(); - -/** Currently, explicitly passing these options is only used for testing. */ -type BuildMessageTruncationOptions = { - /** - * Minimum number of total diff result lines to enable truncation. - * @default {100} - */ - minTruncationLength: number; - /** - * Length of an individual common span in lines to trigger truncation. - * @default {20} - */ - truncationSpanLength: number; - /** - * Length of context in lines either side of a truncated span in a truncated diff. - * @default {10} - */ - truncationContextLength: number; - /** - * Length of context in lines to show at very start and end of a truncated diff. - * @default {1} - */ - truncationExtremityLength: number; -}; - /** Options for {@linkcode buildMessage}. */ export interface BuildMessageOptions { /** @@ -144,6 +119,7 @@ export interface BuildMessageOptions { * * @param diffResult The diff result array. * @param options Optional parameters for customizing the message. + * @param contextLength Truncation context length. Explicitly passing `contextLength` is currently only used for testing. * * @returns An array of strings representing the built message. * @@ -151,9 +127,7 @@ export interface BuildMessageOptions { * ```ts no-assert * import { diffStr, buildMessage } from "@std/internal"; * - * const diffResult = diffStr("Hello, world!", "Hello, world"); - * - * console.log(buildMessage(diffResult)); + * diffStr("Hello, world!", "Hello, world"); * // [ * // "", * // "", @@ -169,10 +143,17 @@ export interface BuildMessageOptions { export function buildMessage( diffResult: ReadonlyArray>, options: BuildMessageOptions = {}, - truncationOptions?: BuildMessageTruncationOptions, + contextLength: number | null = null, ): string[] { - truncationOptions ??= buildMessageTruncationOptions; - diffResult = truncateDiff(diffResult, options, truncationOptions); + contextLength ??= getTruncationContextLengthFromEnv(); + if (contextLength != null) { + diffResult = truncateDiff( + diffResult, + options.stringDiff ?? false, + contextLength, + ); + } + const { stringDiff = false } = options; const messages = [ "", @@ -199,106 +180,3 @@ export function buildMessage( messages.push(...(stringDiff ? [diffMessages.join("")] : diffMessages), ""); return messages; } - -export function truncateDiff( - diffResult: ReadonlyArray>, - options: BuildMessageOptions, - truncationOptions: BuildMessageTruncationOptions, -): ReadonlyArray> { - if (diffResult.length < truncationOptions.minTruncationLength) { - return diffResult; - } - - const messages: DiffResult[] = []; - const commons: CommonDiffResult[] = []; - - for (let i = 0; i < diffResult.length; ++i) { - const result = diffResult[i]!; - - if (result.type === "common") { - commons.push(result as typeof result & { type: typeof result.type }); - } else { - messages.push( - ...consolidateCommon( - commons, - commons.length === i ? "start" : "none", - options, - truncationOptions, - ), - ); - commons.length = 0; - messages.push(result); - } - } - - messages.push( - ...consolidateCommon(commons, "end", options, truncationOptions), - ); - - return messages; -} - -export function consolidateCommon( - commons: ReadonlyArray>, - extremity: "start" | "end" | "none", - options: BuildMessageOptions, - truncationOptions: BuildMessageTruncationOptions, -): ReadonlyArray> { - const { stringDiff } = options; - const { - truncationSpanLength, - truncationContextLength, - truncationExtremityLength, - } = truncationOptions; - - const isStart = extremity === "start"; - const isEnd = extremity === "end"; - - const startTruncationLength = isStart - ? truncationExtremityLength - : truncationContextLength; - const endTruncationLength = isEnd - ? truncationExtremityLength - : truncationContextLength; - - if (commons.length <= truncationSpanLength) return commons; - - const [before, after] = [ - commons[startTruncationLength - 1]!.value, - commons[commons.length - endTruncationLength]!.value, - ]; - - const indent = isStart - ? getIndent(after) - : isEnd - ? getIndent(before) - : commonIndent(before, after); - - return [ - ...commons.slice(0, startTruncationLength), - { - type: "truncation", - value: `${indent}... ${ - commons.length - startTruncationLength - endTruncationLength - } unchanged lines ...${stringDiff ? "\n" : ""}`, - }, - ...commons.slice(-endTruncationLength), - ]; -} - -function commonIndent(line1: string, line2: string): string { - const [indent1, indent2] = [line1, line2].map(getIndent); - return !indent1 || !indent2 - ? "" - : indent1 === indent2 - ? indent1 - : indent1.startsWith(indent2) - ? indent1.slice(0, indent2.length) - : indent2.startsWith(indent1) - ? indent2.slice(0, indent1.length) - : ""; -} - -function getIndent(line: string): string { - return line.match(/^\s+/g)?.[0] ?? ""; -} diff --git a/internal/build_message_test.ts b/internal/build_message_test.ts index c80c58331b4f..16839f478591 100644 --- a/internal/build_message_test.ts +++ b/internal/build_message_test.ts @@ -1,13 +1,7 @@ // Copyright 2018-2025 the Deno authors. MIT license. import { assertEquals } from "@std/assert"; import { bgGreen, bgRed, bold, gray, green, red, white } from "@std/fmt/colors"; -import { - buildMessage, - consolidateCommon, - createColor, - createSign, - truncateDiff, -} from "./build_message.ts"; +import { buildMessage, createColor, createSign } from "./build_message.ts"; Deno.test("buildMessage()", async (t) => { const prelude = [ @@ -52,12 +46,7 @@ Deno.test("buildMessage()", async (t) => { { type: "common", value: "bar" }, ], {}, - { - minTruncationLength: 0, - truncationSpanLength: 5, - truncationContextLength: 2, - truncationExtremityLength: 1, - }, + 2, ), [ ...prelude, @@ -97,67 +86,3 @@ Deno.test("createSign()", () => { // deno-lint-ignore no-explicit-any assertEquals(createSign("unknown" as any), " "); }); - -Deno.test("consolidateDiff()", () => { - assertEquals( - truncateDiff( - [ - { type: "added", value: "foo" }, - { type: "common", value: "[" }, - { type: "common", value: ' "bar-->",' }, - { type: "common", value: ' "bar",' }, - { type: "common", value: ' "bar",' }, - { type: "common", value: ' "<--bar",' }, - { type: "common", value: "]" }, - { type: "removed", value: "foo" }, - ], - {}, - { - minTruncationLength: 0, - truncationSpanLength: 5, - truncationContextLength: 2, - truncationExtremityLength: 1, - }, - ), - [ - { type: "added", value: "foo" }, - { type: "common", value: "[" }, - { type: "common", value: ' "bar-->",' }, - { type: "truncation", value: " ... 2 unchanged lines ..." }, - { type: "common", value: ' "<--bar",' }, - { type: "common", value: "]" }, - { type: "removed", value: "foo" }, - ], - ); -}); - -Deno.test("consolidateCommon()", () => { - assertEquals( - consolidateCommon( - [ - { type: "common", value: "[" }, - { type: "common", value: ' "bar-->",' }, - { type: "common", value: ' "bar",' }, - { type: "common", value: ' "bar",' }, - { type: "common", value: ' "bar",' }, - { type: "common", value: ' "<--bar",' }, - { type: "common", value: "]" }, - ], - "none", - {}, - { - minTruncationLength: 0, - truncationSpanLength: 5, - truncationContextLength: 2, - truncationExtremityLength: 1, - }, - ), - [ - { type: "common", value: "[" }, - { type: "common", value: ' "bar-->",' }, - { type: "truncation", value: " ... 3 unchanged lines ..." }, - { type: "common", value: ' "<--bar",' }, - { type: "common", value: "]" }, - ], - ); -}); diff --git a/internal/types.ts b/internal/types.ts index d81400ba7237..1d98f98b91ef 100644 --- a/internal/types.ts +++ b/internal/types.ts @@ -10,11 +10,19 @@ export type DiffType = DiffResult["type"]; */ export type DiffResult = ChangedDiffResult | CommonDiffResult; +/** + * Represents the result of a common diff operation. + * @typeParam T The type of the value in the diff result. + */ export type CommonDiffResult = { type: "common" | "truncation"; value: T; }; +/** + * Represents the result of a changed diff operation. + * @typeParam T The type of the value in the diff result. + */ export type ChangedDiffResult = { type: "removed" | "added"; value: T; From 9e377059122b0da65e1e71df002909686b7a9ace Mon Sep 17 00:00:00 2001 From: lionel-rowe Date: Sun, 21 Sep 2025 17:34:37 +0800 Subject: [PATCH 4/5] Allow DIFF_CONTEXT_LENGTH=0 --- assert/equals_test.ts | 2 +- internal/_truncate_build_message.ts | 46 +++++++++++------------------ internal/build_message.ts | 5 ++-- 3 files changed, 21 insertions(+), 32 deletions(-) diff --git a/assert/equals_test.ts b/assert/equals_test.ts index 207b85f65643..64d5ac7933aa 100644 --- a/assert/equals_test.ts +++ b/assert/equals_test.ts @@ -261,7 +261,7 @@ Deno.test(`assertEquals() truncates unchanged lines of large diffs when "${DIFF_ throw new Error(`Unexpected permission descriptor: ${x.name}`); })); stack.use(stub(Deno.env, "get", (key) => { - if (key === DIFF_CONTEXT_LENGTH) return "10"; + if (key === DIFF_CONTEXT_LENGTH) return (10).toString(); throw new Error(`Unexpected env var key: ${key}`); })); diff --git a/internal/_truncate_build_message.ts b/internal/_truncate_build_message.ts index f4d74807757e..80d7851b6cb8 100644 --- a/internal/_truncate_build_message.ts +++ b/internal/_truncate_build_message.ts @@ -41,22 +41,16 @@ export function consolidateCommon( stringDiff: boolean, contextLength: number, ): ReadonlyArray> { - const spanLength = contextLength * 2 + 1; - const extremityLength = 1; + const beforeLength = location === "start" ? 1 : contextLength; + const afterLength = location === "end" ? 1 : contextLength; - const startTruncationLength = location === "start" - ? extremityLength - : contextLength; - const endTruncationLength = location === "end" - ? extremityLength - : contextLength; + const omitLength = commons.length - beforeLength - afterLength; - if (commons.length <= spanLength) return commons; + if (omitLength <= 1) return commons; - const [before, after] = [ - commons[startTruncationLength - 1]!.value, - commons[commons.length - endTruncationLength]!.value, - ]; + const before = commons[beforeLength - 1]?.value ?? ""; + const after = commons[commons.length - afterLength]?.value ?? before; + const lineEnd = stringDiff ? "\n" : ""; const indent = location === "start" ? getIndent(after) @@ -64,28 +58,22 @@ export function consolidateCommon( ? getIndent(before) : commonIndent(before, after); + const value = `${indent}... ${omitLength} unchanged lines ...${lineEnd}`; + return [ - ...commons.slice(0, startTruncationLength), - { - type: "truncation", - value: `${indent}... ${ - commons.length - startTruncationLength - endTruncationLength - } unchanged lines ...${stringDiff ? "\n" : ""}`, - }, - ...commons.slice(-endTruncationLength), + ...commons.slice(0, beforeLength), + { type: "truncation", value }, + ...commons.slice(commons.length - afterLength), ]; } function commonIndent(line1: string, line2: string): string { - const [indent1, indent2] = [line1, line2].map(getIndent); - return !indent1 || !indent2 - ? "" - : indent1 === indent2 - ? indent1 - : indent1.startsWith(indent2) - ? indent1.slice(0, indent2.length) + const indent1 = getIndent(line1); + const indent2 = getIndent(line2); + return indent1.startsWith(indent2) + ? indent2 : indent2.startsWith(indent1) - ? indent2.slice(0, indent1.length) + ? indent1 : ""; } diff --git a/internal/build_message.ts b/internal/build_message.ts index fbb40df68b55..e49c3137070b 100644 --- a/internal/build_message.ts +++ b/internal/build_message.ts @@ -98,9 +98,10 @@ function getTruncationEnvVar() { function getTruncationContextLengthFromEnv() { const envVar = getTruncationEnvVar(); - if (envVar == null) return null; + if (!envVar) return null; const truncationContextLength = parseInt(envVar); - return Number.isFinite(truncationContextLength) && truncationContextLength > 0 + return Number.isFinite(truncationContextLength) && + truncationContextLength >= 0 ? truncationContextLength : null; } From 7645e70c4d77625bb07d8ec76111c44b02151c45 Mon Sep 17 00:00:00 2001 From: lionel-rowe Date: Sun, 21 Sep 2025 19:09:44 +0800 Subject: [PATCH 5/5] Unstable --- assert/deno.json | 2 + assert/equals.ts | 10 +- assert/equals_test.ts | 253 ----------------- assert/strict_equals.ts | 10 +- assert/unstable_equals.ts | 56 ++++ assert/unstable_equals_test.ts | 257 ++++++++++++++++++ assert/unstable_strict_equals.ts | 45 +++ internal/_truncate_build_message.ts | 82 ------ internal/build_message.ts | 52 +--- internal/build_message_test.ts | 5 +- internal/deno.json | 1 + internal/mod.ts | 1 + internal/truncate_build_message.ts | 164 +++++++++++ ...test.ts => truncate_build_message_test.ts} | 2 +- 14 files changed, 543 insertions(+), 397 deletions(-) create mode 100644 assert/unstable_equals.ts create mode 100644 assert/unstable_equals_test.ts create mode 100644 assert/unstable_strict_equals.ts delete mode 100644 internal/_truncate_build_message.ts create mode 100644 internal/truncate_build_message.ts rename internal/{_truncate_build_message_test.ts => truncate_build_message_test.ts} (97%) diff --git a/assert/deno.json b/assert/deno.json index 2bc3dd3922d9..4360de6d5b0b 100644 --- a/assert/deno.json +++ b/assert/deno.json @@ -7,6 +7,7 @@ "./almost-equals": "./almost_equals.ts", "./array-includes": "./array_includes.ts", "./equals": "./equals.ts", + "./unstable-equals": "./unstable_equals.ts", "./exists": "./exists.ts", "./false": "./false.ts", "./greater": "./greater.ts", @@ -24,6 +25,7 @@ "./object-match": "./object_match.ts", "./rejects": "./rejects.ts", "./strict-equals": "./strict_equals.ts", + "./unstable-strict-equals": "./unstable_strict_equals.ts", "./string-includes": "./string_includes.ts", "./throws": "./throws.ts", "./assertion-error": "./assertion_error.ts", diff --git a/assert/equals.ts b/assert/equals.ts index 0a48cf74c3a7..4ca43875ab09 100644 --- a/assert/equals.ts +++ b/assert/equals.ts @@ -7,8 +7,6 @@ import { diffStr } from "@std/internal/diff-str"; import { format } from "@std/internal/format"; import { AssertionError } from "./assertion_error.ts"; -// deno-lint-ignore no-unused-vars -import type { DIFF_CONTEXT_LENGTH } from "@std/internal/build-message"; /** * Make an assertion that `actual` and `expected` are equal, deeply. If not @@ -21,11 +19,6 @@ import type { DIFF_CONTEXT_LENGTH } from "@std/internal/build-message"; * `Uint8Array` using the `Blob.bytes()` method and then compare their * contents. * - * The {@linkcode DIFF_CONTEXT_LENGTH} environment variable can be set to - * enable truncation of long diffs, in which case its value should be a - * positive integer representing the number of unchanged context lines to show - * around each changed part of the diff. By default, diffs are not truncated. - * * @example Usage * ```ts ignore * import { assertEquals } from "@std/assert"; @@ -66,7 +59,8 @@ export function assertEquals( const diffResult = stringDiff ? diffStr(actual as string, expected as string) : diff(actualString.split("\n"), expectedString.split("\n")); - const diffMsg = buildMessage(diffResult, { stringDiff }).join("\n"); + const diffMsg = buildMessage(diffResult, { stringDiff }, arguments[3]) + .join("\n"); message = `${message}\n${diffMsg}`; throw new AssertionError(message); } diff --git a/assert/equals_test.ts b/assert/equals_test.ts index 64d5ac7933aa..2f9bd29b9908 100644 --- a/assert/equals_test.ts +++ b/assert/equals_test.ts @@ -1,6 +1,5 @@ // Copyright 2018-2025 the Deno authors. MIT license. import { assertEquals, AssertionError, assertThrows } from "./mod.ts"; -import { DIFF_CONTEXT_LENGTH } from "@std/internal/build-message"; import { bold, gray, @@ -9,9 +8,6 @@ import { stripAnsiCode, yellow, } from "@std/internal/styles"; -import { dedent } from "@std/text/unstable-dedent"; -import { stub } from "@std/testing/mock"; -import { disposableStack } from "../internal/_testing.ts"; function createHeader(): string[] { return [ @@ -232,252 +228,3 @@ Deno.test({ assertEquals(new Set(data), new Set(data)); }, }); - -function assertDiffMessage(a: unknown, b: unknown, expected: string) { - const err = assertThrows(() => assertEquals(a, b), AssertionError); - // TODO(lionel-rowe): re-spell `fullExpectedMessage` indentation once https://github.com/denoland/std/issues/6830 - // is fixed - const fullExpectedMessage = dedent` - Values are not equal. - - - [Diff] Actual / Expected - - - ${expected} - `; - assertEquals( - // TODO(lionel-rowe): compare full messages without trimming once https://github.com/denoland/std/issues/6830 and - // https://github.com/denoland/std/issues/6831 are fixed - stripAnsiCode(err.message).trimEnd(), - fullExpectedMessage, - ); -} - -Deno.test(`assertEquals() truncates unchanged lines of large diffs when "${DIFF_CONTEXT_LENGTH}" is set`, async (t) => { - using stack = disposableStack(); - stack.use(stub(Deno.permissions, "querySync", (x) => { - if (x.name === "env") return { state: "granted" } as Deno.PermissionStatus; - throw new Error(`Unexpected permission descriptor: ${x.name}`); - })); - stack.use(stub(Deno.env, "get", (key) => { - if (key === DIFF_CONTEXT_LENGTH) return (10).toString(); - throw new Error(`Unexpected env var key: ${key}`); - })); - - const a = Array.from({ length: 1000 }, (_, i) => i); - const b = [...a]; - b[500] = -1; - - await t.step("array", () => { - assertDiffMessage( - a, - b, - dedent` - [ - ... 490 unchanged lines ... - 490, - 491, - 492, - 493, - 494, - 495, - 496, - 497, - 498, - 499, - - 500, - + -1, - 501, - 502, - 503, - 504, - 505, - 506, - 507, - 508, - 509, - 510, - ... 489 unchanged lines ... - ] - `, - ); - }); - - await t.step("object", () => { - assertDiffMessage( - Object.fromEntries(a.entries()), - Object.fromEntries(b.entries()), - dedent` - { - ... 437 unchanged lines ... - "492": 492, - "493": 493, - "494": 494, - "495": 495, - "496": 496, - "497": 497, - "498": 498, - "499": 499, - "5": 5, - "50": 50, - - "500": 500, - + "500": -1, - "501": 501, - "502": 502, - "503": 503, - "504": 504, - "505": 505, - "506": 506, - "507": 507, - "508": 508, - "509": 509, - "51": 51, - ... 542 unchanged lines ... - } - `, - ); - }); - - await t.step("string", () => { - assertDiffMessage( - a.join("\n"), - b.join("\n"), - dedent` - 0\\n - ... 489 unchanged lines ... - 490\\n - 491\\n - 492\\n - 493\\n - 494\\n - 495\\n - 496\\n - 497\\n - 498\\n - 499\\n - - 500\\n - + -1\\n - 501\\n - 502\\n - 503\\n - 504\\n - 505\\n - 506\\n - 507\\n - 508\\n - 509\\n - 510\\n - ... 488 unchanged lines ... - 999 - `, - ); - }); - - await t.step("Set", () => { - assertDiffMessage( - new Set(a), - new Set(b), - dedent` - Set(1000) { - + -1, - 0, - 1, - 10, - 100, - 101, - 102, - 103, - 104, - 105, - 106, - ... 427 unchanged lines ... - 492, - 493, - 494, - 495, - 496, - 497, - 498, - 499, - 5, - 50, - - 500, - 501, - 502, - 503, - 504, - 505, - 506, - 507, - 508, - 509, - 51, - ... 542 unchanged lines ... - } - `, - ); - }); - - await t.step("diff near start", () => { - const a = Array.from({ length: 1000 }, (_, i) => i); - const b = [...a]; - b[3] = -1; - - assertDiffMessage( - a, - b, - dedent` - [ - 0, - 1, - 2, - - 3, - + -1, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - ... 986 unchanged lines ... - ] - `, - ); - }); - - await t.step("diff near end", () => { - const a = Array.from({ length: 1000 }, (_, i) => i); - const b = [...a]; - b[996] = -1; - - assertDiffMessage( - a, - b, - dedent` - [ - ... 986 unchanged lines ... - 986, - 987, - 988, - 989, - 990, - 991, - 992, - 993, - 994, - 995, - - 996, - + -1, - 997, - 998, - 999, - ] - `, - ); - }); -}); diff --git a/assert/strict_equals.ts b/assert/strict_equals.ts index ceac09c4ad4a..724b7a2fe378 100644 --- a/assert/strict_equals.ts +++ b/assert/strict_equals.ts @@ -6,18 +6,11 @@ import { diffStr } from "@std/internal/diff-str"; import { format } from "@std/internal/format"; import { red } from "@std/internal/styles"; import { AssertionError } from "./assertion_error.ts"; -// deno-lint-ignore no-unused-vars -import type { DIFF_CONTEXT_LENGTH } from "@std/internal/build-message"; /** * Make an assertion that `actual` and `expected` are strictly equal, using * {@linkcode Object.is} for equality comparison. If not, then throw. * - * The {@linkcode DIFF_CONTEXT_LENGTH} environment variable can be set to - * enable truncation of long diffs, in which case its value should be a - * positive integer representing the number of unchanged context lines to show - * around each changed part of the diff. By default, diffs are not truncated. - * * @example Usage * ```ts ignore * import { assertStrictEquals } from "@std/assert"; @@ -66,7 +59,8 @@ export function assertStrictEquals( const diffResult = stringDiff ? diffStr(actual as string, expected as string) : diff(actualString.split("\n"), expectedString.split("\n")); - const diffMsg = buildMessage(diffResult, { stringDiff }).join("\n"); + const diffMsg = buildMessage(diffResult, { stringDiff }, arguments[3]) + .join("\n"); message = `Values are not strictly equal${msgSuffix}\n${diffMsg}`; } diff --git a/assert/unstable_equals.ts b/assert/unstable_equals.ts new file mode 100644 index 000000000000..e76f087926de --- /dev/null +++ b/assert/unstable_equals.ts @@ -0,0 +1,56 @@ +// Copyright 2018-2025 the Deno authors. MIT license. +// This module is browser compatible. +import { assertEquals as _assertEquals } from "./equals.ts"; +import { truncateDiff } from "@std/internal/truncate-build-message"; +// deno-lint-ignore no-unused-vars +import type { DIFF_CONTEXT_LENGTH } from "@std/internal/truncate-build-message"; + +/** + * Make an assertion that `actual` and `expected` are equal, deeply. If not + * deeply equal, then throw. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * Type parameter can be specified to ensure values under comparison have the + * same type. + * + * Note: When comparing `Blob` objects, you should first convert them to + * `Uint8Array` using the `Blob.bytes()` method and then compare their + * contents. + * + * The {@linkcode DIFF_CONTEXT_LENGTH} environment variable can be set to + * enable truncation of long diffs, in which case its value should be a + * positive integer representing the number of unchanged context lines to show + * around each changed part of the diff. By default, diffs are not truncated. + * + * @example Usage + * ```ts ignore + * import { assertEquals } from "@std/assert"; + * + * assertEquals("world", "world"); // Doesn't throw + * assertEquals("hello", "world"); // Throws + * ``` + * @example Compare `Blob` objects + * ```ts ignore + * import { assertEquals } from "@std/assert"; + * + * const bytes1 = await new Blob(["foo"]).bytes(); + * const bytes2 = await new Blob(["foo"]).bytes(); + * + * assertEquals(bytes1, bytes2); + * ``` + * + * @typeParam T The type of the values to compare. This is usually inferred. + * @param actual The actual value to compare. + * @param expected The expected value to compare. + * @param msg The optional message to display if the assertion fails. + */ +export function assertEquals( + actual: T, + expected: T, + msg?: string, +) { + const args: Parameters = [actual, expected, msg]; + // @ts-expect-error extra arg + _assertEquals(...args, truncateDiff); +} diff --git a/assert/unstable_equals_test.ts b/assert/unstable_equals_test.ts new file mode 100644 index 000000000000..ea7cbc502dca --- /dev/null +++ b/assert/unstable_equals_test.ts @@ -0,0 +1,257 @@ +// Copyright 2018-2025 the Deno authors. MIT license. +import { AssertionError, assertThrows } from "./mod.ts"; +import { assertEquals } from "./unstable_equals.ts"; +import { DIFF_CONTEXT_LENGTH } from "@std/internal/truncate-build-message"; +import { stripAnsiCode } from "@std/internal/styles"; +import { dedent } from "@std/text/unstable-dedent"; +import { stub } from "@std/testing/mock"; +import { disposableStack } from "../internal/_testing.ts"; + +function assertDiffMessage(a: unknown, b: unknown, expected: string) { + const err = assertThrows(() => assertEquals(a, b), AssertionError); + // TODO(lionel-rowe): re-spell `fullExpectedMessage` indentation once https://github.com/denoland/std/issues/6830 + // is fixed + const fullExpectedMessage = dedent` + Values are not equal. + + + [Diff] Actual / Expected + + + ${expected} + `; + assertEquals( + // TODO(lionel-rowe): compare full messages without trimming once https://github.com/denoland/std/issues/6830 and + // https://github.com/denoland/std/issues/6831 are fixed + stripAnsiCode(err.message).trimEnd(), + fullExpectedMessage, + ); +} + +Deno.test(`assertEquals() truncates unchanged lines of large diffs when "${DIFF_CONTEXT_LENGTH}" is set`, async (t) => { + using stack = disposableStack(); + stack.use(stub(Deno.permissions, "querySync", (x) => { + if (x.name === "env") return { state: "granted" } as Deno.PermissionStatus; + throw new Error(`Unexpected permission descriptor: ${x.name}`); + })); + stack.use(stub(Deno.env, "get", (key) => { + if (key === DIFF_CONTEXT_LENGTH) return (10).toString(); + throw new Error(`Unexpected env var key: ${key}`); + })); + + const a = Array.from({ length: 1000 }, (_, i) => i); + const b = [...a]; + b[500] = -1; + + await t.step("array", () => { + assertDiffMessage( + a, + b, + dedent` + [ + ... 490 unchanged lines ... + 490, + 491, + 492, + 493, + 494, + 495, + 496, + 497, + 498, + 499, + - 500, + + -1, + 501, + 502, + 503, + 504, + 505, + 506, + 507, + 508, + 509, + 510, + ... 489 unchanged lines ... + ] + `, + ); + }); + + await t.step("object", () => { + assertDiffMessage( + Object.fromEntries(a.entries()), + Object.fromEntries(b.entries()), + dedent` + { + ... 437 unchanged lines ... + "492": 492, + "493": 493, + "494": 494, + "495": 495, + "496": 496, + "497": 497, + "498": 498, + "499": 499, + "5": 5, + "50": 50, + - "500": 500, + + "500": -1, + "501": 501, + "502": 502, + "503": 503, + "504": 504, + "505": 505, + "506": 506, + "507": 507, + "508": 508, + "509": 509, + "51": 51, + ... 542 unchanged lines ... + } + `, + ); + }); + + await t.step("string", () => { + assertDiffMessage( + a.join("\n"), + b.join("\n"), + dedent` + 0\\n + ... 489 unchanged lines ... + 490\\n + 491\\n + 492\\n + 493\\n + 494\\n + 495\\n + 496\\n + 497\\n + 498\\n + 499\\n + - 500\\n + + -1\\n + 501\\n + 502\\n + 503\\n + 504\\n + 505\\n + 506\\n + 507\\n + 508\\n + 509\\n + 510\\n + ... 488 unchanged lines ... + 999 + `, + ); + }); + + await t.step("Set", () => { + assertDiffMessage( + new Set(a), + new Set(b), + dedent` + Set(1000) { + + -1, + 0, + 1, + 10, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + ... 427 unchanged lines ... + 492, + 493, + 494, + 495, + 496, + 497, + 498, + 499, + 5, + 50, + - 500, + 501, + 502, + 503, + 504, + 505, + 506, + 507, + 508, + 509, + 51, + ... 542 unchanged lines ... + } + `, + ); + }); + + await t.step("diff near start", () => { + const a = Array.from({ length: 1000 }, (_, i) => i); + const b = [...a]; + b[3] = -1; + + assertDiffMessage( + a, + b, + dedent` + [ + 0, + 1, + 2, + - 3, + + -1, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + ... 986 unchanged lines ... + ] + `, + ); + }); + + await t.step("diff near end", () => { + const a = Array.from({ length: 1000 }, (_, i) => i); + const b = [...a]; + b[996] = -1; + + assertDiffMessage( + a, + b, + dedent` + [ + ... 986 unchanged lines ... + 986, + 987, + 988, + 989, + 990, + 991, + 992, + 993, + 994, + 995, + - 996, + + -1, + 997, + 998, + 999, + ] + `, + ); + }); +}); diff --git a/assert/unstable_strict_equals.ts b/assert/unstable_strict_equals.ts new file mode 100644 index 000000000000..e424a3dff7e9 --- /dev/null +++ b/assert/unstable_strict_equals.ts @@ -0,0 +1,45 @@ +// Copyright 2018-2025 the Deno authors. MIT license. +// This module is browser compatible. +import { assertStrictEquals as _assertStrictEquals } from "./strict_equals.ts"; +import { truncateDiff } from "@std/internal/truncate-build-message"; +// deno-lint-ignore no-unused-vars +import type { DIFF_CONTEXT_LENGTH } from "@std/internal/truncate-build-message"; + +/** + * Make an assertion that `actual` and `expected` are strictly equal, using + * {@linkcode Object.is} for equality comparison. If not, then throw. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * The {@linkcode DIFF_CONTEXT_LENGTH} environment variable can be set to + * enable truncation of long diffs, in which case its value should be a + * positive integer representing the number of unchanged context lines to show + * around each changed part of the diff. By default, diffs are not truncated. + * + * @example Usage + * ```ts ignore + * import { assertStrictEquals } from "@std/assert"; + * + * const a = {}; + * const b = a; + * assertStrictEquals(a, b); // Doesn't throw + * + * const c = {}; + * const d = {}; + * assertStrictEquals(c, d); // Throws + * ``` + * + * @typeParam T The type of the expected value. + * @param actual The actual value to compare. + * @param expected The expected value to compare. + * @param msg The optional message to display if the assertion fails. + */ +export function assertStrictEquals( + actual: unknown, + expected: T, + msg?: string, +): asserts actual is T { + const args: Parameters = [actual, expected, msg]; + // @ts-expect-error extra arg + _assertStrictEquals(...args, truncateDiff); +} diff --git a/internal/_truncate_build_message.ts b/internal/_truncate_build_message.ts deleted file mode 100644 index 80d7851b6cb8..000000000000 --- a/internal/_truncate_build_message.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright 2018-2025 the Deno authors. MIT license. -import type { CommonDiffResult, DiffResult } from "@std/internal/types"; - -export function truncateDiff( - diffResult: ReadonlyArray>, - stringDiff: boolean, - contextLength: number, -): ReadonlyArray> { - const messages: DiffResult[] = []; - const commons: CommonDiffResult[] = []; - - for (let i = 0; i < diffResult.length; ++i) { - const result = diffResult[i]!; - - if (result.type === "common") { - commons.push(result as typeof result & { type: typeof result.type }); - } else { - messages.push( - ...consolidateCommon( - commons, - commons.length === i ? "start" : "middle", - stringDiff, - contextLength, - ), - ); - commons.length = 0; - messages.push(result); - } - } - - messages.push( - ...consolidateCommon(commons, "end", stringDiff, contextLength), - ); - - return messages; -} - -export function consolidateCommon( - commons: ReadonlyArray>, - location: "start" | "middle" | "end", - stringDiff: boolean, - contextLength: number, -): ReadonlyArray> { - const beforeLength = location === "start" ? 1 : contextLength; - const afterLength = location === "end" ? 1 : contextLength; - - const omitLength = commons.length - beforeLength - afterLength; - - if (omitLength <= 1) return commons; - - const before = commons[beforeLength - 1]?.value ?? ""; - const after = commons[commons.length - afterLength]?.value ?? before; - const lineEnd = stringDiff ? "\n" : ""; - - const indent = location === "start" - ? getIndent(after) - : location === "end" - ? getIndent(before) - : commonIndent(before, after); - - const value = `${indent}... ${omitLength} unchanged lines ...${lineEnd}`; - - return [ - ...commons.slice(0, beforeLength), - { type: "truncation", value }, - ...commons.slice(commons.length - afterLength), - ]; -} - -function commonIndent(line1: string, line2: string): string { - const indent1 = getIndent(line1); - const indent2 = getIndent(line2); - return indent1.startsWith(indent2) - ? indent2 - : indent2.startsWith(indent1) - ? indent1 - : ""; -} - -function getIndent(line: string): string { - return line.match(/^\s+/g)?.[0] ?? ""; -} diff --git a/internal/build_message.ts b/internal/build_message.ts index e49c3137070b..413bf811a60f 100644 --- a/internal/build_message.ts +++ b/internal/build_message.ts @@ -1,7 +1,6 @@ // Copyright 2018-2025 the Deno authors. MIT license. // This module is browser compatible. -import { truncateDiff } from "./_truncate_build_message.ts"; import { bgGreen, bgRed, bold, gray, green, red, white } from "./styles.ts"; import type { DiffResult, DiffType } from "./types.ts"; @@ -72,40 +71,6 @@ export function createSign(diffType: DiffType): string { } } -/** The environment variable used for setting diff context length. */ -export const DIFF_CONTEXT_LENGTH = "DIFF_CONTEXT_LENGTH"; -function getTruncationEnvVar() { - // deno-lint-ignore no-explicit-any - const { Deno, process } = globalThis as any; - - if (typeof Deno === "object") { - const permissionStatus = Deno.permissions.querySync({ - name: "env", - variable: DIFF_CONTEXT_LENGTH, - }).state ?? "granted"; - - return permissionStatus === "granted" - ? Deno.env.get(DIFF_CONTEXT_LENGTH) ?? null - : null; - } - const nodeEnv = process?.getBuiltinModule?.("node:process")?.env as - | Partial> - | undefined; - return typeof nodeEnv === "object" - ? nodeEnv[DIFF_CONTEXT_LENGTH] ?? null - : null; -} - -function getTruncationContextLengthFromEnv() { - const envVar = getTruncationEnvVar(); - if (!envVar) return null; - const truncationContextLength = parseInt(envVar); - return Number.isFinite(truncationContextLength) && - truncationContextLength >= 0 - ? truncationContextLength - : null; -} - /** Options for {@linkcode buildMessage}. */ export interface BuildMessageOptions { /** @@ -120,7 +85,7 @@ export interface BuildMessageOptions { * * @param diffResult The diff result array. * @param options Optional parameters for customizing the message. - * @param contextLength Truncation context length. Explicitly passing `contextLength` is currently only used for testing. + * @param truncateDiff Function to truncate the diff (default is no truncation). * * @returns An array of strings representing the built message. * @@ -144,15 +109,14 @@ export interface BuildMessageOptions { export function buildMessage( diffResult: ReadonlyArray>, options: BuildMessageOptions = {}, - contextLength: number | null = null, + truncateDiff?: ( + diffResult: ReadonlyArray>, + stringDiff: boolean, + contextLength?: number | null, + ) => ReadonlyArray>, ): string[] { - contextLength ??= getTruncationContextLengthFromEnv(); - if (contextLength != null) { - diffResult = truncateDiff( - diffResult, - options.stringDiff ?? false, - contextLength, - ); + if (truncateDiff != null) { + diffResult = truncateDiff(diffResult, options.stringDiff ?? false); } const { stringDiff = false } = options; diff --git a/internal/build_message_test.ts b/internal/build_message_test.ts index 16839f478591..0751e28027fb 100644 --- a/internal/build_message_test.ts +++ b/internal/build_message_test.ts @@ -2,6 +2,8 @@ import { assertEquals } from "@std/assert"; import { bgGreen, bgRed, bold, gray, green, red, white } from "@std/fmt/colors"; import { buildMessage, createColor, createSign } from "./build_message.ts"; +import { truncateDiff } from "@std/internal/truncate-build-message"; +import type { DiffResult } from "@std/internal/types"; Deno.test("buildMessage()", async (t) => { const prelude = [ @@ -46,7 +48,8 @@ Deno.test("buildMessage()", async (t) => { { type: "common", value: "bar" }, ], {}, - 2, + (diffResult: ReadonlyArray>, stringDiff: boolean) => + truncateDiff(diffResult, stringDiff, 2), ), [ ...prelude, diff --git a/internal/deno.json b/internal/deno.json index 018c6d37bf7f..4f0b73b0a22a 100644 --- a/internal/deno.json +++ b/internal/deno.json @@ -10,6 +10,7 @@ "./format": "./format.ts", "./os": "./os.ts", "./styles": "./styles.ts", + "./truncate-build-message": "./truncate_build_message.ts", "./types": "./types.ts" } } diff --git a/internal/mod.ts b/internal/mod.ts index 3827151d9b34..aa62f2849c25 100644 --- a/internal/mod.ts +++ b/internal/mod.ts @@ -43,4 +43,5 @@ export * from "./diff_str.ts"; export * from "./format.ts"; export * from "./os.ts"; export * from "./styles.ts"; +export * from "./truncate_build_message.ts"; export * from "./types.ts"; diff --git a/internal/truncate_build_message.ts b/internal/truncate_build_message.ts new file mode 100644 index 000000000000..3955859d16cf --- /dev/null +++ b/internal/truncate_build_message.ts @@ -0,0 +1,164 @@ +// Copyright 2018-2025 the Deno authors. MIT license. +import type { CommonDiffResult, DiffResult } from "@std/internal/types"; + +/** The environment variable used for setting diff context length. */ +export const DIFF_CONTEXT_LENGTH = "DIFF_CONTEXT_LENGTH"; + +/** + * Get the truncation context length from the `DIFF_CONTEXT_LENGTH` + * environment variable. + * @returns The truncation context length, or `null` if not set or invalid. + * + * @example Usage + * ```ts no-assert ignore + * Deno.env.set("DIFF_CONTEXT_LENGTH", "10"); + * getTruncationContextLengthFromEnv(); // 10 + * ``` + */ +export function getTruncationContextLengthFromEnv(): number | null { + const envVar = getTruncationEnvVar(); + if (!envVar) return null; + const truncationContextLength = parseInt(envVar); + return Number.isFinite(truncationContextLength) && + truncationContextLength >= 0 + ? truncationContextLength + : null; +} + +function getTruncationEnvVar() { + // deno-lint-ignore no-explicit-any + const { Deno, process } = globalThis as any; + + if (typeof Deno === "object") { + const permissionStatus = Deno.permissions.querySync({ + name: "env", + variable: DIFF_CONTEXT_LENGTH, + }).state ?? "granted"; + + return permissionStatus === "granted" + ? Deno.env.get(DIFF_CONTEXT_LENGTH) ?? null + : null; + } + const nodeEnv = process?.getBuiltinModule?.("node:process")?.env as + | Partial> + | undefined; + return typeof nodeEnv === "object" + ? nodeEnv[DIFF_CONTEXT_LENGTH] ?? null + : null; +} + +/** + * Truncates a diff result by consolidating unchanged lines. + * + * @param diffResult The diff result to truncate. + * @param stringDiff Whether the diff is for strings. + * @param contextLength The number of unchanged context lines to show around + * each changed part of the diff. If not provided, it will be read from the + * `DIFF_CONTEXT_LENGTH` environment variable. If that is not set or invalid, + * no truncation will be performed. + * + * @returns The truncated diff result. + * + * @example Usage + * ```ts no-assert ignore + * truncateDiff(diffResult, false, 2); + * ``` + */ +export function truncateDiff( + diffResult: ReadonlyArray>, + stringDiff: boolean, + contextLength?: number | null, +): ReadonlyArray> { + contextLength ??= getTruncationContextLengthFromEnv(); + if (contextLength == null) return diffResult; + + const messages: DiffResult[] = []; + const commons: CommonDiffResult[] = []; + + for (let i = 0; i < diffResult.length; ++i) { + const result = diffResult[i]!; + + if (result.type === "common") { + commons.push(result as typeof result & { type: typeof result.type }); + } else { + messages.push( + ...consolidateCommon( + commons, + commons.length === i ? "start" : "middle", + stringDiff, + contextLength, + ), + ); + commons.length = 0; + messages.push(result); + } + } + + messages.push( + ...consolidateCommon(commons, "end", stringDiff, contextLength), + ); + + return messages; +} + +/** + * Consolidates a sequence of common diff results by truncating unchanged lines. + * + * @param commons The sequence of common diff results to consolidate. + * @param location The location of the common sequence in the overall diff: + * "start", "middle", or "end". + * @param stringDiff Whether the diff is for strings. + * @param contextLength The number of unchanged context lines to show around + * each changed part of the diff. + * @returns The consolidated sequence of common diff results. + * + * @example Usage + * ```ts no-assert ignore + * consolidateCommon(commons, "middle", false, 2); + * ``` + */ +export function consolidateCommon( + commons: ReadonlyArray>, + location: "start" | "middle" | "end", + stringDiff: boolean, + contextLength: number, +): ReadonlyArray> { + const beforeLength = location === "start" ? 1 : contextLength; + const afterLength = location === "end" ? 1 : contextLength; + + const omitLength = commons.length - beforeLength - afterLength; + + if (omitLength <= 1) return commons; + + const before = commons[beforeLength - 1]?.value ?? ""; + const after = commons[commons.length - afterLength]?.value ?? before; + const lineEnd = stringDiff ? "\n" : ""; + + const indent = location === "start" + ? getIndent(after) + : location === "end" + ? getIndent(before) + : commonIndent(before, after); + + const value = `${indent}... ${omitLength} unchanged lines ...${lineEnd}`; + + return [ + ...commons.slice(0, beforeLength), + { type: "truncation", value }, + ...commons.slice(commons.length - afterLength), + ]; +} + +function commonIndent(line1: string, line2: string): string { + const indent1 = getIndent(line1); + const indent2 = getIndent(line2); + return indent1.startsWith(indent2) + ? indent2 + : indent2.startsWith(indent1) + ? indent1 + : ""; +} + +function getIndent(line: string): string { + return line.match(/^\s+/g)?.[0] ?? ""; +} diff --git a/internal/_truncate_build_message_test.ts b/internal/truncate_build_message_test.ts similarity index 97% rename from internal/_truncate_build_message_test.ts rename to internal/truncate_build_message_test.ts index c996823f6aad..01161372a2c1 100644 --- a/internal/_truncate_build_message_test.ts +++ b/internal/truncate_build_message_test.ts @@ -1,6 +1,6 @@ // Copyright 2018-2025 the Deno authors. MIT license. import { assertEquals } from "@std/assert/equals"; -import { consolidateCommon, truncateDiff } from "./_truncate_build_message.ts"; +import { consolidateCommon, truncateDiff } from "./truncate_build_message.ts"; import type { CommonDiffResult } from "@std/internal/types"; Deno.test("consolidateDiff()", () => {