volaro 0.0.2 → 0.1.0-alpha.3

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 (37) hide show
  1. package/README.md +114 -22
  2. package/bin/vl.js +466 -28
  3. package/compiler/SOURCE_INFO.json +6 -0
  4. package/compiler/SOURCE_REV +1 -0
  5. package/compiler/validator/vlcheck/__init__.py +10 -0
  6. package/compiler/validator/vlcheck/__main__.py +128 -0
  7. package/compiler/validator/vlcheck/ast_nodes.py +397 -0
  8. package/compiler/validator/vlcheck/checks.py +1227 -0
  9. package/compiler/validator/vlcheck/diagnostics.py +88 -0
  10. package/compiler/validator/vlcheck/lexer.py +343 -0
  11. package/compiler/validator/vlcheck/parser.py +1638 -0
  12. package/compiler/validator/vlcheck/project_config.py +97 -0
  13. package/compiler/validator/vlcheck/resolve.py +849 -0
  14. package/compiler/validator/vlcheck/test_ids.py +98 -0
  15. package/compiler/vlbuild/styling/README.md +39 -0
  16. package/compiler/vlbuild/styling/build-css.mjs +103 -0
  17. package/compiler/vlbuild/styling/package-lock.json +1254 -0
  18. package/compiler/vlbuild/styling/package.json +15 -0
  19. package/compiler/vlbuild/styling/test-build-css.mjs +69 -0
  20. package/compiler/vlbuild/vlbuild/__init__.py +16 -0
  21. package/compiler/vlbuild/vlbuild/__main__.py +246 -0
  22. package/compiler/vlbuild/vlbuild/assets/vlrt.css +165 -0
  23. package/compiler/vlbuild/vlbuild/assets/vlrt.js +1291 -0
  24. package/compiler/vlbuild/vlbuild/emit.py +2023 -0
  25. package/compiler/vlbuild/vlbuild/server_emit.py +1551 -0
  26. package/compiler/vlbuild/vlbuild/static_assets.py +83 -0
  27. package/compiler/vlbuild/vlbuild/style_config.py +347 -0
  28. package/compiler/vlbuild/vlbuild/styling.py +39 -0
  29. package/examples/station.vl +2 -2
  30. package/language/crib.md +131 -10
  31. package/language/spec.md +209 -7
  32. package/language/supported.md +185 -0
  33. package/lib/env.js +107 -0
  34. package/package.json +19 -2
  35. package/scripts/record-provenance.mjs +51 -0
  36. package/scripts/selftest.mjs +54 -0
  37. package/scripts/sync-compiler.sh +52 -0
