refactor: share event handling in AST builder

This commit is contained in:
Johann Schopplich
2025-11-24 15:36:03 +01:00
parent 05abb99a7e
commit 8f96b8f0c8
2 changed files with 192 additions and 317 deletions

View File

@@ -1,4 +1,4 @@
import type { ArrayHeaderInfo, DecodeStreamOptions, Depth, JsonStreamEvent, ParsedLine } from '../types' import type { ArrayHeaderInfo, DecodeStreamOptions, Depth, JsonPrimitive, JsonStreamEvent, ParsedLine } from '../types'
import type { StreamingScanState } from './scanner' import type { StreamingScanState } from './scanner'
import { COLON, DEFAULT_DELIMITER, LIST_ITEM_MARKER, LIST_ITEM_PREFIX } from '../constants' import { COLON, DEFAULT_DELIMITER, LIST_ITEM_MARKER, LIST_ITEM_PREFIX } from '../constants'
import { findClosingQuote } from '../shared/string-utils' import { findClosingQuote } from '../shared/string-utils'
@@ -320,13 +320,7 @@ function* decodeTabularArraySync(
assertExpectedCount(values.length, header.fields!.length, 'tabular row values', options) assertExpectedCount(values.length, header.fields!.length, 'tabular row values', options)
const primitives = mapRowValuesToPrimitives(values) const primitives = mapRowValuesToPrimitives(values)
yield* yieldObjectFromFields(header.fields!, primitives)
yield { type: 'startObject' }
for (let i = 0; i < header.fields!.length; i++) {
yield { type: 'key', key: header.fields![i]! }
yield { type: 'primitive', value: primitives[i]! }
}
yield { type: 'endObject' }
rowCount++ rowCount++
} }
@@ -747,13 +741,7 @@ async function* decodeTabularArrayAsync(
assertExpectedCount(values.length, header.fields!.length, 'tabular row values', options) assertExpectedCount(values.length, header.fields!.length, 'tabular row values', options)
const primitives = mapRowValuesToPrimitives(values) const primitives = mapRowValuesToPrimitives(values)
yield* yieldObjectFromFields(header.fields!, primitives)
yield { type: 'startObject' }
for (let i = 0; i < header.fields!.length; i++) {
yield { type: 'key', key: header.fields![i]! }
yield { type: 'primitive', value: primitives[i]! }
}
yield { type: 'endObject' }
rowCount++ rowCount++
} }
@@ -963,3 +951,19 @@ async function* decodeListItemAsync(
} }
// #endregion // #endregion
// #region Shared decoder helpers
function* yieldObjectFromFields(
fields: string[],
primitives: JsonPrimitive[],
): Generator<JsonStreamEvent> {
yield { type: 'startObject' }
for (let i = 0; i < fields.length; i++) {
yield { type: 'key', key: fields[i]! }
yield { type: 'primitive', value: primitives[i]! }
}
yield { type: 'endObject' }
}
// #endregion

View File

