volaro 0.1.0-alpha.13 → 0.1.0-alpha.15
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/__main__.py +31 -6
- package/compiler/validator/vlcheck/ast_nodes.py +16 -4
- package/compiler/validator/vlcheck/async_calls.py +395 -0
- package/compiler/validator/vlcheck/checks.py +386 -18
- package/compiler/validator/vlcheck/pageroutes.py +124 -0
- package/compiler/validator/vlcheck/parser.py +42 -12
- package/compiler/validator/vlcheck/resolve.py +412 -12
- package/compiler/validator/vlcheck/serverrules.py +616 -0
- package/compiler/validator/vlcheck/serverscope.py +684 -0
- package/compiler/vlbuild/vlbuild/__main__.py +12 -3
- package/compiler/vlbuild/vlbuild/assets/vlrt.js +69 -15
- package/compiler/vlbuild/vlbuild/assets/vlrt.min.js +1 -1
- package/compiler/vlbuild/vlbuild/emit.py +634 -147
- package/compiler/vlbuild/vlbuild/pages_build.py +36 -8
- package/compiler/vlbuild/vlbuild/project.py +69 -153
- package/compiler/vlbuild/vlbuild/server_emit.py +241 -206
- package/language/crib.md +98 -18
- package/language/spec.md +94 -5
- package/language/supported.md +199 -17
- 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.15`), 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": "336e0ce359237ddff36fa674c51b098bacb2a295",
|
|
3
3
|
"source": "git",
|
|
4
4
|
"dirty": false,
|
|
5
|
-
"content_sha256": "
|
|
5
|
+
"content_sha256": "588e82948fcb11b97bf14918badf69c335817fd8753f085c368bc6f1ded2f245"
|
|
6
6
|
}
|
package/compiler/SOURCE_REV
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
336e0ce359237ddff36fa674c51b098bacb2a295
|
|
@@ -24,10 +24,12 @@ from .checks import check
|
|
|
24
24
|
from .diagnostics import LineIndex, Severity
|
|
25
25
|
from .lexer import Lexer
|
|
26
26
|
from .parser import Parser
|
|
27
|
-
from .resolve import resolve, resolve_page_server
|
|
27
|
+
from .resolve import resolve, resolve_page_server, resolve_server_ctx
|
|
28
28
|
from .project_config import load_config, ConfigError
|
|
29
29
|
from .diagnostics import Diagnostic
|
|
30
30
|
from .layoutcompose import check_all as check_project_composition
|
|
31
|
+
from .pagemanifest import build_manifest as build_page_manifest
|
|
32
|
+
from .pageroutes import build_manifest as build_route_manifest
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def _iter_paths(args: list[str]) -> list[str]:
|
|
@@ -69,19 +71,32 @@ def check_source(src: str, filename: str, strict_names: bool = False,
|
|
|
69
71
|
resolver = resolve_page_server
|
|
70
72
|
else:
|
|
71
73
|
mod = parser.parse_server_ctx()
|
|
72
|
-
# A `_server.vl` holds only `context` providers
|
|
73
|
-
#
|
|
74
|
-
|
|
74
|
+
# A `_server.vl` holds only `context` providers. Their
|
|
75
|
+
# expressions resolve against what the build gives them: the
|
|
76
|
+
# project's app.vl, the ambient names, ancestor providers and
|
|
77
|
+
# earlier providers in the file (resolve.resolve_server_ctx).
|
|
78
|
+
resolver = resolve_server_ctx
|
|
75
79
|
else:
|
|
76
80
|
mod = parser.parse_module()
|
|
77
81
|
diags = (list(lx.diags) + list(parser.diags)
|
|
78
82
|
+ check(mod, filename, li, strict_names=strict_names, release=release))
|
|
79
83
|
if do_resolve:
|
|
80
84
|
diags += resolver(mod, filename, li)
|
|
85
|
+
# An outbound call in a closure of server code is the server rule's
|
|
86
|
+
# E-API-SERVER-CLOSURE (the code the build gives it); the general
|
|
87
|
+
# E-ASYNC-CALL at the same call would say it twice.
|
|
88
|
+
server = {(d.start, d.end) for d in diags if d.code == "E-API-SERVER-CLOSURE"}
|
|
89
|
+
diags = [d for d in diags
|
|
90
|
+
if not (d.code == "E-ASYNC-CALL" and (d.start, d.end) in server)]
|
|
81
91
|
diags.sort(key=lambda d: (d.start, 0 if d.severity == Severity.ERROR else 1, d.code))
|
|
82
92
|
return li, diags
|
|
83
93
|
|
|
84
94
|
|
|
95
|
+
def _diag_key(d: Diagnostic) -> tuple:
|
|
96
|
+
return (os.path.normcase(os.path.abspath(d.file or "")), d.start, d.code,
|
|
97
|
+
d.message)
|
|
98
|
+
|
|
99
|
+
|
|
85
100
|
def main(argv: list[str]) -> int:
|
|
86
101
|
# Test-only hooks, same posture as vlbuild's VOLARO_TEST_BREACH_API
|
|
87
102
|
# (server_emit.py): inert unless a test harness sets the env var,
|
|
@@ -128,9 +143,12 @@ def main(argv: list[str]) -> int:
|
|
|
128
143
|
total_err = total_warn = 0
|
|
129
144
|
json_rows: list[dict] = []
|
|
130
145
|
|
|
146
|
+
reported: set[tuple] = set()
|
|
147
|
+
|
|
131
148
|
def report(li: LineIndex, diags: list[Diagnostic]) -> None:
|
|
132
149
|
nonlocal total_err, total_warn
|
|
133
150
|
for d in diags:
|
|
151
|
+
reported.add(_diag_key(d))
|
|
134
152
|
if d.severity == Severity.ERROR:
|
|
135
153
|
total_err += 1
|
|
136
154
|
elif d.severity == Severity.WARNING:
|
|
@@ -161,11 +179,18 @@ def main(argv: list[str]) -> int:
|
|
|
161
179
|
# A project directory also gets the cross-file page checks (spec
|
|
162
180
|
# §8.15: layout-chain landmarks, nav names, static ids, literal link
|
|
163
181
|
# targets), the same ones the project build enforces.
|
|
182
|
+
# Route discovery comes first, as in the project build, which refuses
|
|
183
|
+
# to start on any of it: browser pages (E-PAGE-*,
|
|
184
|
+
# E-PROJECT-ENTRY-AMBIGUOUS) and page-server routes (R-PS-*, E-PS-*).
|
|
185
|
+
# Parse errors it repeats were reported with their file above.
|
|
164
186
|
if do_resolve:
|
|
165
187
|
for root in (a for a in args if os.path.isdir(os.path.join(a, "app"))):
|
|
166
188
|
by_file: dict[str, list[Diagnostic]] = {}
|
|
167
|
-
for d in
|
|
168
|
-
|
|
189
|
+
for d in (build_page_manifest(root).diagnostics
|
|
190
|
+
+ build_route_manifest(root).diagnostics
|
|
191
|
+
+ check_project_composition(root)):
|
|
192
|
+
if _diag_key(d) not in reported:
|
|
193
|
+
by_file.setdefault(d.file, []).append(d)
|
|
169
194
|
for path, diags in sorted(by_file.items()):
|
|
170
195
|
with open(path, "r", encoding="utf-8") as fh:
|
|
171
196
|
report(LineIndex(fh.read()), diags)
|
|
@@ -214,10 +214,11 @@ class Guard(Node):
|
|
|
214
214
|
@dataclass
|
|
215
215
|
class PageServerFile(Node):
|
|
216
216
|
"""Root of a `page.server.vl` file (dispatched on the basename, not a
|
|
217
|
-
keyword). Top level is handlers
|
|
218
|
-
|
|
217
|
+
keyword). Top level is `use` lines, handlers and at most one `errors`
|
|
218
|
+
block — no `type` / other module items (spec section 10.2, Imports)."""
|
|
219
219
|
handlers: list = field(default_factory=list) # list[PageServerHandler]
|
|
220
220
|
errors: list = field(default_factory=list) # list[ErrorMapEntry]
|
|
221
|
+
uses: list = field(default_factory=list) # list[Use]
|
|
221
222
|
|
|
222
223
|
|
|
223
224
|
@dataclass
|
|
@@ -246,9 +247,12 @@ class ErrorMapEntry(Node):
|
|
|
246
247
|
# --- _server.vl context providers (spec §10.2) ------------------------------
|
|
247
248
|
@dataclass
|
|
248
249
|
class ServerContextFile(Node):
|
|
249
|
-
"""Root of a `_server.vl` file (dispatched on the basename):
|
|
250
|
-
`context` provider declarations
|
|
250
|
+
"""Root of a `_server.vl` file (dispatched on the basename): `use`
|
|
251
|
+
lines, `context` provider declarations and, in a multi-page project's
|
|
252
|
+
root `app/_server.vl` only, the project's `auth` block (spec 10.2)."""
|
|
251
253
|
providers: list = field(default_factory=list) # list[ContextProvider]
|
|
254
|
+
uses: list = field(default_factory=list) # list[Use]
|
|
255
|
+
auth: Any = None # AuthDecl | None
|
|
252
256
|
|
|
253
257
|
|
|
254
258
|
@dataclass
|
|
@@ -456,3 +460,11 @@ class Range(Node):
|
|
|
456
460
|
class Pattern(Node):
|
|
457
461
|
binds: list = field(default_factory=list)
|
|
458
462
|
head: str = "" # constructor / variant / "_" ; "" for literal & structural patterns
|
|
463
|
+
# What the pattern tests, for the build (spec section 5.6): "wild" (`_`),
|
|
464
|
+
# "name" (a bare name: a fieldless variant, or a binding), "ctor"
|
|
465
|
+
# (`head(a, b)`), "lit" (`lo`), "range" (`lo..hi` / `lo..=hi`),
|
|
466
|
+
# "record" (`T{...}`), "list" (`[...]`) or "other".
|
|
467
|
+
kind: str = ""
|
|
468
|
+
lo: Any = None # A.Literal of a "lit" / "range" pattern
|
|
469
|
+
hi: Any = None # A.Literal upper bound of a "range" pattern
|
|
470
|
+
inclusive: bool = False
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Calls that wait, in positions that cannot wait (alpha.17).
|
|
2
|
+
|
|
3
|
+
A call to a service route, an outbound `api` client, a page-server client
|
|
4
|
+
(`api.ns.m()`), `auth.*`, or a `let` closure holding `try` / `par` returns
|
|
5
|
+
before its work finishes. In a view handler, a route body, a `spawn` task
|
|
6
|
+
or a closure that itself waits, the build awaits it wherever it sits
|
|
7
|
+
(`upper(f(x))`, `"{f(x)}"`, `f(x) + 1`, a condition). Anywhere else -- a
|
|
8
|
+
`derive`, a render value, a `state` initialiser, a plain closure -- the
|
|
9
|
+
value would be a pending promise, so:
|
|
10
|
+
|
|
11
|
+
- E-ASYNC-CALL: a network call in a position that cannot wait.
|
|
12
|
+
- E-CLOSURE-ASYNC: a call to a `let` closure holding `try` / `par` in a
|
|
13
|
+
position that cannot wait, or such a closure handed to a view prop the
|
|
14
|
+
child calls from one (the std-function case is in
|
|
15
|
+
`checks.Checker._check_async_closure_arg`).
|
|
16
|
+
|
|
17
|
+
Names are followed by binding, not by spelling (`Scopes`): a closure
|
|
18
|
+
parameter or a local that shadows a `try` / `par` closure is not that
|
|
19
|
+
closure. A callback prop is followed through the views that pass it on
|
|
20
|
+
(`Child(onSave: onSave)`), so a grandchild calling it where nothing can
|
|
21
|
+
wait counts for the view the closure was handed to.
|
|
22
|
+
|
|
23
|
+
The build (vlbuild.emit) makes the same decision at emission time and
|
|
24
|
+
refuses the same source with the same codes; `fn_params_called_sync` is
|
|
25
|
+
shared by both.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
|
|
33
|
+
from . import ast_nodes as A
|
|
34
|
+
|
|
35
|
+
# Fix lines, shared with the build's messages (vlbuild.emit).
|
|
36
|
+
ASYNC_CALL_FIX = ("fetch it with `load NAME = CALL`, or call it in an `on` "
|
|
37
|
+
"handler or route body and keep the result in `state` or "
|
|
38
|
+
"a `let`")
|
|
39
|
+
CLOSURE_CALL_FIX = ("call it where the call can be awaited -- an `on` handler, "
|
|
40
|
+
"a route body, or another closure that holds `try` / "
|
|
41
|
+
"`par` -- and use the result there (keep it in `state` "
|
|
42
|
+
"for render code)")
|
|
43
|
+
PROP_ASYNC_FIX = ("call '%s' only from an `on` handler in %s, where it is "
|
|
44
|
+
"awaited, or pass a closure without `try` / `par`")
|
|
45
|
+
STD_ASYNC_FIX = ("use `CALL() ?? fallback` inside the closure for a call that "
|
|
46
|
+
"does not wait; make network calls in the handler or route "
|
|
47
|
+
"body first and bind the results with `let`")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def sentence(fix: str) -> str:
|
|
51
|
+
"""A fix line as a sentence of a build message."""
|
|
52
|
+
return fix[:1].upper() + fix[1:]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _children(node):
|
|
56
|
+
for fname in getattr(node, "__dataclass_fields__", {}):
|
|
57
|
+
if fname in ("start", "end"):
|
|
58
|
+
continue
|
|
59
|
+
val = getattr(node, fname)
|
|
60
|
+
for item in (val if isinstance(val, (list, tuple)) else [val]):
|
|
61
|
+
for sub in (item if isinstance(item, tuple) else (item,)):
|
|
62
|
+
if hasattr(sub, "__dataclass_fields__"):
|
|
63
|
+
yield sub
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _closure_async(e) -> bool:
|
|
67
|
+
from .checks import closure_needs_async
|
|
68
|
+
return closure_needs_async(e)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Binding tags: what a name is bound to, as `Scopes.tag` finds it.
|
|
72
|
+
ASYNC = "async" # a `let` bound to a closure holding `try` / `par`
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Scopes:
|
|
76
|
+
"""Lexical scopes, innermost last: each maps a name to its binding's
|
|
77
|
+
tag (`ASYNC`, `("param", name)` for a view's `fn` param, or None)."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, names=()) -> None:
|
|
80
|
+
self.frames: list[dict] = [{n: None for n in names if n}]
|
|
81
|
+
|
|
82
|
+
def push(self, names=()) -> None:
|
|
83
|
+
self.frames.append({n: None for n in names if n})
|
|
84
|
+
|
|
85
|
+
def pop(self) -> None:
|
|
86
|
+
self.frames.pop()
|
|
87
|
+
|
|
88
|
+
def bind(self, name, tag=None) -> None:
|
|
89
|
+
if name:
|
|
90
|
+
self.frames[-1][name] = tag
|
|
91
|
+
|
|
92
|
+
def tag(self, name):
|
|
93
|
+
for frame in reversed(self.frames):
|
|
94
|
+
if name in frame:
|
|
95
|
+
return frame[name]
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def let_tag(st):
|
|
100
|
+
"""`ASYNC` for `let f = fn ...` holding `try` / `par`, else None."""
|
|
101
|
+
return ASYNC if (isinstance(st.init, A.Closure) and _closure_async(st.init)) else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _block(stmts, is_async, sc, visit, names=()):
|
|
105
|
+
sc.push(names)
|
|
106
|
+
try:
|
|
107
|
+
for st in (stmts if isinstance(stmts, list) else [stmts]):
|
|
108
|
+
if st is not None:
|
|
109
|
+
walk(st, is_async, sc, visit)
|
|
110
|
+
finally:
|
|
111
|
+
sc.pop()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def walk(node, is_async, sc: Scopes, visit):
|
|
115
|
+
"""Call `visit(node, is_async, sc)` for every node under `node`, with
|
|
116
|
+
`is_async` true where the build can `await` (an `on` handler, a
|
|
117
|
+
`spawn` task, a closure holding `try` / `par`; a plain closure body is
|
|
118
|
+
not) and `sc` holding the bindings in scope there: a block's `let`s, a
|
|
119
|
+
closure's params, a `for`'s variables, a `match` / `catch` arm's
|
|
120
|
+
bindings. A `let` is bound after its initialiser, which still sees the
|
|
121
|
+
outer binding; a `par` block's lets bind in the enclosing block."""
|
|
122
|
+
visit(node, is_async, sc)
|
|
123
|
+
if isinstance(node, A.Let):
|
|
124
|
+
if node.init is not None:
|
|
125
|
+
walk(node.init, is_async, sc, visit)
|
|
126
|
+
sc.bind(node.name, let_tag(node))
|
|
127
|
+
elif isinstance(node, (A.StateDecl, A.DeriveDecl, A.LoadDecl)):
|
|
128
|
+
for sub in _children(node):
|
|
129
|
+
walk(sub, is_async, sc, visit)
|
|
130
|
+
sc.bind(node.name)
|
|
131
|
+
elif isinstance(node, (A.OnBlock, A.Spawn)):
|
|
132
|
+
_block(node.body, True, sc, visit)
|
|
133
|
+
elif isinstance(node, A.Closure):
|
|
134
|
+
inner = _closure_async(node) if isinstance(node.body, list) else False
|
|
135
|
+
_block(node.body, inner, sc, visit, [p.name for p in node.params])
|
|
136
|
+
elif isinstance(node, A.For):
|
|
137
|
+
walk(node.iter, is_async, sc, visit)
|
|
138
|
+
_block(node.body, is_async, sc, visit, node.vars)
|
|
139
|
+
elif isinstance(node, A.While):
|
|
140
|
+
walk(node.cond, is_async, sc, visit)
|
|
141
|
+
_block(node.body, is_async, sc, visit)
|
|
142
|
+
elif isinstance(node, A.If):
|
|
143
|
+
walk(node.cond, is_async, sc, visit)
|
|
144
|
+
_block(node.then, is_async, sc, visit)
|
|
145
|
+
for cond, blk in node.elifs or []:
|
|
146
|
+
walk(cond, is_async, sc, visit)
|
|
147
|
+
_block(blk, is_async, sc, visit)
|
|
148
|
+
if node.orelse:
|
|
149
|
+
_block(node.orelse, is_async, sc, visit)
|
|
150
|
+
elif isinstance(node, A.Match):
|
|
151
|
+
walk(node.subject, is_async, sc, visit)
|
|
152
|
+
for arm in node.arms:
|
|
153
|
+
sc.push(getattr(arm[0], "binds", None) or ())
|
|
154
|
+
try:
|
|
155
|
+
for part in arm[1:]:
|
|
156
|
+
for st in (part if isinstance(part, list) else [part]):
|
|
157
|
+
if st is not None:
|
|
158
|
+
walk(st, is_async, sc, visit)
|
|
159
|
+
finally:
|
|
160
|
+
sc.pop()
|
|
161
|
+
elif isinstance(node, A.Try):
|
|
162
|
+
walk(node.expr, is_async, sc, visit)
|
|
163
|
+
for pat, body in node.catches:
|
|
164
|
+
_block(body, is_async, sc, visit, getattr(pat, "binds", None) or ())
|
|
165
|
+
elif isinstance(node, A.Element):
|
|
166
|
+
for sub in node.args or []:
|
|
167
|
+
walk(sub, is_async, sc, visit)
|
|
168
|
+
for _k, v in node.attrs or []:
|
|
169
|
+
if hasattr(v, "__dataclass_fields__"):
|
|
170
|
+
walk(v, is_async, sc, visit)
|
|
171
|
+
_block(node.body, is_async, sc, visit)
|
|
172
|
+
else:
|
|
173
|
+
for sub in _children(node):
|
|
174
|
+
walk(sub, is_async, sc, visit)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_calls(expr) -> list:
|
|
178
|
+
"""The calls a `load` awaits itself: its call, and each call on the
|
|
179
|
+
left of a `??` chain after it (`load v = f() ?? g() ?? d`)."""
|
|
180
|
+
out = []
|
|
181
|
+
while True:
|
|
182
|
+
if isinstance(expr, A.Call):
|
|
183
|
+
out.append(expr)
|
|
184
|
+
return out
|
|
185
|
+
if (isinstance(expr, A.Binary) and expr.op == "??"
|
|
186
|
+
and isinstance(expr.left, A.Call)):
|
|
187
|
+
out.append(expr.left)
|
|
188
|
+
expr = expr.right
|
|
189
|
+
continue
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def literal_exprs(lit) -> list:
|
|
194
|
+
"""Each `{expr}` of a string literal, parsed from the literal's own
|
|
195
|
+
text as the build parses it (offsets are not the file's); one that
|
|
196
|
+
does not parse is left out (the build reports it)."""
|
|
197
|
+
from .lexer import Lexer
|
|
198
|
+
from .parser import Parser
|
|
199
|
+
out = []
|
|
200
|
+
raw = getattr(lit, "raw", "") or ""
|
|
201
|
+
for s, e in getattr(lit, "interp_spans", ()) or ():
|
|
202
|
+
text = re.sub(r'\\(["\\])', r"\1", raw[s - lit.start:e - lit.start])
|
|
203
|
+
try:
|
|
204
|
+
out.append(Parser(Lexer(text, "<interp>").lex(), text,
|
|
205
|
+
"<interp>").parse_expr())
|
|
206
|
+
except Exception: # noqa: BLE001
|
|
207
|
+
continue
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def fn_params_called_sync(view, scope_of=None, _memo=None) -> set[str]:
|
|
212
|
+
"""The `fn`-typed params of `view` that end up called from a position
|
|
213
|
+
that cannot wait (a derive, render, a `state` initialiser, a plain
|
|
214
|
+
closure): by `view` itself, or by a view it passes the param on to
|
|
215
|
+
(`Child(onSave: onSave)`), however deep. `scope_of(view)` gives the
|
|
216
|
+
views that view's body can render, by name (its file's own and its
|
|
217
|
+
imports); without it only `view`'s own calls count. A parent must not
|
|
218
|
+
hand a closure that waits to one of these."""
|
|
219
|
+
memo = {} if _memo is None else _memo
|
|
220
|
+
if id(view) in memo:
|
|
221
|
+
return memo[id(view)] # done, or in progress (a cycle): what is known
|
|
222
|
+
found: set[str] = set()
|
|
223
|
+
memo[id(view)] = found
|
|
224
|
+
fn_params = [p.name for p in view.params
|
|
225
|
+
if isinstance(p.type, A.TypeRef) and p.type.is_fn]
|
|
226
|
+
if not fn_params:
|
|
227
|
+
return found
|
|
228
|
+
scope = (scope_of(view) if scope_of else None) or {}
|
|
229
|
+
|
|
230
|
+
def visit(node, is_async, sc):
|
|
231
|
+
if isinstance(node, A.Literal):
|
|
232
|
+
for sub in literal_exprs(node):
|
|
233
|
+
walk(sub, is_async, sc, visit)
|
|
234
|
+
elif isinstance(node, A.Call) and isinstance(node.callee, A.Name):
|
|
235
|
+
t = sc.tag(node.callee.id)
|
|
236
|
+
if not is_async and isinstance(t, tuple):
|
|
237
|
+
found.add(t[1])
|
|
238
|
+
elif isinstance(node, A.Element) and node.name in scope:
|
|
239
|
+
passed = {k: sc.tag(v.id) for k, v in node.attrs
|
|
240
|
+
if isinstance(v, A.Name) and isinstance(sc.tag(v.id), tuple)}
|
|
241
|
+
if passed:
|
|
242
|
+
sync = fn_params_called_sync(scope[node.name], scope_of, memo)
|
|
243
|
+
found.update(t[1] for k, t in passed.items() if k in sync)
|
|
244
|
+
|
|
245
|
+
sc = Scopes([p.name for p in view.params])
|
|
246
|
+
for p in fn_params:
|
|
247
|
+
sc.bind(p, ("param", p))
|
|
248
|
+
for st in view.body:
|
|
249
|
+
walk(st, False, sc, visit)
|
|
250
|
+
return found
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _parse(path: str):
|
|
254
|
+
from .lexer import Lexer
|
|
255
|
+
from .parser import Parser
|
|
256
|
+
with open(path, encoding="utf-8") as fh:
|
|
257
|
+
src = fh.read()
|
|
258
|
+
return Parser(Lexer(src, path).lex(), src, path).parse_module()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _use_target(base: str, it) -> str | None:
|
|
262
|
+
if not (isinstance(it, A.Use) and it.path.startswith(".")):
|
|
263
|
+
return None
|
|
264
|
+
cand = os.path.normpath(os.path.join(base, it.path))
|
|
265
|
+
target = next((c for c in (cand + ".vl", os.path.join(cand, "index.vl"), cand)
|
|
266
|
+
if os.path.isfile(c)), None)
|
|
267
|
+
return os.path.normcase(os.path.abspath(target)) if target else None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def imported_decls(filename: str, mod):
|
|
271
|
+
"""(`api` block names, views, scope_of) the way the build loads them:
|
|
272
|
+
the `api` blocks this file imports with a relative `use`, the views
|
|
273
|
+
it can render (its own and imported), and `scope_of(view)` -> the
|
|
274
|
+
views that view's own file can render, following every imported
|
|
275
|
+
file's own `use`s."""
|
|
276
|
+
files: dict[str, tuple] = {} # path -> (module, the views it can render)
|
|
277
|
+
scopes: dict[int, dict] = {} # id(view) -> its file's views
|
|
278
|
+
|
|
279
|
+
def file_scope(path: str, module) -> dict:
|
|
280
|
+
if path in files:
|
|
281
|
+
return files[path][1]
|
|
282
|
+
scope: dict = {}
|
|
283
|
+
files[path] = (module, scope)
|
|
284
|
+
base = os.path.dirname(path)
|
|
285
|
+
for it in getattr(module, "items", []):
|
|
286
|
+
if isinstance(it, A.ViewDecl):
|
|
287
|
+
scope[it.name] = it
|
|
288
|
+
scopes[id(it)] = scope
|
|
289
|
+
for it in getattr(module, "items", []):
|
|
290
|
+
target = _use_target(base, it)
|
|
291
|
+
if target is None:
|
|
292
|
+
continue
|
|
293
|
+
if target not in files:
|
|
294
|
+
try:
|
|
295
|
+
file_scope(target, _parse(target))
|
|
296
|
+
except Exception: # noqa: BLE001 -- its own check reports it
|
|
297
|
+
continue
|
|
298
|
+
wanted = set(it.names or [])
|
|
299
|
+
for d in files[target][0].items:
|
|
300
|
+
if isinstance(d, A.ViewDecl) and (not wanted or d.name in wanted):
|
|
301
|
+
scope.setdefault(d.name, d)
|
|
302
|
+
return scope
|
|
303
|
+
|
|
304
|
+
path = os.path.normcase(os.path.abspath(filename))
|
|
305
|
+
views = dict(file_scope(path, mod))
|
|
306
|
+
apis: set[str] = set()
|
|
307
|
+
for it in getattr(mod, "items", []):
|
|
308
|
+
target = _use_target(os.path.dirname(path), it)
|
|
309
|
+
if target is None or target not in files:
|
|
310
|
+
continue
|
|
311
|
+
wanted = set(it.names or [])
|
|
312
|
+
apis.update(d.name for d in files[target][0].items
|
|
313
|
+
if isinstance(d, A.ApiDecl) and (not wanted or d.name in wanted))
|
|
314
|
+
return apis, views, (lambda v: scopes.get(id(v), {}))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class AsyncCalls:
|
|
318
|
+
def __init__(self, report, clients: set, views: dict, interp=None,
|
|
319
|
+
scope_of=None):
|
|
320
|
+
self.report = report # report(code, message, node, fix)
|
|
321
|
+
self.interp = interp # (start, end) -> parsed `{expr}` or None
|
|
322
|
+
self.clients = clients # service and api block names
|
|
323
|
+
self.views = views # the views this file can render, by name
|
|
324
|
+
self.scope_of = scope_of # view -> the views its file can render
|
|
325
|
+
self._memo: dict = {}
|
|
326
|
+
|
|
327
|
+
def kind(self, call, sc: Scopes):
|
|
328
|
+
""""net" for a network call, "closure" for a call to a `let`
|
|
329
|
+
closure holding `try` / `par`, else None."""
|
|
330
|
+
callee = call.callee
|
|
331
|
+
if isinstance(callee, A.Member):
|
|
332
|
+
obj = callee.obj
|
|
333
|
+
# `auth.sign_in(...)`, `calc.one()`, `store.get(...)`, and the
|
|
334
|
+
# page-server client `api.posts.get(...)` / legacy `api.x()`
|
|
335
|
+
if isinstance(obj, A.Name) and (
|
|
336
|
+
obj.id in ("api", "auth") or obj.id in self.clients):
|
|
337
|
+
return "net"
|
|
338
|
+
if (isinstance(obj, A.Member) and isinstance(obj.obj, A.Name)
|
|
339
|
+
and obj.obj.id == "api"):
|
|
340
|
+
return "net"
|
|
341
|
+
return None
|
|
342
|
+
if isinstance(callee, A.Name) and sc.tag(callee.id) == ASYNC:
|
|
343
|
+
return "closure"
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
def run(self, stmts, is_async: bool, params=()) -> None:
|
|
347
|
+
"""One declaration's body (a view, a route, a page.server handler),
|
|
348
|
+
with its params in scope."""
|
|
349
|
+
allowed: set[int] = set()
|
|
350
|
+
|
|
351
|
+
def visit(node, ctx, sc):
|
|
352
|
+
if isinstance(node, A.Literal) and self.interp is not None:
|
|
353
|
+
# `"{f(x)}"`: the build lowers each `{expr}` in place
|
|
354
|
+
for s, e in getattr(node, "interp_spans", ()) or ():
|
|
355
|
+
sub = self.interp(s, e)
|
|
356
|
+
if sub is not None:
|
|
357
|
+
walk(sub, ctx, sc, visit)
|
|
358
|
+
elif isinstance(node, A.LoadDecl):
|
|
359
|
+
allowed.update(id(c) for c in load_calls(node.expr))
|
|
360
|
+
elif isinstance(node, A.Element) and node.name in self.views:
|
|
361
|
+
sync = fn_params_called_sync(self.views[node.name], self.scope_of,
|
|
362
|
+
self._memo)
|
|
363
|
+
for k, v in node.attrs:
|
|
364
|
+
waits = ((isinstance(v, A.Closure) and _closure_async(v))
|
|
365
|
+
or (isinstance(v, A.Name) and sc.tag(v.id) == ASYNC))
|
|
366
|
+
if waits and k in sync:
|
|
367
|
+
self.report(
|
|
368
|
+
"E-CLOSURE-ASYNC",
|
|
369
|
+
"the closure passed as '%s' holds `try` or `par`, "
|
|
370
|
+
"but %s calls '%s', itself or through a view it "
|
|
371
|
+
"passes it to, from a derive, render value or plain "
|
|
372
|
+
"closure, which cannot wait for it" % (
|
|
373
|
+
k, node.name, k), v,
|
|
374
|
+
PROP_ASYNC_FIX % (k, node.name))
|
|
375
|
+
elif (isinstance(node, A.Call) and not ctx
|
|
376
|
+
and id(node) not in allowed):
|
|
377
|
+
kind = self.kind(node, sc)
|
|
378
|
+
if kind == "net":
|
|
379
|
+
self.report(
|
|
380
|
+
"E-ASYNC-CALL",
|
|
381
|
+
"this call waits for a network call, and a derive, "
|
|
382
|
+
"render value, `state` initialiser or plain closure "
|
|
383
|
+
"cannot wait for it: it would get a pending value",
|
|
384
|
+
node, ASYNC_CALL_FIX)
|
|
385
|
+
elif kind == "closure":
|
|
386
|
+
self.report(
|
|
387
|
+
"E-CLOSURE-ASYNC",
|
|
388
|
+
"'%s' holds `try` or `par`, so a call to it waits, and "
|
|
389
|
+
"a derive, render value, `state` initialiser or plain "
|
|
390
|
+
"closure cannot wait for it: it would get a pending "
|
|
391
|
+
"value" % node.callee.id, node, CLOSURE_CALL_FIX)
|
|
392
|
+
|
|
393
|
+
sc = Scopes(params)
|
|
394
|
+
for st in stmts or []:
|
|
395
|
+
walk(st, is_async, sc, visit)
|