volaro 0.1.0-alpha.10 → 0.1.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Volaro
2
2
 
3
- **Limited alpha** (this is `0.1.0-alpha.10`), published under both the
3
+ **Limited alpha** (this is `0.1.0-alpha.11`), published under both the
4
4
  `latest` and `alpha` dist-tags: `npm install volaro`.
5
5
 
6
6
  Volaro is an experimental application language intended for AI authoring and
@@ -1,6 +1,6 @@
1
1
  {
2
- "revision": "2d6678105c20c3fd24848ca7ed42b42d4d101a2b",
2
+ "revision": "5f9c32d19082250fc91ab3664ae6726aa2cfd0a0",
3
3
  "source": "git",
4
4
  "dirty": false,
5
- "content_sha256": "792f7b32c77019c401b27beb4fe14845453312d1645499f2fa78bd7f29638719"
5
+ "content_sha256": "53474ecb82ebaf7ff2861f647b7796dc9c84cce73d7061ee64a65c90365a80ae"
6
6
  }
@@ -1 +1 @@
1
- 2d6678105c20c3fd24848ca7ed42b42d4d101a2b
1
+ 5f9c32d19082250fc91ab3664ae6726aa2cfd0a0
@@ -371,6 +371,7 @@ class Literal(Node):
371
371
  raw: str = ""
372
372
  interp_names: list = field(default_factory=list)
373
373
  interp_calls: list = field(default_factory=list) # bare names called: "{len(xs)}"
374
+ interp_spans: list = field(default_factory=list) # [(start, end)] of each {expr}
374
375
 
375
376
 
376
377
  @dataclass
@@ -75,6 +75,55 @@ _METHOD_ONLY = {
75
75
  }
76
76
  # Methods of a view's `load` binding, not of its value (E-LOAD-METHOD).
77
77
  _LOAD_METHODS = ("reload", "loading")
78
+
79
+ # E-METHOD-UNKNOWN: the closed set of methods a Volaro *value* has. `vlbuild`
80
+ # lowers `x.len()` and `x.format(p)` itself (emit.py `_ex_Call`) and E-LOAD-METHOD
81
+ # owns `reload` / `loading`; any other `value.m(...)` is emitted verbatim as a
82
+ # JavaScript method call, so a name JS strings and arrays lack (`.lower()`,
83
+ # `.contains()`) passed the check and threw when the page ran (Testing Lab,
84
+ # Example Demo 2.3 search brief). String and list operations are std functions
85
+ # (spec section 13: std.str / std.list / std.map), called `f(x, ...)` or
86
+ # `x |> f(...)`. Calls on a namespace -- `db.*` chains, `auth.*`, `route.to`,
87
+ # `log.*`, an `api` / `service` client -- are not value methods and are judged
88
+ # elsewhere (resolve.py R-ROUTE / E-AUTH-METHOD, the build).
89
+ _VALUE_METHODS = {"len": 0, "format": 1}
90
+ # Pass-throughs the build emits unchanged and that behave the same on the
91
+ # emitted JS string / array in both targets. Not part of the std surface, but
92
+ # the spec's own example (section 5.8, `u.email.split("@")`) and the repo's
93
+ # reference programs use them (31 `.trim()` calls across the corpus, the
94
+ # Chromium-verified fixtures and gate 4's form), and `slice` / `reverse` have
95
+ # no function form yet. Accepted so existing programs keep checking clean.
96
+ _PASS_THROUGH_METHODS = {"trim", "slice", "reverse", "join", "split"}
97
+ # The std function to suggest, by method name. A list function (`_LIST_FNS`)
98
+ # reads best as a pipeline (`xs |> filter(f)`), a string one as a call
99
+ # (`lower(x)`). Includes the JavaScript / Python spellings agents carry over.
100
+ _METHOD_FN_FORM = {
101
+ "lower": "lower", "upper": "upper", "trim": "trim",
102
+ "toLowerCase": "lower", "toUpperCase": "upper", "strip": "trim",
103
+ "contains": "contains", "includes": "contains",
104
+ "replace": "replace", "replaceAll": "replace", "replace_all": "replace",
105
+ "matches": "matches", "match": "matches", "test": "matches",
106
+ "first_char": "first_char", "split": "split", "join": "join",
107
+ "filter": "filter", "map": "map", "take": "take", "count": "count",
108
+ "sum": "sum", "sort_by": "sort_by", "group_by": "group_by",
109
+ "chunk": "chunk", "concat": "concat", "append": "append", "push": "append",
110
+ "keys": "keys", "values": "values", "get_or": "get_or",
111
+ }
112
+ _LIST_FNS = {"filter", "map", "take", "count", "sum", "sort_by", "group_by",
113
+ "chunk", "concat", "append", "join"}
114
+ # Other common carry-overs with a Volaro spelling that is not a function call.
115
+ _METHOD_OTHER_FIX = {
116
+ "length": "`{r}.len()`", "size": "`{r}.len()`",
117
+ "first": "`{r}[0]` (check `{r}.len() > 0` first)",
118
+ "at": "`{r}[i]`",
119
+ "get": "`{r}[i]` for a list, `get_or({r}, key, default)` for a map",
120
+ "to_string": "string interpolation: `\"{{{r}}}\"`",
121
+ "toString": "string interpolation: `\"{{{r}}}\"`",
122
+ "to_str": "string interpolation: `\"{{{r}}}\"`",
123
+ }
124
+ # Names a page-server / service body has in scope that are ambient objects,
125
+ # not user values.
126
+ _AMBIENT_RECEIVERS = {"session", "request"}
78
127
  # Free functions the spec shows but no emitter lowers as a bare call (not in
