diff --git a/package.json b/package.json index 332983083..7a6adddf1 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "./util": "./src/util/index.ts", "./util/*": "./src/util/*.ts", "./util/hash/*": null, - "./util/serialize/*": null, "./util/hash": { "node": "./src/util/hash/node.ts", "default": "./src/util/hash/browser.ts" @@ -488,7 +487,6 @@ } }, "./util/hash/*": null, - "./util/serialize/*": null, "./util/hash": { "node": { "import": { diff --git a/src/util/hash/browser.ts b/src/util/hash/browser.ts index d33dfdd01..8a5df4d54 100644 --- a/src/util/hash/browser.ts +++ b/src/util/hash/browser.ts @@ -1,5 +1,5 @@ import { sha256 } from './sha256.ts'; -import { serialize } from '../serialize/serialize.ts'; +import { serialize } from '../serialize.ts'; /** * Hashes any value into a stable 43-character string. diff --git a/src/util/hash/node.spec.ts b/src/util/hash/node.spec.ts index 8a5f4c92e..9507b5a51 100644 --- a/src/util/hash/node.spec.ts +++ b/src/util/hash/node.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; import { hash } from './node'; -import { serialize } from '../serialize/serialize'; +import { serialize } from '../serialize'; describe('hash (node)', () => { it('should hash the serialized value with SHA-256 in Base64URL format', () => { diff --git a/src/util/hash/node.ts b/src/util/hash/node.ts index e300281ef..e3e7087d1 100644 --- a/src/util/hash/node.ts +++ b/src/util/hash/node.ts @@ -1,5 +1,5 @@ import { hash as sha256 } from 'node:crypto'; -import { serialize } from '../serialize/serialize.ts'; +import { serialize } from '../serialize.ts'; /** * Hashes any value into a stable 43-character string. diff --git a/src/util/index.ts b/src/util/index.ts index e58c6d0d1..3bb5bf000 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -5,4 +5,4 @@ export { deferAsync } from './deferAsync.ts'; export { invariant } from './invariant.ts'; export { invariant as assert } from './invariant.ts'; export { Queue } from './queue.ts'; -export { serialize } from './serialize/serialize.ts'; +export { serialize } from './serialize.ts'; diff --git a/src/util/serialize.spec.ts b/src/util/serialize.spec.ts new file mode 100644 index 000000000..86642f243 --- /dev/null +++ b/src/util/serialize.spec.ts @@ -0,0 +1,688 @@ +import { describe, expect, it, vi } from 'vitest'; +import { serialize } from './serialize'; + +describe('serialize', () => { + describe('primitives', () => { + it('should serialize strings with quotes', () => { + expect(serialize('hello world 😎')).toBe("'hello world 😎'"); + expect(serialize({ msg: 'hello world 😎' })).toBe("{'msg':'hello world 😎'}"); + }); + + it('should serialize numbers', () => { + expect(serialize(0)).toBe('0'); + expect(serialize(-0)).toBe('0'); + expect(serialize(100)).toBe('100'); + expect(serialize(NaN)).toBe('NaN'); + expect(serialize(Infinity)).toBe('Infinity'); + }); + + it('should serialize booleans', () => { + expect(serialize(true)).toBe('true'); + expect(serialize(false)).toBe('false'); + }); + + it('should serialize null and undefined', () => { + expect(serialize(null)).toBe('null'); + expect(serialize(undefined)).toBe('undefined'); + }); + + it('should serialize bigints', () => { + expect(serialize(123n)).toBe('123n'); + }); + + it('should serialize symbols', () => { + expect(serialize(Symbol('test'))).toBe("Symbol('test')"); + }); + + it('should serialize functions', () => { + expect(serialize(Math.max)).toBe('max:[native]'); + expect(serialize(Array.prototype.push)).toBe('push:[native]'); + expect( + serialize(function add(a: number, b: number) { + return a + b; + }) + ).toBe('add:function add(a, b) {return a + b;}'); + }); + + it('should detect native functions regardless of how the engine formats them', () => { + // Safari formats native functions as `function max() {\n [native code]\n}`. + const max = function max() {}; + const original = Function.prototype.toString; + const spy = vi.spyOn(Function.prototype, 'toString').mockImplementation(function (this: unknown) { + return this === max ? 'function max() {\n [native code]\n}' : original.call(this); + }); + + try { + expect(serialize(max)).toBe('max:[native]'); + } finally { + spy.mockRestore(); + } + }); + + it('should distinguish undefined from the string "undefined"', () => { + expect(serialize([undefined, 'undefined'])).toBe("[undefined,'undefined']"); + }); + }); + + describe('objects', () => { + it('should serialize plain objects with sorted keys', () => { + expect(serialize({ a: 1, b: 2 })).toBe("{'a':1,'b':2}"); + expect(serialize({ b: 2, a: 1 })).toBe("{'a':1,'b':2}"); + }); + + it('should serialize nested structures', () => { + expect(serialize({ a: [1, { b: new Set([2, 1]) }], c: 3n })).toBe("{'a':[1,{'b':Set[1,2]}],'c':3n}"); + }); + + it('should serialize builtin objects', () => { + expect(serialize(new Date(0))).toBe("Date('1970-01-01T00:00:00.000Z')"); + expect(serialize(/.*/)).toBe('RegExp(/.*/)'); + expect(serialize(new Map([['a', 1]]))).toBe("Map{'a':1}"); + expect(serialize(new Uint8Array([1, 2, 3]))).toBe('Uint8Array[1,2,3]'); + }); + + it('should throw a TypeError for unsupported objects', () => { + expect(() => serialize(new WeakMap())).toThrow(TypeError); + expect(() => serialize({ a: new Promise(() => {}) })).toThrow(TypeError); + }); + }); + + describe('stability', () => { + it('should produce the same output regardless of key insertion order', () => { + expect(serialize({ b: 2, a: 1 })).toBe(serialize({ a: 1, b: 2 })); + expect(serialize(new Set([3, 1, 2]))).toBe(serialize(new Set([2, 3, 1]))); + expect( + serialize( + new Map([ + ['a', 1], + ['b', 2], + ]) + ) + ).toBe( + serialize( + new Map([ + ['b', 2], + ['a', 1], + ]) + ) + ); + }); + + it('should sort NaN and mixed numbers and bigints regardless of insertion order', () => { + expect(serialize(new Set([NaN, 1, -1, 2]))).toBe('Set[-1,1,2,NaN]'); + expect(serialize(new Set([1, NaN, -1, 2]))).toBe('Set[-1,1,2,NaN]'); + expect(serialize(new Set([-1, 1, 2, NaN]))).toBe('Set[-1,1,2,NaN]'); + expect(serialize(new Set([NaN, Infinity, -Infinity, 0]))).toBe('Set[-Infinity,0,Infinity,NaN]'); + expect(serialize(new Set([0, Infinity, NaN, -Infinity]))).toBe('Set[-Infinity,0,Infinity,NaN]'); + + expect(serialize(new Set([10, 9n, 2, 11n]))).toBe('Set[2,9n,10,11n]'); + expect(serialize(new Set([11n, 2, 10, 9n]))).toBe('Set[2,9n,10,11n]'); + expect(serialize(new Set([1n, 1, NaN, 2n]))).toBe('Set[1,1n,2n,NaN]'); + expect(serialize(new Set([NaN, 2n, 1, 1n]))).toBe('Set[1,1n,2n,NaN]'); + + expect( + serialize( + new Map([ + [NaN, 1], + [10, 2], + [9n, 3], + [2, 4], + ]) + ) + ).toBe('Map{2:4,9n:3,10:2,NaN:1}'); + expect( + serialize( + new Map([ + [9n, 3], + [2, 4], + [NaN, 1], + [10, 2], + ]) + ) + ).toBe('Map{2:4,9n:3,10:2,NaN:1}'); + }); + + it('should sort mixed types regardless of insertion order', () => { + const values = [2, 'a', 10, null, true, 1n, [1], { k: 1 }, undefined, Symbol.for('s')]; + const expected = serialize(new Set(values)); + + expect(expected).toBe("Set['a',1n,2,10,Symbol('s'),[1],null,true,{'k':1},undefined]"); + + for (let i = 1; i < values.length; i++) { + expect(serialize(new Set([...values.slice(i), ...values.slice(0, i)]))).toBe(expected); + expect(serialize(new Set([...values.slice(0, i).reverse(), ...values.slice(i).reverse()]))).toBe(expected); + } + }); + + it('should produce the same output for structurally equal values', () => { + const shared = { _: 1, b: { _: 2, c: { _: 3 } } }; + + expect(serialize({ a: shared, b: shared })).toBe( + serialize({ + a: { _: 1, b: { _: 2, c: { _: 3 } } }, + b: { _: 1, b: { _: 2, c: { _: 3 } } }, + }) + ); + }); + + it('should not depend on the locale when sorting keys', () => { + // In Slovak, `ch` is a single letter sorted after `h`; code unit + // ordering keeps `checkIn` before `destination` in every locale. + expect(serialize({ destination: 'bar', checkIn: 'foo' })).toBe("{'checkIn':'foo','destination':'bar'}"); + }); + + it('should distinguish values with different types', () => { + expect(serialize(1)).not.toBe(serialize('1')); + expect(serialize([1, 2])).not.toBe(serialize(new Set([1, 2]))); + expect(serialize({ a: 1 })).not.toBe(serialize(new Map([['a', 1]]))); + }); + }); + + describe('strings', () => { + it('should wrap the string in single quotes', () => { + expect(serialize('abc')).toBe("'abc'"); + expect(serialize('')).toBe("''"); + }); + + it('should keep unicode characters as-is', () => { + expect(serialize('hello world 😎')).toBe("'hello world 😎'"); + expect(serialize('ν•œκΈ€')).toBe("'ν•œκΈ€'"); + }); + + it('should not escape quotes in the string', () => { + expect(serialize("a'b")).toBe("'a'b'"); + }); + }); + + describe('numbers', () => { + it('should serialize integers', () => { + expect(serialize(0)).toBe('0'); + expect(serialize(100)).toBe('100'); + expect(serialize(-100)).toBe('-100'); + }); + + it('should serialize -0 as 0', () => { + expect(serialize(-0)).toBe('0'); + }); + + it('should serialize floating point numbers', () => { + expect(serialize(1.5)).toBe('1.5'); + expect(serialize(Number.EPSILON)).toBe('2.220446049250313e-16'); + }); + + it('should serialize NaN and Infinity', () => { + expect(serialize(NaN)).toBe('NaN'); + expect(serialize(Infinity)).toBe('Infinity'); + expect(serialize(-Infinity)).toBe('-Infinity'); + }); + }); + + describe('bigints', () => { + it('should serialize bigints with an n suffix', () => { + expect(serialize(0n)).toBe('0n'); + expect(serialize(123n)).toBe('123n'); + expect(serialize(-123n)).toBe('-123n'); + expect(serialize(9007199254740991n)).toBe('9007199254740991n'); + }); + + it('should serialize bigints larger than Number.MAX_SAFE_INTEGER', () => { + expect(serialize(123456789012345678901234567890n)).toBe('123456789012345678901234567890n'); + }); + }); + + describe('symbols', () => { + it('should serialize symbols with a quoted description', () => { + expect(serialize(Symbol('test'))).toBe("Symbol('test')"); + }); + + it('should distinguish no description from an empty description', () => { + expect(serialize(Symbol())).toBe('Symbol()'); + expect(serialize(Symbol(''))).toBe("Symbol('')"); + }); + + it('should serialize registered symbols the same as unregistered ones', () => { + expect(serialize(Symbol.for('test'))).toBe("Symbol('test')"); + }); + + it('should serialize well-known symbols', () => { + expect(serialize(Symbol.iterator)).toBe("Symbol('Symbol.iterator')"); + }); + }); + + describe('functions', () => { + it('should serialize function declarations as name:source', () => { + function sum(a: number, b: number) { + return a + b; + } + expect(serialize(sum)).toBe('sum:function sum(a, b) {return a + b;}'); + }); + + it('should serialize arrow functions as name:source', () => { + const sum = (a: number, b: number) => a + b; + expect(serialize(sum)).toBe('sum:(a, b) => a + b'); + }); + + it('should collapse newlines and surrounding whitespace in the source', () => { + function withBranches(a: number) { + if (a > 0) { + return a; + } + return -a; + } + expect(serialize(withBranches)).toBe('withBranches:function withBranches(a) {if (a > 0) {return a;};return -a;}'); + }); + + it('should serialize classes as name:source', () => { + class Foo { + x = 1; + } + expect(serialize(Foo)).toBe('Foo:class Foo {x = 1;}'); + }); + + it('should serialize native functions as name:[native]', () => { + expect(serialize(Math.max)).toBe('max:[native]'); + expect(serialize(Array)).toBe('Array:[native]'); + expect(serialize(parseInt)).toBe('parseInt:[native]'); + }); + + it('should serialize bound functions as name:[native]', () => { + function original(a: number) { + return a; + } + expect(serialize(original.bind(null))).toBe('bound original:[native]'); + }); + + it('should distinguish functions with identical sources by name', () => { + const first = (a: number) => a; + const second = (a: number) => a; + expect(serialize(first)).toBe('first:(a) => a'); + expect(serialize(second)).toBe('second:(a) => a'); + }); + + it('should serialize anonymous functions with an empty name', () => { + expect(serialize([(a: number) => a][0])).toBe(':(a) => a'); + }); + }); + + describe('plain objects', () => { + it("should serialize plain objects as {'key':value}", () => { + expect(serialize({ a: 1, b: 2 })).toBe("{'a':1,'b':2}"); + expect(serialize({})).toBe('{}'); + }); + + it('should sort keys by code unit', () => { + expect(serialize({ b: 2, a: 1 })).toBe("{'a':1,'b':2}"); + expect(serialize({ 10: 1, 9: 2, 2: 3 })).toBe("{'10':1,'2':3,'9':2}"); + }); + + it('should serialize nested values', () => { + expect(serialize({ a: { b: 'c' }, d: [1, 2] })).toBe("{'a':{'b':'c'},'d':[1,2]}"); + }); + + it('should ignore symbol keys', () => { + expect(serialize({ [Symbol('s')]: 123 })).toBe('{}'); + }); + + it('should ignore non-enumerable properties', () => { + const object = {}; + Object.defineProperty(object, 'x', { value: 1, enumerable: false }); + expect(serialize(object)).toBe('{}'); + }); + + it('should ignore inherited properties', () => { + const object = Object.create({ inherited: 1 }); + object.own = 2; + expect(serialize(object)).toBe("{'own':2}"); + }); + + it('should serialize objects with a null prototype', () => { + const object = Object.create(null); + object.a = 1; + expect(serialize(object)).toBe("{'a':1}"); + }); + + it('should invoke getters', () => { + const object = { + get a() { + return 1; + }, + }; + expect(serialize(object)).toBe("{'a':1}"); + }); + }); + + describe('arrays', () => { + it('should keep the order of array elements', () => { + expect(serialize([1, 2, 'x', 3])).toBe("[1,2,'x',3]"); + expect(serialize([2, 3, 'x', 1])).toBe("[2,3,'x',1]"); + }); + + it('should serialize empty arrays', () => { + expect(serialize([])).toBe('[]'); + }); + + it('should serialize holes as undefined', () => { + // eslint-disable-next-line no-sparse-arrays + expect(serialize([1, , 3])).toBe('[1,undefined,3]'); + }); + + it('should ignore extra properties on arrays', () => { + const array: number[] & { x?: number } = [1]; + array.x = 2; + expect(serialize(array)).toBe('[1]'); + }); + }); + + describe('Date', () => { + it('should serialize dates in ISO format', () => { + expect(serialize(new Date(0))).toBe("Date('1970-01-01T00:00:00.000Z')"); + }); + + it('should serialize invalid dates as Date(null)', () => { + expect(serialize(new Date(NaN))).toBe('Date(null)'); + }); + }); + + describe('RegExp', () => { + it('should serialize regular expressions with flags', () => { + expect(serialize(/.*/)).toBe('RegExp(/.*/)'); + expect(serialize(/ab+c/gi)).toBe('RegExp(/ab+c/gi)'); + }); + }); + + describe('Set', () => { + it('should sort Set values', () => { + expect(serialize(new Set([1, 2, 3]))).toBe('Set[1,2,3]'); + expect(serialize(new Set([2, 3, 1]))).toBe('Set[1,2,3]'); + }); + + it('should sort object values by their serialized form', () => { + expect(serialize(new Set([{ b: 1 }, { a: 1 }]))).toBe("Set[{'a':1},{'b':1}]"); + }); + + it('should sort mixed values', () => { + // `Array.prototype.sort` always places undefined elements last, + // without consulting the comparator. + expect(serialize(new Set([3, 'a', { b: 1 }, null, undefined]))).toBe("Set['a',3,null,{'b':1},undefined]"); + }); + + it('should sort numbers numerically', () => { + expect(serialize(new Set([10, 9, 1]))).toBe('Set[1,9,10]'); + }); + + it('should sort strings by code unit', () => { + expect(serialize(new Set(['b', 'a', 'B']))).toBe("Set['B','a','b']"); + }); + + it('should not depend on the locale', () => { + // In Slovak, the digraph `ch` is a single letter sorted after `h`. + expect(serialize(new Set(['hz', 'chz']))).toBe("Set['chz','hz']"); + }); + + it('should sort NaN after every other number', () => { + expect(serialize(new Set([NaN, Infinity, 1n, -Infinity]))).toBe('Set[-Infinity,1n,Infinity,NaN]'); + }); + + it('should sort numbers and bigints numerically', () => { + // A number sorts before a bigint of the same value. + expect(serialize(new Set([10n, 2, 1n, 1, 9n]))).toBe('Set[1,1n,2,9n,10n]'); + }); + + it('should sort strings before numbers and numbers before other values', () => { + expect(serialize(new Set([true, 3n, null, 'a', 3]))).toBe("Set['a',3,3n,null,true]"); + }); + + it('should sort other values by their serialized form', () => { + // "[1]" < "null" < "{'b':1}" + expect(serialize(new Set([{ b: 1 }, null, [1]]))).toBe("Set[[1],null,{'b':1}]"); + expect(serialize(new Set([{ a: 1 }, { a: 1 }]))).toBe("Set[{'a':1},{'a':1}]"); + }); + }); + + describe('Map', () => { + it('should sort entries by key and quote string keys', () => { + const map = new Map([ + [1, 4], + [2, 3], + ['z', 2], + ['a', '1'], + [{ x: 42 }, '3'], + ]); + + expect(serialize(map)).toBe("Map{'a':'1','z':2,1:4,2:3,{'x':42}:'3'}"); + }); + + it('should serialize empty maps', () => { + expect(serialize(new Map())).toBe('Map{}'); + }); + }); + + describe('typed arrays', () => { + it('should serialize typed arrays with their type name', () => { + expect(serialize(new Int8Array([1, 2, 3]))).toBe('Int8Array[1,2,3]'); + expect(serialize(new Uint8Array([1, 2, 3]))).toBe('Uint8Array[1,2,3]'); + expect(serialize(new Uint8ClampedArray([1, 2, 3]))).toBe('Uint8ClampedArray[1,2,3]'); + expect(serialize(new Int16Array([1, 2, 3]))).toBe('Int16Array[1,2,3]'); + expect(serialize(new Uint16Array([1, 2, 3]))).toBe('Uint16Array[1,2,3]'); + expect(serialize(new Int32Array([1, 2, 3]))).toBe('Int32Array[1,2,3]'); + expect(serialize(new Uint32Array([1, 2, 3]))).toBe('Uint32Array[1,2,3]'); + expect(serialize(new Float32Array([1, 2, 3]))).toBe('Float32Array[1,2,3]'); + expect(serialize(new Float64Array([1, 2, 3]))).toBe('Float64Array[1,2,3]'); + }); + + it('should serialize bigint typed arrays with n suffixes', () => { + expect(serialize(new BigInt64Array([1n, 2n, 3n]))).toBe('BigInt64Array[1n,2n,3n]'); + expect(serialize(new BigUint64Array([1n, 2n, 3n]))).toBe('BigUint64Array[1n,2n,3n]'); + expect(serialize(new BigInt64Array([]))).toBe('BigInt64Array[]'); + expect(serialize(new BigUint64Array([]))).toBe('BigUint64Array[]'); + }); + + it('should serialize Buffer as Uint8Array', () => { + expect(serialize(Buffer.from('hello'))).toBe('Uint8Array[104,101,108,108,111]'); + }); + }); + + describe('ArrayBuffer', () => { + it('should serialize the underlying bytes', () => { + expect(serialize(new Uint8Array([1, 2, 3]).buffer)).toBe('ArrayBuffer[1,2,3]'); + expect(serialize(new ArrayBuffer(2))).toBe('ArrayBuffer[0,0]'); + }); + }); + + describe('Error', () => { + it('should serialize errors with their name and message', () => { + expect(serialize(new Error('test'))).toBe("Error(Error: 'test')"); + expect(serialize(new TypeError('boom'))).toBe("Error(TypeError: 'boom')"); + }); + + it('should keep the name and message boundary unambiguous', () => { + const first = new Error('b: c'); + first.name = 'a'; + const second = new Error('c'); + second.name = 'a: b'; + + expect(serialize(first)).toBe("Error(a: 'b: c')"); + expect(serialize(second)).toBe("Error(a: b: 'c')"); + expect(serialize(new Error(''))).toBe("Error(Error: '')"); + }); + }); + + describe('class instances', () => { + it('should serialize class instances with the class name', () => { + class Test { + x = 1; + } + expect(serialize(new Test())).toBe("Test{'x':1}"); + }); + + it('should serialize class instances even when the class name exists in globalThis', () => { + class CustomEvent { + y = 1; + } + expect(serialize(new CustomEvent())).toBe("CustomEvent{'y':1}"); + }); + + it('should use toJSON when available', () => { + class TestArray { + toJSON() { + return [1, 2, 3]; + } + } + expect(serialize(new TestArray())).toBe('TestArray[1,2,3]'); + + class TestObject { + toJSON() { + return { a: 1, b: 2 }; + } + } + expect(serialize(new TestObject())).toBe("TestObject{'a':1,'b':2}"); + }); + + it('should wrap primitive toJSON results in parentheses', () => { + class TestNull { + toJSON() { + return null; + } + } + expect(serialize(new TestNull())).toBe('TestNull(null)'); + + class TestString { + toJSON() { + return 'value'; + } + } + expect(serialize(new TestString())).toBe("TestString('value')"); + }); + + it('should serialize toJSON on plain objects', () => { + // A plain object with toJSON is still serialized as a plain object. + expect(serialize({ toJSON: () => 42, a: 1 })).toBe("{'a':1,'toJSON':toJSON:() => 42}"); + }); + + it('should not loop forever when toJSON returns this', () => { + class Test { + toJSON() { + return this; + } + } + expect(serialize(new Test())).toBe('Test#ref0'); + }); + }); + + describe('objects with entries()', () => { + it('should serialize URLSearchParams', () => { + const params = new URLSearchParams(); + params.set('foo', 'bar'); + params.set('bar', 'baz'); + expect(serialize(params)).toBe("URLSearchParams{'bar':'baz','foo':'bar'}"); + }); + + it('should serialize FormData', () => { + const form = new FormData(); + form.set('foo', 'bar'); + form.set('bar', 'baz'); + expect(serialize(form)).toBe("FormData{'bar':'baz','foo':'bar'}"); + }); + + it('should serialize Headers', () => { + const headers = new Headers(); + headers.set('b', '2'); + headers.set('a', '1'); + expect(serialize(headers)).toBe("Headers{'a':'1','b':'2'}"); + }); + }); + + describe('unsupported objects', () => { + it('should throw a TypeError for objects that cannot be serialized', () => { + expect(() => serialize(new Blob(['x']))).toThrow(new TypeError('Cannot serialize Blob')); + expect(() => serialize(new WeakMap())).toThrow(new TypeError('Cannot serialize WeakMap')); + expect(() => serialize(new WeakSet())).toThrow(new TypeError('Cannot serialize WeakSet')); + expect(() => serialize(new WeakRef({}))).toThrow(new TypeError('Cannot serialize WeakRef')); + expect(() => serialize(new Promise(() => {}))).toThrow(new TypeError('Cannot serialize Promise')); + expect(() => serialize(new DataView(new ArrayBuffer(4)))).toThrow(new TypeError('Cannot serialize DataView')); + }); + + it('should throw a TypeError for boxed primitives', () => { + // eslint-disable-next-line no-new-wrappers + expect(() => serialize(new String('x'))).toThrow(new TypeError('Cannot serialize String')); + // eslint-disable-next-line no-new-wrappers + expect(() => serialize(new Number(1))).toThrow(new TypeError('Cannot serialize Number')); + // eslint-disable-next-line no-new-wrappers + expect(() => serialize(new Boolean(true))).toThrow(new TypeError('Cannot serialize Boolean')); + }); + + it('should throw a TypeError for generators', () => { + function* generator() { + yield 1; + } + expect(() => serialize(generator())).toThrow(new TypeError('Cannot serialize Generator')); + + async function* asyncGenerator() { + yield 1; + } + expect(() => serialize(asyncGenerator())).toThrow(new TypeError('Cannot serialize AsyncGenerator')); + }); + }); + + describe('circular references', () => { + it('should serialize a self-referencing object', () => { + const object: Record = {}; + object.foo = object; + expect(serialize(object)).toBe("{'foo':#ref0}"); + }); + + it('should serialize circular references in nested objects', () => { + const object: Record = { a: { b: {} } }; + object.a.b = object; + expect(serialize(object)).toBe("{'a':{'b':#ref0}}"); + }); + + it('should serialize circular references in arrays', () => { + const array: unknown[] = []; + array.push(array); + expect(serialize(array)).toBe('[#ref0]'); + }); + + it('should number back-references by visit order', () => { + const object: Record = { a: { b: { c: {} } } }; + object.a.b.c = object.a; + expect(serialize(object)).toBe("{'a':{'b':{'c':#ref1}}}"); + + const deep: Record = { x: { y: { z: {} } } }; + deep.x.y.z.ref1 = deep.x; + deep.x.y.z.ref2 = deep; + expect(serialize(deep)).toBe("{'x':{'y':{'z':{'ref1':#ref1,'ref2':#ref0}}}}"); + }); + + it('should serialize circular references in Map and Set', () => { + const map = new Map(); + map.set('key', map); + expect(serialize(map)).toBe("Map{'key':#ref0}"); + + const mapAsKey = new Map(); + mapAsKey.set(mapAsKey, 'value'); + expect(serialize(mapAsKey)).toBe("Map{#ref0:'value'}"); + + const set = new Set(); + set.add(set); + expect(serialize(set)).toBe('Set[#ref0]'); + }); + + it('should serialize multiple references to the same object', () => { + const shared: Record = {}; + shared.ref = shared; + expect(serialize({ a: shared, b: shared })).toBe("{'a':{'ref':#ref1},'b':{'ref':#ref1}}"); + }); + + it('should reuse the serialized string of completed objects', () => { + const shared = { _: 1, b: { _: 2, c: { _: 3 } } }; + const references = { a: shared, b: shared }; + const copies = { + a: { _: 1, b: { _: 2, c: { _: 3 } } }, + b: { _: 1, b: { _: 2, c: { _: 3 } } }, + }; + + expect(serialize(references)).toBe(serialize(copies)); + }); + }); +}); diff --git a/src/util/serialize.ts b/src/util/serialize.ts new file mode 100644 index 000000000..2bc700eb1 --- /dev/null +++ b/src/util/serialize.ts @@ -0,0 +1,439 @@ +import { isArrayBuffer } from '../predicate/isArrayBuffer.ts'; +import { isDate } from '../predicate/isDate.ts'; +import { isError } from '../predicate/isError.ts'; +import { isMap } from '../predicate/isMap.ts'; +import { isPlainObject } from '../predicate/isPlainObject.ts'; +import { isRegExp } from '../predicate/isRegExp.ts'; +import { isSet } from '../predicate/isSet.ts'; +import { isTypedArray } from '../predicate/isTypedArray.ts'; + +/** + * Serializes any value into a stable string. + * + * Two values with the same structure always serialize to the same string, + * regardless of key insertion order, so the output is suitable for hashing, + * cache keys, and change detection. It is not designed for security purposes; + * intentional collisions can be crafted from user input. + * + * Plain object keys, `Map` keys, and `Set` values are sorted, so the output + * does not depend on insertion order. String keys are always quoted, so a + * string key never collides with a key of another type. Circular references + * are serialized as `#ref{n}` back-references, where `n` is the order in + * which the object was first visited. + * + * Objects that cannot be serialized meaningfully, such as `Promise`, `WeakMap`, + * or `Blob`, throw a `TypeError`. + * + * @param value - The value to serialize. + * @returns The serialized string. + * @throws {TypeError} If the value contains an object that cannot be serialized. + * + * @example + * serialize({ b: 2, a: 1 }); // "{'a':1,'b':2}" + * serialize([1, 2n, 'a', { k: 1 }]); // "[1,2n,'a',{'k':1}]" + * serialize(new Set([3, 1, 2])); // "Set[1,2,3]" + * serialize(new Date(0)); // "Date('1970-01-01T00:00:00.000Z')" + * + * const obj = {}; + * obj.self = obj; + * serialize(obj); // "{'self':#ref0}" + */ +export function serialize(value: unknown): string { + return serializeValue(value, new Map()); +} + +/** + * Serializes a value with a shared circular reference context. + * + * @param value - The value to serialize. + * @param refs - Objects that are being serialized or have been serialized, + * mapped to their back-reference placeholder or completed serialization. + * @returns The serialized string. + */ +function serializeValue(value: unknown, refs: Map): string { + switch (typeof value) { + case 'string': { + return serializeString(value); + } + case 'number': { + return serializeNumber(value); + } + case 'bigint': { + return serializeBigInt(value); + } + case 'symbol': { + return serializeSymbol(value); + } + case 'function': { + return serializeFunction(value); + } + case 'object': { + return serializeObject(value, refs); + } + case 'boolean': { + return String(value); + } + case 'undefined': { + return 'undefined'; + } + } +} + +/** + * Serializes a string into a single-quoted string literal. + * + * The string is not escaped; the output is intended for hashing and + * change detection, not for parsing or re-evaluation. + * + * @param value - The string to serialize. + * @returns The serialized string. + * + * @example + * serializeString('abc'); // "'abc'" + */ +function serializeString(value: string): string { + return `'${value}'`; +} + +/** + * Serializes a number into a string. + * + * `-0` is serialized as `0`, and `NaN` and `Infinity` are serialized + * as their string representations. + * + * @param value - The number to serialize. + * @returns The serialized string. + * + * @example + * serializeNumber(1); // "1" + * serializeNumber(-0); // "0" + * serializeNumber(NaN); // "NaN" + * serializeNumber(Infinity); // "Infinity" + */ +function serializeNumber(value: number): string { + return String(value); +} + +/** + * Serializes a bigint into a string with an `n` suffix, like a bigint literal. + * + * @param value - The bigint to serialize. + * @returns The serialized string. + * + * @example + * serializeBigInt(123n); // "123n" + */ +function serializeBigInt(value: bigint): string { + return `${value}n`; +} + +/** + * Serializes a symbol into a string using its description. + * + * The description is quoted like any other string, so a symbol without a + * description (`Symbol()`) is distinguishable from one with an empty + * description (`Symbol('')`). Note that two different symbols with the same + * description still serialize to the same string, since a symbol's identity + * cannot be captured in a string. + * + * @param value - The symbol to serialize. + * @returns The serialized string. + * + * @example + * serializeSymbol(Symbol('test')); // "Symbol('test')" + * serializeSymbol(Symbol()); // "Symbol()" + */ +function serializeSymbol(value: symbol): string { + return value.description === undefined ? 'Symbol()' : `Symbol(${serializeString(value.description)})`; +} + +/** + * Serializes a function into a string of the form `name:source`. + * + * Native functions have no meaningful source, so they are serialized as + * `name:[native]`. The whitespace inside `{ [native code] }` differs between + * engines, so it is matched loosely to keep the output platform independent. For other functions, newlines and their surrounding + * whitespace are collapsed so that formatting differences do not change + * the output. + * + * @param value - The function to serialize. + * @returns The serialized string. + * + * @example + * function sum(a, b) { + * return a + b; + * } + * serializeFunction(sum); // "sum:function sum(a, b) {return a + b;}" + * serializeFunction(Math.max); // "max:[native]" + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +function serializeFunction(value: Function): string { + const source = Function.prototype.toString.call(value); + + if (/\{\s*\[native code\]\s*\}$/.test(source)) { + return `${value.name}:[native]`; + } + + return `${value.name}:${source.replace(/\s*\n\s*/g, '')}`; +} + +/** + * Serializes an object, handling circular references and repeated references. + * + * The first time an object is visited, it is registered as `#ref{n}` where `n` + * is the visit order; if the object is reached again while it is still being + * serialized, the back-reference is emitted instead. Once completed, the + * serialized string is memoized so that repeated references serialize + * in constant time. + * + * @param value - The object to serialize, or `null`. + * @param refs - The circular reference context shared across one serialization. + * @returns The serialized string. + * @throws {TypeError} If the object cannot be serialized. + */ +function serializeObject(value: object | null, refs: Map): string { + if (value === null) { + return 'null'; + } + + const cached = refs.get(value); + + if (cached !== undefined) { + return cached; + } + + refs.set(value, `#ref${refs.size}`); + + const result = serializeObjectImpl(value, refs); + + refs.set(value, result); + + return result; +} + +function serializeObjectImpl(value: object, refs: Map): string { + if (Array.isArray(value)) { + return serializeArray(value, refs); + } + + if (isPlainObject(value)) { + return serializePlainObject(value, refs); + } + + if (isDate(value)) { + return Number.isNaN(value.getTime()) ? 'Date(null)' : `Date(${serializeString(value.toISOString())})`; + } + + if (isRegExp(value)) { + return `RegExp(${value.toString()})`; + } + + if (isSet(value)) { + const values = Array.from(value).sort((a, b) => compareValues(a, b, refs)); + return `Set${serializeArray(values, refs)}`; + } + + if (isMap(value)) { + return serializeEntries('Map', value.entries(), refs); + } + + if (isTypedArray(value)) { + const name = (value as unknown as Record)[Symbol.toStringTag]; + + if (name === 'BigInt64Array' || name === 'BigUint64Array') { + return `${name}[${value.join('n,')}${value.length > 0 ? 'n' : ''}]`; + } + + return `${name}[${value.join(',')}]`; + } + + if (isArrayBuffer(value)) { + return `ArrayBuffer[${new Uint8Array(value).join(',')}]`; + } + + if (isError(value)) { + return `Error(${value.name}: ${serializeString(value.message)})`; + } + + const tag = Object.prototype.toString.call(value).slice(8, -1); + + if (tag === 'Object') { + return serializeClassInstance(value, refs); + } + + if (typeof (value as { entries?: unknown }).entries === 'function') { + return serializeEntries(tag, (value as { entries: () => Iterable<[unknown, unknown]> }).entries(), refs); + } + + throw new TypeError(`Cannot serialize ${tag}`); +} + +function serializeArray(array: readonly unknown[], refs: Map): string { + let result = '['; + + for (let i = 0; i < array.length; i++) { + if (i > 0) { + result += ','; + } + + result += serializeValue(array[i], refs); + } + + return result + ']'; +} + +function serializeEntries(tag: string, entries: Iterable<[unknown, unknown]>, refs: Map): string { + const sortedEntries = Array.from(entries).sort((a, b) => compareValues(a[0], b[0], refs)); + + let result = `${tag}{`; + + for (let i = 0; i < sortedEntries.length; i++) { + const [key, value] = sortedEntries[i]; + + if (i > 0) { + result += ','; + } + + result += `${serializeValue(key, refs)}:${serializeValue(value, refs)}`; + } + + return result + '}'; +} + +function serializeClassInstance(value: object, refs: Map): string { + const constructor = value.constructor; + const name = constructor === Object || constructor === undefined ? '' : constructor.name; + + if ('toJSON' in value && typeof value.toJSON === 'function') { + const json = value.toJSON(); + + if (json !== null && typeof json === 'object') { + return name + serializeObject(json, refs); + } + + return `${name}(${serializeValue(json, refs)})`; + } + + return name + serializePlainObject(value, refs); +} + +/** + * Serializes the own enumerable string-keyed properties of an object + * into a `{'key':value}` string. + * + * Keys are sorted by code unit so that the output does not depend on + * property insertion order, and always quoted so that a key containing + * `:` or `,` cannot be confused with the surrounding structure. Symbol + * keys and non-enumerable properties are ignored. + * + * @param object - The object to serialize. + * @param refs - The circular reference context shared with the surrounding serialization. + * @returns The serialized string. + * + * @example + * serializePlainObject({ b: 2, a: 1 }, new Map()); // "{'a':1,'b':2}" + */ +function serializePlainObject(object: object, refs: Map): string { + const keys = Object.keys(object).sort(); + + let result = '{'; + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + + if (i > 0) { + result += ','; + } + + result += `${serializeString(key)}:${serializeValue((object as Record)[key], refs)}`; + } + + return result + '}'; +} + +/** + * Compares two values for sorting `Set` values and `Map` keys. + * + * The comparison is a total order, so the sorted result does not depend on + * the insertion order. Values are grouped by type: strings first, then + * numbers and bigints, then everything else. Strings are compared by code + * unit. Numbers and bigints are compared numerically with each other; + * `NaN` sorts after every other number, and a number sorts before a bigint + * of the same value. Every other value is compared by the code unit order + * of its serialized string. + * + * @param a - The first value to compare. + * @param b - The second value to compare. + * @param refs - The circular reference context shared with the surrounding serialization. + * @returns A negative number if `a` sorts before `b`, a positive number if + * `a` sorts after `b`, and `0` if they are equal. + */ +function compareValues(a: unknown, b: unknown, refs: Map): number { + const rankA = getTypeRank(a); + const rankB = getTypeRank(b); + + if (rankA !== rankB) { + return rankA - rankB; + } + + if (typeof a === 'string' && typeof b === 'string') { + return compareStrings(a, b); + } + + if (rankA === NUMERIC_RANK) { + return compareNumeric(a as number | bigint, b as number | bigint); + } + + return compareStrings(serializeValue(a, refs), serializeValue(b, refs)); +} + +const STRING_RANK = 0; +const NUMERIC_RANK = 1; +const OTHER_RANK = 2; + +function getTypeRank(value: unknown): number { + switch (typeof value) { + case 'string': { + return STRING_RANK; + } + case 'number': + case 'bigint': { + return NUMERIC_RANK; + } + default: { + return OTHER_RANK; + } + } +} + +function compareStrings(a: string, b: string): number { + if (a === b) { + return 0; + } + + return a < b ? -1 : 1; +} + +function compareNumeric(a: number | bigint, b: number | bigint): number { + const isNaNA = typeof a === 'number' && Number.isNaN(a); + const isNaNB = typeof b === 'number' && Number.isNaN(b); + + if (isNaNA || isNaNB) { + return Number(isNaNA) - Number(isNaNB); + } + + // Relational operators compare numbers and bigints by their mathematical values. + if (a < b) { + return -1; + } + + if (a > b) { + return 1; + } + + if (typeof a === typeof b) { + return 0; + } + + return typeof a === 'number' ? -1 : 1; +} diff --git a/src/util/serialize/compareValues.spec.ts b/src/util/serialize/compareValues.spec.ts deleted file mode 100644 index 5e997899a..000000000 --- a/src/util/serialize/compareValues.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { compareValues } from './compareValues'; - -describe('compareValues', () => { - it('should compare numbers numerically', () => { - expect(compareValues(1, 2, new Map())).toBeLessThan(0); - expect(compareValues(10, 9, new Map())).toBeGreaterThan(0); - expect(compareValues(1, 1, new Map())).toBe(0); - }); - - it('should compare strings by code unit', () => { - expect(compareValues('a', 'b', new Map())).toBeLessThan(0); - expect(compareValues('b', 'a', new Map())).toBeGreaterThan(0); - expect(compareValues('a', 'a', new Map())).toBe(0); - }); - - it('should not depend on the locale', () => { - // In Slovak, the digraph `ch` is a single letter sorted after `h`. - expect(compareValues('chz', 'hz', new Map())).toBeLessThan(0); - }); - - it('should sort NaN after every other number', () => { - expect(compareValues(NaN, Infinity, new Map())).toBeGreaterThan(0); - expect(compareValues(-Infinity, NaN, new Map())).toBeLessThan(0); - expect(compareValues(NaN, NaN, new Map())).toBe(0); - expect(compareValues(NaN, 1n, new Map())).toBeGreaterThan(0); - }); - - it('should compare numbers and bigints numerically', () => { - expect(compareValues(2, 10n, new Map())).toBeLessThan(0); - expect(compareValues(11n, 10, new Map())).toBeGreaterThan(0); - expect(compareValues(9n, 10n, new Map())).toBeLessThan(0); - // A number sorts before a bigint of the same value. - expect(compareValues(1, 1n, new Map())).toBeLessThan(0); - expect(compareValues(1n, 1, new Map())).toBeGreaterThan(0); - }); - - it('should sort strings before numbers and numbers before other values', () => { - expect(compareValues(3, 'a', new Map())).toBeGreaterThan(0); - expect(compareValues('a', 3n, new Map())).toBeLessThan(0); - expect(compareValues(3, null, new Map())).toBeLessThan(0); - expect(compareValues(true, 3n, new Map())).toBeGreaterThan(0); - }); - - it('should compare other types by their serialized form', () => { - // "'a'" vs "{'b':1}" - expect(compareValues('a', { b: 1 }, new Map())).toBeLessThan(0); - // "[1]" vs "null" - expect(compareValues([1], null, new Map())).toBeLessThan(0); - expect(compareValues({ a: 1 }, { a: 1 }, new Map())).toBe(0); - }); - - it('should sort in a stable order usable by Array.prototype.sort', () => { - // `Array.prototype.sort` always places undefined elements last, - // without consulting the comparator. - const values = [3, 'a', { b: 1 }, null, undefined]; - const sorted = [...values].sort((a, b) => compareValues(a, b, new Map())); - expect(sorted).toEqual(['a', 3, null, { b: 1 }, undefined]); - }); -}); diff --git a/src/util/serialize/compareValues.ts b/src/util/serialize/compareValues.ts deleted file mode 100644 index 5c92daa5b..000000000 --- a/src/util/serialize/compareValues.ts +++ /dev/null @@ -1,89 +0,0 @@ -// oxlint-disable-next-line import/no-cycle -- serializeValue and serializeObject recurse into each other; the imports are only used at call time -import { serializeValue } from './serialize.ts'; - -/** - * Compares two values for sorting `Set` values and `Map` keys. - * - * The comparison is a total order, so the sorted result does not depend on - * the insertion order. Values are grouped by type: strings first, then - * numbers and bigints, then everything else. Strings are compared by code - * unit. Numbers and bigints are compared numerically with each other; - * `NaN` sorts after every other number, and a number sorts before a bigint - * of the same value. Every other value is compared by the code unit order - * of its serialized string. - * - * @param a - The first value to compare. - * @param b - The second value to compare. - * @param refs - The circular reference context shared with the surrounding serialization. - * @returns A negative number if `a` sorts before `b`, a positive number if - * `a` sorts after `b`, and `0` if they are equal. - */ -export function compareValues(a: unknown, b: unknown, refs: Map): number { - const rankA = getTypeRank(a); - const rankB = getTypeRank(b); - - if (rankA !== rankB) { - return rankA - rankB; - } - - if (typeof a === 'string' && typeof b === 'string') { - return compareStrings(a, b); - } - - if (rankA === NUMERIC_RANK) { - return compareNumeric(a as number | bigint, b as number | bigint); - } - - return compareStrings(serializeValue(a, refs), serializeValue(b, refs)); -} - -const STRING_RANK = 0; -const NUMERIC_RANK = 1; -const OTHER_RANK = 2; - -function getTypeRank(value: unknown): number { - switch (typeof value) { - case 'string': { - return STRING_RANK; - } - case 'number': - case 'bigint': { - return NUMERIC_RANK; - } - default: { - return OTHER_RANK; - } - } -} - -function compareStrings(a: string, b: string): number { - if (a === b) { - return 0; - } - - return a < b ? -1 : 1; -} - -function compareNumeric(a: number | bigint, b: number | bigint): number { - const isNaNA = typeof a === 'number' && Number.isNaN(a); - const isNaNB = typeof b === 'number' && Number.isNaN(b); - - if (isNaNA || isNaNB) { - return Number(isNaNA) - Number(isNaNB); - } - - // Relational operators compare numbers and bigints by their mathematical values. - if (a < b) { - return -1; - } - - if (a > b) { - return 1; - } - - if (typeof a === typeof b) { - return 0; - } - - return typeof a === 'number' ? -1 : 1; -} diff --git a/src/util/serialize/serialize.spec.ts b/src/util/serialize/serialize.spec.ts deleted file mode 100644 index 485a4424b..000000000 --- a/src/util/serialize/serialize.spec.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { serialize } from './serialize'; - -describe('serialize', () => { - describe('primitives', () => { - it('should serialize strings with quotes', () => { - expect(serialize('hello world 😎')).toBe("'hello world 😎'"); - expect(serialize({ msg: 'hello world 😎' })).toBe("{'msg':'hello world 😎'}"); - }); - - it('should serialize numbers', () => { - expect(serialize(0)).toBe('0'); - expect(serialize(-0)).toBe('0'); - expect(serialize(100)).toBe('100'); - expect(serialize(NaN)).toBe('NaN'); - expect(serialize(Infinity)).toBe('Infinity'); - }); - - it('should serialize booleans', () => { - expect(serialize(true)).toBe('true'); - expect(serialize(false)).toBe('false'); - }); - - it('should serialize null and undefined', () => { - expect(serialize(null)).toBe('null'); - expect(serialize(undefined)).toBe('undefined'); - }); - - it('should serialize bigints', () => { - expect(serialize(123n)).toBe('123n'); - }); - - it('should serialize symbols', () => { - expect(serialize(Symbol('test'))).toBe("Symbol('test')"); - }); - - it('should serialize functions', () => { - expect(serialize(Math.max)).toBe('max:[native]'); - expect(serialize(Array.prototype.push)).toBe('push:[native]'); - expect( - serialize(function add(a: number, b: number) { - return a + b; - }) - ).toBe('add:function add(a, b) {return a + b;}'); - }); - - it('should detect native functions regardless of how the engine formats them', () => { - // Safari formats native functions as `function max() {\n [native code]\n}`. - const max = function max() {}; - const original = Function.prototype.toString; - const spy = vi.spyOn(Function.prototype, 'toString').mockImplementation(function (this: unknown) { - return this === max ? 'function max() {\n [native code]\n}' : original.call(this); - }); - - try { - expect(serialize(max)).toBe('max:[native]'); - } finally { - spy.mockRestore(); - } - }); - - it('should distinguish undefined from the string "undefined"', () => { - expect(serialize([undefined, 'undefined'])).toBe("[undefined,'undefined']"); - }); - }); - - describe('objects', () => { - it('should serialize plain objects with sorted keys', () => { - expect(serialize({ a: 1, b: 2 })).toBe("{'a':1,'b':2}"); - expect(serialize({ b: 2, a: 1 })).toBe("{'a':1,'b':2}"); - }); - - it('should serialize nested structures', () => { - expect(serialize({ a: [1, { b: new Set([2, 1]) }], c: 3n })).toBe("{'a':[1,{'b':Set[1,2]}],'c':3n}"); - }); - - it('should serialize builtin objects', () => { - expect(serialize(new Date(0))).toBe("Date('1970-01-01T00:00:00.000Z')"); - expect(serialize(/.*/)).toBe('RegExp(/.*/)'); - expect(serialize(new Map([['a', 1]]))).toBe("Map{'a':1}"); - expect(serialize(new Uint8Array([1, 2, 3]))).toBe('Uint8Array[1,2,3]'); - }); - - it('should throw a TypeError for unsupported objects', () => { - expect(() => serialize(new WeakMap())).toThrow(TypeError); - expect(() => serialize({ a: new Promise(() => {}) })).toThrow(TypeError); - }); - }); - - describe('stability', () => { - it('should produce the same output regardless of key insertion order', () => { - expect(serialize({ b: 2, a: 1 })).toBe(serialize({ a: 1, b: 2 })); - expect(serialize(new Set([3, 1, 2]))).toBe(serialize(new Set([2, 3, 1]))); - expect( - serialize( - new Map([ - ['a', 1], - ['b', 2], - ]) - ) - ).toBe( - serialize( - new Map([ - ['b', 2], - ['a', 1], - ]) - ) - ); - }); - - it('should sort NaN and mixed numbers and bigints regardless of insertion order', () => { - expect(serialize(new Set([NaN, 1, -1, 2]))).toBe('Set[-1,1,2,NaN]'); - expect(serialize(new Set([1, NaN, -1, 2]))).toBe('Set[-1,1,2,NaN]'); - expect(serialize(new Set([-1, 1, 2, NaN]))).toBe('Set[-1,1,2,NaN]'); - expect(serialize(new Set([NaN, Infinity, -Infinity, 0]))).toBe('Set[-Infinity,0,Infinity,NaN]'); - expect(serialize(new Set([0, Infinity, NaN, -Infinity]))).toBe('Set[-Infinity,0,Infinity,NaN]'); - - expect(serialize(new Set([10, 9n, 2, 11n]))).toBe('Set[2,9n,10,11n]'); - expect(serialize(new Set([11n, 2, 10, 9n]))).toBe('Set[2,9n,10,11n]'); - expect(serialize(new Set([1n, 1, NaN, 2n]))).toBe('Set[1,1n,2n,NaN]'); - expect(serialize(new Set([NaN, 2n, 1, 1n]))).toBe('Set[1,1n,2n,NaN]'); - - expect( - serialize( - new Map([ - [NaN, 1], - [10, 2], - [9n, 3], - [2, 4], - ]) - ) - ).toBe('Map{2:4,9n:3,10:2,NaN:1}'); - expect( - serialize( - new Map([ - [9n, 3], - [2, 4], - [NaN, 1], - [10, 2], - ]) - ) - ).toBe('Map{2:4,9n:3,10:2,NaN:1}'); - }); - - it('should sort mixed types regardless of insertion order', () => { - const values = [2, 'a', 10, null, true, 1n, [1], { k: 1 }, undefined, Symbol.for('s')]; - const expected = serialize(new Set(values)); - - expect(expected).toBe("Set['a',1n,2,10,Symbol('s'),[1],null,true,{'k':1},undefined]"); - - for (let i = 1; i < values.length; i++) { - expect(serialize(new Set([...values.slice(i), ...values.slice(0, i)]))).toBe(expected); - expect(serialize(new Set([...values.slice(0, i).reverse(), ...values.slice(i).reverse()]))).toBe(expected); - } - }); - - it('should produce the same output for structurally equal values', () => { - const shared = { _: 1, b: { _: 2, c: { _: 3 } } }; - - expect(serialize({ a: shared, b: shared })).toBe( - serialize({ - a: { _: 1, b: { _: 2, c: { _: 3 } } }, - b: { _: 1, b: { _: 2, c: { _: 3 } } }, - }) - ); - }); - - it('should not depend on the locale when sorting keys', () => { - // In Slovak, `ch` is a single letter sorted after `h`; code unit - // ordering keeps `checkIn` before `destination` in every locale. - expect(serialize({ destination: 'bar', checkIn: 'foo' })).toBe("{'checkIn':'foo','destination':'bar'}"); - }); - - it('should distinguish values with different types', () => { - expect(serialize(1)).not.toBe(serialize('1')); - expect(serialize([1, 2])).not.toBe(serialize(new Set([1, 2]))); - expect(serialize({ a: 1 })).not.toBe(serialize(new Map([['a', 1]]))); - }); - }); - - describe('circular references', () => { - it('should serialize circular references as back-references', () => { - const object: Record = {}; - object.self = object; - expect(serialize(object)).toBe("{'self':#ref0}"); - }); - }); -}); diff --git a/src/util/serialize/serialize.ts b/src/util/serialize/serialize.ts deleted file mode 100644 index 1705daed8..000000000 --- a/src/util/serialize/serialize.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { serializeBigInt } from './serializeBigInt.ts'; -import { serializeFunction } from './serializeFunction.ts'; -import { serializeNumber } from './serializeNumber.ts'; -// oxlint-disable-next-line import/no-cycle -- serializeValue and serializeObject recurse into each other; the imports are only used at call time -import { serializeObject } from './serializeObject.ts'; -import { serializeString } from './serializeString.ts'; -import { serializeSymbol } from './serializeSymbol.ts'; - -/** - * Serializes any value into a stable string. - * - * Two values with the same structure always serialize to the same string, - * regardless of key insertion order, so the output is suitable for hashing, - * cache keys, and change detection. It is not designed for security purposes; - * intentional collisions can be crafted from user input. - * - * Plain object keys, `Map` keys, and `Set` values are sorted, so the output - * does not depend on insertion order. String keys are always quoted, so a - * string key never collides with a key of another type. Circular references - * are serialized as `#ref{n}` back-references, where `n` is the order in - * which the object was first visited. - * - * Objects that cannot be serialized meaningfully, such as `Promise`, `WeakMap`, - * or `Blob`, throw a `TypeError`. - * - * @param value - The value to serialize. - * @returns The serialized string. - * @throws {TypeError} If the value contains an object that cannot be serialized. - * - * @example - * serialize({ b: 2, a: 1 }); // "{'a':1,'b':2}" - * serialize([1, 2n, 'a', { k: 1 }]); // "[1,2n,'a',{'k':1}]" - * serialize(new Set([3, 1, 2])); // "Set[1,2,3]" - * serialize(new Date(0)); // "Date('1970-01-01T00:00:00.000Z')" - * - * const obj = {}; - * obj.self = obj; - * serialize(obj); // "{'self':#ref0}" - */ -export function serialize(value: unknown): string { - return serializeValue(value, new Map()); -} - -/** - * Serializes a value with a shared circular reference context. - * - * @param value - The value to serialize. - * @param refs - Objects that are being serialized or have been serialized, - * mapped to their back-reference placeholder or completed serialization. - * @returns The serialized string. - */ -export function serializeValue(value: unknown, refs: Map): string { - switch (typeof value) { - case 'string': { - return serializeString(value); - } - case 'number': { - return serializeNumber(value); - } - case 'bigint': { - return serializeBigInt(value); - } - case 'symbol': { - return serializeSymbol(value); - } - case 'function': { - return serializeFunction(value); - } - case 'object': { - return serializeObject(value, refs); - } - case 'boolean': { - return String(value); - } - case 'undefined': { - return 'undefined'; - } - } -} diff --git a/src/util/serialize/serializeBigInt.spec.ts b/src/util/serialize/serializeBigInt.spec.ts deleted file mode 100644 index b1f02dba4..000000000 --- a/src/util/serialize/serializeBigInt.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { serializeBigInt } from './serializeBigInt'; - -describe('serializeBigInt', () => { - it('should serialize bigints with an n suffix', () => { - expect(serializeBigInt(0n)).toBe('0n'); - expect(serializeBigInt(123n)).toBe('123n'); - expect(serializeBigInt(-123n)).toBe('-123n'); - expect(serializeBigInt(9007199254740991n)).toBe('9007199254740991n'); - }); - - it('should serialize bigints larger than Number.MAX_SAFE_INTEGER', () => { - expect(serializeBigInt(123456789012345678901234567890n)).toBe('123456789012345678901234567890n'); - }); -}); diff --git a/src/util/serialize/serializeBigInt.ts b/src/util/serialize/serializeBigInt.ts deleted file mode 100644 index 450fb42c6..000000000 --- a/src/util/serialize/serializeBigInt.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Serializes a bigint into a string with an `n` suffix, like a bigint literal. - * - * @param value - The bigint to serialize. - * @returns The serialized string. - * - * @example - * serializeBigInt(123n); // "123n" - */ -export function serializeBigInt(value: bigint): string { - return `${value}n`; -} diff --git a/src/util/serialize/serializeFunction.spec.ts b/src/util/serialize/serializeFunction.spec.ts deleted file mode 100644 index eebefa8db..000000000 --- a/src/util/serialize/serializeFunction.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { serializeFunction } from './serializeFunction'; - -describe('serializeFunction', () => { - it('should serialize function declarations as name:source', () => { - function sum(a: number, b: number) { - return a + b; - } - expect(serializeFunction(sum)).toBe('sum:function sum(a, b) {return a + b;}'); - }); - - it('should serialize arrow functions as name:source', () => { - const sum = (a: number, b: number) => a + b; - expect(serializeFunction(sum)).toBe('sum:(a, b) => a + b'); - }); - - it('should collapse newlines and surrounding whitespace in the source', () => { - function withBranches(a: number) { - if (a > 0) { - return a; - } - return -a; - } - expect(serializeFunction(withBranches)).toBe( - 'withBranches:function withBranches(a) {if (a > 0) {return a;};return -a;}' - ); - }); - - it('should serialize classes as name:source', () => { - class Foo { - x = 1; - } - expect(serializeFunction(Foo)).toBe('Foo:class Foo {x = 1;}'); - }); - - it('should serialize native functions as name:[native]', () => { - expect(serializeFunction(Math.max)).toBe('max:[native]'); - expect(serializeFunction(Array)).toBe('Array:[native]'); - expect(serializeFunction(parseInt)).toBe('parseInt:[native]'); - }); - - it('should serialize bound functions as name:[native]', () => { - function original(a: number) { - return a; - } - expect(serializeFunction(original.bind(null))).toBe('bound original:[native]'); - }); - - it('should distinguish functions with identical sources by name', () => { - const first = (a: number) => a; - const second = (a: number) => a; - expect(serializeFunction(first)).toBe('first:(a) => a'); - expect(serializeFunction(second)).toBe('second:(a) => a'); - }); - - it('should serialize anonymous functions with an empty name', () => { - expect(serializeFunction([(a: number) => a][0])).toBe(':(a) => a'); - }); -}); diff --git a/src/util/serialize/serializeFunction.ts b/src/util/serialize/serializeFunction.ts deleted file mode 100644 index 4d4c0629e..000000000 --- a/src/util/serialize/serializeFunction.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Serializes a function into a string of the form `name:source`. - * - * Native functions have no meaningful source, so they are serialized as - * `name:[native]`. The whitespace inside `{ [native code] }` differs between - * engines, so it is matched loosely to keep the output platform independent. For other functions, newlines and their surrounding - * whitespace are collapsed so that formatting differences do not change - * the output. - * - * @param value - The function to serialize. - * @returns The serialized string. - * - * @example - * function sum(a, b) { - * return a + b; - * } - * serializeFunction(sum); // "sum:function sum(a, b) {return a + b;}" - * serializeFunction(Math.max); // "max:[native]" - */ -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -export function serializeFunction(value: Function): string { - const source = Function.prototype.toString.call(value); - - if (/\{\s*\[native code\]\s*\}$/.test(source)) { - return `${value.name}:[native]`; - } - - return `${value.name}:${source.replace(/\s*\n\s*/g, '')}`; -} diff --git a/src/util/serialize/serializeNumber.spec.ts b/src/util/serialize/serializeNumber.spec.ts deleted file mode 100644 index 9d21a712d..000000000 --- a/src/util/serialize/serializeNumber.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { serializeNumber } from './serializeNumber'; - -describe('serializeNumber', () => { - it('should serialize integers', () => { - expect(serializeNumber(0)).toBe('0'); - expect(serializeNumber(100)).toBe('100'); - expect(serializeNumber(-100)).toBe('-100'); - }); - - it('should serialize -0 as 0', () => { - expect(serializeNumber(-0)).toBe('0'); - }); - - it('should serialize floating point numbers', () => { - expect(serializeNumber(1.5)).toBe('1.5'); - expect(serializeNumber(Number.EPSILON)).toBe('2.220446049250313e-16'); - }); - - it('should serialize NaN and Infinity', () => { - expect(serializeNumber(NaN)).toBe('NaN'); - expect(serializeNumber(Infinity)).toBe('Infinity'); - expect(serializeNumber(-Infinity)).toBe('-Infinity'); - }); -}); diff --git a/src/util/serialize/serializeNumber.ts b/src/util/serialize/serializeNumber.ts deleted file mode 100644 index 06c548f03..000000000 --- a/src/util/serialize/serializeNumber.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Serializes a number into a string. - * - * `-0` is serialized as `0`, and `NaN` and `Infinity` are serialized - * as their string representations. - * - * @param value - The number to serialize. - * @returns The serialized string. - * - * @example - * serializeNumber(1); // "1" - * serializeNumber(-0); // "0" - * serializeNumber(NaN); // "NaN" - * serializeNumber(Infinity); // "Infinity" - */ -export function serializeNumber(value: number): string { - return String(value); -} diff --git a/src/util/serialize/serializeObject.spec.ts b/src/util/serialize/serializeObject.spec.ts deleted file mode 100644 index a143ecbdd..000000000 --- a/src/util/serialize/serializeObject.spec.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { serializeObject } from './serializeObject'; - -function serialize(value: object | null): string { - return serializeObject(value, new Map()); -} - -describe('serializeObject', () => { - it('should serialize null', () => { - expect(serialize(null)).toBe('null'); - }); - - describe('arrays', () => { - it('should keep the order of array elements', () => { - expect(serialize([1, 2, 'x', 3])).toBe("[1,2,'x',3]"); - expect(serialize([2, 3, 'x', 1])).toBe("[2,3,'x',1]"); - }); - - it('should serialize empty arrays', () => { - expect(serialize([])).toBe('[]'); - }); - - it('should serialize holes as undefined', () => { - // eslint-disable-next-line no-sparse-arrays - expect(serialize([1, , 3])).toBe('[1,undefined,3]'); - }); - - it('should ignore extra properties on arrays', () => { - const array: number[] & { x?: number } = [1]; - array.x = 2; - expect(serialize(array)).toBe('[1]'); - }); - }); - - describe('Date', () => { - it('should serialize dates in ISO format', () => { - expect(serialize(new Date(0))).toBe("Date('1970-01-01T00:00:00.000Z')"); - }); - - it('should serialize invalid dates as Date(null)', () => { - expect(serialize(new Date(NaN))).toBe('Date(null)'); - }); - }); - - describe('RegExp', () => { - it('should serialize regular expressions with flags', () => { - expect(serialize(/.*/)).toBe('RegExp(/.*/)'); - expect(serialize(/ab+c/gi)).toBe('RegExp(/ab+c/gi)'); - }); - }); - - describe('Set', () => { - it('should sort Set values', () => { - expect(serialize(new Set([1, 2, 3]))).toBe('Set[1,2,3]'); - expect(serialize(new Set([2, 3, 1]))).toBe('Set[1,2,3]'); - }); - - it('should sort object values by their serialized form', () => { - expect(serialize(new Set([{ b: 1 }, { a: 1 }]))).toBe("Set[{'a':1},{'b':1}]"); - }); - - it('should sort mixed values', () => { - // `Array.prototype.sort` always places undefined elements last, - // without consulting the comparator. - expect(serialize(new Set([3, 'a', { b: 1 }, null, undefined]))).toBe("Set['a',3,null,{'b':1},undefined]"); - }); - }); - - describe('Map', () => { - it('should sort entries by key and quote string keys', () => { - const map = new Map([ - [1, 4], - [2, 3], - ['z', 2], - ['a', '1'], - [{ x: 42 }, '3'], - ]); - - expect(serialize(map)).toBe("Map{'a':'1','z':2,1:4,2:3,{'x':42}:'3'}"); - }); - - it('should serialize empty maps', () => { - expect(serialize(new Map())).toBe('Map{}'); - }); - }); - - describe('typed arrays', () => { - it('should serialize typed arrays with their type name', () => { - expect(serialize(new Int8Array([1, 2, 3]))).toBe('Int8Array[1,2,3]'); - expect(serialize(new Uint8Array([1, 2, 3]))).toBe('Uint8Array[1,2,3]'); - expect(serialize(new Uint8ClampedArray([1, 2, 3]))).toBe('Uint8ClampedArray[1,2,3]'); - expect(serialize(new Int16Array([1, 2, 3]))).toBe('Int16Array[1,2,3]'); - expect(serialize(new Uint16Array([1, 2, 3]))).toBe('Uint16Array[1,2,3]'); - expect(serialize(new Int32Array([1, 2, 3]))).toBe('Int32Array[1,2,3]'); - expect(serialize(new Uint32Array([1, 2, 3]))).toBe('Uint32Array[1,2,3]'); - expect(serialize(new Float32Array([1, 2, 3]))).toBe('Float32Array[1,2,3]'); - expect(serialize(new Float64Array([1, 2, 3]))).toBe('Float64Array[1,2,3]'); - }); - - it('should serialize bigint typed arrays with n suffixes', () => { - expect(serialize(new BigInt64Array([1n, 2n, 3n]))).toBe('BigInt64Array[1n,2n,3n]'); - expect(serialize(new BigUint64Array([1n, 2n, 3n]))).toBe('BigUint64Array[1n,2n,3n]'); - expect(serialize(new BigInt64Array([]))).toBe('BigInt64Array[]'); - expect(serialize(new BigUint64Array([]))).toBe('BigUint64Array[]'); - }); - - it('should serialize Buffer as Uint8Array', () => { - expect(serialize(Buffer.from('hello'))).toBe('Uint8Array[104,101,108,108,111]'); - }); - }); - - describe('ArrayBuffer', () => { - it('should serialize the underlying bytes', () => { - expect(serialize(new Uint8Array([1, 2, 3]).buffer)).toBe('ArrayBuffer[1,2,3]'); - expect(serialize(new ArrayBuffer(2))).toBe('ArrayBuffer[0,0]'); - }); - }); - - describe('Error', () => { - it('should serialize errors with their name and message', () => { - expect(serialize(new Error('test'))).toBe("Error(Error: 'test')"); - expect(serialize(new TypeError('boom'))).toBe("Error(TypeError: 'boom')"); - }); - - it('should keep the name and message boundary unambiguous', () => { - const first = new Error('b: c'); - first.name = 'a'; - const second = new Error('c'); - second.name = 'a: b'; - - expect(serialize(first)).toBe("Error(a: 'b: c')"); - expect(serialize(second)).toBe("Error(a: b: 'c')"); - expect(serialize(new Error(''))).toBe("Error(Error: '')"); - }); - }); - - describe('class instances', () => { - it('should serialize class instances with the class name', () => { - class Test { - x = 1; - } - expect(serialize(new Test())).toBe("Test{'x':1}"); - }); - - it('should serialize class instances even when the class name exists in globalThis', () => { - class CustomEvent { - y = 1; - } - expect(serialize(new CustomEvent())).toBe("CustomEvent{'y':1}"); - }); - - it('should use toJSON when available', () => { - class TestArray { - toJSON() { - return [1, 2, 3]; - } - } - expect(serialize(new TestArray())).toBe('TestArray[1,2,3]'); - - class TestObject { - toJSON() { - return { a: 1, b: 2 }; - } - } - expect(serialize(new TestObject())).toBe("TestObject{'a':1,'b':2}"); - }); - - it('should wrap primitive toJSON results in parentheses', () => { - class TestNull { - toJSON() { - return null; - } - } - expect(serialize(new TestNull())).toBe('TestNull(null)'); - - class TestString { - toJSON() { - return 'value'; - } - } - expect(serialize(new TestString())).toBe("TestString('value')"); - }); - - it('should serialize toJSON on plain objects', () => { - // A plain object with toJSON is still serialized as a plain object. - expect(serialize({ toJSON: () => 42, a: 1 })).toBe("{'a':1,'toJSON':toJSON:() => 42}"); - }); - - it('should not loop forever when toJSON returns this', () => { - class Test { - toJSON() { - return this; - } - } - expect(serialize(new Test())).toBe('Test#ref0'); - }); - }); - - describe('objects with entries()', () => { - it('should serialize URLSearchParams', () => { - const params = new URLSearchParams(); - params.set('foo', 'bar'); - params.set('bar', 'baz'); - expect(serialize(params)).toBe("URLSearchParams{'bar':'baz','foo':'bar'}"); - }); - - it('should serialize FormData', () => { - const form = new FormData(); - form.set('foo', 'bar'); - form.set('bar', 'baz'); - expect(serialize(form)).toBe("FormData{'bar':'baz','foo':'bar'}"); - }); - - it('should serialize Headers', () => { - const headers = new Headers(); - headers.set('b', '2'); - headers.set('a', '1'); - expect(serialize(headers)).toBe("Headers{'a':'1','b':'2'}"); - }); - }); - - describe('unsupported objects', () => { - it('should throw a TypeError for objects that cannot be serialized', () => { - expect(() => serialize(new Blob(['x']))).toThrow(new TypeError('Cannot serialize Blob')); - expect(() => serialize(new WeakMap())).toThrow(new TypeError('Cannot serialize WeakMap')); - expect(() => serialize(new WeakSet())).toThrow(new TypeError('Cannot serialize WeakSet')); - expect(() => serialize(new WeakRef({}))).toThrow(new TypeError('Cannot serialize WeakRef')); - expect(() => serialize(new Promise(() => {}))).toThrow(new TypeError('Cannot serialize Promise')); - expect(() => serialize(new DataView(new ArrayBuffer(4)))).toThrow(new TypeError('Cannot serialize DataView')); - }); - - it('should throw a TypeError for boxed primitives', () => { - // eslint-disable-next-line no-new-wrappers - expect(() => serialize(new String('x'))).toThrow(new TypeError('Cannot serialize String')); - // eslint-disable-next-line no-new-wrappers - expect(() => serialize(new Number(1))).toThrow(new TypeError('Cannot serialize Number')); - // eslint-disable-next-line no-new-wrappers - expect(() => serialize(new Boolean(true))).toThrow(new TypeError('Cannot serialize Boolean')); - }); - - it('should throw a TypeError for generators', () => { - function* generator() { - yield 1; - } - expect(() => serialize(generator())).toThrow(new TypeError('Cannot serialize Generator')); - - async function* asyncGenerator() { - yield 1; - } - expect(() => serialize(asyncGenerator())).toThrow(new TypeError('Cannot serialize AsyncGenerator')); - }); - }); - - describe('circular references', () => { - it('should serialize a self-referencing object', () => { - const object: Record = {}; - object.foo = object; - expect(serialize(object)).toBe("{'foo':#ref0}"); - }); - - it('should serialize circular references in nested objects', () => { - const object: Record = { a: { b: {} } }; - object.a.b = object; - expect(serialize(object)).toBe("{'a':{'b':#ref0}}"); - }); - - it('should serialize circular references in arrays', () => { - const array: unknown[] = []; - array.push(array); - expect(serialize(array)).toBe('[#ref0]'); - }); - - it('should number back-references by visit order', () => { - const object: Record = { a: { b: { c: {} } } }; - object.a.b.c = object.a; - expect(serialize(object)).toBe("{'a':{'b':{'c':#ref1}}}"); - - const deep: Record = { x: { y: { z: {} } } }; - deep.x.y.z.ref1 = deep.x; - deep.x.y.z.ref2 = deep; - expect(serialize(deep)).toBe("{'x':{'y':{'z':{'ref1':#ref1,'ref2':#ref0}}}}"); - }); - - it('should serialize circular references in Map and Set', () => { - const map = new Map(); - map.set('key', map); - expect(serialize(map)).toBe("Map{'key':#ref0}"); - - const mapAsKey = new Map(); - mapAsKey.set(mapAsKey, 'value'); - expect(serialize(mapAsKey)).toBe("Map{#ref0:'value'}"); - - const set = new Set(); - set.add(set); - expect(serialize(set)).toBe('Set[#ref0]'); - }); - - it('should serialize multiple references to the same object', () => { - const shared: Record = {}; - shared.ref = shared; - expect(serialize({ a: shared, b: shared })).toBe("{'a':{'ref':#ref1},'b':{'ref':#ref1}}"); - }); - - it('should reuse the serialized string of completed objects', () => { - const shared = { _: 1, b: { _: 2, c: { _: 3 } } }; - const references = { a: shared, b: shared }; - const copies = { - a: { _: 1, b: { _: 2, c: { _: 3 } } }, - b: { _: 1, b: { _: 2, c: { _: 3 } } }, - }; - - expect(serialize(references)).toBe(serialize(copies)); - }); - }); -}); diff --git a/src/util/serialize/serializeObject.ts b/src/util/serialize/serializeObject.ts deleted file mode 100644 index 03babdd8c..000000000 --- a/src/util/serialize/serializeObject.ts +++ /dev/null @@ -1,155 +0,0 @@ -// oxlint-disable-next-line import/no-cycle -- serializeValue and serializeObject recurse into each other; the imports are only used at call time -import { compareValues } from './compareValues.ts'; -// oxlint-disable-next-line import/no-cycle -- serializeValue and serializeObject recurse into each other; the imports are only used at call time -import { serializeValue } from './serialize.ts'; -// oxlint-disable-next-line import/no-cycle -- serializeValue and serializeObject recurse into each other; the imports are only used at call time -import { serializePlainObject } from './serializePlainObject.ts'; -import { serializeString } from './serializeString.ts'; -import { isArrayBuffer } from '../../predicate/isArrayBuffer.ts'; -import { isDate } from '../../predicate/isDate.ts'; -import { isError } from '../../predicate/isError.ts'; -import { isMap } from '../../predicate/isMap.ts'; -import { isPlainObject } from '../../predicate/isPlainObject.ts'; -import { isRegExp } from '../../predicate/isRegExp.ts'; -import { isSet } from '../../predicate/isSet.ts'; -import { isTypedArray } from '../../predicate/isTypedArray.ts'; - -/** - * Serializes an object, handling circular references and repeated references. - * - * The first time an object is visited, it is registered as `#ref{n}` where `n` - * is the visit order; if the object is reached again while it is still being - * serialized, the back-reference is emitted instead. Once completed, the - * serialized string is memoized so that repeated references serialize - * in constant time. - * - * @param value - The object to serialize, or `null`. - * @param refs - The circular reference context shared across one serialization. - * @returns The serialized string. - * @throws {TypeError} If the object cannot be serialized. - */ -export function serializeObject(value: object | null, refs: Map): string { - if (value === null) { - return 'null'; - } - - const cached = refs.get(value); - - if (cached !== undefined) { - return cached; - } - - refs.set(value, `#ref${refs.size}`); - - const result = serializeObjectImpl(value, refs); - - refs.set(value, result); - - return result; -} - -function serializeObjectImpl(value: object, refs: Map): string { - if (Array.isArray(value)) { - return serializeArray(value, refs); - } - - if (isPlainObject(value)) { - return serializePlainObject(value, refs); - } - - if (isDate(value)) { - return Number.isNaN(value.getTime()) ? 'Date(null)' : `Date(${serializeString(value.toISOString())})`; - } - - if (isRegExp(value)) { - return `RegExp(${value.toString()})`; - } - - if (isSet(value)) { - const values = Array.from(value).sort((a, b) => compareValues(a, b, refs)); - return `Set${serializeArray(values, refs)}`; - } - - if (isMap(value)) { - return serializeEntries('Map', value.entries(), refs); - } - - if (isTypedArray(value)) { - const name = (value as unknown as Record)[Symbol.toStringTag]; - - if (name === 'BigInt64Array' || name === 'BigUint64Array') { - return `${name}[${value.join('n,')}${value.length > 0 ? 'n' : ''}]`; - } - - return `${name}[${value.join(',')}]`; - } - - if (isArrayBuffer(value)) { - return `ArrayBuffer[${new Uint8Array(value).join(',')}]`; - } - - if (isError(value)) { - return `Error(${value.name}: ${serializeString(value.message)})`; - } - - const tag = Object.prototype.toString.call(value).slice(8, -1); - - if (tag === 'Object') { - return serializeClassInstance(value, refs); - } - - if (typeof (value as { entries?: unknown }).entries === 'function') { - return serializeEntries(tag, (value as { entries: () => Iterable<[unknown, unknown]> }).entries(), refs); - } - - throw new TypeError(`Cannot serialize ${tag}`); -} - -function serializeArray(array: readonly unknown[], refs: Map): string { - let result = '['; - - for (let i = 0; i < array.length; i++) { - if (i > 0) { - result += ','; - } - - result += serializeValue(array[i], refs); - } - - return result + ']'; -} - -function serializeEntries(tag: string, entries: Iterable<[unknown, unknown]>, refs: Map): string { - const sortedEntries = Array.from(entries).sort((a, b) => compareValues(a[0], b[0], refs)); - - let result = `${tag}{`; - - for (let i = 0; i < sortedEntries.length; i++) { - const [key, value] = sortedEntries[i]; - - if (i > 0) { - result += ','; - } - - result += `${serializeValue(key, refs)}:${serializeValue(value, refs)}`; - } - - return result + '}'; -} - -function serializeClassInstance(value: object, refs: Map): string { - const constructor = value.constructor; - const name = constructor === Object || constructor === undefined ? '' : constructor.name; - - if ('toJSON' in value && typeof value.toJSON === 'function') { - const json = value.toJSON(); - - if (json !== null && typeof json === 'object') { - return name + serializeObject(json, refs); - } - - return `${name}(${serializeValue(json, refs)})`; - } - - return name + serializePlainObject(value, refs); -} diff --git a/src/util/serialize/serializePlainObject.spec.ts b/src/util/serialize/serializePlainObject.spec.ts deleted file mode 100644 index c5145f8f8..000000000 --- a/src/util/serialize/serializePlainObject.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { serializePlainObject } from './serializePlainObject'; - -describe('serializePlainObject', () => { - it("should serialize plain objects as {'key':value}", () => { - expect(serializePlainObject({ a: 1, b: 2 }, new Map())).toBe("{'a':1,'b':2}"); - expect(serializePlainObject({}, new Map())).toBe('{}'); - }); - - it('should sort keys by code unit', () => { - expect(serializePlainObject({ b: 2, a: 1 }, new Map())).toBe("{'a':1,'b':2}"); - expect(serializePlainObject({ 10: 1, 9: 2, 2: 3 }, new Map())).toBe("{'10':1,'2':3,'9':2}"); - }); - - it('should serialize nested values', () => { - expect(serializePlainObject({ a: { b: 'c' }, d: [1, 2] }, new Map())).toBe("{'a':{'b':'c'},'d':[1,2]}"); - }); - - it('should ignore symbol keys', () => { - expect(serializePlainObject({ [Symbol('s')]: 123 }, new Map())).toBe('{}'); - }); - - it('should ignore non-enumerable properties', () => { - const object = {}; - Object.defineProperty(object, 'x', { value: 1, enumerable: false }); - expect(serializePlainObject(object, new Map())).toBe('{}'); - }); - - it('should ignore inherited properties', () => { - const object = Object.create({ inherited: 1 }); - object.own = 2; - expect(serializePlainObject(object, new Map())).toBe("{'own':2}"); - }); - - it('should serialize objects with a null prototype', () => { - const object = Object.create(null); - object.a = 1; - expect(serializePlainObject(object, new Map())).toBe("{'a':1}"); - }); - - it('should invoke getters', () => { - const object = { - get a() { - return 1; - }, - }; - expect(serializePlainObject(object, new Map())).toBe("{'a':1}"); - }); -}); diff --git a/src/util/serialize/serializePlainObject.ts b/src/util/serialize/serializePlainObject.ts deleted file mode 100644 index 39939bb7c..000000000 --- a/src/util/serialize/serializePlainObject.ts +++ /dev/null @@ -1,37 +0,0 @@ -// oxlint-disable-next-line import/no-cycle -- serializeValue and serializeObject recurse into each other; the imports are only used at call time -import { serializeValue } from './serialize.ts'; -import { serializeString } from './serializeString.ts'; - -/** - * Serializes the own enumerable string-keyed properties of an object - * into a `{'key':value}` string. - * - * Keys are sorted by code unit so that the output does not depend on - * property insertion order, and always quoted so that a key containing - * `:` or `,` cannot be confused with the surrounding structure. Symbol - * keys and non-enumerable properties are ignored. - * - * @param object - The object to serialize. - * @param refs - The circular reference context shared with the surrounding serialization. - * @returns The serialized string. - * - * @example - * serializePlainObject({ b: 2, a: 1 }, new Map()); // "{'a':1,'b':2}" - */ -export function serializePlainObject(object: object, refs: Map): string { - const keys = Object.keys(object).sort(); - - let result = '{'; - - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - - if (i > 0) { - result += ','; - } - - result += `${serializeString(key)}:${serializeValue((object as Record)[key], refs)}`; - } - - return result + '}'; -} diff --git a/src/util/serialize/serializeString.spec.ts b/src/util/serialize/serializeString.spec.ts deleted file mode 100644 index 717a70632..000000000 --- a/src/util/serialize/serializeString.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { serializeString } from './serializeString'; - -describe('serializeString', () => { - it('should wrap the string in single quotes', () => { - expect(serializeString('abc')).toBe("'abc'"); - expect(serializeString('')).toBe("''"); - }); - - it('should keep unicode characters as-is', () => { - expect(serializeString('hello world 😎')).toBe("'hello world 😎'"); - expect(serializeString('ν•œκΈ€')).toBe("'ν•œκΈ€'"); - }); - - it('should not escape quotes in the string', () => { - expect(serializeString("a'b")).toBe("'a'b'"); - }); -}); diff --git a/src/util/serialize/serializeString.ts b/src/util/serialize/serializeString.ts deleted file mode 100644 index fed673f89..000000000 --- a/src/util/serialize/serializeString.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Serializes a string into a single-quoted string literal. - * - * The string is not escaped; the output is intended for hashing and - * change detection, not for parsing or re-evaluation. - * - * @param value - The string to serialize. - * @returns The serialized string. - * - * @example - * serializeString('abc'); // "'abc'" - */ -export function serializeString(value: string): string { - return `'${value}'`; -} diff --git a/src/util/serialize/serializeSymbol.spec.ts b/src/util/serialize/serializeSymbol.spec.ts deleted file mode 100644 index 441687658..000000000 --- a/src/util/serialize/serializeSymbol.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { serializeSymbol } from './serializeSymbol'; - -describe('serializeSymbol', () => { - it('should serialize symbols with a quoted description', () => { - expect(serializeSymbol(Symbol('test'))).toBe("Symbol('test')"); - }); - - it('should distinguish no description from an empty description', () => { - expect(serializeSymbol(Symbol())).toBe('Symbol()'); - expect(serializeSymbol(Symbol(''))).toBe("Symbol('')"); - }); - - it('should serialize registered symbols the same as unregistered ones', () => { - expect(serializeSymbol(Symbol.for('test'))).toBe("Symbol('test')"); - }); - - it('should serialize well-known symbols', () => { - expect(serializeSymbol(Symbol.iterator)).toBe("Symbol('Symbol.iterator')"); - }); -}); diff --git a/src/util/serialize/serializeSymbol.ts b/src/util/serialize/serializeSymbol.ts deleted file mode 100644 index a3b470b2b..000000000 --- a/src/util/serialize/serializeSymbol.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { serializeString } from './serializeString.ts'; - -/** - * Serializes a symbol into a string using its description. - * - * The description is quoted like any other string, so a symbol without a - * description (`Symbol()`) is distinguishable from one with an empty - * description (`Symbol('')`). Note that two different symbols with the same - * description still serialize to the same string, since a symbol's identity - * cannot be captured in a string. - * - * @param value - The symbol to serialize. - * @returns The serialized string. - * - * @example - * serializeSymbol(Symbol('test')); // "Symbol('test')" - * serializeSymbol(Symbol()); // "Symbol()" - */ -export function serializeSymbol(value: symbol): string { - return value.description === undefined ? 'Symbol()' : `Symbol(${serializeString(value.description)})`; -} diff --git a/tests/check-dist.spec.ts b/tests/check-dist.spec.ts index bee122dde..f04157a17 100644 --- a/tests/check-dist.spec.ts +++ b/tests/check-dist.spec.ts @@ -47,14 +47,10 @@ const ENTRYPOINTS = [ // Internal subdirectories are excluded from the package with a `null` export. // These are disjoint from ENTRYPOINTS, which only lists what can be loaded. -const BLOCKED_ENTRYPOINTS = ['./iterator/_internal/*', './util/hash/*', './util/serialize/*']; +const BLOCKED_ENTRYPOINTS = ['./iterator/_internal/*', './util/hash/*']; // One module under each blocked subpath. They exist in `dist`, but must not be resolvable. -const BLOCKED_MODULES = [ - 'es-toolkit/iterator/_internal/iterator', - 'es-toolkit/util/hash/node', - 'es-toolkit/util/serialize/serialize', -]; +const BLOCKED_MODULES = ['es-toolkit/iterator/_internal/iterator', 'es-toolkit/util/hash/node']; /** * Installs the packed `es-toolkit` tarball into a fresh fixture directory, so that tests can load