sqllens 1.9.0 → 1.10.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.
@@ -158,15 +158,29 @@ export declare class SqlDocument {
158
158
  * NOT replace the cache entry: the first occurrence keeps its stable cross-edit identity (the
159
159
  * common case), and only intra-doc duplicates — rare — pay a re-parse per build. */
160
160
  private buildCell;
161
- /** Build the ONE cell for a TEMPLATED document: span [0, text.length) the templated build path
162
- * bypasses `splitStatements` entirely (one cell, whole text), and its products come from a single
163
- * `engine.parse(text, dialect, { provider })` call rather than the plain per-dialect `parse()`.
164
- * `r.tokens`/`r.diagnostics` are ALREADY document coordinates (the cell always starts at 0), so —
165
- * unlike `buildCell` nothing is shifted. Mirrors `buildCell`'s CachedCell/StatementCell shapes
166
- * so every downstream consumer (analyze(), cellAt(), nodeAt()…) sees the same structure whether
167
- * the document is plain or templated. Cached in the SAME cross-edit `_cellCache` as plain cells,
168
- * under a prefixed key so a templated cell can never collide with a plain one for the same text. */
161
+ /** Build the WHOLE-TEXT cell of a TEMPLATED document: span [0, text.length), its products from a
162
+ * single `engine.parse(text, dialect, { provider })` call rather than the plain per-dialect
163
+ * `parse()`. It is the document's one cell when the placeholder splits into one statement (every
164
+ * single-statement model), and the carrier of the engine result (`templated`) that a multi-cell
165
+ * document's slices are built from. `r.tokens`/`r.diagnostics` are ALREADY document coordinates
166
+ * (the cell always starts at 0), so unlike `buildCell` nothing is shifted. Mirrors
167
+ * `buildCell`'s CachedCell/StatementCell shapes so every downstream consumer (analyze(), cellAt(),
168
+ * nodeAt()…) sees the same structure whether the document is plain or templated. Cached in the SAME
169
+ * cross-edit `_cellCache` as plain cells, under a prefixed key so a templated cell can never
170
+ * collide with a plain one for the same text. */
169
171
  private buildTemplatedCell;
172
+ /** Build one statement cell of a MULTI-cell templated document for `span` (a `splitStatements`
173
+ * span over the engine's placeholder). The cell is the plain parse of its placeholder slice,
174
+ * cell-relative like a plain cell, with the whole-document tags correlated onto it by
175
+ * `engine.parseCell` (provider-resolved source names + `template` markers, so the fill never
176
+ * reaches scope/qualify). Cached across edits like a plain cell: the key is the placeholder
177
+ * slice (what parsed) plus the raw slice (which tags sit inside it), the engine + provider
178
+ * version (what the tags resolve to) and `setsKey` (the `{% set %}`/`{% macro %}`/`{% for %}`
179
+ * tags anywhere in the text that steer a bare `{{ t }}` binding). The document-level products
180
+ * are projected onto the cell by span rather than re-derived: `tokens` is the unified SQL+jinja
181
+ * stream sliced, `diagnostics` the scrubbed set filtered (the last cell absorbs end-of-text),
182
+ * `errors` that count. `handedOut` dedupes intra-document duplicates exactly as `buildCell` does. */
183
+ private buildTemplatedSlice;
170
184
  /** Build a document for `text` in `dialect`. Total: never throws, even on broken / mid-edit input.
171
185
  * Starts a FRESH cell cache — cross-edit reuse comes from withText(), not create(). Pass
172
186
  * `templating` to parse through an injected TemplateEngine (jinja-SQL etc.) instead of the plain
@@ -295,8 +309,7 @@ export declare class SqlDocument {
295
309
  * subset, see `scopeOutputColumns`'s pipe doc comment. Falls through to this document's own
296
310
  * (single-arm) answer when there are no variants; there is no pre-existing single-doc
297
311
  * equivalent to delegate to, unlike unionSymbols/unionDiagnostics, so the no-variant case is
298
- * just the one-arm instance of the same algorithm. A MULTI-STATEMENT document (no variants: the
299
- * templated door always forces exactly one cell, so the two "multi" shapes never overlap) merges
312
+ * just the one-arm instance of the same algorithm. A MULTI-STATEMENT document (or arm) merges
300
313
  * every statement CELL's own CTEs instead, each shifted from cell-relative to DOCUMENT coordinates
301
314
  * (the same shift `analyze()` already applies to symbols/diagnostics for a multi-cell document);
302
315
  * the compound facade itself carries no CTEs, so cells are the real per-statement source. Memoized
@@ -315,13 +328,16 @@ export declare class SqlDocument {
315
328
  span: Span;
316
329
  }[];
317
330
  private buildUnionOutputColumns;
318
- /** One "arm" `unionCtes`/`unionOutputColumns` aggregate over, unified across the two shapes that
319
- * can each independently make a document "multi" (never both at once: the templated door always
320
- * builds exactly one statement cell, see its own comment above): a templated document's real
321
- * variants (each a full arm SqlDocument, already in DOCUMENT coordinates, zero shift), or, for a
322
- * plain multi-statement document, each statement CELL (cell-relative scopes, shifted to document
323
- * coordinates by the cell's start, mirroring `buildAnalysis`'s per-cell shift). A single-cell,
324
- * non-templated document is the trivial one-arm case of the same shape (zero shift, this
325
- * document's own scopes/qualification). */
331
+ /** The "arms" `unionCtes`/`unionOutputColumns` aggregate over, unified across the two shapes that
332
+ * make a document "multi", VARIANTS FIRST (ruled 2026-09-13): a templated document's real
333
+ * variants are the unit, each arm SqlDocument (already in DOCUMENT coordinates, since a
334
+ * realization is length-preserving) contributing its own statement cells through `ownArms`; a
335
+ * document without variants contributes its own cells the same way. A region can span a cell
336
+ * boundary, so cells-first (arms inside each cell) is not an option. */
326
337
  private armsData;
338
+ /** This document's own arms, variants ignored: each statement CELL of a multi-cell document
339
+ * (cell-relative scopes, shifted to document coordinates by the cell's start, mirroring
340
+ * `buildAnalysis`'s per-cell shift), or the single-cell document itself (zero shift, its own
341
+ * scopes/qualification). */
342
+ private ownArms;
327
343
  }
