core: modularize config parsing to improve maintainability

Extract error handling, parsing logic, and variable substitution into dedicated
modules. This reduces duplication between tui.json and opencode.json parsing
and makes the config system easier to extend for future config formats.
This commit is contained in:
Dax Raad
2026-04-16 13:29:00 -04:00
parent c5deeee8c7
commit 03e20e6ac1
11 changed files with 335 additions and 273 deletions

View File

@@ -0,0 +1,80 @@
export * as ConfigParse from "./parse"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import z from "zod"
import { ConfigVariable } from "./variable"
import { InvalidError, JsonError } from "./error"
type Schema<T> = z.ZodType<T>
type VariableMode = "error" | "empty"
export type LoadOptions =
| {
type: "path"
path: string
missing?: VariableMode
normalize?: (data: unknown, source: string) => unknown
}
| {
type: "virtual"
dir: string
source: string
missing?: VariableMode
normalize?: (data: unknown, source: string) => unknown
}
function issues(text: string, errors: JsoncParseError[]) {
const lines = text.split("\n")
return errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
}
export function parse<T>(schema: Schema<T>, text: string, filepath: string): T {
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
throw new JsonError({
path: filepath,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues(text, errors)}\n--- End ---`,
})
}
const parsed = schema.safeParse(data)
if (parsed.success) return parsed.data
throw new InvalidError({
path: filepath,
issues: parsed.error.issues,
})
}
export async function load<T>(schema: Schema<T>, text: string, options: LoadOptions): Promise<T> {
const source = options.type === "path" ? options.path : options.source
const expanded = await ConfigVariable.substitute(
text,
options.type === "path" ? { type: "path", path: options.path } : options,
options.missing,
)
const data = parse(z.unknown(), expanded, source)
const normalized = options.normalize ? options.normalize(data, source) : data
const parsed = schema.safeParse(normalized)
if (!parsed.success) {
throw new InvalidError({
path: source,
issues: parsed.error.issues,
})
}
return parsed.data
}