@sharpee/chord 3.1.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/analyzer.d.ts +11 -3
- package/analyzer.d.ts.map +1 -1
- package/analyzer.js +1360 -33
- package/analyzer.js.map +1 -1
- package/ast.d.ts +480 -9
- package/ast.d.ts.map +1 -1
- package/catalog.d.ts +57 -1
- package/catalog.d.ts.map +1 -1
- package/catalog.js +91 -3
- package/catalog.js.map +1 -1
- package/diagnostics.d.ts +1 -1
- package/diagnostics.d.ts.map +1 -1
- package/index.d.ts +32 -13
- package/index.d.ts.map +1 -1
- package/index.js +112 -30
- package/index.js.map +1 -1
- package/ir.d.ts +361 -7
- package/ir.d.ts.map +1 -1
- package/lexer.d.ts +11 -3
- package/lexer.d.ts.map +1 -1
- package/lexer.js +36 -8
- package/lexer.js.map +1 -1
- package/manifests/combat.d.ts +16 -0
- package/manifests/combat.d.ts.map +1 -0
- package/manifests/combat.js +38 -0
- package/manifests/combat.js.map +1 -0
- package/manifests/hunger.d.ts +18 -0
- package/manifests/hunger.d.ts.map +1 -0
- package/manifests/hunger.js +8 -0
- package/manifests/hunger.js.map +1 -0
- package/manifests/index.d.ts +23 -0
- package/manifests/index.d.ts.map +1 -0
- package/manifests/index.js +52 -0
- package/manifests/index.js.map +1 -0
- package/manifests/npc.d.ts +19 -0
- package/manifests/npc.d.ts.map +1 -0
- package/manifests/npc.js +38 -0
- package/manifests/npc.js.map +1 -0
- package/manifests/scoring.d.ts +25 -0
- package/manifests/scoring.d.ts.map +1 -0
- package/manifests/scoring.js +8 -0
- package/manifests/scoring.js.map +1 -0
- package/manifests/state-machines.d.ts +17 -0
- package/manifests/state-machines.d.ts.map +1 -0
- package/manifests/state-machines.js +8 -0
- package/manifests/state-machines.js.map +1 -0
- package/manifests/types.d.ts +41 -0
- package/manifests/types.d.ts.map +1 -0
- package/manifests/types.js +18 -0
- package/manifests/types.js.map +1 -0
- package/message-alias-catalog.d.ts +22 -0
- package/message-alias-catalog.d.ts.map +1 -0
- package/message-alias-catalog.js +826 -0
- package/message-alias-catalog.js.map +1 -0
- package/package.json +1 -1
- package/parser.d.ts +2 -2
- package/parser.d.ts.map +1 -1
- package/parser.js +1644 -131
- package/parser.js.map +1 -1
- package/phrasebooks.d.ts +28 -0
- package/phrasebooks.d.ts.map +1 -0
- package/phrasebooks.js +6 -0
- package/phrasebooks.js.map +1 -0
- package/version.d.ts +38 -0
- package/version.d.ts.map +1 -0
- package/version.js +41 -0
- package/version.js.map +1 -0
- package/sharpee-chord-3.0.0.tgz +0 -0
package/parser.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.parseStory = parseStory;
|
|
4
|
-
const
|
|
5
|
-
const
|
|
4
|
+
const catalog_js_1 = require("./catalog.js");
|
|
5
|
+
const lexer_js_1 = require("./lexer.js");
|
|
6
|
+
const span_js_1 = require("./span.js");
|
|
6
7
|
const ARTICLES = new Set(['the', 'a', 'an']);
|
|
7
8
|
const DIRECTIONS = new Set([
|
|
8
9
|
'north', 'south', 'east', 'west',
|
|
@@ -19,7 +20,9 @@ const ORDINALS = {
|
|
|
19
20
|
};
|
|
20
21
|
/** Words that terminate a noun phrase in condition/statement positions. */
|
|
21
22
|
const PHRASE_STOPS = new Set(['is', 'has', 'holds', 'wears', 'can', 'and', 'or', 'then', 'to', 'while', 'with']);
|
|
22
|
-
|
|
23
|
+
/** Stop words ending an emit-payload value expression (ADR-216). */
|
|
24
|
+
const EMIT_VALUE_STOPS = new Set(['and', 'when']);
|
|
25
|
+
const TOP_KEYWORDS = new Set(['story', 'create', 'define', 'when', 'once', 'every', 'import', 'override']);
|
|
23
26
|
/**
|
|
24
27
|
* Parse `.story` source into an AST.
|
|
25
28
|
* @param source full `.story` text
|
|
@@ -27,7 +30,7 @@ const TOP_KEYWORDS = new Set(['story', 'create', 'define', 'when', 'once', 'ever
|
|
|
27
30
|
* a (possibly partial) StoryFile — callers gate on diagnostics.hasErrors()
|
|
28
31
|
*/
|
|
29
32
|
function parseStory(source, diagnostics) {
|
|
30
|
-
return new Parser((0,
|
|
33
|
+
return new Parser((0, lexer_js_1.lex)(source, diagnostics), diagnostics).parseFile();
|
|
31
34
|
}
|
|
32
35
|
/** Cursor over one line's tokens. */
|
|
33
36
|
class Cursor {
|
|
@@ -62,14 +65,14 @@ class Cursor {
|
|
|
62
65
|
restSpan() {
|
|
63
66
|
const t = this.peek();
|
|
64
67
|
if (t)
|
|
65
|
-
return (0,
|
|
68
|
+
return (0, span_js_1.mergeSpans)(t.span, this.tokens[this.tokens.length - 1].span);
|
|
66
69
|
return lineSpan(this.line);
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
function lineSpan(line) {
|
|
70
73
|
if (line.tokens.length === 0)
|
|
71
|
-
return (0,
|
|
72
|
-
return (0,
|
|
74
|
+
return (0, span_js_1.spanOf)(line.lineNo, line.indent + 1, Math.max(1, line.raw.trim().length));
|
|
75
|
+
return (0, span_js_1.mergeSpans)(line.tokens[0].span, line.tokens[line.tokens.length - 1].span);
|
|
73
76
|
}
|
|
74
77
|
function firstWord(line) {
|
|
75
78
|
const t = line.tokens[0];
|
|
@@ -79,6 +82,15 @@ function firstWord(line) {
|
|
|
79
82
|
function isEndLine(line) {
|
|
80
83
|
return firstWord(line) === 'end';
|
|
81
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* True for any `##`-prefixed line at any indent (ADR-249). In code
|
|
87
|
+
* positions this is always an error — comments are only legal flagged
|
|
88
|
+
* (indent 0) and between top-level constructs. Prose positions never
|
|
89
|
+
* call this: an indented `##` in prose renders verbatim (§5.2 opacity).
|
|
90
|
+
*/
|
|
91
|
+
function looksLikeComment(line) {
|
|
92
|
+
return line.raw.trimStart().startsWith('##');
|
|
93
|
+
}
|
|
82
94
|
/** Words that open a statement or block boundary inside behavior bodies. */
|
|
83
95
|
const STATEMENT_OPENERS = new Set([
|
|
84
96
|
'refuse', 'phrase', 'emit', 'set', 'change', 'move', 'remove', 'award', 'win', 'lose', 'kill',
|
|
@@ -123,9 +135,24 @@ class Parser {
|
|
|
123
135
|
parseFile() {
|
|
124
136
|
let header = null;
|
|
125
137
|
const declarations = [];
|
|
126
|
-
const start = this.lines[0] ? lineSpan(this.lines[0]) : (0,
|
|
138
|
+
const start = this.lines[0] ? lineSpan(this.lines[0]) : (0, span_js_1.spanOf)(1, 1);
|
|
127
139
|
while (this.pos < this.lines.length) {
|
|
128
140
|
const line = this.lines[this.pos];
|
|
141
|
+
if (line.comment) {
|
|
142
|
+
// ADR-249: comments are legal exactly between top-level
|
|
143
|
+
// constructs. A following indented line means this comment sits
|
|
144
|
+
// between a construct's header and its body — inside the block.
|
|
145
|
+
const next = this.lines[this.pos + 1];
|
|
146
|
+
if (next && next.indent > 0) {
|
|
147
|
+
this.reportCommentInsideBlock(line);
|
|
148
|
+
this.pos++;
|
|
149
|
+
this.recoverToTopLevel();
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
this.pos++;
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
129
156
|
if (line.indent !== 0) {
|
|
130
157
|
this.diagnostics.error('parse.unexpected-indent', 'Unexpected indentation — expected a top-level declaration.', lineSpan(line));
|
|
131
158
|
this.recoverToTopLevel();
|
|
@@ -151,6 +178,21 @@ class Parser {
|
|
|
151
178
|
declarations.push(d);
|
|
152
179
|
break;
|
|
153
180
|
}
|
|
181
|
+
case 'import': {
|
|
182
|
+
// ADR-251: `import "<file>"` — the single generalized form.
|
|
183
|
+
// Position IS the spliced content's arbitration position (D4).
|
|
184
|
+
const d = this.parseImport();
|
|
185
|
+
if (d)
|
|
186
|
+
declarations.push(d);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case 'override': {
|
|
190
|
+
// ADR-255: `override message <alias>` / `override messages <locale>`.
|
|
191
|
+
const d = this.parseOverride();
|
|
192
|
+
if (d)
|
|
193
|
+
declarations.push(d);
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
154
196
|
// Ownership-package removals (ratchet 2026-07-11): floating rules
|
|
155
197
|
// are gone — each gets a fix-it naming its owner-attached form.
|
|
156
198
|
case 'when':
|
|
@@ -165,6 +207,18 @@ class Parser {
|
|
|
165
207
|
this.diagnostics.error('parse.removed-every', 'Top-level `every N turns` rules were removed (ownership package, 2026-07-11) — use a story-owned `define sequence`, or an every-turn clause on the owner.', lineSpan(line));
|
|
166
208
|
this.recoverToTopLevel(true);
|
|
167
209
|
break;
|
|
210
|
+
case 'use':
|
|
211
|
+
// ADR-215: `use` lives in the story header's indented body, not
|
|
212
|
+
// at the top level — a pointed fix-it, never a generic error.
|
|
213
|
+
this.diagnostics.error('parse.use-top-level', '`use <extension>` goes inside the story header\'s indented body (with `states:`, scores, …), not at the top level.', lineSpan(line));
|
|
214
|
+
this.recoverToTopLevel(true);
|
|
215
|
+
break;
|
|
216
|
+
case 'rank':
|
|
217
|
+
// ADR-261 D2: a rung lives in the `use scoring` body, so that the
|
|
218
|
+
// ladder cannot drift away from the line that enables it.
|
|
219
|
+
this.diagnostics.error('parse.rank-outside-scoring', '`rank "<name>" at <n>` goes in the indented body of the story header\'s `use scoring` line, not at the top level.', lineSpan(line));
|
|
220
|
+
this.recoverToTopLevel(true);
|
|
221
|
+
break;
|
|
168
222
|
case 'each':
|
|
169
223
|
// Never top-level (given 9: all behavior is owned) — ratchet E3.
|
|
170
224
|
this.diagnostics.error('parse.each-top-level', '`each` blocks run inside an owner\'s behavior — place the block in an `on`/`after` clause body, an action body, a trait clause body, or a sequence step (never top-level).', lineSpan(line));
|
|
@@ -180,9 +234,18 @@ class Parser {
|
|
|
180
234
|
kind: 'story-file',
|
|
181
235
|
header,
|
|
182
236
|
declarations,
|
|
183
|
-
span: last ? (0,
|
|
237
|
+
span: last ? (0, span_js_1.mergeSpans)(start, lineSpan(last)) : start,
|
|
184
238
|
};
|
|
185
239
|
}
|
|
240
|
+
/** ADR-249: the one inside-block comment diagnostic, with its fix-it. */
|
|
241
|
+
reportCommentInsideBlock(line) {
|
|
242
|
+
this.diagnostics.error('parse.comment-inside-block', 'Comments are only legal outside blocks, at the top level of the story file.', lineSpan(line));
|
|
243
|
+
}
|
|
244
|
+
/** Report an inside-block comment and consume the line (block parsing continues). */
|
|
245
|
+
skipCommentInsideBlock(line) {
|
|
246
|
+
this.reportCommentInsideBlock(line);
|
|
247
|
+
this.pos++;
|
|
248
|
+
}
|
|
186
249
|
/** Skip lines until the next top-level keyword at indent 0. */
|
|
187
250
|
recoverToTopLevel(consumeCurrent = false) {
|
|
188
251
|
if (consumeCurrent)
|
|
@@ -223,10 +286,121 @@ class Parser {
|
|
|
223
286
|
const states = [];
|
|
224
287
|
let statesReversible = false;
|
|
225
288
|
const scores = [];
|
|
289
|
+
const onClauses = [];
|
|
290
|
+
const uses = [];
|
|
291
|
+
const usePhrasebooks = [];
|
|
292
|
+
const ranks = [];
|
|
293
|
+
let hunger;
|
|
226
294
|
let span = lineSpan(line);
|
|
227
295
|
while (this.pos < this.lines.length && this.lines[this.pos].indent > 0) {
|
|
296
|
+
const peeked = this.lines[this.pos];
|
|
297
|
+
const peekedWord = firstWord(peeked);
|
|
298
|
+
// `use <extension>` — a trusted platform extension (ADR-215): static,
|
|
299
|
+
// one name per line; the analyzer gates the name against the manifest
|
|
300
|
+
// registry.
|
|
301
|
+
if (peekedWord === 'use') {
|
|
302
|
+
const useLine = this.lines[this.pos++];
|
|
303
|
+
span = (0, span_js_1.mergeSpans)(span, lineSpan(useLine));
|
|
304
|
+
const uc = new Cursor(useLine.tokens, useLine);
|
|
305
|
+
uc.matchWord('use');
|
|
306
|
+
// ADR-250 D2: exactly the word `phrasebook` selects the two-word
|
|
307
|
+
// form — `use phrasebook <name> [while <condition>]`, stackable.
|
|
308
|
+
// Plain `use <extension>` keeps its strict one-word grammar.
|
|
309
|
+
if (uc.isWord('phrasebook')) {
|
|
310
|
+
uc.next();
|
|
311
|
+
const bookTok = uc.next();
|
|
312
|
+
if (!bookTok || bookTok.kind !== 'word') {
|
|
313
|
+
this.diagnostics.error('parse.use-phrasebook', 'Expected `use phrasebook <name> [while <condition>]` — a single kebab-case book name.', uc.restSpan());
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
let bookCondition = null;
|
|
317
|
+
if (uc.isWord('while')) {
|
|
318
|
+
uc.next();
|
|
319
|
+
const condTokens = [];
|
|
320
|
+
while (!uc.atEnd())
|
|
321
|
+
condTokens.push(uc.next());
|
|
322
|
+
bookCondition = this.parseCondition(new Cursor(condTokens, useLine), useLine);
|
|
323
|
+
}
|
|
324
|
+
else if (!uc.atEnd()) {
|
|
325
|
+
this.diagnostics.error('parse.use-phrasebook', 'Unexpected text after the book name — only `while <condition>` may follow.', uc.restSpan());
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
usePhrasebooks.push({ name: bookTok.text, condition: bookCondition, span: lineSpan(useLine) });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const nameTok = uc.next();
|
|
332
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
333
|
+
this.diagnostics.error('parse.use', 'Expected `use <extension>` — one extension name per line.', lineSpan(useLine));
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
// Optional `, announce <mode>` (ADR-262 D3): how a metering extension's
|
|
337
|
+
// band crossings narrate. The analyzer validates the mode word.
|
|
338
|
+
let announce;
|
|
339
|
+
if (uc.peek()?.kind === 'comma') {
|
|
340
|
+
uc.next(); // consume the comma
|
|
341
|
+
if (!uc.isWord('announce')) {
|
|
342
|
+
this.diagnostics.error('parse.use-announce', 'Expected `announce <mode>` after the comma in a `use` line.', uc.restSpan());
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
uc.next(); // consume `announce`
|
|
346
|
+
const modeTok = uc.next();
|
|
347
|
+
if (!modeTok || modeTok.kind !== 'word') {
|
|
348
|
+
this.diagnostics.error('parse.use-announce', 'Expected an announce mode (all, collapsed, combined, silent) after `announce`.', uc.restSpan());
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
announce = modeTok.text;
|
|
352
|
+
}
|
|
353
|
+
if (!uc.atEnd()) {
|
|
354
|
+
this.diagnostics.error('parse.use', 'Expected `use <extension>[, announce <mode>]` — one extension name per line.', lineSpan(useLine));
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
uses.push(announce !== undefined
|
|
358
|
+
? { name: nameTok.text, announce, span: lineSpan(useLine) }
|
|
359
|
+
: { name: nameTok.text, span: lineSpan(useLine) });
|
|
360
|
+
// ADR-261 D2 / ADR-263 D1: `use scoring` (rank ladder) and `use hunger`
|
|
361
|
+
// (satiety meter) are the `use` lines that take an indented body.
|
|
362
|
+
// `use combat` and `use state-machines` take none.
|
|
363
|
+
const body = [];
|
|
364
|
+
while (this.pos < this.lines.length && this.lines[this.pos].indent > useLine.indent) {
|
|
365
|
+
body.push(this.lines[this.pos++]);
|
|
366
|
+
}
|
|
367
|
+
if (body.length === 0)
|
|
368
|
+
continue;
|
|
369
|
+
if (nameTok.text === 'scoring') {
|
|
370
|
+
for (const bodyLine of body) {
|
|
371
|
+
span = (0, span_js_1.mergeSpans)(span, lineSpan(bodyLine));
|
|
372
|
+
const rung = this.parseRankLine(bodyLine);
|
|
373
|
+
if (rung)
|
|
374
|
+
ranks.push(rung);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
else if (nameTok.text === 'hunger') {
|
|
378
|
+
span = (0, span_js_1.mergeSpans)(span, lineSpan(body[body.length - 1]));
|
|
379
|
+
hunger = this.parseHungerBody(body);
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
this.diagnostics.error('parse.use-body', `\`use ${nameTok.text}\` takes no indented body — only \`use scoring\` and \`use hunger\` declare one.`, lineSpan(body[0]));
|
|
383
|
+
}
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
// `on every turn [while <cond>][, once]` — the story-owned daemon
|
|
387
|
+
// (ADR-236 D7, ratchet R4). The only clause form the header hosts;
|
|
388
|
+
// anything else keeps its owner-attached home.
|
|
389
|
+
if (peekedWord === 'on' || peekedWord === 'after') {
|
|
390
|
+
const clause = this.parseOnClause(peeked.indent, peekedWord);
|
|
391
|
+
span = (0, span_js_1.mergeSpans)(span, clause.span);
|
|
392
|
+
if (clause.clauseKind === 'on' && clause.binding === 'every-turn') {
|
|
393
|
+
onClauses.push(clause);
|
|
394
|
+
}
|
|
395
|
+
else if (!(clause.clauseKind === 'after' && clause.binding === 'every-turn')) {
|
|
396
|
+
// (`after every turn` already got its own parse error inside
|
|
397
|
+
// parseOnClause — don't stack a second diagnostic on it.)
|
|
398
|
+
this.diagnostics.error('parse.story-clause', 'The story header hosts only `on every turn` clauses (ADR-236 D7) — action and event clauses belong to the entity they are about.', clause.span);
|
|
399
|
+
}
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
228
402
|
const fieldLine = this.lines[this.pos++];
|
|
229
|
-
span = (0,
|
|
403
|
+
span = (0, span_js_1.mergeSpans)(span, lineSpan(fieldLine));
|
|
230
404
|
const key = firstWord(fieldLine);
|
|
231
405
|
// `states[, reversible]: a, b` — story phases (ratchet D2/D4).
|
|
232
406
|
if (key === 'states') {
|
|
@@ -246,6 +420,13 @@ class Parser {
|
|
|
246
420
|
scores.push(s);
|
|
247
421
|
continue;
|
|
248
422
|
}
|
|
423
|
+
// A rung outside a `use scoring` body (ADR-261 D2). Placing the ladder
|
|
424
|
+
// under the line that enables it is what stops a ladder silently doing
|
|
425
|
+
// nothing, so a stray rung is an error rather than a floating field.
|
|
426
|
+
if (key === 'rank') {
|
|
427
|
+
this.diagnostics.error('parse.rank-outside-scoring', '`rank "<name>" at <n>` belongs in the indented body of a `use scoring` line.', lineSpan(fieldLine));
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
249
430
|
const colon = fieldLine.tokens[1];
|
|
250
431
|
if (!key || !colon || colon.kind !== 'colon') {
|
|
251
432
|
this.diagnostics.error('parse.header-field', 'Expected `key: value` in the story header.', lineSpan(fieldLine));
|
|
@@ -254,7 +435,126 @@ class Parser {
|
|
|
254
435
|
const colonAt = fieldLine.raw.indexOf(':');
|
|
255
436
|
fields[key] = fieldLine.raw.slice(colonAt + 1).trim();
|
|
256
437
|
}
|
|
257
|
-
return { kind: 'story-header', title, author, fields, states, statesReversible, scores, span };
|
|
438
|
+
return { kind: 'story-header', title, author, fields, states, statesReversible, scores, onClauses, uses, usePhrasebooks, ranks, ...(hunger !== undefined ? { hunger } : {}), span };
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* The indented `use hunger` body (ADR-263 D1): `grows N each turn`,
|
|
442
|
+
* `<band> at <n> [says <key>]` rungs, and `fatal at N`.
|
|
443
|
+
*/
|
|
444
|
+
parseHungerBody(body) {
|
|
445
|
+
let grows;
|
|
446
|
+
let fatal;
|
|
447
|
+
const rungs = [];
|
|
448
|
+
for (const line of body) {
|
|
449
|
+
const first = firstWord(line);
|
|
450
|
+
if (first === 'grows') {
|
|
451
|
+
const c = new Cursor(line.tokens, line);
|
|
452
|
+
c.matchWord('grows');
|
|
453
|
+
const nTok = c.next();
|
|
454
|
+
if (!nTok || nTok.kind !== 'number' || !c.matchWord('each') || !c.matchWord('turn') || !c.atEnd()) {
|
|
455
|
+
this.diagnostics.error('parse.hunger-grows', 'Expected `grows <number> each turn`.', lineSpan(line));
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
grows = Number(nTok.text);
|
|
459
|
+
}
|
|
460
|
+
else if (first === 'fatal') {
|
|
461
|
+
const c = new Cursor(line.tokens, line);
|
|
462
|
+
c.matchWord('fatal');
|
|
463
|
+
if (!c.matchWord('at')) {
|
|
464
|
+
this.diagnostics.error('parse.hunger-fatal', 'Expected `fatal at <number>`.', lineSpan(line));
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const nTok = c.next();
|
|
468
|
+
if (!nTok || nTok.kind !== 'number' || !c.atEnd()) {
|
|
469
|
+
this.diagnostics.error('parse.hunger-fatal', 'Expected `fatal at <number>`.', lineSpan(line));
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
fatal = Number(nTok.text);
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
const rung = this.parseMeterRungLine(line);
|
|
476
|
+
if (rung)
|
|
477
|
+
rungs.push(rung);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return { grows, fatal, rungs, span: lineSpan(body[0]) };
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* `<band> at <n> [says <key>]` — one rung of a metering body (ADR-263 D1).
|
|
484
|
+
* The band is a bareword and doubles as the band id.
|
|
485
|
+
*/
|
|
486
|
+
parseMeterRungLine(line) {
|
|
487
|
+
const c = new Cursor(line.tokens, line);
|
|
488
|
+
const bandTok = c.next();
|
|
489
|
+
if (!bandTok || bandTok.kind !== 'word') {
|
|
490
|
+
this.diagnostics.error('parse.hunger-body', 'The `use hunger` body holds `grows N each turn`, `<band> at <n> [says <key>]` rungs, and `fatal at N`.', lineSpan(line));
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
if (!c.matchWord('at')) {
|
|
494
|
+
this.diagnostics.error('parse.meter-threshold', 'Expected `at <number>` after the band name.', c.restSpan());
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
const thresholdTok = c.next();
|
|
498
|
+
if (!thresholdTok || thresholdTok.kind !== 'number') {
|
|
499
|
+
this.diagnostics.error('parse.meter-threshold', 'Expected a number after `at`.', c.restSpan());
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
let phraseKey;
|
|
503
|
+
if (c.isWord('says')) {
|
|
504
|
+
c.next();
|
|
505
|
+
const keyTok = c.next();
|
|
506
|
+
if (!keyTok || keyTok.kind !== 'word') {
|
|
507
|
+
this.diagnostics.error('parse.meter-says', 'Expected a phrase key after `says`.', c.restSpan());
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
phraseKey = keyTok.text;
|
|
511
|
+
}
|
|
512
|
+
if (!c.atEnd()) {
|
|
513
|
+
this.diagnostics.error('parse.meter-extra', 'Unexpected text after the rung — the line is `<band> at <n> [says <key>]`.', c.restSpan());
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
return { kind: 'meter-rung', band: bandTok.text, threshold: Number(thresholdTok.text), phraseKey, span: lineSpan(line) };
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* `rank "<name>" at <n> [says <key>]` — one rung of the ladder, from the
|
|
520
|
+
* indented `use scoring` body (ADR-261 D2/D7).
|
|
521
|
+
*/
|
|
522
|
+
parseRankLine(line) {
|
|
523
|
+
const c = new Cursor(line.tokens, line);
|
|
524
|
+
if (!c.matchWord('rank')) {
|
|
525
|
+
this.diagnostics.error('parse.use-scoring-body', 'The `use scoring` body holds only `rank "<name>" at <n> [says <key>]` lines.', lineSpan(line));
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
const nameTok = c.peek();
|
|
529
|
+
if (!nameTok || nameTok.kind !== 'string') {
|
|
530
|
+
this.diagnostics.error('parse.rank-name', 'Expected a quoted rank name after `rank` — rank prose is author content, written the way entity names are.', c.restSpan());
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
c.next();
|
|
534
|
+
if (!c.matchWord('at')) {
|
|
535
|
+
this.diagnostics.error('parse.rank-threshold', 'Expected `at <number>` after the rank name — a threshold is absolute points, never a percentage of max.', c.restSpan());
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
const thresholdTok = c.next();
|
|
539
|
+
if (!thresholdTok || thresholdTok.kind !== 'number') {
|
|
540
|
+
this.diagnostics.error('parse.rank-threshold', 'Expected a number after `at`.', c.restSpan());
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
let phraseKey;
|
|
544
|
+
if (c.isWord('says')) {
|
|
545
|
+
c.next();
|
|
546
|
+
const keyTok = c.next();
|
|
547
|
+
if (!keyTok || keyTok.kind !== 'word') {
|
|
548
|
+
this.diagnostics.error('parse.rank-says', 'Expected a phrase key after `says` — a kebab-case key in the story\'s own phrase namespace.', c.restSpan());
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
phraseKey = keyTok.text;
|
|
552
|
+
}
|
|
553
|
+
if (!c.atEnd()) {
|
|
554
|
+
this.diagnostics.error('parse.rank-extra', 'Unexpected text after the rung — the line is `rank "<name>" at <n> [says <key>]`.', c.restSpan());
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
return { kind: 'rank', name: nameTok.text, threshold: Number(thresholdTok.text), phraseKey, span: lineSpan(line) };
|
|
258
558
|
}
|
|
259
559
|
/**
|
|
260
560
|
* Parse the tail of a `states` line after the `states` word:
|
|
@@ -313,10 +613,13 @@ class Parser {
|
|
|
313
613
|
kind: 'create',
|
|
314
614
|
name,
|
|
315
615
|
aka: [],
|
|
616
|
+
pronouns: [],
|
|
316
617
|
compositions: [],
|
|
618
|
+
startsStates: [],
|
|
317
619
|
placement: null,
|
|
318
620
|
wears: [],
|
|
319
621
|
carries: [],
|
|
622
|
+
containing: [],
|
|
320
623
|
exits: [],
|
|
321
624
|
blockedExits: [],
|
|
322
625
|
deadlyExits: [],
|
|
@@ -334,7 +637,7 @@ class Parser {
|
|
|
334
637
|
while (this.pos < this.lines.length && this.lines[this.pos].indent > 0) {
|
|
335
638
|
const line = this.lines[this.pos];
|
|
336
639
|
sawBlank = sawBlank || line.afterBlank;
|
|
337
|
-
decl.span = (0,
|
|
640
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
338
641
|
const word = firstWord(line);
|
|
339
642
|
const cur = new Cursor(line.tokens, line);
|
|
340
643
|
if (word === 'aka') {
|
|
@@ -342,6 +645,20 @@ class Parser {
|
|
|
342
645
|
cur.matchWord('aka');
|
|
343
646
|
decl.aka.push(...this.parseCommaWords(cur));
|
|
344
647
|
}
|
|
648
|
+
else if (word === 'pronouns') {
|
|
649
|
+
// ADR-242 D5: `pronouns <word>` — one word (a standard set or a
|
|
650
|
+
// `define pronouns` name). Person-only legality, word resolution,
|
|
651
|
+
// and duplicate-line rejection are the analyzer's gates.
|
|
652
|
+
this.pos++;
|
|
653
|
+
cur.matchWord('pronouns');
|
|
654
|
+
const wordTok = cur.next();
|
|
655
|
+
if (!wordTok || wordTok.kind !== 'word' || !cur.atEnd()) {
|
|
656
|
+
this.diagnostics.error('parse.pronouns-word', 'Expected one pronoun-set word after `pronouns` (e.g. `pronouns she`).', lineSpan(line));
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
decl.pronouns.push({ word: wordTok.text.toLowerCase(), span: lineSpan(line) });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
345
662
|
else if (word === 'states' && (line.tokens[1]?.kind === 'colon' || line.tokens[1]?.kind === 'comma')) {
|
|
346
663
|
this.pos++;
|
|
347
664
|
cur.next();
|
|
@@ -369,7 +686,20 @@ class Parser {
|
|
|
369
686
|
cur.matchWord('carries');
|
|
370
687
|
decl.carries.push(this.parseNameRef(cur, () => false));
|
|
371
688
|
}
|
|
689
|
+
else if (word === 'containing') {
|
|
690
|
+
// ADR-236 D2 (ratchet R2): region membership — `containing the
|
|
691
|
+
// Clearing, the Forest Path, and the Canyon View`. Additive across
|
|
692
|
+
// lines; region-block-only legality is the analyzer's gate.
|
|
693
|
+
this.pos++;
|
|
694
|
+
cur.matchWord('containing');
|
|
695
|
+
decl.containing.push(...this.parseNameRefList(cur, line));
|
|
696
|
+
}
|
|
372
697
|
else if (word === 'starts' && cur.isWord('in', 1)) {
|
|
698
|
+
// The `starts` dispatch is one-token lookahead (ADR-231 D5a):
|
|
699
|
+
// `starts in <place>` is placement (here); `starts <known-state>` is
|
|
700
|
+
// an initializer clause, handled by the composition-line fallthrough
|
|
701
|
+
// below (parseCompositionLine's `starts` branch — same branch that
|
|
702
|
+
// rejects unknown words after `starts`).
|
|
373
703
|
this.pos++;
|
|
374
704
|
cur.next();
|
|
375
705
|
cur.next();
|
|
@@ -426,8 +756,26 @@ class Parser {
|
|
|
426
756
|
this.pos++;
|
|
427
757
|
cur.next();
|
|
428
758
|
cur.next();
|
|
429
|
-
|
|
430
|
-
|
|
759
|
+
// `through` is reserved on exit lines as the door tail (ADR-234 D1,
|
|
760
|
+
// ratchet R2) — it stops the destination name.
|
|
761
|
+
const to = this.parseNameRef(cur, (t) => t.kind === 'word' && t.text === 'through');
|
|
762
|
+
let via = null;
|
|
763
|
+
if (cur.matchWord('through')) {
|
|
764
|
+
const doorRef = this.parseNameRef(cur, () => false);
|
|
765
|
+
if (doorRef.words.length === 0) {
|
|
766
|
+
this.diagnostics.error('parse.exit-through', 'Expected a door name after `through` (e.g. `north to the Hall through the oak door`).', lineSpan(line));
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
via = doorRef;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
// ADR-234 D4: `, one-way` is reserved (traversable in the written
|
|
773
|
+
// direction only) but NOT wired — a legible reservation error, not
|
|
774
|
+
// a generic parse failure, until its own ratchet entry lands.
|
|
775
|
+
if (cur.peek()?.kind === 'comma' && cur.isWord('one-way', 1)) {
|
|
776
|
+
this.diagnostics.error('parse.exit-one-way-reserved', '`, one-way` is reserved but not yet wired — exits and doors are bidirectional for now.', lineSpan(line));
|
|
777
|
+
}
|
|
778
|
+
decl.exits.push({ kind: 'exit', direction: word, to, via, span: lineSpan(line) });
|
|
431
779
|
}
|
|
432
780
|
else if (word && DIRECTIONS.has(word) && cur.isWord('is', 1) && cur.isWord('blocked', 2)) {
|
|
433
781
|
this.pos++;
|
|
@@ -465,7 +813,7 @@ class Parser {
|
|
|
465
813
|
}
|
|
466
814
|
else {
|
|
467
815
|
this.pos++;
|
|
468
|
-
decl.compositions.push(...this.parseCompositionLine(cur, line));
|
|
816
|
+
decl.compositions.push(...this.parseCompositionLine(cur, line, decl.startsStates));
|
|
469
817
|
}
|
|
470
818
|
}
|
|
471
819
|
else {
|
|
@@ -477,7 +825,7 @@ class Parser {
|
|
|
477
825
|
...decl.description,
|
|
478
826
|
text: `${decl.description.text}\n\n${prose.text}`,
|
|
479
827
|
markers: [...decl.description.markers, ...prose.markers],
|
|
480
|
-
span: (0,
|
|
828
|
+
span: (0, span_js_1.mergeSpans)(decl.description.span, prose.span),
|
|
481
829
|
};
|
|
482
830
|
}
|
|
483
831
|
else {
|
|
@@ -513,12 +861,12 @@ class Parser {
|
|
|
513
861
|
this.diagnostics.error('parse.blocked-exit', 'Expected `: <phrase-key>` after `is blocked`.', lineSpan(line));
|
|
514
862
|
return null;
|
|
515
863
|
}
|
|
516
|
-
const key =
|
|
517
|
-
if (!key
|
|
864
|
+
const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
865
|
+
if (!key) {
|
|
518
866
|
this.diagnostics.error('parse.blocked-exit', 'Expected a phrase key after `is blocked:`.', lineSpan(line));
|
|
519
867
|
return null;
|
|
520
868
|
}
|
|
521
|
-
return { kind: 'blocked-exit', direction, phraseKey: key
|
|
869
|
+
return { kind: 'blocked-exit', direction, phraseKey: key, condition, span: lineSpan(line) };
|
|
522
870
|
}
|
|
523
871
|
parseDeadlyExit(direction, line) {
|
|
524
872
|
// <direction> is deadly [while <condition>]: <phrase-key> (ADR-227) —
|
|
@@ -539,17 +887,52 @@ class Parser {
|
|
|
539
887
|
this.diagnostics.error('parse.deadly-exit', 'Expected `: <phrase-key>` after `is deadly`.', lineSpan(line));
|
|
540
888
|
return null;
|
|
541
889
|
}
|
|
542
|
-
const key =
|
|
543
|
-
if (!key
|
|
890
|
+
const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
891
|
+
if (!key) {
|
|
544
892
|
this.diagnostics.error('parse.deadly-exit', 'Expected a phrase key after `is deadly:`.', lineSpan(line));
|
|
545
893
|
return null;
|
|
546
894
|
}
|
|
547
|
-
return { kind: 'deadly-exit', direction, phraseKey: key
|
|
895
|
+
return { kind: 'deadly-exit', direction, phraseKey: key, condition, span: lineSpan(line) };
|
|
548
896
|
}
|
|
549
|
-
parseCompositionLine(c, line) {
|
|
897
|
+
parseCompositionLine(c, line, startsStates) {
|
|
550
898
|
const items = [];
|
|
551
899
|
while (!c.atEnd()) {
|
|
552
900
|
const startTok = c.peek();
|
|
901
|
+
// `starts <state>` initializer clause (ADR-231 D5a) — one-token
|
|
902
|
+
// lookahead on the `starts` word Chord already owns: a known state
|
|
903
|
+
// word (`locked`, `open`, …) initializes the paired trait's initial
|
|
904
|
+
// value; `in` is the placement line's spelling and can't ride a
|
|
905
|
+
// composition list; anything else is its own parse error, never a
|
|
906
|
+
// silent pass into trait-name resolution.
|
|
907
|
+
if (c.isWord('starts')) {
|
|
908
|
+
const startsTok = c.next();
|
|
909
|
+
const stateTok = c.peek();
|
|
910
|
+
if (stateTok && stateTok.kind === 'word' && catalog_js_1.STARTS_STATE_PAIRINGS.has(stateTok.text)) {
|
|
911
|
+
c.next();
|
|
912
|
+
startsStates.push({
|
|
913
|
+
kind: 'starts-state',
|
|
914
|
+
state: stateTok.text,
|
|
915
|
+
span: (0, span_js_1.mergeSpans)(startsTok.span, stateTok.span),
|
|
916
|
+
});
|
|
917
|
+
if (c.peek()?.kind === 'comma') {
|
|
918
|
+
c.next();
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
if (stateTok && stateTok.kind === 'word' && stateTok.text === 'in') {
|
|
924
|
+
this.diagnostics.error('parse.starts-state', '`starts in <place>` is a placement line of its own — it cannot ride a composition list.', (0, span_js_1.mergeSpans)(startsTok.span, stateTok.span));
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
const known = [...catalog_js_1.STARTS_STATE_PAIRINGS.keys()].join(', ');
|
|
928
|
+
this.diagnostics.error('parse.starts-state', stateTok
|
|
929
|
+
? `\`${stateTok.text}\` is not a state \`starts\` can initialize — known states: ${known} (placement is \`starts in <place>\`).`
|
|
930
|
+
: `Expected a state after \`starts\` — known states: ${known} (placement is \`starts in <place>\`).`, stateTok ? stateTok.span : startsTok.span);
|
|
931
|
+
}
|
|
932
|
+
while (!c.atEnd())
|
|
933
|
+
c.next(); // resynchronize: one mistake, one diagnostic
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
553
936
|
let article = null;
|
|
554
937
|
const first = c.peek();
|
|
555
938
|
if (first && first.kind === 'word' && ARTICLES.has(first.text)) {
|
|
@@ -583,7 +966,7 @@ class Parser {
|
|
|
583
966
|
words,
|
|
584
967
|
config,
|
|
585
968
|
condition,
|
|
586
|
-
span: (0,
|
|
969
|
+
span: (0, span_js_1.mergeSpans)(startTok.span, endTok.span),
|
|
587
970
|
});
|
|
588
971
|
if (c.peek()?.kind === 'comma')
|
|
589
972
|
c.next();
|
|
@@ -599,6 +982,33 @@ class Parser {
|
|
|
599
982
|
const settings = [];
|
|
600
983
|
for (;;) {
|
|
601
984
|
const startTok = c.peek();
|
|
985
|
+
// Ratchet R3 (ADR-234 D6): an article directly after `with`/`and`
|
|
986
|
+
// starts the adjective's single-entity config value — no keyword
|
|
987
|
+
// (`lockable with the iron key`). Keyed named fields (`with food the
|
|
988
|
+
// handful of feed` on authored traits) still parse below: their key
|
|
989
|
+
// words come first, so the article is not in first position.
|
|
990
|
+
if (startTok && startTok.kind === 'word' && ARTICLES.has(startTok.text)) {
|
|
991
|
+
c.next(); // article
|
|
992
|
+
const nameWords = [];
|
|
993
|
+
let lastTok = startTok;
|
|
994
|
+
while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('while')) {
|
|
995
|
+
lastTok = c.next();
|
|
996
|
+
nameWords.push(lastTok.text);
|
|
997
|
+
}
|
|
998
|
+
if (nameWords.length === 0) {
|
|
999
|
+
this.diagnostics.error('parse.config-value', 'Expected an entity name after the article.', c.restSpan());
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
settings.push({
|
|
1003
|
+
key: [],
|
|
1004
|
+
value: nameWords.join(' '),
|
|
1005
|
+
valueKind: 'name',
|
|
1006
|
+
span: (0, span_js_1.mergeSpans)(startTok.span, lastTok.span),
|
|
1007
|
+
});
|
|
1008
|
+
if (!c.matchWord('and'))
|
|
1009
|
+
break;
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
602
1012
|
const key = [];
|
|
603
1013
|
while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('while')) {
|
|
604
1014
|
const t = c.peek();
|
|
@@ -628,11 +1038,65 @@ class Parser {
|
|
|
628
1038
|
this.diagnostics.error('parse.config-value', 'Expected an entity name after the article.', c.restSpan());
|
|
629
1039
|
break;
|
|
630
1040
|
}
|
|
1041
|
+
// Ratchet R3 (ADR-234 D6): the `key`/`tool` config keywords are
|
|
1042
|
+
// removed — the entity is written directly after `with`. One form
|
|
1043
|
+
// per concept (Given 7); the fix-it names the replacement.
|
|
1044
|
+
if (key.length === 1 && (key[0] === 'key' || key[0] === 'tool')) {
|
|
1045
|
+
this.diagnostics.error('parse.removed-config-keyword', `\`with ${key[0]} the …\` was removed (ratchet R3) — write the entity directly: \`with the ${nameWords.join(' ')}\`.`, startTok ? (0, span_js_1.mergeSpans)(startTok.span, lastTok.span) : lastTok.span);
|
|
1046
|
+
if (!c.matchWord('and'))
|
|
1047
|
+
break;
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
631
1050
|
settings.push({
|
|
632
1051
|
key,
|
|
633
1052
|
value: nameWords.join(' '),
|
|
634
1053
|
valueKind: 'name',
|
|
635
|
-
span: startTok ? (0,
|
|
1054
|
+
span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, lastTok.span) : lastTok.span,
|
|
1055
|
+
});
|
|
1056
|
+
if (!c.matchWord('and'))
|
|
1057
|
+
break;
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
// `[ … ]` list value (ADR-215): a bracketed, comma-separated list of
|
|
1061
|
+
// name references (`patrol route [Hall, Study, Hall]`). Narrower than
|
|
1062
|
+
// ADR-216's statement-payload arrays — config lists hold names only.
|
|
1063
|
+
const bracketTok = c.peek();
|
|
1064
|
+
if (bracketTok && bracketTok.kind === 'lbracket' && key.length > 0) {
|
|
1065
|
+
c.next(); // [
|
|
1066
|
+
const listValues = [];
|
|
1067
|
+
for (;;) {
|
|
1068
|
+
if (c.peek()?.kind === 'rbracket')
|
|
1069
|
+
break;
|
|
1070
|
+
const ref = this.parseNameRef(c, () => false);
|
|
1071
|
+
if (ref.words.length === 0) {
|
|
1072
|
+
this.diagnostics.error('parse.config-list', 'Expected an entity name inside the `[ … ]` list.', c.restSpan());
|
|
1073
|
+
while (!c.atEnd() && c.peek().kind !== 'rbracket')
|
|
1074
|
+
c.next();
|
|
1075
|
+
break;
|
|
1076
|
+
}
|
|
1077
|
+
listValues.push(ref);
|
|
1078
|
+
if (c.peek()?.kind === 'comma')
|
|
1079
|
+
c.next();
|
|
1080
|
+
else
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1083
|
+
const close = c.next();
|
|
1084
|
+
let lastSpan = bracketTok.span;
|
|
1085
|
+
if (!close || close.kind !== 'rbracket') {
|
|
1086
|
+
this.diagnostics.error('parse.config-list', 'Expected `]` to close the list.', c.restSpan());
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
lastSpan = close.span;
|
|
1090
|
+
}
|
|
1091
|
+
if (listValues.length === 0) {
|
|
1092
|
+
this.diagnostics.error('parse.config-list', 'A `[ … ]` list needs at least one entry.', lastSpan);
|
|
1093
|
+
}
|
|
1094
|
+
settings.push({
|
|
1095
|
+
key,
|
|
1096
|
+
value: '',
|
|
1097
|
+
valueKind: 'list',
|
|
1098
|
+
listValues,
|
|
1099
|
+
span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, lastSpan) : lastSpan,
|
|
636
1100
|
});
|
|
637
1101
|
if (!c.matchWord('and'))
|
|
638
1102
|
break;
|
|
@@ -648,7 +1112,7 @@ class Parser {
|
|
|
648
1112
|
key,
|
|
649
1113
|
value: valueTok.text,
|
|
650
1114
|
valueKind: valueTok.kind,
|
|
651
|
-
span: startTok ? (0,
|
|
1115
|
+
span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, valueTok.span) : valueTok.span,
|
|
652
1116
|
});
|
|
653
1117
|
if (!c.matchWord('and'))
|
|
654
1118
|
break;
|
|
@@ -658,7 +1122,8 @@ class Parser {
|
|
|
658
1122
|
parsePhraseOverride(line) {
|
|
659
1123
|
const c = new Cursor(line.tokens, line);
|
|
660
1124
|
c.matchWord('phrase');
|
|
661
|
-
|
|
1125
|
+
// Key word validated by the caller; phrase-key = WORD { "." WORD } (ADR-231 D1b).
|
|
1126
|
+
const key = this.readLabelKey(c);
|
|
662
1127
|
// CP3: optional `, <strategy>` (the Z5 adverb set, retired fix-its
|
|
663
1128
|
// included) — `phrase present, cycling:`.
|
|
664
1129
|
let strategy = null;
|
|
@@ -713,11 +1178,11 @@ class Parser {
|
|
|
713
1178
|
const last = variants[variants.length - 1];
|
|
714
1179
|
return {
|
|
715
1180
|
kind: 'phrase-override',
|
|
716
|
-
key
|
|
1181
|
+
key,
|
|
717
1182
|
strategy,
|
|
718
1183
|
condition,
|
|
719
1184
|
variants,
|
|
720
|
-
span: (0,
|
|
1185
|
+
span: (0, span_js_1.mergeSpans)(lineSpan(line), last?.span ?? lineSpan(line)),
|
|
721
1186
|
};
|
|
722
1187
|
}
|
|
723
1188
|
/** Same-line phrase text (quoted or bare) was removed — grammar log 2026-07-10. */
|
|
@@ -742,6 +1207,33 @@ class Parser {
|
|
|
742
1207
|
out.push(current.join(' '));
|
|
743
1208
|
return out;
|
|
744
1209
|
}
|
|
1210
|
+
/**
|
|
1211
|
+
* A comma-separated list of entity name references, Oxford-`and` welcome
|
|
1212
|
+
* (`the Clearing, the Forest Path, and the Canyon View`). Each reference
|
|
1213
|
+
* keeps its own article and span; an empty list is a parse error.
|
|
1214
|
+
*/
|
|
1215
|
+
parseNameRefList(c, line) {
|
|
1216
|
+
const refs = [];
|
|
1217
|
+
for (;;) {
|
|
1218
|
+
const ref = this.parseNameRef(c, (t) => t.kind === 'word' && t.text === 'and');
|
|
1219
|
+
if (ref.words.length === 0) {
|
|
1220
|
+
this.diagnostics.error('parse.name-list', 'Expected an entity name.', c.atEnd() ? lineSpan(line) : c.restSpan());
|
|
1221
|
+
break;
|
|
1222
|
+
}
|
|
1223
|
+
refs.push(ref);
|
|
1224
|
+
if (c.peek()?.kind === 'comma') {
|
|
1225
|
+
c.next();
|
|
1226
|
+
c.matchWord('and'); // Oxford `and` after the comma
|
|
1227
|
+
}
|
|
1228
|
+
else if (!c.matchWord('and')) {
|
|
1229
|
+
break;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
if (!c.atEnd()) {
|
|
1233
|
+
this.diagnostics.error('parse.name-list', `Unexpected trailing text in name list: \`${c.peek().text}\`.`, c.restSpan());
|
|
1234
|
+
}
|
|
1235
|
+
return refs;
|
|
1236
|
+
}
|
|
745
1237
|
parseStateList(c) {
|
|
746
1238
|
const out = [];
|
|
747
1239
|
while (!c.atEnd()) {
|
|
@@ -786,7 +1278,7 @@ class Parser {
|
|
|
786
1278
|
const text = line.raw.trim();
|
|
787
1279
|
this.extractMarkers(line, markers);
|
|
788
1280
|
current.push(text);
|
|
789
|
-
span = span ? (0,
|
|
1281
|
+
span = span ? (0, span_js_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
|
|
790
1282
|
first = false;
|
|
791
1283
|
}
|
|
792
1284
|
if (current.length)
|
|
@@ -796,7 +1288,7 @@ class Parser {
|
|
|
796
1288
|
form: 'prose',
|
|
797
1289
|
text: paragraphs.map((p) => p.join(' ')).join('\n\n'),
|
|
798
1290
|
markers,
|
|
799
|
-
span: span ?? (0,
|
|
1291
|
+
span: span ?? (0, span_js_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
|
|
800
1292
|
};
|
|
801
1293
|
}
|
|
802
1294
|
/**
|
|
@@ -818,7 +1310,7 @@ class Parser {
|
|
|
818
1310
|
break;
|
|
819
1311
|
collected.push({ raw: line.raw, blankBefore: !first && line.afterBlank });
|
|
820
1312
|
this.extractMarkers(line, markers);
|
|
821
|
-
span = span ? (0,
|
|
1313
|
+
span = span ? (0, span_js_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
|
|
822
1314
|
this.pos++;
|
|
823
1315
|
first = false;
|
|
824
1316
|
}
|
|
@@ -834,14 +1326,14 @@ class Parser {
|
|
|
834
1326
|
form: 'verbatim',
|
|
835
1327
|
text: lines.join('\n'),
|
|
836
1328
|
markers,
|
|
837
|
-
span: span ?? (0,
|
|
1329
|
+
span: span ?? (0, span_js_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
|
|
838
1330
|
};
|
|
839
1331
|
}
|
|
840
1332
|
extractMarkers(line, into) {
|
|
841
1333
|
const re = /\{([^}]*)\}/g;
|
|
842
1334
|
let m;
|
|
843
1335
|
while ((m = re.exec(line.raw)) !== null) {
|
|
844
|
-
into.push({ content: m[1], span: (0,
|
|
1336
|
+
into.push({ content: m[1], span: (0, span_js_1.spanOf)(line.lineNo, m.index + 1, m[0].length) });
|
|
845
1337
|
}
|
|
846
1338
|
}
|
|
847
1339
|
/** Error-recovery only: a removed same-line quoted value, kept as prose text. */
|
|
@@ -850,7 +1342,7 @@ class Parser {
|
|
|
850
1342
|
const re = /\{([^}]*)\}/g;
|
|
851
1343
|
let m;
|
|
852
1344
|
while ((m = re.exec(tok.text)) !== null) {
|
|
853
|
-
markers.push({ content: m[1], span: (0,
|
|
1345
|
+
markers.push({ content: m[1], span: (0, span_js_1.spanOf)(tok.span.line, tok.span.column + 1 + m.index, m[0].length) });
|
|
854
1346
|
}
|
|
855
1347
|
return { kind: 'text', form: 'prose', text: tok.text, markers, span: tok.span };
|
|
856
1348
|
}
|
|
@@ -864,6 +1356,10 @@ class Parser {
|
|
|
864
1356
|
return this.parseDefineCondition();
|
|
865
1357
|
case 'phrase':
|
|
866
1358
|
return this.parseDefinePhrase();
|
|
1359
|
+
case 'phrasebook':
|
|
1360
|
+
// ADR-245/250 D1 (David 2026-07-21) — named, predicated phrase
|
|
1361
|
+
// collections; entries reuse the phrase-override grammar.
|
|
1362
|
+
return this.parseDefinePhrasebook();
|
|
867
1363
|
case 'phrases':
|
|
868
1364
|
return this.parseDefinePhrases();
|
|
869
1365
|
case 'verb':
|
|
@@ -879,8 +1375,15 @@ class Parser {
|
|
|
879
1375
|
return this.parseDefineTrait();
|
|
880
1376
|
case 'action':
|
|
881
1377
|
return this.parseDefineAction();
|
|
1378
|
+
case 'chain':
|
|
1379
|
+
return this.parseDefineChainHatch();
|
|
882
1380
|
case 'behavior':
|
|
883
|
-
|
|
1381
|
+
// ADR-235 D2 (removal, 2026-07-18): the behavior hatch carried no
|
|
1382
|
+
// trait/action binding key, so it structurally could never fire —
|
|
1383
|
+
// removed rather than repaired.
|
|
1384
|
+
this.diagnostics.error('parse.removed-behavior-hatch', '`define behavior … from` was removed (ADR-235 D2) — it had no binding key and could never fire. Author the behavior in-language (`define trait <name>` with `on <verb> it` clauses, composed on the entity), or ship a full action with `define action <name> from "<module>"`.', lineSpan(this.lines[this.pos]));
|
|
1385
|
+
this.pos++;
|
|
1386
|
+
return null;
|
|
884
1387
|
case 'score':
|
|
885
1388
|
// Removed — ratchet D12/CP5 (2026-07-11).
|
|
886
1389
|
this.diagnostics.error('parse.removed-score', '`define score` was removed (ownership package) — attach the score to its earning owner: `score <name> worth N` in the owner\'s create/trait/action block or the story header.', lineSpan(line));
|
|
@@ -888,6 +1391,29 @@ class Parser {
|
|
|
888
1391
|
return null;
|
|
889
1392
|
case 'sequence':
|
|
890
1393
|
return this.parseDefineSequence();
|
|
1394
|
+
case 'machine':
|
|
1395
|
+
// ADR-215 `use state-machines` depth (spelling A, 2026-07-18) —
|
|
1396
|
+
// the `use` requirement is the analyzer's gate.
|
|
1397
|
+
return this.parseDefineMachine();
|
|
1398
|
+
case 'sound':
|
|
1399
|
+
case 'image':
|
|
1400
|
+
case 'music':
|
|
1401
|
+
// ADR-216 declared assets — DATA references, never hatches.
|
|
1402
|
+
return this.parseDefineAsset(subWord);
|
|
1403
|
+
case 'ambient':
|
|
1404
|
+
case 'layer':
|
|
1405
|
+
// ADR-241 D2 — named family channels (an ambient bed / an image
|
|
1406
|
+
// layer), one-liners beside the asset declarations.
|
|
1407
|
+
return this.parseDefineFamilyChannel(subWord);
|
|
1408
|
+
case 'channel':
|
|
1409
|
+
// ADR-216 custom channels (spelling A, 2026-07-18) — data projections.
|
|
1410
|
+
return this.parseDefineChannel();
|
|
1411
|
+
case 'topics':
|
|
1412
|
+
// ADR-239 D3 (as amended) — the ask/tell topic table block.
|
|
1413
|
+
return this.parseDefineTopics();
|
|
1414
|
+
case 'pronouns':
|
|
1415
|
+
// ADR-242 D7 (ruled Q-1) — named pronoun set, five named rows.
|
|
1416
|
+
return this.parseDefinePronouns();
|
|
891
1417
|
default:
|
|
892
1418
|
this.diagnostics.error('parse.unknown-define', `Unknown declaration \`define ${subWord ?? ''}\`.`, lineSpan(line));
|
|
893
1419
|
this.recoverToTopLevel(true);
|
|
@@ -917,19 +1443,27 @@ class Parser {
|
|
|
917
1443
|
const c = new Cursor(headLine.tokens, headLine);
|
|
918
1444
|
c.next();
|
|
919
1445
|
c.next(); // define phrase
|
|
920
|
-
|
|
921
|
-
|
|
1446
|
+
// EBNF: phrase-key = WORD { "." WORD } (ADR-230 D5). Previously the
|
|
1447
|
+
// parser silently registered only the first segment (`if.action.taking`
|
|
1448
|
+
// became `if`), which made story-wide overrides of platform message ids
|
|
1449
|
+
// impossible.
|
|
1450
|
+
const key = this.readLabelKey(c) ?? '';
|
|
922
1451
|
if (!key) {
|
|
923
1452
|
this.diagnostics.error('parse.phrase-key', 'Expected a phrase key after `define phrase`.', lineSpan(headLine));
|
|
1453
|
+
c.next(); // skip the offending token so header options still parse
|
|
924
1454
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1455
|
+
const body = this.readPhraseBody(c, headLine, 'phrase');
|
|
1456
|
+
return { kind: 'define-phrase', key, ...body };
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Read a phrase body from a header cursor positioned after the key/alias:
|
|
1460
|
+
* the optional `, <strategy>|verbatim` header modifier, an optional trailing
|
|
1461
|
+
* `while <condition>` gate, then the indented variant lines up to
|
|
1462
|
+
* `end <endWord>`. Shared by `define phrase` and ADR-255's `override message`
|
|
1463
|
+
* so the two constructs never drift (ADR-255 D1). Returns the body fields
|
|
1464
|
+
* (the caller supplies the `kind` and key/alias).
|
|
1465
|
+
*/
|
|
1466
|
+
readPhraseBody(c, headLine, endWord) {
|
|
933
1467
|
let strategy = null;
|
|
934
1468
|
let verbatim = false;
|
|
935
1469
|
if (c.peek()?.kind === 'comma') {
|
|
@@ -959,15 +1493,22 @@ class Parser {
|
|
|
959
1493
|
}
|
|
960
1494
|
const variants = [];
|
|
961
1495
|
let span = lineSpan(headLine);
|
|
1496
|
+
let flaggedFlushLeft = false;
|
|
962
1497
|
for (;;) {
|
|
963
1498
|
const line = this.lines[this.pos];
|
|
964
1499
|
if (!line) {
|
|
965
|
-
this.diagnostics.error('parse.unterminated-block',
|
|
1500
|
+
this.diagnostics.error('parse.unterminated-block', `Missing \`end ${endWord}\`.`, span);
|
|
966
1501
|
break;
|
|
967
1502
|
}
|
|
1503
|
+
if (line.comment) {
|
|
1504
|
+
// ADR-249 — indented `##` stays prose here; only a flagged
|
|
1505
|
+
// (indent-0) comment line is an inside-block error.
|
|
1506
|
+
this.skipCommentInsideBlock(line);
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
968
1509
|
if (isEndLine(line)) {
|
|
969
|
-
span = (0,
|
|
970
|
-
this.consumeEnd(
|
|
1510
|
+
span = (0, span_js_1.mergeSpans)(span, lineSpan(line));
|
|
1511
|
+
this.consumeEnd(endWord, headLine);
|
|
971
1512
|
break;
|
|
972
1513
|
}
|
|
973
1514
|
if (firstWord(line) === 'or' && line.tokens.length === 1) {
|
|
@@ -978,19 +1519,153 @@ class Parser {
|
|
|
978
1519
|
continue;
|
|
979
1520
|
}
|
|
980
1521
|
if (line.indent === 0 && TOP_KEYWORDS.has(firstWord(line) ?? '')) {
|
|
981
|
-
this.diagnostics.error('parse.unterminated-block',
|
|
1522
|
+
this.diagnostics.error('parse.unterminated-block', `Missing \`end ${endWord}\`.`, span);
|
|
982
1523
|
break;
|
|
983
1524
|
}
|
|
1525
|
+
if (line.indent === 0) {
|
|
1526
|
+
// A flush-left non-keyword line makes zero progress in either
|
|
1527
|
+
// variant parser below (both require depth > 0) — without this
|
|
1528
|
+
// guard the loop appended empty variants until OOM. One diagnostic
|
|
1529
|
+
// for the first offending line; the rest of the flush-left run is
|
|
1530
|
+
// consumed so `end <endWord>` still terminates the block.
|
|
1531
|
+
if (!flaggedFlushLeft) {
|
|
1532
|
+
flaggedFlushLeft = true;
|
|
1533
|
+
this.diagnostics.error('parse.phrase-text-indent', `Text must be indented under \`${endWord === 'override' ? 'override message' : 'define phrase'}\`.`, lineSpan(line));
|
|
1534
|
+
}
|
|
1535
|
+
this.pos++;
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
984
1538
|
const variant = verbatim ? this.parseVerbatimBlock() : this.parseProseParagraph(1, 0);
|
|
985
1539
|
variants.push(variant);
|
|
986
|
-
span = (0,
|
|
1540
|
+
span = (0, span_js_1.mergeSpans)(span, variant.span);
|
|
1541
|
+
}
|
|
1542
|
+
return { strategy, verbatim, condition, variants, span };
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* `define phrasebook <name> [while <condition>] … end phrasebook`
|
|
1546
|
+
* (ADR-250 D1). Entries are phrase-override-shaped lines
|
|
1547
|
+
* (`<key>[, strategy]:` + `or` variants) at one indent level; the
|
|
1548
|
+
* body carries the ADR-249 comment guard like every end-terminated
|
|
1549
|
+
* block. Entry-level `while` parses here (override grammar) and is
|
|
1550
|
+
* gated in the analyzer (`analysis.phrasebook-entry-gate`).
|
|
1551
|
+
*/
|
|
1552
|
+
parseDefinePhrasebook() {
|
|
1553
|
+
const headLine = this.lines[this.pos++];
|
|
1554
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
1555
|
+
c.matchWord('define');
|
|
1556
|
+
c.matchWord('phrasebook');
|
|
1557
|
+
let name = '';
|
|
1558
|
+
const nameTok = c.next();
|
|
1559
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
1560
|
+
this.diagnostics.error('parse.phrasebook-header', 'Expected `define phrasebook <name> [while <condition>]` — a single kebab-case book name.', c.restSpan());
|
|
1561
|
+
}
|
|
1562
|
+
else {
|
|
1563
|
+
name = nameTok.text;
|
|
1564
|
+
}
|
|
1565
|
+
let condition = null;
|
|
1566
|
+
if (c.isWord('while')) {
|
|
1567
|
+
c.next();
|
|
1568
|
+
const condTokens = [];
|
|
1569
|
+
while (!c.atEnd())
|
|
1570
|
+
condTokens.push(c.next());
|
|
1571
|
+
condition = this.parseCondition(new Cursor(condTokens, headLine), headLine);
|
|
1572
|
+
}
|
|
1573
|
+
else if (!c.atEnd()) {
|
|
1574
|
+
this.diagnostics.error('parse.phrasebook-header', 'Unexpected text after the book name — only `while <condition>` may follow.', c.restSpan());
|
|
1575
|
+
}
|
|
1576
|
+
const entries = [];
|
|
1577
|
+
let span = lineSpan(headLine);
|
|
1578
|
+
for (;;) {
|
|
1579
|
+
const line = this.lines[this.pos];
|
|
1580
|
+
if (!line) {
|
|
1581
|
+
this.diagnostics.error('parse.phrasebook-end', 'Missing `end phrasebook`.', span);
|
|
1582
|
+
break;
|
|
1583
|
+
}
|
|
1584
|
+
if (line.comment) {
|
|
1585
|
+
// ADR-249: `##` inside a block is never a comment.
|
|
1586
|
+
this.skipCommentInsideBlock(line);
|
|
1587
|
+
continue;
|
|
1588
|
+
}
|
|
1589
|
+
if (isEndLine(line)) {
|
|
1590
|
+
span = (0, span_js_1.mergeSpans)(span, this.consumeEnd('phrasebook', headLine));
|
|
1591
|
+
break;
|
|
1592
|
+
}
|
|
1593
|
+
if (line.indent === 0) {
|
|
1594
|
+
this.diagnostics.error('parse.phrasebook-end', 'Missing `end phrasebook`.', span);
|
|
1595
|
+
break;
|
|
1596
|
+
}
|
|
1597
|
+
if (line.tokens[0]?.kind !== 'word' || !line.tokens.some((t) => t.kind === 'colon')) {
|
|
1598
|
+
this.diagnostics.error('parse.phrasebook-entry', 'Expected `<key>[, strategy]:` to open a phrasebook entry.', lineSpan(line));
|
|
1599
|
+
this.pos++;
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
this.pos++;
|
|
1603
|
+
const entry = this.parsePhraseOverride(line);
|
|
1604
|
+
entries.push(entry);
|
|
1605
|
+
span = (0, span_js_1.mergeSpans)(span, entry.span);
|
|
1606
|
+
}
|
|
1607
|
+
return { kind: 'define-phrasebook', name, condition, entries, span };
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* `import "<file>"` (ADR-251 D1) — the single generalized import form.
|
|
1611
|
+
* The path is extension-free (the compiler appends `.chord` at resolve
|
|
1612
|
+
* time — D2); any string is accepted at parse time. A bare word after
|
|
1613
|
+
* `import` (the removed `phrasebook` sub-word, or anything else) or a
|
|
1614
|
+
* missing string is `parse.import-form`.
|
|
1615
|
+
*/
|
|
1616
|
+
parseImport() {
|
|
1617
|
+
const line = this.lines[this.pos++];
|
|
1618
|
+
const c = new Cursor(line.tokens, line);
|
|
1619
|
+
c.matchWord('import');
|
|
1620
|
+
const pathTok = c.next();
|
|
1621
|
+
if (!pathTok || pathTok.kind !== 'string' || !c.atEnd()) {
|
|
1622
|
+
this.diagnostics.error('parse.import-form', 'Expected a quoted file name: `import "<file>"` (no extension — `.chord` is assumed).', c.restSpan());
|
|
1623
|
+
return null;
|
|
987
1624
|
}
|
|
988
|
-
return { kind: '
|
|
1625
|
+
return { kind: 'import', path: pathTok.text, span: lineSpan(line) };
|
|
989
1626
|
}
|
|
990
1627
|
parseDefinePhrases() {
|
|
991
1628
|
const headLine = this.lines[this.pos++];
|
|
992
1629
|
return this.parsePhrasesBlock(headLine, 2);
|
|
993
1630
|
}
|
|
1631
|
+
/**
|
|
1632
|
+
* ADR-255: dispatch the two override forms off the word after `override`.
|
|
1633
|
+
* `override message <alias> … end override` (full phrase body) vs.
|
|
1634
|
+
* `override messages <locale>` (locale block of `alias: text` entries).
|
|
1635
|
+
*/
|
|
1636
|
+
parseOverride() {
|
|
1637
|
+
const headLine = this.lines[this.pos];
|
|
1638
|
+
const kindTok = headLine.tokens[1];
|
|
1639
|
+
if (kindTok?.kind === 'word' && kindTok.text === 'messages') {
|
|
1640
|
+
return this.parseOverrideMessages();
|
|
1641
|
+
}
|
|
1642
|
+
if (kindTok?.kind === 'word' && kindTok.text === 'message') {
|
|
1643
|
+
return this.parseOverrideMessage();
|
|
1644
|
+
}
|
|
1645
|
+
this.diagnostics.error('parse.override', 'Expected `override message <alias>` or `override messages <locale>`.', lineSpan(headLine));
|
|
1646
|
+
this.pos++;
|
|
1647
|
+
return null;
|
|
1648
|
+
}
|
|
1649
|
+
/** `override message <alias> [, strategy] [while <cond>] … end override` (ADR-255 D1). */
|
|
1650
|
+
parseOverrideMessage() {
|
|
1651
|
+
const headLine = this.lines[this.pos++];
|
|
1652
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
1653
|
+
c.next(); // override
|
|
1654
|
+
c.next(); // message
|
|
1655
|
+
const alias = this.readLabelKey(c) ?? '';
|
|
1656
|
+
if (!alias) {
|
|
1657
|
+
this.diagnostics.error('parse.override-alias', 'Expected an override alias after `override message`.', lineSpan(headLine));
|
|
1658
|
+
c.next(); // skip the offending token so header options still parse
|
|
1659
|
+
}
|
|
1660
|
+
const body = this.readPhraseBody(c, headLine, 'override');
|
|
1661
|
+
return { kind: 'override-message', alias, ...body };
|
|
1662
|
+
}
|
|
1663
|
+
/** `override messages <locale>` — a locale block of `alias: text` entries (ADR-255 D1). */
|
|
1664
|
+
parseOverrideMessages() {
|
|
1665
|
+
const headLine = this.lines[this.pos++];
|
|
1666
|
+
const phrases = this.parsePhrasesBlock(headLine, 2);
|
|
1667
|
+
return { kind: 'override-messages', locale: phrases.locale, entries: phrases.entries, span: phrases.span };
|
|
1668
|
+
}
|
|
994
1669
|
/**
|
|
995
1670
|
* Parse a phrases block from its header line (`define phrases <locale>` at
|
|
996
1671
|
* top level, or `phrases <locale>` inside a trait/action, Phase B). The
|
|
@@ -1010,16 +1685,18 @@ class Parser {
|
|
|
1010
1685
|
let span = lineSpan(headLine);
|
|
1011
1686
|
while (this.pos < this.lines.length && this.lines[this.pos].indent > headLine.indent) {
|
|
1012
1687
|
const line = this.lines[this.pos];
|
|
1013
|
-
const
|
|
1014
|
-
const
|
|
1015
|
-
|
|
1688
|
+
const ec = new Cursor(line.tokens, line);
|
|
1689
|
+
const key = this.readLabelKey(ec); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
1690
|
+
const colon = ec.peek();
|
|
1691
|
+
if (!key || !colon || colon.kind !== 'colon') {
|
|
1016
1692
|
this.diagnostics.error('parse.phrase-entry', 'Expected `key: <text>` in the phrases block.', lineSpan(line));
|
|
1017
1693
|
this.pos++;
|
|
1018
1694
|
continue;
|
|
1019
1695
|
}
|
|
1696
|
+
ec.next(); // colon
|
|
1020
1697
|
this.pos++;
|
|
1021
1698
|
let value;
|
|
1022
|
-
const inline =
|
|
1699
|
+
const inline = ec.peek();
|
|
1023
1700
|
if (inline && inline.kind === 'string') {
|
|
1024
1701
|
this.reportSameLineText(inline.span);
|
|
1025
1702
|
value = this.textFromString(inline); // recovery: keep the text so analysis continues
|
|
@@ -1036,11 +1713,11 @@ class Parser {
|
|
|
1036
1713
|
else {
|
|
1037
1714
|
value = this.parseProseParagraph(line.indent + 1, line.indent);
|
|
1038
1715
|
if (value.text === '') {
|
|
1039
|
-
this.diagnostics.error('parse.phrase-entry-empty', `Phrase \`${key
|
|
1716
|
+
this.diagnostics.error('parse.phrase-entry-empty', `Phrase \`${key}\` has no text.`, lineSpan(line));
|
|
1040
1717
|
}
|
|
1041
1718
|
}
|
|
1042
|
-
entries.push({ key
|
|
1043
|
-
span = (0,
|
|
1719
|
+
entries.push({ key, value, span: (0, span_js_1.mergeSpans)(lineSpan(line), value.span) });
|
|
1720
|
+
span = (0, span_js_1.mergeSpans)(span, entries[entries.length - 1].span);
|
|
1044
1721
|
}
|
|
1045
1722
|
return { kind: 'define-phrases', locale, entries, span };
|
|
1046
1723
|
}
|
|
@@ -1074,7 +1751,7 @@ class Parser {
|
|
|
1074
1751
|
this.diagnostics.error('parse.verb-slot', 'Expected `(something)` slot in the verb pattern.', t.span);
|
|
1075
1752
|
return null;
|
|
1076
1753
|
}
|
|
1077
|
-
pattern.push({ kind: 'slot', word: slot.text, span: (0,
|
|
1754
|
+
pattern.push({ kind: 'slot', word: slot.text, span: (0, span_js_1.mergeSpans)(t.span, close.span) });
|
|
1078
1755
|
}
|
|
1079
1756
|
else if (t.kind === 'word') {
|
|
1080
1757
|
pattern.push({ kind: 'word', word: t.text, span: t.span });
|
|
@@ -1129,6 +1806,10 @@ class Parser {
|
|
|
1129
1806
|
});
|
|
1130
1807
|
while (this.pos < this.lines.length) {
|
|
1131
1808
|
const line = this.lines[this.pos];
|
|
1809
|
+
if (looksLikeComment(line)) {
|
|
1810
|
+
this.skipCommentInsideBlock(line);
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1132
1813
|
if (line.indent === 0) {
|
|
1133
1814
|
if (isEndLine(line))
|
|
1134
1815
|
break;
|
|
@@ -1173,13 +1854,17 @@ class Parser {
|
|
|
1173
1854
|
}
|
|
1174
1855
|
}
|
|
1175
1856
|
const endSpan = this.consumeEnd('trait', headLine);
|
|
1176
|
-
return build((0,
|
|
1857
|
+
return build((0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan));
|
|
1177
1858
|
}
|
|
1178
1859
|
/** `data` block fields: `locked: flag`, `body part: optional name`, `kind: one of a, b`. */
|
|
1179
1860
|
parseTraitFields(dataLine) {
|
|
1180
1861
|
const fields = [];
|
|
1181
1862
|
while (this.pos < this.lines.length && this.lines[this.pos].indent > dataLine.indent) {
|
|
1182
1863
|
const line = this.lines[this.pos++];
|
|
1864
|
+
if (looksLikeComment(line)) {
|
|
1865
|
+
this.reportCommentInsideBlock(line);
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1183
1868
|
const c = new Cursor(line.tokens, line);
|
|
1184
1869
|
const name = [];
|
|
1185
1870
|
while (!c.atEnd() && c.peek().kind === 'word')
|
|
@@ -1276,6 +1961,10 @@ class Parser {
|
|
|
1276
1961
|
const line = this.lines[this.pos];
|
|
1277
1962
|
if (line.indent === 0)
|
|
1278
1963
|
break; // dedent-terminated (no `end action`, design.md §2.3/§3.4)
|
|
1964
|
+
if (looksLikeComment(line)) {
|
|
1965
|
+
this.skipCommentInsideBlock(line);
|
|
1966
|
+
continue;
|
|
1967
|
+
}
|
|
1279
1968
|
const word = firstWord(line);
|
|
1280
1969
|
if (word === 'grammar' && line.tokens.length === 1) {
|
|
1281
1970
|
this.pos++;
|
|
@@ -1316,9 +2005,9 @@ class Parser {
|
|
|
1316
2005
|
this.diagnostics.error('parse.action-otherwise', 'Expected `otherwise refuse <phrase-key>`.', lineSpan(line));
|
|
1317
2006
|
}
|
|
1318
2007
|
else {
|
|
1319
|
-
const key =
|
|
1320
|
-
if (key
|
|
1321
|
-
otherwise = { phraseKey: key
|
|
2008
|
+
const key = this.readLabelKey(oc); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
2009
|
+
if (key)
|
|
2010
|
+
otherwise = { phraseKey: key, span: lineSpan(line) };
|
|
1322
2011
|
else
|
|
1323
2012
|
this.diagnostics.error('parse.action-otherwise', 'Expected a phrase key after `otherwise refuse`.', oc.restSpan());
|
|
1324
2013
|
}
|
|
@@ -1332,7 +2021,7 @@ class Parser {
|
|
|
1332
2021
|
if (stmt)
|
|
1333
2022
|
body.push(stmt);
|
|
1334
2023
|
}
|
|
1335
|
-
span = (0,
|
|
2024
|
+
span = (0, span_js_1.mergeSpans)(span, lineSpan(line));
|
|
1336
2025
|
}
|
|
1337
2026
|
return { kind: 'define-action', name: nameTok.text, patterns, constraints, musts, refusals, otherwise, scores, phrases, body, span };
|
|
1338
2027
|
}
|
|
@@ -1343,13 +2032,14 @@ class Parser {
|
|
|
1343
2032
|
*/
|
|
1344
2033
|
parseMustLine(line) {
|
|
1345
2034
|
const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
1346
|
-
if (colonIndex === -1 || colonIndex
|
|
2035
|
+
if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
|
|
1347
2036
|
this.diagnostics.error('parse.must', 'Expected `<subject> must <predicate>: <phrase-key>`.', lineSpan(line));
|
|
1348
2037
|
return null;
|
|
1349
2038
|
}
|
|
1350
|
-
const
|
|
1351
|
-
|
|
1352
|
-
|
|
2039
|
+
const keyCursor = new Cursor(line.tokens.slice(colonIndex + 1), line);
|
|
2040
|
+
const phraseKey = this.readLabelKey(keyCursor); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
2041
|
+
if (!phraseKey || !keyCursor.atEnd()) {
|
|
2042
|
+
this.diagnostics.error('parse.must', 'Expected a phrase key after the colon in the `must` requirement.', line.tokens[colonIndex + 1].span);
|
|
1353
2043
|
return null;
|
|
1354
2044
|
}
|
|
1355
2045
|
const c = new Cursor(line.tokens.slice(0, colonIndex), line);
|
|
@@ -1367,7 +2057,7 @@ class Parser {
|
|
|
1367
2057
|
const predicate = this.parseInfinitivePredicate(c, line);
|
|
1368
2058
|
if (!predicate)
|
|
1369
2059
|
return null;
|
|
1370
|
-
return { kind: 'must', subject, predicate, phraseKey
|
|
2060
|
+
return { kind: 'must', subject, predicate, phraseKey, span: lineSpan(line) };
|
|
1371
2061
|
}
|
|
1372
2062
|
/** Infinitive predicate after `must`: be / have / hold / wear / see / reach. */
|
|
1373
2063
|
parseInfinitivePredicate(c, line) {
|
|
@@ -1397,7 +2087,7 @@ class Parser {
|
|
|
1397
2087
|
if (nameTok && nameTok.kind === 'word' && this.isBareConditionRef(c, 1)) {
|
|
1398
2088
|
const anyTok = c.next();
|
|
1399
2089
|
c.next();
|
|
1400
|
-
return { kind: 'is-any', condition: nameTok.text, span: (0,
|
|
2090
|
+
return { kind: 'is-any', condition: nameTok.text, span: (0, span_js_1.mergeSpans)(anyTok.span, nameTok.span) };
|
|
1401
2091
|
}
|
|
1402
2092
|
}
|
|
1403
2093
|
// `be no <name>` — a negated requirement in disguise (decision 6):
|
|
@@ -1427,7 +2117,7 @@ class Parser {
|
|
|
1427
2117
|
case 'see':
|
|
1428
2118
|
case 'reach': {
|
|
1429
2119
|
const thing = this.parseNameRef(c, (tok) => tok.kind === 'word' && PHRASE_STOPS.has(tok.text));
|
|
1430
|
-
return { kind: 'can', ability: t.text, thing, span: (0,
|
|
2120
|
+
return { kind: 'can', ability: t.text, thing, span: (0, span_js_1.mergeSpans)(t.span, thing.span) };
|
|
1431
2121
|
}
|
|
1432
2122
|
default:
|
|
1433
2123
|
this.diagnostics.error('parse.must-predicate', `Unknown predicate \`${t.text}\` after \`must\` — expected be, have, hold, wear, see, or reach.`, t.span);
|
|
@@ -1453,7 +2143,7 @@ class Parser {
|
|
|
1453
2143
|
if (t.kind === 'colon') {
|
|
1454
2144
|
const slot = c.next();
|
|
1455
2145
|
if (slot && slot.kind === 'word') {
|
|
1456
|
-
parts.push({ kind: 'slot', word: slot.text, span: (0,
|
|
2146
|
+
parts.push({ kind: 'slot', word: slot.text, span: (0, span_js_1.mergeSpans)(t.span, slot.span) });
|
|
1457
2147
|
}
|
|
1458
2148
|
else {
|
|
1459
2149
|
this.diagnostics.error('parse.action-slot', 'Expected a slot name after `:`.', t.span);
|
|
@@ -1502,43 +2192,49 @@ class Parser {
|
|
|
1502
2192
|
if (form === 'without') {
|
|
1503
2193
|
const slot = c.next();
|
|
1504
2194
|
const colon = c.next();
|
|
1505
|
-
|
|
1506
|
-
|
|
2195
|
+
if (!slot || slot.kind !== 'word' || !colon || colon.kind !== 'colon') {
|
|
2196
|
+
this.diagnostics.error('parse.action-refusal', 'Expected `refuse without <slot>: <phrase-key>`.', lineSpan(line));
|
|
2197
|
+
return null;
|
|
2198
|
+
}
|
|
2199
|
+
const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
2200
|
+
if (!key) {
|
|
1507
2201
|
this.diagnostics.error('parse.action-refusal', 'Expected `refuse without <slot>: <phrase-key>`.', lineSpan(line));
|
|
1508
2202
|
return null;
|
|
1509
2203
|
}
|
|
1510
|
-
return { kind: 'without', slot: slot.text, condition: null, phraseKey: key
|
|
2204
|
+
return { kind: 'without', slot: slot.text, condition: null, phraseKey: key, span: lineSpan(line) };
|
|
1511
2205
|
}
|
|
1512
2206
|
// when: condition runs to the LAST colon; the key follows it.
|
|
1513
2207
|
const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
1514
|
-
if (colonIndex === -1 || colonIndex
|
|
2208
|
+
if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
|
|
1515
2209
|
this.diagnostics.error('parse.action-refusal', 'Expected `refuse when <condition>: <phrase-key>`.', lineSpan(line));
|
|
1516
2210
|
return null;
|
|
1517
2211
|
}
|
|
1518
2212
|
const condCursor = new Cursor(line.tokens.slice(2, colonIndex), line);
|
|
1519
2213
|
const condition = this.parseCondition(condCursor, line);
|
|
1520
|
-
const
|
|
1521
|
-
|
|
2214
|
+
const keyCursor = new Cursor(line.tokens.slice(colonIndex + 1), line);
|
|
2215
|
+
const key = this.readLabelKey(keyCursor); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
2216
|
+
if (!key || !keyCursor.atEnd()) {
|
|
1522
2217
|
this.diagnostics.error('parse.action-refusal', 'Expected a phrase key after the colon.', lineSpan(line));
|
|
1523
2218
|
return null;
|
|
1524
2219
|
}
|
|
1525
|
-
return { kind: 'when', slot: null, condition, phraseKey: key
|
|
2220
|
+
return { kind: 'when', slot: null, condition, phraseKey: key, span: lineSpan(line) };
|
|
1526
2221
|
}
|
|
1527
|
-
/** `define
|
|
1528
|
-
|
|
2222
|
+
/** `define chain <name> from "<module>"` — a TS chain hatch (ADR-094): the
|
|
2223
|
+
* named stdlib event chain is replaced by the module's handler. */
|
|
2224
|
+
parseDefineChainHatch() {
|
|
1529
2225
|
const line = this.lines[this.pos];
|
|
1530
2226
|
const c = new Cursor(line.tokens, line);
|
|
1531
2227
|
c.next();
|
|
1532
|
-
c.next(); // define
|
|
1533
|
-
const
|
|
1534
|
-
if (!
|
|
1535
|
-
this.diagnostics.error('parse.
|
|
2228
|
+
c.next(); // define chain
|
|
2229
|
+
const name = this.readLabelKey(c); // curated chain alias, single kebab word (ADR-254)
|
|
2230
|
+
if (!name) {
|
|
2231
|
+
this.diagnostics.error('parse.chain-hatch-name', 'Expected a chain name after `define chain` (e.g. `define chain opened-revealed from "…"`).', c.restSpan());
|
|
1536
2232
|
this.pos++;
|
|
1537
2233
|
return null;
|
|
1538
2234
|
}
|
|
1539
|
-
return this.parseHatchTail(line, c, '
|
|
2235
|
+
return this.parseHatchTail(line, c, 'chain', name);
|
|
1540
2236
|
}
|
|
1541
|
-
/** Shared `from "<module>"` tail for action/
|
|
2237
|
+
/** Shared `from "<module>"` tail for action / chain hatches. */
|
|
1542
2238
|
parseHatchTail(line, c, hatchKind, name) {
|
|
1543
2239
|
this.pos++;
|
|
1544
2240
|
if (!c.matchWord('from')) {
|
|
@@ -1569,6 +2265,10 @@ class Parser {
|
|
|
1569
2265
|
const line = this.lines[this.pos];
|
|
1570
2266
|
if (line.indent === 0)
|
|
1571
2267
|
break;
|
|
2268
|
+
if (looksLikeComment(line)) {
|
|
2269
|
+
this.skipCommentInsideBlock(line);
|
|
2270
|
+
continue;
|
|
2271
|
+
}
|
|
1572
2272
|
const sc = new Cursor(line.tokens, line);
|
|
1573
2273
|
let timing = null;
|
|
1574
2274
|
let turns = 0;
|
|
@@ -1615,7 +2315,138 @@ class Parser {
|
|
|
1615
2315
|
steps.push({ kind: 'sequence-step', timing, turns, owner, state, body, span: lineSpan(line) });
|
|
1616
2316
|
}
|
|
1617
2317
|
const endSpan = this.consumeEnd('sequence', headLine);
|
|
1618
|
-
return { kind: 'define-sequence', name, steps, span: (0,
|
|
2318
|
+
return { kind: 'define-sequence', name, steps, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
2319
|
+
}
|
|
2320
|
+
/**
|
|
2321
|
+
* `define topics for <entity> … end topics` (ADR-239 D3 as amended) —
|
|
2322
|
+
* the ask/tell topic table: `about` rows in two tiers (entity /
|
|
2323
|
+
* quoted free-text with comma-separated aliases), each answering with
|
|
2324
|
+
* a one-line statement or an indented statement body.
|
|
2325
|
+
*/
|
|
2326
|
+
parseDefineTopics() {
|
|
2327
|
+
const headLine = this.lines[this.pos++];
|
|
2328
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
2329
|
+
c.next();
|
|
2330
|
+
c.next(); // define topics
|
|
2331
|
+
let owner;
|
|
2332
|
+
if (!c.matchWord('for')) {
|
|
2333
|
+
this.diagnostics.error('parse.topics-for', 'Expected `for <entity>` after `define topics`.', c.restSpan());
|
|
2334
|
+
owner = { kind: 'name', article: null, words: [], span: lineSpan(headLine) };
|
|
2335
|
+
}
|
|
2336
|
+
else {
|
|
2337
|
+
owner = this.parseNameRef(c, () => false);
|
|
2338
|
+
if (owner.words.length === 0 || !c.atEnd()) {
|
|
2339
|
+
this.diagnostics.error('parse.topics-for', 'Expected `define topics for <entity>` — the owner name runs to the end of the line.', c.restSpan());
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
const decl = { kind: 'define-topics', owner, rows: [], span: lineSpan(headLine) };
|
|
2343
|
+
while (this.pos < this.lines.length) {
|
|
2344
|
+
const line = this.lines[this.pos];
|
|
2345
|
+
const word = firstWord(line);
|
|
2346
|
+
const lc = new Cursor(line.tokens, line);
|
|
2347
|
+
if (word === 'end' && lc.isWord('topics', 1)) {
|
|
2348
|
+
this.pos++;
|
|
2349
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2350
|
+
if (decl.rows.length === 0) {
|
|
2351
|
+
this.diagnostics.error('parse.topics-empty', 'This `define topics` block declares no rows — add `about …: <response>` rows, or remove the block.', decl.span);
|
|
2352
|
+
}
|
|
2353
|
+
return decl;
|
|
2354
|
+
}
|
|
2355
|
+
if (looksLikeComment(line)) {
|
|
2356
|
+
this.skipCommentInsideBlock(line);
|
|
2357
|
+
continue;
|
|
2358
|
+
}
|
|
2359
|
+
if (line.indent === 0)
|
|
2360
|
+
break; // dedent without `end topics` — reported below
|
|
2361
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2362
|
+
if (word === 'about') {
|
|
2363
|
+
const row = this.parseTopicRow(line);
|
|
2364
|
+
if (row) {
|
|
2365
|
+
decl.rows.push(row);
|
|
2366
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, row.span);
|
|
2367
|
+
}
|
|
2368
|
+
continue;
|
|
2369
|
+
}
|
|
2370
|
+
this.diagnostics.error('parse.topics-row', `Unrecognized line in \`define topics\`: \`${line.raw.trim()}\` — expected an \`about …: <response>\` row or \`end topics\`.`, lineSpan(line));
|
|
2371
|
+
this.pos++;
|
|
2372
|
+
}
|
|
2373
|
+
this.diagnostics.error('parse.topics-end', 'Expected `end topics` to close the block.', decl.span);
|
|
2374
|
+
if (decl.rows.length === 0) {
|
|
2375
|
+
this.diagnostics.error('parse.topics-empty', 'This `define topics` block declares no rows — add `about …: <response>` rows, or remove the block.', decl.span);
|
|
2376
|
+
}
|
|
2377
|
+
return decl;
|
|
2378
|
+
}
|
|
2379
|
+
/**
|
|
2380
|
+
* One `about …: <response>` row. Entity tier: `about the <entity>:`.
|
|
2381
|
+
* Free-text tier: `about "<text>"[, "<text>" …]:` — comma-separated
|
|
2382
|
+
* quoted aliases (spelling ruled 2026-07-18). The response is either the
|
|
2383
|
+
* rest of the line (one statement) or an indented statement body.
|
|
2384
|
+
*/
|
|
2385
|
+
parseTopicRow(headLine) {
|
|
2386
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
2387
|
+
c.next(); // about
|
|
2388
|
+
let filter;
|
|
2389
|
+
const first = c.peek();
|
|
2390
|
+
if (first && first.kind === 'string') {
|
|
2391
|
+
c.next();
|
|
2392
|
+
if (first.text.trim() === '') {
|
|
2393
|
+
this.diagnostics.error('parse.topics-row', 'A quoted topic cannot be empty.', first.span);
|
|
2394
|
+
this.pos++;
|
|
2395
|
+
return null;
|
|
2396
|
+
}
|
|
2397
|
+
const aliases = [];
|
|
2398
|
+
let span = first.span;
|
|
2399
|
+
while (c.peek()?.kind === 'comma') {
|
|
2400
|
+
c.next();
|
|
2401
|
+
const alias = c.next();
|
|
2402
|
+
if (!alias || alias.kind !== 'string' || alias.text.trim() === '') {
|
|
2403
|
+
this.diagnostics.error('parse.topics-alias', 'Expected a quoted alias after the comma — aliases are declared quoted spellings: `about "treasure", "the hoard": …`.', alias?.span ?? c.restSpan());
|
|
2404
|
+
this.pos++;
|
|
2405
|
+
return null;
|
|
2406
|
+
}
|
|
2407
|
+
aliases.push(alias.text);
|
|
2408
|
+
span = (0, span_js_1.mergeSpans)(span, alias.span);
|
|
2409
|
+
}
|
|
2410
|
+
filter = { kind: 'text', primary: first.text, aliases, span };
|
|
2411
|
+
}
|
|
2412
|
+
else {
|
|
2413
|
+
const ref = this.parseNameRef(c, () => false);
|
|
2414
|
+
if (ref.words.length === 0) {
|
|
2415
|
+
this.diagnostics.error('parse.topics-row', 'Expected an entity name or a quoted topic after `about`.', c.restSpan());
|
|
2416
|
+
this.pos++;
|
|
2417
|
+
return null;
|
|
2418
|
+
}
|
|
2419
|
+
filter = { kind: 'entity', ref };
|
|
2420
|
+
}
|
|
2421
|
+
const colon = c.next();
|
|
2422
|
+
if (!colon || colon.kind !== 'colon') {
|
|
2423
|
+
this.diagnostics.error('parse.topics-colon', 'Expected `:` after the topic key.', colon?.span ?? c.restSpan());
|
|
2424
|
+
this.pos++;
|
|
2425
|
+
return null;
|
|
2426
|
+
}
|
|
2427
|
+
let body;
|
|
2428
|
+
let span = lineSpan(headLine);
|
|
2429
|
+
if (!c.atEnd()) {
|
|
2430
|
+
// One-line response: the rest of the line is a single statement.
|
|
2431
|
+
// parseStatement consumes this.lines[this.pos] (the row line itself).
|
|
2432
|
+
const rest = { ...headLine, tokens: headLine.tokens.slice(c.i) };
|
|
2433
|
+
const stmt = this.parseStatement(rest, 'topics');
|
|
2434
|
+
if (!stmt)
|
|
2435
|
+
return null; // the statement error is already reported
|
|
2436
|
+
body = [stmt];
|
|
2437
|
+
}
|
|
2438
|
+
else {
|
|
2439
|
+
// Indented statement body on the following lines.
|
|
2440
|
+
this.pos++;
|
|
2441
|
+
body = this.parseStatements(headLine.indent, 'topics');
|
|
2442
|
+
if (body.length > 0)
|
|
2443
|
+
span = (0, span_js_1.mergeSpans)(span, body[body.length - 1].span);
|
|
2444
|
+
}
|
|
2445
|
+
if (body.length === 0) {
|
|
2446
|
+
this.diagnostics.error('parse.topics-response', 'Expected a response — a one-line statement after the `:`, or an indented statement body.', lineSpan(headLine));
|
|
2447
|
+
return null;
|
|
2448
|
+
}
|
|
2449
|
+
return { kind: 'topic-row', filter, body, span };
|
|
1619
2450
|
}
|
|
1620
2451
|
// ------------------------------------------------------------- on clause
|
|
1621
2452
|
/**
|
|
@@ -1704,8 +2535,629 @@ class Parser {
|
|
|
1704
2535
|
once,
|
|
1705
2536
|
ordering,
|
|
1706
2537
|
body,
|
|
1707
|
-
span: (0,
|
|
2538
|
+
span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan),
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
/**
|
|
2542
|
+
* ADR-216 media sugar statements: `play sound|music|ambient <asset>
|
|
2543
|
+
* [looping]`, `stop music|ambient`, `show image <asset> [in <layer>]`,
|
|
2544
|
+
* `hide image`, `transition <kind>`, `clear` — each with the standard
|
|
2545
|
+
* `when` suffix. Asset resolution/kind-checking is the analyzer's.
|
|
2546
|
+
*/
|
|
2547
|
+
parseMediaStatement(first, c, line) {
|
|
2548
|
+
c.next(); // the keyword
|
|
2549
|
+
const fail = (message) => {
|
|
2550
|
+
this.diagnostics.error('parse.media', message, lineSpan(line));
|
|
2551
|
+
return null;
|
|
1708
2552
|
};
|
|
2553
|
+
let form;
|
|
2554
|
+
let asset = null;
|
|
2555
|
+
let layer = null;
|
|
2556
|
+
let channel = null;
|
|
2557
|
+
let looping = false;
|
|
2558
|
+
let transitionKind = null;
|
|
2559
|
+
/** ADR-241 D3: optional `in <channel-word>` tail on the ambient forms. */
|
|
2560
|
+
const parseChannelTail = () => {
|
|
2561
|
+
if (!c.matchWord('in'))
|
|
2562
|
+
return true;
|
|
2563
|
+
const channelTok = c.next();
|
|
2564
|
+
if (!channelTok || channelTok.kind !== 'word') {
|
|
2565
|
+
fail('Expected a channel word after `in`.');
|
|
2566
|
+
return false;
|
|
2567
|
+
}
|
|
2568
|
+
channel = channelTok.text;
|
|
2569
|
+
return true;
|
|
2570
|
+
};
|
|
2571
|
+
if (first === 'play') {
|
|
2572
|
+
const what = c.next();
|
|
2573
|
+
if (!what || what.kind !== 'word' || !['sound', 'music', 'ambient'].includes(what.text)) {
|
|
2574
|
+
return fail('Expected `play sound|music|ambient <asset>`.');
|
|
2575
|
+
}
|
|
2576
|
+
const assetTok = c.next();
|
|
2577
|
+
if (!assetTok || assetTok.kind !== 'word')
|
|
2578
|
+
return fail(`Expected a declared asset name after \`play ${what.text}\`.`);
|
|
2579
|
+
asset = assetTok.text;
|
|
2580
|
+
if (what.text === 'music' && c.isWord('looping')) {
|
|
2581
|
+
c.next();
|
|
2582
|
+
looping = true;
|
|
2583
|
+
}
|
|
2584
|
+
if (what.text === 'ambient' && !parseChannelTail())
|
|
2585
|
+
return null;
|
|
2586
|
+
form = `play-${what.text}`;
|
|
2587
|
+
}
|
|
2588
|
+
else if (first === 'stop') {
|
|
2589
|
+
const what = c.next();
|
|
2590
|
+
if (!what || what.kind !== 'word' || !['music', 'ambient'].includes(what.text)) {
|
|
2591
|
+
return fail('Expected `stop music` or `stop ambient`.');
|
|
2592
|
+
}
|
|
2593
|
+
if (what.text === 'ambient' && !parseChannelTail())
|
|
2594
|
+
return null;
|
|
2595
|
+
form = `stop-${what.text}`;
|
|
2596
|
+
}
|
|
2597
|
+
else if (first === 'show') {
|
|
2598
|
+
if (!c.matchWord('image'))
|
|
2599
|
+
return fail('Expected `show image <asset> [in <layer>]`.');
|
|
2600
|
+
const assetTok = c.next();
|
|
2601
|
+
if (!assetTok || assetTok.kind !== 'word')
|
|
2602
|
+
return fail('Expected a declared asset name after `show image`.');
|
|
2603
|
+
asset = assetTok.text;
|
|
2604
|
+
if (c.matchWord('in')) {
|
|
2605
|
+
const layerTok = c.next();
|
|
2606
|
+
if (!layerTok || layerTok.kind !== 'word')
|
|
2607
|
+
return fail('Expected a layer word after `in`.');
|
|
2608
|
+
layer = layerTok.text;
|
|
2609
|
+
}
|
|
2610
|
+
form = 'show-image';
|
|
2611
|
+
}
|
|
2612
|
+
else if (first === 'hide') {
|
|
2613
|
+
if (!c.matchWord('image'))
|
|
2614
|
+
return fail('Expected `hide image`.');
|
|
2615
|
+
form = 'hide-image';
|
|
2616
|
+
}
|
|
2617
|
+
else if (first === 'transition') {
|
|
2618
|
+
const kindTok = c.next();
|
|
2619
|
+
if (!kindTok || kindTok.kind !== 'word')
|
|
2620
|
+
return fail('Expected a transition kind after `transition` (e.g. `transition fade`).');
|
|
2621
|
+
transitionKind = kindTok.text;
|
|
2622
|
+
form = 'transition';
|
|
2623
|
+
}
|
|
2624
|
+
else {
|
|
2625
|
+
form = 'clear';
|
|
2626
|
+
}
|
|
2627
|
+
const stmtWhen = this.parseStatementWhen(c, line);
|
|
2628
|
+
if (!c.atEnd())
|
|
2629
|
+
return fail(`Unexpected trailing text: \`${c.peek().text}\`.`);
|
|
2630
|
+
return { kind: 'media', form, asset, layer, channel, looping, transitionKind, stmtWhen, span: lineSpan(line) };
|
|
2631
|
+
}
|
|
2632
|
+
/**
|
|
2633
|
+
* `define channel <name> … end channel` (ADR-216; spelling A): keyword
|
|
2634
|
+
* body lines `mode <word>`, `gated by <capability>`, `from event
|
|
2635
|
+
* <dotted.key>`, `take <field>, …`. Value validation (mode set,
|
|
2636
|
+
* capability flags, required lines) is the analyzer's.
|
|
2637
|
+
*/
|
|
2638
|
+
parseDefineChannel() {
|
|
2639
|
+
const headLine = this.lines[this.pos++];
|
|
2640
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
2641
|
+
c.next();
|
|
2642
|
+
c.next(); // define channel
|
|
2643
|
+
const nameTok = c.next();
|
|
2644
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
2645
|
+
this.diagnostics.error('parse.channel-name', 'Expected a channel name after `define channel`.', lineSpan(headLine));
|
|
2646
|
+
return null;
|
|
2647
|
+
}
|
|
2648
|
+
const decl = {
|
|
2649
|
+
kind: 'define-channel',
|
|
2650
|
+
name: nameTok.text,
|
|
2651
|
+
mode: null,
|
|
2652
|
+
gatedBy: null,
|
|
2653
|
+
fromEvent: null,
|
|
2654
|
+
returns: null,
|
|
2655
|
+
span: lineSpan(headLine),
|
|
2656
|
+
};
|
|
2657
|
+
while (this.pos < this.lines.length) {
|
|
2658
|
+
const line = this.lines[this.pos];
|
|
2659
|
+
const word = firstWord(line);
|
|
2660
|
+
const lc = new Cursor(line.tokens, line);
|
|
2661
|
+
if (word === 'end' && lc.isWord('channel', 1)) {
|
|
2662
|
+
this.pos++;
|
|
2663
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2664
|
+
return decl;
|
|
2665
|
+
}
|
|
2666
|
+
if (looksLikeComment(line)) {
|
|
2667
|
+
this.skipCommentInsideBlock(line);
|
|
2668
|
+
continue;
|
|
2669
|
+
}
|
|
2670
|
+
if (line.indent === 0)
|
|
2671
|
+
break;
|
|
2672
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2673
|
+
this.pos++;
|
|
2674
|
+
if (word === 'mode') {
|
|
2675
|
+
lc.next();
|
|
2676
|
+
const modeTok = lc.next();
|
|
2677
|
+
if (!modeTok || modeTok.kind !== 'word' || !lc.atEnd()) {
|
|
2678
|
+
this.diagnostics.error('parse.channel-mode', 'Expected `mode replace|append|event`.', lineSpan(line));
|
|
2679
|
+
}
|
|
2680
|
+
else {
|
|
2681
|
+
decl.mode = modeTok.text;
|
|
2682
|
+
}
|
|
2683
|
+
continue;
|
|
2684
|
+
}
|
|
2685
|
+
if (word === 'gated' && lc.isWord('by', 1)) {
|
|
2686
|
+
lc.next();
|
|
2687
|
+
lc.next();
|
|
2688
|
+
const capTok = lc.next();
|
|
2689
|
+
if (!capTok || capTok.kind !== 'word' || !lc.atEnd()) {
|
|
2690
|
+
this.diagnostics.error('parse.channel-gate', 'Expected `gated by <capability>`.', lineSpan(line));
|
|
2691
|
+
}
|
|
2692
|
+
else {
|
|
2693
|
+
decl.gatedBy = capTok.text;
|
|
2694
|
+
}
|
|
2695
|
+
continue;
|
|
2696
|
+
}
|
|
2697
|
+
if (word === 'return') {
|
|
2698
|
+
// ADR-253 D1: `return <field | "text" | phrase <key>> from <event>` —
|
|
2699
|
+
// the event rides this clause (the standalone `from event`/`take` lines
|
|
2700
|
+
// are removed below).
|
|
2701
|
+
lc.next(); // return
|
|
2702
|
+
const returns = this.parseChannelReturn(lc, line);
|
|
2703
|
+
if (!returns)
|
|
2704
|
+
continue; // error already reported
|
|
2705
|
+
if (!lc.matchWord('from')) {
|
|
2706
|
+
this.diagnostics.error('parse.channel-return', 'Expected `from <event>` after the returned construct — `return <construct> from <event>`.', lineSpan(line));
|
|
2707
|
+
continue;
|
|
2708
|
+
}
|
|
2709
|
+
const key = this.readLabelKey(lc); // ADR-256: dotless Chord event id; the loader translates to the platform id
|
|
2710
|
+
if (!key || !lc.atEnd()) {
|
|
2711
|
+
this.diagnostics.error('parse.channel-return', 'Expected a single event id after `from`.', lineSpan(line));
|
|
2712
|
+
continue;
|
|
2713
|
+
}
|
|
2714
|
+
decl.returns = returns;
|
|
2715
|
+
decl.fromEvent = key;
|
|
2716
|
+
continue;
|
|
2717
|
+
}
|
|
2718
|
+
if (word === 'from' && lc.isWord('event', 1)) {
|
|
2719
|
+
this.diagnostics.error('parse.channel-from-removed', '`from event` was removed (ADR-253) — the event rides the `return … from <event>` clause: `return <construct> from <event>`.', lineSpan(line));
|
|
2720
|
+
continue;
|
|
2721
|
+
}
|
|
2722
|
+
if (word === 'take') {
|
|
2723
|
+
this.diagnostics.error('parse.channel-take-removed', '`take` was removed (ADR-253) — a channel `return`s a construct: `return <field> from <event>`, `return "text (slot)" from <event>`, or `return phrase <key> from <event>`.', lineSpan(line));
|
|
2724
|
+
continue;
|
|
2725
|
+
}
|
|
2726
|
+
this.diagnostics.error('parse.channel-body', `Unrecognized line in \`define channel\`: \`${line.raw.trim()}\` — expected \`mode\`, \`gated by\`, \`return … from <event>\`, or \`end channel\`.`, lineSpan(line));
|
|
2727
|
+
}
|
|
2728
|
+
this.diagnostics.error('parse.channel-end', 'Expected `end channel` to close the block.', decl.span);
|
|
2729
|
+
return decl;
|
|
2730
|
+
}
|
|
2731
|
+
/**
|
|
2732
|
+
* Parse the construct after `return` (ADR-253 D1), stopping before `from`:
|
|
2733
|
+
* a `"quoted"` text template, a `phrase <key>`, or a bare field word. On a
|
|
2734
|
+
* malformed construct reports `parse.channel-return` and returns null.
|
|
2735
|
+
*/
|
|
2736
|
+
parseChannelReturn(lc, line) {
|
|
2737
|
+
const tok = lc.peek();
|
|
2738
|
+
if (!tok) {
|
|
2739
|
+
this.diagnostics.error('parse.channel-return', 'Expected a field, a "text" template, or `phrase <key>` after `return`.', lineSpan(line));
|
|
2740
|
+
return null;
|
|
2741
|
+
}
|
|
2742
|
+
if (tok.kind === 'string') {
|
|
2743
|
+
lc.next();
|
|
2744
|
+
return { kind: 'text', text: tok.text };
|
|
2745
|
+
}
|
|
2746
|
+
if (tok.kind === 'word' && tok.text === 'phrase') {
|
|
2747
|
+
lc.next(); // phrase
|
|
2748
|
+
const key = this.readLabelKey(lc);
|
|
2749
|
+
if (!key) {
|
|
2750
|
+
this.diagnostics.error('parse.channel-return', 'Expected a phrase key after `return phrase`.', lineSpan(line));
|
|
2751
|
+
return null;
|
|
2752
|
+
}
|
|
2753
|
+
return { kind: 'phrase', phrase: key };
|
|
2754
|
+
}
|
|
2755
|
+
if (tok.kind === 'word') {
|
|
2756
|
+
const field = this.readLabelKey(lc);
|
|
2757
|
+
if (!field) {
|
|
2758
|
+
this.diagnostics.error('parse.channel-return', 'Expected a field name after `return`.', lineSpan(line));
|
|
2759
|
+
return null;
|
|
2760
|
+
}
|
|
2761
|
+
return { kind: 'field', field };
|
|
2762
|
+
}
|
|
2763
|
+
this.diagnostics.error('parse.channel-return', 'Expected a field, a "text" template, or `phrase <key>` after `return`.', lineSpan(line));
|
|
2764
|
+
return null;
|
|
2765
|
+
}
|
|
2766
|
+
/**
|
|
2767
|
+
* `define pronouns <name> … end pronouns` (ADR-242 D7, ruled Q-1):
|
|
2768
|
+
* five named rows, each `<case> <form>` — `subject`, `object`,
|
|
2769
|
+
* `possessive`, `possessive-pronoun`, `reflexive`. Row completeness,
|
|
2770
|
+
* duplicates, and standard-word shadowing are the analyzer's gates
|
|
2771
|
+
* (the parseDefineChannel split: parser collects, analyzer validates).
|
|
2772
|
+
*/
|
|
2773
|
+
parseDefinePronouns() {
|
|
2774
|
+
const headLine = this.lines[this.pos++];
|
|
2775
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
2776
|
+
c.next();
|
|
2777
|
+
c.next(); // define pronouns
|
|
2778
|
+
const nameTok = c.next();
|
|
2779
|
+
if (!nameTok || nameTok.kind !== 'word' || !c.atEnd()) {
|
|
2780
|
+
this.diagnostics.error('parse.pronouns-name', 'Expected a set name after `define pronouns` (e.g. `define pronouns ze`).', lineSpan(headLine));
|
|
2781
|
+
return null;
|
|
2782
|
+
}
|
|
2783
|
+
const decl = {
|
|
2784
|
+
kind: 'define-pronouns',
|
|
2785
|
+
name: nameTok.text.toLowerCase(),
|
|
2786
|
+
rows: [],
|
|
2787
|
+
span: lineSpan(headLine),
|
|
2788
|
+
};
|
|
2789
|
+
while (this.pos < this.lines.length) {
|
|
2790
|
+
const line = this.lines[this.pos];
|
|
2791
|
+
const word = firstWord(line);
|
|
2792
|
+
const lc = new Cursor(line.tokens, line);
|
|
2793
|
+
if (word === 'end' && lc.isWord('pronouns', 1)) {
|
|
2794
|
+
this.pos++;
|
|
2795
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2796
|
+
return decl;
|
|
2797
|
+
}
|
|
2798
|
+
if (looksLikeComment(line)) {
|
|
2799
|
+
this.skipCommentInsideBlock(line);
|
|
2800
|
+
continue;
|
|
2801
|
+
}
|
|
2802
|
+
if (line.indent === 0)
|
|
2803
|
+
break;
|
|
2804
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2805
|
+
this.pos++;
|
|
2806
|
+
if (word && catalog_js_1.PRONOUN_CASES.includes(word)) {
|
|
2807
|
+
lc.next();
|
|
2808
|
+
const formTok = lc.next();
|
|
2809
|
+
if (!formTok || formTok.kind !== 'word' || !lc.atEnd()) {
|
|
2810
|
+
this.diagnostics.error('parse.pronouns-row', `Expected one form word after \`${word}\` (e.g. \`${word} zir\`).`, lineSpan(line));
|
|
2811
|
+
}
|
|
2812
|
+
else {
|
|
2813
|
+
decl.rows.push({ case: word, form: formTok.text, span: lineSpan(line) });
|
|
2814
|
+
}
|
|
2815
|
+
continue;
|
|
2816
|
+
}
|
|
2817
|
+
this.diagnostics.error('parse.pronouns-body', `Unrecognized line in \`define pronouns\`: \`${line.raw.trim()}\` — expected \`${catalog_js_1.PRONOUN_CASES.join('`, `')}\`, or \`end pronouns\`.`, lineSpan(line));
|
|
2818
|
+
}
|
|
2819
|
+
this.diagnostics.error('parse.pronouns-end', 'Expected `end pronouns` to close the block.', decl.span);
|
|
2820
|
+
return decl;
|
|
2821
|
+
}
|
|
2822
|
+
/** `define sound|image|music <name> from "<file>"` (ADR-216) — data asset. */
|
|
2823
|
+
parseDefineAsset(assetKind) {
|
|
2824
|
+
const line = this.lines[this.pos++];
|
|
2825
|
+
const c = new Cursor(line.tokens, line);
|
|
2826
|
+
c.next();
|
|
2827
|
+
c.next(); // define <kind>
|
|
2828
|
+
const nameTok = c.next();
|
|
2829
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
2830
|
+
this.diagnostics.error('parse.asset-name', `Expected an asset name after \`define ${assetKind}\`.`, c.restSpan());
|
|
2831
|
+
return null;
|
|
2832
|
+
}
|
|
2833
|
+
if (!c.matchWord('from')) {
|
|
2834
|
+
this.diagnostics.error('parse.asset-from', `Expected \`from "<file>"\` in the ${assetKind} declaration.`, c.restSpan());
|
|
2835
|
+
return null;
|
|
2836
|
+
}
|
|
2837
|
+
const pathTok = c.next();
|
|
2838
|
+
if (!pathTok || pathTok.kind !== 'string') {
|
|
2839
|
+
this.diagnostics.error('parse.asset-path', 'Expected a quoted file path after `from`.', c.restSpan());
|
|
2840
|
+
return null;
|
|
2841
|
+
}
|
|
2842
|
+
return { kind: 'define-asset', assetKind, name: nameTok.text, path: pathTok.text, span: lineSpan(line) };
|
|
2843
|
+
}
|
|
2844
|
+
/**
|
|
2845
|
+
* `define ambient <word>` / `define layer <word>` (ADR-241 D2): a
|
|
2846
|
+
* one-line named family channel declaration. Exactly one word — the
|
|
2847
|
+
* bed/layer name; the registered id is the loader's business.
|
|
2848
|
+
*/
|
|
2849
|
+
parseDefineFamilyChannel(family) {
|
|
2850
|
+
const line = this.lines[this.pos++];
|
|
2851
|
+
const c = new Cursor(line.tokens, line);
|
|
2852
|
+
c.next();
|
|
2853
|
+
c.next(); // define ambient|layer
|
|
2854
|
+
const nameTok = c.next();
|
|
2855
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
2856
|
+
this.diagnostics.error('parse.channel-name', `Expected a ${family === 'ambient' ? 'bed' : 'layer'} name after \`define ${family}\`.`, c.restSpan());
|
|
2857
|
+
return null;
|
|
2858
|
+
}
|
|
2859
|
+
if (!c.atEnd()) {
|
|
2860
|
+
this.diagnostics.error('parse.channel-name', `Unexpected trailing text after \`define ${family} ${nameTok.text}\` — the declaration is one word.`, c.restSpan());
|
|
2861
|
+
return null;
|
|
2862
|
+
}
|
|
2863
|
+
return { kind: 'define-family-channel', family, name: nameTok.text, span: lineSpan(line) };
|
|
2864
|
+
}
|
|
2865
|
+
// ---------------------------------------------------------- emit payload
|
|
2866
|
+
/**
|
|
2867
|
+
* ADR-216 payload fields: `<field> <value>` separated by `and` at the
|
|
2868
|
+
* flat (statement) level, by commas inside `{ … }` objects. Values:
|
|
2869
|
+
* literals, `[ … ]` arrays, `{ … }` objects, or value expressions
|
|
2870
|
+
* (world-state reads).
|
|
2871
|
+
*/
|
|
2872
|
+
parseEmitFields(c, line, mode) {
|
|
2873
|
+
const fields = [];
|
|
2874
|
+
for (;;) {
|
|
2875
|
+
if (mode === 'braced' && c.peek()?.kind === 'rbrace')
|
|
2876
|
+
break;
|
|
2877
|
+
if (mode === 'flat' && (c.atEnd() || c.isWord('when')))
|
|
2878
|
+
break;
|
|
2879
|
+
const startTok = c.peek();
|
|
2880
|
+
const key = [];
|
|
2881
|
+
while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('when')) {
|
|
2882
|
+
const t = c.peek();
|
|
2883
|
+
if (ARTICLES.has(t.text) && key.length > 0)
|
|
2884
|
+
break;
|
|
2885
|
+
const after = c.peek(1);
|
|
2886
|
+
const atValuePosition = !after ||
|
|
2887
|
+
after.kind === 'comma' ||
|
|
2888
|
+
after.kind === 'rbrace' ||
|
|
2889
|
+
(after.kind === 'word' && (after.text === 'and' || after.text === 'when'));
|
|
2890
|
+
if (atValuePosition && key.length > 0)
|
|
2891
|
+
break;
|
|
2892
|
+
key.push(t.text);
|
|
2893
|
+
c.next();
|
|
2894
|
+
}
|
|
2895
|
+
if (key.length === 0) {
|
|
2896
|
+
this.diagnostics.error('parse.emit-payload', 'Expected a payload field name.', c.restSpan());
|
|
2897
|
+
break;
|
|
2898
|
+
}
|
|
2899
|
+
const value = this.parseEmitValue(c, line);
|
|
2900
|
+
if (!value)
|
|
2901
|
+
break;
|
|
2902
|
+
fields.push({ key, value, span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, value.span) : value.span });
|
|
2903
|
+
if (mode === 'flat') {
|
|
2904
|
+
if (!c.matchWord('and'))
|
|
2905
|
+
break;
|
|
2906
|
+
}
|
|
2907
|
+
else if (c.peek()?.kind === 'comma') {
|
|
2908
|
+
c.next();
|
|
2909
|
+
}
|
|
2910
|
+
else {
|
|
2911
|
+
break;
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
return fields;
|
|
2915
|
+
}
|
|
2916
|
+
/** One ADR-216 payload value (recursive: literals, arrays, objects, value exprs). */
|
|
2917
|
+
parseEmitValue(c, line) {
|
|
2918
|
+
const t = c.peek();
|
|
2919
|
+
if (!t) {
|
|
2920
|
+
this.diagnostics.error('parse.emit-payload', 'Expected a payload value.', c.restSpan());
|
|
2921
|
+
return null;
|
|
2922
|
+
}
|
|
2923
|
+
if (t.kind === 'number' || t.kind === 'string') {
|
|
2924
|
+
c.next();
|
|
2925
|
+
return { kind: 'literal', value: t.text, literalKind: t.kind, span: t.span };
|
|
2926
|
+
}
|
|
2927
|
+
if (t.kind === 'lbracket') {
|
|
2928
|
+
c.next();
|
|
2929
|
+
const items = [];
|
|
2930
|
+
while (c.peek() && c.peek().kind !== 'rbracket') {
|
|
2931
|
+
const item = this.parseEmitValue(c, line);
|
|
2932
|
+
if (!item)
|
|
2933
|
+
break;
|
|
2934
|
+
items.push(item);
|
|
2935
|
+
if (c.peek()?.kind === 'comma')
|
|
2936
|
+
c.next();
|
|
2937
|
+
else
|
|
2938
|
+
break;
|
|
2939
|
+
}
|
|
2940
|
+
const close = c.next();
|
|
2941
|
+
let endSpan = t.span;
|
|
2942
|
+
if (!close || close.kind !== 'rbracket') {
|
|
2943
|
+
this.diagnostics.error('parse.emit-payload', 'Expected `]` to close the array.', c.restSpan());
|
|
2944
|
+
}
|
|
2945
|
+
else {
|
|
2946
|
+
endSpan = close.span;
|
|
2947
|
+
}
|
|
2948
|
+
return { kind: 'array', items, span: (0, span_js_1.mergeSpans)(t.span, endSpan) };
|
|
2949
|
+
}
|
|
2950
|
+
if (t.kind === 'lbrace') {
|
|
2951
|
+
c.next();
|
|
2952
|
+
const fields = this.parseEmitFields(c, line, 'braced');
|
|
2953
|
+
const close = c.next();
|
|
2954
|
+
let endSpan = t.span;
|
|
2955
|
+
if (!close || close.kind !== 'rbrace') {
|
|
2956
|
+
this.diagnostics.error('parse.emit-payload', 'Expected `}` to close the object.', c.restSpan());
|
|
2957
|
+
}
|
|
2958
|
+
else {
|
|
2959
|
+
endSpan = close.span;
|
|
2960
|
+
}
|
|
2961
|
+
return { kind: 'object', fields, span: (0, span_js_1.mergeSpans)(t.span, endSpan) };
|
|
2962
|
+
}
|
|
2963
|
+
const expr = this.parseValueExpr(c, line, EMIT_VALUE_STOPS);
|
|
2964
|
+
return { kind: 'expr', expr, span: expr.span };
|
|
2965
|
+
}
|
|
2966
|
+
// -------------------------------------------------------------- machines
|
|
2967
|
+
/**
|
|
2968
|
+
* `define machine <name> … end machine` (ADR-215 `use state-machines`
|
|
2969
|
+
* depth; spelling A ratified 2026-07-18): `role <name> is <entity>`
|
|
2970
|
+
* bindings, `starts <state>`, and `state <name>[, terminal]` blocks. The
|
|
2971
|
+
* `use` requirement is the analyzer's gate.
|
|
2972
|
+
*/
|
|
2973
|
+
parseDefineMachine() {
|
|
2974
|
+
const headLine = this.lines[this.pos++];
|
|
2975
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
2976
|
+
c.next();
|
|
2977
|
+
c.next(); // define machine
|
|
2978
|
+
const name = [];
|
|
2979
|
+
while (!c.atEnd() && c.peek().kind === 'word')
|
|
2980
|
+
name.push(c.next().text);
|
|
2981
|
+
if (name.length === 0) {
|
|
2982
|
+
this.diagnostics.error('parse.machine-name', 'Expected a machine name after `define machine`.', lineSpan(headLine));
|
|
2983
|
+
}
|
|
2984
|
+
const decl = { kind: 'define-machine', name, roles: [], initialState: null, states: [], span: lineSpan(headLine) };
|
|
2985
|
+
while (this.pos < this.lines.length) {
|
|
2986
|
+
const line = this.lines[this.pos];
|
|
2987
|
+
const word = firstWord(line);
|
|
2988
|
+
const lc = new Cursor(line.tokens, line);
|
|
2989
|
+
if (word === 'end' && lc.isWord('machine', 1)) {
|
|
2990
|
+
this.pos++;
|
|
2991
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2992
|
+
return decl;
|
|
2993
|
+
}
|
|
2994
|
+
if (looksLikeComment(line)) {
|
|
2995
|
+
this.skipCommentInsideBlock(line);
|
|
2996
|
+
continue;
|
|
2997
|
+
}
|
|
2998
|
+
if (line.indent === 0)
|
|
2999
|
+
break; // dedent without `end machine` — reported below
|
|
3000
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
3001
|
+
if (word === 'role') {
|
|
3002
|
+
this.pos++;
|
|
3003
|
+
lc.next();
|
|
3004
|
+
const roleTok = lc.next();
|
|
3005
|
+
if (!roleTok || roleTok.kind !== 'word' || !lc.matchWord('is')) {
|
|
3006
|
+
this.diagnostics.error('parse.machine-role', 'Expected `role <name> is <entity>`.', lineSpan(line));
|
|
3007
|
+
continue;
|
|
3008
|
+
}
|
|
3009
|
+
const entity = this.parseNameRef(lc, () => false);
|
|
3010
|
+
if (entity.words.length === 0 || !lc.atEnd()) {
|
|
3011
|
+
this.diagnostics.error('parse.machine-role', 'Expected `role <name> is <entity>`.', lineSpan(line));
|
|
3012
|
+
continue;
|
|
3013
|
+
}
|
|
3014
|
+
decl.roles.push({ name: roleTok.text, entity, span: lineSpan(line) });
|
|
3015
|
+
continue;
|
|
3016
|
+
}
|
|
3017
|
+
if (word === 'starts') {
|
|
3018
|
+
this.pos++;
|
|
3019
|
+
lc.next();
|
|
3020
|
+
const stateTok = lc.next();
|
|
3021
|
+
if (!stateTok || stateTok.kind !== 'word' || !lc.atEnd()) {
|
|
3022
|
+
this.diagnostics.error('parse.machine-starts', 'Expected `starts <state>`.', lineSpan(line));
|
|
3023
|
+
continue;
|
|
3024
|
+
}
|
|
3025
|
+
if (decl.initialState !== null) {
|
|
3026
|
+
this.diagnostics.error('parse.machine-starts', 'This machine already declared its `starts` state.', lineSpan(line));
|
|
3027
|
+
continue;
|
|
3028
|
+
}
|
|
3029
|
+
decl.initialState = stateTok.text;
|
|
3030
|
+
continue;
|
|
3031
|
+
}
|
|
3032
|
+
if (word === 'state') {
|
|
3033
|
+
const state = this.parseMachineState(line);
|
|
3034
|
+
if (state) {
|
|
3035
|
+
decl.states.push(state);
|
|
3036
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, state.span);
|
|
3037
|
+
}
|
|
3038
|
+
continue;
|
|
3039
|
+
}
|
|
3040
|
+
this.diagnostics.error('parse.machine-body', `Unrecognized line in \`define machine\`: \`${line.raw.trim()}\` — expected \`role\`, \`starts\`, \`state\`, or \`end machine\`.`, lineSpan(line));
|
|
3041
|
+
this.pos++;
|
|
3042
|
+
}
|
|
3043
|
+
this.diagnostics.error('parse.machine-end', 'Expected `end machine` to close the block.', decl.span);
|
|
3044
|
+
return decl;
|
|
3045
|
+
}
|
|
3046
|
+
/** One `state <name>[, terminal]` block: transition lines + `on enter`/`on exit` bodies. */
|
|
3047
|
+
parseMachineState(headLine) {
|
|
3048
|
+
this.pos++;
|
|
3049
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
3050
|
+
c.next(); // state
|
|
3051
|
+
const nameTok = c.next();
|
|
3052
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
3053
|
+
this.diagnostics.error('parse.machine-state', 'Expected a state name after `state`.', lineSpan(headLine));
|
|
3054
|
+
return null;
|
|
3055
|
+
}
|
|
3056
|
+
let terminal = false;
|
|
3057
|
+
if (c.peek()?.kind === 'comma') {
|
|
3058
|
+
c.next();
|
|
3059
|
+
if (c.matchWord('terminal'))
|
|
3060
|
+
terminal = true;
|
|
3061
|
+
else
|
|
3062
|
+
this.diagnostics.error('parse.machine-state', 'Only `, terminal` may follow the state name.', c.restSpan());
|
|
3063
|
+
}
|
|
3064
|
+
const state = { name: nameTok.text, terminal, transitions: [], onEnter: [], onExit: [], span: lineSpan(headLine) };
|
|
3065
|
+
while (this.pos < this.lines.length) {
|
|
3066
|
+
const line = this.lines[this.pos];
|
|
3067
|
+
if (line.indent <= headLine.indent)
|
|
3068
|
+
break; // next state / end machine
|
|
3069
|
+
state.span = (0, span_js_1.mergeSpans)(state.span, lineSpan(line));
|
|
3070
|
+
const word = firstWord(line);
|
|
3071
|
+
if (word === 'when') {
|
|
3072
|
+
const transition = this.parseMachineTransition(line);
|
|
3073
|
+
if (transition)
|
|
3074
|
+
state.transitions.push(transition);
|
|
3075
|
+
continue;
|
|
3076
|
+
}
|
|
3077
|
+
if (word === 'on') {
|
|
3078
|
+
const lc = new Cursor(line.tokens, line);
|
|
3079
|
+
lc.next();
|
|
3080
|
+
const which = lc.next();
|
|
3081
|
+
if (which && which.kind === 'word' && (which.text === 'enter' || which.text === 'exit') && lc.atEnd()) {
|
|
3082
|
+
this.pos++;
|
|
3083
|
+
const body = this.parseStatements(line.indent, 'on');
|
|
3084
|
+
const endSpan = this.consumeEnd('on', line);
|
|
3085
|
+
state.span = (0, span_js_1.mergeSpans)(state.span, endSpan);
|
|
3086
|
+
if (which.text === 'enter')
|
|
3087
|
+
state.onEnter.push(...body);
|
|
3088
|
+
else
|
|
3089
|
+
state.onExit.push(...body);
|
|
3090
|
+
continue;
|
|
3091
|
+
}
|
|
3092
|
+
this.diagnostics.error('parse.machine-on', 'Only `on enter` and `on exit` blocks live in a machine `state`.', lineSpan(line));
|
|
3093
|
+
this.pos++;
|
|
3094
|
+
continue;
|
|
3095
|
+
}
|
|
3096
|
+
this.diagnostics.error('parse.machine-state-body', `Unrecognized line in a machine \`state\` block: \`${line.raw.trim()}\` — expected \`when …: <state>\`, \`on enter\`, or \`on exit\`.`, lineSpan(line));
|
|
3097
|
+
this.pos++;
|
|
3098
|
+
}
|
|
3099
|
+
return state;
|
|
3100
|
+
}
|
|
3101
|
+
/**
|
|
3102
|
+
* `when <trigger>[ while <condition>]: <target-state>`. Triggers:
|
|
3103
|
+
* `event <dotted.key>`, `<gerund> <entity name>` (action on a target),
|
|
3104
|
+
* a single bare word (analyzer resolves condition-vs-gerund), or a
|
|
3105
|
+
* condition.
|
|
3106
|
+
*/
|
|
3107
|
+
parseMachineTransition(line) {
|
|
3108
|
+
this.pos++;
|
|
3109
|
+
const tokens = line.tokens;
|
|
3110
|
+
const colonIndex = tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
3111
|
+
if (colonIndex === -1 || colonIndex !== tokens.length - 2 || tokens[tokens.length - 1].kind !== 'word') {
|
|
3112
|
+
this.diagnostics.error('parse.machine-when', 'Expected `when <trigger>[ while <condition>]: <target-state>`.', lineSpan(line));
|
|
3113
|
+
return null;
|
|
3114
|
+
}
|
|
3115
|
+
const target = tokens[tokens.length - 1].text;
|
|
3116
|
+
let head = tokens.slice(1, colonIndex);
|
|
3117
|
+
let condition = null;
|
|
3118
|
+
const whileIndex = head.findIndex((t) => t.kind === 'word' && t.text === 'while');
|
|
3119
|
+
if (whileIndex !== -1) {
|
|
3120
|
+
condition = this.parseCondition(new Cursor(head.slice(whileIndex + 1), line), line);
|
|
3121
|
+
head = head.slice(0, whileIndex);
|
|
3122
|
+
}
|
|
3123
|
+
const hc = new Cursor(head, line);
|
|
3124
|
+
const first = hc.peek();
|
|
3125
|
+
if (!first) {
|
|
3126
|
+
this.diagnostics.error('parse.machine-when', 'Expected a trigger after `when`.', lineSpan(line));
|
|
3127
|
+
return null;
|
|
3128
|
+
}
|
|
3129
|
+
let trigger;
|
|
3130
|
+
if (first.kind === 'word' && first.text === 'event') {
|
|
3131
|
+
hc.next();
|
|
3132
|
+
const key = this.readLabelKey(hc); // ADR-256: dotless Chord event id; story-loader translates to the platform id
|
|
3133
|
+
if (!key || !hc.atEnd()) {
|
|
3134
|
+
this.diagnostics.error('parse.machine-when', 'Expected `event <event.key>`.', lineSpan(line));
|
|
3135
|
+
return null;
|
|
3136
|
+
}
|
|
3137
|
+
trigger = { kind: 'event', event: key };
|
|
3138
|
+
}
|
|
3139
|
+
else if (first.kind === 'word' && !ARTICLES.has(first.text) && head.length === 1) {
|
|
3140
|
+
trigger = { kind: 'word', word: first.text, span: first.span };
|
|
3141
|
+
}
|
|
3142
|
+
else if (first.kind === 'word' && !ARTICLES.has(first.text) && head[1]?.kind === 'word' && (ARTICLES.has(head[1].text) || head[1].text === 'it')) {
|
|
3143
|
+
const action = hc.next().text;
|
|
3144
|
+
if (hc.isWord('it')) {
|
|
3145
|
+
// Machines are story-owned — there is no `it` (mirror of the
|
|
3146
|
+
// story-clause rule); name the entity or role.
|
|
3147
|
+
this.diagnostics.error('parse.machine-when', '`it` is not bound in a machine — name the entity or a declared role.', lineSpan(line));
|
|
3148
|
+
return null;
|
|
3149
|
+
}
|
|
3150
|
+
const targetRef = this.parseNameRef(hc, () => false);
|
|
3151
|
+
if (targetRef.words.length === 0 || !hc.atEnd()) {
|
|
3152
|
+
this.diagnostics.error('parse.machine-when', `Expected an entity or role name after \`${action}\`.`, lineSpan(line));
|
|
3153
|
+
return null;
|
|
3154
|
+
}
|
|
3155
|
+
trigger = { kind: 'action', action, target: targetRef };
|
|
3156
|
+
}
|
|
3157
|
+
else {
|
|
3158
|
+
trigger = { kind: 'condition', condition: this.parseCondition(hc, line) };
|
|
3159
|
+
}
|
|
3160
|
+
return { trigger, condition, target, span: lineSpan(line) };
|
|
1709
3161
|
}
|
|
1710
3162
|
// ------------------------------------------------------------ statements
|
|
1711
3163
|
/**
|
|
@@ -1720,6 +3172,10 @@ class Parser {
|
|
|
1720
3172
|
break;
|
|
1721
3173
|
if (isEndLine(line) || ((firstWord(line) === 'else' || firstWord(line) === 'or') && line.tokens.length === 1))
|
|
1722
3174
|
break;
|
|
3175
|
+
if (looksLikeComment(line)) {
|
|
3176
|
+
this.skipCommentInsideBlock(line);
|
|
3177
|
+
continue;
|
|
3178
|
+
}
|
|
1723
3179
|
const stmt = this.parseStatement(line, blockKeyword);
|
|
1724
3180
|
if (stmt)
|
|
1725
3181
|
body.push(stmt);
|
|
@@ -1755,7 +3211,7 @@ class Parser {
|
|
|
1755
3211
|
}
|
|
1756
3212
|
return this.parseRefuseWhenStatement(line);
|
|
1757
3213
|
}
|
|
1758
|
-
const key = this.
|
|
3214
|
+
const key = this.readLabelKey(c);
|
|
1759
3215
|
if (!key) {
|
|
1760
3216
|
this.diagnostics.error('parse.phrase-ref', `Expected a phrase key after \`${word}\`.`, c.restSpan());
|
|
1761
3217
|
return null;
|
|
@@ -1766,6 +3222,14 @@ class Parser {
|
|
|
1766
3222
|
this.reportRefusalInAfter(line);
|
|
1767
3223
|
return null;
|
|
1768
3224
|
}
|
|
3225
|
+
// Misordered prohibition (platform-issue-sweep Phase 8 #15c):
|
|
3226
|
+
// `refuse <key> when <condition>` used to leave the `when …`
|
|
3227
|
+
// tokens unconsumed and silently compile as an UNCONDITIONAL
|
|
3228
|
+
// refuse. Error with a fix-it instead.
|
|
3229
|
+
if (c.isWord('when')) {
|
|
3230
|
+
this.diagnostics.error('parse.refuse-order', `The condition comes first — write \`refuse when <condition>: ${key}\`.`, c.restSpan());
|
|
3231
|
+
return null;
|
|
3232
|
+
}
|
|
1769
3233
|
return { kind: 'refuse', phraseKey: key, params, span: lineSpan(line) };
|
|
1770
3234
|
}
|
|
1771
3235
|
const stmtWhen = this.parseStatementWhen(c, line);
|
|
@@ -1776,21 +3240,43 @@ class Parser {
|
|
|
1776
3240
|
if (next && next.indent > line.indent && !isStatementLine(next)) {
|
|
1777
3241
|
inlineText = this.parseProseParagraph(line.indent + 1, line.indent);
|
|
1778
3242
|
}
|
|
1779
|
-
const span = inlineText ? (0,
|
|
3243
|
+
const span = inlineText ? (0, span_js_1.mergeSpans)(lineSpan(line), inlineText.span) : lineSpan(line);
|
|
1780
3244
|
return { kind: 'phrase', phraseKey: key, params, inlineText, stmtWhen, span };
|
|
1781
3245
|
}
|
|
1782
3246
|
case 'emit': {
|
|
1783
3247
|
this.pos++;
|
|
1784
3248
|
c.next();
|
|
3249
|
+
// ADR-256: an event id is a single dotless Chord token (`media-sound-play`);
|
|
3250
|
+
// story-loader translates it to the platform's dotted id at the emit seam.
|
|
3251
|
+
// A `.` now raises `parse.dotted-key` (ADR-254 uniform).
|
|
1785
3252
|
const event = [];
|
|
1786
|
-
while (!c.atEnd() && !c.isWord('when'))
|
|
1787
|
-
|
|
3253
|
+
while (!c.atEnd() && !c.isWord('when') && !c.isWord('with')) {
|
|
3254
|
+
const segment = this.readLabelKey(c);
|
|
3255
|
+
if (!segment)
|
|
3256
|
+
break;
|
|
3257
|
+
event.push(segment);
|
|
3258
|
+
}
|
|
1788
3259
|
if (event.length === 0) {
|
|
1789
3260
|
this.diagnostics.error('parse.emit', 'Expected an event name after `emit`.', lineSpan(line));
|
|
1790
3261
|
return null;
|
|
1791
3262
|
}
|
|
3263
|
+
// ADR-216 payload: `with <field> <value> [and …]` — the create-data
|
|
3264
|
+
// grammar; bracketed/braced structures inside separate with commas.
|
|
3265
|
+
const payload = [];
|
|
3266
|
+
if (c.matchWord('with')) {
|
|
3267
|
+
payload.push(...this.parseEmitFields(c, line, 'flat'));
|
|
3268
|
+
}
|
|
1792
3269
|
const stmtWhen = this.parseStatementWhen(c, line);
|
|
1793
|
-
return { kind: 'emit', event, stmtWhen, span: lineSpan(line) };
|
|
3270
|
+
return { kind: 'emit', event, payload, stmtWhen, span: lineSpan(line) };
|
|
3271
|
+
}
|
|
3272
|
+
case 'play':
|
|
3273
|
+
case 'stop':
|
|
3274
|
+
case 'show':
|
|
3275
|
+
case 'hide':
|
|
3276
|
+
case 'transition':
|
|
3277
|
+
case 'clear': {
|
|
3278
|
+
this.pos++;
|
|
3279
|
+
return this.parseMediaStatement(word, c, line);
|
|
1794
3280
|
}
|
|
1795
3281
|
case 'set': {
|
|
1796
3282
|
this.pos++;
|
|
@@ -1917,38 +3403,53 @@ class Parser {
|
|
|
1917
3403
|
/** `refuse when <condition>: <key>` as a body statement (prohibition, D6). */
|
|
1918
3404
|
parseRefuseWhenStatement(line) {
|
|
1919
3405
|
const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
1920
|
-
if (colonIndex === -1 || colonIndex
|
|
3406
|
+
if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
|
|
1921
3407
|
this.diagnostics.error('parse.refuse-when', 'Expected `refuse when <condition>: <phrase-key>`.', lineSpan(line));
|
|
1922
3408
|
return null;
|
|
1923
3409
|
}
|
|
1924
|
-
const
|
|
1925
|
-
|
|
1926
|
-
|
|
3410
|
+
const keyCursor = new Cursor(line.tokens.slice(colonIndex + 1), line);
|
|
3411
|
+
const key = this.readLabelKey(keyCursor); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
3412
|
+
if (!key || !keyCursor.atEnd()) {
|
|
3413
|
+
this.diagnostics.error('parse.refuse-when', 'Expected a phrase key after the colon.', line.tokens[colonIndex + 1].span);
|
|
1927
3414
|
return null;
|
|
1928
3415
|
}
|
|
1929
3416
|
const condCursor = new Cursor(line.tokens.slice(2, colonIndex), line);
|
|
1930
3417
|
const condition = this.parseCondition(condCursor, line);
|
|
1931
|
-
return { kind: 'refuse-when', condition, phraseKey:
|
|
3418
|
+
return { kind: 'refuse-when', condition, phraseKey: key, span: lineSpan(line) };
|
|
1932
3419
|
}
|
|
1933
3420
|
/** `refuse`/`must` inside an `after` clause — reactions cannot refuse (D3). */
|
|
1934
3421
|
reportRefusalInAfter(line) {
|
|
1935
3422
|
this.diagnostics.error('parse.react-refusal', 'Refusals (`refuse`, `must`) cannot appear in an `after` clause — reactions run after the action succeeded. Use an `on` clause to intercept.', lineSpan(line));
|
|
1936
3423
|
}
|
|
1937
3424
|
/**
|
|
1938
|
-
* Read a
|
|
1939
|
-
*
|
|
3425
|
+
* Read a label/key: a single kebab-case `WORD` (ADR-254). A `.` in any label
|
|
3426
|
+
* position — author labels (phrase, exit, refusal keys) AND the event-type
|
|
3427
|
+
* sites (`emit`, channel `from event`, machine `when event`) — is a parse
|
|
3428
|
+
* error (`parse.dotted-key`). The ban is uniform: ADR-256 made the event-type
|
|
3429
|
+
* sites dotless too (the dotless Chord event id is translated to the platform's
|
|
3430
|
+
* dotted id in `@sharpee/story-loader`, so no dotted form ever appears in a
|
|
3431
|
+
* `.story`). Returns `null` when no word is present.
|
|
1940
3432
|
*/
|
|
1941
|
-
|
|
3433
|
+
readLabelKey(c) {
|
|
1942
3434
|
const first = c.peek();
|
|
1943
3435
|
if (!first || first.kind !== 'word')
|
|
1944
3436
|
return null;
|
|
1945
3437
|
c.next();
|
|
1946
|
-
|
|
1947
|
-
|
|
3438
|
+
const key = first.text;
|
|
3439
|
+
const dotFollows = () => c.peek()?.kind === 'punct' && c.peek().text === '.' && c.peek(1)?.kind === 'word';
|
|
3440
|
+
if (!dotFollows())
|
|
3441
|
+
return key;
|
|
3442
|
+
// ADR-254/256: a `.` in a label is a parse error. Consume the dotted
|
|
3443
|
+
// segments (one clean error, no cascade), flag the first dot, and hand back
|
|
3444
|
+
// the kebab-cased form so downstream parsing does not spuriously break.
|
|
3445
|
+
const dot = c.peek();
|
|
3446
|
+
const segments = [key];
|
|
3447
|
+
while (dotFollows()) {
|
|
1948
3448
|
c.next();
|
|
1949
|
-
|
|
3449
|
+
segments.push(c.next().text);
|
|
1950
3450
|
}
|
|
1951
|
-
|
|
3451
|
+
this.diagnostics.error('parse.dotted-key', `Labels are single words; "." is not allowed in "${segments.join('.')}" — use kebab-case, e.g. "${segments.join('-')}".`, dot.span);
|
|
3452
|
+
return segments.join('-');
|
|
1952
3453
|
}
|
|
1953
3454
|
parseParams(c, line) {
|
|
1954
3455
|
const params = [];
|
|
@@ -1964,7 +3465,7 @@ class Parser {
|
|
|
1964
3465
|
break;
|
|
1965
3466
|
}
|
|
1966
3467
|
const value = this.parseValueExpr(c, line, new Set(['with']));
|
|
1967
|
-
params.push({ param, value, span: start ? (0,
|
|
3468
|
+
params.push({ param, value, span: start ? (0, span_js_1.mergeSpans)(start.span, value.span) : value.span });
|
|
1968
3469
|
}
|
|
1969
3470
|
return params;
|
|
1970
3471
|
}
|
|
@@ -1982,7 +3483,7 @@ class Parser {
|
|
|
1982
3483
|
const stmt = this.parseStatement(line, 'ordinal');
|
|
1983
3484
|
if (stmt) {
|
|
1984
3485
|
body.push(stmt);
|
|
1985
|
-
span = (0,
|
|
3486
|
+
span = (0, span_js_1.mergeSpans)(span, stmt.span);
|
|
1986
3487
|
}
|
|
1987
3488
|
}
|
|
1988
3489
|
return { kind: 'ordinal', ordinal: ORDINALS[word], ordinalWord: word, body, span };
|
|
@@ -2009,7 +3510,7 @@ class Parser {
|
|
|
2009
3510
|
}
|
|
2010
3511
|
const body = this.parseStatements(headLine.indent, blockKeyword);
|
|
2011
3512
|
const endSpan = this.consumeEnd('each', headLine);
|
|
2012
|
-
return { kind: 'each', condition: nameTok.text, body, span: (0,
|
|
3513
|
+
return { kind: 'each', condition: nameTok.text, body, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
2013
3514
|
}
|
|
2014
3515
|
parseSelect(headLine) {
|
|
2015
3516
|
this.pos++;
|
|
@@ -2040,7 +3541,7 @@ class Parser {
|
|
|
2040
3541
|
}
|
|
2041
3542
|
}
|
|
2042
3543
|
const endSpan = this.consumeEnd('select', headLine);
|
|
2043
|
-
return { kind: 'select-on', subject, arms, span: (0,
|
|
3544
|
+
return { kind: 'select-on', subject, arms, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
2044
3545
|
}
|
|
2045
3546
|
const strategyTok = c.next();
|
|
2046
3547
|
if (!strategyTok || strategyTok.kind !== 'word' || !STRATEGIES.has(strategyTok.text)) {
|
|
@@ -2066,7 +3567,7 @@ class Parser {
|
|
|
2066
3567
|
break;
|
|
2067
3568
|
}
|
|
2068
3569
|
const endSpan = this.consumeEnd('select', headLine);
|
|
2069
|
-
return { kind: 'select-strategy', strategy: strategyTok.text, alternatives, span: (0,
|
|
3570
|
+
return { kind: 'select-strategy', strategy: strategyTok.text, alternatives, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
2070
3571
|
}
|
|
2071
3572
|
/** Consume the expected `end <keyword>` line; diagnose a mismatch or absence. */
|
|
2072
3573
|
consumeEnd(keyword, openLine) {
|
|
@@ -2113,10 +3614,10 @@ class Parser {
|
|
|
2113
3614
|
if (stop(t))
|
|
2114
3615
|
break;
|
|
2115
3616
|
words.push(t.text);
|
|
2116
|
-
span = span ? (0,
|
|
3617
|
+
span = span ? (0, span_js_1.mergeSpans)(span, t.span) : t.span;
|
|
2117
3618
|
c.next();
|
|
2118
3619
|
}
|
|
2119
|
-
return { kind: 'name', article, words, span: span ?? (0,
|
|
3620
|
+
return { kind: 'name', article, words, span: span ?? (0, span_js_1.spanOf)(c.line.lineNo, 1) };
|
|
2120
3621
|
}
|
|
2121
3622
|
/**
|
|
2122
3623
|
* Parse a value expression: literal, name reference, or possessive chain
|
|
@@ -2144,7 +3645,7 @@ class Parser {
|
|
|
2144
3645
|
if (m && m.kind === 'word' && m.text === 'match' && matchStandsAlone) {
|
|
2145
3646
|
c.next();
|
|
2146
3647
|
c.next();
|
|
2147
|
-
return { kind: 'match', span: (0,
|
|
3648
|
+
return { kind: 'match', span: (0, span_js_1.mergeSpans)(first.span, m.span) };
|
|
2148
3649
|
}
|
|
2149
3650
|
}
|
|
2150
3651
|
// `its <field>` — possessive on `it`.
|
|
@@ -2152,7 +3653,7 @@ class Parser {
|
|
|
2152
3653
|
c.next();
|
|
2153
3654
|
const field = this.collectWords(c, extraStops);
|
|
2154
3655
|
const base = { kind: 'ref', ref: { kind: 'name', article: null, words: ['it'], span: first.span }, span: first.span };
|
|
2155
|
-
return { kind: 'possessive', base, field: field.words, span: (0,
|
|
3656
|
+
return { kind: 'possessive', base, field: field.words, span: (0, span_js_1.mergeSpans)(first.span, field.span ?? first.span) };
|
|
2156
3657
|
}
|
|
2157
3658
|
let article = null;
|
|
2158
3659
|
let span = first.span;
|
|
@@ -2169,7 +3670,7 @@ class Parser {
|
|
|
2169
3670
|
if (PHRASE_STOPS.has(t.text) || extraStops.has(t.text))
|
|
2170
3671
|
break;
|
|
2171
3672
|
c.next();
|
|
2172
|
-
span = (0,
|
|
3673
|
+
span = (0, span_js_1.mergeSpans)(span, t.span);
|
|
2173
3674
|
const poss = /^(.*)'s$/.exec(t.text);
|
|
2174
3675
|
if (poss) {
|
|
2175
3676
|
words.push(poss[1]);
|
|
@@ -2189,7 +3690,7 @@ class Parser {
|
|
|
2189
3690
|
};
|
|
2190
3691
|
if (possessiveBase !== null) {
|
|
2191
3692
|
const field = this.collectWords(c, extraStops);
|
|
2192
|
-
return { kind: 'possessive', base: refExpr, field: field.words, span: (0,
|
|
3693
|
+
return { kind: 'possessive', base: refExpr, field: field.words, span: (0, span_js_1.mergeSpans)(span, field.span ?? span) };
|
|
2193
3694
|
}
|
|
2194
3695
|
return refExpr;
|
|
2195
3696
|
}
|
|
@@ -2201,7 +3702,7 @@ class Parser {
|
|
|
2201
3702
|
if (t.kind !== 'word' || PHRASE_STOPS.has(t.text) || extraStops.has(t.text))
|
|
2202
3703
|
break;
|
|
2203
3704
|
words.push(t.text);
|
|
2204
|
-
span = span ? (0,
|
|
3705
|
+
span = span ? (0, span_js_1.mergeSpans)(span, t.span) : t.span;
|
|
2205
3706
|
c.next();
|
|
2206
3707
|
}
|
|
2207
3708
|
return { words, span };
|
|
@@ -2217,7 +3718,7 @@ class Parser {
|
|
|
2217
3718
|
while (c.matchWord('or')) {
|
|
2218
3719
|
const right = this.parseConditionAnd(c, line);
|
|
2219
3720
|
operands.push(right);
|
|
2220
|
-
span = (0,
|
|
3721
|
+
span = (0, span_js_1.mergeSpans)(span, right.span);
|
|
2221
3722
|
}
|
|
2222
3723
|
return { kind: 'or', operands, span };
|
|
2223
3724
|
}
|
|
@@ -2230,7 +3731,7 @@ class Parser {
|
|
|
2230
3731
|
while (c.matchWord('and')) {
|
|
2231
3732
|
const right = this.parseConditionUnary(c, line);
|
|
2232
3733
|
operands.push(right);
|
|
2233
|
-
span = (0,
|
|
3734
|
+
span = (0, span_js_1.mergeSpans)(span, right.span);
|
|
2234
3735
|
}
|
|
2235
3736
|
return { kind: 'and', operands, span };
|
|
2236
3737
|
}
|
|
@@ -2243,7 +3744,19 @@ class Parser {
|
|
|
2243
3744
|
if (t.kind === 'word' && t.text === 'not') {
|
|
2244
3745
|
c.next();
|
|
2245
3746
|
const operand = this.parseConditionUnary(c, line);
|
|
2246
|
-
return { kind: 'not', operand, span: (0,
|
|
3747
|
+
return { kind: 'not', operand, span: (0, span_js_1.mergeSpans)(t.span, operand.span) };
|
|
3748
|
+
}
|
|
3749
|
+
// `client has <capability>` (ADR-216) — `client` is reserved in
|
|
3750
|
+
// condition-subject position; the capability word is the analyzer's gate.
|
|
3751
|
+
if (t.kind === 'word' && t.text === 'client' && c.isWord('has', 1)) {
|
|
3752
|
+
c.next();
|
|
3753
|
+
c.next(); // client has
|
|
3754
|
+
const capability = c.next();
|
|
3755
|
+
if (!capability || capability.kind !== 'word') {
|
|
3756
|
+
this.diagnostics.error('parse.client-has', 'Expected a capability word after `client has` (sound, images, …).', c.restSpan());
|
|
3757
|
+
return { kind: 'condition-ref', name: '', span: lineSpan(line) };
|
|
3758
|
+
}
|
|
3759
|
+
return { kind: 'client-has', capability: capability.text, span: (0, span_js_1.mergeSpans)(t.span, capability.span) };
|
|
2247
3760
|
}
|
|
2248
3761
|
if (t.kind === 'lparen') {
|
|
2249
3762
|
c.next();
|
|
@@ -2264,7 +3777,7 @@ class Parser {
|
|
|
2264
3777
|
this.diagnostics.error('parse.chance', 'Expected a number after `one chance in`.', n?.span ?? c.restSpan());
|
|
2265
3778
|
return { kind: 'chance', n: 0, span: t.span };
|
|
2266
3779
|
}
|
|
2267
|
-
return { kind: 'chance', n: Number(n.text), span: (0,
|
|
3780
|
+
return { kind: 'chance', n: Number(n.text), span: (0, span_js_1.mergeSpans)(t.span, n.span) };
|
|
2268
3781
|
}
|
|
2269
3782
|
// `any <name>` / `no <name>` — existential / negated existential over a
|
|
2270
3783
|
// named open condition (ratchet E1/E2, 2026-07-12). Triggers only on the
|
|
@@ -2279,7 +3792,7 @@ class Parser {
|
|
|
2279
3792
|
return {
|
|
2280
3793
|
kind: t.text === 'any' ? 'any-of' : 'none-of',
|
|
2281
3794
|
condition: nameTok.text,
|
|
2282
|
-
span: (0,
|
|
3795
|
+
span: (0, span_js_1.mergeSpans)(t.span, nameTok.span),
|
|
2283
3796
|
};
|
|
2284
3797
|
}
|
|
2285
3798
|
}
|
|
@@ -2321,7 +3834,7 @@ class Parser {
|
|
|
2321
3834
|
kind: 'predicate',
|
|
2322
3835
|
subject,
|
|
2323
3836
|
predicate: { kind: 'is-a', negated, classifier: cls.words, span: cls.span ?? t.span },
|
|
2324
|
-
span: (0,
|
|
3837
|
+
span: (0, span_js_1.mergeSpans)(subject.span, cls.span ?? t.span),
|
|
2325
3838
|
};
|
|
2326
3839
|
}
|
|
2327
3840
|
if (c.isWord('in')) {
|
|
@@ -2331,7 +3844,7 @@ class Parser {
|
|
|
2331
3844
|
kind: 'predicate',
|
|
2332
3845
|
subject,
|
|
2333
3846
|
predicate: { kind: 'is-in', negated, place, span: place.span },
|
|
2334
|
-
span: (0,
|
|
3847
|
+
span: (0, span_js_1.mergeSpans)(subject.span, place.span),
|
|
2335
3848
|
};
|
|
2336
3849
|
}
|
|
2337
3850
|
if (c.isWord('here')) {
|
|
@@ -2343,7 +3856,7 @@ class Parser {
|
|
|
2343
3856
|
kind: 'predicate',
|
|
2344
3857
|
subject,
|
|
2345
3858
|
predicate: { kind: 'is-here', negated, span: hereTok.span },
|
|
2346
|
-
span: (0,
|
|
3859
|
+
span: (0, span_js_1.mergeSpans)(subject.span, hereTok.span),
|
|
2347
3860
|
};
|
|
2348
3861
|
}
|
|
2349
3862
|
const value = this.parseValueExpr(c, line, new Set());
|
|
@@ -2351,7 +3864,7 @@ class Parser {
|
|
|
2351
3864
|
kind: 'predicate',
|
|
2352
3865
|
subject,
|
|
2353
3866
|
predicate: { kind: 'is', negated, value, span: value.span },
|
|
2354
|
-
span: (0,
|
|
3867
|
+
span: (0, span_js_1.mergeSpans)(subject.span, value.span),
|
|
2355
3868
|
};
|
|
2356
3869
|
}
|
|
2357
3870
|
if (t.text === 'has' || t.text === 'holds' || t.text === 'wears') {
|
|
@@ -2361,7 +3874,7 @@ class Parser {
|
|
|
2361
3874
|
kind: 'predicate',
|
|
2362
3875
|
subject,
|
|
2363
3876
|
predicate: { kind: t.text, thing, span: thing.span },
|
|
2364
|
-
span: (0,
|
|
3877
|
+
span: (0, span_js_1.mergeSpans)(subject.span, thing.span),
|
|
2365
3878
|
};
|
|
2366
3879
|
}
|
|
2367
3880
|
if (t.text === 'can') {
|
|
@@ -2376,8 +3889,8 @@ class Parser {
|
|
|
2376
3889
|
return {
|
|
2377
3890
|
kind: 'predicate',
|
|
2378
3891
|
subject,
|
|
2379
|
-
predicate: { kind: 'can', ability: ability.text, thing, span: (0,
|
|
2380
|
-
span: (0,
|
|
3892
|
+
predicate: { kind: 'can', ability: ability.text, thing, span: (0, span_js_1.mergeSpans)(ability.span, thing.span) },
|
|
3893
|
+
span: (0, span_js_1.mergeSpans)(subject.span, thing.span),
|
|
2381
3894
|
};
|
|
2382
3895
|
}
|
|
2383
3896
|
this.diagnostics.error('parse.predicate', `Unknown predicate \`${t.text}\` — expected is, is a, is in, has, holds, wears, or can see/reach.`, t.span);
|