79
128
  # vlbuild's STD_FNS). Only an error where the build emits the call -- a view
80
129
  # or a service route; a module-level `fn` body is never emitted into either.
@@ -125,6 +174,11 @@ _STD_SUPPORTED_NAMES = {
125
174
  # `VL.std.replace`, and `server_emit.py`'s server-side `VL.std.replace`
126
175
  # shim in sync by hand, same as `matches`/`split` above.
127
176
  "replace",
177
+ # std.str's `contains(s, part) bool` (case-sensitive substring test).
178
+ # Also a `where()` predicate name (BUILTINS above); vlbuild lowers a
179
+ # `where(col: contains(x))` value as the predicate and every other call
180
+ # as `VL.std.contains`. Keep in sync with `vlbuild/emit.py`'s `STD_FNS`.
181
+ "contains",
128
182
  }
129
183
 
130
184
 
@@ -270,6 +324,9 @@ class Checker:
270
324
  # (module-level fn bodies)
271
325
  self._module_names: set[str] = set()
272
326
  self._emitted = True
327
+ # names of `fn`-typed record fields in this module: `x.on_pick(id)`
328
+ # calls one, which E-METHOD-UNKNOWN must not report
329
+ self._fn_fields: set[str] = set()
273
330
 
274
331
  # -- emit ----------------------------------------------------------
275
332
  def _d(self, code: str, sev: Severity, msg: str, node, fix: str | None = None) -> None:
@@ -294,6 +351,7 @@ class Checker:
294
351
  self.api_base(mod)
295
352
  self.imports(mod)
296
353
  self.semantic(mod)
354
+ self.silent_drops(mod)
297
355
  return self.diags
298
356
 
299
357
  # -- std imports: only names vlbuild actually lowers are usable --------
@@ -882,8 +940,13 @@ class Checker:
882
940
  "'alt:decorative' before release", node,
883
941
  'alt:"a clear description of what the image shows"')
884
942
 
943
+ # `select` / `optgroup` / `datalist` judge the elements they
944
+ # actually RENDER, through any `list` / `if` wrapper: a
945
+ # data-driven `list cats as c key:c` of `option`s is the
946
+ # ordinary way to build a filter, and a `div` inside that
947
+ # `list` is just as wrong as a direct one.
885
948
  if node.name == "select":
886
- kids = [b for b in node.body if isinstance(b, A.Element)]
949
+ kids = list(_rendered_children(node))
887
950
  if not kids:
888
951
  self._d("E-SELECT-EMPTY", Severity.ERROR,
889
952
  "'select' needs at least one 'option'", node,
@@ -900,8 +963,8 @@ class Checker:
900
963
  "'optgroup' needs a 'label:' -- it is the group's "
901
964
  "own accessible heading", node,
902
965
  'optgroup label:"Fruit"')
903
- for kid in node.body:
904
- if isinstance(kid, A.Element) and kid.name != "option":
966
+ for kid in _rendered_children(node):
967
+ if kid.name != "option":
905
968
  self._d("E-OPTGROUP-CHILD", Severity.ERROR,
906
969
  "'optgroup' takes only 'option' children, "
907
970
  "not '%s'" % kid.name, kid)
@@ -921,8 +984,8 @@ class Checker:
921
984
  self._d("E-DATALIST-ID", Severity.ERROR,
922
985
  "'datalist' needs an 'id:' so an 'input list:' "
923
986
  "can refer to it", node, 'datalist id:"flavors"')
924
- for kid in node.body:
925
- if isinstance(kid, A.Element) and kid.name != "option":
987
+ for kid in _rendered_children(node):
988
+ if kid.name != "option":
926
989
  self._d("E-DATALIST-CHILD", Severity.ERROR,
927
990
  "'datalist' takes only 'option' children, "
928
991
  "not '%s'" % kid.name, kid)