@@ -0,0 +1,1638 @@
1
+ """A tolerant recursive-descent parser with a Pratt expression sub-parser.
2
+
3
+ Design choices for a Stage 1 validator:
4
+ - Never abort. On an unexpected token, record E-PARSE and resynchronize on the
5
+ next NEWLINE / DEDENT so the rest of the file still gets checked.
6
+ - Parse types, `fn` signatures, and the skeleton of views / services precisely,
7
+ because that is where the checks live.
8
+ - Parse the many free-form configuration lines (api settings, auth providers,
9
+ theme, service errors/before/after) permissively as `Directive` nodes.
10
+ - Binding powers follow spec section 5.2 ordering (range binds tighter than
11
+ comparison, looser than additive). See PROJECT.md section 5.3 for the spec's
12
+ internal disagreement here.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+
19
+ from . import ast_nodes as A
20
+ from .diagnostics import Diagnostic, Severity
21
+ from .lexer import Token
22
+
23
+ _METHODS = ("get", "post", "put", "patch", "delete")
24
+ _ASSIGN_OPS = ("=", "+=", "-=", "*=", "/=")
25
+ _IDENT_RE = re.compile(r"(?<![.\w])([A-Za-z_][A-Za-z0-9_]*)")
26
+
27
+ # binop -> (left_bp, right_bp); equal bp marks non-associative
28
+ _BINOPS = {
29
+ "|>": (1, 2),
30
+ "??": (4, 3), # right associative
31
+ "or": (5, 6),
32
+ "and": (7, 8),
33
+ "==": (9, 9), "!=": (9, 9),
34
+ "<": (11, 11), "<=": (11, 11), ">": (11, 11), ">=": (11, 11),
35
+ "..": (12, 12), "..=": (12, 12),
36
+ "+": (13, 14), "-": (13, 14),
37
+ "*": (15, 16), "/": (15, 16), "%": (15, 16),
38
+ }
39
+ _PREFIX_BP = 17
40
+ _CMP = {"<", "<=", ">", ">=", "==", "!="}
41
+
42
+
43
+ class Parser:
44
+ def __init__(self, tokens: list[Token], src: str, filename: str = "<input>") -> None:
45
+ self.toks = tokens
46
+ self.src = src
47
+ self.filename = filename
48
+ self.pos = 0
49
+ self.diags: list[Diagnostic] = []
50
+ # Mirrors the lexer's own `bracket_depth` at the token-stream level
51
+ # (same characters, so always in lockstep) — used by `_end_line` to
52
+ # tolerate a statement ending at a closing bracket / comma instead of
53
+ # a real NEWLINE, the shape a block-bodied closure's last statement
54
+ # takes when it is itself a call argument (see
55
+ # `_parse_bracketed_closure_body`, B2 in the callback-props decision).
56
+ self.bracket_depth = 0
57
+ # One-shot: set by `_parse_bracketed_closure_body` when it ends a
58
+ # block-bodied closure's block by finding a sibling call argument at
59
+ # a shallower column, having already consumed (inside that last
60
+ # statement's own `_end_line()`) the real NEWLINE token that would
61
+ # otherwise have told the enclosing call-argument loop a separator
62
+ # occurred. Read via `_take_closure_ate_sep`, which resets it.
63
+ self._closure_ate_sep = False
64
+
65
+ def _take_closure_ate_sep(self) -> bool:
66
+ v = self._closure_ate_sep
67
+ self._closure_ate_sep = False
68
+ return v
69
+
70
+ # -- cursor ----------------------------------------------------------
71
+ def peek(self, k: int = 0) -> Token:
72
+ j = self.pos + k
73
+ return self.toks[j] if j < len(self.toks) else self.toks[-1]
74
+
75
+ @property
76
+ def cur(self) -> Token:
77
+ return self.peek()
78
+
79
+ def at(self, kind: str) -> bool:
80
+ return self.cur.kind == kind
81
+
82
+ def at_kw(self, *words: str) -> bool:
83
+ return self.cur.kind == "KW" and (not words or self.cur.value in words)
84
+
85
+ def at_soft(self, *words: str) -> bool:
86
+ return self.cur.kind == "NAME" and self.cur.value in words
87
+
88
+ def at_op(self, *ops: str) -> bool:
89
+ return self.cur.kind == "OP" and self.cur.value in ops
90
+
91
+ def advance(self) -> Token:
92
+ t = self.cur
93
+ if t.kind == "OP" and t.value in "([{":
94
+ self.bracket_depth += 1
95
+ elif t.kind == "OP" and t.value in ")]}":
96
+ self.bracket_depth = max(0, self.bracket_depth - 1)
97
+ if self.pos < len(self.toks) - 1:
98
+ self.pos += 1
99
+ return t
100
+
101
+ def expect_op(self, op: str, ctx: str = "") -> Token | None:
102
+ if self.at_op(op):
103
+ return self.advance()
104
+ self.error(f"expected {op!r}{(' ' + ctx) if ctx else ''}, found {self._desc(self.cur)}",
105
+ self.cur)
106
+ return None
107
+
108
+ def error(self, msg: str, tok: Token, code: str = "E-PARSE", fix: str | None = None) -> None:
109
+ self.diags.append(Diagnostic(code, Severity.ERROR, msg, tok.start, max(tok.end, tok.start + 1),
110
+ fix, self.filename))
111
+
112
+ def _desc(self, t: Token) -> str:
113
+ if t.kind == "EOF":
114
+ return "end of file"
115
+ if t.kind == "NEWLINE":
116
+ return "end of line"
117
+ if t.kind in ("INDENT", "DEDENT"):
118
+ return t.kind.lower()
119
+ return f"{t.value!r}" if t.value else t.kind
120
+
121
+ def skip_newlines(self) -> None:
122
+ while self.at("NEWLINE"):
123
+ self.advance()
124
+
125
+ def _at_line_end(self) -> bool:
126
+ return self.cur.kind in ("NEWLINE", "DEDENT", "EOF")
127
+
128
+ def _end_line(self) -> None:
129
+ if self.at("NEWLINE"):
130
+ self.advance()
131
+ elif self.cur.kind in ("DEDENT", "EOF"):
132
+ return
133
+ elif self.bracket_depth > 0 and self.at_op(")", "]", "}", ","):
134
+ # The statement ended because the enclosing bracket closes (or the
135
+ # next comma-separated item starts) right here, not because of a
136
+ # real newline — exactly the shape of the last statement in a
137
+ # block-bodied closure passed as a call argument (see
138
+ # `_parse_bracketed_closure_body`): the lexer suppresses
139
+ # INDENT/DEDENT inside any open bracket, so there is no DEDENT to
140
+ # end it with either; `parse_stmt` still calls `_end_line`
141
+ # unconditionally, so this has to be tolerated here.
142
+ return
143
+ else:
144
+ self.error(f"expected end of line, found {self._desc(self.cur)}", self.cur)
145
+ self._recover_to_line()
146
+
147
+ def _recover_to_line(self) -> None:
148
+ while self.cur.kind not in ("EOF",):
149
+ if self.at("NEWLINE"):
150
+ self.advance()
151
+ return
152
+ if self.at("DEDENT"):
153
+ return
154
+ self.advance()
155
+
156
+ # -- blocks ----------------------------------------------------------
157
+ def parse_block(self, parse_item) -> list:
158
+ items: list = []
159
+ self.skip_newlines()
160
+ if not self.at("INDENT"):
161
+ if self.bracket_depth > 0:
162
+ # `_parse_bracketed_closure_body` (a flat statement sequence,
163
+ # tracked by source column) is the only block form available
164
+ # inside an open bracket -- the lexer never emits INDENT/
165
+ # DEDENT there, so a *nested* block (this `if`/`match`/`for`/
166
+ # `while`/`try`/`par`/`spawn` trying to open one) has nothing
167
+ # to parse. Name the real cause instead of the generic
168
+ # message, which otherwise reads as unrelated to the actual
169
+ # `fn(...)` closure a few tokens up.
170
+ self.error(
171
+ "a nested block (if / match / for / while / try / par / "
172
+ "spawn) is not supported inside a closure written "
173
+ "directly as a call argument -- only a flat sequence of "
174
+ "statements is. Bind the closure with `let` above the "
175
+ "call instead, then pass it by name.",
176
+ self.cur, code="E-CLOSURE-NESTED-BLOCK")
177
+ else:
178
+ self.error("expected an indented block", self.cur)
179
+ return items
180
+ self.advance() # INDENT
181
+ while not self.at("DEDENT") and not self.at("EOF"):
182
+ self.skip_newlines()
183
+ if self.at("DEDENT") or self.at("EOF"):
184
+ break
185
+ before = self.pos
186
+ node = parse_item()
187
+ if node is not None:
188
+ if isinstance(node, list):
189
+ items.extend(node)
190
+ else:
191
+ items.append(node)
192
+ self.skip_newlines()
193
+ if self.pos == before:
194
+ self._recover_to_line()
195
+ if self.at("DEDENT"):
196
+ self.advance()
197
+ return items
198
+
199
+ # -- module / items ----------------------------------------------------
200
+ def parse_module(self) -> A.Module:
201
+ mod = A.Module(start=0)
202
+ self.skip_newlines()
203
+ while not self.at("EOF"):
204
+ before = self.pos
205
+ item = self.parse_item()
206
+ if item is not None:
207
+ mod.items.append(item)
208
+ self.skip_newlines()
209
+ if self.pos == before:
210
+ self.advance()
211
+ mod.end = self.cur.end
212
+ return mod
213
+
214
+ def parse_item(self):
215
+ t = self.cur
216
+ if self.at_kw("use"):
217
+ return self.parse_use()
218
+ if self.at_kw("type"):
219
+ return self.parse_type_decl()
220
+ if self.at_kw("fn"):
221
+ return self.parse_fn_decl()
222
+ if self.at_soft("view"):
223
+ return self.parse_view_decl()
224
+ if self.at_soft("api"):
225
+ return self.parse_api_decl()
226
+ if self.at_soft("service"):
227
+ return self.parse_service_decl()
228
+ if self.at_kw("auth"):
229
+ return self.parse_auth_decl()
230
+ if self.at_soft("model"):
231
+ return self.parse_model_decl()
232
+ if self.at_soft("theme"):
233
+ return self.parse_theme_decl()
234
+ if self.at_soft("recipe"):
235
+ return self.parse_recipe_decl()
236
+ if self.at_soft("page"):
237
+ return self.parse_page_decl()
238
+ if self.at_kw("let") or self.at_soft("state") or self.at_soft("derive"):
239
+ self.error(
240
+ f"'{t.value}' binds a value and a module holds declarations only",
241
+ t, fix="move it inside a view or fn; for a shared value use a "
242
+ "zero-parameter fn, and for config use volaro.toml / env")
243
+ self._recover_to_line()
244
+ return None
245
+ self.error(f"unexpected {self._desc(t)} at top level", t)
246
+ self._recover_to_line()
247
+ return None
248
+
249
+ def parse_use(self) -> A.Use:
250
+ start = self.advance().start
251
+ parts: list[str] = []
252
+ # A module filename may coincide with a language keyword (`auth.vl` is
253
+ # the project's own shared authentication declaration). Once `use` has
254
+ # started, keyword tokens are path text too; punctuation/newline still
255
+ # delimit the path unambiguously.
256
+ while (self.cur.kind in ("NAME", "TYPENAME", "PRIM", "KW")
257
+ or self.at_op(".", "..", "/")):
258
+ parts.append(self.advance().value)
259
+ names: list[str] = []
260
+ if self.at_op("{"):
261
+ self.advance()
262
+ while not self.at_op("}") and not self.at("EOF"):
263
+ if self.cur.kind in ("NAME", "TYPENAME"):
264
+ names.append(self.advance().value)
265
+ if self.at_op(","):
266
+ self.advance()
267
+ else:
268
+ break
269
+ self.expect_op("}")
270
+ end = self.cur.end
271
+ self._end_line()
272
+ return A.Use(start=start, end=end, path="".join(parts), names=names)
273
+
274
+ # -- type / model ----------------------------------------------------
275
+ def parse_type_decl(self) -> A.TypeDecl:
276
+ start = self.advance().start
277
+ name = self.advance().value if self.at("TYPENAME") else "?"
278
+ node = A.TypeDecl(start=start, name=name)
279
+ if self.at_op("="):
280
+ self.advance()
281
+ if self.at("NEWLINE"):
282
+ node.kind = "union"
283
+ node.variants = self.parse_block(self.parse_variant_block)
284
+ return node
285
+ tref = self.parse_type_ref()
286
+ if self.at_op("|"):
287
+ node.kind = "union"
288
+ node.variants.append(A.Variant(start=tref.start, name=tref.name))
289
+ while self.at_op("|"):
290
+ self.advance()
291
+ node.variants.append(self.parse_variant_inline())
292
+ self._end_line()
293
+ else:
294
+ node.kind = "alias"
295
+ node.alias_of = tref
296
+ if self.at_soft("distinct"):
297
+ self.advance()
298
+ node.distinct = True
299
+ self._end_line()
300
+ return node
301
+ node.kind = "record"
302
+ node.fields = self.parse_block(self.parse_field)
303
+ node.end = self.cur.end
304
+ return node
305
+
306
+ def parse_variant_block(self):
307
+ if self.cur.kind not in ("NAME", "TYPENAME"):
308
+ self.error("expected a variant name", self.cur)
309
+ self._recover_to_line()
310
+ return None
311
+ v = A.Variant(start=self.cur.start, name=self.advance().value)
312
+ if self.at_op("("):
313
+ self.advance()
314
+ v.params = self._param_list_until(")")
315
+ self.expect_op(")")
316
+ self._end_line()
317
+ return v
318
+
319
+ def parse_variant_inline(self) -> A.Variant:
320
+ v = A.Variant(start=self.cur.start,
321
+ name=self.advance().value if self.cur.kind in ("NAME", "TYPENAME") else "?")
322
+ if self.at_op("("):
323
+ self.advance()
324
+ v.params = self._param_list_until(")")
325
+ self.expect_op(")")
326
+ return v
327
+
328
+ def parse_field(self):
329
+ # field / param names may be keywords (e.g. `role`, `type`) per spec section 4.3
330
+ if self.cur.kind not in ("NAME", "KW"):
331
+ self.error("expected a field name", self.cur)
332
+ self._recover_to_line()
333
+ return None
334
+ f = A.Field(start=self.cur.start, name=self.advance().value)
335
+ if not self._at_line_end() and not self.at_op("="):
336
+ f.type = self.parse_type_ref()
337
+ if self.at_op("="):
338
+ self.advance()
339
+ f.default = self.parse_expr()
340
+ self._end_line()
341
+ return f
342
+
343
+ def parse_model_decl(self) -> A.ModelDecl:
344
+ start = self.advance().start
345
+ name = self.advance().value if self.at("TYPENAME") else "?"
346
+ node = A.ModelDecl(start=start, name=name)
347
+
348
+ def item():
349
+ if self.at_soft("index"):
350
+ self.advance()
351
+ cols: list[str] = []
352
+ if self.at_op("("):
353
+ self.advance()
354
+ while not self.at_op(")") and not self.at("EOF"):
355
+ if self.at("NAME"):
356
+ cols.append(self.advance().value)
357
+ if self.at_soft("desc"):
358
+ self.advance()
359
+ if self.at_op(","):
360
+ self.advance()
361
+ else:
362
+ break
363
+ self.expect_op(")")
364
+ self._end_line()
365
+ node.indexes.append(cols)
366
+ return None
367
+ if self.cur.kind not in ("NAME", "KW"):
368
+ self.error("expected a model field", self.cur)
369
+ self._recover_to_line()
370
+ return None
371
+ f = A.Field(start=self.cur.start, name=self.advance().value)
372
+ if not self._at_line_end() and not self.at_op("="):
373
+ f.type = self.parse_type_ref()
374
+ while not self._at_line_end():
375
+ if self.at_op("="):
376
+ self.advance()
377
+ f.default = self.parse_expr()
378
+ continue
379
+ if self.at("NAME"):
380
+ a = self.advance().value
381
+ if self.at_op(":"):
382
+ self.advance()
383
+ val: list[str] = []
384
+ while self.cur.kind in ("NAME", "TYPENAME") or self.at_op("."):
385
+ val.append(self.advance().value)
386
+ f.attrs.append(f"{a}:{''.join(val)}")
387
+ else:
388
+ f.attrs.append(a)
389
+ continue
390
+ self.error(f"unexpected {self._desc(self.cur)} in model field", self.cur)
391
+ self._recover_to_line()
392
+ break
393
+ self._end_line()
394
+ node.fields.append(f)
395
+ return None
396
+
397
+ self.parse_block(item)
398
+ node.end = self.cur.end
399
+ return node
400
+
401
+ # -- type refs -----------------------------------------------------
402
+ def parse_type_ref(self) -> A.TypeRef:
403
+ start = self.cur.start
404
+ if self.at_kw("fn"):
405
+ self.advance()
406
+ self.expect_op("(")
407
+ fps = []
408
+ while not self.at_op(")") and not self.at("EOF") and not self.at("NEWLINE"):
409
+ fps.append(self.parse_type_ref())
410
+ if self.at_op(","):
411
+ self.advance()
412
+ else:
413
+ break
414
+ self.expect_op(")")
415
+ ret = self.parse_type_ref()
416
+ return A.TypeRef(start=start, end=self.cur.end, is_fn=True, fn_params=fps, fn_ret=ret)
417
+
418
+ if self.cur.kind in ("PRIM", "TYPENAME", "NAME"):
419
+ tr = A.TypeRef(start=start, name=self.advance().value)
420
+ if self.at_op("<"):
421
+ self.advance()
422
+ while not self.at_op(">") and not self.at("EOF") and not self.at("NEWLINE"):
423
+ tr.args.append(self.parse_type_ref())
424
+ if self.at_op(","):
425
+ self.advance()
426
+ else:
427
+ break
428
+ self.expect_op(">")
429
+ elif self.at("NIL"):
430
+ self.advance()
431
+ tr = A.TypeRef(start=start, name="nil")
432
+ else:
433
+ self.error(f"expected a type, found {self._desc(self.cur)}", self.cur)
434
+ return A.TypeRef(start=start, end=self.cur.end, name="?")
435
+
436
+ while True:
437
+ if self.at_op("??"): # lexer glues '?' '?' into one token
438
+ self.advance()
439
+ tr.optional = True
440
+ tr.opt_nested = True
441
+ continue
442
+ if self.at_op("?"):
443
+ self.advance()
444
+ if tr.optional:
445
+ tr.opt_nested = True
446
+ tr.optional = True
447
+ if self.at_op("?"):
448
+ tr.opt_nested = True
449
+ self.advance()
450
+ continue
451
+ if self.at_op("!"):
452
+ self.advance()
453
+ tr.fallible = True
454
+ if self.cur.kind in ("TYPENAME", "PRIM"):
455
+ tr.err_type = self.parse_type_ref()
456
+ continue
457
+ break
458
+ tr.end = self.cur.end
459
+ return tr
460
+
461
+ # -- params ------------------------------------------------------------
462
+ def _param_list_until(self, closer: str) -> list:
463
+ params = []
464
+ self.skip_newlines() # a param list may span lines inside ( )
465
+ while not self.at_op(closer) and not self.at("EOF"):
466
+ if not self.at("NAME"):
467
+ self.error(f"expected a parameter name, found {self._desc(self.cur)}", self.cur)
468
+ break
469
+ p = A.Param(start=self.cur.start, name=self.advance().value)
470
+ if not self.at_op(",", closer, "="):
471
+ p.type = self.parse_type_ref()
472
+ if self.at_op("="):
473
+ self.advance()
474
+ p.default = self.parse_expr()
475
+ params.append(p)
476
+ self.skip_newlines()
477
+ if self.at_op(","):
478
+ self.advance()
479
+ self.skip_newlines()
480
+ else:
481
+ break
482
+ return params
483
+
484
+ # -- functions -----------------------------------------------------
485
+ def parse_fn_decl(self) -> A.FnDecl:
486
+ start = self.advance().start
487
+ name = self.advance().value if self.at("NAME") else "?"
488
+ fn = A.FnDecl(start=start, name=name)
489
+ self.expect_op("(")
490
+ fn.params = self._param_list_until(")")
491
+ self.expect_op(")")
492
+ if not self._at_line_end():
493
+ fn.ret = self.parse_type_ref()
494
+ fn.body = self.parse_block(self.parse_stmt)
495
+ fn.end = self.cur.end
496
+ return fn
497
+
498
+ # -- views -------------------------------------------------------
499
+ def parse_view_decl(self) -> A.ViewDecl:
500
+ start = self.advance().start
501
+ name = self.advance().value if self.at("TYPENAME") else "?"
502
+ v = A.ViewDecl(start=start, name=name)
503
+ self.expect_op("(")
504
+ v.params = self._param_list_until(")")
505
+ self.expect_op(")")
506
+ v.body = self.parse_block(self.parse_view_stmt)
507
+ v.end = self.cur.end
508
+ return v
509
+
510
+ def parse_view_stmt(self):
511
+ t = self.cur
512
+ # `state` / `load` are contextual: keywords only as the head of a view
513
+ # statement (`state x = ...`), plain identifiers otherwise (`state = x`,
514
+ # `load(id)`).
515
+ if self.at_soft("state") and self.peek(1).kind == "NAME":
516
+ self.advance()
517
+ n = self.advance().value if self.at("NAME") else "?"
518
+ typ = None
519
+ if not self.at_op("="):
520
+ typ = self.parse_type_ref()
521
+ self.expect_op("=")
522
+ init = self.parse_expr()
523
+ self._end_line()
524
+ return A.StateDecl(start=t.start, name=n, type=typ, init=init)
525
+ if self.at_kw("derive"):
526
+ self.advance()
527
+ n = self.advance().value if self.at("NAME") else "?"
528
+ self.expect_op("=")
529
+ e = self.parse_expr()
530
+ self._end_line()
531
+ return A.DeriveDecl(start=t.start, name=n, expr=e)
532
+ if self.at_soft("load") and self.peek(1).kind == "NAME":
533
+ return self.parse_load()
534
+ if self.at_kw("on"):
535
+ return self.parse_on_block()
536
+ if self.at_soft("list") and self._is_list_block():
537
+ return self.parse_list_block()
538
+ if self.at_kw("if"):
539
+ return self.parse_if(self.parse_view_stmt)
540
+ if self.at_kw("match"):
541
+ return self.parse_match_stmt(self.parse_view_stmt)
542
+ if self.cur.kind in ("NAME", "TYPENAME") and not self.at_kw():
543
+ if self.at("NAME") and self.peek(1).kind == "OP" and self.peek(1).value in _ASSIGN_OPS:
544
+ return self.parse_stmt()
545
+ return self.parse_element()
546
+ return self.parse_stmt()
547
+
548
+ def _is_list_block(self) -> bool:
549
+ j = self.pos + 1
550
+ depth = 0
551
+ while j < len(self.toks):
552
+ tk = self.toks[j]
553
+ if tk.kind in ("NEWLINE", "EOF", "INDENT", "DEDENT"):
554
+ return False
555
+ if tk.kind == "OP" and tk.value in "([{":
556
+ depth += 1
557
+ elif tk.kind == "OP" and tk.value in ")]}":
558
+ depth -= 1
559
+ elif depth == 0 and tk.kind == "KW" and tk.value == "as":
560
+ return True
561
+ j += 1
562
+ return False
563
+
564
+ def parse_list_block(self) -> A.ListBlock:
565
+ start = self.advance().start # 'list'
566
+ src = self.parse_expr()
567
+ if self.at_kw("as"):
568
+ self.advance()
569
+ else:
570
+ self.error("expected 'as' in list block", self.cur)
571
+ var = self.advance().value if self.at("NAME") else "?"
572
+ node = A.ListBlock(start=start, src=src, var=var)
573
+ if self.at_soft("key"):
574
+ self.advance()
575
+ if self.at_op(":"):
576
+ self.advance()
577
+ node.key = self.parse_expr()
578
+ node.has_key = True
579
+ else:
580
+ self.error("expected ':' after 'key'", self.cur)
581
+ node.end = self.cur.end
582
+ self.skip_newlines()
583
+ if self.at("INDENT"):
584
+ node.body = self.parse_block(self.parse_view_stmt)
585
+ else:
586
+ self._end_line()
587
+ return node
588
+
589
+ def parse_load(self) -> A.LoadDecl:
590
+ start = self.advance().start
591
+ n = self.advance().value if self.at("NAME") else "?"
592
+ self.expect_op("=")
593
+ e = self.parse_expr()
594
+ node = A.LoadDecl(start=start, name=n, expr=e)
595
+ node.end = self.cur.end
596
+ self.skip_newlines()
597
+ if self.at("INDENT"):
598
+ self.advance()
599
+ while not self.at("DEDENT") and not self.at("EOF"):
600
+ self.skip_newlines()
601
+ if self.at("DEDENT"):
602
+ break
603
+ if self.at_soft("pending"):
604
+ self.advance()
605
+ node.pending = self.parse_block(self.parse_view_stmt)
606
+ elif self.at_soft("error"):
607
+ self.advance()
608
+ if self.at("NAME"):
609
+ node.error_name = self.advance().value
610
+ node.error_body = self.parse_block(self.parse_view_stmt)
611
+ else:
612
+ self.error("expected 'pending' or 'error' in load block", self.cur)
613
+ self._recover_to_line()
614
+ self.skip_newlines()
615
+ if self.at("DEDENT"):
616
+ self.advance()
617
+ else:
618
+ self._end_line()
619
+ return node
620
+
621
+ def parse_on_block(self) -> A.OnBlock:
622
+ start = self.advance().start # 'on'
623
+ ev = self.advance().value if self.cur.kind in ("NAME", "KW") else "?"
624
+ args: list[str] = []
625
+ while self.at("NAME"):
626
+ args.append(self.advance().value)
627
+ if self.at_op(","):
628
+ self.advance()
629
+ else:
630
+ break
631
+ node = A.OnBlock(start=start, event=ev, args=args)
632
+ if self.at_op(":"): # inline form: `on tap: <stmt>`
633
+ self.advance()
634
+ node.body = self._inline_handler_body()
635
+ self._end_line()
636
+ else:
637
+ node.body = self.parse_block(self.parse_stmt)
638
+ node.end = self.cur.end
639
+ return node
640
+
641
+ def _inline_handler_body(self) -> list:
642
+ """One statement after `on <event>:` — an expression or an assignment
643
+ (`on tap: open = false`)."""
644
+ start = self.cur.start
645
+ expr = self.parse_expr()
646
+ if self.at_op(*_ASSIGN_OPS):
647
+ op = self.advance().value
648
+ return [A.Assign(start=start, target=expr, op=op, value=self.parse_expr())]
649
+ return [A.ExprStmt(start=start, expr=expr)]
650
+
651
+ def parse_element(self) -> A.Element:
652
+ start = self.cur.start
653
+ name = self.advance().value
654
+ el = A.Element(start=start, name=name)
655
+
656
+ def is_attr() -> bool:
657
+ # `name:` where name is an identifier or a keyword like `type` / `on`
658
+ return (self.cur.kind in ("NAME", "KW")
659
+ and not self.at_kw("on", "if", "match")
660
+ and self.peek(1).kind == "OP" and self.peek(1).value == ":")
661
+
662
+ if self.at_op("("):
663
+ self.advance()
664
+ self.skip_newlines() # attrs may open onto their own lines
665
+ while not self.at_op(")") and not self.at("EOF"):
666
+ if is_attr():
667
+ an = self.advance().value
668
+ self.advance()
669
+ el.attrs.append((an, self.parse_expr()))
670
+ else:
671
+ el.args.append(self.parse_expr())
672
+ # spec 3.7: a bare newline also separates items inside an open
673
+ # bracket, matching `_parse_postfix`'s own call-argument loop
674
+ # -- needed for a component with more than one callback prop
675
+ # (DESIGN-01), each written as its own multi-line closure, with
676
+ # no comma between them. `_take_closure_ate_sep` reads (and
677
+ # resets) the one-shot flag `_parse_bracketed_closure_body`
678
+ # sets when a closure attr's own last statement already
679
+ # consumed the newline that would otherwise separate it here.
680
+ had_nl = self.at("NEWLINE") or self._take_closure_ate_sep()
681
+ self.skip_newlines()
682
+ if self.at_op(","):
683
+ self.advance()
684
+ self.skip_newlines()
685
+ elif not had_nl and not self.at_op(")"):
686
+ break
687
+ self.expect_op(")")
688
+
689
+ while not self._at_line_end():
690
+ if is_attr():
691
+ an = self.advance().value
692
+ self.advance()
693
+ el.attrs.append((an, self.parse_expr()))
694
+ continue
695
+ if self.at_kw("on"):
696
+ self.advance()
697
+ ev = self.advance().value if self.cur.kind in ("NAME", "KW") else "?"
698
+ on_args: list[str] = []
699
+ while self.at("NAME"):
700
+ on_args.append(self.advance().value)
701
+ if self.at_op(","):
702
+ self.advance()
703
+ else:
704
+ break
705
+ if self.at_op(":"): # `... on tap: <stmt>`
706
+ self.advance()
707
+ el.body.append(A.OnBlock(start=start, event=ev, args=on_args,
708
+ body=self._inline_handler_body()))
709
+ continue
710
+ # `... on tap` then an indented block — terminates the element line
711
+ ob = A.OnBlock(start=start, event=ev, args=on_args)
712
+ if self.at("NEWLINE"):
713
+ self.advance()
714
+ ob.body = self.parse_block(self.parse_stmt) if self.at("INDENT") else []
715
+ el.body.append(ob)
716
+ el.end = self.cur.end
717
+ return el
718
+ if self.at_kw("if", "match"):
719
+ break
720
+ before = self.pos
721
+ el.args.append(self.parse_expr())
722
+ if self.pos == before:
723
+ self.advance()
724
+ break
725
+
726
+ el.end = self.cur.end
727
+ if self.at("NEWLINE"):
728
+ self.advance()
729
+ if self.at("INDENT"):
730
+ el.body = list(el.body) + self.parse_block(self.parse_view_stmt)
731
+ return el
732
+
733
+ # -- api / service / auth / theme ----------------------------------------
734
+ def parse_api_decl(self) -> A.ApiDecl:
735
+ start = self.advance().start
736
+ name = self.advance().value if self.cur.kind in ("NAME", "KW") else "?"
737
+ node = A.ApiDecl(start=start, name=name)
738
+ if self.at_soft("from"):
739
+ self.advance()
740
+ node.from_expr = self.parse_expr()
741
+
742
+ def item():
743
+ if self.at("NAME") and self.cur.value in _METHODS:
744
+ return self.parse_endpoint()
745
+ return self.parse_directive()
746
+
747
+ for r in self.parse_block(item):
748
+ if isinstance(r, A.Endpoint):
749
+ node.endpoints.append(r)
750
+ elif r is not None:
751
+ node.settings.append(r)
752
+ node.end = self.cur.end
753
+ return node
754
+
755
+ def parse_endpoint(self) -> A.Endpoint:
756
+ start = self.cur.start
757
+ method = self.advance().value
758
+ name = self.advance().value if self.at("NAME") else "?"
759
+ ep = A.Endpoint(start=start, method=method, name=name)
760
+ self.expect_op("(")
761
+ ep.params = self._param_list_until(")")
762
+ self.expect_op(")")
763
+ if not self._at_line_end():
764
+ ep.ret = self.parse_type_ref()
765
+ self.skip_newlines()
766
+ if self.at("INDENT"):
767
+ ep.settings = self.parse_block(self.parse_directive)
768
+ else:
769
+ self._end_line()
770
+ ep.end = self.cur.end
771
+ return ep
772
+
773
+ def parse_service_decl(self) -> A.ServiceDecl:
774
+ start = self.advance().start
775
+ name = self.advance().value if self.cur.kind in ("NAME", "KW") else "?"
776
+ node = A.ServiceDecl(start=start, name=name)
777
+ if self.at_soft("public"):
778
+ self.advance()
779
+ node.is_public = True
780
+
781
+ def item():
782
+ if self.at("NAME") and self.cur.value in _METHODS:
783
+ return self.parse_route()
784
+ return self.parse_directive()
785
+
786
+ for r in self.parse_block(item):
787
+ if isinstance(r, A.Route):
788
+ node.routes.append(r)
789
+ elif r is not None:
790
+ node.settings.append(r)
791
+ node.end = self.cur.end
792
+ return node
793
+
794
+ def parse_route(self) -> A.Route:
795
+ start = self.cur.start
796
+ method = self.advance().value
797
+ parts: list[str] = []
798
+ while self.at_op("/"):
799
+ parts.append(self.advance().value) # '/'
800
+ if self.at_op(":"):
801
+ parts.append(self.advance().value) # ':'
802
+ if self.at("NAME"):
803
+ parts.append(self.advance().value) # segment name
804
+ rt = A.Route(start=start, method=method, path="".join(parts))
805
+ if self.at_op("("):
806
+ self.advance()
807
+ rt.params = self._param_list_until(")")
808
+ self.expect_op(")")
809
+ if not self._at_line_end() and not self.at_kw("as"):
810
+ rt.ret = self.parse_type_ref()
811
+ if self.at_kw("as"):
812
+ self.advance()
813
+ if self.cur.kind in ("NAME", "TYPENAME"):
814
+ rt.alias = self.advance().value
815
+ # end the header line, then look for an optional indented body. A bodyless
816
+ # route (`get /posts/:id Post!`) is valid and may be followed by a sibling.
817
+ if self.at("NEWLINE"):
818
+ self.advance()
819
+ elif not self.at("INDENT") and self.cur.kind not in ("DEDENT", "EOF"):
820
+ self.error(f"expected end of line, found {self._desc(self.cur)}", self.cur)
821
+ self._recover_to_line()
822
+ self.skip_newlines()
823
+ if self.at("INDENT"):
824
+ self.advance()
825
+ body = []
826
+ while not self.at("DEDENT") and not self.at("EOF"):
827
+ self.skip_newlines()
828
+ if self.at("DEDENT"):
829
+ break
830
+ if self.at_kw("auth"):
831
+ rt.guard = self.parse_guard()
832
+ else:
833
+ before = self.pos
834
+ body.append(self.parse_stmt())
835
+ if self.pos == before:
836
+ self._recover_to_line()
837
+ self.skip_newlines()
838
+ if self.at("DEDENT"):
839
+ self.advance()
840
+ rt.body = body
841
+ rt.end = self.cur.end
842
+ return rt
843
+
844
+ def parse_guard(self) -> A.Guard:
845
+ start = self.advance().start # 'auth'
846
+ g = A.Guard(start=start)
847
+ # The natural spelling for the open §20.8 gap is otherwise especially
848
+ # unhelpful: `auth role:admin or owner:...` falls through into an
849
+ # ordinary end-of-line parse error. Recognise the attempted composition
850
+ # before parsing either side and turn it into a repair instruction.
851
+ line = []
852
+ i = self.pos
853
+ while i < len(self.toks) and self.toks[i].kind not in ("NEWLINE", "DEDENT", "EOF"):
854
+ line.append(self.toks[i])
855
+ i += 1
856
+ joiner = next((t for t in line if t.kind == "KW" and t.value in ("or", "and")), None)
857
+ if joiner is not None and any(t.value in ("role", "owner") for t in line):
858
+ self.error(
859
+ "authorization guards do not compose in Volaro v0.2 (§20.8)",
860
+ joiner,
861
+ code="E-AUTH-COMPOSE",
862
+ fix=("use `auth required`, perform the combined check in the handler, "
863
+ "and mark it `# LANGUAGE GAP (§20.8)`; do not present the "
864
+ "workaround as idiomatic Volaro"),
865
+ )
866
+ g.kind = "compose"
867
+ self._recover_to_line()
868
+ g.end = self.cur.end
869
+ return g
870
+ if self.at("NAME") and self.cur.value in ("required", "optional", "none"):
871
+ g.kind = self.advance().value
872
+ elif self.at_soft("role"):
873
+ self.advance()
874
+ g.kind = "role"
875
+ if self.at_op(":"):
876
+ self.advance()
877
+ g.detail = self._role_list()
878
+ elif self.at_soft("owner"):
879
+ self.advance()
880
+ g.kind = "owner"
881
+ if self.at_op(":"):
882
+ self.advance()
883
+ g.detail = self.parse_expr()
884
+ else:
885
+ g.kind = self.advance().value if self.cur.kind in ("NAME", "KW") else "?"
886
+ g.end = self.cur.end
887
+ self._end_line()
888
+ return g
889
+
890
+ def _role_list(self) -> list:
891
+ roles: list[str] = []
892
+ if self.at_op("["):
893
+ self.advance()
894
+ while not self.at_op("]") and not self.at("EOF"):
895
+ if self.cur.kind in ("NAME", "KW"):
896
+ roles.append(self.advance().value)
897
+ if self.at_op(","):
898
+ self.advance()
899
+ else:
900
+ break
901
+ self.expect_op("]")
902
+ elif self.cur.kind in ("NAME", "KW"):
903
+ roles.append(self.advance().value)
904
+ return roles
905
+
906
+ def parse_auth_decl(self) -> A.AuthDecl:
907
+ start = self.advance().start
908
+ node = A.AuthDecl(start=start)
909
+ node.settings = self.parse_block(self.parse_directive)
910
+ node.end = self.cur.end
911
+ return node
912
+
913
+ def parse_theme_decl(self) -> A.Directive:
914
+ start = self.advance().start
915
+ node = A.Directive(start=start, head="theme")
916
+ node.body = self.parse_block(self.parse_directive)
917
+ node.end = self.cur.end
918
+ return node
919
+
920
+ def parse_recipe_decl(self) -> A.RecipeDecl:
921
+ start = self.advance().start
922
+ primitive = (self.advance().value
923
+ if self.cur.kind in ("NAME", "KW") else "?")
924
+ node = A.RecipeDecl(start=start, primitive=primitive)
925
+ node.body = self.parse_block(self.parse_directive)
926
+ node.end = self.cur.end
927
+ return node
928
+
929
+ def parse_page_decl(self) -> A.PageDecl:
930
+ start = self.advance().start # 'page'
931
+ node = A.PageDecl(start=start)
932
+ # inline `name:<expr>` attributes on the same line, element-style
933
+ while not self._at_line_end():
934
+ if (self.cur.kind in ("NAME", "KW") and self.peek(1).kind == "OP"
935
+ and self.peek(1).value == ":"):
936
+ an = self.advance().value
937
+ self.advance() # ':'
938
+ node.attrs.append((an, self.parse_expr()))
939
+ continue
940
+ self.error("`page` takes `name:<expr>` settings (e.g. `page "
941
+ "title:\"…\"`)", self.cur)
942
+ self._recover_to_line()
943
+ break
944
+ node.end = self.cur.end
945
+ if self.at("NEWLINE"):
946
+ self.advance()
947
+ return node
948
+
949
+ def parse_directive(self):
950
+ if self.cur.kind in ("DEDENT", "EOF"):
951
+ return None
952
+ start = self.cur.start
953
+ head = self.cur.value if self.cur.kind in ("NAME", "KW", "TYPENAME") else self._desc(self.cur)
954
+ toks: list[str] = []
955
+ while not self.at("NEWLINE") and self.cur.kind not in ("DEDENT", "EOF"):
956
+ toks.append(self.cur.value)
957
+ self.advance()
958
+ node = A.Directive(start=start, end=self.cur.end, head=head, tokens=toks)
959
+ self.skip_newlines()
960
+ if self.at("INDENT"):
961
+ node.body = self.parse_block(self.parse_directive)
962
+ return node
963
+
964
+ # -- statements ----------------------------------------------------
965
+ def parse_stmt(self):
966
+ t = self.cur
967
+ if self.at_kw("let"):
968
+ return self.parse_let()
969
+ if self.at_kw("if"):
970
+ return self.parse_if(self.parse_stmt)
971
+ if self.at_kw("match"):
972
+ return self.parse_match_stmt(self.parse_stmt)
973
+ if self.at_kw("for"):
974
+ return self.parse_for()
975
+ if self.at_kw("while"):
976
+ return self.parse_while()
977
+ if self.at_kw("par"):
978
+ return self.parse_par()
979
+ if self.at_kw("spawn"):
980
+ return self.parse_spawn()
981
+ if self.at_kw("try"):
982
+ return self.parse_try()
983
+ if self.at_kw("return"):
984
+ self.advance()
985
+ val = None if self._at_line_end() else self.parse_expr()
986
+ self._end_line()
987
+ return A.Return(start=t.start, value=val)
988
+ if self.at_kw("break"):
989
+ self.advance()
990
+ self._end_line()
991
+ return A.Break(start=t.start)
992
+ if self.at_kw("skip"):
993
+ self.advance()
994
+ self._end_line()
995
+ return A.Skip(start=t.start)
996
+
997
+ expr = self.parse_expr()
998
+ if self.at_op(*_ASSIGN_OPS):
999
+ op = self.advance().value
1000
+ val = self.parse_expr()
1001
+ if not self._closure_block_body(val):
1002
+ self._end_line()
1003
+ return A.Assign(start=t.start, target=expr, op=op, value=val)
1004
+ if not self._closure_block_body(expr):
1005
+ self._end_line()
1006
+ return A.ExprStmt(start=t.start, expr=expr)
1007
+
1008
+ @staticmethod
1009
+ def _closure_block_body(node) -> bool:
1010
+ """True when `node` is a closure whose body is a statement block that
1011
+ has already been fully parsed through its own terminator (a DEDENT
1012
+ via `parse_block`, or the bracket-tracked end of
1013
+ `_parse_bracketed_closure_body`) — so the caller must NOT also call
1014
+ `_end_line()` for it: there is no pending NEWLINE/DEDENT left to
1015
+ consume, only the next statement (B3 in the callback-props
1016
+ decision — `let x = fn(...)\\n <block>` followed by a sibling
1017
+ statement broke here before this check existed)."""
1018
+ return isinstance(node, A.Closure) and isinstance(node.body, list)
1019
+
1020
+ def parse_let(self) -> A.Let:
1021
+ start = self.advance().start
1022
+ mut = False
1023
+ if self.at_kw("mut"):
1024
+ self.advance()
1025
+ mut = True
1026
+ name = self.advance().value if self.at("NAME") else "?"
1027
+ node = A.Let(start=start, name=name, mut=mut)
1028
+ if not self.at_op("="):
1029
+ node.type = self.parse_type_ref()
1030
+ self.expect_op("=")
1031
+ # `let x = try EXPR \n catch pat block(stmt)` (spec section 6.2,
1032
+ # expression position) -- `try` is not an ordinary expression prefix
1033
+ # (parse_expr doesn't know it), so it's special-cased here the same
1034
+ # way parse_stmt special-cases the statement form. Reuses parse_try
1035
+ # verbatim: the AST shape (A.Try: expr + catches) is identical either
1036
+ # way: emit.py tells them apart by where the A.Try node ends up.
1037
+ if self.at_kw("try"):
1038
+ node.init = self.parse_try()
1039
+ node.end = self.cur.end
1040
+ return node
1041
+ node.init = self.parse_expr()
1042
+ if (node.type is None and isinstance(node.init, (A.ListLit, A.RecordLit))
1043
+ and getattr(node.init, "empty", False)):
1044
+ node.empty_literal_no_type = True
1045
+ if self._closure_block_body(node.init):
1046
+ node.end = node.init.end
1047
+ return node
1048
+ self._end_line()
1049
+ node.end = self.cur.end
1050
+ return node
1051
+
1052
+ def parse_if(self, sub) -> A.If:
1053
+ start = self.advance().start
1054
+ cond = self.parse_expr()
1055
+ node = A.If(start=start, cond=cond)
1056
+ node.then = self.parse_block(sub)
1057
+ while (self.at_kw("else") and self.peek(1).kind == "KW" and self.peek(1).value == "if"):
1058
+ self.advance()
1059
+ self.advance()
1060
+ c = self.parse_expr()
1061
+ node.elifs.append((c, self.parse_block(sub)))
1062
+ if self.at_kw("else"):
1063
+ self.advance()
1064
+ node.has_else = True
1065
+ node.orelse = self.parse_block(sub)
1066
+ node.end = self.cur.end
1067
+ return node
1068
+
1069
+ def parse_match_stmt(self, sub) -> A.Match:
1070
+ start = self.advance().start
1071
+ node = A.Match(start=start, subject=self.parse_expr())
1072
+ self.skip_newlines()
1073
+ if not self.at("INDENT"):
1074
+ self.error("expected indented match arms", self.cur)
1075
+ return node
1076
+ self.advance()
1077
+ while not self.at("DEDENT") and not self.at("EOF"):
1078
+ self.skip_newlines()
1079
+ if self.at("DEDENT"):
1080
+ break
1081
+ arm = self._match_arm(sub)
1082
+ if arm is not None:
1083
+ node.arms.append(arm)
1084
+ self.skip_newlines()
1085
+ if self.at("DEDENT"):
1086
+ self.advance()
1087
+ node.end = self.cur.end
1088
+ return node
1089
+
1090
+ def _match_arm(self, sub):
1091
+ before = self.pos
1092
+ pat = self.parse_pattern()
1093
+ guard = None
1094
+ if self.at_kw("if"):
1095
+ self.advance()
1096
+ guard = self.parse_expr()
1097
+ if self.at_op("->"):
1098
+ self.advance()
1099
+ else:
1100
+ self.error(f"expected '->' in match arm, found {self._desc(self.cur)}", self.cur)
1101
+ if self.at("NEWLINE"):
1102
+ body = self.parse_block(sub)
1103
+ else:
1104
+ body = [A.ExprStmt(expr=self.parse_expr())]
1105
+ self._end_line()
1106
+ if self.pos == before:
1107
+ self.advance()
1108
+ return (pat, guard, body)
1109
+
1110
+ def parse_for(self) -> A.For:
1111
+ start = self.advance().start
1112
+ node = A.For(start=start)
1113
+ if self.at("NAME"):
1114
+ node.vars.append(self.advance().value)
1115
+ if self.at_op(","):
1116
+ self.advance()
1117
+ if self.at("NAME"):
1118
+ node.vars.append(self.advance().value)
1119
+ if self.at_kw("in"):
1120
+ self.advance()
1121
+ else:
1122
+ self.error("expected 'in' in for loop", self.cur)
1123
+ node.iter = self.parse_expr()
1124
+ node.body = self.parse_block(self.parse_stmt)
1125
+ node.end = self.cur.end
1126
+ return node
1127
+
1128
+ def parse_while(self) -> A.While:
1129
+ start = self.advance().start
1130
+ node = A.While(start=start, cond=self.parse_expr())
1131
+ node.body = self.parse_block(self.parse_stmt)
1132
+ node.end = self.cur.end
1133
+ return node
1134
+
1135
+ def parse_par(self) -> A.Par:
1136
+ start = self.advance().start
1137
+ node = A.Par(start=start)
1138
+ node.lets = self.parse_block(self.parse_stmt)
1139
+ node.end = self.cur.end
1140
+ return node
1141
+
1142
+ def parse_spawn(self) -> A.Spawn:
1143
+ start = self.advance().start
1144
+ node = A.Spawn(start=start)
1145
+ node.body = self.parse_block(self.parse_stmt)
1146
+ node.end = self.cur.end
1147
+ return node
1148
+
1149
+ def parse_try(self) -> A.Try:
1150
+ start = self.advance().start # 'try'
1151
+ if self._at_line_end():
1152
+ # `try` with nothing else on its line -- e.g. `try` followed by
1153
+ # an indented block of statements, treating it as a general
1154
+ # block the way `try { ... }` works in other languages. Volaro's
1155
+ # `try` wraps exactly one fallible EXPRESSION on its own line;
1156
+ # it is not a block construct. Name the actual mistake instead
1157
+ # of letting the indented block that follows cascade into
1158
+ # unrelated "expected end of line" / "unexpected dedent" noise.
1159
+ self.error(
1160
+ "`try` needs its fallible expression on the SAME line -- "
1161
+ "`try EXPR`, with `catch` starting its own dedented line "
1162
+ "after it. `try` is not a block and cannot wrap multiple "
1163
+ "statements the way `try { ... }` does in other languages",
1164
+ self.cur, fix="try some_call(...)\ncatch e\n fallback_value")
1165
+ self._skip_block_for_recovery()
1166
+ node = A.Try(start=start,
1167
+ expr=A.Literal(start=start, end=self.cur.end, kind="nil", raw="nil"))
1168
+ node.end = self.cur.end
1169
+ return node
1170
+ node = A.Try(start=start, expr=self.parse_expr())
1171
+ if self.at_kw("catch"):
1172
+ # `catch` right after the expression, on the SAME line -- e.g.
1173
+ # `let r = try f() catch e fallback`. `catch` must start its own
1174
+ # line, dedented to align with `try` (or the `let` it's part
1175
+ # of). Named directly instead of the generic "expected end of
1176
+ # line" this would otherwise produce.
1177
+ self.error(
1178
+ "`catch` must start its own line, dedented to align with "
1179
+ "`try` -- `try EXPR catch ...` all on one line is not valid",
1180
+ self.cur, fix="try f()\ncatch e\n fallback_value")
1181
+ self._recover_to_line()
1182
+ else:
1183
+ self._end_line()
1184
+ self.skip_newlines()
1185
+ while self.at_kw("catch"):
1186
+ self.advance()
1187
+ pat = self.parse_pattern()
1188
+ body = self.parse_block(self.parse_stmt)
1189
+ node.catches.append((pat, body))
1190
+ self.skip_newlines()
1191
+ node.end = self.cur.end
1192
+ return node
1193
+
1194
+ def _skip_block_for_recovery(self) -> None:
1195
+ """Best-effort error recovery: consume an entire INDENT...DEDENT
1196
+ block without building AST for it, so one malformed `try` (used as
1197
+ a block) doesn't cascade into a wall of unrelated follow-on parse
1198
+ errors on every statement inside it."""
1199
+ self.skip_newlines()
1200
+ if not self.at("INDENT"):
1201
+ return
1202
+ depth = 0
1203
+ while self.cur.kind != "EOF":
1204
+ if self.at("INDENT"):
1205
+ depth += 1
1206
+ elif self.at("DEDENT"):
1207
+ depth -= 1
1208
+ if depth == 0:
1209
+ self.advance()
1210
+ return
1211
+ self.advance()
1212
+
1213
+ def parse_pattern(self) -> A.Pattern:
1214
+ start = self.cur.start
1215
+ binds: list[str] = []
1216
+ head = ""
1217
+
1218
+ def ctor_args():
1219
+ self.advance() # (
1220
+ while not self.at_op(")") and not self.at("EOF"):
1221
+ if self.at("NAME"):
1222
+ binds.append(self.advance().value)
1223
+ else:
1224
+ self.advance()
1225
+ if self.at_op(","):
1226
+ self.advance()
1227
+ else:
1228
+ break
1229
+ self.expect_op(")")
1230
+
1231
+ if self.at("NAME") and self.cur.value == "_":
1232
+ self.advance()
1233
+ head = "_"
1234
+ elif self.at_op("_"):
1235
+ self.advance()
1236
+ head = "_"
1237
+ elif self.at("NAME"):
1238
+ nm = self.advance().value
1239
+ head = nm
1240
+ if self.at_op("("):
1241
+ ctor_args()
1242
+ else:
1243
+ binds.append(nm)
1244
+ elif self.at("TYPENAME"):
1245
+ head = self.cur.value
1246
+ self.advance()
1247
+ if self.at_op("("):
1248
+ ctor_args()
1249
+ elif self.at_op("{"):
1250
+ self.advance()
1251
+ while not self.at_op("}") and not self.at("EOF"):
1252
+ if self.at("NAME"):
1253
+ fn = self.advance().value
1254
+ if self.at_op(":"):
1255
+ self.advance()
1256
+ if self.at("NAME"):
1257
+ binds.append(self.advance().value)
1258
+ else:
1259
+ self.parse_expr()
1260
+ else:
1261
+ binds.append(fn)
1262
+ if self.at_op(","):
1263
+ self.advance()
1264
+ else:
1265
+ break
1266
+ self.expect_op("}")
1267
+ elif self.cur.kind in ("INT", "FLOAT", "STRING", "BOOL", "NIL", "DURATION"):
1268
+ self.advance()
1269
+ if self.at_op("..", "..="):
1270
+ self.advance()
1271
+ if self.cur.kind in ("INT", "FLOAT", "STRING", "DURATION"):
1272
+ self.advance()
1273
+ elif self.at_op("["):
1274
+ self.advance()
1275
+ while not self.at_op("]") and not self.at("EOF"):
1276
+ if self.at("NAME"):
1277
+ binds.append(self.advance().value)
1278
+ else:
1279
+ self.advance()
1280
+ if self.at_op(","):
1281
+ self.advance()
1282
+ else:
1283
+ break
1284
+ self.expect_op("]")
1285
+ else:
1286
+ self.advance()
1287
+
1288
+ return A.Pattern(start=start, end=self.cur.end, binds=binds, head=head)
1289
+
1290
+ # -- expressions (Pratt) ---------------------------------------------
1291
+ def parse_expr(self, min_bp: int = 0):
1292
+ left = self._parse_prefix()
1293
+ while True:
1294
+ op = self._peek_binop()
1295
+ if op is None:
1296
+ break
1297
+ text, lbp, rbp = op
1298
+ if lbp < min_bp:
1299
+ break
1300
+ self.advance()
1301
+ if text in ("..", "..="):
1302
+ right = self.parse_expr(rbp)
1303
+ left = A.Range(start=getattr(left, "start", 0), end=getattr(right, "end", 0),
1304
+ lo=left, hi=right, inclusive=(text == "..="))
1305
+ continue
1306
+ right = self.parse_expr(rbp)
1307
+ node = A.Binary(start=getattr(left, "start", 0), end=getattr(right, "end", 0),
1308
+ op=text, left=left, right=right)
1309
+ if text in _CMP:
1310
+ nxt = self._peek_binop()
1311
+ inner = right.op if isinstance(right, A.Binary) else None
1312
+ if (nxt and nxt[0] in _CMP) or (inner in _CMP):
1313
+ self.diags.append(Diagnostic(
1314
+ "E-CHAIN-CMP", Severity.ERROR,
1315
+ "comparison operators do not chain",
1316
+ node.start, node.end,
1317
+ "split into two comparisons joined by 'and'", self.filename))
1318
+ left = node
1319
+ return left
1320
+
1321
+ def _peek_binop(self):
1322
+ t = self.cur
1323
+ if t.kind == "OP" and t.value in _BINOPS:
1324
+ lbp, rbp = _BINOPS[t.value]
1325
+ return (t.value, lbp, rbp)
1326
+ if t.kind == "KW" and t.value in ("and", "or"):
1327
+ lbp, rbp = _BINOPS[t.value]
1328
+ return (t.value, lbp, rbp)
1329
+ return None
1330
+
1331
+ def _parse_prefix(self):
1332
+ t = self.cur
1333
+ if self.at_kw("not"):
1334
+ self.advance()
1335
+ operand = self.parse_expr(_PREFIX_BP)
1336
+ return A.Unary(start=t.start, end=getattr(operand, "end", t.end), op="not", operand=operand)
1337
+ if self.at_op("-"):
1338
+ self.advance()
1339
+ operand = self.parse_expr(_PREFIX_BP)
1340
+ return A.Unary(start=t.start, end=getattr(operand, "end", t.end), op="-", operand=operand)
1341
+ return self._parse_postfix(self._parse_primary())
1342
+
1343
+ def _parse_postfix(self, node):
1344
+ while True:
1345
+ if self.at_op("."):
1346
+ self.advance()
1347
+ nm = self.advance().value if self.cur.kind in ("NAME", "TYPENAME", "KW") else "?"
1348
+ node = A.Member(start=getattr(node, "start", 0), end=self.cur.end, obj=node, name=nm)
1349
+ elif self.at_op("("):
1350
+ # In `if (condition) (then_expr) else other`, the closing
1351
+ # condition parenthesis is a boundary, not a callable value.
1352
+ if self.pos in getattr(self, "_if_condition_ends", ()):
1353
+ break
1354
+ self.advance()
1355
+ args, named = [], []
1356
+ self.skip_newlines() # arguments may span lines inside ( )
1357
+ while not self.at_op(")") and not self.at("EOF"):
1358
+ if self.at("NAME") and self.peek(1).kind == "OP" and self.peek(1).value == ":":
1359
+ an = self.advance().value
1360
+ self.advance()
1361
+ named.append((an, self.parse_expr()))
1362
+ else:
1363
+ args.append(self.parse_expr())
1364
+ had_nl = self.at("NEWLINE") or self._take_closure_ate_sep()
1365
+ self.skip_newlines()
1366
+ if self.at_op(","):
1367
+ self.advance()
1368
+ self.skip_newlines()
1369
+ elif not had_nl and not self.at_op(")"):
1370
+ break # spec 3.7: a bare newline also separates args
1371
+ self.expect_op(")")
1372
+ node = A.Call(start=getattr(node, "start", 0), end=self.cur.end,
1373
+ callee=node, args=args, named=named)
1374
+ elif self.at_op("["):
1375
+ self.advance()
1376
+ idx = self.parse_expr()
1377
+ self.expect_op("]")
1378
+ node = A.Index(start=getattr(node, "start", 0), end=self.cur.end, obj=node, index=idx)
1379
+ elif self.at_op("?"):
1380
+ self.advance()
1381
+ node = A.TryOp(start=getattr(node, "start", 0), end=self.cur.end, operand=node)
1382
+ else:
1383
+ break
1384
+ return node
1385
+
1386
+ def _parse_primary(self):
1387
+ t = self.cur
1388
+ if t.kind == "INT":
1389
+ self.advance()
1390
+ return A.Literal(start=t.start, end=t.end, kind="int", raw=t.value)
1391
+ if t.kind == "FLOAT":
1392
+ self.advance()
1393
+ return A.Literal(start=t.start, end=t.end, kind="float", raw=t.value)
1394
+ if t.kind == "DURATION":
1395
+ self.advance()
1396
+ return A.Literal(start=t.start, end=t.end, kind="duration", raw=t.value)
1397
+ if t.kind == "BOOL":
1398
+ self.advance()
1399
+ return A.Literal(start=t.start, end=t.end, kind="bool", raw=t.value)
1400
+ if t.kind == "NIL":
1401
+ self.advance()
1402
+ return A.Literal(start=t.start, end=t.end, kind="nil", raw="nil")
1403
+ if t.kind == "STRING":
1404
+ self.advance()
1405
+ lit = A.Literal(start=t.start, end=t.end, kind="str", raw=t.value)
1406
+ lit.interp_names = self._interp_names(t)
1407
+ return lit
1408
+ if self.at_kw("if"):
1409
+ return self._parse_if_expr()
1410
+ if self.at_kw("match"):
1411
+ return self.parse_match_expr()
1412
+ if self.at_kw("fn"):
1413
+ return self._parse_closure()
1414
+ if self.at_op("("):
1415
+ self.advance()
1416
+ e = self.parse_expr()
1417
+ self.expect_op(")")
1418
+ return e
1419
+ if self.at_op("["):
1420
+ return self._parse_list_lit()
1421
+ if self.at_op("{"):
1422
+ return self._parse_record_lit(None)
1423
+ if t.kind == "TYPENAME":
1424
+ self.advance()
1425
+ if self.at_op("{"):
1426
+ return self._parse_record_lit(t.value)
1427
+ return A.Name(start=t.start, end=t.end, id=t.value)
1428
+ # `err` and `auth` are reserved but read as values here; `api` / `session`
1429
+ # are contextual and already arrive as NAME tokens.
1430
+ if t.kind in ("NAME", "PRIM") or self.at_kw("err", "auth"):
1431
+ self.advance()
1432
+ return A.Name(start=t.start, end=t.end, id=t.value)
1433
+ self.error(f"expected an expression, found {self._desc(t)}", t)
1434
+ self.advance()
1435
+ return A.Name(start=t.start, end=t.end, id="?")
1436
+
1437
+ def _parse_if_expr(self) -> A.IfExpr:
1438
+ start = self.advance().start
1439
+ boundary = None
1440
+ if self.at_op("("):
1441
+ depth = 0
1442
+ for i in range(self.pos, len(self.toks)):
1443
+ t = self.toks[i]
1444
+ if t.kind == "OP" and t.value == "(":
1445
+ depth += 1
1446
+ elif t.kind == "OP" and t.value == ")":
1447
+ depth -= 1
1448
+ if depth == 0:
1449
+ boundary = i + 1
1450
+ break
1451
+ previous = getattr(self, "_if_condition_ends", set())
1452
+ self._if_condition_ends = previous | ({boundary} if boundary is not None else set())
1453
+ try:
1454
+ cond = self.parse_expr()
1455
+ finally:
1456
+ self._if_condition_ends = previous
1457
+ node = A.IfExpr(start=start, cond=cond, then=self.parse_expr())
1458
+ if self.at_kw("else"):
1459
+ self.advance()
1460
+ node.orelse = self.parse_expr()
1461
+ node.has_else = True
1462
+ else:
1463
+ self.diags.append(Diagnostic(
1464
+ "E-IF-EXPR-ELSE", Severity.ERROR,
1465
+ "an `if` expression is `if <cond> <then> else <else>` -- `else` "
1466
+ "is required, each branch is one expression, and it is binary "
1467
+ "(nest for 3+, no `else if` / `match`). If this looks complete, "
1468
+ "a `(` right after a value was read as a call -- wrap the "
1469
+ "condition: `if (cond) a else b`.",
1470
+ start, self.cur.end,
1471
+ "if (cond) a else b # binary; nest for more cases",
1472
+ self.filename))
1473
+ node.end = self.cur.end
1474
+ return node
1475
+
1476
+ def parse_match_expr(self) -> A.MatchExpr:
1477
+ start = self.advance().start
1478
+ node = A.MatchExpr(start=start, subject=self.parse_expr())
1479
+ self.skip_newlines()
1480
+ if self.at("INDENT"):
1481
+ self.advance()
1482
+ while not self.at("DEDENT") and not self.at("EOF"):
1483
+ self.skip_newlines()
1484
+ if self.at("DEDENT"):
1485
+ break
1486
+ arm = self._match_arm(self.parse_stmt)
1487
+ if arm is not None:
1488
+ node.arms.append(arm)
1489
+ self.skip_newlines()
1490
+ if self.at("DEDENT"):
1491
+ self.advance()
1492
+ node.end = self.cur.end
1493
+ return node
1494
+
1495
+ def _parse_closure(self) -> A.Closure:
1496
+ start = self.advance().start # 'fn'
1497
+ self.expect_op("(")
1498
+ params = self._param_list_until(")")
1499
+ self.expect_op(")")
1500
+ node = A.Closure(start=start, params=params)
1501
+ if self.cur.kind in ("TYPENAME", "PRIM") and self.peek(1).kind == "NEWLINE":
1502
+ node.ret = self.parse_type_ref()
1503
+ if self.at("NEWLINE"):
1504
+ self.skip_newlines()
1505
+ if self.at("INDENT"):
1506
+ node.body = self.parse_block(self.parse_stmt)
1507
+ elif self.bracket_depth > 0:
1508
+ # A block-bodied closure written as a call argument (e.g. a
1509
+ # callback prop's closure at its most natural call site,
1510
+ # `Child(onPick: fn(item)\n selected = item.id)`): the lexer
1511
+ # suppresses INDENT/DEDENT inside any open bracket (spec 3.7
1512
+ # keeps NEWLINE as the item separator there), so there is no
1513
+ # INDENT token to find the block by. Track it by source
1514
+ # column instead, entirely inside the parser.
1515
+ node.body = self._parse_bracketed_closure_body()
1516
+ node.end = self.cur.end
1517
+ return node
1518
+ node.body = self.parse_expr()
1519
+ node.end = getattr(node.body, "end", self.cur.end)
1520
+ return node
1521
+
1522
+ def _col(self, tok: Token) -> int:
1523
+ """0-based column of `tok` on its own source line."""
1524
+ return tok.start - (self.src.rfind("\n", 0, tok.start) + 1)
1525
+
1526
+ def _parse_bracketed_closure_body(self) -> list:
1527
+ """The statement-block form of `_parse_closure`, for when it is
1528
+ reached inside an open bracket (see the call site). `parse_block`
1529
+ can't be reused here — it waits for a DEDENT token, which the lexer
1530
+ never produces while any bracket is open — so this parses statements
1531
+ directly and decides where the block ends by comparing each
1532
+ statement's column to the first one's, stopping at a shallower
1533
+ column or at whatever closes/continues the enclosing bracket
1534
+ (`)` / `]` / `}` / `,`) — the same tokens `_end_line`'s matching
1535
+ tolerance (above) lets each statement end on."""
1536
+ self.skip_newlines()
1537
+ if self.cur.kind in ("DEDENT", "EOF") or self.at_op(")", "]", "}", ","):
1538
+ return []
1539
+ body_col = self._col(self.cur)
1540
+ stmts: list = []
1541
+ while True:
1542
+ before = self.pos
1543
+ stmts.append(self.parse_stmt())
1544
+ if self.pos == before:
1545
+ self.advance()
1546
+ break
1547
+ self.skip_newlines()
1548
+ if self.cur.kind in ("DEDENT", "EOF") or self.at_op(")", "]", "}", ","):
1549
+ break
1550
+ if self._col(self.cur) != body_col:
1551
+ # A sibling at a shallower column follows (e.g. the next
1552
+ # named call argument) -- the block ends here. Getting here
1553
+ # required a real NEWLINE, already consumed inside the last
1554
+ # statement's own `_end_line()`; tell the call-argument loop
1555
+ # that resumes once `_parse_closure` returns, since it can no
1556
+ # longer see that token to know a separator occurred.
1557
+ self._closure_ate_sep = True
1558
+ break
1559
+ return stmts
1560
+
1561
+ def _parse_list_lit(self) -> A.ListLit:
1562
+ start = self.advance().start # [
1563
+ node = A.ListLit(start=start)
1564
+ self.skip_newlines() # spec 3.7: a literal may open onto its own lines
1565
+ while not self.at_op("]") and not self.at("EOF"):
1566
+ before = self.pos
1567
+ node.items.append(self.parse_expr())
1568
+ # This loop has no separator-required break (unlike a call's
1569
+ # argument list), so it needs no `_closure_ate_sep` reading --
1570
+ # but drain it regardless, so a block-bodied closure item here
1571
+ # can never leave a stale flag for some later, unrelated call's
1572
+ # argument loop to misread.
1573
+ self._closure_ate_sep = False
1574
+ if self.at_op(","):
1575
+ comma = self.advance()
1576
+ self.skip_newlines() # `,` then a newline before the next item / `]`
1577
+ if self.at_op("]"):
1578
+ self.diags.append(Diagnostic(
1579
+ "E-TRAILING-COMMA", Severity.ERROR,
1580
+ "trailing commas are not allowed",
1581
+ comma.start, comma.end, "remove the trailing comma", self.filename))
1582
+ else:
1583
+ self.skip_newlines() # spec 3.7: a newline separates items in block position
1584
+ if self.pos == before:
1585
+ self.advance()
1586
+ self.expect_op("]")
1587
+ node.empty = not node.items
1588
+ node.end = self.cur.end
1589
+ return node
1590
+
1591
+ def _parse_record_lit(self, type_name) -> A.RecordLit:
1592
+ start = self.cur.start
1593
+ self.expect_op("{")
1594
+ node = A.RecordLit(start=start, type_name=type_name)
1595
+ self.skip_newlines() # spec 3.7: a literal may open onto its own lines
1596
+ while not self.at_op("}") and not self.at("EOF"):
1597
+ before = self.pos
1598
+ if self.at("NAME") and self.peek(1).kind == "OP" and self.peek(1).value == ":":
1599
+ fn = self.advance().value
1600
+ self.advance()
1601
+ node.fields.append((fn, self.parse_expr()))
1602
+ else:
1603
+ key = self.parse_expr()
1604
+ if self.at_op(":"):
1605
+ self.advance()
1606
+ node.fields.append((None, self.parse_expr()))
1607
+ else:
1608
+ node.fields.append((None, key))
1609
+ self._closure_ate_sep = False # see the matching drain in _parse_list_lit
1610
+ if self.at_op(","):
1611
+ comma = self.advance()
1612
+ self.skip_newlines() # `,` then a newline before the next field / `}`
1613
+ if self.at_op("}"):
1614
+ self.diags.append(Diagnostic(
1615
+ "E-TRAILING-COMMA", Severity.ERROR,
1616
+ "trailing commas are not allowed",
1617
+ comma.start, comma.end, "remove the trailing comma", self.filename))
1618
+ else:
1619
+ self.skip_newlines() # spec 3.7: a newline separates fields in block position
1620
+ if self.pos == before:
1621
+ self.advance()
1622
+ self.expect_op("}")
1623
+ node.empty = not node.fields
1624
+ node.end = self.cur.end
1625
+ return node
1626
+
1627
+ def _interp_names(self, tok: Token) -> list:
1628
+ out: list[str] = []
1629
+ for (s, e) in getattr(tok, "interps", []):
1630
+ frag = self.src[s:e]
1631
+ for m in _IDENT_RE.finditer(frag):
1632
+ out.append(m.group(1))
1633
+ return out
1634
+
1635
+
1636
+ def parse(tokens: list[Token], src: str, filename: str = "<input>"):
1637
+ p = Parser(tokens, src, filename)
1638
+ return p.parse_module(), p.diags