@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/errors.ts CHANGED
@@ -1,9 +1,10 @@
1
- // A single error type for every OQX failure — lex, parse, and evaluation. `stage`
1
+ // A single error type for every OQX failure — lex, parse, evaluation, and
2
+ // printing (a binding has no source text). `stage`
2
3
  // distinguishes where it came from so callers (and tests) can branch without
3
4
  // string-matching messages. Mirrors the reference impl's FilterInvalid, but the
4
5
  // generic kernel has no SQL/CEL layer to name.
5
6
 
6
- export type OqxStage = "lex" | "parse" | "eval";
7
+ export type OqxStage = "lex" | "parse" | "eval" | "print";
7
8
 
8
9
  export class OqxError extends Error {
9
10
  readonly stage: OqxStage;
package/src/index.ts CHANGED
@@ -18,21 +18,26 @@
18
18
  // with `PlannedEngine` finishing the residual in-memory.
19
19
  // All backends must obey the scalar rules in `./semantics.ts` (the conformance
20
20
  // suite verifies this).
21
+ //
22
+ // The parsed `Query` is a first-class AST (spec/oqx/AST.md): spans on every
23
+ // node, `visit`/`transform` to walk it, `print` to write it back, `toJSON` for
24
+ // the shared JSON shape. `where` keeps its surface form; the entry points below
25
+ // (`oqx`, `parse` + `run`, `execute`, `runQuery`) apply `resolveAliases` exactly
26
+ // once before evaluation — an `Engine` evaluates the query it is given.
21
27
 
22
28
  import type { Query } from "./ast.ts";
23
29
  import { parseTemplate, parseString } from "./parser.ts";
24
30
  import type { Engine, OqxResult } from "./engine.ts";
25
- import { InMemoryEngine, runQuery } from "./engine.ts";
31
+ import { InMemoryEngine } from "./engine.ts";
26
32
  import type { DataContext } from "./context.ts";
27
33
  import { DefaultContext } from "./context.ts";
34
+ import { resolveAliases } from "./resolve.ts";
28
35
 
29
36
  export { OqxError } from "./errors.ts";
30
-
31
- /** The OQX spec version (`spec/oqx/VERSION`, `major.minor`) this implementation
32
- * conforms to — distinct from the package's own semver. */
33
- export const LANGUAGE_VERSION = "0.15";
37
+ export type { OqxStage } from "./errors.ts";
38
+ export { LANGUAGE_VERSION } from "./version.ts";
34
39
  export type { OqxResult, Engine, InMemoryEngineOptions, TraceEvent } from "./engine.ts";
35
- export { InMemoryEngine, runQuery } from "./engine.ts";
40
+ export { InMemoryEngine } from "./engine.ts";
36
41
  export type { BlockPlan, Correlation, Rule, RuleContext, RowIndex } from "./optimize/index.ts";
37
42
  export {
38
43
  DEFAULT_RULES, correlatedEqualityProbe, stableReceiver, invariantBlock, cardinalityOnly,
@@ -48,9 +53,19 @@ export { IndexedCollection } from "./adapters/indexed.ts";
48
53
  export { ROWS_ROOT, partitionPushable, residualQuery, asEquality, isConst, constValue } from "./plan.ts";
49
54
  export * as semantics from "./semantics.ts";
50
55
  export type * from "./ast.ts";
56
+ export { isExpr } from "./ast.ts";
57
+ export { parseTemplate } from "./parser.ts";
58
+ export { rawSource, toUtf16, codePointLength } from "./lexer.ts";
59
+ export { resolveAliases } from "./resolve.ts";
60
+ export type { Clause, VisitContext, Visitor, ChildKey, Stripped } from "./walk.ts";
61
+ export { CHILDREN, visit, transform, stripSpans, toJSON } from "./walk.ts";
62
+ export type { Template } from "./print.ts";
63
+ export { print, printTemplate } from "./print.ts";
64
+ export * as build from "./build.ts";
51
65
 
52
66
  // Compiled-query cache keyed by the template's stable `strings` identity, so the
53
- // same call site parses once and re-runs with fresh bindings.
67
+ // same call site parses (and resolves its aliases) once and re-runs with fresh
68
+ // bindings.
54
69
  const templateCache = new WeakMap<TemplateStringsArray, Query>();
55
70
 
56
71
  /** The OQX tagged template. Returns the query result shaped by its consumer:
@@ -59,13 +74,13 @@ const templateCache = new WeakMap<TemplateStringsArray, Query>();
59
74
  export function oqx(strings: TemplateStringsArray, ...values: unknown[]): unknown {
60
75
  let query = templateCache.get(strings);
61
76
  if (!query) {
62
- query = parseTemplate(strings, values.length);
77
+ query = resolveAliases(parseTemplate(strings, values.length));
63
78
  templateCache.set(strings, query);
64
79
  }
65
- return unwrap(runQuery(query, values, undefined));
80
+ return unwrap(new InMemoryEngine(new DefaultContext()).run(query, values));
66
81
  }
67
82
 
68
- /** Parse a query string into a reusable AST. */
83
+ /** Parse a query string into a reusable AST (spec/oqx/AST.md). */
69
84
  export function parse(source: string): Query {
70
85
  return parseString(source);
71
86
  }
@@ -78,20 +93,31 @@ export function execute(source: string, roots?: Record<string, unknown>): unknow
78
93
 
79
94
  /** Run a pre-parsed query with explicit bindings and/or a backend, returning the
80
95
  * full discriminated result. Provide `engine` (any `Engine`), or `context` (a
81
- * `DataContext`, run in-memory), or `roots` (plain-object named roots). */
96
+ * `DataContext`, run in-memory), or `roots` (plain-object named roots). The
97
+ * query's `select` aliases are resolved here (`resolveAliases`), once, before
98
+ * the engine sees it. */
82
99
  export function run(
83
100
  query: Query,
84
101
  opts: { values?: readonly unknown[]; roots?: Record<string, unknown>; context?: DataContext; engine?: Engine } = {},
85
102
  ): OqxResult {
86
103
  const values = opts.values ?? [];
87
- if (opts.engine) return opts.engine.run(query, values);
104
+ const resolved = resolveAliases(query);
105
+ if (opts.engine) return opts.engine.run(resolved, values);
88
106
  const context = opts.context ?? new DefaultContext(opts.roots ?? {});
89
- return new InMemoryEngine(context).run(query, values);
107
+ return new InMemoryEngine(context).run(resolved, values);
108
+ }
109
+
110
+ /** Convenience: resolve the query's aliases and run it in-memory over
111
+ * plain-object roots (the default context). */
112
+ export function runQuery(query: Query, bindings: readonly unknown[], roots: unknown): OqxResult {
113
+ const ctx = new DefaultContext((roots as Record<string, unknown>) ?? {});
114
+ return new InMemoryEngine(ctx).run(resolveAliases(query), bindings);
90
115
  }
91
116
 
92
117
  function unwrap(result: OqxResult): unknown {
93
118
  switch (result.consumer) {
94
- case "collect": return result.rows;
119
+ // A `collect` over one object is that one record, or `null` (SEMANTICS §3).
120
+ case "collect": return result.single ? (result.rows[0] ?? null) : result.rows;
95
121
  case "exists": return result.exists;
96
122
  case "none": return result.none;
97
123
  case "count": return result.count;
package/src/lexer.ts CHANGED
@@ -11,18 +11,27 @@
11
11
  // host-bindings design note calls for. (A consequence: `${x}` inside a string
12
12
  // literal does not interpolate — that fragment would be an unterminated string —
13
13
  // which is exactly the desired "interpolation is a value, never source text".)
14
+ //
15
+ // Positions (`Token.pos` / `Token.end`, the `span` of every AST node, and the
16
+ // offsets quoted in error messages) count Unicode CODE POINTS over the raw
17
+ // source (`rawSource` for a template, where a binding occupies its `${n}`
18
+ // marker) — not UTF-16 code units — so the two implementations agree on every
19
+ // offset (spec/oqx/AST.md §3). `toUtf16` converts a span for a UTF-16 editor.
14
20
 
15
21
  import { OqxError } from "./errors.ts";
22
+ import type { Span } from "./ast.ts";
16
23
 
17
24
  export type TokType =
18
25
  | "ident"
19
- | "kw" // from | where | select
26
+ | "kw" // from | where | select | is | not | and | or
20
27
  | "string"
21
28
  | "number"
22
29
  | "lparen"
23
30
  | "rparen"
24
31
  | "lbrace"
25
32
  | "rbrace"
33
+ | "lbracket" // [ — a bracket lookup (since 0.17)
34
+ | "rbracket"
26
35
  | "comma"
27
36
  | "colon"
28
37
  | "caret" // ^ — one-scope lift marker
@@ -34,20 +43,33 @@ export type TokType =
34
43
 
35
44
  export interface Token {
36
45
  type: TokType;
46
+ /** The source text of the token, except: a `string` token carries its decoded
47
+ * value (quotes stripped, escapes resolved); a `binding` carries the display
48
+ * marker `${N}`; `eof` carries `""`. */
37
49
  value: string;
50
+ /** Code-point offset of the token's first character. */
38
51
  pos: number;
52
+ /** Code-point offset just past the token's last character (`[pos, end)`). */
53
+ end: number;
39
54
  index?: number; // binding tokens only
40
55
  }
41
56
 
42
- const KEYWORDS = new Set(["from", "where", "select"]);
57
+ const KEYWORDS = new Set(["from", "where", "select", "is", "not", "and", "or"]);
43
58
 
44
59
  // Multi-char operators, longest first (the scanner tries these before singles).
45
60
  const MULTI_OPS = ["==", "!=", "<=", ">=", "&&", "||"];
46
61
  const SINGLE_OPS = new Set(["<", ">", "!", "+", "-", "*", "/", "%"]);
47
62
 
48
- const isIdentStart = (c: string): boolean => /[A-Za-z_$]/.test(c);
49
- const isIdentPart = (c: string): boolean => /[A-Za-z0-9_$]/.test(c);
50
- const isDigit = (c: string): boolean => c >= "0" && c <= "9";
63
+ const isIdentStart = (c: string): boolean => /^[A-Za-z_$]$/.test(c);
64
+ const isIdentPart = (c: string): boolean => /^[A-Za-z0-9_$]$/.test(c);
65
+ const isDigit = (c: string): boolean => c.length === 1 && c >= "0" && c <= "9";
66
+
67
+ /** The length of a string in Unicode code points. */
68
+ export function codePointLength(s: string): number {
69
+ let n = 0;
70
+ for (const _ of s) n++;
71
+ return n;
72
+ }
51
73
 
52
74
  /** Lex a tagged-template call: the cooked string fragments and the count of
53
75
  * interpolated values. Emits a single flat token stream with `binding` tokens
@@ -57,18 +79,18 @@ export function lexTemplate(fragments: readonly string[], values: number): Token
57
79
  let base = 0; // running offset across fragments + rendered `${…}` markers
58
80
  for (let f = 0; f < fragments.length; f++) {
59
81
  lexFragment(fragments[f]!, base, tokens);
60
- base += fragments[f]!.length;
82
+ base += codePointLength(fragments[f]!);
61
83
  if (f < fragments.length - 1) {
62
84
  // account for the value's rendered width in the display source (see rawSource)
63
85
  const marker = `\${${f}}`;
64
- tokens.push({ type: "binding", value: marker, pos: base, index: f });
86
+ tokens.push({ type: "binding", value: marker, pos: base, end: base + marker.length, index: f });
65
87
  base += marker.length;
66
88
  }
67
89
  }
68
90
  if (values !== fragments.length - 1) {
69
91
  throw new OqxError(`template arity mismatch: ${fragments.length} fragments, ${values} values`, "lex");
70
92
  }
71
- tokens.push({ type: "eof", value: "", pos: base });
93
+ tokens.push({ type: "eof", value: "", pos: base, end: base });
72
94
  return tokens;
73
95
  }
74
96
 
@@ -76,36 +98,43 @@ export function lexTemplate(fragments: readonly string[], values: number): Token
76
98
  export function lexString(src: string): Token[] {
77
99
  const tokens: Token[] = [];
78
100
  lexFragment(src, 0, tokens);
79
- tokens.push({ type: "eof", value: "", pos: src.length });
101
+ const n = codePointLength(src);
102
+ tokens.push({ type: "eof", value: "", pos: n, end: n });
80
103
  return tokens;
81
104
  }
82
105
 
83
106
  function lexFragment(src: string, base: number, out: Token[]): void {
107
+ // One entry per code point, so every index below is a code-point offset.
108
+ const cps = Array.from(src);
84
109
  let i = 0;
85
- const n = src.length;
86
- const push = (type: TokType, value: string, at: number): void => {
87
- out.push({ type, value, pos: base + at });
110
+ const n = cps.length;
111
+ const at = (j: number): string => cps[j] ?? "";
112
+ const text = (from: number, to: number): string => cps.slice(from, to).join("");
113
+ const push = (type: TokType, value: string, start: number, end: number): void => {
114
+ out.push({ type, value, pos: base + start, end: base + end });
88
115
  };
89
116
 
90
117
  while (i < n) {
91
- const c = src[i]!;
118
+ const c = cps[i]!;
92
119
 
93
120
  if (c === " " || c === "\t" || c === "\n" || c === "\r") { i++; continue; }
94
121
 
95
- if (c === "(") { push("lparen", c, i); i++; continue; }
96
- if (c === ")") { push("rparen", c, i); i++; continue; }
97
- if (c === "{") { push("lbrace", c, i); i++; continue; }
98
- if (c === "}") { push("rbrace", c, i); i++; continue; }
99
- if (c === ",") { push("comma", c, i); i++; continue; }
100
- if (c === ":") { push("colon", c, i); i++; continue; }
101
- if (c === "^") { push("caret", c, i); i++; continue; }
122
+ if (c === "(") { push("lparen", c, i, i + 1); i++; continue; }
123
+ if (c === ")") { push("rparen", c, i, i + 1); i++; continue; }
124
+ if (c === "{") { push("lbrace", c, i, i + 1); i++; continue; }
125
+ if (c === "}") { push("rbrace", c, i, i + 1); i++; continue; }
126
+ if (c === "[") { push("lbracket", c, i, i + 1); i++; continue; }
127
+ if (c === "]") { push("rbracket", c, i, i + 1); i++; continue; }
128
+ if (c === ",") { push("comma", c, i, i + 1); i++; continue; }
129
+ if (c === ":") { push("colon", c, i, i + 1); i++; continue; }
130
+ if (c === "^") { push("caret", c, i, i + 1); i++; continue; }
102
131
 
103
132
  // range operator — `...` (exclusive end) or `..` (inclusive), longest first.
104
133
  // Scanned before the dot rule so `a..b` never looks like member navigation,
105
134
  // and before the number rule so the bounds lex as separate numbers.
106
- if (c === "." && src[i + 1] === ".") {
107
- if (src[i + 2] === ".") { push("range", "...", i); i += 3; continue; }
108
- push("range", "..", i); i += 2; continue;
135
+ if (c === "." && at(i + 1) === ".") {
136
+ if (at(i + 2) === ".") { push("range", "...", i, i + 3); i += 3; continue; }
137
+ push("range", "..", i, i + 2); i += 2; continue;
109
138
  }
110
139
 
111
140
  // `.` followed by a digit is neither navigation (a property name cannot start
@@ -113,12 +142,12 @@ function lexFragment(src: string, base: number, out: Token[]): void {
113
142
  // malformed number, reported as such rather than surfacing as a confusing
114
143
  // parse error downstream. Any other `.` is member navigation.
115
144
  if (c === ".") {
116
- if (isDigit(src[i + 1] ?? "")) {
117
- const end = scanNumberTail(src, i + 1, base);
118
- const lit = src.slice(i, end);
145
+ if (isDigit(at(i + 1))) {
146
+ const end = scanNumberTail(cps, i + 1, base);
147
+ const lit = text(i, end);
119
148
  throw new OqxError(`malformed number ${JSON.stringify(lit)} at ${base + i} — a number starts with a digit (write 0${lit}), and a property name cannot be a digit (there is no index access)`, "lex");
120
149
  }
121
- push("dot", c, i); i++; continue;
150
+ push("dot", c, i, i + 1); i++; continue;
122
151
  }
123
152
 
124
153
  // string literal — decode into its VALUE (quotes stripped, escapes resolved).
@@ -127,18 +156,18 @@ function lexFragment(src: string, base: number, out: Token[]): void {
127
156
  const start = i;
128
157
  i++;
129
158
  let sval = "";
130
- while (i < n && src[i] !== quote) {
131
- if (src[i] === "\\") {
159
+ while (i < n && cps[i] !== quote) {
160
+ if (cps[i] === "\\") {
132
161
  i++;
133
- sval += unescape(src[i]);
162
+ sval += unescape(cps[i]);
134
163
  } else {
135
- sval += src[i];
164
+ sval += cps[i]!;
136
165
  }
137
166
  i++;
138
167
  }
139
168
  if (i >= n) throw new OqxError(`unterminated string literal at ${base + start}`, "lex");
140
169
  i++; // closing quote
141
- out.push({ type: "string", value: sval, pos: base + start });
170
+ push("string", sval, start, i);
142
171
  continue;
143
172
  }
144
173
 
@@ -149,23 +178,23 @@ function lexFragment(src: string, base: number, out: Token[]): void {
149
178
  // NaN or a dangling dot.
150
179
  if (isDigit(c)) {
151
180
  const start = i;
152
- i = scanNumberTail(src, i, base);
153
- push("number", src.slice(start, i), start);
181
+ i = scanNumberTail(cps, i, base);
182
+ push("number", text(start, i), start, i);
154
183
  continue;
155
184
  }
156
185
 
157
186
  // operators (multi-char first)
158
- const two = src.slice(i, i + 2);
159
- if (MULTI_OPS.includes(two)) { push("op", two, i); i += 2; continue; }
160
- if (SINGLE_OPS.has(c)) { push("op", c, i); i++; continue; }
187
+ const two = c + at(i + 1);
188
+ if (MULTI_OPS.includes(two)) { push("op", two, i, i + 2); i += 2; continue; }
189
+ if (SINGLE_OPS.has(c)) { push("op", c, i, i + 1); i++; continue; }
161
190
 
162
191
  // identifier / keyword
163
192
  if (isIdentStart(c)) {
164
193
  const start = i;
165
194
  i++;
166
- while (i < n && isIdentPart(src[i]!)) i++;
167
- const word = src.slice(start, i);
168
- push(KEYWORDS.has(word) ? "kw" : "ident", word, start);
195
+ while (i < n && isIdentPart(cps[i]!)) i++;
196
+ const word = text(start, i);
197
+ push(KEYWORDS.has(word) ? "kw" : "ident", word, start, i);
169
198
  continue;
170
199
  }
171
200
 
@@ -176,24 +205,25 @@ function lexFragment(src: string, base: number, out: Token[]): void {
176
205
  // Scan a number whose first digit is at `i`; return the index just past it.
177
206
  // Throws the malformed-number lex error for a trailing decimal point or an
178
207
  // exponent without digits.
179
- function scanNumberTail(src: string, i: number, base = 0): number {
208
+ function scanNumberTail(cps: readonly string[], i: number, base = 0): number {
180
209
  const start = i;
181
- const n = src.length;
210
+ const n = cps.length;
211
+ const at = (j: number): string => cps[j] ?? "";
182
212
  const fail = (end: number, why: string): never => {
183
- throw new OqxError(`malformed number ${JSON.stringify(src.slice(start, end))} at ${base + start} — ${why}`, "lex");
213
+ throw new OqxError(`malformed number ${JSON.stringify(cps.slice(start, end).join(""))} at ${base + start} — ${why}`, "lex");
184
214
  };
185
- while (i < n && isDigit(src[i]!)) i++;
186
- if (src[i] === "." && src[i + 1] !== ".") {
187
- if (!isDigit(src[i + 1] ?? "")) fail(i + 1, "a decimal point needs a digit after it (write 1.0, not 1.)");
215
+ while (i < n && isDigit(cps[i]!)) i++;
216
+ if (at(i) === "." && at(i + 1) !== ".") {
217
+ if (!isDigit(at(i + 1))) fail(i + 1, "a decimal point needs a digit after it (write 1.0, not 1.)");
188
218
  i++;
189
- while (i < n && isDigit(src[i]!)) i++;
219
+ while (i < n && isDigit(cps[i]!)) i++;
190
220
  }
191
- if (src[i] === "e" || src[i] === "E") {
221
+ if (at(i) === "e" || at(i) === "E") {
192
222
  let j = i + 1;
193
- if (src[j] === "+" || src[j] === "-") j++;
194
- if (!isDigit(src[j] ?? "")) fail(j, "an exponent needs at least one digit (write 1e5)");
223
+ if (at(j) === "+" || at(j) === "-") j++;
224
+ if (!isDigit(at(j))) fail(j, "an exponent needs at least one digit (write 1e5)");
195
225
  i = j;
196
- while (i < n && isDigit(src[i]!)) i++;
226
+ while (i < n && isDigit(cps[i]!)) i++;
197
227
  }
198
228
  return i;
199
229
  }
@@ -214,3 +244,24 @@ function unescape(c: string | undefined): string {
214
244
  export function rawSource(fragments: readonly string[]): string {
215
245
  return fragments.map((s, i) => (i < fragments.length - 1 ? `${s}\${${i}}` : s)).join("");
216
246
  }
247
+
248
+ /** Convert a code-point span (as every AST node carries) to UTF-16 code-unit
249
+ * offsets over `source` — the units a JavaScript string, a DOM range or an
250
+ * editor API counts in. `source` is the raw source the span was measured over
251
+ * (for a template, `rawSource(strings)`). Offsets past the end clamp. */
252
+ export function toUtf16(span: Span, source: string): Span {
253
+ const [start, end] = span;
254
+ let cp = 0;
255
+ let unit = 0;
256
+ let startUnit = -1;
257
+ let endUnit = -1;
258
+ for (const ch of source) {
259
+ if (cp === start) startUnit = unit;
260
+ if (cp === end) endUnit = unit;
261
+ cp++;
262
+ unit += ch.length;
263
+ }
264
+ if (startUnit < 0) startUnit = unit;
265
+ if (endUnit < 0) endUnit = unit;
266
+ return [startUnit, endUnit];
267
+ }
@@ -29,11 +29,12 @@ export function exprReadsScopeIn(e: Expr, at: number, upTo: number): boolean {
29
29
  case "ident": return at >= 1 && at <= upTo;
30
30
  case "outer": { const d = at - e.levels; return d >= 1 && d <= upTo; }
31
31
  case "member": return exprReadsScopeIn(e.recv, at, upTo);
32
- case "index": return exprReadsScopeIn(e.recv, at, upTo) || exprReadsScopeIn(e.index, at, upTo);
33
32
  case "call": return (e.recv !== null && exprReadsScopeIn(e.recv, at, upTo)) || e.args.some((a) => exprReadsScopeIn(a, at, upTo));
34
- case "unary": return exprReadsScopeIn(e.expr, at, upTo);
33
+ case "unary": case "required": return exprReadsScopeIn(e.expr, at, upTo);
34
+ case "op": return opReadsScopeIn(e, at, upTo);
35
35
  case "binary": case "logical": case "in": return exprReadsScopeIn(e.left, at, upTo) || exprReadsScopeIn(e.right, at, upTo);
36
36
  case "range": return (e.lo !== null && exprReadsScopeIn(e.lo, at, upTo)) || (e.hi !== null && exprReadsScopeIn(e.hi, at, upTo));
37
+ case "tuple": return e.items.some((x) => exprReadsScopeIn(x, at, upTo));
37
38
  }
38
39
  }
39
40
 
@@ -47,22 +48,26 @@ export function whereReadsScopeIn(w: Where, at: number, upTo: number): boolean {
47
48
  }
48
49
 
49
50
  /** A directive evaluated in a scope at depth `at`: its receiver is read there;
50
- * its rows are scopes at `at + 1`. */
51
- export function opReadsScopeIn(op: OpNode, at: number, upTo: number): boolean {
52
- return exprReadsScopeIn(op.receiver, at, upTo) || subReadsScopeIn(op.sub, at + 1, upTo);
51
+ * its rows are scopes at `at + 1`. `destination` marks a follow destination
52
+ * block (its `frontier` is read one level out). */
53
+ export function opReadsScopeIn(op: OpNode, at: number, upTo: number, destination = false): boolean {
54
+ return exprReadsScopeIn(op.receiver, at, upTo) || subReadsScopeIn(op.sub, at + 1, upTo, destination);
53
55
  }
54
56
 
55
57
  /** A block body whose rows are scopes at depth `at`. `from` re-projections,
56
58
  * `where`, `select`, `order by` and the bound are read in the row scope (the
57
- * bound in a row-less scope at the same depth); `follow` walks occurrences at
58
- * `at`, with its successor `where` one deeper (§20). */
59
- function subReadsScopeIn(sub: Subquery, at: number, upTo: number): boolean {
59
+ * bound in a row-less scope at the same depth); a destination block's
60
+ * `frontier` is read one level out, under the walk's enclosing scope (§20),
61
+ * any other block's `frontier` (its seeds') in the row scope like `where`;
62
+ * `follow` walks occurrences at `at`. */
63
+ function subReadsScopeIn(sub: Subquery, at: number, upTo: number, destination: boolean): boolean {
60
64
  if (sub.from.some((e) => exprReadsScopeIn(e, at, upTo))) return true;
61
65
  if (sub.where && whereReadsScopeIn(sub.where, at, upTo)) return true;
62
66
  if (sub.select.some((item) => itemReadsScopeIn(item, at, upTo))) return true;
63
67
  if (sub.orderBy?.some((o) => exprReadsScopeIn(o.expr, at, upTo))) return true;
64
68
  if (sub.limit && exprReadsScopeIn(sub.limit, at, upTo)) return true;
65
69
  if (sub.offset && exprReadsScopeIn(sub.offset, at, upTo)) return true;
70
+ if (sub.frontier && exprReadsScopeIn(sub.frontier, destination ? at - 1 : at, upTo)) return true;
66
71
  return sub.follow !== null && followReadsScopeIn(sub.follow, at, upTo);
67
72
  }
68
73
 
@@ -71,12 +76,7 @@ function itemReadsScopeIn(item: SelectItem, at: number, upTo: number): boolean {
71
76
  }
72
77
 
73
78
  function followReadsScopeIn(f: Follow, at: number, upTo: number): boolean {
74
- for (const d of f.destinations) {
75
- if (d.kind === "op" ? opReadsScopeIn(d, at, upTo) : exprReadsScopeIn(d, at, upTo)) return true;
76
- }
77
- if (f.where && exprReadsScopeIn(f.where, at + 1, upTo)) return true;
78
- if (f.frontier && exprReadsScopeIn(f.frontier, at, upTo)) return true;
79
- return f.by !== null && exprReadsScopeIn(f.by, at, upTo);
79
+ return f.destinations.some((d) => (d.kind === "op" ? opReadsScopeIn(d, at, upTo, true) : exprReadsScopeIn(d, at, upTo)));
80
80
  }
81
81
 
82
82
  /** Does `e` read the scope it is evaluated in (a bare identifier anywhere)? */
@@ -94,11 +94,13 @@ export function exprRaiseFree(e: Expr, ctx: RuleContext): boolean {
94
94
  case "lit": case "ident": case "outer": return true;
95
95
  case "binding": return e.index < ctx.bindingCount;
96
96
  case "call": return false;
97
+ case "required": return false; // raises when absent
98
+ case "op": return opRaiseFree(e, ctx); // a value-position directive projects: never raise-free
97
99
  case "member": return exprRaiseFree(e.recv, ctx);
98
- case "index": return exprRaiseFree(e.recv, ctx) && exprRaiseFree(e.index, ctx);
99
100
  case "unary": return exprRaiseFree(e.expr, ctx);
100
101
  case "binary": case "logical": case "in": return exprRaiseFree(e.left, ctx) && exprRaiseFree(e.right, ctx);
101
102
  case "range": return (e.lo === null || exprRaiseFree(e.lo, ctx)) && (e.hi === null || exprRaiseFree(e.hi, ctx));
103
+ case "tuple": return e.items.every((x) => exprRaiseFree(x, ctx));
102
104
  }
103
105
  }
104
106
 
@@ -43,7 +43,7 @@ export interface BlockPlan {
43
43
  readonly projection: { readonly select: readonly SelectItem[]; readonly values: boolean };
44
44
  readonly orderBy: readonly OrderSpec[] | null;
45
45
  readonly distinct: boolean;
46
- readonly bound: { readonly limit: Expr | undefined; readonly offset: Expr | undefined };
46
+ readonly bound: { readonly limit: Expr | null; readonly offset: Expr | null };
47
47
  readonly follow: Follow | null;
48
48
  /** Equalities answered by an index probe on the receiver (in conjunct order). */
49
49
  readonly correlated: readonly Correlation[];
@@ -85,9 +85,9 @@ export function logicalBlock(node: OpNode, depth: number): BlockPlan {
85
85
  receiver: node.receiver,
86
86
  from: sub.from,
87
87
  where: sub.where,
88
- projection: { select: sub.select, values: sub.values ?? false },
88
+ projection: { select: sub.select, values: sub.values },
89
89
  orderBy: sub.orderBy,
90
- distinct: node.distinct ?? false,
90
+ distinct: node.distinct,
91
91
  bound: { limit: sub.limit, offset: sub.offset },
92
92
  follow: sub.follow,
93
93
  correlated: [],
@@ -100,7 +100,7 @@ export function logicalBlock(node: OpNode, depth: number): BlockPlan {
100
100
 
101
101
  /** Rebuild a conjunction from its remaining parts. */
102
102
  export function conjunction(parts: readonly Where[]): Where | null {
103
- return parts.length === 0 ? null : parts.length === 1 ? parts[0]! : { kind: "and", parts: [...parts] };
103
+ return parts.length === 0 ? null : parts.length === 1 ? parts[0]! : { kind: "and", span: [0, 0], parts: [...parts] };
104
104
  }
105
105
 
106
106
  /** The top-level `&&` conjuncts of a where tree (a non-`and` tree is one). */