volaro 0.1.0-alpha.4 → 0.1.0-alpha.6
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 +8 -9
- package/bin/vl.js +2 -2
- 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 +279 -32
- package/compiler/validator/vlcheck/elements.py +70 -1
- package/compiler/validator/vlcheck/layoutcompose.py +36 -9
- package/compiler/validator/vlcheck/parser.py +24 -0
- package/compiler/vlbuild/styling/README.md +10 -1
- package/compiler/vlbuild/styling/build-css.mjs +87 -9
- package/compiler/vlbuild/styling/test-build-css.mjs +81 -1
- package/compiler/vlbuild/vlbuild/__main__.py +13 -8
- package/compiler/vlbuild/vlbuild/assets/vlrouter.js +439 -110
- package/compiler/vlbuild/vlbuild/assets/vlrt.css +113 -7
- package/compiler/vlbuild/vlbuild/assets/vlrt.js +94 -19
- package/compiler/vlbuild/vlbuild/emit.py +263 -47
- package/compiler/vlbuild/vlbuild/pages_build.py +147 -17
- package/compiler/vlbuild/vlbuild/server_emit.py +62 -10
- package/compiler/vlbuild/vlbuild/style_config.py +52 -18
- package/compiler/vlbuild/vlbuild/styling.py +8 -6
- package/language/crib.md +72 -14
- package/language/spec.md +10 -4
- package/language/supported.md +61 -13
- package/package.json +1 -1
- package/scripts/sync-compiler.sh +0 -0
package/README.md
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# Volaro
|
|
2
2
|
|
|
3
|
-
**Limited alpha
|
|
4
|
-
`
|
|
5
|
-
`0.0.x` placeholder) — always specify `@alpha` or the exact version.
|
|
3
|
+
**Limited alpha** (this is `0.1.0-alpha.6`), published under both the
|
|
4
|
+
`latest` and `alpha` dist-tags: `npm install volaro`.
|
|
6
5
|
|
|
7
6
|
Volaro is an experimental application language intended for AI authoring and
|
|
8
7
|
human review. This package ships the language reference **and a working
|
|
@@ -113,11 +112,11 @@ dynamic route), so `volaro dev` on a project directory runs that server.
|
|
|
113
112
|
## Scaffold a project
|
|
114
113
|
|
|
115
114
|
```bash
|
|
116
|
-
npm create volaro
|
|
115
|
+
npm create volaro my-app # the separate create-volaro package
|
|
117
116
|
```
|
|
118
117
|
|
|
119
|
-
(
|
|
120
|
-
|
|
118
|
+
(If `npm create volaro` prints "This release is a placeholder", npm is
|
|
119
|
+
reusing a cached copy of the old `0.0.1`: run `npm cache clean --force`.)
|
|
121
120
|
|
|
122
121
|
It writes one single-page starter, a `volaro.json`, and `package.json` scripts
|
|
123
122
|
(`dev` / `build` / `check`) that call `volaro`.
|
|
@@ -155,9 +154,9 @@ use them.
|
|
|
155
154
|
**Form controls:** `input` (a full type matrix, including `checkbox`/`radio`
|
|
156
155
|
binding a `checked` state), `textarea`, `select`/`option`/`optgroup`,
|
|
157
156
|
`fieldset`/`legend`, `datalist`, `button`, `link`. `disabled:` works on
|
|
158
|
-
`button input textarea select fieldset optgroup option`.
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
`button input textarea select fieldset optgroup option`. A toggle is
|
|
158
|
+
`button pressed:<bool>` (a toggle button, `aria-pressed`; there is no
|
|
159
|
+
`switch` role). There is no standalone `label` element. `variant:` is a literal
|
|
161
160
|
style name, not a computed expression. `if` used as an expression is binary
|
|
162
161
|
(`if c a else b`); `match` and block `if` are statements only. A module-level
|
|
163
162
|
`fn` is not available to a view. **`volaro supported` and `volaro crib` are
|
package/bin/vl.js
CHANGED
|
@@ -771,8 +771,8 @@ choose the interpreter if \`python3\` is not the one you want.
|
|
|
771
771
|
|
|
772
772
|
SHIPPED SUBSET: single page (no router); \`input\` (full type matrix incl.
|
|
773
773
|
checkbox/radio), \`textarea\`, \`select\`/\`option\`/\`optgroup\`,
|
|
774
|
-
\`fieldset\`/\`legend\`, \`datalist\`, \`button
|
|
775
|
-
|
|
774
|
+
\`fieldset\`/\`legend\`, \`datalist\`, \`button\` (a toggle is \`button pressed:\`,
|
|
775
|
+
aria-pressed; no switch role), \`link\` (no standalone \`label\`); \`if\` as an expression is
|
|
776
776
|
binary; \`match\` is a statement; a module-level \`fn\` cannot be called from a
|
|
777
777
|
view. \`volaro crib\` + \`volaro supported\` are authoritative for this build;
|
|
778
778
|
\`volaro spec\` is the wider design, not what it accepts.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"revision": "
|
|
2
|
+
"revision": "78304efecb7e728025c959e1fdf207749b3652ed",
|
|
3
3
|
"source": "git",
|
|
4
4
|
"dirty": false,
|
|
5
|
-
"content_sha256": "
|
|
5
|
+
"content_sha256": "1747ce8d53c4ae2caed129b014f01f7e4af5619ab5358a833e0c888e940c830e"
|
|
6
6
|
}
|
package/compiler/SOURCE_REV
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
78304efecb7e728025c959e1fdf207749b3652ed
|
|
@@ -17,7 +17,7 @@ from .elements import (
|
|
|
17
17
|
AUTOCOMPLETE_TOKENS, BUTTON_TYPES, ELEMENT_EVENTS, INPUT_TYPE_ATTRS,
|
|
18
18
|
KNOWN_EVENTS,
|
|
19
19
|
EXCLUSIVE_CHILDREN, EXCLUSIVE_PARENTS, NAMED_LANDMARKS,
|
|
20
|
-
NATIVE_ELEMENT_TAGS, OL_TYPES,
|
|
20
|
+
NATIVE_ELEMENT_TAGS, OL_TYPES, SPECIAL_ELEMENT_NAMES, attr_is_known,
|
|
21
21
|
PHRASING_ELEMENTS, PHRASING_ONLY_PARENTS, TEXT_ARG_ELEMENTS,
|
|
22
22
|
VOID_ELEMENTS,
|
|
23
23
|
BDI_DIRS, BDO_DIRS, INTERACTIVE_ELEMENTS, LINK_RELS, LINK_TARGETS,
|
|
@@ -66,6 +66,23 @@ BUILTINS = {
|
|
|
66
66
|
BUILTINS.update(ELEMENT_NAMES)
|
|
67
67
|
_PRIM_NAMES = {"int", "float", "str", "bool", "bytes", "time", "dur", "uuid", "json", "nil"}
|
|
68
68
|
|
|
69
|
+
# Names on the BUILTINS allowlist that are methods in the language, never free
|
|
70
|
+
# functions. A bare call (`len(xs)`) passed --resolve and was emitted verbatim,
|
|
71
|
+
# an undefined JS function: ReferenceError at load (action-48).
|
|
72
|
+
_METHOD_ONLY = {
|
|
73
|
+
"len": "xs.len()", "format": "value.format(pattern)",
|
|
74
|
+
"reload": "posts.reload()", "loading": "posts.loading()",
|
|
75
|
+
}
|
|
76
|
+
# Free functions the spec shows but no emitter lowers as a bare call (not in
|
|
77
|
+
# vlbuild's STD_FNS). Only an error where the build emits the call -- a view
|
|
78
|
+
# or a service route; a module-level `fn` body is never emitted into either.
|
|
79
|
+
_FN_NOT_BUILT = {
|
|
80
|
+
"sorted": "sort_by(xs, fn(x) x)", "first": "xs[0] (check xs.len() > 0 first)",
|
|
81
|
+
"fold": "a `for` loop over a `let mut` accumulator",
|
|
82
|
+
"merge": "a record literal naming every field",
|
|
83
|
+
"to_int": "no string-to-int conversion is built yet; keep the value an int",
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
# `use std.X { name, ... }` names `vlbuild` actually lowers to a working call
|
|
70
87
|
# -- NOT the same list as `BUILTINS` above (that one is a generous, deliberately
|
|
71
88
|
# loose allowlist for the W-UNDEF-NAME heuristic, and includes several names,
|
|
@@ -246,6 +263,11 @@ class Checker:
|
|
|
246
263
|
# keeps a `alt_todo:` / other TODO marker out of a shipped build.
|
|
247
264
|
self.release = release
|
|
248
265
|
self.diags: list[Diagnostic] = []
|
|
266
|
+
# semantic(): names the module declares or imports, and whether the
|
|
267
|
+
# code being walked is emitted by the build (views, routes) or not
|
|
268
|
+
# (module-level fn bodies)
|
|
269
|
+
self._module_names: set[str] = set()
|
|
270
|
+
self._emitted = True
|
|
249
271
|
|
|
250
272
|
# -- emit ----------------------------------------------------------
|
|
251
273
|
def _d(self, code: str, sev: Severity, msg: str, node, fix: str | None = None) -> None:
|
|
@@ -255,6 +277,7 @@ class Checker:
|
|
|
255
277
|
|
|
256
278
|
def run(self, mod: A.Module) -> list[Diagnostic]:
|
|
257
279
|
from .test_ids import check_test_ids
|
|
280
|
+
self._module = mod
|
|
258
281
|
self.diags.extend(check_test_ids(mod, self.filename))
|
|
259
282
|
self.structural(mod)
|
|
260
283
|
self.styling(mod)
|
|
@@ -266,6 +289,7 @@ class Checker:
|
|
|
266
289
|
self.children_marker(mod)
|
|
267
290
|
self.services(mod)
|
|
268
291
|
self.api_params(mod)
|
|
292
|
+
self.api_base(mod)
|
|
269
293
|
self.imports(mod)
|
|
270
294
|
self.semantic(mod)
|
|
271
295
|
return self.diags
|
|
@@ -327,17 +351,12 @@ class Checker:
|
|
|
327
351
|
elif isinstance(node, A.For):
|
|
328
352
|
value_names.update(getattr(node, "vars", []) or [])
|
|
329
353
|
|
|
354
|
+
# (1) is checked by semantic(), whose walker scopes names
|
|
355
|
+
# lexically: an assignment to a list/for var, closure or handler
|
|
356
|
+
# parameter, match/catch bind or `let` that shadows a readonly
|
|
357
|
+
# name writes that local, not the view-level value (issue #17).
|
|
330
358
|
for node in _walk(it.body):
|
|
331
|
-
if isinstance(node, A.
|
|
332
|
-
tgt = getattr(node, "target", None)
|
|
333
|
-
nm = tgt.id if isinstance(tgt, A.Name) else None
|
|
334
|
-
if nm in readonly:
|
|
335
|
-
self._d("E-ASSIGN-READONLY", Severity.ERROR,
|
|
336
|
-
"'%s' is a %s and cannot be assigned to -- it is "
|
|
337
|
-
"recomputed, not stored. Declare a `state` for a "
|
|
338
|
-
"value you change." % (nm, readonly[nm]),
|
|
339
|
-
node, None)
|
|
340
|
-
elif isinstance(node, A.Element):
|
|
359
|
+
if isinstance(node, A.Element):
|
|
341
360
|
for k, v in node.attrs:
|
|
342
361
|
if k != "variant":
|
|
343
362
|
continue
|
|
@@ -488,13 +507,14 @@ class Checker:
|
|
|
488
507
|
if not valid:
|
|
489
508
|
self._d("E-THEME-SHAPE", Severity.ERROR,
|
|
490
509
|
"malformed %s token row" % category.head, category)
|
|
491
|
-
allowed = {"button", "input", "card"
|
|
510
|
+
allowed = {"button", "input", "card", "select", "textarea", "checkbox",
|
|
511
|
+
"radio", "link", "fieldset", "table", "details"}
|
|
492
512
|
seen_recipes: set[str] = set()
|
|
493
513
|
for recipe in (x for x in mod.items if isinstance(x, A.RecipeDecl)):
|
|
494
514
|
if recipe.primitive not in allowed:
|
|
495
515
|
self._d("E-RECIPE-UNKNOWN-PRIMITIVE", Severity.ERROR,
|
|
496
|
-
"
|
|
497
|
-
% recipe.primitive, recipe)
|
|
516
|
+
"recipes support %s; got %r"
|
|
517
|
+
% (", ".join(sorted(allowed)), recipe.primitive), recipe)
|
|
498
518
|
continue
|
|
499
519
|
if recipe.primitive in seen_recipes:
|
|
500
520
|
self._d("E-RECIPE-SHAPE", Severity.ERROR,
|
|
@@ -541,7 +561,8 @@ class Checker:
|
|
|
541
561
|
if value not in axes.get(axis, set()):
|
|
542
562
|
self._d("E-RECIPE-SHAPE", Severity.ERROR,
|
|
543
563
|
"default %s:%s is not declared" % (axis, value), recipe)
|
|
544
|
-
if recipe.primitive in ("button", "input"
|
|
564
|
+
if recipe.primitive in ("button", "input", "select", "textarea",
|
|
565
|
+
"checkbox", "radio", "link"):
|
|
545
566
|
all_tokens = " ".join(" ".join(r.tokens) for r in recipe.body)
|
|
546
567
|
if "focus-visible:" not in all_tokens:
|
|
547
568
|
self._d("E-RECIPE-FOCUS", Severity.ERROR,
|
|
@@ -583,7 +604,10 @@ class Checker:
|
|
|
583
604
|
if any("[" in c and "]" in c for c in classes):
|
|
584
605
|
self._d("W-CLASS-ARBITRARY", Severity.WARNING,
|
|
585
606
|
"arbitrary values bypass the accessibility denylist", node)
|
|
586
|
-
|
|
607
|
+
# The same element set vlbuild's resolve_classes enforces; the
|
|
608
|
+
# two must agree or `vlcheck --resolve` passes source the build
|
|
609
|
+
# then rejects, and the write -> check -> repair loop breaks.
|
|
610
|
+
if (node.name in {"button", "input", "link", "select", "textarea"}
|
|
587
611
|
and any(c in {"outline-none", "outline-0", "ring-0"} for c in utilities)
|
|
588
612
|
and not any("focus-visible:" in c and c.rsplit(":", 1)[-1]
|
|
589
613
|
not in {"outline-none", "outline-0", "ring-0"}
|
|
@@ -1050,6 +1074,17 @@ class Checker:
|
|
|
1050
1074
|
elif k in ("start", "reversed") and name != "ol":
|
|
1051
1075
|
self._d("E-ATTR-TARGET", Severity.ERROR,
|
|
1052
1076
|
"'%s:' is only supported on 'ol'" % k, node, None)
|
|
1077
|
+
elif k == "pressed" and name != "button":
|
|
1078
|
+
# action-40 (Toggle). `pressed:` is a button's toggle state
|
|
1079
|
+
# (aria-pressed); on any other element it contradicts the
|
|
1080
|
+
# element's role. vlbuild refuses it too (E-PRESSED-TARGET);
|
|
1081
|
+
# the checker must agree or the write -> check -> repair
|
|
1082
|
+
# loop passes source the build then rejects.
|
|
1083
|
+
self._d("E-ATTR-TARGET", Severity.ERROR,
|
|
1084
|
+
"'pressed:' is only supported on 'button' -- it is a "
|
|
1085
|
+
"toggle button's on/off state (aria-pressed), and on "
|
|
1086
|
+
"'%s' it would contradict the element's role" % name,
|
|
1087
|
+
node, "use a 'button' with 'pressed:' for a toggle")
|
|
1053
1088
|
elif k == "cite" and name not in ("blockquote", "q"):
|
|
1054
1089
|
self._d("E-ATTR-TARGET", Severity.ERROR,
|
|
1055
1090
|
"'cite:' is only supported on 'blockquote' and 'q'",
|
|
@@ -1059,6 +1094,85 @@ class Checker:
|
|
|
1059
1094
|
self._table_element(node, parent_el)
|
|
1060
1095
|
self._media_element(node, parent_el)
|
|
1061
1096
|
self._global_and_style_surface(node, parent_el)
|
|
1097
|
+
self._unknown_attrs(node)
|
|
1098
|
+
|
|
1099
|
+
def _unknown_attrs(self, node) -> None:
|
|
1100
|
+
"""`E-ATTR-UNKNOWN`: an attribute no rule gives a meaning to.
|
|
1101
|
+
vlbuild refuses these (`Emitter._apply_attr`); until this rule the
|
|
1102
|
+
checker passed them, so `row wrap:true` checked clean and then failed
|
|
1103
|
+
the build. `attr_is_known` holds the emitter's name list, and a
|
|
1104
|
+
vlbuild test sweeps it against the real emitter."""
|
|
1105
|
+
name = node.name
|
|
1106
|
+
if name not in NATIVE_ELEMENT_TAGS or name in SPECIAL_ELEMENT_NAMES:
|
|
1107
|
+
return
|
|
1108
|
+
axes = self._recipe_axes_for(node)
|
|
1109
|
+
for k, _v in node.attrs:
|
|
1110
|
+
if attr_is_known(name, k):
|
|
1111
|
+
continue
|
|
1112
|
+
if axes is None or k in axes:
|
|
1113
|
+
# A recipe's style axis, or a recipes.vl this pass cannot
|
|
1114
|
+
# read: the build decides.
|
|
1115
|
+
continue
|
|
1116
|
+
if any(d.severity == Severity.ERROR and d.start == node.start
|
|
1117
|
+
and ("'%s:'" % k) in d.message for d in self.diags):
|
|
1118
|
+
continue # an earlier rule already refused this attribute
|
|
1119
|
+
self._d("E-ATTR-UNKNOWN", Severity.ERROR,
|
|
1120
|
+
"unknown attribute '%s:' on '%s' -- Volaro has no contract "
|
|
1121
|
+
"for it, and the build refuses it" % (k, name), node,
|
|
1122
|
+
"check the spelling, or see the supported attribute list "
|
|
1123
|
+
"for '%s'" % name)
|
|
1124
|
+
|
|
1125
|
+
def _recipe_axes_for(self, node):
|
|
1126
|
+
"""The style-axis names of the recipe this element uses, as the
|
|
1127
|
+
build resolves them (vlbuild style_config.recipe_for): recipes in this
|
|
1128
|
+
file, in a recipes.vl beside it, or in the project's root recipes.vl
|
|
1129
|
+
for a file under app/. None when a recipes.vl does not parse."""
|
|
1130
|
+
recipes = self._recipes()
|
|
1131
|
+
if recipes is None:
|
|
1132
|
+
return None
|
|
1133
|
+
key = name = node.name
|
|
1134
|
+
if name == "input":
|
|
1135
|
+
for k, v in node.attrs:
|
|
1136
|
+
if k == "type" and isinstance(v, A.Name) and v.id in ("checkbox", "radio"):
|
|
1137
|
+
key = v.id
|
|
1138
|
+
return recipes.get(key, frozenset())
|
|
1139
|
+
|
|
1140
|
+
def _recipes(self):
|
|
1141
|
+
if hasattr(self, "_recipe_cache"):
|
|
1142
|
+
return self._recipe_cache
|
|
1143
|
+
mods = [self._module]
|
|
1144
|
+
here = os.path.dirname(os.path.abspath(self.filename))
|
|
1145
|
+
dirs = [here]
|
|
1146
|
+
d = here
|
|
1147
|
+
while os.path.dirname(d) != d:
|
|
1148
|
+
if os.path.basename(d) == "app":
|
|
1149
|
+
dirs.append(os.path.dirname(d))
|
|
1150
|
+
break
|
|
1151
|
+
d = os.path.dirname(d)
|
|
1152
|
+
for d in dict.fromkeys(dirs):
|
|
1153
|
+
path = os.path.join(d, "recipes.vl")
|
|
1154
|
+
if os.path.abspath(path) == os.path.abspath(self.filename) \
|
|
1155
|
+
or not os.path.isfile(path):
|
|
1156
|
+
continue
|
|
1157
|
+
from .lexer import Lexer
|
|
1158
|
+
from .parser import Parser
|
|
1159
|
+
with open(path, encoding="utf-8") as fh:
|
|
1160
|
+
src = fh.read()
|
|
1161
|
+
lx = Lexer(src, path)
|
|
1162
|
+
ps = Parser(lx.lex(), src, path)
|
|
1163
|
+
mod = ps.parse_module()
|
|
1164
|
+
if any(x.severity == Severity.ERROR for x in list(lx.diags) + list(ps.diags)):
|
|
1165
|
+
self._recipe_cache = None
|
|
1166
|
+
return None
|
|
1167
|
+
mods.append(mod)
|
|
1168
|
+
out: dict[str, set] = {}
|
|
1169
|
+
for mod in mods:
|
|
1170
|
+
for r in (x for x in mod.items if isinstance(x, A.RecipeDecl)):
|
|
1171
|
+
for row in r.body:
|
|
1172
|
+
if row.head not in ("base", "default") and row.tokens:
|
|
1173
|
+
out.setdefault(r.primitive, set()).add(row.tokens[0])
|
|
1174
|
+
self._recipe_cache = out
|
|
1175
|
+
return out
|
|
1062
1176
|
|
|
1063
1177
|
# -- inline text and links (action-45.4) ---------------------------------
|
|
1064
1178
|
def _inline_element(self, node) -> None:
|
|
@@ -1965,10 +2079,13 @@ class Checker:
|
|
|
1965
2079
|
"'spellcheck:' is the literal 'true' or 'false'",
|
|
1966
2080
|
node, "spellcheck:false")
|
|
1967
2081
|
|
|
1968
|
-
if "title" in attrs and name not in TITLE_HOME:
|
|
2082
|
+
if "title" in attrs and name not in TITLE_HOME and name != "dialog":
|
|
1969
2083
|
# A warning, not an error. It is genuinely harmless as
|
|
1970
2084
|
# supplementary text and 45.3 already documented it in the global
|
|
1971
2085
|
# set, so an error would break existing source for no safety gain.
|
|
2086
|
+
# `dialog title:` is not a tooltip: spec §8.8 requires it
|
|
2087
|
+
# (E-DIALOG-TITLE) and the build makes it the dialog's heading and
|
|
2088
|
+
# accessible name.
|
|
1972
2089
|
self._d("W-TITLE-TOOLTIP", Severity.WARNING,
|
|
1973
2090
|
"'title:' on '%s' is a tooltip: it is not shown on a touch "
|
|
1974
2091
|
"device at all, is not reachable by keyboard, and most "
|
|
@@ -2535,6 +2652,43 @@ class Checker:
|
|
|
2535
2652
|
"to carry it", ep,
|
|
2536
2653
|
f"add '{p.name}' to the 'query' line, or '{{{p.name}}}' to the path")
|
|
2537
2654
|
|
|
2655
|
+
def api_base(self, mod: A.Module) -> None:
|
|
2656
|
+
# An outbound `base` is joined to each endpoint's `path` with exactly
|
|
2657
|
+
# one `/` between them (spec section 9). A base that cannot join
|
|
2658
|
+
# safely is refused here, before the build: `base "/"` with
|
|
2659
|
+
# `path "/api/login"` used to produce `//api/login`, which a browser
|
|
2660
|
+
# sends to a host named `api` -- with the login body. A bare host or
|
|
2661
|
+
# a relative path is refused for the same reason: it resolves
|
|
2662
|
+
# against whatever page is open. vlbuild's `normalize_api_base` is
|
|
2663
|
+
# the fail-closed backstop with the same rule.
|
|
2664
|
+
for it in mod.items:
|
|
2665
|
+
if not isinstance(it, A.ApiDecl):
|
|
2666
|
+
continue
|
|
2667
|
+
for d in it.settings:
|
|
2668
|
+
if not isinstance(d, A.Directive) or d.head != "base":
|
|
2669
|
+
continue
|
|
2670
|
+
rest = d.tokens[1:] if d.tokens and d.tokens[0] == "base" else list(d.tokens)
|
|
2671
|
+
raw = rest[0] if rest else ""
|
|
2672
|
+
if len(raw) >= 2 and raw[0] == '"' and raw[-1] == '"':
|
|
2673
|
+
raw = raw[1:-1]
|
|
2674
|
+
b = raw.strip()
|
|
2675
|
+
if b == "":
|
|
2676
|
+
continue
|
|
2677
|
+
if b.startswith("//"):
|
|
2678
|
+
why = ("starts with '//', which a browser reads as a URL on "
|
|
2679
|
+
"another host (protocol-relative), not a path on this origin")
|
|
2680
|
+
elif b.startswith("/") or re.match(r"^https?://[^/?#\s]+", b):
|
|
2681
|
+
if "?" not in b and "#" not in b:
|
|
2682
|
+
continue
|
|
2683
|
+
why = "must not carry a query string or fragment"
|
|
2684
|
+
else:
|
|
2685
|
+
why = ("must be an absolute http(s):// URL or a same-origin path "
|
|
2686
|
+
"starting with '/'")
|
|
2687
|
+
self._d("E-API-BASE", Severity.ERROR,
|
|
2688
|
+
f"'api {it.name}' base \"{b}\" {why}", d,
|
|
2689
|
+
'base "/" (this origin; joined to each path with one /) '
|
|
2690
|
+
'or base "https://api.example.com/v1"')
|
|
2691
|
+
|
|
2538
2692
|
# -- page.server.vl (spec section 10.2) ----------------------------------
|
|
2539
2693
|
_PS_WRITE_METHODS = ("post", "put", "patch", "delete")
|
|
2540
2694
|
|
|
@@ -2624,13 +2778,18 @@ class Checker:
|
|
|
2624
2778
|
# -- semantic (scoped) ---------------------------------------------
|
|
2625
2779
|
def semantic(self, mod: A.Module) -> None:
|
|
2626
2780
|
top = set(BUILTINS) | set(_PRIM_NAMES)
|
|
2781
|
+
# names the module itself declares or imports: a call to one of these
|
|
2782
|
+
# is the author's own function, never the builtin of the same name
|
|
2783
|
+
self._module_names = set()
|
|
2627
2784
|
for it in mod.items:
|
|
2628
2785
|
nm = getattr(it, "name", None)
|
|
2629
2786
|
if nm:
|
|
2630
2787
|
top.add(nm)
|
|
2788
|
+
self._module_names.add(nm)
|
|
2631
2789
|
if isinstance(it, A.Use):
|
|
2632
2790
|
for n in it.names:
|
|
2633
2791
|
top.add(n)
|
|
2792
|
+
self._module_names.add(n)
|
|
2634
2793
|
base = it.path.replace("./", "").replace("../", "")
|
|
2635
2794
|
base = base.split("/")[-1].split(".")[-1]
|
|
2636
2795
|
if base:
|
|
@@ -2646,20 +2805,28 @@ class Checker:
|
|
|
2646
2805
|
|
|
2647
2806
|
for it in mod.items:
|
|
2648
2807
|
if isinstance(it, A.FnDecl):
|
|
2649
|
-
scope =
|
|
2808
|
+
scope = _Scope((p.name for p in it.params), "function parameter")
|
|
2809
|
+
self._emitted = False
|
|
2650
2810
|
self._scoped(it.body, [top, scope], fallible=_fallible(it.ret))
|
|
2811
|
+
self._emitted = True
|
|
2651
2812
|
elif isinstance(it, A.ViewDecl):
|
|
2652
|
-
scope =
|
|
2813
|
+
scope = _Scope((p.name for p in it.params), "parameter")
|
|
2814
|
+
scope.bind("session", "session")
|
|
2653
2815
|
for s in it.body:
|
|
2654
|
-
if isinstance(s,
|
|
2655
|
-
scope.
|
|
2816
|
+
if isinstance(s, A.StateDecl):
|
|
2817
|
+
scope.bind(s.name, "state")
|
|
2818
|
+
elif isinstance(s, A.DeriveDecl):
|
|
2819
|
+
scope.bind(s.name, "derive")
|
|
2820
|
+
elif isinstance(s, A.LoadDecl):
|
|
2821
|
+
scope.bind(s.name, "load result")
|
|
2656
2822
|
self._scoped(it.body, [top, scope], fallible=True)
|
|
2657
2823
|
elif isinstance(it, A.ServiceDecl):
|
|
2658
2824
|
for r in it.routes:
|
|
2659
|
-
scope =
|
|
2825
|
+
scope = _Scope((p.name for p in r.params), "route parameter")
|
|
2826
|
+
scope.bind("session", "session")
|
|
2660
2827
|
for seg in r.path.split("/"):
|
|
2661
2828
|
if seg.startswith(":"):
|
|
2662
|
-
scope.
|
|
2829
|
+
scope.bind(seg[1:], "route parameter")
|
|
2663
2830
|
self._scoped(r.body, [top, scope], fallible=_fallible(r.ret))
|
|
2664
2831
|
|
|
2665
2832
|
def _resolve(self, name: str, scopes: list[set]) -> bool:
|
|
@@ -2686,9 +2853,12 @@ class Checker:
|
|
|
2686
2853
|
if isinstance(st, A.Let):
|
|
2687
2854
|
self._expr(st.init, scopes, fallible)
|
|
2688
2855
|
self._declare(st.name, scopes, st)
|
|
2856
|
+
if st.name and st.name != "?" and isinstance(scopes[-1], _Scope):
|
|
2857
|
+
scopes[-1].kinds[st.name] = "mutable let" if st.mut else "`let` binding"
|
|
2689
2858
|
elif isinstance(st, A.Assign):
|
|
2690
2859
|
self._expr(st.target, scopes, fallible)
|
|
2691
2860
|
self._expr(st.value, scopes, fallible)
|
|
2861
|
+
self._check_assign(st, scopes)
|
|
2692
2862
|
elif isinstance(st, A.ExprStmt):
|
|
2693
2863
|
self._expr(st.expr, scopes, fallible)
|
|
2694
2864
|
elif isinstance(st, A.Return):
|
|
@@ -2703,13 +2873,13 @@ class Checker:
|
|
|
2703
2873
|
elif isinstance(st, A.Match):
|
|
2704
2874
|
self._expr(st.subject, scopes, fallible)
|
|
2705
2875
|
for pat, guard, body in st.arms:
|
|
2706
|
-
inner = scopes + [
|
|
2876
|
+
inner = scopes + [_Scope(getattr(pat, "binds", []), "match binding")]
|
|
2707
2877
|
if guard is not None:
|
|
2708
2878
|
self._expr(guard, inner, fallible)
|
|
2709
2879
|
self._block(body, inner, fallible, in_loop)
|
|
2710
2880
|
elif isinstance(st, A.For):
|
|
2711
2881
|
self._expr(st.iter, scopes, fallible)
|
|
2712
|
-
inner = scopes + [
|
|
2882
|
+
inner = scopes + [_Scope(st.vars, "loop variable")]
|
|
2713
2883
|
self._scoped(st.body, inner, fallible, in_loop=True)
|
|
2714
2884
|
elif isinstance(st, A.While):
|
|
2715
2885
|
self._expr(st.cond, scopes, fallible)
|
|
@@ -2721,7 +2891,7 @@ class Checker:
|
|
|
2721
2891
|
elif isinstance(st, A.Try):
|
|
2722
2892
|
self._expr(st.expr, scopes, fallible)
|
|
2723
2893
|
for pat, body in st.catches:
|
|
2724
|
-
inner = scopes + [
|
|
2894
|
+
inner = scopes + [_Scope(getattr(pat, "binds", []), "catch binding")]
|
|
2725
2895
|
self._scoped(body, inner, fallible, in_loop)
|
|
2726
2896
|
elif isinstance(st, (A.Break, A.Skip)):
|
|
2727
2897
|
if not in_loop:
|
|
@@ -2737,14 +2907,15 @@ class Checker:
|
|
|
2737
2907
|
elif isinstance(st, A.LoadDecl):
|
|
2738
2908
|
self._expr(st.expr, scopes, fallible)
|
|
2739
2909
|
self._scoped(st.pending, scopes, fallible)
|
|
2740
|
-
inner = scopes + [
|
|
2910
|
+
inner = (scopes + [_Scope([st.error_name], "load error")]
|
|
2911
|
+
if st.error_name else scopes)
|
|
2741
2912
|
self._scoped(st.error_body, inner, fallible)
|
|
2742
2913
|
elif isinstance(st, A.OnBlock):
|
|
2743
|
-
inner = scopes + [
|
|
2914
|
+
inner = scopes + [_Scope(st.args, "handler argument")]
|
|
2744
2915
|
self._scoped(st.body, inner, fallible, in_loop=False)
|
|
2745
2916
|
elif isinstance(st, A.ListBlock):
|
|
2746
2917
|
self._expr(st.src, scopes, fallible)
|
|
2747
|
-
inner = scopes + [
|
|
2918
|
+
inner = scopes + [_Scope([st.var], "list variable")]
|
|
2748
2919
|
if st.key is not None:
|
|
2749
2920
|
self._expr(st.key, inner, fallible)
|
|
2750
2921
|
self._scoped(st.body, inner, fallible, in_loop=False)
|
|
@@ -2760,8 +2931,60 @@ class Checker:
|
|
|
2760
2931
|
if isinstance(node, A.Name):
|
|
2761
2932
|
self._use(node, scopes)
|
|
2762
2933
|
|
|
2934
|
+
def _check_builtin_call(self, call, scopes: list[set]) -> None:
|
|
2935
|
+
"""A bare call to an allowlisted name the build cannot lower compiles
|
|
2936
|
+
to an undefined JS function (action-48). Skipped when the name is the
|
|
2937
|
+
author's own: a local binding or a module declaration or import."""
|
|
2938
|
+
callee = call.callee
|
|
2939
|
+
if not isinstance(callee, A.Name):
|
|
2940
|
+
return
|
|
2941
|
+
nm = callee.id
|
|
2942
|
+
if nm in self._module_names or any(nm in s for s in scopes[1:]):
|
|
2943
|
+
return
|
|
2944
|
+
if nm in _METHOD_ONLY:
|
|
2945
|
+
self._d("E-METHOD-AS-FN", Severity.ERROR,
|
|
2946
|
+
"'%s' is a method, not a function -- `%s(...)` compiles to "
|
|
2947
|
+
"an undefined function and throws when the page runs"
|
|
2948
|
+
% (nm, nm), call, "call it on the value: `%s`" % _METHOD_ONLY[nm])
|
|
2949
|
+
elif nm in _FN_NOT_BUILT and self._emitted:
|
|
2950
|
+
self._d("E-FN-NOT-BUILT", Severity.ERROR,
|
|
2951
|
+
"'%s' is in the spec but this build has no working `%s` -- "
|
|
2952
|
+
"the call compiles and throws when it runs" % (nm, nm),
|
|
2953
|
+
call, "use %s" % _FN_NOT_BUILT[nm])
|
|
2954
|
+
|
|
2955
|
+
def _check_assign(self, st, scopes: list[set]) -> None:
|
|
2956
|
+
"""Only a view `state` and a `let mut` can be reassigned (spec 5.1:
|
|
2957
|
+
bindings are immutable unless declared `mut`). The binding that counts
|
|
2958
|
+
is the one the name resolves to -- the innermost scope declaring it --
|
|
2959
|
+
so a local that shadows a derive is judged as that local (issue #17).
|
|
2960
|
+
A view `derive` / `load` value or parameter is recomputed, not stored:
|
|
2961
|
+
assigning to one lowers to a dead JS statement. A loop variable lowers
|
|
2962
|
+
to a JS `const`, so assigning to one throws at runtime."""
|
|
2963
|
+
tgt = getattr(st, "target", None)
|
|
2964
|
+
if not isinstance(tgt, A.Name):
|
|
2965
|
+
return
|
|
2966
|
+
nm = tgt.id
|
|
2967
|
+
owner = next((s for s in reversed(scopes) if nm in s), None)
|
|
2968
|
+
kind = getattr(owner, "kinds", {}).get(nm)
|
|
2969
|
+
if kind is None or kind in _ASSIGNABLE:
|
|
2970
|
+
return # module-level / builtin names are not judged here
|
|
2971
|
+
if kind in _READONLY_VIEW:
|
|
2972
|
+
self._d("E-ASSIGN-READONLY", Severity.ERROR,
|
|
2973
|
+
"'%s' is a %s and cannot be assigned to -- it is "
|
|
2974
|
+
"recomputed, not stored. Declare a `state` for a "
|
|
2975
|
+
"value you change." % (nm, kind),
|
|
2976
|
+
st, None)
|
|
2977
|
+
return
|
|
2978
|
+
fix = ("declare it `let mut %s = ...`" % nm if kind == "`let` binding" else
|
|
2979
|
+
"copy it into a new binding first -- `let mut next = %s` -- "
|
|
2980
|
+
"and assign to that" % nm)
|
|
2981
|
+
self._d("E-ASSIGN-IMMUTABLE", Severity.ERROR,
|
|
2982
|
+
"'%s' is a %s and cannot be reassigned -- bindings are "
|
|
2983
|
+
"immutable unless declared `let mut`." % (nm, kind),
|
|
2984
|
+
st, fix)
|
|
2985
|
+
|
|
2763
2986
|
def _block(self, stmts, scopes: list[set], fallible: bool, in_loop: bool = False) -> None:
|
|
2764
|
-
self._scoped(stmts, scopes + [
|
|
2987
|
+
self._scoped(stmts, scopes + [_Scope()], fallible, in_loop)
|
|
2765
2988
|
|
|
2766
2989
|
def _expr(self, ex, scopes: list[set], fallible: bool) -> None:
|
|
2767
2990
|
if ex is None:
|
|
@@ -2769,6 +2992,10 @@ class Checker:
|
|
|
2769
2992
|
if isinstance(ex, A.Name):
|
|
2770
2993
|
self._use(ex, scopes)
|
|
2771
2994
|
elif isinstance(ex, A.Literal):
|
|
2995
|
+
for nm in ex.interp_calls:
|
|
2996
|
+
self._check_builtin_call(A.Call(start=ex.start, end=ex.end,
|
|
2997
|
+
callee=A.Name(start=ex.start, end=ex.end, id=nm)),
|
|
2998
|
+
scopes)
|
|
2772
2999
|
if self.strict_names:
|
|
2773
3000
|
for n in ex.interp_names:
|
|
2774
3001
|
if not self._resolve(n, scopes):
|
|
@@ -2781,6 +3008,7 @@ class Checker:
|
|
|
2781
3008
|
self._expr(ex.obj, scopes, fallible)
|
|
2782
3009
|
self._expr(ex.index, scopes, fallible)
|
|
2783
3010
|
elif isinstance(ex, A.Call):
|
|
3011
|
+
self._check_builtin_call(ex, scopes)
|
|
2784
3012
|
self._expr(ex.callee, scopes, fallible)
|
|
2785
3013
|
for a in ex.args:
|
|
2786
3014
|
self._expr(a, scopes, fallible)
|
|
@@ -2807,12 +3035,12 @@ class Checker:
|
|
|
2807
3035
|
elif isinstance(ex, A.MatchExpr):
|
|
2808
3036
|
self._expr(ex.subject, scopes, fallible)
|
|
2809
3037
|
for pat, guard, body in ex.arms:
|
|
2810
|
-
inner = scopes + [
|
|
3038
|
+
inner = scopes + [_Scope(getattr(pat, "binds", []), "match binding")]
|
|
2811
3039
|
if guard is not None:
|
|
2812
3040
|
self._expr(guard, inner, fallible)
|
|
2813
3041
|
self._scoped(body, inner, fallible)
|
|
2814
3042
|
elif isinstance(ex, A.Closure):
|
|
2815
|
-
inner = scopes + [
|
|
3043
|
+
inner = scopes + [_Scope((p.name for p in ex.params), "closure parameter")]
|
|
2816
3044
|
if isinstance(ex.body, list):
|
|
2817
3045
|
self._scoped(ex.body, inner, fallible)
|
|
2818
3046
|
else:
|
|
@@ -2837,6 +3065,25 @@ class Checker:
|
|
|
2837
3065
|
"check the name, or add the binding / import")
|
|
2838
3066
|
|
|
2839
3067
|
|
|
3068
|
+
class _Scope(set):
|
|
3069
|
+
"""One lexical scope for semantic(): the names it binds, and each name's
|
|
3070
|
+
binding kind (what an assignment to it is judged by)."""
|
|
3071
|
+
|
|
3072
|
+
def __init__(self, names=(), kind: str | None = None) -> None:
|
|
3073
|
+
super().__init__(names)
|
|
3074
|
+
self.kinds: dict[str, str] = {n: kind for n in self} if kind else {}
|
|
3075
|
+
|
|
3076
|
+
def bind(self, name: str, kind: str) -> None:
|
|
3077
|
+
self.add(name)
|
|
3078
|
+
self.kinds[name] = kind
|
|
3079
|
+
|
|
3080
|
+
|
|
3081
|
+
# binding kinds an assignment may target
|
|
3082
|
+
_ASSIGNABLE = {"state", "mutable let"}
|
|
3083
|
+
# view values that are recomputed, not stored (E-ASSIGN-READONLY)
|
|
3084
|
+
_READONLY_VIEW = {"parameter", "derive", "load result"}
|
|
3085
|
+
|
|
3086
|
+
|
|
2840
3087
|
def _fallible(tref) -> bool:
|
|
2841
3088
|
return isinstance(tref, A.TypeRef) and tref.fallible
|
|
2842
3089
|
|