Files
toon/src/decoders.ts
2025-10-29 07:42:15 +01:00

420 lines
11 KiB
TypeScript

import type { LineCursor } from './scanner'
import type {
ArrayHeaderInfo,
Depth,
JsonArray,
JsonObject,
JsonPrimitive,
JsonValue,
ParsedLine,
ResolvedDecodeOptions,
} from './types'
import {
COLON,
DEFAULT_DELIMITER,
LIST_ITEM_PREFIX,
} from './constants'
import {
isArrayHeaderAfterHyphen,
isObjectFirstFieldAfterHyphen,
parseArrayHeaderLine,
parseKeyToken,
parsePrimitiveToken,
parseRowValuesToPrimitives,
splitDelimitedValues,
} from './parser'
// #region Entry decoding
export function decodeValueFromLines(cursor: LineCursor, options: ResolvedDecodeOptions): JsonValue {
const first = cursor.peek()
if (!first) {
throw new Error('No content to decode')
}
// Check for root array
if (isRootArrayHeaderLine(first)) {
const headerInfo = parseArrayHeaderLine(first.content, DEFAULT_DELIMITER)
if (headerInfo) {
cursor.advance() // Move past the header line
return decodeArrayFromHeader(headerInfo.header, first, cursor, 0, options)
}
}
// Check for single primitive value
if (cursor.length === 1 && !isKeyValueLine(first)) {
return parsePrimitiveToken(first.content.trim())
}
// Default to object
return decodeObject(cursor, 0, options)
}
function isRootArrayHeaderLine(line: ParsedLine): boolean {
const content = line.content.trim()
// Root array: starts with [ and has a colon
return content.startsWith('[') && content.includes(COLON)
}
function isKeyValueLine(line: ParsedLine): boolean {
const content = line.content
// Look for unquoted colon or quoted key followed by colon
if (content.startsWith('"')) {
// Quoted key
let i = 1
while (i < content.length) {
if (content[i] === '\\' && i + 1 < content.length) {
i += 2
continue
}
if (content[i] === '"') {
// Found end of quoted key, check for colon
return content[i + 1] === COLON
}
i++
}
return false
}
else {
// Unquoted key - look for first colon not inside quotes
return content.includes(COLON)
}
}
// #endregion
// #region Object decoding
function decodeObject(cursor: LineCursor, baseDepth: Depth, options: ResolvedDecodeOptions): JsonObject {
const obj: JsonObject = {}
while (!cursor.atEnd()) {
const line = cursor.peek()
if (!line || line.depth < baseDepth) {
break
}
if (line.depth === baseDepth) {
const [key, value] = decodeKeyValuePair(line, cursor, baseDepth, options)
obj[key] = value
}
else {
break
}
}
return obj
}
function decodeKeyValuePair(
line: ParsedLine,
cursor: LineCursor,
baseDepth: Depth,
options: ResolvedDecodeOptions,
): [key: string, value: JsonValue] {
cursor.advance()
// Check for array header first (before parsing key)
const arrayHeader = parseArrayHeaderLine(line.content, DEFAULT_DELIMITER)
if (arrayHeader && arrayHeader.header.key) {
const value = decodeArrayFromHeader(arrayHeader.header, line, cursor, baseDepth, options)
return [arrayHeader.header.key, value]
}
// Regular key-value pair
const { key, end } = parseKeyToken(line.content, 0)
const rest = line.content.slice(end).trim()
// No value after colon - expect nested object or empty
if (!rest) {
const nextLine = cursor.peek()
if (nextLine && nextLine.depth > baseDepth) {
const nested = expectNestedObject(cursor, baseDepth + 1, options)
return [key, nested]
}
// Empty object
return [key, {}]
}
// Inline primitive value
const value = parsePrimitiveToken(rest)
return [key, value]
}
function expectNestedObject(cursor: LineCursor, nestedDepth: Depth, options: ResolvedDecodeOptions): JsonObject {
return decodeObject(cursor, nestedDepth, options)
}
// #endregion
// #region Array decoding
function decodeArrayFromHeader(
header: ArrayHeaderInfo,
line: ParsedLine,
cursor: LineCursor,
baseDepth: Depth,
options: ResolvedDecodeOptions,
): JsonArray {
const arrayHeader = parseArrayHeaderLine(line.content, DEFAULT_DELIMITER)
if (!arrayHeader) {
throw new Error('Invalid array header')
}
// Inline primitive array
if (arrayHeader.inlineValues) {
// For inline arrays, cursor should already be advanced or will be by caller
return decodeInlinePrimitiveArray(header, arrayHeader.inlineValues, options)
}
// For multi-line arrays (tabular or list), the cursor should already be positioned
// at the array header line, but we haven't advanced past it yet
// Tabular array
if (header.fields && header.fields.length > 0) {
return decodeTabularArray(header, cursor, baseDepth, options)
}
// List array
return decodeListArray(header, cursor, baseDepth, options)
}
function decodeInlinePrimitiveArray(
header: ArrayHeaderInfo,
inlineValues: string,
options: ResolvedDecodeOptions,
): JsonPrimitive[] {
if (!inlineValues.trim()) {
assertExpectedCount(0, header.length, 'inline array items', options)
return []
}
const values = splitDelimitedValues(inlineValues, header.delimiter)
const primitives = parseRowValuesToPrimitives(values)
assertExpectedCount(primitives.length, header.length, 'inline array items', options)
return primitives
}
function decodeListArray(
header: ArrayHeaderInfo,
cursor: LineCursor,
baseDepth: Depth,
options: ResolvedDecodeOptions,
): JsonValue[] {
const items: JsonValue[] = []
const itemDepth = baseDepth + 1
while (!cursor.atEnd() && items.length < header.length) {
const line = cursor.peek()
if (!line || line.depth < itemDepth) {
break
}
if (line.depth === itemDepth && line.content.startsWith(LIST_ITEM_PREFIX)) {
const item = decodeListItem(cursor, itemDepth, header.delimiter, options)
items.push(item)
}
else {
break
}
}
assertExpectedCount(items.length, header.length, 'list array items', options)
// In strict mode, check for extra items
if (options.strict && !cursor.atEnd()) {
const nextLine = cursor.peek()
if (nextLine && nextLine.depth === itemDepth && nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
throw new Error(`Expected ${header.length} list array items, but found more`)
}
}
return items
}
function decodeTabularArray(
header: ArrayHeaderInfo,
cursor: LineCursor,
baseDepth: Depth,
options: ResolvedDecodeOptions,
): JsonObject[] {
const objects: JsonObject[] = []
const rowDepth = baseDepth + 1
while (!cursor.atEnd() && objects.length < header.length) {
const line = cursor.peek()
if (!line || line.depth < rowDepth) {
break
}
if (line.depth === rowDepth) {
cursor.advance()
const values = splitDelimitedValues(line.content, header.delimiter)
assertExpectedCount(values.length, header.fields!.length, 'tabular row values', options)
const primitives = parseRowValuesToPrimitives(values)
const obj: JsonObject = {}
for (let i = 0; i < header.fields!.length; i++) {
obj[header.fields![i]!] = primitives[i]!
}
objects.push(obj)
}
else {
break
}
}
assertExpectedCount(objects.length, header.length, 'tabular rows', options)
// In strict mode, check for extra rows
if (options.strict && !cursor.atEnd()) {
const nextLine = cursor.peek()
if (nextLine && nextLine.depth === rowDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
// A key-value pair has a colon (and if it has delimiter, colon comes first)
// A data row either has no colon, or has delimiter before colon
const hasColon = nextLine.content.includes(COLON)
const hasDelimiter = nextLine.content.includes(header.delimiter)
if (!hasColon) {
// No colon = data row (for single-field tables)
throw new Error(`Expected ${header.length} tabular rows, but found more`)
}
else if (hasDelimiter) {
// Has both colon and delimiter - check which comes first
const colonPos = nextLine.content.indexOf(COLON)
const delimiterPos = nextLine.content.indexOf(header.delimiter)
if (delimiterPos < colonPos) {
// Delimiter before colon = data row
throw new Error(`Expected ${header.length} tabular rows, but found more`)
}
// Colon before delimiter = key-value pair, OK
}
// Has colon but no delimiter = key-value pair, OK
}
}
return objects
}
// #endregion
// #region List item decoding
function decodeListItem(
cursor: LineCursor,
baseDepth: Depth,
activeDelimiter: string,
options: ResolvedDecodeOptions,
): JsonValue {
const line = cursor.next()
if (!line) {
throw new Error('Expected list item')
}
const afterHyphen = line.content.slice(LIST_ITEM_PREFIX.length)
// Check for array header after hyphen
if (isArrayHeaderAfterHyphen(afterHyphen)) {
const arrayHeader = parseArrayHeaderLine(afterHyphen, activeDelimiter as any)
if (arrayHeader) {
return decodeArrayFromHeader(arrayHeader.header, line, cursor, baseDepth, options)
}
}
// Check for object first field after hyphen
if (isObjectFirstFieldAfterHyphen(afterHyphen)) {
return decodeObjectFromListItem(line, cursor, baseDepth, options)
}
// Primitive value
return parsePrimitiveToken(afterHyphen)
}
function decodeObjectFromListItem(
firstLine: ParsedLine,
cursor: LineCursor,
baseDepth: Depth,
options: ResolvedDecodeOptions,
): JsonObject {
const afterHyphen = firstLine.content.slice(LIST_ITEM_PREFIX.length)
const { key, value, followDepth } = decodeFirstFieldOnHyphen(afterHyphen, cursor, baseDepth, options)
const obj: JsonObject = { [key]: value }
// Read subsequent fields
while (!cursor.atEnd()) {
const line = cursor.peek()
if (!line || line.depth < followDepth) {
break
}
if (line.depth === followDepth && !line.content.startsWith(LIST_ITEM_PREFIX)) {
const [k, v] = decodeKeyValuePair(line, cursor, followDepth, options)
obj[k] = v
}
else {
break
}
}
return obj
}
function decodeFirstFieldOnHyphen(
rest: string,
cursor: LineCursor,
baseDepth: Depth,
options: ResolvedDecodeOptions,
): { key: string, value: JsonValue, followDepth: Depth } {
// Check for array header as first field
const arrayHeader = parseArrayHeaderLine(rest, DEFAULT_DELIMITER)
if (arrayHeader) {
// Create a synthetic line for array decoding
const syntheticLine: ParsedLine = {
raw: rest,
content: rest,
indent: baseDepth * options.indent,
depth: baseDepth,
}
const value = decodeArrayFromHeader(arrayHeader.header, syntheticLine, cursor, baseDepth, options)
// After an array, subsequent fields are at baseDepth + 1 (where array content is)
return {
key: arrayHeader.header.key!,
value,
followDepth: baseDepth + 1,
}
}
// Regular key-value pair
const { key, end } = parseKeyToken(rest, 0)
const afterKey = rest.slice(end).trim()
if (!afterKey) {
// Nested object
const nested = expectNestedObject(cursor, baseDepth + 1, options)
return { key, value: nested, followDepth: baseDepth + 1 }
}
// Inline primitive
const value = parsePrimitiveToken(afterKey)
return { key, value, followDepth: baseDepth + 1 }
}
// #endregion
// #region Validation
function assertExpectedCount(actual: number, expected: number, what: string, options: ResolvedDecodeOptions): void {
if (options.strict && actual !== expected) {
throw new Error(`Expected ${expected} ${what}, but got ${actual}`)
}
}
// #endregion