@words-lang/parser 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.
Files changed (41) hide show
  1. package/dist/analyser/analyser.d.ts +106 -0
  2. package/dist/analyser/analyser.d.ts.map +1 -0
  3. package/dist/analyser/analyser.js +291 -0
  4. package/dist/analyser/analyser.js.map +1 -0
  5. package/dist/analyser/diagnostics.d.ts +166 -0
  6. package/dist/analyser/diagnostics.d.ts.map +1 -0
  7. package/dist/analyser/diagnostics.js +139 -0
  8. package/dist/analyser/diagnostics.js.map +1 -0
  9. package/dist/analyser/workspace.d.ts +198 -0
  10. package/dist/analyser/workspace.d.ts.map +1 -0
  11. package/dist/analyser/workspace.js +403 -0
  12. package/dist/analyser/workspace.js.map +1 -0
  13. package/dist/index.d.ts +8 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +31 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/lexer/lexer.d.ts +120 -0
  18. package/dist/lexer/lexer.d.ts.map +1 -0
  19. package/dist/lexer/lexer.js +365 -0
  20. package/dist/lexer/lexer.js.map +1 -0
  21. package/dist/lexer/token.d.ts +247 -0
  22. package/dist/lexer/token.d.ts.map +1 -0
  23. package/dist/lexer/token.js +250 -0
  24. package/dist/lexer/token.js.map +1 -0
  25. package/dist/parser/ast.d.ts +685 -0
  26. package/dist/parser/ast.d.ts.map +1 -0
  27. package/dist/parser/ast.js +3 -0
  28. package/dist/parser/ast.js.map +1 -0
  29. package/dist/parser/parser.d.ts +411 -0
  30. package/dist/parser/parser.d.ts.map +1 -0
  31. package/dist/parser/parser.js +1600 -0
  32. package/dist/parser/parser.js.map +1 -0
  33. package/package.json +23 -0
  34. package/src/analyser/analyser.ts +403 -0
  35. package/src/analyser/diagnostics.ts +232 -0
  36. package/src/analyser/workspace.ts +457 -0
  37. package/src/index.ts +7 -0
  38. package/src/lexer/lexer.ts +379 -0
  39. package/src/lexer/token.ts +331 -0
  40. package/src/parser/ast.ts +798 -0
  41. package/src/parser/parser.ts +1815 -0