@@ -2589,6 +2652,123 @@ class Checker:
2589
2652
  "move the handler to a surrounding form",
2590
2653
  )
2591
2654
 
2655
+ # -- constructs the build would silently drop -----------------------
2656
+ def silent_drops(self, mod: A.Module) -> None:
2657
+ """Source the build used to accept and then drop, with only a `warn:`
2658
+ line in its output (or nothing): a `derive` inside an element body,
2659
+ a child of a `textarea`, a view-level `on unmount`, a record literal
2660
+ field with no name, a second `service` block. Each one checked clean
2661
+ and shipped a page that did less than its source said. `vlbuild`
2662
+ keeps a matching `BuildError` backstop for every rule here."""
2663
+ services = [it for it in mod.items if isinstance(it, A.ServiceDecl)]
2664
+ for extra in services[1:]:
2665
+ self._d("E-SERVICE-MULTIPLE", Severity.ERROR,
2666
+ "a module declares one `service`; this build wires only "
2667
+ "the first ('%s') and would drop '%s' and its routes"
2668
+ % (services[0].name, extra.name), extra,
2669
+ "move these routes into service '%s'" % services[0].name)
2670
+
2671
+ for it in mod.items:
2672
+ if not isinstance(it, A.ViewDecl):
2673
+ continue
2674
+ for st in it.body:
2675
+ if isinstance(st, A.OnBlock) and st.event != "mount":
2676
+ self._view_level_on(st, it.name)
2677
+ self._nested_view_stmts(it.body, None, it.name)
2678
+
2679
+ self.record_field_names(mod)
2680
+
2681
+ def record_field_names(self, mod) -> None:
2682
+ for node in _walk(mod):
2683
+ if isinstance(node, A.RecordLit):
2684
+ if any(k is None for k, _v in node.fields):
2685
+ self._d("E-RECORD-FIELD-NAME", Severity.ERROR,
2686
+ "every field of a record literal needs a name "
2687
+ "(`{name: value}`, spec 4.3) -- a positional or "
2688
+ "computed-key field is dropped by the build", node,
2689
+ "%s{field: value}" % (node.type_name or ""))
2690
+
2691
+ def _view_level_on(self, st: A.OnBlock, view: str) -> None:
2692
+ if st.event in ("change", "unmount"):
2693
+ self._d("E-LIFECYCLE-UNSUPPORTED", Severity.ERROR,
2694
+ "'on %s' (spec 8.5) is not implemented in this build -- "
2695
+ "only `on mount` runs; this block would be dropped"
2696
+ % st.event, st,
2697
+ "run the logic in the handler that changes the value, or "
2698
+ "in `on mount`" if st.event == "change"
2699
+ else "there is no unmount hook yet; a `load` is already "
2700
+ "cancelled on unmount")
2701
+ elif st.event in KNOWN_EVENTS:
2702
+ self._d("E-EVENT-TARGET", Severity.ERROR,
2703
+ "'on %s' at the top of view '%s' is attached to nothing, "
2704
+ "so it would never run -- a view-level `on` is a lifecycle "
2705
+ "hook (`on mount`)" % (st.event, view), st,
2706
+ "put it on the element that fires it:\n"
2707
+ "button \"Go\"\n on %s\n ..." % st.event)
2708
+ else:
2709
+ self._d("E-EVENT-UNKNOWN", Severity.ERROR,
2710
+ "'on %s' is not a view lifecycle hook -- the only one "
2711
+ "this build runs is `on mount`" % st.event, st, "on mount")
2712
+
2713
+ # Statements an element / `list` / `if` / `load` pending or error body
2714
+ # renders. Everything else there was dropped by the build.
2715
+ _RENDERED = (A.Element, A.ListBlock, A.If, A.Match, A.ChildrenMarker,
2716
+ A.OnBlock)
2717
+ _STMT_WORD = {"StateDecl": "`state`", "DeriveDecl": "`derive`",
2718
+ "LoadDecl": "`load`", "Let": "`let`",
2719
+ "Assign": "an assignment",
2720
+ "ExprStmt": "an expression statement", "For": "`for`",
2721
+ "While": "`while`", "Spawn": "`spawn`", "Try": "`try`",
2722
+ "Return": "`return`", "Par": "`par`", "Break": "`break`",
2723
+ "Skip": "`skip`"}
2724
+
2725
+ def _nested_view_stmts(self, stmts, where: str | None, view: str) -> None:
2726
+ """Walk a view's render tree. `where` is None at the view's top
2727
+ level (where state/derive/load/let/assignments are ordinary) and
2728
+ names the enclosing block below it, where only render nodes and
2729
+ element `on` handlers are lowered."""
2730
+ for st in stmts or []:
2731
+ if isinstance(st, A.OnBlock):
2732
+ continue
2733
+ if isinstance(st, A.Element):
2734
+ if st.name == "textarea":
2735
+ bad = next((b for b in st.body
2736
+ if not isinstance(b, A.OnBlock)), None)
2737
+ if bad is not None:
2738
+ self._d("E-TEXTAREA-CONTENT", Severity.ERROR,
2739
+ "'textarea' takes no children -- its text is "
2740
+ "its value, and the build would drop this",
2741
+ bad, "bind it to a state and give the state "
2742
+ "its initial text: textarea label:\"Notes\" "
2743
+ "bind:notes")
2744
+ continue
2745
+ self._nested_view_stmts(st.body, "'%s'" % st.name, view)
2746
+ elif isinstance(st, A.ListBlock):
2747
+ self._nested_view_stmts(st.body, "a `list` block", view)
2748
+ elif isinstance(st, A.If):
2749
+ for body in [st.then, *(b for _c, b in st.elifs), st.orelse]:
2750
+ self._nested_view_stmts(body, "an `if` block", view)
2751
+ elif isinstance(st, (A.Match, A.ChildrenMarker)):
2752
+ continue
2753
+ elif where is None:
2754
+ if isinstance(st, A.LoadDecl):
2755
+ self._nested_view_stmts(st.pending, "a `load` pending block", view)
2756
+ self._nested_view_stmts(st.error_body, "a `load` error block", view)
2757
+ else:
2758
+ word = self._STMT_WORD.get(type(st).__name__, type(st).__name__)
2759
+ if isinstance(st, (A.StateDecl, A.DeriveDecl, A.LoadDecl, A.Let)):
2760
+ fix = ("declare it at the top of view '%s', before its "
2761
+ "elements; for a per-row value inside a `list`, "
2762
+ "compute it inline or render a child view that "
2763
+ "takes the row as a prop" % view)
2764
+ else:
2765
+ fix = ("put it in an `on` handler (such as `on tap`), or "
2766
+ "in `on mount` at the top of view '%s'" % view)
2767
+ self._d("E-VIEW-STMT-NESTED", Severity.ERROR,
2768
+ "%s inside %s is not supported -- only elements, "
2769
+ "`list`, `if` and `on` handlers render there, and the "
2770
+ "build would drop it" % (word, where), st, fix)
2771
+
2592
2772
  # -- services ----------------------------------------------------
