Add text-DSL parser with line-numbered errors

Two-pass-free line-based scanner. Each directive carries its source
line so downstream validation errors can be attributed correctly.
Parser checks syntax only — atlas/tile resolution and bounds checks
are deferred to the builder so each tier has one responsibility.
This commit is contained in:
Axel Meyer
2026-05-23 14:19:10 +02:00
parent a3ff2f5e6e
commit e96431b44f
5 changed files with 329 additions and 0 deletions

180
src/dsl-parser.js Normal file
View File

@@ -0,0 +1,180 @@
'use strict';
const VALID_LAYERS = new Set([
'foundation', 'subsurface', 'surface', 'topsurface',
'lower_wall', 'wall', 'upper_wall', 'canopy',
]);
class DslError extends Error {
constructor(line, message) {
super(message);
this.line = line;
}
}
function parseIntStrict(s, line, field) {
if (!/^-?\d+$/.test(s)) {
throw new DslError(line, `${field} '${s}' is not an integer`);
}
return parseInt(s, 10);
}
function parseTileRef(s, line) {
const idx = s.indexOf(':');
if (idx <= 0 || idx === s.length - 1) {
throw new DslError(line, `expected atlas_id:tile_name, got '${s}'`);
}
return { atlasId: s.slice(0, idx), tileName: s.slice(idx + 1) };
}
function parseOptionalRot(tokens, line) {
if (tokens.length === 0) return 0;
if (tokens[0] !== 'rot') {
throw new DslError(line, `unexpected token '${tokens[0]}' (only 'rot N' allowed here)`);
}
if (tokens.length < 2) {
throw new DslError(line, `'rot' needs a value 0..3`);
}
if (tokens.length > 2) {
throw new DslError(line, `unexpected tokens after rotation: ${tokens.slice(2).join(' ')}`);
}
const r = parseIntStrict(tokens[1], line, 'rotation');
if (r < 0 || r > 3) {
throw new DslError(line, `rotation ${r} out of range [0,3]`);
}
return r;
}
function parseDsl(text) {
const ast = {
id: null,
size: null,
atlases: [],
layers: {},
roof: [],
};
let currentBlock = null; // null | { kind: 'layer', name } | { kind: 'roof' }
let sawSize = false;
const rawLines = text.split(/\r?\n/);
for (let i = 0; i < rawLines.length; i++) {
const lineNo = i + 1;
const stripped = rawLines[i].replace(/#.*$/, '').trim();
if (stripped.length === 0) continue;
const tokens = stripped.split(/\s+/);
const head = tokens[0];
if (head === 'id') {
if (sawSize) throw new DslError(lineNo, `'id' must appear before 'size'`);
if (ast.id !== null) throw new DslError(lineNo, `'id' declared twice`);
if (tokens.length !== 2) throw new DslError(lineNo, `usage: id <map_id>`);
ast.id = tokens[1];
continue;
}
if (head === 'size') {
if (sawSize) throw new DslError(lineNo, `'size' declared twice`);
if (tokens.length !== 3) throw new DslError(lineNo, `usage: size <W> <H>`);
const w = parseIntStrict(tokens[1], lineNo, 'W');
const h = parseIntStrict(tokens[2], lineNo, 'H');
if (w < 1 || w > 1024 || h < 1 || h > 1024) {
throw new DslError(lineNo, `size ${w}x${h} out of range [1..1024]`);
}
ast.size = { w, h };
sawSize = true;
continue;
}
if (!sawSize) {
throw new DslError(lineNo, `'size' must be the first directive (before '${head}')`);
}
if (head === 'atlas') {
if (currentBlock !== null) {
throw new DslError(lineNo, `'atlas' must come before any layer/roof block`);
}
if (tokens.length !== 2) throw new DslError(lineNo, `usage: atlas <atlas_id>`);
ast.atlases.push(tokens[1]);
continue;
}
if (head === 'layer') {
if (tokens.length !== 2) throw new DslError(lineNo, `usage: layer <layer_name>`);
const name = tokens[1];
if (!VALID_LAYERS.has(name)) {
throw new DslError(lineNo, `unknown layer '${name}' (valid: ${[...VALID_LAYERS].join(', ')})`);
}
currentBlock = { kind: 'layer', name };
if (!ast.layers[name]) ast.layers[name] = [];
continue;
}
if (head === 'roof') {
if (tokens.length !== 1) throw new DslError(lineNo, `usage: roof`);
currentBlock = { kind: 'roof' };
continue;
}
if (head === 'set' || head === 'fill') {
if (currentBlock === null) {
throw new DslError(lineNo, `'${head}' must appear inside a 'layer' or 'roof' block`);
}
if (currentBlock.kind === 'roof') {
if (head !== 'set') {
throw new DslError(lineNo, `'roof' block only allows 'set X Y 0|1'`);
}
if (tokens.length !== 4) {
throw new DslError(lineNo, `usage: set <x> <y> <0|1>`);
}
const x = parseIntStrict(tokens[1], lineNo, 'x');
const y = parseIntStrict(tokens[2], lineNo, 'y');
const v = parseIntStrict(tokens[3], lineNo, 'value');
if (v !== 0 && v !== 1) {
throw new DslError(lineNo, `roof value ${v} must be 0 or 1`);
}
ast.roof.push({ line: lineNo, x, y, value: v });
continue;
}
// layer block
if (head === 'set') {
if (tokens.length < 4) {
throw new DslError(lineNo, `usage: set <x> <y> <atlas_id>:<tile_name> [rot N]`);
}
const x = parseIntStrict(tokens[1], lineNo, 'x');
const y = parseIntStrict(tokens[2], lineNo, 'y');
const { atlasId, tileName } = parseTileRef(tokens[3], lineNo);
const rot = parseOptionalRot(tokens.slice(4), lineNo);
ast.layers[currentBlock.name].push({
type: 'set', line: lineNo, x, y, atlasId, tileName, rot,
});
continue;
}
// fill
if (tokens.length < 6) {
throw new DslError(lineNo, `usage: fill <x0> <y0> <x1> <y1> <atlas_id>:<tile_name> [rot N]`);
}
const x0 = parseIntStrict(tokens[1], lineNo, 'x0');
const y0 = parseIntStrict(tokens[2], lineNo, 'y0');
const x1 = parseIntStrict(tokens[3], lineNo, 'x1');
const y1 = parseIntStrict(tokens[4], lineNo, 'y1');
if (x0 > x1) throw new DslError(lineNo, `fill: x0 (${x0}) > x1 (${x1})`);
if (y0 > y1) throw new DslError(lineNo, `fill: y0 (${y0}) > y1 (${y1})`);
const { atlasId, tileName } = parseTileRef(tokens[5], lineNo);
const rot = parseOptionalRot(tokens.slice(6), lineNo);
ast.layers[currentBlock.name].push({
type: 'fill', line: lineNo, x0, y0, x1, y1, atlasId, tileName, rot,
});
continue;
}
throw new DslError(lineNo, `unknown directive '${head}'`);
}
if (!sawSize) {
throw new DslError(1, `missing required 'size' directive`);
}
return ast;
}
module.exports = { parseDsl, DslError, VALID_LAYERS };