@sharpee/chord 3.0.0 → 3.3.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 +1217 -33
- package/analyzer.js.map +1 -1
- package/ast.d.ts +458 -9
- package/ast.d.ts.map +1 -1
- package/catalog.d.ts +57 -1
- package/catalog.d.ts.map +1 -1
- package/catalog.js +95 -3
- package/catalog.js.map +1 -1
- package/diagnostics.d.ts +1 -1
- package/diagnostics.d.ts.map +1 -1
- package/index.d.ts +31 -13
- package/index.d.ts.map +1 -1
- package/index.js +110 -30
- package/index.js.map +1 -1
- package/ir.d.ts +316 -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/index.d.ts +21 -0
- package/manifests/index.d.ts.map +1 -0
- package/manifests/index.js +46 -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/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 +830 -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 +1566 -148
- 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/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,9 +82,18 @@ 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
|
-
'refuse', 'phrase', 'emit', 'set', 'change', 'move', 'remove', 'award', 'win', 'lose',
|
|
96
|
+
'refuse', 'phrase', 'emit', 'set', 'change', 'move', 'remove', 'award', 'win', 'lose', 'kill',
|
|
85
97
|
'if', 'select', 'each', 'end', 'else', 'or', 'when', 'at',
|
|
86
98
|
]);
|
|
87
99
|
/**
|
|
@@ -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,12 @@ 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;
|
|
168
216
|
case 'each':
|
|
169
217
|
// Never top-level (given 9: all behavior is owned) — ratchet E3.
|
|
170
218
|
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 +228,18 @@ class Parser {
|
|
|
180
228
|
kind: 'story-file',
|
|
181
229
|
header,
|
|
182
230
|
declarations,
|
|
183
|
-
span: last ? (0,
|
|
231
|
+
span: last ? (0, span_js_1.mergeSpans)(start, lineSpan(last)) : start,
|
|
184
232
|
};
|
|
185
233
|
}
|
|
234
|
+
/** ADR-249: the one inside-block comment diagnostic, with its fix-it. */
|
|
235
|
+
reportCommentInsideBlock(line) {
|
|
236
|
+
this.diagnostics.error('parse.comment-inside-block', 'Comments are only legal outside blocks, at the top level of the story file.', lineSpan(line));
|
|
237
|
+
}
|
|
238
|
+
/** Report an inside-block comment and consume the line (block parsing continues). */
|
|
239
|
+
skipCommentInsideBlock(line) {
|
|
240
|
+
this.reportCommentInsideBlock(line);
|
|
241
|
+
this.pos++;
|
|
242
|
+
}
|
|
186
243
|
/** Skip lines until the next top-level keyword at indent 0. */
|
|
187
244
|
recoverToTopLevel(consumeCurrent = false) {
|
|
188
245
|
if (consumeCurrent)
|
|
@@ -223,10 +280,73 @@ class Parser {
|
|
|
223
280
|
const states = [];
|
|
224
281
|
let statesReversible = false;
|
|
225
282
|
const scores = [];
|
|
283
|
+
const onClauses = [];
|
|
284
|
+
const uses = [];
|
|
285
|
+
const usePhrasebooks = [];
|
|
226
286
|
let span = lineSpan(line);
|
|
227
287
|
while (this.pos < this.lines.length && this.lines[this.pos].indent > 0) {
|
|
288
|
+
const peeked = this.lines[this.pos];
|
|
289
|
+
const peekedWord = firstWord(peeked);
|
|
290
|
+
// `use <extension>` — a trusted platform extension (ADR-215): static,
|
|
291
|
+
// one name per line; the analyzer gates the name against the manifest
|
|
292
|
+
// registry.
|
|
293
|
+
if (peekedWord === 'use') {
|
|
294
|
+
const useLine = this.lines[this.pos++];
|
|
295
|
+
span = (0, span_js_1.mergeSpans)(span, lineSpan(useLine));
|
|
296
|
+
const uc = new Cursor(useLine.tokens, useLine);
|
|
297
|
+
uc.matchWord('use');
|
|
298
|
+
// ADR-250 D2: exactly the word `phrasebook` selects the two-word
|
|
299
|
+
// form — `use phrasebook <name> [while <condition>]`, stackable.
|
|
300
|
+
// Plain `use <extension>` keeps its strict one-word grammar.
|
|
301
|
+
if (uc.isWord('phrasebook')) {
|
|
302
|
+
uc.next();
|
|
303
|
+
const bookTok = uc.next();
|
|
304
|
+
if (!bookTok || bookTok.kind !== 'word') {
|
|
305
|
+
this.diagnostics.error('parse.use-phrasebook', 'Expected `use phrasebook <name> [while <condition>]` — a single kebab-case book name.', uc.restSpan());
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
let bookCondition = null;
|
|
309
|
+
if (uc.isWord('while')) {
|
|
310
|
+
uc.next();
|
|
311
|
+
const condTokens = [];
|
|
312
|
+
while (!uc.atEnd())
|
|
313
|
+
condTokens.push(uc.next());
|
|
314
|
+
bookCondition = this.parseCondition(new Cursor(condTokens, useLine), useLine);
|
|
315
|
+
}
|
|
316
|
+
else if (!uc.atEnd()) {
|
|
317
|
+
this.diagnostics.error('parse.use-phrasebook', 'Unexpected text after the book name — only `while <condition>` may follow.', uc.restSpan());
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
usePhrasebooks.push({ name: bookTok.text, condition: bookCondition, span: lineSpan(useLine) });
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const nameTok = uc.next();
|
|
324
|
+
if (!nameTok || nameTok.kind !== 'word' || !uc.atEnd()) {
|
|
325
|
+
this.diagnostics.error('parse.use', 'Expected `use <extension>` — one extension name per line.', lineSpan(useLine));
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
uses.push({ name: nameTok.text, span: lineSpan(useLine) });
|
|
329
|
+
}
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
// `on every turn [while <cond>][, once]` — the story-owned daemon
|
|
333
|
+
// (ADR-236 D7, ratchet R4). The only clause form the header hosts;
|
|
334
|
+
// anything else keeps its owner-attached home.
|
|
335
|
+
if (peekedWord === 'on' || peekedWord === 'after') {
|
|
336
|
+
const clause = this.parseOnClause(peeked.indent, peekedWord);
|
|
337
|
+
span = (0, span_js_1.mergeSpans)(span, clause.span);
|
|
338
|
+
if (clause.clauseKind === 'on' && clause.binding === 'every-turn') {
|
|
339
|
+
onClauses.push(clause);
|
|
340
|
+
}
|
|
341
|
+
else if (!(clause.clauseKind === 'after' && clause.binding === 'every-turn')) {
|
|
342
|
+
// (`after every turn` already got its own parse error inside
|
|
343
|
+
// parseOnClause — don't stack a second diagnostic on it.)
|
|
344
|
+
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);
|
|
345
|
+
}
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
228
348
|
const fieldLine = this.lines[this.pos++];
|
|
229
|
-
span = (0,
|
|
349
|
+
span = (0, span_js_1.mergeSpans)(span, lineSpan(fieldLine));
|
|
230
350
|
const key = firstWord(fieldLine);
|
|
231
351
|
// `states[, reversible]: a, b` — story phases (ratchet D2/D4).
|
|
232
352
|
if (key === 'states') {
|
|
@@ -254,7 +374,7 @@ class Parser {
|
|
|
254
374
|
const colonAt = fieldLine.raw.indexOf(':');
|
|
255
375
|
fields[key] = fieldLine.raw.slice(colonAt + 1).trim();
|
|
256
376
|
}
|
|
257
|
-
return { kind: 'story-header', title, author, fields, states, statesReversible, scores, span };
|
|
377
|
+
return { kind: 'story-header', title, author, fields, states, statesReversible, scores, onClauses, uses, usePhrasebooks, span };
|
|
258
378
|
}
|
|
259
379
|
/**
|
|
260
380
|
* Parse the tail of a `states` line after the `states` word:
|
|
@@ -313,11 +433,17 @@ class Parser {
|
|
|
313
433
|
kind: 'create',
|
|
314
434
|
name,
|
|
315
435
|
aka: [],
|
|
436
|
+
pronouns: [],
|
|
316
437
|
compositions: [],
|
|
438
|
+
startsStates: [],
|
|
317
439
|
placement: null,
|
|
318
440
|
wears: [],
|
|
441
|
+
carries: [],
|
|
442
|
+
containing: [],
|
|
319
443
|
exits: [],
|
|
320
444
|
blockedExits: [],
|
|
445
|
+
deadlyExits: [],
|
|
446
|
+
deadly: null,
|
|
321
447
|
states: [],
|
|
322
448
|
statesReversible: false,
|
|
323
449
|
scores: [],
|
|
@@ -331,7 +457,7 @@ class Parser {
|
|
|
331
457
|
while (this.pos < this.lines.length && this.lines[this.pos].indent > 0) {
|
|
332
458
|
const line = this.lines[this.pos];
|
|
333
459
|
sawBlank = sawBlank || line.afterBlank;
|
|
334
|
-
decl.span = (0,
|
|
460
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
335
461
|
const word = firstWord(line);
|
|
336
462
|
const cur = new Cursor(line.tokens, line);
|
|
337
463
|
if (word === 'aka') {
|
|
@@ -339,6 +465,20 @@ class Parser {
|
|
|
339
465
|
cur.matchWord('aka');
|
|
340
466
|
decl.aka.push(...this.parseCommaWords(cur));
|
|
341
467
|
}
|
|
468
|
+
else if (word === 'pronouns') {
|
|
469
|
+
// ADR-242 D5: `pronouns <word>` — one word (a standard set or a
|
|
470
|
+
// `define pronouns` name). Person-only legality, word resolution,
|
|
471
|
+
// and duplicate-line rejection are the analyzer's gates.
|
|
472
|
+
this.pos++;
|
|
473
|
+
cur.matchWord('pronouns');
|
|
474
|
+
const wordTok = cur.next();
|
|
475
|
+
if (!wordTok || wordTok.kind !== 'word' || !cur.atEnd()) {
|
|
476
|
+
this.diagnostics.error('parse.pronouns-word', 'Expected one pronoun-set word after `pronouns` (e.g. `pronouns she`).', lineSpan(line));
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
decl.pronouns.push({ word: wordTok.text.toLowerCase(), span: lineSpan(line) });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
342
482
|
else if (word === 'states' && (line.tokens[1]?.kind === 'colon' || line.tokens[1]?.kind === 'comma')) {
|
|
343
483
|
this.pos++;
|
|
344
484
|
cur.next();
|
|
@@ -359,7 +499,27 @@ class Parser {
|
|
|
359
499
|
cur.matchWord('wears');
|
|
360
500
|
decl.wears.push(this.parseNameRef(cur, () => false));
|
|
361
501
|
}
|
|
502
|
+
else if (word === 'carries') {
|
|
503
|
+
// ADR-230 Phase 6 (sketch ruling 7): start inventory, the missing
|
|
504
|
+
// half of `wears` — previously accepted silently and dropped.
|
|
505
|
+
this.pos++;
|
|
506
|
+
cur.matchWord('carries');
|
|
507
|
+
decl.carries.push(this.parseNameRef(cur, () => false));
|
|
508
|
+
}
|
|
509
|
+
else if (word === 'containing') {
|
|
510
|
+
// ADR-236 D2 (ratchet R2): region membership — `containing the
|
|
511
|
+
// Clearing, the Forest Path, and the Canyon View`. Additive across
|
|
512
|
+
// lines; region-block-only legality is the analyzer's gate.
|
|
513
|
+
this.pos++;
|
|
514
|
+
cur.matchWord('containing');
|
|
515
|
+
decl.containing.push(...this.parseNameRefList(cur, line));
|
|
516
|
+
}
|
|
362
517
|
else if (word === 'starts' && cur.isWord('in', 1)) {
|
|
518
|
+
// The `starts` dispatch is one-token lookahead (ADR-231 D5a):
|
|
519
|
+
// `starts in <place>` is placement (here); `starts <known-state>` is
|
|
520
|
+
// an initializer clause, handled by the composition-line fallthrough
|
|
521
|
+
// below (parseCompositionLine's `starts` branch — same branch that
|
|
522
|
+
// rejects unknown words after `starts`).
|
|
363
523
|
this.pos++;
|
|
364
524
|
cur.next();
|
|
365
525
|
cur.next();
|
|
@@ -416,8 +576,26 @@ class Parser {
|
|
|
416
576
|
this.pos++;
|
|
417
577
|
cur.next();
|
|
418
578
|
cur.next();
|
|
419
|
-
|
|
420
|
-
|
|
579
|
+
// `through` is reserved on exit lines as the door tail (ADR-234 D1,
|
|
580
|
+
// ratchet R2) — it stops the destination name.
|
|
581
|
+
const to = this.parseNameRef(cur, (t) => t.kind === 'word' && t.text === 'through');
|
|
582
|
+
let via = null;
|
|
583
|
+
if (cur.matchWord('through')) {
|
|
584
|
+
const doorRef = this.parseNameRef(cur, () => false);
|
|
585
|
+
if (doorRef.words.length === 0) {
|
|
586
|
+
this.diagnostics.error('parse.exit-through', 'Expected a door name after `through` (e.g. `north to the Hall through the oak door`).', lineSpan(line));
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
via = doorRef;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// ADR-234 D4: `, one-way` is reserved (traversable in the written
|
|
593
|
+
// direction only) but NOT wired — a legible reservation error, not
|
|
594
|
+
// a generic parse failure, until its own ratchet entry lands.
|
|
595
|
+
if (cur.peek()?.kind === 'comma' && cur.isWord('one-way', 1)) {
|
|
596
|
+
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));
|
|
597
|
+
}
|
|
598
|
+
decl.exits.push({ kind: 'exit', direction: word, to, via, span: lineSpan(line) });
|
|
421
599
|
}
|
|
422
600
|
else if (word && DIRECTIONS.has(word) && cur.isWord('is', 1) && cur.isWord('blocked', 2)) {
|
|
423
601
|
this.pos++;
|
|
@@ -425,6 +603,29 @@ class Parser {
|
|
|
425
603
|
if (blocked)
|
|
426
604
|
decl.blockedExits.push(blocked);
|
|
427
605
|
}
|
|
606
|
+
else if (word && DIRECTIONS.has(word) && cur.isWord('is', 1) && cur.isWord('deadly', 2)) {
|
|
607
|
+
this.pos++;
|
|
608
|
+
const deadly = this.parseDeadlyExit(word, line);
|
|
609
|
+
if (deadly)
|
|
610
|
+
decl.deadlyExits.push(deadly);
|
|
611
|
+
}
|
|
612
|
+
else if (word === 'deadly' && line.tokens[1]?.kind === 'colon') {
|
|
613
|
+
// `deadly: <phrase>` — the no-escape room marker (ADR-227).
|
|
614
|
+
this.pos++;
|
|
615
|
+
const dc = new Cursor(line.tokens, line);
|
|
616
|
+
dc.next(); // deadly
|
|
617
|
+
dc.next(); // colon
|
|
618
|
+
const key = dc.next();
|
|
619
|
+
if (!key || key.kind !== 'word') {
|
|
620
|
+
this.diagnostics.error('parse.deadly-room', 'Expected a phrase key after `deadly:`.', lineSpan(line));
|
|
621
|
+
}
|
|
622
|
+
else if (decl.deadly) {
|
|
623
|
+
this.diagnostics.error('parse.deadly-room', 'Duplicate `deadly:` marker in this `create` block.', lineSpan(line));
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
decl.deadly = { kind: 'deadly-room', phraseKey: key.text, span: lineSpan(line) };
|
|
627
|
+
}
|
|
628
|
+
}
|
|
428
629
|
else if (!sawBlank || !line.afterBlank) {
|
|
429
630
|
if (sawBlank) {
|
|
430
631
|
this.diagnostics.error('parse.create-property', `Unrecognized line in \`create\` block: \`${line.raw.trim()}\`.`, lineSpan(line));
|
|
@@ -432,7 +633,7 @@ class Parser {
|
|
|
432
633
|
}
|
|
433
634
|
else {
|
|
434
635
|
this.pos++;
|
|
435
|
-
decl.compositions.push(...this.parseCompositionLine(cur, line));
|
|
636
|
+
decl.compositions.push(...this.parseCompositionLine(cur, line, decl.startsStates));
|
|
436
637
|
}
|
|
437
638
|
}
|
|
438
639
|
else {
|
|
@@ -444,7 +645,7 @@ class Parser {
|
|
|
444
645
|
...decl.description,
|
|
445
646
|
text: `${decl.description.text}\n\n${prose.text}`,
|
|
446
647
|
markers: [...decl.description.markers, ...prose.markers],
|
|
447
|
-
span: (0,
|
|
648
|
+
span: (0, span_js_1.mergeSpans)(decl.description.span, prose.span),
|
|
448
649
|
};
|
|
449
650
|
}
|
|
450
651
|
else {
|
|
@@ -480,17 +681,78 @@ class Parser {
|
|
|
480
681
|
this.diagnostics.error('parse.blocked-exit', 'Expected `: <phrase-key>` after `is blocked`.', lineSpan(line));
|
|
481
682
|
return null;
|
|
482
683
|
}
|
|
483
|
-
const key =
|
|
484
|
-
if (!key
|
|
684
|
+
const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
685
|
+
if (!key) {
|
|
485
686
|
this.diagnostics.error('parse.blocked-exit', 'Expected a phrase key after `is blocked:`.', lineSpan(line));
|
|
486
687
|
return null;
|
|
487
688
|
}
|
|
488
|
-
return { kind: 'blocked-exit', direction, phraseKey: key
|
|
689
|
+
return { kind: 'blocked-exit', direction, phraseKey: key, condition, span: lineSpan(line) };
|
|
690
|
+
}
|
|
691
|
+
parseDeadlyExit(direction, line) {
|
|
692
|
+
// <direction> is deadly [while <condition>]: <phrase-key> (ADR-227) —
|
|
693
|
+
// mirrors the blocked-exit grammar exactly.
|
|
694
|
+
const c = new Cursor(line.tokens, line);
|
|
695
|
+
c.next(); // direction
|
|
696
|
+
c.next(); // is
|
|
697
|
+
c.next(); // deadly
|
|
698
|
+
let condition = null;
|
|
699
|
+
if (c.matchWord('while')) {
|
|
700
|
+
const condTokens = [];
|
|
701
|
+
while (!c.atEnd() && c.peek().kind !== 'colon')
|
|
702
|
+
condTokens.push(c.next());
|
|
703
|
+
condition = this.parseCondition(new Cursor(condTokens, line), line);
|
|
704
|
+
}
|
|
705
|
+
const colon = c.next();
|
|
706
|
+
if (!colon || colon.kind !== 'colon') {
|
|
707
|
+
this.diagnostics.error('parse.deadly-exit', 'Expected `: <phrase-key>` after `is deadly`.', lineSpan(line));
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
711
|
+
if (!key) {
|
|
712
|
+
this.diagnostics.error('parse.deadly-exit', 'Expected a phrase key after `is deadly:`.', lineSpan(line));
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
return { kind: 'deadly-exit', direction, phraseKey: key, condition, span: lineSpan(line) };
|
|
489
716
|
}
|
|
490
|
-
parseCompositionLine(c, line) {
|
|
717
|
+
parseCompositionLine(c, line, startsStates) {
|
|
491
718
|
const items = [];
|
|
492
719
|
while (!c.atEnd()) {
|
|
493
720
|
const startTok = c.peek();
|
|
721
|
+
// `starts <state>` initializer clause (ADR-231 D5a) — one-token
|
|
722
|
+
// lookahead on the `starts` word Chord already owns: a known state
|
|
723
|
+
// word (`locked`, `open`, …) initializes the paired trait's initial
|
|
724
|
+
// value; `in` is the placement line's spelling and can't ride a
|
|
725
|
+
// composition list; anything else is its own parse error, never a
|
|
726
|
+
// silent pass into trait-name resolution.
|
|
727
|
+
if (c.isWord('starts')) {
|
|
728
|
+
const startsTok = c.next();
|
|
729
|
+
const stateTok = c.peek();
|
|
730
|
+
if (stateTok && stateTok.kind === 'word' && catalog_js_1.STARTS_STATE_PAIRINGS.has(stateTok.text)) {
|
|
731
|
+
c.next();
|
|
732
|
+
startsStates.push({
|
|
733
|
+
kind: 'starts-state',
|
|
734
|
+
state: stateTok.text,
|
|
735
|
+
span: (0, span_js_1.mergeSpans)(startsTok.span, stateTok.span),
|
|
736
|
+
});
|
|
737
|
+
if (c.peek()?.kind === 'comma') {
|
|
738
|
+
c.next();
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
if (stateTok && stateTok.kind === 'word' && stateTok.text === 'in') {
|
|
744
|
+
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));
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
const known = [...catalog_js_1.STARTS_STATE_PAIRINGS.keys()].join(', ');
|
|
748
|
+
this.diagnostics.error('parse.starts-state', stateTok
|
|
749
|
+
? `\`${stateTok.text}\` is not a state \`starts\` can initialize — known states: ${known} (placement is \`starts in <place>\`).`
|
|
750
|
+
: `Expected a state after \`starts\` — known states: ${known} (placement is \`starts in <place>\`).`, stateTok ? stateTok.span : startsTok.span);
|
|
751
|
+
}
|
|
752
|
+
while (!c.atEnd())
|
|
753
|
+
c.next(); // resynchronize: one mistake, one diagnostic
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
494
756
|
let article = null;
|
|
495
757
|
const first = c.peek();
|
|
496
758
|
if (first && first.kind === 'word' && ARTICLES.has(first.text)) {
|
|
@@ -524,7 +786,7 @@ class Parser {
|
|
|
524
786
|
words,
|
|
525
787
|
config,
|
|
526
788
|
condition,
|
|
527
|
-
span: (0,
|
|
789
|
+
span: (0, span_js_1.mergeSpans)(startTok.span, endTok.span),
|
|
528
790
|
});
|
|
529
791
|
if (c.peek()?.kind === 'comma')
|
|
530
792
|
c.next();
|
|
@@ -540,6 +802,33 @@ class Parser {
|
|
|
540
802
|
const settings = [];
|
|
541
803
|
for (;;) {
|
|
542
804
|
const startTok = c.peek();
|
|
805
|
+
// Ratchet R3 (ADR-234 D6): an article directly after `with`/`and`
|
|
806
|
+
// starts the adjective's single-entity config value — no keyword
|
|
807
|
+
// (`lockable with the iron key`). Keyed named fields (`with food the
|
|
808
|
+
// handful of feed` on authored traits) still parse below: their key
|
|
809
|
+
// words come first, so the article is not in first position.
|
|
810
|
+
if (startTok && startTok.kind === 'word' && ARTICLES.has(startTok.text)) {
|
|
811
|
+
c.next(); // article
|
|
812
|
+
const nameWords = [];
|
|
813
|
+
let lastTok = startTok;
|
|
814
|
+
while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('while')) {
|
|
815
|
+
lastTok = c.next();
|
|
816
|
+
nameWords.push(lastTok.text);
|
|
817
|
+
}
|
|
818
|
+
if (nameWords.length === 0) {
|
|
819
|
+
this.diagnostics.error('parse.config-value', 'Expected an entity name after the article.', c.restSpan());
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
settings.push({
|
|
823
|
+
key: [],
|
|
824
|
+
value: nameWords.join(' '),
|
|
825
|
+
valueKind: 'name',
|
|
826
|
+
span: (0, span_js_1.mergeSpans)(startTok.span, lastTok.span),
|
|
827
|
+
});
|
|
828
|
+
if (!c.matchWord('and'))
|
|
829
|
+
break;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
543
832
|
const key = [];
|
|
544
833
|
while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('while')) {
|
|
545
834
|
const t = c.peek();
|
|
@@ -569,11 +858,65 @@ class Parser {
|
|
|
569
858
|
this.diagnostics.error('parse.config-value', 'Expected an entity name after the article.', c.restSpan());
|
|
570
859
|
break;
|
|
571
860
|
}
|
|
861
|
+
// Ratchet R3 (ADR-234 D6): the `key`/`tool` config keywords are
|
|
862
|
+
// removed — the entity is written directly after `with`. One form
|
|
863
|
+
// per concept (Given 7); the fix-it names the replacement.
|
|
864
|
+
if (key.length === 1 && (key[0] === 'key' || key[0] === 'tool')) {
|
|
865
|
+
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);
|
|
866
|
+
if (!c.matchWord('and'))
|
|
867
|
+
break;
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
572
870
|
settings.push({
|
|
573
871
|
key,
|
|
574
872
|
value: nameWords.join(' '),
|
|
575
873
|
valueKind: 'name',
|
|
576
|
-
span: startTok ? (0,
|
|
874
|
+
span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, lastTok.span) : lastTok.span,
|
|
875
|
+
});
|
|
876
|
+
if (!c.matchWord('and'))
|
|
877
|
+
break;
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
// `[ … ]` list value (ADR-215): a bracketed, comma-separated list of
|
|
881
|
+
// name references (`patrol route [Hall, Study, Hall]`). Narrower than
|
|
882
|
+
// ADR-216's statement-payload arrays — config lists hold names only.
|
|
883
|
+
const bracketTok = c.peek();
|
|
884
|
+
if (bracketTok && bracketTok.kind === 'lbracket' && key.length > 0) {
|
|
885
|
+
c.next(); // [
|
|
886
|
+
const listValues = [];
|
|
887
|
+
for (;;) {
|
|
888
|
+
if (c.peek()?.kind === 'rbracket')
|
|
889
|
+
break;
|
|
890
|
+
const ref = this.parseNameRef(c, () => false);
|
|
891
|
+
if (ref.words.length === 0) {
|
|
892
|
+
this.diagnostics.error('parse.config-list', 'Expected an entity name inside the `[ … ]` list.', c.restSpan());
|
|
893
|
+
while (!c.atEnd() && c.peek().kind !== 'rbracket')
|
|
894
|
+
c.next();
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
listValues.push(ref);
|
|
898
|
+
if (c.peek()?.kind === 'comma')
|
|
899
|
+
c.next();
|
|
900
|
+
else
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
const close = c.next();
|
|
904
|
+
let lastSpan = bracketTok.span;
|
|
905
|
+
if (!close || close.kind !== 'rbracket') {
|
|
906
|
+
this.diagnostics.error('parse.config-list', 'Expected `]` to close the list.', c.restSpan());
|
|
907
|
+
}
|
|
908
|
+
else {
|
|
909
|
+
lastSpan = close.span;
|
|
910
|
+
}
|
|
911
|
+
if (listValues.length === 0) {
|
|
912
|
+
this.diagnostics.error('parse.config-list', 'A `[ … ]` list needs at least one entry.', lastSpan);
|
|
913
|
+
}
|
|
914
|
+
settings.push({
|
|
915
|
+
key,
|
|
916
|
+
value: '',
|
|
917
|
+
valueKind: 'list',
|
|
918
|
+
listValues,
|
|
919
|
+
span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, lastSpan) : lastSpan,
|
|
577
920
|
});
|
|
578
921
|
if (!c.matchWord('and'))
|
|
579
922
|
break;
|
|
@@ -589,7 +932,7 @@ class Parser {
|
|
|
589
932
|
key,
|
|
590
933
|
value: valueTok.text,
|
|
591
934
|
valueKind: valueTok.kind,
|
|
592
|
-
span: startTok ? (0,
|
|
935
|
+
span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, valueTok.span) : valueTok.span,
|
|
593
936
|
});
|
|
594
937
|
if (!c.matchWord('and'))
|
|
595
938
|
break;
|
|
@@ -599,7 +942,8 @@ class Parser {
|
|
|
599
942
|
parsePhraseOverride(line) {
|
|
600
943
|
const c = new Cursor(line.tokens, line);
|
|
601
944
|
c.matchWord('phrase');
|
|
602
|
-
|
|
945
|
+
// Key word validated by the caller; phrase-key = WORD { "." WORD } (ADR-231 D1b).
|
|
946
|
+
const key = this.readLabelKey(c);
|
|
603
947
|
// CP3: optional `, <strategy>` (the Z5 adverb set, retired fix-its
|
|
604
948
|
// included) — `phrase present, cycling:`.
|
|
605
949
|
let strategy = null;
|
|
@@ -654,11 +998,11 @@ class Parser {
|
|
|
654
998
|
const last = variants[variants.length - 1];
|
|
655
999
|
return {
|
|
656
1000
|
kind: 'phrase-override',
|
|
657
|
-
key
|
|
1001
|
+
key,
|
|
658
1002
|
strategy,
|
|
659
1003
|
condition,
|
|
660
1004
|
variants,
|
|
661
|
-
span: (0,
|
|
1005
|
+
span: (0, span_js_1.mergeSpans)(lineSpan(line), last?.span ?? lineSpan(line)),
|
|
662
1006
|
};
|
|
663
1007
|
}
|
|
664
1008
|
/** Same-line phrase text (quoted or bare) was removed — grammar log 2026-07-10. */
|
|
@@ -683,6 +1027,33 @@ class Parser {
|
|
|
683
1027
|
out.push(current.join(' '));
|
|
684
1028
|
return out;
|
|
685
1029
|
}
|
|
1030
|
+
/**
|
|
1031
|
+
* A comma-separated list of entity name references, Oxford-`and` welcome
|
|
1032
|
+
* (`the Clearing, the Forest Path, and the Canyon View`). Each reference
|
|
1033
|
+
* keeps its own article and span; an empty list is a parse error.
|
|
1034
|
+
*/
|
|
1035
|
+
parseNameRefList(c, line) {
|
|
1036
|
+
const refs = [];
|
|
1037
|
+
for (;;) {
|
|
1038
|
+
const ref = this.parseNameRef(c, (t) => t.kind === 'word' && t.text === 'and');
|
|
1039
|
+
if (ref.words.length === 0) {
|
|
1040
|
+
this.diagnostics.error('parse.name-list', 'Expected an entity name.', c.atEnd() ? lineSpan(line) : c.restSpan());
|
|
1041
|
+
break;
|
|
1042
|
+
}
|
|
1043
|
+
refs.push(ref);
|
|
1044
|
+
if (c.peek()?.kind === 'comma') {
|
|
1045
|
+
c.next();
|
|
1046
|
+
c.matchWord('and'); // Oxford `and` after the comma
|
|
1047
|
+
}
|
|
1048
|
+
else if (!c.matchWord('and')) {
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
if (!c.atEnd()) {
|
|
1053
|
+
this.diagnostics.error('parse.name-list', `Unexpected trailing text in name list: \`${c.peek().text}\`.`, c.restSpan());
|
|
1054
|
+
}
|
|
1055
|
+
return refs;
|
|
1056
|
+
}
|
|
686
1057
|
parseStateList(c) {
|
|
687
1058
|
const out = [];
|
|
688
1059
|
while (!c.atEnd()) {
|
|
@@ -727,7 +1098,7 @@ class Parser {
|
|
|
727
1098
|
const text = line.raw.trim();
|
|
728
1099
|
this.extractMarkers(line, markers);
|
|
729
1100
|
current.push(text);
|
|
730
|
-
span = span ? (0,
|
|
1101
|
+
span = span ? (0, span_js_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
|
|
731
1102
|
first = false;
|
|
732
1103
|
}
|
|
733
1104
|
if (current.length)
|
|
@@ -737,7 +1108,7 @@ class Parser {
|
|
|
737
1108
|
form: 'prose',
|
|
738
1109
|
text: paragraphs.map((p) => p.join(' ')).join('\n\n'),
|
|
739
1110
|
markers,
|
|
740
|
-
span: span ?? (0,
|
|
1111
|
+
span: span ?? (0, span_js_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
|
|
741
1112
|
};
|
|
742
1113
|
}
|
|
743
1114
|
/**
|
|
@@ -759,7 +1130,7 @@ class Parser {
|
|
|
759
1130
|
break;
|
|
760
1131
|
collected.push({ raw: line.raw, blankBefore: !first && line.afterBlank });
|
|
761
1132
|
this.extractMarkers(line, markers);
|
|
762
|
-
span = span ? (0,
|
|
1133
|
+
span = span ? (0, span_js_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
|
|
763
1134
|
this.pos++;
|
|
764
1135
|
first = false;
|
|
765
1136
|
}
|
|
@@ -775,14 +1146,14 @@ class Parser {
|
|
|
775
1146
|
form: 'verbatim',
|
|
776
1147
|
text: lines.join('\n'),
|
|
777
1148
|
markers,
|
|
778
|
-
span: span ?? (0,
|
|
1149
|
+
span: span ?? (0, span_js_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
|
|
779
1150
|
};
|
|
780
1151
|
}
|
|
781
1152
|
extractMarkers(line, into) {
|
|
782
1153
|
const re = /\{([^}]*)\}/g;
|
|
783
1154
|
let m;
|
|
784
1155
|
while ((m = re.exec(line.raw)) !== null) {
|
|
785
|
-
into.push({ content: m[1], span: (0,
|
|
1156
|
+
into.push({ content: m[1], span: (0, span_js_1.spanOf)(line.lineNo, m.index + 1, m[0].length) });
|
|
786
1157
|
}
|
|
787
1158
|
}
|
|
788
1159
|
/** Error-recovery only: a removed same-line quoted value, kept as prose text. */
|
|
@@ -791,7 +1162,7 @@ class Parser {
|
|
|
791
1162
|
const re = /\{([^}]*)\}/g;
|
|
792
1163
|
let m;
|
|
793
1164
|
while ((m = re.exec(tok.text)) !== null) {
|
|
794
|
-
markers.push({ content: m[1], span: (0,
|
|
1165
|
+
markers.push({ content: m[1], span: (0, span_js_1.spanOf)(tok.span.line, tok.span.column + 1 + m.index, m[0].length) });
|
|
795
1166
|
}
|
|
796
1167
|
return { kind: 'text', form: 'prose', text: tok.text, markers, span: tok.span };
|
|
797
1168
|
}
|
|
@@ -805,6 +1176,10 @@ class Parser {
|
|
|
805
1176
|
return this.parseDefineCondition();
|
|
806
1177
|
case 'phrase':
|
|
807
1178
|
return this.parseDefinePhrase();
|
|
1179
|
+
case 'phrasebook':
|
|
1180
|
+
// ADR-245/250 D1 (David 2026-07-21) — named, predicated phrase
|
|
1181
|
+
// collections; entries reuse the phrase-override grammar.
|
|
1182
|
+
return this.parseDefinePhrasebook();
|
|
808
1183
|
case 'phrases':
|
|
809
1184
|
return this.parseDefinePhrases();
|
|
810
1185
|
case 'verb':
|
|
@@ -820,8 +1195,15 @@ class Parser {
|
|
|
820
1195
|
return this.parseDefineTrait();
|
|
821
1196
|
case 'action':
|
|
822
1197
|
return this.parseDefineAction();
|
|
1198
|
+
case 'chain':
|
|
1199
|
+
return this.parseDefineChainHatch();
|
|
823
1200
|
case 'behavior':
|
|
824
|
-
|
|
1201
|
+
// ADR-235 D2 (removal, 2026-07-18): the behavior hatch carried no
|
|
1202
|
+
// trait/action binding key, so it structurally could never fire —
|
|
1203
|
+
// removed rather than repaired.
|
|
1204
|
+
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]));
|
|
1205
|
+
this.pos++;
|
|
1206
|
+
return null;
|
|
825
1207
|
case 'score':
|
|
826
1208
|
// Removed — ratchet D12/CP5 (2026-07-11).
|
|
827
1209
|
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));
|
|
@@ -829,6 +1211,29 @@ class Parser {
|
|
|
829
1211
|
return null;
|
|
830
1212
|
case 'sequence':
|
|
831
1213
|
return this.parseDefineSequence();
|
|
1214
|
+
case 'machine':
|
|
1215
|
+
// ADR-215 `use state-machines` depth (spelling A, 2026-07-18) —
|
|
1216
|
+
// the `use` requirement is the analyzer's gate.
|
|
1217
|
+
return this.parseDefineMachine();
|
|
1218
|
+
case 'sound':
|
|
1219
|
+
case 'image':
|
|
1220
|
+
case 'music':
|
|
1221
|
+
// ADR-216 declared assets — DATA references, never hatches.
|
|
1222
|
+
return this.parseDefineAsset(subWord);
|
|
1223
|
+
case 'ambient':
|
|
1224
|
+
case 'layer':
|
|
1225
|
+
// ADR-241 D2 — named family channels (an ambient bed / an image
|
|
1226
|
+
// layer), one-liners beside the asset declarations.
|
|
1227
|
+
return this.parseDefineFamilyChannel(subWord);
|
|
1228
|
+
case 'channel':
|
|
1229
|
+
// ADR-216 custom channels (spelling A, 2026-07-18) — data projections.
|
|
1230
|
+
return this.parseDefineChannel();
|
|
1231
|
+
case 'topics':
|
|
1232
|
+
// ADR-239 D3 (as amended) — the ask/tell topic table block.
|
|
1233
|
+
return this.parseDefineTopics();
|
|
1234
|
+
case 'pronouns':
|
|
1235
|
+
// ADR-242 D7 (ruled Q-1) — named pronoun set, five named rows.
|
|
1236
|
+
return this.parseDefinePronouns();
|
|
832
1237
|
default:
|
|
833
1238
|
this.diagnostics.error('parse.unknown-define', `Unknown declaration \`define ${subWord ?? ''}\`.`, lineSpan(line));
|
|
834
1239
|
this.recoverToTopLevel(true);
|
|
@@ -858,11 +1263,27 @@ class Parser {
|
|
|
858
1263
|
const c = new Cursor(headLine.tokens, headLine);
|
|
859
1264
|
c.next();
|
|
860
1265
|
c.next(); // define phrase
|
|
861
|
-
|
|
862
|
-
|
|
1266
|
+
// EBNF: phrase-key = WORD { "." WORD } (ADR-230 D5). Previously the
|
|
1267
|
+
// parser silently registered only the first segment (`if.action.taking`
|
|
1268
|
+
// became `if`), which made story-wide overrides of platform message ids
|
|
1269
|
+
// impossible.
|
|
1270
|
+
const key = this.readLabelKey(c) ?? '';
|
|
863
1271
|
if (!key) {
|
|
864
1272
|
this.diagnostics.error('parse.phrase-key', 'Expected a phrase key after `define phrase`.', lineSpan(headLine));
|
|
1273
|
+
c.next(); // skip the offending token so header options still parse
|
|
865
1274
|
}
|
|
1275
|
+
const body = this.readPhraseBody(c, headLine, 'phrase');
|
|
1276
|
+
return { kind: 'define-phrase', key, ...body };
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Read a phrase body from a header cursor positioned after the key/alias:
|
|
1280
|
+
* the optional `, <strategy>|verbatim` header modifier, an optional trailing
|
|
1281
|
+
* `while <condition>` gate, then the indented variant lines up to
|
|
1282
|
+
* `end <endWord>`. Shared by `define phrase` and ADR-255's `override message`
|
|
1283
|
+
* so the two constructs never drift (ADR-255 D1). Returns the body fields
|
|
1284
|
+
* (the caller supplies the `kind` and key/alias).
|
|
1285
|
+
*/
|
|
1286
|
+
readPhraseBody(c, headLine, endWord) {
|
|
866
1287
|
let strategy = null;
|
|
867
1288
|
let verbatim = false;
|
|
868
1289
|
if (c.peek()?.kind === 'comma') {
|
|
@@ -892,15 +1313,22 @@ class Parser {
|
|
|
892
1313
|
}
|
|
893
1314
|
const variants = [];
|
|
894
1315
|
let span = lineSpan(headLine);
|
|
1316
|
+
let flaggedFlushLeft = false;
|
|
895
1317
|
for (;;) {
|
|
896
1318
|
const line = this.lines[this.pos];
|
|
897
1319
|
if (!line) {
|
|
898
|
-
this.diagnostics.error('parse.unterminated-block',
|
|
1320
|
+
this.diagnostics.error('parse.unterminated-block', `Missing \`end ${endWord}\`.`, span);
|
|
899
1321
|
break;
|
|
900
1322
|
}
|
|
1323
|
+
if (line.comment) {
|
|
1324
|
+
// ADR-249 — indented `##` stays prose here; only a flagged
|
|
1325
|
+
// (indent-0) comment line is an inside-block error.
|
|
1326
|
+
this.skipCommentInsideBlock(line);
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
901
1329
|
if (isEndLine(line)) {
|
|
902
|
-
span = (0,
|
|
903
|
-
this.consumeEnd(
|
|
1330
|
+
span = (0, span_js_1.mergeSpans)(span, lineSpan(line));
|
|
1331
|
+
this.consumeEnd(endWord, headLine);
|
|
904
1332
|
break;
|
|
905
1333
|
}
|
|
906
1334
|
if (firstWord(line) === 'or' && line.tokens.length === 1) {
|
|
@@ -911,19 +1339,153 @@ class Parser {
|
|
|
911
1339
|
continue;
|
|
912
1340
|
}
|
|
913
1341
|
if (line.indent === 0 && TOP_KEYWORDS.has(firstWord(line) ?? '')) {
|
|
914
|
-
this.diagnostics.error('parse.unterminated-block',
|
|
1342
|
+
this.diagnostics.error('parse.unterminated-block', `Missing \`end ${endWord}\`.`, span);
|
|
915
1343
|
break;
|
|
916
1344
|
}
|
|
1345
|
+
if (line.indent === 0) {
|
|
1346
|
+
// A flush-left non-keyword line makes zero progress in either
|
|
1347
|
+
// variant parser below (both require depth > 0) — without this
|
|
1348
|
+
// guard the loop appended empty variants until OOM. One diagnostic
|
|
1349
|
+
// for the first offending line; the rest of the flush-left run is
|
|
1350
|
+
// consumed so `end <endWord>` still terminates the block.
|
|
1351
|
+
if (!flaggedFlushLeft) {
|
|
1352
|
+
flaggedFlushLeft = true;
|
|
1353
|
+
this.diagnostics.error('parse.phrase-text-indent', `Text must be indented under \`${endWord === 'override' ? 'override message' : 'define phrase'}\`.`, lineSpan(line));
|
|
1354
|
+
}
|
|
1355
|
+
this.pos++;
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
917
1358
|
const variant = verbatim ? this.parseVerbatimBlock() : this.parseProseParagraph(1, 0);
|
|
918
1359
|
variants.push(variant);
|
|
919
|
-
span = (0,
|
|
1360
|
+
span = (0, span_js_1.mergeSpans)(span, variant.span);
|
|
1361
|
+
}
|
|
1362
|
+
return { strategy, verbatim, condition, variants, span };
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* `define phrasebook <name> [while <condition>] … end phrasebook`
|
|
1366
|
+
* (ADR-250 D1). Entries are phrase-override-shaped lines
|
|
1367
|
+
* (`<key>[, strategy]:` + `or` variants) at one indent level; the
|
|
1368
|
+
* body carries the ADR-249 comment guard like every end-terminated
|
|
1369
|
+
* block. Entry-level `while` parses here (override grammar) and is
|
|
1370
|
+
* gated in the analyzer (`analysis.phrasebook-entry-gate`).
|
|
1371
|
+
*/
|
|
1372
|
+
parseDefinePhrasebook() {
|
|
1373
|
+
const headLine = this.lines[this.pos++];
|
|
1374
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
1375
|
+
c.matchWord('define');
|
|
1376
|
+
c.matchWord('phrasebook');
|
|
1377
|
+
let name = '';
|
|
1378
|
+
const nameTok = c.next();
|
|
1379
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
1380
|
+
this.diagnostics.error('parse.phrasebook-header', 'Expected `define phrasebook <name> [while <condition>]` — a single kebab-case book name.', c.restSpan());
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
name = nameTok.text;
|
|
1384
|
+
}
|
|
1385
|
+
let condition = null;
|
|
1386
|
+
if (c.isWord('while')) {
|
|
1387
|
+
c.next();
|
|
1388
|
+
const condTokens = [];
|
|
1389
|
+
while (!c.atEnd())
|
|
1390
|
+
condTokens.push(c.next());
|
|
1391
|
+
condition = this.parseCondition(new Cursor(condTokens, headLine), headLine);
|
|
1392
|
+
}
|
|
1393
|
+
else if (!c.atEnd()) {
|
|
1394
|
+
this.diagnostics.error('parse.phrasebook-header', 'Unexpected text after the book name — only `while <condition>` may follow.', c.restSpan());
|
|
1395
|
+
}
|
|
1396
|
+
const entries = [];
|
|
1397
|
+
let span = lineSpan(headLine);
|
|
1398
|
+
for (;;) {
|
|
1399
|
+
const line = this.lines[this.pos];
|
|
1400
|
+
if (!line) {
|
|
1401
|
+
this.diagnostics.error('parse.phrasebook-end', 'Missing `end phrasebook`.', span);
|
|
1402
|
+
break;
|
|
1403
|
+
}
|
|
1404
|
+
if (line.comment) {
|
|
1405
|
+
// ADR-249: `##` inside a block is never a comment.
|
|
1406
|
+
this.skipCommentInsideBlock(line);
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
if (isEndLine(line)) {
|
|
1410
|
+
span = (0, span_js_1.mergeSpans)(span, this.consumeEnd('phrasebook', headLine));
|
|
1411
|
+
break;
|
|
1412
|
+
}
|
|
1413
|
+
if (line.indent === 0) {
|
|
1414
|
+
this.diagnostics.error('parse.phrasebook-end', 'Missing `end phrasebook`.', span);
|
|
1415
|
+
break;
|
|
1416
|
+
}
|
|
1417
|
+
if (line.tokens[0]?.kind !== 'word' || !line.tokens.some((t) => t.kind === 'colon')) {
|
|
1418
|
+
this.diagnostics.error('parse.phrasebook-entry', 'Expected `<key>[, strategy]:` to open a phrasebook entry.', lineSpan(line));
|
|
1419
|
+
this.pos++;
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
this.pos++;
|
|
1423
|
+
const entry = this.parsePhraseOverride(line);
|
|
1424
|
+
entries.push(entry);
|
|
1425
|
+
span = (0, span_js_1.mergeSpans)(span, entry.span);
|
|
1426
|
+
}
|
|
1427
|
+
return { kind: 'define-phrasebook', name, condition, entries, span };
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* `import "<file>"` (ADR-251 D1) — the single generalized import form.
|
|
1431
|
+
* The path is extension-free (the compiler appends `.chord` at resolve
|
|
1432
|
+
* time — D2); any string is accepted at parse time. A bare word after
|
|
1433
|
+
* `import` (the removed `phrasebook` sub-word, or anything else) or a
|
|
1434
|
+
* missing string is `parse.import-form`.
|
|
1435
|
+
*/
|
|
1436
|
+
parseImport() {
|
|
1437
|
+
const line = this.lines[this.pos++];
|
|
1438
|
+
const c = new Cursor(line.tokens, line);
|
|
1439
|
+
c.matchWord('import');
|
|
1440
|
+
const pathTok = c.next();
|
|
1441
|
+
if (!pathTok || pathTok.kind !== 'string' || !c.atEnd()) {
|
|
1442
|
+
this.diagnostics.error('parse.import-form', 'Expected a quoted file name: `import "<file>"` (no extension — `.chord` is assumed).', c.restSpan());
|
|
1443
|
+
return null;
|
|
920
1444
|
}
|
|
921
|
-
return { kind: '
|
|
1445
|
+
return { kind: 'import', path: pathTok.text, span: lineSpan(line) };
|
|
922
1446
|
}
|
|
923
1447
|
parseDefinePhrases() {
|
|
924
1448
|
const headLine = this.lines[this.pos++];
|
|
925
1449
|
return this.parsePhrasesBlock(headLine, 2);
|
|
926
1450
|
}
|
|
1451
|
+
/**
|
|
1452
|
+
* ADR-255: dispatch the two override forms off the word after `override`.
|
|
1453
|
+
* `override message <alias> … end override` (full phrase body) vs.
|
|
1454
|
+
* `override messages <locale>` (locale block of `alias: text` entries).
|
|
1455
|
+
*/
|
|
1456
|
+
parseOverride() {
|
|
1457
|
+
const headLine = this.lines[this.pos];
|
|
1458
|
+
const kindTok = headLine.tokens[1];
|
|
1459
|
+
if (kindTok?.kind === 'word' && kindTok.text === 'messages') {
|
|
1460
|
+
return this.parseOverrideMessages();
|
|
1461
|
+
}
|
|
1462
|
+
if (kindTok?.kind === 'word' && kindTok.text === 'message') {
|
|
1463
|
+
return this.parseOverrideMessage();
|
|
1464
|
+
}
|
|
1465
|
+
this.diagnostics.error('parse.override', 'Expected `override message <alias>` or `override messages <locale>`.', lineSpan(headLine));
|
|
1466
|
+
this.pos++;
|
|
1467
|
+
return null;
|
|
1468
|
+
}
|
|
1469
|
+
/** `override message <alias> [, strategy] [while <cond>] … end override` (ADR-255 D1). */
|
|
1470
|
+
parseOverrideMessage() {
|
|
1471
|
+
const headLine = this.lines[this.pos++];
|
|
1472
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
1473
|
+
c.next(); // override
|
|
1474
|
+
c.next(); // message
|
|
1475
|
+
const alias = this.readLabelKey(c) ?? '';
|
|
1476
|
+
if (!alias) {
|
|
1477
|
+
this.diagnostics.error('parse.override-alias', 'Expected an override alias after `override message`.', lineSpan(headLine));
|
|
1478
|
+
c.next(); // skip the offending token so header options still parse
|
|
1479
|
+
}
|
|
1480
|
+
const body = this.readPhraseBody(c, headLine, 'override');
|
|
1481
|
+
return { kind: 'override-message', alias, ...body };
|
|
1482
|
+
}
|
|
1483
|
+
/** `override messages <locale>` — a locale block of `alias: text` entries (ADR-255 D1). */
|
|
1484
|
+
parseOverrideMessages() {
|
|
1485
|
+
const headLine = this.lines[this.pos++];
|
|
1486
|
+
const phrases = this.parsePhrasesBlock(headLine, 2);
|
|
1487
|
+
return { kind: 'override-messages', locale: phrases.locale, entries: phrases.entries, span: phrases.span };
|
|
1488
|
+
}
|
|
927
1489
|
/**
|
|
928
1490
|
* Parse a phrases block from its header line (`define phrases <locale>` at
|
|
929
1491
|
* top level, or `phrases <locale>` inside a trait/action, Phase B). The
|
|
@@ -943,16 +1505,18 @@ class Parser {
|
|
|
943
1505
|
let span = lineSpan(headLine);
|
|
944
1506
|
while (this.pos < this.lines.length && this.lines[this.pos].indent > headLine.indent) {
|
|
945
1507
|
const line = this.lines[this.pos];
|
|
946
|
-
const
|
|
947
|
-
const
|
|
948
|
-
|
|
1508
|
+
const ec = new Cursor(line.tokens, line);
|
|
1509
|
+
const key = this.readLabelKey(ec); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
1510
|
+
const colon = ec.peek();
|
|
1511
|
+
if (!key || !colon || colon.kind !== 'colon') {
|
|
949
1512
|
this.diagnostics.error('parse.phrase-entry', 'Expected `key: <text>` in the phrases block.', lineSpan(line));
|
|
950
1513
|
this.pos++;
|
|
951
1514
|
continue;
|
|
952
1515
|
}
|
|
1516
|
+
ec.next(); // colon
|
|
953
1517
|
this.pos++;
|
|
954
1518
|
let value;
|
|
955
|
-
const inline =
|
|
1519
|
+
const inline = ec.peek();
|
|
956
1520
|
if (inline && inline.kind === 'string') {
|
|
957
1521
|
this.reportSameLineText(inline.span);
|
|
958
1522
|
value = this.textFromString(inline); // recovery: keep the text so analysis continues
|
|
@@ -969,11 +1533,11 @@ class Parser {
|
|
|
969
1533
|
else {
|
|
970
1534
|
value = this.parseProseParagraph(line.indent + 1, line.indent);
|
|
971
1535
|
if (value.text === '') {
|
|
972
|
-
this.diagnostics.error('parse.phrase-entry-empty', `Phrase \`${key
|
|
1536
|
+
this.diagnostics.error('parse.phrase-entry-empty', `Phrase \`${key}\` has no text.`, lineSpan(line));
|
|
973
1537
|
}
|
|
974
1538
|
}
|
|
975
|
-
entries.push({ key
|
|
976
|
-
span = (0,
|
|
1539
|
+
entries.push({ key, value, span: (0, span_js_1.mergeSpans)(lineSpan(line), value.span) });
|
|
1540
|
+
span = (0, span_js_1.mergeSpans)(span, entries[entries.length - 1].span);
|
|
977
1541
|
}
|
|
978
1542
|
return { kind: 'define-phrases', locale, entries, span };
|
|
979
1543
|
}
|
|
@@ -1007,7 +1571,7 @@ class Parser {
|
|
|
1007
1571
|
this.diagnostics.error('parse.verb-slot', 'Expected `(something)` slot in the verb pattern.', t.span);
|
|
1008
1572
|
return null;
|
|
1009
1573
|
}
|
|
1010
|
-
pattern.push({ kind: 'slot', word: slot.text, span: (0,
|
|
1574
|
+
pattern.push({ kind: 'slot', word: slot.text, span: (0, span_js_1.mergeSpans)(t.span, close.span) });
|
|
1011
1575
|
}
|
|
1012
1576
|
else if (t.kind === 'word') {
|
|
1013
1577
|
pattern.push({ kind: 'word', word: t.text, span: t.span });
|
|
@@ -1062,6 +1626,10 @@ class Parser {
|
|
|
1062
1626
|
});
|
|
1063
1627
|
while (this.pos < this.lines.length) {
|
|
1064
1628
|
const line = this.lines[this.pos];
|
|
1629
|
+
if (looksLikeComment(line)) {
|
|
1630
|
+
this.skipCommentInsideBlock(line);
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1065
1633
|
if (line.indent === 0) {
|
|
1066
1634
|
if (isEndLine(line))
|
|
1067
1635
|
break;
|
|
@@ -1106,13 +1674,17 @@ class Parser {
|
|
|
1106
1674
|
}
|
|
1107
1675
|
}
|
|
1108
1676
|
const endSpan = this.consumeEnd('trait', headLine);
|
|
1109
|
-
return build((0,
|
|
1677
|
+
return build((0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan));
|
|
1110
1678
|
}
|
|
1111
1679
|
/** `data` block fields: `locked: flag`, `body part: optional name`, `kind: one of a, b`. */
|
|
1112
1680
|
parseTraitFields(dataLine) {
|
|
1113
1681
|
const fields = [];
|
|
1114
1682
|
while (this.pos < this.lines.length && this.lines[this.pos].indent > dataLine.indent) {
|
|
1115
1683
|
const line = this.lines[this.pos++];
|
|
1684
|
+
if (looksLikeComment(line)) {
|
|
1685
|
+
this.reportCommentInsideBlock(line);
|
|
1686
|
+
continue;
|
|
1687
|
+
}
|
|
1116
1688
|
const c = new Cursor(line.tokens, line);
|
|
1117
1689
|
const name = [];
|
|
1118
1690
|
while (!c.atEnd() && c.peek().kind === 'word')
|
|
@@ -1209,6 +1781,10 @@ class Parser {
|
|
|
1209
1781
|
const line = this.lines[this.pos];
|
|
1210
1782
|
if (line.indent === 0)
|
|
1211
1783
|
break; // dedent-terminated (no `end action`, design.md §2.3/§3.4)
|
|
1784
|
+
if (looksLikeComment(line)) {
|
|
1785
|
+
this.skipCommentInsideBlock(line);
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1212
1788
|
const word = firstWord(line);
|
|
1213
1789
|
if (word === 'grammar' && line.tokens.length === 1) {
|
|
1214
1790
|
this.pos++;
|
|
@@ -1249,9 +1825,9 @@ class Parser {
|
|
|
1249
1825
|
this.diagnostics.error('parse.action-otherwise', 'Expected `otherwise refuse <phrase-key>`.', lineSpan(line));
|
|
1250
1826
|
}
|
|
1251
1827
|
else {
|
|
1252
|
-
const key =
|
|
1253
|
-
if (key
|
|
1254
|
-
otherwise = { phraseKey: key
|
|
1828
|
+
const key = this.readLabelKey(oc); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
1829
|
+
if (key)
|
|
1830
|
+
otherwise = { phraseKey: key, span: lineSpan(line) };
|
|
1255
1831
|
else
|
|
1256
1832
|
this.diagnostics.error('parse.action-otherwise', 'Expected a phrase key after `otherwise refuse`.', oc.restSpan());
|
|
1257
1833
|
}
|
|
@@ -1265,7 +1841,7 @@ class Parser {
|
|
|
1265
1841
|
if (stmt)
|
|
1266
1842
|
body.push(stmt);
|
|
1267
1843
|
}
|
|
1268
|
-
span = (0,
|
|
1844
|
+
span = (0, span_js_1.mergeSpans)(span, lineSpan(line));
|
|
1269
1845
|
}
|
|
1270
1846
|
return { kind: 'define-action', name: nameTok.text, patterns, constraints, musts, refusals, otherwise, scores, phrases, body, span };
|
|
1271
1847
|
}
|
|
@@ -1276,13 +1852,14 @@ class Parser {
|
|
|
1276
1852
|
*/
|
|
1277
1853
|
parseMustLine(line) {
|
|
1278
1854
|
const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
1279
|
-
if (colonIndex === -1 || colonIndex
|
|
1855
|
+
if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
|
|
1280
1856
|
this.diagnostics.error('parse.must', 'Expected `<subject> must <predicate>: <phrase-key>`.', lineSpan(line));
|
|
1281
1857
|
return null;
|
|
1282
1858
|
}
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
|
|
1859
|
+
const keyCursor = new Cursor(line.tokens.slice(colonIndex + 1), line);
|
|
1860
|
+
const phraseKey = this.readLabelKey(keyCursor); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
1861
|
+
if (!phraseKey || !keyCursor.atEnd()) {
|
|
1862
|
+
this.diagnostics.error('parse.must', 'Expected a phrase key after the colon in the `must` requirement.', line.tokens[colonIndex + 1].span);
|
|
1286
1863
|
return null;
|
|
1287
1864
|
}
|
|
1288
1865
|
const c = new Cursor(line.tokens.slice(0, colonIndex), line);
|
|
@@ -1300,7 +1877,7 @@ class Parser {
|
|
|
1300
1877
|
const predicate = this.parseInfinitivePredicate(c, line);
|
|
1301
1878
|
if (!predicate)
|
|
1302
1879
|
return null;
|
|
1303
|
-
return { kind: 'must', subject, predicate, phraseKey
|
|
1880
|
+
return { kind: 'must', subject, predicate, phraseKey, span: lineSpan(line) };
|
|
1304
1881
|
}
|
|
1305
1882
|
/** Infinitive predicate after `must`: be / have / hold / wear / see / reach. */
|
|
1306
1883
|
parseInfinitivePredicate(c, line) {
|
|
@@ -1330,7 +1907,7 @@ class Parser {
|
|
|
1330
1907
|
if (nameTok && nameTok.kind === 'word' && this.isBareConditionRef(c, 1)) {
|
|
1331
1908
|
const anyTok = c.next();
|
|
1332
1909
|
c.next();
|
|
1333
|
-
return { kind: 'is-any', condition: nameTok.text, span: (0,
|
|
1910
|
+
return { kind: 'is-any', condition: nameTok.text, span: (0, span_js_1.mergeSpans)(anyTok.span, nameTok.span) };
|
|
1334
1911
|
}
|
|
1335
1912
|
}
|
|
1336
1913
|
// `be no <name>` — a negated requirement in disguise (decision 6):
|
|
@@ -1360,7 +1937,7 @@ class Parser {
|
|
|
1360
1937
|
case 'see':
|
|
1361
1938
|
case 'reach': {
|
|
1362
1939
|
const thing = this.parseNameRef(c, (tok) => tok.kind === 'word' && PHRASE_STOPS.has(tok.text));
|
|
1363
|
-
return { kind: 'can', ability: t.text, thing, span: (0,
|
|
1940
|
+
return { kind: 'can', ability: t.text, thing, span: (0, span_js_1.mergeSpans)(t.span, thing.span) };
|
|
1364
1941
|
}
|
|
1365
1942
|
default:
|
|
1366
1943
|
this.diagnostics.error('parse.must-predicate', `Unknown predicate \`${t.text}\` after \`must\` — expected be, have, hold, wear, see, or reach.`, t.span);
|
|
@@ -1386,7 +1963,7 @@ class Parser {
|
|
|
1386
1963
|
if (t.kind === 'colon') {
|
|
1387
1964
|
const slot = c.next();
|
|
1388
1965
|
if (slot && slot.kind === 'word') {
|
|
1389
|
-
parts.push({ kind: 'slot', word: slot.text, span: (0,
|
|
1966
|
+
parts.push({ kind: 'slot', word: slot.text, span: (0, span_js_1.mergeSpans)(t.span, slot.span) });
|
|
1390
1967
|
}
|
|
1391
1968
|
else {
|
|
1392
1969
|
this.diagnostics.error('parse.action-slot', 'Expected a slot name after `:`.', t.span);
|
|
@@ -1435,43 +2012,49 @@ class Parser {
|
|
|
1435
2012
|
if (form === 'without') {
|
|
1436
2013
|
const slot = c.next();
|
|
1437
2014
|
const colon = c.next();
|
|
1438
|
-
|
|
1439
|
-
|
|
2015
|
+
if (!slot || slot.kind !== 'word' || !colon || colon.kind !== 'colon') {
|
|
2016
|
+
this.diagnostics.error('parse.action-refusal', 'Expected `refuse without <slot>: <phrase-key>`.', lineSpan(line));
|
|
2017
|
+
return null;
|
|
2018
|
+
}
|
|
2019
|
+
const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
2020
|
+
if (!key) {
|
|
1440
2021
|
this.diagnostics.error('parse.action-refusal', 'Expected `refuse without <slot>: <phrase-key>`.', lineSpan(line));
|
|
1441
2022
|
return null;
|
|
1442
2023
|
}
|
|
1443
|
-
return { kind: 'without', slot: slot.text, condition: null, phraseKey: key
|
|
2024
|
+
return { kind: 'without', slot: slot.text, condition: null, phraseKey: key, span: lineSpan(line) };
|
|
1444
2025
|
}
|
|
1445
2026
|
// when: condition runs to the LAST colon; the key follows it.
|
|
1446
2027
|
const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
1447
|
-
if (colonIndex === -1 || colonIndex
|
|
2028
|
+
if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
|
|
1448
2029
|
this.diagnostics.error('parse.action-refusal', 'Expected `refuse when <condition>: <phrase-key>`.', lineSpan(line));
|
|
1449
2030
|
return null;
|
|
1450
2031
|
}
|
|
1451
2032
|
const condCursor = new Cursor(line.tokens.slice(2, colonIndex), line);
|
|
1452
2033
|
const condition = this.parseCondition(condCursor, line);
|
|
1453
|
-
const
|
|
1454
|
-
|
|
2034
|
+
const keyCursor = new Cursor(line.tokens.slice(colonIndex + 1), line);
|
|
2035
|
+
const key = this.readLabelKey(keyCursor); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
2036
|
+
if (!key || !keyCursor.atEnd()) {
|
|
1455
2037
|
this.diagnostics.error('parse.action-refusal', 'Expected a phrase key after the colon.', lineSpan(line));
|
|
1456
2038
|
return null;
|
|
1457
2039
|
}
|
|
1458
|
-
return { kind: 'when', slot: null, condition, phraseKey: key
|
|
2040
|
+
return { kind: 'when', slot: null, condition, phraseKey: key, span: lineSpan(line) };
|
|
1459
2041
|
}
|
|
1460
|
-
/** `define
|
|
1461
|
-
|
|
2042
|
+
/** `define chain <name> from "<module>"` — a TS chain hatch (ADR-094): the
|
|
2043
|
+
* named stdlib event chain is replaced by the module's handler. */
|
|
2044
|
+
parseDefineChainHatch() {
|
|
1462
2045
|
const line = this.lines[this.pos];
|
|
1463
2046
|
const c = new Cursor(line.tokens, line);
|
|
1464
2047
|
c.next();
|
|
1465
|
-
c.next(); // define
|
|
1466
|
-
const
|
|
1467
|
-
if (!
|
|
1468
|
-
this.diagnostics.error('parse.
|
|
2048
|
+
c.next(); // define chain
|
|
2049
|
+
const name = this.readLabelKey(c); // curated chain alias, single kebab word (ADR-254)
|
|
2050
|
+
if (!name) {
|
|
2051
|
+
this.diagnostics.error('parse.chain-hatch-name', 'Expected a chain name after `define chain` (e.g. `define chain opened-revealed from "…"`).', c.restSpan());
|
|
1469
2052
|
this.pos++;
|
|
1470
2053
|
return null;
|
|
1471
2054
|
}
|
|
1472
|
-
return this.parseHatchTail(line, c, '
|
|
2055
|
+
return this.parseHatchTail(line, c, 'chain', name);
|
|
1473
2056
|
}
|
|
1474
|
-
/** Shared `from "<module>"` tail for action/
|
|
2057
|
+
/** Shared `from "<module>"` tail for action / chain hatches. */
|
|
1475
2058
|
parseHatchTail(line, c, hatchKind, name) {
|
|
1476
2059
|
this.pos++;
|
|
1477
2060
|
if (!c.matchWord('from')) {
|
|
@@ -1502,6 +2085,10 @@ class Parser {
|
|
|
1502
2085
|
const line = this.lines[this.pos];
|
|
1503
2086
|
if (line.indent === 0)
|
|
1504
2087
|
break;
|
|
2088
|
+
if (looksLikeComment(line)) {
|
|
2089
|
+
this.skipCommentInsideBlock(line);
|
|
2090
|
+
continue;
|
|
2091
|
+
}
|
|
1505
2092
|
const sc = new Cursor(line.tokens, line);
|
|
1506
2093
|
let timing = null;
|
|
1507
2094
|
let turns = 0;
|
|
@@ -1548,43 +2135,174 @@ class Parser {
|
|
|
1548
2135
|
steps.push({ kind: 'sequence-step', timing, turns, owner, state, body, span: lineSpan(line) });
|
|
1549
2136
|
}
|
|
1550
2137
|
const endSpan = this.consumeEnd('sequence', headLine);
|
|
1551
|
-
return { kind: 'define-sequence', name, steps, span: (0,
|
|
2138
|
+
return { kind: 'define-sequence', name, steps, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
1552
2139
|
}
|
|
1553
|
-
// ------------------------------------------------------------- on clause
|
|
1554
2140
|
/**
|
|
1555
|
-
* `
|
|
1556
|
-
*
|
|
1557
|
-
*
|
|
2141
|
+
* `define topics for <entity> … end topics` (ADR-239 D3 as amended) —
|
|
2142
|
+
* the ask/tell topic table: `about` rows in two tiers (entity /
|
|
2143
|
+
* quoted free-text with comma-separated aliases), each answering with
|
|
2144
|
+
* a one-line statement or an indented statement body.
|
|
1558
2145
|
*/
|
|
1559
|
-
|
|
2146
|
+
parseDefineTopics() {
|
|
1560
2147
|
const headLine = this.lines[this.pos++];
|
|
1561
2148
|
const c = new Cursor(headLine.tokens, headLine);
|
|
1562
|
-
c.
|
|
1563
|
-
|
|
1564
|
-
let
|
|
1565
|
-
if (
|
|
1566
|
-
|
|
2149
|
+
c.next();
|
|
2150
|
+
c.next(); // define topics
|
|
2151
|
+
let owner;
|
|
2152
|
+
if (!c.matchWord('for')) {
|
|
2153
|
+
this.diagnostics.error('parse.topics-for', 'Expected `for <entity>` after `define topics`.', c.restSpan());
|
|
2154
|
+
owner = { kind: 'name', article: null, words: [], span: lineSpan(headLine) };
|
|
1567
2155
|
}
|
|
1568
2156
|
else {
|
|
1569
|
-
this.
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
let role = null;
|
|
1573
|
-
let condition = null;
|
|
1574
|
-
let once = false;
|
|
1575
|
-
let ordering = null;
|
|
1576
|
-
if (actionWord === 'every' && c.isWord('turn')) {
|
|
1577
|
-
// `on every turn [while <condition>] [, once]` (§3.3 + D5).
|
|
1578
|
-
c.next();
|
|
1579
|
-
binding = 'every-turn';
|
|
1580
|
-
actionWord = 'every-turn';
|
|
1581
|
-
if (clauseKind === 'after') {
|
|
1582
|
-
this.diagnostics.error('parse.after-every-turn', '`after every turn` is not a form — every-turn clauses are `on every turn` (they are not reactions to an action).', lineSpan(headLine));
|
|
2157
|
+
owner = this.parseNameRef(c, () => false);
|
|
2158
|
+
if (owner.words.length === 0 || !c.atEnd()) {
|
|
2159
|
+
this.diagnostics.error('parse.topics-for', 'Expected `define topics for <entity>` — the owner name runs to the end of the line.', c.restSpan());
|
|
1583
2160
|
}
|
|
1584
2161
|
}
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
2162
|
+
const decl = { kind: 'define-topics', owner, rows: [], span: lineSpan(headLine) };
|
|
2163
|
+
while (this.pos < this.lines.length) {
|
|
2164
|
+
const line = this.lines[this.pos];
|
|
2165
|
+
const word = firstWord(line);
|
|
2166
|
+
const lc = new Cursor(line.tokens, line);
|
|
2167
|
+
if (word === 'end' && lc.isWord('topics', 1)) {
|
|
2168
|
+
this.pos++;
|
|
2169
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2170
|
+
if (decl.rows.length === 0) {
|
|
2171
|
+
this.diagnostics.error('parse.topics-empty', 'This `define topics` block declares no rows — add `about …: <response>` rows, or remove the block.', decl.span);
|
|
2172
|
+
}
|
|
2173
|
+
return decl;
|
|
2174
|
+
}
|
|
2175
|
+
if (looksLikeComment(line)) {
|
|
2176
|
+
this.skipCommentInsideBlock(line);
|
|
2177
|
+
continue;
|
|
2178
|
+
}
|
|
2179
|
+
if (line.indent === 0)
|
|
2180
|
+
break; // dedent without `end topics` — reported below
|
|
2181
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2182
|
+
if (word === 'about') {
|
|
2183
|
+
const row = this.parseTopicRow(line);
|
|
2184
|
+
if (row) {
|
|
2185
|
+
decl.rows.push(row);
|
|
2186
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, row.span);
|
|
2187
|
+
}
|
|
2188
|
+
continue;
|
|
2189
|
+
}
|
|
2190
|
+
this.diagnostics.error('parse.topics-row', `Unrecognized line in \`define topics\`: \`${line.raw.trim()}\` — expected an \`about …: <response>\` row or \`end topics\`.`, lineSpan(line));
|
|
2191
|
+
this.pos++;
|
|
2192
|
+
}
|
|
2193
|
+
this.diagnostics.error('parse.topics-end', 'Expected `end topics` to close the block.', decl.span);
|
|
2194
|
+
if (decl.rows.length === 0) {
|
|
2195
|
+
this.diagnostics.error('parse.topics-empty', 'This `define topics` block declares no rows — add `about …: <response>` rows, or remove the block.', decl.span);
|
|
2196
|
+
}
|
|
2197
|
+
return decl;
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* One `about …: <response>` row. Entity tier: `about the <entity>:`.
|
|
2201
|
+
* Free-text tier: `about "<text>"[, "<text>" …]:` — comma-separated
|
|
2202
|
+
* quoted aliases (spelling ruled 2026-07-18). The response is either the
|
|
2203
|
+
* rest of the line (one statement) or an indented statement body.
|
|
2204
|
+
*/
|
|
2205
|
+
parseTopicRow(headLine) {
|
|
2206
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
2207
|
+
c.next(); // about
|
|
2208
|
+
let filter;
|
|
2209
|
+
const first = c.peek();
|
|
2210
|
+
if (first && first.kind === 'string') {
|
|
2211
|
+
c.next();
|
|
2212
|
+
if (first.text.trim() === '') {
|
|
2213
|
+
this.diagnostics.error('parse.topics-row', 'A quoted topic cannot be empty.', first.span);
|
|
2214
|
+
this.pos++;
|
|
2215
|
+
return null;
|
|
2216
|
+
}
|
|
2217
|
+
const aliases = [];
|
|
2218
|
+
let span = first.span;
|
|
2219
|
+
while (c.peek()?.kind === 'comma') {
|
|
2220
|
+
c.next();
|
|
2221
|
+
const alias = c.next();
|
|
2222
|
+
if (!alias || alias.kind !== 'string' || alias.text.trim() === '') {
|
|
2223
|
+
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());
|
|
2224
|
+
this.pos++;
|
|
2225
|
+
return null;
|
|
2226
|
+
}
|
|
2227
|
+
aliases.push(alias.text);
|
|
2228
|
+
span = (0, span_js_1.mergeSpans)(span, alias.span);
|
|
2229
|
+
}
|
|
2230
|
+
filter = { kind: 'text', primary: first.text, aliases, span };
|
|
2231
|
+
}
|
|
2232
|
+
else {
|
|
2233
|
+
const ref = this.parseNameRef(c, () => false);
|
|
2234
|
+
if (ref.words.length === 0) {
|
|
2235
|
+
this.diagnostics.error('parse.topics-row', 'Expected an entity name or a quoted topic after `about`.', c.restSpan());
|
|
2236
|
+
this.pos++;
|
|
2237
|
+
return null;
|
|
2238
|
+
}
|
|
2239
|
+
filter = { kind: 'entity', ref };
|
|
2240
|
+
}
|
|
2241
|
+
const colon = c.next();
|
|
2242
|
+
if (!colon || colon.kind !== 'colon') {
|
|
2243
|
+
this.diagnostics.error('parse.topics-colon', 'Expected `:` after the topic key.', colon?.span ?? c.restSpan());
|
|
2244
|
+
this.pos++;
|
|
2245
|
+
return null;
|
|
2246
|
+
}
|
|
2247
|
+
let body;
|
|
2248
|
+
let span = lineSpan(headLine);
|
|
2249
|
+
if (!c.atEnd()) {
|
|
2250
|
+
// One-line response: the rest of the line is a single statement.
|
|
2251
|
+
// parseStatement consumes this.lines[this.pos] (the row line itself).
|
|
2252
|
+
const rest = { ...headLine, tokens: headLine.tokens.slice(c.i) };
|
|
2253
|
+
const stmt = this.parseStatement(rest, 'topics');
|
|
2254
|
+
if (!stmt)
|
|
2255
|
+
return null; // the statement error is already reported
|
|
2256
|
+
body = [stmt];
|
|
2257
|
+
}
|
|
2258
|
+
else {
|
|
2259
|
+
// Indented statement body on the following lines.
|
|
2260
|
+
this.pos++;
|
|
2261
|
+
body = this.parseStatements(headLine.indent, 'topics');
|
|
2262
|
+
if (body.length > 0)
|
|
2263
|
+
span = (0, span_js_1.mergeSpans)(span, body[body.length - 1].span);
|
|
2264
|
+
}
|
|
2265
|
+
if (body.length === 0) {
|
|
2266
|
+
this.diagnostics.error('parse.topics-response', 'Expected a response — a one-line statement after the `:`, or an indented statement body.', lineSpan(headLine));
|
|
2267
|
+
return null;
|
|
2268
|
+
}
|
|
2269
|
+
return { kind: 'topic-row', filter, body, span };
|
|
2270
|
+
}
|
|
2271
|
+
// ------------------------------------------------------------- on clause
|
|
2272
|
+
/**
|
|
2273
|
+
* `on <verb> it` (intercept) / `after <verb> it` (react, ratchet D3) /
|
|
2274
|
+
* `on every turn` — with `while <cond>` on any binding and the `, once`
|
|
2275
|
+
* clause modifier (D5). Terminated by `end on` / `end after`.
|
|
2276
|
+
*/
|
|
2277
|
+
parseOnClause(indent, clauseKind) {
|
|
2278
|
+
const headLine = this.lines[this.pos++];
|
|
2279
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
2280
|
+
c.matchWord(clauseKind);
|
|
2281
|
+
const action = c.next();
|
|
2282
|
+
let actionWord = '';
|
|
2283
|
+
if (action && action.kind === 'word') {
|
|
2284
|
+
actionWord = action.text;
|
|
2285
|
+
}
|
|
2286
|
+
else {
|
|
2287
|
+
this.diagnostics.error('parse.on-action', `Expected an action word after \`${clauseKind}\`.`, lineSpan(headLine));
|
|
2288
|
+
}
|
|
2289
|
+
let binding = 'it';
|
|
2290
|
+
let role = null;
|
|
2291
|
+
let condition = null;
|
|
2292
|
+
let once = false;
|
|
2293
|
+
let ordering = null;
|
|
2294
|
+
if (actionWord === 'every' && c.isWord('turn')) {
|
|
2295
|
+
// `on every turn [while <condition>] [, once]` (§3.3 + D5).
|
|
2296
|
+
c.next();
|
|
2297
|
+
binding = 'every-turn';
|
|
2298
|
+
actionWord = 'every-turn';
|
|
2299
|
+
if (clauseKind === 'after') {
|
|
2300
|
+
this.diagnostics.error('parse.after-every-turn', '`after every turn` is not a form — every-turn clauses are `on every turn` (they are not reactions to an action).', lineSpan(headLine));
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
else if (c.matchWord('anything')) {
|
|
2304
|
+
// `on <action> anything as the <role>` (design.md §2.2 role binding).
|
|
2305
|
+
binding = 'role';
|
|
1588
2306
|
if (!c.matchWord('as') || !c.matchWord('the')) {
|
|
1589
2307
|
this.diagnostics.error('parse.on-role', 'Expected `as the <role>` after `anything`.', c.restSpan());
|
|
1590
2308
|
}
|
|
@@ -1637,8 +2355,629 @@ class Parser {
|
|
|
1637
2355
|
once,
|
|
1638
2356
|
ordering,
|
|
1639
2357
|
body,
|
|
1640
|
-
span: (0,
|
|
2358
|
+
span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan),
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
/**
|
|
2362
|
+
* ADR-216 media sugar statements: `play sound|music|ambient <asset>
|
|
2363
|
+
* [looping]`, `stop music|ambient`, `show image <asset> [in <layer>]`,
|
|
2364
|
+
* `hide image`, `transition <kind>`, `clear` — each with the standard
|
|
2365
|
+
* `when` suffix. Asset resolution/kind-checking is the analyzer's.
|
|
2366
|
+
*/
|
|
2367
|
+
parseMediaStatement(first, c, line) {
|
|
2368
|
+
c.next(); // the keyword
|
|
2369
|
+
const fail = (message) => {
|
|
2370
|
+
this.diagnostics.error('parse.media', message, lineSpan(line));
|
|
2371
|
+
return null;
|
|
1641
2372
|
};
|
|
2373
|
+
let form;
|
|
2374
|
+
let asset = null;
|
|
2375
|
+
let layer = null;
|
|
2376
|
+
let channel = null;
|
|
2377
|
+
let looping = false;
|
|
2378
|
+
let transitionKind = null;
|
|
2379
|
+
/** ADR-241 D3: optional `in <channel-word>` tail on the ambient forms. */
|
|
2380
|
+
const parseChannelTail = () => {
|
|
2381
|
+
if (!c.matchWord('in'))
|
|
2382
|
+
return true;
|
|
2383
|
+
const channelTok = c.next();
|
|
2384
|
+
if (!channelTok || channelTok.kind !== 'word') {
|
|
2385
|
+
fail('Expected a channel word after `in`.');
|
|
2386
|
+
return false;
|
|
2387
|
+
}
|
|
2388
|
+
channel = channelTok.text;
|
|
2389
|
+
return true;
|
|
2390
|
+
};
|
|
2391
|
+
if (first === 'play') {
|
|
2392
|
+
const what = c.next();
|
|
2393
|
+
if (!what || what.kind !== 'word' || !['sound', 'music', 'ambient'].includes(what.text)) {
|
|
2394
|
+
return fail('Expected `play sound|music|ambient <asset>`.');
|
|
2395
|
+
}
|
|
2396
|
+
const assetTok = c.next();
|
|
2397
|
+
if (!assetTok || assetTok.kind !== 'word')
|
|
2398
|
+
return fail(`Expected a declared asset name after \`play ${what.text}\`.`);
|
|
2399
|
+
asset = assetTok.text;
|
|
2400
|
+
if (what.text === 'music' && c.isWord('looping')) {
|
|
2401
|
+
c.next();
|
|
2402
|
+
looping = true;
|
|
2403
|
+
}
|
|
2404
|
+
if (what.text === 'ambient' && !parseChannelTail())
|
|
2405
|
+
return null;
|
|
2406
|
+
form = `play-${what.text}`;
|
|
2407
|
+
}
|
|
2408
|
+
else if (first === 'stop') {
|
|
2409
|
+
const what = c.next();
|
|
2410
|
+
if (!what || what.kind !== 'word' || !['music', 'ambient'].includes(what.text)) {
|
|
2411
|
+
return fail('Expected `stop music` or `stop ambient`.');
|
|
2412
|
+
}
|
|
2413
|
+
if (what.text === 'ambient' && !parseChannelTail())
|
|
2414
|
+
return null;
|
|
2415
|
+
form = `stop-${what.text}`;
|
|
2416
|
+
}
|
|
2417
|
+
else if (first === 'show') {
|
|
2418
|
+
if (!c.matchWord('image'))
|
|
2419
|
+
return fail('Expected `show image <asset> [in <layer>]`.');
|
|
2420
|
+
const assetTok = c.next();
|
|
2421
|
+
if (!assetTok || assetTok.kind !== 'word')
|
|
2422
|
+
return fail('Expected a declared asset name after `show image`.');
|
|
2423
|
+
asset = assetTok.text;
|
|
2424
|
+
if (c.matchWord('in')) {
|
|
2425
|
+
const layerTok = c.next();
|
|
2426
|
+
if (!layerTok || layerTok.kind !== 'word')
|
|
2427
|
+
return fail('Expected a layer word after `in`.');
|
|
2428
|
+
layer = layerTok.text;
|
|
2429
|
+
}
|
|
2430
|
+
form = 'show-image';
|
|
2431
|
+
}
|
|
2432
|
+
else if (first === 'hide') {
|
|
2433
|
+
if (!c.matchWord('image'))
|
|
2434
|
+
return fail('Expected `hide image`.');
|
|
2435
|
+
form = 'hide-image';
|
|
2436
|
+
}
|
|
2437
|
+
else if (first === 'transition') {
|
|
2438
|
+
const kindTok = c.next();
|
|
2439
|
+
if (!kindTok || kindTok.kind !== 'word')
|
|
2440
|
+
return fail('Expected a transition kind after `transition` (e.g. `transition fade`).');
|
|
2441
|
+
transitionKind = kindTok.text;
|
|
2442
|
+
form = 'transition';
|
|
2443
|
+
}
|
|
2444
|
+
else {
|
|
2445
|
+
form = 'clear';
|
|
2446
|
+
}
|
|
2447
|
+
const stmtWhen = this.parseStatementWhen(c, line);
|
|
2448
|
+
if (!c.atEnd())
|
|
2449
|
+
return fail(`Unexpected trailing text: \`${c.peek().text}\`.`);
|
|
2450
|
+
return { kind: 'media', form, asset, layer, channel, looping, transitionKind, stmtWhen, span: lineSpan(line) };
|
|
2451
|
+
}
|
|
2452
|
+
/**
|
|
2453
|
+
* `define channel <name> … end channel` (ADR-216; spelling A): keyword
|
|
2454
|
+
* body lines `mode <word>`, `gated by <capability>`, `from event
|
|
2455
|
+
* <dotted.key>`, `take <field>, …`. Value validation (mode set,
|
|
2456
|
+
* capability flags, required lines) is the analyzer's.
|
|
2457
|
+
*/
|
|
2458
|
+
parseDefineChannel() {
|
|
2459
|
+
const headLine = this.lines[this.pos++];
|
|
2460
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
2461
|
+
c.next();
|
|
2462
|
+
c.next(); // define channel
|
|
2463
|
+
const nameTok = c.next();
|
|
2464
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
2465
|
+
this.diagnostics.error('parse.channel-name', 'Expected a channel name after `define channel`.', lineSpan(headLine));
|
|
2466
|
+
return null;
|
|
2467
|
+
}
|
|
2468
|
+
const decl = {
|
|
2469
|
+
kind: 'define-channel',
|
|
2470
|
+
name: nameTok.text,
|
|
2471
|
+
mode: null,
|
|
2472
|
+
gatedBy: null,
|
|
2473
|
+
fromEvent: null,
|
|
2474
|
+
returns: null,
|
|
2475
|
+
span: lineSpan(headLine),
|
|
2476
|
+
};
|
|
2477
|
+
while (this.pos < this.lines.length) {
|
|
2478
|
+
const line = this.lines[this.pos];
|
|
2479
|
+
const word = firstWord(line);
|
|
2480
|
+
const lc = new Cursor(line.tokens, line);
|
|
2481
|
+
if (word === 'end' && lc.isWord('channel', 1)) {
|
|
2482
|
+
this.pos++;
|
|
2483
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2484
|
+
return decl;
|
|
2485
|
+
}
|
|
2486
|
+
if (looksLikeComment(line)) {
|
|
2487
|
+
this.skipCommentInsideBlock(line);
|
|
2488
|
+
continue;
|
|
2489
|
+
}
|
|
2490
|
+
if (line.indent === 0)
|
|
2491
|
+
break;
|
|
2492
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2493
|
+
this.pos++;
|
|
2494
|
+
if (word === 'mode') {
|
|
2495
|
+
lc.next();
|
|
2496
|
+
const modeTok = lc.next();
|
|
2497
|
+
if (!modeTok || modeTok.kind !== 'word' || !lc.atEnd()) {
|
|
2498
|
+
this.diagnostics.error('parse.channel-mode', 'Expected `mode replace|append|event`.', lineSpan(line));
|
|
2499
|
+
}
|
|
2500
|
+
else {
|
|
2501
|
+
decl.mode = modeTok.text;
|
|
2502
|
+
}
|
|
2503
|
+
continue;
|
|
2504
|
+
}
|
|
2505
|
+
if (word === 'gated' && lc.isWord('by', 1)) {
|
|
2506
|
+
lc.next();
|
|
2507
|
+
lc.next();
|
|
2508
|
+
const capTok = lc.next();
|
|
2509
|
+
if (!capTok || capTok.kind !== 'word' || !lc.atEnd()) {
|
|
2510
|
+
this.diagnostics.error('parse.channel-gate', 'Expected `gated by <capability>`.', lineSpan(line));
|
|
2511
|
+
}
|
|
2512
|
+
else {
|
|
2513
|
+
decl.gatedBy = capTok.text;
|
|
2514
|
+
}
|
|
2515
|
+
continue;
|
|
2516
|
+
}
|
|
2517
|
+
if (word === 'return') {
|
|
2518
|
+
// ADR-253 D1: `return <field | "text" | phrase <key>> from <event>` —
|
|
2519
|
+
// the event rides this clause (the standalone `from event`/`take` lines
|
|
2520
|
+
// are removed below).
|
|
2521
|
+
lc.next(); // return
|
|
2522
|
+
const returns = this.parseChannelReturn(lc, line);
|
|
2523
|
+
if (!returns)
|
|
2524
|
+
continue; // error already reported
|
|
2525
|
+
if (!lc.matchWord('from')) {
|
|
2526
|
+
this.diagnostics.error('parse.channel-return', 'Expected `from <event>` after the returned construct — `return <construct> from <event>`.', lineSpan(line));
|
|
2527
|
+
continue;
|
|
2528
|
+
}
|
|
2529
|
+
const key = this.readLabelKey(lc); // ADR-256: dotless Chord event id; the loader translates to the platform id
|
|
2530
|
+
if (!key || !lc.atEnd()) {
|
|
2531
|
+
this.diagnostics.error('parse.channel-return', 'Expected a single event id after `from`.', lineSpan(line));
|
|
2532
|
+
continue;
|
|
2533
|
+
}
|
|
2534
|
+
decl.returns = returns;
|
|
2535
|
+
decl.fromEvent = key;
|
|
2536
|
+
continue;
|
|
2537
|
+
}
|
|
2538
|
+
if (word === 'from' && lc.isWord('event', 1)) {
|
|
2539
|
+
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));
|
|
2540
|
+
continue;
|
|
2541
|
+
}
|
|
2542
|
+
if (word === 'take') {
|
|
2543
|
+
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));
|
|
2544
|
+
continue;
|
|
2545
|
+
}
|
|
2546
|
+
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));
|
|
2547
|
+
}
|
|
2548
|
+
this.diagnostics.error('parse.channel-end', 'Expected `end channel` to close the block.', decl.span);
|
|
2549
|
+
return decl;
|
|
2550
|
+
}
|
|
2551
|
+
/**
|
|
2552
|
+
* Parse the construct after `return` (ADR-253 D1), stopping before `from`:
|
|
2553
|
+
* a `"quoted"` text template, a `phrase <key>`, or a bare field word. On a
|
|
2554
|
+
* malformed construct reports `parse.channel-return` and returns null.
|
|
2555
|
+
*/
|
|
2556
|
+
parseChannelReturn(lc, line) {
|
|
2557
|
+
const tok = lc.peek();
|
|
2558
|
+
if (!tok) {
|
|
2559
|
+
this.diagnostics.error('parse.channel-return', 'Expected a field, a "text" template, or `phrase <key>` after `return`.', lineSpan(line));
|
|
2560
|
+
return null;
|
|
2561
|
+
}
|
|
2562
|
+
if (tok.kind === 'string') {
|
|
2563
|
+
lc.next();
|
|
2564
|
+
return { kind: 'text', text: tok.text };
|
|
2565
|
+
}
|
|
2566
|
+
if (tok.kind === 'word' && tok.text === 'phrase') {
|
|
2567
|
+
lc.next(); // phrase
|
|
2568
|
+
const key = this.readLabelKey(lc);
|
|
2569
|
+
if (!key) {
|
|
2570
|
+
this.diagnostics.error('parse.channel-return', 'Expected a phrase key after `return phrase`.', lineSpan(line));
|
|
2571
|
+
return null;
|
|
2572
|
+
}
|
|
2573
|
+
return { kind: 'phrase', phrase: key };
|
|
2574
|
+
}
|
|
2575
|
+
if (tok.kind === 'word') {
|
|
2576
|
+
const field = this.readLabelKey(lc);
|
|
2577
|
+
if (!field) {
|
|
2578
|
+
this.diagnostics.error('parse.channel-return', 'Expected a field name after `return`.', lineSpan(line));
|
|
2579
|
+
return null;
|
|
2580
|
+
}
|
|
2581
|
+
return { kind: 'field', field };
|
|
2582
|
+
}
|
|
2583
|
+
this.diagnostics.error('parse.channel-return', 'Expected a field, a "text" template, or `phrase <key>` after `return`.', lineSpan(line));
|
|
2584
|
+
return null;
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* `define pronouns <name> … end pronouns` (ADR-242 D7, ruled Q-1):
|
|
2588
|
+
* five named rows, each `<case> <form>` — `subject`, `object`,
|
|
2589
|
+
* `possessive`, `possessive-pronoun`, `reflexive`. Row completeness,
|
|
2590
|
+
* duplicates, and standard-word shadowing are the analyzer's gates
|
|
2591
|
+
* (the parseDefineChannel split: parser collects, analyzer validates).
|
|
2592
|
+
*/
|
|
2593
|
+
parseDefinePronouns() {
|
|
2594
|
+
const headLine = this.lines[this.pos++];
|
|
2595
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
2596
|
+
c.next();
|
|
2597
|
+
c.next(); // define pronouns
|
|
2598
|
+
const nameTok = c.next();
|
|
2599
|
+
if (!nameTok || nameTok.kind !== 'word' || !c.atEnd()) {
|
|
2600
|
+
this.diagnostics.error('parse.pronouns-name', 'Expected a set name after `define pronouns` (e.g. `define pronouns ze`).', lineSpan(headLine));
|
|
2601
|
+
return null;
|
|
2602
|
+
}
|
|
2603
|
+
const decl = {
|
|
2604
|
+
kind: 'define-pronouns',
|
|
2605
|
+
name: nameTok.text.toLowerCase(),
|
|
2606
|
+
rows: [],
|
|
2607
|
+
span: lineSpan(headLine),
|
|
2608
|
+
};
|
|
2609
|
+
while (this.pos < this.lines.length) {
|
|
2610
|
+
const line = this.lines[this.pos];
|
|
2611
|
+
const word = firstWord(line);
|
|
2612
|
+
const lc = new Cursor(line.tokens, line);
|
|
2613
|
+
if (word === 'end' && lc.isWord('pronouns', 1)) {
|
|
2614
|
+
this.pos++;
|
|
2615
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2616
|
+
return decl;
|
|
2617
|
+
}
|
|
2618
|
+
if (looksLikeComment(line)) {
|
|
2619
|
+
this.skipCommentInsideBlock(line);
|
|
2620
|
+
continue;
|
|
2621
|
+
}
|
|
2622
|
+
if (line.indent === 0)
|
|
2623
|
+
break;
|
|
2624
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2625
|
+
this.pos++;
|
|
2626
|
+
if (word && catalog_js_1.PRONOUN_CASES.includes(word)) {
|
|
2627
|
+
lc.next();
|
|
2628
|
+
const formTok = lc.next();
|
|
2629
|
+
if (!formTok || formTok.kind !== 'word' || !lc.atEnd()) {
|
|
2630
|
+
this.diagnostics.error('parse.pronouns-row', `Expected one form word after \`${word}\` (e.g. \`${word} zir\`).`, lineSpan(line));
|
|
2631
|
+
}
|
|
2632
|
+
else {
|
|
2633
|
+
decl.rows.push({ case: word, form: formTok.text, span: lineSpan(line) });
|
|
2634
|
+
}
|
|
2635
|
+
continue;
|
|
2636
|
+
}
|
|
2637
|
+
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));
|
|
2638
|
+
}
|
|
2639
|
+
this.diagnostics.error('parse.pronouns-end', 'Expected `end pronouns` to close the block.', decl.span);
|
|
2640
|
+
return decl;
|
|
2641
|
+
}
|
|
2642
|
+
/** `define sound|image|music <name> from "<file>"` (ADR-216) — data asset. */
|
|
2643
|
+
parseDefineAsset(assetKind) {
|
|
2644
|
+
const line = this.lines[this.pos++];
|
|
2645
|
+
const c = new Cursor(line.tokens, line);
|
|
2646
|
+
c.next();
|
|
2647
|
+
c.next(); // define <kind>
|
|
2648
|
+
const nameTok = c.next();
|
|
2649
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
2650
|
+
this.diagnostics.error('parse.asset-name', `Expected an asset name after \`define ${assetKind}\`.`, c.restSpan());
|
|
2651
|
+
return null;
|
|
2652
|
+
}
|
|
2653
|
+
if (!c.matchWord('from')) {
|
|
2654
|
+
this.diagnostics.error('parse.asset-from', `Expected \`from "<file>"\` in the ${assetKind} declaration.`, c.restSpan());
|
|
2655
|
+
return null;
|
|
2656
|
+
}
|
|
2657
|
+
const pathTok = c.next();
|
|
2658
|
+
if (!pathTok || pathTok.kind !== 'string') {
|
|
2659
|
+
this.diagnostics.error('parse.asset-path', 'Expected a quoted file path after `from`.', c.restSpan());
|
|
2660
|
+
return null;
|
|
2661
|
+
}
|
|
2662
|
+
return { kind: 'define-asset', assetKind, name: nameTok.text, path: pathTok.text, span: lineSpan(line) };
|
|
2663
|
+
}
|
|
2664
|
+
/**
|
|
2665
|
+
* `define ambient <word>` / `define layer <word>` (ADR-241 D2): a
|
|
2666
|
+
* one-line named family channel declaration. Exactly one word — the
|
|
2667
|
+
* bed/layer name; the registered id is the loader's business.
|
|
2668
|
+
*/
|
|
2669
|
+
parseDefineFamilyChannel(family) {
|
|
2670
|
+
const line = this.lines[this.pos++];
|
|
2671
|
+
const c = new Cursor(line.tokens, line);
|
|
2672
|
+
c.next();
|
|
2673
|
+
c.next(); // define ambient|layer
|
|
2674
|
+
const nameTok = c.next();
|
|
2675
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
2676
|
+
this.diagnostics.error('parse.channel-name', `Expected a ${family === 'ambient' ? 'bed' : 'layer'} name after \`define ${family}\`.`, c.restSpan());
|
|
2677
|
+
return null;
|
|
2678
|
+
}
|
|
2679
|
+
if (!c.atEnd()) {
|
|
2680
|
+
this.diagnostics.error('parse.channel-name', `Unexpected trailing text after \`define ${family} ${nameTok.text}\` — the declaration is one word.`, c.restSpan());
|
|
2681
|
+
return null;
|
|
2682
|
+
}
|
|
2683
|
+
return { kind: 'define-family-channel', family, name: nameTok.text, span: lineSpan(line) };
|
|
2684
|
+
}
|
|
2685
|
+
// ---------------------------------------------------------- emit payload
|
|
2686
|
+
/**
|
|
2687
|
+
* ADR-216 payload fields: `<field> <value>` separated by `and` at the
|
|
2688
|
+
* flat (statement) level, by commas inside `{ … }` objects. Values:
|
|
2689
|
+
* literals, `[ … ]` arrays, `{ … }` objects, or value expressions
|
|
2690
|
+
* (world-state reads).
|
|
2691
|
+
*/
|
|
2692
|
+
parseEmitFields(c, line, mode) {
|
|
2693
|
+
const fields = [];
|
|
2694
|
+
for (;;) {
|
|
2695
|
+
if (mode === 'braced' && c.peek()?.kind === 'rbrace')
|
|
2696
|
+
break;
|
|
2697
|
+
if (mode === 'flat' && (c.atEnd() || c.isWord('when')))
|
|
2698
|
+
break;
|
|
2699
|
+
const startTok = c.peek();
|
|
2700
|
+
const key = [];
|
|
2701
|
+
while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('when')) {
|
|
2702
|
+
const t = c.peek();
|
|
2703
|
+
if (ARTICLES.has(t.text) && key.length > 0)
|
|
2704
|
+
break;
|
|
2705
|
+
const after = c.peek(1);
|
|
2706
|
+
const atValuePosition = !after ||
|
|
2707
|
+
after.kind === 'comma' ||
|
|
2708
|
+
after.kind === 'rbrace' ||
|
|
2709
|
+
(after.kind === 'word' && (after.text === 'and' || after.text === 'when'));
|
|
2710
|
+
if (atValuePosition && key.length > 0)
|
|
2711
|
+
break;
|
|
2712
|
+
key.push(t.text);
|
|
2713
|
+
c.next();
|
|
2714
|
+
}
|
|
2715
|
+
if (key.length === 0) {
|
|
2716
|
+
this.diagnostics.error('parse.emit-payload', 'Expected a payload field name.', c.restSpan());
|
|
2717
|
+
break;
|
|
2718
|
+
}
|
|
2719
|
+
const value = this.parseEmitValue(c, line);
|
|
2720
|
+
if (!value)
|
|
2721
|
+
break;
|
|
2722
|
+
fields.push({ key, value, span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, value.span) : value.span });
|
|
2723
|
+
if (mode === 'flat') {
|
|
2724
|
+
if (!c.matchWord('and'))
|
|
2725
|
+
break;
|
|
2726
|
+
}
|
|
2727
|
+
else if (c.peek()?.kind === 'comma') {
|
|
2728
|
+
c.next();
|
|
2729
|
+
}
|
|
2730
|
+
else {
|
|
2731
|
+
break;
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
return fields;
|
|
2735
|
+
}
|
|
2736
|
+
/** One ADR-216 payload value (recursive: literals, arrays, objects, value exprs). */
|
|
2737
|
+
parseEmitValue(c, line) {
|
|
2738
|
+
const t = c.peek();
|
|
2739
|
+
if (!t) {
|
|
2740
|
+
this.diagnostics.error('parse.emit-payload', 'Expected a payload value.', c.restSpan());
|
|
2741
|
+
return null;
|
|
2742
|
+
}
|
|
2743
|
+
if (t.kind === 'number' || t.kind === 'string') {
|
|
2744
|
+
c.next();
|
|
2745
|
+
return { kind: 'literal', value: t.text, literalKind: t.kind, span: t.span };
|
|
2746
|
+
}
|
|
2747
|
+
if (t.kind === 'lbracket') {
|
|
2748
|
+
c.next();
|
|
2749
|
+
const items = [];
|
|
2750
|
+
while (c.peek() && c.peek().kind !== 'rbracket') {
|
|
2751
|
+
const item = this.parseEmitValue(c, line);
|
|
2752
|
+
if (!item)
|
|
2753
|
+
break;
|
|
2754
|
+
items.push(item);
|
|
2755
|
+
if (c.peek()?.kind === 'comma')
|
|
2756
|
+
c.next();
|
|
2757
|
+
else
|
|
2758
|
+
break;
|
|
2759
|
+
}
|
|
2760
|
+
const close = c.next();
|
|
2761
|
+
let endSpan = t.span;
|
|
2762
|
+
if (!close || close.kind !== 'rbracket') {
|
|
2763
|
+
this.diagnostics.error('parse.emit-payload', 'Expected `]` to close the array.', c.restSpan());
|
|
2764
|
+
}
|
|
2765
|
+
else {
|
|
2766
|
+
endSpan = close.span;
|
|
2767
|
+
}
|
|
2768
|
+
return { kind: 'array', items, span: (0, span_js_1.mergeSpans)(t.span, endSpan) };
|
|
2769
|
+
}
|
|
2770
|
+
if (t.kind === 'lbrace') {
|
|
2771
|
+
c.next();
|
|
2772
|
+
const fields = this.parseEmitFields(c, line, 'braced');
|
|
2773
|
+
const close = c.next();
|
|
2774
|
+
let endSpan = t.span;
|
|
2775
|
+
if (!close || close.kind !== 'rbrace') {
|
|
2776
|
+
this.diagnostics.error('parse.emit-payload', 'Expected `}` to close the object.', c.restSpan());
|
|
2777
|
+
}
|
|
2778
|
+
else {
|
|
2779
|
+
endSpan = close.span;
|
|
2780
|
+
}
|
|
2781
|
+
return { kind: 'object', fields, span: (0, span_js_1.mergeSpans)(t.span, endSpan) };
|
|
2782
|
+
}
|
|
2783
|
+
const expr = this.parseValueExpr(c, line, EMIT_VALUE_STOPS);
|
|
2784
|
+
return { kind: 'expr', expr, span: expr.span };
|
|
2785
|
+
}
|
|
2786
|
+
// -------------------------------------------------------------- machines
|
|
2787
|
+
/**
|
|
2788
|
+
* `define machine <name> … end machine` (ADR-215 `use state-machines`
|
|
2789
|
+
* depth; spelling A ratified 2026-07-18): `role <name> is <entity>`
|
|
2790
|
+
* bindings, `starts <state>`, and `state <name>[, terminal]` blocks. The
|
|
2791
|
+
* `use` requirement is the analyzer's gate.
|
|
2792
|
+
*/
|
|
2793
|
+
parseDefineMachine() {
|
|
2794
|
+
const headLine = this.lines[this.pos++];
|
|
2795
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
2796
|
+
c.next();
|
|
2797
|
+
c.next(); // define machine
|
|
2798
|
+
const name = [];
|
|
2799
|
+
while (!c.atEnd() && c.peek().kind === 'word')
|
|
2800
|
+
name.push(c.next().text);
|
|
2801
|
+
if (name.length === 0) {
|
|
2802
|
+
this.diagnostics.error('parse.machine-name', 'Expected a machine name after `define machine`.', lineSpan(headLine));
|
|
2803
|
+
}
|
|
2804
|
+
const decl = { kind: 'define-machine', name, roles: [], initialState: null, states: [], span: lineSpan(headLine) };
|
|
2805
|
+
while (this.pos < this.lines.length) {
|
|
2806
|
+
const line = this.lines[this.pos];
|
|
2807
|
+
const word = firstWord(line);
|
|
2808
|
+
const lc = new Cursor(line.tokens, line);
|
|
2809
|
+
if (word === 'end' && lc.isWord('machine', 1)) {
|
|
2810
|
+
this.pos++;
|
|
2811
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2812
|
+
return decl;
|
|
2813
|
+
}
|
|
2814
|
+
if (looksLikeComment(line)) {
|
|
2815
|
+
this.skipCommentInsideBlock(line);
|
|
2816
|
+
continue;
|
|
2817
|
+
}
|
|
2818
|
+
if (line.indent === 0)
|
|
2819
|
+
break; // dedent without `end machine` — reported below
|
|
2820
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
2821
|
+
if (word === 'role') {
|
|
2822
|
+
this.pos++;
|
|
2823
|
+
lc.next();
|
|
2824
|
+
const roleTok = lc.next();
|
|
2825
|
+
if (!roleTok || roleTok.kind !== 'word' || !lc.matchWord('is')) {
|
|
2826
|
+
this.diagnostics.error('parse.machine-role', 'Expected `role <name> is <entity>`.', lineSpan(line));
|
|
2827
|
+
continue;
|
|
2828
|
+
}
|
|
2829
|
+
const entity = this.parseNameRef(lc, () => false);
|
|
2830
|
+
if (entity.words.length === 0 || !lc.atEnd()) {
|
|
2831
|
+
this.diagnostics.error('parse.machine-role', 'Expected `role <name> is <entity>`.', lineSpan(line));
|
|
2832
|
+
continue;
|
|
2833
|
+
}
|
|
2834
|
+
decl.roles.push({ name: roleTok.text, entity, span: lineSpan(line) });
|
|
2835
|
+
continue;
|
|
2836
|
+
}
|
|
2837
|
+
if (word === 'starts') {
|
|
2838
|
+
this.pos++;
|
|
2839
|
+
lc.next();
|
|
2840
|
+
const stateTok = lc.next();
|
|
2841
|
+
if (!stateTok || stateTok.kind !== 'word' || !lc.atEnd()) {
|
|
2842
|
+
this.diagnostics.error('parse.machine-starts', 'Expected `starts <state>`.', lineSpan(line));
|
|
2843
|
+
continue;
|
|
2844
|
+
}
|
|
2845
|
+
if (decl.initialState !== null) {
|
|
2846
|
+
this.diagnostics.error('parse.machine-starts', 'This machine already declared its `starts` state.', lineSpan(line));
|
|
2847
|
+
continue;
|
|
2848
|
+
}
|
|
2849
|
+
decl.initialState = stateTok.text;
|
|
2850
|
+
continue;
|
|
2851
|
+
}
|
|
2852
|
+
if (word === 'state') {
|
|
2853
|
+
const state = this.parseMachineState(line);
|
|
2854
|
+
if (state) {
|
|
2855
|
+
decl.states.push(state);
|
|
2856
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, state.span);
|
|
2857
|
+
}
|
|
2858
|
+
continue;
|
|
2859
|
+
}
|
|
2860
|
+
this.diagnostics.error('parse.machine-body', `Unrecognized line in \`define machine\`: \`${line.raw.trim()}\` — expected \`role\`, \`starts\`, \`state\`, or \`end machine\`.`, lineSpan(line));
|
|
2861
|
+
this.pos++;
|
|
2862
|
+
}
|
|
2863
|
+
this.diagnostics.error('parse.machine-end', 'Expected `end machine` to close the block.', decl.span);
|
|
2864
|
+
return decl;
|
|
2865
|
+
}
|
|
2866
|
+
/** One `state <name>[, terminal]` block: transition lines + `on enter`/`on exit` bodies. */
|
|
2867
|
+
parseMachineState(headLine) {
|
|
2868
|
+
this.pos++;
|
|
2869
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
2870
|
+
c.next(); // state
|
|
2871
|
+
const nameTok = c.next();
|
|
2872
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
2873
|
+
this.diagnostics.error('parse.machine-state', 'Expected a state name after `state`.', lineSpan(headLine));
|
|
2874
|
+
return null;
|
|
2875
|
+
}
|
|
2876
|
+
let terminal = false;
|
|
2877
|
+
if (c.peek()?.kind === 'comma') {
|
|
2878
|
+
c.next();
|
|
2879
|
+
if (c.matchWord('terminal'))
|
|
2880
|
+
terminal = true;
|
|
2881
|
+
else
|
|
2882
|
+
this.diagnostics.error('parse.machine-state', 'Only `, terminal` may follow the state name.', c.restSpan());
|
|
2883
|
+
}
|
|
2884
|
+
const state = { name: nameTok.text, terminal, transitions: [], onEnter: [], onExit: [], span: lineSpan(headLine) };
|
|
2885
|
+
while (this.pos < this.lines.length) {
|
|
2886
|
+
const line = this.lines[this.pos];
|
|
2887
|
+
if (line.indent <= headLine.indent)
|
|
2888
|
+
break; // next state / end machine
|
|
2889
|
+
state.span = (0, span_js_1.mergeSpans)(state.span, lineSpan(line));
|
|
2890
|
+
const word = firstWord(line);
|
|
2891
|
+
if (word === 'when') {
|
|
2892
|
+
const transition = this.parseMachineTransition(line);
|
|
2893
|
+
if (transition)
|
|
2894
|
+
state.transitions.push(transition);
|
|
2895
|
+
continue;
|
|
2896
|
+
}
|
|
2897
|
+
if (word === 'on') {
|
|
2898
|
+
const lc = new Cursor(line.tokens, line);
|
|
2899
|
+
lc.next();
|
|
2900
|
+
const which = lc.next();
|
|
2901
|
+
if (which && which.kind === 'word' && (which.text === 'enter' || which.text === 'exit') && lc.atEnd()) {
|
|
2902
|
+
this.pos++;
|
|
2903
|
+
const body = this.parseStatements(line.indent, 'on');
|
|
2904
|
+
const endSpan = this.consumeEnd('on', line);
|
|
2905
|
+
state.span = (0, span_js_1.mergeSpans)(state.span, endSpan);
|
|
2906
|
+
if (which.text === 'enter')
|
|
2907
|
+
state.onEnter.push(...body);
|
|
2908
|
+
else
|
|
2909
|
+
state.onExit.push(...body);
|
|
2910
|
+
continue;
|
|
2911
|
+
}
|
|
2912
|
+
this.diagnostics.error('parse.machine-on', 'Only `on enter` and `on exit` blocks live in a machine `state`.', lineSpan(line));
|
|
2913
|
+
this.pos++;
|
|
2914
|
+
continue;
|
|
2915
|
+
}
|
|
2916
|
+
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));
|
|
2917
|
+
this.pos++;
|
|
2918
|
+
}
|
|
2919
|
+
return state;
|
|
2920
|
+
}
|
|
2921
|
+
/**
|
|
2922
|
+
* `when <trigger>[ while <condition>]: <target-state>`. Triggers:
|
|
2923
|
+
* `event <dotted.key>`, `<gerund> <entity name>` (action on a target),
|
|
2924
|
+
* a single bare word (analyzer resolves condition-vs-gerund), or a
|
|
2925
|
+
* condition.
|
|
2926
|
+
*/
|
|
2927
|
+
parseMachineTransition(line) {
|
|
2928
|
+
this.pos++;
|
|
2929
|
+
const tokens = line.tokens;
|
|
2930
|
+
const colonIndex = tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
2931
|
+
if (colonIndex === -1 || colonIndex !== tokens.length - 2 || tokens[tokens.length - 1].kind !== 'word') {
|
|
2932
|
+
this.diagnostics.error('parse.machine-when', 'Expected `when <trigger>[ while <condition>]: <target-state>`.', lineSpan(line));
|
|
2933
|
+
return null;
|
|
2934
|
+
}
|
|
2935
|
+
const target = tokens[tokens.length - 1].text;
|
|
2936
|
+
let head = tokens.slice(1, colonIndex);
|
|
2937
|
+
let condition = null;
|
|
2938
|
+
const whileIndex = head.findIndex((t) => t.kind === 'word' && t.text === 'while');
|
|
2939
|
+
if (whileIndex !== -1) {
|
|
2940
|
+
condition = this.parseCondition(new Cursor(head.slice(whileIndex + 1), line), line);
|
|
2941
|
+
head = head.slice(0, whileIndex);
|
|
2942
|
+
}
|
|
2943
|
+
const hc = new Cursor(head, line);
|
|
2944
|
+
const first = hc.peek();
|
|
2945
|
+
if (!first) {
|
|
2946
|
+
this.diagnostics.error('parse.machine-when', 'Expected a trigger after `when`.', lineSpan(line));
|
|
2947
|
+
return null;
|
|
2948
|
+
}
|
|
2949
|
+
let trigger;
|
|
2950
|
+
if (first.kind === 'word' && first.text === 'event') {
|
|
2951
|
+
hc.next();
|
|
2952
|
+
const key = this.readLabelKey(hc); // ADR-256: dotless Chord event id; story-loader translates to the platform id
|
|
2953
|
+
if (!key || !hc.atEnd()) {
|
|
2954
|
+
this.diagnostics.error('parse.machine-when', 'Expected `event <event.key>`.', lineSpan(line));
|
|
2955
|
+
return null;
|
|
2956
|
+
}
|
|
2957
|
+
trigger = { kind: 'event', event: key };
|
|
2958
|
+
}
|
|
2959
|
+
else if (first.kind === 'word' && !ARTICLES.has(first.text) && head.length === 1) {
|
|
2960
|
+
trigger = { kind: 'word', word: first.text, span: first.span };
|
|
2961
|
+
}
|
|
2962
|
+
else if (first.kind === 'word' && !ARTICLES.has(first.text) && head[1]?.kind === 'word' && (ARTICLES.has(head[1].text) || head[1].text === 'it')) {
|
|
2963
|
+
const action = hc.next().text;
|
|
2964
|
+
if (hc.isWord('it')) {
|
|
2965
|
+
// Machines are story-owned — there is no `it` (mirror of the
|
|
2966
|
+
// story-clause rule); name the entity or role.
|
|
2967
|
+
this.diagnostics.error('parse.machine-when', '`it` is not bound in a machine — name the entity or a declared role.', lineSpan(line));
|
|
2968
|
+
return null;
|
|
2969
|
+
}
|
|
2970
|
+
const targetRef = this.parseNameRef(hc, () => false);
|
|
2971
|
+
if (targetRef.words.length === 0 || !hc.atEnd()) {
|
|
2972
|
+
this.diagnostics.error('parse.machine-when', `Expected an entity or role name after \`${action}\`.`, lineSpan(line));
|
|
2973
|
+
return null;
|
|
2974
|
+
}
|
|
2975
|
+
trigger = { kind: 'action', action, target: targetRef };
|
|
2976
|
+
}
|
|
2977
|
+
else {
|
|
2978
|
+
trigger = { kind: 'condition', condition: this.parseCondition(hc, line) };
|
|
2979
|
+
}
|
|
2980
|
+
return { trigger, condition, target, span: lineSpan(line) };
|
|
1642
2981
|
}
|
|
1643
2982
|
// ------------------------------------------------------------ statements
|
|
1644
2983
|
/**
|
|
@@ -1653,6 +2992,10 @@ class Parser {
|
|
|
1653
2992
|
break;
|
|
1654
2993
|
if (isEndLine(line) || ((firstWord(line) === 'else' || firstWord(line) === 'or') && line.tokens.length === 1))
|
|
1655
2994
|
break;
|
|
2995
|
+
if (looksLikeComment(line)) {
|
|
2996
|
+
this.skipCommentInsideBlock(line);
|
|
2997
|
+
continue;
|
|
2998
|
+
}
|
|
1656
2999
|
const stmt = this.parseStatement(line, blockKeyword);
|
|
1657
3000
|
if (stmt)
|
|
1658
3001
|
body.push(stmt);
|
|
@@ -1688,7 +3031,7 @@ class Parser {
|
|
|
1688
3031
|
}
|
|
1689
3032
|
return this.parseRefuseWhenStatement(line);
|
|
1690
3033
|
}
|
|
1691
|
-
const key = this.
|
|
3034
|
+
const key = this.readLabelKey(c);
|
|
1692
3035
|
if (!key) {
|
|
1693
3036
|
this.diagnostics.error('parse.phrase-ref', `Expected a phrase key after \`${word}\`.`, c.restSpan());
|
|
1694
3037
|
return null;
|
|
@@ -1699,6 +3042,14 @@ class Parser {
|
|
|
1699
3042
|
this.reportRefusalInAfter(line);
|
|
1700
3043
|
return null;
|
|
1701
3044
|
}
|
|
3045
|
+
// Misordered prohibition (platform-issue-sweep Phase 8 #15c):
|
|
3046
|
+
// `refuse <key> when <condition>` used to leave the `when …`
|
|
3047
|
+
// tokens unconsumed and silently compile as an UNCONDITIONAL
|
|
3048
|
+
// refuse. Error with a fix-it instead.
|
|
3049
|
+
if (c.isWord('when')) {
|
|
3050
|
+
this.diagnostics.error('parse.refuse-order', `The condition comes first — write \`refuse when <condition>: ${key}\`.`, c.restSpan());
|
|
3051
|
+
return null;
|
|
3052
|
+
}
|
|
1702
3053
|
return { kind: 'refuse', phraseKey: key, params, span: lineSpan(line) };
|
|
1703
3054
|
}
|
|
1704
3055
|
const stmtWhen = this.parseStatementWhen(c, line);
|
|
@@ -1709,21 +3060,43 @@ class Parser {
|
|
|
1709
3060
|
if (next && next.indent > line.indent && !isStatementLine(next)) {
|
|
1710
3061
|
inlineText = this.parseProseParagraph(line.indent + 1, line.indent);
|
|
1711
3062
|
}
|
|
1712
|
-
const span = inlineText ? (0,
|
|
3063
|
+
const span = inlineText ? (0, span_js_1.mergeSpans)(lineSpan(line), inlineText.span) : lineSpan(line);
|
|
1713
3064
|
return { kind: 'phrase', phraseKey: key, params, inlineText, stmtWhen, span };
|
|
1714
3065
|
}
|
|
1715
3066
|
case 'emit': {
|
|
1716
3067
|
this.pos++;
|
|
1717
3068
|
c.next();
|
|
3069
|
+
// ADR-256: an event id is a single dotless Chord token (`media-sound-play`);
|
|
3070
|
+
// story-loader translates it to the platform's dotted id at the emit seam.
|
|
3071
|
+
// A `.` now raises `parse.dotted-key` (ADR-254 uniform).
|
|
1718
3072
|
const event = [];
|
|
1719
|
-
while (!c.atEnd() && !c.isWord('when'))
|
|
1720
|
-
|
|
3073
|
+
while (!c.atEnd() && !c.isWord('when') && !c.isWord('with')) {
|
|
3074
|
+
const segment = this.readLabelKey(c);
|
|
3075
|
+
if (!segment)
|
|
3076
|
+
break;
|
|
3077
|
+
event.push(segment);
|
|
3078
|
+
}
|
|
1721
3079
|
if (event.length === 0) {
|
|
1722
3080
|
this.diagnostics.error('parse.emit', 'Expected an event name after `emit`.', lineSpan(line));
|
|
1723
3081
|
return null;
|
|
1724
3082
|
}
|
|
3083
|
+
// ADR-216 payload: `with <field> <value> [and …]` — the create-data
|
|
3084
|
+
// grammar; bracketed/braced structures inside separate with commas.
|
|
3085
|
+
const payload = [];
|
|
3086
|
+
if (c.matchWord('with')) {
|
|
3087
|
+
payload.push(...this.parseEmitFields(c, line, 'flat'));
|
|
3088
|
+
}
|
|
1725
3089
|
const stmtWhen = this.parseStatementWhen(c, line);
|
|
1726
|
-
return { kind: 'emit', event, stmtWhen, span: lineSpan(line) };
|
|
3090
|
+
return { kind: 'emit', event, payload, stmtWhen, span: lineSpan(line) };
|
|
3091
|
+
}
|
|
3092
|
+
case 'play':
|
|
3093
|
+
case 'stop':
|
|
3094
|
+
case 'show':
|
|
3095
|
+
case 'hide':
|
|
3096
|
+
case 'transition':
|
|
3097
|
+
case 'clear': {
|
|
3098
|
+
this.pos++;
|
|
3099
|
+
return this.parseMediaStatement(word, c, line);
|
|
1727
3100
|
}
|
|
1728
3101
|
case 'set': {
|
|
1729
3102
|
this.pos++;
|
|
@@ -1807,6 +3180,24 @@ class Parser {
|
|
|
1807
3180
|
const stmtWhen = this.parseStatementWhen(c, line);
|
|
1808
3181
|
return { kind: word, phraseKey, stmtWhen, span: lineSpan(line) };
|
|
1809
3182
|
}
|
|
3183
|
+
case 'kill': {
|
|
3184
|
+
// `kill the player [<phrase-key>] [when <cond>]` (ADR-227) — peer to
|
|
3185
|
+
// win/lose; terminal death via the platform killPlayer sink.
|
|
3186
|
+
this.pos++;
|
|
3187
|
+
c.next();
|
|
3188
|
+
if (!c.matchWord('the') || !c.matchWord('player')) {
|
|
3189
|
+
this.diagnostics.error('parse.kill-statement', 'Expected `kill the player [<phrase-key>] [when <condition>]`.', lineSpan(line));
|
|
3190
|
+
return null;
|
|
3191
|
+
}
|
|
3192
|
+
const key = c.peek();
|
|
3193
|
+
let phraseKey = null;
|
|
3194
|
+
if (key && key.kind === 'word' && key.text !== 'when') {
|
|
3195
|
+
phraseKey = key.text;
|
|
3196
|
+
c.next();
|
|
3197
|
+
}
|
|
3198
|
+
const stmtWhen = this.parseStatementWhen(c, line);
|
|
3199
|
+
return { kind: 'kill', phraseKey, stmtWhen, span: lineSpan(line) };
|
|
3200
|
+
}
|
|
1810
3201
|
case 'if':
|
|
1811
3202
|
// Removed — given 4 amended (ratchet 2026-07-11).
|
|
1812
3203
|
this.diagnostics.error('parse.removed-if', '`if` was removed (given 4 amended) — use a `must` requirement for guards (`it must be hungry: already-fed`), a statement `when` suffix for conditionals (`award X when <condition>`), or `select` for branching.', lineSpan(line));
|
|
@@ -1832,38 +3223,53 @@ class Parser {
|
|
|
1832
3223
|
/** `refuse when <condition>: <key>` as a body statement (prohibition, D6). */
|
|
1833
3224
|
parseRefuseWhenStatement(line) {
|
|
1834
3225
|
const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
1835
|
-
if (colonIndex === -1 || colonIndex
|
|
3226
|
+
if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
|
|
1836
3227
|
this.diagnostics.error('parse.refuse-when', 'Expected `refuse when <condition>: <phrase-key>`.', lineSpan(line));
|
|
1837
3228
|
return null;
|
|
1838
3229
|
}
|
|
1839
|
-
const
|
|
1840
|
-
|
|
1841
|
-
|
|
3230
|
+
const keyCursor = new Cursor(line.tokens.slice(colonIndex + 1), line);
|
|
3231
|
+
const key = this.readLabelKey(keyCursor); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
3232
|
+
if (!key || !keyCursor.atEnd()) {
|
|
3233
|
+
this.diagnostics.error('parse.refuse-when', 'Expected a phrase key after the colon.', line.tokens[colonIndex + 1].span);
|
|
1842
3234
|
return null;
|
|
1843
3235
|
}
|
|
1844
3236
|
const condCursor = new Cursor(line.tokens.slice(2, colonIndex), line);
|
|
1845
3237
|
const condition = this.parseCondition(condCursor, line);
|
|
1846
|
-
return { kind: 'refuse-when', condition, phraseKey:
|
|
3238
|
+
return { kind: 'refuse-when', condition, phraseKey: key, span: lineSpan(line) };
|
|
1847
3239
|
}
|
|
1848
3240
|
/** `refuse`/`must` inside an `after` clause — reactions cannot refuse (D3). */
|
|
1849
3241
|
reportRefusalInAfter(line) {
|
|
1850
3242
|
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));
|
|
1851
3243
|
}
|
|
1852
3244
|
/**
|
|
1853
|
-
* Read a
|
|
1854
|
-
*
|
|
3245
|
+
* Read a label/key: a single kebab-case `WORD` (ADR-254). A `.` in any label
|
|
3246
|
+
* position — author labels (phrase, exit, refusal keys) AND the event-type
|
|
3247
|
+
* sites (`emit`, channel `from event`, machine `when event`) — is a parse
|
|
3248
|
+
* error (`parse.dotted-key`). The ban is uniform: ADR-256 made the event-type
|
|
3249
|
+
* sites dotless too (the dotless Chord event id is translated to the platform's
|
|
3250
|
+
* dotted id in `@sharpee/story-loader`, so no dotted form ever appears in a
|
|
3251
|
+
* `.story`). Returns `null` when no word is present.
|
|
1855
3252
|
*/
|
|
1856
|
-
|
|
3253
|
+
readLabelKey(c) {
|
|
1857
3254
|
const first = c.peek();
|
|
1858
3255
|
if (!first || first.kind !== 'word')
|
|
1859
3256
|
return null;
|
|
1860
3257
|
c.next();
|
|
1861
|
-
|
|
1862
|
-
|
|
3258
|
+
const key = first.text;
|
|
3259
|
+
const dotFollows = () => c.peek()?.kind === 'punct' && c.peek().text === '.' && c.peek(1)?.kind === 'word';
|
|
3260
|
+
if (!dotFollows())
|
|
3261
|
+
return key;
|
|
3262
|
+
// ADR-254/256: a `.` in a label is a parse error. Consume the dotted
|
|
3263
|
+
// segments (one clean error, no cascade), flag the first dot, and hand back
|
|
3264
|
+
// the kebab-cased form so downstream parsing does not spuriously break.
|
|
3265
|
+
const dot = c.peek();
|
|
3266
|
+
const segments = [key];
|
|
3267
|
+
while (dotFollows()) {
|
|
1863
3268
|
c.next();
|
|
1864
|
-
|
|
3269
|
+
segments.push(c.next().text);
|
|
1865
3270
|
}
|
|
1866
|
-
|
|
3271
|
+
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);
|
|
3272
|
+
return segments.join('-');
|
|
1867
3273
|
}
|
|
1868
3274
|
parseParams(c, line) {
|
|
1869
3275
|
const params = [];
|
|
@@ -1879,7 +3285,7 @@ class Parser {
|
|
|
1879
3285
|
break;
|
|
1880
3286
|
}
|
|
1881
3287
|
const value = this.parseValueExpr(c, line, new Set(['with']));
|
|
1882
|
-
params.push({ param, value, span: start ? (0,
|
|
3288
|
+
params.push({ param, value, span: start ? (0, span_js_1.mergeSpans)(start.span, value.span) : value.span });
|
|
1883
3289
|
}
|
|
1884
3290
|
return params;
|
|
1885
3291
|
}
|
|
@@ -1897,7 +3303,7 @@ class Parser {
|
|
|
1897
3303
|
const stmt = this.parseStatement(line, 'ordinal');
|
|
1898
3304
|
if (stmt) {
|
|
1899
3305
|
body.push(stmt);
|
|
1900
|
-
span = (0,
|
|
3306
|
+
span = (0, span_js_1.mergeSpans)(span, stmt.span);
|
|
1901
3307
|
}
|
|
1902
3308
|
}
|
|
1903
3309
|
return { kind: 'ordinal', ordinal: ORDINALS[word], ordinalWord: word, body, span };
|
|
@@ -1924,7 +3330,7 @@ class Parser {
|
|
|
1924
3330
|
}
|
|
1925
3331
|
const body = this.parseStatements(headLine.indent, blockKeyword);
|
|
1926
3332
|
const endSpan = this.consumeEnd('each', headLine);
|
|
1927
|
-
return { kind: 'each', condition: nameTok.text, body, span: (0,
|
|
3333
|
+
return { kind: 'each', condition: nameTok.text, body, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
1928
3334
|
}
|
|
1929
3335
|
parseSelect(headLine) {
|
|
1930
3336
|
this.pos++;
|
|
@@ -1955,7 +3361,7 @@ class Parser {
|
|
|
1955
3361
|
}
|
|
1956
3362
|
}
|
|
1957
3363
|
const endSpan = this.consumeEnd('select', headLine);
|
|
1958
|
-
return { kind: 'select-on', subject, arms, span: (0,
|
|
3364
|
+
return { kind: 'select-on', subject, arms, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
1959
3365
|
}
|
|
1960
3366
|
const strategyTok = c.next();
|
|
1961
3367
|
if (!strategyTok || strategyTok.kind !== 'word' || !STRATEGIES.has(strategyTok.text)) {
|
|
@@ -1981,7 +3387,7 @@ class Parser {
|
|
|
1981
3387
|
break;
|
|
1982
3388
|
}
|
|
1983
3389
|
const endSpan = this.consumeEnd('select', headLine);
|
|
1984
|
-
return { kind: 'select-strategy', strategy: strategyTok.text, alternatives, span: (0,
|
|
3390
|
+
return { kind: 'select-strategy', strategy: strategyTok.text, alternatives, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
1985
3391
|
}
|
|
1986
3392
|
/** Consume the expected `end <keyword>` line; diagnose a mismatch or absence. */
|
|
1987
3393
|
consumeEnd(keyword, openLine) {
|
|
@@ -2028,10 +3434,10 @@ class Parser {
|
|
|
2028
3434
|
if (stop(t))
|
|
2029
3435
|
break;
|
|
2030
3436
|
words.push(t.text);
|
|
2031
|
-
span = span ? (0,
|
|
3437
|
+
span = span ? (0, span_js_1.mergeSpans)(span, t.span) : t.span;
|
|
2032
3438
|
c.next();
|
|
2033
3439
|
}
|
|
2034
|
-
return { kind: 'name', article, words, span: span ?? (0,
|
|
3440
|
+
return { kind: 'name', article, words, span: span ?? (0, span_js_1.spanOf)(c.line.lineNo, 1) };
|
|
2035
3441
|
}
|
|
2036
3442
|
/**
|
|
2037
3443
|
* Parse a value expression: literal, name reference, or possessive chain
|
|
@@ -2059,7 +3465,7 @@ class Parser {
|
|
|
2059
3465
|
if (m && m.kind === 'word' && m.text === 'match' && matchStandsAlone) {
|
|
2060
3466
|
c.next();
|
|
2061
3467
|
c.next();
|
|
2062
|
-
return { kind: 'match', span: (0,
|
|
3468
|
+
return { kind: 'match', span: (0, span_js_1.mergeSpans)(first.span, m.span) };
|
|
2063
3469
|
}
|
|
2064
3470
|
}
|
|
2065
3471
|
// `its <field>` — possessive on `it`.
|
|
@@ -2067,7 +3473,7 @@ class Parser {
|
|
|
2067
3473
|
c.next();
|
|
2068
3474
|
const field = this.collectWords(c, extraStops);
|
|
2069
3475
|
const base = { kind: 'ref', ref: { kind: 'name', article: null, words: ['it'], span: first.span }, span: first.span };
|
|
2070
|
-
return { kind: 'possessive', base, field: field.words, span: (0,
|
|
3476
|
+
return { kind: 'possessive', base, field: field.words, span: (0, span_js_1.mergeSpans)(first.span, field.span ?? first.span) };
|
|
2071
3477
|
}
|
|
2072
3478
|
let article = null;
|
|
2073
3479
|
let span = first.span;
|
|
@@ -2084,7 +3490,7 @@ class Parser {
|
|
|
2084
3490
|
if (PHRASE_STOPS.has(t.text) || extraStops.has(t.text))
|
|
2085
3491
|
break;
|
|
2086
3492
|
c.next();
|
|
2087
|
-
span = (0,
|
|
3493
|
+
span = (0, span_js_1.mergeSpans)(span, t.span);
|
|
2088
3494
|
const poss = /^(.*)'s$/.exec(t.text);
|
|
2089
3495
|
if (poss) {
|
|
2090
3496
|
words.push(poss[1]);
|
|
@@ -2104,7 +3510,7 @@ class Parser {
|
|
|
2104
3510
|
};
|
|
2105
3511
|
if (possessiveBase !== null) {
|
|
2106
3512
|
const field = this.collectWords(c, extraStops);
|
|
2107
|
-
return { kind: 'possessive', base: refExpr, field: field.words, span: (0,
|
|
3513
|
+
return { kind: 'possessive', base: refExpr, field: field.words, span: (0, span_js_1.mergeSpans)(span, field.span ?? span) };
|
|
2108
3514
|
}
|
|
2109
3515
|
return refExpr;
|
|
2110
3516
|
}
|
|
@@ -2116,7 +3522,7 @@ class Parser {
|
|
|
2116
3522
|
if (t.kind !== 'word' || PHRASE_STOPS.has(t.text) || extraStops.has(t.text))
|
|
2117
3523
|
break;
|
|
2118
3524
|
words.push(t.text);
|
|
2119
|
-
span = span ? (0,
|
|
3525
|
+
span = span ? (0, span_js_1.mergeSpans)(span, t.span) : t.span;
|
|
2120
3526
|
c.next();
|
|
2121
3527
|
}
|
|
2122
3528
|
return { words, span };
|
|
@@ -2132,7 +3538,7 @@ class Parser {
|
|
|
2132
3538
|
while (c.matchWord('or')) {
|
|
2133
3539
|
const right = this.parseConditionAnd(c, line);
|
|
2134
3540
|
operands.push(right);
|
|
2135
|
-
span = (0,
|
|
3541
|
+
span = (0, span_js_1.mergeSpans)(span, right.span);
|
|
2136
3542
|
}
|
|
2137
3543
|
return { kind: 'or', operands, span };
|
|
2138
3544
|
}
|
|
@@ -2145,7 +3551,7 @@ class Parser {
|
|
|
2145
3551
|
while (c.matchWord('and')) {
|
|
2146
3552
|
const right = this.parseConditionUnary(c, line);
|
|
2147
3553
|
operands.push(right);
|
|
2148
|
-
span = (0,
|
|
3554
|
+
span = (0, span_js_1.mergeSpans)(span, right.span);
|
|
2149
3555
|
}
|
|
2150
3556
|
return { kind: 'and', operands, span };
|
|
2151
3557
|
}
|
|
@@ -2158,7 +3564,19 @@ class Parser {
|
|
|
2158
3564
|
if (t.kind === 'word' && t.text === 'not') {
|
|
2159
3565
|
c.next();
|
|
2160
3566
|
const operand = this.parseConditionUnary(c, line);
|
|
2161
|
-
return { kind: 'not', operand, span: (0,
|
|
3567
|
+
return { kind: 'not', operand, span: (0, span_js_1.mergeSpans)(t.span, operand.span) };
|
|
3568
|
+
}
|
|
3569
|
+
// `client has <capability>` (ADR-216) — `client` is reserved in
|
|
3570
|
+
// condition-subject position; the capability word is the analyzer's gate.
|
|
3571
|
+
if (t.kind === 'word' && t.text === 'client' && c.isWord('has', 1)) {
|
|
3572
|
+
c.next();
|
|
3573
|
+
c.next(); // client has
|
|
3574
|
+
const capability = c.next();
|
|
3575
|
+
if (!capability || capability.kind !== 'word') {
|
|
3576
|
+
this.diagnostics.error('parse.client-has', 'Expected a capability word after `client has` (sound, images, …).', c.restSpan());
|
|
3577
|
+
return { kind: 'condition-ref', name: '', span: lineSpan(line) };
|
|
3578
|
+
}
|
|
3579
|
+
return { kind: 'client-has', capability: capability.text, span: (0, span_js_1.mergeSpans)(t.span, capability.span) };
|
|
2162
3580
|
}
|
|
2163
3581
|
if (t.kind === 'lparen') {
|
|
2164
3582
|
c.next();
|
|
@@ -2179,7 +3597,7 @@ class Parser {
|
|
|
2179
3597
|
this.diagnostics.error('parse.chance', 'Expected a number after `one chance in`.', n?.span ?? c.restSpan());
|
|
2180
3598
|
return { kind: 'chance', n: 0, span: t.span };
|
|
2181
3599
|
}
|
|
2182
|
-
return { kind: 'chance', n: Number(n.text), span: (0,
|
|
3600
|
+
return { kind: 'chance', n: Number(n.text), span: (0, span_js_1.mergeSpans)(t.span, n.span) };
|
|
2183
3601
|
}
|
|
2184
3602
|
// `any <name>` / `no <name>` — existential / negated existential over a
|
|
2185
3603
|
// named open condition (ratchet E1/E2, 2026-07-12). Triggers only on the
|
|
@@ -2194,7 +3612,7 @@ class Parser {
|
|
|
2194
3612
|
return {
|
|
2195
3613
|
kind: t.text === 'any' ? 'any-of' : 'none-of',
|
|
2196
3614
|
condition: nameTok.text,
|
|
2197
|
-
span: (0,
|
|
3615
|
+
span: (0, span_js_1.mergeSpans)(t.span, nameTok.span),
|
|
2198
3616
|
};
|
|
2199
3617
|
}
|
|
2200
3618
|
}
|
|
@@ -2236,7 +3654,7 @@ class Parser {
|
|
|
2236
3654
|
kind: 'predicate',
|
|
2237
3655
|
subject,
|
|
2238
3656
|
predicate: { kind: 'is-a', negated, classifier: cls.words, span: cls.span ?? t.span },
|
|
2239
|
-
span: (0,
|
|
3657
|
+
span: (0, span_js_1.mergeSpans)(subject.span, cls.span ?? t.span),
|
|
2240
3658
|
};
|
|
2241
3659
|
}
|
|
2242
3660
|
if (c.isWord('in')) {
|
|
@@ -2246,7 +3664,7 @@ class Parser {
|
|
|
2246
3664
|
kind: 'predicate',
|
|
2247
3665
|
subject,
|
|
2248
3666
|
predicate: { kind: 'is-in', negated, place, span: place.span },
|
|
2249
|
-
span: (0,
|
|
3667
|
+
span: (0, span_js_1.mergeSpans)(subject.span, place.span),
|
|
2250
3668
|
};
|
|
2251
3669
|
}
|
|
2252
3670
|
if (c.isWord('here')) {
|
|
@@ -2258,7 +3676,7 @@ class Parser {
|
|
|
2258
3676
|
kind: 'predicate',
|
|
2259
3677
|
subject,
|
|
2260
3678
|
predicate: { kind: 'is-here', negated, span: hereTok.span },
|
|
2261
|
-
span: (0,
|
|
3679
|
+
span: (0, span_js_1.mergeSpans)(subject.span, hereTok.span),
|
|
2262
3680
|
};
|
|
2263
3681
|
}
|
|
2264
3682
|
const value = this.parseValueExpr(c, line, new Set());
|
|
@@ -2266,7 +3684,7 @@ class Parser {
|
|
|
2266
3684
|
kind: 'predicate',
|
|
2267
3685
|
subject,
|
|
2268
3686
|
predicate: { kind: 'is', negated, value, span: value.span },
|
|
2269
|
-
span: (0,
|
|
3687
|
+
span: (0, span_js_1.mergeSpans)(subject.span, value.span),
|
|
2270
3688
|
};
|
|
2271
3689
|
}
|
|
2272
3690
|
if (t.text === 'has' || t.text === 'holds' || t.text === 'wears') {
|
|
@@ -2276,7 +3694,7 @@ class Parser {
|
|
|
2276
3694
|
kind: 'predicate',
|
|
2277
3695
|
subject,
|
|
2278
3696
|
predicate: { kind: t.text, thing, span: thing.span },
|
|
2279
|
-
span: (0,
|
|
3697
|
+
span: (0, span_js_1.mergeSpans)(subject.span, thing.span),
|
|
2280
3698
|
};
|
|
2281
3699
|
}
|
|
2282
3700
|
if (t.text === 'can') {
|
|
@@ -2291,8 +3709,8 @@ class Parser {
|
|
|
2291
3709
|
return {
|
|
2292
3710
|
kind: 'predicate',
|
|
2293
3711
|
subject,
|
|
2294
|
-
predicate: { kind: 'can', ability: ability.text, thing, span: (0,
|
|
2295
|
-
span: (0,
|
|
3712
|
+
predicate: { kind: 'can', ability: ability.text, thing, span: (0, span_js_1.mergeSpans)(ability.span, thing.span) },
|
|
3713
|
+
span: (0, span_js_1.mergeSpans)(subject.span, thing.span),
|
|
2296
3714
|
};
|
|
2297
3715
|
}
|
|
2298
3716
|
this.diagnostics.error('parse.predicate', `Unknown predicate \`${t.text}\` — expected is, is a, is in, has, holds, wears, or can see/reach.`, t.span);
|