english-lang 0.1.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/README.md +119 -0
- package/dist/cli/engc.js +198 -0
- package/dist/index.js +20 -0
- package/dist/src/ast/ASTNodes.js +6 -0
- package/dist/src/codegen/ReactNativeBackend.js +657 -0
- package/dist/src/lexer/Lexer.js +167 -0
- package/dist/src/parser/Parser.js +1165 -0
- package/package.json +23 -0
|
@@ -0,0 +1,1165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ============================================================
|
|
3
|
+
// Parser.ts — Recursive-descent parser for the English language
|
|
4
|
+
//
|
|
5
|
+
// Consumes the token stream from Lexer.ts and produces a typed
|
|
6
|
+
// AST (see ASTNodes.ts). Handles:
|
|
7
|
+
// Phase 1: screen, state, events, actions, layout, if/otherwise
|
|
8
|
+
// ============================================================
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.Parser = void 0;
|
|
11
|
+
// ─────────────────────────────────────────────────────────────
|
|
12
|
+
class Parser {
|
|
13
|
+
constructor(tokens) {
|
|
14
|
+
this.pos = 0;
|
|
15
|
+
this.tokens = tokens;
|
|
16
|
+
}
|
|
17
|
+
// ── Public entry point ────────────────────────────────────
|
|
18
|
+
parse() {
|
|
19
|
+
const screens = [];
|
|
20
|
+
const types = [];
|
|
21
|
+
const components = [];
|
|
22
|
+
while (!this.check('EOF')) {
|
|
23
|
+
this.skipNewlines();
|
|
24
|
+
if (this.check('EOF'))
|
|
25
|
+
break;
|
|
26
|
+
const word = this.peekWord();
|
|
27
|
+
if (word === 'screen') {
|
|
28
|
+
screens.push(this.parseScreenDecl());
|
|
29
|
+
}
|
|
30
|
+
else if (word === 'noun' || word === 'type') {
|
|
31
|
+
types.push(this.parseTypeDecl());
|
|
32
|
+
}
|
|
33
|
+
else if (word === 'component') {
|
|
34
|
+
components.push(this.parseComponentDecl());
|
|
35
|
+
}
|
|
36
|
+
else if (word === 'app' || word === 'shared') {
|
|
37
|
+
// Skip top-level app / shared state for Phase 1
|
|
38
|
+
this.skipBlock();
|
|
39
|
+
}
|
|
40
|
+
else if (word === 'style') {
|
|
41
|
+
this.skipBlock();
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
throw this.error(`Expected top-level declaration (screen, type, component), got "${this.peek().value}"`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { kind: 'Program', screens, types, components };
|
|
48
|
+
}
|
|
49
|
+
// ── Screen ────────────────────────────────────────────────
|
|
50
|
+
parseScreenDecl() {
|
|
51
|
+
const line = this.peek().line;
|
|
52
|
+
this.expectWord('screen');
|
|
53
|
+
const name = this.expectWord();
|
|
54
|
+
this.expect('COLON');
|
|
55
|
+
this.expect('NEWLINE');
|
|
56
|
+
this.expect('INDENT');
|
|
57
|
+
let title = null;
|
|
58
|
+
const state = [];
|
|
59
|
+
const events = [];
|
|
60
|
+
const actions = [];
|
|
61
|
+
let layout = [];
|
|
62
|
+
while (!this.check('DEDENT') && !this.check('EOF')) {
|
|
63
|
+
this.skipNewlines();
|
|
64
|
+
if (this.check('DEDENT') || this.check('EOF'))
|
|
65
|
+
break;
|
|
66
|
+
const word = this.peekWord();
|
|
67
|
+
if (word === 'title') {
|
|
68
|
+
this.consumeWord();
|
|
69
|
+
this.expectWord('is');
|
|
70
|
+
title = this.expect('STRING').value;
|
|
71
|
+
this.expectNewline();
|
|
72
|
+
}
|
|
73
|
+
else if (word === 'state') {
|
|
74
|
+
this.consumeWord();
|
|
75
|
+
this.expect('COLON');
|
|
76
|
+
this.expect('NEWLINE');
|
|
77
|
+
this.expect('INDENT');
|
|
78
|
+
while (!this.check('DEDENT') && !this.check('EOF')) {
|
|
79
|
+
this.skipNewlines();
|
|
80
|
+
if (this.check('DEDENT'))
|
|
81
|
+
break;
|
|
82
|
+
state.push(this.parseStateField());
|
|
83
|
+
}
|
|
84
|
+
this.expect('DEDENT');
|
|
85
|
+
}
|
|
86
|
+
else if (word === 'persistent') {
|
|
87
|
+
// persistent state: block — Phase 3
|
|
88
|
+
this.skipBlock();
|
|
89
|
+
}
|
|
90
|
+
else if (word === 'computed') {
|
|
91
|
+
// computed: block — Phase 3
|
|
92
|
+
this.skipBlock();
|
|
93
|
+
}
|
|
94
|
+
else if (word === 'when') {
|
|
95
|
+
events.push(this.parseEventHandler());
|
|
96
|
+
}
|
|
97
|
+
else if (word === 'to') {
|
|
98
|
+
actions.push(this.parseActionDef());
|
|
99
|
+
}
|
|
100
|
+
else if (word === 'layout') {
|
|
101
|
+
this.consumeWord();
|
|
102
|
+
this.expect('COLON');
|
|
103
|
+
this.expect('NEWLINE');
|
|
104
|
+
this.expect('INDENT');
|
|
105
|
+
layout = this.parseLayoutNodes();
|
|
106
|
+
this.expect('DEDENT');
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
throw this.error(`Unexpected keyword "${word}" in screen body`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
this.expect('DEDENT');
|
|
113
|
+
return { kind: 'ScreenDecl', name, title, state, events, actions, layout, line };
|
|
114
|
+
}
|
|
115
|
+
// ── State field ───────────────────────────────────────────
|
|
116
|
+
//
|
|
117
|
+
// name is "value"
|
|
118
|
+
// name is 0
|
|
119
|
+
// name is true
|
|
120
|
+
// name is a list of Product
|
|
121
|
+
// name is an empty list
|
|
122
|
+
parseStateField() {
|
|
123
|
+
const line = this.peek().line;
|
|
124
|
+
const name = this.expectWord();
|
|
125
|
+
this.expectWord('is');
|
|
126
|
+
// "a list of Type" or "an empty list"
|
|
127
|
+
if (this.peekWord() === 'a' || this.peekWord() === 'an') {
|
|
128
|
+
const article = this.consumeWord();
|
|
129
|
+
if (this.peekWord() === 'list') {
|
|
130
|
+
this.consumeWord();
|
|
131
|
+
this.expectWord('of');
|
|
132
|
+
const typeName = this.expectWord();
|
|
133
|
+
this.expectNewline();
|
|
134
|
+
return {
|
|
135
|
+
kind: 'StateField', name,
|
|
136
|
+
typeHint: { kind: 'ListType', itemType: typeName },
|
|
137
|
+
defaultValue: { kind: 'EmptyListLiteral' },
|
|
138
|
+
persistent: false, storageKey: null, line
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (article === 'an' && this.peekWord() === 'empty') {
|
|
142
|
+
this.consumeWord();
|
|
143
|
+
this.expectWord('list');
|
|
144
|
+
this.expectNewline();
|
|
145
|
+
return {
|
|
146
|
+
kind: 'StateField', name,
|
|
147
|
+
typeHint: { kind: 'ListType', itemType: 'any' },
|
|
148
|
+
defaultValue: { kind: 'EmptyListLiteral' },
|
|
149
|
+
persistent: false, storageKey: null, line
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
throw this.error(`Expected "list of Type" or "empty list" after "${article}"`);
|
|
153
|
+
}
|
|
154
|
+
const defaultValue = this.parseExpr();
|
|
155
|
+
this.expectNewline();
|
|
156
|
+
return {
|
|
157
|
+
kind: 'StateField', name,
|
|
158
|
+
typeHint: null,
|
|
159
|
+
defaultValue,
|
|
160
|
+
persistent: false, storageKey: null, line
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// ── Event handlers ────────────────────────────────────────
|
|
164
|
+
//
|
|
165
|
+
// when screen opens:
|
|
166
|
+
// when screen closes:
|
|
167
|
+
// when "Button" is pressed:
|
|
168
|
+
// when "field" changes:
|
|
169
|
+
parseEventHandler() {
|
|
170
|
+
const line = this.peek().line;
|
|
171
|
+
this.expectWord('when');
|
|
172
|
+
let trigger;
|
|
173
|
+
if (this.peekWord() === 'screen') {
|
|
174
|
+
this.consumeWord();
|
|
175
|
+
if (this.peekWord() === 'opens') {
|
|
176
|
+
this.consumeWord();
|
|
177
|
+
trigger = { kind: 'ScreenOpens' };
|
|
178
|
+
}
|
|
179
|
+
else if (this.peekWord() === 'closes') {
|
|
180
|
+
this.consumeWord();
|
|
181
|
+
trigger = { kind: 'ScreenCloses' };
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
throw this.error('Expected "opens" or "closes" after "screen"');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else if (this.peekWord() === 'pulled') {
|
|
188
|
+
this.consumeWord();
|
|
189
|
+
this.expectWord('to');
|
|
190
|
+
this.expectWord('refresh');
|
|
191
|
+
trigger = { kind: 'PulledToRefresh' };
|
|
192
|
+
}
|
|
193
|
+
else if (this.peekWord() === 'item') {
|
|
194
|
+
this.consumeWord();
|
|
195
|
+
this.expectWord('is');
|
|
196
|
+
this.expectWord('tapped');
|
|
197
|
+
trigger = { kind: 'ItemTapped' };
|
|
198
|
+
}
|
|
199
|
+
else if (this.check('STRING')) {
|
|
200
|
+
const label = this.advance().value;
|
|
201
|
+
this.expectWord('is');
|
|
202
|
+
const action = this.expectWord(); // "pressed" or "changes"
|
|
203
|
+
if (action === 'pressed') {
|
|
204
|
+
trigger = { kind: 'ButtonPressed', label };
|
|
205
|
+
}
|
|
206
|
+
else if (action === 'changes') {
|
|
207
|
+
trigger = { kind: 'ValueChanges', name: label };
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
throw this.error(`Expected "pressed" or "changes", got "${action}"`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
throw this.error(`Unrecognised event trigger at "${this.peek().value}"`);
|
|
215
|
+
}
|
|
216
|
+
this.expect('COLON');
|
|
217
|
+
this.expect('NEWLINE');
|
|
218
|
+
this.expect('INDENT');
|
|
219
|
+
const body = this.parseStatements();
|
|
220
|
+
this.expect('DEDENT');
|
|
221
|
+
return { kind: 'EventHandler', trigger, body, line };
|
|
222
|
+
}
|
|
223
|
+
// ── Action definitions ────────────────────────────────────
|
|
224
|
+
//
|
|
225
|
+
// to load products:
|
|
226
|
+
// to send a welcome email to "address":
|
|
227
|
+
// to move "product" from "source" to "destination":
|
|
228
|
+
parseActionDef() {
|
|
229
|
+
const line = this.peek().line;
|
|
230
|
+
this.expectWord('to');
|
|
231
|
+
const verbPhrase = [];
|
|
232
|
+
const params = [];
|
|
233
|
+
// Collect verb words and quoted parameter slots until COLON
|
|
234
|
+
while (!this.check('COLON') && !this.check('NEWLINE') && !this.check('EOF')) {
|
|
235
|
+
if (this.check('STRING')) {
|
|
236
|
+
// "paramName" — a parameter slot in the verb phrase
|
|
237
|
+
const paramName = this.advance().value;
|
|
238
|
+
params.push({ name: paramName, type: { kind: 'SimpleType', name: 'any' } });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (this.check('WORD')) {
|
|
242
|
+
verbPhrase.push(this.consumeWord());
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
this.expect('COLON');
|
|
248
|
+
this.expect('NEWLINE');
|
|
249
|
+
this.expect('INDENT');
|
|
250
|
+
const body = this.parseStatements();
|
|
251
|
+
this.expect('DEDENT');
|
|
252
|
+
return { kind: 'ActionDef', verbPhrase, params, returnType: null, body, line };
|
|
253
|
+
}
|
|
254
|
+
// ── Statements ────────────────────────────────────────────
|
|
255
|
+
parseStatements() {
|
|
256
|
+
const stmts = [];
|
|
257
|
+
while (!this.check('DEDENT') && !this.check('EOF')) {
|
|
258
|
+
this.skipNewlines();
|
|
259
|
+
if (this.check('DEDENT') || this.check('EOF'))
|
|
260
|
+
break;
|
|
261
|
+
stmts.push(this.parseStatement());
|
|
262
|
+
}
|
|
263
|
+
return stmts;
|
|
264
|
+
}
|
|
265
|
+
parseStatement() {
|
|
266
|
+
const word = this.peekWord();
|
|
267
|
+
if (word === 'set')
|
|
268
|
+
return this.parseSetStatement();
|
|
269
|
+
if (word === 'add')
|
|
270
|
+
return this.parseAddStatement();
|
|
271
|
+
if (word === 'remove')
|
|
272
|
+
return this.parseRemoveStatement();
|
|
273
|
+
if (word === 'fetch')
|
|
274
|
+
return this.parseFetchStatement();
|
|
275
|
+
if (word === 'go')
|
|
276
|
+
return this.parseGoStatement();
|
|
277
|
+
if (word === 'present')
|
|
278
|
+
return this.parsePresentStatement();
|
|
279
|
+
if (word === 'dismiss')
|
|
280
|
+
return this.parseDismissStatement();
|
|
281
|
+
if (word === 'return')
|
|
282
|
+
return this.parseReturnStatement();
|
|
283
|
+
if (word === 'if')
|
|
284
|
+
return this.parseIfStatement();
|
|
285
|
+
// Default: function call (verb phrase)
|
|
286
|
+
return this.parseCallStatement();
|
|
287
|
+
}
|
|
288
|
+
// set X to VALUE
|
|
289
|
+
parseSetStatement() {
|
|
290
|
+
const line = this.peek().line;
|
|
291
|
+
this.expectWord('set');
|
|
292
|
+
const name = this.expectWord();
|
|
293
|
+
this.expectWord('to');
|
|
294
|
+
const value = this.parseExpr();
|
|
295
|
+
this.expectNewline();
|
|
296
|
+
return { kind: 'SetStatement', name, value, line };
|
|
297
|
+
}
|
|
298
|
+
// fetch X from "url"
|
|
299
|
+
// expecting a Type / list of Type
|
|
300
|
+
// on success: (data) ->
|
|
301
|
+
// ...
|
|
302
|
+
// on failure: (error) ->
|
|
303
|
+
// ...
|
|
304
|
+
parseFetchStatement() {
|
|
305
|
+
const line = this.peek().line;
|
|
306
|
+
this.expectWord('fetch');
|
|
307
|
+
const resultName = this.expectWord();
|
|
308
|
+
this.expectWord('from');
|
|
309
|
+
const url = this.parseExpr();
|
|
310
|
+
let method = 'GET';
|
|
311
|
+
let sendBody = null;
|
|
312
|
+
let expectedType = null;
|
|
313
|
+
let onSuccess = null;
|
|
314
|
+
let onFailure = null;
|
|
315
|
+
// Optional "sending body" suffix on same line
|
|
316
|
+
if (this.peekWord() === 'sending') {
|
|
317
|
+
this.consumeWord();
|
|
318
|
+
this.consumeWord(); // "sending body"
|
|
319
|
+
method = 'POST';
|
|
320
|
+
}
|
|
321
|
+
this.expectNewline();
|
|
322
|
+
// Sub-clauses appear as an indented block
|
|
323
|
+
if (this.check('INDENT')) {
|
|
324
|
+
this.advance();
|
|
325
|
+
while (!this.check('DEDENT') && !this.check('EOF')) {
|
|
326
|
+
this.skipNewlines();
|
|
327
|
+
if (this.check('DEDENT'))
|
|
328
|
+
break;
|
|
329
|
+
const kw = this.peekWord();
|
|
330
|
+
if (kw === 'method') {
|
|
331
|
+
this.consumeWord();
|
|
332
|
+
method = this.expectWord();
|
|
333
|
+
this.expectNewline();
|
|
334
|
+
}
|
|
335
|
+
else if (kw === 'expecting') {
|
|
336
|
+
this.consumeWord();
|
|
337
|
+
expectedType = this.parseTypeAnnotation();
|
|
338
|
+
this.expectNewline();
|
|
339
|
+
}
|
|
340
|
+
else if (kw === 'on') {
|
|
341
|
+
this.consumeWord();
|
|
342
|
+
const branch = this.expectWord(); // "success" or "failure"
|
|
343
|
+
this.expect('COLON');
|
|
344
|
+
// Inline: (param) -> ...
|
|
345
|
+
this.expect('LPAREN');
|
|
346
|
+
const param = this.expectWord();
|
|
347
|
+
this.expect('RPAREN');
|
|
348
|
+
this.expect('ARROW');
|
|
349
|
+
let branchBody = [];
|
|
350
|
+
if (this.check('NEWLINE')) {
|
|
351
|
+
// Block form
|
|
352
|
+
this.advance();
|
|
353
|
+
this.expect('INDENT');
|
|
354
|
+
branchBody = this.parseStatements();
|
|
355
|
+
this.expect('DEDENT');
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
// Inline single statement (no newline yet)
|
|
359
|
+
branchBody = [this.parseStatement()];
|
|
360
|
+
}
|
|
361
|
+
if (branch === 'success')
|
|
362
|
+
onSuccess = { param, body: branchBody };
|
|
363
|
+
else
|
|
364
|
+
onFailure = { param, body: branchBody };
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
throw this.error(`Unexpected fetch sub-clause "${kw}"`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
this.expect('DEDENT');
|
|
371
|
+
}
|
|
372
|
+
return { kind: 'FetchStatement', resultName, url, method, sendBody, expectedType, onSuccess, onFailure, line };
|
|
373
|
+
}
|
|
374
|
+
// go to ScreenName [passing expr]
|
|
375
|
+
// go back
|
|
376
|
+
parseGoStatement() {
|
|
377
|
+
const line = this.peek().line;
|
|
378
|
+
this.expectWord('go');
|
|
379
|
+
if (this.peekWord() === 'back') {
|
|
380
|
+
this.consumeWord();
|
|
381
|
+
this.expectNewline();
|
|
382
|
+
return { kind: 'GoBackStatement', line };
|
|
383
|
+
}
|
|
384
|
+
this.expectWord('to');
|
|
385
|
+
const screen = this.expectWord();
|
|
386
|
+
let passing = null;
|
|
387
|
+
if (this.peekWord() === 'passing') {
|
|
388
|
+
this.consumeWord();
|
|
389
|
+
passing = this.parseExpr();
|
|
390
|
+
}
|
|
391
|
+
this.expectNewline();
|
|
392
|
+
return { kind: 'GoToStatement', screen, passing, line };
|
|
393
|
+
}
|
|
394
|
+
// present ScreenName as modal
|
|
395
|
+
parsePresentStatement() {
|
|
396
|
+
const line = this.peek().line;
|
|
397
|
+
this.expectWord('present');
|
|
398
|
+
const screen = this.expectWord();
|
|
399
|
+
this.expectWord('as');
|
|
400
|
+
this.expectWord('modal');
|
|
401
|
+
this.expectNewline();
|
|
402
|
+
return { kind: 'PresentStatement', screen, line };
|
|
403
|
+
}
|
|
404
|
+
// dismiss this screen
|
|
405
|
+
parseDismissStatement() {
|
|
406
|
+
const line = this.peek().line;
|
|
407
|
+
this.expectWord('dismiss');
|
|
408
|
+
this.expectWord('this');
|
|
409
|
+
this.expectWord('screen');
|
|
410
|
+
this.expectNewline();
|
|
411
|
+
return { kind: 'DismissStatement', line };
|
|
412
|
+
}
|
|
413
|
+
// return value
|
|
414
|
+
parseReturnStatement() {
|
|
415
|
+
const line = this.peek().line;
|
|
416
|
+
this.expectWord('return');
|
|
417
|
+
const value = this.parseExpr();
|
|
418
|
+
this.expectNewline();
|
|
419
|
+
return { kind: 'ReturnStatement', value, line };
|
|
420
|
+
}
|
|
421
|
+
// add N to name → set name to name + N
|
|
422
|
+
parseAddStatement() {
|
|
423
|
+
const line = this.peek().line;
|
|
424
|
+
this.expectWord('add');
|
|
425
|
+
const amount = this.parseExpr();
|
|
426
|
+
this.expectWord('to');
|
|
427
|
+
const name = this.expectWord();
|
|
428
|
+
this.expectNewline();
|
|
429
|
+
return {
|
|
430
|
+
kind: 'SetStatement', name, line,
|
|
431
|
+
value: { kind: 'BinaryExpr', op: '+', left: { kind: 'VariableRef', name }, right: amount }
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
// remove N from name → set name to name - N
|
|
435
|
+
parseRemoveStatement() {
|
|
436
|
+
const line = this.peek().line;
|
|
437
|
+
this.expectWord('remove');
|
|
438
|
+
const amount = this.parseExpr();
|
|
439
|
+
this.expectWord('from');
|
|
440
|
+
const name = this.expectWord();
|
|
441
|
+
this.expectNewline();
|
|
442
|
+
return {
|
|
443
|
+
kind: 'SetStatement', name, line,
|
|
444
|
+
value: { kind: 'BinaryExpr', op: '-', left: { kind: 'VariableRef', name }, right: amount }
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
// if condition: ... [otherwise if condition: ...] [otherwise: ...]
|
|
448
|
+
parseIfStatement() {
|
|
449
|
+
const line = this.peek().line;
|
|
450
|
+
this.expectWord('if');
|
|
451
|
+
const condition = this.parseCondition();
|
|
452
|
+
this.expect('COLON');
|
|
453
|
+
this.expect('NEWLINE');
|
|
454
|
+
this.expect('INDENT');
|
|
455
|
+
const thenBody = this.parseStatements();
|
|
456
|
+
this.expect('DEDENT');
|
|
457
|
+
const otherwiseIfBranches = [];
|
|
458
|
+
let elseBody = null;
|
|
459
|
+
while (this.peekWord() === 'otherwise') {
|
|
460
|
+
this.consumeWord();
|
|
461
|
+
if (this.peekWord() === 'if') {
|
|
462
|
+
this.consumeWord();
|
|
463
|
+
const cond = this.parseCondition();
|
|
464
|
+
this.expect('COLON');
|
|
465
|
+
this.expect('NEWLINE');
|
|
466
|
+
this.expect('INDENT');
|
|
467
|
+
const body = this.parseStatements();
|
|
468
|
+
this.expect('DEDENT');
|
|
469
|
+
otherwiseIfBranches.push({ condition: cond, body });
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
this.expect('COLON');
|
|
473
|
+
this.expect('NEWLINE');
|
|
474
|
+
this.expect('INDENT');
|
|
475
|
+
elseBody = this.parseStatements();
|
|
476
|
+
this.expect('DEDENT');
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return { kind: 'IfStatement', condition, thenBody, otherwiseIfBranches, elseBody, line };
|
|
481
|
+
}
|
|
482
|
+
// Default call: verb phrase possibly followed by expressions
|
|
483
|
+
parseCallStatement() {
|
|
484
|
+
const line = this.peek().line;
|
|
485
|
+
const verbPhrase = [];
|
|
486
|
+
const args = [];
|
|
487
|
+
// Collect words until NEWLINE / COLON / EOF / DEDENT
|
|
488
|
+
while (this.check('WORD') && !this.check('NEWLINE') && !this.check('EOF') && !this.check('DEDENT')) {
|
|
489
|
+
verbPhrase.push(this.consumeWord());
|
|
490
|
+
}
|
|
491
|
+
// Any remaining non-word tokens on same line become args
|
|
492
|
+
while (!this.check('NEWLINE') && !this.check('EOF') && !this.check('DEDENT')) {
|
|
493
|
+
if (this.check('STRING') || this.check('NUMBER')) {
|
|
494
|
+
args.push(this.parseExpr());
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
this.expectNewline();
|
|
501
|
+
return { kind: 'CallStatement', verbPhrase, args, line };
|
|
502
|
+
}
|
|
503
|
+
// ── Expressions ───────────────────────────────────────────
|
|
504
|
+
parseExpr() {
|
|
505
|
+
const tok = this.peek();
|
|
506
|
+
if (tok.type === 'STRING') {
|
|
507
|
+
this.advance();
|
|
508
|
+
return { kind: 'StringLiteral', value: tok.value };
|
|
509
|
+
}
|
|
510
|
+
if (tok.type === 'NUMBER') {
|
|
511
|
+
this.advance();
|
|
512
|
+
return { kind: 'NumberLiteral', value: parseFloat(tok.value) };
|
|
513
|
+
}
|
|
514
|
+
if (tok.type === 'WORD') {
|
|
515
|
+
if (tok.value === 'true') {
|
|
516
|
+
this.advance();
|
|
517
|
+
return { kind: 'BooleanLiteral', value: true };
|
|
518
|
+
}
|
|
519
|
+
if (tok.value === 'false') {
|
|
520
|
+
this.advance();
|
|
521
|
+
return { kind: 'BooleanLiteral', value: false };
|
|
522
|
+
}
|
|
523
|
+
if (tok.value === 'nothing' || tok.value === 'null') {
|
|
524
|
+
this.advance();
|
|
525
|
+
return { kind: 'NullLiteral' };
|
|
526
|
+
}
|
|
527
|
+
this.advance();
|
|
528
|
+
// Property access via dot: product.name
|
|
529
|
+
if (this.check('DOT')) {
|
|
530
|
+
this.advance();
|
|
531
|
+
const prop = this.expectWord();
|
|
532
|
+
return { kind: 'PropertyAccess', object: tok.value, property: prop };
|
|
533
|
+
}
|
|
534
|
+
// Possessive: product's name
|
|
535
|
+
if (this.check('APOSTROPHE_S')) {
|
|
536
|
+
this.advance();
|
|
537
|
+
const prop = this.expectWord();
|
|
538
|
+
return { kind: 'PropertyAccess', object: tok.value, property: prop };
|
|
539
|
+
}
|
|
540
|
+
return { kind: 'VariableRef', name: tok.value };
|
|
541
|
+
}
|
|
542
|
+
throw this.error(`Expected expression, got ${tok.type} "${tok.value}"`);
|
|
543
|
+
}
|
|
544
|
+
// ── Conditions ────────────────────────────────────────────
|
|
545
|
+
parseCondition() {
|
|
546
|
+
const left = this.parseExpr();
|
|
547
|
+
const word = this.peekWord();
|
|
548
|
+
if (word === 'has') {
|
|
549
|
+
this.consumeWord();
|
|
550
|
+
this.expectWord('items');
|
|
551
|
+
return { kind: 'HasItems', expr: left };
|
|
552
|
+
}
|
|
553
|
+
if (word === 'is') {
|
|
554
|
+
this.consumeWord();
|
|
555
|
+
const next = this.peekWord();
|
|
556
|
+
if (next === 'empty') {
|
|
557
|
+
this.consumeWord();
|
|
558
|
+
return { kind: 'IsEmpty', expr: left };
|
|
559
|
+
}
|
|
560
|
+
if (next === 'not') {
|
|
561
|
+
this.consumeWord();
|
|
562
|
+
return { kind: 'NotEquals', left, right: this.parseExpr() };
|
|
563
|
+
}
|
|
564
|
+
if (next === 'greater') {
|
|
565
|
+
this.consumeWord();
|
|
566
|
+
this.expectWord('than');
|
|
567
|
+
return { kind: 'GreaterThan', left, right: this.parseExpr() };
|
|
568
|
+
}
|
|
569
|
+
if (next === 'less') {
|
|
570
|
+
this.consumeWord();
|
|
571
|
+
this.expectWord('than');
|
|
572
|
+
return { kind: 'LessThan', left, right: this.parseExpr() };
|
|
573
|
+
}
|
|
574
|
+
if (next === 'at') {
|
|
575
|
+
this.consumeWord();
|
|
576
|
+
const qualifier = this.expectWord();
|
|
577
|
+
if (qualifier === 'least')
|
|
578
|
+
return { kind: 'AtLeast', left, right: this.parseExpr() };
|
|
579
|
+
if (qualifier === 'most')
|
|
580
|
+
return { kind: 'AtMost', left, right: this.parseExpr() };
|
|
581
|
+
throw this.error(`Expected "least" or "most" after "at"`);
|
|
582
|
+
}
|
|
583
|
+
return { kind: 'Equals', left, right: this.parseExpr() };
|
|
584
|
+
}
|
|
585
|
+
if (word === 'exists') {
|
|
586
|
+
this.consumeWord();
|
|
587
|
+
return { kind: 'Exists', expr: left };
|
|
588
|
+
}
|
|
589
|
+
if (word === 'contains') {
|
|
590
|
+
this.consumeWord();
|
|
591
|
+
return { kind: 'Contains', haystack: left, needle: this.parseExpr() };
|
|
592
|
+
}
|
|
593
|
+
// Bare variable name — truthy check
|
|
594
|
+
return { kind: 'Truthy', expr: left };
|
|
595
|
+
}
|
|
596
|
+
// ── Type annotations ──────────────────────────────────────
|
|
597
|
+
parseTypeAnnotation() {
|
|
598
|
+
if (this.peekWord() === 'a' || this.peekWord() === 'an') {
|
|
599
|
+
this.consumeWord();
|
|
600
|
+
}
|
|
601
|
+
if (this.peekWord() === 'list') {
|
|
602
|
+
this.consumeWord();
|
|
603
|
+
this.expectWord('of');
|
|
604
|
+
const name = this.expectWord();
|
|
605
|
+
return { kind: 'ListType', itemType: name };
|
|
606
|
+
}
|
|
607
|
+
if (this.peekWord() === 'optional') {
|
|
608
|
+
this.consumeWord();
|
|
609
|
+
const name = this.expectWord();
|
|
610
|
+
return { kind: 'OptionalType', inner: { kind: 'SimpleType', name } };
|
|
611
|
+
}
|
|
612
|
+
const name = this.expectWord();
|
|
613
|
+
return { kind: 'SimpleType', name };
|
|
614
|
+
}
|
|
615
|
+
// ── Layout nodes ──────────────────────────────────────────
|
|
616
|
+
parseLayoutNodes() {
|
|
617
|
+
const nodes = [];
|
|
618
|
+
while (!this.check('DEDENT') && !this.check('EOF')) {
|
|
619
|
+
this.skipNewlines();
|
|
620
|
+
if (this.check('DEDENT') || this.check('EOF'))
|
|
621
|
+
break;
|
|
622
|
+
nodes.push(this.parseLayoutNode());
|
|
623
|
+
}
|
|
624
|
+
return nodes;
|
|
625
|
+
}
|
|
626
|
+
parseLayoutNode() {
|
|
627
|
+
const word = this.peekWord();
|
|
628
|
+
if (word === 'vertical')
|
|
629
|
+
return this.parseVerticalStack();
|
|
630
|
+
if (word === 'horizontal')
|
|
631
|
+
return this.parseHorizontalStack();
|
|
632
|
+
if (word === 'scroll')
|
|
633
|
+
return this.parseScrollView();
|
|
634
|
+
if (word === 'grid')
|
|
635
|
+
return this.parseGrid();
|
|
636
|
+
if (word === 'card')
|
|
637
|
+
return this.parseCard();
|
|
638
|
+
if (word === 'show')
|
|
639
|
+
return this.parseTextNode();
|
|
640
|
+
if (word === 'text') {
|
|
641
|
+
// "text field bound to X" vs "text X as style"
|
|
642
|
+
const next2 = this.tokens[this.pos + 1];
|
|
643
|
+
if (next2 && next2.type === 'WORD' && next2.value === 'field') {
|
|
644
|
+
return this.parseTextField();
|
|
645
|
+
}
|
|
646
|
+
return this.parseTextNode();
|
|
647
|
+
}
|
|
648
|
+
if (word === 'button')
|
|
649
|
+
return this.parseButton();
|
|
650
|
+
if (word === 'image')
|
|
651
|
+
return this.parseImage();
|
|
652
|
+
if (word === 'icon')
|
|
653
|
+
return this.parseIcon();
|
|
654
|
+
if (word === 'spacer') {
|
|
655
|
+
this.consumeWord();
|
|
656
|
+
this.expectNewline();
|
|
657
|
+
return { kind: 'Spacer' };
|
|
658
|
+
}
|
|
659
|
+
if (word === 'divider') {
|
|
660
|
+
this.consumeWord();
|
|
661
|
+
this.expectNewline();
|
|
662
|
+
return { kind: 'Divider' };
|
|
663
|
+
}
|
|
664
|
+
if (word === 'loading')
|
|
665
|
+
return this.parseLoadingSpinner();
|
|
666
|
+
if (word === 'if')
|
|
667
|
+
return this.parseLayoutIf();
|
|
668
|
+
if (word === 'for')
|
|
669
|
+
return this.parseForEach();
|
|
670
|
+
// Component call: starts with uppercase (e.g. ProductCard(...))
|
|
671
|
+
if (word && /^[A-Z]/.test(word))
|
|
672
|
+
return this.parseComponentCall();
|
|
673
|
+
throw this.error(`Unknown layout element "${word}"`);
|
|
674
|
+
}
|
|
675
|
+
// vertical stack [spacing N] [padding N]:
|
|
676
|
+
parseVerticalStack() {
|
|
677
|
+
this.expectWord('vertical');
|
|
678
|
+
this.expectWord('stack');
|
|
679
|
+
let spacing = null;
|
|
680
|
+
let padding = null;
|
|
681
|
+
while (this.check('WORD')) {
|
|
682
|
+
const kw = this.peekWord();
|
|
683
|
+
if (kw === 'spacing') {
|
|
684
|
+
this.consumeWord();
|
|
685
|
+
spacing = parseFloat(this.expect('NUMBER').value);
|
|
686
|
+
}
|
|
687
|
+
else if (kw === 'padding') {
|
|
688
|
+
this.consumeWord();
|
|
689
|
+
padding = parseFloat(this.expect('NUMBER').value);
|
|
690
|
+
}
|
|
691
|
+
else
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
this.expect('COLON');
|
|
695
|
+
this.expect('NEWLINE');
|
|
696
|
+
this.expect('INDENT');
|
|
697
|
+
const children = this.parseLayoutNodes();
|
|
698
|
+
this.expect('DEDENT');
|
|
699
|
+
return { kind: 'VerticalStack', spacing, padding, children };
|
|
700
|
+
}
|
|
701
|
+
// horizontal stack [spacing N]:
|
|
702
|
+
parseHorizontalStack() {
|
|
703
|
+
this.expectWord('horizontal');
|
|
704
|
+
this.expectWord('stack');
|
|
705
|
+
let spacing = null;
|
|
706
|
+
if (this.peekWord() === 'spacing') {
|
|
707
|
+
this.consumeWord();
|
|
708
|
+
spacing = parseFloat(this.expect('NUMBER').value);
|
|
709
|
+
}
|
|
710
|
+
this.expect('COLON');
|
|
711
|
+
this.expect('NEWLINE');
|
|
712
|
+
this.expect('INDENT');
|
|
713
|
+
const children = this.parseLayoutNodes();
|
|
714
|
+
this.expect('DEDENT');
|
|
715
|
+
return { kind: 'HorizontalStack', spacing, children };
|
|
716
|
+
}
|
|
717
|
+
// scroll view [vertical|horizontal]:
|
|
718
|
+
parseScrollView() {
|
|
719
|
+
this.expectWord('scroll');
|
|
720
|
+
this.expectWord('view');
|
|
721
|
+
let direction = 'vertical';
|
|
722
|
+
if (this.peekWord() === 'vertical' || this.peekWord() === 'horizontal') {
|
|
723
|
+
direction = this.consumeWord();
|
|
724
|
+
}
|
|
725
|
+
this.expect('COLON');
|
|
726
|
+
this.expect('NEWLINE');
|
|
727
|
+
this.expect('INDENT');
|
|
728
|
+
const children = this.parseLayoutNodes();
|
|
729
|
+
this.expect('DEDENT');
|
|
730
|
+
return { kind: 'ScrollView', direction, children };
|
|
731
|
+
}
|
|
732
|
+
// grid with N columns [spacing N]:
|
|
733
|
+
parseGrid() {
|
|
734
|
+
this.expectWord('grid');
|
|
735
|
+
this.expectWord('with');
|
|
736
|
+
const columns = parseFloat(this.expect('NUMBER').value);
|
|
737
|
+
this.expectWord('columns');
|
|
738
|
+
let spacing = null;
|
|
739
|
+
if (this.peekWord() === 'spacing') {
|
|
740
|
+
this.consumeWord();
|
|
741
|
+
spacing = parseFloat(this.expect('NUMBER').value);
|
|
742
|
+
}
|
|
743
|
+
this.expect('COLON');
|
|
744
|
+
this.expect('NEWLINE');
|
|
745
|
+
this.expect('INDENT');
|
|
746
|
+
const children = this.parseLayoutNodes();
|
|
747
|
+
this.expect('DEDENT');
|
|
748
|
+
return { kind: 'Grid', columns, spacing, children };
|
|
749
|
+
}
|
|
750
|
+
// card [corner radius N]:
|
|
751
|
+
parseCard() {
|
|
752
|
+
this.expectWord('card');
|
|
753
|
+
let cornerRadius = null;
|
|
754
|
+
if (this.peekWord() === 'corner') {
|
|
755
|
+
this.consumeWord();
|
|
756
|
+
this.expectWord('radius');
|
|
757
|
+
cornerRadius = parseFloat(this.expect('NUMBER').value);
|
|
758
|
+
}
|
|
759
|
+
this.expect('COLON');
|
|
760
|
+
this.expect('NEWLINE');
|
|
761
|
+
this.expect('INDENT');
|
|
762
|
+
const children = this.parseLayoutNodes();
|
|
763
|
+
this.expect('DEDENT');
|
|
764
|
+
return { kind: 'Card', cornerRadius, children };
|
|
765
|
+
}
|
|
766
|
+
// show "text" [as style] [color X]
|
|
767
|
+
// show variable [as style] [color X]
|
|
768
|
+
// text "text" as style
|
|
769
|
+
// text variable as style
|
|
770
|
+
parseTextNode() {
|
|
771
|
+
const kw = this.consumeWord(); // 'show' or 'text'
|
|
772
|
+
if (kw !== 'show' && kw !== 'text')
|
|
773
|
+
throw this.error(`Expected "show" or "text", got "${kw}"`);
|
|
774
|
+
let expr;
|
|
775
|
+
if (this.check('STRING')) {
|
|
776
|
+
expr = { kind: 'StringLiteral', value: this.advance().value };
|
|
777
|
+
}
|
|
778
|
+
else if (this.check('WORD')) {
|
|
779
|
+
expr = this.parseExpr();
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
throw this.error('Expected string or variable after "show"/"text"');
|
|
783
|
+
}
|
|
784
|
+
let style = 'body';
|
|
785
|
+
if (this.peekWord() === 'as') {
|
|
786
|
+
this.consumeWord();
|
|
787
|
+
style = this.expectWord();
|
|
788
|
+
}
|
|
789
|
+
let color = null;
|
|
790
|
+
if (this.peekWord() === 'color') {
|
|
791
|
+
this.consumeWord();
|
|
792
|
+
color = this.check('STRING') ? this.advance().value : this.expectWord();
|
|
793
|
+
}
|
|
794
|
+
this.expectNewline();
|
|
795
|
+
return { kind: 'Text', expr, style, color };
|
|
796
|
+
}
|
|
797
|
+
// button "Label" [style StyleName]
|
|
798
|
+
parseButton() {
|
|
799
|
+
this.expectWord('button');
|
|
800
|
+
const label = this.expect('STRING').value;
|
|
801
|
+
let style = null;
|
|
802
|
+
if (this.peekWord() === 'style') {
|
|
803
|
+
this.consumeWord();
|
|
804
|
+
style = this.expectWord();
|
|
805
|
+
}
|
|
806
|
+
this.expectNewline();
|
|
807
|
+
return { kind: 'Button', label, style };
|
|
808
|
+
}
|
|
809
|
+
// text field bound to varName [placeholder "..."]
|
|
810
|
+
parseTextField() {
|
|
811
|
+
this.expectWord('text');
|
|
812
|
+
this.expectWord('field');
|
|
813
|
+
this.expectWord('bound');
|
|
814
|
+
this.expectWord('to');
|
|
815
|
+
const boundTo = this.expectWord();
|
|
816
|
+
let placeholder = null;
|
|
817
|
+
if (this.peekWord() === 'placeholder') {
|
|
818
|
+
this.consumeWord();
|
|
819
|
+
placeholder = this.expect('STRING').value;
|
|
820
|
+
}
|
|
821
|
+
this.expectNewline();
|
|
822
|
+
return { kind: 'TextField', placeholder, boundTo };
|
|
823
|
+
}
|
|
824
|
+
// image from expr [width N] [height N]
|
|
825
|
+
parseImage() {
|
|
826
|
+
this.expectWord('image');
|
|
827
|
+
this.expectWord('from');
|
|
828
|
+
const source = this.parseExpr();
|
|
829
|
+
let width = null;
|
|
830
|
+
let height = null;
|
|
831
|
+
if (this.peekWord() === 'width') {
|
|
832
|
+
this.consumeWord();
|
|
833
|
+
width = parseFloat(this.expect('NUMBER').value);
|
|
834
|
+
}
|
|
835
|
+
if (this.peekWord() === 'height') {
|
|
836
|
+
this.consumeWord();
|
|
837
|
+
height = parseFloat(this.expect('NUMBER').value);
|
|
838
|
+
}
|
|
839
|
+
this.expectNewline();
|
|
840
|
+
return { kind: 'Image', source, width, height };
|
|
841
|
+
}
|
|
842
|
+
// icon named "name" [size N]
|
|
843
|
+
parseIcon() {
|
|
844
|
+
this.expectWord('icon');
|
|
845
|
+
this.expectWord('named');
|
|
846
|
+
const name = this.expect('STRING').value;
|
|
847
|
+
let size = null;
|
|
848
|
+
if (this.peekWord() === 'size') {
|
|
849
|
+
this.consumeWord();
|
|
850
|
+
size = parseFloat(this.expect('NUMBER').value);
|
|
851
|
+
}
|
|
852
|
+
this.expectNewline();
|
|
853
|
+
return { kind: 'Icon', name, size };
|
|
854
|
+
}
|
|
855
|
+
// loading spinner [centered]
|
|
856
|
+
parseLoadingSpinner() {
|
|
857
|
+
this.expectWord('loading');
|
|
858
|
+
this.expectWord('spinner');
|
|
859
|
+
let centered = false;
|
|
860
|
+
if (this.peekWord() === 'centered') {
|
|
861
|
+
this.consumeWord();
|
|
862
|
+
centered = true;
|
|
863
|
+
}
|
|
864
|
+
this.expectNewline();
|
|
865
|
+
return { kind: 'LoadingSpinner', centered };
|
|
866
|
+
}
|
|
867
|
+
// if condition: ... [otherwise if condition: ...] [otherwise: ...]
|
|
868
|
+
parseLayoutIf() {
|
|
869
|
+
this.expectWord('if');
|
|
870
|
+
const condition = this.parseCondition();
|
|
871
|
+
this.expect('COLON');
|
|
872
|
+
this.expect('NEWLINE');
|
|
873
|
+
this.expect('INDENT');
|
|
874
|
+
const thenBranch = this.parseLayoutNodes();
|
|
875
|
+
this.expect('DEDENT');
|
|
876
|
+
const otherwiseIfBranches = [];
|
|
877
|
+
let elseBranch = null;
|
|
878
|
+
while (this.peekWord() === 'otherwise') {
|
|
879
|
+
this.consumeWord();
|
|
880
|
+
if (this.peekWord() === 'if') {
|
|
881
|
+
this.consumeWord();
|
|
882
|
+
const cond = this.parseCondition();
|
|
883
|
+
this.expect('COLON');
|
|
884
|
+
this.expect('NEWLINE');
|
|
885
|
+
this.expect('INDENT');
|
|
886
|
+
const body = this.parseLayoutNodes();
|
|
887
|
+
this.expect('DEDENT');
|
|
888
|
+
otherwiseIfBranches.push({ condition: cond, body });
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
this.expect('COLON');
|
|
892
|
+
this.expect('NEWLINE');
|
|
893
|
+
this.expect('INDENT');
|
|
894
|
+
elseBranch = this.parseLayoutNodes();
|
|
895
|
+
this.expect('DEDENT');
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return { kind: 'LayoutIf', condition, thenBranch, otherwiseIfBranches, elseBranch };
|
|
900
|
+
}
|
|
901
|
+
// for each item in collection:
|
|
902
|
+
parseForEach() {
|
|
903
|
+
this.expectWord('for');
|
|
904
|
+
this.expectWord('each');
|
|
905
|
+
const item = this.expectWord();
|
|
906
|
+
this.expectWord('in');
|
|
907
|
+
const collection = this.parseExpr();
|
|
908
|
+
this.expect('COLON');
|
|
909
|
+
this.expect('NEWLINE');
|
|
910
|
+
this.expect('INDENT');
|
|
911
|
+
const children = this.parseLayoutNodes();
|
|
912
|
+
this.expect('DEDENT');
|
|
913
|
+
return { kind: 'ForEach', item, collection, children };
|
|
914
|
+
}
|
|
915
|
+
// ComponentName(prop: value, ...)
|
|
916
|
+
parseComponentCall() {
|
|
917
|
+
const name = this.expectWord();
|
|
918
|
+
const args = [];
|
|
919
|
+
if (this.check('LPAREN')) {
|
|
920
|
+
this.advance();
|
|
921
|
+
while (!this.check('RPAREN') && !this.check('EOF')) {
|
|
922
|
+
const argName = this.expectWord();
|
|
923
|
+
this.expect('COLON');
|
|
924
|
+
const argValue = this.parseExpr();
|
|
925
|
+
args.push({ name: argName, value: argValue });
|
|
926
|
+
if (this.check('COMMA'))
|
|
927
|
+
this.advance();
|
|
928
|
+
}
|
|
929
|
+
this.expect('RPAREN');
|
|
930
|
+
}
|
|
931
|
+
this.expectNewline();
|
|
932
|
+
return { kind: 'ComponentCall', name, args };
|
|
933
|
+
}
|
|
934
|
+
// ── Type and component declarations ───────────────────────
|
|
935
|
+
parseTypeDecl() {
|
|
936
|
+
const line = this.peek().line;
|
|
937
|
+
this.consumeWord(); // consume 'noun' or 'type'
|
|
938
|
+
// Name — may include relationship clauses: "Dog that is an Animal"
|
|
939
|
+
const name = this.expectWord();
|
|
940
|
+
// Skip relationship clause: "that is a/an Animal", "that cannot be used directly",
|
|
941
|
+
// "that is Payable", "that holds T"
|
|
942
|
+
if (this.peekWord() === 'that') {
|
|
943
|
+
while (!this.check('COLON') && !this.check('NEWLINE') && !this.check('EOF')) {
|
|
944
|
+
this.advance();
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
this.expect('COLON');
|
|
948
|
+
this.expect('NEWLINE');
|
|
949
|
+
this.expect('INDENT');
|
|
950
|
+
const fields = [];
|
|
951
|
+
while (!this.check('DEDENT') && !this.check('EOF')) {
|
|
952
|
+
this.skipNewlines();
|
|
953
|
+
if (this.check('DEDENT'))
|
|
954
|
+
break;
|
|
955
|
+
const fieldLine = this.peek().line;
|
|
956
|
+
const kw = this.peekWord();
|
|
957
|
+
// Method definition inside noun — skip for now (Phase 2)
|
|
958
|
+
if (kw === 'to' || kw === 'shared' || kw === 'must') {
|
|
959
|
+
this.skipBlock();
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
// Trait: "has a [type] name" | "has a list of Type" | "has a Name"
|
|
963
|
+
// Hidden trait: "keep [type] name private"
|
|
964
|
+
if (kw === 'has') {
|
|
965
|
+
this.consumeWord(); // 'has'
|
|
966
|
+
const article = this.peekWord();
|
|
967
|
+
if (article === 'a' || article === 'an' || article === 'an')
|
|
968
|
+
this.consumeWord();
|
|
969
|
+
// List trait: "has a list of Type"
|
|
970
|
+
if (this.peekWord() === 'list') {
|
|
971
|
+
this.consumeWord();
|
|
972
|
+
this.expectWord('of');
|
|
973
|
+
const itemType = this.expectWord();
|
|
974
|
+
// infer field name as lowercase plural of type
|
|
975
|
+
const fieldName = itemType.charAt(0).toLowerCase() + itemType.slice(1) + 's';
|
|
976
|
+
// Skip optional "starting as value"
|
|
977
|
+
if (this.peekWord() === 'starting') {
|
|
978
|
+
while (!this.check('NEWLINE') && !this.check('EOF'))
|
|
979
|
+
this.advance();
|
|
980
|
+
}
|
|
981
|
+
this.expectNewline();
|
|
982
|
+
fields.push({ kind: 'TypeField', name: fieldName, type: { kind: 'ListType', itemType }, line: fieldLine });
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
const firstWord = this.expectWord();
|
|
986
|
+
// If next token is a WORD and not a modifier, it's "has a [type] [name]"
|
|
987
|
+
const nextIsName = this.check('WORD') &&
|
|
988
|
+
!['starting', 'private'].includes(this.peekWord() ?? '');
|
|
989
|
+
let typeName;
|
|
990
|
+
let fieldName;
|
|
991
|
+
if (nextIsName) {
|
|
992
|
+
// "has a text name" — type + name both present
|
|
993
|
+
typeName = firstWord;
|
|
994
|
+
fieldName = this.expectWord();
|
|
995
|
+
// Handle multi-word names: "profile photo" → profilePhoto
|
|
996
|
+
while (this.check('WORD') && !['starting', 'private'].includes(this.peekWord() ?? '')) {
|
|
997
|
+
const extra = this.consumeWord();
|
|
998
|
+
fieldName = fieldName + extra.charAt(0).toUpperCase() + extra.slice(1);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
// "has a User" — custom noun, name inferred from type
|
|
1003
|
+
typeName = firstWord;
|
|
1004
|
+
fieldName = firstWord.charAt(0).toLowerCase() + firstWord.slice(1);
|
|
1005
|
+
}
|
|
1006
|
+
// Optional default: "starting as value"
|
|
1007
|
+
let defaultVal = null;
|
|
1008
|
+
if (this.peekWord() === 'starting') {
|
|
1009
|
+
this.consumeWord();
|
|
1010
|
+
this.expectWord('as');
|
|
1011
|
+
defaultVal = this.peek().value;
|
|
1012
|
+
this.advance();
|
|
1013
|
+
}
|
|
1014
|
+
this.expectNewline();
|
|
1015
|
+
fields.push({ kind: 'TypeField', name: fieldName, type: { kind: 'SimpleType', name: typeName }, line: fieldLine });
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
if (kw === 'keep') {
|
|
1019
|
+
// "keep [type] name private" or "keep [type] name starting as value private"
|
|
1020
|
+
this.consumeWord(); // 'keep'
|
|
1021
|
+
const typeName = this.expectWord();
|
|
1022
|
+
const fieldName = this.expectWord();
|
|
1023
|
+
// Skip the rest of the line (starting as, private)
|
|
1024
|
+
while (!this.check('NEWLINE') && !this.check('EOF'))
|
|
1025
|
+
this.advance();
|
|
1026
|
+
this.expectNewline();
|
|
1027
|
+
fields.push({ kind: 'TypeField', name: fieldName, type: { kind: 'SimpleType', name: typeName }, line: fieldLine });
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
// Unknown — skip line
|
|
1031
|
+
while (!this.check('NEWLINE') && !this.check('EOF'))
|
|
1032
|
+
this.advance();
|
|
1033
|
+
this.expectNewline();
|
|
1034
|
+
}
|
|
1035
|
+
this.expect('DEDENT');
|
|
1036
|
+
return { kind: 'TypeDecl', name, fields, line };
|
|
1037
|
+
}
|
|
1038
|
+
parseComponentDecl() {
|
|
1039
|
+
const line = this.peek().line;
|
|
1040
|
+
this.expectWord('component');
|
|
1041
|
+
const name = this.expectWord();
|
|
1042
|
+
const params = [];
|
|
1043
|
+
if (this.check('LPAREN')) {
|
|
1044
|
+
this.advance();
|
|
1045
|
+
while (!this.check('RPAREN') && !this.check('EOF')) {
|
|
1046
|
+
const pName = this.expectWord();
|
|
1047
|
+
this.expect('COLON');
|
|
1048
|
+
const pType = this.expectWord();
|
|
1049
|
+
params.push({ name: pName, type: { kind: 'SimpleType', name: pType } });
|
|
1050
|
+
if (this.check('COMMA'))
|
|
1051
|
+
this.advance();
|
|
1052
|
+
}
|
|
1053
|
+
this.expect('RPAREN');
|
|
1054
|
+
}
|
|
1055
|
+
this.expect('COLON');
|
|
1056
|
+
this.expect('NEWLINE');
|
|
1057
|
+
this.expect('INDENT');
|
|
1058
|
+
const events = [];
|
|
1059
|
+
let layout = [];
|
|
1060
|
+
while (!this.check('DEDENT') && !this.check('EOF')) {
|
|
1061
|
+
this.skipNewlines();
|
|
1062
|
+
if (this.check('DEDENT'))
|
|
1063
|
+
break;
|
|
1064
|
+
const word = this.peekWord();
|
|
1065
|
+
if (word === 'when') {
|
|
1066
|
+
events.push(this.parseEventHandler());
|
|
1067
|
+
}
|
|
1068
|
+
else {
|
|
1069
|
+
// Everything else is layout
|
|
1070
|
+
layout = this.parseLayoutNodes();
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
this.expect('DEDENT');
|
|
1074
|
+
return { kind: 'ComponentDecl', name, params, layout, events, line };
|
|
1075
|
+
}
|
|
1076
|
+
// ── Skip helpers ──────────────────────────────────────────
|
|
1077
|
+
skipBlock() {
|
|
1078
|
+
// Consume until the block is finished (reached matching DEDENT)
|
|
1079
|
+
while (!this.check('COLON') && !this.check('NEWLINE') && !this.check('EOF'))
|
|
1080
|
+
this.advance();
|
|
1081
|
+
if (this.check('COLON'))
|
|
1082
|
+
this.advance();
|
|
1083
|
+
if (this.check('NEWLINE'))
|
|
1084
|
+
this.advance();
|
|
1085
|
+
if (this.check('INDENT')) {
|
|
1086
|
+
let depth = 1;
|
|
1087
|
+
this.advance();
|
|
1088
|
+
while (depth > 0 && !this.check('EOF')) {
|
|
1089
|
+
if (this.check('INDENT')) {
|
|
1090
|
+
depth++;
|
|
1091
|
+
this.advance();
|
|
1092
|
+
}
|
|
1093
|
+
else if (this.check('DEDENT')) {
|
|
1094
|
+
depth--;
|
|
1095
|
+
this.advance();
|
|
1096
|
+
}
|
|
1097
|
+
else
|
|
1098
|
+
this.advance();
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
// ── Token primitives ──────────────────────────────────────
|
|
1103
|
+
peek(offset = 0) {
|
|
1104
|
+
const idx = this.pos + offset;
|
|
1105
|
+
return this.tokens[idx] ?? this.tokens[this.tokens.length - 1];
|
|
1106
|
+
}
|
|
1107
|
+
advance() {
|
|
1108
|
+
const tok = this.tokens[this.pos];
|
|
1109
|
+
this.pos++;
|
|
1110
|
+
return tok;
|
|
1111
|
+
}
|
|
1112
|
+
check(type, value) {
|
|
1113
|
+
const tok = this.peek();
|
|
1114
|
+
if (tok.type !== type)
|
|
1115
|
+
return false;
|
|
1116
|
+
if (value !== undefined && tok.value !== value)
|
|
1117
|
+
return false;
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
expect(type, value) {
|
|
1121
|
+
if (!this.check(type, value)) {
|
|
1122
|
+
const got = this.peek();
|
|
1123
|
+
const expected = value ? `${type}("${value}")` : type;
|
|
1124
|
+
throw this.error(`Expected ${expected}, got ${got.type}("${got.value}")`);
|
|
1125
|
+
}
|
|
1126
|
+
return this.advance();
|
|
1127
|
+
}
|
|
1128
|
+
peekWord() {
|
|
1129
|
+
const tok = this.peek();
|
|
1130
|
+
return tok.type === 'WORD' ? tok.value : null;
|
|
1131
|
+
}
|
|
1132
|
+
expectWord(value) {
|
|
1133
|
+
const tok = this.peek();
|
|
1134
|
+
if (tok.type !== 'WORD') {
|
|
1135
|
+
throw this.error(`Expected word${value ? ` "${value}"` : ''}, got ${tok.type}("${tok.value}")`);
|
|
1136
|
+
}
|
|
1137
|
+
if (value !== undefined && tok.value !== value) {
|
|
1138
|
+
throw this.error(`Expected word "${value}", got "${tok.value}"`);
|
|
1139
|
+
}
|
|
1140
|
+
this.advance();
|
|
1141
|
+
return tok.value;
|
|
1142
|
+
}
|
|
1143
|
+
consumeWord() {
|
|
1144
|
+
return this.expectWord();
|
|
1145
|
+
}
|
|
1146
|
+
expectNewline() {
|
|
1147
|
+
if (this.check('NEWLINE')) {
|
|
1148
|
+
this.advance();
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
// At end of block / file — acceptable
|
|
1152
|
+
if (this.check('DEDENT') || this.check('EOF'))
|
|
1153
|
+
return;
|
|
1154
|
+
throw this.error(`Expected newline, got ${this.peek().type}("${this.peek().value}")`);
|
|
1155
|
+
}
|
|
1156
|
+
skipNewlines() {
|
|
1157
|
+
while (this.check('NEWLINE'))
|
|
1158
|
+
this.advance();
|
|
1159
|
+
}
|
|
1160
|
+
error(msg) {
|
|
1161
|
+
const tok = this.peek();
|
|
1162
|
+
return new Error(`[Parser] Line ${tok.line}: ${msg}`);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
exports.Parser = Parser;
|