2593
2773
  def services(self, mod: A.Module) -> None:
2594
2774
  for it in mod.items:
@@ -2783,6 +2963,11 @@ class Checker:
2783
2963
  # names the module itself declares or imports: a call to one of these
2784
2964
  # is the author's own function, never the builtin of the same name
2785
2965
  self._module_names = set()
2966
+ self._fn_fields = {
2967
+ f.name for it in mod.items
2968
+ if isinstance(it, (A.TypeDecl, A.ModelDecl))
2969
+ for f in (getattr(it, "fields", None) or [])
2970
+ if getattr(getattr(f, "type", None), "is_fn", False)}
2786
2971
  for it in mod.items:
2787
2972
  nm = getattr(it, "name", None)
2788
2973
  if nm:
@@ -2954,6 +3139,118 @@ class Checker:
2954
3139
  "the call compiles and throws when it runs" % (nm, nm),
2955
3140
  call, "use %s" % _FN_NOT_BUILT[nm])
2956
3141
 
3142
+ @staticmethod
3143
+ def _value_receiver(obj, scopes: list[set]) -> bool:
3144
+ """True when `obj` -- the receiver of `obj.m(...)` -- is an ordinary
3145
+ value, so `.m` must be one of the language's value methods. False for
3146
+ a namespace or client (`db.posts...`, `auth`, `route`, `log`, an
3147
+ imported `api` client), an ambient server object, or a name this
3148
+ pass cannot place: those stay lenient."""
3149
+ while True:
3150
+ if isinstance(obj, (A.Member, A.Index)):
3151
+ obj = obj.obj
3152
+ elif isinstance(obj, A.TryOp):
3153
+ obj = obj.operand
3154
+ elif isinstance(obj, A.Call):
3155
+ if not isinstance(obj.callee, A.Member):
3156
+ return True # a function / constructor result
3157
+ obj = obj.callee.obj
3158
+ else:
3159
+ break
3160
+ if isinstance(obj, A.Name):
3161
+ n = obj.id
3162
+ if n in _AMBIENT_RECEIVERS:
3163
+ return False
3164
+ owner = next((i for i in range(len(scopes) - 1, -1, -1)
3165
+ if n in scopes[i]), None)
3166
+ # scopes[0] holds the builtins and module-level names: namespaces,
3167
+ # clients, declarations. A local binding is a value.
3168
+ return owner is not None and owner > 0
3169
+ return isinstance(obj, (A.Literal, A.ListLit, A.RecordLit, A.Binary,
3170
+ A.Unary, A.IfExpr, A.Range))
3171
+
3172
+ def _interp_expr(self, s: int, e: int):
3173
+ """Parse the `{expr}` at source offsets [s, e) the way `vlbuild`'s
3174
+ `_parse_sub_expr` does, with node offsets moved to the whole file.
3175
+ None when it does not parse (the build reports that itself)."""
3176
+ from .lexer import Lexer
3177
+ from .parser import Parser
3178
+ frag = self.li.text[s:e]
3179
+ try:
3180
+ sub = Parser(Lexer(frag, self.filename).lex(), frag,
3181
+ self.filename).parse_expr()
3182
+ except Exception: # noqa: BLE001 -- a bad fragment is not ours to report
3183
+ return None
3184
+ for node in _walk(sub):
3185
+ node.start += s
3186
+ node.end += s
3187
+ return sub
3188
+
3189
+ def _src(self, node, limit: int = 40) -> str | None:
3190
+ """The source text of an expression node for a `fix:` line, or None
3191
+ when it is long or spans lines. Some nodes end at the end of the token
3192
+ after them (the `.` of a member access, the `)` closing the call
3193
+ around them), so a trailing `.` and any unbalanced closer is
3194
+ trimmed."""
3195
+ text = self.li.text[node.start:node.end].strip().rstrip(".").rstrip()
3196
+ for op, cl in (("(", ")"), ("[", "]")):
3197
+ while text.endswith(cl) and text.count(cl) > text.count(op):
3198
+ text = text[:-1].rstrip()
3199
+ return text if text and len(text) <= limit and "\n" not in text else None
3200
+
3201
+ def _check_value_method(self, call, scopes: list[set]) -> None:
3202
+ """E-METHOD-UNKNOWN: `value.m(...)` where `m` is not a method of a
3203
+ Volaro value. See `_VALUE_METHODS` for why this used to throw at run
3204
+ time instead of failing the check."""
3205
+ callee = call.callee
3206
+ if not isinstance(callee, A.Member):
3207
+ return
3208
+ m = callee.name
3209
+ if m in _LOAD_METHODS or m in _PASS_THROUGH_METHODS:
3210
+ return # E-LOAD-METHOD owns reload / loading
3211
+ if m in self._fn_fields:
3212
+ return # calling a `fn`-typed record field
3213
+ if not self._value_receiver(callee.obj, scopes):
3214
+ return
3215
+ r = self._src(callee.obj) or "x"
3216
+ if m in _VALUE_METHODS:
3217
+ want = _VALUE_METHODS[m]
3218
+ got = len(call.args) + len(call.named)
3219
+ if got != want:
3220
+ sig = "len()" if m == "len" else "format(pattern)"
3221
+ self._d("E-METHOD-UNKNOWN", Severity.ERROR,
3222
+ "'.%s' takes %s -- `%s.%s(...)` with %d compiles to a "
3223
+ "method JavaScript does not have and throws when it runs"
3224
+ % (m, "no arguments" if want == 0 else "one argument",
3225
+ r, m, got),
3226
+ call, "write `%s.%s`" % (r, sig))
3227
+ return
3228
+ fn = _METHOD_FN_FORM.get(m)
3229
+ parts = ([self._src(a) for a in call.args]
3230
+ + ["%s: %s" % (k, self._src(v)) for k, v in call.named])
3231
+ args = ", ".join(p or "..." for p in parts)
3232
+ if len(args) > 40:
3233
+ args = "..."
3234
+ if fn is not None:
3235
+ if fn in _LIST_FNS:
3236
+ form = "%s |> %s(%s)" % (r, fn, args)
3237
+ elif fn == "contains" and not args:
3238
+ form = "contains(%s, y)" % r
3239
+ else:
3240
+ form = "%s(%s%s)" % (fn, r, (", " + args) if args else "")
3241
+ fix = "write `%s` -- %s is a std function, not a method" % (form, fn)
3242
+ elif m in _METHOD_OTHER_FIX:
3243
+ fix = "write " + _METHOD_OTHER_FIX[m].format(r=r)
3244
+ else:
3245
+ fix = ("a value's only methods are `.len()` and `.format(pattern)` "
3246
+ "(plus `.reload()` / `.loading()` on a view's `load` name); "
3247
+ "string and list operations are std functions -- `f(x, ...)` "
3248
+ "or `x |> f(...)`")
3249
+ self._d("E-METHOD-UNKNOWN", Severity.ERROR,
3250
+ "'.%s()' is not a method of a Volaro value -- the build emits "
3251
+ "`%s.%s(...)` as a raw JavaScript method call, which throws "
3252
+ "when the method does not exist" % (m, r, m), call, fix)
3253
+
2957
3254
  def _check_assign(self, st, scopes: list[set]) -> None:
