sqllens 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -367,7 +367,11 @@ that run on incomplete, mid-edit text. They never need a clean parse:
367
367
  - `SqlDocument` is a persistent, immutable, position-addressable per-file model.
368
368
  It runs `parse → resolveScopes` once (plus lazy `analyze(schema)`), caches the
369
369
  result, and answers `tokenAt` / `nodeAt`. An edit yields a new document; an
370
- O(log n) `LineIndex` maps positions to offsets.
370
+ O(log n) `LineIndex` maps positions to offsets. Its `statements` are the
371
+ per-statement cells; each cell's `span` carries the separator token that ends
372
+ it (`;`, or the `GO` word), so the statement's own text ends where that starts.
373
+ - `statementSpans(text, dialect, { templating? })`: the same cell spans without
374
+ the per-cell parses, for code lenses and run-at-cursor ranges on every change.
371
375
  - `completeAt(doc, offset, schema?)`: scope-aware completion (keywords, columns,
372
376
  tables, functions) from an ATN (Augmented Transition Network, the grammar's
373
377
  state-machine form) candidate walk over the grammar, our own, with no third-party
package/dist/api.d.ts CHANGED
@@ -116,6 +116,7 @@ export type { Token, TokenRole } from "./token/token.js";
116
116
  export { SqlDocument, type DocumentAnalysis, type StatementCell, type DocumentVariant, type UnionCte, } from "./document/document.js";
117
117
  export { LineIndex } from "./document/line-index.js";
118
118
  export type { StatementCellSpan } from "./document/split.js";
119
+ export { statementSpans } from "./document/spans.js";
119
120
  export { complete, completeAt, type Completion, type CompletionResult, type ReplaceRange, type CompleteOptions, type CandidateIdentity, type CandidateDecoration, type DecorateCandidate, } from "./completion/complete.js";
120
121
  export { jinjaSlotAt, type JinjaSlot } from "./completion/jinja-slot.js";
121
122
  export { signatureAt, type SignatureHelpInfo, type SignatureLabel } from "./signature/signature.js";
package/dist/api.js CHANGED
@@ -202,6 +202,9 @@ export { tokenize } from "./token/tokenize.js";
202
202
  // these at call time, never at module-eval time.
203
203
  export { SqlDocument, } from "./document/document.js";
204
204
  export { LineIndex } from "./document/line-index.js";
205
+ // The cell spans alone (plain or templated), for a consumer that needs statement ranges without
206
+ // the per-cell parses; identical to the spans the document's cells carry.
207
+ export { statementSpans } from "./document/spans.js";
205
208
  // Scope-aware completion over a SqlDocument — the broken-input editor feature (keywords + schema
206
209
  // tables/columns + function names at the caret). Total: never throws.
207
210
  export { complete, completeAt, } from "./completion/complete.js";
@@ -163,7 +163,7 @@ export declare class SqlDocument {
163
163
  * `parse()`. It is the document's one cell when the placeholder splits into one statement (every
164
164
  * single-statement model), and the carrier of the engine result (`templated`) that a multi-cell
165
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
166
+ * (the cell always starts at 0), so, unlike `buildCell`, nothing is shifted. Mirrors
167
167
  * `buildCell`'s CachedCell/StatementCell shapes so every downstream consumer (analyze(), cellAt(),
168
168
  * nodeAt()…) sees the same structure whether the document is plain or templated. Cached in the SAME
169
169
  * cross-edit `_cellCache` as plain cells, under a prefixed key so a templated cell can never
@@ -168,7 +168,8 @@ export class SqlDocument {
168
168
  const r = whole.cached.templated;
169
169
  const spans = opts.templating.parseCell ? splitStatements(r.placeholder, dialect) : [whole.cell.span];
170
170
  if (spans.length === 1) {
171
- cells = [whole.cell];
171
+ // The whole-text cell, under the split's own span so it carries its separator.
172
+ cells = [Object.freeze({ ...whole.cell, span: spans[0] })];
172
173
  backing = [whole.cached];
173
174
  templated = r;
174
175
  }
@@ -182,7 +183,7 @@ export class SqlDocument {
182
183
  cells.push(built.cell);
183
184
  backing.push(built.cached);
184
185
  }
185
- templated = withCellCorrelation(r, backing);
186
+ templated = withCellCorrelation(r, cells, backing);
186
187
  }
187
188
  }
