@omgbase/oqx 0.15.0 → 0.20.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 (76) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +308 -70
  3. package/dist/ast.d.ts +97 -27
  4. package/dist/ast.d.ts.map +1 -1
  5. package/dist/ast.js +21 -6
  6. package/dist/ast.js.map +1 -1
  7. package/dist/build.d.ts +43 -0
  8. package/dist/build.d.ts.map +1 -0
  9. package/dist/build.js +58 -0
  10. package/dist/build.js.map +1 -0
  11. package/dist/context.d.ts +30 -6
  12. package/dist/context.d.ts.map +1 -1
  13. package/dist/context.js +11 -10
  14. package/dist/context.js.map +1 -1
  15. package/dist/engine.d.ts +13 -3
  16. package/dist/engine.d.ts.map +1 -1
  17. package/dist/engine.js +281 -100
  18. package/dist/engine.js.map +1 -1
  19. package/dist/errors.d.ts +1 -1
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +2 -1
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +19 -6
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +36 -13
  26. package/dist/index.js.map +1 -1
  27. package/dist/lexer.d.ts +15 -1
  28. package/dist/lexer.d.ts.map +1 -1
  29. package/dist/lexer.js +104 -51
  30. package/dist/lexer.js.map +1 -1
  31. package/dist/optimize/analysis.d.ts +3 -2
  32. package/dist/optimize/analysis.d.ts.map +1 -1
  33. package/dist/optimize/analysis.js +19 -18
  34. package/dist/optimize/analysis.js.map +1 -1
  35. package/dist/optimize/ir.d.ts +2 -2
  36. package/dist/optimize/ir.d.ts.map +1 -1
  37. package/dist/optimize/ir.js +3 -3
  38. package/dist/optimize/ir.js.map +1 -1
  39. package/dist/parser.d.ts.map +1 -1
  40. package/dist/parser.js +782 -335
  41. package/dist/parser.js.map +1 -1
  42. package/dist/plan.d.ts.map +1 -1
  43. package/dist/plan.js +4 -2
  44. package/dist/plan.js.map +1 -1
  45. package/dist/print.d.ts +17 -0
  46. package/dist/print.d.ts.map +1 -0
  47. package/dist/print.js +266 -0
  48. package/dist/print.js.map +1 -0
  49. package/dist/resolve.d.ts +23 -0
  50. package/dist/resolve.d.ts.map +1 -0
  51. package/dist/resolve.js +333 -0
  52. package/dist/resolve.js.map +1 -0
  53. package/dist/version.d.ts +5 -0
  54. package/dist/version.d.ts.map +1 -0
  55. package/dist/version.js +5 -0
  56. package/dist/version.js.map +1 -0
  57. package/dist/walk.d.ts +56 -0
  58. package/dist/walk.d.ts.map +1 -0
  59. package/dist/walk.js +157 -0
  60. package/dist/walk.js.map +1 -0
  61. package/package.json +1 -1
  62. package/src/ast.ts +126 -40
  63. package/src/build.ts +73 -0
  64. package/src/context.ts +36 -12
  65. package/src/engine.ts +285 -110
  66. package/src/errors.ts +3 -2
  67. package/src/index.ts +40 -14
  68. package/src/lexer.ts +102 -51
  69. package/src/optimize/analysis.ts +17 -15
  70. package/src/optimize/ir.ts +4 -4
  71. package/src/parser.ts +721 -289
  72. package/src/plan.ts +3 -2
  73. package/src/print.ts +276 -0
  74. package/src/resolve.ts +309 -0
  75. package/src/version.ts +4 -0
  76. package/src/walk.ts +201 -0
package/src/parser.ts CHANGED
@@ -17,20 +17,52 @@
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
26
29
  // source itself, so its block's `from` is an optional re-projection). Only
27
30
  // `select` may drop its keyword, and only when it is the first clause written
28
- // (`name, age from people`); every other clause always carries its keyword, so
29
- // a predicate is never implicit — a block filters with `where`. An out-of-order
30
- // clause is a parse error naming the order. `where` may reference the same
31
- // body's `select` aliases: after a body is parsed, each bare identifier in its
32
- // `where` that names an alias is replaced by the alias's expression (a
33
- // compile-time rewrite — see `inlineAliases`).
31
+ // (`name, age from people`); every other clause always carries its keyword. A
32
+ // BLOCK may instead lead with a predicate (since 0.17): a leading expression that
33
+ // is syntactically a predicate — not a bare name, a dotted navigation or a lift —
34
+ // opens a `where`-first body (`people exists { age > 50 }`); at the top level a
35
+ // leading predicate is still an error, since `where` would precede `from`. An
36
+ // out-of-order clause is a parse error naming the order.
37
+ //
38
+ // Sugar (0.17) desugars HERE, to existing nodes: `x { … }` is `x collect { … }`
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
42
+ // trailing `!` making the first a `single` (predicate) or a `required` (index);
43
+ // `is x` is `!!x`, `not x` is `!x`. Two shapes are new: the postfix `required`
44
+ // (`x!`, tightest) and the identity operators `is` / `is not` (comparison
45
+ // level). A directive may stand in value position (`jobs first { }.pay`), which
46
+ // is how the canonical printer writes a bracket chain back. `where` may reference the same
47
+ // body's `select` aliases: the parser VALIDATES those references (a cycle or a
48
+ // block alias inside an expression is a parse error) but keeps the surface
49
+ // form; `resolveAliases` (resolve.ts) substitutes them before evaluation.
50
+ //
51
+ // Absolute scope references (0.18) desugar HERE too: `N^name` — an unsigned
52
+ // integer literal immediately followed by `^` — is the `outer` reference (or the
53
+ // lift) reaching scope N, i.e. `levels = depth − N`, where `depth` is the
54
+ // syntactic scope depth of the position being parsed (SEMANTICS §2: the root is
55
+ // 0, a top-level row 1, a block's body one deeper than its receiver, a follow
56
+ // `where` one deeper than the frontier row). The parser tracks that depth
57
+ // (`this.depth`) exactly as the `visit` walk computes it; `N >= depth` is a
58
+ // parse error. There is no new node: `print` writes the carets back.
59
+ //
60
+ // Every node carries its `span` — `[start, end)` in code points over the raw
61
+ // source, from the first token that produced it to the end of the last; a
62
+ // parenthesized operand's span includes its parentheses (spec/oqx/AST.md).
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.
34
66
  //
35
67
  // Principle of least surprise, applied to the grammar: a rule a careful user
36
68
  // would not predict is a bug. Hence `!` binds tighter than comparison in every
@@ -46,12 +78,24 @@ import { lexTemplate, lexString } from "./lexer.ts";
46
78
  import { OqxError } from "./errors.ts";
47
79
  import type {
48
80
  Query, Where, Expr, OpNode, Subquery, SelectItem, OrderSpec, Follow, FollowDestination, Consumer, RelOp,
81
+ BinaryOp, Span,
49
82
  } from "./ast.ts";
83
+ import { resolveSelect, resolveWhere } from "./resolve.ts";
84
+ import { printTemplate } from "./print.ts";
50
85
 
51
86
  const CONSUMERS = new Set<string>(["collect", "exists", "none", "count", "first", "single"]);
52
87
  // The fixed clause order of a body (ADR-020). Each clause appears at most once.
53
- const CLAUSE_ORDER = ["select", "from", "where", "follow", "order by", "limit", "offset"] as const;
88
+ // `frontier` is a clause of every body that has a `follow` (a destination
89
+ // block's marks the occurrences it reaches, any other body's its seeds — 0.20);
90
+ // `depth` is legal only in a follow destination block, and the order sentence
91
+ // names it only there.
92
+ const CLAUSE_ORDER = ["select", "from", "where", "frontier", "depth", "follow", "order by", "limit", "offset"] as const;
54
93
  type Clause = (typeof CLAUSE_ORDER)[number];
94
+ const DESTINATION_ONLY: ReadonlySet<Clause> = new Set<Clause>(["depth"]);
95
+ // The clauses a body may hold, in order.
96
+ function clausesOf(ctx: BodyCtx): Clause[] {
97
+ return CLAUSE_ORDER.filter((c) => ctx.destination || !DESTINATION_ONLY.has(c));
98
+ }
55
99
  // Contextual clause words lexed as bare idents; an open-ended range must stop
56
100
  // before them rather than consume them as its high bound.