2958
3255
  """Only a view `state` and a `let mut` can be reassigned (spec 5.1:
2959
3256
  bindings are immutable unless declared `mut`). The binding that counts
@@ -3015,6 +3312,12 @@ class Checker:
3015
3312
  self._check_builtin_call(A.Call(start=ex.start, end=ex.end,
3016
3313
  callee=A.Name(start=ex.start, end=ex.end, id=nm)),
3017
3314
  scopes)
3315
+ # `"{a.title.lower()}"`: the build lowers each `{expr}` as a
3316
+ # full expression, so its method calls are judged the same way.
3317
+ for s, e in getattr(ex, "interp_spans", ()):
3318
+ for node in _walk(self._interp_expr(s, e)):
3319
+ if isinstance(node, A.Call):
3320
+ self._check_value_method(node, scopes)
3018
3321
  if self.strict_names:
3019
3322
  for n in ex.interp_names:
3020
3323
  if not self._resolve(n, scopes):
@@ -3029,6 +3332,7 @@ class Checker:
3029
3332
  self._expr(ex.index, scopes, fallible)
3030
3333
  elif isinstance(ex, A.Call):
3031
3334
  self._check_builtin_call(ex, scopes)
3335
+ self._check_value_method(ex, scopes)
3032
3336
  callee = ex.callee
3033
3337
  if isinstance(callee, A.Member) and callee.name in _LOAD_METHODS:
3034
3338
  self._check_load_call(ex, scopes)
@@ -3178,6 +3482,7 @@ def check(mod: A.Module, filename: str, li: LineIndex,
3178
3482
  c = Checker(filename, li, strict_names, release)
3179
3483
  if isinstance(mod, A.PageServerFile):
3180
3484
  c.page_server(mod)
3485
+ c.record_field_names(mod)
3181
3486
  return c.diags
3182
3487
  if isinstance(mod, A.ServerContextFile):
3183
3488
  # Provider expressions get the ambient server names in scope; earlier
@@ -1608,6 +1608,7 @@ class Parser:
1608
1608
  lit.interp_names = self._interp_names(t)
1609
1609
  lit.interp_calls = [m.group(1) for s, e in getattr(t, "interps", [])
1610
1610
  for m in _CALL_RE.finditer(self.src[s:e])]
1611
+ lit.interp_spans = list(getattr(t, "interps", []))
1611
1612
  return lit
1612
1613
  if self.at_kw("if"):
1613
1614
  return self._parse_if_expr()
@@ -66,6 +66,12 @@ _STD_EXTRA = {
66
66
  "replace", "pad_start", "pad_end", "repeat", "now", "uuid4", "random",
67
67
  "matches",
68
68
  }
69
+ # std.list / std.str functions whose result is a list (E-LIST-OPERATOR);
70
+ # only when the name is not the module's own declaration or a local binding
71
+ _LIST_STD_FNS = {"concat", "append", "filter", "map", "take", "sort_by",
72
+ "chunk", "keys", "values", "split"}
73
+ _ARITH = {"+", "-", "*", "/", "%"}
74
+ _COMPOUND_ARITH = {"+=", "-=", "*=", "/="}
69
75
  # bare-word identifiers the spec uses in argument position (not bindings)
70
76
  _CONTEXTUAL = {"asc", "desc", "google", "github"}
71
77
  # Spec §3.4: a component's case carries meaning.
@@ -78,6 +84,10 @@ _UI_STD_VIEWS = {
78
84
  "Spinner", "Avatar", "Icon", "Image", "Portal", "Fragment", "Divider",
79
85
  "Skeleton", "Badge",
80
86
  }
87
+ # The std.ui views this build's runtime actually renders (vlbuild
88
+ # RUNTIME_COMPONENTS). The rest of `_UI_STD_VIEWS` resolves as a name but is
89
+ # E-COMPONENT-UNSUPPORTED at the call site.
90
+ _RUNTIME_VIEWS = {"Spinner"}
81
91
  # Spec §11.3, exact: the compiler-owned `auth` client surface, eight calls.
82
92
  # Unlike `_STD_EXTRA` (names with no signature to check), the spec gives each of
83
93
  # these a parameter list, so an unknown method or a wrong argument here is a
@@ -511,7 +521,10 @@ class Resolver:
511
521
  sc[s.name] = (_tref(s.type)[:2] if s.type
512
522
  else self._typeof(s.init, [sc]))
513
523
  elif isinstance(s, A.DeriveDecl):
514
- sc[s.name] = None
524
+ # only list-ness is tracked for a derive (E-LIST-OPERATOR);
525
+ # its element type is left unknown, as before
526
+ ti = self._typeof(s.expr, [sc])
527
+ sc[s.name] = (None, True) if ti and ti[1] else None
515
528
  elif isinstance(s, A.LoadDecl):
516
529
  sc[s.name] = self._load_type(s, [sc])
517
530
  self._in_view = True
@@ -557,6 +570,9 @@ class Resolver:
557
570
  elif isinstance(st, A.Assign):
558
571
  self._expr(st.target, scopes)
559
572
  self._expr(st.value, scopes)
573
+ if st.op in _COMPOUND_ARITH:
574
+ self._check_list_operator(st.op[0], st.target, st.value, st,
575
+ scopes)
560
576
  elif isinstance(st, A.ExprStmt):
561
577
  self._expr(st.expr, scopes)
562
578
  elif isinstance(st, A.Return):
@@ -630,6 +646,8 @@ class Resolver:
630
646
  if name[:1].isupper() and name not in _UI_STD_VIEWS and sym is None:
631
647
  self._d("R-UNDEF", f"component '{name}' is not defined", st,
632
648
  "declare a view with this name, or check the spelling")
649
+ elif name[:1].isupper():
650
+ self._component_shape(st, sym)
633
651
  if sym is not None and sym.kind == "view" and sym.params is not None:
634
652
  given = {k for k, _ in st.attrs}
635
653
  for pn, has_def, is_opt in sym.params:
@@ -644,6 +662,47 @@ class Resolver:
644
662
  self._expr(v, scopes)
645
663
  self._scoped(st.body, scopes)
646
664
 
665
+ def _component_shape(self, st: A.Element, sym) -> None:
666
+ """An uppercase element the build cannot lower as written. Each case
667
+ used to build with a `warn:` line only: an unknown component became
668
+ an empty placeholder `div`, and a positional argument or a runtime
669
+ component's arguments and children were dropped."""
670
+ name = st.name
671
+ if sym is None and name in _UI_STD_VIEWS and name not in _RUNTIME_VIEWS:
672
+ self._d("E-COMPONENT-UNSUPPORTED",
673
+ f"'{name}' is not a view this build provides (the only "
674
+ f"built-in view is {', '.join(sorted(_RUNTIME_VIEWS))}); "
675
+ "the build would render an empty placeholder", st,
676
+ f"declare `view {name}(...)` in this project and import it")
677
+ return
678
+ if sym is None: # a runtime view: Spinner
679
+ if st.args or st.attrs or any(not isinstance(b, A.OnBlock)
680
+ for b in st.body):
681
+ self._d("E-COMPONENT-ARGS",
682
+ f"'{name}' takes no arguments or children -- the "
683
+ "build would drop them", st, f"{name}()")
684
+ return
685
+ if sym.kind != "view":
686
+ what = {"record": "a record type", "union": "a union type",
687
+ "alias": "a type alias", "variant": "a union variant",
688
+ "fn": "a function", "service": "a service",
689
+ "api": "an api client"}.get(sym.kind, "not a view")
690
+ self._d("E-COMPONENT-NOT-VIEW",
691
+ f"'{name}' is {what}"
692
+ + (" imported from a package this build cannot compile"
693
+ if sym.kind == "opaque" else "")
694
+ + " -- only a `view` renders as a component, and the "
695
+ "build would emit an empty placeholder", st,
696
+ "declare a view with this name, or import one "
697
+ "(use ./components { Name })")
698
+ return
699
+ if st.args:
700
+ first = (sym.params or [("name", False, False)])[0][0]
701
+ self._d("E-COMPONENT-POSITIONAL",
702
+ f"'{name}' takes named props, not a positional argument -- "
703
+ "the build would drop it", st.args[0],
704
+ f"{name}({first}: ...)")
705
+
647
706
  # -------------------------------------------------------------- expressions
