@sharpee/chord 3.1.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 +1202 -33
- package/analyzer.js.map +1 -1
- package/ast.d.ts +415 -9
- package/ast.d.ts.map +1 -1
- package/catalog.d.ts +57 -1
- package/catalog.d.ts.map +1 -1
- package/catalog.js +91 -3
- package/catalog.js.map +1 -1
- package/diagnostics.d.ts +1 -1
- package/diagnostics.d.ts.map +1 -1
- package/index.d.ts +31 -13
- package/index.d.ts.map +1 -1
- package/index.js +110 -30
- package/index.js.map +1 -1
- package/ir.d.ts +290 -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 +1507 -174
- 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/sharpee-chord-3.0.0.tgz +0 -0
package/parser.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.parseStory = parseStory;
|
|
4
|
-
const
|
|
5
|
-
const
|
|
4
|
+
const catalog_js_1 = require("./catalog.js");
|
|
5
|
+
const lexer_js_1 = require("./lexer.js");
|
|
6
|
+
const span_js_1 = require("./span.js");
|
|
6
7
|
const ARTICLES = new Set(['the', 'a', 'an']);
|
|
7
8
|
const DIRECTIONS = new Set([
|
|
8
9
|
'north', 'south', 'east', 'west',
|
|
@@ -19,7 +20,9 @@ const ORDINALS = {
|
|
|
19
20
|
};
|
|
20
21
|
/** Words that terminate a noun phrase in condition/statement positions. */
|
|
21
22
|
const PHRASE_STOPS = new Set(['is', 'has', 'holds', 'wears', 'can', 'and', 'or', 'then', 'to', 'while', 'with']);
|
|
22
|
-
|
|
23
|
+
/** Stop words ending an emit-payload value expression (ADR-216). */
|
|
24
|
+
const EMIT_VALUE_STOPS = new Set(['and', 'when']);
|
|
25
|
+
const TOP_KEYWORDS = new Set(['story', 'create', 'define', 'when', 'once', 'every', 'import', 'override']);
|
|
23
26
|
/**
|
|
24
27
|
* Parse `.story` source into an AST.
|
|
25
28
|
* @param source full `.story` text
|
|
@@ -27,7 +30,7 @@ const TOP_KEYWORDS = new Set(['story', 'create', 'define', 'when', 'once', 'ever
|
|
|
27
30
|
* a (possibly partial) StoryFile — callers gate on diagnostics.hasErrors()
|
|
28
31
|
*/
|
|
29
32
|
function parseStory(source, diagnostics) {
|
|
30
|
-
return new Parser((0,
|
|
33
|
+
return new Parser((0, lexer_js_1.lex)(source, diagnostics), diagnostics).parseFile();
|
|
31
34
|
}
|
|
32
35
|
/** Cursor over one line's tokens. */
|
|
33
36
|
class Cursor {
|
|
@@ -62,14 +65,14 @@ class Cursor {
|
|
|
62
65
|
restSpan() {
|
|
63
66
|
const t = this.peek();
|
|
64
67
|
if (t)
|
|
65
|
-
return (0,
|
|
68
|
+
return (0, span_js_1.mergeSpans)(t.span, this.tokens[this.tokens.length - 1].span);
|
|
66
69
|
return lineSpan(this.line);
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
function lineSpan(line) {
|
|
70
73
|
if (line.tokens.length === 0)
|
|
71
|
-
return (0,
|
|
72
|
-
return (0,
|
|
74
|
+
return (0, span_js_1.spanOf)(line.lineNo, line.indent + 1, Math.max(1, line.raw.trim().length));
|
|
75
|
+
return (0, span_js_1.mergeSpans)(line.tokens[0].span, line.tokens[line.tokens.length - 1].span);
|
|
73
76
|
}
|
|
74
77
|
function firstWord(line) {
|
|
75
78
|
const t = line.tokens[0];
|
|
@@ -79,6 +82,15 @@ function firstWord(line) {
|
|
|
79
82
|
function isEndLine(line) {
|
|
80
83
|
return firstWord(line) === 'end';
|
|
81
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* True for any `##`-prefixed line at any indent (ADR-249). In code
|
|
87
|
+
* positions this is always an error — comments are only legal flagged
|
|
88
|
+
* (indent 0) and between top-level constructs. Prose positions never
|
|
89
|
+
* call this: an indented `##` in prose renders verbatim (§5.2 opacity).
|
|
90
|
+
*/
|
|
91
|
+
function looksLikeComment(line) {
|
|
92
|
+
return line.raw.trimStart().startsWith('##');
|
|
93
|
+
}
|
|
82
94
|
/** Words that open a statement or block boundary inside behavior bodies. */
|
|
83
95
|
const STATEMENT_OPENERS = new Set([
|
|
84
96
|
'refuse', 'phrase', 'emit', 'set', 'change', 'move', 'remove', 'award', 'win', 'lose', 'kill',
|
|
@@ -123,9 +135,24 @@ class Parser {
|
|
|
123
135
|
parseFile() {
|
|
124
136
|
let header = null;
|
|
125
137
|
const declarations = [];
|
|
126
|
-
const start = this.lines[0] ? lineSpan(this.lines[0]) : (0,
|
|
138
|
+
const start = this.lines[0] ? lineSpan(this.lines[0]) : (0, span_js_1.spanOf)(1, 1);
|
|
127
139
|
while (this.pos < this.lines.length) {
|
|
128
140
|
const line = this.lines[this.pos];
|
|
141
|
+
if (line.comment) {
|
|
142
|
+
// ADR-249: comments are legal exactly between top-level
|
|
143
|
+
// constructs. A following indented line means this comment sits
|
|
144
|
+
// between a construct's header and its body — inside the block.
|
|
145
|
+
const next = this.lines[this.pos + 1];
|
|
146
|
+
if (next && next.indent > 0) {
|
|
147
|
+
this.reportCommentInsideBlock(line);
|
|
148
|
+
this.pos++;
|
|
149
|
+
this.recoverToTopLevel();
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
this.pos++;
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
129
156
|
if (line.indent !== 0) {
|
|
130
157
|
this.diagnostics.error('parse.unexpected-indent', 'Unexpected indentation — expected a top-level declaration.', lineSpan(line));
|
|
131
158
|
this.recoverToTopLevel();
|
|
@@ -151,6 +178,21 @@ class Parser {
|
|
|
151
178
|
declarations.push(d);
|
|
152
179
|
break;
|
|
153
180
|
}
|
|
181
|
+
case 'import': {
|
|
182
|
+
// ADR-251: `import "<file>"` — the single generalized form.
|
|
183
|
+
// Position IS the spliced content's arbitration position (D4).
|
|
184
|
+
const d = this.parseImport();
|
|
185
|
+
if (d)
|
|
186
|
+
declarations.push(d);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case 'override': {
|
|
190
|
+
// ADR-255: `override message <alias>` / `override messages <locale>`.
|
|
191
|
+
const d = this.parseOverride();
|
|
192
|
+
if (d)
|
|
193
|
+
declarations.push(d);
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
154
196
|
// Ownership-package removals (ratchet 2026-07-11): floating rules
|
|
155
197
|
// are gone — each gets a fix-it naming its owner-attached form.
|
|
156
198
|
case 'when':
|
|
@@ -165,6 +207,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,10 +433,13 @@ 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: [],
|
|
319
441
|
carries: [],
|
|
442
|
+
containing: [],
|
|
320
443
|
exits: [],
|
|
321
444
|
blockedExits: [],
|
|
322
445
|
deadlyExits: [],
|
|
@@ -334,7 +457,7 @@ class Parser {
|
|
|
334
457
|
while (this.pos < this.lines.length && this.lines[this.pos].indent > 0) {
|
|
335
458
|
const line = this.lines[this.pos];
|
|
336
459
|
sawBlank = sawBlank || line.afterBlank;
|
|
337
|
-
decl.span = (0,
|
|
460
|
+
decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
|
|
338
461
|
const word = firstWord(line);
|
|
339
462
|
const cur = new Cursor(line.tokens, line);
|
|
340
463
|
if (word === 'aka') {
|
|
@@ -342,6 +465,20 @@ class Parser {
|
|
|
342
465
|
cur.matchWord('aka');
|
|
343
466
|
decl.aka.push(...this.parseCommaWords(cur));
|
|
344
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
|
+
}
|
|
345
482
|
else if (word === 'states' && (line.tokens[1]?.kind === 'colon' || line.tokens[1]?.kind === 'comma')) {
|
|
346
483
|
this.pos++;
|
|
347
484
|
cur.next();
|
|
@@ -369,7 +506,20 @@ class Parser {
|
|
|
369
506
|
cur.matchWord('carries');
|
|
370
507
|
decl.carries.push(this.parseNameRef(cur, () => false));
|
|
371
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
|
+
}
|
|
372
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`).
|
|
373
523
|
this.pos++;
|
|
374
524
|
cur.next();
|
|
375
525
|
cur.next();
|
|
@@ -426,8 +576,26 @@ class Parser {
|
|
|
426
576
|
this.pos++;
|
|
427
577
|
cur.next();
|
|
428
578
|
cur.next();
|
|
429
|
-
|
|
430
|
-
|
|
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) });
|
|
431
599
|
}
|
|
432
600
|
else if (word && DIRECTIONS.has(word) && cur.isWord('is', 1) && cur.isWord('blocked', 2)) {
|
|
433
601
|
this.pos++;
|
|
@@ -465,7 +633,7 @@ class Parser {
|
|
|
465
633
|
}
|
|
466
634
|
else {
|
|
467
635
|
this.pos++;
|
|
468
|
-
decl.compositions.push(...this.parseCompositionLine(cur, line));
|
|
636
|
+
decl.compositions.push(...this.parseCompositionLine(cur, line, decl.startsStates));
|
|
469
637
|
}
|
|
470
638
|
}
|
|
471
639
|
else {
|
|
@@ -477,7 +645,7 @@ class Parser {
|
|
|
477
645
|
...decl.description,
|
|
478
646
|
text: `${decl.description.text}\n\n${prose.text}`,
|
|
479
647
|
markers: [...decl.description.markers, ...prose.markers],
|
|
480
|
-
span: (0,
|
|
648
|
+
span: (0, span_js_1.mergeSpans)(decl.description.span, prose.span),
|
|
481
649
|
};
|
|
482
650
|
}
|
|
483
651
|
else {
|
|
@@ -513,12 +681,12 @@ class Parser {
|
|
|
513
681
|
this.diagnostics.error('parse.blocked-exit', 'Expected `: <phrase-key>` after `is blocked`.', lineSpan(line));
|
|
514
682
|
return null;
|
|
515
683
|
}
|
|
516
|
-
const key =
|
|
517
|
-
if (!key
|
|
684
|
+
const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
685
|
+
if (!key) {
|
|
518
686
|
this.diagnostics.error('parse.blocked-exit', 'Expected a phrase key after `is blocked:`.', lineSpan(line));
|
|
519
687
|
return null;
|
|
520
688
|
}
|
|
521
|
-
return { kind: 'blocked-exit', direction, phraseKey: key
|
|
689
|
+
return { kind: 'blocked-exit', direction, phraseKey: key, condition, span: lineSpan(line) };
|
|
522
690
|
}
|
|
523
691
|
parseDeadlyExit(direction, line) {
|
|
524
692
|
// <direction> is deadly [while <condition>]: <phrase-key> (ADR-227) —
|
|
@@ -539,17 +707,52 @@ class Parser {
|
|
|
539
707
|
this.diagnostics.error('parse.deadly-exit', 'Expected `: <phrase-key>` after `is deadly`.', lineSpan(line));
|
|
540
708
|
return null;
|
|
541
709
|
}
|
|
542
|
-
const key =
|
|
543
|
-
if (!key
|
|
710
|
+
const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
|
|
711
|
+
if (!key) {
|
|
544
712
|
this.diagnostics.error('parse.deadly-exit', 'Expected a phrase key after `is deadly:`.', lineSpan(line));
|
|
545
713
|
return null;
|
|
546
714
|
}
|
|
547
|
-
return { kind: 'deadly-exit', direction, phraseKey: key
|
|
715
|
+
return { kind: 'deadly-exit', direction, phraseKey: key, condition, span: lineSpan(line) };
|
|
548
716
|
}
|
|
549
|
-
parseCompositionLine(c, line) {
|
|
717
|
+
parseCompositionLine(c, line, startsStates) {
|
|
550
718
|
const items = [];
|
|
551
719
|
while (!c.atEnd()) {
|
|
552
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
|
+
}
|
|
553
756
|
let article = null;
|
|
554
757
|
const first = c.peek();
|
|
555
758
|
if (first && first.kind === 'word' && ARTICLES.has(first.text)) {
|
|
@@ -583,7 +786,7 @@ class Parser {
|
|
|
583
786
|
words,
|
|
584
787
|
config,
|
|
585
788
|
condition,
|
|
586
|
-
span: (0,
|
|
789
|
+
span: (0, span_js_1.mergeSpans)(startTok.span, endTok.span),
|
|
587
790
|
});
|
|
588
791
|
if (c.peek()?.kind === 'comma')
|
|
589
792
|
c.next();
|
|
@@ -599,6 +802,33 @@ class Parser {
|
|
|
599
802
|
const settings = [];
|
|
600
803
|
for (;;) {
|
|
601
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
|
+
}
|
|
602
832
|
const key = [];
|
|
603
833
|
while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('while')) {
|
|
604
834
|
const t = c.peek();
|
|
@@ -628,11 +858,65 @@ class Parser {
|
|
|
628
858
|
this.diagnostics.error('parse.config-value', 'Expected an entity name after the article.', c.restSpan());
|
|
629
859
|
break;
|
|
630
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
|
+
}
|
|
631
870
|
settings.push({
|
|
632
871
|
key,
|
|
633
872
|
value: nameWords.join(' '),
|
|
634
873
|
valueKind: 'name',
|
|
635
|
-
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,
|
|
636
920
|
});
|
|
637
921
|
if (!c.matchWord('and'))
|
|
638
922
|
break;
|
|
@@ -648,7 +932,7 @@ class Parser {
|
|
|
648
932
|
key,
|
|
649
933
|
value: valueTok.text,
|
|
650
934
|
valueKind: valueTok.kind,
|
|
651
|
-
span: startTok ? (0,
|
|
935
|
+
span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, valueTok.span) : valueTok.span,
|
|
652
936
|
});
|
|
653
937
|
if (!c.matchWord('and'))
|
|
654
938
|
break;
|
|
@@ -658,7 +942,8 @@ class Parser {
|
|
|
658
942
|
parsePhraseOverride(line) {
|
|
659
943
|
const c = new Cursor(line.tokens, line);
|
|
660
944
|
c.matchWord('phrase');
|
|
661
|
-
|
|
945
|
+
// Key word validated by the caller; phrase-key = WORD { "." WORD } (ADR-231 D1b).
|
|
946
|
+
const key = this.readLabelKey(c);
|
|
662
947
|
// CP3: optional `, <strategy>` (the Z5 adverb set, retired fix-its
|
|
663
948
|
// included) — `phrase present, cycling:`.
|
|
664
949
|
let strategy = null;
|
|
@@ -713,11 +998,11 @@ class Parser {
|
|
|
713
998
|
const last = variants[variants.length - 1];
|
|
714
999
|
return {
|
|
715
1000
|
kind: 'phrase-override',
|
|
716
|
-
key
|
|
1001
|
+
key,
|
|
717
1002
|
strategy,
|
|
718
1003
|
condition,
|
|
719
1004
|
variants,
|
|
720
|
-
span: (0,
|
|
1005
|
+
span: (0, span_js_1.mergeSpans)(lineSpan(line), last?.span ?? lineSpan(line)),
|
|
721
1006
|
};
|
|
722
1007
|
}
|
|
723
1008
|
/** Same-line phrase text (quoted or bare) was removed — grammar log 2026-07-10. */
|
|
@@ -742,6 +1027,33 @@ class Parser {
|
|
|
742
1027
|
out.push(current.join(' '));
|
|
743
1028
|
return out;
|
|
744
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
|
+
}
|
|
745
1057
|
parseStateList(c) {
|
|
746
1058
|
const out = [];
|
|
747
1059
|
while (!c.atEnd()) {
|
|
@@ -786,7 +1098,7 @@ class Parser {
|
|
|
786
1098
|
const text = line.raw.trim();
|
|
787
1099
|
this.extractMarkers(line, markers);
|
|
788
1100
|
current.push(text);
|
|
789
|
-
span = span ? (0,
|
|
1101
|
+
span = span ? (0, span_js_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
|
|
790
1102
|
first = false;
|
|
791
1103
|
}
|
|
792
1104
|
if (current.length)
|
|
@@ -796,7 +1108,7 @@ class Parser {
|
|
|
796
1108
|
form: 'prose',
|
|
797
1109
|
text: paragraphs.map((p) => p.join(' ')).join('\n\n'),
|
|
798
1110
|
markers,
|
|
799
|
-
span: span ?? (0,
|
|
1111
|
+
span: span ?? (0, span_js_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
|
|
800
1112
|
};
|
|
801
1113
|
}
|
|
802
1114
|
/**
|
|
@@ -818,7 +1130,7 @@ class Parser {
|
|
|
818
1130
|
break;
|
|
819
1131
|
collected.push({ raw: line.raw, blankBefore: !first && line.afterBlank });
|
|
820
1132
|
this.extractMarkers(line, markers);
|
|
821
|
-
span = span ? (0,
|
|
1133
|
+
span = span ? (0, span_js_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
|
|
822
1134
|
this.pos++;
|
|
823
1135
|
first = false;
|
|
824
1136
|
}
|
|
@@ -834,14 +1146,14 @@ class Parser {
|
|
|
834
1146
|
form: 'verbatim',
|
|
835
1147
|
text: lines.join('\n'),
|
|
836
1148
|
markers,
|
|
837
|
-
span: span ?? (0,
|
|
1149
|
+
span: span ?? (0, span_js_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
|
|
838
1150
|
};
|
|
839
1151
|
}
|
|
840
1152
|
extractMarkers(line, into) {
|
|
841
1153
|
const re = /\{([^}]*)\}/g;
|
|
842
1154
|
let m;
|
|
843
1155
|
while ((m = re.exec(line.raw)) !== null) {
|
|
844
|
-
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) });
|
|
845
1157
|
}
|
|
846
1158
|
}
|
|
847
1159
|
/** Error-recovery only: a removed same-line quoted value, kept as prose text. */
|
|
@@ -850,7 +1162,7 @@ class Parser {
|
|
|
850
1162
|
const re = /\{([^}]*)\}/g;
|
|
851
1163
|
let m;
|
|
852
1164
|
while ((m = re.exec(tok.text)) !== null) {
|
|
853
|
-
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) });
|
|
854
1166
|
}
|
|
855
1167
|
return { kind: 'text', form: 'prose', text: tok.text, markers, span: tok.span };
|
|
856
1168
|
}
|
|
@@ -864,6 +1176,10 @@ class Parser {
|
|
|
864
1176
|
return this.parseDefineCondition();
|
|
865
1177
|
case 'phrase':
|
|
866
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();
|
|
867
1183
|
case 'phrases':
|
|
868
1184
|
return this.parseDefinePhrases();
|
|
869
1185
|
case 'verb':
|
|
@@ -879,8 +1195,15 @@ class Parser {
|
|
|
879
1195
|
return this.parseDefineTrait();
|
|
880
1196
|
case 'action':
|
|
881
1197
|
return this.parseDefineAction();
|
|
1198
|
+
case 'chain':
|
|
1199
|
+
return this.parseDefineChainHatch();
|
|
882
1200
|
case 'behavior':
|
|
883
|
-
|
|
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;
|
|
884
1207
|
case 'score':
|
|
885
1208
|
// Removed — ratchet D12/CP5 (2026-07-11).
|
|
886
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));
|
|
@@ -888,6 +1211,29 @@ class Parser {
|
|
|
888
1211
|
return null;
|
|
889
1212
|
case 'sequence':
|
|
890
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();
|
|
891
1237
|
default:
|
|
892
1238
|
this.diagnostics.error('parse.unknown-define', `Unknown declaration \`define ${subWord ?? ''}\`.`, lineSpan(line));
|
|
893
1239
|
this.recoverToTopLevel(true);
|
|
@@ -917,19 +1263,27 @@ class Parser {
|
|
|
917
1263
|
const c = new Cursor(headLine.tokens, headLine);
|
|
918
1264
|
c.next();
|
|
919
1265
|
c.next(); // define phrase
|
|
920
|
-
|
|
921
|
-
|
|
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) ?? '';
|
|
922
1271
|
if (!key) {
|
|
923
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
|
|
924
1274
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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) {
|
|
933
1287
|
let strategy = null;
|
|
934
1288
|
let verbatim = false;
|
|
935
1289
|
if (c.peek()?.kind === 'comma') {
|
|
@@ -959,15 +1313,22 @@ class Parser {
|
|
|
959
1313
|
}
|
|
960
1314
|
const variants = [];
|
|
961
1315
|
let span = lineSpan(headLine);
|
|
1316
|
+
let flaggedFlushLeft = false;
|
|
962
1317
|
for (;;) {
|
|
963
1318
|
const line = this.lines[this.pos];
|
|
964
1319
|
if (!line) {
|
|
965
|
-
this.diagnostics.error('parse.unterminated-block',
|
|
1320
|
+
this.diagnostics.error('parse.unterminated-block', `Missing \`end ${endWord}\`.`, span);
|
|
966
1321
|
break;
|
|
967
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
|
+
}
|
|
968
1329
|
if (isEndLine(line)) {
|
|
969
|
-
span = (0,
|
|
970
|
-
this.consumeEnd(
|
|
1330
|
+
span = (0, span_js_1.mergeSpans)(span, lineSpan(line));
|
|
1331
|
+
this.consumeEnd(endWord, headLine);
|
|
971
1332
|
break;
|
|
972
1333
|
}
|
|
973
1334
|
if (firstWord(line) === 'or' && line.tokens.length === 1) {
|
|
@@ -978,19 +1339,153 @@ class Parser {
|
|
|
978
1339
|
continue;
|
|
979
1340
|
}
|
|
980
1341
|
if (line.indent === 0 && TOP_KEYWORDS.has(firstWord(line) ?? '')) {
|
|
981
|
-
this.diagnostics.error('parse.unterminated-block',
|
|
1342
|
+
this.diagnostics.error('parse.unterminated-block', `Missing \`end ${endWord}\`.`, span);
|
|
982
1343
|
break;
|
|
983
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
|
+
}
|
|
984
1358
|
const variant = verbatim ? this.parseVerbatimBlock() : this.parseProseParagraph(1, 0);
|
|
985
1359
|
variants.push(variant);
|
|
986
|
-
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;
|
|
987
1444
|
}
|
|
988
|
-
return { kind: '
|
|
1445
|
+
return { kind: 'import', path: pathTok.text, span: lineSpan(line) };
|
|
989
1446
|
}
|
|
990
1447
|
parseDefinePhrases() {
|
|
991
1448
|
const headLine = this.lines[this.pos++];
|
|
992
1449
|
return this.parsePhrasesBlock(headLine, 2);
|
|
993
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
|
+
}
|
|
994
1489
|
/**
|
|
995
1490
|
* Parse a phrases block from its header line (`define phrases <locale>` at
|
|
996
1491
|
* top level, or `phrases <locale>` inside a trait/action, Phase B). The
|
|
@@ -1010,16 +1505,18 @@ class Parser {
|
|
|
1010
1505
|
let span = lineSpan(headLine);
|
|
1011
1506
|
while (this.pos < this.lines.length && this.lines[this.pos].indent > headLine.indent) {
|
|
1012
1507
|
const line = this.lines[this.pos];
|
|
1013
|
-
const
|
|
1014
|
-
const
|
|
1015
|
-
|
|
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') {
|
|
1016
1512
|
this.diagnostics.error('parse.phrase-entry', 'Expected `key: <text>` in the phrases block.', lineSpan(line));
|
|
1017
1513
|
this.pos++;
|
|
1018
1514
|
continue;
|
|
1019
1515
|
}
|
|
1516
|
+
ec.next(); // colon
|
|
1020
1517
|
this.pos++;
|
|
1021
1518
|
let value;
|
|
1022
|
-
const inline =
|
|
1519
|
+
const inline = ec.peek();
|
|
1023
1520
|
if (inline && inline.kind === 'string') {
|
|
1024
1521
|
this.reportSameLineText(inline.span);
|
|
1025
1522
|
value = this.textFromString(inline); // recovery: keep the text so analysis continues
|
|
@@ -1036,11 +1533,11 @@ class Parser {
|
|
|
1036
1533
|
else {
|
|
1037
1534
|
value = this.parseProseParagraph(line.indent + 1, line.indent);
|
|
1038
1535
|
if (value.text === '') {
|
|
1039
|
-
this.diagnostics.error('parse.phrase-entry-empty', `Phrase \`${key
|
|
1536
|
+
this.diagnostics.error('parse.phrase-entry-empty', `Phrase \`${key}\` has no text.`, lineSpan(line));
|
|
1040
1537
|
}
|
|
1041
1538
|
}
|
|
1042
|
-
entries.push({ key
|
|
1043
|
-
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);
|
|
1044
1541
|
}
|
|
1045
1542
|
return { kind: 'define-phrases', locale, entries, span };
|
|
1046
1543
|
}
|
|
@@ -1074,7 +1571,7 @@ class Parser {
|
|
|
1074
1571
|
this.diagnostics.error('parse.verb-slot', 'Expected `(something)` slot in the verb pattern.', t.span);
|
|
1075
1572
|
return null;
|
|
1076
1573
|
}
|
|
1077
|
-
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) });
|
|
1078
1575
|
}
|
|
1079
1576
|
else if (t.kind === 'word') {
|
|
1080
1577
|
pattern.push({ kind: 'word', word: t.text, span: t.span });
|
|
@@ -1129,6 +1626,10 @@ class Parser {
|
|
|
1129
1626
|
});
|
|
1130
1627
|
while (this.pos < this.lines.length) {
|
|
1131
1628
|
const line = this.lines[this.pos];
|
|
1629
|
+
if (looksLikeComment(line)) {
|
|
1630
|
+
this.skipCommentInsideBlock(line);
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1132
1633
|
if (line.indent === 0) {
|
|
1133
1634
|
if (isEndLine(line))
|
|
1134
1635
|
break;
|
|
@@ -1173,13 +1674,17 @@ class Parser {
|
|
|
1173
1674
|
}
|
|
1174
1675
|
}
|
|
1175
1676
|
const endSpan = this.consumeEnd('trait', headLine);
|
|
1176
|
-
return build((0,
|
|
1677
|
+
return build((0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan));
|
|
1177
1678
|
}
|
|
1178
1679
|
/** `data` block fields: `locked: flag`, `body part: optional name`, `kind: one of a, b`. */
|
|
1179
1680
|
parseTraitFields(dataLine) {
|
|
1180
1681
|
const fields = [];
|
|
1181
1682
|
while (this.pos < this.lines.length && this.lines[this.pos].indent > dataLine.indent) {
|
|
1182
1683
|
const line = this.lines[this.pos++];
|
|
1684
|
+
if (looksLikeComment(line)) {
|
|
1685
|
+
this.reportCommentInsideBlock(line);
|
|
1686
|
+
continue;
|
|
1687
|
+
}
|
|
1183
1688
|
const c = new Cursor(line.tokens, line);
|
|
1184
1689
|
const name = [];
|
|
1185
1690
|
while (!c.atEnd() && c.peek().kind === 'word')
|
|
@@ -1276,6 +1781,10 @@ class Parser {
|
|
|
1276
1781
|
const line = this.lines[this.pos];
|
|
1277
1782
|
if (line.indent === 0)
|
|
1278
1783
|
break; // dedent-terminated (no `end action`, design.md §2.3/§3.4)
|
|
1784
|
+
if (looksLikeComment(line)) {
|
|
1785
|
+
this.skipCommentInsideBlock(line);
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1279
1788
|
const word = firstWord(line);
|
|
1280
1789
|
if (word === 'grammar' && line.tokens.length === 1) {
|
|
1281
1790
|
this.pos++;
|
|
@@ -1316,9 +1825,9 @@ class Parser {
|
|
|
1316
1825
|
this.diagnostics.error('parse.action-otherwise', 'Expected `otherwise refuse <phrase-key>`.', lineSpan(line));
|
|
1317
1826
|
}
|
|
1318
1827
|
else {
|
|
1319
|
-
const key =
|
|
1320
|
-
if (key
|
|
1321
|
-
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) };
|
|
1322
1831
|
else
|
|
1323
1832
|
this.diagnostics.error('parse.action-otherwise', 'Expected a phrase key after `otherwise refuse`.', oc.restSpan());
|
|
1324
1833
|
}
|
|
@@ -1332,7 +1841,7 @@ class Parser {
|
|
|
1332
1841
|
if (stmt)
|
|
1333
1842
|
body.push(stmt);
|
|
1334
1843
|
}
|
|
1335
|
-
span = (0,
|
|
1844
|
+
span = (0, span_js_1.mergeSpans)(span, lineSpan(line));
|
|
1336
1845
|
}
|
|
1337
1846
|
return { kind: 'define-action', name: nameTok.text, patterns, constraints, musts, refusals, otherwise, scores, phrases, body, span };
|
|
1338
1847
|
}
|
|
@@ -1343,13 +1852,14 @@ class Parser {
|
|
|
1343
1852
|
*/
|
|
1344
1853
|
parseMustLine(line) {
|
|
1345
1854
|
const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
1346
|
-
if (colonIndex === -1 || colonIndex
|
|
1855
|
+
if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
|
|
1347
1856
|
this.diagnostics.error('parse.must', 'Expected `<subject> must <predicate>: <phrase-key>`.', lineSpan(line));
|
|
1348
1857
|
return null;
|
|
1349
1858
|
}
|
|
1350
|
-
const
|
|
1351
|
-
|
|
1352
|
-
|
|
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);
|
|
1353
1863
|
return null;
|
|
1354
1864
|
}
|
|
1355
1865
|
const c = new Cursor(line.tokens.slice(0, colonIndex), line);
|
|
@@ -1367,7 +1877,7 @@ class Parser {
|
|
|
1367
1877
|
const predicate = this.parseInfinitivePredicate(c, line);
|
|
1368
1878
|
if (!predicate)
|
|
1369
1879
|
return null;
|
|
1370
|
-
return { kind: 'must', subject, predicate, phraseKey
|
|
1880
|
+
return { kind: 'must', subject, predicate, phraseKey, span: lineSpan(line) };
|
|
1371
1881
|
}
|
|
1372
1882
|
/** Infinitive predicate after `must`: be / have / hold / wear / see / reach. */
|
|
1373
1883
|
parseInfinitivePredicate(c, line) {
|
|
@@ -1397,7 +1907,7 @@ class Parser {
|
|
|
1397
1907
|
if (nameTok && nameTok.kind === 'word' && this.isBareConditionRef(c, 1)) {
|
|
1398
1908
|
const anyTok = c.next();
|
|
1399
1909
|
c.next();
|
|
1400
|
-
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) };
|
|
1401
1911
|
}
|
|
1402
1912
|
}
|
|
1403
1913
|
// `be no <name>` — a negated requirement in disguise (decision 6):
|
|
@@ -1427,7 +1937,7 @@ class Parser {
|
|
|
1427
1937
|
case 'see':
|
|
1428
1938
|
case 'reach': {
|
|
1429
1939
|
const thing = this.parseNameRef(c, (tok) => tok.kind === 'word' && PHRASE_STOPS.has(tok.text));
|
|
1430
|
-
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) };
|
|
1431
1941
|
}
|
|
1432
1942
|
default:
|
|
1433
1943
|
this.diagnostics.error('parse.must-predicate', `Unknown predicate \`${t.text}\` after \`must\` — expected be, have, hold, wear, see, or reach.`, t.span);
|
|
@@ -1453,7 +1963,7 @@ class Parser {
|
|
|
1453
1963
|
if (t.kind === 'colon') {
|
|
1454
1964
|
const slot = c.next();
|
|
1455
1965
|
if (slot && slot.kind === 'word') {
|
|
1456
|
-
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) });
|
|
1457
1967
|
}
|
|
1458
1968
|
else {
|
|
1459
1969
|
this.diagnostics.error('parse.action-slot', 'Expected a slot name after `:`.', t.span);
|
|
@@ -1502,43 +2012,49 @@ class Parser {
|
|
|
1502
2012
|
if (form === 'without') {
|
|
1503
2013
|
const slot = c.next();
|
|
1504
2014
|
const colon = c.next();
|
|
1505
|
-
|
|
1506
|
-
|
|
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) {
|
|
1507
2021
|
this.diagnostics.error('parse.action-refusal', 'Expected `refuse without <slot>: <phrase-key>`.', lineSpan(line));
|
|
1508
2022
|
return null;
|
|
1509
2023
|
}
|
|
1510
|
-
return { kind: 'without', slot: slot.text, condition: null, phraseKey: key
|
|
2024
|
+
return { kind: 'without', slot: slot.text, condition: null, phraseKey: key, span: lineSpan(line) };
|
|
1511
2025
|
}
|
|
1512
2026
|
// when: condition runs to the LAST colon; the key follows it.
|
|
1513
2027
|
const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
1514
|
-
if (colonIndex === -1 || colonIndex
|
|
2028
|
+
if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
|
|
1515
2029
|
this.diagnostics.error('parse.action-refusal', 'Expected `refuse when <condition>: <phrase-key>`.', lineSpan(line));
|
|
1516
2030
|
return null;
|
|
1517
2031
|
}
|
|
1518
2032
|
const condCursor = new Cursor(line.tokens.slice(2, colonIndex), line);
|
|
1519
2033
|
const condition = this.parseCondition(condCursor, line);
|
|
1520
|
-
const
|
|
1521
|
-
|
|
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()) {
|
|
1522
2037
|
this.diagnostics.error('parse.action-refusal', 'Expected a phrase key after the colon.', lineSpan(line));
|
|
1523
2038
|
return null;
|
|
1524
2039
|
}
|
|
1525
|
-
return { kind: 'when', slot: null, condition, phraseKey: key
|
|
2040
|
+
return { kind: 'when', slot: null, condition, phraseKey: key, span: lineSpan(line) };
|
|
1526
2041
|
}
|
|
1527
|
-
/** `define
|
|
1528
|
-
|
|
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() {
|
|
1529
2045
|
const line = this.lines[this.pos];
|
|
1530
2046
|
const c = new Cursor(line.tokens, line);
|
|
1531
2047
|
c.next();
|
|
1532
|
-
c.next(); // define
|
|
1533
|
-
const
|
|
1534
|
-
if (!
|
|
1535
|
-
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());
|
|
1536
2052
|
this.pos++;
|
|
1537
2053
|
return null;
|
|
1538
2054
|
}
|
|
1539
|
-
return this.parseHatchTail(line, c, '
|
|
2055
|
+
return this.parseHatchTail(line, c, 'chain', name);
|
|
1540
2056
|
}
|
|
1541
|
-
/** Shared `from "<module>"` tail for action/
|
|
2057
|
+
/** Shared `from "<module>"` tail for action / chain hatches. */
|
|
1542
2058
|
parseHatchTail(line, c, hatchKind, name) {
|
|
1543
2059
|
this.pos++;
|
|
1544
2060
|
if (!c.matchWord('from')) {
|
|
@@ -1569,6 +2085,10 @@ class Parser {
|
|
|
1569
2085
|
const line = this.lines[this.pos];
|
|
1570
2086
|
if (line.indent === 0)
|
|
1571
2087
|
break;
|
|
2088
|
+
if (looksLikeComment(line)) {
|
|
2089
|
+
this.skipCommentInsideBlock(line);
|
|
2090
|
+
continue;
|
|
2091
|
+
}
|
|
1572
2092
|
const sc = new Cursor(line.tokens, line);
|
|
1573
2093
|
let timing = null;
|
|
1574
2094
|
let turns = 0;
|
|
@@ -1615,64 +2135,195 @@ class Parser {
|
|
|
1615
2135
|
steps.push({ kind: 'sequence-step', timing, turns, owner, state, body, span: lineSpan(line) });
|
|
1616
2136
|
}
|
|
1617
2137
|
const endSpan = this.consumeEnd('sequence', headLine);
|
|
1618
|
-
return { kind: 'define-sequence', name, steps, span: (0,
|
|
2138
|
+
return { kind: 'define-sequence', name, steps, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
1619
2139
|
}
|
|
1620
|
-
// ------------------------------------------------------------- on clause
|
|
1621
2140
|
/**
|
|
1622
|
-
* `
|
|
1623
|
-
*
|
|
1624
|
-
*
|
|
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.
|
|
1625
2145
|
*/
|
|
1626
|
-
|
|
2146
|
+
parseDefineTopics() {
|
|
1627
2147
|
const headLine = this.lines[this.pos++];
|
|
1628
2148
|
const c = new Cursor(headLine.tokens, headLine);
|
|
1629
|
-
c.
|
|
1630
|
-
|
|
1631
|
-
let
|
|
1632
|
-
if (
|
|
1633
|
-
|
|
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) };
|
|
1634
2155
|
}
|
|
1635
2156
|
else {
|
|
1636
|
-
this.
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
let role = null;
|
|
1640
|
-
let condition = null;
|
|
1641
|
-
let once = false;
|
|
1642
|
-
let ordering = null;
|
|
1643
|
-
if (actionWord === 'every' && c.isWord('turn')) {
|
|
1644
|
-
// `on every turn [while <condition>] [, once]` (§3.3 + D5).
|
|
1645
|
-
c.next();
|
|
1646
|
-
binding = 'every-turn';
|
|
1647
|
-
actionWord = 'every-turn';
|
|
1648
|
-
if (clauseKind === 'after') {
|
|
1649
|
-
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());
|
|
1650
2160
|
}
|
|
1651
2161
|
}
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
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;
|
|
1661
2174
|
}
|
|
1662
|
-
|
|
1663
|
-
this.
|
|
2175
|
+
if (looksLikeComment(line)) {
|
|
2176
|
+
this.skipCommentInsideBlock(line);
|
|
2177
|
+
continue;
|
|
1664
2178
|
}
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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;
|
|
1669
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++;
|
|
1670
2192
|
}
|
|
1671
|
-
|
|
1672
|
-
if (
|
|
1673
|
-
|
|
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);
|
|
1674
2196
|
}
|
|
1675
|
-
|
|
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';
|
|
2306
|
+
if (!c.matchWord('as') || !c.matchWord('the')) {
|
|
2307
|
+
this.diagnostics.error('parse.on-role', 'Expected `as the <role>` after `anything`.', c.restSpan());
|
|
2308
|
+
}
|
|
2309
|
+
const roleTok = c.next();
|
|
2310
|
+
if (roleTok && roleTok.kind === 'word') {
|
|
2311
|
+
role = roleTok.text;
|
|
2312
|
+
}
|
|
2313
|
+
else {
|
|
2314
|
+
this.diagnostics.error('parse.on-role', 'Expected a role name after `as the`.', c.restSpan());
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
else {
|
|
2318
|
+
if (!c.matchWord('it')) {
|
|
2319
|
+
this.diagnostics.error('parse.on-target', `Expected \`it\`, \`anything as the <role>\`, or \`every turn\` in the \`${clauseKind}\` header.`, c.restSpan());
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
// `while <condition>` — legal on every binding (ownership package).
|
|
2323
|
+
if (c.matchWord('while')) {
|
|
2324
|
+
condition = this.parseCondition(c, headLine);
|
|
2325
|
+
}
|
|
2326
|
+
// Comma modifiers: `, once` (D5) and `, before/after <trait>` ordering (§2.2).
|
|
1676
2327
|
while (c.peek()?.kind === 'comma') {
|
|
1677
2328
|
c.next();
|
|
1678
2329
|
const mod = c.next();
|
|
@@ -1704,8 +2355,629 @@ class Parser {
|
|
|
1704
2355
|
once,
|
|
1705
2356
|
ordering,
|
|
1706
2357
|
body,
|
|
1707
|
-
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;
|
|
1708
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) };
|
|
1709
2981
|
}
|
|
1710
2982
|
// ------------------------------------------------------------ statements
|
|
1711
2983
|
/**
|
|
@@ -1720,6 +2992,10 @@ class Parser {
|
|
|
1720
2992
|
break;
|
|
1721
2993
|
if (isEndLine(line) || ((firstWord(line) === 'else' || firstWord(line) === 'or') && line.tokens.length === 1))
|
|
1722
2994
|
break;
|
|
2995
|
+
if (looksLikeComment(line)) {
|
|
2996
|
+
this.skipCommentInsideBlock(line);
|
|
2997
|
+
continue;
|
|
2998
|
+
}
|
|
1723
2999
|
const stmt = this.parseStatement(line, blockKeyword);
|
|
1724
3000
|
if (stmt)
|
|
1725
3001
|
body.push(stmt);
|
|
@@ -1755,7 +3031,7 @@ class Parser {
|
|
|
1755
3031
|
}
|
|
1756
3032
|
return this.parseRefuseWhenStatement(line);
|
|
1757
3033
|
}
|
|
1758
|
-
const key = this.
|
|
3034
|
+
const key = this.readLabelKey(c);
|
|
1759
3035
|
if (!key) {
|
|
1760
3036
|
this.diagnostics.error('parse.phrase-ref', `Expected a phrase key after \`${word}\`.`, c.restSpan());
|
|
1761
3037
|
return null;
|
|
@@ -1766,6 +3042,14 @@ class Parser {
|
|
|
1766
3042
|
this.reportRefusalInAfter(line);
|
|
1767
3043
|
return null;
|
|
1768
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
|
+
}
|
|
1769
3053
|
return { kind: 'refuse', phraseKey: key, params, span: lineSpan(line) };
|
|
1770
3054
|
}
|
|
1771
3055
|
const stmtWhen = this.parseStatementWhen(c, line);
|
|
@@ -1776,21 +3060,43 @@ class Parser {
|
|
|
1776
3060
|
if (next && next.indent > line.indent && !isStatementLine(next)) {
|
|
1777
3061
|
inlineText = this.parseProseParagraph(line.indent + 1, line.indent);
|
|
1778
3062
|
}
|
|
1779
|
-
const span = inlineText ? (0,
|
|
3063
|
+
const span = inlineText ? (0, span_js_1.mergeSpans)(lineSpan(line), inlineText.span) : lineSpan(line);
|
|
1780
3064
|
return { kind: 'phrase', phraseKey: key, params, inlineText, stmtWhen, span };
|
|
1781
3065
|
}
|
|
1782
3066
|
case 'emit': {
|
|
1783
3067
|
this.pos++;
|
|
1784
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).
|
|
1785
3072
|
const event = [];
|
|
1786
|
-
while (!c.atEnd() && !c.isWord('when'))
|
|
1787
|
-
|
|
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
|
+
}
|
|
1788
3079
|
if (event.length === 0) {
|
|
1789
3080
|
this.diagnostics.error('parse.emit', 'Expected an event name after `emit`.', lineSpan(line));
|
|
1790
3081
|
return null;
|
|
1791
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
|
+
}
|
|
1792
3089
|
const stmtWhen = this.parseStatementWhen(c, line);
|
|
1793
|
-
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);
|
|
1794
3100
|
}
|
|
1795
3101
|
case 'set': {
|
|
1796
3102
|
this.pos++;
|
|
@@ -1917,38 +3223,53 @@ class Parser {
|
|
|
1917
3223
|
/** `refuse when <condition>: <key>` as a body statement (prohibition, D6). */
|
|
1918
3224
|
parseRefuseWhenStatement(line) {
|
|
1919
3225
|
const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
1920
|
-
if (colonIndex === -1 || colonIndex
|
|
3226
|
+
if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
|
|
1921
3227
|
this.diagnostics.error('parse.refuse-when', 'Expected `refuse when <condition>: <phrase-key>`.', lineSpan(line));
|
|
1922
3228
|
return null;
|
|
1923
3229
|
}
|
|
1924
|
-
const
|
|
1925
|
-
|
|
1926
|
-
|
|
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);
|
|
1927
3234
|
return null;
|
|
1928
3235
|
}
|
|
1929
3236
|
const condCursor = new Cursor(line.tokens.slice(2, colonIndex), line);
|
|
1930
3237
|
const condition = this.parseCondition(condCursor, line);
|
|
1931
|
-
return { kind: 'refuse-when', condition, phraseKey:
|
|
3238
|
+
return { kind: 'refuse-when', condition, phraseKey: key, span: lineSpan(line) };
|
|
1932
3239
|
}
|
|
1933
3240
|
/** `refuse`/`must` inside an `after` clause — reactions cannot refuse (D3). */
|
|
1934
3241
|
reportRefusalInAfter(line) {
|
|
1935
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));
|
|
1936
3243
|
}
|
|
1937
3244
|
/**
|
|
1938
|
-
* Read a
|
|
1939
|
-
*
|
|
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.
|
|
1940
3252
|
*/
|
|
1941
|
-
|
|
3253
|
+
readLabelKey(c) {
|
|
1942
3254
|
const first = c.peek();
|
|
1943
3255
|
if (!first || first.kind !== 'word')
|
|
1944
3256
|
return null;
|
|
1945
3257
|
c.next();
|
|
1946
|
-
|
|
1947
|
-
|
|
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()) {
|
|
1948
3268
|
c.next();
|
|
1949
|
-
|
|
3269
|
+
segments.push(c.next().text);
|
|
1950
3270
|
}
|
|
1951
|
-
|
|
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('-');
|
|
1952
3273
|
}
|
|
1953
3274
|
parseParams(c, line) {
|
|
1954
3275
|
const params = [];
|
|
@@ -1964,7 +3285,7 @@ class Parser {
|
|
|
1964
3285
|
break;
|
|
1965
3286
|
}
|
|
1966
3287
|
const value = this.parseValueExpr(c, line, new Set(['with']));
|
|
1967
|
-
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 });
|
|
1968
3289
|
}
|
|
1969
3290
|
return params;
|
|
1970
3291
|
}
|
|
@@ -1982,7 +3303,7 @@ class Parser {
|
|
|
1982
3303
|
const stmt = this.parseStatement(line, 'ordinal');
|
|
1983
3304
|
if (stmt) {
|
|
1984
3305
|
body.push(stmt);
|
|
1985
|
-
span = (0,
|
|
3306
|
+
span = (0, span_js_1.mergeSpans)(span, stmt.span);
|
|
1986
3307
|
}
|
|
1987
3308
|
}
|
|
1988
3309
|
return { kind: 'ordinal', ordinal: ORDINALS[word], ordinalWord: word, body, span };
|
|
@@ -2009,7 +3330,7 @@ class Parser {
|
|
|
2009
3330
|
}
|
|
2010
3331
|
const body = this.parseStatements(headLine.indent, blockKeyword);
|
|
2011
3332
|
const endSpan = this.consumeEnd('each', headLine);
|
|
2012
|
-
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) };
|
|
2013
3334
|
}
|
|
2014
3335
|
parseSelect(headLine) {
|
|
2015
3336
|
this.pos++;
|
|
@@ -2040,7 +3361,7 @@ class Parser {
|
|
|
2040
3361
|
}
|
|
2041
3362
|
}
|
|
2042
3363
|
const endSpan = this.consumeEnd('select', headLine);
|
|
2043
|
-
return { kind: 'select-on', subject, arms, span: (0,
|
|
3364
|
+
return { kind: 'select-on', subject, arms, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
2044
3365
|
}
|
|
2045
3366
|
const strategyTok = c.next();
|
|
2046
3367
|
if (!strategyTok || strategyTok.kind !== 'word' || !STRATEGIES.has(strategyTok.text)) {
|
|
@@ -2066,7 +3387,7 @@ class Parser {
|
|
|
2066
3387
|
break;
|
|
2067
3388
|
}
|
|
2068
3389
|
const endSpan = this.consumeEnd('select', headLine);
|
|
2069
|
-
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) };
|
|
2070
3391
|
}
|
|
2071
3392
|
/** Consume the expected `end <keyword>` line; diagnose a mismatch or absence. */
|
|
2072
3393
|
consumeEnd(keyword, openLine) {
|
|
@@ -2113,10 +3434,10 @@ class Parser {
|
|
|
2113
3434
|
if (stop(t))
|
|
2114
3435
|
break;
|
|
2115
3436
|
words.push(t.text);
|
|
2116
|
-
span = span ? (0,
|
|
3437
|
+
span = span ? (0, span_js_1.mergeSpans)(span, t.span) : t.span;
|
|
2117
3438
|
c.next();
|
|
2118
3439
|
}
|
|
2119
|
-
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) };
|
|
2120
3441
|
}
|
|
2121
3442
|
/**
|
|
2122
3443
|
* Parse a value expression: literal, name reference, or possessive chain
|
|
@@ -2144,7 +3465,7 @@ class Parser {
|
|
|
2144
3465
|
if (m && m.kind === 'word' && m.text === 'match' && matchStandsAlone) {
|
|
2145
3466
|
c.next();
|
|
2146
3467
|
c.next();
|
|
2147
|
-
return { kind: 'match', span: (0,
|
|
3468
|
+
return { kind: 'match', span: (0, span_js_1.mergeSpans)(first.span, m.span) };
|
|
2148
3469
|
}
|
|
2149
3470
|
}
|
|
2150
3471
|
// `its <field>` — possessive on `it`.
|
|
@@ -2152,7 +3473,7 @@ class Parser {
|
|
|
2152
3473
|
c.next();
|
|
2153
3474
|
const field = this.collectWords(c, extraStops);
|
|
2154
3475
|
const base = { kind: 'ref', ref: { kind: 'name', article: null, words: ['it'], span: first.span }, span: first.span };
|
|
2155
|
-
return { kind: 'possessive', base, field: field.words, span: (0,
|
|
3476
|
+
return { kind: 'possessive', base, field: field.words, span: (0, span_js_1.mergeSpans)(first.span, field.span ?? first.span) };
|
|
2156
3477
|
}
|
|
2157
3478
|
let article = null;
|
|
2158
3479
|
let span = first.span;
|
|
@@ -2169,7 +3490,7 @@ class Parser {
|
|
|
2169
3490
|
if (PHRASE_STOPS.has(t.text) || extraStops.has(t.text))
|
|
2170
3491
|
break;
|
|
2171
3492
|
c.next();
|
|
2172
|
-
span = (0,
|
|
3493
|
+
span = (0, span_js_1.mergeSpans)(span, t.span);
|
|
2173
3494
|
const poss = /^(.*)'s$/.exec(t.text);
|
|
2174
3495
|
if (poss) {
|
|
2175
3496
|
words.push(poss[1]);
|
|
@@ -2189,7 +3510,7 @@ class Parser {
|
|
|
2189
3510
|
};
|
|
2190
3511
|
if (possessiveBase !== null) {
|
|
2191
3512
|
const field = this.collectWords(c, extraStops);
|
|
2192
|
-
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) };
|
|
2193
3514
|
}
|
|
2194
3515
|
return refExpr;
|
|
2195
3516
|
}
|
|
@@ -2201,7 +3522,7 @@ class Parser {
|
|
|
2201
3522
|
if (t.kind !== 'word' || PHRASE_STOPS.has(t.text) || extraStops.has(t.text))
|
|
2202
3523
|
break;
|
|
2203
3524
|
words.push(t.text);
|
|
2204
|
-
span = span ? (0,
|
|
3525
|
+
span = span ? (0, span_js_1.mergeSpans)(span, t.span) : t.span;
|
|
2205
3526
|
c.next();
|
|
2206
3527
|
}
|
|
2207
3528
|
return { words, span };
|
|
@@ -2217,7 +3538,7 @@ class Parser {
|
|
|
2217
3538
|
while (c.matchWord('or')) {
|
|
2218
3539
|
const right = this.parseConditionAnd(c, line);
|
|
2219
3540
|
operands.push(right);
|
|
2220
|
-
span = (0,
|
|
3541
|
+
span = (0, span_js_1.mergeSpans)(span, right.span);
|
|
2221
3542
|
}
|
|
2222
3543
|
return { kind: 'or', operands, span };
|
|
2223
3544
|
}
|
|
@@ -2230,7 +3551,7 @@ class Parser {
|
|
|
2230
3551
|
while (c.matchWord('and')) {
|
|
2231
3552
|
const right = this.parseConditionUnary(c, line);
|
|
2232
3553
|
operands.push(right);
|
|
2233
|
-
span = (0,
|
|
3554
|
+
span = (0, span_js_1.mergeSpans)(span, right.span);
|
|
2234
3555
|
}
|
|
2235
3556
|
return { kind: 'and', operands, span };
|
|
2236
3557
|
}
|
|
@@ -2243,7 +3564,19 @@ class Parser {
|
|
|
2243
3564
|
if (t.kind === 'word' && t.text === 'not') {
|
|
2244
3565
|
c.next();
|
|
2245
3566
|
const operand = this.parseConditionUnary(c, line);
|
|
2246
|
-
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) };
|
|
2247
3580
|
}
|
|
2248
3581
|
if (t.kind === 'lparen') {
|
|
2249
3582
|
c.next();
|
|
@@ -2264,7 +3597,7 @@ class Parser {
|
|
|
2264
3597
|
this.diagnostics.error('parse.chance', 'Expected a number after `one chance in`.', n?.span ?? c.restSpan());
|
|
2265
3598
|
return { kind: 'chance', n: 0, span: t.span };
|
|
2266
3599
|
}
|
|
2267
|
-
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) };
|
|
2268
3601
|
}
|
|
2269
3602
|
// `any <name>` / `no <name>` — existential / negated existential over a
|
|
2270
3603
|
// named open condition (ratchet E1/E2, 2026-07-12). Triggers only on the
|
|
@@ -2279,7 +3612,7 @@ class Parser {
|
|
|
2279
3612
|
return {
|
|
2280
3613
|
kind: t.text === 'any' ? 'any-of' : 'none-of',
|
|
2281
3614
|
condition: nameTok.text,
|
|
2282
|
-
span: (0,
|
|
3615
|
+
span: (0, span_js_1.mergeSpans)(t.span, nameTok.span),
|
|
2283
3616
|
};
|
|
2284
3617
|
}
|
|
2285
3618
|
}
|
|
@@ -2321,7 +3654,7 @@ class Parser {
|
|
|
2321
3654
|
kind: 'predicate',
|
|
2322
3655
|
subject,
|
|
2323
3656
|
predicate: { kind: 'is-a', negated, classifier: cls.words, span: cls.span ?? t.span },
|
|
2324
|
-
span: (0,
|
|
3657
|
+
span: (0, span_js_1.mergeSpans)(subject.span, cls.span ?? t.span),
|
|
2325
3658
|
};
|
|
2326
3659
|
}
|
|
2327
3660
|
if (c.isWord('in')) {
|
|
@@ -2331,7 +3664,7 @@ class Parser {
|
|
|
2331
3664
|
kind: 'predicate',
|
|
2332
3665
|
subject,
|
|
2333
3666
|
predicate: { kind: 'is-in', negated, place, span: place.span },
|
|
2334
|
-
span: (0,
|
|
3667
|
+
span: (0, span_js_1.mergeSpans)(subject.span, place.span),
|
|
2335
3668
|
};
|
|
2336
3669
|
}
|
|
2337
3670
|
if (c.isWord('here')) {
|
|
@@ -2343,7 +3676,7 @@ class Parser {
|
|
|
2343
3676
|
kind: 'predicate',
|
|
2344
3677
|
subject,
|
|
2345
3678
|
predicate: { kind: 'is-here', negated, span: hereTok.span },
|
|
2346
|
-
span: (0,
|
|
3679
|
+
span: (0, span_js_1.mergeSpans)(subject.span, hereTok.span),
|
|
2347
3680
|
};
|
|
2348
3681
|
}
|
|
2349
3682
|
const value = this.parseValueExpr(c, line, new Set());
|
|
@@ -2351,7 +3684,7 @@ class Parser {
|
|
|
2351
3684
|
kind: 'predicate',
|
|
2352
3685
|
subject,
|
|
2353
3686
|
predicate: { kind: 'is', negated, value, span: value.span },
|
|
2354
|
-
span: (0,
|
|
3687
|
+
span: (0, span_js_1.mergeSpans)(subject.span, value.span),
|
|
2355
3688
|
};
|
|
2356
3689
|
}
|
|
2357
3690
|
if (t.text === 'has' || t.text === 'holds' || t.text === 'wears') {
|
|
@@ -2361,7 +3694,7 @@ class Parser {
|
|
|
2361
3694
|
kind: 'predicate',
|
|
2362
3695
|
subject,
|
|
2363
3696
|
predicate: { kind: t.text, thing, span: thing.span },
|
|
2364
|
-
span: (0,
|
|
3697
|
+
span: (0, span_js_1.mergeSpans)(subject.span, thing.span),
|
|
2365
3698
|
};
|
|
2366
3699
|
}
|
|
2367
3700
|
if (t.text === 'can') {
|
|
@@ -2376,8 +3709,8 @@ class Parser {
|
|
|
2376
3709
|
return {
|
|
2377
3710
|
kind: 'predicate',
|
|
2378
3711
|
subject,
|
|
2379
|
-
predicate: { kind: 'can', ability: ability.text, thing, span: (0,
|
|
2380
|
-
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),
|
|
2381
3714
|
};
|
|
2382
3715
|
}
|
|
2383
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);
|