@omgbase/oqx 0.18.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +177 -64
  3. package/dist/ast.d.ts +28 -5
  4. package/dist/ast.d.ts.map +1 -1
  5. package/dist/ast.js +1 -1
  6. package/dist/ast.js.map +1 -1
  7. package/dist/build.d.ts +11 -2
  8. package/dist/build.d.ts.map +1 -1
  9. package/dist/build.js +10 -3
  10. package/dist/build.js.map +1 -1
  11. package/dist/context.d.ts +19 -5
  12. package/dist/context.d.ts.map +1 -1
  13. package/dist/context.js +6 -9
  14. package/dist/context.js.map +1 -1
  15. package/dist/engine.d.ts +12 -1
  16. package/dist/engine.d.ts.map +1 -1
  17. package/dist/engine.js +263 -84
  18. package/dist/engine.js.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/lexer.d.ts +3 -3
  23. package/dist/lexer.d.ts.map +1 -1
  24. package/dist/lexer.js +79 -2
  25. package/dist/lexer.js.map +1 -1
  26. package/dist/optimize/analysis.d.ts +3 -2
  27. package/dist/optimize/analysis.d.ts.map +1 -1
  28. package/dist/optimize/analysis.js +18 -15
  29. package/dist/optimize/analysis.js.map +1 -1
  30. package/dist/parser.d.ts.map +1 -1
  31. package/dist/parser.js +250 -118
  32. package/dist/parser.js.map +1 -1
  33. package/dist/print.d.ts.map +1 -1
  34. package/dist/print.js +47 -29
  35. package/dist/print.js.map +1 -1
  36. package/dist/resolve.d.ts.map +1 -1
  37. package/dist/resolve.js +15 -8
  38. package/dist/resolve.js.map +1 -1
  39. package/dist/version.d.ts +1 -1
  40. package/dist/version.js +1 -1
  41. package/dist/walk.d.ts +5 -3
  42. package/dist/walk.d.ts.map +1 -1
  43. package/dist/walk.js +34 -11
  44. package/dist/walk.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/ast.ts +33 -6
  47. package/src/build.ts +12 -8
  48. package/src/context.ts +22 -11
  49. package/src/engine.ts +258 -91
  50. package/src/index.ts +2 -1
  51. package/src/lexer.ts +76 -6
  52. package/src/optimize/analysis.ts +17 -12
  53. package/src/parser.ts +227 -101
  54. package/src/print.ts +44 -16
  55. package/src/resolve.ts +15 -7
  56. package/src/version.ts +1 -1
  57. package/src/walk.ts +39 -14
package/src/parser.ts CHANGED
@@ -17,9 +17,12 @@
17
17
  //
18
18
  // A `follow` destination is a relation of the current row (a receiver, never
19
19
  // an outer reference) or a destination block — a receiver (which MAY be an
20
- // outer reference, `^people`) immediately followed by `collect`/`first`/
21
- // `single` [`distinct`] `{ body }`, exactly the select-position directive
22
- // `tryOp(false)` recognizes, re-evaluated per frontier row (language 0.14).
20
+ // outer reference, `^people`, or a tuple `(a, b)`) followed by `{ body }`, or
21
+ // by `collect`/`first`/`single` [`distinct`] `{ body }`, exactly the
22
+ // select-position directive `tryOp` recognizes, re-evaluated per frontier row.
23
+ // Since 0.20 there is no options block: a brace after ANY destination opens
24
+ // that destination's block, whose body admits two clauses no other block has —
25
+ // `frontier P` and `depth n`, in the clause order after `where`.
23
26
  //
24
27
  // Every clause is optional except that a top-level body needs `from` (the
25
28
  // receiver-plus-consumer form `<receiver> <consumer> { block }` supplies the
@@ -33,8 +36,9 @@
33
36
  // out-of-order clause is a parse error naming the order.
34
37
  //
35
38
  // Sugar (0.17) desugars HERE, to existing nodes: `x { … }` is `x collect { … }`
36
- // (not in `follow` destination position, where a brace is the options block);
37
- // `x[p]` is `x first { where p }`, `x[n]` is `x first { offset n }`, with a
39
+ // wherever a value stands (a select item, a where leaf, a follow destination, a
40
+ // call argument — since 0.20 without exception) and `x distinct { … }` is
41
+ // `x collect distinct { … }` (0.20); `x[p]` is `x first { where p }`, `x[n]` is `x first { offset n }`, with a
38
42
  // trailing `!` making the first a `single` (predicate) or a `required` (index);
