From 18370070480f0c65d2409a1a2366e79b607b387d Mon Sep 17 00:00:00 2001 From: Johann Schopplich Date: Mon, 10 Nov 2025 14:30:54 +0100 Subject: [PATCH] perf: improve empty object checks --- packages/toon/src/decode/decoders.ts | 17 +++-------- packages/toon/src/decode/expand.ts | 7 ++--- packages/toon/src/decode/parser.ts | 11 ++++--- packages/toon/src/decode/scanner.ts | 12 +------- packages/toon/src/decode/validation.ts | 20 ++++--------- packages/toon/src/encode/encoders.ts | 41 ++++++++++---------------- packages/toon/src/encode/folding.ts | 22 ++++---------- packages/toon/src/encode/normalize.ts | 10 +++++-- packages/toon/src/encode/primitives.ts | 4 +-- packages/toon/src/shared/validation.ts | 4 +-- 10 files changed, 49 insertions(+), 99 deletions(-) diff --git a/packages/toon/src/decode/decoders.ts b/packages/toon/src/decode/decoders.ts index de6bb8d..d3dad68 100644 --- a/packages/toon/src/decode/decoders.ts +++ b/packages/toon/src/decode/decoders.ts @@ -73,7 +73,8 @@ function decodeObject(cursor: LineCursor, baseDepth: Depth, options: ResolvedDec } if (line.depth === computedDepth) { - const [key, value, isQuoted] = decodeKeyValuePair(line, cursor, computedDepth, options) + cursor.advance() + const { key, value, isQuoted } = decodeKeyValue(line.content, cursor, computedDepth, options) obj[key] = value // Track quoted dotted keys for expansion phase @@ -134,17 +135,6 @@ function decodeKeyValue( return { key, value: decodedValue, followDepth: baseDepth + 1, isQuoted } } -function decodeKeyValuePair( - line: ParsedLine, - cursor: LineCursor, - baseDepth: Depth, - options: ResolvedDecodeOptions, -): [key: string, value: JsonValue, isQuoted: boolean] { - cursor.advance() - const { key, value, isQuoted } = decodeKeyValue(line.content, cursor, baseDepth, options) - return [key, value, isQuoted] -} - // #endregion // #region Array decoding @@ -396,7 +386,8 @@ function decodeObjectFromListItem( } if (line.depth === followDepth && !line.content.startsWith(LIST_ITEM_PREFIX)) { - const [k, v, kIsQuoted] = decodeKeyValuePair(line, cursor, followDepth, options) + cursor.advance() + const { key: k, value: v, isQuoted: kIsQuoted } = decodeKeyValue(line.content, cursor, followDepth, options) obj[k] = v // Track quoted dotted keys diff --git a/packages/toon/src/decode/expand.ts b/packages/toon/src/decode/expand.ts index 0f90447..94b9364 100644 --- a/packages/toon/src/decode/expand.ts +++ b/packages/toon/src/decode/expand.ts @@ -52,13 +52,11 @@ export function expandPathsSafe(value: JsonValue, strict: boolean): JsonValue { if (isJsonObject(value)) { const expandedObject: JsonObject = {} - const keys = Object.keys(value) // Check if this object has quoted key metadata const quotedKeys = (value as ObjectWithQuotedKeys)[QUOTED_KEY_MARKER] - for (const key of keys) { - const keyValue = value[key]! + for (const [key, keyValue] of Object.entries(value)) { // Skip expansion for keys that were originally quoted const isQuoted = quotedKeys?.has(key) @@ -207,8 +205,7 @@ function mergeObjects( source: JsonObject, strict: boolean, ): void { - for (const key of Object.keys(source)) { - const sourceValue = source[key]! + for (const [key, sourceValue] of Object.entries(source)) { const targetValue = target[key] if (targetValue === undefined) { diff --git a/packages/toon/src/decode/parser.ts b/packages/toon/src/decode/parser.ts index a317cf0..640a62c 100644 --- a/packages/toon/src/decode/parser.ts +++ b/packages/toon/src/decode/parser.ts @@ -302,12 +302,11 @@ export function parseQuotedKey(content: string, start: number): { key: string, e } export function parseKeyToken(content: string, start: number): { key: string, end: number, isQuoted: boolean } { - if (content[start] === DOUBLE_QUOTE) { - return { ...parseQuotedKey(content, start), isQuoted: true } - } - else { - return { ...parseUnquotedKey(content, start), isQuoted: false } - } + const isQuoted = content[start] === DOUBLE_QUOTE + const result = isQuoted + ? parseQuotedKey(content, start) + : parseUnquotedKey(content, start) + return { ...result, isQuoted } } // #endregion diff --git a/packages/toon/src/decode/scanner.ts b/packages/toon/src/decode/scanner.ts index 8dfb96d..cb7c9a3 100644 --- a/packages/toon/src/decode/scanner.ts +++ b/packages/toon/src/decode/scanner.ts @@ -47,17 +47,7 @@ export class LineCursor { peekAtDepth(targetDepth: Depth): ParsedLine | undefined { const line = this.peek() - if (!line || line.depth < targetDepth) { - return undefined - } - if (line.depth === targetDepth) { - return line - } - return undefined - } - - hasMoreAtDepth(targetDepth: Depth): boolean { - return this.peekAtDepth(targetDepth) !== undefined + return line?.depth === targetDepth ? line : undefined } } diff --git a/packages/toon/src/decode/validation.ts b/packages/toon/src/decode/validation.ts index f05ebb6..fa438af 100644 --- a/packages/toon/src/decode/validation.ts +++ b/packages/toon/src/decode/validation.ts @@ -24,11 +24,8 @@ export function validateNoExtraListItems( itemDepth: Depth, expectedCount: number, ): void { - if (cursor.atEnd()) - return - const nextLine = cursor.peek() - if (nextLine && nextLine.depth === itemDepth && nextLine.content.startsWith(LIST_ITEM_PREFIX)) { + if (nextLine?.depth === itemDepth && nextLine.content.startsWith(LIST_ITEM_PREFIX)) { throw new RangeError(`Expected ${expectedCount} list array items, but found more`) } } @@ -41,13 +38,9 @@ export function validateNoExtraTabularRows( rowDepth: Depth, header: ArrayHeaderInfo, ): void { - if (cursor.atEnd()) - return - const nextLine = cursor.peek() if ( - nextLine - && nextLine.depth === rowDepth + nextLine?.depth === rowDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX) && isDataRow(nextLine.content, header.delimiter) ) { @@ -71,14 +64,13 @@ export function validateNoBlankLinesInRange( // Find blank lines within the range // Note: We don't filter by depth because ANY blank line between array items is an error, // regardless of its indentation level - const blanksInRange = blankLines.filter( - blank => blank.lineNumber > startLine - && blank.lineNumber < endLine, + const firstBlank = blankLines.find( + blank => blank.lineNumber > startLine && blank.lineNumber < endLine, ) - if (blanksInRange.length > 0) { + if (firstBlank) { throw new SyntaxError( - `Line ${blanksInRange[0]!.lineNumber}: Blank lines inside ${context} are not allowed in strict mode`, + `Line ${firstBlank.lineNumber}: Blank lines inside ${context} are not allowed in strict mode`, ) } } diff --git a/packages/toon/src/encode/encoders.ts b/packages/toon/src/encode/encoders.ts index b6d9e5c..e508d01 100644 --- a/packages/toon/src/encode/encoders.ts +++ b/packages/toon/src/encode/encoders.ts @@ -1,7 +1,7 @@ import type { Depth, JsonArray, JsonObject, JsonPrimitive, JsonValue, ResolvedEncodeOptions } from '../types' import { DOT, LIST_ITEM_MARKER } from '../constants' import { tryFoldKeyChain } from './folding' -import { isArrayOfArrays, isArrayOfObjects, isArrayOfPrimitives, isJsonArray, isJsonObject, isJsonPrimitive } from './normalize' +import { isArrayOfArrays, isArrayOfObjects, isArrayOfPrimitives, isEmptyObject, isJsonArray, isJsonObject, isJsonPrimitive } from './normalize' import { encodeAndJoinPrimitives, encodeKey, encodePrimitive, formatHeader } from './primitives' import { LineWriter } from './writer' @@ -38,8 +38,8 @@ export function encodeObject(value: JsonObject, writer: LineWriter, depth: Depth const effectiveFlattenDepth = remainingDepth ?? options.flattenDepth - for (const key of keys) { - encodeKeyValuePair(key, value[key]!, writer, depth, options, keys, rootLiteralKeys, pathPrefix, effectiveFlattenDepth) + for (const [key, val] of Object.entries(value)) { + encodeKeyValuePair(key, val, writer, depth, options, keys, rootLiteralKeys, pathPrefix, effectiveFlattenDepth) } } @@ -66,7 +66,7 @@ export function encodeKeyValuePair(key: string, value: JsonValue, writer: LineWr encodeArray(foldedKey, leafValue, writer, depth, options) return } - else if (isJsonObject(leafValue) && Object.keys(leafValue).length === 0) { + else if (isJsonObject(leafValue) && isEmptyObject(leafValue)) { writer.push(depth, `${encodedFoldedKey}:`) return } @@ -94,13 +94,8 @@ export function encodeKeyValuePair(key: string, value: JsonValue, writer: LineWr encodeArray(key, value, writer, depth, options) } else if (isJsonObject(value)) { - const nestedKeys = Object.keys(value) - if (nestedKeys.length === 0) { - // Empty object - writer.push(depth, `${encodedKey}:`) - } - else { - writer.push(depth, `${encodedKey}:`) + writer.push(depth, `${encodedKey}:`) + if (!isEmptyObject(value)) { encodeObject(value, writer, depth + 1, options, rootLiteralKeys, currentPath, effectiveFlattenDepth) } } @@ -279,16 +274,14 @@ export function encodeMixedArrayAsListItems( } export function encodeObjectAsListItem(obj: JsonObject, writer: LineWriter, depth: Depth, options: ResolvedEncodeOptions): void { - const keys = Object.keys(obj) - if (keys.length === 0) { + if (isEmptyObject(obj)) { writer.push(depth, LIST_ITEM_MARKER) return } - // First key-value on the same line as "- " - const firstKey = keys[0]! + const entries = Object.entries(obj) + const [firstKey, firstValue] = entries[0]! const encodedKey = encodeKey(firstKey) - const firstValue = obj[firstKey]! if (isJsonPrimitive(firstValue)) { writer.pushListItem(depth, `${encodedKey}: ${encodePrimitive(firstValue, options.delimiter)}`) @@ -327,20 +320,16 @@ export function encodeObjectAsListItem(obj: JsonObject, writer: LineWriter, dept } } else if (isJsonObject(firstValue)) { - const nestedKeys = Object.keys(firstValue) - if (nestedKeys.length === 0) { - writer.pushListItem(depth, `${encodedKey}:`) - } - else { - writer.pushListItem(depth, `${encodedKey}:`) + writer.pushListItem(depth, `${encodedKey}:`) + if (!isEmptyObject(firstValue)) { encodeObject(firstValue, writer, depth + 2, options) } } - // Remaining keys on indented lines - for (let i = 1; i < keys.length; i++) { - const key = keys[i]! - encodeKeyValuePair(key, obj[key]!, writer, depth + 1, options) + // Remaining entries on indented lines + for (let i = 1; i < entries.length; i++) { + const [key, value] = entries[i]! + encodeKeyValuePair(key, value, writer, depth + 1, options) } } diff --git a/packages/toon/src/encode/folding.ts b/packages/toon/src/encode/folding.ts index 56e7d73..31444f4 100644 --- a/packages/toon/src/encode/folding.ts +++ b/packages/toon/src/encode/folding.ts @@ -1,7 +1,7 @@ import type { JsonValue, ResolvedEncodeOptions } from '../types' import { DOT } from '../constants' import { isIdentifierSegment } from '../shared/validation' -import { isJsonObject } from './normalize' +import { isEmptyObject, isJsonObject } from './normalize' // #region Key folding helpers @@ -160,25 +160,13 @@ function collectSingleKeyChain( currentValue = nextValue } - // Determine the tail - simplified with early returns - if (!isJsonObject(currentValue)) { - // Array, primitive, or null - this is a leaf value + // Determine the tail + if (!isJsonObject(currentValue) || isEmptyObject(currentValue)) { + // Array, primitive, null, or empty object - this is a leaf value return { segments, tail: undefined, leafValue: currentValue } } - const keys = Object.keys(currentValue) - - if (keys.length === 0) { - // Empty object is a leaf - return { segments, tail: undefined, leafValue: currentValue } - } - - if (keys.length === 1 && segments.length === maxDepth) { - // Hit depth limit with remaining chain - return { segments, tail: currentValue, leafValue: currentValue } - } - - // Multi-key object is the remainder + // Has keys - return as tail (remainder) return { segments, tail: currentValue, leafValue: currentValue } } diff --git a/packages/toon/src/encode/normalize.ts b/packages/toon/src/encode/normalize.ts index 250f0d9..53538a2 100644 --- a/packages/toon/src/encode/normalize.ts +++ b/packages/toon/src/encode/normalize.ts @@ -94,6 +94,10 @@ export function isJsonObject(value: unknown): value is JsonObject { return value !== null && typeof value === 'object' && !Array.isArray(value) } +export function isEmptyObject(value: JsonObject): boolean { + return Object.keys(value).length === 0 +} + export function isPlainObject(value: unknown): value is Record { if (value === null || typeof value !== 'object') { return false @@ -108,15 +112,15 @@ export function isPlainObject(value: unknown): value is Record // #region Array type detection export function isArrayOfPrimitives(value: JsonArray): value is readonly JsonPrimitive[] { - return value.every(item => isJsonPrimitive(item)) + return value.length === 0 || value.every(item => isJsonPrimitive(item)) } export function isArrayOfArrays(value: JsonArray): value is readonly JsonArray[] { - return value.every(item => isJsonArray(item)) + return value.length === 0 || value.every(item => isJsonArray(item)) } export function isArrayOfObjects(value: JsonArray): value is readonly JsonObject[] { - return value.every(item => isJsonObject(item)) + return value.length === 0 || value.every(item => isJsonObject(item)) } // #endregion diff --git a/packages/toon/src/encode/primitives.ts b/packages/toon/src/encode/primitives.ts index d4c2a62..0ef6e7f 100644 --- a/packages/toon/src/encode/primitives.ts +++ b/packages/toon/src/encode/primitives.ts @@ -21,7 +21,7 @@ export function encodePrimitive(value: JsonPrimitive, delimiter?: string): strin return encodeStringLiteral(value, delimiter) } -export function encodeStringLiteral(value: string, delimiter: string = COMMA): string { +export function encodeStringLiteral(value: string, delimiter: string = DEFAULT_DELIMITER): string { if (isSafeUnquoted(value, delimiter)) { return value } @@ -45,7 +45,7 @@ export function encodeKey(key: string): string { // #region Value joining -export function encodeAndJoinPrimitives(values: readonly JsonPrimitive[], delimiter: string = COMMA): string { +export function encodeAndJoinPrimitives(values: readonly JsonPrimitive[], delimiter: string = DEFAULT_DELIMITER): string { return values.map(v => encodePrimitive(v, delimiter)).join(delimiter) } diff --git a/packages/toon/src/shared/validation.ts b/packages/toon/src/shared/validation.ts index 854c492..2270766 100644 --- a/packages/toon/src/shared/validation.ts +++ b/packages/toon/src/shared/validation.ts @@ -1,4 +1,4 @@ -import { COMMA, LIST_ITEM_MARKER } from '../constants' +import { DEFAULT_DELIMITER, LIST_ITEM_MARKER } from '../constants' import { isBooleanOrNullLiteral } from './literal-utils' /** @@ -39,7 +39,7 @@ export function isIdentifierSegment(key: string): boolean { * - Contains the active delimiter * - Starts with a list marker (hyphen) */ -export function isSafeUnquoted(value: string, delimiter: string = COMMA): boolean { +export function isSafeUnquoted(value: string, delimiter: string = DEFAULT_DELIMITER): boolean { if (!value) { return false }