volaro 0.1.0-alpha.1 → 0.1.0-alpha.3
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 +29 -3
- package/bin/vl.js +38 -9
- package/compiler/SOURCE_INFO.json +6 -0
- package/compiler/SOURCE_REV +1 -1
- package/compiler/validator/vlcheck/checks.py +154 -2
- package/compiler/validator/vlcheck/lexer.py +34 -2
- package/compiler/validator/vlcheck/parser.py +214 -8
- package/compiler/validator/vlcheck/resolve.py +1 -0
- package/compiler/vlbuild/vlbuild/__main__.py +3 -0
- package/compiler/vlbuild/vlbuild/assets/vlrt.js +143 -3
- package/compiler/vlbuild/vlbuild/emit.py +211 -27
- package/compiler/vlbuild/vlbuild/server_emit.py +3 -1
- package/compiler/vlbuild/vlbuild/static_assets.py +83 -0
- package/language/crib.md +83 -9
- package/language/spec.md +190 -7
- package/language/supported.md +72 -4
- package/package.json +1 -1
- package/scripts/record-provenance.mjs +51 -0
- package/scripts/sync-compiler.sh +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Volaro
|
|
2
2
|
|
|
3
|
-
**Limited alpha
|
|
4
|
-
|
|
3
|
+
**Limited alpha, published as `0.1.0-alpha.1` under the `alpha` dist-tag** —
|
|
4
|
+
`npm install volaro@alpha`. Not `latest` (that still resolves to an earlier
|
|
5
|
+
`0.0.x` placeholder) — always specify `@alpha` or the exact version.
|
|
5
6
|
|
|
6
7
|
Volaro is an experimental application language intended for AI authoring and
|
|
7
8
|
human review. This package ships the language reference **and a working
|
|
@@ -65,9 +66,12 @@ Node 22.5+ for `node:sqlite`.
|
|
|
65
66
|
## Scaffold a project
|
|
66
67
|
|
|
67
68
|
```bash
|
|
68
|
-
npm create volaro my-app # the separate create-volaro package
|
|
69
|
+
npm create volaro@alpha my-app # the separate create-volaro package
|
|
69
70
|
```
|
|
70
71
|
|
|
72
|
+
(`@alpha` matters — `npm create volaro` with no tag resolves `latest`, which
|
|
73
|
+
is still an earlier `0.0.x` placeholder, not this build.)
|
|
74
|
+
|
|
71
75
|
It writes one single-page starter, a `volaro.json`, and `package.json` scripts
|
|
72
76
|
(`dev` / `build` / `check`) that call `volaro`.
|
|
73
77
|
|
|
@@ -110,3 +114,25 @@ The legacy `volara` npm package is unrelated to this release.
|
|
|
110
114
|
## License
|
|
111
115
|
|
|
112
116
|
MIT
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
## Local assets
|
|
120
|
+
|
|
121
|
+
Put public files in `assets/` beside your entry `.vl` file and use
|
|
122
|
+
`img src:"assets/logo.svg" alt:"Logo"`. Build and dev copy them to the browser
|
|
123
|
+
bundle. Dev watches changes; refresh the browser after a rebuild. Missing
|
|
124
|
+
literal asset references fail the build. Output `assets/` is compiler-owned.
|
|
125
|
+
|
|
126
|
+
## Package provenance
|
|
127
|
+
|
|
128
|
+
`compiler/SOURCE_REV` records the source commit. `compiler/SOURCE_INFO.json`
|
|
129
|
+
adds its origin, Git dirty status where available, and a SHA-256 digest of
|
|
130
|
+
compiler and CLI/documentation content. A package version alone does not
|
|
131
|
+
identify a local candidate; preserve the tarball hash as well.
|
|
132
|
+
|
|
133
|
+
Packaging a Git export uses `.volaro-source-rev`, expanded by `git archive`.
|
|
134
|
+
For other exported source, set `VOLARO_SOURCE_REV` to the full source commit
|
|
135
|
+
hash before `npm pack`. Missing/invalid revisions fail packaging; an explicit
|
|
136
|
+
revision cannot override a different checkout HEAD. Export metadata identifies
|
|
137
|
+
the base revision, not an assurance that someone has not modified the export;
|
|
138
|
+
the content hash distinguishes modified candidates. Nothing is published by packing.
|
package/bin/vl.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// Python is located and version-checked up front with an actionable message
|
|
13
13
|
// (see ../lib/env.js). Nothing here depends on the Volaro repository layout.
|
|
14
14
|
|
|
15
|
-
import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
|
|
15
|
+
import { readFileSync, existsSync, statSync, lstatSync, readdirSync } from "node:fs";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { dirname, join, resolve as resolvePath, extname, relative, sep, delimiter } from "node:path";
|
|
18
18
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -148,6 +148,13 @@ const MIME = {
|
|
|
148
148
|
".css": "text/css; charset=utf-8",
|
|
149
149
|
".json": "application/json; charset=utf-8",
|
|
150
150
|
".svg": "image/svg+xml",
|
|
151
|
+
".png": "image/png",
|
|
152
|
+
".jpg": "image/jpeg",
|
|
153
|
+
".jpeg": "image/jpeg",
|
|
154
|
+
".webp": "image/webp",
|
|
155
|
+
".gif": "image/gif",
|
|
156
|
+
".ico": "image/x-icon",
|
|
157
|
+
".woff2": "font/woff2",
|
|
151
158
|
".map": "application/json",
|
|
152
159
|
};
|
|
153
160
|
|
|
@@ -184,7 +191,7 @@ function staticServer(dir, port) {
|
|
|
184
191
|
// Polling beats fs.watch here: it is identical across platforms and editors
|
|
185
192
|
// (rename-replace, truncate-write and append all show up), which fs.watch is
|
|
186
193
|
// not, and the cost is trivial for a project-sized tree.
|
|
187
|
-
function scanVl(dir, depth = 6, acc = new Map()) {
|
|
194
|
+
function scanVl(dir, depth = 6, acc = new Map(), excluded = null) {
|
|
188
195
|
let entries;
|
|
189
196
|
try {
|
|
190
197
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
@@ -194,8 +201,9 @@ function scanVl(dir, depth = 6, acc = new Map()) {
|
|
|
194
201
|
for (const e of entries) {
|
|
195
202
|
if (e.name === "node_modules" || e.name === ".git" || e.name.startsWith(".")) continue;
|
|
196
203
|
const p = join(dir, e.name);
|
|
204
|
+
if (resolvePath(p) === excluded) continue;
|
|
197
205
|
if (e.isDirectory()) {
|
|
198
|
-
if (depth > 0) scanVl(p, depth - 1, acc);
|
|
206
|
+
if (depth > 0) scanVl(p, depth - 1, acc, excluded);
|
|
199
207
|
} else if (extname(e.name) === ".vl") {
|
|
200
208
|
try {
|
|
201
209
|
const s = statSync(p);
|
|
@@ -214,10 +222,24 @@ function snapEqual(a, b) {
|
|
|
214
222
|
|
|
215
223
|
// Poll watchDir for .vl changes and call onChange (debounced by the interval).
|
|
216
224
|
// Returns a close() that stops polling.
|
|
217
|
-
function watchVl(watchDir, onChange) {
|
|
218
|
-
|
|
225
|
+
function watchVl(watchDir, onChange, outDir) {
|
|
226
|
+
const snapshot = () => {
|
|
227
|
+
const acc = scanVl(watchDir, 6, new Map(), resolvePath(outDir));
|
|
228
|
+
const scanAssets = (dir) => {
|
|
229
|
+
try {
|
|
230
|
+
const s = lstatSync(dir);
|
|
231
|
+
acc.set(dir, `${s.mtimeMs}:${s.ctimeMs}:${s.size}`);
|
|
232
|
+
if (s.isDirectory() && !s.isSymbolicLink()) {
|
|
233
|
+
for (const name of readdirSync(dir)) scanAssets(join(dir, name));
|
|
234
|
+
}
|
|
235
|
+
} catch {}
|
|
236
|
+
};
|
|
237
|
+
scanAssets(join(watchDir, "assets"));
|
|
238
|
+
return acc;
|
|
239
|
+
};
|
|
240
|
+
let prev = snapshot();
|
|
219
241
|
const iv = setInterval(() => {
|
|
220
|
-
const now =
|
|
242
|
+
const now = snapshot();
|
|
221
243
|
if (!snapEqual(prev, now)) {
|
|
222
244
|
prev = now;
|
|
223
245
|
onChange();
|
|
@@ -299,7 +321,7 @@ function cmdDev(args) {
|
|
|
299
321
|
await restartServer();
|
|
300
322
|
startServer();
|
|
301
323
|
process.stdout.write("volaro dev: server restarted\n");
|
|
302
|
-
});
|
|
324
|
+
}, out);
|
|
303
325
|
|
|
304
326
|
const bye = () => {
|
|
305
327
|
if (shuttingDown) return;
|
|
@@ -326,7 +348,7 @@ function cmdDev(args) {
|
|
|
326
348
|
process.stdout.write("volaro dev: change detected — rebuilding\n");
|
|
327
349
|
const r = buildOnce(entry, out);
|
|
328
350
|
process.stdout.write(r.ok ? "volaro dev: rebuilt\n" : "volaro dev: build failed — keeping last good bundle\n");
|
|
329
|
-
});
|
|
351
|
+
}, out);
|
|
330
352
|
|
|
331
353
|
const bye = () => {
|
|
332
354
|
if (shuttingDown) return;
|
|
@@ -351,7 +373,14 @@ function cmdReference(which) {
|
|
|
351
373
|
);
|
|
352
374
|
}
|
|
353
375
|
const file = which === "supported" ? "supported" : which;
|
|
354
|
-
|
|
376
|
+
// `supported.md`'s own title carries this build's exact version as a
|
|
377
|
+
// `{{VERSION}}` placeholder rather than a hardcoded string -- found stale
|
|
378
|
+
// (still reading a prior release's version after a version bump, since
|
|
379
|
+
// nothing kept it in sync) in the Test_003 benchmark's packaged-docs
|
|
380
|
+
// finding. Substituted from `package.json` at print time so it can never
|
|
381
|
+
// drift again, the same way the "supported"-vs-"spec" note above already
|
|
382
|
+
// reads `pkg.version` live rather than a copy-pasted string.
|
|
383
|
+
process.stdout.write(read(`language/${file}.md`).replace("{{VERSION}}", pkg.version));
|
|
355
384
|
}
|
|
356
385
|
|
|
357
386
|
function cmdExample(name) {
|
package/compiler/SOURCE_REV
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
a450e6fe6549b8c7df43ba343793ab2e52758a78
|
|
@@ -39,6 +39,36 @@ BUILTINS = {
|
|
|
39
39
|
}
|
|
40
40
|
_PRIM_NAMES = {"int", "float", "str", "bool", "bytes", "time", "dur", "uuid", "json", "nil"}
|
|
41
41
|
|
|
42
|
+
# `use std.X { name, ... }` names `vlbuild` actually lowers to a working call
|
|
43
|
+
# -- NOT the same list as `BUILTINS` above (that one is a generous, deliberately
|
|
44
|
+
# loose allowlist for the W-UNDEF-NAME heuristic, and includes several names,
|
|
45
|
+
# e.g. `merge`/`fold`/`first`/`to_int`, the spec shows but this build's emitter
|
|
46
|
+
# has no lowering for). Duplicated from `vlbuild/emit.py`'s `STD_FNS` (plus
|
|
47
|
+
# `now`, whose one working lowering — a `model` field default — lives in
|
|
48
|
+
# `server_emit.py`'s `_sql_default`) rather than imported: `vlcheck` has no
|
|
49
|
+
# dependency on `vlbuild` (the dependency runs the other way), so the two
|
|
50
|
+
# packages cannot share this table directly. Keep in sync by hand; a mismatch
|
|
51
|
+
# only makes this check too strict or too loose, never silently wrong, since
|
|
52
|
+
# `vlbuild` is the actual authority on what it lowers.
|
|
53
|
+
#
|
|
54
|
+
# Found reproducing the login-page benchmark's Bug 3 (`test-only`
|
|
55
|
+
# `login-page/volaro/NOTES.md`): `use std.http { post }` passes `vlcheck`
|
|
56
|
+
# clean and compiles to a bare, undefined `post(...)` call in the emitted JS
|
|
57
|
+
# (`emit.py`'s `_ex_Call` has no special case for an unrecognized `A.Name`
|
|
58
|
+
# callee, so it falls through to emitting the call verbatim) -- a silent
|
|
59
|
+
# ReferenceError at runtime, not a build-time diagnostic. `std.http` has no
|
|
60
|
+
# member in either list below; nothing under it can pass this check.
|
|
61
|
+
_STD_SUPPORTED_NAMES = {
|
|
62
|
+
"filter", "map", "take", "count", "sum", "sort_by", "group_by", "chunk",
|
|
63
|
+
"join", "split", "first_char", "int_to_float", "float_to_int",
|
|
64
|
+
"trim", "upper", "lower", "keys", "values", "get_or", "concat", "append",
|
|
65
|
+
"now",
|
|
66
|
+
# std.str's `regex` (spec section 13): `matches(s, pattern)`, added
|
|
67
|
+
# alongside its `vlbuild/emit.py` `STD_FNS` entry and `VL.std.matches`
|
|
68
|
+
# (assets/vlrt.js) -- keep all three in sync by hand.
|
|
69
|
+
"matches",
|
|
70
|
+
}
|
|
71
|
+
|
|
42
72
|
|
|
43
73
|
def _lit_text(v):
|
|
44
74
|
"""The literal text of a plain string literal, or None when `v` is not one
|
|
@@ -125,13 +155,39 @@ class Checker:
|
|
|
125
155
|
self.styling(mod)
|
|
126
156
|
self.accessible_primitives(mod)
|
|
127
157
|
self.view_bindings(mod)
|
|
158
|
+
self.callback_props(mod)
|
|
128
159
|
self.document_structure(mod)
|
|
129
160
|
self.composed_document(mod)
|
|
130
161
|
self.services(mod)
|
|
131
162
|
self.api_params(mod)
|
|
163
|
+
self.imports(mod)
|
|
132
164
|
self.semantic(mod)
|
|
133
165
|
return self.diags
|
|
134
166
|
|
|
167
|
+
# -- std imports: only names vlbuild actually lowers are usable --------
|
|
168
|
+
def imports(self, mod: A.Module) -> None:
|
|
169
|
+
"""`use std.X { name }` where `name` is not one `vlbuild` lowers
|
|
170
|
+
compiles clean today and fails at runtime instead (see
|
|
171
|
+
`_STD_SUPPORTED_NAMES`'s docstring above) -- the same silent-wrong
|
|
172
|
+
shape `E-VARIANT-LITERAL` / `E-ASSIGN-READONLY` exist to catch
|
|
173
|
+
elsewhere in this file, just for the standard library's surface
|
|
174
|
+
rather than a view's. A relative (`./`/`../`) or third-party `use` is
|
|
175
|
+
untouched here -- `vlcheck --resolve` (resolve.py) is the pass that
|
|
176
|
+
follows a project-relative path; this only judges `std.*`, the one
|
|
177
|
+
namespace with a closed, compiler-owned surface."""
|
|
178
|
+
for it in mod.items:
|
|
179
|
+
if not (isinstance(it, A.Use) and it.path.startswith("std.")):
|
|
180
|
+
continue
|
|
181
|
+
for name in it.names:
|
|
182
|
+
if name not in _STD_SUPPORTED_NAMES:
|
|
183
|
+
self._d("E-UNKNOWN-STD-IMPORT", Severity.ERROR,
|
|
184
|
+
f"'{name}' is not a function this build of Volaro "
|
|
185
|
+
f"implements -- '{it.path}' has no working '{name}'; "
|
|
186
|
+
"it would compile with 0 errors and fail at runtime "
|
|
187
|
+
"calling an undefined function", it,
|
|
188
|
+
"supported std functions: "
|
|
189
|
+
+ ", ".join(sorted(_STD_SUPPORTED_NAMES)))
|
|
190
|
+
|
|
135
191
|
# -- view bindings: what may be assigned to, and literal-only attrs ----
|
|
136
192
|
def view_bindings(self, mod: A.Module) -> None:
|
|
137
193
|
"""Per view: (1) a `derive` / `load` value and a parameter are
|
|
@@ -195,6 +251,88 @@ class Checker:
|
|
|
195
251
|
"label instead." % (v.id, v.id),
|
|
196
252
|
node, "variant:primary")
|
|
197
253
|
|
|
254
|
+
# -- callback props (DESIGN-01: child->parent data flow) -----------------
|
|
255
|
+
def callback_props(self, mod: A.Module) -> None:
|
|
256
|
+
"""A `fn(...)`-typed view param is a callback prop (the decided
|
|
257
|
+
parent->child mutation idiom --
|
|
258
|
+
`documentation/language-design/CHILD-PARENT-DATA-FLOW-DECISION.md`):
|
|
259
|
+
the parent hands the child a closure that mutates the parent's own
|
|
260
|
+
`state`, never a raw setter. Two things this checker CAN decide
|
|
261
|
+
without real type inference (see the module docstring):
|
|
262
|
+
|
|
263
|
+
(1) at every call site that instantiates the view, the value given
|
|
264
|
+
for that prop must be a plausible callable -- a closure literal
|
|
265
|
+
(whose own arity is checked against the declared signature), or a
|
|
266
|
+
bare name (assumed to resolve to a callable somewhere in scope --
|
|
267
|
+
this prototype has no type inference to confirm it, matching how
|
|
268
|
+
`_resolve` / `R-UNDEF` elsewhere stay conservative rather than
|
|
269
|
+
guess). A literal of any other shape can never be called; the
|
|
270
|
+
emitter would still produce `<literal>(...)`, a guaranteed runtime
|
|
271
|
+
TypeError caught here at check time instead.
|
|
272
|
+
|
|
273
|
+
(2) inside the child's own body, calling that prop with the wrong
|
|
274
|
+
number of arguments -- the same arity mistake an ordinary function
|
|
275
|
+
call already gets, except no existing check reaches a `fn`-typed
|
|
276
|
+
param (it is a local binding, not a module-level declaration
|
|
277
|
+
`resolve.py`'s symbol table would recognise)."""
|
|
278
|
+
views = {it.name: it for it in mod.items if isinstance(it, A.ViewDecl)}
|
|
279
|
+
fn_params: dict[str, dict[str, A.TypeRef]] = {}
|
|
280
|
+
for name, v in views.items():
|
|
281
|
+
d = {p.name: p.type for p in v.params
|
|
282
|
+
if isinstance(p.type, A.TypeRef) and p.type.is_fn}
|
|
283
|
+
if d:
|
|
284
|
+
fn_params[name] = d
|
|
285
|
+
if not fn_params:
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
# (1) every call site that passes one of these props.
|
|
289
|
+
for it in mod.items:
|
|
290
|
+
if not isinstance(it, A.ViewDecl):
|
|
291
|
+
continue
|
|
292
|
+
for node in _walk(it.body):
|
|
293
|
+
if not isinstance(node, A.Element) or node.name not in fn_params:
|
|
294
|
+
continue
|
|
295
|
+
wanted = fn_params[node.name]
|
|
296
|
+
for k, v in node.attrs:
|
|
297
|
+
sig = wanted.get(k)
|
|
298
|
+
if sig is None:
|
|
299
|
+
continue
|
|
300
|
+
if isinstance(v, A.Closure):
|
|
301
|
+
want_n, got_n = len(sig.fn_params), len(v.params)
|
|
302
|
+
if got_n != want_n:
|
|
303
|
+
self._d("E-CALLBACK-PROP-ARITY", Severity.ERROR,
|
|
304
|
+
"'%s:' on <%s> is %s -- the closure passed "
|
|
305
|
+
"has %d parameter%s, not %d"
|
|
306
|
+
% (k, node.name, _fn_sig(sig), got_n,
|
|
307
|
+
"" if got_n == 1 else "s", want_n),
|
|
308
|
+
v, None)
|
|
309
|
+
continue
|
|
310
|
+
if isinstance(v, A.Name):
|
|
311
|
+
continue # assumed callable; no type inference here
|
|
312
|
+
self._d("E-CALLBACK-PROP-TYPE", Severity.ERROR,
|
|
313
|
+
"'%s:' on <%s> is a callback prop (%s) and needs "
|
|
314
|
+
"a closure or a callable value -- a literal can "
|
|
315
|
+
"never be called" % (k, node.name, _fn_sig(sig)),
|
|
316
|
+
v,
|
|
317
|
+
"%s: fn(%s) ..." % (
|
|
318
|
+
k, ", ".join("_" for _ in sig.fn_params)))
|
|
319
|
+
|
|
320
|
+
# (2) arity at each call *inside* the child that owns the prop.
|
|
321
|
+
for name, params in fn_params.items():
|
|
322
|
+
for node in _walk(views[name].body):
|
|
323
|
+
if not isinstance(node, A.Call) or not isinstance(node.callee, A.Name):
|
|
324
|
+
continue
|
|
325
|
+
sig = params.get(node.callee.id)
|
|
326
|
+
if sig is None:
|
|
327
|
+
continue
|
|
328
|
+
want, got = len(sig.fn_params), len(node.args)
|
|
329
|
+
if got != want:
|
|
330
|
+
self._d("E-CALLBACK-PROP-ARITY", Severity.ERROR,
|
|
331
|
+
"'%s' takes %d argument%s (%s) -- called here "
|
|
332
|
+
"with %d" % (node.callee.id, want,
|
|
333
|
+
"" if want == 1 else "s", _fn_sig(sig), got),
|
|
334
|
+
node, None)
|
|
335
|
+
|
|
198
336
|
def styling(self, mod: A.Module) -> None:
|
|
199
337
|
"""Checks that are decidable within one source file. Project-wide
|
|
200
338
|
recipe selection/conflicts are checked by vlbuild after discovery."""
|
|
@@ -401,8 +539,11 @@ class Checker:
|
|
|
401
539
|
if (len(node.args) != 1 or node.body
|
|
402
540
|
or any(k not in ("class", "test_id", "test_scope") for k, _ in node.attrs)):
|
|
403
541
|
self._d("E-STATUS-SHAPE", Severity.ERROR,
|
|
404
|
-
"'status' takes one
|
|
405
|
-
|
|
542
|
+
"'status' takes one POSITIONAL expression (not an "
|
|
543
|
+
"attribute), optional class:, and no children",
|
|
544
|
+
node, 'status outcome_message — a bare expression, '
|
|
545
|
+
'never `status message:outcome_message`; keep it '
|
|
546
|
+
'mounted, use nil or "" while idle')
|
|
406
547
|
|
|
407
548
|
if node.name == "input":
|
|
408
549
|
# spec §8.9: every input carries a programmatic name,
|
|
@@ -1070,6 +1211,17 @@ def _fallible(tref) -> bool:
|
|
|
1070
1211
|
return isinstance(tref, A.TypeRef) and tref.fallible
|
|
1071
1212
|
|
|
1072
1213
|
|
|
1214
|
+
def _fn_sig(tref: A.TypeRef) -> str:
|
|
1215
|
+
"""Render a `fn(...)`-typed prop's signature for a diagnostic, e.g.
|
|
1216
|
+
'fn(int, str) nil'."""
|
|
1217
|
+
def name(t):
|
|
1218
|
+
if t is None:
|
|
1219
|
+
return "nil"
|
|
1220
|
+
return "fn(...)" if getattr(t, "is_fn", False) else (t.name or "?")
|
|
1221
|
+
return "fn(%s) %s" % (", ".join(name(p) for p in tref.fn_params),
|
|
1222
|
+
name(tref.fn_ret))
|
|
1223
|
+
|
|
1224
|
+
|
|
1073
1225
|
def check(mod: A.Module, filename: str, li: LineIndex,
|
|
1074
1226
|
strict_names: bool = False, release: bool = False) -> list[Diagnostic]:
|
|
1075
1227
|
return Checker(filename, li, strict_names, release).run(mod)
|
|
@@ -191,7 +191,10 @@ class Lexer:
|
|
|
191
191
|
if src[self.i:self.i + 3] == '"""':
|
|
192
192
|
self.i += 3
|
|
193
193
|
while self.i < self.n and src[self.i:self.i + 3] != '"""':
|
|
194
|
-
self.i
|
|
194
|
+
if src[self.i] == "\\":
|
|
195
|
+
self._lex_escape()
|
|
196
|
+
else:
|
|
197
|
+
self.i += 1
|
|
195
198
|
if src[self.i:self.i + 3] == '"""':
|
|
196
199
|
self.i += 3
|
|
197
200
|
else:
|
|
@@ -204,7 +207,7 @@ class Lexer:
|
|
|
204
207
|
while self.i < self.n:
|
|
205
208
|
c = src[self.i]
|
|
206
209
|
if c == "\\":
|
|
207
|
-
self.
|
|
210
|
+
self._lex_escape()
|
|
208
211
|
continue
|
|
209
212
|
if c == "\n":
|
|
210
213
|
self._err("E-STRING", "unterminated string literal", s, self.i,
|
|
@@ -236,6 +239,35 @@ class Lexer:
|
|
|
236
239
|
tok = self._add("STRING", src[s:self.i], s, self.i)
|
|
237
240
|
tok.interps = interps
|
|
238
241
|
|
|
242
|
+
def _lex_escape(self) -> None:
|
|
243
|
+
"""Validate escapes before emission can silently change their meaning."""
|
|
244
|
+
start = self.i
|
|
245
|
+
self.i += 1
|
|
246
|
+
if self.i < self.n and self.src[self.i] in 'nt"\\{}':
|
|
247
|
+
self.i += 1
|
|
248
|
+
return
|
|
249
|
+
if self.src[self.i:self.i + 2] == "u{":
|
|
250
|
+
end = self.src.find("}", self.i + 2)
|
|
251
|
+
digits = self.src[self.i + 2:end] if end != -1 else ""
|
|
252
|
+
if (1 <= len(digits) <= 6
|
|
253
|
+
and all(c in "0123456789abcdefABCDEF" for c in digits)):
|
|
254
|
+
value = int(digits, 16)
|
|
255
|
+
if value <= 0x10ffff and not 0xd800 <= value <= 0xdfff:
|
|
256
|
+
self.i = end + 1
|
|
257
|
+
return
|
|
258
|
+
self._err("E-STRING-ESCAPE", "invalid Unicode string escape", start,
|
|
259
|
+
min(self.n, end + 1 if end != -1 else self.i + 1),
|
|
260
|
+
r"use \u{HEX} with a Unicode scalar value (no surrogate code points)")
|
|
261
|
+
if end != -1 and not any(c in self.src[self.i:end] for c in '\n"'):
|
|
262
|
+
self.i = end + 1
|
|
263
|
+
return
|
|
264
|
+
self._err("E-STRING-ESCAPE", "unknown string escape", start,
|
|
265
|
+
min(self.n, self.i + 1),
|
|
266
|
+
r'double a literal backslash: "\\s" for regex \s; supported escapes: \n \t \" \\ \{ \} \u{HEX}')
|
|
267
|
+
# Keep a closing quote/newline available for normal string recovery.
|
|
268
|
+
if self.i < self.n and self.src[self.i] not in '\n"':
|
|
269
|
+
self.i += 1
|
|
270
|
+
|
|
239
271
|
def _lex_number(self) -> None:
|
|
240
272
|
s = self.i
|
|
241
273
|
src = self.src
|