648
707
  def _expr(self, ex, scopes):
649
708
  if ex is None:
@@ -680,6 +739,10 @@ class Resolver:
680
739
  elif isinstance(ex, A.Binary):
681
740
  self._expr(ex.left, scopes)
682
741
  self._expr(ex.right, scopes)
742
+ if ex.op in _ARITH and not (isinstance(ex.left, A.Binary)
743
+ and ex.left.op in _ARITH
744
+ and self._is_list(ex.left, scopes)):
745
+ self._check_list_operator(ex.op, ex.left, ex.right, ex, scopes)
683
746
  elif isinstance(ex, A.Range):
684
747
  self._expr(ex.lo, scopes)
685
748
  self._expr(ex.hi, scopes)
@@ -803,7 +866,27 @@ class Resolver:
803
866
  if isinstance(ex, A.Index):
804
867
  ot = self._typeof(ex.obj, scopes)
805
868
  return (ot[0], False) if ot and ot[1] else None
869
+ if isinstance(ex, A.ListLit):
870
+ return (None, True)
871
+ if (isinstance(ex, A.Binary) and ex.op == "|>"
872
+ and isinstance(ex.right, (A.Call, A.Name))):
873
+ right = ex.right
874
+ call = (A.Call(callee=right.callee, args=[ex.left, *right.args],
875
+ named=list(right.named))
876
+ if isinstance(right, A.Call)
877
+ else A.Call(callee=right, args=[ex.left]))
878
+ return self._typeof(call, scopes)
879
+ if isinstance(ex, A.Binary) and ex.op in _ARITH:
880
+ # a list on either side is already an E-LIST-OPERATOR; carrying
881
+ # the list-ness keeps `a + b + c` to one diagnostic
882
+ if self._is_list(ex.left, scopes) or self._is_list(ex.right, scopes):
883
+ return (None, True)
884
+ return None
806
885
  if isinstance(ex, A.Call):
