feat(decoder): indentation strict-mode enforcement

This commit is contained in:
Johann Schopplich
2025-10-29 13:54:55 +01:00
parent 6040c018e0
commit e6c006bc67
4 changed files with 127 additions and 7 deletions

View File

@@ -1,5 +1,5 @@
import type { Depth, ParsedLine } from '../types'
import { SPACE } from '../constants'
import { SPACE, TAB } from '../constants'
export class LineCursor {
private lines: ParsedLine[]
@@ -50,7 +50,7 @@ export class LineCursor {
}
}
export function toParsedLines(source: string, indentSize: number): ParsedLine[] {
export function toParsedLines(source: string, indentSize: number, strict: boolean): ParsedLine[] {
if (!source.trim()) {
return []
}
@@ -58,15 +58,41 @@ export function toParsedLines(source: string, indentSize: number): ParsedLine[]
const lines = source.split('\n')
const parsed: ParsedLine[] = []
for (const raw of lines) {
for (let i = 0; i < lines.length; i++) {
const raw = lines[i]!
let indent = 0
while (indent < raw.length && raw[indent] === SPACE) {
indent++
}
const content = raw.slice(indent)
// Skip empty lines or lines with only whitespace
if (!content.trim()) {
continue
}
const depth = computeDepthFromIndent(indent, indentSize)
// Strict mode validation
if (strict) {
// Find the full leading whitespace region (spaces and tabs)
let wsEnd = 0
while (wsEnd < raw.length && (raw[wsEnd] === SPACE || raw[wsEnd] === TAB)) {
wsEnd++
}
// Check for tabs in leading whitespace (before actual content)
if (raw.slice(0, wsEnd).includes(TAB)) {
throw new SyntaxError(`Line ${i + 1}: Tabs are not allowed in indentation in strict mode`)
}
// Check for exact multiples of indentSize
if (indent > 0 && indent % indentSize !== 0) {
throw new SyntaxError(`Line ${i + 1}: Indentation must be exact multiple of ${indentSize}, but found ${indent} spaces`)
}
}
parsed.push({ raw, indent, content, depth })
}

View File

@@ -26,15 +26,15 @@ export function encode(input: unknown, options?: EncodeOptions): string {
}
export function decode(input: string, options?: DecodeOptions): JsonValue {
const resolved = resolveDecodeOptions(options)
const lines = toParsedLines(input, resolved.indent)
const resolvedOptions = resolveDecodeOptions(options)
const lines = toParsedLines(input, resolvedOptions.indent, resolvedOptions.strict)
if (lines.length === 0) {
throw new TypeError('Cannot decode empty input: input must be a non-empty string')
}
const cursor = new LineCursor(lines)
return decodeValueFromLines(cursor, resolved)
return decodeValueFromLines(cursor, resolvedOptions)
}
function resolveOptions(options?: EncodeOptions): ResolvedEncodeOptions {