diff --git a/assert/equal.ts b/assert/equal.ts index bb42dcac2..bae5383aa 100644 --- a/assert/equal.ts +++ b/assert/equal.ts @@ -6,17 +6,92 @@ function isKeyedCollection(x: unknown): x is KeyedCollection { return x instanceof Set || x instanceof Map; } -function constructorsEqual(a: object, b: object) { - return a.constructor === b.constructor || - a.constructor === Object && !b.constructor || - !a.constructor && b.constructor === Object; +function prototypesEqual(a: object, b: object) { + const pa = Object.getPrototypeOf(a); + const pb = Object.getPrototypeOf(b); + return pa === pb || + pa === Object.prototype && pb === null || + pa === null && pb === Object.prototype; +} + +function isBasicObjectOrArray(obj: object) { + const proto = Object.getPrototypeOf(obj); + return proto === null || proto === Object.prototype || + proto === Array.prototype; +} + +// Slightly faster than Reflect.ownKeys in V8 as of 12.9.202.13-rusty (2024-10-28) +function ownKeys(obj: object) { + return [ + ...Object.getOwnPropertyNames(obj), + ...Object.getOwnPropertySymbols(obj), + ]; +} + +function getKeysDeep(obj: object) { + const keys = new Set(); + + while (obj !== Object.prototype && obj !== Array.prototype && obj != null) { + for (const key of ownKeys(obj)) { + keys.add(key); + } + obj = Object.getPrototypeOf(obj); + } + + return keys; +} + +// deno-lint-ignore no-explicit-any +const Temporal: any = (globalThis as any).Temporal ?? + new Proxy({}, { get: () => {} }); + +/** A non-exhaustive list of prototypes that can be accurately fast-path compared with `String(instance)` */ +const stringComparablePrototypes = new Set( + [ + Intl.Locale, + RegExp, + Temporal.Duration, + Temporal.Instant, + Temporal.PlainDate, + Temporal.PlainDateTime, + Temporal.PlainTime, + Temporal.PlainYearMonth, + Temporal.PlainMonthDay, + Temporal.ZonedDateTime, + URL, + URLSearchParams, + ].filter((x) => x != null).map((x) => x.prototype), +); + +function isPrimitive(x: unknown) { + return typeof x === "string" || + typeof x === "number" || + typeof x === "boolean" || + typeof x === "bigint" || + typeof x === "symbol" || + x == null; +} + +type TypedArray = Pick; +const TypedArray = Object.getPrototypeOf(Uint8Array); +function compareTypedArrays(a: TypedArray, b: TypedArray) { + if (a.length !== b.length) return false; + for (let i = 0; i < b.length; i++) { + if (!sameValueZero(a[i], b[i])) return false; + } + return true; +} + +/** Check both strict equality (`0 == -0`) and `Object.is` (`NaN == NaN`) */ +function sameValueZero(a: unknown, b: unknown) { + return a === b || Object.is(a, b); } /** * Deep equality comparison used in assertions. * - * @param c The actual value - * @param d The expected value + * @param a The actual value + * @param b The expected value * @returns `true` if the values are deeply equal, `false` otherwise * * @example Usage @@ -24,53 +99,33 @@ function constructorsEqual(a: object, b: object) { * import { equal } from "@std/assert/equal"; * * equal({ foo: "bar" }, { foo: "bar" }); // Returns `true` - * equal({ foo: "bar" }, { foo: "baz" }); // Returns `false + * equal({ foo: "bar" }, { foo: "baz" }); // Returns `false` * ``` */ -export function equal(c: unknown, d: unknown): boolean { - const seen = new Map(); +export function equal(a: unknown, b: unknown): boolean { + const seen = new Map(); return (function compare(a: unknown, b: unknown): boolean { - // Have to render RegExp & Date for string comparison - // unless it's mistreated as object - if ( - a && - b && - ((a instanceof RegExp && b instanceof RegExp) || - (a instanceof URL && b instanceof URL)) - ) { - return String(a) === String(b); - } + if (sameValueZero(a, b)) return true; + if (isPrimitive(a) || isPrimitive(b)) return false; + if (a instanceof Date && b instanceof Date) { - const aTime = a.getTime(); - const bTime = b.getTime(); - // Check for NaN equality manually since NaN is not - // equal to itself. - if (Number.isNaN(aTime) && Number.isNaN(bTime)) { - return true; - } - return aTime === bTime; - } - if (typeof a === "number" && typeof b === "number") { - return Number.isNaN(a) && Number.isNaN(b) || a === b; - } - if (Object.is(a, b)) { - return true; + return Object.is(a.getTime(), b.getTime()); } if (a && typeof a === "object" && b && typeof b === "object") { - if (a && b && !constructorsEqual(a, b)) { + if (!prototypesEqual(a, b)) { return false; } - if (a instanceof WeakMap || b instanceof WeakMap) { - if (!(a instanceof WeakMap && b instanceof WeakMap)) return false; + if (a instanceof TypedArray) { + return compareTypedArrays(a as TypedArray, b as TypedArray); + } + if (a instanceof WeakMap) { throw new TypeError("cannot compare WeakMap instances"); } - if (a instanceof WeakSet || b instanceof WeakSet) { - if (!(a instanceof WeakSet && b instanceof WeakSet)) return false; + if (a instanceof WeakSet) { throw new TypeError("cannot compare WeakSet instances"); } - if (a instanceof WeakRef || b instanceof WeakRef) { - if (!(a instanceof WeakRef && b instanceof WeakRef)) return false; - return compare(a.deref(), b.deref()); + if (a instanceof WeakRef) { + return compare(a.deref(), (b as WeakRef).deref()); } if (seen.get(a) === b) { return true; @@ -85,14 +140,7 @@ export function equal(c: unknown, d: unknown): boolean { } const aKeys = [...a.keys()]; - const primitiveKeysFastPath = aKeys.every((k) => { - return typeof k === "string" || - typeof k === "number" || - typeof k === "boolean" || - typeof k === "bigint" || - typeof k === "symbol" || - k == null; - }); + const primitiveKeysFastPath = aKeys.every(isPrimitive); if (primitiveKeysFastPath) { if (a instanceof Set) { return a.symmetricDifference(b).size === 0; @@ -130,15 +178,23 @@ export function equal(c: unknown, d: unknown): boolean { return unmatchedEntries === 0; } - const merged = { ...a, ...b }; - for ( - const key of [ - ...Object.getOwnPropertyNames(merged), - ...Object.getOwnPropertySymbols(merged), - ] - ) { - type Key = keyof typeof merged; - if (!compare(a && a[key as Key], b && b[key as Key])) { + + let keys: Iterable; + + if (isBasicObjectOrArray(a)) { + // fast path + keys = ownKeys({ ...a, ...b }); + } else if (stringComparablePrototypes.has(Object.getPrototypeOf(a))) { + // medium path + return String(a) === String(b); + } else { + // slow path + keys = getKeysDeep(a).union(getKeysDeep(b)); + } + + for (const key of keys) { + type Key = keyof typeof a; + if (!compare(a[key as Key], b[key as Key])) { return false; } if (((key in a) && (!(key in b))) || ((key in b) && (!(key in a)))) { @@ -148,5 +204,5 @@ export function equal(c: unknown, d: unknown): boolean { return true; } return false; - })(c, d); + })(a, b); } diff --git a/assert/equal_test.ts b/assert/equal_test.ts index 34c41d53e..41f52f6b0 100644 --- a/assert/equal_test.ts +++ b/assert/equal_test.ts @@ -344,3 +344,149 @@ Deno.test("equal() keyed collection edge cases", () => { new Map([[1, { a: 1 }], [2, { b: 3 }]]), )); }); + +Deno.test("equal() with constructor and prototype", async (t) => { + await t.step( + "value-equal but reference-unequal property named `constructor`", + () => { + assert(equal( + { constructor: { x: 1 } }, + { constructor: { x: 1 } }, + )); + }, + ); + await t.step( + "instance vs plain object with property named `constructor`", + () => { + class X {} + const a = new X(); + const b = { constructor: X }; + assert(equal(a.constructor, b.constructor)); + assertFalse(equal(a, b)); + }, + ); + await t.step("manually set prototype", () => { + class X { + prop = 1; + } + const a = new X(); + const b = {} as X; + + // false as prototype differs + assertFalse(equal(a, b)); + Object.setPrototypeOf(b, X.prototype); + // still false as `prop` is not set + assertFalse(equal(a, b)); + b.prop = 1; + // now true + assert(equal(a, b)); + }); +}); + +Deno.test("equal() with dynamic properties defined on the prototype", async (t) => { + await t.step("built-in web APIs", async (t) => { + await t.step("URLPattern", () => { + assert(equal( + new URLPattern("*://*.*"), + new URLPattern("*://*.*"), + )); + + assertFalse(equal( + new URLPattern("*://*.*"), + new URLPattern("*://*.com"), + )); + }); + + await t.step("URLSearchParams", () => { + assert(equal( + new URLSearchParams("a=1&b=2"), + new URLSearchParams("a=1&b=2"), + )); + + assertFalse(equal( + new URLSearchParams("a=1&b=2"), + new URLSearchParams("a=1&b=99999"), + )); + }); + + await t.step("Intl.Locale", () => { + assert(equal( + new Intl.Locale("es-MX"), + new Intl.Locale("es-MX"), + )); + + assertFalse(equal( + new Intl.Locale("es-MX"), + new Intl.Locale("pt-BR"), + )); + }); + }); + + await t.step("custom prototype", () => { + type Obj = { + prop: number; + }; + const wm = new WeakMap(); + const proto: Obj = { + get prop() { + return wm.get(this)!; + }, + }; + const makeObj = (val: number): Obj => { + const x = Object.create(proto); + wm.set(x, val); + return x; + }; + + assert(equal( + makeObj(1), + makeObj(1), + )); + + assertFalse(equal( + makeObj(1), + makeObj(99999), + )); + }); +}); + +Deno.test("equal() with typed arrays", async (t) => { + await t.step("Uint8Array", async (t) => { + await t.step("equal", () => { + assert(equal( + new Uint8Array([1, 2, 3]), + new Uint8Array([1, 2, 3]), + )); + }); + + await t.step("unequal", () => { + assertFalse(equal( + new Uint8Array([1, 2, 3]), + new Uint8Array([1, 2, 4]), + )); + }); + + await t.step("length unequal", () => { + assertFalse(equal( + new Uint8Array([0]), + new Uint8Array([0, 0]), + )); + }); + }); + + await t.step("Float64Array", async (t) => { + await t.step("NaN == NaN", () => { + assert(equal( + new Float64Array([NaN]), + new Float64Array([NaN]), + )); + }); + + await t.step("0 == -0", () => { + assert(equal( + new Float64Array([0]), + new Float64Array([-0]), + )); + }); + }); +});