@@ -155,12 +155,35 @@ export class SqlDocument {
155
155
  this._provider = opts.provider;
156
156
  let cells;
157
157
  let backing;
158
+ let templated;
158
159
  if (opts.templating) {
159
- // The templated door: ONE cell spanning the whole text, bypassing splitStatements —
160
- // its products come from the engine, not the plain per-dialect parse (see buildTemplatedCell).
161
- const built = this.buildTemplatedCell(text, opts.templating, opts.provider);
162
- cells = [built.cell];
163
- backing = [built.cached];
160
+ // The templated door: the engine runs ONCE over the whole text (buildTemplatedCell), then the
161
+ // placeholder it saw is split into statement cells exactly like a plain document's text. The
162
+ // fill is length- and newline-preserving, so the spans are document coordinates already, and
163
+ // each cell is the plain parse of its placeholder slice with the whole-document tags
164
+ // correlated onto it by the engine (buildTemplatedSlice). One span (every single-statement
165
+ // model, or a tiling failure) keeps the whole-text cell itself: byte-identical products,
166
+ // same cache entry as before cells existed on this door.
167
+ const whole = this.buildTemplatedCell(text, opts.templating, opts.provider);
168
+ const r = whole.cached.templated;
169
+ const spans = opts.templating.parseCell ? splitStatements(r.placeholder, dialect) : [whole.cell.span];
170
+ if (spans.length === 1) {
171
+ cells = [whole.cell];
172
+ backing = [whole.cached];
173
+ templated = r;
174
+ }
175
+ else {
176
+ const setsKey = setResolutionKey(r, text);
177
+ const handedOut = new Set();
178
+ cells = [];
179
+ backing = [];
180
+ for (const span of spans) {
181
+ const built = this.buildTemplatedSlice(span, r, opts.templating, opts.provider, setsKey, handedOut);
182
+ cells.push(built.cell);
183
+ backing.push(built.cached);
184
+ }
185
+ templated = withCellCorrelation(r, backing);
186
+ }
164
187
  }
165
188
  else {
166
189
  // Split into per-statement cells and parse each independently, reusing unchanged cells from
@@ -178,7 +201,7 @@ export class SqlDocument {
178
201
  }
179
202
  this.statements = Object.freeze(cells);
180
203
  this._cells = backing;
181
- this.templated = opts.templating ? backing[0].templated : undefined;
204
+ this.templated = templated;
182
205
  // Whole-document facade. tokens/diagnostics/errors are the cheap concat/sum across cells.
183
206
  this.tokens = cells.flatMap((c) => c.tokens);
184
207
  this.diagnostics = cells.flatMap((c) => c.diagnostics);
@@ -254,14 +277,16 @@ export class SqlDocument {
254
277
  });
255
278
  return { cell, cached };
256
279
  }
257
- /** Build the ONE cell for a TEMPLATED document: span [0, text.length) the templated build path
258
- * bypasses `splitStatements` entirely (one cell, whole text), and its products come from a single
259
- * `engine.parse(text, dialect, { provider })` call rather than the plain per-dialect `parse()`.
260
- * `r.tokens`/`r.diagnostics` are ALREADY document coordinates (the cell always starts at 0), so —
261
- * unlike `buildCell` nothing is shifted. Mirrors `buildCell`'s CachedCell/StatementCell shapes
262
- * so every downstream consumer (analyze(), cellAt(), nodeAt()…) sees the same structure whether
263
- * the document is plain or templated. Cached in the SAME cross-edit `_cellCache` as plain cells,
264
- * under a prefixed key so a templated cell can never collide with a plain one for the same text. */
280
+ /** Build the WHOLE-TEXT cell of a TEMPLATED document: span [0, text.length), its products from a
281
+ * single `engine.parse(text, dialect, { provider })` call rather than the plain per-dialect
282
+ * `parse()`. It is the document's one cell when the placeholder splits into one statement (every
283
+ * single-statement model), and the carrier of the engine result (`templated`) that a multi-cell
284
+ * document's slices are built from. `r.tokens`/`r.diagnostics` are ALREADY document coordinates
285
+ * (the cell always starts at 0), so unlike `buildCell` nothing is shifted. Mirrors
286
+ * `buildCell`'s CachedCell/StatementCell shapes so every downstream consumer (analyze(), cellAt(),
287
+ * nodeAt()…) sees the same structure whether the document is plain or templated. Cached in the SAME
288
+ * cross-edit `_cellCache` as plain cells, under a prefixed key so a templated cell can never
289
+ * collide with a plain one for the same text. */
265
290
  buildTemplatedCell(text, engine, provider) {
266
291
  const span = { start: 0, end: text.length };
267
292
  // Collision-proofed against a plain cell's `dialect + " " + text` key by the "templated "
@@ -301,6 +326,64 @@ export class SqlDocument {
301
326
  });
302
327
  return { cell, cached };
303
328
  }
