From e96431b44fb3abcc4dddb642f89ee7472a3c39a6 Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Sat, 23 May 2026 14:19:10 +0200 Subject: [PATCH] Add text-DSL parser with line-numbered errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/dsl-parser.js | 180 +++++++++++++++++++++++++++++++ tests/dsl-parser.test.js | 125 +++++++++++++++++++++ tests/fixtures/dsl/error_oob.txt | 4 + tests/fixtures/dsl/full.txt | 16 +++ tests/fixtures/dsl/minimal.txt | 4 + 5 files changed, 329 insertions(+) create mode 100644 src/dsl-parser.js create mode 100644 tests/dsl-parser.test.js create mode 100644 tests/fixtures/dsl/error_oob.txt create mode 100644 tests/fixtures/dsl/full.txt create mode 100644 tests/fixtures/dsl/minimal.txt diff --git a/src/dsl-parser.js b/src/dsl-parser.js new file mode 100644 index 0000000..ef96cfd --- /dev/null +++ b/src/dsl-parser.js @@ -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 `); + 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 `); + 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 `); + ast.atlases.push(tokens[1]); + continue; + } + + if (head === 'layer') { + if (tokens.length !== 2) throw new DslError(lineNo, `usage: layer `); + 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 <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 : [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 : [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 }; diff --git a/tests/dsl-parser.test.js b/tests/dsl-parser.test.js new file mode 100644 index 0000000..048a2b8 --- /dev/null +++ b/tests/dsl-parser.test.js @@ -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); +}); diff --git a/tests/fixtures/dsl/error_oob.txt b/tests/fixtures/dsl/error_oob.txt new file mode 100644 index 0000000..003f171 --- /dev/null +++ b/tests/fixtures/dsl/error_oob.txt @@ -0,0 +1,4 @@ +size 4 4 +atlas atlas_a +layer surface + set 10 10 atlas_a:grass diff --git a/tests/fixtures/dsl/full.txt b/tests/fixtures/dsl/full.txt new file mode 100644 index 0000000..8ded0ab --- /dev/null +++ b/tests/fixtures/dsl/full.txt @@ -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 diff --git a/tests/fixtures/dsl/minimal.txt b/tests/fixtures/dsl/minimal.txt new file mode 100644 index 0000000..96d3109 --- /dev/null +++ b/tests/fixtures/dsl/minimal.txt @@ -0,0 +1,4 @@ +size 4 4 +atlas atlas_a +layer surface + set 0 0 atlas_a:grass