@omgbase/oqx 0.20.0 → 0.21.0
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/CHANGELOG.md +6 -0
- package/README.md +28 -0
- package/dist/ast.d.ts +7 -0
- package/dist/ast.d.ts.map +1 -1
- package/dist/ast.js +1 -1
- package/dist/ast.js.map +1 -1
- package/dist/build.d.ts +4 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +3 -0
- package/dist/build.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +28 -1
- package/dist/engine.js.map +1 -1
- package/dist/lexer.d.ts +3 -3
- package/dist/lexer.d.ts.map +1 -1
- package/dist/lexer.js +79 -2
- package/dist/lexer.js.map +1 -1
- package/dist/optimize/analysis.d.ts.map +1 -1
- package/dist/optimize/analysis.js +4 -0
- package/dist/optimize/analysis.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +54 -4
- package/dist/parser.js.map +1 -1
- package/dist/print.js +35 -17
- package/dist/print.js.map +1 -1
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +4 -0
- package/dist/resolve.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +8 -3
- package/dist/walk.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +11 -1
- package/src/build.ts +4 -1
- package/src/engine.ts +22 -1
- package/src/lexer.ts +76 -6
- package/src/optimize/analysis.ts +4 -0
- package/src/parser.ts +47 -5
- package/src/print.ts +33 -7
- package/src/resolve.ts +4 -0
- package/src/version.ts +1 -1
- package/src/walk.ts +7 -3
package/src/ast.ts
CHANGED
|
@@ -35,6 +35,10 @@ export type UnaryOp = "!" | "-";
|
|
|
35
35
|
/** The operator of a `logical` expression. */
|
|
36
36
|
export type LogicalOp = "&&" | "||";
|
|
37
37
|
|
|
38
|
+
/** One part of a template literal (since 0.21): a decoded text run, or an
|
|
39
|
+
* interpolated expression. The strings are not nodes (no `kind`, no span). */
|
|
40
|
+
export type TemplatePart = string | Expr;
|
|
41
|
+
|
|
38
42
|
/** Scalar value/predicate expression, evaluated against a row scope + bindings. */
|
|
39
43
|
export type Expr =
|
|
40
44
|
| { kind: "lit"; span: Span; value: string | number | boolean | null }
|
|
@@ -58,6 +62,12 @@ export type Expr =
|
|
|
58
62
|
// receiver (`(a, b) { … }`, `follow (a, b) { depth 2 }`). One expression in
|
|
59
63
|
// parentheses is grouping and leaves no node.
|
|
60
64
|
| { kind: "tuple"; span: Span; items: Expr[] }
|
|
65
|
+
// A template literal `` `text ${expr} text` `` (since 0.21): `parts` alternates
|
|
66
|
+
// strictly — text, interpolation, text, … — so it always has an odd length and
|
|
67
|
+
// begins and ends with a (possibly empty) text part; the text is decoded (the
|
|
68
|
+
// GRAMMAR §1 escapes resolved). Evaluates to the concatenation of the text and
|
|
69
|
+
// the interpolated values' string forms (SEMANTICS §7b).
|
|
70
|
+
| { kind: "template"; span: Span; parts: TemplatePart[] }
|
|
61
71
|
// A value-position directive (since 0.17): `recv first { … }.pay`, and what a
|
|
62
72
|
// bracket lookup `x[…]` desugars to. `isExpr` is false for it — an op is an
|
|
63
73
|
// expression only by position; discriminate on `kind === "op"`.
|
|
@@ -193,7 +203,7 @@ export type AstKind = AstNode["kind"];
|
|
|
193
203
|
|
|
194
204
|
/** The expression kinds (`Expr["kind"]`), for `isExpr`. */
|
|
195
205
|
const EXPR_KINDS: ReadonlySet<string> = new Set([
|
|
196
|
-
"lit", "ident", "outer", "binding", "member", "call", "unary", "required", "binary", "logical", "in", "range", "tuple",
|
|
206
|
+
"lit", "ident", "outer", "binding", "member", "call", "unary", "required", "binary", "logical", "in", "range", "tuple", "template",
|
|
197
207
|
]);
|
|
198
208
|
|
|
199
209
|
/** Whether a node is a scalar `Expr` (an `op` in expression position is not: it
|
package/src/build.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type {
|
|
7
7
|
BinaryOp, Consumer, CountCmp, Expr, Follow, FollowDestination, LogicalOp, OpNode, OrderSpec, Query, SelectItem, Span,
|
|
8
|
-
Subquery, UnaryOp, Where,
|
|
8
|
+
Subquery, TemplatePart, UnaryOp, Where,
|
|
9
9
|
} from "./ast.ts";
|
|
10
10
|
|
|
11
11
|
/** A fresh empty span (never shared: spans are mutable tuples). */
|
|
@@ -28,6 +28,9 @@ export const inOp = (left: Expr, right: Expr): Expr => ({ kind: "in", span: span
|
|
|
28
28
|
export const range = (lo: Expr | null, hi: Expr | null, exclusiveEnd = false): Expr => ({ kind: "range", span: span(), lo, hi, exclusiveEnd });
|
|
29
29
|
/** `(e1, e2, …)` — a tuple of two or more expressions (SEMANTICS §3b). */
|
|
30
30
|
export const tuple = (items: Expr[]): Expr => ({ kind: "tuple", span: span(), items });
|
|
31
|
+
/** `` `text ${expr} …` `` — a template literal (SEMANTICS §7b); `parts` alternates
|
|
32
|
+
* text, expression, text, … (odd length, text first and last). */
|
|
33
|
+
export const template = (parts: TemplatePart[]): Expr => ({ kind: "template", span: span(), parts });
|
|
31
34
|
|
|
32
35
|
export const scalar = (expr: Expr): Where => ({ kind: "scalar", span: span(), expr });
|
|
33
36
|
export const and = (parts: Where[]): Where => ({ kind: "and", span: span(), parts });
|
package/src/engine.ts
CHANGED
|
@@ -26,7 +26,7 @@ import type { DataContext } from "./context.ts";
|
|
|
26
26
|
import { DefaultContext } from "./context.ts";
|
|
27
27
|
import { OqxError } from "./errors.ts";
|
|
28
28
|
import {
|
|
29
|
-
relate, arith, membership, truthy, compareForSort, compare, canonicalKey, makeRange, isEntry, isRange,
|
|
29
|
+
relate, arith, membership, truthy, compareForSort, compare, canonicalKey, makeRange, isEntry, isRange, stringForm,
|
|
30
30
|
} from "./semantics.ts";
|
|
31
31
|
import type { BlockPlan, Correlation, Rule, RowIndex } from "./optimize/index.ts";
|
|
32
32
|
import { printTemplate } from "./print.ts";
|
|
@@ -904,6 +904,26 @@ export class InMemoryEngine implements Engine {
|
|
|
904
904
|
}
|
|
905
905
|
return out;
|
|
906
906
|
}
|
|
907
|
+
// A template literal (SEMANTICS §7b): the text parts verbatim and each
|
|
908
|
+
// interpolated value's string form (§7), concatenated. Every interpolation
|
|
909
|
+
// is evaluated, in order; a list, a record or a range is an eval error (it
|
|
910
|
+
// has no string form the language specifies); an absent value makes the
|
|
911
|
+
// whole literal absent, as `+` does.
|
|
912
|
+
case "template": {
|
|
913
|
+
let out = "";
|
|
914
|
+
let absent = false;
|
|
915
|
+
for (const part of e.parts) {
|
|
916
|
+
if (typeof part === "string") { out += part; continue; }
|
|
917
|
+
const v = this.evalExpr(part, scope);
|
|
918
|
+
if (v == null) { absent = true; continue; }
|
|
919
|
+
if (typeof v === "object") {
|
|
920
|
+
const what = isRange(v) ? "a range" : Array.isArray(v) || Symbol.iterator in v ? "a list" : "a record";
|
|
921
|
+
throw new OqxError(`cannot interpolate \`\${${describe(part)}}\` in a template literal: it is ${what} — only a string, a number, a boolean or an absent value has a string form`, "eval");
|
|
922
|
+
}
|
|
923
|
+
out += stringForm(v)!;
|
|
924
|
+
}
|
|
925
|
+
return absent ? undefined : out;
|
|
926
|
+
}
|
|
907
927
|
}
|
|
908
928
|
}
|
|
909
929
|
|
|
@@ -1093,6 +1113,7 @@ function exprHasRecur(e: Expr): boolean {
|
|
|
1093
1113
|
case "binary": case "logical": case "in": return exprHasRecur(e.left) || exprHasRecur(e.right);
|
|
1094
1114
|
case "range": return (e.lo != null && exprHasRecur(e.lo)) || (e.hi != null && exprHasRecur(e.hi));
|
|
1095
1115
|
case "tuple": return e.items.some(exprHasRecur);
|
|
1116
|
+
case "template": return e.parts.some((p) => typeof p !== "string" && exprHasRecur(p));
|
|
1096
1117
|
default: return false;
|
|
1097
1118
|
}
|
|
1098
1119
|
}
|
package/src/lexer.ts
CHANGED
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
// alter the grammar — the prepared-statement / injection-safe property the
|
|
11
11
|
// host-bindings design note calls for. (A consequence: `${x}` inside a string
|
|
12
12
|
// literal does not interpolate — that fragment would be an unterminated string —
|
|
13
|
-
// which is exactly the desired "interpolation is a value, never source text".
|
|
13
|
+
// which is exactly the desired "interpolation is a value, never source text".
|
|
14
|
+
// The same holds for a backtick template literal (0.21): a fragment that ends
|
|
15
|
+
// inside one is an unterminated literal, so a bound value never stands in one.)
|
|
16
|
+
//
|
|
17
|
+
// Template literals `` `text ${expr}` `` are lexed with a mode stack: the text
|
|
18
|
+
// between the backticks is one `tmpl_text` token per run (decoded), a `${`
|
|
19
|
+
// opens an interpolation whose tokens are ordinary source until the `}` that
|
|
20
|
+
// balances it, and an interpolation may hold another template literal.
|
|
14
21
|
//
|
|
15
22
|
// Positions (`Token.pos` / `Token.end`, the `span` of every AST node, and the
|
|
16
23
|
// offsets quoted in error messages) count Unicode CODE POINTS over the raw
|
|
@@ -39,13 +46,21 @@ export type TokType =
|
|
|
39
46
|
| "range" // `..` (inclusive) or `...` (exclusive end) — a Ruby-style range operator (value carries which)
|
|
40
47
|
| "op" // == != <= >= < > && || ! + - * / % (value carries the operator)
|
|
41
48
|
| "binding" // a ${…} interpolation; `index` names the value slot
|
|
49
|
+
// A template literal (since 0.21): the opening and closing backticks, a run of
|
|
50
|
+
// its decoded text, and the `${` / `}` around an interpolation — whose `}` is a
|
|
51
|
+
// token of its own, so a block's `}` inside the interpolation stays `rbrace`.
|
|
52
|
+
| "tmpl_start"
|
|
53
|
+
| "tmpl_text"
|
|
54
|
+
| "tmpl_interp_start"
|
|
55
|
+
| "tmpl_interp_end"
|
|
56
|
+
| "tmpl_end"
|
|
42
57
|
| "eof";
|
|
43
58
|
|
|
44
59
|
export interface Token {
|
|
45
60
|
type: TokType;
|
|
46
61
|
/** The source text of the token, except: a `string` token carries its decoded
|
|
47
|
-
* value (quotes stripped, escapes resolved)
|
|
48
|
-
* marker `${N}`; `eof` carries `""`. */
|
|
62
|
+
* value (quotes stripped, escapes resolved), and so does a `tmpl_text` run; a
|
|
63
|
+
* `binding` carries the display marker `${N}`; `eof` carries `""`. */
|
|
49
64
|
value: string;
|
|
50
65
|
/** Code-point offset of the token's first character. */
|
|
51
66
|
pos: number;
|
|
@@ -78,7 +93,16 @@ export function lexTemplate(fragments: readonly string[], values: number): Token
|
|
|
78
93
|
const tokens: Token[] = [];
|
|
79
94
|
let base = 0; // running offset across fragments + rendered `${…}` markers
|
|
80
95
|
for (let f = 0; f < fragments.length; f++) {
|
|
81
|
-
|
|
96
|
+
try {
|
|
97
|
+
lexFragment(fragments[f]!, base, tokens);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// A fragment ends inside a backtick literal: the bound value would have
|
|
100
|
+
// to stand inside it, and a bound value is never source text (GRAMMAR §1).
|
|
101
|
+
if (err instanceof OqxError && f < fragments.length - 1 && err.message.startsWith("unterminated template literal")) {
|
|
102
|
+
throw new OqxError(`${err.message} — a template literal must be complete within one fragment: a \${…} binding cannot stand inside a backtick literal (interpolate a name, or bind the whole string)`, "lex");
|
|
103
|
+
}
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
82
106
|
base += codePointLength(fragments[f]!);
|
|
83
107
|
if (f < fragments.length - 1) {
|
|
84
108
|
// account for the value's rendered width in the display source (see rawSource)
|
|
@@ -103,6 +127,14 @@ export function lexString(src: string): Token[] {
|
|
|
103
127
|
return tokens;
|
|
104
128
|
}
|
|
105
129
|
|
|
130
|
+
// The lexer's mode inside a template literal (GRAMMAR §1, since 0.21): the text of
|
|
131
|
+
// the literal, or a `${…}` interpolation, which is ordinary source again — with a
|
|
132
|
+
// brace depth, so the `}` that closes the interpolation is told from a block's.
|
|
133
|
+
// Nesting is a stack: an interpolation may hold another template literal.
|
|
134
|
+
type Mode =
|
|
135
|
+
| { kind: "template"; start: number }
|
|
136
|
+
| { kind: "interp"; start: number; depth: number };
|
|
137
|
+
|
|
106
138
|
function lexFragment(src: string, base: number, out: Token[]): void {
|
|
107
139
|
// One entry per code point, so every index below is a code-point offset.
|
|
108
140
|
const cps = Array.from(src);
|
|
@@ -113,16 +145,47 @@ function lexFragment(src: string, base: number, out: Token[]): void {
|
|
|
113
145
|
const push = (type: TokType, value: string, start: number, end: number): void => {
|
|
114
146
|
out.push({ type, value, pos: base + start, end: base + end });
|
|
115
147
|
};
|
|
148
|
+
const modes: Mode[] = [];
|
|
116
149
|
|
|
117
150
|
while (i < n) {
|
|
151
|
+
const mode = modes[modes.length - 1];
|
|
152
|
+
|
|
153
|
+
// Template text: everything up to the closing backtick or the `${` of an
|
|
154
|
+
// interpolation, decoded with the string escapes (`\<c>` is `<c>`, so `` \` ``
|
|
155
|
+
// and `\$` keep a backtick or a dollar literal). Newlines are kept verbatim.
|
|
156
|
+
if (mode?.kind === "template") {
|
|
157
|
+
const start = i;
|
|
158
|
+
let sval = "";
|
|
159
|
+
while (i < n) {
|
|
160
|
+
const c = cps[i]!;
|
|
161
|
+
if (c === "`" || (c === "$" && at(i + 1) === "{")) break;
|
|
162
|
+
if (c === "\\") { i++; sval += unescape(cps[i]); i++; continue; }
|
|
163
|
+
sval += c;
|
|
164
|
+
i++;
|
|
165
|
+
}
|
|
166
|
+
if (i > start) push("tmpl_text", sval, start, Math.min(i, n));
|
|
167
|
+
if (i >= n) break; // unterminated — reported below
|
|
168
|
+
if (cps[i] === "`") { push("tmpl_end", "`", i, i + 1); i++; modes.pop(); continue; }
|
|
169
|
+
push("tmpl_interp_start", "${", i, i + 2);
|
|
170
|
+
modes.push({ kind: "interp", start: i, depth: 0 });
|
|
171
|
+
i += 2;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
118
175
|
const c = cps[i]!;
|
|
119
176
|
|
|
120
177
|
if (c === " " || c === "\t" || c === "\n" || c === "\r") { i++; continue; }
|
|
121
178
|
|
|
179
|
+
if (c === "`") { push("tmpl_start", c, i, i + 1); modes.push({ kind: "template", start: i }); i++; continue; }
|
|
122
180
|
if (c === "(") { push("lparen", c, i, i + 1); i++; continue; }
|
|
123
181
|
if (c === ")") { push("rparen", c, i, i + 1); i++; continue; }
|
|
124
|
-
if (c === "{") { push("lbrace", c, i, i + 1); i++; continue; }
|
|
125
|
-
if (c === "}") {
|
|
182
|
+
if (c === "{") { if (mode) mode.depth++; push("lbrace", c, i, i + 1); i++; continue; }
|
|
183
|
+
if (c === "}") {
|
|
184
|
+
// Inside an interpolation a `}` at depth 0 closes it; deeper, it closes a block.
|
|
185
|
+
if (mode && mode.depth === 0) { push("tmpl_interp_end", c, i, i + 1); i++; modes.pop(); continue; }
|
|
186
|
+
if (mode) mode.depth--;
|
|
187
|
+
push("rbrace", c, i, i + 1); i++; continue;
|
|
188
|
+
}
|
|
126
189
|
if (c === "[") { push("lbracket", c, i, i + 1); i++; continue; }
|
|
127
190
|
if (c === "]") { push("rbracket", c, i, i + 1); i++; continue; }
|
|
128
191
|
if (c === ",") { push("comma", c, i, i + 1); i++; continue; }
|
|
@@ -200,6 +263,13 @@ function lexFragment(src: string, base: number, out: Token[]): void {
|
|
|
200
263
|
|
|
201
264
|
throw new OqxError(`unexpected character ${JSON.stringify(c)} at ${base + i}`, "lex");
|
|
202
265
|
}
|
|
266
|
+
|
|
267
|
+
// The end of the source inside a template literal.
|
|
268
|
+
const open = modes[modes.length - 1];
|
|
269
|
+
if (open?.kind === "template") throw new OqxError(`unterminated template literal at ${base + open.start}`, "lex");
|
|
270
|
+
if (open?.kind === "interp") {
|
|
271
|
+
throw new OqxError(`unterminated \${…} interpolation in a template literal at ${base + open.start} — expected '}'`, "lex");
|
|
272
|
+
}
|
|
203
273
|
}
|
|
204
274
|
|
|
205
275
|
// Scan a number whose first digit is at `i`; return the index just past it.
|
package/src/optimize/analysis.ts
CHANGED
|
@@ -35,6 +35,7 @@ export function exprReadsScopeIn(e: Expr, at: number, upTo: number): boolean {
|
|
|
35
35
|
case "binary": case "logical": case "in": return exprReadsScopeIn(e.left, at, upTo) || exprReadsScopeIn(e.right, at, upTo);
|
|
36
36
|
case "range": return (e.lo !== null && exprReadsScopeIn(e.lo, at, upTo)) || (e.hi !== null && exprReadsScopeIn(e.hi, at, upTo));
|
|
37
37
|
case "tuple": return e.items.some((x) => exprReadsScopeIn(x, at, upTo));
|
|
38
|
+
case "template": return e.parts.some((p) => typeof p !== "string" && exprReadsScopeIn(p, at, upTo));
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -101,6 +102,9 @@ export function exprRaiseFree(e: Expr, ctx: RuleContext): boolean {
|
|
|
101
102
|
case "binary": case "logical": case "in": return exprRaiseFree(e.left, ctx) && exprRaiseFree(e.right, ctx);
|
|
102
103
|
case "range": return (e.lo === null || exprRaiseFree(e.lo, ctx)) && (e.hi === null || exprRaiseFree(e.hi, ctx));
|
|
103
104
|
case "tuple": return e.items.every((x) => exprRaiseFree(x, ctx));
|
|
105
|
+
// A template literal raises on a list or record interpolation (§7b), which
|
|
106
|
+
// only the data decides: with an interpolation it is never raise-free.
|
|
107
|
+
case "template": return e.parts.every((p) => typeof p === "string");
|
|
104
108
|
}
|
|
105
109
|
}
|
|
106
110
|
|
package/src/parser.ts
CHANGED
|
@@ -64,6 +64,11 @@
|
|
|
64
64
|
// Tuples (0.20): `(e1, e2, …)` with two or more elements is a `tuple` node — a
|
|
65
65
|
// value and a receiver; one expression in parentheses stays grouping.
|
|
66
66
|
//
|
|
67
|
+
// Template literals (0.21): `` `text ${expr} text` `` is a `template` node — a
|
|
68
|
+
// primary value whose interpolations are full expressions read at the current
|
|
69
|
+
// depth (`parseTemplateLiteral`); the lexer balances the braces, so an
|
|
70
|
+
// interpolation may hold a `{ … }` block or another template literal.
|
|
71
|
+
//
|
|
67
72
|
// Principle of least surprise, applied to the grammar: a rule a careful user
|
|
68
73
|
// would not predict is a bug. Hence `!` binds tighter than comparison in every
|
|
69
74
|
// position (`!a == b` is `(!a) == b`, as in C); parentheses in `where` group a
|
|
@@ -78,7 +83,7 @@ import { lexTemplate, lexString } from "./lexer.ts";
|
|
|
78
83
|
import { OqxError } from "./errors.ts";
|
|
79
84
|
import type {
|
|
80
85
|
Query, Where, Expr, OpNode, Subquery, SelectItem, OrderSpec, Follow, FollowDestination, Consumer, RelOp,
|
|
81
|
-
BinaryOp, Span,
|
|
86
|
+
BinaryOp, Span, TemplatePart,
|
|
82
87
|
} from "./ast.ts";
|
|
83
88
|
import { resolveSelect, resolveWhere } from "./resolve.ts";
|
|
84
89
|
import { printTemplate } from "./print.ts";
|
|
@@ -194,7 +199,10 @@ class Parser {
|
|
|
194
199
|
return t.type === "op" && t.value === value;
|
|
195
200
|
}
|
|
196
201
|
private fail(msg: string): never {
|
|
197
|
-
|
|
202
|
+
this.failAt(this.peek().pos, msg);
|
|
203
|
+
}
|
|
204
|
+
private failAt(pos: number, msg: string): never {
|
|
205
|
+
throw new OqxError(`${msg} (at offset ${pos})`, "parse");
|
|
198
206
|
}
|
|
199
207
|
/** Code-point offset of the next token (the start of a node about to be parsed). */
|
|
200
208
|
private start(): number { return this.peek().pos; }
|
|
@@ -1042,7 +1050,7 @@ class Parser {
|
|
|
1042
1050
|
// `recv first { where p }`; a trailing `!` makes the index required and the
|
|
1043
1051
|
// predicate a `single` (required too, so that zero matches is an error).
|
|
1044
1052
|
private parseBracket(recv: Expr): Expr {
|
|
1045
|
-
if (recv.kind === "lit") this.fail(`\`${describe(recv)}\` is a literal, not a collection`);
|
|
1053
|
+
if (recv.kind === "lit" || recv.kind === "template") this.fail(`\`${describe(recv)}\` is a literal, not a collection`);
|
|
1046
1054
|
const start = recv.span[0];
|
|
1047
1055
|
const lbracket = this.next().pos; // '['
|
|
1048
1056
|
let index: Expr | null = null;
|
|
@@ -1170,7 +1178,7 @@ class Parser {
|
|
|
1170
1178
|
if (!t) return false;
|
|
1171
1179
|
if (t.type === "ident") return !CLAUSE_WORDS.has(t.value);
|
|
1172
1180
|
if (t.type === "kw") return t.value === "is" || t.value === "not"; // the prefix operators
|
|
1173
|
-
if (t.type === "number" || t.type === "string" ||
|
|
1181
|
+
if (t.type === "number" || t.type === "string" || t.type === "tmpl_start" ||
|
|
1174
1182
|
t.type === "binding" || t.type === "lparen" || t.type === "caret") return true;
|
|
1175
1183
|
return t.type === "op" && (t.value === "-" || t.value === "!");
|
|
1176
1184
|
}
|
|
@@ -1242,7 +1250,7 @@ class Parser {
|
|
|
1242
1250
|
// { … }` (0.20: the receiver-block sugar stands wherever a value does,
|
|
1243
1251
|
// `size(children { is checked })`). exists/none/count have no value form
|
|
1244
1252
|
// and are left to the body's own error.
|
|
1245
|
-
if (expr.kind === "lit") this.fail(`\`${describe(expr)}\` is a literal, not a collection`);
|
|
1253
|
+
if (expr.kind === "lit" || expr.kind === "template") this.fail(`\`${describe(expr)}\` is a literal, not a collection`);
|
|
1246
1254
|
expr = this.parseConsumerBlock(expr, this.atBlock()!, false);
|
|
1247
1255
|
} else {
|
|
1248
1256
|
break; // anything else ends the chain
|
|
@@ -1282,6 +1290,7 @@ class Parser {
|
|
|
1282
1290
|
}
|
|
1283
1291
|
if (t.type === "number") { this.next(); return { kind: "lit", span: [t.pos, t.end], value: Number(t.value) }; }
|
|
1284
1292
|
if (t.type === "string") { this.next(); return { kind: "lit", span: [t.pos, t.end], value: t.value }; }
|
|
1293
|
+
if (t.type === "tmpl_start") return this.parseTemplateLiteral();
|
|
1285
1294
|
if (t.type === "binding") { this.next(); return this.bindingNode(t); }
|
|
1286
1295
|
if (t.type === "lparen") {
|
|
1287
1296
|
this.next();
|
|
@@ -1308,6 +1317,39 @@ class Parser {
|
|
|
1308
1317
|
}
|
|
1309
1318
|
this.fail(`unexpected ${this.tokDesc()} — expected a value`);
|
|
1310
1319
|
}
|
|
1320
|
+
|
|
1321
|
+
// A template literal (GRAMMAR §4, since 0.21): the cursor is at the opening
|
|
1322
|
+
// backtick. The lexer has already split the literal into text runs and `${ … }`
|
|
1323
|
+
// interpolations (with balanced braces, so an interpolation may hold a block
|
|
1324
|
+
// or another template literal); each interpolation holds one full value
|
|
1325
|
+
// expression, read at the current scope depth. `parts` alternates strictly —
|
|
1326
|
+
// text, expression, text, … — with a (possibly empty) text part first and last.
|
|
1327
|
+
private parseTemplateLiteral(): Expr {
|
|
1328
|
+
const open = this.next(); // the backtick
|
|
1329
|
+
const parts: TemplatePart[] = [];
|
|
1330
|
+
let text = "";
|
|
1331
|
+
for (;;) {
|
|
1332
|
+
const t = this.peek();
|
|
1333
|
+
if (t.type === "tmpl_text") { this.next(); text += t.value; continue; }
|
|
1334
|
+
if (t.type === "tmpl_end") { this.next(); break; }
|
|
1335
|
+
if (t.type === "tmpl_interp_start") {
|
|
1336
|
+
this.next();
|
|
1337
|
+
if (this.at("tmpl_interp_end")) {
|
|
1338
|
+
this.failAt(t.pos, "empty interpolation `${}` in a template literal — an interpolation holds an expression (`${name}`)");
|
|
1339
|
+
}
|
|
1340
|
+
const e = this.parseValueExpr();
|
|
1341
|
+
if (!this.at("tmpl_interp_end")) this.fail("expected '}' to close the `${…}` interpolation of a template literal");
|
|
1342
|
+
this.next();
|
|
1343
|
+
parts.push(text, e);
|
|
1344
|
+
text = "";
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
// Unreachable: the lexer closes every literal it opens or fails.
|
|
1348
|
+
this.fail(`unexpected ${this.tokDesc()} in a template literal`);
|
|
1349
|
+
}
|
|
1350
|
+
parts.push(text);
|
|
1351
|
+
return { kind: "template", span: [open.pos, this.end()], parts };
|
|
1352
|
+
}
|
|
1311
1353
|
}
|
|
1312
1354
|
|
|
1313
1355
|
// The default key of an unaliased projection item: the last segment of a bare /
|
package/src/print.ts
CHANGED
|
@@ -66,7 +66,7 @@ const OR = 1, AND = 2, CMP = 3, RANGE = 4, ADD = 5, MUL = 6, UNARY = 7, POSTFIX
|
|
|
66
66
|
|
|
67
67
|
function prec(e: Expr): number {
|
|
68
68
|
switch (e.kind) {
|
|
69
|
-
case "lit": case "ident": case "outer": case "binding": case "tuple": return PRIMARY;
|
|
69
|
+
case "lit": case "ident": case "outer": case "binding": case "tuple": case "template": return PRIMARY;
|
|
70
70
|
case "member": case "call": case "required": case "op": return POSTFIX;
|
|
71
71
|
case "unary": return UNARY;
|
|
72
72
|
case "binary": return e.op === "+" || e.op === "-" ? ADD : e.op === "*" || e.op === "/" || e.op === "%" ? MUL : CMP;
|
|
@@ -231,6 +231,10 @@ function expr(e: Expr): Out {
|
|
|
231
231
|
return cat(e.lo ? operand(e.lo, ADD) : "", e.exclusiveEnd ? "..." : "..", e.hi ? operand(e.hi, ADD) : "");
|
|
232
232
|
// A tuple is self-delimiting; its elements are full expressions.
|
|
233
233
|
case "tuple": return cat("(", join(e.items.map(expr), ", "), ")");
|
|
234
|
+
// A template literal prints as one: its text with the GRAMMAR §1 escapes
|
|
235
|
+
// (`` \` ``, `\\`, `\$` before a `{`, and the control characters), each
|
|
236
|
+
// interpolation as `${…}` around the full expression.
|
|
237
|
+
case "template": return cat("`", ...e.parts.map((p) => (typeof p === "string" ? templateText(p) : cat("${", expr(p), "}"))), "`");
|
|
234
238
|
}
|
|
235
239
|
}
|
|
236
240
|
|
|
@@ -264,13 +268,35 @@ function quote(s: string): string {
|
|
|
264
268
|
for (const ch of s) {
|
|
265
269
|
switch (ch) {
|
|
266
270
|
case '"': out += '\\"'; break;
|
|
267
|
-
|
|
268
|
-
case "\n": out += "\\n"; break;
|
|
269
|
-
case "\t": out += "\\t"; break;
|
|
270
|
-
case "\r": out += "\\r"; break;
|
|
271
|
-
case "\0": out += "\\0"; break;
|
|
272
|
-
default: out += ch;
|
|
271
|
+
default: out += escapeChar(ch);
|
|
273
272
|
}
|
|
274
273
|
}
|
|
275
274
|
return out + '"';
|
|
276
275
|
}
|
|
276
|
+
|
|
277
|
+
/** The text of a template literal part, escaped so it re-lexes to itself: the
|
|
278
|
+
* backtick and the backslash, the control characters as in a string, and a `$`
|
|
279
|
+
* that a `{` follows (which would otherwise open an interpolation). */
|
|
280
|
+
function templateText(s: string): string {
|
|
281
|
+
const cps = Array.from(s);
|
|
282
|
+
let out = "";
|
|
283
|
+
for (let i = 0; i < cps.length; i++) {
|
|
284
|
+
const ch = cps[i]!;
|
|
285
|
+
if (ch === "`") out += "\\`";
|
|
286
|
+
else if (ch === "$" && cps[i + 1] === "{") out += "\\$";
|
|
287
|
+
else out += escapeChar(ch);
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The escapes a string literal and a template literal share (GRAMMAR §1). */
|
|
293
|
+
function escapeChar(ch: string): string {
|
|
294
|
+
switch (ch) {
|
|
295
|
+
case "\\": return "\\\\";
|
|
296
|
+
case "\n": return "\\n";
|
|
297
|
+
case "\t": return "\\t";
|
|
298
|
+
case "\r": return "\\r";
|
|
299
|
+
case "\0": return "\\0";
|
|
300
|
+
default: return ch;
|
|
301
|
+
}
|
|
302
|
+
}
|
package/src/resolve.ts
CHANGED
|
@@ -141,6 +141,10 @@ function mapExpr(e: Expr, f: (e: Expr) => Expr | null): Expr {
|
|
|
141
141
|
const items = mapSame(e.items, go);
|
|
142
142
|
return items === e.items ? e : { ...e, items };
|
|
143
143
|
}
|
|
144
|
+
case "template": { // the text parts are strings; only the interpolations are expressions
|
|
145
|
+
const parts = mapSame(e.parts, (p) => (typeof p === "string" ? p : go(p)));
|
|
146
|
+
return parts === e.parts ? e : { ...e, parts };
|
|
147
|
+
}
|
|
144
148
|
case "op": { // the receiver is read in this scope; the block is its own scope
|
|
145
149
|
const receiver = go(e.receiver);
|
|
146
150
|
return receiver === e.receiver ? e : { ...e, receiver };
|
package/src/version.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** The OQX language version (`spec/oqx/VERSION`, `major.minor`) this implementation
|
|
2
2
|
* conforms to — distinct from the package's own semver, whose `major.minor` it
|
|
3
3
|
* equals. Stamped on every `toJSON` document. */
|
|
4
|
-
export const LANGUAGE_VERSION = "0.
|
|
4
|
+
export const LANGUAGE_VERSION = "0.21";
|
package/src/walk.ts
CHANGED
|
@@ -97,9 +97,13 @@ export const CHILDREN: { readonly [K in AstKind]: readonly ChildKey[] } = {
|
|
|
97
97
|
in: [{ key: "left" }, { key: "right" }],
|
|
98
98
|
range: [{ key: "lo" }, { key: "hi" }],
|
|
99
99
|
tuple: [{ key: "items" }],
|
|
100
|
+
// The interpolations; the text parts between them are strings, not nodes.
|
|
101
|
+
template: [{ key: "parts" }],
|
|
100
102
|
};
|
|
101
103
|
|
|
102
|
-
|
|
104
|
+
/** A child slot's value: a node, a list of nodes (a template's `parts` also
|
|
105
|
+
* holds its text strings, which the walks skip), or `null`. */
|
|
106
|
+
type Slot = AstNode | (AstNode | string)[] | null;
|
|
103
107
|
|
|
104
108
|
function slot(node: AstNode, key: string): Slot {
|
|
105
109
|
return (node as unknown as Record<string, Slot>)[key] ?? null;
|
|
@@ -134,7 +138,7 @@ export function visit(root: AstNode, visitor: Visitor): void {
|
|
|
134
138
|
if (v === null) continue;
|
|
135
139
|
const c = child.clause ?? clause;
|
|
136
140
|
const d = depth + childDepth(node, child, path);
|
|
137
|
-
if (Array.isArray(v)) for (const x of v) go(x, c, d);
|
|
141
|
+
if (Array.isArray(v)) { for (const x of v) if (typeof x !== "string") go(x, c, d); }
|
|
138
142
|
else go(v, c, d);
|
|
139
143
|
}
|
|
140
144
|
path.pop();
|
|
@@ -162,7 +166,7 @@ export function transform<T extends Query | Subquery>(root: T, f: (expr: Expr, c
|
|
|
162
166
|
let next: Slot;
|
|
163
167
|
if (Array.isArray(v)) {
|
|
164
168
|
let changed = false;
|
|
165
|
-
const mapped = v.map((x) => { const y = go(x, c, d); if (y !== x) changed = true; return y; });
|
|
169
|
+
const mapped = v.map((x) => { if (typeof x === "string") return x; const y = go(x, c, d); if (y !== x) changed = true; return y; });
|
|
166
170
|
next = changed ? mapped : v;
|
|
167
171
|
} else next = go(v, c, d);
|
|
168
172
|
if (next !== v) out = { ...out, [child.key]: next } as AstNode;
|