/** * The `$variants` directive: parse a CSV into a typed object array and * extend the original object with each row. * * Per docs/bgm/format.md §1: * - The CSV's first row is the header, the second row is the type declaration * (`string`, `number`, `string[]`, `[number;number;number;number]`, ...), * the remaining rows are data. * - Rows are validated against a schema derived from the type row. * - A cell for an array/tuple type uses `;` as the element separator * (`[0;0;5;2]`), because `,` is the CSV delimiter. * - `$variants` can be a single source or an array of them. A source is a * file/URL path if its first line ends in `.csv`, otherwise inline CSV. * * Parsing is delegated to `typed-csv`'s `parseCsv`, which implements exactly * this header/schema/data layout and validates each row against a schema * derived from the type row. * * Paths resolve against the virtual def map — the same names `include` and * `file=` resolve against — so a CSV can be a real file or a markdown code * block (` ```csv file=parts/cargo.csv `). */ import * as path from 'node:path'; import { parseCsv } from 'typed-csv/csv-loader'; import { BgmError, type DefFile } from './types.js'; /** The parsed rows of a CSV, converted to typed values. */ export interface CsvData { /** Column names from the header row. */ header: string[]; /** One object per data row. */ rows: Record[]; } /** * Parse CSV text into typed row objects using `typed-csv`. * * @param text the CSV source (header + schema + data rows) * @param source the source location, for error messages */ export function parseCsvData(text: string, source: string): CsvData { try { const result = parseCsv(text, { resolveReferences: false }); return { header: result.propertyConfigs.map((p) => p.name), rows: result.data }; } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new BgmError(`Invalid CSV: ${message}`, source); } } /** * Look up a CSV in the virtual def map and parse it. * * @param name the CSV's path-style name (relative to the games root) * @param defs the virtual def map * @param source the referencing def file's source, for error messages */ export function parseCsvByName( name: string, defs: Map, source: string, ): CsvData { const list = defs.get(name); const file = list?.[0]; if (!file) { throw new BgmError(`CSV not found: "${name}"`, source); } if (file.kind !== 'csv') { throw new BgmError(`Expected a CSV file, got "${file.kind}" for "${name}"`, source); } return parseCsvData(file.text, file.source); } /** * Expand a `$variants` value into rows. * * @param value the `$variants` value: a path or inline CSV string, or an * array of them * @param baseName the path-style name of the referencing def file; a path * value resolves relative to its directory * @param defs the virtual def map, for resolving the path * @param source the def file's source location, for error messages */ export function expandVariants( value: unknown, baseName: string, defs: Map, source: string, ): Record[] { const sources = Array.isArray(value) ? value : [value]; if (sources.length === 0) { throw new BgmError('`$variants` array must not be empty', source); } const rows: Record[] = []; for (const item of sources) { if (typeof item !== 'string') { throw new BgmError( '`$variants` must be a path or inline CSV string, or an array of them', source, ); } rows.push(...expandVariantsOne(item, baseName, defs, source)); } return rows; } /** * Expand a single `$variants` source: a path or inline CSV. * * A source is a path when its first line ends in `.csv`; otherwise it is * inline CSV. This keeps the two forms self-documenting and applies the same * rule to single values and array elements alike. */ function expandVariantsOne( value: string, baseName: string, defs: Map, source: string, ): Record[] { const firstLine = value.split('\n', 1)[0] ?? value; if (/[.]csv$/i.test(firstLine)) { const name = path.posix.join(path.posix.dirname(baseName), value); return parseCsvByName(name, defs, source).rows; } return parseCsvData(value, source).rows; }