39
43
  // `is x` is `!!x`, `not x` is `!x`. Two shapes are new: the postfix `required`
40
44
  // (`x!`, tightest) and the identity operators `is` / `is not` (comparison
@@ -57,6 +61,14 @@
57
61
  // source, from the first token that produced it to the end of the last; a
58
62
  // parenthesized operand's span includes its parentheses (spec/oqx/AST.md).
59
63
  //
64
+ // Tuples (0.20): `(e1, e2, …)` with two or more elements is a `tuple` node — a
65
+ // value and a receiver; one expression in parentheses stays grouping.
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
+ //
60
72
  // Principle of least surprise, applied to the grammar: a rule a careful user
61
73
  // would not predict is a bug. Hence `!` binds tighter than comparison in every
62
74
  // position (`!a == b` is `(!a) == b`, as in C); parentheses in `where` group a
@@ -71,15 +83,24 @@ import { lexTemplate, lexString } from "./lexer.ts";
71
83
  import { OqxError } from "./errors.ts";
72
84
  import type {
73
85
  Query, Where, Expr, OpNode, Subquery, SelectItem, OrderSpec, Follow, FollowDestination, Consumer, RelOp,
74
- BinaryOp, Span,
86
+ BinaryOp, Span, TemplatePart,
75
87
  } from "./ast.ts";
76
88
  import { resolveSelect, resolveWhere } from "./resolve.ts";
77
89
  import { printTemplate } from "./print.ts";
78
90
 
79
91
  const CONSUMERS = new Set<string>(["collect", "exists", "none", "count", "first", "single"]);
80
92
  // The fixed clause order of a body (ADR-020). Each clause appears at most once.
81
- const CLAUSE_ORDER = ["select", "from", "where", "follow", "order by", "limit", "offset"] as const;
93
+ // `frontier` is a clause of every body that has a `follow` (a destination
94
+ // block's marks the occurrences it reaches, any other body's its seeds — 0.20);
95
+ // `depth` is legal only in a follow destination block, and the order sentence
96
+ // names it only there.
97
+ const CLAUSE_ORDER = ["select", "from", "where", "frontier", "depth", "follow", "order by", "limit", "offset"] as const;
82
98
  type Clause = (typeof CLAUSE_ORDER)[number];
99
+ const DESTINATION_ONLY: ReadonlySet<Clause> = new Set<Clause>(["depth"]);
100
+ // The clauses a body may hold, in order.
101
+ function clausesOf(ctx: BodyCtx): Clause[] {
102
+ return CLAUSE_ORDER.filter((c) => ctx.destination || !DESTINATION_ONLY.has(c));
103
+ }
83
104
  // Contextual clause words lexed as bare idents; an open-ended range must stop
84
105
  // before them rather than consume them as its high bound.
