@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/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); a `binding` carries the display
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
- lexFragment(fragments[f]!, base, tokens);
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 === "}") { push("rbrace", c, i, i + 1); i++; continue; }
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.
@@ -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
- throw new OqxError(`${msg} (at offset ${this.peek().pos})`, "parse");
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
- case "\\": out += "\\\\"; break;
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.20";
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
- type Slot = AstNode | AstNode[] | null;
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;