@@ -0,0 +1,1815 @@
1
+ /**
2
+ * parser.ts
3
+ *
4
+ * The WORDS parser. Consumes a flat token stream produced by the Lexer and
5
+ * builds an AST described in ast.ts.
6
+ *
7
+ * Design principles:
8
+ *
9
+ * - Recursive descent. Each grammar rule has a corresponding private method.
10
+ * Methods consume tokens and return AST nodes.
11
+ *
12
+ * - Error recovery. When an unexpected token is encountered the parser emits
13
+ * a diagnostic, skips tokens until it finds a safe synchronisation point
14
+ * (typically a newline, a closing paren, or a known top-level keyword), and
15
+ * continues. This means a single parse pass collects all errors in the file.
16
+ *
17
+ * - No exceptions for parse errors. All problems are collected in `this.diagnostics`
18
+ * and returned alongside the partial AST in the `ParseResult`.
19
+ *
20
+ * - Comments and newlines are consumed transparently by the `skip()` helper
21
+ * unless the calling rule explicitly needs them (e.g. ownership declaration
22
+ * detection requires newlines).
23
+ *
24
+ * - `system` and `state` are both keywords and identifier prefixes in access
25
+ * expressions (`system.setContext`, `state.context`). The parser disambiguates
26
+ * by context — inside a `uses` block or argument value position, these are
27
+ * treated as access expression roots, not construct keywords.
28
+ */
29
+
30
+ import {
31
+ DocumentNode,
32
+ TopLevelNode,
33
+ SystemNode,
34
+ ModuleNode,
35
+ StateNode,
36
+ ContextNode,
37
+ ScreenNode,
38
+ ViewNode,
39
+ ProviderNode,
40
+ AdapterNode,
41
+ InterfaceNode,
42
+ ProcessNode,
43
+ WhenRuleNode,
44
+ InlineContextNode,
45
+ ImplementsHandlerNode,
46
+ ImplementsBranchNode,
47
+ PropNode,
48
+ MethodNode,
49
+ TypeNode,
50
+ PrimitiveTypeNode,
51
+ ListTypeNode,
52
+ MapTypeNode,
53
+ NamedTypeNode,
54
+ ReturnsNode,
55
+ SimpleReturnsNode,
56
+ ExpandedReturnsNode,
57
+ ExpandedReturnNode,
58
+ SideEffectNode,
59
+ ArgumentNode,
60
+ UseEntryNode,
61
+ ComponentUseNode,
62
+ ConditionalBlockNode,
63
+ IterationBlockNode,
64
+ ConditionNode,
65
+ ExpressionNode,
66
+ AccessExpressionNode,
67
+ CallExpressionNode,
68
+ StateReturnExpressionNode,
69
+ BlockExpressionNode,
70
+ StatementNode,
71
+ AssignmentStatementNode,
72
+ StateReturnStatementNode,
73
+ LiteralNode,
74
+ StringLiteralNode,
75
+ IntegerLiteralNode,
76
+ FloatLiteralNode,
77
+ BooleanLiteralNode,
78
+ ListLiteralNode,
79
+ MapLiteralNode,
80
+ QualifiedName,
81
+ } from './ast'
82
+
83
+ import { Token, TokenType } from '../lexer/token'
84
+ import {
85
+ Diagnostic,
86
+ DiagnosticCode,
87
+ parseDiagnostic,
88
+ rangeFromToken,
89
+ } from '../analyser/diagnostics'
90
+
91
+ // ── Parse result ──────────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * The value returned by `Parser.parse()`.
95
+ * Always contains both a (possibly partial) document and all diagnostics
96
+ * collected during the parse — even when errors were encountered.
97
+ */
98
+ export interface ParseResult {
99
+ document: DocumentNode
100
+ diagnostics: Diagnostic[]
101
+ }
102
+
103
+ // ── Parser ────────────────────────────────────────────────────────────────────
104
+
105
+ export class Parser {
106
+ /** The full token stream from the lexer, including EOF. */
107
+ private tokens: Token[]
108
+
109
+ /** Current position in the token stream. */
110
+ private pos: number = 0
111
+
112
+ /** Diagnostics collected during this parse. */
113
+ private diagnostics: Diagnostic[] = []
114
+
115
+ constructor(tokens: Token[]) {
116
+ this.tokens = tokens
117
+ }
118
+
119
+ // ── Public API ─────────────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * Parses the entire token stream and returns a ParseResult containing
123
+ * the document AST and all collected diagnostics.
124
+ * Never throws — all errors are collected as diagnostics.
125
+ */
126
+ parse(): ParseResult {
127
+ const document = this.parseDocument()
128
+ return { document, diagnostics: this.diagnostics }
129
+ }
130
+
131
+ // ── Document ───────────────────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Parses a complete `.wds` file.
135
+ *
136
+ * Detects the ownership declaration pattern — a bare `module ModuleName`
137
+ * on its own line at the top of component files. If the first non-trivial
138
+ * content is `module PascalIdent Newline` (with no opening `(`), it is
139
+ * captured as `ownerModule` and the next construct is parsed normally.
140
+ */
141
+ private parseDocument(): DocumentNode {
142
+ this.skipComments()
143
+
144
+ let ownerModule: string | null = null
145
+
146
+ // Detect ownership declaration: module ModuleName \n (no body follows)
147
+ if (this.check(TokenType.Module)) {
148
+ const saved = this.pos
149
+ this.advance() // consume 'module'
150
+ this.skipComments()
151
+ if (this.check(TokenType.PascalIdent)) {
152
+ const name = this.current().value
153
+ this.advance() // consume name
154
+ this.skipComments()
155
+ // If the next meaningful token is a newline or EOF (not a string or '('),
156
+ // this is an ownership declaration, not a module definition.
157
+ if (this.check(TokenType.Newline) || this.check(TokenType.EOF)) {
158
+ ownerModule = name
159
+ this.skipTrivia()
160
+ } else {
161
+ // Not an ownership declaration — restore and parse normally.
162
+ this.pos = saved
163
+ }
164
+ } else {
165
+ this.pos = saved
166
+ }
167
+ }
168
+
169
+ const nodes: TopLevelNode[] = []
170
+
171
+ while (!this.check(TokenType.EOF)) {
172
+ this.skipTrivia()
173
+ if (this.check(TokenType.EOF)) break
174
+
175
+ const savedPos = this.pos
176
+ const node = this.parseTopLevel()
177
+ if (node !== null) {
178
+ nodes.push(node)
179
+ } else if (this.pos === savedPos) {
180
+ // parseTopLevel returned null without advancing (e.g. synchronise()
181
+ // stopped at an RParen that has no matching open at the top level).
182
+ // Force progress to prevent an infinite loop.
183
+ this.advance()
184
+ }
185
+ }
186
+
187
+ return { kind: 'Document', ownerModule, nodes }
188
+ }
189
+
190
+ // ── Top-level dispatch ─────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Parses one top-level construct and returns it, or emits a diagnostic
194
+ * and returns null if the current token does not start a known construct.
195
+ */
196
+ private parseTopLevel(): TopLevelNode | null {
197
+ const tok = this.current()
198
+
199
+ switch (tok.type) {
200
+ case TokenType.System: return this.parseSystem()
201
+ case TokenType.Module: return this.parseModule()
202
+ case TokenType.State: return this.parseState()
203
+ case TokenType.Context: return this.parseContext()
204
+ case TokenType.Screen: return this.parseScreen()
205
+ case TokenType.View: return this.parseView()
206
+ case TokenType.Provider: return this.parseProvider()
207
+ case TokenType.Adapter: return this.parseAdapter()
208
+ case TokenType.Interface: return this.parseInterface()
209
+ default:
210
+ this.error(
211
+ DiagnosticCode.P_INVALID_CONSTRUCT_POSITION,
212
+ `Unexpected token '${tok.value}' — expected a construct keyword (system, module, state, context, screen, view, provider, adapter, interface)`,
213
+ tok
214
+ )
215
+ this.synchronise()
216
+ return null
217
+ }
218
+ }
219
+
220
+ // ── System ─────────────────────────────────────────────────────────────────
221
+
222
+ /**
223
+ * Parses:
224
+ * system SystemName "description" (
225
+ * modules ( ModuleOne ModuleTwo )
226
+ * interface ( ... )
227
+ * )
228
+ */
229
+ private parseSystem(): SystemNode {
230
+ const tok = this.expect(TokenType.System)!
231
+ this.skipTrivia()
232
+ const name = this.expectIdent('system name')
233
+ this.skipTrivia()
234
+ const description = this.parseOptionalString()
235
+ this.skipTrivia()
236
+ this.expect(TokenType.LParen)
237
+
238
+ const modules: string[] = []
239
+ const interfaceMethods: MethodNode[] = []
240
+
241
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
242
+ this.skipTrivia()
243
+ if (this.check(TokenType.Modules)) {
244
+ this.advance()
245
+ this.skipTrivia()
246
+ this.expect(TokenType.LParen)
247
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
248
+ this.skipTrivia()
249
+ if (this.check(TokenType.PascalIdent)) {
250
+ modules.push(this.advance().value)
251
+ } else if (!this.check(TokenType.RParen)) {
252
+ this.error(DiagnosticCode.P_MISSING_IDENTIFIER, `Expected module name`, this.current())
253
+ this.advance()
254
+ }
255
+ }
256
+ this.expect(TokenType.RParen)
257
+ } else if (this.check(TokenType.Interface)) {
258
+ this.advance()
259
+ this.skipTrivia()
260
+ this.expect(TokenType.LParen)
261
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
262
+ this.skipTrivia()
263
+ if (this.check(TokenType.CamelIdent)) {
264
+ interfaceMethods.push(this.parseMethod())
265
+ } else if (!this.check(TokenType.RParen)) {
266
+ this.advance()
267
+ }
268
+ }
269
+ this.expect(TokenType.RParen)
270
+ } else if (!this.check(TokenType.RParen)) {
271
+ this.advance()
272
+ }
273
+ }
274
+
275
+ this.expect(TokenType.RParen)
276
+ return { kind: 'System', token: tok, name, description, modules, interfaceMethods }
277
+ }
278
+
279
+ // ── Module ─────────────────────────────────────────────────────────────────
280
+
281
+ /**
282
+ * Parses:
283
+ * module ModuleName "description" (
284
+ * process ... ( when ... )
285
+ * start StateName
286
+ * implements Module.Handler ( methodName param(Type) ( if ... ) )
287
+ * system.Module.subscribeRoute ...
288
+ * interface HandlerName ( ... )
289
+ * )
290
+ */
291
+ private parseModule(): ModuleNode {
292
+ const tok = this.expect(TokenType.Module)!
293
+ this.skipTrivia()
294
+ const name = this.expectIdent('module name')
295
+ this.skipTrivia()
296
+ const description = this.parseOptionalString()
297
+ this.skipTrivia()
298
+ this.expect(TokenType.LParen)
299
+
300
+ const processes: ProcessNode[] = []
301
+ let startState: string | null = null
302
+ const implementations: ImplementsHandlerNode[] = []
303
+ const subscriptions: CallExpressionNode[] = []
304
+ const inlineInterfaces: InterfaceNode[] = []
305
+
306
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
307
+ this.skipTrivia()
308
+ if (this.check(TokenType.Process)) {
309
+ processes.push(this.parseProcess())
310
+ } else if (this.check(TokenType.Start)) {
311
+ this.advance()
312
+ this.skipTrivia()
313
+ startState = this.expectIdent('start state name')
314
+ } else if (this.check(TokenType.Implements)) {
315
+ implementations.push(this.parseImplements())
316
+ } else if (this.check(TokenType.Interface)) {
317
+ inlineInterfaces.push(this.parseInterface())
318
+ } else if (this.checkSystemCall()) {
319
+ subscriptions.push(this.parseSystemCall())
320
+ } else if (!this.check(TokenType.RParen)) {
321
+ this.advance()
322
+ }
323
+ }
324
+
325
+ this.expect(TokenType.RParen)
326
+ return {
327
+ kind: 'Module', token: tok, name, description,
328
+ processes, startState,
329
+ implements: implementations,
330
+ subscriptions, inlineInterfaces,
331
+ }
332
+ }
333
+
334
+ // ── Process ────────────────────────────────────────────────────────────────
335
+
336
+ /**
337
+ * Parses:
338
+ * process ProcessName "description" (
339
+ * when State returns Context
340
+ * enter NextState "narrative"
341
+ * ...
342
+ * )
343
+ */
344
+ private parseProcess(): ProcessNode {
345
+ const tok = this.expect(TokenType.Process)!
346
+ this.skipTrivia()
347
+ const name = this.expectIdent('process name')
348
+ this.skipTrivia()
349
+ const description = this.parseOptionalString()
350
+ this.skipTrivia()
351
+ this.expect(TokenType.LParen)
352
+
353
+ const rules: WhenRuleNode[] = []
354
+
355
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
356
+ this.skipTrivia()
357
+ if (this.check(TokenType.When)) {
358
+ rules.push(this.parseWhenRule())
359
+ } else if (!this.check(TokenType.RParen)) {
360
+ this.advance()
361
+ }
362
+ }
363
+
364
+ this.expect(TokenType.RParen)
365
+ return { kind: 'Process', token: tok, name, description, rules }
366
+ }
367
+
368
+ /**
369
+ * Parses:
370
+ * when CurrentState returns ProducedContext
371
+ * enter NextState "narrative" ( inlineContext? )
372
+ */
373
+ private parseWhenRule(): WhenRuleNode {
374
+ const tok = this.expect(TokenType.When)!
375
+ this.skipTrivia()
376
+ const currentState = this.expectIdent('state name in when rule')
377
+ this.skipTrivia()
378
+
379
+ if (!this.check(TokenType.Returns)) {
380
+ this.error(DiagnosticCode.P_MISSING_RETURNS, `Expected 'returns' in when rule`, this.current())
381
+ } else {
382
+ this.advance()
383
+ }
384
+
385
+ this.skipTrivia()
386
+ const producedContext = this.expectIdent('context name in when rule')
387
+ this.skipTrivia()
388
+
389
+ if (!this.check(TokenType.Enter)) {
390
+ this.error(DiagnosticCode.P_MISSING_ENTER, `Expected 'enter' in when rule`, this.current())
391
+ } else {
392
+ this.advance()
393
+ }
394
+
395
+ this.skipTrivia()
396
+ const nextState = this.expectIdent('next state name in when rule')
397
+ this.skipTrivia()
398
+ const narrative = this.parseOptionalString()
399
+ this.skipTrivia()
400
+
401
+ // Optional inline context construction block
402
+ let inlineContext: InlineContextNode | null = null
403
+ if (this.check(TokenType.LParen)) {
404
+ inlineContext = this.parseInlineContext()
405
+ }
406
+
407
+ return { kind: 'WhenRule', token: tok, currentState, producedContext, nextState, narrative, inlineContext }
408
+ }
409
+
410
+ /**
411
+ * Parses an inline context construction block:
412
+ * ( reason is "..." code is "..." )
413
+ */
414
+ private parseInlineContext(): InlineContextNode {
415
+ const tok = this.expect(TokenType.LParen)!
416
+ const args = this.parseArguments()
417
+ this.expect(TokenType.RParen)
418
+ return { kind: 'InlineContext', token: tok, args }
419
+ }
420
+
421
+ // ── Implements ─────────────────────────────────────────────────────────────
422
+
423
+ /**
424
+ * Parses:
425
+ * implements Module.HandlerInterface (
426
+ * methodName param(Type) (
427
+ * if param is "/path"
428
+ * enter State "narrative"
429
+ * )
430
+ * )
431
+ *
432
+ * The method name (e.g. `switch`) is a plain camelCase identifier chosen
433
+ * by the designer on the handler interface — it is not a reserved keyword.
434
+ */
435
+ private parseImplements(): ImplementsHandlerNode {
436
+ const tok = this.expect(TokenType.Implements)!
437
+ this.skipTrivia()
438
+ const interfaceName = this.parseQualifiedName()
439
+ this.skipTrivia()
440
+ this.expect(TokenType.LParen)
441
+ this.skipTrivia()
442
+
443
+ // handler method name param(Type) ( ... )
444
+ // The method name (e.g. `switch`) is a plain camelCase name chosen by the
445
+ // designer on the handler interface — it is not a reserved keyword.
446
+ // We consume the method name then the parameter name and its type.
447
+ this.expectIdent('handler method name') // e.g. 'switch' — consumed but not stored
448
+ this.skipTrivia()
449
+ const switchParam = this.expectIdent('handler method parameter name')
450
+ this.skipTrivia()
451
+ this.expect(TokenType.LParen)
452
+ const switchParamType = this.parseType()
453
+ this.expect(TokenType.RParen)
454
+ this.skipTrivia()
455
+ this.expect(TokenType.LParen)
456
+
457
+ const branches: ImplementsBranchNode[] = []
458
+
459
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
460
+ this.skipTrivia()
461
+ if (this.check(TokenType.If)) {
462
+ branches.push(this.parseImplementsBranch())
463
+ } else if (!this.check(TokenType.RParen)) {
464
+ this.advance()
465
+ }
466
+ }
467
+
468
+ this.expect(TokenType.RParen) // close method body
469
+ this.skipTrivia()
470
+ this.expect(TokenType.RParen) // close implements body
471
+
472
+ return { kind: 'ImplementsHandler', token: tok, interfaceName, switchParam, switchParamType, branches }
473
+ }
474
+
475
+ /**
476
+ * Parses one branch inside an implements handler body:
477
+ * if param is "/path"
478
+ * enter State "narrative"
479
+ */
480
+ private parseImplementsBranch(): ImplementsBranchNode {
481
+ const tok = this.expect(TokenType.If)!
482
+ this.skipTrivia()
483
+ const condition = this.parseCondition()
484
+ this.skipTrivia()
485
+ this.expect(TokenType.Enter)
486
+ this.skipTrivia()
487
+ const targetState = this.expectIdent('target state name')
488
+ this.skipTrivia()
489
+ const narrative = this.parseOptionalString()
490
+ return { kind: 'ImplementsBranch', token: tok, condition, targetState, narrative }
491
+ }
492
+
493
+ // ── State ──────────────────────────────────────────────────────────────────
494
+
495
+ /**
496
+ * Parses:
497
+ * state StateName receives ?ContextName (
498
+ * returns ContextA, ContextB
499
+ * uses screen ScreenName
500
+ * )
501
+ */
502
+ private parseState(): StateNode {
503
+ const tok = this.expect(TokenType.State)!
504
+ this.skipTrivia()
505
+ const name = this.expectIdent('state name')
506
+ this.skipTrivia()
507
+
508
+ // Optional receives clause
509
+ let receives: string | null = null
510
+ let receivesOptional = false
511
+ if (this.check(TokenType.Receives)) {
512
+ this.advance()
513
+ this.skipTrivia()
514
+ if (this.check(TokenType.Question)) {
515
+ receivesOptional = true
516
+ this.advance()
517
+ }
518
+ receives = this.expectIdent('context name in receives clause')
519
+ this.skipTrivia()
520
+ }
521
+
522
+ this.expect(TokenType.LParen)
523
+
524
+ let returns: ReturnsNode | null = null
525
+ const uses: UseEntryNode[] = []
526
+
527
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
528
+ this.skipTrivia()
529
+ if (this.check(TokenType.Returns)) {
530
+ returns = this.parseReturns()
531
+ } else if (this.check(TokenType.Uses)) {
532
+ uses.push(...this.parseUsesBlock())
533
+ } else if (!this.check(TokenType.RParen)) {
534
+ this.advance()
535
+ }
536
+ }
537
+
538
+ this.expect(TokenType.RParen)
539
+ return { kind: 'State', token: tok, module: '', name, receives, receivesOptional, returns, uses }
540
+ }
541
+
542
+ // ── Context ────────────────────────────────────────────────────────────────
543
+
544
+ /**
545
+ * Parses:
546
+ * context ContextName (
547
+ * field(Type),
548
+ * field(Type)
549
+ * )
550
+ */
551
+ private parseContext(): ContextNode {
552
+ const tok = this.expect(TokenType.Context)!
553
+ this.skipTrivia()
554
+ const name = this.expectIdent('context name')
555
+ this.skipTrivia()
556
+ this.expect(TokenType.LParen)
557
+ const fields = this.parsePropList()
558
+ this.expect(TokenType.RParen)
559
+ return { kind: 'Context', token: tok, module: '', name, fields }
560
+ }
561
+
562
+ // ── Screen ─────────────────────────────────────────────────────────────────
563
+
564
+ /**
565
+ * Parses:
566
+ * screen ScreenName "description" (
567
+ * uses ( ... )
568
+ * )
569
+ */
570
+ private parseScreen(): ScreenNode {
571
+ const tok = this.expect(TokenType.Screen)!
572
+ this.skipTrivia()
573
+ const name = this.expectIdent('screen name')
574
+ this.skipTrivia()
575
+ const description = this.parseOptionalString()
576
+ this.skipTrivia()
577
+ this.expect(TokenType.LParen)
578
+
579
+ const uses: UseEntryNode[] = []
580
+
581
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
582
+ this.skipTrivia()
583
+ if (this.check(TokenType.Uses)) {
584
+ uses.push(...this.parseUsesBlock())
585
+ } else if (!this.check(TokenType.RParen)) {
586
+ this.advance()
587
+ }
588
+ }
589
+
590
+ this.expect(TokenType.RParen)
591
+ return { kind: 'Screen', token: tok, module: '', name, description, uses }
592
+ }
593
+
594
+ // ── View ───────────────────────────────────────────────────────────────────
595
+
596
+ /**
597
+ * Parses:
598
+ * view ViewName "description" (
599
+ * props ( ... )
600
+ * state ( ... )
601
+ * uses ( ... )
602
+ * )
603
+ */
604
+ private parseView(): ViewNode {
605
+ const tok = this.expect(TokenType.View)!
606
+ this.skipTrivia()
607
+ const name = this.expectIdent('view name')
608
+ this.skipTrivia()
609
+ const description = this.parseOptionalString()
610
+ this.skipTrivia()
611
+ this.expect(TokenType.LParen)
612
+
613
+ let props: PropNode[] = []
614
+ let stateFields: PropNode[] = []
615
+ const uses: UseEntryNode[] = []
616
+
617
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
618
+ this.skipTrivia()
619
+ if (this.check(TokenType.Props)) {
620
+ this.advance(); this.skipTrivia()
621
+ this.expect(TokenType.LParen)
622
+ props = this.parsePropList()
623
+ this.expect(TokenType.RParen)
624
+ } else if (this.check(TokenType.State)) {
625
+ this.advance(); this.skipTrivia()
626
+ this.expect(TokenType.LParen)
627
+ stateFields = this.parsePropList()
628
+ this.expect(TokenType.RParen)
629
+ } else if (this.check(TokenType.Uses)) {
630
+ uses.push(...this.parseUsesBlock())
631
+ } else if (!this.check(TokenType.RParen)) {
632
+ this.advance()
633
+ }
634
+ }
635
+
636
+ this.expect(TokenType.RParen)
637
+ return { kind: 'View', token: tok, module: '', name, description, props, state: stateFields, uses }
638
+ }
639
+
640
+ // ── Provider ───────────────────────────────────────────────────────────────
641
+
642
+ /**
643
+ * Parses:
644
+ * provider ProviderName "description" (
645
+ * props ( ... )
646
+ * state ( ... )
647
+ * interface ( methods... )
648
+ * )
649
+ */
650
+ private parseProvider(): ProviderNode {
651
+ const tok = this.expect(TokenType.Provider)!
652
+ this.skipTrivia()
653
+ const name = this.expectIdent('provider name')
654
+ this.skipTrivia()
655
+ const description = this.parseOptionalString()
656
+ this.skipTrivia()
657
+ this.expect(TokenType.LParen)
658
+
659
+ let props: PropNode[] = []
660
+ let stateFields: PropNode[] = []
661
+ const methods: MethodNode[] = []
662
+
663
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
664
+ this.skipTrivia()
665
+ if (this.check(TokenType.Props)) {
666
+ this.advance(); this.skipTrivia()
667
+ this.expect(TokenType.LParen)
668
+ props = this.parsePropList()
669
+ this.expect(TokenType.RParen)
670
+ } else if (this.check(TokenType.State)) {
671
+ this.advance(); this.skipTrivia()
672
+ this.expect(TokenType.LParen)
673
+ stateFields = this.parsePropList()
674
+ this.expect(TokenType.RParen)
675
+ } else if (this.check(TokenType.Interface)) {
676
+ this.advance(); this.skipTrivia()
677
+ this.expect(TokenType.LParen)
678
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
679
+ this.skipTrivia()
680
+ if (this.check(TokenType.CamelIdent)) {
681
+ methods.push(this.parseMethod())
682
+ } else if (!this.check(TokenType.RParen)) {
683
+ this.advance()
684
+ }
685
+ }
686
+ this.expect(TokenType.RParen)
687
+ } else if (!this.check(TokenType.RParen)) {
688
+ this.advance()
689
+ }
690
+ }
691
+
692
+ this.expect(TokenType.RParen)
693
+ return { kind: 'Provider', token: tok, module: '', name, description, props, state: stateFields, methods }
694
+ }
695
+
696
+ // ── Adapter ────────────────────────────────────────────────────────────────
697
+
698
+ /**
699
+ * Parses:
700
+ * adapter AdapterName "description" (
701
+ * props ( ... )
702
+ * state ( ... )
703
+ * interface ( methods... )
704
+ * )
705
+ */
706
+ private parseAdapter(): AdapterNode {
707
+ const tok = this.expect(TokenType.Adapter)!
708
+ this.skipTrivia()
709
+ const name = this.expectIdent('adapter name')
710
+ this.skipTrivia()
711
+ const description = this.parseOptionalString()
712
+ this.skipTrivia()
713
+ this.expect(TokenType.LParen)
714
+
715
+ let props: PropNode[] = []
716
+ let stateFields: PropNode[] = []
717
+ const methods: MethodNode[] = []
718
+
719
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
720
+ this.skipTrivia()
721
+ if (this.check(TokenType.Props)) {
722
+ this.advance(); this.skipTrivia()
723
+ this.expect(TokenType.LParen)
724
+ props = this.parsePropList()
725
+ this.expect(TokenType.RParen)
726
+ } else if (this.check(TokenType.State)) {
727
+ this.advance(); this.skipTrivia()
728
+ this.expect(TokenType.LParen)
729
+ stateFields = this.parsePropList()
730
+ this.expect(TokenType.RParen)
731
+ } else if (this.check(TokenType.Interface)) {
732
+ this.advance(); this.skipTrivia()
733
+ this.expect(TokenType.LParen)
734
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
735
+ this.skipTrivia()
736
+ if (this.check(TokenType.CamelIdent)) {
737
+ methods.push(this.parseMethod())
738
+ } else if (!this.check(TokenType.RParen)) {
739
+ this.advance()
740
+ }
741
+ }
742
+ this.expect(TokenType.RParen)
743
+ } else if (!this.check(TokenType.RParen)) {
744
+ this.advance()
745
+ }
746
+ }
747
+
748
+ this.expect(TokenType.RParen)
749
+ return { kind: 'Adapter', token: tok, module: '', name, description, props, state: stateFields, methods }
750
+ }
751
+
752
+ // ── Interface ──────────────────────────────────────────────────────────────
753
+
754
+ /**
755
+ * Parses:
756
+ * interface InterfaceName "description" (
757
+ * props ( ... )
758
+ * state ( ... )
759
+ * uses ( ... )
760
+ * methodName param(Type) returns(Type) "description"
761
+ * ...
762
+ * )
763
+ *
764
+ * When used as a module-level handler interface, the body contains a
765
+ * method declaration whose body is a series of `if` branches. The method
766
+ * name (e.g. `switch`) is a plain camelCase identifier — not a keyword.
767
+ */
768
+ private parseInterface(): InterfaceNode {
769
+ const tok = this.expect(TokenType.Interface)!
770
+ this.skipTrivia()
771
+ const name = this.expectIdent('interface name')
772
+ this.skipTrivia()
773
+ const description = this.parseOptionalString()
774
+ this.skipTrivia()
775
+ this.expect(TokenType.LParen)
776
+
777
+ let props: PropNode[] = []
778
+ let stateFields: PropNode[] = []
779
+ const methods: MethodNode[] = []
780
+ const uses: UseEntryNode[] = []
781
+
782
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
783
+ this.skipTrivia()
784
+ if (this.check(TokenType.Props)) {
785
+ this.advance(); this.skipTrivia()
786
+ this.expect(TokenType.LParen)
787
+ props = this.parsePropList()
788
+ this.expect(TokenType.RParen)
789
+ } else if (this.check(TokenType.State)) {
790
+ this.advance(); this.skipTrivia()
791
+ this.expect(TokenType.LParen)
792
+ stateFields = this.parsePropList()
793
+ this.expect(TokenType.RParen)
794
+ } else if (this.check(TokenType.Uses)) {
795
+ uses.push(...this.parseUsesBlock())
796
+ } else if (this.check(TokenType.CamelIdent)) {
797
+ // Methods appear directly in the body after props.
798
+ // This includes handler interface method declarations whose body
799
+ // contains if-branches (e.g. `switch path(string) ( if ... )`).
800
+ methods.push(this.parseInterfaceMethod())
801
+ } else if (!this.check(TokenType.RParen)) {
802
+ this.advance()
803
+ }
804
+ }
805
+
806
+ this.expect(TokenType.RParen)
807
+ return { kind: 'Interface', token: tok, module: '', name, description, props, state: stateFields, methods, uses }
808
+ }
809
+
810
+ // ── Returns clause ─────────────────────────────────────────────────────────
811
+
812
+ /**
813
+ * Parses either the simple or expanded form of a returns clause.
814
+ *
815
+ * Simple: returns ContextA, ContextB
816
+ * Expanded: returns ( ContextA ( sideEffects ) ContextB ( sideEffects ) )
817
+ */
818
+ private parseReturns(): ReturnsNode {
819
+ const tok = this.expect(TokenType.Returns)!
820
+ this.skipTrivia()
821
+
822
+ // Expanded form starts with '('
823
+ if (this.check(TokenType.LParen)) {
824
+ this.advance()
825
+ const entries: ExpandedReturnNode[] = []
826
+
827
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
828
+ this.skipTrivia()
829
+ if (this.check(TokenType.PascalIdent)) {
830
+ const ctxTok = this.current()
831
+ const contextName = this.advance().value
832
+ this.skipTrivia()
833
+ const sideEffects: SideEffectNode[] = []
834
+ if (this.check(TokenType.LParen)) {
835
+ this.advance()
836
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
837
+ this.skipTrivia()
838
+ if (this.checkSystemCall()) {
839
+ const callTok = this.current()
840
+ const call = this.parseQualifiedName()
841
+ this.skipTrivia()
842
+ const args = this.parseInlineArgList()
843
+ sideEffects.push({ kind: 'SideEffect', token: callTok, call, args })
844
+ } else if (!this.check(TokenType.RParen)) {
845
+ this.advance()
846
+ }
847
+ }
848
+ this.expect(TokenType.RParen)
849
+ }
850
+ entries.push({ kind: 'ExpandedReturn', token: ctxTok, contextName, sideEffects })
851
+ } else if (!this.check(TokenType.RParen)) {
852
+ this.advance()
853
+ }
854
+ }
855
+
856
+ this.expect(TokenType.RParen)
857
+ return { kind: 'ExpandedReturns', token: tok, entries } as ExpandedReturnsNode
858
+ }
859
+
860
+ // Simple form — comma-separated list of context names
861
+ const contexts: string[] = []
862
+ if (this.check(TokenType.PascalIdent)) {
863
+ contexts.push(this.advance().value)
864
+ while (this.check(TokenType.Comma)) {
865
+ this.advance()
866
+ this.skipTrivia()
867
+ if (this.check(TokenType.PascalIdent)) {
868
+ contexts.push(this.advance().value)
869
+ }
870
+ }
871
+ }
872
+
873
+ return { kind: 'SimpleReturns', token: tok, contexts } as SimpleReturnsNode
874
+ }
875
+
876
+ // ── Uses block ─────────────────────────────────────────────────────────────
877
+
878
+ /**
879
+ * Parses a `uses` block and returns its entries.
880
+ * Handles both the single-entry form and the parenthesised list form.
881
+ *
882
+ * Single: `uses screen LoginScreen`
883
+ * List: `uses ( view A, view B, if ... )`
884
+ */
885
+ private parseUsesBlock(): UseEntryNode[] {
886
+ this.expect(TokenType.Uses)
887
+ this.skipTrivia()
888
+
889
+ if (this.check(TokenType.LParen)) {
890
+ this.advance()
891
+ const entries = this.parseUseEntries()
892
+ this.expect(TokenType.RParen)
893
+ return entries
894
+ }
895
+
896
+ // Single entry without parentheses
897
+ const entry = this.parseUseEntry()
898
+ return entry ? [entry] : []
899
+ }
900
+
901
+ /**
902
+ * Parses a comma-separated sequence of use entries inside a `uses ( ... )` block.
903
+ */
904
+ private parseUseEntries(): UseEntryNode[] {
905
+ const entries: UseEntryNode[] = []
906
+ let lastPos = -1
907
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
908
+ if (this.pos === lastPos) { this.advance(); continue }
909
+ lastPos = this.pos
910
+ this.skipTrivia()
911
+ if (this.check(TokenType.RParen)) break
912
+ const entry = this.parseUseEntry()
913
+ if (entry) entries.push(entry)
914
+ this.skipTrivia()
915
+ if (this.check(TokenType.Comma)) this.advance()
916
+ }
917
+ return entries
918
+ }
919
+
920
+ /**
921
+ * Parses a single use entry — a component use, a conditional block, or
922
+ * an iteration block.
923
+ */
924
+ private parseUseEntry(): UseEntryNode | null {
925
+ this.skipTrivia()
926
+ const tok = this.current()
927
+
928
+ if (this.check(TokenType.If)) return this.parseConditionalBlock()
929
+ if (this.check(TokenType.For)) return this.parseIterationBlock()
930
+
931
+ // Component kinds: screen, view, adapter, provider, interface
932
+ if (
933
+ this.check(TokenType.Screen) ||
934
+ this.check(TokenType.View) ||
935
+ this.check(TokenType.Adapter) ||
936
+ this.check(TokenType.Provider) ||
937
+ this.check(TokenType.Interface)
938
+ ) {
939
+ return this.parseComponentUse()
940
+ }
941
+
942
+ this.error(
943
+ DiagnosticCode.P_INVALID_CONSTRUCT_POSITION,
944
+ `Unexpected token '${tok.value}' inside uses block`,
945
+ tok
946
+ )
947
+ this.advance()
948
+ return null
949
+ }
950
+
951
+ /**
952
+ * Parses a single component use:
953
+ * view UIModule.LoginForm ( args... uses... )
954
+ * adapter SessionAdapter.checkSession
955
+ * screen LoginScreen
956
+ */
957
+ private parseComponentUse(): ComponentUseNode {
958
+ const tok = this.current()
959
+ const componentKind = tok.value as ComponentUseNode['componentKind']
960
+ this.advance()
961
+ this.skipTrivia()
962
+
963
+ const name = this.parseQualifiedName()
964
+ this.skipTrivia()
965
+
966
+ const args: ArgumentNode[] = []
967
+ const uses: UseEntryNode[] = []
968
+
969
+ // Arguments and nested uses can appear either inline or in a paren block
970
+ if (this.check(TokenType.LParen)) {
971
+ this.advance()
972
+ // Inside the paren block: arguments and nested use entries
973
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
974
+ this.skipTrivia()
975
+ if (this.check(TokenType.Uses)) {
976
+ uses.push(...this.parseUsesBlock())
977
+ } else if (this.checkArgumentStart()) {
978
+ args.push(...this.parseArguments())
979
+ } else if (this.isUseEntry()) {
980
+ const entry = this.parseUseEntry()
981
+ if (entry) uses.push(entry)
982
+ } else if (!this.check(TokenType.RParen)) {
983
+ this.advance()
984
+ }
985
+ }
986
+ this.expect(TokenType.RParen)
987
+ } else {
988
+ // Inline arguments without parens: view X key is value, key is value
989
+ if (this.checkArgumentStart()) {
990
+ args.push(...this.parseInlineArgList())
991
+ }
992
+ }
993
+
994
+ return { kind: 'ComponentUse', token: tok, componentKind, name, args, uses }
995
+ }
996
+
997
+ /**
998
+ * Parses:
999
+ * if condition ( useEntries... )
1000
+ */
1001
+ private parseConditionalBlock(): ConditionalBlockNode {
1002
+ const tok = this.expect(TokenType.If)!
1003
+ this.skipTrivia()
1004
+ const condition = this.parseCondition()
1005
+ this.skipTrivia()
1006
+ this.expect(TokenType.LParen)
1007
+ const body = this.parseUseEntries()
1008
+ this.expect(TokenType.RParen)
1009
+ return { kind: 'ConditionalBlock', token: tok, condition, body }
1010
+ }
1011
+
1012
+ /**
1013
+ * Parses:
1014
+ * for collection as binding ( useEntries... )
1015
+ * for collection as key, value ( useEntries... )
1016
+ */
1017
+ private parseIterationBlock(): IterationBlockNode {
1018
+ const tok = this.expect(TokenType.For)!
1019
+ this.skipTrivia()
1020
+ const collection = this.parseAccessExpression()
1021
+ this.skipTrivia()
1022
+ this.expect(TokenType.As)
1023
+ this.skipTrivia()
1024
+
1025
+ const bindings: string[] = []
1026
+ bindings.push(this.expectIdent('iteration binding'))
1027
+ this.skipTrivia()
1028
+ // Map iteration: two bindings separated by comma
1029
+ if (this.check(TokenType.Comma)) {
1030
+ this.advance()
1031
+ this.skipTrivia()
1032
+ bindings.push(this.expectIdent('second iteration binding'))
1033
+ this.skipTrivia()
1034
+ }
1035
+
1036
+ this.expect(TokenType.LParen)
1037
+ const body = this.parseUseEntries()
1038
+ this.expect(TokenType.RParen)
1039
+ return { kind: 'IterationBlock', token: tok, collection, bindings, body }
1040
+ }
1041
+
1042
+ // ── Condition ──────────────────────────────────────────────────────────────
1043
+
1044
+ /**
1045
+ * Parses a condition expression:
1046
+ * state.context is AccountDeauthenticated
1047
+ * state.context.status is "pending"
1048
+ * state.context is not AccountRecovered
1049
+ */
1050
+ private parseCondition(): ConditionNode {
1051
+ const tok = this.current()
1052
+ const left = this.parseAccessExpression()
1053
+ this.skipTrivia()
1054
+
1055
+ let operator: 'is' | 'is not' = 'is'
1056
+ if (this.check(TokenType.IsNot)) {
1057
+ operator = 'is not'
1058
+ this.advance()
1059
+ } else if (this.check(TokenType.Is)) {
1060
+ this.advance()
1061
+ } else {
1062
+ this.error(DiagnosticCode.P_MISSING_IS, `Expected 'is' or 'is not' in condition`, this.current())
1063
+ }
1064
+
1065
+ this.skipTrivia()
1066
+ const right = this.parseExpression()
1067
+ return { kind: 'Condition', token: tok, left, operator, right }
1068
+ }
1069
+
1070
+ // ── Arguments ──────────────────────────────────────────────────────────────
1071
+
1072
+ /**
1073
+ * Parses a comma-separated list of `name is value` arguments.
1074
+ * Used inside parenthesised component use bodies and system calls.
1075
+ */
1076
+ private parseArguments(): ArgumentNode[] {
1077
+ const args: ArgumentNode[] = []
1078
+ let lastPos = -1
1079
+ while (this.checkArgumentStart()) {
1080
+ if (this.pos === lastPos) break
1081
+ lastPos = this.pos
1082
+ args.push(this.parseArgument())
1083
+ this.skipTrivia()
1084
+ if (this.check(TokenType.Comma)) this.advance()
1085
+ this.skipTrivia()
1086
+ }
1087
+ return args
1088
+ }
1089
+
1090
+ /**
1091
+ * Parses a comma-separated list of inline `name is value` arguments
1092
+ * without an enclosing paren block. Stops at newline, `)`, or EOF.
1093
+ */
1094
+ private parseInlineArgList(): ArgumentNode[] {
1095
+ const args: ArgumentNode[] = []
1096
+ while (this.checkArgumentStart()) {
1097
+ args.push(this.parseArgument())
1098
+ this.skipTrivia()
1099
+ if (this.check(TokenType.Comma)) {
1100
+ this.advance()
1101
+ this.skipTrivia()
1102
+ } else {
1103
+ break
1104
+ }
1105
+ }
1106
+ return args
1107
+ }
1108
+
1109
+ /**
1110
+ * Parses a single argument: `name is value`
1111
+ */
1112
+ private parseArgument(): ArgumentNode {
1113
+ const tok = this.current()
1114
+ const name = this.expectIdent('argument name')
1115
+ this.skipTrivia()
1116
+ if (!this.check(TokenType.Is)) {
1117
+ this.error(DiagnosticCode.P_MISSING_IS, `Expected 'is' after argument name '${name}'`, this.current())
1118
+ } else {
1119
+ this.advance()
1120
+ }
1121
+ this.skipTrivia()
1122
+ const value = this.parseExpression()
1123
+ return { kind: 'Argument', token: tok, name, value }
1124
+ }
1125
+
1126
+ // ── Expressions ────────────────────────────────────────────────────────────
1127
+
1128
+ /**
1129
+ * Parses an expression — a value on the right-hand side of an `is` assignment
1130
+ * or a condition operand.
1131
+ *
1132
+ * Handles:
1133
+ * - Block expressions: `( state.return(x) )`
1134
+ * - state.return(): `state.return(contextName)`
1135
+ * - Access expressions: `state.context.fullName`, `props.items`
1136
+ * - Call expressions: `system.getContext(SystemUser)`
1137
+ * - Literals: `"string"`, `42`, `3.14`, `true`, `false`, `[]`, `{}`
1138
+ * - PascalCase type references: `SystemUser` (e.g. in system.getContext(SystemUser))
1139
+ */
1140
+ private parseExpression(): ExpressionNode {
1141
+ const tok = this.current()
1142
+
1143
+ // Block expression: ( ... )
1144
+ if (this.check(TokenType.LParen)) {
1145
+ return this.parseBlockExpression()
1146
+ }
1147
+
1148
+ // Literals
1149
+ if (this.check(TokenType.StringLit)) return this.parseStringLiteral()
1150
+ if (this.check(TokenType.IntegerLit)) return this.parseIntegerLiteral()
1151
+ if (this.check(TokenType.FloatLit)) return this.parseFloatLiteral()
1152
+ if (this.check(TokenType.BooleanLit)) return this.parseBooleanLiteral()
1153
+ if (this.checkListLiteral()) return this.parseListLiteral()
1154
+ if (this.checkMapLiteral()) return this.parseMapLiteral()
1155
+
1156
+ // PascalCase identifier — type reference as argument value
1157
+ // e.g. system.getContext(SystemUser)
1158
+ if (this.check(TokenType.PascalIdent)) {
1159
+ return {
1160
+ kind: 'AccessExpression',
1161
+ token: tok,
1162
+ path: [this.advance().value],
1163
+ } as AccessExpressionNode
1164
+ }
1165
+
1166
+ // Access expression or call expression starting with a camelCase name,
1167
+ // 'state', or 'system'
1168
+ if (
1169
+ this.check(TokenType.CamelIdent) ||
1170
+ this.check(TokenType.State) ||
1171
+ this.check(TokenType.System)
1172
+ ) {
1173
+ return this.parseAccessOrCall()
1174
+ }
1175
+
1176
+ // Fallback — emit error and return a dummy access expression
1177
+ this.error(
1178
+ DiagnosticCode.P_UNEXPECTED_TOKEN,
1179
+ `Unexpected token '${tok.value}' in expression`,
1180
+ tok
1181
+ )
1182
+ this.advance()
1183
+ return { kind: 'AccessExpression', token: tok, path: ['?'] } as AccessExpressionNode
1184
+ }
1185
+
1186
+ /**
1187
+ * Parses a block expression: `( statements... )`
1188
+ * The body contains either state.return() calls or assignment statements.
1189
+ */
1190
+ private parseBlockExpression(): BlockExpressionNode {
1191
+ const tok = this.expect(TokenType.LParen)!
1192
+ const statements: StatementNode[] = []
1193
+
1194
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
1195
+ this.skipTrivia()
1196
+ if (this.check(TokenType.RParen)) break
1197
+
1198
+ const stmt = this.parseStatement()
1199
+ if (stmt) statements.push(stmt)
1200
+ this.skipTrivia()
1201
+ }
1202
+
1203
+ this.expect(TokenType.RParen)
1204
+ return { kind: 'BlockExpression', token: tok, statements }
1205
+ }
1206
+
1207
+ /**
1208
+ * Parses a statement inside a block expression body.
1209
+ *
1210
+ * `state.return(contextName)` → StateReturnStatement
1211
+ * `state.fieldName is value` → AssignmentStatement
1212
+ */
1213
+ private parseStatement(): StatementNode | null {
1214
+ const tok = this.current()
1215
+
1216
+ if (this.check(TokenType.State)) {
1217
+ this.advance() // consume 'state'
1218
+ this.skipTrivia()
1219
+ this.expect(TokenType.Dot)
1220
+ this.skipTrivia()
1221
+
1222
+ // state.return(contextName)
1223
+ if (this.check(TokenType.CamelIdent) && this.current().value === 'return') {
1224
+ this.advance() // consume 'return'
1225
+ this.expect(TokenType.LParen)
1226
+ this.skipTrivia()
1227
+ const contextName = this.expectIdent('context name in state.return()')
1228
+ this.skipTrivia()
1229
+ this.expect(TokenType.RParen)
1230
+ return { kind: 'StateReturnStatement', token: tok, contextName } as StateReturnStatementNode
1231
+ }
1232
+
1233
+ // state.fieldName is value — assignment
1234
+ const fieldName = this.expectIdent('state field name')
1235
+ this.skipTrivia()
1236
+ this.expect(TokenType.Is)
1237
+ this.skipTrivia()
1238
+ const value = this.parseExpression()
1239
+ const target: AccessExpressionNode = { kind: 'AccessExpression', token: tok, path: ['state', fieldName] }
1240
+ return { kind: 'AssignmentStatement', token: tok, target, value } as AssignmentStatementNode
1241
+ }
1242
+
1243
+ // Unknown statement — skip
1244
+ this.error(DiagnosticCode.P_UNEXPECTED_TOKEN, `Unexpected token '${tok.value}' in block`, tok)
1245
+ this.advance()
1246
+ return null
1247
+ }
1248
+
1249
+ /**
1250
+ * Parses an access expression or call expression starting with a camelCase
1251
+ * name, 'state', or 'system'.
1252
+ *
1253
+ * If the path ends with `(...)`, it becomes a CallExpression.
1254
+ * If the path ends with `return(...)`, it becomes a StateReturnExpression.
1255
+ */
1256
+ private parseAccessOrCall(): ExpressionNode {
1257
+ const tok = this.current()
1258
+ const path: string[] = []
1259
+
1260
+ // Consume the first segment
1261
+ path.push(this.advance().value)
1262
+
1263
+ // Follow dot-separated segments
1264
+ while (this.check(TokenType.Dot)) {
1265
+ this.advance() // consume '.'
1266
+ if (
1267
+ this.check(TokenType.CamelIdent) ||
1268
+ this.check(TokenType.PascalIdent) ||
1269
+ this.check(TokenType.State) ||
1270
+ this.check(TokenType.System) ||
1271
+ this.check(TokenType.Context) ||
1272
+ this.check(TokenType.Returns)
1273
+ ) {
1274
+ path.push(this.advance().value)
1275
+ } else {
1276
+ break
1277
+ }
1278
+ }
1279
+
1280
+ // state.return(contextName) — special form
1281
+ if (path[0] === 'state' && path[1] === 'return' && this.check(TokenType.LParen)) {
1282
+ this.advance() // consume '('
1283
+ this.skipTrivia()
1284
+ const contextName = this.expectIdent('context name in state.return()')
1285
+ this.skipTrivia()
1286
+ this.expect(TokenType.RParen)
1287
+ return { kind: 'StateReturnExpression', token: tok, contextName } as StateReturnExpressionNode
1288
+ }
1289
+
1290
+ // Call expression: path(args...)
1291
+ // Supports both keyword-style args (`name is value`) and a single positional
1292
+ // PascalIdent type reference (e.g. `system.getContext(SystemUser)`).
1293
+ if (this.check(TokenType.LParen)) {
1294
+ this.advance() // consume '('
1295
+ const args = this.parseArguments()
1296
+ if (args.length === 0 && this.check(TokenType.PascalIdent)) {
1297
+ const valTok = this.current()
1298
+ const valName = this.advance().value
1299
+ const value: AccessExpressionNode = { kind: 'AccessExpression', token: valTok, path: [valName] }
1300
+ args.push({ kind: 'Argument', token: valTok, name: '', value })
1301
+ }
1302
+ this.expect(TokenType.RParen)
1303
+ const callee: AccessExpressionNode = { kind: 'AccessExpression', token: tok, path }
1304
+ return { kind: 'CallExpression', token: tok, callee, args } as CallExpressionNode
1305
+ }
1306
+
1307
+ // Plain access expression
1308
+ return { kind: 'AccessExpression', token: tok, path } as AccessExpressionNode
1309
+ }
1310
+
1311
+ /**
1312
+ * Parses a dot-separated access path — always returns an AccessExpressionNode.
1313
+ * Used in conditions and iteration blocks where a call is not expected.
1314
+ */
1315
+ private parseAccessExpression(): AccessExpressionNode {
1316
+ const tok = this.current()
1317
+ const path: string[] = []
1318
+
1319
+ path.push(this.advance().value)
1320
+
1321
+ while (this.check(TokenType.Dot)) {
1322
+ this.advance()
1323
+ if (
1324
+ this.check(TokenType.CamelIdent) ||
1325
+ this.check(TokenType.PascalIdent) ||
1326
+ this.check(TokenType.State) ||
1327
+ this.check(TokenType.System) ||
1328
+ this.check(TokenType.Context)
1329
+ ) {
1330
+ path.push(this.advance().value)
1331
+ } else {
1332
+ break
1333
+ }
1334
+ }
1335
+
1336
+ return { kind: 'AccessExpression', token: tok, path }
1337
+ }
1338
+
1339
+ // ── Literals ───────────────────────────────────────────────────────────────
1340
+
1341
+ private parseStringLiteral(): StringLiteralNode {
1342
+ const tok = this.advance()
1343
+ // Strip surrounding quotes from the raw token value
1344
+ const value = tok.value.slice(1, -1)
1345
+ return { kind: 'StringLiteral', token: tok, value }
1346
+ }
1347
+
1348
+ private parseIntegerLiteral(): IntegerLiteralNode {
1349
+ const tok = this.advance()
1350
+ return { kind: 'IntegerLiteral', token: tok, value: parseInt(tok.value, 10) }
1351
+ }
1352
+
1353
+ private parseFloatLiteral(): FloatLiteralNode {
1354
+ const tok = this.advance()
1355
+ return { kind: 'FloatLiteral', token: tok, value: parseFloat(tok.value) }
1356
+ }
1357
+
1358
+ private parseBooleanLiteral(): BooleanLiteralNode {
1359
+ const tok = this.advance()
1360
+ return { kind: 'BooleanLiteral', token: tok, value: tok.value === 'true' }
1361
+ }
1362
+
1363
+ private parseListLiteral(): ListLiteralNode {
1364
+ const tok = this.current()
1365
+ this.advance() // '['
1366
+ this.advance() // ']'
1367
+ return { kind: 'ListLiteral', token: tok, elements: [] }
1368
+ }
1369
+
1370
+ private parseMapLiteral(): MapLiteralNode {
1371
+ const tok = this.current()
1372
+ this.advance() // '{'
1373
+ this.advance() // '}'
1374
+ return { kind: 'MapLiteral', token: tok, entries: [] }
1375
+ }
1376
+
1377
+ // ── Props ──────────────────────────────────────────────────────────────────
1378
+
1379
+ /**
1380
+ * Parses a comma-separated list of prop declarations inside a `props` or
1381
+ * `state` or `context` block. Stops at `)` or EOF.
1382
+ *
1383
+ * Forms:
1384
+ * `name(Type)` — typed data prop
1385
+ * `name(Type) is default` — typed prop with default value
1386
+ * `name argName(Type)` — interaction prop with argument variable
1387
+ * `name` — interaction prop with no payload
1388
+ * `?name(Type)` — optional typed prop (in context fields)
1389
+ */
1390
+ private parsePropList(): PropNode[] {
1391
+ const props: PropNode[] = []
1392
+ while (!this.check(TokenType.RParen) && !this.check(TokenType.EOF)) {
1393
+ this.skipTrivia()
1394
+ if (this.check(TokenType.RParen)) break
1395
+
1396
+ const tok = this.current()
1397
+ let optional = false
1398
+
1399
+ if (this.check(TokenType.Question)) {
1400
+ optional = true
1401
+ this.advance()
1402
+ }
1403
+
1404
+ if (!this.check(TokenType.CamelIdent)) break
1405
+
1406
+ const name = this.advance().value
1407
+ this.skipTrivia()
1408
+
1409
+ let type: TypeNode | null = null
1410
+ let argName: string | null = null
1411
+ let defaultValue: LiteralNode | null = null
1412
+
1413
+ if (this.check(TokenType.LParen)) {
1414
+ // name(Type) — direct type annotation
1415
+ this.advance()
1416
+ type = this.parseType()
1417
+ this.expect(TokenType.RParen)
1418
+ optional = optional || (type.kind === 'NamedType' && type.optional)
1419
+ } else if (this.check(TokenType.CamelIdent)) {
1420
+ // name argName(Type) — interaction prop with argument variable
1421
+ argName = this.advance().value
1422
+ this.skipTrivia()
1423
+ if (this.check(TokenType.LParen)) {
1424
+ this.advance()
1425
+ type = this.parseType()
1426
+ this.expect(TokenType.RParen)
1427
+ }
1428
+ }
1429
+
1430
+ this.skipTrivia()
1431
+
1432
+ if (this.check(TokenType.Is)) {
1433
+ this.advance()
1434
+ this.skipTrivia()
1435
+ defaultValue = this.parseLiteralValue()
1436
+ }
1437
+
1438
+ props.push({ kind: 'Prop', token: tok, name, type, optional, defaultValue, argName })
1439
+
1440
+ this.skipTrivia()
1441
+ if (this.check(TokenType.Comma)) this.advance()
1442
+ }
1443
+ return props
1444
+ }
1445
+
1446
+ // ── Types ──────────────────────────────────────────────────────────────────
1447
+
1448
+ /**
1449
+ * Parses a type annotation. Called when the cursor is on the type keyword or name.
1450
+ */
1451
+ private parseType(): TypeNode {
1452
+ const tok = this.current()
1453
+
1454
+ if (this.check(TokenType.TList)) {
1455
+ this.advance()
1456
+ this.expect(TokenType.LParen)
1457
+ const elementType = this.parseType()
1458
+ this.expect(TokenType.RParen)
1459
+ return { kind: 'ListType', token: tok, elementType } as ListTypeNode
1460
+ }
1461
+
1462
+ if (this.check(TokenType.TMap)) {
1463
+ this.advance()
1464
+ this.expect(TokenType.LParen)
1465
+ const keyType = this.parseType()
1466
+ this.expect(TokenType.Comma)
1467
+ this.skipTrivia()
1468
+ const valueType = this.parseType()
1469
+ this.expect(TokenType.RParen)
1470
+ return { kind: 'MapType', token: tok, keyType, valueType } as MapTypeNode
1471
+ }
1472
+
1473
+ // Primitive types
1474
+ // Note: the lexer maps the word 'context' to TokenType.Context (the construct
1475
+ // keyword token), never to TContext. We accept both here so 'context' works
1476
+ // as a type in system interface methods (e.g. returns(context)).
1477
+ const primitiveMap: Partial<Record<TokenType, string>> = {
1478
+ [TokenType.TString]: 'string',
1479
+ [TokenType.TInteger]: 'integer',
1480
+ [TokenType.TFloat]: 'float',
1481
+ [TokenType.TBoolean]: 'boolean',
1482
+ [TokenType.TContext]: 'context',
1483
+ [TokenType.Context]: 'context',
1484
+ }
1485
+ if (tok.type in primitiveMap) {
1486
+ this.advance()
1487
+ return { kind: 'PrimitiveType', token: tok, name: primitiveMap[tok.type] } as PrimitiveTypeNode
1488
+ }
1489
+
1490
+ // Optional named type: ?Product
1491
+ let optional = false
1492
+ if (this.check(TokenType.Question)) {
1493
+ optional = true
1494
+ this.advance()
1495
+ }
1496
+
1497
+ if (this.check(TokenType.PascalIdent)) {
1498
+ const name = this.advance().value
1499
+ return { kind: 'NamedType', token: tok, name, optional } as NamedTypeNode
1500
+ }
1501
+
1502
+ this.error(DiagnosticCode.P_MISSING_TYPE, `Expected a type`, tok)
1503
+ return { kind: 'NamedType', token: tok, name: '?', optional: false } as NamedTypeNode
1504
+ }
1505
+
1506
+ // ── Methods ────────────────────────────────────────────────────────────────
1507
+
1508
+ /**
1509
+ * Parses a standard method declaration in a provider, adapter, or interface body.
1510
+ * Delegates to `parseInterfaceMethod` which also handles handler method bodies.
1511
+ */
1512
+ private parseMethod(): MethodNode {
1513
+ return this.parseInterfaceMethod()
1514
+ }
1515
+
1516
+ /**
1517
+ * Parses a method declaration that may optionally have a body block of
1518
+ * `if` branches — used for handler interface method declarations.
1519
+ *
1520
+ * Forms:
1521
+ * `methodName "description"`
1522
+ * `methodName param(Type) returns(Type) "description"`
1523
+ * `methodName param(Type) ( if param is "/path" enter State "narrative" )`
1524
+ */
1525
+ private parseInterfaceMethod(): MethodNode {
1526
+ const tok = this.current()
1527
+ const name = this.advance().value // camelIdent
1528
+ this.skipTrivia()
1529
+
1530
+ const params: PropNode[] = []
1531
+ let returnType: TypeNode | null = null
1532
+ let description: string | null = null
1533
+
1534
+ // Parse parameters: paramName(Type) ...
1535
+ while (this.check(TokenType.CamelIdent)) {
1536
+ const paramTok = this.current()
1537
+ const paramName = this.advance().value
1538
+ this.skipTrivia()
1539
+ let paramType: TypeNode | null = null
1540
+ if (this.check(TokenType.LParen)) {
1541
+ this.advance()
1542
+ paramType = this.parseType()
1543
+ this.expect(TokenType.RParen)
1544
+ }
1545
+ params.push({ kind: 'Prop', token: paramTok, name: paramName, type: paramType, optional: false, defaultValue: null, argName: null })
1546
+ this.skipTrivia()
1547
+ }
1548
+
1549
+ // Handler interface method body: ( if ... enter ... )
1550
+ // Consume and discard — the body is structural metadata, not executable logic.
1551
+ if (this.check(TokenType.LParen)) {
1552
+ this.advance()
1553
+ let depth = 1
1554
+ while (!this.check(TokenType.EOF) && depth > 0) {
1555
+ if (this.check(TokenType.LParen)) depth++
1556
+ else if (this.check(TokenType.RParen)) depth--
1557
+ if (depth > 0) this.advance()
1558
+ }
1559
+ this.expect(TokenType.RParen)
1560
+ return { kind: 'Method', token: tok, name, params, returnType: null, description: null }
1561
+ }
1562
+
1563
+ // Optional returns(Type)
1564
+ if (this.check(TokenType.Returns)) {
1565
+ this.advance()
1566
+ this.expect(TokenType.LParen)
1567
+ returnType = this.parseType()
1568
+ this.expect(TokenType.RParen)
1569
+ this.skipTrivia()
1570
+ }
1571
+
1572
+ // Optional description string
1573
+ if (this.check(TokenType.StringLit)) {
1574
+ description = this.advance().value.slice(1, -1)
1575
+ }
1576
+
1577
+ return { kind: 'Method', token: tok, name, params, returnType, description }
1578
+ }
1579
+
1580
+ // ── Qualified name ─────────────────────────────────────────────────────────
1581
+
1582
+ /**
1583
+ * Parses a dot-separated qualified name.
1584
+ * e.g. `UIModule.LoginForm`, `system.setContext`, `SessionAdapter.checkSession`
1585
+ */
1586
+ private parseQualifiedName(): QualifiedName {
1587
+ const tok = this.current()
1588
+ const parts: string[] = []
1589
+
1590
+ if (
1591
+ this.check(TokenType.PascalIdent) ||
1592
+ this.check(TokenType.CamelIdent) ||
1593
+ this.check(TokenType.System) ||
1594
+ this.check(TokenType.State)
1595
+ ) {
1596
+ parts.push(this.advance().value)
1597
+ }
1598
+
1599
+ while (this.check(TokenType.Dot)) {
1600
+ this.advance()
1601
+ if (
1602
+ this.check(TokenType.PascalIdent) ||
1603
+ this.check(TokenType.CamelIdent) ||
1604
+ this.check(TokenType.State) ||
1605
+ this.check(TokenType.System)
1606
+ ) {
1607
+ parts.push(this.advance().value)
1608
+ } else {
1609
+ break
1610
+ }
1611
+ }
1612
+
1613
+ return { kind: 'QualifiedName', token: tok, parts }
1614
+ }
1615
+
1616
+ // ── Literal value (for defaults) ───────────────────────────────────────────
1617
+
1618
+ /**
1619
+ * Parses a literal value used as a default in a prop or state declaration.
1620
+ * Handles strings, integers, floats, booleans, `[]`, and `{}`.
1621
+ */
1622
+ private parseLiteralValue(): LiteralNode {
1623
+ const tok = this.current()
1624
+ if (this.check(TokenType.StringLit)) return this.parseStringLiteral()
1625
+ if (this.check(TokenType.IntegerLit)) return this.parseIntegerLiteral()
1626
+ if (this.check(TokenType.FloatLit)) return this.parseFloatLiteral()
1627
+ if (this.check(TokenType.BooleanLit)) return this.parseBooleanLiteral()
1628
+ if (this.checkListLiteral()) return this.parseListLiteral()
1629
+ if (this.checkMapLiteral()) return this.parseMapLiteral()
1630
+ this.error(DiagnosticCode.P_UNEXPECTED_TOKEN, `Expected a literal value`, tok)
1631
+ this.advance()
1632
+ return { kind: 'StringLiteral', token: tok, value: '' }
1633
+ }
1634
+
1635
+ // ── Token stream helpers ───────────────────────────────────────────────────
1636
+
1637
+ /** Returns the token at the current position. */
1638
+ private current(): Token {
1639
+ return this.tokens[this.pos] ?? this.tokens[this.tokens.length - 1]
1640
+ }
1641
+
1642
+ /** Returns true if the current token has the given type. */
1643
+ private check(type: TokenType): boolean {
1644
+ return this.current().type === type
1645
+ }
1646
+
1647
+ /**
1648
+ * Consumes and returns the current token, advancing the position.
1649
+ */
1650
+ private advance(): Token {
1651
+ const tok = this.current()
1652
+ if (tok.type !== TokenType.EOF) this.pos++
1653
+ return tok
1654
+ }
1655
+
1656
+ /**
1657
+ * Consumes the current token if it matches `type` and returns it.
1658
+ * If it does not match, emits a diagnostic and returns null without advancing.
1659
+ */
1660
+ private expect(type: TokenType): Token | null {
1661
+ if (this.check(type)) return this.advance()
1662
+ const tok = this.current()
1663
+ this.error(
1664
+ DiagnosticCode.P_UNEXPECTED_TOKEN,
1665
+ `Expected '${type}' but found '${tok.value}'`,
1666
+ tok
1667
+ )
1668
+ return null
1669
+ }
1670
+
1671
+ /**
1672
+ * Consumes a PascalIdent or CamelIdent and returns its value.
1673
+ * Emits a diagnostic if neither is present.
1674
+ */
1675
+ private expectIdent(context: string): string {
1676
+ if (this.check(TokenType.PascalIdent) || this.check(TokenType.CamelIdent)) {
1677
+ return this.advance().value
1678
+ }
1679
+ const tok = this.current()
1680
+ this.error(
1681
+ DiagnosticCode.P_MISSING_IDENTIFIER,
1682
+ `Expected identifier (${context}) but found '${tok.value}'`,
1683
+ tok
1684
+ )
1685
+ return '?'
1686
+ }
1687
+
1688
+ /**
1689
+ * Consumes and returns the string value if the current token is a StringLit.
1690
+ * Returns null without advancing if it is not.
1691
+ */
1692
+ private parseOptionalString(): string | null {
1693
+ if (this.check(TokenType.StringLit)) {
1694
+ const val = this.advance().value
1695
+ return val.slice(1, -1)
1696
+ }
1697
+ return null
1698
+ }
1699
+
1700
+ /**
1701
+ * Skips comment tokens only.
1702
+ */
1703
+ private skipComments(): void {
1704
+ while (this.check(TokenType.Comment)) this.advance()
1705
+ }
1706
+
1707
+ /**
1708
+ * Skips comments and newlines — the whitespace between meaningful tokens.
1709
+ */
1710
+ private skipTrivia(): void {
1711
+ while (this.check(TokenType.Comment) || this.check(TokenType.Newline)) this.advance()
1712
+ }
1713
+
1714
+ /**
1715
+ * Returns true if the current token looks like a system.* call.
1716
+ */
1717
+ private checkSystemCall(): boolean {
1718
+ return this.check(TokenType.System)
1719
+ }
1720
+
1721
+ /**
1722
+ * Returns true if the current token can start an argument (`name is value`).
1723
+ * Arguments start with a camelCase identifier.
1724
+ */
1725
+ private checkArgumentStart(): boolean {
1726
+ return this.check(TokenType.CamelIdent)
1727
+ }
1728
+
1729
+ /**
1730
+ * Returns true if the current token can start a use entry (component kind,
1731
+ * if, or for).
1732
+ */
1733
+ private isUseEntry(): boolean {
1734
+ return (
1735
+ this.check(TokenType.View) ||
1736
+ this.check(TokenType.Screen) ||
1737
+ this.check(TokenType.Adapter) ||
1738
+ this.check(TokenType.Provider) ||
1739
+ this.check(TokenType.Interface) ||
1740
+ this.check(TokenType.If) ||
1741
+ this.check(TokenType.For)
1742
+ )
1743
+ }
1744
+
1745
+ /**
1746
+ * Returns true if the current two tokens form a `[` `]` list literal.
1747
+ * The lexer does not produce bracket tokens — `[]` is detected by checking
1748
+ * for an Unknown token with value `[` followed by one with value `]`.
1749
+ */
1750
+ private checkListLiteral(): boolean {
1751
+ return (
1752
+ this.current().type === TokenType.Unknown &&
1753
+ this.current().value === '[' &&
1754
+ this.tokens[this.pos + 1]?.value === ']'
1755
+ )
1756
+ }
1757
+
1758
+ /**
1759
+ * Returns true if the current two tokens form a `{` `}` map literal.
1760
+ */
1761
+ private checkMapLiteral(): boolean {
1762
+ return (
1763
+ this.current().type === TokenType.Unknown &&
1764
+ this.current().value === '{' &&
1765
+ this.tokens[this.pos + 1]?.value === '}'
1766
+ )
1767
+ }
1768
+
1769
+ /**
1770
+ * Parses a system.* call expression appearing at the statement level
1771
+ * (e.g. inside a module body or a uses block).
1772
+ */
1773
+ private parseSystemCall(): CallExpressionNode {
1774
+ const tok = this.current()
1775
+ const callee = this.parseAccessExpression()
1776
+ this.skipTrivia()
1777
+ const args = this.parseInlineArgList()
1778
+ return { kind: 'CallExpression', token: tok, callee, args }
1779
+ }
1780
+
1781
+ // ── Error recovery ─────────────────────────────────────────────────────────
1782
+
1783
+ /**
1784
+ * Emits a diagnostic at the given token's position.
1785
+ */
1786
+ private error(code: DiagnosticCode, message: string, tok: Token): void {
1787
+ const range = rangeFromToken(tok.line, tok.column, tok.value.length || 1)
1788
+ this.diagnostics.push(parseDiagnostic(code, message, 'error', range))
1789
+ }
1790
+
1791
+ /**
1792
+ * Advances past tokens until a safe synchronisation point is found.
1793
+ * Used after an unrecoverable parse error to resume at the next construct.
1794
+ * Synchronisation points: top-level keyword, closing paren, or EOF.
1795
+ */
1796
+ private synchronise(): void {
1797
+ while (!this.check(TokenType.EOF)) {
1798
+ switch (this.current().type) {
1799
+ case TokenType.System:
1800
+ case TokenType.Module:
1801
+ case TokenType.State:
1802
+ case TokenType.Context:
1803
+ case TokenType.Screen:
1804
+ case TokenType.View:
1805
+ case TokenType.Provider:
1806
+ case TokenType.Adapter:
1807
+ case TokenType.Interface:
1808
+ case TokenType.RParen:
1809
+ return
1810
+ default:
1811
+ this.advance()
1812
+ }
1813
+ }
1814
+ }
1815
+ }