@sharpee/chord 3.0.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 +31 -0
- package/analyzer.d.ts.map +1 -0
- package/analyzer.js +1656 -0
- package/analyzer.js.map +1 -0
- package/ast.d.ts +684 -0
- package/ast.d.ts.map +1 -0
- package/ast.js +3 -0
- package/ast.js.map +1 -0
- package/catalog.d.ts +43 -0
- package/catalog.d.ts.map +1 -0
- package/catalog.js +77 -0
- package/catalog.js.map +1 -0
- package/diagnostics.d.ts +38 -0
- package/diagnostics.d.ts.map +1 -0
- package/diagnostics.js +29 -0
- package/diagnostics.js.map +1 -0
- package/index.d.ts +39 -0
- package/index.d.ts.map +1 -0
- package/index.js +77 -0
- package/index.js.map +1 -0
- package/ir.d.ts +511 -0
- package/ir.d.ts.map +1 -0
- package/ir.js +6 -0
- package/ir.js.map +1 -0
- package/lexer.d.ts +49 -0
- package/lexer.d.ts.map +1 -0
- package/lexer.js +88 -0
- package/lexer.js.map +1 -0
- package/package.json +40 -0
- package/parser.d.ts +29 -0
- package/parser.d.ts.map +1 -0
- package/parser.js +2303 -0
- package/parser.js.map +1 -0
- package/span.d.ts +30 -0
- package/span.d.ts.map +1 -0
- package/span.js +34 -0
- package/span.js.map +1 -0
package/parser.js
ADDED
|
@@ -0,0 +1,2303 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseStory = parseStory;
|
|
4
|
+
const lexer_1 = require("./lexer");
|
|
5
|
+
const span_1 = require("./span");
|
|
6
|
+
const ARTICLES = new Set(['the', 'a', 'an']);
|
|
7
|
+
const DIRECTIONS = new Set([
|
|
8
|
+
'north', 'south', 'east', 'west',
|
|
9
|
+
'northeast', 'northwest', 'southeast', 'southwest',
|
|
10
|
+
'up', 'down',
|
|
11
|
+
]);
|
|
12
|
+
/** Z5 strategy adverbs (ADR-211 Decision 4). `ordered`/`once` are retired — load errors naming their replacement. */
|
|
13
|
+
const STRATEGIES = new Set(['randomly', 'cycling', 'stopping', 'sticky', 'first-time']);
|
|
14
|
+
/** Retired strategy adverb → its replacement, for the AC-13 fix-it diagnostic. */
|
|
15
|
+
const RETIRED_STRATEGIES = { ordered: 'stopping', once: 'first-time' };
|
|
16
|
+
const ORDINALS = {
|
|
17
|
+
first: 1, second: 2, third: 3, fourth: 4, fifth: 5,
|
|
18
|
+
sixth: 6, seventh: 7, eighth: 8, ninth: 9, tenth: 10,
|
|
19
|
+
};
|
|
20
|
+
/** Words that terminate a noun phrase in condition/statement positions. */
|
|
21
|
+
const PHRASE_STOPS = new Set(['is', 'has', 'holds', 'wears', 'can', 'and', 'or', 'then', 'to', 'while', 'with']);
|
|
22
|
+
const TOP_KEYWORDS = new Set(['story', 'create', 'define', 'when', 'once', 'every']);
|
|
23
|
+
/**
|
|
24
|
+
* Parse `.story` source into an AST.
|
|
25
|
+
* @param source full `.story` text
|
|
26
|
+
* @param diagnostics receives lex + parse diagnostics; parse always returns
|
|
27
|
+
* a (possibly partial) StoryFile — callers gate on diagnostics.hasErrors()
|
|
28
|
+
*/
|
|
29
|
+
function parseStory(source, diagnostics) {
|
|
30
|
+
return new Parser((0, lexer_1.lex)(source, diagnostics), diagnostics).parseFile();
|
|
31
|
+
}
|
|
32
|
+
/** Cursor over one line's tokens. */
|
|
33
|
+
class Cursor {
|
|
34
|
+
tokens;
|
|
35
|
+
line;
|
|
36
|
+
i = 0;
|
|
37
|
+
constructor(tokens, line) {
|
|
38
|
+
this.tokens = tokens;
|
|
39
|
+
this.line = line;
|
|
40
|
+
}
|
|
41
|
+
peek(offset = 0) {
|
|
42
|
+
return this.tokens[this.i + offset] ?? null;
|
|
43
|
+
}
|
|
44
|
+
next() {
|
|
45
|
+
return this.tokens[this.i++] ?? null;
|
|
46
|
+
}
|
|
47
|
+
atEnd() {
|
|
48
|
+
return this.i >= this.tokens.length;
|
|
49
|
+
}
|
|
50
|
+
/** Consume the next token iff it is the given word (case-sensitive). */
|
|
51
|
+
matchWord(word) {
|
|
52
|
+
const t = this.peek();
|
|
53
|
+
if (t && t.kind === 'word' && t.text === word)
|
|
54
|
+
return this.next();
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
isWord(word, offset = 0) {
|
|
58
|
+
const t = this.peek(offset);
|
|
59
|
+
return !!t && t.kind === 'word' && t.text === word;
|
|
60
|
+
}
|
|
61
|
+
/** Span from the current position to end of line (or whole line when spent). */
|
|
62
|
+
restSpan() {
|
|
63
|
+
const t = this.peek();
|
|
64
|
+
if (t)
|
|
65
|
+
return (0, span_1.mergeSpans)(t.span, this.tokens[this.tokens.length - 1].span);
|
|
66
|
+
return lineSpan(this.line);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function lineSpan(line) {
|
|
70
|
+
if (line.tokens.length === 0)
|
|
71
|
+
return (0, span_1.spanOf)(line.lineNo, line.indent + 1, Math.max(1, line.raw.trim().length));
|
|
72
|
+
return (0, span_1.mergeSpans)(line.tokens[0].span, line.tokens[line.tokens.length - 1].span);
|
|
73
|
+
}
|
|
74
|
+
function firstWord(line) {
|
|
75
|
+
const t = line.tokens[0];
|
|
76
|
+
return t && t.kind === 'word' ? t.text : null;
|
|
77
|
+
}
|
|
78
|
+
/** True for `end <keyword>` lines. */
|
|
79
|
+
function isEndLine(line) {
|
|
80
|
+
return firstWord(line) === 'end';
|
|
81
|
+
}
|
|
82
|
+
/** Words that open a statement or block boundary inside behavior bodies. */
|
|
83
|
+
const STATEMENT_OPENERS = new Set([
|
|
84
|
+
'refuse', 'phrase', 'emit', 'set', 'change', 'move', 'remove', 'award', 'win', 'lose',
|
|
85
|
+
'if', 'select', 'each', 'end', 'else', 'or', 'when', 'at',
|
|
86
|
+
]);
|
|
87
|
+
/**
|
|
88
|
+
* True when a line reads as a statement/boundary rather than bare prose —
|
|
89
|
+
* used to decide whether a deeper-indented line after `phrase <key>` is the
|
|
90
|
+
* declare-and-emit inline text. Case-sensitive on purpose: prose sentences
|
|
91
|
+
* start capitalized, statement keywords are lowercase.
|
|
92
|
+
*/
|
|
93
|
+
function isStatementLine(line) {
|
|
94
|
+
const word = firstWord(line);
|
|
95
|
+
if (word && STATEMENT_OPENERS.has(word))
|
|
96
|
+
return true;
|
|
97
|
+
if (word && ORDINALS[word] !== undefined)
|
|
98
|
+
return true;
|
|
99
|
+
if (lineHasMust(line))
|
|
100
|
+
return true;
|
|
101
|
+
// `<n> turns later` sequence-step headers open with a number.
|
|
102
|
+
return line.tokens[0]?.kind === 'number' && line.tokens[1]?.text === 'turns';
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* True for `must`-requirement lines (ratchet D6): a lowercase subject
|
|
106
|
+
* opener plus a `must` token. Case-sensitive so capitalized prose
|
|
107
|
+
* ("The goats must be fed.") stays prose.
|
|
108
|
+
*/
|
|
109
|
+
function lineHasMust(line) {
|
|
110
|
+
const word = firstWord(line);
|
|
111
|
+
return (word === 'the' || word === 'it' || word === 'its')
|
|
112
|
+
&& line.tokens.some((t) => t.kind === 'word' && t.text === 'must');
|
|
113
|
+
}
|
|
114
|
+
class Parser {
|
|
115
|
+
lines;
|
|
116
|
+
diagnostics;
|
|
117
|
+
pos = 0;
|
|
118
|
+
constructor(lines, diagnostics) {
|
|
119
|
+
this.lines = lines;
|
|
120
|
+
this.diagnostics = diagnostics;
|
|
121
|
+
}
|
|
122
|
+
// ------------------------------------------------------------------ file
|
|
123
|
+
parseFile() {
|
|
124
|
+
let header = null;
|
|
125
|
+
const declarations = [];
|
|
126
|
+
const start = this.lines[0] ? lineSpan(this.lines[0]) : (0, span_1.spanOf)(1, 1);
|
|
127
|
+
while (this.pos < this.lines.length) {
|
|
128
|
+
const line = this.lines[this.pos];
|
|
129
|
+
if (line.indent !== 0) {
|
|
130
|
+
this.diagnostics.error('parse.unexpected-indent', 'Unexpected indentation — expected a top-level declaration.', lineSpan(line));
|
|
131
|
+
this.recoverToTopLevel();
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const word = firstWord(line);
|
|
135
|
+
switch (word) {
|
|
136
|
+
case 'story':
|
|
137
|
+
if (header) {
|
|
138
|
+
this.diagnostics.error('parse.duplicate-story-header', 'Duplicate story header — only one `story` line is allowed.', lineSpan(line));
|
|
139
|
+
this.recoverToTopLevel(true);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
header = this.parseStoryHeader();
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
case 'create':
|
|
146
|
+
declarations.push(this.parseCreate());
|
|
147
|
+
break;
|
|
148
|
+
case 'define': {
|
|
149
|
+
const d = this.parseDefine();
|
|
150
|
+
if (d)
|
|
151
|
+
declarations.push(d);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
// Ownership-package removals (ratchet 2026-07-11): floating rules
|
|
155
|
+
// are gone — each gets a fix-it naming its owner-attached form.
|
|
156
|
+
case 'when':
|
|
157
|
+
this.diagnostics.error('parse.removed-when', 'Top-level `when` rules were removed (ownership package, 2026-07-11) — attach the rule to its owner: `after <verb> it` on the entity or room it is about.', lineSpan(line));
|
|
158
|
+
this.recoverToTopLevel(true);
|
|
159
|
+
break;
|
|
160
|
+
case 'once':
|
|
161
|
+
this.diagnostics.error('parse.removed-once', 'Top-level `once <condition>` rules were removed (ownership package, 2026-07-11) — use a `, once` clause modifier on the owner: `on every turn while <condition>, once`.', lineSpan(line));
|
|
162
|
+
this.recoverToTopLevel(true);
|
|
163
|
+
break;
|
|
164
|
+
case 'every':
|
|
165
|
+
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
|
+
this.recoverToTopLevel(true);
|
|
167
|
+
break;
|
|
168
|
+
case 'each':
|
|
169
|
+
// Never top-level (given 9: all behavior is owned) — ratchet E3.
|
|
170
|
+
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));
|
|
171
|
+
this.recoverToTopLevel(true);
|
|
172
|
+
break;
|
|
173
|
+
default:
|
|
174
|
+
this.diagnostics.error('parse.unknown-declaration', `Unknown declaration \`${word ?? line.raw.trim()}\` — expected story, create, or define.`, lineSpan(line));
|
|
175
|
+
this.recoverToTopLevel(true);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const last = this.lines[this.lines.length - 1];
|
|
179
|
+
return {
|
|
180
|
+
kind: 'story-file',
|
|
181
|
+
header,
|
|
182
|
+
declarations,
|
|
183
|
+
span: last ? (0, span_1.mergeSpans)(start, lineSpan(last)) : start,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** Skip lines until the next top-level keyword at indent 0. */
|
|
187
|
+
recoverToTopLevel(consumeCurrent = false) {
|
|
188
|
+
if (consumeCurrent)
|
|
189
|
+
this.pos++;
|
|
190
|
+
while (this.pos < this.lines.length) {
|
|
191
|
+
const line = this.lines[this.pos];
|
|
192
|
+
if (line.indent === 0 && TOP_KEYWORDS.has(firstWord(line) ?? ''))
|
|
193
|
+
return;
|
|
194
|
+
this.pos++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// ---------------------------------------------------------------- header
|
|
198
|
+
parseStoryHeader() {
|
|
199
|
+
const line = this.lines[this.pos++];
|
|
200
|
+
const c = new Cursor(line.tokens, line);
|
|
201
|
+
c.matchWord('story');
|
|
202
|
+
const titleTok = c.peek();
|
|
203
|
+
let title = '';
|
|
204
|
+
if (titleTok && titleTok.kind === 'string') {
|
|
205
|
+
title = titleTok.text;
|
|
206
|
+
c.next();
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
this.diagnostics.error('parse.story-title', 'Expected a quoted story title after `story`.', c.restSpan());
|
|
210
|
+
}
|
|
211
|
+
let author = '';
|
|
212
|
+
if (c.matchWord('by')) {
|
|
213
|
+
const authorTok = c.peek();
|
|
214
|
+
if (authorTok && authorTok.kind === 'string') {
|
|
215
|
+
author = authorTok.text;
|
|
216
|
+
c.next();
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
this.diagnostics.error('parse.story-author', 'Expected a quoted author after `by`.', c.restSpan());
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const fields = {};
|
|
223
|
+
const states = [];
|
|
224
|
+
let statesReversible = false;
|
|
225
|
+
const scores = [];
|
|
226
|
+
let span = lineSpan(line);
|
|
227
|
+
while (this.pos < this.lines.length && this.lines[this.pos].indent > 0) {
|
|
228
|
+
const fieldLine = this.lines[this.pos++];
|
|
229
|
+
span = (0, span_1.mergeSpans)(span, lineSpan(fieldLine));
|
|
230
|
+
const key = firstWord(fieldLine);
|
|
231
|
+
// `states[, reversible]: a, b` — story phases (ratchet D2/D4).
|
|
232
|
+
if (key === 'states') {
|
|
233
|
+
const sc = new Cursor(fieldLine.tokens, fieldLine);
|
|
234
|
+
sc.next();
|
|
235
|
+
const parsed = this.parseStatesTail(sc, fieldLine);
|
|
236
|
+
if (parsed) {
|
|
237
|
+
states.push(...parsed.states);
|
|
238
|
+
statesReversible = parsed.reversible;
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
// `score <name> worth N` — story-owned score identity (ratchet D12).
|
|
243
|
+
if (key === 'score') {
|
|
244
|
+
const s = this.parseScoreLine(fieldLine);
|
|
245
|
+
if (s)
|
|
246
|
+
scores.push(s);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const colon = fieldLine.tokens[1];
|
|
250
|
+
if (!key || !colon || colon.kind !== 'colon') {
|
|
251
|
+
this.diagnostics.error('parse.header-field', 'Expected `key: value` in the story header.', lineSpan(fieldLine));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const colonAt = fieldLine.raw.indexOf(':');
|
|
255
|
+
fields[key] = fieldLine.raw.slice(colonAt + 1).trim();
|
|
256
|
+
}
|
|
257
|
+
return { kind: 'story-header', title, author, fields, states, statesReversible, scores, span };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Parse the tail of a `states` line after the `states` word:
|
|
261
|
+
* `[, reversible] : <name>, <name>…` (ratchet D2/D4/D8). Shared by the
|
|
262
|
+
* story header, create blocks, and trait blocks.
|
|
263
|
+
*/
|
|
264
|
+
parseStatesTail(c, line) {
|
|
265
|
+
let reversible = false;
|
|
266
|
+
if (c.peek()?.kind === 'comma') {
|
|
267
|
+
c.next();
|
|
268
|
+
if (c.matchWord('reversible')) {
|
|
269
|
+
reversible = true;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
this.diagnostics.error('parse.states-modifier', 'Expected `reversible` after the comma in the `states` declaration.', c.restSpan());
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const colon = c.next();
|
|
277
|
+
if (!colon || colon.kind !== 'colon') {
|
|
278
|
+
this.diagnostics.error('parse.states', 'Expected `:` after `states`.', c.restSpan());
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
return { states: this.parseStateList(c), reversible };
|
|
282
|
+
}
|
|
283
|
+
/** `score <name> worth <n>` — owner-attached score line (ratchet D12). */
|
|
284
|
+
parseScoreLine(line) {
|
|
285
|
+
const c = new Cursor(line.tokens, line);
|
|
286
|
+
c.matchWord('score');
|
|
287
|
+
const name = c.next();
|
|
288
|
+
if (!name || name.kind !== 'word') {
|
|
289
|
+
this.diagnostics.error('parse.score-name', 'Expected a score name after `score`.', c.restSpan());
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
if (!c.matchWord('worth')) {
|
|
293
|
+
this.diagnostics.error('parse.score-worth', 'Expected `worth <number>` in the score line.', c.restSpan());
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
const worth = c.next();
|
|
297
|
+
if (!worth || worth.kind !== 'number') {
|
|
298
|
+
this.diagnostics.error('parse.score-worth', 'Expected a number after `worth`.', c.restSpan());
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
return { kind: 'score', name: name.text, worth: Number(worth.text), span: lineSpan(line) };
|
|
302
|
+
}
|
|
303
|
+
// ---------------------------------------------------------------- create
|
|
304
|
+
parseCreate() {
|
|
305
|
+
const headLine = this.lines[this.pos++];
|
|
306
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
307
|
+
c.matchWord('create');
|
|
308
|
+
const name = this.parseNameRef(c, () => false);
|
|
309
|
+
if (name.words.length === 0) {
|
|
310
|
+
this.diagnostics.error('parse.create-name', 'Expected an entity name after `create`.', lineSpan(headLine));
|
|
311
|
+
}
|
|
312
|
+
const decl = {
|
|
313
|
+
kind: 'create',
|
|
314
|
+
name,
|
|
315
|
+
aka: [],
|
|
316
|
+
compositions: [],
|
|
317
|
+
placement: null,
|
|
318
|
+
wears: [],
|
|
319
|
+
exits: [],
|
|
320
|
+
blockedExits: [],
|
|
321
|
+
states: [],
|
|
322
|
+
statesReversible: false,
|
|
323
|
+
scores: [],
|
|
324
|
+
description: null,
|
|
325
|
+
initialDescription: null,
|
|
326
|
+
phraseOverrides: [],
|
|
327
|
+
onClauses: [],
|
|
328
|
+
span: lineSpan(headLine),
|
|
329
|
+
};
|
|
330
|
+
let sawBlank = false;
|
|
331
|
+
while (this.pos < this.lines.length && this.lines[this.pos].indent > 0) {
|
|
332
|
+
const line = this.lines[this.pos];
|
|
333
|
+
sawBlank = sawBlank || line.afterBlank;
|
|
334
|
+
decl.span = (0, span_1.mergeSpans)(decl.span, lineSpan(line));
|
|
335
|
+
const word = firstWord(line);
|
|
336
|
+
const cur = new Cursor(line.tokens, line);
|
|
337
|
+
if (word === 'aka') {
|
|
338
|
+
this.pos++;
|
|
339
|
+
cur.matchWord('aka');
|
|
340
|
+
decl.aka.push(...this.parseCommaWords(cur));
|
|
341
|
+
}
|
|
342
|
+
else if (word === 'states' && (line.tokens[1]?.kind === 'colon' || line.tokens[1]?.kind === 'comma')) {
|
|
343
|
+
this.pos++;
|
|
344
|
+
cur.next();
|
|
345
|
+
const parsed = this.parseStatesTail(cur, line);
|
|
346
|
+
if (parsed) {
|
|
347
|
+
decl.states.push(...parsed.states);
|
|
348
|
+
decl.statesReversible = parsed.reversible;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
else if (word === 'score') {
|
|
352
|
+
this.pos++;
|
|
353
|
+
const s = this.parseScoreLine(line);
|
|
354
|
+
if (s)
|
|
355
|
+
decl.scores.push(s);
|
|
356
|
+
}
|
|
357
|
+
else if (word === 'wears') {
|
|
358
|
+
this.pos++;
|
|
359
|
+
cur.matchWord('wears');
|
|
360
|
+
decl.wears.push(this.parseNameRef(cur, () => false));
|
|
361
|
+
}
|
|
362
|
+
else if (word === 'starts' && cur.isWord('in', 1)) {
|
|
363
|
+
this.pos++;
|
|
364
|
+
cur.next();
|
|
365
|
+
cur.next();
|
|
366
|
+
decl.placement = this.finishPlacement('starts-in', cur, line);
|
|
367
|
+
}
|
|
368
|
+
else if ((word === 'in' || word === 'on') && line.tokens[1] && line.tokens[1].kind === 'word' && ARTICLES.has(line.tokens[1].text)) {
|
|
369
|
+
this.pos++;
|
|
370
|
+
cur.next();
|
|
371
|
+
decl.placement = this.finishPlacement(word, cur, line);
|
|
372
|
+
}
|
|
373
|
+
else if (word === 'on') {
|
|
374
|
+
decl.onClauses.push(this.parseOnClause(line.indent, 'on'));
|
|
375
|
+
}
|
|
376
|
+
else if (word === 'after') {
|
|
377
|
+
decl.onClauses.push(this.parseOnClause(line.indent, 'after'));
|
|
378
|
+
}
|
|
379
|
+
else if (word === 'phrase' &&
|
|
380
|
+
line.tokens[1]?.kind === 'word' &&
|
|
381
|
+
line.tokens.some((t) => t.kind === 'colon')) {
|
|
382
|
+
// Colon anywhere on the line: the header may carry `, <strategy>`
|
|
383
|
+
// and/or `while <condition>` before it (CP3/Z3b).
|
|
384
|
+
this.pos++;
|
|
385
|
+
decl.phraseOverrides.push(this.parsePhraseOverride(line));
|
|
386
|
+
}
|
|
387
|
+
else if (word && ORDINALS[word] !== undefined && cur.isWord('time', 1)) {
|
|
388
|
+
// Z1: a `first time` block whose body is bare prose is the
|
|
389
|
+
// first-VISIT description (RoomTrait.initialDescription). Other
|
|
390
|
+
// ordinals have no platform field at create scope — load errors,
|
|
391
|
+
// never a guess.
|
|
392
|
+
this.pos++;
|
|
393
|
+
if (word !== 'first') {
|
|
394
|
+
this.diagnostics.error('parse.create-ordinal-time', `\`${word} time\` is not allowed in a \`create\` block — only \`first time\` exists (RoomTrait.initialDescription has no later-visit siblings).`, lineSpan(line));
|
|
395
|
+
while (this.pos < this.lines.length && this.lines[this.pos].indent > line.indent && !isEndLine(this.lines[this.pos])) {
|
|
396
|
+
this.pos++;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
else if (decl.initialDescription) {
|
|
400
|
+
this.diagnostics.error('parse.first-time-duplicate', 'This `create` block already has a `first time` description.', lineSpan(line));
|
|
401
|
+
while (this.pos < this.lines.length && this.lines[this.pos].indent > line.indent && !isEndLine(this.lines[this.pos])) {
|
|
402
|
+
this.pos++;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
const next = this.lines[this.pos];
|
|
407
|
+
if (!next || next.indent <= line.indent || isEndLine(next)) {
|
|
408
|
+
this.diagnostics.error('parse.first-time-empty', '`first time` needs an indented prose block beneath it.', lineSpan(line));
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
decl.initialDescription = this.parseProseParagraph(next.indent, line.indent);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
else if (word && DIRECTIONS.has(word) && cur.isWord('to', 1)) {
|
|
416
|
+
this.pos++;
|
|
417
|
+
cur.next();
|
|
418
|
+
cur.next();
|
|
419
|
+
const to = this.parseNameRef(cur, () => false);
|
|
420
|
+
decl.exits.push({ kind: 'exit', direction: word, to, span: lineSpan(line) });
|
|
421
|
+
}
|
|
422
|
+
else if (word && DIRECTIONS.has(word) && cur.isWord('is', 1) && cur.isWord('blocked', 2)) {
|
|
423
|
+
this.pos++;
|
|
424
|
+
const blocked = this.parseBlockedExit(word, line);
|
|
425
|
+
if (blocked)
|
|
426
|
+
decl.blockedExits.push(blocked);
|
|
427
|
+
}
|
|
428
|
+
else if (!sawBlank || !line.afterBlank) {
|
|
429
|
+
if (sawBlank) {
|
|
430
|
+
this.diagnostics.error('parse.create-property', `Unrecognized line in \`create\` block: \`${line.raw.trim()}\`.`, lineSpan(line));
|
|
431
|
+
this.pos++;
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
this.pos++;
|
|
435
|
+
decl.compositions.push(...this.parseCompositionLine(cur, line));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
const prose = this.parseProseParagraph(line.indent);
|
|
440
|
+
if (decl.description) {
|
|
441
|
+
// All consecutive bare paragraphs form the description — a later
|
|
442
|
+
// bare paragraph appends as a new paragraph (grammar log 2026-07-10).
|
|
443
|
+
decl.description = {
|
|
444
|
+
...decl.description,
|
|
445
|
+
text: `${decl.description.text}\n\n${prose.text}`,
|
|
446
|
+
markers: [...decl.description.markers, ...prose.markers],
|
|
447
|
+
span: (0, span_1.mergeSpans)(decl.description.span, prose.span),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
decl.description = prose;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return decl;
|
|
456
|
+
}
|
|
457
|
+
finishPlacement(relation, c, line) {
|
|
458
|
+
const place = this.parseNameRef(c, () => false);
|
|
459
|
+
if (place.words.length === 0) {
|
|
460
|
+
this.diagnostics.error('parse.placement', 'Expected a place name.', lineSpan(line));
|
|
461
|
+
}
|
|
462
|
+
return { kind: 'placement', relation, place, span: lineSpan(line) };
|
|
463
|
+
}
|
|
464
|
+
parseBlockedExit(direction, line) {
|
|
465
|
+
// <direction> is blocked [while <condition>]: <phrase-key>
|
|
466
|
+
// (conditional form: grammar log 2026-07-10, Phase B)
|
|
467
|
+
const c = new Cursor(line.tokens, line);
|
|
468
|
+
c.next(); // direction
|
|
469
|
+
c.next(); // is
|
|
470
|
+
c.next(); // blocked
|
|
471
|
+
let condition = null;
|
|
472
|
+
if (c.matchWord('while')) {
|
|
473
|
+
const condTokens = [];
|
|
474
|
+
while (!c.atEnd() && c.peek().kind !== 'colon')
|
|
475
|
+
condTokens.push(c.next());
|
|
476
|
+
condition = this.parseCondition(new Cursor(condTokens, line), line);
|
|
477
|
+
}
|
|
478
|
+
const colon = c.next();
|
|
479
|
+
if (!colon || colon.kind !== 'colon') {
|
|
480
|
+
this.diagnostics.error('parse.blocked-exit', 'Expected `: <phrase-key>` after `is blocked`.', lineSpan(line));
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
const key = c.next();
|
|
484
|
+
if (!key || key.kind !== 'word') {
|
|
485
|
+
this.diagnostics.error('parse.blocked-exit', 'Expected a phrase key after `is blocked:`.', lineSpan(line));
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
return { kind: 'blocked-exit', direction, phraseKey: key.text, condition, span: lineSpan(line) };
|
|
489
|
+
}
|
|
490
|
+
parseCompositionLine(c, line) {
|
|
491
|
+
const items = [];
|
|
492
|
+
while (!c.atEnd()) {
|
|
493
|
+
const startTok = c.peek();
|
|
494
|
+
let article = null;
|
|
495
|
+
const first = c.peek();
|
|
496
|
+
if (first && first.kind === 'word' && ARTICLES.has(first.text)) {
|
|
497
|
+
article = first.text;
|
|
498
|
+
c.next();
|
|
499
|
+
}
|
|
500
|
+
const words = [];
|
|
501
|
+
while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('with') && !c.isWord('while')) {
|
|
502
|
+
if (c.peek().kind !== 'word')
|
|
503
|
+
break;
|
|
504
|
+
words.push(c.next().text);
|
|
505
|
+
if (c.peek()?.kind === 'comma')
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
const config = [];
|
|
509
|
+
let condition = null;
|
|
510
|
+
if (c.matchWord('with')) {
|
|
511
|
+
config.push(...this.parseConfigSettings(c, line));
|
|
512
|
+
}
|
|
513
|
+
if (c.matchWord('while')) {
|
|
514
|
+
condition = this.parseCondition(c, line);
|
|
515
|
+
}
|
|
516
|
+
const endTok = c.tokens[c.i - 1] ?? startTok;
|
|
517
|
+
if (words.length === 0) {
|
|
518
|
+
this.diagnostics.error('parse.composition', 'Expected a kind or trait name.', lineSpan(line));
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
items.push({
|
|
522
|
+
kind: 'composition',
|
|
523
|
+
article,
|
|
524
|
+
words,
|
|
525
|
+
config,
|
|
526
|
+
condition,
|
|
527
|
+
span: (0, span_1.mergeSpans)(startTok.span, endTok.span),
|
|
528
|
+
});
|
|
529
|
+
if (c.peek()?.kind === 'comma')
|
|
530
|
+
c.next();
|
|
531
|
+
else
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
if (!c.atEnd()) {
|
|
535
|
+
this.diagnostics.error('parse.composition-trailing', `Unexpected trailing text in composition line: \`${c.peek().text}\`.`, c.restSpan());
|
|
536
|
+
}
|
|
537
|
+
return items;
|
|
538
|
+
}
|
|
539
|
+
parseConfigSettings(c, line) {
|
|
540
|
+
const settings = [];
|
|
541
|
+
for (;;) {
|
|
542
|
+
const startTok = c.peek();
|
|
543
|
+
const key = [];
|
|
544
|
+
while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('while')) {
|
|
545
|
+
const t = c.peek();
|
|
546
|
+
// An article starts an entity-name value (`with food the handful of
|
|
547
|
+
// feed`, Phase B) — everything after it is the value.
|
|
548
|
+
if (ARTICLES.has(t.text) && key.length > 0)
|
|
549
|
+
break;
|
|
550
|
+
// Otherwise the last token of a setting is its value; words before
|
|
551
|
+
// it are the key.
|
|
552
|
+
const after = c.peek(1);
|
|
553
|
+
const isValuePosition = !after || after.kind === 'comma' || (after.kind === 'word' && (after.text === 'and' || after.text === 'while'));
|
|
554
|
+
if (isValuePosition && key.length > 0)
|
|
555
|
+
break;
|
|
556
|
+
key.push(t.text);
|
|
557
|
+
c.next();
|
|
558
|
+
}
|
|
559
|
+
const articleTok = c.peek();
|
|
560
|
+
if (articleTok && articleTok.kind === 'word' && ARTICLES.has(articleTok.text) && key.length > 0) {
|
|
561
|
+
c.next(); // article
|
|
562
|
+
const nameWords = [];
|
|
563
|
+
let lastTok = articleTok;
|
|
564
|
+
while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('while')) {
|
|
565
|
+
lastTok = c.next();
|
|
566
|
+
nameWords.push(lastTok.text);
|
|
567
|
+
}
|
|
568
|
+
if (nameWords.length === 0) {
|
|
569
|
+
this.diagnostics.error('parse.config-value', 'Expected an entity name after the article.', c.restSpan());
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
settings.push({
|
|
573
|
+
key,
|
|
574
|
+
value: nameWords.join(' '),
|
|
575
|
+
valueKind: 'name',
|
|
576
|
+
span: startTok ? (0, span_1.mergeSpans)(startTok.span, lastTok.span) : lastTok.span,
|
|
577
|
+
});
|
|
578
|
+
if (!c.matchWord('and'))
|
|
579
|
+
break;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const valueTok = c.peek();
|
|
583
|
+
if (!valueTok || (valueTok.kind !== 'number' && valueTok.kind !== 'string' && valueTok.kind !== 'word')) {
|
|
584
|
+
this.diagnostics.error('parse.config-value', 'Expected a value to end this `with` setting.', c.restSpan());
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
c.next();
|
|
588
|
+
settings.push({
|
|
589
|
+
key,
|
|
590
|
+
value: valueTok.text,
|
|
591
|
+
valueKind: valueTok.kind,
|
|
592
|
+
span: startTok ? (0, span_1.mergeSpans)(startTok.span, valueTok.span) : valueTok.span,
|
|
593
|
+
});
|
|
594
|
+
if (!c.matchWord('and'))
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
return settings;
|
|
598
|
+
}
|
|
599
|
+
parsePhraseOverride(line) {
|
|
600
|
+
const c = new Cursor(line.tokens, line);
|
|
601
|
+
c.matchWord('phrase');
|
|
602
|
+
const key = c.next(); // validated by caller
|
|
603
|
+
// CP3: optional `, <strategy>` (the Z5 adverb set, retired fix-its
|
|
604
|
+
// included) — `phrase present, cycling:`.
|
|
605
|
+
let strategy = null;
|
|
606
|
+
if (c.peek()?.kind === 'comma') {
|
|
607
|
+
c.next();
|
|
608
|
+
const s = c.next();
|
|
609
|
+
if (s && s.kind === 'word' && STRATEGIES.has(s.text)) {
|
|
610
|
+
strategy = s.text;
|
|
611
|
+
}
|
|
612
|
+
else if (s && s.kind === 'word' && RETIRED_STRATEGIES[s.text]) {
|
|
613
|
+
this.diagnostics.error('parse.phrase-strategy-retired', `\`${s.text}\` is no longer a strategy adverb — use \`${RETIRED_STRATEGIES[s.text]}\` (ADR-211 Decision 4).`, s.span);
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
this.diagnostics.error('parse.phrase-strategy', 'Expected a strategy (randomly, cycling, stopping, sticky, first-time) after the comma.', s?.span ?? c.restSpan());
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
// Z3b: optional `while <condition>` up to the colon — `phrase detail
|
|
620
|
+
// while it is on:` (`it` = the owner; the analyzer resolves).
|
|
621
|
+
let condition = null;
|
|
622
|
+
if (c.isWord('while')) {
|
|
623
|
+
c.next();
|
|
624
|
+
const condTokens = [];
|
|
625
|
+
while (!c.atEnd() && c.peek().kind !== 'colon')
|
|
626
|
+
condTokens.push(c.next());
|
|
627
|
+
condition = this.parseCondition(new Cursor(condTokens, line), line);
|
|
628
|
+
}
|
|
629
|
+
if (c.peek()?.kind === 'colon') {
|
|
630
|
+
c.next();
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
this.diagnostics.error('parse.phrase-override-colon', 'Expected `:` to end the `phrase` header.', c.restSpan());
|
|
634
|
+
}
|
|
635
|
+
const variants = [];
|
|
636
|
+
const str = c.peek();
|
|
637
|
+
if (str && str.kind === 'string') {
|
|
638
|
+
c.next();
|
|
639
|
+
this.reportSameLineText(str.span);
|
|
640
|
+
variants.push(this.textFromString(str)); // recovery: keep the text so analysis continues
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
// First variant, then `or`-separated further variants (CP3): `or`
|
|
644
|
+
// stands alone at the SAME indent as the `phrase` header line.
|
|
645
|
+
variants.push(this.parseProseParagraph(line.indent + 1, line.indent));
|
|
646
|
+
for (;;) {
|
|
647
|
+
const next = this.lines[this.pos];
|
|
648
|
+
if (!next || next.indent !== line.indent || firstWord(next) !== 'or' || next.tokens.length !== 1)
|
|
649
|
+
break;
|
|
650
|
+
this.pos++;
|
|
651
|
+
variants.push(this.parseProseParagraph(line.indent + 1, line.indent));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const last = variants[variants.length - 1];
|
|
655
|
+
return {
|
|
656
|
+
kind: 'phrase-override',
|
|
657
|
+
key: key.text,
|
|
658
|
+
strategy,
|
|
659
|
+
condition,
|
|
660
|
+
variants,
|
|
661
|
+
span: (0, span_1.mergeSpans)(lineSpan(line), last?.span ?? lineSpan(line)),
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
/** Same-line phrase text (quoted or bare) was removed — grammar log 2026-07-10. */
|
|
665
|
+
reportSameLineText(span) {
|
|
666
|
+
this.diagnostics.error('parse.phrase-text-form', 'Phrase text goes in an indented prose block — the same-line form was removed (grammar log 2026-07-10).', span);
|
|
667
|
+
}
|
|
668
|
+
parseCommaWords(c) {
|
|
669
|
+
const out = [];
|
|
670
|
+
let current = [];
|
|
671
|
+
while (!c.atEnd()) {
|
|
672
|
+
const t = c.next();
|
|
673
|
+
if (t.kind === 'comma') {
|
|
674
|
+
if (current.length)
|
|
675
|
+
out.push(current.join(' '));
|
|
676
|
+
current = [];
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
current.push(t.text);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (current.length)
|
|
683
|
+
out.push(current.join(' '));
|
|
684
|
+
return out;
|
|
685
|
+
}
|
|
686
|
+
parseStateList(c) {
|
|
687
|
+
const out = [];
|
|
688
|
+
while (!c.atEnd()) {
|
|
689
|
+
const t = c.next();
|
|
690
|
+
if (t.kind === 'word')
|
|
691
|
+
out.push({ name: t.text, span: t.span });
|
|
692
|
+
else if (t.kind !== 'comma') {
|
|
693
|
+
this.diagnostics.error('parse.states', 'Expected a comma-separated list of state names.', t.span);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return out;
|
|
697
|
+
}
|
|
698
|
+
// ------------------------------------------------------------------ prose
|
|
699
|
+
/**
|
|
700
|
+
* Collect a prose block: consecutive lines at indent >= minIndent. When
|
|
701
|
+
* `strictlyAbove` is given, lines must be indented strictly deeper than it
|
|
702
|
+
* (phrase-entry values, variants, overrides) — and in that mode a blank
|
|
703
|
+
* line starts a new PARAGRAPH (`\n\n` in the text) instead of ending the
|
|
704
|
+
* block (grammar log 2026-07-10), since the block is already delimited by
|
|
705
|
+
* shallower-indented lines. In minIndent mode (create-block descriptions)
|
|
706
|
+
* a blank line still ends the block — the create loop merges consecutive
|
|
707
|
+
* bare paragraphs itself, keeping keyword lines out of prose.
|
|
708
|
+
*/
|
|
709
|
+
parseProseParagraph(minIndent, strictlyAbove) {
|
|
710
|
+
const paragraphs = [];
|
|
711
|
+
let current = [];
|
|
712
|
+
const markers = [];
|
|
713
|
+
let span = null;
|
|
714
|
+
let first = true;
|
|
715
|
+
while (this.pos < this.lines.length) {
|
|
716
|
+
const line = this.lines[this.pos];
|
|
717
|
+
const deepEnough = strictlyAbove !== undefined ? line.indent > strictlyAbove : line.indent >= minIndent;
|
|
718
|
+
if (!deepEnough || isEndLine(line))
|
|
719
|
+
break;
|
|
720
|
+
if (!first && line.afterBlank) {
|
|
721
|
+
if (strictlyAbove === undefined)
|
|
722
|
+
break;
|
|
723
|
+
paragraphs.push(current);
|
|
724
|
+
current = [];
|
|
725
|
+
}
|
|
726
|
+
this.pos++;
|
|
727
|
+
const text = line.raw.trim();
|
|
728
|
+
this.extractMarkers(line, markers);
|
|
729
|
+
current.push(text);
|
|
730
|
+
span = span ? (0, span_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
|
|
731
|
+
first = false;
|
|
732
|
+
}
|
|
733
|
+
if (current.length)
|
|
734
|
+
paragraphs.push(current);
|
|
735
|
+
return {
|
|
736
|
+
kind: 'text',
|
|
737
|
+
form: 'prose',
|
|
738
|
+
text: paragraphs.map((p) => p.join(' ')).join('\n\n'),
|
|
739
|
+
markers,
|
|
740
|
+
span: span ?? (0, span_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Collect a verbatim block (`define phrase X, verbatim`): every line
|
|
745
|
+
* deeper than the head line, with line structure, interior blank lines,
|
|
746
|
+
* and relative indentation preserved exactly. The common leading indent
|
|
747
|
+
* is stripped; lines join with `\n`.
|
|
748
|
+
*/
|
|
749
|
+
parseVerbatimBlock() {
|
|
750
|
+
const collected = [];
|
|
751
|
+
const markers = [];
|
|
752
|
+
let span = null;
|
|
753
|
+
let first = true;
|
|
754
|
+
while (this.pos < this.lines.length) {
|
|
755
|
+
const line = this.lines[this.pos];
|
|
756
|
+
// Only a column-1 line (`end phrase`, next top-level keyword) ends the
|
|
757
|
+
// block — verbatim content may contain any words, including `end`.
|
|
758
|
+
if (line.indent === 0)
|
|
759
|
+
break;
|
|
760
|
+
collected.push({ raw: line.raw, blankBefore: !first && line.afterBlank });
|
|
761
|
+
this.extractMarkers(line, markers);
|
|
762
|
+
span = span ? (0, span_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
|
|
763
|
+
this.pos++;
|
|
764
|
+
first = false;
|
|
765
|
+
}
|
|
766
|
+
const common = Math.min(...collected.map((l) => l.raw.length - l.raw.trimStart().length));
|
|
767
|
+
const lines = [];
|
|
768
|
+
for (const l of collected) {
|
|
769
|
+
if (l.blankBefore)
|
|
770
|
+
lines.push('');
|
|
771
|
+
lines.push(l.raw.slice(common).trimEnd());
|
|
772
|
+
}
|
|
773
|
+
return {
|
|
774
|
+
kind: 'text',
|
|
775
|
+
form: 'verbatim',
|
|
776
|
+
text: lines.join('\n'),
|
|
777
|
+
markers,
|
|
778
|
+
span: span ?? (0, span_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
extractMarkers(line, into) {
|
|
782
|
+
const re = /\{([^}]*)\}/g;
|
|
783
|
+
let m;
|
|
784
|
+
while ((m = re.exec(line.raw)) !== null) {
|
|
785
|
+
into.push({ content: m[1], span: (0, span_1.spanOf)(line.lineNo, m.index + 1, m[0].length) });
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
/** Error-recovery only: a removed same-line quoted value, kept as prose text. */
|
|
789
|
+
textFromString(tok) {
|
|
790
|
+
const markers = [];
|
|
791
|
+
const re = /\{([^}]*)\}/g;
|
|
792
|
+
let m;
|
|
793
|
+
while ((m = re.exec(tok.text)) !== null) {
|
|
794
|
+
markers.push({ content: m[1], span: (0, span_1.spanOf)(tok.span.line, tok.span.column + 1 + m.index, m[0].length) });
|
|
795
|
+
}
|
|
796
|
+
return { kind: 'text', form: 'prose', text: tok.text, markers, span: tok.span };
|
|
797
|
+
}
|
|
798
|
+
// ---------------------------------------------------------------- define
|
|
799
|
+
parseDefine() {
|
|
800
|
+
const line = this.lines[this.pos];
|
|
801
|
+
const sub = line.tokens[1];
|
|
802
|
+
const subWord = sub && sub.kind === 'word' ? sub.text : null;
|
|
803
|
+
switch (subWord) {
|
|
804
|
+
case 'condition':
|
|
805
|
+
return this.parseDefineCondition();
|
|
806
|
+
case 'phrase':
|
|
807
|
+
return this.parseDefinePhrase();
|
|
808
|
+
case 'phrases':
|
|
809
|
+
return this.parseDefinePhrases();
|
|
810
|
+
case 'verb':
|
|
811
|
+
return this.parseDefineVerb();
|
|
812
|
+
case 'text':
|
|
813
|
+
return this.parseDefineText();
|
|
814
|
+
case 'flag':
|
|
815
|
+
// Removed — given 8 (ratchet 2026-07-11).
|
|
816
|
+
this.diagnostics.error('parse.removed-flag', '`define flag` was removed (given 8: no global booleans) — model the fact as `states:` on its owner, or derive it with a condition over world state.', lineSpan(line));
|
|
817
|
+
this.recoverToTopLevel(true);
|
|
818
|
+
return null;
|
|
819
|
+
case 'trait':
|
|
820
|
+
return this.parseDefineTrait();
|
|
821
|
+
case 'action':
|
|
822
|
+
return this.parseDefineAction();
|
|
823
|
+
case 'behavior':
|
|
824
|
+
return this.parseDefineBehaviorHatch();
|
|
825
|
+
case 'score':
|
|
826
|
+
// Removed — ratchet D12/CP5 (2026-07-11).
|
|
827
|
+
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));
|
|
828
|
+
this.recoverToTopLevel(true);
|
|
829
|
+
return null;
|
|
830
|
+
case 'sequence':
|
|
831
|
+
return this.parseDefineSequence();
|
|
832
|
+
default:
|
|
833
|
+
this.diagnostics.error('parse.unknown-define', `Unknown declaration \`define ${subWord ?? ''}\`.`, lineSpan(line));
|
|
834
|
+
this.recoverToTopLevel(true);
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
parseDefineCondition() {
|
|
839
|
+
const line = this.lines[this.pos++];
|
|
840
|
+
const c = new Cursor(line.tokens, line);
|
|
841
|
+
c.next();
|
|
842
|
+
c.next(); // define condition
|
|
843
|
+
const name = c.next();
|
|
844
|
+
if (!name || name.kind !== 'word') {
|
|
845
|
+
this.diagnostics.error('parse.condition-name', 'Expected a condition name after `define condition`.', c.restSpan());
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
const colon = c.next();
|
|
849
|
+
if (!colon || colon.kind !== 'colon') {
|
|
850
|
+
this.diagnostics.error('parse.condition-colon', 'Expected `:` after the condition name.', c.restSpan());
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
const condition = this.parseCondition(c, line);
|
|
854
|
+
return { kind: 'define-condition', name: name.text, condition, span: lineSpan(line) };
|
|
855
|
+
}
|
|
856
|
+
parseDefinePhrase() {
|
|
857
|
+
const headLine = this.lines[this.pos++];
|
|
858
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
859
|
+
c.next();
|
|
860
|
+
c.next(); // define phrase
|
|
861
|
+
const keyTok = c.next();
|
|
862
|
+
const key = keyTok && keyTok.kind === 'word' ? keyTok.text : '';
|
|
863
|
+
if (!key) {
|
|
864
|
+
this.diagnostics.error('parse.phrase-key', 'Expected a phrase key after `define phrase`.', lineSpan(headLine));
|
|
865
|
+
}
|
|
866
|
+
let strategy = null;
|
|
867
|
+
let verbatim = false;
|
|
868
|
+
if (c.peek()?.kind === 'comma') {
|
|
869
|
+
c.next();
|
|
870
|
+
const s = c.next();
|
|
871
|
+
if (s && s.kind === 'word' && STRATEGIES.has(s.text)) {
|
|
872
|
+
strategy = s.text;
|
|
873
|
+
}
|
|
874
|
+
else if (s && s.kind === 'word' && RETIRED_STRATEGIES[s.text]) {
|
|
875
|
+
// Z5 / AC-13: retired adverbs are load errors that name their replacement.
|
|
876
|
+
this.diagnostics.error('parse.phrase-strategy-retired', `\`${s.text}\` is no longer a strategy adverb — use \`${RETIRED_STRATEGIES[s.text]}\` (ADR-211 Decision 4).`, s.span);
|
|
877
|
+
}
|
|
878
|
+
else if (s && s.kind === 'word' && s.text === 'verbatim') {
|
|
879
|
+
verbatim = true; // grammar log 2026-07-10: whitespace-preserving text
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
this.diagnostics.error('parse.phrase-strategy', 'Expected a strategy (randomly, cycling, stopping, sticky, first-time) or `verbatim` after the comma.', s?.span ?? c.restSpan());
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
// Z2/CP1': optional trailing `while <condition>` gates the fragment —
|
|
886
|
+
// presence conditions compile to `mentions`, anything else registers on
|
|
887
|
+
// the ADR-211 gate seam (resolution is the analyzer's/loader's job).
|
|
888
|
+
let condition = null;
|
|
889
|
+
if (c.isWord('while')) {
|
|
890
|
+
c.next();
|
|
891
|
+
condition = this.parseCondition(c, headLine);
|
|
892
|
+
}
|
|
893
|
+
const variants = [];
|
|
894
|
+
let span = lineSpan(headLine);
|
|
895
|
+
for (;;) {
|
|
896
|
+
const line = this.lines[this.pos];
|
|
897
|
+
if (!line) {
|
|
898
|
+
this.diagnostics.error('parse.unterminated-block', 'Missing `end phrase`.', span);
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
if (isEndLine(line)) {
|
|
902
|
+
span = (0, span_1.mergeSpans)(span, lineSpan(line));
|
|
903
|
+
this.consumeEnd('phrase', headLine);
|
|
904
|
+
break;
|
|
905
|
+
}
|
|
906
|
+
if (firstWord(line) === 'or' && line.tokens.length === 1) {
|
|
907
|
+
if (verbatim) {
|
|
908
|
+
this.diagnostics.error('parse.verbatim-variants', 'A verbatim phrase has a single text — `or` variants need a strategy instead.', lineSpan(line));
|
|
909
|
+
}
|
|
910
|
+
this.pos++;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
if (line.indent === 0 && TOP_KEYWORDS.has(firstWord(line) ?? '')) {
|
|
914
|
+
this.diagnostics.error('parse.unterminated-block', 'Missing `end phrase`.', span);
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
const variant = verbatim ? this.parseVerbatimBlock() : this.parseProseParagraph(1, 0);
|
|
918
|
+
variants.push(variant);
|
|
919
|
+
span = (0, span_1.mergeSpans)(span, variant.span);
|
|
920
|
+
}
|
|
921
|
+
return { kind: 'define-phrase', key, strategy, verbatim, condition, variants, span };
|
|
922
|
+
}
|
|
923
|
+
parseDefinePhrases() {
|
|
924
|
+
const headLine = this.lines[this.pos++];
|
|
925
|
+
return this.parsePhrasesBlock(headLine, 2);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Parse a phrases block from its header line (`define phrases <locale>` at
|
|
929
|
+
* top level, or `phrases <locale>` inside a trait/action, Phase B). The
|
|
930
|
+
* locale token index distinguishes the two; entries are lines indented
|
|
931
|
+
* deeper than the header.
|
|
932
|
+
*/
|
|
933
|
+
parsePhrasesBlock(headLine, localeTokenIndex) {
|
|
934
|
+
const locTok = headLine.tokens[localeTokenIndex];
|
|
935
|
+
let locale = '';
|
|
936
|
+
if (locTok && locTok.kind === 'word') {
|
|
937
|
+
locale = locTok.text;
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
this.diagnostics.error('parse.phrases-locale', 'Expected a locale after `phrases` (e.g. en-US).', lineSpan(headLine));
|
|
941
|
+
}
|
|
942
|
+
const entries = [];
|
|
943
|
+
let span = lineSpan(headLine);
|
|
944
|
+
while (this.pos < this.lines.length && this.lines[this.pos].indent > headLine.indent) {
|
|
945
|
+
const line = this.lines[this.pos];
|
|
946
|
+
const key = line.tokens[0];
|
|
947
|
+
const colon = line.tokens[1];
|
|
948
|
+
if (!key || key.kind !== 'word' || !colon || colon.kind !== 'colon') {
|
|
949
|
+
this.diagnostics.error('parse.phrase-entry', 'Expected `key: <text>` in the phrases block.', lineSpan(line));
|
|
950
|
+
this.pos++;
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
this.pos++;
|
|
954
|
+
let value;
|
|
955
|
+
const inline = line.tokens[2];
|
|
956
|
+
if (inline && inline.kind === 'string') {
|
|
957
|
+
this.reportSameLineText(inline.span);
|
|
958
|
+
value = this.textFromString(inline); // recovery: keep the text so analysis continues
|
|
959
|
+
}
|
|
960
|
+
else if (inline) {
|
|
961
|
+
// Same-line bare text after the colon — removed with the quoted form.
|
|
962
|
+
this.reportSameLineText(inline.span);
|
|
963
|
+
const colonAt = line.raw.indexOf(':');
|
|
964
|
+
const text = line.raw.slice(colonAt + 1).trim();
|
|
965
|
+
const markers = [];
|
|
966
|
+
this.extractMarkers(line, markers);
|
|
967
|
+
value = { kind: 'text', form: 'prose', text, markers, span: lineSpan(line) };
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
value = this.parseProseParagraph(line.indent + 1, line.indent);
|
|
971
|
+
if (value.text === '') {
|
|
972
|
+
this.diagnostics.error('parse.phrase-entry-empty', `Phrase \`${key.text}\` has no text.`, lineSpan(line));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
entries.push({ key: key.text, value, span: (0, span_1.mergeSpans)(lineSpan(line), value.span) });
|
|
976
|
+
span = (0, span_1.mergeSpans)(span, entries[entries.length - 1].span);
|
|
977
|
+
}
|
|
978
|
+
return { kind: 'define-phrases', locale, entries, span };
|
|
979
|
+
}
|
|
980
|
+
parseDefineVerb() {
|
|
981
|
+
const line = this.lines[this.pos++];
|
|
982
|
+
const c = new Cursor(line.tokens, line);
|
|
983
|
+
c.next();
|
|
984
|
+
c.next(); // define verb
|
|
985
|
+
const verbs = [];
|
|
986
|
+
for (;;) {
|
|
987
|
+
const v = c.next();
|
|
988
|
+
if (!v || v.kind !== 'word') {
|
|
989
|
+
this.diagnostics.error('parse.verb-name', 'Expected a verb word.', v?.span ?? c.restSpan());
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
verbs.push(v.text);
|
|
993
|
+
if (!c.matchWord('or'))
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
if (!c.matchWord('means')) {
|
|
997
|
+
this.diagnostics.error('parse.verb-means', 'Expected `means <pattern>` in the verb definition.', c.restSpan());
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
const pattern = [];
|
|
1001
|
+
while (!c.atEnd()) {
|
|
1002
|
+
const t = c.next();
|
|
1003
|
+
if (t.kind === 'lparen') {
|
|
1004
|
+
const slot = c.next();
|
|
1005
|
+
const close = c.next();
|
|
1006
|
+
if (!slot || slot.kind !== 'word' || !close || close.kind !== 'rparen') {
|
|
1007
|
+
this.diagnostics.error('parse.verb-slot', 'Expected `(something)` slot in the verb pattern.', t.span);
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
pattern.push({ kind: 'slot', word: slot.text, span: (0, span_1.mergeSpans)(t.span, close.span) });
|
|
1011
|
+
}
|
|
1012
|
+
else if (t.kind === 'word') {
|
|
1013
|
+
pattern.push({ kind: 'word', word: t.text, span: t.span });
|
|
1014
|
+
}
|
|
1015
|
+
else {
|
|
1016
|
+
this.diagnostics.error('parse.verb-pattern', `Unexpected \`${t.text}\` in the verb pattern.`, t.span);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return { kind: 'define-verb', verbs, pattern, span: lineSpan(line) };
|
|
1020
|
+
}
|
|
1021
|
+
parseDefineText() {
|
|
1022
|
+
const line = this.lines[this.pos++];
|
|
1023
|
+
const c = new Cursor(line.tokens, line);
|
|
1024
|
+
c.next();
|
|
1025
|
+
c.next(); // define text
|
|
1026
|
+
const name = c.next();
|
|
1027
|
+
if (!name || name.kind !== 'word') {
|
|
1028
|
+
this.diagnostics.error('parse.text-name', 'Expected a producer name after `define text`.', c.restSpan());
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
if (!c.matchWord('from')) {
|
|
1032
|
+
this.diagnostics.error('parse.text-from', 'Expected `from "<module>"` in the hatch declaration.', c.restSpan());
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
const mod = c.next();
|
|
1036
|
+
if (!mod || mod.kind !== 'string') {
|
|
1037
|
+
this.diagnostics.error('parse.text-module', 'Expected a quoted module path after `from`.', c.restSpan());
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
return { kind: 'define-text', name: name.text, modulePath: mod.text, span: lineSpan(line) };
|
|
1041
|
+
}
|
|
1042
|
+
// -------------------------------------------- Phase B declarations (§2.2/§2.3/§2.5)
|
|
1043
|
+
/** `define trait <name>` … `end trait` — data, phrases, on-clauses. */
|
|
1044
|
+
parseDefineTrait() {
|
|
1045
|
+
const headLine = this.lines[this.pos++];
|
|
1046
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
1047
|
+
c.next();
|
|
1048
|
+
c.next(); // define trait
|
|
1049
|
+
const nameTok = c.next();
|
|
1050
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
1051
|
+
this.diagnostics.error('parse.trait-name', 'Expected a trait name after `define trait`.', c.restSpan());
|
|
1052
|
+
return null;
|
|
1053
|
+
}
|
|
1054
|
+
const data = [];
|
|
1055
|
+
const states = [];
|
|
1056
|
+
let statesReversible = false;
|
|
1057
|
+
const scores = [];
|
|
1058
|
+
let phrases = null;
|
|
1059
|
+
const onClauses = [];
|
|
1060
|
+
const build = (span) => ({
|
|
1061
|
+
kind: 'define-trait', name: nameTok.text, data, states, statesReversible, scores, phrases, onClauses, span,
|
|
1062
|
+
});
|
|
1063
|
+
while (this.pos < this.lines.length) {
|
|
1064
|
+
const line = this.lines[this.pos];
|
|
1065
|
+
if (line.indent === 0) {
|
|
1066
|
+
if (isEndLine(line))
|
|
1067
|
+
break;
|
|
1068
|
+
this.diagnostics.error('parse.unterminated-block', 'Missing `end trait`.', lineSpan(headLine));
|
|
1069
|
+
return build(lineSpan(headLine));
|
|
1070
|
+
}
|
|
1071
|
+
const word = firstWord(line);
|
|
1072
|
+
if (word === 'data' && line.tokens.length === 1) {
|
|
1073
|
+
this.pos++;
|
|
1074
|
+
data.push(...this.parseTraitFields(line));
|
|
1075
|
+
}
|
|
1076
|
+
else if (word === 'states') {
|
|
1077
|
+
// Trait-declared states (ratchet D8) — every composer gets the set.
|
|
1078
|
+
this.pos++;
|
|
1079
|
+
const sc = new Cursor(line.tokens, line);
|
|
1080
|
+
sc.next();
|
|
1081
|
+
const parsed = this.parseStatesTail(sc, line);
|
|
1082
|
+
if (parsed) {
|
|
1083
|
+
states.push(...parsed.states);
|
|
1084
|
+
statesReversible = parsed.reversible;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
else if (word === 'score') {
|
|
1088
|
+
this.pos++;
|
|
1089
|
+
const s = this.parseScoreLine(line);
|
|
1090
|
+
if (s)
|
|
1091
|
+
scores.push(s);
|
|
1092
|
+
}
|
|
1093
|
+
else if (word === 'phrases') {
|
|
1094
|
+
this.pos++;
|
|
1095
|
+
phrases = this.parsePhrasesBlock(line, 1);
|
|
1096
|
+
}
|
|
1097
|
+
else if (word === 'on') {
|
|
1098
|
+
onClauses.push(this.parseOnClause(line.indent, 'on'));
|
|
1099
|
+
}
|
|
1100
|
+
else if (word === 'after') {
|
|
1101
|
+
onClauses.push(this.parseOnClause(line.indent, 'after'));
|
|
1102
|
+
}
|
|
1103
|
+
else {
|
|
1104
|
+
this.diagnostics.error('parse.trait-section', `Unrecognized line in \`define trait\`: \`${line.raw.trim()}\` — expected data, states, score, phrases, or an \`on\`/\`after\` clause.`, lineSpan(line));
|
|
1105
|
+
this.pos++;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
const endSpan = this.consumeEnd('trait', headLine);
|
|
1109
|
+
return build((0, span_1.mergeSpans)(lineSpan(headLine), endSpan));
|
|
1110
|
+
}
|
|
1111
|
+
/** `data` block fields: `locked: flag`, `body part: optional name`, `kind: one of a, b`. */
|
|
1112
|
+
parseTraitFields(dataLine) {
|
|
1113
|
+
const fields = [];
|
|
1114
|
+
while (this.pos < this.lines.length && this.lines[this.pos].indent > dataLine.indent) {
|
|
1115
|
+
const line = this.lines[this.pos++];
|
|
1116
|
+
const c = new Cursor(line.tokens, line);
|
|
1117
|
+
const name = [];
|
|
1118
|
+
while (!c.atEnd() && c.peek().kind === 'word')
|
|
1119
|
+
name.push(c.next().text);
|
|
1120
|
+
const colon = c.next();
|
|
1121
|
+
if (name.length === 0 || !colon || colon.kind !== 'colon') {
|
|
1122
|
+
this.diagnostics.error('parse.trait-field', 'Expected `<field>: <type>` in the data block.', lineSpan(line));
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
const optional = !!c.matchWord('optional');
|
|
1126
|
+
let type = null;
|
|
1127
|
+
let oneOf = null;
|
|
1128
|
+
if (c.isWord('one') && c.isWord('of', 1)) {
|
|
1129
|
+
c.next();
|
|
1130
|
+
c.next();
|
|
1131
|
+
type = 'one-of';
|
|
1132
|
+
oneOf = [];
|
|
1133
|
+
while (!c.atEnd()) {
|
|
1134
|
+
const t = c.next();
|
|
1135
|
+
if (t.kind === 'word')
|
|
1136
|
+
oneOf.push(t.text);
|
|
1137
|
+
else if (t.kind !== 'comma')
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
if (oneOf.length === 0) {
|
|
1141
|
+
this.diagnostics.error('parse.trait-field-oneof', 'Expected members after `one of`.', c.restSpan());
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
else {
|
|
1145
|
+
const typeTok = c.next();
|
|
1146
|
+
if (typeTok && typeTok.kind === 'word' && ['entity', 'number', 'name'].includes(typeTok.text)) {
|
|
1147
|
+
type = typeTok.text;
|
|
1148
|
+
}
|
|
1149
|
+
else if (typeTok && typeTok.kind === 'word' && typeTok.text === 'flag') {
|
|
1150
|
+
// Removed — given 8 / ratchet D8 (2026-07-11).
|
|
1151
|
+
this.diagnostics.error('parse.removed-flag-field', 'The `flag` field type was removed (given 8: no booleans at any scope) — declare `states[, reversible]: …` on the trait and name what the thing IS in each state.', typeTok.span);
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
else {
|
|
1155
|
+
this.diagnostics.error('parse.trait-field-type', 'Expected a field type: entity, number, name, or `one of …`.', typeTok?.span ?? c.restSpan());
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
let initial = null;
|
|
1160
|
+
if (c.peek()?.kind === 'comma') {
|
|
1161
|
+
c.next();
|
|
1162
|
+
if (c.matchWord('starts')) {
|
|
1163
|
+
const v = c.next();
|
|
1164
|
+
if (v)
|
|
1165
|
+
initial = v.text;
|
|
1166
|
+
else
|
|
1167
|
+
this.diagnostics.error('parse.trait-field-starts', 'Expected a value after `starts`.', c.restSpan());
|
|
1168
|
+
}
|
|
1169
|
+
else {
|
|
1170
|
+
this.diagnostics.error('parse.trait-field-starts', 'Expected `starts <value>` after the comma.', c.restSpan());
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
// Trailing tokens were previously dropped silently — `shine: number
|
|
1174
|
+
// starts 1` (missing comma) parsed as a plain field with NO initial
|
|
1175
|
+
// (found 2026-07-12 building the zoo chain). Never a guess.
|
|
1176
|
+
if (!c.atEnd()) {
|
|
1177
|
+
this.diagnostics.error('parse.trait-field-trailing', 'Unexpected words after the field type — an initial value is written `, starts <value>` (the comma is required).', c.restSpan());
|
|
1178
|
+
}
|
|
1179
|
+
fields.push({ name, type: type, optional, initial, oneOf, span: lineSpan(line) });
|
|
1180
|
+
}
|
|
1181
|
+
return fields;
|
|
1182
|
+
}
|
|
1183
|
+
/** `define action <name>` — dispatch declaration (dedent-terminated) or `from "<mod>"` hatch. */
|
|
1184
|
+
parseDefineAction() {
|
|
1185
|
+
const headLine = this.lines[this.pos];
|
|
1186
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
1187
|
+
c.next();
|
|
1188
|
+
c.next(); // define action
|
|
1189
|
+
const nameTok = c.next();
|
|
1190
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
1191
|
+
this.diagnostics.error('parse.action-name', 'Expected an action name after `define action`.', c.restSpan());
|
|
1192
|
+
this.pos++;
|
|
1193
|
+
return null;
|
|
1194
|
+
}
|
|
1195
|
+
if (c.isWord('from')) {
|
|
1196
|
+
return this.parseHatchTail(headLine, c, 'action', nameTok.text);
|
|
1197
|
+
}
|
|
1198
|
+
this.pos++;
|
|
1199
|
+
const patterns = [];
|
|
1200
|
+
const constraints = [];
|
|
1201
|
+
const musts = [];
|
|
1202
|
+
const refusals = [];
|
|
1203
|
+
let otherwise = null;
|
|
1204
|
+
const scores = [];
|
|
1205
|
+
let phrases = null;
|
|
1206
|
+
const body = [];
|
|
1207
|
+
let span = lineSpan(headLine);
|
|
1208
|
+
while (this.pos < this.lines.length) {
|
|
1209
|
+
const line = this.lines[this.pos];
|
|
1210
|
+
if (line.indent === 0)
|
|
1211
|
+
break; // dedent-terminated (no `end action`, design.md §2.3/§3.4)
|
|
1212
|
+
const word = firstWord(line);
|
|
1213
|
+
if (word === 'grammar' && line.tokens.length === 1) {
|
|
1214
|
+
this.pos++;
|
|
1215
|
+
patterns.push(...this.parseActionPatterns(line));
|
|
1216
|
+
}
|
|
1217
|
+
else if (lineHasMust(line)) {
|
|
1218
|
+
// With a `: <key>` tail it is a must-requirement (ratchet D6);
|
|
1219
|
+
// without one it is the scope-constraint kit (`must be reachable`).
|
|
1220
|
+
this.pos++;
|
|
1221
|
+
if (line.tokens.some((t) => t.kind === 'colon')) {
|
|
1222
|
+
const m = this.parseMustLine(line);
|
|
1223
|
+
if (m)
|
|
1224
|
+
musts.push(m);
|
|
1225
|
+
}
|
|
1226
|
+
else {
|
|
1227
|
+
const sc = this.parseScopeConstraint(line);
|
|
1228
|
+
if (sc)
|
|
1229
|
+
constraints.push(sc);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
else if (word === 'score') {
|
|
1233
|
+
this.pos++;
|
|
1234
|
+
const s = this.parseScoreLine(line);
|
|
1235
|
+
if (s)
|
|
1236
|
+
scores.push(s);
|
|
1237
|
+
}
|
|
1238
|
+
else if (word === 'refuse' && (line.tokens[1]?.text === 'without' || line.tokens[1]?.text === 'when')) {
|
|
1239
|
+
this.pos++;
|
|
1240
|
+
const r = this.parseActionRefusal(line);
|
|
1241
|
+
if (r)
|
|
1242
|
+
refusals.push(r);
|
|
1243
|
+
}
|
|
1244
|
+
else if (word === 'otherwise') {
|
|
1245
|
+
this.pos++;
|
|
1246
|
+
const oc = new Cursor(line.tokens, line);
|
|
1247
|
+
oc.next();
|
|
1248
|
+
if (!oc.matchWord('refuse')) {
|
|
1249
|
+
this.diagnostics.error('parse.action-otherwise', 'Expected `otherwise refuse <phrase-key>`.', lineSpan(line));
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
const key = oc.next();
|
|
1253
|
+
if (key && key.kind === 'word')
|
|
1254
|
+
otherwise = { phraseKey: key.text, span: lineSpan(line) };
|
|
1255
|
+
else
|
|
1256
|
+
this.diagnostics.error('parse.action-otherwise', 'Expected a phrase key after `otherwise refuse`.', oc.restSpan());
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
else if (word === 'phrases') {
|
|
1260
|
+
this.pos++;
|
|
1261
|
+
phrases = this.parsePhrasesBlock(line, 1);
|
|
1262
|
+
}
|
|
1263
|
+
else {
|
|
1264
|
+
const stmt = this.parseStatement(line, 'action');
|
|
1265
|
+
if (stmt)
|
|
1266
|
+
body.push(stmt);
|
|
1267
|
+
}
|
|
1268
|
+
span = (0, span_1.mergeSpans)(span, lineSpan(line));
|
|
1269
|
+
}
|
|
1270
|
+
return { kind: 'define-action', name: nameTok.text, patterns, constraints, musts, refusals, otherwise, scores, phrases, body, span };
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* `<subject> must <predicate>: <phrase-key>` — the D6 requirement form
|
|
1274
|
+
* (define-action line AND body statement). The predicate is infinitive
|
|
1275
|
+
* (`be hungry`, `have its food`, `hold the camera`) and normalized here.
|
|
1276
|
+
*/
|
|
1277
|
+
parseMustLine(line) {
|
|
1278
|
+
const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
1279
|
+
if (colonIndex === -1 || colonIndex !== line.tokens.length - 2) {
|
|
1280
|
+
this.diagnostics.error('parse.must', 'Expected `<subject> must <predicate>: <phrase-key>`.', lineSpan(line));
|
|
1281
|
+
return null;
|
|
1282
|
+
}
|
|
1283
|
+
const keyTok = line.tokens[colonIndex + 1];
|
|
1284
|
+
if (keyTok.kind !== 'word') {
|
|
1285
|
+
this.diagnostics.error('parse.must', 'Expected a phrase key after the colon in the `must` requirement.', keyTok.span);
|
|
1286
|
+
return null;
|
|
1287
|
+
}
|
|
1288
|
+
const c = new Cursor(line.tokens.slice(0, colonIndex), line);
|
|
1289
|
+
const subject = this.parseValueExpr(c, line, new Set(['must']));
|
|
1290
|
+
if (!c.matchWord('must')) {
|
|
1291
|
+
this.diagnostics.error('parse.must', 'Expected `must` after the subject in the requirement.', c.restSpan());
|
|
1292
|
+
return null;
|
|
1293
|
+
}
|
|
1294
|
+
if (c.isWord('not')) {
|
|
1295
|
+
// Requirements are positive by design (decision 6) — same stance as
|
|
1296
|
+
// the analysis.negated-requirement gate on `refuse when not`.
|
|
1297
|
+
this.diagnostics.error('parse.must-negative', 'State requirements positively — `must not …` is not a form. Use `refuse when <condition>: <key>` for prohibitions.', c.restSpan());
|
|
1298
|
+
return null;
|
|
1299
|
+
}
|
|
1300
|
+
const predicate = this.parseInfinitivePredicate(c, line);
|
|
1301
|
+
if (!predicate)
|
|
1302
|
+
return null;
|
|
1303
|
+
return { kind: 'must', subject, predicate, phraseKey: keyTok.text, span: lineSpan(line) };
|
|
1304
|
+
}
|
|
1305
|
+
/** Infinitive predicate after `must`: be / have / hold / wear / see / reach. */
|
|
1306
|
+
parseInfinitivePredicate(c, line) {
|
|
1307
|
+
const t = c.next();
|
|
1308
|
+
if (!t || t.kind !== 'word') {
|
|
1309
|
+
this.diagnostics.error('parse.must-predicate', 'Expected a predicate after `must`: be, have, hold, wear, see, or reach.', t?.span ?? c.restSpan());
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
switch (t.text) {
|
|
1313
|
+
case 'be': {
|
|
1314
|
+
if (c.isWord('a') || c.isWord('an')) {
|
|
1315
|
+
c.next();
|
|
1316
|
+
const cls = this.collectWords(c, new Set());
|
|
1317
|
+
return { kind: 'is-a', negated: false, classifier: cls.words, span: cls.span ?? t.span };
|
|
1318
|
+
}
|
|
1319
|
+
if (c.isWord('in')) {
|
|
1320
|
+
c.next();
|
|
1321
|
+
const place = this.parseNameRef(c, () => false);
|
|
1322
|
+
return { kind: 'is-in', negated: false, place, span: place.span };
|
|
1323
|
+
}
|
|
1324
|
+
// `be any <name>` — membership over a named open condition (David,
|
|
1325
|
+
// 2026-07-12, each package P3). Same standalone-name rule as the
|
|
1326
|
+
// condition-position quantifiers: a value that merely starts with
|
|
1327
|
+
// `any` keeps its ordinary parse.
|
|
1328
|
+
if (c.isWord('any')) {
|
|
1329
|
+
const nameTok = c.peek(1);
|
|
1330
|
+
if (nameTok && nameTok.kind === 'word' && this.isBareConditionRef(c, 1)) {
|
|
1331
|
+
const anyTok = c.next();
|
|
1332
|
+
c.next();
|
|
1333
|
+
return { kind: 'is-any', condition: nameTok.text, span: (0, span_1.mergeSpans)(anyTok.span, nameTok.span) };
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
// `be no <name>` — a negated requirement in disguise (decision 6):
|
|
1337
|
+
// same stance as `must not`.
|
|
1338
|
+
if (c.isWord('no')) {
|
|
1339
|
+
const nameTok = c.peek(1);
|
|
1340
|
+
if (nameTok && nameTok.kind === 'word' && this.isBareConditionRef(c, 1)) {
|
|
1341
|
+
this.diagnostics.error('parse.must-negative', 'State requirements positively — `must be no <name>` is not a form. Use `refuse when <condition>: <key>` for prohibitions.', c.restSpan());
|
|
1342
|
+
return null;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
const value = this.parseValueExpr(c, line, new Set());
|
|
1346
|
+
return { kind: 'is', negated: false, value, span: value.span };
|
|
1347
|
+
}
|
|
1348
|
+
case 'have': {
|
|
1349
|
+
const thing = this.parseNameRef(c, (tok) => tok.kind === 'word' && PHRASE_STOPS.has(tok.text));
|
|
1350
|
+
return { kind: 'has', thing, span: thing.span };
|
|
1351
|
+
}
|
|
1352
|
+
case 'hold': {
|
|
1353
|
+
const thing = this.parseNameRef(c, (tok) => tok.kind === 'word' && PHRASE_STOPS.has(tok.text));
|
|
1354
|
+
return { kind: 'holds', thing, span: thing.span };
|
|
1355
|
+
}
|
|
1356
|
+
case 'wear': {
|
|
1357
|
+
const thing = this.parseNameRef(c, (tok) => tok.kind === 'word' && PHRASE_STOPS.has(tok.text));
|
|
1358
|
+
return { kind: 'wears', thing, span: thing.span };
|
|
1359
|
+
}
|
|
1360
|
+
case 'see':
|
|
1361
|
+
case 'reach': {
|
|
1362
|
+
const thing = this.parseNameRef(c, (tok) => tok.kind === 'word' && PHRASE_STOPS.has(tok.text));
|
|
1363
|
+
return { kind: 'can', ability: t.text, thing, span: (0, span_1.mergeSpans)(t.span, thing.span) };
|
|
1364
|
+
}
|
|
1365
|
+
default:
|
|
1366
|
+
this.diagnostics.error('parse.must-predicate', `Unknown predicate \`${t.text}\` after \`must\` — expected be, have, hold, wear, see, or reach.`, t.span);
|
|
1367
|
+
return null;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
/** grammar-block pattern lines: words + `:slot`s, optional `→ each …` cardinality. */
|
|
1371
|
+
parseActionPatterns(grammarLine) {
|
|
1372
|
+
const patterns = [];
|
|
1373
|
+
while (this.pos < this.lines.length && this.lines[this.pos].indent > grammarLine.indent) {
|
|
1374
|
+
const line = this.lines[this.pos++];
|
|
1375
|
+
const parts = [];
|
|
1376
|
+
let cardinality = null;
|
|
1377
|
+
const c = new Cursor(line.tokens, line);
|
|
1378
|
+
while (!c.atEnd()) {
|
|
1379
|
+
const t = c.next();
|
|
1380
|
+
if (t.kind === 'punct' && t.text === '→') {
|
|
1381
|
+
cardinality = [];
|
|
1382
|
+
while (!c.atEnd())
|
|
1383
|
+
cardinality.push(c.next().text);
|
|
1384
|
+
break;
|
|
1385
|
+
}
|
|
1386
|
+
if (t.kind === 'colon') {
|
|
1387
|
+
const slot = c.next();
|
|
1388
|
+
if (slot && slot.kind === 'word') {
|
|
1389
|
+
parts.push({ kind: 'slot', word: slot.text, span: (0, span_1.mergeSpans)(t.span, slot.span) });
|
|
1390
|
+
}
|
|
1391
|
+
else {
|
|
1392
|
+
this.diagnostics.error('parse.action-slot', 'Expected a slot name after `:`.', t.span);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
else if (t.kind === 'word') {
|
|
1396
|
+
parts.push({ kind: 'word', word: t.text, span: t.span });
|
|
1397
|
+
}
|
|
1398
|
+
else {
|
|
1399
|
+
this.diagnostics.error('parse.action-pattern', `Unexpected \`${t.text}\` in a grammar pattern.`, t.span);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
if (parts.length === 0) {
|
|
1403
|
+
this.diagnostics.error('parse.action-pattern', 'Empty grammar pattern.', lineSpan(line));
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
patterns.push({ parts, cardinality, span: lineSpan(line) });
|
|
1407
|
+
}
|
|
1408
|
+
return patterns;
|
|
1409
|
+
}
|
|
1410
|
+
/** `the <slot> must be <requirement>` */
|
|
1411
|
+
parseScopeConstraint(line) {
|
|
1412
|
+
const c = new Cursor(line.tokens, line);
|
|
1413
|
+
c.next(); // the
|
|
1414
|
+
const slot = c.next();
|
|
1415
|
+
if (!slot || slot.kind !== 'word') {
|
|
1416
|
+
this.diagnostics.error('parse.action-constraint', 'Expected a slot name after `the`.', c.restSpan());
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
if (!c.matchWord('must') || !c.matchWord('be')) {
|
|
1420
|
+
this.diagnostics.error('parse.action-constraint', 'Expected `must be <requirement>`.', c.restSpan());
|
|
1421
|
+
return null;
|
|
1422
|
+
}
|
|
1423
|
+
const req = c.next();
|
|
1424
|
+
if (!req || req.kind !== 'word') {
|
|
1425
|
+
this.diagnostics.error('parse.action-constraint', 'Expected a requirement word (reachable, visible, …).', c.restSpan());
|
|
1426
|
+
return null;
|
|
1427
|
+
}
|
|
1428
|
+
return { slot: slot.text, requirement: req.text, span: lineSpan(line) };
|
|
1429
|
+
}
|
|
1430
|
+
/** `refuse without <slot>: <key>` / `refuse when <condition>: <key>` */
|
|
1431
|
+
parseActionRefusal(line) {
|
|
1432
|
+
const c = new Cursor(line.tokens, line);
|
|
1433
|
+
c.next(); // refuse
|
|
1434
|
+
const form = c.next().text;
|
|
1435
|
+
if (form === 'without') {
|
|
1436
|
+
const slot = c.next();
|
|
1437
|
+
const colon = c.next();
|
|
1438
|
+
const key = c.next();
|
|
1439
|
+
if (!slot || slot.kind !== 'word' || !colon || colon.kind !== 'colon' || !key || key.kind !== 'word') {
|
|
1440
|
+
this.diagnostics.error('parse.action-refusal', 'Expected `refuse without <slot>: <phrase-key>`.', lineSpan(line));
|
|
1441
|
+
return null;
|
|
1442
|
+
}
|
|
1443
|
+
return { kind: 'without', slot: slot.text, condition: null, phraseKey: key.text, span: lineSpan(line) };
|
|
1444
|
+
}
|
|
1445
|
+
// when: condition runs to the LAST colon; the key follows it.
|
|
1446
|
+
const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
1447
|
+
if (colonIndex === -1 || colonIndex !== line.tokens.length - 2) {
|
|
1448
|
+
this.diagnostics.error('parse.action-refusal', 'Expected `refuse when <condition>: <phrase-key>`.', lineSpan(line));
|
|
1449
|
+
return null;
|
|
1450
|
+
}
|
|
1451
|
+
const condCursor = new Cursor(line.tokens.slice(2, colonIndex), line);
|
|
1452
|
+
const condition = this.parseCondition(condCursor, line);
|
|
1453
|
+
const key = line.tokens[colonIndex + 1];
|
|
1454
|
+
if (!key || key.kind !== 'word') {
|
|
1455
|
+
this.diagnostics.error('parse.action-refusal', 'Expected a phrase key after the colon.', lineSpan(line));
|
|
1456
|
+
return null;
|
|
1457
|
+
}
|
|
1458
|
+
return { kind: 'when', slot: null, condition, phraseKey: key.text, span: lineSpan(line) };
|
|
1459
|
+
}
|
|
1460
|
+
/** `define behavior <name> from "<module>"` — CapabilityBehavior hatch. */
|
|
1461
|
+
parseDefineBehaviorHatch() {
|
|
1462
|
+
const line = this.lines[this.pos];
|
|
1463
|
+
const c = new Cursor(line.tokens, line);
|
|
1464
|
+
c.next();
|
|
1465
|
+
c.next(); // define behavior
|
|
1466
|
+
const nameTok = c.next();
|
|
1467
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
1468
|
+
this.diagnostics.error('parse.behavior-name', 'Expected a behavior name after `define behavior`.', c.restSpan());
|
|
1469
|
+
this.pos++;
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
return this.parseHatchTail(line, c, 'behavior', nameTok.text);
|
|
1473
|
+
}
|
|
1474
|
+
/** Shared `from "<module>"` tail for action/behavior hatches. */
|
|
1475
|
+
parseHatchTail(line, c, hatchKind, name) {
|
|
1476
|
+
this.pos++;
|
|
1477
|
+
if (!c.matchWord('from')) {
|
|
1478
|
+
this.diagnostics.error('parse.hatch-from', 'Expected `from "<module>"` in the hatch declaration.', c.restSpan());
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
const mod = c.next();
|
|
1482
|
+
if (!mod || mod.kind !== 'string') {
|
|
1483
|
+
this.diagnostics.error('parse.hatch-module', 'Expected a quoted module path after `from`.', c.restSpan());
|
|
1484
|
+
return null;
|
|
1485
|
+
}
|
|
1486
|
+
return { kind: 'define-hatch', hatchKind, name, modulePath: mod.text, span: lineSpan(line) };
|
|
1487
|
+
}
|
|
1488
|
+
/** `define sequence <name>` … steps … `end sequence` — timeline (§2.5/§3.3). */
|
|
1489
|
+
parseDefineSequence() {
|
|
1490
|
+
const headLine = this.lines[this.pos++];
|
|
1491
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
1492
|
+
c.next();
|
|
1493
|
+
c.next(); // define sequence
|
|
1494
|
+
const name = [];
|
|
1495
|
+
while (!c.atEnd() && c.peek().kind === 'word')
|
|
1496
|
+
name.push(c.next().text);
|
|
1497
|
+
if (name.length === 0) {
|
|
1498
|
+
this.diagnostics.error('parse.sequence-name', 'Expected a sequence name after `define sequence`.', c.restSpan());
|
|
1499
|
+
}
|
|
1500
|
+
const steps = [];
|
|
1501
|
+
while (this.pos < this.lines.length) {
|
|
1502
|
+
const line = this.lines[this.pos];
|
|
1503
|
+
if (line.indent === 0)
|
|
1504
|
+
break;
|
|
1505
|
+
const sc = new Cursor(line.tokens, line);
|
|
1506
|
+
let timing = null;
|
|
1507
|
+
let turns = 0;
|
|
1508
|
+
let owner = null;
|
|
1509
|
+
let state = null;
|
|
1510
|
+
if (sc.isWord('at') && sc.isWord('turn', 1)) {
|
|
1511
|
+
sc.next();
|
|
1512
|
+
sc.next();
|
|
1513
|
+
const n = sc.next();
|
|
1514
|
+
if (n && n.kind === 'number') {
|
|
1515
|
+
timing = 'at-turn';
|
|
1516
|
+
turns = Number(n.text);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
else if (line.tokens[0]?.kind === 'number' && line.tokens[1]?.text === 'turns' && line.tokens[2]?.text === 'later') {
|
|
1520
|
+
timing = 'later';
|
|
1521
|
+
turns = Number(line.tokens[0].text);
|
|
1522
|
+
}
|
|
1523
|
+
else if (sc.isWord('when')) {
|
|
1524
|
+
// `when <owner> becomes <state>` — state anchor (ratchet D10).
|
|
1525
|
+
sc.next();
|
|
1526
|
+
owner = this.parseNameRef(sc, (t) => t.kind === 'word' && t.text === 'becomes');
|
|
1527
|
+
if (sc.matchWord('becomes')) {
|
|
1528
|
+
const st = sc.next();
|
|
1529
|
+
if (st && st.kind === 'word') {
|
|
1530
|
+
timing = 'becomes';
|
|
1531
|
+
state = st.text;
|
|
1532
|
+
}
|
|
1533
|
+
else {
|
|
1534
|
+
this.diagnostics.error('parse.sequence-anchor', 'Expected a state name after `becomes`.', sc.restSpan());
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
else {
|
|
1538
|
+
this.diagnostics.error('parse.sequence-anchor', 'Expected `when <owner> becomes <state>` in the step anchor.', sc.restSpan());
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
if (!timing) {
|
|
1542
|
+
this.diagnostics.error('parse.sequence-step', 'Expected `at turn <n>`, `<n> turns later`, or `when <owner> becomes <state>` to open a sequence step.', lineSpan(line));
|
|
1543
|
+
this.pos++;
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
this.pos++;
|
|
1547
|
+
const body = this.parseStatements(line.indent, 'sequence');
|
|
1548
|
+
steps.push({ kind: 'sequence-step', timing, turns, owner, state, body, span: lineSpan(line) });
|
|
1549
|
+
}
|
|
1550
|
+
const endSpan = this.consumeEnd('sequence', headLine);
|
|
1551
|
+
return { kind: 'define-sequence', name, steps, span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
1552
|
+
}
|
|
1553
|
+
// ------------------------------------------------------------- on clause
|
|
1554
|
+
/**
|
|
1555
|
+
* `on <verb> it` (intercept) / `after <verb> it` (react, ratchet D3) /
|
|
1556
|
+
* `on every turn` — with `while <cond>` on any binding and the `, once`
|
|
1557
|
+
* clause modifier (D5). Terminated by `end on` / `end after`.
|
|
1558
|
+
*/
|
|
1559
|
+
parseOnClause(indent, clauseKind) {
|
|
1560
|
+
const headLine = this.lines[this.pos++];
|
|
1561
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
1562
|
+
c.matchWord(clauseKind);
|
|
1563
|
+
const action = c.next();
|
|
1564
|
+
let actionWord = '';
|
|
1565
|
+
if (action && action.kind === 'word') {
|
|
1566
|
+
actionWord = action.text;
|
|
1567
|
+
}
|
|
1568
|
+
else {
|
|
1569
|
+
this.diagnostics.error('parse.on-action', `Expected an action word after \`${clauseKind}\`.`, lineSpan(headLine));
|
|
1570
|
+
}
|
|
1571
|
+
let binding = 'it';
|
|
1572
|
+
let role = null;
|
|
1573
|
+
let condition = null;
|
|
1574
|
+
let once = false;
|
|
1575
|
+
let ordering = null;
|
|
1576
|
+
if (actionWord === 'every' && c.isWord('turn')) {
|
|
1577
|
+
// `on every turn [while <condition>] [, once]` (§3.3 + D5).
|
|
1578
|
+
c.next();
|
|
1579
|
+
binding = 'every-turn';
|
|
1580
|
+
actionWord = 'every-turn';
|
|
1581
|
+
if (clauseKind === 'after') {
|
|
1582
|
+
this.diagnostics.error('parse.after-every-turn', '`after every turn` is not a form — every-turn clauses are `on every turn` (they are not reactions to an action).', lineSpan(headLine));
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
else if (c.matchWord('anything')) {
|
|
1586
|
+
// `on <action> anything as the <role>` (design.md §2.2 role binding).
|
|
1587
|
+
binding = 'role';
|
|
1588
|
+
if (!c.matchWord('as') || !c.matchWord('the')) {
|
|
1589
|
+
this.diagnostics.error('parse.on-role', 'Expected `as the <role>` after `anything`.', c.restSpan());
|
|
1590
|
+
}
|
|
1591
|
+
const roleTok = c.next();
|
|
1592
|
+
if (roleTok && roleTok.kind === 'word') {
|
|
1593
|
+
role = roleTok.text;
|
|
1594
|
+
}
|
|
1595
|
+
else {
|
|
1596
|
+
this.diagnostics.error('parse.on-role', 'Expected a role name after `as the`.', c.restSpan());
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
else {
|
|
1600
|
+
if (!c.matchWord('it')) {
|
|
1601
|
+
this.diagnostics.error('parse.on-target', `Expected \`it\`, \`anything as the <role>\`, or \`every turn\` in the \`${clauseKind}\` header.`, c.restSpan());
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
// `while <condition>` — legal on every binding (ownership package).
|
|
1605
|
+
if (c.matchWord('while')) {
|
|
1606
|
+
condition = this.parseCondition(c, headLine);
|
|
1607
|
+
}
|
|
1608
|
+
// Comma modifiers: `, once` (D5) and `, before/after <trait>` ordering (§2.2).
|
|
1609
|
+
while (c.peek()?.kind === 'comma') {
|
|
1610
|
+
c.next();
|
|
1611
|
+
const mod = c.next();
|
|
1612
|
+
if (mod && mod.kind === 'word' && mod.text === 'once') {
|
|
1613
|
+
once = true;
|
|
1614
|
+
}
|
|
1615
|
+
else if (mod && (mod.text === 'before' || mod.text === 'after')) {
|
|
1616
|
+
const traitTok = c.next();
|
|
1617
|
+
if (traitTok && traitTok.kind === 'word') {
|
|
1618
|
+
ordering = { relation: mod.text, trait: traitTok.text };
|
|
1619
|
+
}
|
|
1620
|
+
else {
|
|
1621
|
+
this.diagnostics.error('parse.on-ordering', `Expected a trait name after \`${mod.text}\`.`, c.restSpan());
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
else {
|
|
1625
|
+
this.diagnostics.error('parse.on-modifier', 'Expected `once`, `before <trait>`, or `after <trait>` after the comma.', mod?.span ?? c.restSpan());
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
const body = this.parseStatements(headLine.indent, clauseKind);
|
|
1629
|
+
const endSpan = this.consumeEnd(clauseKind, headLine);
|
|
1630
|
+
return {
|
|
1631
|
+
kind: 'on-clause',
|
|
1632
|
+
clauseKind,
|
|
1633
|
+
action: actionWord,
|
|
1634
|
+
binding,
|
|
1635
|
+
role,
|
|
1636
|
+
condition,
|
|
1637
|
+
once,
|
|
1638
|
+
ordering,
|
|
1639
|
+
body,
|
|
1640
|
+
span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan),
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
// ------------------------------------------------------------ statements
|
|
1644
|
+
/**
|
|
1645
|
+
* Parse statement lines until an `end`/`else`/`or`/`when`-arm boundary at
|
|
1646
|
+
* or below `openIndent` is reached. The terminator line is NOT consumed.
|
|
1647
|
+
*/
|
|
1648
|
+
parseStatements(openIndent, blockKeyword) {
|
|
1649
|
+
const body = [];
|
|
1650
|
+
while (this.pos < this.lines.length) {
|
|
1651
|
+
const line = this.lines[this.pos];
|
|
1652
|
+
if (line.indent <= openIndent)
|
|
1653
|
+
break;
|
|
1654
|
+
if (isEndLine(line) || ((firstWord(line) === 'else' || firstWord(line) === 'or') && line.tokens.length === 1))
|
|
1655
|
+
break;
|
|
1656
|
+
const stmt = this.parseStatement(line, blockKeyword);
|
|
1657
|
+
if (stmt)
|
|
1658
|
+
body.push(stmt);
|
|
1659
|
+
}
|
|
1660
|
+
return body;
|
|
1661
|
+
}
|
|
1662
|
+
parseStatement(line, blockKeyword) {
|
|
1663
|
+
const word = firstWord(line);
|
|
1664
|
+
const c = new Cursor(line.tokens, line);
|
|
1665
|
+
if (word && ORDINALS[word] !== undefined && c.isWord('time', 1)) {
|
|
1666
|
+
return this.parseOrdinalBlock(line);
|
|
1667
|
+
}
|
|
1668
|
+
// `<subject> must <predicate>: <key>` body statement (ratchet D6).
|
|
1669
|
+
if (lineHasMust(line)) {
|
|
1670
|
+
this.pos++;
|
|
1671
|
+
if (blockKeyword === 'after') {
|
|
1672
|
+
this.reportRefusalInAfter(line);
|
|
1673
|
+
return null;
|
|
1674
|
+
}
|
|
1675
|
+
return this.parseMustLine(line);
|
|
1676
|
+
}
|
|
1677
|
+
switch (word) {
|
|
1678
|
+
case 'refuse':
|
|
1679
|
+
case 'phrase': {
|
|
1680
|
+
this.pos++;
|
|
1681
|
+
c.next();
|
|
1682
|
+
// `refuse when <condition>: <key>` — the prohibition form (D6) in
|
|
1683
|
+
// body position; same shape as the define-action line.
|
|
1684
|
+
if (word === 'refuse' && c.isWord('when')) {
|
|
1685
|
+
if (blockKeyword === 'after') {
|
|
1686
|
+
this.reportRefusalInAfter(line);
|
|
1687
|
+
return null;
|
|
1688
|
+
}
|
|
1689
|
+
return this.parseRefuseWhenStatement(line);
|
|
1690
|
+
}
|
|
1691
|
+
const key = this.readDottedKey(c);
|
|
1692
|
+
if (!key) {
|
|
1693
|
+
this.diagnostics.error('parse.phrase-ref', `Expected a phrase key after \`${word}\`.`, c.restSpan());
|
|
1694
|
+
return null;
|
|
1695
|
+
}
|
|
1696
|
+
const params = this.parseParams(c, line);
|
|
1697
|
+
if (word === 'refuse') {
|
|
1698
|
+
if (blockKeyword === 'after') {
|
|
1699
|
+
this.reportRefusalInAfter(line);
|
|
1700
|
+
return null;
|
|
1701
|
+
}
|
|
1702
|
+
return { kind: 'refuse', phraseKey: key, params, span: lineSpan(line) };
|
|
1703
|
+
}
|
|
1704
|
+
const stmtWhen = this.parseStatementWhen(c, line);
|
|
1705
|
+
// Declare-and-emit sugar (§2.6/§3.3): a deeper-indented bare prose
|
|
1706
|
+
// block after `phrase <key>` registers the text under the key.
|
|
1707
|
+
let inlineText = null;
|
|
1708
|
+
const next = this.lines[this.pos];
|
|
1709
|
+
if (next && next.indent > line.indent && !isStatementLine(next)) {
|
|
1710
|
+
inlineText = this.parseProseParagraph(line.indent + 1, line.indent);
|
|
1711
|
+
}
|
|
1712
|
+
const span = inlineText ? (0, span_1.mergeSpans)(lineSpan(line), inlineText.span) : lineSpan(line);
|
|
1713
|
+
return { kind: 'phrase', phraseKey: key, params, inlineText, stmtWhen, span };
|
|
1714
|
+
}
|
|
1715
|
+
case 'emit': {
|
|
1716
|
+
this.pos++;
|
|
1717
|
+
c.next();
|
|
1718
|
+
const event = [];
|
|
1719
|
+
while (!c.atEnd() && !c.isWord('when'))
|
|
1720
|
+
event.push(c.next().text);
|
|
1721
|
+
if (event.length === 0) {
|
|
1722
|
+
this.diagnostics.error('parse.emit', 'Expected an event name after `emit`.', lineSpan(line));
|
|
1723
|
+
return null;
|
|
1724
|
+
}
|
|
1725
|
+
const stmtWhen = this.parseStatementWhen(c, line);
|
|
1726
|
+
return { kind: 'emit', event, stmtWhen, span: lineSpan(line) };
|
|
1727
|
+
}
|
|
1728
|
+
case 'set': {
|
|
1729
|
+
this.pos++;
|
|
1730
|
+
c.next();
|
|
1731
|
+
const target = this.parseValueExpr(c, line, new Set(['to']));
|
|
1732
|
+
if (!c.matchWord('to')) {
|
|
1733
|
+
this.diagnostics.error('parse.set-to', 'Expected `to <value>` in the `set` statement.', c.restSpan());
|
|
1734
|
+
return null;
|
|
1735
|
+
}
|
|
1736
|
+
const value = this.parseValueExpr(c, line, new Set());
|
|
1737
|
+
return { kind: 'set', target, value, span: lineSpan(line) };
|
|
1738
|
+
}
|
|
1739
|
+
case 'change': {
|
|
1740
|
+
this.pos++;
|
|
1741
|
+
c.next();
|
|
1742
|
+
const entity = this.parseNameRef(c, (t) => t.kind === 'word' && t.text === 'to');
|
|
1743
|
+
if (!c.matchWord('to')) {
|
|
1744
|
+
this.diagnostics.error('parse.change-to', 'Expected `to <state>` in the `change` statement.', c.restSpan());
|
|
1745
|
+
return null;
|
|
1746
|
+
}
|
|
1747
|
+
const state = c.next();
|
|
1748
|
+
if (!state || state.kind !== 'word') {
|
|
1749
|
+
this.diagnostics.error('parse.change-state', 'Expected a state name after `to`.', c.restSpan());
|
|
1750
|
+
return null;
|
|
1751
|
+
}
|
|
1752
|
+
const stmtWhen = this.parseStatementWhen(c, line);
|
|
1753
|
+
return { kind: 'change', entity, state: state.text, stmtWhen, span: lineSpan(line) };
|
|
1754
|
+
}
|
|
1755
|
+
case 'move': {
|
|
1756
|
+
this.pos++;
|
|
1757
|
+
c.next();
|
|
1758
|
+
const entity = this.parseNameRef(c, (t) => t.kind === 'word' && t.text === 'to');
|
|
1759
|
+
if (!c.matchWord('to')) {
|
|
1760
|
+
this.diagnostics.error('parse.move-to', 'Expected `to <place>` in the `move` statement.', c.restSpan());
|
|
1761
|
+
return null;
|
|
1762
|
+
}
|
|
1763
|
+
const place = this.parseNameRef(c, (t) => t.kind === 'word' && t.text === 'when');
|
|
1764
|
+
const stmtWhen = this.parseStatementWhen(c, line);
|
|
1765
|
+
return { kind: 'move', entity, place, stmtWhen, span: lineSpan(line) };
|
|
1766
|
+
}
|
|
1767
|
+
case 'remove': {
|
|
1768
|
+
// Z6 (ADR-213 Q3): `remove <entity> [when <cond>]` — out of play
|
|
1769
|
+
// entirely, permanently. No `to` clause (orphaning is not a form).
|
|
1770
|
+
this.pos++;
|
|
1771
|
+
c.next();
|
|
1772
|
+
const entity = this.parseNameRef(c, (t) => t.kind === 'word' && t.text === 'when');
|
|
1773
|
+
if (entity.words.length === 0) {
|
|
1774
|
+
this.diagnostics.error('parse.remove-entity', 'Expected an entity after `remove`.', c.restSpan());
|
|
1775
|
+
return null;
|
|
1776
|
+
}
|
|
1777
|
+
const stmtWhen = this.parseStatementWhen(c, line);
|
|
1778
|
+
return { kind: 'remove', entity, stmtWhen, span: lineSpan(line) };
|
|
1779
|
+
}
|
|
1780
|
+
case 'award': {
|
|
1781
|
+
this.pos++;
|
|
1782
|
+
c.next();
|
|
1783
|
+
const expression = [];
|
|
1784
|
+
let once = false;
|
|
1785
|
+
while (!c.atEnd() && !c.isWord('when')) {
|
|
1786
|
+
const t = c.next();
|
|
1787
|
+
if (t.kind === 'comma' && c.isWord('once')) {
|
|
1788
|
+
c.next();
|
|
1789
|
+
once = true;
|
|
1790
|
+
break;
|
|
1791
|
+
}
|
|
1792
|
+
expression.push(t.text);
|
|
1793
|
+
}
|
|
1794
|
+
const stmtWhen = this.parseStatementWhen(c, line);
|
|
1795
|
+
return { kind: 'award', expression, once, stmtWhen, span: lineSpan(line) };
|
|
1796
|
+
}
|
|
1797
|
+
case 'win':
|
|
1798
|
+
case 'lose': {
|
|
1799
|
+
this.pos++;
|
|
1800
|
+
c.next();
|
|
1801
|
+
const key = c.peek();
|
|
1802
|
+
let phraseKey = null;
|
|
1803
|
+
if (key && key.kind === 'word' && key.text !== 'when') {
|
|
1804
|
+
phraseKey = key.text;
|
|
1805
|
+
c.next();
|
|
1806
|
+
}
|
|
1807
|
+
const stmtWhen = this.parseStatementWhen(c, line);
|
|
1808
|
+
return { kind: word, phraseKey, stmtWhen, span: lineSpan(line) };
|
|
1809
|
+
}
|
|
1810
|
+
case 'if':
|
|
1811
|
+
// Removed — given 4 amended (ratchet 2026-07-11).
|
|
1812
|
+
this.diagnostics.error('parse.removed-if', '`if` was removed (given 4 amended) — use a `must` requirement for guards (`it must be hungry: already-fed`), a statement `when` suffix for conditionals (`award X when <condition>`), or `select` for branching.', lineSpan(line));
|
|
1813
|
+
this.pos++;
|
|
1814
|
+
this.recoverPastEndNested('if', line.indent);
|
|
1815
|
+
return null;
|
|
1816
|
+
case 'select':
|
|
1817
|
+
return this.parseSelect(line);
|
|
1818
|
+
case 'each':
|
|
1819
|
+
return this.parseEachBlock(line, blockKeyword);
|
|
1820
|
+
default:
|
|
1821
|
+
this.diagnostics.error('parse.unknown-statement', `Unknown statement \`${word ?? line.raw.trim()}\` in \`${blockKeyword}\` block.`, lineSpan(line));
|
|
1822
|
+
this.pos++;
|
|
1823
|
+
return null;
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
/** The statement `when <condition>` suffix (ratchet D7); null when absent. */
|
|
1827
|
+
parseStatementWhen(c, line) {
|
|
1828
|
+
if (!c.matchWord('when'))
|
|
1829
|
+
return null;
|
|
1830
|
+
return this.parseCondition(c, line);
|
|
1831
|
+
}
|
|
1832
|
+
/** `refuse when <condition>: <key>` as a body statement (prohibition, D6). */
|
|
1833
|
+
parseRefuseWhenStatement(line) {
|
|
1834
|
+
const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
|
|
1835
|
+
if (colonIndex === -1 || colonIndex !== line.tokens.length - 2) {
|
|
1836
|
+
this.diagnostics.error('parse.refuse-when', 'Expected `refuse when <condition>: <phrase-key>`.', lineSpan(line));
|
|
1837
|
+
return null;
|
|
1838
|
+
}
|
|
1839
|
+
const keyTok = line.tokens[colonIndex + 1];
|
|
1840
|
+
if (keyTok.kind !== 'word') {
|
|
1841
|
+
this.diagnostics.error('parse.refuse-when', 'Expected a phrase key after the colon.', keyTok.span);
|
|
1842
|
+
return null;
|
|
1843
|
+
}
|
|
1844
|
+
const condCursor = new Cursor(line.tokens.slice(2, colonIndex), line);
|
|
1845
|
+
const condition = this.parseCondition(condCursor, line);
|
|
1846
|
+
return { kind: 'refuse-when', condition, phraseKey: keyTok.text, span: lineSpan(line) };
|
|
1847
|
+
}
|
|
1848
|
+
/** `refuse`/`must` inside an `after` clause — reactions cannot refuse (D3). */
|
|
1849
|
+
reportRefusalInAfter(line) {
|
|
1850
|
+
this.diagnostics.error('parse.react-refusal', 'Refusals (`refuse`, `must`) cannot appear in an `after` clause — reactions run after the action succeeded. Use an `on` clause to intercept.', lineSpan(line));
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* Read a phrase key: a word, optionally continued by `.`-joined words
|
|
1854
|
+
* (`zoo.pa.closing-3`, design.md §3.3). Returns null when no word follows.
|
|
1855
|
+
*/
|
|
1856
|
+
readDottedKey(c) {
|
|
1857
|
+
const first = c.peek();
|
|
1858
|
+
if (!first || first.kind !== 'word')
|
|
1859
|
+
return null;
|
|
1860
|
+
c.next();
|
|
1861
|
+
let key = first.text;
|
|
1862
|
+
while (c.peek()?.kind === 'punct' && c.peek().text === '.' && c.peek(1)?.kind === 'word') {
|
|
1863
|
+
c.next();
|
|
1864
|
+
key += '.' + c.next().text;
|
|
1865
|
+
}
|
|
1866
|
+
return key;
|
|
1867
|
+
}
|
|
1868
|
+
parseParams(c, line) {
|
|
1869
|
+
const params = [];
|
|
1870
|
+
while (c.matchWord('with')) {
|
|
1871
|
+
const start = c.peek();
|
|
1872
|
+
const param = [];
|
|
1873
|
+
while (!c.atEnd() && !(c.peek().kind === 'punct' && c.peek().text === '=')) {
|
|
1874
|
+
param.push(c.next().text);
|
|
1875
|
+
}
|
|
1876
|
+
const eq = c.next();
|
|
1877
|
+
if (!eq) {
|
|
1878
|
+
this.diagnostics.error('parse.param-eq', 'Expected `= <value>` in the `with` binding.', c.restSpan());
|
|
1879
|
+
break;
|
|
1880
|
+
}
|
|
1881
|
+
const value = this.parseValueExpr(c, line, new Set(['with']));
|
|
1882
|
+
params.push({ param, value, span: start ? (0, span_1.mergeSpans)(start.span, value.span) : value.span });
|
|
1883
|
+
}
|
|
1884
|
+
return params;
|
|
1885
|
+
}
|
|
1886
|
+
parseOrdinalBlock(headLine) {
|
|
1887
|
+
this.pos++;
|
|
1888
|
+
const word = firstWord(headLine);
|
|
1889
|
+
const body = [];
|
|
1890
|
+
let span = lineSpan(headLine);
|
|
1891
|
+
while (this.pos < this.lines.length) {
|
|
1892
|
+
const line = this.lines[this.pos];
|
|
1893
|
+
if (line.indent <= headLine.indent)
|
|
1894
|
+
break;
|
|
1895
|
+
if (isEndLine(line))
|
|
1896
|
+
break;
|
|
1897
|
+
const stmt = this.parseStatement(line, 'ordinal');
|
|
1898
|
+
if (stmt) {
|
|
1899
|
+
body.push(stmt);
|
|
1900
|
+
span = (0, span_1.mergeSpans)(span, stmt.span);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
return { kind: 'ordinal', ordinal: ORDINALS[word], ordinalWord: word, body, span };
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* `each <condition-name> … end each` — body-position iteration block
|
|
1907
|
+
* (ratchet E3, 2026-07-12). The body takes the host's statement kit —
|
|
1908
|
+
* `blockKeyword` propagates so refusal legality follows the host clause
|
|
1909
|
+
* (legal in `on`, error in `after`, exactly as outside the block).
|
|
1910
|
+
* The open-condition requirement is the analyzer's gate, not the parser's.
|
|
1911
|
+
*/
|
|
1912
|
+
parseEachBlock(headLine, blockKeyword) {
|
|
1913
|
+
this.pos++;
|
|
1914
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
1915
|
+
c.matchWord('each');
|
|
1916
|
+
const nameTok = c.next();
|
|
1917
|
+
if (!nameTok || nameTok.kind !== 'word') {
|
|
1918
|
+
this.diagnostics.error('parse.each-condition', 'Expected a condition name after `each`.', c.restSpan());
|
|
1919
|
+
this.recoverPastEndNested('each', headLine.indent);
|
|
1920
|
+
return null;
|
|
1921
|
+
}
|
|
1922
|
+
if (!c.atEnd()) {
|
|
1923
|
+
this.diagnostics.error('parse.each-trailing', `Unexpected \`${c.peek().text}\` after the condition name — an \`each\` header is \`each <condition-name>\`.`, c.restSpan());
|
|
1924
|
+
}
|
|
1925
|
+
const body = this.parseStatements(headLine.indent, blockKeyword);
|
|
1926
|
+
const endSpan = this.consumeEnd('each', headLine);
|
|
1927
|
+
return { kind: 'each', condition: nameTok.text, body, span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
1928
|
+
}
|
|
1929
|
+
parseSelect(headLine) {
|
|
1930
|
+
this.pos++;
|
|
1931
|
+
const c = new Cursor(headLine.tokens, headLine);
|
|
1932
|
+
c.matchWord('select');
|
|
1933
|
+
if (c.matchWord('on')) {
|
|
1934
|
+
const subject = this.parseValueExpr(c, headLine, new Set());
|
|
1935
|
+
const arms = [];
|
|
1936
|
+
while (this.pos < this.lines.length) {
|
|
1937
|
+
const line = this.lines[this.pos];
|
|
1938
|
+
if (line.indent <= headLine.indent)
|
|
1939
|
+
break;
|
|
1940
|
+
if (firstWord(line) === 'when') {
|
|
1941
|
+
this.pos++;
|
|
1942
|
+
const armCursor = new Cursor(line.tokens, line);
|
|
1943
|
+
armCursor.next();
|
|
1944
|
+
const valueTok = armCursor.next();
|
|
1945
|
+
if (!valueTok || valueTok.kind !== 'word') {
|
|
1946
|
+
this.diagnostics.error('parse.select-arm', 'Expected a value after `when` in the select arm.', lineSpan(line));
|
|
1947
|
+
continue;
|
|
1948
|
+
}
|
|
1949
|
+
const body = this.parseStatements(line.indent, 'select');
|
|
1950
|
+
arms.push({ value: valueTok.text, body, span: lineSpan(line) });
|
|
1951
|
+
}
|
|
1952
|
+
else {
|
|
1953
|
+
this.diagnostics.error('parse.select-body', `Expected \`when <value>\` arm in \`select on\`, got \`${line.raw.trim()}\`.`, lineSpan(line));
|
|
1954
|
+
this.pos++;
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
const endSpan = this.consumeEnd('select', headLine);
|
|
1958
|
+
return { kind: 'select-on', subject, arms, span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
1959
|
+
}
|
|
1960
|
+
const strategyTok = c.next();
|
|
1961
|
+
if (!strategyTok || strategyTok.kind !== 'word' || !STRATEGIES.has(strategyTok.text)) {
|
|
1962
|
+
// Z5 applies everywhere strategies are legal (ratchet 2026-07-12) —
|
|
1963
|
+
// a retired adverb here gets the same fix-it as at `define phrase`.
|
|
1964
|
+
if (strategyTok && strategyTok.kind === 'word' && RETIRED_STRATEGIES[strategyTok.text]) {
|
|
1965
|
+
this.diagnostics.error('parse.select-strategy-retired', `\`${strategyTok.text}\` is no longer a strategy adverb — use \`${RETIRED_STRATEGIES[strategyTok.text]}\` (ADR-211 Decision 4).`, strategyTok.span);
|
|
1966
|
+
}
|
|
1967
|
+
else {
|
|
1968
|
+
this.diagnostics.error('parse.select-strategy', 'Expected `on <value>` or a strategy (randomly, cycling, stopping, sticky, first-time) after `select`.', strategyTok?.span ?? lineSpan(headLine));
|
|
1969
|
+
}
|
|
1970
|
+
this.recoverPastEndNested('select', headLine.indent);
|
|
1971
|
+
return null;
|
|
1972
|
+
}
|
|
1973
|
+
const alternatives = [];
|
|
1974
|
+
for (;;) {
|
|
1975
|
+
alternatives.push(this.parseStatements(headLine.indent, 'select'));
|
|
1976
|
+
const line = this.lines[this.pos];
|
|
1977
|
+
if (line && firstWord(line) === 'or' && line.tokens.length === 1 && line.indent === headLine.indent) {
|
|
1978
|
+
this.pos++;
|
|
1979
|
+
continue;
|
|
1980
|
+
}
|
|
1981
|
+
break;
|
|
1982
|
+
}
|
|
1983
|
+
const endSpan = this.consumeEnd('select', headLine);
|
|
1984
|
+
return { kind: 'select-strategy', strategy: strategyTok.text, alternatives, span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan) };
|
|
1985
|
+
}
|
|
1986
|
+
/** Consume the expected `end <keyword>` line; diagnose a mismatch or absence. */
|
|
1987
|
+
consumeEnd(keyword, openLine) {
|
|
1988
|
+
const line = this.lines[this.pos];
|
|
1989
|
+
if (line && isEndLine(line)) {
|
|
1990
|
+
this.pos++;
|
|
1991
|
+
const kw = line.tokens[1];
|
|
1992
|
+
if (!kw || kw.kind !== 'word' || kw.text !== keyword) {
|
|
1993
|
+
this.diagnostics.error('parse.end-mismatch', `Expected \`end ${keyword}\` to close the block opened at line ${openLine.lineNo}, got \`${line.raw.trim()}\`.`, lineSpan(line));
|
|
1994
|
+
}
|
|
1995
|
+
return lineSpan(line);
|
|
1996
|
+
}
|
|
1997
|
+
this.diagnostics.error('parse.unterminated-block', `Missing \`end ${keyword}\` for the block opened at line ${openLine.lineNo}.`, line ? lineSpan(line) : lineSpan(openLine));
|
|
1998
|
+
return line ? lineSpan(line) : lineSpan(openLine);
|
|
1999
|
+
}
|
|
2000
|
+
/** Error recovery inside statement blocks: skip past a nested `end` at the open indent. */
|
|
2001
|
+
recoverPastEndNested(keyword, openIndent) {
|
|
2002
|
+
while (this.pos < this.lines.length) {
|
|
2003
|
+
const line = this.lines[this.pos];
|
|
2004
|
+
if (line.indent <= openIndent && isEndLine(line)) {
|
|
2005
|
+
this.pos++;
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
if (line.indent === 0 && TOP_KEYWORDS.has(firstWord(line) ?? ''))
|
|
2009
|
+
return;
|
|
2010
|
+
this.pos++;
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
// ----------------------------------------------------------- expressions
|
|
2014
|
+
/** Parse a name reference: optional article + words until a stop. */
|
|
2015
|
+
parseNameRef(c, stop) {
|
|
2016
|
+
let article = null;
|
|
2017
|
+
const first = c.peek();
|
|
2018
|
+
let span = first ? first.span : null;
|
|
2019
|
+
if (first && first.kind === 'word' && ARTICLES.has(first.text)) {
|
|
2020
|
+
article = first.text;
|
|
2021
|
+
c.next();
|
|
2022
|
+
}
|
|
2023
|
+
const words = [];
|
|
2024
|
+
while (!c.atEnd()) {
|
|
2025
|
+
const t = c.peek();
|
|
2026
|
+
if (t.kind !== 'word' && t.kind !== 'number')
|
|
2027
|
+
break;
|
|
2028
|
+
if (stop(t))
|
|
2029
|
+
break;
|
|
2030
|
+
words.push(t.text);
|
|
2031
|
+
span = span ? (0, span_1.mergeSpans)(span, t.span) : t.span;
|
|
2032
|
+
c.next();
|
|
2033
|
+
}
|
|
2034
|
+
return { kind: 'name', article, words, span: span ?? (0, span_1.spanOf)(c.line.lineNo, 1) };
|
|
2035
|
+
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Parse a value expression: literal, name reference, or possessive chain
|
|
2038
|
+
* (`the player's location`, `its state`). Stops at PHRASE_STOPS or any
|
|
2039
|
+
* word in `extraStops`.
|
|
2040
|
+
*/
|
|
2041
|
+
parseValueExpr(c, line, extraStops) {
|
|
2042
|
+
const first = c.peek();
|
|
2043
|
+
if (!first) {
|
|
2044
|
+
this.diagnostics.error('parse.value', 'Expected a value.', lineSpan(line));
|
|
2045
|
+
return { kind: 'bare', words: [], span: lineSpan(line) };
|
|
2046
|
+
}
|
|
2047
|
+
if (first.kind === 'number' || first.kind === 'string') {
|
|
2048
|
+
c.next();
|
|
2049
|
+
return { kind: 'literal', value: first.text, literalKind: first.kind, span: first.span };
|
|
2050
|
+
}
|
|
2051
|
+
// `the match` — the `each`-block binder (ratchet E3, 2026-07-12).
|
|
2052
|
+
// Only the exact form: a following name word keeps the ordinary
|
|
2053
|
+
// reference parse (`the match box` stays an entity name). Position
|
|
2054
|
+
// validity (inside an `each` body) is the analyzer's gate.
|
|
2055
|
+
if (first.kind === 'word' && first.text === 'the') {
|
|
2056
|
+
const m = c.peek(1);
|
|
2057
|
+
const after = c.peek(2);
|
|
2058
|
+
const matchStandsAlone = !after || after.kind !== 'word' || PHRASE_STOPS.has(after.text) || extraStops.has(after.text);
|
|
2059
|
+
if (m && m.kind === 'word' && m.text === 'match' && matchStandsAlone) {
|
|
2060
|
+
c.next();
|
|
2061
|
+
c.next();
|
|
2062
|
+
return { kind: 'match', span: (0, span_1.mergeSpans)(first.span, m.span) };
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
// `its <field>` — possessive on `it`.
|
|
2066
|
+
if (first.kind === 'word' && first.text === 'its') {
|
|
2067
|
+
c.next();
|
|
2068
|
+
const field = this.collectWords(c, extraStops);
|
|
2069
|
+
const base = { kind: 'ref', ref: { kind: 'name', article: null, words: ['it'], span: first.span }, span: first.span };
|
|
2070
|
+
return { kind: 'possessive', base, field: field.words, span: (0, span_1.mergeSpans)(first.span, field.span ?? first.span) };
|
|
2071
|
+
}
|
|
2072
|
+
let article = null;
|
|
2073
|
+
let span = first.span;
|
|
2074
|
+
if (first.kind === 'word' && ARTICLES.has(first.text)) {
|
|
2075
|
+
article = first.text;
|
|
2076
|
+
c.next();
|
|
2077
|
+
}
|
|
2078
|
+
const words = [];
|
|
2079
|
+
let possessiveBase = null;
|
|
2080
|
+
while (!c.atEnd()) {
|
|
2081
|
+
const t = c.peek();
|
|
2082
|
+
if (t.kind !== 'word')
|
|
2083
|
+
break;
|
|
2084
|
+
if (PHRASE_STOPS.has(t.text) || extraStops.has(t.text))
|
|
2085
|
+
break;
|
|
2086
|
+
c.next();
|
|
2087
|
+
span = (0, span_1.mergeSpans)(span, t.span);
|
|
2088
|
+
const poss = /^(.*)'s$/.exec(t.text);
|
|
2089
|
+
if (poss) {
|
|
2090
|
+
words.push(poss[1]);
|
|
2091
|
+
possessiveBase = poss[1];
|
|
2092
|
+
break;
|
|
2093
|
+
}
|
|
2094
|
+
words.push(t.text);
|
|
2095
|
+
}
|
|
2096
|
+
if (words.length === 0) {
|
|
2097
|
+
this.diagnostics.error('parse.value', 'Expected a value.', first.span);
|
|
2098
|
+
return { kind: 'bare', words: [], span: first.span };
|
|
2099
|
+
}
|
|
2100
|
+
const refExpr = {
|
|
2101
|
+
kind: 'ref',
|
|
2102
|
+
ref: { kind: 'name', article, words, span },
|
|
2103
|
+
span,
|
|
2104
|
+
};
|
|
2105
|
+
if (possessiveBase !== null) {
|
|
2106
|
+
const field = this.collectWords(c, extraStops);
|
|
2107
|
+
return { kind: 'possessive', base: refExpr, field: field.words, span: (0, span_1.mergeSpans)(span, field.span ?? span) };
|
|
2108
|
+
}
|
|
2109
|
+
return refExpr;
|
|
2110
|
+
}
|
|
2111
|
+
collectWords(c, extraStops) {
|
|
2112
|
+
const words = [];
|
|
2113
|
+
let span = null;
|
|
2114
|
+
while (!c.atEnd()) {
|
|
2115
|
+
const t = c.peek();
|
|
2116
|
+
if (t.kind !== 'word' || PHRASE_STOPS.has(t.text) || extraStops.has(t.text))
|
|
2117
|
+
break;
|
|
2118
|
+
words.push(t.text);
|
|
2119
|
+
span = span ? (0, span_1.mergeSpans)(span, t.span) : t.span;
|
|
2120
|
+
c.next();
|
|
2121
|
+
}
|
|
2122
|
+
return { words, span };
|
|
2123
|
+
}
|
|
2124
|
+
// ------------------------------------------------------------ conditions
|
|
2125
|
+
/** `or`-composed condition (lowest precedence). */
|
|
2126
|
+
parseCondition(c, line) {
|
|
2127
|
+
const left = this.parseConditionAnd(c, line);
|
|
2128
|
+
if (!c.isWord('or'))
|
|
2129
|
+
return left;
|
|
2130
|
+
const operands = [left];
|
|
2131
|
+
let span = left.span;
|
|
2132
|
+
while (c.matchWord('or')) {
|
|
2133
|
+
const right = this.parseConditionAnd(c, line);
|
|
2134
|
+
operands.push(right);
|
|
2135
|
+
span = (0, span_1.mergeSpans)(span, right.span);
|
|
2136
|
+
}
|
|
2137
|
+
return { kind: 'or', operands, span };
|
|
2138
|
+
}
|
|
2139
|
+
parseConditionAnd(c, line) {
|
|
2140
|
+
const left = this.parseConditionUnary(c, line);
|
|
2141
|
+
if (!c.isWord('and'))
|
|
2142
|
+
return left;
|
|
2143
|
+
const operands = [left];
|
|
2144
|
+
let span = left.span;
|
|
2145
|
+
while (c.matchWord('and')) {
|
|
2146
|
+
const right = this.parseConditionUnary(c, line);
|
|
2147
|
+
operands.push(right);
|
|
2148
|
+
span = (0, span_1.mergeSpans)(span, right.span);
|
|
2149
|
+
}
|
|
2150
|
+
return { kind: 'and', operands, span };
|
|
2151
|
+
}
|
|
2152
|
+
parseConditionUnary(c, line) {
|
|
2153
|
+
const t = c.peek();
|
|
2154
|
+
if (!t) {
|
|
2155
|
+
this.diagnostics.error('parse.condition', 'Expected a condition.', lineSpan(line));
|
|
2156
|
+
return { kind: 'condition-ref', name: '', span: lineSpan(line) };
|
|
2157
|
+
}
|
|
2158
|
+
if (t.kind === 'word' && t.text === 'not') {
|
|
2159
|
+
c.next();
|
|
2160
|
+
const operand = this.parseConditionUnary(c, line);
|
|
2161
|
+
return { kind: 'not', operand, span: (0, span_1.mergeSpans)(t.span, operand.span) };
|
|
2162
|
+
}
|
|
2163
|
+
if (t.kind === 'lparen') {
|
|
2164
|
+
c.next();
|
|
2165
|
+
const inner = this.parseCondition(c, line);
|
|
2166
|
+
const close = c.next();
|
|
2167
|
+
if (!close || close.kind !== 'rparen') {
|
|
2168
|
+
this.diagnostics.error('parse.condition-paren', 'Missing closing `)` in the condition.', c.restSpan());
|
|
2169
|
+
}
|
|
2170
|
+
return inner;
|
|
2171
|
+
}
|
|
2172
|
+
// one chance in <n>
|
|
2173
|
+
if (t.kind === 'word' && t.text === 'one' && c.isWord('chance', 1) && c.isWord('in', 2)) {
|
|
2174
|
+
c.next();
|
|
2175
|
+
c.next();
|
|
2176
|
+
c.next();
|
|
2177
|
+
const n = c.next();
|
|
2178
|
+
if (!n || n.kind !== 'number') {
|
|
2179
|
+
this.diagnostics.error('parse.chance', 'Expected a number after `one chance in`.', n?.span ?? c.restSpan());
|
|
2180
|
+
return { kind: 'chance', n: 0, span: t.span };
|
|
2181
|
+
}
|
|
2182
|
+
return { kind: 'chance', n: Number(n.text), span: (0, span_1.mergeSpans)(t.span, n.span) };
|
|
2183
|
+
}
|
|
2184
|
+
// `any <name>` / `no <name>` — existential / negated existential over a
|
|
2185
|
+
// named open condition (ratchet E1/E2, 2026-07-12). Triggers only on the
|
|
2186
|
+
// quantifier plus a single STANDALONE condition name, so a subject that
|
|
2187
|
+
// merely starts with one of these words (`no smoking sign is …`) keeps
|
|
2188
|
+
// its ordinary predicate parse.
|
|
2189
|
+
if (t.kind === 'word' && (t.text === 'any' || t.text === 'no')) {
|
|
2190
|
+
const nameTok = c.peek(1);
|
|
2191
|
+
if (nameTok && nameTok.kind === 'word' && this.isBareConditionRef(c, 1)) {
|
|
2192
|
+
c.next();
|
|
2193
|
+
c.next();
|
|
2194
|
+
return {
|
|
2195
|
+
kind: t.text === 'any' ? 'any-of' : 'none-of',
|
|
2196
|
+
condition: nameTok.text,
|
|
2197
|
+
span: (0, span_1.mergeSpans)(t.span, nameTok.span),
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
// Bare single word (before a connective or end of condition): a named
|
|
2202
|
+
// condition reference, e.g. `while in-darkness`.
|
|
2203
|
+
if (t.kind === 'word' && !ARTICLES.has(t.text) && this.isBareConditionRef(c)) {
|
|
2204
|
+
c.next();
|
|
2205
|
+
return { kind: 'condition-ref', name: t.text, span: t.span };
|
|
2206
|
+
}
|
|
2207
|
+
// <subject> <predicate>
|
|
2208
|
+
const subject = this.parseValueExpr(c, line, new Set());
|
|
2209
|
+
return this.parsePredicate(c, line, subject);
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* True when the word at `offset` stands alone (connective, comma, or
|
|
2213
|
+
* nothing follows) — a bare condition-name position.
|
|
2214
|
+
*/
|
|
2215
|
+
isBareConditionRef(c, offset = 0) {
|
|
2216
|
+
const after = c.peek(offset + 1);
|
|
2217
|
+
if (!after)
|
|
2218
|
+
return true;
|
|
2219
|
+
if (after.kind === 'rparen' || after.kind === 'comma')
|
|
2220
|
+
return true;
|
|
2221
|
+
return after.kind === 'word' && (after.text === 'and' || after.text === 'or' || after.text === 'then');
|
|
2222
|
+
}
|
|
2223
|
+
parsePredicate(c, line, subject) {
|
|
2224
|
+
const t = c.peek();
|
|
2225
|
+
if (!t || t.kind !== 'word') {
|
|
2226
|
+
this.diagnostics.error('parse.predicate', 'Expected a predicate (is, has, holds, wears) after the subject.', t?.span ?? c.restSpan());
|
|
2227
|
+
return { kind: 'predicate', subject, predicate: { kind: 'is', negated: false, value: { kind: 'bare', words: [], span: subject.span }, span: subject.span }, span: subject.span };
|
|
2228
|
+
}
|
|
2229
|
+
if (t.text === 'is') {
|
|
2230
|
+
c.next();
|
|
2231
|
+
const negated = !!c.matchWord('not');
|
|
2232
|
+
if (c.isWord('a') || c.isWord('an')) {
|
|
2233
|
+
c.next();
|
|
2234
|
+
const cls = this.collectWords(c, new Set());
|
|
2235
|
+
return {
|
|
2236
|
+
kind: 'predicate',
|
|
2237
|
+
subject,
|
|
2238
|
+
predicate: { kind: 'is-a', negated, classifier: cls.words, span: cls.span ?? t.span },
|
|
2239
|
+
span: (0, span_1.mergeSpans)(subject.span, cls.span ?? t.span),
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
if (c.isWord('in')) {
|
|
2243
|
+
c.next();
|
|
2244
|
+
const place = this.parseNameRef(c, () => false);
|
|
2245
|
+
return {
|
|
2246
|
+
kind: 'predicate',
|
|
2247
|
+
subject,
|
|
2248
|
+
predicate: { kind: 'is-in', negated, place, span: place.span },
|
|
2249
|
+
span: (0, span_1.mergeSpans)(subject.span, place.span),
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
if (c.isWord('here')) {
|
|
2253
|
+
// Z4 deictic: `<subject> is here` — subject shares the player's
|
|
2254
|
+
// location. Must precede the generic value branch (`here` would
|
|
2255
|
+
// otherwise parse as a bare value word).
|
|
2256
|
+
const hereTok = c.next();
|
|
2257
|
+
return {
|
|
2258
|
+
kind: 'predicate',
|
|
2259
|
+
subject,
|
|
2260
|
+
predicate: { kind: 'is-here', negated, span: hereTok.span },
|
|
2261
|
+
span: (0, span_1.mergeSpans)(subject.span, hereTok.span),
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
const value = this.parseValueExpr(c, line, new Set());
|
|
2265
|
+
return {
|
|
2266
|
+
kind: 'predicate',
|
|
2267
|
+
subject,
|
|
2268
|
+
predicate: { kind: 'is', negated, value, span: value.span },
|
|
2269
|
+
span: (0, span_1.mergeSpans)(subject.span, value.span),
|
|
2270
|
+
};
|
|
2271
|
+
}
|
|
2272
|
+
if (t.text === 'has' || t.text === 'holds' || t.text === 'wears') {
|
|
2273
|
+
c.next();
|
|
2274
|
+
const thing = this.parseNameRef(c, (tok) => tok.kind === 'word' && PHRASE_STOPS.has(tok.text));
|
|
2275
|
+
return {
|
|
2276
|
+
kind: 'predicate',
|
|
2277
|
+
subject,
|
|
2278
|
+
predicate: { kind: t.text, thing, span: thing.span },
|
|
2279
|
+
span: (0, span_1.mergeSpans)(subject.span, thing.span),
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2282
|
+
if (t.text === 'can') {
|
|
2283
|
+
// `can see <thing>` / `can reach <thing>` (design.md §2.7, Phase B).
|
|
2284
|
+
c.next();
|
|
2285
|
+
const ability = c.next();
|
|
2286
|
+
if (!ability || ability.kind !== 'word' || (ability.text !== 'see' && ability.text !== 'reach')) {
|
|
2287
|
+
this.diagnostics.error('parse.predicate-can', 'Expected `see` or `reach` after `can`.', ability?.span ?? c.restSpan());
|
|
2288
|
+
return { kind: 'condition-ref', name: 'can', span: t.span };
|
|
2289
|
+
}
|
|
2290
|
+
const thing = this.parseNameRef(c, (tok) => tok.kind === 'word' && PHRASE_STOPS.has(tok.text));
|
|
2291
|
+
return {
|
|
2292
|
+
kind: 'predicate',
|
|
2293
|
+
subject,
|
|
2294
|
+
predicate: { kind: 'can', ability: ability.text, thing, span: (0, span_1.mergeSpans)(ability.span, thing.span) },
|
|
2295
|
+
span: (0, span_1.mergeSpans)(subject.span, thing.span),
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
this.diagnostics.error('parse.predicate', `Unknown predicate \`${t.text}\` — expected is, is a, is in, has, holds, wears, or can see/reach.`, t.span);
|
|
2299
|
+
c.next();
|
|
2300
|
+
return { kind: 'condition-ref', name: t.text, span: t.span };
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
//# sourceMappingURL=parser.js.map
|