sqllens 1.8.1 → 1.9.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.
@@ -85,6 +85,7 @@ export const fragmentGrammar = defineFragmentGrammar({
85
85
  newLexer: (input) => new GoogleSQLLexer(input),
86
86
  newParser: (tokens) => new GoogleSQLParser(tokens),
87
87
  postParse: postParseDiagnostics,
88
+ separator: GoogleSQLLexer.COMMA_SYMBOL,
88
89
  entries: {
89
90
  statement: (p) => p.root(),
90
91
  expression: (p) => p.expression(),
@@ -79,6 +79,7 @@ function attachErrorCounter(lexer, parser, listener) {
79
79
  export const fragmentGrammar = defineFragmentGrammar({
80
80
  newLexer: (input) => new DatabricksLexer(input),
81
81
  newParser: (tokens) => new DatabricksParser(tokens),
82
+ separator: DatabricksLexer.COMMA,
82
83
  entries: {
83
84
  statement: (p) => p.multiStatement(),
84
85
  expression: (p) => p.expression(),
@@ -70,6 +70,7 @@ function attachErrorCounter(lexer, parser, listener) {
70
70
  export const fragmentGrammar = defineFragmentGrammar({
71
71
  newLexer: (input) => new DuckdbLexer(input),
72
72
  newParser: (tokens) => new DuckdbParser(tokens),
73
+ separator: DuckdbLexer.COMMA,
73
74
  entries: {
74
75
  statement: (p) => p.root(),
75
76
  expression: (p) => p.a_expr(),
@@ -38,6 +38,9 @@ export interface FragmentGrammarSpec<P extends Parser> {
38
38
  entries: {
39
39
  readonly [K in FragmentKind]: FragmentEntry<P>;
40
40
  };
41
+ /** The list separator token type (COMMA). A list body (`cteList`/`selectList`) may end with
42
+ * one: a macro's CTE list often ends `),` because the caller appends more CTEs. */
43
+ separator: number;
41
44
  /** Tree-walking checks the dialect's statement entry runs after the parse (bigquery). */
42
45
  postParse?: (tree: ParserRuleContext) => SyntaxDiagnostic[];
43
46
  }
@@ -19,12 +19,14 @@ export const FRAGMENT_KINDS = [
19
19
  "cteList",
20
20
  "selectList",
21
21
  ];
22
+ const LIST_KINDS = new Set(["cteList", "selectList"]);
22
23
  /** `item (sep item)*` driven from outside the grammar, for dialects without a list rule
23
24
  * (a CTE list without its WITH, a select list). The tree is the FIRST item's. */
24
25
  export function separatedList(item, separator) {
25
26
  return (parser) => {
26
27
  const first = item(parser);
27
- while (parser.inputStream.LA(1) === separator) {
28
+ // A separator followed by EOF is a trailing one (`run` consumes it), not another item.
29
+ while (parser.inputStream.LA(1) === separator && parser.inputStream.LA(2) !== AntlrToken.EOF) {
28
30
  parser.inputStream.consume(); // between rules there is no context to attach the separator to
29
31
  item(parser);
30
32
  }
@@ -48,7 +50,15 @@ function trailingInput(parser) {
48
50
  }
49
51
  /** Bind a dialect's lexer, parser and fragment entry rules into a `FragmentGrammar`. */
50
52
  export function defineFragmentGrammar(spec) {
51
- const { newLexer, newParser, entries, postParse } = spec;
53
+ const { newLexer, newParser, entries, separator, postParse } = spec;
54
+ /** 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);
57
+ const input = parser.inputStream;
58
+ if (LIST_KINDS.has(kind) && input.LA(1) === separator && input.LA(2) === AntlrToken.EOF)
59
+ input.consume();
60
+ return tree;
61
+ };
52
62
  const lex = spec.lex ??
53
63
  ((text) => {
54
64
  const lexer = newLexer(CharStream.fromString(text));
@@ -69,7 +79,7 @@ export function defineFragmentGrammar(spec) {
69
79
  sim.predictionMode = PredictionMode.SLL;
70
80
  let tree;
71
81
  try {
72
- tree = entries[kind](parser);
82
+ tree = run(parser, kind);
73
83
  }
74
84
  catch {
75
85
  return undefined;
@@ -82,7 +92,7 @@ export function defineFragmentGrammar(spec) {
82
92
  return post.length === 0 ? { tree, diagnostics: [] } : undefined;
83
93
  }
84
94
  sim.predictionMode = PredictionMode.LL;
85
- const tree = entries[kind](parser);
95
+ const tree = run(parser, kind);
86
96
  const trailing = trailingInput(parser);
87
97
  return {
88
98
  tree,
@@ -24,6 +24,9 @@ export interface FragmentSession {
24
24
  * verdict). Never throws: every attempt runs a recovering parser over a fixed kind list.
25
25
  */
26
26
  parse(ranges: readonly FragmentRange[], kinds?: readonly FragmentKind[]): FragmentResult | undefined;
27
+ /** The first kind the ranges parse clean as, or undefined (no token, or no clean reading).
28
+ * Probes only (SLL + bail); never a diagnostic. */
29
+ verdict(ranges: readonly FragmentRange[], kinds?: readonly FragmentKind[]): FragmentKind | undefined;
27
30
  }
28
31
  /** Lex `text` once with `dialect`'s statement-entry token pipeline; parse ranges of it after. */
29
32
  export declare function openFragments(text: string, dialect: Dialect): FragmentSession;
package/dist/fragment.js CHANGED
@@ -41,19 +41,36 @@ export function openFragments(text, dialect) {
41
41
  const grammar = FRAGMENT_GRAMMARS[dialect];
42
42
  const lexed = grammar.lex(text);
43
43
  const inRanges = (offset, ranges) => ranges.some((r) => offset >= r.start && offset < r.end);
44
+ const sliceOf = (ranges) => {
45
+ const slice = lexed.tokens.filter((t) => inRanges(t.start, ranges));
46
+ return slice.some((t) => t.channel === 0) ? slice : undefined;
47
+ };
48
+ // Token-derived diagnostics of a slice (bigquery literal escapes) hold under every reading.
49
+ const lexDiagsOf = (ranges) => lexed.diagnostics.filter((d) => d.offset !== undefined && inRanges(d.offset, ranges));
50
+ const probe = (slice, kinds) => {
51
+ for (const kind of kinds) {
52
+ const clean = grammar.parse(slice, kind, true);
53
+ if (clean)
54
+ return { kind, clean: true, tree: clean.tree, diagnostics: [] };
55
+ }
56
+ return undefined;
57
+ };
44
58
  return {
59
+ verdict(ranges, kinds = FRAGMENT_KINDS) {
60
+ const slice = sliceOf(ranges);
61
+ if (!slice || lexDiagsOf(ranges).length > 0)
62
+ return undefined;
63
+ return probe(slice, kinds)?.kind;
64
+ },
45
65
  parse(ranges, kinds = FRAGMENT_KINDS) {
46
- const slice = lexed.tokens.filter((t) => inRanges(t.start, ranges));
47
- if (!slice.some((t) => t.channel === 0))
66
+ const slice = sliceOf(ranges);
67
+ if (!slice)
48
68
  return undefined;
49
- // Token-derived diagnostics of the slice (bigquery literal escapes) hold under every reading.
50
- const lexDiags = lexed.diagnostics.filter((d) => d.offset !== undefined && inRanges(d.offset, ranges));
69
+ const lexDiags = lexDiagsOf(ranges);
51
70
  if (lexDiags.length === 0) {
52
- for (const kind of kinds) {
53
- const clean = grammar.parse(slice, kind, true);
54
- if (clean)
55
- return { kind, clean: true, tree: clean.tree, diagnostics: [] };
56
- }
71
+ const clean = probe(slice, kinds);
72
+ if (clean)
73
+ return clean;
57
74
  }
58
75
  let best;
59
76
  let bestAt = -1;
@@ -1,5 +1,6 @@
1
1
  export { minijinja } from "./engine.js";
2
- export { parseTemplated, tokenizeTemplated } from "./parse.js";
2
+ export { parseTemplated, tokenizeTemplated, shapesForCall } from "./parse.js";
3
+ export type { MacroShape } from "../template/engine.js";
3
4
  export type { TemplatedParseResult, TemplatedParseOptions } from "../template/engine.js";
4
5
  export type { TagNode, MacroCall } from "./parse.js";
5
6
  export { templateRegions, templateSymbols } from "./regions.js";
@@ -3,6 +3,6 @@
3
3
  // canonically declared in src/template/engine.ts and re-exported both there
4
4
  // and here.
5
5
  export { minijinja } from "./engine.js";
6
- export { parseTemplated, tokenizeTemplated } from "./parse.js";
6
+ export { parseTemplated, tokenizeTemplated, shapesForCall } from "./parse.js";
7
7
  export { templateRegions, templateSymbols } from "./regions.js";
8
8
  export { templateVariants } from "./variants.js";
@@ -1,10 +1,20 @@
1
1
  import type { Dialect } from "../dialect.js";
2
2
  import type { Token } from "../token/token.js";
3
- import type { TemplatedParseOptions, TemplatedParseResult } from "../template/engine.js";
3
+ import type { MacroShape, TemplatedParseOptions, TemplatedParseResult } from "../template/engine.js";
4
+ import type { ExpansionShape } from "../qualify/template-provider.js";
5
+ import type { TemplateCall } from "../ir/ir.js";
4
6
  export type { TagNode, MacroCall } from "./tag-ast.js";
5
7
  export type { TemplateRegion, TemplateArm, TemplateSymbol } from "./regions.js";
6
8
  export { templateRegions, templateSymbols } from "./regions.js";
7
- export type { TemplatedParseOptions, TemplatedParseResult } from "../template/engine.js";
9
+ export type { MacroShape, TemplatedParseOptions, TemplatedParseResult } from "../template/engine.js";
10
+ /**
11
+ * The shapes a specific CALL of a macro takes: `macro.shapes`, with the keyword-parameter hole
12
+ * (if any) resolved from the call's own literal argument (positional or keyword) or the
13
+ * parameter's default. A call whose keyword is not a literal, or names a word that opens no
14
+ * known clause, resolves to nothing for that hole (never-wrong). Pure: definition text + call
15
+ * text, no project knowledge; a host's `shapeOf(call)` is `shapesForCall(index.get(call.name), call)`.
16
+ */
17
+ export declare function shapesForCall(macro: MacroShape, call: TemplateCall): ExpansionShape[];
8
18
  /**
9
19
  * Parse raw jinja-SQL: one whole-document jinja lex (segment()), the untouched
10
20
  * per-dialect SQL parse over the resulting placeholder, a per-tag jinja parse over
@@ -202,28 +202,49 @@ function parseSliceTag(slice) {
202
202
  const tree = parser.tag();
203
203
  return { tree, diagnostics: collector.diagnostics };
204
204
  }
205
+ /** A pure placeholder-fill run: `j` + up to two base-35 ordinal chars + `j` padding (segment.ts). */
206
+ const FILL_RUN = /^j[0-9a-ik-z]{0,2}j*$/;
205
207
  /**
206
- * Scrub placeholder gibberish out of SQL syntax diagnostics. A diagnostic whose
207
- * offending token starts inside a tag range is really complaining about the TAG:
208
- * rewrite every occurrence of the placeholder token's text in the message with the
209
- * tag's original source text, and widen offset/length (+ line/column) to the whole
210
- * tag. Diagnostics outside every tag pass through untouched. `bySegment` carries the
211
- * widened diagnostics keyed by the owning tag segment — build() maps that back to a
212
- * TagNode for `diagnosticsOf`.
208
+ * Scrub placeholder gibberish out of SQL syntax diagnostics. Two parts:
209
+ * - every message: each placeholder FILL RUN of any tag is rewritten to that tag's original
210
+ * source text. ANTLR's "no viable alternative at input '…'" quotes a whole token RANGE, so a
211
+ * fill can sit inside a message whose offending token is plain SQL far away; fills are
212
+ * ordinal-unique, so plain substring replacement (longest fill first, a shorter fill can be
213
+ * a prefix of a longer one) is exact;
214
+ * - a diagnostic whose offending token starts inside a tag range is really complaining about
215
+ * the TAG: its offset/length (+ line/column) widen to the whole tag.
216
+ * `bySegment` carries the widened diagnostics keyed by the owning tag segment — build() maps
217
+ * that back to a TagNode for `diagnosticsOf`.
213
218
  */
214
219
  function scrubPlaceholderDiagnostics(diags, tagRanges, text, placeholder) {
215
220
  const bySegment = new Map();
216
221
  if (tagRanges.length === 0)
217
222
  return { diagnostics: diags, bySegment };
223
+ // fill run → the tag's source text, longest fill first.
224
+ const fills = [];
225
+ for (const tag of tagRanges) {
226
+ const tagText = text.slice(tag.start, tag.end);
227
+ for (const run of placeholder.slice(tag.start, tag.end).split(/\s+/)) {
228
+ if (run.length >= 3 && FILL_RUN.test(run))
229
+ fills.push([run, tagText]);
230
+ }
231
+ }
232
+ fills.sort((a, b) => b[0].length - a[0].length);
233
+ const scrubMessage = (message) => {
234
+ for (const [run, tagText] of fills)
235
+ if (message.includes(run))
236
+ message = message.split(run).join(tagText);
237
+ return message;
238
+ };
218
239
  const diagnostics = diags.map((d) => {
219
- if (d.offset === undefined)
220
- return d;
221
- const tag = tagRanges.find((s) => d.offset >= s.start && d.offset < s.end);
222
- if (!tag)
223
- return d;
240
+ const tag = d.offset === undefined ? undefined : tagRanges.find((s) => d.offset >= s.start && d.offset < s.end);
241
+ if (!tag) {
242
+ const message = scrubMessage(d.message);
243
+ return message === d.message ? d : { ...d, message };
244
+ }
224
245
  const seen = placeholder.slice(d.offset, d.offset + d.length);
225
246
  const tagText = text.slice(tag.start, tag.end);
226
- const message = seen.length > 0 ? d.message.split(`'${seen}'`).join(`'${tagText}'`) : d.message;
247
+ const message = scrubMessage(seen.length > 0 ? d.message.split(`'${seen}'`).join(`'${tagText}'`) : d.message);
227
248
  const pos = docPosAt(text, tag.start);
228
249
  const widened = {
229
250
  ...d,
@@ -247,10 +268,12 @@ function scrubPlaceholderDiagnostics(diags, tagRanges, text, placeholder) {
247
268
  * diagnostics are replaced wholesale: they are recovery noise once a body has derailed it
248
269
  * (a body that is a CASE expression leaves "missing 'CASE' at EOF" far outside itself).
249
270
  * What replaces them: the text OUTSIDE the macro bodies read as a statement batch (a model
250
- * file that also defines a macro keeps its real errors), plus each body's own fragment
251
- * read. Lexer diagnostics (offset-less) are kept as they are. Each region learns what its
252
- * body is (`body`); a body with no SQL token gets no verdict and no diagnostic. Mutates the
253
- * regions' `body` field only. Files without a macro region are untouched.
271
+ * file that also defines a macro keeps its real errors). Lexer diagnostics (offset-less)
272
+ * are kept as they are. Each region learns what its body is (`body`); a body that reads
273
+ * clean as none of the kinds carries NO diagnostic and no verdict: a body is whatever gets
274
+ * pasted at the call site (a clause tail led by a keyword hole has no reading and never
275
+ * will), so "matches no known shape" is not evidence of invalid SQL (never-wrong). Mutates
276
+ * the regions' `body` field only. Files without a macro region are untouched.
254
277
  */
255
278
  function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
256
279
  const macros = [];
@@ -270,7 +293,6 @@ function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
270
293
  return diagnostics;
271
294
  const fragments = openFragments(placeholder, dialect);
272
295
  const bodies = [];
273
- const own = [];
274
296
  for (const region of macros) {
275
297
  const arm = region.arms[0];
276
298
  if (!arm)
@@ -282,12 +304,9 @@ function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
282
304
  if (body.end <= body.start)
283
305
  continue;
284
306
  bodies.push(body);
285
- const fragment = fragments.parse([body]);
286
- if (!fragment)
287
- continue;
288
- own.push(...fragment.diagnostics);
289
- if (fragment.clean)
290
- region.body = fragment.kind;
307
+ const verdict = fragments.verdict([body]);
308
+ if (verdict)
309
+ region.body = verdict;
291
310
  }
292
311
  // The remainder: everything between the bodies, as the statement batch it always was.
293
312
  const remainder = [];
@@ -300,7 +319,153 @@ function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
300
319
  if (at < placeholder.length)
301
320
  remainder.push({ start: at, end: placeholder.length });
302
321
  const rest = fragments.parse(remainder, ["statement"]);
303
- return [...diagnostics.filter((d) => d.offset === undefined), ...(rest?.diagnostics ?? []), ...own];
322
+ return [...diagnostics.filter((d) => d.offset === undefined), ...(rest?.diagnostics ?? [])];
323
+ }
324
+ /** Insert a hidden WS-shaped token over every gap in the source-ordered stream whose original text
325
+ * is not pure whitespace (see the call site). Mutates `tokens` in place, keeping it sorted. */
326
+ function fillDeadGaps(tokens, text) {
327
+ const out = [];
328
+ let at = 0;
329
+ const fill = (start, end) => {
330
+ const slice = text.slice(start, end);
331
+ if (slice.trim().length === 0)
332
+ return;
333
+ const pos = docPosAt(text, start);
334
+ const endPos = endPosition(pos.line, pos.column, slice);
335
+ out.push({
336
+ type: 0, // antlr's INVALID_TYPE: no lexer rule produced this token
337
+ name: "WS",
338
+ text: slice,
339
+ start,
340
+ stop: end - 1,
341
+ line: pos.line,
342
+ column: pos.column,
343
+ endLine: endPos.endLine,
344
+ endColumn: endPos.endColumn,
345
+ channel: 1,
346
+ role: "whitespace",
347
+ });
348
+ };
349
+ for (const tok of tokens) {
350
+ if (tok.start > at)
351
+ fill(at, tok.start);
352
+ out.push(tok);
353
+ at = Math.max(at, tok.stop + 1);
354
+ }
355
+ if (at < text.length)
356
+ fill(at, text.length);
357
+ if (out.length !== tokens.length)
358
+ tokens.splice(0, tokens.length, ...out);
359
+ }
360
+ /** Fragment verdict → the provider's shape vocabulary (`MacroShape.shapes`). */
361
+ const VERDICT_SHAPE = {
362
+ statement: "statement",
363
+ expression: "expr",
364
+ tableSource: "relation",
365
+ cteList: "cte-definition",
366
+ selectList: "column-list",
367
+ };
368
+ /** The keyword a leading hole's jinja `default('…')` filter names → the clause shape it opens. */
369
+ const DEFAULT_KEYWORD_SHAPE = {
370
+ where: "where-clause",
371
+ and: "conjunct",
372
+ or: "conjunct",
373
+ };
374
+ /**
375
+ * `MacroShape` for every macro region, read from the text alone. Three sources, all in-text:
376
+ * 1. the body's fragment verdict (`region.body`), mapped 1:1;
377
+ * 2. a body led by a `{{ x|default('where') }}`-style hole: the default literal is the
378
+ * keyword the body opens with (`where` → where-clause, `and`/`or` → conjunct);
379
+ * 3. control flow: when every SQL byte of the body sits under `if` regions that have no
380
+ * `else` arm, the macro can render to nothing → `nothing`, last.
381
+ * Anything else stays out (never-wrong): a hole with no visible default, a return-only body.
382
+ */
383
+ function macroShapesOf(regions, tags, text, placeholder) {
384
+ const out = [];
385
+ const visit = (list) => {
386
+ for (const region of list) {
387
+ if (region.kind !== "macro") {
388
+ for (const arm of region.arms)
389
+ visit(arm.children);
390
+ continue;
391
+ }
392
+ const arm = region.arms[0];
393
+ const open = tags.find((t) => t.kind === "control" && t.keyword === "macro" && t.tagSpan.start === region.span.start);
394
+ if (!arm || !open?.name || !open.nameSpan)
395
+ continue;
396
+ const shapes = [];
397
+ let keywordParam;
398
+ if (region.body)
399
+ shapes.push(VERDICT_SHAPE[region.body]);
400
+ else {
401
+ // 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).
403
+ const lead = leadingHole(arm.bodySpan, tags, placeholder);
404
+ const holeText = lead ? text.slice(lead.tagSpan.start, lead.tagSpan.end) : "";
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];
409
+ if (bound !== undefined && index >= 0) {
410
+ keywordParam = { name: bound, index, ...(fallback !== undefined ? { default: fallback } : {}) };
411
+ }
412
+ const clause = fallback ? DEFAULT_KEYWORD_SHAPE[fallback.toLowerCase()] : undefined;
413
+ if (clause)
414
+ shapes.push(clause);
415
+ }
416
+ if ((shapes.length > 0 || keywordParam) && rendersToNothing(arm, placeholder))
417
+ shapes.push("nothing");
418
+ out.push({
419
+ name: open.name,
420
+ nameSpan: open.nameSpan,
421
+ span: region.span,
422
+ shapes,
423
+ ...(keywordParam ? { keywordParam } : {}),
424
+ });
425
+ }
426
+ };
427
+ visit(regions);
428
+ return out;
429
+ }
430
+ /**
431
+ * The shapes a specific CALL of a macro takes: `macro.shapes`, with the keyword-parameter hole
432
+ * (if any) resolved from the call's own literal argument (positional or keyword) or the
433
+ * parameter's default. A call whose keyword is not a literal, or names a word that opens no
434
+ * known clause, resolves to nothing for that hole (never-wrong). Pure: definition text + call
435
+ * text, no project knowledge; a host's `shapeOf(call)` is `shapesForCall(index.get(call.name), call)`.
436
+ */
437
+ export function shapesForCall(macro, call) {
438
+ const kp = macro.keywordParam;
439
+ if (!kp)
440
+ return macro.shapes;
441
+ const kwarg = call.kwargs?.find((k) => k.name === kp.name)?.value;
442
+ const positional = call.args[kp.index];
443
+ const word = (kwarg ?? positional ?? kp.default)?.toLowerCase();
444
+ const clause = word !== undefined ? DEFAULT_KEYWORD_SHAPE[word] : undefined;
445
+ const rest = macro.shapes.filter((s) => s !== "where-clause" && s !== "conjunct");
446
+ return clause ? [clause, ...rest] : rest;
447
+ }
448
+ /** The expression tag at the very start of a body (only whitespace, comments and control tags
449
+ * before it in the placeholder), or undefined when the body opens with SQL. */
450
+ function leadingHole(body, tags, placeholder) {
451
+ const first = placeholder.slice(body.start, body.end).search(/\S/);
452
+ if (first === -1)
453
+ return undefined;
454
+ const at = body.start + first;
455
+ return tags.find((t) => (t.kind === "call" || t.kind === "other") && t.tagSpan.start <= at && at < t.tagSpan.end);
456
+ }
457
+ /** True when every non-whitespace placeholder byte of the arm's body lies inside an `if` child
458
+ * region that has no `else` arm: nothing outside such regions, so the macro can render empty. */
459
+ function rendersToNothing(arm, placeholder) {
460
+ const optional = arm.children.filter((c) => c.kind === "if" && !c.arms.some((a) => a.keyword === "else"));
461
+ if (optional.length === 0)
462
+ return false;
463
+ const chars = placeholder.slice(arm.bodySpan.start, arm.bodySpan.end).split("");
464
+ for (const c of optional) {
465
+ for (let k = c.span.start; k < c.span.end; k++)
466
+ chars[k - arm.bodySpan.start] = " ";
467
+ }
468
+ return chars.join("").trim().length === 0;
304
469
  }
305
470
  /** The core build — total by construction (every composed piece is total). */
306
471
  function build(text, dialect, provider) {
@@ -365,6 +530,12 @@ function build(text, dialect, provider) {
365
530
  // disjoint (tag-contained SQL tokens were dropped), so a stable sort by start
366
531
  // (stop as tiebreak) tiles the source.
367
532
  const tokens = [...sqlTokens, ...jinjaTokens].sort((a, b) => a.start - b.start || a.stop - b.stop);
533
+ // Dead text with no carrier: the statically-dead loop arm above rides as trivia ONLY when an SQL
534
+ // token covers its blanked span. A dialect whose whitespace rule is `-> skip` (tsql) lexes no
535
+ // token over an all-space span, so the arm's true text (`union all`, a trailing `,`) would fall
536
+ // out of the stream. Every uncovered gap holding non-whitespace source text gets a synthesized
537
+ // hidden trivia token carrying that text, the same shape the carrier token takes elsewhere.
538
+ fillDeadGaps(tokens, text);
368
539
  // Diagnostics: SQL + jinja, both already in document coordinates, source-ordered
369
540
  // so squiggles line up with the merged stream. SQL diagnostics whose offending
370
541
  // token is a placeholder fill are scrubbed first — the message quotes the ORIGINAL
@@ -385,6 +556,7 @@ function build(text, dialect, provider) {
385
556
  // the statement parse reported inside that body. The IR and tokens stay the whole-file
386
557
  // parse's: the fragment verdict rides the region as `body`.
387
558
  const sqlDiagnostics = reparseMacroBodies(regions, sqlResult.diagnostics, placeholder, dialect);
559
+ const macros = macroShapesOf(regions, tags, text, placeholder);
388
560
  const { diagnostics: scrubbed, bySegment } = scrubPlaceholderDiagnostics(sqlDiagnostics, tagRanges, text, placeholder);
389
561
  // Fold the scrubbed SQL diagnostics into the same per-tag map as the jinja ones
390
562
  // (Task 10) — a tag's diagnostics are its own jinja parse errors PLUS whatever
@@ -407,6 +579,7 @@ function build(text, dialect, provider) {
407
579
  tags,
408
580
  regions,
409
581
  symbols,
582
+ macros,
410
583
  diagnostics,
411
584
  placeholder,
412
585
  tagOf: (node) => correlation.byNode.get(node),
@@ -436,6 +609,7 @@ export function parseTemplated(text, dialect, opts) {
436
609
  tags: [],
437
610
  regions: [],
438
611
  symbols: [],
612
+ macros: [],
439
613
  diagnostics: sql.diagnostics,
440
614
  placeholder: text,
441
615
  degraded: true,
@@ -568,7 +568,11 @@ export function segment(text, provider) {
568
568
  // ONE provider consult per tag — the uniform seam. stmt/comment tags are pure
569
569
  // jinja control text and always whitespace-fill (no consult needed).
570
570
  const exp = seg.tagKind === "expr" && info.call ? provider.expansion(info.call) : undefined;
571
- const shape = exp?.shape;
571
+ // Candidate shapes, most specific first. The first one the slot admits fills the tag;
572
+ // `nothing`/`expr` never fail admission, so a list ends with one of them or falls through
573
+ // to the identifier fill like a single unadmitted shape does.
574
+ const candidates = exp?.shapes ?? (exp?.shape !== undefined ? [exp.shape] : []);
575
+ let shape = candidates[0];
572
576
  // Fusion boundary (anvil torture-corpus, 2026-07-06): a tag GLUED to a preceding SQL
573
577
  // clause/operator keyword (`from{{ ref('x') }}` — dbt compiles it because the rendered
574
578
  // relation opens with a quote char) must not fuse with the fill into one token: the old
@@ -595,19 +599,25 @@ export function segment(text, provider) {
595
599
  // comma is omitted. Anything else — another CTE name, or "" (ambiguous) — keeps it, which
596
600
  // is exactly the already-working "another CTE follows" behavior, unchanged.
597
601
  let shaped;
598
- if (shape === "cte-definition") {
599
- if (info.isCall && cteDefinitionSlotAdmits(slot)) {
600
- const needsComma = !QUERY_START_WORDS.has(followingSlot(chars, seg.end));
601
- shaped = fitWindow(seg, `${PLACEHOLDER_CHAR}${ordinalFill(ordinal)} as (select 1)${needsComma ? "," : ""}`);
602
+ for (const candidate of candidates) {
603
+ shape = candidate;
604
+ if (candidate === "cte-definition") {
605
+ if (info.isCall && cteDefinitionSlotAdmits(slot)) {
606
+ const needsComma = !QUERY_START_WORDS.has(followingSlot(chars, seg.end));
607
+ shaped = fitWindow(seg, `${PLACEHOLDER_CHAR}${ordinalFill(ordinal)} as (select 1)${needsComma ? "," : ""}`);
608
+ }
609
+ else {
610
+ shaped = undefined;
611
+ }
612
+ if (shaped !== undefined)
613
+ ordinal += 1;
602
614
  }
603
615
  else {
604
- shaped = undefined;
616
+ shaped = fragmentFill(seg, candidate, info.isCall, slot);
605
617
  }
606
- if (shaped !== undefined)
607
- ordinal += 1;
608
- }
609
- else {
610
- shaped = shape !== undefined ? fragmentFill(seg, shape, info.isCall, slot) : undefined;
618
+ // Admitted (a fragment landed), or a shape whose fill is positional and never refused.
619
+ if (shaped !== undefined || candidate === "nothing" || candidate === "expr")
620
+ break;
611
621
  }
612
622
  if (shaped !== undefined) {
613
623
  // Fragment fill: at the fit window's start (`at` — tag start for a one-line tag, the
@@ -76,6 +76,7 @@ function attachErrorCounter(lexer, parser, listener) {
76
76
  export const fragmentGrammar = defineFragmentGrammar({
77
77
  newLexer: (input) => new MysqlLexer(input),
78
78
  newParser: (tokens) => new MysqlParser(tokens),
79
+ separator: MysqlLexer.COMMA,
79
80
  entries: {
80
81
  statement: (p) => p.root(),
81
82
  expression: (p) => p.expression(),
@@ -75,6 +75,7 @@ function attachErrorCounter(lexer, parser, listener) {
75
75
  export const fragmentGrammar = defineFragmentGrammar({
76
76
  newLexer: (input) => new PostgresLexer(input),
77
77
  newParser: (tokens) => new PostgresParser(tokens),
78
+ separator: PostgresLexer.COMMA,
78
79
  entries: {
79
80
  statement: (p) => p.root(),
80
81
  expression: (p) => p.a_expr(),
@@ -48,8 +48,13 @@ export interface TemplateCandidate {
48
48
  /** Everything known about one call's expansion. Every field optional; `undefined` = unknown. */
49
49
  export interface ResolvedExpansion {
50
50
  /** Parse-time shape. When absent, derived from the strongest present field:
51
- * relation → "relation", columns → "column-list", value → "expr" (an explicit shape always wins). */
51
+ * relation → "relation", columns → "column-list", value → "expr" (an explicit shape always wins).
52
+ * With `shapes` present this is `shapes[0]`. */
52
53
  shape?: ExpansionShape;
54
+ /** Every shape the call can take, most specific first (a macro whose body is an `if` arm with
55
+ * no `else` is `["conjunct", "nothing"]`). The segmenter takes the first one the slot admits;
56
+ * `nothing` and `expr` are always admitted, so they belong last. Absent = `shape` alone. */
57
+ shapes?: ExpansionShape[];
53
58
  /** A relation-producing call (ref, source, a TVF-like macro). */
54
59
  relation?: ResolvedRelation;
55
60
  /** A scalar value — `{{ var('x') }}`, `{{ env_var('Y') }}`, a scalar macro. */
@@ -100,7 +105,7 @@ export declare class DefaultTemplateProvider implements SchemaProvider {
100
105
  /** The rendered-output shape of a call. Neutral floor: unknown (the engine derives a shape from
101
106
  * stronger fields, or falls back to its positional fill). The dbt no-output builtins → "nothing"
102
107
  * is `DbtTemplateProvider` knowledge. */
103
- shapeOf(_call: TemplateCall): ExpansionShape | undefined;
108
+ shapeOf(_call: TemplateCall): ExpansionShape | readonly ExpansionShape[] | undefined;
104
109
  /** The columns a column-list-producing macro emits. Default: unknown. */
105
110
  columnsOf(_call: TemplateCall): Column[] | undefined;
106
111
  /** The items a loop collection holds. Default: unknown (loops analyze one representative pass). */
@@ -167,7 +172,7 @@ export declare class DbtTemplateProvider extends DefaultTemplateProvider {
167
172
  valueOf(call: TemplateCall): {
168
173
  type: ValueType;
169
174
  } | undefined;
170
- shapeOf(call: TemplateCall): ExpansionShape | undefined;
175
+ shapeOf(call: TemplateCall): ExpansionShape | readonly ExpansionShape[] | undefined;
171
176
  }
172
177
  /** The provider type the engine consults — the shipped base (or any subclass of it, e.g.
173
178
  * `DbtTemplateProvider`). */
@@ -152,7 +152,10 @@ export class DefaultTemplateProvider {
152
152
  * undefined when nothing at all is known (the engine's zero-knowledge floor).
153
153
  */
154
154
  expansion(call) {
155
- const shape = this.shapeOf(call);
155
+ const answered = this.shapeOf(call);
156
+ // A list answer keeps its order; an empty list is no answer at all.
157
+ const shapes = Array.isArray(answered) ? (answered.length > 0 ? [...answered] : undefined) : undefined;
158
+ const shape = shapes ? shapes[0] : answered;
156
159
  const relation = this.relationOf(call);
157
160
  const value = this.valueOf(call);
158
161
  const columns = this.columnsOf(call);
@@ -162,6 +165,7 @@ export class DefaultTemplateProvider {
162
165
  return undefined;
163
166
  return {
164
167
  ...(derived !== undefined ? { shape: derived } : {}),
168
+ ...(shapes && shapes.length > 1 ? { shapes } : {}),
165
169
  ...(relation ? { relation } : {}),
166
170
  ...(value ? { value } : {}),
167
171
  ...(columns ? { columns } : {}),
@@ -76,6 +76,7 @@ function attachErrorCounter(lexer, parser, listener) {
76
76
  export const fragmentGrammar = defineFragmentGrammar({
77
77
  newLexer: (input) => new RedshiftLexer(input),
78
78
  newParser: (tokens) => new RedshiftParser(tokens),
79
+ separator: RedshiftLexer.COMMA,
79
80
  entries: {
80
81
  statement: (p) => p.root(),
81
82
  expression: (p) => p.a_expr(),
@@ -76,6 +76,7 @@ function attachErrorCounter(lexer, parser, listener) {
76
76
  export const fragmentGrammar = defineFragmentGrammar({
77
77
  newLexer: (input) => new SnowflakeLexer(input),
78
78
  newParser: (tokens) => new SnowflakeParser(tokens),
79
+ separator: SnowflakeLexer.COMMA,
79
80
  entries: {
80
81
  statement: (p) => p.snowflake_file(),
81
82
  expression: (p) => p.expr(),
@@ -76,6 +76,7 @@ function attachErrorCounter(lexer, parser, listener) {
76
76
  export const fragmentGrammar = defineFragmentGrammar({
77
77
  newLexer: (input) => new SqliteLexer(input),
78
78
  newParser: (tokens) => new SqliteParser(tokens),
79
+ separator: SqliteLexer.COMMA,
79
80
  entries: {
80
81
  statement: (p) => p.parse(),
81
82
  expression: (p) => p.expr(),
@@ -1,8 +1,9 @@
1
1
  import type { Dialect } from "../dialect.js";
2
+ import type { PartSpan } from "../ir/part-span.js";
2
3
  import type { ParseResultIR } from "../api.js";
3
4
  import type { SyntaxDiagnostic } from "../parse-diagnostics.js";
4
5
  import type { Token } from "../token/token.js";
5
- import type { TemplateProvider } from "../qualify/template-provider.js";
6
+ import type { ExpansionShape, TemplateProvider } from "../qualify/template-provider.js";
6
7
  import type { TagNode } from "../minijinja/tag-ast.js";
7
8
  import type { TemplateRegion, TemplateSymbol } from "../minijinja/regions.js";
8
9
  import type { TemplateVariant } from "../minijinja/variants.js";
@@ -13,6 +14,39 @@ export type { TemplateVariant } from "../minijinja/variants.js";
13
14
  export interface TemplatedParseOptions {
14
15
  provider?: TemplateProvider;
15
16
  }
17
+ /**
18
+ * What a `{% macro %}` definition's body can stand in for at a call site, read from the
19
+ * definition text alone (no project, no rendering): the body's fragment verdict mapped onto the
20
+ * provider's shape vocabulary, plus what its control flow adds. A host answers `shapeOf(call)`
21
+ * for a call by looking the macro up by name and returning `shapes` as they are.
22
+ */
23
+ export interface MacroShape {
24
+ /** The declared macro name (`{% macro name(...) %}`). */
25
+ name: string;
26
+ nameSpan: PartSpan;
27
+ /** The whole block, opening tag through `{% endmacro %}`. */
28
+ span: PartSpan;
29
+ /**
30
+ * Every shape the body can take, most specific first; empty when nothing can be established
31
+ * (never-wrong). `expression` → `expr`, `cteList` → `cte-definition`, `selectList` →
32
+ * `column-list`, `tableSource` → `relation`, `statement` → `statement`; a body led by a hole
33
+ * whose jinja `default('where')` / `default('and')` is visible → `where-clause` / `conjunct`;
34
+ * a body whose SQL all sits under an `if` with no `else` adds `nothing` last.
35
+ */
36
+ shapes: ExpansionShape[];
37
+ /**
38
+ * Present when the body OPENS with a hole bound to one of the macro's own parameters
39
+ * (`{{ stat|default('where') }} {{ col }} = 0`): the clause keyword the body starts with is
40
+ * whatever the caller passes for that parameter (or `default` when the caller passes
41
+ * nothing). `shapesForCall` resolves it per call; `shapes` above carries only the default's
42
+ * shape (or nothing when there is no default).
43
+ */
44
+ keywordParam?: {
45
+ name: string;
46
+ index: number;
47
+ default?: string;
48
+ };
49
+ }
16
50
  /** The unified result of parsing raw jinja-SQL: one token stream + the SQL parse + tags. */
17
51
  export interface TemplatedParseResult {
18
52
  /** ONE source-ordered stream: SQL tokens (channel 0) + jinja tokens (channel 2, role "minijinja"). */
@@ -25,6 +59,9 @@ export interface TemplatedParseResult {
25
59
  regions: TemplateRegion[];
26
60
  /** R4 go-to-def template symbols (set targets / macro names). */
27
61
  symbols: TemplateSymbol[];
62
+ /** Every `{% macro %}` definition in the text with the shapes its body can take (see
63
+ * `MacroShape`). Empty when the text defines no macro. */
64
+ macros: MacroShape[];
28
65
  /** SQL diagnostics (+ jinja diagnostics from Task 4), positioned in original coordinates. */
29
66
  diagnostics: SyntaxDiagnostic[];
30
67
  /**
@@ -73,6 +73,7 @@ function attachErrorCounter(lexer, parser, listener) {
73
73
  export const fragmentGrammar = defineFragmentGrammar({
74
74
  newLexer: (input) => new TrinoLexer(input),
75
75
  newParser: (tokens) => new TrinoParser(tokens),
76
+ separator: TrinoLexer.COMMA,
76
77
  entries: {
77
78
  statement: (p) => p.root(),
78
79
  expression: (p) => p.expression(),
@@ -80,6 +80,7 @@ function attachErrorCounter(lexer, parser, listener) {
80
80
  export const fragmentGrammar = defineFragmentGrammar({
81
81
  newLexer: (input) => new TSqlLexer(input),
82
82
  newParser: (tokens) => new TSqlParser(tokens),
83
+ separator: TSqlLexer.COMMA,
83
84
  entries: {
84
85
  statement: (p) => p.tsql_file(),
85
86
  expression: (p) => p.expression(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqllens",
3
- "version": "1.8.1",
3
+ "version": "1.9.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",