886
+ if (isinstance(ex.callee, A.Name) and ex.callee.id in _LIST_STD_FNS
887
+ and ex.callee.id not in self.table
888
+ and not any(ex.callee.id in sc for sc in scopes)):
889
+ return (None, True)
807
890
  if isinstance(ex.callee, A.Member) and isinstance(ex.callee.obj, A.Name) \
808
891
  and (ex.callee.obj.id == "api"
809
892
  or (self.table.get(ex.callee.obj.id) or Sym("x")).kind in ("service", "api")):
@@ -815,6 +898,32 @@ class Resolver:
815
898
  return s.ret
816
899
  return None
817
900
 
901
+ def _is_list(self, ex, scopes) -> bool:
902
+ ti = self._typeof(ex, scopes)
903
+ return bool(ti and ti[1])
904
+
905
+ def _check_list_operator(self, op, left, right, node, scopes) -> None:
906
+ """E-LIST-OPERATOR: an arithmetic operator with a list operand. The
907
+ build lowers `+` straight to JavaScript's `+`, which joins two arrays
908
+ into a STRING (`[1] + [2]` is "12"), so `pub = pub + [x]` checked
909
+ clean and the next `group_by` over `pub` threw. Spec 20.9: lists grow
910
+ only through `concat(a, b)` / `append(xs, x)`. Judged only where the
911
+ resolver can tell a value is a list; an unknown type is skipped."""
912
+ if not (self._is_list(left, scopes) or self._is_list(right, scopes)):
913
+ return
914
+ if op == "+":
915
+ self._d("E-LIST-OPERATOR",
916
+ "'+' does not combine lists -- there is no list `+` "
917
+ "(spec 20.9), and the build would join the two lists "
918
+ "into a string", node,
919
+ "use concat(a, b) or xs |> append(x)")
920
+ else:
921
+ self._d("E-LIST-OPERATOR",
922
+ f"'{op}' is not defined on a list -- arithmetic operators "
923
+ "take numbers", node,
924
+ "use a std.list function (filter, map, take, concat, "
925
+ "append)")
926
+
818
927
  def _record(self, ti):
