volaro 0.1.0-alpha.5 → 0.1.0-alpha.7
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 +4 -4
- package/bin/vl.js +7 -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 +273 -28
- 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 +6 -0
- package/compiler/vlbuild/vlbuild/assets/vlrt.js +94 -19
- package/compiler/vlbuild/vlbuild/emit.py +253 -45
- package/compiler/vlbuild/vlbuild/pages_build.py +115 -17
- package/compiler/vlbuild/vlbuild/server_emit.py +62 -10
- package/compiler/vlbuild/vlbuild/static_assets.py +58 -11
- package/compiler/vlbuild/vlbuild/style_config.py +27 -14
- package/compiler/vlbuild/vlbuild/styling.py +8 -9
- package/language/crib.md +55 -14
- package/language/spec.md +3 -2
- package/language/supported.md +54 -13
- package/package.json +1 -1
- package/scripts/selftest.mjs +10 -0
- package/scripts/sync-compiler.sh +0 -0
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.7`), 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
|
|
@@ -154,9 +154,9 @@ use them.
|
|
|
154
154
|
**Form controls:** `input` (a full type matrix, including `checkbox`/`radio`
|
|
155
155
|
binding a `checked` state), `textarea`, `select`/`option`/`optgroup`,
|
|
156
156
|
`fieldset`/`legend`, `datalist`, `button`, `link`. `disabled:` works on
|
|
157
|
-
`button input textarea select fieldset optgroup option`.
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
160
160
|
style name, not a computed expression. `if` used as an expression is binary
|
|
161
161
|
(`if c a else b`); `match` and block `if` are statements only. A module-level
|
|
162
162
|
`fn` is not available to a view. **`volaro supported` and `volaro crib` are
|
package/bin/vl.js
CHANGED
|
@@ -69,6 +69,11 @@ function pyInvocation(pp, moduleName, moduleArgs) {
|
|
|
69
69
|
...process.env,
|
|
70
70
|
PYTHONPATH: [pp, VALIDATOR_PP, process.env.PYTHONPATH].filter(Boolean).join(delimiter),
|
|
71
71
|
PYTHONDONTWRITEBYTECODE: "1",
|
|
72
|
+
// Diagnostics and build summaries contain non-ASCII (box drawing, arrows).
|
|
73
|
+
// Without this, Windows encodes a redirected stdout with the legacy code
|
|
74
|
+
// page and the compiler dies with UnicodeEncodeError; the capturing path
|
|
75
|
+
// below also decodes the child's output as UTF-8.
|
|
76
|
+
PYTHONIOENCODING: "utf-8",
|
|
72
77
|
};
|
|
73
78
|
return { cmd: py.cmd, argv: ["-B", "-m", moduleName, ...moduleArgs], env };
|
|
74
79
|
}
|
|
@@ -771,8 +776,8 @@ choose the interpreter if \`python3\` is not the one you want.
|
|
|
771
776
|
|
|
772
777
|
SHIPPED SUBSET: single page (no router); \`input\` (full type matrix incl.
|
|
773
778
|
checkbox/radio), \`textarea\`, \`select\`/\`option\`/\`optgroup\`,
|
|
774
|
-
\`fieldset\`/\`legend\`, \`datalist\`, \`button
|
|
775
|
-
|
|
779
|
+
\`fieldset\`/\`legend\`, \`datalist\`, \`button\` (a toggle is \`button pressed:\`,
|
|
780
|
+
aria-pressed; no switch role), \`link\` (no standalone \`label\`); \`if\` as an expression is
|
|
776
781
|
binary; \`match\` is a statement; a module-level \`fn\` cannot be called from a
|
|
777
782
|
view. \`volaro crib\` + \`volaro supported\` are authoritative for this build;
|
|
778
783
|
\`volaro spec\` is the wider design, not what it accepts.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"revision": "
|
|
2
|
+
"revision": "3469656bab89d0e379c1e31cb19013a21fdef556",
|
|
3
3
|
"source": "git",
|
|
4
4
|
"dirty": false,
|
|
5
|
-
"content_sha256": "
|
|
5
|
+
"content_sha256": "656e9ef8c494c763da68868cf0f80ce509bf51bfd9e71b79ae153407eeb16e83"
|
|
6
6
|
}
|
package/compiler/SOURCE_REV
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
3469656bab89d0e379c1e31cb19013a21fdef556
|
|
@@ -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
|
|
@@ -585,7 +604,10 @@ class Checker:
|
|
|
585
604
|
if any("[" in c and "]" in c for c in classes):
|
|
586
605
|
self._d("W-CLASS-ARBITRARY", Severity.WARNING,
|
|
587
606
|
"arbitrary values bypass the accessibility denylist", node)
|
|
588
|
-
|
|
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"}
|
|
589
611
|
and any(c in {"outline-none", "outline-0", "ring-0"} for c in utilities)
|
|
590
612
|
and not any("focus-visible:" in c and c.rsplit(":", 1)[-1]
|
|
591
613
|
not in {"outline-none", "outline-0", "ring-0"}
|
|
@@ -1052,6 +1074,17 @@ class Checker:
|
|
|
1052
1074
|
elif k in ("start", "reversed") and name != "ol":
|
|
1053
1075
|
self._d("E-ATTR-TARGET", Severity.ERROR,
|
|
1054
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")
|
|
1055
1088
|
elif k == "cite" and name not in ("blockquote", "q"):
|
|
1056
1089
|
self._d("E-ATTR-TARGET", Severity.ERROR,
|
|
1057
1090
|
"'cite:' is only supported on 'blockquote' and 'q'",
|
|
@@ -1061,6 +1094,85 @@ class Checker:
|
|
|
1061
1094
|
self._table_element(node, parent_el)
|
|
1062
1095
|
self._media_element(node, parent_el)
|
|
1063
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
|
|
1064
1176
|
|
|
1065
1177
|
# -- inline text and links (action-45.4) ---------------------------------
|
|
1066
1178
|
def _inline_element(self, node) -> None:
|
|
@@ -1967,10 +2079,13 @@ class Checker:
|
|
|
1967
2079
|
"'spellcheck:' is the literal 'true' or 'false'",
|
|
1968
2080
|
node, "spellcheck:false")
|
|
1969
2081
|
|
|
1970
|
-
if "title" in attrs and name not in TITLE_HOME:
|
|
2082
|
+
if "title" in attrs and name not in TITLE_HOME and name != "dialog":
|
|
1971
2083
|
# A warning, not an error. It is genuinely harmless as
|
|
1972
2084
|
# supplementary text and 45.3 already documented it in the global
|
|
1973
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.
|
|
1974
2089
|
self._d("W-TITLE-TOOLTIP", Severity.WARNING,
|
|
1975
2090
|
"'title:' on '%s' is a tooltip: it is not shown on a touch "
|
|
1976
2091
|
"device at all, is not reachable by keyboard, and most "
|
|
@@ -2537,6 +2652,43 @@ class Checker:
|
|
|
2537
2652
|
"to carry it", ep,
|
|
2538
2653
|
f"add '{p.name}' to the 'query' line, or '{{{p.name}}}' to the path")
|
|
2539
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
|
+
|
|
2540
2692
|
# -- page.server.vl (spec section 10.2) ----------------------------------
|
|
2541
2693
|
_PS_WRITE_METHODS = ("post", "put", "patch", "delete")
|
|
2542
2694
|
|
|
@@ -2626,13 +2778,18 @@ class Checker:
|
|
|
2626
2778
|
# -- semantic (scoped) ---------------------------------------------
|
|
2627
2779
|
def semantic(self, mod: A.Module) -> None:
|
|
2628
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()
|
|
2629
2784
|
for it in mod.items:
|
|
2630
2785
|
nm = getattr(it, "name", None)
|
|
2631
2786
|
if nm:
|
|
2632
2787
|
top.add(nm)
|
|
2788
|
+
self._module_names.add(nm)
|
|
2633
2789
|
if isinstance(it, A.Use):
|
|
2634
2790
|
for n in it.names:
|
|
2635
2791
|
top.add(n)
|
|
2792
|
+
self._module_names.add(n)
|
|
2636
2793
|
base = it.path.replace("./", "").replace("../", "")
|
|
2637
2794
|
base = base.split("/")[-1].split(".")[-1]
|
|
2638
2795
|
if base:
|
|
@@ -2648,20 +2805,28 @@ class Checker:
|
|
|
2648
2805
|
|
|
2649
2806
|
for it in mod.items:
|
|
2650
2807
|
if isinstance(it, A.FnDecl):
|
|
2651
|
-
scope =
|
|
2808
|
+
scope = _Scope((p.name for p in it.params), "function parameter")
|
|
2809
|
+
self._emitted = False
|
|
2652
2810
|
self._scoped(it.body, [top, scope], fallible=_fallible(it.ret))
|
|
2811
|
+
self._emitted = True
|
|
2653
2812
|
elif isinstance(it, A.ViewDecl):
|
|
2654
|
-
scope =
|
|
2813
|
+
scope = _Scope((p.name for p in it.params), "parameter")
|
|
2814
|
+
scope.bind("session", "session")
|
|
2655
2815
|
for s in it.body:
|
|
2656
|
-
if isinstance(s,
|
|
2657
|
-
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")
|
|
2658
2822
|
self._scoped(it.body, [top, scope], fallible=True)
|
|
2659
2823
|
elif isinstance(it, A.ServiceDecl):
|
|
2660
2824
|
for r in it.routes:
|
|
2661
|
-
scope =
|
|
2825
|
+
scope = _Scope((p.name for p in r.params), "route parameter")
|
|
2826
|
+
scope.bind("session", "session")
|
|
2662
2827
|
for seg in r.path.split("/"):
|
|
2663
2828
|
if seg.startswith(":"):
|
|
2664
|
-
scope.
|
|
2829
|
+
scope.bind(seg[1:], "route parameter")
|
|
2665
2830
|
self._scoped(r.body, [top, scope], fallible=_fallible(r.ret))
|
|
2666
2831
|
|
|
2667
2832
|
def _resolve(self, name: str, scopes: list[set]) -> bool:
|
|
@@ -2688,9 +2853,12 @@ class Checker:
|
|
|
2688
2853
|
if isinstance(st, A.Let):
|
|
2689
2854
|
self._expr(st.init, scopes, fallible)
|
|
2690
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"
|
|
2691
2858
|
elif isinstance(st, A.Assign):
|
|
2692
2859
|
self._expr(st.target, scopes, fallible)
|
|
2693
2860
|
self._expr(st.value, scopes, fallible)
|
|
2861
|
+
self._check_assign(st, scopes)
|
|
2694
2862
|
elif isinstance(st, A.ExprStmt):
|
|
2695
2863
|
self._expr(st.expr, scopes, fallible)
|
|
2696
2864
|
elif isinstance(st, A.Return):
|
|
@@ -2705,13 +2873,13 @@ class Checker:
|
|
|
2705
2873
|
elif isinstance(st, A.Match):
|
|
2706
2874
|
self._expr(st.subject, scopes, fallible)
|
|
2707
2875
|
for pat, guard, body in st.arms:
|
|
2708
|
-
inner = scopes + [
|
|
2876
|
+
inner = scopes + [_Scope(getattr(pat, "binds", []), "match binding")]
|
|
2709
2877
|
if guard is not None:
|
|
2710
2878
|
self._expr(guard, inner, fallible)
|
|
2711
2879
|
self._block(body, inner, fallible, in_loop)
|
|
2712
2880
|
elif isinstance(st, A.For):
|
|
2713
2881
|
self._expr(st.iter, scopes, fallible)
|
|
2714
|
-
inner = scopes + [
|
|
2882
|
+
inner = scopes + [_Scope(st.vars, "loop variable")]
|
|
2715
2883
|
self._scoped(st.body, inner, fallible, in_loop=True)
|
|
2716
2884
|
elif isinstance(st, A.While):
|
|
2717
2885
|
self._expr(st.cond, scopes, fallible)
|
|
@@ -2723,7 +2891,7 @@ class Checker:
|
|
|
2723
2891
|
elif isinstance(st, A.Try):
|
|
2724
2892
|
self._expr(st.expr, scopes, fallible)
|
|
2725
2893
|
for pat, body in st.catches:
|
|
2726
|
-
inner = scopes + [
|
|
2894
|
+
inner = scopes + [_Scope(getattr(pat, "binds", []), "catch binding")]
|
|
2727
2895
|
self._scoped(body, inner, fallible, in_loop)
|
|
2728
2896
|
elif isinstance(st, (A.Break, A.Skip)):
|
|
2729
2897
|
if not in_loop:
|
|
@@ -2739,14 +2907,15 @@ class Checker:
|
|
|
2739
2907
|
elif isinstance(st, A.LoadDecl):
|
|
2740
2908
|
self._expr(st.expr, scopes, fallible)
|
|
2741
2909
|
self._scoped(st.pending, scopes, fallible)
|
|
2742
|
-
inner = scopes + [
|
|
2910
|
+
inner = (scopes + [_Scope([st.error_name], "load error")]
|
|
2911
|
+
if st.error_name else scopes)
|
|
2743
2912
|
self._scoped(st.error_body, inner, fallible)
|
|
2744
2913
|
elif isinstance(st, A.OnBlock):
|
|
2745
|
-
inner = scopes + [
|
|
2914
|
+
inner = scopes + [_Scope(st.args, "handler argument")]
|
|
2746
2915
|
self._scoped(st.body, inner, fallible, in_loop=False)
|
|
2747
2916
|
elif isinstance(st, A.ListBlock):
|
|
2748
2917
|
self._expr(st.src, scopes, fallible)
|
|
2749
|
-
inner = scopes + [
|
|
2918
|
+
inner = scopes + [_Scope([st.var], "list variable")]
|
|
2750
2919
|
if st.key is not None:
|
|
2751
2920
|
self._expr(st.key, inner, fallible)
|
|
2752
2921
|
self._scoped(st.body, inner, fallible, in_loop=False)
|
|
@@ -2762,8 +2931,60 @@ class Checker:
|
|
|
2762
2931
|
if isinstance(node, A.Name):
|
|
2763
2932
|
self._use(node, scopes)
|
|
2764
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
|
+
|
|
2765
2986
|
def _block(self, stmts, scopes: list[set], fallible: bool, in_loop: bool = False) -> None:
|
|
2766
|
-
self._scoped(stmts, scopes + [
|
|
2987
|
+
self._scoped(stmts, scopes + [_Scope()], fallible, in_loop)
|
|
2767
2988
|
|
|
2768
2989
|
def _expr(self, ex, scopes: list[set], fallible: bool) -> None:
|
|
2769
2990
|
if ex is None:
|
|
@@ -2771,6 +2992,10 @@ class Checker:
|
|
|
2771
2992
|
if isinstance(ex, A.Name):
|
|
2772
2993
|
self._use(ex, scopes)
|
|
2773
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)
|
|
2774
2999
|
if self.strict_names:
|
|
2775
3000
|
for n in ex.interp_names:
|
|
2776
3001
|
if not self._resolve(n, scopes):
|
|
@@ -2783,6 +3008,7 @@ class Checker:
|
|
|
2783
3008
|
self._expr(ex.obj, scopes, fallible)
|
|
2784
3009
|
self._expr(ex.index, scopes, fallible)
|
|
2785
3010
|
elif isinstance(ex, A.Call):
|
|
3011
|
+
self._check_builtin_call(ex, scopes)
|
|
2786
3012
|
self._expr(ex.callee, scopes, fallible)
|
|
2787
3013
|
for a in ex.args:
|
|
2788
3014
|
self._expr(a, scopes, fallible)
|
|
@@ -2809,12 +3035,12 @@ class Checker:
|
|
|
2809
3035
|
elif isinstance(ex, A.MatchExpr):
|
|
2810
3036
|
self._expr(ex.subject, scopes, fallible)
|
|
2811
3037
|
for pat, guard, body in ex.arms:
|
|
2812
|
-
inner = scopes + [
|
|
3038
|
+
inner = scopes + [_Scope(getattr(pat, "binds", []), "match binding")]
|
|
2813
3039
|
if guard is not None:
|
|
2814
3040
|
self._expr(guard, inner, fallible)
|
|
2815
3041
|
self._scoped(body, inner, fallible)
|
|
2816
3042
|
elif isinstance(ex, A.Closure):
|
|
2817
|
-
inner = scopes + [
|
|
3043
|
+
inner = scopes + [_Scope((p.name for p in ex.params), "closure parameter")]
|
|
2818
3044
|
if isinstance(ex.body, list):
|
|
2819
3045
|
self._scoped(ex.body, inner, fallible)
|
|
2820
3046
|
else:
|
|
@@ -2839,6 +3065,25 @@ class Checker:
|
|
|
2839
3065
|
"check the name, or add the binding / import")
|
|
2840
3066
|
|
|
2841
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
|
+
|
|
2842
3087
|
def _fallible(tref) -> bool:
|
|
2843
3088
|
return isinstance(tref, A.TypeRef) and tref.fallible
|
|
2844
3089
|
|
|
@@ -413,7 +413,11 @@ CONTROL_ATTRS = {
|
|
|
413
413
|
}),
|
|
414
414
|
"option": frozenset({"value", "label", "disabled"}),
|
|
415
415
|
"optgroup": frozenset({"label", "disabled"}),
|
|
416
|
-
|
|
416
|
+
# action-40 (Toggle): `pressed:` makes a button a WAI-ARIA toggle
|
|
417
|
+
# button (`aria-pressed` true/false). The primitive carries the
|
|
418
|
+
# semantics, so it is a contract here, not an aria passthrough.
|
|
419
|
+
"button": frozenset({"type", "name", "value", "disabled", "aria_label",
|
|
420
|
+
"pressed"}),
|
|
417
421
|
"fieldset": frozenset({"name", "disabled"}),
|
|
418
422
|
"legend": frozenset(),
|
|
419
423
|
"datalist": frozenset(),
|
|
@@ -1189,3 +1193,68 @@ ELEMENT_NAMES = frozenset(NATIVE_ELEMENT_TAGS) | SPECIAL_ELEMENT_NAMES
|
|
|
1189
1193
|
# Hook support follows emission capability, not merely name recognition.
|
|
1190
1194
|
TEST_ID_ELEMENT_NAMES = frozenset(NATIVE_ELEMENT_TAGS) | {"status"}
|
|
1191
1195
|
TEST_SCOPE_ELEMENT_NAMES = TEST_ID_ELEMENT_NAMES | {"dialog", "tabs"}
|
|
1196
|
+
|
|
1197
|
+
# ---- The attribute names the emitter lowers ------------------------------
|
|
1198
|
+
# vlbuild's `Emitter._apply_attr` ends in `E-ATTR-UNKNOWN` for any name none
|
|
1199
|
+
# of its branches claims. vlcheck must refuse the same names, or the
|
|
1200
|
+
# write -> check -> repair loop passes source the build then rejects
|
|
1201
|
+
# (`row wrap:true`). This is the list of names those branches claim; the
|
|
1202
|
+
# emitter-parity test in vlbuild/tests/test_build.py drives the real
|
|
1203
|
+
# `_apply_attr` over every element and every name here, so a new branch
|
|
1204
|
+
# there without an entry here fails the gates.
|
|
1205
|
+
#
|
|
1206
|
+
# Names a branch claims on ANY element. Reaching the branch may still be an
|
|
1207
|
+
# error (`gap:` on a `strong` is E-LAYOUT-TARGET), but it is not UNKNOWN.
|
|
1208
|
+
APPLY_ATTR_NAMES = (
|
|
1209
|
+
frozenset(REFUSED_GLOBAL_ATTRS) | LAYOUT_STYLE_ATTRS | CELL_SPAN_ATTRS
|
|
1210
|
+
| frozenset({
|
|
1211
|
+
"hidden", "lang", "spellcheck", "dir", "test_id", "bind", "tabindex",
|
|
1212
|
+
# vlbuild PX_KEYS, `weight`, `color`/`bg`, `align`, `justify`
|
|
1213
|
+
"gap", "pad", "radius", "size", "max_w", "min_w", "w", "h",
|
|
1214
|
+
"pad_x", "pad_y", "weight", "color", "bg", "align", "justify",
|
|
1215
|
+
"to", "href", "target", "rel", "download", "scope", "open",
|
|
1216
|
+
"datetime", "src", "srcset", "poster", "captions", "preload", "kind",
|
|
1217
|
+
"srclang", "shape", "coords", "use_map", "crossorigin", "loading",
|
|
1218
|
+
"decoding", "variant", "aria_label", "disabled", "pressed",
|
|
1219
|
+
"read_only", "required", "multiple", "pre", "start", "reversed",
|
|
1220
|
+
"cite", "autocomplete",
|
|
1221
|
+
"type", "placeholder", "name", "id", "alt", "title", "fallback",
|
|
1222
|
+
"value", "label", "min", "max", "step", "pattern", "accept",
|
|
1223
|
+
"maxlength", "minlength", "list", "cols", "rows",
|
|
1224
|
+
}))
|
|
1225
|
+
|
|
1226
|
+
# Names a branch claims only on some elements; elsewhere they fall through
|
|
1227
|
+
# to E-ATTR-UNKNOWN.
|
|
1228
|
+
APPLY_ATTR_NAMES_ON = {
|
|
1229
|
+
"span": lambda el: el in ("colgroup", "col"),
|
|
1230
|
+
"low": lambda el: el in ("progress", "meter"),
|
|
1231
|
+
"high": lambda el: el in ("progress", "meter"),
|
|
1232
|
+
"optimum": lambda el: el in ("progress", "meter"),
|
|
1233
|
+
"width": lambda el: el in ("img", "video"),
|
|
1234
|
+
"height": lambda el: el in ("img", "video"),
|
|
1235
|
+
"sizes": lambda el: el in ("img", "source"),
|
|
1236
|
+
"media": lambda el: el == "source",
|
|
1237
|
+
}
|
|
1238
|
+
for _flag in MEDIA_FLAGS:
|
|
1239
|
+
APPLY_ATTR_NAMES_ON[_flag] = (
|
|
1240
|
+
lambda el: el in MEDIA_ATTRS or el == "img")
|
|
1241
|
+
|
|
1242
|
+
# The attributes the emitter consumes before `_apply_attr` runs.
|
|
1243
|
+
FIELD_ELEMENTS = frozenset({"input", "textarea", "select"})
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def attr_is_known(el_name: str, k: str) -> bool:
|
|
1247
|
+
"""True when vlbuild lowers attribute `k` on native element `el_name`
|
|
1248
|
+
(or refuses it with a more specific code), false when the build would
|
|
1249
|
+
stop with E-ATTR-UNKNOWN. Recipe style axes and `class:` are the
|
|
1250
|
+
caller's to exclude: they depend on the project's recipes."""
|
|
1251
|
+
if k in ("class", "test_scope"):
|
|
1252
|
+
return True
|
|
1253
|
+
if el_name == "img" and k in ("alt", "alt_todo"):
|
|
1254
|
+
return True
|
|
1255
|
+
if el_name in FIELD_ELEMENTS and k in FIELD_A11Y_ATTRS:
|
|
1256
|
+
return True
|
|
1257
|
+
if k in APPLY_ATTR_NAMES:
|
|
1258
|
+
return True
|
|
1259
|
+
only = APPLY_ATTR_NAMES_ON.get(k)
|
|
1260
|
+
return only is not None and only(el_name)
|