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

125
tests/dsl-parser.test.js Normal file
View 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);
});