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.
@@ -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;