/** * Parse raw definition files (yaml/json/toml text) into JSON objects. * * A def file's document can be either a single JSON object (the root) or a * list of objects; both are handled per docs/bgm-format.md ยง3. In list mode, * each object is a separate definition. */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { parse as parseYaml } from 'yaml'; import { parse as parseToml } from 'smol-toml'; import { BgmError, type DefFile, type ParsedDef } from './types.js'; /** * Parse a def file's text into a list of definition objects. * * @returns the parsed objects; the root object (index `-1`) or the list * items (index `0..n`) */ export function parseDefText(file: DefFile): ParsedDef[] { if (file.kind === 'csv') return []; const text = file.text.trim(); if (!text) return []; const out: ParsedDef[] = []; let doc: unknown; try { doc = parseText(file.kind, text); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new BgmError(`Failed to parse ${file.kind}: ${message}`, file.source); } const push = (value: unknown, index: number) => { if (value !== null && typeof value === 'object' && !Array.isArray(value)) { out.push({ file: file.name, index, value: value as Record, source: file.source }); } else if (value !== null) { throw new BgmError(`Expected a JSON object, got ${typeof value}`, file.source); } }; if (Array.isArray(doc)) { doc.forEach((item, index) => push(item, index)); } else { push(doc, -1); } return out; } function parseText(kind: DefFile['kind'], text: string): unknown { switch (kind) { case 'json': return JSON.parse(text); case 'yaml': return parseYaml(text); case 'toml': return parseToml(text); case 'markdown': // Markdown-only blocks contain no definitions; handled by the caller. return null; } } /** * Parse a directory of real files (yaml/json/toml/md) into def files. * Markdown files are also returned here as-is; code-block extraction happens * in `collect.ts` via `scanMarkdown`. * * @param dir absolute directory to scan * @param root the path-style root the file names are relative to (for * consistent naming with virtual files), e.g. `harbor` */ export function readDefFiles(dir: string, root: string): DefFile[] { const out: DefFile[] = []; const walk = (current: string, rel: string) => { for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const abs = path.join(current, entry.name); const relPath = rel ? `${rel}/${entry.name}` : entry.name; if (entry.isDirectory()) { walk(abs, relPath); } else if (/csv$/i.test(entry.name)) { out.push({ name: `${root}/${relPath}`, text: fs.readFileSync(abs, 'utf8'), source: abs, kind: 'csv', }); } else if (/\.(ya?ml|json|toml|md|markdown)$/i.test(entry.name)) { const kind = kindOf(entry.name); out.push({ name: `${root}/${relPath}`, text: fs.readFileSync(abs, 'utf8'), source: abs, kind, }); } } }; walk(dir, ''); return out; } function kindOf(name: string): DefFile['kind'] { if (name.endsWith('.json')) return 'json'; if (name.endsWith('.toml')) return 'toml'; if (name.endsWith('.md') || name.endsWith('.markdown')) return 'markdown'; if (name.endsWith('.csv')) return 'csv'; return 'yaml'; }