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 +1 -1
- package/compiler/SOURCE_INFO.json +2 -2
- package/compiler/SOURCE_REV +1 -1
- package/compiler/validator/vlcheck/ast_nodes.py +1 -0
- package/compiler/validator/vlcheck/checks.py +310 -5
- package/compiler/validator/vlcheck/parser.py +1 -0
- package/compiler/validator/vlcheck/resolve.py +110 -1
- package/compiler/vlbuild/vlbuild/__main__.py +3 -0
- package/compiler/vlbuild/vlbuild/assets/vlrt.js +4 -0
- package/compiler/vlbuild/vlbuild/assets/vlrt.min.js +1 -1
- package/compiler/vlbuild/vlbuild/emit.py +315 -46
- package/compiler/vlbuild/vlbuild/server_emit.py +9 -0
- package/compiler/vlbuild/vlbuild/static_assets.py +1 -1
- package/language/crib.md +5 -0
- package/language/spec.md +2 -2
- package/language/supported.md +47 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Volaro
|
|
2
2
|
|
|
3
|
-
**Limited alpha** (this is `0.1.0-alpha.
|
|
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": "
|
|
2
|
+
"revision": "5f9c32d19082250fc91ab3664ae6726aa2cfd0a0",
|
|
3
3
|
"source": "git",
|
|
4
4
|
"dirty": false,
|
|
5
|
-
"content_sha256": "
|
|
5
|
+
"content_sha256": "53474ecb82ebaf7ff2861f647b7796dc9c84cce73d7061ee64a65c90365a80ae"
|
|
6
6
|
}
|
package/compiler/SOURCE_REV
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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 =
|
|
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
|
|
904
|
-
if
|
|
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
|
|
925
|
-
if
|
|
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
|
-
|
|
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(); },
|