188
189
  else {
@@ -282,7 +283,7 @@ export class SqlDocument {
282
283
  * `parse()`. It is the document's one cell when the placeholder splits into one statement (every
283
284
  * single-statement model), and the carrier of the engine result (`templated`) that a multi-cell
284
285
  * 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
+ * (the cell always starts at 0), so, unlike `buildCell`, nothing is shifted. Mirrors
286
287
  * `buildCell`'s CachedCell/StatementCell shapes so every downstream consumer (analyze(), cellAt(),
287
288
  * nodeAt()…) sees the same structure whether the document is plain or templated. Cached in the SAME
288
289
  * cross-edit `_cellCache` as plain cells, under a prefixed key so a templated cell can never
@@ -354,15 +355,18 @@ export class SqlDocument {
354
355
  category: c.sql.ast.statement ?? "other",
355
356
  ast: c.sql.ast,
356
357
  cst: c.sql.cst,
357
- // Resolve scopes from the already-lowered (marker-carrying) ast do NOT re-parse.
358
+ // Resolve scopes from the already-lowered (marker-carrying) ast: never re-parse.
358
359
  scopes: toScopes(c.sql.ast, { dialect: this.dialect }),
359
360
  tokens: c.sql.tokens,
360
361
  errors: c.sql.errors,
361
362
  diagnostics: c.sql.diagnostics,
362
363
  analysis: new WeakMap(),
363
- correlation: { tagOf: c.tagOf, nodeOf: c.nodeOf },
364
+ correlation: {
365
+ tagStartOf: new Map(c.links.map((l) => [l.node, l.tagStart])),
366
+ primaryAt: new Map(c.links.filter((l) => l.primary).map((l) => [l.tagStart, l.node])),
367
+ },
364
368
  };
365
- // Cache only the FIRST product for a key (see buildCell duplicates stay uncached).
369
+ // Cache only the FIRST product for a key (see buildCell: duplicates stay uncached).
366
370
  if (this._cellCache.get(key) === undefined)
367
371
  this._cellCache.set(key, cached);
368
372
  }
@@ -920,25 +924,29 @@ function setResolutionKey(r, text) {
920
924
  return parts.join("\n");
921
925
  }