85
106
  const CLAUSE_WORDS = new Set<string>([
@@ -103,8 +124,12 @@ interface BodyCtx {
103
124
  op: Consumer | null;
104
125
  /** Whether `^name: expr` lift items are legal: only in a where-position `collect { … }`. */
105
126
  liftsAllowed: boolean;
127
+ /** A `follow` destination block (0.20): the only body that admits `depth`, and
128
+ * the one whose `frontier` marks the occurrences it reaches (read one level out)
129
+ * rather than the seeds of its own `follow`. */
130
+ destination: boolean;
106
131
  }
107
- const TOP_CTX: BodyCtx = { top: true, op: null, liftsAllowed: false };
132
+ const TOP_CTX: BodyCtx = { top: true, op: null, liftsAllowed: false, destination: false };
108
133
 
109
134
  /** Parse a tagged-template call into a Query. */
110
135
  export function parseTemplate(fragments: readonly string[], values: number): Query {
@@ -122,6 +147,8 @@ interface BodyClauses {
122
147
  select: SelectItem[];
123
148
  orderBy: OrderSpec[] | null;
124
149
  follow: Follow | null;
150
+ frontier: Expr | null;
151
+ depth: number | null;
125
152
  distinct: boolean;
126
153
  values: boolean;
127
154
  limit: Expr | null;
@@ -172,7 +199,10 @@ class Parser {
172
199
  return t.type === "op" && t.value === value;
173
200
  }
174
201
  private fail(msg: string): never {
175
- 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");
176
206
  }
177
207
  /** Code-point offset of the next token (the start of a node about to be parsed). */
178
208
  private start(): number { return this.peek().pos; }
@@ -231,6 +261,7 @@ class Parser {
231
261
  orderBy: body.orderBy,
232
262
  consumer: "collect",
233
263
  follow: body.follow,
264
+ frontier: body.frontier,
234
265
  distinct: body.distinct,
235
266
  values: body.values,
236
267
  limit: body.limit,
@@ -243,7 +274,7 @@ class Parser {
243
274
  private queryFromOp(e: OpNode, start: number): Query {
244
275
  return {
245
276
  kind: "query", span: this.sp(start), source: e.receiver, from: e.sub.from, where: e.sub.where, select: e.sub.select,
246
- orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, distinct: e.distinct, values: e.sub.values,
277
+ orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, distinct: e.distinct, values: e.sub.values,
247
278
  limit: e.sub.limit, offset: e.sub.offset,
248
279
  };
249
280
  }
@@ -264,6 +295,8 @@ class Parser {
264
295
  let select: SelectItem[] = [];
265
296
  let orderBy: OrderSpec[] | null = null;
266
297
  let follow: Follow | null = null;
298
+ let frontier: Expr | null = null;
299
+ let depth: number | null = null;
267
300
  let distinct = false;
268
301
  let values = false;
269
302
  let limit: Expr | null = null;
@@ -278,7 +311,7 @@ class Parser {
278
311
  const idx = CLAUSE_ORDER.indexOf(clause);
279
312
  if (idx === stage) this.fail(`duplicate \`${clause}\` clause`);
280
313
  if (idx < stage) {
281
- this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${CLAUSE_ORDER.join(", ")}`);
314
+ this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${clausesOf(ctx).join(", ")}`);
282
315
  }
283
316
  stage = idx;
284
317
  };
@@ -303,6 +336,27 @@ class Parser {
303
336
  where = this.atDepth(rowDepth, () => this.parseWhere());
304
337
  continue;
305
338
  }
339
+ // `frontier P` (0.20) — in a follow destination block the destination's
340
+ // own clause, read in the occurrence's scope whose parent is the walk's
341
+ // enclosing scope: one level OUT of the block's rows (SEMANTICS §20), so
342
+ // `^` there is the enclosing scope, not the frontier row. In any other
343
+ // body it marks the seeds of the body's own `follow` (checked below) and
344
+ // is read in the seed's scope, like `where`.
345
+ if (this.at("ident", "frontier") && this.startsValue(this.peekAt(1))) {
346
+ enter("frontier");
347
+ this.next();
348
+ frontier = this.atDepth(ctx.destination ? rowDepth - 1 : rowDepth, () => this.parseValueExpr());
349
+ continue;
350
+ }
351
+ // `depth n` — a follow destination block's own clause (0.20).
352
+ if (ctx.destination && this.at("ident", "depth") && this.peekAt(1)?.type === "number") {
353
+ enter("depth");
354
+ this.next();
355
+ const v = Number(this.next().value);
356
+ if (!Number.isInteger(v) || v < 1 || v > 8) this.fail("follow depth must be an integer between 1 and 8");
357
+ depth = v;
358
+ continue;
359
+ }
306
360
  if (this.atFollow()) {
307
361
  enter("follow");
308
362
  follow = this.atDepth(rowDepth, () => this.parseFollow());
@@ -349,7 +403,12 @@ class Parser {
349
403
  // surface form is kept. `where` first: its cycle report follows its own path.
350
404
  if (where) resolveWhere(select, where, (msg) => this.fail(msg));
351
405
  resolveSelect(select, (msg) => this.fail(msg));
352
- return { froms, where, select, orderBy, follow, distinct, values, limit, offset };
406
+ // Outside a destination block `frontier` marks the seeds of this body's walk,
407
+ // so it needs one.
408
+ if (frontier && !follow && !ctx.destination) {
409
+ this.fail("`frontier` needs a `follow` — a body's `frontier <expr>` marks which of its rows (the walk's seeds) are frontiers; a destination block's marks the occurrences reached through it");
410
+ }
411
+ return { froms, where, select, orderBy, follow, frontier, depth, distinct, values, limit, offset };
353
412
  }
354
413
 
355
414
  // The keyword-less run that opens a body. At the top level it is always the
@@ -403,8 +462,8 @@ class Parser {
403
462
  // consumer word where a whole-query directive was meant), or a stray token.
404
463
  private failUnexpectedInBody(stage: number, ctx: BodyCtx): never {
405
464
  const t = this.peek();
406
- const remaining = CLAUSE_ORDER.slice(stage + 1).join("/");
407
465
  const last = stage >= 0 ? CLAUSE_ORDER[stage] : null;
466
+ const remaining = clausesOf(ctx).filter((c) => CLAUSE_ORDER.indexOf(c) > stage).join("/");
408
467
  const isWord = t.type === "ident" || t.type === "kw" || t.type === "binding" || t.type === "caret";
409
468
  // Punctuation, an operator, or a literal after a complete clause is a stray
410
469
  // token, not a misplaced predicate: name where the body ends. (A comma keeps
@@ -421,6 +480,11 @@ class Parser {
421
480
  if (t.value === "limit" || t.value === "offset") {
422
481
  this.fail(`unexpected '${t.value}' after \`${last}\` — a bound is a non-negative number literal, a binding, or (inside a block) an outer reference \`^name\``);
423
482
  }
483
+ if (t.value === "depth" && !ctx.destination) {
484
+ this.fail(`unexpected 'depth' after \`${last}\` — \`depth\` is a clause of a follow destination block only (\`follow <relation> { depth 2 }\`)`);
485
+ }
486
+ if (t.value === "depth") this.fail("expected an integer after `depth` (`depth 1`..`depth 8`)");
487
+ if (t.value === "frontier") this.fail(`unexpected 'frontier' after \`${last}\` — \`frontier\` takes an expression (\`frontier <expr>\`) and belongs to a body that has a \`follow\``);
424
488
  }
425
489
  if (last === "from" && t.type === "ident" && CONSUMERS.has(t.value)) {
426
490
  this.fail(`unexpected \`${t.value}\` after \`from\` — a whole-query consumer is written \`<collection> ${t.value} { … }\`; to project a field named ${t.value} write \`select ${t.value} from …\`; a predicate needs \`where\``);
@@ -493,12 +557,13 @@ class Parser {
493
557
  private atFollow(): boolean {
494
558
  if (!this.at("ident", "follow")) return false;
495
559
  const nx = this.peekAt(1);
496
- return !!nx && (nx.type === "ident" || nx.type === "binding" || this.atScopeRefAt(1));
560
+ return !!nx && (nx.type === "ident" || nx.type === "binding" || nx.type === "lparen" || this.atScopeRefAt(1));
497
561
  }
498
562
 
499
- // Whether the cursor is at something that can begin a `follow` destination.
563
+ // Whether the cursor is at something that can begin a `follow` destination:
564
+ // a name, a binding, a scope reference, or a tuple's `(`.
500
565
  private atDestinationStart(): boolean {
501
- return this.at("ident") || this.at("binding") || this.atScopeRefAt(0);
566
+ return this.at("ident") || this.at("binding") || this.at("lparen") || this.atScopeRefAt(0);
502
567
  }
503
568
 
504
569
  // ---- follow ---------------------------------------------------------------
@@ -523,75 +588,31 @@ class Parser {
523
588
  }
524
589
  destinations.push(this.parseFollowDestination());
525
590
  }
526
- const follow: Follow = { kind: "follow", span: this.sp(start), destinations, distinct, where: null, frontier: null, depth: null, by: null };
527
- if (!this.at("lbrace")) return follow;
528
- this.next(); // '{'
529
- while (!this.at("eof") && !this.at("rbrace")) {
530
- if (this.at("kw", "where")) {
531
- if (follow.where) this.fail("duplicate `where` in follow clause");
532
- this.next();
533
- // Read in the successor's scope, one deeper than the frontier row.
534
- follow.where = this.atDepth(this.depth + 1, () => this.parseValueExpr());
535
- } else if (this.at("ident", "frontier")) {
536
- if (follow.frontier) this.fail("duplicate `frontier` in follow clause");
537
- this.next();
538
- follow.frontier = this.parseValueExpr();
539
- } else if (this.at("ident", "by")) {
540
- if (follow.by !== null) this.fail("duplicate `by` in follow clause");
541
- this.next();
542
- follow.by = this.parseValueExpr();
543
- } else if (this.at("ident", "depth")) {
544
- if (follow.depth !== null) this.fail("duplicate `depth` in follow clause");
545
- this.next();
546
- if (!this.at("number")) this.fail("expected an integer after `depth`");
547
- const v = Number(this.next().value);
548
- if (!Number.isInteger(v) || v < 1 || v > 8) this.fail("follow depth must be an integer between 1 and 8");
549
- follow.depth = v;
550
- } else {
551
- this.fail(`unexpected ${this.tokDesc()} in follow block — expected where/frontier/depth/by (a brace after the last destination is the options block; a destination block needs its consumer: \`follow <relation> collect { … }\`)`);
552
- }
553
- }
554
- if (!this.at("rbrace")) this.fail("expected '}' to close the follow block");
555
- this.next();
556
- follow.span = this.sp(start);
557
- return follow;
591
+ return { kind: "follow", span: this.sp(start), destinations, distinct };
558
592
  }
559
593
 
560
- // One `follow` destination. The lookahead mirrors `tryOp`: a receiver (binding,
561
- // or carets + a dotted name) followed by a consumer word and `{` / `distinct {`
562
- // is a destination block, parsed by `tryOp(false)` itself — but only the
563
- // select-position consumers (collect/first/single) yield rows to walk; the
564
- // where-position tests are rejected by name before their body is read. With
565
- // no directive the tokens are re-read as a plain relation of the current row,
566
- // where an outer reference is still an error (a `^name` head only makes sense
567
- // as the receiver of a block, since the block is what varies per row).
594
+ // One `follow` destination: a receiver (a binding, a dotted name — headed by a
595
+ // bare name, an outer reference or a free-function call — or a tuple) that is
596
+ // either a plain relation of the current row or, when a block follows, a
597
+ // destination block. The block is what `tryOp` recognizes in select position
598
+ // — `{ … }` (the implicit `collect`), `distinct { … }`, or a consumer word and
599
+ // `{` / `distinct {` — read with the destination flag so its body admits
600
+ // `frontier` and `depth`. Only collect/first/single yield rows to walk; the
601
+ // where-position tests are rejected by name. A receiver headed by an outer
602
+ // reference can never be a plain destination (plain destinations are relations
603
+ // of the current row), so without a block it is an error naming the block
604
+ // spellings.
568
605
  private parseFollowDestination(): FollowDestination {
569
- const start = this.pos;
570
- let isReceiver = false;
571
- if (this.at("binding")) { this.next(); isReceiver = true; }
572
- else if (this.at("ident") || this.atScopeRefAt(0)) {
573
- const startPos = this.start();
574
- this.parseScopeRef();
575
- if (this.at("ident")) { this.parseNavFrom(this.next(), 0, startPos); isReceiver = true; }
576
- }
577
- if (isReceiver && this.at("ident") && CONSUMERS.has(this.peek().value)) {
578
- const op = this.peek().value;
579
- const after = this.peekAt(1);
580
- const opThenBrace = after?.type === "lbrace";
581
- const opDistinctBrace = after?.type === "ident" && after.value === "distinct" && this.peekAt(2)?.type === "lbrace";
582
- if (opThenBrace || opDistinctBrace) {
583
- if (op !== "collect" && op !== "first" && op !== "single") {
584
- this.fail(`a follow destination must use collect/first/single, not \`${op}\` (exists/none/count are where-position tests)`);
585
- }
586
- this.pos = start;
587
- const block = this.tryOp(false);
588
- if (!block) this.fail("expected a destination block (`<receiver> collect { … }`) after `follow`");
589
- return block;
606
+ const outer = this.atScopeRefAt(0);
607
+ const op = this.tryOp(false, true);
608
+ if (op) {
609
+ if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
610
+ this.fail(`a follow destination must use collect/first/single, not \`${op.op}\` (exists/none/count are where-position tests)`);
590
611
  }
612
+ return op;
591
613
  }
592
- this.pos = start;
593
- if (this.atScopeRefAt(0)) {
594
- this.fail("`follow` takes a relation of the current row (`follow <relation>`); an outer reference `^name` is not allowed there — it may only head a destination block (`follow ^name collect { … }`)");
614
+ if (outer) {
615
+ this.fail("`follow` takes a relation of the current row (`follow <relation>`); an outer reference walks a root collection only as a destination block — `follow ^docs { where … }` or `follow ^docs collect { … }`");
595
616
  }
596
617
  return this.parseReceiver();
597
618
  }
@@ -603,6 +624,11 @@ class Parser {
603
624
  // call (`entries(prefs)`), so a computed collection can be consumed directly.
604
625
  private parseReceiver(): Expr {
605
626
  if (this.at("binding")) return this.bindingNode(this.next());
627
+ if (this.at("lparen")) {
628
+ const t = this.tryTuple();
629
+ if (t) return t;
630
+ this.fail("expected a collection navigation (a property/relation name) or a tuple `(a, b)`");
631
+ }
606
632
  const start = this.start();
607
633
  const levels = this.parseScopeRef();
608
634
  if (!this.at("ident")) this.fail("expected a collection navigation (a property/relation name)");
@@ -787,6 +813,16 @@ class Parser {
787
813
  else break;
788
814
  }
789
815
  if (this.at("lparen")) {
816
+ // A tuple (0.20) is a value, not a group: the receiver of a consumer test
817
+ // (`(a, b) exists { }`) or a scalar operand (`(a, b).size() > 1`).
818
+ const tuple = this.tryTuple();
819
+ if (tuple) {
820
+ if (this.atBlock()) return wrapNot(this.finishWhereOp(this.parseConsumerBlock(tuple, this.atBlock()!, true)), notStarts);
821
+ let e = this.parsePostfix(tuple);
822
+ for (let i = notStarts.length - 1; i >= 0; i--) e = { kind: "unary", span: [notStarts[i]!, e.span[1]], op: "!", expr: e };
823
+ const expr = this.parseCmp(e);
824
+ return { kind: "scalar", span: expr.span, expr };
825
+ }
790
826
  const lparen = this.next().pos;
791
827
  const inner = this.parseWhere();
792
828
  if (!this.at("rparen")) this.fail("expected ')' to close a grouped where expression");
@@ -886,9 +922,11 @@ class Parser {
886
922
  }
887
923
 
888
924
  // Detect + parse a postfix consumer op `<receiver> <consumer> { <sub> }` — or
889
- // its implicit-`collect` form `<receiver> { <sub> }`. Returns null (rewinding)
890
- // when the lookahead is not a directive.
891
- private tryOp(inWhere: boolean): OpNode | null {
925
+ // its implicit-`collect` forms `<receiver> { <sub> }` and `<receiver> distinct
926
+ // { <sub> }` (0.20). Returns null (rewinding) when the lookahead is not a
927
+ // directive. `destination` marks a follow destination block (its body admits
928
+ // `frontier`/`depth`).
929
+ private tryOp(inWhere: boolean, destination = false): OpNode | null {
892
930
  const start = this.pos;
893
931
  const startPos = this.start();
894
932
  let receiver: Expr;
@@ -899,7 +937,13 @@ class Parser {
899
937
  // until the lookahead has decided.
900
938
  let scopeErr: OqxError | null = null;
901
939
  if (this.at("binding")) receiver = this.bindingNode(this.next());
902
- else if (this.at("ident") || this.atScopeRefAt(0)) {
940
+ else if (this.at("lparen")) {
941
+ // A tuple receiver, `(a, b) { … }`; a single parenthesized expression is
942
+ // grouping and never a receiver.
943
+ const t = this.tryTuple();
944
+ if (!t) { this.pos = start; return null; }
945
+ receiver = t;
946
+ } else if (this.at("ident") || this.atScopeRefAt(0)) {
903
947
  let levels = 0;
904
948
  try {
905
949
  levels = this.parseScopeRef();
@@ -913,20 +957,29 @@ class Parser {
913
957
  receiver = this.parseNavFrom(this.next(), levels, startPos).expr;
914
958
  } else return null;
915
959
 
916
- const op = this.at("lbrace") ? "collect" : this.atConsumerBlock();
960
+ const op = this.atBlock();
917
961
  if (op) {
918
962
  if (scopeErr) throw scopeErr;
919
963
  if (receiver.kind === "ident" && LITERAL_WORDS.has(receiver.name)) this.fail(`\`${receiver.name}\` is a literal, not a collection`);
920
964
  // `count { }` with nothing before it: the consumer word is not a receiver.
921
- if (op === "collect" && receiver.kind === "ident" && CONSUMERS.has(receiver.name)) {
965
+ if (op === "collect" && receiver.kind === "ident" && CONSUMERS.has(receiver.name) && this.at("lbrace")) {
922
966
  this.fail(`unexpected '{' after \`${receiver.name}\` — a consumer needs a receiver: \`<collection> ${receiver.name} { … }\``);
923
967
  }
924
- return this.parseConsumerBlock(receiver, op, inWhere);
968
+ return this.parseConsumerBlock(receiver, op, inWhere, destination);
925
969
  }
926
970
  this.pos = start;
927
971
  return null;
928
972
  }
929
973
 
974
+ // The cursor is at what opens a block over an already-read receiver: `{` or
975
+ // `distinct {` (the implicit `collect`), or a consumer word and `{` /
976
+ // `distinct {`. Consumes nothing.
977
+ private atBlock(): Consumer | null {
978
+ if (this.at("lbrace")) return "collect";
979
+ if (this.at("ident", "distinct") && this.peekAt(1)?.type === "lbrace") return "collect";
980
+ return this.atConsumerBlock();
981
+ }
982
+
930
983
  // The cursor is at a consumer word that opens a block — `<op> {` or
931
984
  // `<op> distinct {`. Consumes nothing.
932
985
  private atConsumerBlock(): Consumer | null {
@@ -938,17 +991,19 @@ class Parser {
938
991
  }
939
992
 
940
993
  // `[<op>] [distinct] { <sub> }` over an already-parsed receiver; the cursor is
941
- // at the consumer word, or at the `{` of the implicit `collect`.
942
- private parseConsumerBlock(receiver: Expr, op: Consumer, inWhere: boolean): OpNode {
994
+ // at the consumer word, at `distinct`, or at the `{` of the implicit `collect`.
995
+ // `destination` marks a follow destination block (0.20): its body may carry
996
+ // `frontier` and `depth`.
997
+ private parseConsumerBlock(receiver: Expr, op: Consumer, inWhere: boolean, destination = false): OpNode {
943
998
  const startPos = receiver.span[0];
944
- if (this.at("ident")) this.next(); // the consumer word (absent for `x { … }`)
999
+ if (this.at("ident") && CONSUMERS.has(this.peek().value)) this.next(); // the consumer word (absent for `x { … }`)
945
1000
  let distinct = false;
946
1001
  if (this.at("ident", "distinct")) { this.next(); distinct = true; }
947
1002
  const lbrace = this.next().pos; // '{'
948
1003
  const defer = this.deferLookup;
949
1004
  this.deferLookup = false;
950
1005
  // The receiver was read at the enclosing depth; the block's rows are one deeper.
951
- const body = this.atDepth(this.depth + 1, () => this.parseBody({ top: false, op, liftsAllowed: inWhere && op === "collect" }));
1006
+ const body = this.atDepth(this.depth + 1, () => this.parseBody({ top: false, op, liftsAllowed: inWhere && op === "collect", destination }));
952
1007
  this.deferLookup = defer;
953
1008
  if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
954
1009
  this.next();
@@ -960,6 +1015,8 @@ class Parser {
960
1015
  select: body.select,
961
1016
  orderBy: body.orderBy,
962
1017
  follow: body.follow,
1018
+ frontier: body.frontier,
1019
+ depth: body.depth,
963
1020
  values: body.values,
964
1021
  limit: body.limit,
965
1022
  offset: body.offset,
@@ -967,12 +1024,33 @@ class Parser {
967
1024
  return { kind: "op", span: this.sp(startPos), receiver, op, sub, countCmp: null, distinct: distinct || body.distinct };
968
1025
  }
969
1026
 
1027
+ // A tuple `(e1, e2, …)` at the cursor's `(` (0.20): two or more comma-separated
1028
+ // expressions. A single parenthesized expression is grouping, not a tuple —
1029
+ // the cursor is rewound and null returned (also on a parse error, so the
1030
+ // caller's own reading reports its own error).
1031
+ private tryTuple(): Expr | null {
1032
+ const save = this.pos;
1033
+ const lparen = this.next().pos; // '('
1034
+ try {
1035
+ const items = [this.parseValueExpr()];
1036
+ if (!this.at("comma")) { this.pos = save; return null; }
1037
+ while (this.at("comma")) { this.next(); items.push(this.parseValueExpr()); }
1038
+ if (!this.at("rparen")) this.fail("expected ')' to close the tuple");
1039
+ this.next();
1040
+ return { kind: "tuple", span: [lparen, this.end()], items };
1041
+ } catch (err) {
1042
+ if (!(err instanceof OqxError)) throw err;
1043
+ this.pos = save;
1044
+ return null;
1045
+ }
1046
+ }
1047
+
970
1048
  // A bracket suffix on `recv` (GRAMMAR §4): `[n]` / `[${…}]` is positional —
971
1049
  // `recv first { offset n }` — and anything else a predicate —
972
1050
  // `recv first { where p }`; a trailing `!` makes the index required and the
973
1051
  // predicate a `single` (required too, so that zero matches is an error).
974
1052
  private parseBracket(recv: Expr): Expr {
975
- 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`);
976
1054
  const start = recv.span[0];
977
1055
  const lbracket = this.next().pos; // '['
978
1056
  let index: Expr | null = null;
@@ -997,7 +1075,7 @@ class Parser {
997
1075
  this.next();
998
1076
  const subSpan: Span = [lbracket, this.end()];
999
1077
  const sub = (parts: Partial<Subquery>): Subquery => ({
1000
- kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, values: false, limit: null, offset: null, ...parts,
1078
+ kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, values: false, limit: null, offset: null, ...parts,
1001
1079
  });
1002
1080
  const bang = this.atOp("!") ? this.next() : null;
1003
1081
  if (index) {
@@ -1093,10 +1171,14 @@ class Parser {
1093
1171
  // consumers, …) are lexed as bare idents, so they must NOT count as a value
1094
1172
  // start, or `where age in 18.. order by name` would read `order` as the bound.
1095
1173
  private canStartValue(): boolean {
1096
- const t = this.peek();
1174
+ return this.startsValue(this.peek());
1175
+ }
1176
+
1177
+ private startsValue(t: Token | undefined): boolean {
1178
+ if (!t) return false;
1097
1179
  if (t.type === "ident") return !CLAUSE_WORDS.has(t.value);
1098
1180
  if (t.type === "kw") return t.value === "is" || t.value === "not"; // the prefix operators
1099
- if (t.type === "number" || t.type === "string" ||
1181
+ if (t.type === "number" || t.type === "string" || t.type === "tmpl_start" ||
1100
1182
  t.type === "binding" || t.type === "lparen" || t.type === "caret") return true;
1101
1183
  return t.type === "op" && (t.value === "-" || t.value === "!");
1102
1184
  }
@@ -1162,14 +1244,16 @@ class Parser {
1162
1244
  // never places two operands side by side, so it cannot begin one here.
1163
1245
  const bang = this.next();
1164
1246
  expr = { kind: "required", span: [start, bang.end], expr };
1165
- } else if (this.atValueDirective()) {
1247
+ } else if (this.atValueDirective() || this.at("lbrace") || (this.at("ident", "distinct") && this.peekAt(1)?.type === "lbrace")) {
1166
1248
  // A value-position directive, `recv first { … }` — how a bracket chain
1167
- // prints back; exists/none/count have no value form and are left to
1168
- // the body's own error.
1169
- if (expr.kind === "lit") this.fail(`\`${describe(expr)}\` is a literal, not a collection`);
1170
- expr = this.parseConsumerBlock(expr, this.atConsumerBlock()!, false);
1249
+ // prints back — or the implicit `collect` of a bare `{ … }` / `distinct
1250
+ // { … }` (0.20: the receiver-block sugar stands wherever a value does,
1251
+ // `size(children { is checked })`). exists/none/count have no value form
1252
+ // and are left to the body's own error.
1253
+ if (expr.kind === "lit" || expr.kind === "template") this.fail(`\`${describe(expr)}\` is a literal, not a collection`);
1254
+ expr = this.parseConsumerBlock(expr, this.atBlock()!, false);
1171
1255
  } else {
1172
- break; // a bare `{` is a consumer block boundary — not part of a value expression; anything else ends the chain too
1256
+ break; // anything else ends the chain
1173
1257
  }
1174
1258
  }
1175
1259
  return expr;
@@ -1206,10 +1290,19 @@ class Parser {
1206
1290
  }
1207
1291
  if (t.type === "number") { this.next(); return { kind: "lit", span: [t.pos, t.end], value: Number(t.value) }; }
1208
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();
1209
1294
  if (t.type === "binding") { this.next(); return this.bindingNode(t); }
1210
1295
  if (t.type === "lparen") {
1211
1296
  this.next();
1212
1297
  const e = this.parseValueExpr();
1298
+ if (this.at("comma")) {
1299
+ // A tuple `(e1, e2, …)` (0.20): two or more elements.
1300
+ const items = [e];
1301
+ while (this.at("comma")) { this.next(); items.push(this.parseValueExpr()); }
1302
+ if (!this.at("rparen")) this.fail("expected ')' to close the tuple");
1303
+ this.next();
1304
+ return { kind: "tuple", span: [t.pos, this.end()], items };
1305
+ }
1213
1306
  if (!this.at("rparen")) this.fail("expected ')'");
1214
1307
  this.next();
1215
1308
  return withSpan(e, [t.pos, this.end()]);
@@ -1224,6 +1317,39 @@ class Parser {
1224
1317
  }
1225
1318
  this.fail(`unexpected ${this.tokDesc()} — expected a value`);
1226
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
+ }
1227
1353
  }
1228
1354
 
1229
1355
  // The default key of an unaliased projection item: the last segment of a bare /