57
101
  const CLAUSE_WORDS = new Set<string>([
@@ -75,8 +119,12 @@ interface BodyCtx {
75
119
  op: Consumer | null;
76
120
  /** Whether `^name: expr` lift items are legal: only in a where-position `collect { … }`. */
77
121
  liftsAllowed: boolean;
122
+ /** A `follow` destination block (0.20): the only body that admits `depth`, and
123
+ * the one whose `frontier` marks the occurrences it reaches (read one level out)
124
+ * rather than the seeds of its own `follow`. */
125
+ destination: boolean;
78
126
  }
79
- const TOP_CTX: BodyCtx = { top: true, op: null, liftsAllowed: false };
127
+ const TOP_CTX: BodyCtx = { top: true, op: null, liftsAllowed: false, destination: false };
80
128
 
81
129
  /** Parse a tagged-template call into a Query. */
82
130
  export function parseTemplate(fragments: readonly string[], values: number): Query {
@@ -94,20 +142,45 @@ interface BodyClauses {
94
142
  select: SelectItem[];
95
143
  orderBy: OrderSpec[] | null;
96
144
  follow: Follow | null;
145
+ frontier: Expr | null;
146
+ depth: number | null;
97
147
  distinct: boolean;
98
148
  values: boolean;
99
149
  limit: Expr | null;
100
150
  offset: Expr | null;
101
151
  }
102
152
 
153
+ // A copy of a node with another span (a parenthesized operand takes the span of
154
+ // its parentheses).
155
+ function withSpan<T extends { span: Span }>(node: T, span: Span): T {
156
+ return { ...node, span };
157
+ }
158
+
103
159
  class Parser {
104
160
  private tokens: Token[];
105
161
  private pos = 0;
162
+ /** While a block's leading run is read speculatively as a predicate, a bare
163
+ * `first`/`single` leaf is not rejected on sight: the body decides after the
164
+ * run is classified (`rejectBareLookups`). */
165
+ private deferLookup = false;
166
+ /** The scope depth (SEMANTICS §2) at which the construct being parsed is
167
+ * evaluated: 0 at the root (the top-level source and bounds), 1 for a
168
+ * top-level row's clauses, one deeper per block body and per follow `where`.
169
+ * An absolute reference `N^name` reads `depth − N` scopes out. */
170
+ private depth = 0;
106
171
 
107
172
  constructor(tokens: Token[]) {
108
173
  this.tokens = tokens;
109
174
  }
110
175
 
176
+ /** Run `f` with the scope depth set to `d`, restoring it afterwards (also on
177
+ * the error path, so a speculative read that fails leaves the depth intact). */
178
+ private atDepth<T>(d: number, f: () => T): T {
179
+ const saved = this.depth;
180
+ this.depth = d;
181
+ try { return f(); } finally { this.depth = saved; }
182
+ }
183
+
111
184
  // ---- cursor helpers -------------------------------------------------------
112
185
  private peek(): Token { return this.tokens[this.pos]!; }
113
186
  private peekAt(n: number): Token | undefined { return this.tokens[this.pos + n]; }
@@ -123,26 +196,46 @@ class Parser {
123
196
  private fail(msg: string): never {
124
197
  throw new OqxError(`${msg} (at offset ${this.peek().pos})`, "parse");
125
198
  }
199
+ /** Code-point offset of the next token (the start of a node about to be parsed). */
200
+ private start(): number { return this.peek().pos; }
201
+ /** Code-point offset just past the last consumed token (the end of a node just parsed). */
202
+ private end(): number { return this.pos === 0 ? 0 : this.tokens[this.pos - 1]!.end; }
203
+ private sp(start: number): Span { return [start, this.end()]; }
126
204
 
127
205
  // ---- top level ------------------------------------------------------------
128
206
  parseQuery(): Query {
207
+ const start = this.start();
129
208
  // Directive form: `<receiver> <consumer> { … }` consuming the whole query.
130
- const directive = this.tryOp(false);
131
- if (directive && this.at("eof")) {
132
- return {
133
- source: directive.receiver,
134
- from: directive.sub.from,
135
- where: directive.sub.where,
136
- select: directive.sub.select,
137
- orderBy: directive.sub.orderBy,
138
- consumer: directive.op,
139
- follow: directive.sub.follow,
140
- distinct: directive.distinct ?? false,
141
- values: directive.sub.values ?? false,
142
- ...bounds(directive.sub),
143
- };
209
+ let directive = this.tryOp(false);
210
+ if (directive && !this.at("eof") && (this.at("lbracket") || this.atValueDirective())) {
211
+ // A longer chain whose outermost node is still a directive
212
+ // (`people first { } first { }` — how `people[p][0]` prints back).
213
+ const save = this.pos;
214
+ try {
215
+ const e = this.parsePostfix(directive);
216
+ if (e.kind === "op" && this.at("eof")) directive = e;
217
+ else this.pos = save;
218
+ } catch (err) {
219
+ if (!(err instanceof OqxError)) throw err;
220
+ this.pos = save;
221
+ }
144
222
  }
223
+ if (directive && this.at("eof")) return this.queryFromOp(directive, start);
145
224
  if (directive) this.fail(`unexpected ${this.tokDesc()} after the top-level directive`);
225
+ // A bracket chain whose outermost node is a directive is the whole query too
226
+ // (`people[age > 40]` ≡ `people first { where age > 40 }`). Read
227
+ // speculatively: anything else (a parse error, a longer expression) is the
228
+ // body form's business.
229
+ if (this.at("ident") || this.at("caret") || this.at("binding")) {
230
+ const save = this.pos;
231
+ try {
232
+ const e = this.parsePostfix();
233
+ if (e.kind === "op" && this.at("eof")) return this.queryFromOp(e, start);
234
+ } catch (err) {
235
+ if (!(err instanceof OqxError)) throw err;
236
+ }
237
+ this.pos = save;
238
+ }
146
239
 
147
240
  // Body form: the fixed clause list; its `from` is the source.
148
241
  const body = this.parseBody(TOP_CTX);
@@ -151,6 +244,8 @@ class Parser {
151
244
  this.fail("a query must name its source with `from <collection>` (or be `<collection> <consumer> { … }`)");
152
245
  }
153
246
  return {
247
+ kind: "query",
248
+ span: this.sp(start),
154
249
  source: body.froms[0]!,
155
250
  from: body.froms.slice(1),
156
251
  where: body.where,
@@ -158,9 +253,21 @@ class Parser {
158
253
  orderBy: body.orderBy,
159
254
  consumer: "collect",
160
255
  follow: body.follow,
256
+ frontier: body.frontier,
161
257
  distinct: body.distinct,
162
258
  values: body.values,
163
- ...bounds(body),
259
+ limit: body.limit,
260
+ offset: body.offset,
261
+ };
262
+ }
263
+
264
+ // The whole-query form of a directive: its receiver is the source, its block
265
+ // the body, its consumer the query's.
266
+ private queryFromOp(e: OpNode, start: number): Query {
267
+ return {
268
+ kind: "query", span: this.sp(start), source: e.receiver, from: e.sub.from, where: e.sub.where, select: e.sub.select,
269
+ orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, distinct: e.distinct, values: e.sub.values,
270
+ limit: e.sub.limit, offset: e.sub.offset,
164
271
  };
165
272
  }
166
273
 
@@ -180,17 +287,23 @@ class Parser {
180
287
  let select: SelectItem[] = [];
181
288
  let orderBy: OrderSpec[] | null = null;
182
289
  let follow: Follow | null = null;
290
+ let frontier: Expr | null = null;
291
+ let depth: number | null = null;
183
292
  let distinct = false;
184
293
  let values = false;
185
294
  let limit: Expr | null = null;
186
295
  let offset: Expr | null = null;
187
296
  let stage = -1;
297
+ // A top-level body's row clauses (select/where/follow/order by) are read at
298
+ // depth 1, its source and bounds at the root (0); a block's clauses are all
299
+ // at the block's depth, which `parseConsumerBlock` set.
300
+ const rowDepth = ctx.top ? 1 : this.depth;
188
301
 
189
302
  const enter = (clause: Clause): void => {
190
303
  const idx = CLAUSE_ORDER.indexOf(clause);
191
304
  if (idx === stage) this.fail(`duplicate \`${clause}\` clause`);
192
305
  if (idx < stage) {
193
- this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${CLAUSE_ORDER.join(", ")}`);
306
+ this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${clausesOf(ctx).join(", ")}`);
194
307
  }
195
308
  stage = idx;
196
309
  };
@@ -200,7 +313,7 @@ class Parser {
200
313
  enter("select");
201
314
  this.next();
202
315
  if (this.at("ident", "distinct")) { this.next(); distinct = true; }
203
- ({ items: select, values } = this.parseProjection(ctx));
316
+ ({ items: select, values } = this.atDepth(rowDepth, () => this.parseProjection(ctx)));
204
317
  continue;
205
318
  }
206
319
  if (this.at("kw", "from")) {
@@ -212,18 +325,39 @@ class Parser {
212
325
  if (this.at("kw", "where")) {
213
326
  enter("where");
214
327
  this.next();
215
- where = this.parseWhere();
328
+ where = this.atDepth(rowDepth, () => this.parseWhere());
329
+ continue;
330
+ }
331
+ // `frontier P` (0.20) — in a follow destination block the destination's
332
+ // own clause, read in the occurrence's scope whose parent is the walk's
333
+ // enclosing scope: one level OUT of the block's rows (SEMANTICS §20), so
334
+ // `^` there is the enclosing scope, not the frontier row. In any other
335
+ // body it marks the seeds of the body's own `follow` (checked below) and
336
+ // is read in the seed's scope, like `where`.
337
+ if (this.at("ident", "frontier") && this.startsValue(this.peekAt(1))) {
338
+ enter("frontier");
339
+ this.next();
340
+ frontier = this.atDepth(ctx.destination ? rowDepth - 1 : rowDepth, () => this.parseValueExpr());
341
+ continue;
342
+ }
343
+ // `depth n` — a follow destination block's own clause (0.20).
344
+ if (ctx.destination && this.at("ident", "depth") && this.peekAt(1)?.type === "number") {
345
+ enter("depth");
346
+ this.next();
347
+ const v = Number(this.next().value);
348
+ if (!Number.isInteger(v) || v < 1 || v > 8) this.fail("follow depth must be an integer between 1 and 8");
349
+ depth = v;
216
350
  continue;
217
351
  }
218
352
  if (this.atFollow()) {
219
353
  enter("follow");
220
- follow = this.parseFollow();
354
+ follow = this.atDepth(rowDepth, () => this.parseFollow());
221
355
  continue;
222
356
  }
223
357
  if (this.atOrderBy()) {
224
358
  enter("order by");
225
359
  this.next(); this.next(); // `order` `by`
226
- orderBy = this.parseOrderSpecs();
360
+ orderBy = this.atDepth(rowDepth, () => this.parseOrderSpecs());
227
361
  continue;
228
362
  }
229
363
  if (this.atBound()) {
@@ -240,17 +374,79 @@ class Parser {
240
374
  continue;
241
375
  }
242
376
  // A keyword-less run. Before any clause it is the projection (`select` is
243
- // the one keyword that may be dropped, and only in first position); after
244
- // any clause it is an error — a predicate is never implicit.
377
+ // the one keyword that may be dropped, and only in first position) — or,
378
+ // in a block, a leading predicate that opens a `where`-first body. After
379
+ // any clause it is an error.
245
380
  if (stage === -1 && (this.at("ident") || this.at("binding") || this.at("caret") || this.canStartValue())) {
246
- enter("select");
247
- ({ items: select, values } = this.parseProjection(ctx));
381
+ const lead = this.atDepth(rowDepth, () => this.parseLeadingRun(ctx));
382
+ if (lead.where) {
383
+ enter("where");
384
+ where = lead.where;
385
+ } else {
386
+ enter("select");
387
+ ({ items: select, values } = lead.projection!);
388
+ }
248
389
  continue;
249
390
  }
250
391
  this.failUnexpectedInBody(stage, ctx);
251
392
  }
252
- where = this.inlineAliases(select, where);
253
- return { froms, where, select, orderBy, follow, distinct, values, limit, offset };
393
+ // Validate the alias references of this body (cycles, a block alias inside a
394
+ // `where` expression, a `select` item naming an item to its right); the
395
+ // surface form is kept. `where` first: its cycle report follows its own path.
396
+ if (where) resolveWhere(select, where, (msg) => this.fail(msg));
397
+ resolveSelect(select, (msg) => this.fail(msg));
398
+ // Outside a destination block `frontier` marks the seeds of this body's walk,
399
+ // so it needs one.
400
+ if (frontier && !follow && !ctx.destination) {
401
+ 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");
402
+ }
403
+ return { froms, where, select, orderBy, follow, frontier, depth, distinct, values, limit, offset };
404
+ }
405
+
406
+ // The keyword-less run that opens a body. At the top level it is always the
407
+ // projection. In a block it is a `where`-first body when the leading expression
408
+ // is SYNTACTICALLY a predicate (GRAMMAR §2): the run is read as a where tree
409
+ // first; if that tree is predicate-shaped — anything but a bare name, a dotted
410
+ // navigation or a lift, with a parenthesized expression counting as a
411
+ // predicate — and is not the item of a `values` projection, it is the `where`.
412
+ // Otherwise the tokens are re-read as the projection. When both readings fail,
413
+ // the error that got further wins (the farthest-failure rule), so a mistake
414
+ // deep inside `{ jobs exists { … } }` is reported where it is.
415
+ private parseLeadingRun(ctx: BodyCtx): { where: Where | null; projection: { items: SelectItem[]; values: boolean } | null } {
416
+ if (!ctx.top) {
417
+ const save = this.pos;
418
+ const first = this.peek();
419
+ let w: Where | null = null;
420
+ let specErr: OqxError | null = null;
421
+ let specPos = -1;
422
+ this.deferLookup = true;
423
+ try {
424
+ w = this.parseWhere();
425
+ } catch (e) {
426
+ if (!(e instanceof OqxError)) throw e;
427
+ specErr = e;
428
+ specPos = this.pos;
429
+ } finally {
430
+ this.deferLookup = false;
431
+ }
432
+ if (w && predicateShaped(w, first) && !this.at("ident", "values")) {
433
+ if (this.at("comma")) {
434
+ this.fail(`a leading predicate starts a \`where\`-first body, which has no projection — write the projection first: \`{ <projection> where ${describe(w)} }\``);
435
+ }
436
+ this.rejectBareLookups(w);
437
+ return { where: w, projection: null };
438
+ }
439
+ this.pos = save;
440
+ if (specErr) {
441
+ try {
442
+ return { where: null, projection: this.parseProjection(ctx) };
443
+ } catch (e) {
444
+ if (e instanceof OqxError && this.pos < specPos) throw specErr;
445
+ throw e;
446
+ }
447
+ }
448
+ }
449
+ return { where: null, projection: this.parseProjection(ctx) };
254
450
  }
255
451
 
256
452
  // The error for a token that starts no clause, phrased for the mistake it most
@@ -258,8 +454,8 @@ class Parser {
258
454
  // consumer word where a whole-query directive was meant), or a stray token.
259
455
  private failUnexpectedInBody(stage: number, ctx: BodyCtx): never {
260
456
  const t = this.peek();
261
- const remaining = CLAUSE_ORDER.slice(stage + 1).join("/");
262
457
  const last = stage >= 0 ? CLAUSE_ORDER[stage] : null;
458
+ const remaining = clausesOf(ctx).filter((c) => CLAUSE_ORDER.indexOf(c) > stage).join("/");
263
459
  const isWord = t.type === "ident" || t.type === "kw" || t.type === "binding" || t.type === "caret";
264
460
  // Punctuation, an operator, or a literal after a complete clause is a stray
265
461
  // token, not a misplaced predicate: name where the body ends. (A comma keeps
@@ -276,6 +472,11 @@ class Parser {
276
472
  if (t.value === "limit" || t.value === "offset") {
277
473
  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\``);
278
474
  }
475
+ if (t.value === "depth" && !ctx.destination) {
476
+ this.fail(`unexpected 'depth' after \`${last}\` — \`depth\` is a clause of a follow destination block only (\`follow <relation> { depth 2 }\`)`);
477
+ }
478
+ if (t.value === "depth") this.fail("expected an integer after `depth` (`depth 1`..`depth 8`)");
479
+ 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\``);
279
480
  }
280
481
  if (last === "from" && t.type === "ident" && CONSUMERS.has(t.value)) {
281
482
  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\``);
@@ -283,10 +484,10 @@ class Parser {
283
484
  if (last === "from") {
284
485
  this.fail(`unexpected ${this.tokDesc()} after \`from\` — a predicate needs \`where\` (there is no implicit where), and a projection goes before \`from\` (\`select … from …\`); expected ${remaining}`);
285
486
  }
286
- // `{ rel exists { … } }` / `{ rel count { … } >= 2 }`: the leading `rel` was
287
- // read as the projection, so the consumer word is where the mistake shows.
487
+ // `{ name, rel exists { … } }`: the leading items were read as the projection,
488
+ // so the consumer word is where the mistake shows.
288
489
  if (last === "select" && t.type === "ident" && CONSUMERS.has(t.value)) {
289
- this.fail(`unexpected \`${t.value}\` after a projection — a consumer test is a predicate: write \`where <relation> ${t.value} { … }\` — a predicate is never implicit; a nested block in a projection needs a name (\`name: <relation> collect { … }\`)`);
490
+ this.fail(`unexpected \`${t.value}\` after a projection — a consumer test is a predicate: write \`where <relation> ${t.value} { … }\` after the projection (a block may lead with a predicate only when nothing is projected); a nested block in a projection needs a name (\`name: <relation> collect { … }\`)`);
290
491
  }
291
492
  if (last === null) this.fail(`unexpected ${this.tokDesc()} — expected a projection or ${remaining}`);
292
493
  this.fail(`unexpected ${this.tokDesc()} after \`${last}\` — expected ${remaining}`);
@@ -302,74 +503,39 @@ class Parser {
302
503
  return !!nx && (nx.type === "number" || nx.type === "binding" || nx.type === "caret");
303
504
  }
304
505
 
305
- // ---- alias inlining -------------------------------------------------------
306
- // `where` may reference the same body's `select` aliases. This is a
307
- // compile-time rewrite, not a second execution pass: every bare identifier in
308
- // the where tree that names an alias is replaced by that alias's expression,
309
- // so the engine and any pushdown planner see an ordinary where over row
310
- // fields. Rules:
311
- // • an alias shadows a same-named row field inside `where`;
312
- // • an alias's own name inside its own expression is the row field
313
- // (`name: name.upper()` is not recursive), but a chain of aliases that
314
- // comes back to one being resolved (`a: b, b: a`) is a cycle → error;
315
- // • an alias whose value is a `collect`/`first`/`single { … }` block may
316
- // stand alone as a where leaf (a collection in predicate position means
317
- // non-empty) but not appear inside an expression;
318
- // • nested blocks (consumer bodies, follow blocks) are their own scopes and
319
- // are not rewritten against this body's select — each body rewrites
320
- // against its own.
321
- private inlineAliases(select: SelectItem[], where: Where | null): Where | null {
322
- if (!where || select.length === 0) return where;
323
- const aliases = new Map<string, SelectItem>();
324
- for (const it of select) {
325
- if (it.kind === "collect") aliases.set(it.name, it);
326
- else if (it.lift === 0 && it.name !== "") aliases.set(it.name, it);
327
- }
328
- if (aliases.size === 0) return where;
329
- const resolving: string[] = [];
330
- const subst = (e: Expr): Expr => {
331
- switch (e.kind) {
332
- case "ident": {
333
- const a = aliases.get(e.name);
334
- if (!a) return e;
335
- if (resolving[resolving.length - 1] === e.name) return e; // its own name inside its own expression: the row field
336
- if (resolving.includes(e.name)) {
337
- const cycle = [...resolving.slice(resolving.indexOf(e.name)), e.name].join(" → ");
338
- this.fail(`select aliases form a cycle: ${cycle} — an alias used in \`where\` cannot depend on itself`);
339
- }
340
- if (a.kind === "collect") {
341
- this.fail(`select alias '${e.name}' is a ${a.op.op} { … } block — in \`where\` it can only stand alone as a non-empty test, not inside an expression`);
342
- }
343
- resolving.push(e.name);
344
- const out = subst(a.expr);
345
- resolving.pop();
346
- return out;
347
- }
348
- case "member": return { ...e, recv: subst(e.recv) };
349
- case "index": return { ...e, recv: subst(e.recv), index: subst(e.index) };
350
- case "call": return { ...e, recv: e.recv ? subst(e.recv) : null, args: e.args.map(subst) };
351
- case "unary": return { ...e, expr: subst(e.expr) };
352
- case "binary": case "logical": case "in": return { ...e, left: subst(e.left), right: subst(e.right) };
353
- case "range": return { ...e, lo: e.lo ? subst(e.lo) : null, hi: e.hi ? subst(e.hi) : null };
354
- default: return e; // lit, binding, outer (`^name` reads an enclosing row, never an alias)
355
- }
356
- };
357
- const walk = (w: Where): Where => {
358
- switch (w.kind) {
359
- case "and": return { kind: "and", parts: w.parts.map(walk) };
360
- case "or": return { kind: "or", parts: w.parts.map(walk) };
361
- case "not": return { kind: "not", expr: walk(w.expr) };
362
- case "scalar": {
363
- if (w.expr.kind === "ident") {
364
- const a = aliases.get(w.expr.name);
365
- if (a?.kind === "collect") return a.op; // a collection in predicate position: non-empty
366
- }
367
- return { kind: "scalar", expr: subst(w.expr) };
368
- }
369
- case "op": return { ...w, receiver: subst(w.receiver) }; // the receiver is read in this scope; the block is its own scope
506
+ // Whether the token `n` ahead can head a scope reference: a `^` run, or the
507
+ // `N^` of an absolute reference.
508
+ private atScopeRefAt(n: number): boolean {
509
+ return this.peekAt(n)?.type === "caret" || this.atAbsoluteRefAt(n);
510
+ }
511
+
512
+ // `N^` — an unsigned integer literal (digits only) immediately followed by a
513
+ // caret, no whitespace between: an absolute scope reference (GRAMMAR §4).
514
+ // `1 ^x` is not one (the `^` then fails as a stray token), nor is `1.0^x`.
515
+ private atAbsoluteRef(): boolean { return this.atAbsoluteRefAt(0); }
516
+ private atAbsoluteRefAt(n: number): boolean {
517
+ const t = this.peekAt(n);
518
+ const nx = this.peekAt(n + 1);
519
+ return t?.type === "number" && /^\d+$/.test(t.value) && nx?.type === "caret" && nx.pos === t.end;
520
+ }
521
+
522
+ // The head of an outer reference or a lift: a run of carets (one scope out
523
+ // per caret), or an absolute reference `N^` (scope N, GRAMMAR §4), both as the
524
+ // number of scopes out from the current depth. Returns 0 when there is none.
525
+ // The absolute form is checked before anything is consumed, so a failing
526
+ // lookahead (`tryOp`) leaves the cursor on the integer.
527
+ private parseScopeRef(): number {
528
+ if (this.atAbsoluteRef()) {
529
+ const n = this.peek();
530
+ const target = Number(n.value);
531
+ if (target >= this.depth) {
532
+ this.fail(`scope ${n.value} does not enclose this block (the current scope is depth ${this.depth})`);
370
533
  }
371
- };
372
- return walk(where);
534
+ this.next(); // the integer
535
+ this.next(); // the caret
536
+ return this.depth - target;
537
+ }
538
+ return this.parseCarets();
373
539
  }
374
540
 
375
541
  private atOrderBy(): boolean {
@@ -383,11 +549,18 @@ class Parser {
383
549
  private atFollow(): boolean {
384
550
  if (!this.at("ident", "follow")) return false;
385
551
  const nx = this.peekAt(1);
386
- return !!nx && (nx.type === "ident" || nx.type === "binding" || nx.type === "caret");
552
+ return !!nx && (nx.type === "ident" || nx.type === "binding" || nx.type === "lparen" || this.atScopeRefAt(1));
553
+ }
554
+
555
+ // Whether the cursor is at something that can begin a `follow` destination:
556
+ // a name, a binding, a scope reference, or a tuple's `(`.
557
+ private atDestinationStart(): boolean {
558
+ return this.at("ident") || this.at("binding") || this.at("lparen") || this.atScopeRefAt(0);
387
559
  }
388
560
 
389
561
  // ---- follow ---------------------------------------------------------------
390
562
  private parseFollow(): Follow {
563
+ const start = this.start();
391
564
  this.next(); // `follow`
392
565
  let distinct = false;
393
566
  // After `follow`, `distinct` is a keyword (a relation literally named
@@ -395,84 +568,43 @@ class Parser {
395
568
  if (this.at("ident", "distinct")) {
396
569
  this.next();
397
570
  distinct = true;
398
- if (!this.at("ident") && !this.at("binding") && !this.at("caret")) {
571
+ if (!this.atDestinationStart()) {
399
572
  this.fail("expected a relation after `follow distinct` (`follow distinct <relation>`)");
400
573
  }
401
574
  }
402
575
  const destinations: FollowDestination[] = [this.parseFollowDestination()];
403
576
  while (this.at("comma")) {
404
577
  this.next();
405
- if (!this.at("ident") && !this.at("binding") && !this.at("caret")) {
578
+ if (!this.atDestinationStart()) {
406
579
  this.fail("expected a relation after ',' (`follow <relation>, <relation>`)");
407
580
  }
408
581
  destinations.push(this.parseFollowDestination());
409
582
  }
410
- const follow: Follow = { destinations, distinct, where: null, frontier: null, depth: null, by: null };
411
- if (!this.at("lbrace")) return follow;
412
- this.next(); // '{'
413
- while (!this.at("eof") && !this.at("rbrace")) {
414
- if (this.at("kw", "where")) {
415
- if (follow.where) this.fail("duplicate `where` in follow clause");
416
- this.next();
417
- follow.where = this.parseValueExpr();
418
- } else if (this.at("ident", "frontier")) {
419
- if (follow.frontier) this.fail("duplicate `frontier` in follow clause");
420
- this.next();
421
- follow.frontier = this.parseValueExpr();
422
- } else if (this.at("ident", "by")) {
423
- if (follow.by !== null) this.fail("duplicate `by` in follow clause");
424
- this.next();
425
- follow.by = this.parseValueExpr();
426
- } else if (this.at("ident", "depth")) {
427
- if (follow.depth !== null) this.fail("duplicate `depth` in follow clause");
428
- this.next();
429
- if (!this.at("number")) this.fail("expected an integer after `depth`");
430
- const v = Number(this.next().value);
431
- if (!Number.isInteger(v) || v < 1 || v > 8) this.fail("follow depth must be an integer between 1 and 8");
432
- follow.depth = v;
433
- } else {
434
- this.fail(`unexpected ${this.tokDesc()} in follow block — expected where/frontier/depth/by`);
435
- }
436
- }
437
- if (!this.at("rbrace")) this.fail("expected '}' to close the follow block");
438
- this.next();
439
- return follow;
583
+ return { kind: "follow", span: this.sp(start), destinations, distinct };
440
584
  }
441
585
 
442
- // One `follow` destination. The lookahead mirrors `tryOp`: a receiver (binding,
443
- // or carets + a dotted name) followed by a consumer word and `{` / `distinct {`
444
- // is a destination block, parsed by `tryOp(false)` itself — but only the
445
- // select-position consumers (collect/first/single) yield rows to walk; the
446
- // where-position tests are rejected by name before their body is read. With
447
- // no directive the tokens are re-read as a plain relation of the current row,
448
- // where an outer reference is still an error (a `^name` head only makes sense
449
- // as the receiver of a block, since the block is what varies per row).
586
+ // One `follow` destination: a receiver (a binding, a dotted name — headed by a
587
+ // bare name, an outer reference or a free-function call — or a tuple) that is
588
+ // either a plain relation of the current row or, when a block follows, a
589
+ // destination block. The block is what `tryOp` recognizes in select position
590
+ // — `{ … }` (the implicit `collect`), `distinct { … }`, or a consumer word and
591
+ // `{` / `distinct {` — read with the destination flag so its body admits
592
+ // `frontier` and `depth`. Only collect/first/single yield rows to walk; the
593
+ // where-position tests are rejected by name. A receiver headed by an outer
594
+ // reference can never be a plain destination (plain destinations are relations
595
+ // of the current row), so without a block it is an error naming the block
596
+ // spellings.
450
597
  private parseFollowDestination(): FollowDestination {
451
- const start = this.pos;
452
- let isReceiver = false;
453
- if (this.at("binding")) { this.next(); isReceiver = true; }
454
- else if (this.at("ident") || this.at("caret")) {
455
- this.parseCarets();
456
- if (this.at("ident")) { this.parseNavFrom(this.next()); isReceiver = true; }
457
- }
458
- if (isReceiver && this.at("ident") && CONSUMERS.has(this.peek().value)) {
459
- const op = this.peek().value;
460
- const after = this.peekAt(1);
461
- const opThenBrace = after?.type === "lbrace";
462
- const opDistinctBrace = after?.type === "ident" && after.value === "distinct" && this.peekAt(2)?.type === "lbrace";
463
- if (opThenBrace || opDistinctBrace) {
464
- if (op !== "collect" && op !== "first" && op !== "single") {
465
- this.fail(`a follow destination must use collect/first/single, not \`${op}\` (exists/none/count are where-position tests)`);
466
- }
467
- this.pos = start;
468
- const block = this.tryOp(false);
469
- if (!block) this.fail("expected a destination block (`<receiver> collect { … }`) after `follow`");
470
- return block;
598
+ const outer = this.atScopeRefAt(0);
599
+ const op = this.tryOp(false, true);
600
+ if (op) {
601
+ if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
602
+ this.fail(`a follow destination must use collect/first/single, not \`${op.op}\` (exists/none/count are where-position tests)`);
471
603
  }
604
+ return op;
472
605
  }
473
- this.pos = start;
474
- if (this.at("caret")) {
475
- 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 { … }`)");
606
+ if (outer) {
607
+ 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 { … }`");
476
608
  }
477
609
  return this.parseReceiver();
478
610
  }
@@ -483,11 +615,21 @@ class Parser {
483
615
  // named root is only reachable as a receiver through `^` — or a free-function
484
616
  // call (`entries(prefs)`), so a computed collection can be consumed directly.
485
617
  private parseReceiver(): Expr {
486
- if (this.at("binding")) return { kind: "binding", index: this.next().index! };
487
- const levels = this.parseCarets();
618
+ if (this.at("binding")) return this.bindingNode(this.next());
619
+ if (this.at("lparen")) {
620
+ const t = this.tryTuple();
621
+ if (t) return t;
622
+ this.fail("expected a collection navigation (a property/relation name) or a tuple `(a, b)`");
623
+ }
624
+ const start = this.start();
625
+ const levels = this.parseScopeRef();
488
626
  if (!this.at("ident")) this.fail("expected a collection navigation (a property/relation name)");
489
627
  if (LITERAL_WORDS.has(this.peek().value)) this.fail(`\`${this.peek().value}\` is a literal, not a collection`);
490
- return this.parseNavFrom(this.next(), levels).expr;
628
+ return this.parseNavFrom(this.next(), levels, start).expr;
629
+ }
630
+
631
+ private bindingNode(t: Token): Expr {
632
+ return { kind: "binding", span: [t.pos, t.end], index: t.index! };
491
633
  }
492
634
 
493
635
  // Consume a run of `^` and return its length (0 when there is none).
@@ -498,17 +640,23 @@ class Parser {
498
640
  }
499
641
 
500
642
  // A dotted navigation chain from `head`; `levels` > 0 makes the head an outer
501
- // reference read exactly that many scopes out. A bare head followed by `(` is
502
- // a free-function call (`entries(x)`), which may then be navigated further.
503
- private parseNavFrom(head: Token, levels = 0): { expr: Expr; name: string } {
504
- let expr: Expr = levels > 0 ? { kind: "outer", levels, name: head.value } : { kind: "ident", name: head.value };
643
+ // reference read exactly that many scopes out (`start` is then the position
644
+ // of the first caret). A bare head followed by `(` is a free-function call
645
+ // (`entries(x)`), which may then be navigated further.
646
+ private parseNavFrom(head: Token, levels = 0, start = head.pos): { expr: Expr; name: string } {
647
+ let expr: Expr = levels > 0
648
+ ? { kind: "outer", span: [start, head.end], levels, name: head.value }
649
+ : { kind: "ident", span: [start, head.end], name: head.value };
505
650
  let name = head.value;
506
- if (levels === 0 && this.at("lparen")) expr = { kind: "call", recv: null, name: head.value, args: this.parseArgs() };
651
+ if (levels === 0 && this.at("lparen")) {
652
+ const args = this.parseArgs();
653
+ expr = { kind: "call", span: this.sp(start), recv: null, name: head.value, args };
654
+ }
507
655
  while (this.at("dot")) {
508
656
  this.next();
509
657
  if (!this.at("ident")) this.fail("expected a property name after '.'");
510
658
  name = this.next().value;
511
- expr = { kind: "member", recv: expr, name };
659
+ expr = { kind: "member", span: this.sp(start), recv: expr, name };
512
660
  }
513
661
  return { expr, name };
514
662
  }
@@ -536,7 +684,11 @@ class Parser {
536
684
  // `^^x` bind different rows), so the lift depth is part of the key.
537
685
  const seen = new Set<string>();
538
686
  for (const it of items) {
539
- if (it.name === "") this.fail("a leading expression is a projection (select): an item that is not a plain name needs an alias (`name: expr`) or `values`; to filter by it write `where …` — a predicate is never implicit");
687
+ if (it.name === "") {
688
+ if (it.kind === "collect") this.fail("a nested block in a projection needs a name (`name: <relation> collect { … }`); as a predicate, a consumer test is written `where <relation> exists { … }`");
689
+ if (ctx.top) this.fail("a leading expression is a projection (select): an item that is not a plain name needs an alias (`name: expr`) or `values`; to filter by it write `where …` after `from` — at the top level a predicate is never implicit (the clause order puts `from` first)");
690
+ this.fail("an item that is not a plain name needs an alias (`name: expr`) or `values`; to filter by it write `where …` after the projection (a block may lead with a predicate only when nothing is projected: `{ age > 18 }`)");
691
+ }
540
692
  const key = `${"^".repeat(it.kind === "field" ? it.lift : 0)}${it.name}`;
541
693
  if (seen.has(key)) this.fail(`duplicate projection name '${it.name}' — each projected item needs its own name (alias one: \`other: expr\`)`);
542
694
  seen.add(key);
@@ -546,12 +698,13 @@ class Parser {
546
698
  }
547
699
 
548
700
  private parseSelectItem(ctx: BodyCtx): SelectItem {
701
+ const start = this.start();
549
702
  // Leading `^`s mark a lift; the count is how many scopes out it binds. A
550
703
  // lift is bound by a `collect { … }` in where position and nowhere else — at
551
704
  // the top level, in a select-position block, or in an exists/none/count
552
705
  // block it would silently do nothing (or act as a plain field), so it is an
553
- // error there.
554
- const lift = this.parseCarets();
706
+ // error there. `N^name:` is the lift into scope N (GRAMMAR §4).
707
+ const lift = this.parseScopeRef();
555
708
  if (!this.at("ident") && !this.canStartValue()) this.fail("expected a projection name");
556
709
  if (lift > 0 && !ctx.liftsAllowed) {
557
710
  const what = this.at("ident") ? `^${this.peek().value}` : "^name";
@@ -562,20 +715,30 @@ class Parser {
562
715
  this.next(); // ':'
563
716
  const op = this.tryOp(false);
564
717
  if (op) {
718
+ // `name: jobs first { }.pay`: the directive is the head of a longer value.
719
+ if (this.atScalarContinuation() || this.atValueDirective()) return this.finishItem(name, this.parseOr(op), lift, start);
565
720
  if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
566
721
  this.fail(`projection '${name}' must use collect/first/single, not ${op.op} (exists/none/count are where-position tests)`);
567
722
  }
568
- if (lift) this.fail(`a lift (^${name}) value must be a scalar expression, not ${op.op} { … }`);
569
- return { kind: "collect", name, op };
723
+ return this.finishItem(name, op, lift, start);
570
724
  }
571
- const expr = this.parseValueExpr();
572
- return { kind: "field", name, expr, lift };
725
+ return this.finishItem(name, this.parseValueExpr(), lift, start);
573
726
  }
574
- // Unaliased item: a bare/dotted navigation keys by its last segment; any
575
- // other expression is unnamed ("") — legal only under `values` (checked by
576
- // parseProjection, which sees the whole list).
727
+ // Unaliased item: a bare/dotted navigation (under any `!`) keys by its last
728
+ // segment; any other expression is unnamed ("") — legal only under `values`
729
+ // (checked by parseProjection, which sees the whole list).
577
730
  const expr = this.parseValueExpr();
578
- return { kind: "field", name: navKey(expr) ?? "", expr, lift };
731
+ return this.finishItem(navKey(expr) ?? "", expr, lift, start);
732
+ }
733
+
734
+ // An item from its value: exactly a directive (written out, or the bracket
735
+ // sugar) is the `collect` item kind; anything else is a `field`.
736
+ private finishItem(name: string, expr: Expr, lift: number, start: number): SelectItem {
737
+ if (expr.kind === "op") {
738
+ if (lift) this.fail(`a lift (^${name}) value must be a scalar expression, not ${expr.op} { … }`);
739
+ return { kind: "collect", span: this.sp(start), name, op: expr };
740
+ }
741
+ return { kind: "field", span: this.sp(start), name, expr, lift };
579
742
  }
580
743
 
581
744
  // ---- order by -------------------------------------------------------------
@@ -586,11 +749,12 @@ class Parser {
586
749
  }
587
750
 
588
751
  private parseOrderSpec(): OrderSpec {
752
+ const start = this.start();
589
753
  const expr = this.parseValueExpr();
590
754
  let desc = false;
591
755
  if (this.at("ident", "asc")) this.next();
592
756
  else if (this.at("ident", "desc")) { this.next(); desc = true; }
593
- return { expr, desc };
757
+ return { kind: "order", span: this.sp(start), expr, desc };
594
758
  }
595
759
 
596
760
  // ---- where boolean tree: or → and → primary --------------------------------
@@ -600,21 +764,28 @@ class Parser {
600
764
  private parseWhere(): Where { return this.parseWhereOr(); }
601
765
 
602
766
  private parseWhereOr(): Where {
767
+ const start = this.start();
603
768
  const left = this.parseWhereAnd();
604
- if (!this.atOp("||")) return left;
769
+ if (!this.atOr()) return left;
605
770
  const parts = [left];
606
- while (this.atOp("||")) { this.next(); parts.push(this.parseWhereAnd()); }
607
- return { kind: "or", parts };
771
+ while (this.atOr()) { this.next(); parts.push(this.parseWhereAnd()); }
772
+ return { kind: "or", span: this.sp(start), parts };
608
773
  }
609
774
 
610
775
  private parseWhereAnd(): Where {
776
+ const start = this.start();
611
777
  const left = this.parseWherePrimary();
612
- if (!this.atOp("&&")) return left;
778
+ if (!this.atAnd()) return left;
613
779
  const parts = [left];
614
- while (this.atOp("&&")) { this.next(); parts.push(this.parseWherePrimary()); }
615
- return { kind: "and", parts };
780
+ while (this.atAnd()) { this.next(); parts.push(this.parseWherePrimary()); }
781
+ return { kind: "and", span: this.sp(start), parts };
616
782
  }
617
783
 
784
+ // `&&` / `and` and `||` / `or` are one operator each (0.17): the words are
785
+ // exact synonyms of the symbols, parsed to the same nodes.
786
+ private atAnd(): boolean { return this.atOp("&&") || this.at("kw", "and"); }
787
+ private atOr(): boolean { return this.atOp("||") || this.at("kw", "or"); }
788
+
618
789
  // A where operand: `[!…] ( where )`, `[!…] <receiver> <consumer> { … }`, or a
619
790
  // scalar leaf (a cmp-level expression, which handles its own `!`).
620
791
  //
@@ -626,34 +797,82 @@ class Parser {
626
797
  // predicate.
627
798
  private parseWherePrimary(): Where {
628
799
  const start = this.pos;
629
- let nots = 0;
630
- while (this.atOp("!")) { this.next(); nots++; }
800
+ // Leading negations: `!`, `not` (one each) and `is` (two — `is x` ≡ `!!x`).
801
+ const notStarts: number[] = [];
802
+ for (;;) {
803
+ if (this.atOp("!") || this.at("kw", "not")) notStarts.push(this.next().pos);
804
+ else if (this.at("kw", "is")) { const p = this.next().pos; notStarts.push(p, p); }
805
+ else break;
806
+ }
631
807
  if (this.at("lparen")) {
632
- this.next();
808
+ // A tuple (0.20) is a value, not a group: the receiver of a consumer test
809
+ // (`(a, b) exists { }`) or a scalar operand (`(a, b).size() > 1`).
810
+ const tuple = this.tryTuple();
811
+ if (tuple) {
812
+ if (this.atBlock()) return wrapNot(this.finishWhereOp(this.parseConsumerBlock(tuple, this.atBlock()!, true)), notStarts);
813
+ let e = this.parsePostfix(tuple);
814
+ for (let i = notStarts.length - 1; i >= 0; i--) e = { kind: "unary", span: [notStarts[i]!, e.span[1]], op: "!", expr: e };
815
+ const expr = this.parseCmp(e);
816
+ return { kind: "scalar", span: expr.span, expr };
817
+ }
818
+ const lparen = this.next().pos;
633
819
  const inner = this.parseWhere();
634
820
  if (!this.at("rparen")) this.fail("expected ')' to close a grouped where expression");
635
821
  this.next();
822
+ const group: Span = [lparen, this.end()];
636
823
  if (this.atScalarContinuation()) {
637
- let e = this.parsePostfix(this.whereToExpr(inner));
638
- for (let i = 0; i < nots; i++) e = { kind: "unary", op: "!", expr: e };
639
- return { kind: "scalar", expr: this.parseCmp(e) };
824
+ let e = this.parsePostfix(withSpan(this.whereToExpr(inner), group));
825
+ for (let i = notStarts.length - 1; i >= 0; i--) e = { kind: "unary", span: [notStarts[i]!, e.span[1]], op: "!", expr: e };
826
+ const expr = this.parseCmp(e);
827
+ return { kind: "scalar", span: expr.span, expr };
640
828
  }
641
- return wrapNot(inner, nots);
829
+ return wrapNot(withSpan(inner, group), notStarts);
642
830
  }
643
831
  const op = this.tryOp(true);
644
- if (op) return wrapNot(this.finishWhereOp(op), nots);
832
+ // A `first`/`single` directive is a value, never a test: the scalar grammar
833
+ // reads it (as a value-position directive) with whatever follows it, and
834
+ // `rejectBareLookup` turns a bare one into the lookup error.
835
+ if (op && op.op !== "first" && op.op !== "single") return wrapNot(this.finishWhereOp(op), notStarts);
645
836
  // A scalar leaf, re-read from the first `!` so the scalar grammar gives `!`
646
837
  // its one precedence (tighter than comparison). A leaf whose whole value is
647
838
  // a negation keeps the `not` node shape (`where !active`).
648
839
  this.pos = start;
649
- return scalarLeaf(this.parseCmp());
840
+ const leaf = scalarLeaf(this.parseCmp());
841
+ if (!this.deferLookup) this.rejectBareLookup(leaf);
842
+ return leaf;
843
+ }
844
+
845
+ // A where leaf that is exactly a `first`/`single` directive (under any `!`) is
846
+ // the select-position lookup error: a lookup is a value, and alone in `where`
847
+ // the writer almost certainly meant `exists`.
848
+ private rejectBareLookup(w: Where): void {
849
+ let core: Where = w;
850
+ while (core.kind === "not") core = core.expr;
851
+ if (core.kind !== "scalar") return;
852
+ let e = core.expr;
853
+ while (e.kind === "unary" && e.op === "!") e = e.expr;
854
+ if (e.kind === "op" && (e.op === "first" || e.op === "single")) {
855
+ this.fail(`${e.op} { … } is a select-position lookup; in where use exists { … } / none { … } or count { … } <op> N (\`x[p]\` is \`x first { where p }\`; a lookup is a value — compare it, navigate it or require it: \`x[p].name == …\`, \`x[0]!\`)`);
856
+ }
857
+ }
858
+
859
+ // `rejectBareLookup` over every leaf of a tree read with `deferLookup` on.
860
+ private rejectBareLookups(w: Where): void {
861
+ switch (w.kind) {
862
+ case "and": case "or": w.parts.forEach((p) => this.rejectBareLookups(p)); return;
863
+ case "not": case "scalar": this.rejectBareLookup(w); return;
864
+ case "op": return;
865
+ }
650
866
  }
651
867
 
652
- // Whether the token after a `)` continues a scalar expression.
868
+ // Whether the token after a `)` (or a directive) continues a scalar
869
+ // expression: an operator, `.`-navigation, a bracket, a postfix `!`, a range,
870
+ // `in`, or `is`.
653
871
  private atScalarContinuation(): boolean {
654
872
  const t = this.peek();
655
- if (t.type === "op") return CMP_OPS.has(t.value) || ADD_OPS.has(t.value) || MUL_OPS.has(t.value);
656
- return t.type === "dot" || t.type === "range" || (t.type === "ident" && t.value === "in");
873
+ if (t.type === "op") return CMP_OPS.has(t.value) || ADD_OPS.has(t.value) || MUL_OPS.has(t.value) || t.value === "!";
874
+ if (t.type === "kw") return t.value === "is";
875
+ return t.type === "dot" || t.type === "range" || t.type === "lbracket" || (t.type === "ident" && t.value === "in");
657
876
  }
658
877
 
659
878
  // A parenthesized where-group that turned out to be a scalar operand, as an
@@ -661,10 +880,10 @@ class Parser {
661
880
  private whereToExpr(w: Where): Expr {
662
881
  switch (w.kind) {
663
882
  case "scalar": return w.expr;
664
- case "not": return { kind: "unary", op: "!", expr: this.whereToExpr(w.expr) };
883
+ case "not": return { kind: "unary", span: w.span, op: "!", expr: this.whereToExpr(w.expr) };
665
884
  case "and": case "or": {
666
885
  const op = w.kind === "and" ? "&&" : "||";
667
- return w.parts.map((p) => this.whereToExpr(p)).reduce((l, r) => ({ kind: "logical", op, left: l, right: r }));
886
+ return w.parts.map((p) => this.whereToExpr(p)).reduce((l, r) => ({ kind: "logical", span: [l.span[0], r.span[1]], op, left: l, right: r }));
668
887
  }
669
888
  case "op": {
670
889
  const hint = w.op === "count" ? ` — write \`<relation> count { … } ${this.peek().value} N\` without the parentheses` : "";
@@ -689,64 +908,203 @@ class Parser {
689
908
  const v = Number(this.next().value);
690
909
  if (!Number.isInteger(v)) this.fail("count comparison takes an integer");
691
910
  op.countCmp = { op: relop, value: v };
911
+ op.span = [op.span[0], this.end()];
692
912
  }
693
913
  return op;
694
914
  }
695
915
 
696
- // Detect + parse a postfix consumer op `<receiver> <consumer> { <sub> }`.
697
- // Returns null (rewinding) when the lookahead is not a consumer op.
698
- private tryOp(inWhere: boolean): OpNode | null {
916
+ // Detect + parse a postfix consumer op `<receiver> <consumer> { <sub> }` — or
917
+ // its implicit-`collect` forms `<receiver> { <sub> }` and `<receiver> distinct
918
+ // { <sub> }` (0.20). Returns null (rewinding) when the lookahead is not a
919
+ // directive. `destination` marks a follow destination block (its body admits
920
+ // `frontier`/`depth`).
921
+ private tryOp(inWhere: boolean, destination = false): OpNode | null {
699
922
  const start = this.pos;
923
+ const startPos = this.start();
700
924
  let receiver: Expr;
701
- if (this.at("binding")) receiver = { kind: "binding", index: this.next().index! };
702
- else if (this.at("ident") || this.at("caret")) {
703
- const levels = this.parseCarets();
925
+ // An absolute reference that does not enclose this scope is an error only
926
+ // if the tokens do form a directive (`0^docs collect { }` at the root);
927
+ // otherwise the expression grammar gets to read them (a top-level `0^name`
928
+ // item is a lift, with the lift's own error), so the failure is held back
929
+ // until the lookahead has decided.
930
+ let scopeErr: OqxError | null = null;
931
+ if (this.at("binding")) receiver = this.bindingNode(this.next());
932
+ else if (this.at("lparen")) {
933
+ // A tuple receiver, `(a, b) { … }`; a single parenthesized expression is
934
+ // grouping and never a receiver.
935
+ const t = this.tryTuple();
936
+ if (!t) { this.pos = start; return null; }
937
+ receiver = t;
938
+ } else if (this.at("ident") || this.atScopeRefAt(0)) {
939
+ let levels = 0;
940
+ try {
941
+ levels = this.parseScopeRef();
942
+ } catch (err) {
943
+ if (!(err instanceof OqxError)) throw err;
944
+ scopeErr = err;
945
+ this.next(); this.next(); // the integer and the caret, as if it had parsed
946
+ levels = 1;
947
+ }
704
948
  if (!this.at("ident")) { this.pos = start; return null; }
705
- receiver = this.parseNavFrom(this.next(), levels).expr;
949
+ receiver = this.parseNavFrom(this.next(), levels, startPos).expr;
706
950
  } else return null;
707
951
 
708
- if (this.at("ident") && CONSUMERS.has(this.peek().value)) {
709
- const after = this.peekAt(1);
710
- // `<op> { … }` or `<op> distinct { … }`.
711
- const opThenBrace = after?.type === "lbrace";
712
- const opDistinctBrace = after?.type === "ident" && after.value === "distinct" && this.peekAt(2)?.type === "lbrace";
713
- if (opThenBrace || opDistinctBrace) {
714
- if (receiver.kind === "ident" && LITERAL_WORDS.has(receiver.name)) this.fail(`\`${receiver.name}\` is a literal, not a collection`);
715
- const op = this.next().value as Consumer;
716
- let distinct = false;
717
- if (this.at("ident", "distinct")) { this.next(); distinct = true; }
718
- this.next(); // '{'
719
- const { sub, distinct: bodyDistinct } = this.parseSubquery({ top: false, op, liftsAllowed: inWhere && op === "collect" });
720
- if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
721
- this.next();
722
- return { kind: "op", receiver, op, sub, distinct: distinct || bodyDistinct };
952
+ const op = this.atBlock();
953
+ if (op) {
954
+ if (scopeErr) throw scopeErr;
955
+ if (receiver.kind === "ident" && LITERAL_WORDS.has(receiver.name)) this.fail(`\`${receiver.name}\` is a literal, not a collection`);
956
+ // `count { }` with nothing before it: the consumer word is not a receiver.
957
+ if (op === "collect" && receiver.kind === "ident" && CONSUMERS.has(receiver.name) && this.at("lbrace")) {
958
+ this.fail(`unexpected '{' after \`${receiver.name}\` — a consumer needs a receiver: \`<collection> ${receiver.name} { … }\``);
723
959
  }
960
+ return this.parseConsumerBlock(receiver, op, inWhere, destination);
724
961
  }
725
962
  this.pos = start;
726
963
  return null;
727
964
  }
728
965
 
729
- private parseSubquery(ctx: BodyCtx): { sub: Subquery; distinct: boolean } {
730
- const body = this.parseBody(ctx);
731
- return {
732
- sub: { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow, values: body.values, ...bounds(body) },
733
- distinct: body.distinct,
966
+ // The cursor is at what opens a block over an already-read receiver: `{` or
967
+ // `distinct {` (the implicit `collect`), or a consumer word and `{` /
968
+ // `distinct {`. Consumes nothing.
969
+ private atBlock(): Consumer | null {
970
+ if (this.at("lbrace")) return "collect";
971
+ if (this.at("ident", "distinct") && this.peekAt(1)?.type === "lbrace") return "collect";
972
+ return this.atConsumerBlock();
973
+ }
974
+
975
+ // The cursor is at a consumer word that opens a block — `<op> {` or
976
+ // `<op> distinct {`. Consumes nothing.
977
+ private atConsumerBlock(): Consumer | null {
978
+ if (!this.at("ident") || !CONSUMERS.has(this.peek().value)) return null;
979
+ const after = this.peekAt(1);
980
+ const opThenBrace = after?.type === "lbrace";
981
+ const opDistinctBrace = after?.type === "ident" && after.value === "distinct" && this.peekAt(2)?.type === "lbrace";
982
+ return opThenBrace || opDistinctBrace ? (this.peek().value as Consumer) : null;
983
+ }
984
+
985
+ // `[<op>] [distinct] { <sub> }` over an already-parsed receiver; the cursor is
986
+ // at the consumer word, at `distinct`, or at the `{` of the implicit `collect`.
987
+ // `destination` marks a follow destination block (0.20): its body may carry
988
+ // `frontier` and `depth`.
989
+ private parseConsumerBlock(receiver: Expr, op: Consumer, inWhere: boolean, destination = false): OpNode {
990
+ const startPos = receiver.span[0];
991
+ if (this.at("ident") && CONSUMERS.has(this.peek().value)) this.next(); // the consumer word (absent for `x { … }`)
992
+ let distinct = false;
993
+ if (this.at("ident", "distinct")) { this.next(); distinct = true; }
994
+ const lbrace = this.next().pos; // '{'
995
+ const defer = this.deferLookup;
996
+ this.deferLookup = false;
997
+ // The receiver was read at the enclosing depth; the block's rows are one deeper.
998
+ const body = this.atDepth(this.depth + 1, () => this.parseBody({ top: false, op, liftsAllowed: inWhere && op === "collect", destination }));
999
+ this.deferLookup = defer;
1000
+ if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
1001
+ this.next();
1002
+ const sub: Subquery = {
1003
+ kind: "subquery",
1004
+ span: this.sp(lbrace),
1005
+ from: body.froms,
1006
+ where: body.where,
1007
+ select: body.select,
1008
+ orderBy: body.orderBy,
1009
+ follow: body.follow,
1010
+ frontier: body.frontier,
1011
+ depth: body.depth,
1012
+ values: body.values,
1013
+ limit: body.limit,
1014
+ offset: body.offset,
734
1015
  };
1016
+ return { kind: "op", span: this.sp(startPos), receiver, op, sub, countCmp: null, distinct: distinct || body.distinct };
1017
+ }
1018
+
1019
+ // A tuple `(e1, e2, …)` at the cursor's `(` (0.20): two or more comma-separated
1020
+ // expressions. A single parenthesized expression is grouping, not a tuple —
1021
+ // the cursor is rewound and null returned (also on a parse error, so the
1022
+ // caller's own reading reports its own error).
1023
+ private tryTuple(): Expr | null {
1024
+ const save = this.pos;
1025
+ const lparen = this.next().pos; // '('
1026
+ try {
1027
+ const items = [this.parseValueExpr()];
1028
+ if (!this.at("comma")) { this.pos = save; return null; }
1029
+ while (this.at("comma")) { this.next(); items.push(this.parseValueExpr()); }
1030
+ if (!this.at("rparen")) this.fail("expected ')' to close the tuple");
1031
+ this.next();
1032
+ return { kind: "tuple", span: [lparen, this.end()], items };
1033
+ } catch (err) {
1034
+ if (!(err instanceof OqxError)) throw err;
1035
+ this.pos = save;
1036
+ return null;
1037
+ }
1038
+ }
1039
+
1040
+ // A bracket suffix on `recv` (GRAMMAR §4): `[n]` / `[${…}]` is positional —
1041
+ // `recv first { offset n }` — and anything else a predicate —
1042
+ // `recv first { where p }`; a trailing `!` makes the index required and the
1043
+ // predicate a `single` (required too, so that zero matches is an error).
1044
+ private parseBracket(recv: Expr): Expr {
1045
+ if (recv.kind === "lit") this.fail(`\`${describe(recv)}\` is a literal, not a collection`);
1046
+ const start = recv.span[0];
1047
+ const lbracket = this.next().pos; // '['
1048
+ let index: Expr | null = null;
1049
+ let where: Where | null = null;
1050
+ if (this.at("number") && this.peekAt(1)?.type === "rbracket") {
1051
+ const t = this.next();
1052
+ const v = Number(t.value);
1053
+ if (!Number.isInteger(v)) this.fail(`an index is a non-negative integer literal or a binding (got ${t.value})`);
1054
+ index = { kind: "lit", span: [t.pos, t.end], value: v };
1055
+ } else if (this.at("binding") && this.peekAt(1)?.type === "rbracket") {
1056
+ index = this.bindingNode(this.next());
1057
+ } else if (this.atOp("-") && this.peekAt(1)?.type === "number" && this.peekAt(2)?.type === "rbracket") {
1058
+ this.fail("negative indices are not supported (`x[-1]`): an index is a non-negative integer literal or a binding");
1059
+ } else {
1060
+ const defer = this.deferLookup;
1061
+ this.deferLookup = false;
1062
+ // The predicate is the block's `where`: read in the rows' scope, one deeper.
1063
+ where = this.atDepth(this.depth + 1, () => this.parseWhere());
1064
+ this.deferLookup = defer;
1065
+ }
1066
+ if (!this.at("rbracket")) this.fail("expected ']' to close the bracket");
1067
+ this.next();
1068
+ const subSpan: Span = [lbracket, this.end()];
1069
+ const sub = (parts: Partial<Subquery>): Subquery => ({
1070
+ kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, values: false, limit: null, offset: null, ...parts,
1071
+ });
1072
+ const bang = this.atOp("!") ? this.next() : null;
1073
+ if (index) {
1074
+ const op: OpNode = { kind: "op", span: [start, subSpan[1]], receiver: recv, op: "first", sub: sub({ offset: index }), countCmp: null, distinct: false };
1075
+ return bang ? { kind: "required", span: [start, bang.end], expr: op } : op;
1076
+ }
1077
+ if (bang) {
1078
+ const op: OpNode = { kind: "op", span: [start, bang.end], receiver: recv, op: "single", sub: sub({ where }), countCmp: null, distinct: false };
1079
+ return { kind: "required", span: [start, bang.end], expr: op };
1080
+ }
1081
+ return { kind: "op", span: [start, subSpan[1]], receiver: recv, op: "first", sub: sub({ where }), countCmp: null, distinct: false };
735
1082
  }
736
1083
 
737
1084
  // ---- expression Pratt parser ----------------------------------------------
738
- // Value position (select/order/follow/source): full boolean+arithmetic.
1085
+ // Value position (select/order/follow/source): full boolean+arithmetic. Each
1086
+ // level takes an optional already-parsed `left` operand (a directive read by
1087
+ // `tryOp`, a promoted where-group), so it continues into the operator tail
1088
+ // without re-reading its tokens.
739
1089
  private parseValueExpr(): Expr { return this.parseOr(); }
740
1090
 
741
- private parseOr(): Expr {
742
- let left = this.parseAnd();
743
- while (this.atOp("||")) { this.next(); left = { kind: "logical", op: "||", left, right: this.parseAnd() }; }
1091
+ private parseOr(left?: Expr): Expr {
1092
+ left = this.parseAnd(left);
1093
+ while (this.atOr()) {
1094
+ this.next();
1095
+ const right = this.parseAnd();
1096
+ left = { kind: "logical", span: [left.span[0], right.span[1]], op: "||", left, right };
1097
+ }
744
1098
  return left;
745
1099
  }
746
1100
 
747
- private parseAnd(): Expr {
748
- let left = this.parseCmp();
749
- while (this.atOp("&&")) { this.next(); left = { kind: "logical", op: "&&", left, right: this.parseCmp() }; }
1101
+ private parseAnd(left?: Expr): Expr {
1102
+ left = this.parseCmp(left);
1103
+ while (this.atAnd()) {
1104
+ this.next();
1105
+ const right = this.parseCmp();
1106
+ left = { kind: "logical", span: [left.span[0], right.span[1]], op: "&&", left, right };
1107
+ }
750
1108
  return left;
751
1109
  }
752
1110
 
@@ -760,11 +1118,16 @@ class Parser {
760
1118
  private parseCmp(left?: Expr): Expr {
761
1119
  const lhs = this.parseRange(left);
762
1120
  const t = this.peek();
763
- const isCmp = (): boolean => (this.peek().type === "op" && CMP_OPS.has(this.peek().value)) || this.at("ident", "in");
1121
+ const isCmp = (): boolean => (this.peek().type === "op" && CMP_OPS.has(this.peek().value)) || this.at("ident", "in") || this.at("kw", "is");
764
1122
  if (!isCmp()) return lhs;
765
- const op = this.next().value;
1123
+ let op = this.next().value;
1124
+ // `is` / `is not` — identity (SEMANTICS §5b); `is not` is the compound.
1125
+ if (op === "is" && this.at("kw", "not")) { this.next(); op = "is not"; }
766
1126
  const rhs = this.parseRange();
767
- const result: Expr = op === "in" ? { kind: "in", left: lhs, right: rhs } : { kind: "binary", op, left: lhs, right: rhs };
1127
+ const span: Span = [lhs.span[0], rhs.span[1]];
1128
+ const result: Expr = op === "in"
1129
+ ? { kind: "in", span, left: lhs, right: rhs }
1130
+ : { kind: "binary", span, op: op as BinaryOp, left: lhs, right: rhs };
768
1131
  if (isCmp()) {
769
1132
  this.fail(`comparisons do not chain: \`a ${t.value} b ${this.peek().value} c\` — write two comparisons joined with \`&&\``);
770
1133
  }
@@ -778,15 +1141,18 @@ class Parser {
778
1141
  // opens the high end.
779
1142
  private parseRange(left?: Expr): Expr {
780
1143
  if (left === undefined && this.at("range")) {
781
- const exclusiveEnd = this.next().value === "...";
1144
+ const tok = this.next();
1145
+ const exclusiveEnd = tok.value === "...";
782
1146
  if (!this.canStartValue()) this.fail("a range needs at least one bound: `lo..hi`, `lo..`, or `..hi`");
783
- return { kind: "range", lo: null, hi: this.parseAdd(), exclusiveEnd };
1147
+ const hi = this.parseAdd();
1148
+ return { kind: "range", span: [tok.pos, hi.span[1]], lo: null, hi, exclusiveEnd };
784
1149
  }
785
1150
  const lo = this.parseAdd(left);
786
1151
  if (this.at("range")) {
787
- const exclusiveEnd = this.next().value === "...";
1152
+ const tok = this.next();
1153
+ const exclusiveEnd = tok.value === "...";
788
1154
  const hi = this.canStartValue() ? this.parseAdd() : null;
789
- return { kind: "range", lo, hi, exclusiveEnd };
1155
+ return { kind: "range", span: [lo.span[0], hi ? hi.span[1] : tok.end], lo, hi, exclusiveEnd };
790
1156
  }
791
1157
  return lo;
792
1158
  }
@@ -797,8 +1163,13 @@ class Parser {
797
1163
  // consumers, …) are lexed as bare idents, so they must NOT count as a value
798
1164
  // start, or `where age in 18.. order by name` would read `order` as the bound.
799
1165
  private canStartValue(): boolean {
800
- const t = this.peek();
1166
+ return this.startsValue(this.peek());
1167
+ }
1168
+
1169
+ private startsValue(t: Token | undefined): boolean {
1170
+ if (!t) return false;
801
1171
  if (t.type === "ident") return !CLAUSE_WORDS.has(t.value);
1172
+ if (t.type === "kw") return t.value === "is" || t.value === "not"; // the prefix operators
802
1173
  if (t.type === "number" || t.type === "string" ||
803
1174
  t.type === "binding" || t.type === "lparen" || t.type === "caret") return true;
804
1175
  return t.type === "op" && (t.value === "-" || t.value === "!");
@@ -807,8 +1178,9 @@ class Parser {
807
1178
  private parseAdd(left?: Expr): Expr {
808
1179
  left = this.parseMul(left);
809
1180
  while (this.peek().type === "op" && ADD_OPS.has(this.peek().value)) {
810
- const op = this.next().value;
811
- left = { kind: "binary", op, left, right: this.parseMul() };
1181
+ const op = this.next().value as BinaryOp;
1182
+ const right = this.parseMul();
1183
+ left = { kind: "binary", span: [left.span[0], right.span[1]], op, left, right };
812
1184
  }
813
1185
  return left;
814
1186
  }
@@ -816,43 +1188,74 @@ class Parser {
816
1188
  private parseMul(left?: Expr): Expr {
817
1189
  left = this.parseUnary(left);
818
1190
  while (this.peek().type === "op" && MUL_OPS.has(this.peek().value)) {
819
- const op = this.next().value;
820
- left = { kind: "binary", op, left, right: this.parseUnary() };
1191
+ const op = this.next().value as BinaryOp;
1192
+ const right = this.parseUnary();
1193
+ left = { kind: "binary", span: [left.span[0], right.span[1]], op, left, right };
821
1194
  }
822
1195
  return left;
823
1196
  }
824
1197
 
825
1198
  private parseUnary(left?: Expr): Expr {
826
1199
  if (left !== undefined) return this.parsePostfix(left);
827
- if (this.atOp("!")) { this.next(); return { kind: "unary", op: "!", expr: this.parseUnary() }; }
828
- if (this.atOp("-")) { this.next(); return { kind: "unary", op: "-", expr: this.parseUnary() }; }
1200
+ if (this.atOp("!") || this.atOp("-") || this.at("kw", "is") || this.at("kw", "not")) {
1201
+ const tok = this.next();
1202
+ const expr = this.parseUnary();
1203
+ const span: Span = [tok.pos, expr.span[1]];
1204
+ // `is x` ≡ `!!x`, `not x` ≡ `!x`: pure sugar, both nodes spanning the sugar.
1205
+ if (tok.type === "kw") {
1206
+ const not: Expr = { kind: "unary", span, op: "!", expr };
1207
+ return tok.value === "is" ? { kind: "unary", span, op: "!", expr: not } : not;
1208
+ }
1209
+ return { kind: "unary", span, op: tok.value as "!" | "-", expr };
1210
+ }
829
1211
  return this.parsePostfix();
830
1212
  }
831
1213
 
832
1214
  private parsePostfix(left?: Expr): Expr {
833
1215
  let expr = left ?? this.parsePrimary();
1216
+ const start = expr.span[0];
834
1217
  for (;;) {
835
1218
  if (this.at("dot")) {
836
1219
  this.next();
837
1220
  if (!this.at("ident")) this.fail("expected a property name after '.'");
838
1221
  const name = this.next().value;
839
1222
  if (this.at("lparen")) {
840
- expr = { kind: "call", recv: expr, name, args: this.parseArgs() };
1223
+ const args = this.parseArgs();
1224
+ expr = { kind: "call", span: this.sp(start), recv: expr, name, args };
841
1225
  } else {
842
- expr = { kind: "member", recv: expr, name };
1226
+ expr = { kind: "member", span: this.sp(start), recv: expr, name };
843
1227
  }
844
1228
  } else if (this.at("lparen") && left === undefined && expr.kind === "ident") {
845
1229
  // free function call: name(args) — a bare name read here, not `(f)(x)`
846
- expr = { kind: "call", recv: null, name: expr.name, args: this.parseArgs() };
847
- } else if (this.at("lbrace")) {
848
- break; // a consumer block boundary — not part of a value expression
1230
+ const args = this.parseArgs();
1231
+ expr = { kind: "call", span: this.sp(start), recv: null, name: expr.name, args };
1232
+ } else if (this.at("lbracket")) {
1233
+ expr = this.parseBracket(expr);
1234
+ } else if (this.atOp("!")) {
1235
+ // Postfix `!`: a `!` right after a complete chain is `required` — OQX
1236
+ // never places two operands side by side, so it cannot begin one here.
1237
+ const bang = this.next();
1238
+ expr = { kind: "required", span: [start, bang.end], expr };
1239
+ } else if (this.atValueDirective() || this.at("lbrace") || (this.at("ident", "distinct") && this.peekAt(1)?.type === "lbrace")) {
1240
+ // A value-position directive, `recv first { … }` — how a bracket chain
1241
+ // prints back — or the implicit `collect` of a bare `{ … }` / `distinct
1242
+ // { … }` (0.20: the receiver-block sugar stands wherever a value does,
1243
+ // `size(children { is checked })`). exists/none/count have no value form
1244
+ // and are left to the body's own error.
1245
+ if (expr.kind === "lit") this.fail(`\`${describe(expr)}\` is a literal, not a collection`);
1246
+ expr = this.parseConsumerBlock(expr, this.atBlock()!, false);
849
1247
  } else {
850
- break;
1248
+ break; // anything else ends the chain
851
1249
  }
852
1250
  }
853
1251
  return expr;
854
1252
  }
855
1253
 
1254
+ private atValueDirective(): boolean {
1255
+ const op = this.atConsumerBlock();
1256
+ return op === "collect" || op === "first" || op === "single";
1257
+ }
1258
+
856
1259
  private parseArgs(): Expr[] {
857
1260
  this.next(); // '('
858
1261
  const args: Expr[] = [];
@@ -871,27 +1274,37 @@ class Parser {
871
1274
  // select-item head `^name:` is a lift, handled in parseSelectItem; here, in
872
1275
  // expression position, `^` reads an enclosing row's field even when the
873
1276
  // current row shadows the name.)
874
- if (t.type === "caret") {
875
- const levels = this.parseCarets();
1277
+ if (t.type === "caret" || this.atAbsoluteRef()) {
1278
+ const levels = this.parseScopeRef();
876
1279
  if (!this.at("ident")) this.fail("expected an identifier after '^' (an outer reference)");
877
- return { kind: "outer", levels, name: this.next().value };
1280
+ const name = this.next();
1281
+ return { kind: "outer", span: [t.pos, name.end], levels, name: name.value };
878
1282
  }
879
- if (t.type === "number") { this.next(); return { kind: "lit", value: Number(t.value) }; }
880
- if (t.type === "string") { this.next(); return { kind: "lit", value: t.value }; }
881
- if (t.type === "binding") { this.next(); return { kind: "binding", index: t.index! }; }
1283
+ if (t.type === "number") { this.next(); return { kind: "lit", span: [t.pos, t.end], value: Number(t.value) }; }
1284
+ if (t.type === "string") { this.next(); return { kind: "lit", span: [t.pos, t.end], value: t.value }; }
1285
+ if (t.type === "binding") { this.next(); return this.bindingNode(t); }
882
1286
  if (t.type === "lparen") {
883
1287
  this.next();
884
1288
  const e = this.parseValueExpr();
1289
+ if (this.at("comma")) {
1290
+ // A tuple `(e1, e2, …)` (0.20): two or more elements.
1291
+ const items = [e];
1292
+ while (this.at("comma")) { this.next(); items.push(this.parseValueExpr()); }
1293
+ if (!this.at("rparen")) this.fail("expected ')' to close the tuple");
1294
+ this.next();
1295
+ return { kind: "tuple", span: [t.pos, this.end()], items };
1296
+ }
885
1297
  if (!this.at("rparen")) this.fail("expected ')'");
886
1298
  this.next();
887
- return e;
1299
+ return withSpan(e, [t.pos, this.end()]);
888
1300
  }
889
1301
  if (t.type === "ident") {
890
1302
  this.next();
891
- if (t.value === "true") return { kind: "lit", value: true };
892
- if (t.value === "false") return { kind: "lit", value: false };
893
- if (t.value === "null") return { kind: "lit", value: null };
894
- return { kind: "ident", name: t.value };
1303
+ const span: Span = [t.pos, t.end];
1304
+ if (t.value === "true") return { kind: "lit", span, value: true };
1305
+ if (t.value === "false") return { kind: "lit", span, value: false };
1306
+ if (t.value === "null") return { kind: "lit", span, value: null };
1307
+ return { kind: "ident", span, name: t.value };
895
1308
  }
896
1309
  this.fail(`unexpected ${this.tokDesc()} — expected a value`);
897
1310
  }
@@ -904,25 +1317,44 @@ function navKey(e: Expr): string | null {
904
1317
  case "ident": return e.name;
905
1318
  case "outer": return e.name;
906
1319
  case "member": return e.name;
1320
+ case "required": return navKey(e.expr);
907
1321
  default: return null;
908
1322
  }
909
1323
  }
910
1324
 
911
- // `!` applied `n` times to a where node.
912
- function wrapNot(w: Where, n: number): Where {
913
- for (let i = 0; i < n; i++) w = { kind: "not", expr: w };
1325
+ // Whether a block's leading run, read as a where tree, is syntactically a
1326
+ // predicate (GRAMMAR §2): anything but a bare name, a dotted navigation or a lift
1327
+ // (an `outer`), with a parenthesized leading expression always a predicate.
1328
+ function predicateShaped(w: Where, first: Token): boolean {
1329
+ if (first.type === "lparen") return true;
1330
+ if (w.kind !== "scalar") return true;
1331
+ return !isNavigation(w.expr);
1332
+ }
1333
+
1334
+ function isNavigation(e: Expr): boolean {
1335
+ switch (e.kind) {
1336
+ case "ident": case "outer": return true;
1337
+ case "member": return isNavigation(e.recv);
1338
+ default: return false;
1339
+ }
1340
+ }
1341
+
1342
+ // The canonical text of a node for an error message; a binding shows as its
1343
+ // `${n}` marker (the printer refuses to turn a bound value into source).
1344
+ function describe(node: Expr | Where): string {
1345
+ const t = printTemplate(node);
1346
+ return t.strings.map((s, i) => (i < t.count ? `${s}\${${t.indices[i]}}` : s)).join("");
1347
+ }
1348
+
1349
+ // `!` applied once per recorded `!` position to a where node, innermost first.
1350
+ function wrapNot(w: Where, notStarts: readonly number[]): Where {
1351
+ for (let i = notStarts.length - 1; i >= 0; i--) w = { kind: "not", span: [notStarts[i]!, w.span[1]], expr: w };
914
1352
  return w;
915
1353
  }
916
1354
 
917
1355
  // A scalar where leaf. A leading `!` on the whole leaf becomes a `not` node (so
918
1356
  // `where !active` keeps its shape); inside a comparison it stays a unary `!`.
919
1357
  function scalarLeaf(e: Expr): Where {
920
- if (e.kind === "unary" && e.op === "!") return { kind: "not", expr: scalarLeaf(e.expr) };
921
- return { kind: "scalar", expr: e };
922
- }
923
-
924
- // The optional `limit`/`offset` fields of a Query/Subquery, present only when set
925
- // (so a Query built without them is unchanged).
926
- function bounds(b: { limit?: Expr | null; offset?: Expr | null }): { limit?: Expr; offset?: Expr } {
927
- return { ...(b.limit ? { limit: b.limit } : {}), ...(b.offset ? { offset: b.offset } : {}) };
1358
+ if (e.kind === "unary" && e.op === "!") return { kind: "not", span: e.span, expr: scalarLeaf(e.expr) };
1359
+ return { kind: "scalar", span: e.span, expr: e };
928
1360
  }