922
926
  /** 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
+ * `tagOf`/`nodeOf` answering from the CELLS' own correlations (the per-statement IR consumers reach
928
+ * through `statements`/`cellAt`/`nodeAt`), never from the whole-text parse, whose nodes a multi-cell
929
+ * document does not expose (its `ast` is the compound facade). A cell's join names its tag by
930
+ * cell-relative start, so the answer is always one of THIS parse's TagNodes, whichever parse the
931
+ * cached cell was built under. Everything else is the engine's. */
932
+ function withCellCorrelation(r, cells, backing) {
933
+ const tagAt = new Map(r.tags.map((t) => [t.tagSpan.start, t]));
927
934
  return {
928
935
  ...r,
929
936
  tagOf: (node) => {
930
- for (const c of cells) {
931
- const tag = c.correlation?.tagOf(node);
932
- if (tag)
933
- return tag;
937
+ for (let i = 0; i < backing.length; i++) {
938
+ const rel = backing[i].correlation?.tagStartOf.get(node);
939
+ if (rel !== undefined)
940
+ return tagAt.get(cells[i].span.start + rel);
934
941
  }
935
942
  return undefined;
936
943
  },
937
944
  nodeOf: (tag) => {
938
- for (const c of cells) {
939
- const node = c.correlation?.nodeOf(tag);
940
- if (node)
941
- return node;
945
+ for (let i = 0; i < backing.length; i++) {
946
+ const span = cells[i].span;
947
+ if (tag.tagSpan.start < span.start || tag.tagSpan.start >= span.end)
948
+ continue;
949
+ return backing[i].correlation?.primaryAt.get(tag.tagSpan.start - span.start);
942
950
  }
943
951
  return undefined;
944
952
  },
@@ -0,0 +1,14 @@
1
+ import type { Dialect } from "../dialect.js";
2
+ import type { TemplateProvider } from "../qualify/template-provider.js";
3
+ import type { TemplateEngine } from "../template/engine.js";
4
+ import { type StatementCellSpan } from "./split.js";
5
+ /**
6
+ * The statement cell spans `SqlDocument.create(text, dialect, opts)` would produce, without the
7
+ * per-cell parses. Plain text splits directly; with `templating` the engine's placeholder is
8
+ * split (its `placeholder` hook when it has one, else its full `parse`), and an engine without
9
+ * `parseCell` answers one whole-text span, exactly as the document door does. Total: never throws.
10
+ */
11
+ export declare function statementSpans(text: string, dialect: Dialect, opts?: {
12
+ templating?: TemplateEngine;
13
+ provider?: TemplateProvider;
14
+ }): StatementCellSpan[];
@@ -0,0 +1,34 @@
1
+ // ---------------------------------------------------------------------------
2
+ // statementSpans() — the statement cell spans of a text without building the
3
+ // document. The SqlDocument constructor derives its cells from exactly this
4
+ // split (the plain text, or the engine's placeholder for a templated one), so
5
+ // the spans answered here are the spans `doc.statements[i].span` would carry;
6
+ // a consumer that only needs "where do the statements start and end" (code
7
+ // lenses, run-at-cursor ranges) pays the lex and the split, not the parses.
8
+ // ---------------------------------------------------------------------------
9
+ import { debugRethrow } from "../debug.js";
10
+ import { splitStatements } from "./split.js";
11
+ /**
12
+ * The statement cell spans `SqlDocument.create(text, dialect, opts)` would produce, without the
13
+ * per-cell parses. Plain text splits directly; with `templating` the engine's placeholder is
14
+ * split (its `placeholder` hook when it has one, else its full `parse`), and an engine without
15
+ * `parseCell` answers one whole-text span, exactly as the document door does. Total: never throws.
16
+ */
17
+ export function statementSpans(text, dialect, opts = {}) {
18
+ const engine = opts.templating;
19
+ if (!engine)
20
+ return splitStatements(text, dialect);
21
+ if (!engine.parseCell)
22
+ return [{ start: 0, end: text.length }];
23
+ let placeholder;
24
+ try {
25
+ placeholder = engine.placeholder
26
+ ? engine.placeholder(text, dialect, { provider: opts.provider })
27
+ : engine.parse(text, dialect, { provider: opts.provider }).placeholder;
28
+ }
29
+ catch (e) {
30
+ debugRethrow(e);
31
+ placeholder = text;
32
+ }
33
+ return splitStatements(placeholder, dialect);
34
+ }
@@ -2,15 +2,26 @@ import type { Dialect } from "../dialect.js";
2
2
  export interface StatementCellSpan {
3
3
  /** doc offset, inclusive — cell text includes leading trivia. */
4
4
  start: number;
5
- /** doc offset, exclusive — includes the trailing separator (`;` / GO line). */
5
+ /** doc offset, exclusive — includes the trailing separator (`;` / GO line); the document's last
6
+ * cell also includes whatever trivia follows its separator, up to `text.length`. */
6
7
  end: number;
8
+ /** The separator token that terminates this cell, as doc offsets (`start` inclusive, `end`
9
+ * exclusive): the `;`, or the `GO` word itself (not its line). Absent when the cell has no
10
+ * separator (an unterminated final statement, a separator-free document, the whole-document
11
+ * fallback). The statement's own text ends where this starts, before any trailing trivia. */
12
+ separator?: {
13
+ start: number;
14
+ end: number;
15
+ };
7
16
  }
8
17
  /**
9
18
  * Split `text` into top-level statement cells using `tokenize(text, dialect)`.
10
19
  * Total: never throws. Splits at channel-0 `;` at compound depth 0 (BEGIN/CASE
11
20
  * increment, END decrements, floor 0; a T-SQL `BEGIN TRAN`/`TRANSACTION`/
12
21
  * `DISTRIBUTED` does not open a depth level) plus, for T-SQL, a `GO` batch
13
- * separator alone on its line. Returns the whole doc as one cell when
22
+ * separator alone on its line; text ending inside an open level splits at every
23
+ * separator from the unclosed opener on (see `findSplitEnds`). Each cell carries
24
+ * the separator token that ends it. Returns the whole doc as one cell when
14
25
  * splitting is unsafe (the tiling invariant fails) or pointless (no separators).
15
26
  */
16
27
  export declare function splitStatements(text: string, dialect: Dialect): StatementCellSpan[];
@@ -26,11 +26,43 @@ const NON_OPENER_END_SUFFIXES = new Set(["IF", "WHILE", "FOR", "LOOP", "REPEAT"]
26
26
  function wholeDoc(text) {
27
27
  return [{ start: 0, end: text.length }];
28
28
  }
29
- /** Every offset in `text` where a top-level separator ends (exclusive), in ascending order. */
29
+ /** The split point a `;` or a T-SQL `GO` at `channel0[i]` makes, ignoring depth, or undefined when
30
+ * the token is neither. A GO batch separator must sit alone on its line among channel-0 tokens
31
+ * (otherwise it is an identifier/alias use of the word); its cell end is the end of that line. */
32
+ function separatorAt(text, channel0, i, dialect) {
33
+ const t = channel0[i];
34
+ const separator = { start: t.start, end: t.stop + 1 };
35
+ if (t.text === ";")
36
+ return { end: t.stop + 1, separator };
37
+ if (dialect === "tsql" && t.text.toUpperCase() === "GO") {
38
+ const prev = channel0[i - 1];
39
+ const next = channel0[i + 1];
40
+ const alone = (!prev || prev.line !== t.line) && (!next || next.line !== t.line);
41
+ if (!alone)
42
+ return undefined;
43
+ const nl = text.indexOf("\n", t.stop + 1);
44
+ return { end: nl === -1 ? text.length : nl + 1, separator };
45
+ }
46
+ return undefined;
47
+ }
48
+ /** Every top-level split point in `text`, in ascending order, plus `tail`: the separator of a
49
+ * terminated final statement that nothing real follows (only whitespace, comments, the final
50
+ * newline). That separator is not a split end, the trivia after it belongs to the cell it
51
+ * terminates, so a single terminated statement is one cell rather than a statement plus a
52
+ * token-less tail cell; `tail` lets that cell still carry its separator.
53
+ *
54
+ * Depth: `BEGIN`/`CASE` open a level, `END` closes one, and only a separator at depth 0 splits.
55
+ * When the text ends INSIDE an open level (an unclosed CASE mid-typing, jinja arms that leave
56
+ * the placeholder with more openers than closers), the walk cannot know where that block ends,
57
+ * and one cell silently spanning several statements is the worse failure (a "run statement at
58
+ * cursor" would run them all): the split points before the outermost unclosed opener stand, and
59
+ * from that opener on every separator splits regardless of depth. Balanced text is unaffected. */
30
60
  function findSplitEnds(text, tokens, dialect) {
31
61
  const channel0 = tokens.filter((t) => t.channel === 0);
32
- const ends = [];
62
+ let ends = [];
33
63
  let depth = 0;
64
+ /** The outermost currently-open level: where it opened and how many split points preceded it. */
65
+ let opener;
34
66
  for (let i = 0; i < channel0.length; i++) {
35
67
  const t = channel0[i];
36
68
  const upper = t.text.toUpperCase();
@@ -38,10 +70,15 @@ function findSplitEnds(text, tokens, dialect) {
38
70
  // `BEGIN TRAN`/`TRANSACTION`/`DISTRIBUTED` (T-SQL) starts a transaction, not a
39
71
  // scripting compound — it has no matching END, so it must not open a depth level.
40
72
  const next = channel0[i + 1];
41
- if (!next || !TRAN_WORDS.has(next.text.toUpperCase()))
73
+ if (!next || !TRAN_WORDS.has(next.text.toUpperCase())) {
74
+ if (depth === 0)
75
+ opener = { index: i, endsBefore: ends.length };
42
76
  depth++;
77
+ }
43
78
  }
44
79
  else if (upper === "CASE") {
80
+ if (depth === 0)
81
+ opener = { index: i, endsBefore: ends.length };
45
82
  depth++;
46
83
  }
47
84
  else if (upper === "END") {
@@ -63,37 +100,46 @@ function findSplitEnds(text, tokens, dialect) {
63
100
  else {
64
101
  depth = Math.max(0, depth - 1);
65
102
  }
66
- }
67
- else if (t.text === ";") {
68
103
  if (depth === 0)
69
- ends.push(t.stop + 1);
104
+ opener = undefined;
70
105
  }
71
- else if (dialect === "tsql" && upper === "GO" && depth === 0) {
72
- // A GO batch separator must sit alone on its line among channel-0 tokens —
73
- // otherwise it's an identifier/alias use of the word `GO`, not a separator.
74
- const prev = channel0[i - 1];
75
- const next = channel0[i + 1];
76
- const alone = (!prev || prev.line !== t.line) && (!next || next.line !== t.line);
77
- if (alone) {
78
- const nl = text.indexOf("\n", t.stop + 1);
79
- ends.push(nl === -1 ? text.length : nl + 1);
80
- }
106
+ else if (depth === 0) {
107
+ const sep = separatorAt(text, channel0, i, dialect);
108
+ if (sep)
109
+ ends.push(sep);
81
110
  }
82
111
  }
83
- return ends;
112
+ if (depth > 0 && opener) {
113
+ // Unclosed level: keep the split points before it, then split at every separator from it on.
114
+ ends = ends.slice(0, opener.endsBefore);
115
+ for (let i = opener.index; i < channel0.length; i++) {
116
+ const sep = separatorAt(text, channel0, i, dialect);
117
+ if (sep)
118
+ ends.push(sep);
119
+ }
120
+ }
121
+ // Tokens are in source order, so the last channel-0 token decides whether anything real follows
122
+ // the last separator.
123
+ const lastReal = channel0[channel0.length - 1];
124
+ const last = ends[ends.length - 1];
125
+ if (last && (lastReal === undefined || lastReal.start < last.end)) {
126
+ ends.pop();
127
+ return { ends, tail: last.separator };
128
+ }
129
+ return { ends };
84
130
  }
85
- /** Turn ascending split-end offsets into contiguous cells tiling `[0, text.length)`. A doc
86
- * with no separators is one cell; trailing text after the last separator is its own cell
87
- * but when the last separator already reaches `length` there is no trailing cell to add. */
88
- function buildCells(splitEnds, length) {
131
+ /** Turn ascending split points into contiguous cells tiling `[0, text.length)`. A doc with no
132
+ * split points is one cell; text after the last split point is the final cell, carrying `tail`
133
+ * (its own separator, when it is a terminated statement whose trailing trivia was folded in). */
134
+ function buildCells(splitEnds, tail, length) {
89
135
  const spans = [];
90
136
  let start = 0;
91
- for (const end of splitEnds) {
92
- spans.push({ start, end });
93
- start = end;
137
+ for (const e of splitEnds) {
138
+ spans.push({ start, end: e.end, separator: e.separator });
139
+ start = e.end;
94
140
  }
95
141
  if (start < length || spans.length === 0)
96
- spans.push({ start, end: length });
142
+ spans.push(tail ? { start, end: length, separator: tail } : { start, end: length });
97
143
  return spans;
98
144
  }
99
145
  /** The tiling invariant: contiguous, starts at 0, ends at `length`, in order. */
@@ -115,14 +161,16 @@ function tiles(spans, length) {
115
161
  * Total: never throws. Splits at channel-0 `;` at compound depth 0 (BEGIN/CASE
116
162
  * increment, END decrements, floor 0; a T-SQL `BEGIN TRAN`/`TRANSACTION`/
117
163
  * `DISTRIBUTED` does not open a depth level) plus, for T-SQL, a `GO` batch
118
- * separator alone on its line. Returns the whole doc as one cell when
164
+ * separator alone on its line; text ending inside an open level splits at every
165
+ * separator from the unclosed opener on (see `findSplitEnds`). Each cell carries
166
+ * the separator token that ends it. Returns the whole doc as one cell when
119
167
  * splitting is unsafe (the tiling invariant fails) or pointless (no separators).
120
168
  */
121
169
  export function splitStatements(text, dialect) {
122
170
  try {
123
171
  const tokens = tokenize(text, dialect);
124
- const splitEnds = findSplitEnds(text, tokens, dialect);
125
- const spans = buildCells(splitEnds, text.length);
172
+ const { ends, tail } = findSplitEnds(text, tokens, dialect);
173
+ const spans = buildCells(ends, tail, text.length);
126
174
  return tiles(spans, text.length) ? spans : wholeDoc(text);
127
175
  }
128
176
  catch (e) {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { parse, analyze, toAst, toScopes, qualify, lineage, deriveSymbols, TypeInfo, type Nullability, Lineage, originsOfExpr, lineageAt, lineageOf, type LineageHop, type ViaStep, tokenize, SqlDocument, LineIndex, complete, completeAt, type Completion, type CompletionResult, type ReplaceRange, type CompleteOptions, type CandidateIdentity, type CandidateDecoration, type DecorateCandidate, jinjaSlotAt, type JinjaSlot, signatureAt, SIGNATURES, lookupSignature, hasSignature, renderSignature, FN_DOCS, lookupFnDoc, referencesAt, type Occurrence, type Occurrences, frameAt, type Frame, clausesOf, type ClauseInfo, type ClauseKind, setOpArmsOf, type SetOpArm, type SetOpArms, dialectSymbols, type DialectSymbols, dialectVocabulary, type DialectVocabulary, reservedKeywords, type KeywordEntry, type KeywordReservation, CallbackSchema, type SchemaProvider, type TableResolver, DERIVED_DIALECTS, resolveDialect, foldIdentifier, displayName, type IdentKind, type SignatureHelpInfo, type SignatureLabel, type FnSignature, type ParamSig, type RenderSignatureOptions, type FnDoc, type Dialect, type DialectOpts, type ParseResultIR, type Analysis, type Token, type TokenRole, type DocumentAnalysis, type StatementCell, type StatementCellSpan, type DocumentVariant, type UnionCte, } from "./api.js";
1
+ export { parse, analyze, toAst, toScopes, qualify, lineage, deriveSymbols, TypeInfo, type Nullability, Lineage, originsOfExpr, lineageAt, lineageOf, type LineageHop, type ViaStep, tokenize, SqlDocument, LineIndex, complete, completeAt, type Completion, type CompletionResult, type ReplaceRange, type CompleteOptions, type CandidateIdentity, type CandidateDecoration, type DecorateCandidate, jinjaSlotAt, type JinjaSlot, signatureAt, SIGNATURES, lookupSignature, hasSignature, renderSignature, FN_DOCS, lookupFnDoc, referencesAt, type Occurrence, type Occurrences, frameAt, type Frame, clausesOf, type ClauseInfo, type ClauseKind, setOpArmsOf, type SetOpArm, type SetOpArms, dialectSymbols, type DialectSymbols, dialectVocabulary, type DialectVocabulary, reservedKeywords, type KeywordEntry, type KeywordReservation, CallbackSchema, type SchemaProvider, type TableResolver, DERIVED_DIALECTS, resolveDialect, foldIdentifier, displayName, type IdentKind, type SignatureHelpInfo, type SignatureLabel, type FnSignature, type ParamSig, type RenderSignatureOptions, type FnDoc, type Dialect, type DialectOpts, type ParseResultIR, type Analysis, type Token, type TokenRole, type DocumentAnalysis, type StatementCell, type StatementCellSpan, statementSpans, type DocumentVariant, type UnionCte, } from "./api.js";
2
2
  export { SqlSession, type SessionOptions } from "./session.js";
3
3
  export { parseDatabricks } from "./databricks/parse.js";
4
4
  export { parseTSql } from "./tsql/parse.js";
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // - Building blocks: the per-dialect parse*/lower and the raw shared passes, for callers who
11
11
  // want a specific tier or the raw CST escape hatch.
12
12
  // --- Uniform entry, lift helpers, typed wrappers, composable passes (src/api.ts) ---
13
- export { parse, analyze, toAst, toScopes, qualify, lineage, deriveSymbols, TypeInfo, Lineage, originsOfExpr, lineageAt, lineageOf, tokenize, SqlDocument, LineIndex, complete, completeAt, jinjaSlotAt, signatureAt, SIGNATURES, lookupSignature, hasSignature, renderSignature, FN_DOCS, lookupFnDoc, referencesAt, frameAt, clausesOf, setOpArmsOf, dialectSymbols, dialectVocabulary, reservedKeywords, CallbackSchema, DERIVED_DIALECTS, resolveDialect, foldIdentifier, displayName, } from "./api.js";
13
+ export { parse, analyze, toAst, toScopes, qualify, lineage, deriveSymbols, TypeInfo, Lineage, originsOfExpr, lineageAt, lineageOf, tokenize, SqlDocument, LineIndex, complete, completeAt, jinjaSlotAt, signatureAt, SIGNATURES, lookupSignature, hasSignature, renderSignature, FN_DOCS, lookupFnDoc, referencesAt, frameAt, clausesOf, setOpArmsOf, dialectSymbols, dialectVocabulary, reservedKeywords, CallbackSchema, DERIVED_DIALECTS, resolveDialect, foldIdentifier, displayName, statementSpans, } from "./api.js";
14
14
  // SqlSession — the verb-shaped facade over one SqlDocument (offset in, answer out; pure delegation).
15
15
  export { SqlSession } from "./session.js";
16
16
  // --- Per-dialect building blocks: parse* (CST + errors) and lower* (CST → IR), kept as one
@@ -15,6 +15,12 @@ export interface TagCorrelation {
15
15
  byNode: WeakMap<object, TagNode>;
16
16
  /** TagNode → the IR node it became (undefined-by-absence for tags with no IR presence). */
17
17
  byTag: Map<TagNode, object>;
18
+ /** Every attached (node, tag) pair in attach order: the enumerable form of `byNode`, for a
19
+ * caller that must re-key the join to another parse's TagNodes (a cached statement cell). */
20
+ links: {
21
+ node: object;
22
+ tag: TagNode;
23
+ }[];
18
24
  }
19
25
  /**
20
26
  * Rewrite templated FROM/JOIN sources in `ast` to carry their provider-resolved name (when the
@@ -39,6 +39,7 @@ const ZERO_BASE = { line: 0, column: 0, offset: 0 };
39
39
  * of which one this walk visits first. */
40
40
  function attach(ctx, node, tag) {
41
41
  ctx.byNode.set(node, tag);
42
+ ctx.links.push({ node, tag });
42
43
  const existing = ctx.byTag.get(tag);
43
44
  if (existing?.kind !== "column")
44
45
  ctx.byTag.set(tag, node);
@@ -59,13 +60,14 @@ function attach(ctx, node, tag) {
59
60
  export function applyTemplateTags(ast, tags, text, provider, base = ZERO_BASE) {
60
61
  const byNode = new WeakMap();
61
62
  const byTag = new Map();
63
+ const links = [];
62
64
  try {
63
65
  // config is a no-output tag (whitespace-filled), so it can never yield a table
64
66
  // source and stays out of the correlation set even though ExprTag admits it.
65
67
  // An incomplete/mid-typing call (`{{ ref('cu`) is NOT a resolved source, so skip it.
66
68
  const relTags = tags.filter((t) => (t.kind === "call" && !t.incomplete) || t.kind === "other");
67
69
  if (relTags.length === 0)
68
- return { ast, byNode, byTag };
70
+ return { ast, byNode, byTag, links };
69
71
  const nameConfig = ast.dialect !== undefined ? resolveBehavior(ast.dialect).nameConfig : undefined;
70
72
  const ctx = {
71
73
  relTags,
@@ -75,6 +77,7 @@ export function applyTemplateTags(ast, tags, text, provider, base = ZERO_BASE) {
75
77
  base,
76
78
  byNode,
77
79
  byTag,
80
+ links,
78
81
  ...(nameConfig ? { nameConfig } : {}),
79
82
  };
80
83
  const next = transformQuery(ast, ctx);
@@ -82,11 +85,11 @@ export function applyTemplateTags(ast, tags, text, provider, base = ZERO_BASE) {
82
85
  // fill gets a `template` marker (span + provider key), so inference resolves it
83
86
  // through the provider and qualify never checks the placeholder as a real column.
84
87
  const marked = markTemplateExprs(next, ctx);
85
- return { ast: marked === ast ? ast : freezeIR(marked), byNode, byTag };
88
+ return { ast: marked === ast ? ast : freezeIR(marked), byNode, byTag, links };
86
89
  }
87
90
  catch (e) {
88
91
  debugRethrow(e);
89
- return { ast, byNode: new WeakMap(), byTag: new Map() };
92
+ return { ast, byNode: new WeakMap(), byTag: new Map(), links: [] };
90
93
  }
91
94
  }
92
95
  /** The TemplateExprInfo for a scalar-slot tag: its span + (when an identity is extractable)
@@ -1,4 +1,4 @@
1
- import { parseTemplated, parseTemplatedCell } from "./parse.js";
1
+ import { parseTemplated, parseTemplatedCell, placeholderOf } 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,6 +7,7 @@ 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
+ placeholder: (text, _dialect, opts) => placeholderOf(text, opts),
10
11
  parseCell: (whole, span, text, dialect, opts) => parseTemplatedCell(whole, span, text, dialect, opts),
11
12
  };
12
13
  }
@@ -35,10 +35,15 @@ export declare function tokenizeTemplated(text: string, dialect: Dialect, opts?:
35
35
  * correlated onto it by DOCUMENT offset (a node's cell offset plus the cell's start). The cell's
36
36
  * sources then carry their provider-resolved names and `template` markers exactly as the
37
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.
38
+ * every other span in the cell IR, and the tag↔node join is returned as cell-relative `links`
39
+ * so a cached cell re-keys it to whatever parse it is later reused under.
39
40
  * Total: `applyTemplateTags` leaves the plain parse in place on any internal surprise.
40
41
  */
41
42
  export declare function parseTemplatedCell(whole: TemplatedParseResult, span: {
42
43
  start: number;
43
44
  end: number;
44
45
  }, text: string, dialect: Dialect, opts?: TemplatedParseOptions): TemplatedCellResult;
46
+ /** The placeholder `parseTemplated` would parse for `text` (`TemplateEngine.placeholder`): the
47
+ * segmenter alone, no SQL parse. Total: the text itself when segmentation gives up, the same
48
+ * floor `parseTemplated`'s degrade path reports as its placeholder. */
49
+ export declare function placeholderOf(text: string, opts?: TemplatedParseOptions): string;
@@ -658,7 +658,8 @@ export function tokenizeTemplated(text, dialect, opts) {
658
658
  * correlated onto it by DOCUMENT offset (a node's cell offset plus the cell's start). The cell's
659
659
  * sources then carry their provider-resolved names and `template` markers exactly as the
660
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.
661
+ * every other span in the cell IR, and the tag↔node join is returned as cell-relative `links`
662
+ * so a cached cell re-keys it to whatever parse it is later reused under.
662
663
  * Total: `applyTemplateTags` leaves the plain parse in place on any internal surprise.
663
664
  */
664
665
  export function parseTemplatedCell(whole, span, text, dialect, opts) {
@@ -667,10 +668,25 @@ export function parseTemplatedCell(whole, span, text, dialect, opts) {
667
668
  const correlation = applyTemplateTags(sql.ast, whole.tags, text, provider, cellBaseOf(text, span.start));
668
669
  return {
669
670
  sql: { ...sql, ast: correlation.ast },
670
- tagOf: (node) => correlation.byNode.get(node),
671
- nodeOf: (tag) => correlation.byTag.get(tag),
671
+ links: correlation.links.map(({ node, tag }) => ({
672
+ node,
673
+ tagStart: tag.tagSpan.start - span.start,
674
+ primary: correlation.byTag.get(tag) === node,
675
+ })),
672
676
  };
673
677
  }
678
+ /** The placeholder `parseTemplated` would parse for `text` (`TemplateEngine.placeholder`): the
679
+ * segmenter alone, no SQL parse. Total: the text itself when segmentation gives up, the same
680
+ * floor `parseTemplated`'s degrade path reports as its placeholder. */
681
+ export function placeholderOf(text, opts) {
682
+ try {
683
+ return segment(text, opts?.provider ?? OPEN_PROVIDER).placeholder;
684
+ }
685
+ catch (e) {
686
+ debugRethrow(e);
687
+ return text;
688
+ }
689
+ }
674
690
  /** The cell start's 0-based line / column / char offset in `text` (`\n` is the line break, the
675
691
  * convention every span in the pipeline follows; a `\r` is an ordinary column). */
676
692
  function cellBaseOf(text, offset) {
@@ -88,6 +88,17 @@ export interface TemplatedParseResult {
88
88
  * scrubber widened to it. Empty array when none. */
89
89
  diagnosticsOf(tag: TagNode): SyntaxDiagnostic[];
90
90
  }
91
+ /** One template-marked node of a statement cell's IR and the tag it came from, the tag named by
92
+ * its CELL-relative start offset rather than by object: a cell is cached across edits and reused
93
+ * under a later parse whose TagNodes are fresh objects, so the join is re-keyed to that parse's
94
+ * tag at the same offset. `primary` marks the tag's one answer (a scalar-slot tag marks both a
95
+ * column expression and its column-ref record; the expression is primary). */
96
+ export interface TemplatedCellLink {
97
+ node: object;
98
+ /** `tag.tagSpan.start - cell.span.start`. */
99
+ tagStart: number;
100
+ primary: boolean;
101
+ }
91
102
  /** One statement CELL of a templated document (`TemplateEngine.parseCell`): the plain per-dialect
92
103
  * parse of the cell's placeholder slice, in CELL-relative coordinates like a plain document's
93
104
  * cells, with the whole-document tags correlated onto it (provider-resolved source names,
@@ -96,11 +107,9 @@ export interface TemplatedCellResult {
96
107
  /** The cell's SQL parse over its placeholder slice (ast / cst / tokens / errors / diagnostics),
97
108
  * every span cell-relative. A `template` marker's span is cell-relative too. */
98
109
  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;
110
+ /** The tag↔node join over THIS cell's IR, position- and identity-independent (see
111
+ * `TemplatedCellLink`). Empty when nothing correlates. */
112
+ links: TemplatedCellLink[];
104
113
  }
105
114
  /** A template engine: the syntax front end for one templating language over
106
115
  * SQL. `parse` must satisfy the engine contract the conformance suite
@@ -113,7 +122,11 @@ export interface TemplateEngine {
113
122
  parse(text: string, dialect: Dialect, opts?: TemplatedParseOptions): TemplatedParseResult;
114
123
  /** Optional: coherent per-branch variant enumeration, for engines with control-flow arms. */
115
124
  variants?(text: string, dialect: Dialect): TemplateVariant[];
116
- /** Optional: the products of ONE statement cell of a templated document — the plain parse of
125
+ /** Optional: the placeholder-filled SQL text `parse` would see for `text` (see
126
+ * `TemplatedParseResult.placeholder`), without running the SQL parse. Total: on any internal
127
+ * surprise answer `text` itself. Backs `statementSpans`, which only needs the split. */
128
+ placeholder?(text: string, dialect: Dialect, opts?: TemplatedParseOptions): string;
129
+ /** Optional: the products of ONE statement cell of a templated document, the plain parse of
117
130
  * `whole.placeholder`'s slice `[span.start, span.end)` (cell-relative) with `whole`'s tags
118
131
  * correlated onto it. `whole` is this engine's own `parse` result for the full `text`, `span`
119
132
  * a `splitStatements` span over that placeholder. An engine without it keeps the templated
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqllens",
3
- "version": "1.10.0",
3
+ "version": "1.11.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",