@@ -3,22 +3,50 @@ import { QUOTED_KEY_MARKER } from './expand'
// #region Build context types // #region Build context types
/**
* Stack context for building JSON values from events.
*/
type BuildContext type BuildContext
= | { type: 'object', obj: JsonObject, currentKey?: string, quotedKeys: Set<string> } = | { type: 'object', obj: JsonObject, currentKey?: string, quotedKeys: Set<string> }
| { type: 'array', arr: JsonValue[] } | { type: 'array', arr: JsonValue[] }
interface BuildState {
stack: BuildContext[]
root: JsonValue | undefined
}
// #endregion // #endregion
// #region Synchronous AST builder // #region Synchronous AST builder
export function buildValueFromEvents(events: Iterable<JsonStreamEvent>): JsonValue { export function buildValueFromEvents(events: Iterable<JsonStreamEvent>): JsonValue {
const stack: BuildContext[] = [] const state: BuildState = { stack: [], root: undefined }
let root: JsonValue | undefined
for (const event of events) { for (const event of events) {
applyEvent(state, event)
}
return finalizeState(state)
}
// #endregion
// #region Asynchronous AST builder
export async function buildValueFromEventsAsync(events: AsyncIterable<JsonStreamEvent>): Promise<JsonValue> {
const state: BuildState = { stack: [], root: undefined }
for await (const event of events) {
applyEvent(state, event)
}
return finalizeState(state)
}
// #endregion
// #region Shared event handlers
function applyEvent(state: BuildState, event: JsonStreamEvent): void {
const { stack } = state
switch (event.type) { switch (event.type) {
case 'startObject': { case 'startObject': {
const obj: JsonObject = {} const obj: JsonObject = {}
@@ -69,7 +97,7 @@ export function buildValueFromEvents(events: Iterable<JsonStreamEvent>): JsonVal
} }
if (stack.length === 0) { if (stack.length === 0) {
root = context.obj state.root = context.obj
} }
break break
@@ -112,7 +140,7 @@ export function buildValueFromEvents(events: Iterable<JsonStreamEvent>): JsonVal
} }
if (stack.length === 0) { if (stack.length === 0) {
root = context.arr state.root = context.arr
} }
break break
@@ -141,7 +169,7 @@ export function buildValueFromEvents(events: Iterable<JsonStreamEvent>): JsonVal
case 'primitive': { case 'primitive': {
if (stack.length === 0) { if (stack.length === 0) {
// Root primitive // Root primitive
root = event.value state.root = event.value
} }
else { else {
const parent = stack[stack.length - 1]! const parent = stack[stack.length - 1]!
@@ -160,175 +188,18 @@ export function buildValueFromEvents(events: Iterable<JsonStreamEvent>): JsonVal
break break
} }
} }
} }
if (stack.length !== 0) { function finalizeState(state: BuildState): JsonValue {
if (state.stack.length !== 0) {
throw new Error('Incomplete event stream: stack not empty at end') throw new Error('Incomplete event stream: stack not empty at end')
} }
if (root === undefined) { if (state.root === undefined) {
throw new Error('No root value built from events') throw new Error('No root value built from events')
} }
return root return state.root
}
// #endregion
// #region Asynchronous AST builder
export async function buildValueFromEventsAsync(events: AsyncIterable<JsonStreamEvent>): Promise<JsonValue> {
const stack: BuildContext[] = []
let root: JsonValue | undefined
for await (const event of events) {
switch (event.type) {
case 'startObject': {
const obj: JsonObject = {}
const quotedKeys = new Set<string>()
if (stack.length === 0) {
stack.push({ type: 'object', obj, quotedKeys })
}
else {
const parent = stack[stack.length - 1]!
if (parent.type === 'object') {
if (parent.currentKey === undefined) {
throw new Error('Object startObject event without preceding key')
}
parent.obj[parent.currentKey] = obj
parent.currentKey = undefined
}
else if (parent.type === 'array') {
parent.arr.push(obj)
}
stack.push({ type: 'object', obj, quotedKeys })
}
break
}
case 'endObject': {
if (stack.length === 0) {
throw new Error('Unexpected endObject event')
}
const context = stack.pop()!
if (context.type !== 'object') {
throw new Error('Mismatched endObject event')
}
// Attach quoted keys metadata if any keys were quoted
if (context.quotedKeys.size > 0) {
Object.defineProperty(context.obj, QUOTED_KEY_MARKER, {
value: context.quotedKeys,
enumerable: false,
writable: false,
configurable: false,
})
}
if (stack.length === 0) {
root = context.obj
}
break
}
case 'startArray': {
const arr: JsonValue[] = []
if (stack.length === 0) {
stack.push({ type: 'array', arr })
}
else {
const parent = stack[stack.length - 1]!
if (parent.type === 'object') {
if (parent.currentKey === undefined) {
throw new Error('Array startArray event without preceding key')
}
parent.obj[parent.currentKey] = arr
parent.currentKey = undefined
}
else if (parent.type === 'array') {
parent.arr.push(arr)
}
stack.push({ type: 'array', arr })
}
break
}
case 'endArray': {
if (stack.length === 0) {
throw new Error('Unexpected endArray event')
}
const context = stack.pop()!
if (context.type !== 'array') {
throw new Error('Mismatched endArray event')
}
if (stack.length === 0) {
root = context.arr
}
break
}
case 'key': {
if (stack.length === 0) {
throw new Error('Key event outside of object context')
}
const parent = stack[stack.length - 1]!
if (parent.type !== 'object') {
throw new Error('Key event in non-object context')
}
parent.currentKey = event.key
// Track quoted keys for path expansion
if (event.wasQuoted) {
parent.quotedKeys.add(event.key)
}
break
}
case 'primitive': {
if (stack.length === 0) {
root = event.value
}
else {
const parent = stack[stack.length - 1]!
if (parent.type === 'object') {
if (parent.currentKey === undefined) {
throw new Error('Primitive event without preceding key in object')
}
parent.obj[parent.currentKey] = event.value
parent.currentKey = undefined
}
else if (parent.type === 'array') {
parent.arr.push(event.value)
}
}
break
}
}
}
if (stack.length !== 0) {
throw new Error('Incomplete event stream: stack not empty at end')
}
if (root === undefined) {
throw new Error('No root value built from events')
}
return root
} }
// #endregion // #endregion