Skip to content

Commit 1837007

Browse files
perf: improve empty object checks
1 parent 661dcbe commit 1837007

File tree

10 files changed

+49
-99
lines changed

10 files changed

+49
-99
lines changed

packages/toon/src/decode/decoders.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ function decodeObject(cursor: LineCursor, baseDepth: Depth, options: ResolvedDec
7373
}
7474

7575
if (line.depth === computedDepth) {
76-
const [key, value, isQuoted] = decodeKeyValuePair(line, cursor, computedDepth, options)
76+
cursor.advance()
77+
const { key, value, isQuoted } = decodeKeyValue(line.content, cursor, computedDepth, options)
7778
obj[key] = value
7879

7980
// Track quoted dotted keys for expansion phase
@@ -134,17 +135,6 @@ function decodeKeyValue(
134135
return { key, value: decodedValue, followDepth: baseDepth + 1, isQuoted }
135136
}
136137

137-
function decodeKeyValuePair(
138-
line: ParsedLine,
139-
cursor: LineCursor,
140-
baseDepth: Depth,
141-
options: ResolvedDecodeOptions,
142-
): [key: string, value: JsonValue, isQuoted: boolean] {
143-
cursor.advance()
144-
const { key, value, isQuoted } = decodeKeyValue(line.content, cursor, baseDepth, options)
145-
return [key, value, isQuoted]
146-
}
147-
148138
// #endregion
149139

150140
// #region Array decoding
@@ -396,7 +386,8 @@ function decodeObjectFromListItem(
396386
}
397387

398388
if (line.depth === followDepth && !line.content.startsWith(LIST_ITEM_PREFIX)) {
399-
const [k, v, kIsQuoted] = decodeKeyValuePair(line, cursor, followDepth, options)
389+
cursor.advance()
390+
const { key: k, value: v, isQuoted: kIsQuoted } = decodeKeyValue(line.content, cursor, followDepth, options)
400391
obj[k] = v
401392

402393
// Track quoted dotted keys

packages/toon/src/decode/expand.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,11 @@ export function expandPathsSafe(value: JsonValue, strict: boolean): JsonValue {
5252

5353
if (isJsonObject(value)) {
5454
const expandedObject: JsonObject = {}
55-
const keys = Object.keys(value)
5655

5756
// Check if this object has quoted key metadata
5857
const quotedKeys = (value as ObjectWithQuotedKeys)[QUOTED_KEY_MARKER]
5958

60-
for (const key of keys) {
61-
const keyValue = value[key]!
59+
for (const [key, keyValue] of Object.entries(value)) {
6260

6361
// Skip expansion for keys that were originally quoted
6462
const isQuoted = quotedKeys?.has(key)
@@ -207,8 +205,7 @@ function mergeObjects(
207205
source: JsonObject,
208206
strict: boolean,
209207
): void {
210-
for (const key of Object.keys(source)) {
211-
const sourceValue = source[key]!
208+
for (const [key, sourceValue] of Object.entries(source)) {
212209
const targetValue = target[key]
213210

214211
if (targetValue === undefined) {

packages/toon/src/decode/parser.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -302,12 +302,11 @@ export function parseQuotedKey(content: string, start: number): { key: string, e
302302
}
303303

304304
export function parseKeyToken(content: string, start: number): { key: string, end: number, isQuoted: boolean } {
305-
if (content[start] === DOUBLE_QUOTE) {
306-
return { ...parseQuotedKey(content, start), isQuoted: true }
307-
}
308-
else {
309-
return { ...parseUnquotedKey(content, start), isQuoted: false }
310-
}
305+
const isQuoted = content[start] === DOUBLE_QUOTE
306+
const result = isQuoted
307+
? parseQuotedKey(content, start)
308+
: parseUnquotedKey(content, start)
309+
return { ...result, isQuoted }
311310
}
312311

313312
// #endregion

packages/toon/src/decode/scanner.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,7 @@ export class LineCursor {
4747

4848
peekAtDepth(targetDepth: Depth): ParsedLine | undefined {
4949
const line = this.peek()
50-
if (!line || line.depth < targetDepth) {
51-
return undefined
52-
}
53-
if (line.depth === targetDepth) {
54-
return line
55-
}
56-
return undefined
57-
}
58-
59-
hasMoreAtDepth(targetDepth: Depth): boolean {
60-
return this.peekAtDepth(targetDepth) !== undefined
50+
return line?.depth === targetDepth ? line : undefined
6151
}
6252
}
6353

packages/toon/src/decode/validation.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,8 @@ export function validateNoExtraListItems(
2424
itemDepth: Depth,
2525
expectedCount: number,
2626
): void {
27-
if (cursor.atEnd())
28-
return
29-
3027
const nextLine = cursor.peek()
31-
if (nextLine && nextLine.depth === itemDepth && nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
28+
if (nextLine?.depth === itemDepth && nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
3229
throw new RangeError(`Expected ${expectedCount} list array items, but found more`)
3330
}
3431
}
@@ -41,13 +38,9 @@ export function validateNoExtraTabularRows(
4138
rowDepth: Depth,
4239
header: ArrayHeaderInfo,
4340
): void {
44-
if (cursor.atEnd())
45-
return
46-
4741
const nextLine = cursor.peek()
4842
if (
49-
nextLine
50-
&& nextLine.depth === rowDepth
43+
nextLine?.depth === rowDepth
5144
&& !nextLine.content.startsWith(LIST_ITEM_PREFIX)
5245
&& isDataRow(nextLine.content, header.delimiter)
5346
) {
@@ -71,14 +64,13 @@ export function validateNoBlankLinesInRange(
7164
// Find blank lines within the range
7265
// Note: We don't filter by depth because ANY blank line between array items is an error,
7366
// regardless of its indentation level
74-
const blanksInRange = blankLines.filter(
75-
blank => blank.lineNumber > startLine
76-
&& blank.lineNumber < endLine,
67+
const firstBlank = blankLines.find(
68+
blank => blank.lineNumber > startLine && blank.lineNumber < endLine,
7769
)
7870

79-
if (blanksInRange.length > 0) {
71+
if (firstBlank) {
8072
throw new SyntaxError(
81-
`Line ${blanksInRange[0]!.lineNumber}: Blank lines inside ${context} are not allowed in strict mode`,
73+
`Line ${firstBlank.lineNumber}: Blank lines inside ${context} are not allowed in strict mode`,
8274
)
8375
}
8476
}

packages/toon/src/encode/encoders.ts

Lines changed: 15 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Depth, JsonArray, JsonObject, JsonPrimitive, JsonValue, ResolvedEncodeOptions } from '../types'
22
import { DOT, LIST_ITEM_MARKER } from '../constants'
33
import { tryFoldKeyChain } from './folding'
4-
import { isArrayOfArrays, isArrayOfObjects, isArrayOfPrimitives, isJsonArray, isJsonObject, isJsonPrimitive } from './normalize'
4+
import { isArrayOfArrays, isArrayOfObjects, isArrayOfPrimitives, isEmptyObject, isJsonArray, isJsonObject, isJsonPrimitive } from './normalize'
55
import { encodeAndJoinPrimitives, encodeKey, encodePrimitive, formatHeader } from './primitives'
66
import { LineWriter } from './writer'
77

@@ -38,8 +38,8 @@ export function encodeObject(value: JsonObject, writer: LineWriter, depth: Depth
3838

3939
const effectiveFlattenDepth = remainingDepth ?? options.flattenDepth
4040

41-
for (const key of keys) {
42-
encodeKeyValuePair(key, value[key]!, writer, depth, options, keys, rootLiteralKeys, pathPrefix, effectiveFlattenDepth)
41+
for (const [key, val] of Object.entries(value)) {
42+
encodeKeyValuePair(key, val, writer, depth, options, keys, rootLiteralKeys, pathPrefix, effectiveFlattenDepth)
4343
}
4444
}
4545

@@ -66,7 +66,7 @@ export function encodeKeyValuePair(key: string, value: JsonValue, writer: LineWr
6666
encodeArray(foldedKey, leafValue, writer, depth, options)
6767
return
6868
}
69-
else if (isJsonObject(leafValue) && Object.keys(leafValue).length === 0) {
69+
else if (isJsonObject(leafValue) && isEmptyObject(leafValue)) {
7070
writer.push(depth, `${encodedFoldedKey}:`)
7171
return
7272
}
@@ -94,13 +94,8 @@ export function encodeKeyValuePair(key: string, value: JsonValue, writer: LineWr
9494
encodeArray(key, value, writer, depth, options)
9595
}
9696
else if (isJsonObject(value)) {
97-
const nestedKeys = Object.keys(value)
98-
if (nestedKeys.length === 0) {
99-
// Empty object
100-
writer.push(depth, `${encodedKey}:`)
101-
}
102-
else {
103-
writer.push(depth, `${encodedKey}:`)
97+
writer.push(depth, `${encodedKey}:`)
98+
if (!isEmptyObject(value)) {
10499
encodeObject(value, writer, depth + 1, options, rootLiteralKeys, currentPath, effectiveFlattenDepth)
105100
}
106101
}
@@ -279,16 +274,14 @@ export function encodeMixedArrayAsListItems(
279274
}
280275

281276
export function encodeObjectAsListItem(obj: JsonObject, writer: LineWriter, depth: Depth, options: ResolvedEncodeOptions): void {
282-
const keys = Object.keys(obj)
283-
if (keys.length === 0) {
277+
if (isEmptyObject(obj)) {
284278
writer.push(depth, LIST_ITEM_MARKER)
285279
return
286280
}
287281

288-
// First key-value on the same line as "- "
289-
const firstKey = keys[0]!
282+
const entries = Object.entries(obj)
283+
const [firstKey, firstValue] = entries[0]!
290284
const encodedKey = encodeKey(firstKey)
291-
const firstValue = obj[firstKey]!
292285

293286
if (isJsonPrimitive(firstValue)) {
294287
writer.pushListItem(depth, `${encodedKey}: ${encodePrimitive(firstValue, options.delimiter)}`)
@@ -327,20 +320,16 @@ export function encodeObjectAsListItem(obj: JsonObject, writer: LineWriter, dept
327320
}
328321
}
329322
else if (isJsonObject(firstValue)) {
330-
const nestedKeys = Object.keys(firstValue)
331-
if (nestedKeys.length === 0) {
332-
writer.pushListItem(depth, `${encodedKey}:`)
333-
}
334-
else {
335-
writer.pushListItem(depth, `${encodedKey}:`)
323+
writer.pushListItem(depth, `${encodedKey}:`)
324+
if (!isEmptyObject(firstValue)) {
336325
encodeObject(firstValue, writer, depth + 2, options)
337326
}
338327
}
339328

340-
// Remaining keys on indented lines
341-
for (let i = 1; i < keys.length; i++) {
342-
const key = keys[i]!
343-
encodeKeyValuePair(key, obj[key]!, writer, depth + 1, options)
329+
// Remaining entries on indented lines
330+
for (let i = 1; i < entries.length; i++) {
331+
const [key, value] = entries[i]!
332+
encodeKeyValuePair(key, value, writer, depth + 1, options)
344333
}
345334
}
346335

packages/toon/src/encode/folding.ts

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { JsonValue, ResolvedEncodeOptions } from '../types'
22
import { DOT } from '../constants'
33
import { isIdentifierSegment } from '../shared/validation'
4-
import { isJsonObject } from './normalize'
4+
import { isEmptyObject, isJsonObject } from './normalize'
55

66
// #region Key folding helpers
77

@@ -160,25 +160,13 @@ function collectSingleKeyChain(
160160
currentValue = nextValue
161161
}
162162

163-
// Determine the tail - simplified with early returns
164-
if (!isJsonObject(currentValue)) {
165-
// Array, primitive, or null - this is a leaf value
163+
// Determine the tail
164+
if (!isJsonObject(currentValue) || isEmptyObject(currentValue)) {
165+
// Array, primitive, null, or empty object - this is a leaf value
166166
return { segments, tail: undefined, leafValue: currentValue }
167167
}
168168

169-
const keys = Object.keys(currentValue)
170-
171-
if (keys.length === 0) {
172-
// Empty object is a leaf
173-
return { segments, tail: undefined, leafValue: currentValue }
174-
}
175-
176-
if (keys.length === 1 && segments.length === maxDepth) {
177-
// Hit depth limit with remaining chain
178-
return { segments, tail: currentValue, leafValue: currentValue }
179-
}
180-
181-
// Multi-key object is the remainder
169+
// Has keys - return as tail (remainder)
182170
return { segments, tail: currentValue, leafValue: currentValue }
183171
}
184172

packages/toon/src/encode/normalize.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ export function isJsonObject(value: unknown): value is JsonObject {
9494
return value !== null && typeof value === 'object' && !Array.isArray(value)
9595
}
9696

97+
export function isEmptyObject(value: JsonObject): boolean {
98+
return Object.keys(value).length === 0
99+
}
100+
97101
export function isPlainObject(value: unknown): value is Record<string, unknown> {
98102
if (value === null || typeof value !== 'object') {
99103
return false
@@ -108,15 +112,15 @@ export function isPlainObject(value: unknown): value is Record<string, unknown>
108112
// #region Array type detection
109113

110114
export function isArrayOfPrimitives(value: JsonArray): value is readonly JsonPrimitive[] {
111-
return value.every(item => isJsonPrimitive(item))
115+
return value.length === 0 || value.every(item => isJsonPrimitive(item))
112116
}
113117

114118
export function isArrayOfArrays(value: JsonArray): value is readonly JsonArray[] {
115-
return value.every(item => isJsonArray(item))
119+
return value.length === 0 || value.every(item => isJsonArray(item))
116120
}
117121

118122
export function isArrayOfObjects(value: JsonArray): value is readonly JsonObject[] {
119-
return value.every(item => isJsonObject(item))
123+
return value.length === 0 || value.every(item => isJsonObject(item))
120124
}
121125

122126
// #endregion

packages/toon/src/encode/primitives.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function encodePrimitive(value: JsonPrimitive, delimiter?: string): strin
2121
return encodeStringLiteral(value, delimiter)
2222
}
2323

24-
export function encodeStringLiteral(value: string, delimiter: string = COMMA): string {
24+
export function encodeStringLiteral(value: string, delimiter: string = DEFAULT_DELIMITER): string {
2525
if (isSafeUnquoted(value, delimiter)) {
2626
return value
2727
}
@@ -45,7 +45,7 @@ export function encodeKey(key: string): string {
4545

4646
// #region Value joining
4747

48-
export function encodeAndJoinPrimitives(values: readonly JsonPrimitive[], delimiter: string = COMMA): string {
48+
export function encodeAndJoinPrimitives(values: readonly JsonPrimitive[], delimiter: string = DEFAULT_DELIMITER): string {
4949
return values.map(v => encodePrimitive(v, delimiter)).join(delimiter)
5050
}
5151

packages/toon/src/shared/validation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { COMMA, LIST_ITEM_MARKER } from '../constants'
1+
import { DEFAULT_DELIMITER, LIST_ITEM_MARKER } from '../constants'
22
import { isBooleanOrNullLiteral } from './literal-utils'
33

44
/**
@@ -39,7 +39,7 @@ export function isIdentifierSegment(key: string): boolean {
3939
* - Contains the active delimiter
4040
* - Starts with a list marker (hyphen)
4141
*/
42-
export function isSafeUnquoted(value: string, delimiter: string = COMMA): boolean {
42+
export function isSafeUnquoted(value: string, delimiter: string = DEFAULT_DELIMITER): boolean {
4343
if (!value) {
4444
return false
4545
}

0 commit comments

Comments
 (0)