diff --git a/src/accessDeep.ts b/src/accessDeep.ts index ea98613..05c2ec2 100644 --- a/src/accessDeep.ts +++ b/src/accessDeep.ts @@ -13,6 +13,19 @@ const getNthKey = (value: Map | Set, n: number): any => { }; function validatePath(path: (string | number)[]) { + let suspicious = false; + for (let i = 0; i < path.length; i++) { + const key = path[i]; + if (key === '__proto__' || key === 'prototype' || key === 'constructor') { + suspicious = true; + break; + } + } + + if (!suspicious) { + return; + } + if (includes(path, '__proto__')) { throw new Error('__proto__ is not allowed as a property'); } diff --git a/src/custom-transformer-registry.ts b/src/custom-transformer-registry.ts index ebd3db6..83fff62 100644 --- a/src/custom-transformer-registry.ts +++ b/src/custom-transformer-registry.ts @@ -1,5 +1,4 @@ import { JSONValue } from './types.js'; -import { find } from './util.js'; export interface CustomTransfomer { name: string; @@ -10,15 +9,26 @@ export interface CustomTransfomer { export class CustomTransformerRegistry { private transfomers: Record> = {}; + private ordered: CustomTransfomer[] = []; register(transformer: CustomTransfomer) { this.transfomers[transformer.name] = transformer; + this.ordered = Object.values(this.transfomers); + } + + get size(): number { + return this.ordered.length; } findApplicable(v: T) { - return find(this.transfomers, transformer => - transformer.isApplicable(v) - ) as CustomTransfomer | undefined; + const transformers = this.ordered; + for (let i = 0; i < transformers.length; i++) { + if (transformers[i].isApplicable(v)) { + return transformers[i] as CustomTransfomer; + } + } + + return undefined; } findByName(name: string) { diff --git a/src/date.test.ts b/src/date.test.ts new file mode 100644 index 0000000..399ed9b --- /dev/null +++ b/src/date.test.ts @@ -0,0 +1,71 @@ +import { parseISOString, toISOString } from './date.js'; + +import { expect, test } from 'vitest'; + +const sameTime = (a: Date, b: Date) => + Object.is(a.getTime(), b.getTime()); + +test('toISOString matches Date#toISOString', () => { + const times = [ + 0, + -1, + Date.UTC(999, 11, 31, 23, 59, 59, 999), + Date.UTC(1000, 0, 1), + Date.UTC(2024, 1, 29, 12, 5, 9, 7), + Date.UTC(9999, 11, 31, 23, 59, 59, 999), + Date.UTC(10000, 0, 1), + -62198755200000, + 8.64e15, + -8.64e15, + ]; + for (let i = 0; i < 20000; i++) { + times.push(Math.floor((Math.random() - 0.4) * 4e14)); + } + + for (const time of times) { + const date = new Date(time); + expect(toISOString(date)).toBe(date.toISOString()); + } +}); + +test('parseISOString matches new Date(string)', () => { + const inputs = [ + '2024-02-29T12:00:00.000Z', + '2023-02-29T12:00:00.000Z', + '2023-04-31T00:00:00.000Z', + '2023-00-10T00:00:00.000Z', + '2023-13-10T00:00:00.000Z', + '2023-01-00T00:00:00.000Z', + '2023-01-32T00:00:00.000Z', + '2023-01-01T24:00:00.000Z', + '2023-01-01T23:60:00.000Z', + '2023-01-01T23:59:60.000Z', + '0099-01-01T00:00:00.000Z', + '0999-12-31T23:59:59.999Z', + '1000-01-01T00:00:00.000Z', + '9999-12-31T23:59:59.999Z', + '+010000-01-01T00:00:00.000Z', + '-000001-01-01T00:00:00.000Z', + '2023-01-01T00:00:00.000+01:00', + '2023-01-01 00:00:00.000Z', + '2023-01-0aT00:00:00.000Z', + '2023-01-01T00:00:00.0-0Z', + 'not a date', + '', + ]; + for (let i = 0; i < 20000; i++) { + inputs.push(new Date(Math.floor((Math.random() - 0.4) * 4e14)).toISOString()); + } + + const chars = '0123456789-T:.Z+ x'; + for (let i = 0; i < 20000; i++) { + const base = inputs[Math.floor(Math.random() * inputs.length)].split(''); + base[Math.floor(Math.random() * base.length)] = + chars[Math.floor(Math.random() * chars.length)]; + inputs.push(base.join('')); + } + + for (const input of inputs) { + expect(sameTime(parseISOString(input), new Date(input))).toBe(true); + } +}); diff --git a/src/date.ts b/src/date.ts new file mode 100644 index 0000000..4130a96 --- /dev/null +++ b/src/date.ts @@ -0,0 +1,87 @@ +const pad2 = (n: number) => (n < 10 ? '0' + n : '' + n); +const pad3 = (n: number) => (n < 10 ? '00' + n : n < 100 ? '0' + n : '' + n); + +// Same output as Date#toISOString, which is several times slower in V8. +export function toISOString(date: Date): string { + const year = date.getUTCFullYear(); + if (year < 1000 || year > 9999) { + return date.toISOString(); + } + + return ( + year + + '-' + + pad2(date.getUTCMonth() + 1) + + '-' + + pad2(date.getUTCDate()) + + 'T' + + pad2(date.getUTCHours()) + + ':' + + pad2(date.getUTCMinutes()) + + ':' + + pad2(date.getUTCSeconds()) + + '.' + + pad3(date.getUTCMilliseconds()) + + 'Z' + ); +} + +const digits = (s: string, from: number, count: number): number => { + let n = 0; + for (let i = from; i < from + count; i++) { + const d = s.charCodeAt(i) - 48; + if (d < 0 || d > 9) { + return -1; + } + + n = n * 10 + d; + } + + return n; +}; + +// Same result as `new Date(value)`. Only the exact toISOString shape with in-range fields takes the +// fast path; everything else goes to the native parser. +export function parseISOString(value: string): Date { + if ( + typeof value === 'string' && + value.length === 24 && + value.charCodeAt(4) === 45 && + value.charCodeAt(7) === 45 && + value.charCodeAt(10) === 84 && + value.charCodeAt(13) === 58 && + value.charCodeAt(16) === 58 && + value.charCodeAt(19) === 46 && + value.charCodeAt(23) === 90 + ) { + const year = digits(value, 0, 4); + const month = digits(value, 5, 2); + const day = digits(value, 8, 2); + const hours = digits(value, 11, 2); + const minutes = digits(value, 14, 2); + const seconds = digits(value, 17, 2); + const ms = digits(value, 20, 3); + if ( + year >= 1000 && + month >= 1 && + month <= 12 && + day >= 1 && + hours >= 0 && + hours <= 23 && + minutes >= 0 && + minutes <= 59 && + seconds >= 0 && + seconds <= 59 && + ms >= 0 + ) { + const date = new Date( + Date.UTC(year, month - 1, day, hours, minutes, seconds, ms) + ); + if (date.getUTCDate() === day) { + return date; + } + } + } + + return new Date(value); +} diff --git a/src/index.ts b/src/index.ts index 9a11ad7..3704788 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,9 +9,9 @@ import { applyReferentialEqualityAnnotations, applyValueAnnotations, generateReferentialEqualityAnnotations, - walker, + walkRoot, } from './plainer.js'; -import { copy } from 'copy-anything'; +import { copy } from './util.js'; export default class SuperJSON { /** @@ -31,8 +31,7 @@ export default class SuperJSON { } serialize(object: SuperJSONValue): SuperJSONResult { - const identities = new Map(); - const output = walker(object, identities, this, this.dedupe); + const output = walkRoot(object, this, this.dedupe); const res: SuperJSONResult = { json: output.transformedValue, }; @@ -45,7 +44,7 @@ export default class SuperJSON { } const equalityAnnotations = generateReferentialEqualityAnnotations( - identities, + output.identities, this.dedupe ); if (equalityAnnotations) { diff --git a/src/is.ts b/src/is.ts index a2d7504..0812ff4 100644 --- a/src/is.ts +++ b/src/is.ts @@ -1,6 +1,3 @@ -const getType = (payload: any): string => - Object.prototype.toString.call(payload).slice(8, -1); - export const isUndefined = (payload: any): payload is undefined => typeof payload === 'undefined'; @@ -11,9 +8,9 @@ export const isPlainObject = ( ): payload is { [key: string]: any } => { if (typeof payload !== 'object' || payload === null) return false; if (payload === Object.prototype) return false; - if (Object.getPrototypeOf(payload) === null) return true; + const prototype = Object.getPrototypeOf(payload); - return Object.getPrototypeOf(payload) === Object.prototype; + return prototype === null || prototype === Object.prototype; }; export const isEmptyObject = (payload: any): payload is {} => @@ -40,8 +37,14 @@ export const isMap = (payload: any): payload is Map => export const isSet = (payload: any): payload is Set => payload instanceof Set; +// Object.prototype.toString reports an object as "Symbol" only through its @@toStringTag. +const hasSymbolTag = (payload: any): boolean => + ((typeof payload === 'object' && payload !== null) || + typeof payload === 'function') && + payload[Symbol.toStringTag] === 'Symbol'; + export const isSymbol = (payload: any): payload is symbol => - getType(payload) === 'Symbol'; + typeof payload === 'symbol' || hasSymbolTag(payload); export const isDate = (payload: any): payload is Date => payload instanceof Date && !isNaN(payload.valueOf()); @@ -54,13 +57,23 @@ export const isNaNValue = (payload: any): payload is typeof NaN => export const isPrimitive = ( payload: any -): payload is boolean | null | undefined | number | string | symbol => - isBoolean(payload) || - isNull(payload) || - isUndefined(payload) || - isNumber(payload) || - isString(payload) || - isSymbol(payload); +): payload is boolean | null | undefined | number | string | symbol => { + switch (typeof payload) { + case 'boolean': + case 'string': + case 'undefined': + case 'symbol': + return true; + case 'number': + return !isNaN(payload); + case 'object': + return payload === null || hasSymbolTag(payload); + case 'function': + return hasSymbolTag(payload); + default: + return false; + } +}; export const isBigint = (payload: any): payload is bigint => typeof payload === 'bigint'; diff --git a/src/pathstringifier.ts b/src/pathstringifier.ts index efbf867..7d62f16 100644 --- a/src/pathstringifier.ts +++ b/src/pathstringifier.ts @@ -2,7 +2,9 @@ export type StringifiedPath = string; type Path = string[]; export const escapeKey = (key: string) => - key.replace(/\\/g, '\\\\').replace(/\./g, '\\.'); + key.indexOf('\\') === -1 && key.indexOf('.') === -1 + ? key + : key.replace(/\\/g, '\\\\').replace(/\./g, '\\.'); export const stringifyPath = (path: Path): StringifiedPath => path @@ -10,7 +12,27 @@ export const stringifyPath = (path: Path): StringifiedPath => .map(escapeKey) .join('.'); +// V8's String.prototype.split is several times slower than this on short keys. +const splitOnDots = (string: string): string[] => { + const result: string[] = []; + let start = 0; + for (;;) { + const dot = string.indexOf('.', start); + if (dot === -1) { + result.push(start === 0 ? string : string.slice(start)); + return result; + } + + result.push(string.slice(start, dot)); + start = dot + 1; + } +}; + export const parsePath = (string: StringifiedPath, legacyPaths: boolean) => { + if (typeof string === 'string' && string.indexOf('\\') === -1) { + return splitOnDots(string); + } + const result: string[] = []; let segment = ''; diff --git a/src/plainer.ts b/src/plainer.ts index 8e9059f..62c4e20 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -10,11 +10,12 @@ import { import { escapeKey, stringifyPath } from './pathstringifier.js'; import { isInstanceOfRegisteredClass, + transformRegisteredValue, transformValue, TypeAnnotation, untransformValue, } from './transformer.js'; -import { includes, forEach } from './util.js'; +import { forEach } from './util.js'; import { parsePath } from './pathstringifier.js'; import { getDeep, setDeep } from './accessDeep.js'; import SuperJSON from './index.js'; @@ -122,16 +123,6 @@ const isDeep = (object: any, superJson: SuperJSON): boolean => isError(object) || isInstanceOfRegisteredClass(object, superJson); -function addIdentity(object: any, path: any[], identities: Map) { - const existingSet = identities.get(object); - - if (existingSet) { - existingSet.push(path); - } else { - identities.set(object, [path]); - } -} - interface Result { transformedValue: any; annotations?: MinimisedTree; @@ -185,62 +176,167 @@ export function generateReferentialEqualityAnnotations( } } -export const walker = ( - object: any, - identities: Map, - superJson: SuperJSON, - dedupe: boolean, - path: any[] = [], - objectsInThisPath: any[] = [], - seenObjects = new Map() -): Result => { - const primitive = isPrimitive(object); +class PathNode { + /** `key` is null only for the root. */ + constructor(readonly parent: PathNode | null, readonly key: string | null) {} +} - if (!primitive) { - addIdentity(object, path, identities); +/** The first visit of a non-primitive, which is also the path node of its children. */ +class Seen extends PathNode { + more: PathNode[] | undefined = undefined; + done = false; + value: any = undefined; + annotations: Annotations = undefined; +} - const seen = seenObjects.get(object); - if (seen) { - // short-circuit result if we've seen this object before - return dedupe - ? { - transformedValue: null, - } - : seen; +const toPath = (node: PathNode | null): string[] => { + const path: string[] = []; + for (; node; node = node.parent) { + if (node.key !== null) { + path.push(node.key); } } - if (!isDeep(object, superJson)) { - const transformed = transformValue(object, superJson); + return path.reverse(); +}; + +const pathsOf = (seen: Seen): string[][] => { + const paths = [toPath(seen)]; + seen.more?.forEach(node => paths.push(toPath(node))); + + return paths; +}; + +/** + * The annotation record of a plain container, built lazily so each flattened key is joined once. + * Keys are kept as an object would order them: array indices ascending, then insertion order. + */ +class PendingAnnotations { + /** Pairs: escaped array-index key, tree. */ + readonly indices: any[] = []; + /** Pairs: escaped key and tree, or `key.` prefix and a nested PendingAnnotations. */ + readonly rest: any[] = []; +} + +const isArrayIndex = (key: string): boolean => { + const length = key.length; + if (length === 0 || length > 10) { + return false; + } + + for (let i = 0; i < length; i++) { + const code = key.charCodeAt(i); + if (code < 48 || code > 57) { + return false; + } + } + + if (key.charCodeAt(0) === 48) { + return length === 1; + } + + return length < 10 || +key < 4294967295; +}; + +function materialize( + pending: PendingAnnotations, + prefix: string, + out: Record> +): Record> { + const { indices, rest } = pending; + for (let i = 0; i < indices.length; i += 2) { + out[prefix + indices[i]] = indices[i + 1]; + } + + for (let i = 0; i < rest.length; i += 2) { + const value = rest[i + 1]; + if (value instanceof PendingAnnotations) { + materialize(value, prefix + rest[i], out); + } else { + out[prefix + rest[i]] = value; + } + } + + return out; +} + +const toRecord = ( + annotations: Annotations +): MinimisedTree => + annotations instanceof PendingAnnotations + ? materialize(annotations, '', {}) + : annotations; + +type Annotations = Tree | PendingAnnotations | undefined; + +interface WalkContext { + superJson: SuperJSON; + dedupe: boolean; + /** Every non-primitive in visiting order. `done` is false while its walk is still running. */ + seen: Map; + /** Annotations of the value the last `walk` call returned. */ + annotations: Annotations; +} + +// No built-in rule (Date, Map, typed array...) can match an object with one of these prototypes. +const isPlainContainer = (object: any): boolean => + isPlainObject(object) || + (isArray(object) && Object.getPrototypeOf(object) === Array.prototype); + +function walk( + object: any, + ctx: WalkContext, + parent: PathNode | null, + key: string | null +): any { + const primitive = isPrimitive(object); + let seen: Seen | undefined; - const result: Result = transformed - ? { - transformedValue: transformed.value, - annotations: [transformed.type], + if (!primitive) { + seen = ctx.seen.get(object); + if (seen) { + (seen.more ??= []).push(new PathNode(parent, key)); + if (seen.done) { + if (ctx.dedupe) { + ctx.annotations = undefined; + return null; } - : { - transformedValue: object, - }; - if (!primitive) { - seenObjects.set(object, result); + + ctx.annotations = seen.annotations; + return seen.value; + } + + ctx.annotations = undefined; + return isDeep(object, ctx.superJson) + ? null + : leaf(object, ctx, seen); } - return result; + + seen = new Seen(parent, key); + ctx.seen.set(object, seen); + parent = seen; } - if (includes(objectsInThisPath, object)) { - // prevent circular references - return { - transformedValue: null, - }; + const plainContainer = !primitive && isPlainContainer(object); + if (!plainContainer && !isDeep(object, ctx.superJson)) { + return leaf(object, ctx, seen); } - const transformationResult = transformValue(object, superJson); + if (primitive && key !== null) { + parent = new PathNode(parent, key); + } + + const transformationResult = plainContainer + ? transformRegisteredValue(object, ctx.superJson) + : transformValue(object, ctx.superJson); const transformed = transformationResult?.value ?? object; const transformedValue: any = isArray(transformed) ? [] : {}; - const innerAnnotations: Record> = {}; + let inner: PendingAnnotations | undefined; - forEach(transformed, (value, index) => { + const keys = Object.keys(transformed); + for (let i = 0; i < keys.length; i++) { + const index = keys[i]; if ( index === '__proto__' || index === 'constructor' || @@ -251,43 +347,95 @@ export const walker = ( ); } - const recursiveResult = walker( - value, - identities, - superJson, - dedupe, - [...path, index], - [...objectsInThisPath, object], - seenObjects - ); + transformedValue[index] = walk(transformed[index], ctx, parent, index); + + const childAnnotations = ctx.annotations; + if (childAnnotations !== undefined) { + inner ??= new PendingAnnotations(); + const escaped = escapeKey(index); + if (childAnnotations instanceof PendingAnnotations) { + inner.rest.push(escaped + '.', childAnnotations); + } else if (isArrayIndex(escaped)) { + inner.indices.push(escaped, childAnnotations); + } else { + inner.rest.push(escaped, childAnnotations); + } + } + } - transformedValue[index] = recursiveResult.transformedValue; + const annotations: Annotations = inner + ? transformationResult + ? [transformationResult.type, materialize(inner, '', {})] + : inner + : transformationResult + ? [transformationResult.type] + : undefined; - if (isArray(recursiveResult.annotations)) { - innerAnnotations[escapeKey(index)] = recursiveResult.annotations; - } else if (isPlainObject(recursiveResult.annotations)) { - forEach(recursiveResult.annotations, (tree, key) => { - innerAnnotations[escapeKey(index) + '.' + key] = tree; - }); + return finish(ctx, seen, transformedValue, annotations); +} + +function leaf(object: any, ctx: WalkContext, seen: Seen | undefined) { + const transformed = transformValue(object, ctx.superJson); + + return transformed + ? finish(ctx, seen, transformed.value, [transformed.type]) + : finish(ctx, seen, object, undefined); +} + +function finish( + ctx: WalkContext, + seen: Seen | undefined, + value: any, + annotations: Annotations +) { + if (seen) { + seen.done = true; + seen.value = value; + seen.annotations = annotations; + } + + ctx.annotations = annotations; + return value; +} + +export function walkRoot( + object: any, + superJson: SuperJSON, + dedupe: boolean +): Result & { identities: Map } { + const ctx: WalkContext = { + superJson, + dedupe, + seen: new Map(), + annotations: undefined, + }; + const transformedValue = walk(object, ctx, null, null); + + const identities = new Map(); + ctx.seen.forEach((seen, value) => { + if (seen.more) { + identities.set(value, pathsOf(seen)); } }); - const result: Result = isEmptyObject(innerAnnotations) - ? { - transformedValue, - annotations: !!transformationResult - ? [transformationResult.type] - : undefined, - } - : { - transformedValue, - annotations: !!transformationResult - ? [transformationResult.type, innerAnnotations] - : innerAnnotations, - }; - if (!primitive) { - seenObjects.set(object, result); - } + return { + transformedValue, + annotations: toRecord(ctx.annotations), + identities, + }; +} + +export const walker = ( + object: any, + identities: Map, + superJson: SuperJSON, + dedupe: boolean +): Result => { + const result = walkRoot(object, superJson, dedupe); + result.identities.forEach((paths, value) => identities.set(value, paths)); - return result; + return { + transformedValue: result.transformedValue, + annotations: result.annotations, + }; }; diff --git a/src/registry.ts b/src/registry.ts index a007127..662d457 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -21,6 +21,10 @@ export class Registry { this.kv.clear(); } + get size(): number { + return this.kv.valueToKey.size; + } + getIdentifier(value: T) { return this.kv.getByValue(value); } diff --git a/src/transformer.ts b/src/transformer.ts index cce7d55..a627ce4 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -15,8 +15,8 @@ import { BigIntTypedArrayConstructor, isURL, } from './is.js'; -import { findArr } from './util.js'; import SuperJSON from './index.js'; +import { parseISOString, toISOString } from './date.js'; export type PrimitiveTypeAnnotation = 'number' | 'undefined' | 'bigint'; @@ -75,8 +75,8 @@ const simpleRules = [ simpleTransformation( isDate, 'Date', - v => v.toISOString(), - v => new Date(v) + toISOString, + parseISOString ), simpleTransformation( @@ -190,7 +190,7 @@ function compositeTransformation( const symbolRule = compositeTransformation( (s, superJson): s is Symbol => { - if (isSymbol(s)) { + if (superJson.symbolRegistry.size > 0 && isSymbol(s)) { const isRegistered = !!superJson.symbolRegistry.getIdentifier(s); return isRegistered; } @@ -282,7 +282,7 @@ export function isInstanceOfRegisteredClass( potentialClass: any, superJson: SuperJSON ): potentialClass is any { - if (potentialClass?.constructor) { + if (superJson.classRegistry.size > 0 && potentialClass?.constructor) { const isRegistered = !!superJson.classRegistry.getIdentifier( potentialClass.constructor ); @@ -349,36 +349,116 @@ const customRule = compositeTransformation( } ); -const compositeRules = [classRule, symbolRule, customRule, typedArrayRule]; +const [ + undefinedRule, + bigintRule, + dateRule, + errorRule, + regexpRule, + setRule, + mapRule, + nonFiniteRule, + negativeZeroRule, + urlRule, +] = simpleRules; + +const objectRules = [dateRule, errorRule, regexpRule, setRule, mapRule, urlRule]; + +const applySimple = ( + rule: typeof simpleRules[number], + value: any, + superJson: SuperJSON +) => ({ + value: rule.transform(value as never, superJson), + type: rule.annotation, +}); -export const transformValue = ( +// Same rule order as before: class, symbol, custom, typed array, then the simple rules. The simple +// rules are split by typeof because each one only accepts a single typeof. +/** Class, symbol and custom rules only. */ +export const transformRegisteredValue = ( value: any, superJson: SuperJSON ): { value: any; type: TypeAnnotation } | undefined => { - const applicableCompositeRule = findArr(compositeRules, rule => - rule.isApplicable(value, superJson) - ); - if (applicableCompositeRule) { + if (classRule.isApplicable(value, superJson)) { return { - value: applicableCompositeRule.transform(value as never, superJson), - type: applicableCompositeRule.annotation(value, superJson), + value: classRule.transform(value, superJson), + type: classRule.annotation(value, superJson), }; } - const applicableSimpleRule = findArr(simpleRules, rule => - rule.isApplicable(value, superJson) - ); - - if (applicableSimpleRule) { + if (symbolRule.isApplicable(value, superJson)) { return { - value: applicableSimpleRule.transform(value as never, superJson), - type: applicableSimpleRule.annotation, + value: symbolRule.transform(value, superJson), + type: symbolRule.annotation(value, superJson), }; } + if (superJson.customTransformerRegistry.size > 0) { + const transformer = superJson.customTransformerRegistry.findApplicable( + value + ); + if (transformer) { + return { + value: transformer.serialize(value), + type: ['custom', transformer.name], + }; + } + } + return undefined; }; +export const transformValue = ( + value: any, + superJson: SuperJSON +): { value: any; type: TypeAnnotation } | undefined => { + const registered = transformRegisteredValue(value, superJson); + if (registered) { + return registered; + } + + switch (typeof value) { + case 'undefined': + return applySimple(undefinedRule, value, superJson); + case 'bigint': + return applySimple(bigintRule, value, superJson); + case 'number': + if (nonFiniteRule.isApplicable(value, superJson)) { + return applySimple(nonFiniteRule, value, superJson); + } + + if (negativeZeroRule.isApplicable(value, superJson)) { + return applySimple(negativeZeroRule, value, superJson); + } + + return undefined; + case 'object': + case 'function': { + if (value === null) { + return undefined; + } + + if (typedArrayRule.isApplicable(value, superJson)) { + return { + value: typedArrayRule.transform(value, superJson), + type: typedArrayRule.annotation(value, superJson), + }; + } + + for (let i = 0; i < objectRules.length; i++) { + if (objectRules[i].isApplicable(value, superJson)) { + return applySimple(objectRules[i], value, superJson); + } + } + + return undefined; + } + default: + return undefined; + } +}; + const simpleRulesByAnnotation: Record = {}; simpleRules.forEach(rule => { simpleRulesByAnnotation[rule.annotation] = rule; diff --git a/src/util.ts b/src/util.ts index 13a5288..e0452dd 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,64 +1,68 @@ -function valuesOfObj(record: Record): T[] { - if ('values' in Object) { - // eslint-disable-next-line es5/no-es6-methods - return Object.values(record); - } - - const values: T[] = []; +import { isArray } from './is.js'; - // eslint-disable-next-line no-restricted-syntax - for (const key in record) { - if (record.hasOwnProperty(key)) { - values.push(record[key]); - } +export function forEach( + record: Record, + run: (v: T, key: string) => void +) { + const keys = Object.keys(record); + for (let i = 0; i < keys.length; i++) { + run(record[keys[i]], keys[i]); } +} - return values; +export function includes(arr: T[], value: T) { + return arr.indexOf(value) !== -1; } -export function find( - record: Record, - predicate: (v: T) => boolean -): T | undefined { - const values = valuesOfObj(record); - if ('find' in values) { - // eslint-disable-next-line es5/no-es6-methods - return values.find(predicate); - } +const getType = (payload: any): string => + Object.prototype.toString.call(payload).slice(8, -1); - const valuesNotNever = values as T[]; +// Same result as `copy` from copy-anything 4: clones arrays and plain objects (enumerable own keys +// and symbols, `__proto__` skipped) and returns everything else by reference. An object without +// @@toStringTag skips the Object.prototype.toString check, because its tag can only be "Array" or +// "Object" here. +export function copy(target: any): any { + if (typeof target !== 'object' || target === null) { + return target; + } - for (let i = 0; i < valuesNotNever.length; i++) { - const value = valuesNotNever[i]; - if (predicate(value)) { - return value; + if (Symbol.toStringTag in target) { + const type = getType(target); + if (type === 'Array') { + return target.map(copy); } + + return type === 'Object' ? copyObject(target) : target; } - return undefined; -} + if (isArray(target)) { + return target.map(copy); + } -export function forEach( - record: Record, - run: (v: T, key: string) => void -) { - Object.entries(record).forEach(([key, value]) => run(value, key)); + return copyObject(target); } -export function includes(arr: T[], value: T) { - return arr.indexOf(value) !== -1; -} +function copyObject(target: any): any { + const prototype = Object.getPrototypeOf(target); + if (prototype !== Object.prototype || prototype.constructor !== Object) { + return target; + } + + const result: any = {}; + const keys = Object.keys(target); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key !== '__proto__') { + result[key] = copy(target[key]); + } + } -export function findArr( - record: T[], - predicate: (v: T) => boolean -): T | undefined { - for (let i = 0; i < record.length; i++) { - const value = record[i]; - if (predicate(value)) { - return value; + const symbols = Object.getOwnPropertySymbols(target); + for (let i = 0; i < symbols.length; i++) { + if (Object.prototype.propertyIsEnumerable.call(target, symbols[i])) { + result[symbols[i]] = copy(target[symbols[i]]); } } - return undefined; + return result; }