329
+ /** Build one statement cell of a MULTI-cell templated document for `span` (a `splitStatements`
330
+ * span over the engine's placeholder). The cell is the plain parse of its placeholder slice,
331
+ * cell-relative like a plain cell, with the whole-document tags correlated onto it by
332
+ * `engine.parseCell` (provider-resolved source names + `template` markers, so the fill never
333
+ * reaches scope/qualify). Cached across edits like a plain cell: the key is the placeholder
334
+ * slice (what parsed) plus the raw slice (which tags sit inside it), the engine + provider
335
+ * version (what the tags resolve to) and `setsKey` (the `{% set %}`/`{% macro %}`/`{% for %}`
336
+ * tags anywhere in the text that steer a bare `{{ t }}` binding). The document-level products
337
+ * are projected onto the cell by span rather than re-derived: `tokens` is the unified SQL+jinja
338
+ * stream sliced, `diagnostics` the scrubbed set filtered (the last cell absorbs end-of-text),
339
+ * `errors` that count. `handedOut` dedupes intra-document duplicates exactly as `buildCell` does. */
340
+ buildTemplatedSlice(span, r, engine, provider, setsKey, handedOut) {
341
+ const rawText = this.text.slice(span.start, span.end);
342
+ const placeholderText = r.placeholder.slice(span.start, span.end);
343
+ const providerVersion = provider?.version ?? 0;
344
+ // `setsKey` is length-prefixed and the two slices are equal-length (the fill is
345
+ // length-preserving), so the plain concatenation is unambiguous without a separator byte.
346
+ const key = `templated-cell ${engine.name}@${providerVersion} ${this.dialect} ${setsKey.length}:${setsKey}${placeholderText}${rawText}`;
347
+ let cached = this._cellCache.get(key);
348
+ if (cached !== undefined && handedOut.has(cached))
349
+ cached = undefined; // intra-doc duplicate
350
+ if (cached === undefined) {
351
+ const c = engine.parseCell(r, span, this.text, this.dialect, { provider });
352
+ cached = {
353
+ text: rawText,
354
+ category: c.sql.ast.statement ?? "other",
355
+ ast: c.sql.ast,
356
+ cst: c.sql.cst,
357
+ // Resolve scopes from the already-lowered (marker-carrying) ast — do NOT re-parse.
358
+ scopes: toScopes(c.sql.ast, { dialect: this.dialect }),
359
+ tokens: c.sql.tokens,
360
+ errors: c.sql.errors,
361
+ diagnostics: c.sql.diagnostics,
362
+ analysis: new WeakMap(),
363
+ correlation: { tagOf: c.tagOf, nodeOf: c.nodeOf },
364
+ };
365
+ // Cache only the FIRST product for a key (see buildCell — duplicates stay uncached).
366
+ if (this._cellCache.get(key) === undefined)
367
+ this._cellCache.set(key, cached);
368
+ }
369
+ handedOut.add(cached);
370
+ const last = span.end === this.text.length;
371
+ const inCell = (offset) => offset >= span.start && (offset < span.end || last);
372
+ const tokens = r.tokens.filter((t) => inCell(t.start));
373
+ const diagnostics = r.diagnostics.filter((d) => inCell(d.offset ?? this.lines.offsetAt(d.line - 1, d.column)));
374
+ const cell = Object.freeze({
375
+ span,
376
+ text: cached.text,
377
+ category: cached.category,
378
+ ast: cached.ast,
379
+ cst: cached.cst,
380
+ scopes: cached.scopes,
381
+ tokens,
382
+ errors: diagnostics.length,
383
+ diagnostics,
384
+ });
385
+ return { cell, cached };
386
+ }
304
387
  /** Build a document for `text` in `dialect`. Total: never throws, even on broken / mid-edit input.
305
388
  * Starts a FRESH cell cache — cross-edit reuse comes from withText(), not create(). Pass
306
389
  * `templating` to parse through an injected TemplateEngine (jinja-SQL etc.) instead of the plain
@@ -716,8 +799,7 @@ export class SqlDocument {
716
799
  * subset, see `scopeOutputColumns`'s pipe doc comment. Falls through to this document's own
717
800
  * (single-arm) answer when there are no variants; there is no pre-existing single-doc
718
801
  * equivalent to delegate to, unlike unionSymbols/unionDiagnostics, so the no-variant case is
719
- * just the one-arm instance of the same algorithm. A MULTI-STATEMENT document (no variants: the
720
- * templated door always forces exactly one cell, so the two "multi" shapes never overlap) merges
802
+ * just the one-arm instance of the same algorithm. A MULTI-STATEMENT document (or arm) merges
721
803
  * every statement CELL's own CTEs instead, each shifted from cell-relative to DOCUMENT coordinates
722
804
  * (the same shift `analyze()` already applies to symbols/diagnostics for a multi-cell document);
723
805
  * the compound facade itself carries no CTEs, so cells are the real per-statement source. Memoized
@@ -783,27 +865,23 @@ export class SqlDocument {
783
865
  }
784
866
  return order.map((name) => ({ name, span: byName.get(name) }));
785
867
  }
786
- /** One "arm" `unionCtes`/`unionOutputColumns` aggregate over, unified across the two shapes that
787
- * can each independently make a document "multi" (never both at once: the templated door always
788
- * builds exactly one statement cell, see its own comment above): a templated document's real
789
- * variants (each a full arm SqlDocument, already in DOCUMENT coordinates, zero shift), or, for a
790
- * plain multi-statement document, each statement CELL (cell-relative scopes, shifted to document
791
- * coordinates by the cell's start, mirroring `buildAnalysis`'s per-cell shift). A single-cell,
792
- * non-templated document is the trivial one-arm case of the same shape (zero shift, this
793
- * document's own scopes/qualification). */
868
+ /** The "arms" `unionCtes`/`unionOutputColumns` aggregate over, unified across the two shapes that
869
+ * make a document "multi", VARIANTS FIRST (ruled 2026-09-13): a templated document's real
870
+ * variants are the unit, each arm SqlDocument (already in DOCUMENT coordinates, since a
871
+ * realization is length-preserving) contributing its own statement cells through `ownArms`; a
872
+ * document without variants contributes its own cells the same way. A region can span a cell
873
+ * boundary, so cells-first (arms inside each cell) is not an option. */
794
874
  armsData(s) {
875
+ if (this.variants.length > 0)
876
+ return this.variants.flatMap((v) => v.doc().ownArms(s));
877
+ return this.ownArms(s);
878
+ }
879
+ /** This document's own arms, variants ignored: each statement CELL of a multi-cell document
880
+ * (cell-relative scopes, shifted to document coordinates by the cell's start, mirroring
881
+ * `buildAnalysis`'s per-cell shift), or the single-cell document itself (zero shift, its own
882
+ * scopes/qualification). */
883
+ ownArms(s) {
795
884
  const ZERO = { line: 0, col: 0, offset: 0 };
796
- if (this.variants.length > 0) {
797
- return this.variants.map((v) => {
798
- const doc = v.doc();
799
- return {
800
- scopeRoot: doc.scopes.root,
801
- qualification: doc.analyze(s).qualification,
802
- dialect: doc.dialect,
803
- base: ZERO,
804
- };
805
- });
806
- }
807
885
  if (this.statements.length > 1) {
808
886
  return this.statements.map((cell, i) => {
809
887
  const p = this.lines.positionAt(cell.span.start);
@@ -825,6 +903,48 @@ export class SqlDocument {
825
903
  ];
826
904
  }
827
905
  }
906
+ /** The cache-key component of a templated cell's tag correlation that the cell's own text does not
907
+ * determine: the raw text of every `{% set %}` / `{% macro %}` / `{% for %}` tag in the document. A
908
+ * bare `{{ t }}` source binds through a literal `{% set t = ref(...) %}` declared ANYWHERE, and an
909
+ * inline macro or a `for` target anywhere disables that binding (apply-tags' `resolveSets`), so a
910
+ * change to any of them must miss every cell. */
911
+ function setResolutionKey(r, text) {
912
+ const parts = [];
913
+ for (const t of r.tags) {
914
+ if (t.kind !== "control")
915
+ continue;
916
+ if (t.keyword === "set" || t.keyword === "macro" || t.keyword === "for")
917
+ parts.push(text.slice(t.tagSpan.start, t.tagSpan.end));
918
+ }
919
+ // A control tag's own text never contains a tag boundary, so a newline join is unambiguous.
920
+ return parts.join("\n");
921
+ }
922
+ /** The `templated` facade of a MULTI-cell templated document: the whole-text engine result with
923
+ * `tagOf`/`nodeOf` answering from the CELLS' own correlations — the per-statement IR consumers reach
924
+ * through `statements`/`cellAt`/`nodeAt` — never from the whole-text parse, whose nodes a multi-cell
925
+ * document does not expose (its `ast` is the compound facade). Everything else is the engine's. */
926
+ function withCellCorrelation(r, cells) {
927
+ return {
928
+ ...r,
929
+ tagOf: (node) => {
930
+ for (const c of cells) {
931
+ const tag = c.correlation?.tagOf(node);
932
+ if (tag)
933
+ return tag;
934
+ }
935
+ return undefined;
936
+ },
937
+ nodeOf: (tag) => {
938
+ for (const c of cells) {
939
+ const node = c.correlation?.nodeOf(tag);
940
+ if (node)
941
+ return node;
942
+ }
943
+ return undefined;
944
+ },
945
+ diagnosticsOf: (tag) => r.diagnosticsOf(tag),
946
+ };
947
+ }
828
948
  /** Dedup `items` by a string key, keeping the FIRST occurrence of each key — arm/document order, so
829
949
  * the "first live arm wins" representative-data rule falls out of plain array order. */
830
950
  function dedupBy(items, keyOf) {
@@ -35,8 +35,10 @@ export interface FragmentGrammarSpec<P extends Parser> {
35
35
  lex?: (text: string) => FragmentLex;
36
36
  newLexer: (input: CharStream) => Lexer;
37
37
  newParser: (tokens: CommonTokenStream) => P;
38
+ /** One entry per kind, or several tried in order (tsql's `expression` is its scalar `expression`
39
+ * then `search_condition`: the grammar keeps comparisons out of the scalar rule). */
38
40
  entries: {
39
- readonly [K in FragmentKind]: FragmentEntry<P>;
41
+ readonly [K in FragmentKind]: FragmentEntry<P> | readonly FragmentEntry<P>[];
40
42
  };
41
43
  /** The list separator token type (COMMA). A list body (`cteList`/`selectList`) may end with
42
44
  * one: a macro's CTE list often ends `),` because the caller appends more CTEs. */
@@ -51,9 +51,13 @@ function trailingInput(parser) {
51
51
  /** Bind a dialect's lexer, parser and fragment entry rules into a `FragmentGrammar`. */
52
52
  export function defineFragmentGrammar(spec) {
53
53
  const { newLexer, newParser, entries, separator, postParse } = spec;
54
+ const alternatives = (kind) => {
55
+ const e = entries[kind];
56
+ return Array.isArray(e) ? e : [e];
57
+ };
54
58
  /** The entry, then a trailing separator on a list body is consumed rather than left over. */
55
- const run = (parser, kind) => {
56
- const tree = entries[kind](parser);
59
+ const run = (parser, entry, kind) => {
60
+ const tree = entry(parser);
57
61
  const input = parser.inputStream;
58
62
  if (LIST_KINDS.has(kind) && input.LA(1) === separator && input.LA(2) === AntlrToken.EOF)
59
63
  input.consume();
@@ -68,36 +72,53 @@ export function defineFragmentGrammar(spec) {
68
72
  return {
69
73
  lex,
70
74
  parse(slice, kind, bail) {
71
- const tokens = new CommonTokenStream(new ListTokenSource([...slice]));
72
- const parser = newParser(tokens);
73
- const collector = makeErrorCollector();
74
- parser.removeErrorListeners();
75
- parser.addErrorListener(collector.listener);
76
- const sim = parser.interpreter;
77
- if (bail) {
78
- parser.errorHandler = new BailErrorStrategy();
79
- sim.predictionMode = PredictionMode.SLL;
80
- let tree;
81
- try {
82
- tree = run(parser, kind);
75
+ // Each alternative gets a fresh parser over the same slice; bail mode returns the first
76
+ // clean one, LL mode the alternative that got furthest before its first error.
77
+ let best;
78
+ let bestAt = -1;
79
+ for (const entry of alternatives(kind)) {
80
+ const tokens = new CommonTokenStream(new ListTokenSource([...slice]));
81
+ const parser = newParser(tokens);
82
+ const collector = makeErrorCollector();
83
+ parser.removeErrorListeners();
84
+ parser.addErrorListener(collector.listener);
85
+ const sim = parser.interpreter;
86
+ if (bail) {
87
+ parser.errorHandler = new BailErrorStrategy();
88
+ sim.predictionMode = PredictionMode.SLL;
89
+ let tree;
90
+ try {
91
+ tree = run(parser, entry, kind);
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ // A grammar action can report through the listener without throwing (bigquery's
97
+ // join-balance check); that is not a clean parse either. Nor is leftover input.
98
+ if (collector.diagnostics.length > 0 || trailingInput(parser))
99
+ continue;
100
+ const post = postParse?.(tree) ?? [];
101
+ if (post.length === 0)
102
+ return { tree, diagnostics: [] };
103
+ continue;
83
104
  }
84
- catch {
85
- return undefined;
105
+ sim.predictionMode = PredictionMode.LL;
106
+ const tree = run(parser, entry, kind);
107
+ const trailing = trailingInput(parser);
108
+ const diagnostics = [
109
+ ...collector.diagnostics,
110
+ ...(trailing ? [trailing] : []),
111
+ ...(postParse?.(tree) ?? []),
112
+ ];
113
+ if (diagnostics.length === 0)
114
+ return { tree, diagnostics };
115
+ const at = diagnostics[0].offset ?? Number.MAX_SAFE_INTEGER;
116
+ if (at > bestAt) {
117
+ bestAt = at;
118
+ best = { tree, diagnostics };
86
119
  }
87
- // A grammar action can report through the listener without throwing (bigquery's
88
- // join-balance check); that is not a clean parse either. Nor is leftover input.
89
- if (collector.diagnostics.length > 0 || trailingInput(parser))
90
- return undefined;
91
- const post = postParse?.(tree) ?? [];
92
- return post.length === 0 ? { tree, diagnostics: [] } : undefined;
93
120
  }
94
- sim.predictionMode = PredictionMode.LL;
95
- const tree = run(parser, kind);
96
- const trailing = trailingInput(parser);
97
- return {
98
- tree,
99
- diagnostics: [...collector.diagnostics, ...(trailing ? [trailing] : []), ...(postParse?.(tree) ?? [])],
100
- };
121
+ return best;
101
122
  },
102
123
  };
103
124
  }
@@ -1,6 +1,13 @@
1
1
  import type { QueryExpr } from "../ir/ir.js";
2
2
  import type { TemplateCall, TemplateProvider } from "../qualify/template-provider.js";
3
3
  import type { MacroCall, TagNode } from "./tag-ast.js";
4
+ /** A statement cell's start in the document: 0-based line, 0-based column, char offset (the shape
5
+ * `LineIndex.positionAt` answers). Zero = the whole-text parse. */
6
+ export interface CellBase {
7
+ line: number;
8
+ column: number;
9
+ offset: number;
10
+ }
4
11
  /** The rebuilt AST plus the tag↔node correlations collected while building it. */
5
12
  export interface TagCorrelation {
6
13
  ast: QueryExpr;
@@ -15,8 +22,13 @@ export interface TagCorrelation {
15
22
  * containment. Returns the SAME `ast` reference when nothing correlates (structural sharing);
16
23
  * returns a re-frozen rebuilt tree otherwise. Total, never throws; the correlation maps are empty
17
24
  * (not absent) on the no-op and error paths.
25
+ *
26
+ * `base` is the cell's start position when `ast` is ONE STATEMENT CELL's parse of the placeholder
27
+ * (`parseTemplatedCell`): the node offsets are cell-relative while `tags` and `text` are the whole
28
+ * document's, so containment compares `offset + base.offset`, and every span this transform writes
29
+ * into the IR is rebased to cell coordinates. Zero (the default) is the whole-text parse.
18
30
  */
19
- export declare function applyTemplateTags(ast: QueryExpr, tags: TagNode[], text: string, provider: TemplateProvider): TagCorrelation;
31
+ export declare function applyTemplateTags(ast: QueryExpr, tags: TagNode[], text: string, provider: TemplateProvider, base?: CellBase): TagCorrelation;
20
32
  /**
21
33
  * The provider key of a tag-AST MacroCall — name + package + literal args, with kwargs
22
34
  * carried separately (the channel-agreed TemplateCall contract: quote-stripped, escapes
@@ -30,6 +30,7 @@ import { debugRethrow } from "../debug.js";
30
30
  import { freezeIR } from "../ir/freeze.js";
31
31
  import { qualifiedNameOf, synthesizedQualifiedName } from "../ir/qualified-name.js";
32
32
  import { resolveBehavior } from "../dialect-behavior/registry.js";
33
+ const ZERO_BASE = { line: 0, column: 0, offset: 0 };
33
34
  /** Record a freshly built node's correlation to the tag it came from, then return it unchanged
34
35
  * (a passthrough so call sites stay expression-shaped). A scalar-slot tag lowers to BOTH a
35
36
  * column Expr (`kind: "column"`) and a parallel ColumnRef record (`kind: "columnref"`, same
@@ -49,8 +50,13 @@ function attach(ctx, node, tag) {
49
50
  * containment. Returns the SAME `ast` reference when nothing correlates (structural sharing);
50
51
  * returns a re-frozen rebuilt tree otherwise. Total, never throws; the correlation maps are empty
51
52
  * (not absent) on the no-op and error paths.
53
+ *
54
+ * `base` is the cell's start position when `ast` is ONE STATEMENT CELL's parse of the placeholder
55
+ * (`parseTemplatedCell`): the node offsets are cell-relative while `tags` and `text` are the whole
56
+ * document's, so containment compares `offset + base.offset`, and every span this transform writes
57
+ * into the IR is rebased to cell coordinates. Zero (the default) is the whole-text parse.
52
58
  */
53
- export function applyTemplateTags(ast, tags, text, provider) {
59
+ export function applyTemplateTags(ast, tags, text, provider, base = ZERO_BASE) {
54
60
  const byNode = new WeakMap();
55
61
  const byTag = new Map();
56
62
  try {
@@ -66,6 +72,7 @@ export function applyTemplateTags(ast, tags, text, provider) {
66
72
  sets: resolveSets(tags, text, provider),
67
73
  text,
68
74
  provider,
75
+ base,
69
76
  byNode,
70
77
  byTag,
71
78
  ...(nameConfig ? { nameConfig } : {}),
@@ -89,14 +96,14 @@ function exprInfoOf(tag, ctx) {
89
96
  // A call tag (ref/source/var/env_var/a macro) carries its provider key straight off the call,
90
97
  // callOf reads name + literal args from the source, uniform across every callee.
91
98
  if (tag.kind === "call")
92
- return { span: tag.tagSpan, call: callOf(tag, ctx.text) };
99
+ return { span: cellSpan(tag.tagSpan, ctx), call: callOf(tag, ctx.text) };
93
100
  // A non-call `other` tag: a bare `{{ t }}` resolving through a single-call `{% set t = … %}`
94
101
  // carries that RHS call; anything else is opaque.
95
102
  const ident = bareIdentOf(tag, ctx.text);
96
103
  const resolved = ident !== undefined ? ctx.sets.get(ident) : undefined;
97
104
  if (resolved)
98
- return { span: tag.tagSpan, call: resolved.call };
99
- return { span: tag.tagSpan };
105
+ return { span: cellSpan(tag.tagSpan, ctx), call: resolved.call };
106
+ return { span: cellSpan(tag.tagSpan, ctx) };
100
107
  }
101
108
  /** `{{ var('x') }}` / `{{ env_var('Y', …) }}` — the name + first literal arg, lexically. */
102
109
  const VALUE_CALL_TAG = /^\{\{-?\s*(var|env_var)\s*\(\s*(['"])([^'"\\]*)\2\s*(,[\s\S]*?)?\)\s*(?:\|[\s\S]*)?-?\}\}$/;
@@ -129,7 +136,7 @@ function markTemplateExprs(node, ctx) {
129
136
  if ((isColumnExpr || isColumnRef) && rec.template === undefined) {
130
137
  const start = rec.cst?.start?.start;
131
138
  if (start !== undefined) {
132
- const tag = containingTag(ctx.relTags, start);
139
+ const tag = containingTag(ctx.relTags, start + ctx.base.offset);
133
140
  if (tag)
134
141
  return attach(ctx, { ...rec, template: exprInfoOf(tag, ctx) }, tag);
135
142
  }
@@ -280,6 +287,22 @@ function containingTag(tags, offset) {
280
287
  }
281
288
  return undefined;
282
289
  }
290
+ /** A document-coordinate tag span rebased to the cell's coordinates (the inverse of
291
+ * src/document/shift.ts's `shiftPartSpan`): a span on the cell's first line also loses the cell's
292
+ * start column. The whole-text parse (zero base) keeps the span object itself. */
293
+ function cellSpan(p, ctx) {
294
+ const b = ctx.base;
295
+ if (b.offset === 0 && b.line === 0 && b.column === 0)
296
+ return p;
297
+ return {
298
+ start: p.start - b.offset,
299
+ end: p.end - b.offset,
300
+ line: p.line - b.line,
301
+ column: p.line === b.line + 1 ? p.column - b.column : p.column,
302
+ endLine: p.endLine - b.line,
303
+ endColumn: p.endLine === b.line + 1 ? p.endColumn - b.column : p.endColumn,
304
+ };
305
+ }
283
306
  function transformQuery(q, ctx) {
284
307
  const ctes = mapShared(q.ctes, (c) => transformCte(c, ctx));
285
308
  const body = transformBody(q.body, ctx);
@@ -401,7 +424,7 @@ function transformTableSource(src, ctx) {
401
424
  const startTok = src.cst?.start;
402
425
  if (!startTok)
403
426
  return src;
404
- const tag = containingTag(ctx.relTags, startTok.start);
427
+ const tag = containingTag(ctx.relTags, startTok.start + ctx.base.offset);
405
428
  if (!tag)
406
429
  return src;
407
430
  // A placeholder-fill alias sits INSIDE the tag span: a multi-line tag fills one
@@ -414,7 +437,7 @@ function transformTableSource(src, ctx) {
414
437
  // fill limitation, out of apply-tags' reach); making it `undefined` here is honest,
415
438
  // where `jjj…` was a fabrication.
416
439
  const aliasTok = src.aliasCst?.start;
417
- const base = aliasTok != null && inSpan(aliasTok.start, tag.tagSpan) ? withoutAlias(src) : src;
440
+ const base = aliasTok != null && inSpan(aliasTok.start + ctx.base.offset, tag.tagSpan) ? withoutAlias(src) : src;
418
441
  // An unresolved source's name is the RAW TAG TEXT — the bytes the user actually wrote. The
419
442
  // placeholder fill is scaffolding this library invented so the grammar parses; letting it
420
443
  // escape as a relation name (scope sources, lineage dependencies, go-to-def) is fabrication
@@ -432,7 +455,8 @@ function transformTableSource(src, ctx) {
432
455
  : qualifiedNameOf(parts, ctx.nameConfig);
433
456
  return { ...b, relation };
434
457
  };
435
- // NOTE: `template.span` intentionally aliases `tag.tagSpan` BY REFERENCE. freezeIR
458
+ // NOTE: on the whole-text parse `template.span` intentionally aliases `tag.tagSpan` BY
459
+ // REFERENCE (a statement cell's parse gets a rebased copy, see `cellSpan`). freezeIR
436
460
  // therefore also freezes the TagNode.tagSpan object returned in `.tags`, benign
437
461
  // since spans are read-only. Every call marker carries its `call`, the provider key
438
462
  // the semantic layer resolves the relation and its columns through (relation-columns.ts).
@@ -444,7 +468,7 @@ function transformTableSource(src, ctx) {
444
468
  // Either way the `call` keeps it consultable, so an unresolved call is not a dead end: a
445
469
  // provider added later resolves it. ref vs source is not stored here, it is call.name.
446
470
  const named = rel ? renamed(base, [...rel.nameParts], true) : renamed(base, rawTagName, false);
447
- const template = { kind: "call", span: tag.tagSpan, call };
471
+ const template = { kind: "call", span: cellSpan(tag.tagSpan, ctx), call };
448
472
  return attach(ctx, { ...named, template }, tag);
449
473
  }
450
474
  // Non-call expression tag (var / env_var / other) in a FROM slot. A bare `{{ t }}` resolving
@@ -455,9 +479,14 @@ function transformTableSource(src, ctx) {
455
479
  const resolved = ident !== undefined ? ctx.sets.get(ident) : undefined;
456
480
  if (resolved) {
457
481
  const named = resolved.name ? renamed(base, [...resolved.name], true) : renamed(base, rawTagName, false);
458
- const template = { kind: "call", span: tag.tagSpan, indirect: true, call: resolved.call };
482
+ const template = {
483
+ kind: "call",
484
+ span: cellSpan(tag.tagSpan, ctx),
485
+ indirect: true,
486
+ call: resolved.call,
487
+ };
459
488
  return attach(ctx, { ...named, template }, tag);
460
489
  }
461
- const template = { kind: "expr", span: tag.tagSpan, opaque: true };
490
+ const template = { kind: "expr", span: cellSpan(tag.tagSpan, ctx), opaque: true };
462
491
  return attach(ctx, { ...renamed(base, rawTagName, false), template }, tag);
463
492
  }
@@ -1,4 +1,4 @@
1
- import { parseTemplated } from "./parse.js";
1
+ import { parseTemplated, parseTemplatedCell } from "./parse.js";
2
2
  import { templateVariants } from "./variants.js";
3
3
  /** The minijinja template engine (the Rust engine dbt Fusion uses — the grammar
4
4
  * oracle for what we accept). The shipped, and only, TemplateEngine. */
@@ -7,5 +7,6 @@ export function minijinja() {
7
7
  name: "minijinja",
8
8
  parse: (text, dialect, opts) => parseTemplated(text, dialect, opts),
9
9
  variants: (text, dialect) => templateVariants(text, dialect),
10
+ parseCell: (whole, span, text, dialect, opts) => parseTemplatedCell(whole, span, text, dialect, opts),
10
11
  };
11
12
  }
@@ -1,6 +1,6 @@
1
1
  import type { Dialect } from "../dialect.js";
2
2
  import type { Token } from "../token/token.js";
3
- import type { MacroShape, TemplatedParseOptions, TemplatedParseResult } from "../template/engine.js";
3
+ import type { MacroShape, TemplatedCellResult, TemplatedParseOptions, TemplatedParseResult } from "../template/engine.js";
4
4
  import type { ExpansionShape } from "../qualify/template-provider.js";
5
5
  import type { TemplateCall } from "../ir/ir.js";
6
6
  export type { TagNode, MacroCall } from "./tag-ast.js";
@@ -28,3 +28,17 @@ export declare function parseTemplated(text: string, dialect: Dialect, opts?: Te
28
28
  * of parseTemplated. Total — never throws.
29
29
  */
30
30
  export declare function tokenizeTemplated(text: string, dialect: Dialect, opts?: TemplatedParseOptions): Token[];
31
+ /**
32
+ * One statement cell of a templated document (`TemplateEngine.parseCell`): the plain per-dialect
33
+ * parse of the placeholder slice `[span.start, span.end)`, the same batch-of-one path a plain
34
+ * document's cells take, so IR / CST / tokens / diagnostics are CELL-relative, with `whole`'s tags
35
+ * correlated onto it by DOCUMENT offset (a node's cell offset plus the cell's start). The cell's
36
+ * sources then carry their provider-resolved names and `template` markers exactly as the
37
+ * whole-text parse's do, never the fill; a marker's `span` is rebased to cell coordinates like
38
+ * every other span in the cell IR, while `tagOf`/`nodeOf` answer with `whole.tags`'s own nodes.
39
+ * Total: `applyTemplateTags` leaves the plain parse in place on any internal surprise.
40
+ */
41
+ export declare function parseTemplatedCell(whole: TemplatedParseResult, span: {
42
+ start: number;
43
+ end: number;
44
+ }, text: string, dialect: Dialect, opts?: TemplatedParseOptions): TemplatedCellResult;
@@ -275,7 +275,7 @@ function scrubPlaceholderDiagnostics(diags, tagRanges, text, placeholder) {
275
275
  * will), so "matches no known shape" is not evidence of invalid SQL (never-wrong). Mutates
276
276
  * the regions' `body` field only. Files without a macro region are untouched.
277
277
  */
278
- function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
278
+ function reparseMacroBodies(regions, diagnostics, placeholder, fragments) {
279
279
  const macros = [];
280
280
  // Macros can sit under an if/for (a guarded definition); a macro inside a macro body is
281
281
  // covered by the outer body's read and not visited on its own.
@@ -291,7 +291,6 @@ function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
291
291
  visit(regions);
292
292
  if (macros.length === 0)
293
293
  return diagnostics;
294
- const fragments = openFragments(placeholder, dialect);
295
294
  const bodies = [];
296
295
  for (const region of macros) {
297
296
  const arm = region.arms[0];
@@ -304,7 +303,7 @@ function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
304
303
  if (body.end <= body.start)
305
304
  continue;
306
305
  bodies.push(body);
307
- const verdict = fragments.verdict([body]);
306
+ const verdict = fragments().verdict([body]);
308
307
  if (verdict)
309
308
  region.body = verdict;
310
309
  }
@@ -318,7 +317,7 @@ function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
318
317
  }
319
318
  if (at < placeholder.length)
320
319
  remainder.push({ start: at, end: placeholder.length });
321
- const rest = fragments.parse(remainder, ["statement"]);
320
+ const rest = fragments().parse(remainder, ["statement"]);
322
321
  return [...diagnostics.filter((d) => d.offset === undefined), ...(rest?.diagnostics ?? [])];
323
322
  }
324
323
  /** Insert a hidden WS-shaped token over every gap in the source-ordered stream whose original text
@@ -380,7 +379,7 @@ const DEFAULT_KEYWORD_SHAPE = {
380
379
  * `else` arm, the macro can render to nothing → `nothing`, last.
381
380
  * Anything else stays out (never-wrong): a hole with no visible default, a return-only body.
382
381
  */
383
- function macroShapesOf(regions, tags, text, placeholder) {
382
+ function macroShapesOf(regions, tags, text, placeholder, fragments) {
384
383
  const out = [];
385
384
  const visit = (list) => {
386
385
  for (const region of list) {
@@ -399,19 +398,28 @@ function macroShapesOf(regions, tags, text, placeholder) {
399
398
  shapes.push(VERDICT_SHAPE[region.body]);
400
399
  else {
401
400
  // A body opening with a hole: `{{ name }}` / `{{ name|default('kw') }}`, `name` one of
402
- // the macro's declared parameters (the signature's args are bare identifiers).
401
+ // the macro's declared parameters. The signature's args are `name` or `name=default`
402
+ // (the jinja-standard default spelling); the filter default wins over the signature's.
403
403
  const lead = leadingHole(arm.bodySpan, tags, placeholder);
404
404
  const holeText = lead ? text.slice(lead.tagSpan.start, lead.tagSpan.end) : "";
405
405
  const bound = /^\{\{-?\s*([A-Za-z_]\w*)\s*(?:\||-?\}\})/.exec(holeText)?.[1];
406
- const params = (open.calls[0]?.args ?? []).map((a) => text.slice(a.span.start, a.span.end).trim());
407
- const index = bound === undefined ? -1 : params.indexOf(bound);
408
- const fallback = /\|\s*default\(\s*['"](\w+)['"]\s*\)/.exec(holeText)?.[1];
406
+ const params = (open.calls[0]?.args ?? []).map((a) => signatureParam(text.slice(a.span.start, a.span.end)));
407
+ const index = bound === undefined ? -1 : params.findIndex((p) => p?.name === bound);
408
+ const fallback = /\|\s*default\(\s*['"](\w+)['"]\s*\)/.exec(holeText)?.[1] ?? params[index]?.default;
409
409
  if (bound !== undefined && index >= 0) {
410
410
  keywordParam = { name: bound, index, ...(fallback !== undefined ? { default: fallback } : {}) };
411
411
  }
412
412
  const clause = fallback ? DEFAULT_KEYWORD_SHAPE[fallback.toLowerCase()] : undefined;
413
413
  if (clause)
414
414
  shapes.push(clause);
415
+ // A body that literally opens with the clause keyword (`and {{ c }} = 0`): the shape the
416
+ // same word resolves to through a hole, provided the rest reads as an expression.
417
+ if (!lead) {
418
+ const led = leadingKeyword(arm.bodySpan, placeholder);
419
+ if (led && fragments().verdict([{ start: led.end, end: arm.bodySpan.end }], ["expression"])) {
420
+ shapes.push(DEFAULT_KEYWORD_SHAPE[led.word]);
421
+ }
422
+ }
415
423
  }
416
424
  if ((shapes.length > 0 || keywordParam) && rendersToNothing(arm, placeholder))
417
425
  shapes.push("nothing");
@@ -445,6 +453,22 @@ export function shapesForCall(macro, call) {
445
453
  const rest = macro.shapes.filter((s) => s !== "where-clause" && s !== "conjunct");
446
454
  return clause ? [clause, ...rest] : rest;
447
455
  }
456
+ /** One signature argument, `name` or `name=default` (a quoted string default is unquoted). */
457
+ function signatureParam(argText) {
458
+ const m = /^\s*([A-Za-z_]\w*)\s*(?:=\s*(.+?))?\s*$/s.exec(argText);
459
+ if (!m)
460
+ return undefined;
461
+ const raw = m[2];
462
+ const literal = raw === undefined ? undefined : /^(['\"])(.*)\1$/s.exec(raw)?.[2];
463
+ return { name: m[1], ...(literal !== undefined ? { default: literal } : {}) };
464
+ }
465
+ /** A clause keyword (`and`/`or`/`where`) opening the body, past whitespace and `--` comment lines:
466
+ * the word (lowercased) and the offset just past it. */
467
+ function leadingKeyword(body, placeholder) {
468
+ const slice = placeholder.slice(body.start, body.end);
469
+ const m = /^(?:\s|--[^\n]*\n)*(and|or|where)\b/i.exec(slice);
470
+ return m ? { word: m[1].toLowerCase(), end: body.start + m[0].length } : undefined;
471
+ }
448
472
  /** The expression tag at the very start of a body (only whitespace, comments and control tags
449
473
  * before it in the placeholder), or undefined when the body opens with SQL. */
450
474
  function leadingHole(body, tags, placeholder) {
@@ -555,8 +579,11 @@ function build(text, dialect, provider) {
555
579
  // source, CTE list, select list; src/fragment.ts) and its own diagnostics replace whatever
556
580
  // the statement parse reported inside that body. The IR and tokens stay the whole-file
557
581
  // parse's: the fragment verdict rides the region as `body`.
558
- const sqlDiagnostics = reparseMacroBodies(regions, sqlResult.diagnostics, placeholder, dialect);
559
- const macros = macroShapesOf(regions, tags, text, placeholder);
582
+ // One lex of the placeholder, opened on first use, shared by both macro passes.
583
+ let session;
584
+ const fragments = () => (session ??= openFragments(placeholder, dialect));
585
+ const sqlDiagnostics = reparseMacroBodies(regions, sqlResult.diagnostics, placeholder, fragments);
586
+ const macros = macroShapesOf(regions, tags, text, placeholder, fragments);
560
587
  const { diagnostics: scrubbed, bySegment } = scrubPlaceholderDiagnostics(sqlDiagnostics, tagRanges, text, placeholder);
561
588
  // Fold the scrubbed SQL diagnostics into the same per-tag map as the jinja ones
562
589
  // (Task 10) — a tag's diagnostics are its own jinja parse errors PLUS whatever
@@ -624,3 +651,36 @@ export function parseTemplated(text, dialect, opts) {
624
651
  export function tokenizeTemplated(text, dialect, opts) {
625
652
  return parseTemplated(text, dialect, opts).tokens;
626
653
  }
654
+ /**
655
+ * One statement cell of a templated document (`TemplateEngine.parseCell`): the plain per-dialect
656
+ * parse of the placeholder slice `[span.start, span.end)`, the same batch-of-one path a plain
657
+ * document's cells take, so IR / CST / tokens / diagnostics are CELL-relative, with `whole`'s tags
658
+ * correlated onto it by DOCUMENT offset (a node's cell offset plus the cell's start). The cell's
659
+ * sources then carry their provider-resolved names and `template` markers exactly as the
660
+ * whole-text parse's do, never the fill; a marker's `span` is rebased to cell coordinates like
661
+ * every other span in the cell IR, while `tagOf`/`nodeOf` answer with `whole.tags`'s own nodes.
662
+ * Total: `applyTemplateTags` leaves the plain parse in place on any internal surprise.
663
+ */
664
+ export function parseTemplatedCell(whole, span, text, dialect, opts) {
665
+ const sql = parse(whole.placeholder.slice(span.start, span.end), dialect);
666
+ const provider = opts?.provider ?? OPEN_PROVIDER;
667
+ const correlation = applyTemplateTags(sql.ast, whole.tags, text, provider, cellBaseOf(text, span.start));
668
+ return {
669
+ sql: { ...sql, ast: correlation.ast },
670
+ tagOf: (node) => correlation.byNode.get(node),
671
+ nodeOf: (tag) => correlation.byTag.get(tag),
672
+ };
673
+ }
674
+ /** The cell start's 0-based line / column / char offset in `text` (`\n` is the line break, the
675
+ * convention every span in the pipeline follows; a `\r` is an ordinary column). */
676
+ function cellBaseOf(text, offset) {
677
+ let line = 0;
678
+ let lineStart = 0;
679
+ for (let i = 0; i < offset; i++) {
680
+ if (text.charCodeAt(i) === 10) {
681
+ line++;
682
+ lineStart = i + 1;
683
+ }
684
+ }
685
+ return { line, column: offset - lineStart, offset };
686
+ }
@@ -88,6 +88,20 @@ export interface TemplatedParseResult {
88
88
  * scrubber widened to it. Empty array when none. */
89
89
  diagnosticsOf(tag: TagNode): SyntaxDiagnostic[];
90
90
  }
91
+ /** One statement CELL of a templated document (`TemplateEngine.parseCell`): the plain per-dialect
92
+ * parse of the cell's placeholder slice, in CELL-relative coordinates like a plain document's
93
+ * cells, with the whole-document tags correlated onto it (provider-resolved source names,
94
+ * `template` markers, and the two-spine join). */
95
+ export interface TemplatedCellResult {
96
+ /** The cell's SQL parse over its placeholder slice (ast / cst / tokens / errors / diagnostics),
97
+ * every span cell-relative. A `template` marker's span is cell-relative too. */
98
+ sql: ParseResultIR;
99
+ /** The whole document's TagNode a template-marked node of THIS cell's IR came from. */
100
+ tagOf(node: object): TagNode | undefined;
101
+ /** The node of THIS cell's IR a whole-document tag became; undefined when the tag sits in
102
+ * another cell or has no IR presence. */
103
+ nodeOf(tag: TagNode): object | undefined;
104
+ }
91
105
  /** A template engine: the syntax front end for one templating language over
92
106
  * SQL. `parse` must satisfy the engine contract the conformance suite
93
107
  * checks — tokens tile the source byte-for-byte, every span in original
@@ -99,4 +113,13 @@ export interface TemplateEngine {
99
113
  parse(text: string, dialect: Dialect, opts?: TemplatedParseOptions): TemplatedParseResult;
100
114
  /** Optional: coherent per-branch variant enumeration, for engines with control-flow arms. */
101
115
  variants?(text: string, dialect: Dialect): TemplateVariant[];
116
+ /** Optional: the products of ONE statement cell of a templated document — the plain parse of
117
+ * `whole.placeholder`'s slice `[span.start, span.end)` (cell-relative) with `whole`'s tags
118
+ * correlated onto it. `whole` is this engine's own `parse` result for the full `text`, `span`
119
+ * a `splitStatements` span over that placeholder. An engine without it keeps the templated
120
+ * `SqlDocument` door at one whole-text cell. */
121
+ parseCell?(whole: TemplatedParseResult, span: {
122
+ start: number;
123
+ end: number;
124
+ }, text: string, dialect: Dialect, opts?: TemplatedParseOptions): TemplatedCellResult;
102
125
  }
@@ -83,7 +83,9 @@ export const fragmentGrammar = defineFragmentGrammar({
83
83
  separator: TSqlLexer.COMMA,
84
84
  entries: {
85
85
  statement: (p) => p.tsql_file(),
86
- expression: (p) => p.expression(),
86
+ // T-SQL keeps comparisons out of the scalar `expression` rule (they are `search_condition`
87
+ // predicates), so an expression body is either.
88
+ expression: [(p) => p.expression(), (p) => p.search_condition()],
87
89
  tableSource: (p) => p.table_source(),
88
90
  cteList: separatedList((p) => p.common_table_expression(), TSqlLexer.COMMA),
89
91
  selectList: (p) => p.select_list(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqllens",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "A TypeScript SQL parser and static analyzer: parse, resolve names, infer types, and trace column lineage across many SQL dialects (Databricks, T-SQL, Snowflake, BigQuery, Redshift, PostgreSQL, DuckDB, Trino, SQLite, MySQL).",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",