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:
180
src/dsl-parser.js
Normal file
180
src/dsl-parser.js
Normal 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 };
|
||||
125
tests/dsl-parser.test.js
Normal file
125
tests/dsl-parser.test.js
Normal file
@@ -0,0 +1,125 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { parseDsl } = require('../src/dsl-parser');
|
||||
|
||||
const FIX = path.join(__dirname, 'fixtures', 'dsl');
|
||||
const read = (n) => fs.readFileSync(path.join(FIX, n), 'utf8');
|
||||
|
||||
test('parseDsl: minimal map yields correct AST', () => {
|
||||
const ast = parseDsl(read('minimal.txt'));
|
||||
assert.equal(ast.id, null);
|
||||
assert.deepEqual(ast.size, { w: 4, h: 4 });
|
||||
assert.deepEqual(ast.atlases, ['atlas_a']);
|
||||
assert.equal(ast.layers.surface.length, 1);
|
||||
const d = ast.layers.surface[0];
|
||||
assert.equal(d.type, 'set');
|
||||
assert.equal(d.x, 0);
|
||||
assert.equal(d.y, 0);
|
||||
assert.equal(d.atlasId, 'atlas_a');
|
||||
assert.equal(d.tileName, 'grass');
|
||||
assert.equal(d.rot, 0);
|
||||
assert.deepEqual(ast.roof, []);
|
||||
});
|
||||
|
||||
test('parseDsl: full map parses id, comments, rotation, multiple layers, roof', () => {
|
||||
const ast = parseDsl(read('full.txt'));
|
||||
assert.equal(ast.id, 'full_demo');
|
||||
assert.deepEqual(ast.size, { w: 4, h: 4 });
|
||||
assert.deepEqual(ast.atlases, ['atlas_a', 'atlas_b']);
|
||||
|
||||
const surface = ast.layers.surface;
|
||||
assert.equal(surface.length, 2);
|
||||
assert.equal(surface[0].type, 'fill');
|
||||
assert.equal(surface[0].x1, 3);
|
||||
assert.equal(surface[1].type, 'set');
|
||||
assert.equal(surface[1].rot, 2);
|
||||
|
||||
assert.equal(ast.layers.wall.length, 1);
|
||||
assert.equal(ast.layers.wall[0].rot, 1);
|
||||
|
||||
assert.equal(ast.roof.length, 2);
|
||||
assert.deepEqual(ast.roof[0], { line: 15, x: 1, y: 1, value: 1 });
|
||||
});
|
||||
|
||||
test('parseDsl: missing size before content is an error', () => {
|
||||
assert.throws(
|
||||
() => parseDsl('atlas a\nlayer surface\n'),
|
||||
(err) => err.message.match(/size/) && err.line === 1
|
||||
);
|
||||
});
|
||||
|
||||
test('parseDsl: unknown layer name is an error with line number', () => {
|
||||
const src = 'size 4 4\natlas a\nlayer bogus\n';
|
||||
assert.throws(
|
||||
() => parseDsl(src),
|
||||
(err) => err.message.match(/unknown layer 'bogus'/) && err.line === 3
|
||||
);
|
||||
});
|
||||
|
||||
test('parseDsl: fill with x0 > x1 is an error', () => {
|
||||
const src = 'size 4 4\natlas a\nlayer surface\n fill 3 0 0 3 a:tile\n';
|
||||
assert.throws(
|
||||
() => parseDsl(src),
|
||||
(err) => err.message.match(/fill.*x0.*x1/) && err.line === 4
|
||||
);
|
||||
});
|
||||
|
||||
test('parseDsl: rotation out of [0,3] is an error', () => {
|
||||
const src = 'size 4 4\natlas a\nlayer surface\n set 0 0 a:tile rot 5\n';
|
||||
assert.throws(
|
||||
() => parseDsl(src),
|
||||
(err) => err.message.match(/rotation/) && err.line === 4
|
||||
);
|
||||
});
|
||||
|
||||
test('parseDsl: rot without value is an error', () => {
|
||||
const src = 'size 4 4\natlas a\nlayer surface\n set 0 0 a:tile rot\n';
|
||||
assert.throws(
|
||||
() => parseDsl(src),
|
||||
(err) => err.line === 4
|
||||
);
|
||||
});
|
||||
|
||||
test('parseDsl: set with malformed atlas:tile form is an error', () => {
|
||||
const src = 'size 4 4\natlas a\nlayer surface\n set 0 0 grass\n';
|
||||
assert.throws(
|
||||
() => parseDsl(src),
|
||||
(err) => err.line === 4
|
||||
);
|
||||
});
|
||||
|
||||
test('parseDsl: roof set with value other than 0/1 is an error', () => {
|
||||
const src = 'size 4 4\natlas a\nroof\n set 0 0 2\n';
|
||||
assert.throws(
|
||||
() => parseDsl(src),
|
||||
(err) => err.message.match(/0.*1/) && err.line === 4
|
||||
);
|
||||
});
|
||||
|
||||
test('parseDsl: comments and blank lines do not contribute line offset', () => {
|
||||
// The error is on line 5 of the source (1-indexed), even though
|
||||
// lines 2 and 4 are blank/comment.
|
||||
const src = 'size 4 4\n\n# a comment\n\natlas\n';
|
||||
assert.throws(
|
||||
() => parseDsl(src),
|
||||
(err) => err.line === 5
|
||||
);
|
||||
});
|
||||
|
||||
test('parseDsl: directive before its layer/roof block is an error', () => {
|
||||
const src = 'size 4 4\natlas a\nset 0 0 a:tile\n';
|
||||
assert.throws(
|
||||
() => parseDsl(src),
|
||||
(err) => err.line === 3
|
||||
);
|
||||
});
|
||||
|
||||
test('parseDsl: second "layer surface" appends to the same layer', () => {
|
||||
const src = 'size 4 4\natlas a\nlayer surface\n set 0 0 a:t1\nlayer surface\n set 1 1 a:t2\n';
|
||||
const ast = parseDsl(src);
|
||||
assert.equal(ast.layers.surface.length, 2);
|
||||
});
|
||||
4
tests/fixtures/dsl/error_oob.txt
vendored
Normal file
4
tests/fixtures/dsl/error_oob.txt
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
size 4 4
|
||||
atlas atlas_a
|
||||
layer surface
|
||||
set 10 10 atlas_a:grass
|
||||
16
tests/fixtures/dsl/full.txt
vendored
Normal file
16
tests/fixtures/dsl/full.txt
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# full demo with comments and rotation
|
||||
id full_demo
|
||||
size 4 4
|
||||
atlas atlas_a
|
||||
atlas atlas_b
|
||||
|
||||
layer surface
|
||||
fill 0 0 3 3 atlas_a:grass
|
||||
set 1 1 atlas_a:stone rot 2
|
||||
|
||||
layer wall
|
||||
set 0 0 atlas_b:water rot 1
|
||||
|
||||
roof
|
||||
set 1 1 1
|
||||
set 2 2 0
|
||||
4
tests/fixtures/dsl/minimal.txt
vendored
Normal file
4
tests/fixtures/dsl/minimal.txt
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
size 4 4
|
||||
atlas atlas_a
|
||||
layer surface
|
||||
set 0 0 atlas_a:grass
|
||||
Reference in New Issue
Block a user