819
928
  if not ti or ti[1]:
820
929
  return None
@@ -328,6 +328,9 @@ def main(argv: list[str]) -> int:
328
328
  else:
329
329
  print("vlbuild: wrote %s/" % out_dir)
330
330
  print(" views: %s" % ", ".join(v.name for v in emitter.views))
331
+ if emitter.imported_views:
332
+ print(" imported views: %s"
333
+ % ", ".join(v.name for v in emitter.imported_views))
331
334
  print(" routes: %s" % (", ".join("route_" + k for k in emitter.routes) or "(none)"))
332
335
  print(" data: %s" % data_note)
333
336
  for w in emitter.warnings:
@@ -117,6 +117,10 @@
117
117
  replace: function (s, target, repl) {
118
118
  return String(s).split(String(target)).join(String(repl));
119
119
  },
120
+ // std.str's substring test: case-sensitive, an empty `part` is true.
121
+ // Distinct from the `where(col: contains(x))` DB predicate, which the
122
+ // compiler lowers to an `{op: "contains"}` marker instead of this call.
123
+ contains: function (s, part) { return String(s).indexOf(String(part)) !== -1; },
120
124
  first_char: function (s) { return String(s).slice(0, 1); },
121
125
  trim: function (s) { return String(s).trim(); },
122
126
  upper: function (s) { return String(s).toUpperCase(); },