mirror of
https://github.com/voson-wang/toon.git
synced 2026-01-29 15:24:10 +08:00
420 lines
11 KiB
TypeScript
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
|