mirror of
https://github.com/voson-wang/toon.git
synced 2026-01-29 23:34:10 +08:00
feat: decode method (#10)
This commit is contained in:
419
src/decoders.ts
Normal file
419
src/decoders.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
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
|
||||
Reference in New Issue
Block a user