sqllens 1.8.1 → 1.9.1
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/dist/bigquery/parse.js +1 -0
- package/dist/databricks/parse.js +1 -0
- package/dist/duckdb/parse.js +1 -0
- package/dist/fragment-grammar.d.ts +6 -1
- package/dist/fragment-grammar.js +60 -29
- package/dist/fragment.d.ts +3 -0
- package/dist/fragment.js +26 -9
- package/dist/minijinja/index.d.ts +2 -1
- package/dist/minijinja/index.js +1 -1
- package/dist/minijinja/parse.d.ts +12 -2
- package/dist/minijinja/parse.js +230 -29
- package/dist/minijinja/segment.js +21 -11
- package/dist/mysql/parse.js +1 -0
- package/dist/postgres/parse.js +1 -0
- package/dist/qualify/template-provider.d.ts +8 -3
- package/dist/qualify/template-provider.js +5 -1
- package/dist/redshift/parse.js +1 -0
- package/dist/snowflake/parse.js +1 -0
- package/dist/sqlite/parse.js +1 -0
- package/dist/template/engine.d.ts +38 -1
- package/dist/trino/parse.js +1 -0
- package/dist/tsql/parse.js +4 -1
- package/package.json +1 -1
package/dist/bigquery/parse.js
CHANGED
|
@@ -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(),
|
package/dist/databricks/parse.js
CHANGED
|
@@ -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(),
|
package/dist/duckdb/parse.js
CHANGED
|
@@ -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(),
|
|
@@ -35,9 +35,14 @@ 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
|
};
|
|
43
|
+
/** The list separator token type (COMMA). A list body (`cteList`/`selectList`) may end with
|
|
44
|
+
* one: a macro's CTE list often ends `),` because the caller appends more CTEs. */
|
|
45
|
+
separator: number;
|
|
41
46
|
/** Tree-walking checks the dialect's statement entry runs after the parse (bigquery). */
|
|
42
47
|
postParse?: (tree: ParserRuleContext) => SyntaxDiagnostic[];
|
|
43
48
|
}
|
package/dist/fragment-grammar.js
CHANGED
|
@@ -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
|
-
|
|
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,19 @@ 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
|
+
const alternatives = (kind) => {
|
|
55
|
+
const e = entries[kind];
|
|
56
|
+
return Array.isArray(e) ? e : [e];
|
|
57
|
+
};
|
|
58
|
+
/** The entry, then a trailing separator on a list body is consumed rather than left over. */
|
|
59
|
+
const run = (parser, entry, kind) => {
|
|
60
|
+
const tree = entry(parser);
|
|
61
|
+
const input = parser.inputStream;
|
|
62
|
+
if (LIST_KINDS.has(kind) && input.LA(1) === separator && input.LA(2) === AntlrToken.EOF)
|
|
63
|
+
input.consume();
|
|
64
|
+
return tree;
|
|
65
|
+
};
|
|
52
66
|
const lex = spec.lex ??
|
|
53
67
|
((text) => {
|
|
54
68
|
const lexer = newLexer(CharStream.fromString(text));
|
|
@@ -58,36 +72,53 @@ export function defineFragmentGrammar(spec) {
|
|
|
58
72
|
return {
|
|
59
73
|
lex,
|
|
60
74
|
parse(slice, kind, bail) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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;
|
|
73
104
|
}
|
|
74
|
-
|
|
75
|
-
|
|
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 };
|
|
76
119
|
}
|
|
77
|
-
// A grammar action can report through the listener without throwing (bigquery's
|
|
78
|
-
// join-balance check); that is not a clean parse either. Nor is leftover input.
|
|
79
|
-
if (collector.diagnostics.length > 0 || trailingInput(parser))
|
|
80
|
-
return undefined;
|
|
81
|
-
const post = postParse?.(tree) ?? [];
|
|
82
|
-
return post.length === 0 ? { tree, diagnostics: [] } : undefined;
|
|
83
120
|
}
|
|
84
|
-
|
|
85
|
-
const tree = entries[kind](parser);
|
|
86
|
-
const trailing = trailingInput(parser);
|
|
87
|
-
return {
|
|
88
|
-
tree,
|
|
89
|
-
diagnostics: [...collector.diagnostics, ...(trailing ? [trailing] : []), ...(postParse?.(tree) ?? [])],
|
|
90
|
-
};
|
|
121
|
+
return best;
|
|
91
122
|
},
|
|
92
123
|
};
|
|
93
124
|
}
|
package/dist/fragment.d.ts
CHANGED
|
@@ -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 =
|
|
47
|
-
if (!slice
|
|
66
|
+
const slice = sliceOf(ranges);
|
|
67
|
+
if (!slice)
|
|
48
68
|
return undefined;
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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";
|
package/dist/minijinja/index.js
CHANGED
|
@@ -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
|
package/dist/minijinja/parse.js
CHANGED
|
@@ -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.
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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,12 +268,14 @@ 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)
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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
|
-
function reparseMacroBodies(regions, diagnostics, placeholder,
|
|
278
|
+
function reparseMacroBodies(regions, diagnostics, placeholder, fragments) {
|
|
256
279
|
const macros = [];
|
|
257
280
|
// Macros can sit under an if/for (a guarded definition); a macro inside a macro body is
|
|
258
281
|
// covered by the outer body's read and not visited on its own.
|
|
@@ -268,9 +291,7 @@ function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
|
|
|
268
291
|
visit(regions);
|
|
269
292
|
if (macros.length === 0)
|
|
270
293
|
return diagnostics;
|
|
271
|
-
const fragments = openFragments(placeholder, dialect);
|
|
272
294
|
const bodies = [];
|
|
273
|
-
const own = [];
|
|
274
295
|
for (const region of macros) {
|
|
275
296
|
const arm = region.arms[0];
|
|
276
297
|
if (!arm)
|
|
@@ -282,12 +303,9 @@ function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
|
|
|
282
303
|
if (body.end <= body.start)
|
|
283
304
|
continue;
|
|
284
305
|
bodies.push(body);
|
|
285
|
-
const
|
|
286
|
-
if (
|
|
287
|
-
|
|
288
|
-
own.push(...fragment.diagnostics);
|
|
289
|
-
if (fragment.clean)
|
|
290
|
-
region.body = fragment.kind;
|
|
306
|
+
const verdict = fragments().verdict([body]);
|
|
307
|
+
if (verdict)
|
|
308
|
+
region.body = verdict;
|
|
291
309
|
}
|
|
292
310
|
// The remainder: everything between the bodies, as the statement batch it always was.
|
|
293
311
|
const remainder = [];
|
|
@@ -299,8 +317,179 @@ function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
|
|
|
299
317
|
}
|
|
300
318
|
if (at < placeholder.length)
|
|
301
319
|
remainder.push({ start: at, end: placeholder.length });
|
|
302
|
-
const rest = fragments.parse(remainder, ["statement"]);
|
|
303
|
-
return [...diagnostics.filter((d) => d.offset === undefined), ...(rest?.diagnostics ?? [])
|
|
320
|
+
const rest = fragments().parse(remainder, ["statement"]);
|
|
321
|
+
return [...diagnostics.filter((d) => d.offset === undefined), ...(rest?.diagnostics ?? [])];
|
|
322
|
+
}
|
|
323
|
+
/** Insert a hidden WS-shaped token over every gap in the source-ordered stream whose original text
|
|
324
|
+
* is not pure whitespace (see the call site). Mutates `tokens` in place, keeping it sorted. */
|
|
325
|
+
function fillDeadGaps(tokens, text) {
|
|
326
|
+
const out = [];
|
|
327
|
+
let at = 0;
|
|
328
|
+
const fill = (start, end) => {
|
|
329
|
+
const slice = text.slice(start, end);
|
|
330
|
+
if (slice.trim().length === 0)
|
|
331
|
+
return;
|
|
332
|
+
const pos = docPosAt(text, start);
|
|
333
|
+
const endPos = endPosition(pos.line, pos.column, slice);
|
|
334
|
+
out.push({
|
|
335
|
+
type: 0, // antlr's INVALID_TYPE: no lexer rule produced this token
|
|
336
|
+
name: "WS",
|
|
337
|
+
text: slice,
|
|
338
|
+
start,
|
|
339
|
+
stop: end - 1,
|
|
340
|
+
line: pos.line,
|
|
341
|
+
column: pos.column,
|
|
342
|
+
endLine: endPos.endLine,
|
|
343
|
+
endColumn: endPos.endColumn,
|
|
344
|
+
channel: 1,
|
|
345
|
+
role: "whitespace",
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
for (const tok of tokens) {
|
|
349
|
+
if (tok.start > at)
|
|
350
|
+
fill(at, tok.start);
|
|
351
|
+
out.push(tok);
|
|
352
|
+
at = Math.max(at, tok.stop + 1);
|
|
353
|
+
}
|
|
354
|
+
if (at < text.length)
|
|
355
|
+
fill(at, text.length);
|
|
356
|
+
if (out.length !== tokens.length)
|
|
357
|
+
tokens.splice(0, tokens.length, ...out);
|
|
358
|
+
}
|
|
359
|
+
/** Fragment verdict → the provider's shape vocabulary (`MacroShape.shapes`). */
|
|
360
|
+
const VERDICT_SHAPE = {
|
|
361
|
+
statement: "statement",
|
|
362
|
+
expression: "expr",
|
|
363
|
+
tableSource: "relation",
|
|
364
|
+
cteList: "cte-definition",
|
|
365
|
+
selectList: "column-list",
|
|
366
|
+
};
|
|
367
|
+
/** The keyword a leading hole's jinja `default('…')` filter names → the clause shape it opens. */
|
|
368
|
+
const DEFAULT_KEYWORD_SHAPE = {
|
|
369
|
+
where: "where-clause",
|
|
370
|
+
and: "conjunct",
|
|
371
|
+
or: "conjunct",
|
|
372
|
+
};
|
|
373
|
+
/**
|
|
374
|
+
* `MacroShape` for every macro region, read from the text alone. Three sources, all in-text:
|
|
375
|
+
* 1. the body's fragment verdict (`region.body`), mapped 1:1;
|
|
376
|
+
* 2. a body led by a `{{ x|default('where') }}`-style hole: the default literal is the
|
|
377
|
+
* keyword the body opens with (`where` → where-clause, `and`/`or` → conjunct);
|
|
378
|
+
* 3. control flow: when every SQL byte of the body sits under `if` regions that have no
|
|
379
|
+
* `else` arm, the macro can render to nothing → `nothing`, last.
|
|
380
|
+
* Anything else stays out (never-wrong): a hole with no visible default, a return-only body.
|
|
381
|
+
*/
|
|
382
|
+
function macroShapesOf(regions, tags, text, placeholder, fragments) {
|
|
383
|
+
const out = [];
|
|
384
|
+
const visit = (list) => {
|
|
385
|
+
for (const region of list) {
|
|
386
|
+
if (region.kind !== "macro") {
|
|
387
|
+
for (const arm of region.arms)
|
|
388
|
+
visit(arm.children);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const arm = region.arms[0];
|
|
392
|
+
const open = tags.find((t) => t.kind === "control" && t.keyword === "macro" && t.tagSpan.start === region.span.start);
|
|
393
|
+
if (!arm || !open?.name || !open.nameSpan)
|
|
394
|
+
continue;
|
|
395
|
+
const shapes = [];
|
|
396
|
+
let keywordParam;
|
|
397
|
+
if (region.body)
|
|
398
|
+
shapes.push(VERDICT_SHAPE[region.body]);
|
|
399
|
+
else {
|
|
400
|
+
// A body opening with a hole: `{{ name }}` / `{{ name|default('kw') }}`, `name` one of
|
|
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
|
+
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) => 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
|
+
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
|
+
// 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
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if ((shapes.length > 0 || keywordParam) && rendersToNothing(arm, placeholder))
|
|
425
|
+
shapes.push("nothing");
|
|
426
|
+
out.push({
|
|
427
|
+
name: open.name,
|
|
428
|
+
nameSpan: open.nameSpan,
|
|
429
|
+
span: region.span,
|
|
430
|
+
shapes,
|
|
431
|
+
...(keywordParam ? { keywordParam } : {}),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
visit(regions);
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* The shapes a specific CALL of a macro takes: `macro.shapes`, with the keyword-parameter hole
|
|
440
|
+
* (if any) resolved from the call's own literal argument (positional or keyword) or the
|
|
441
|
+
* parameter's default. A call whose keyword is not a literal, or names a word that opens no
|
|
442
|
+
* known clause, resolves to nothing for that hole (never-wrong). Pure: definition text + call
|
|
443
|
+
* text, no project knowledge; a host's `shapeOf(call)` is `shapesForCall(index.get(call.name), call)`.
|
|
444
|
+
*/
|
|
445
|
+
export function shapesForCall(macro, call) {
|
|
446
|
+
const kp = macro.keywordParam;
|
|
447
|
+
if (!kp)
|
|
448
|
+
return macro.shapes;
|
|
449
|
+
const kwarg = call.kwargs?.find((k) => k.name === kp.name)?.value;
|
|
450
|
+
const positional = call.args[kp.index];
|
|
451
|
+
const word = (kwarg ?? positional ?? kp.default)?.toLowerCase();
|
|
452
|
+
const clause = word !== undefined ? DEFAULT_KEYWORD_SHAPE[word] : undefined;
|
|
453
|
+
const rest = macro.shapes.filter((s) => s !== "where-clause" && s !== "conjunct");
|
|
454
|
+
return clause ? [clause, ...rest] : rest;
|
|
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
|
+
}
|
|
472
|
+
/** The expression tag at the very start of a body (only whitespace, comments and control tags
|
|
473
|
+
* before it in the placeholder), or undefined when the body opens with SQL. */
|
|
474
|
+
function leadingHole(body, tags, placeholder) {
|
|
475
|
+
const first = placeholder.slice(body.start, body.end).search(/\S/);
|
|
476
|
+
if (first === -1)
|
|
477
|
+
return undefined;
|
|
478
|
+
const at = body.start + first;
|
|
479
|
+
return tags.find((t) => (t.kind === "call" || t.kind === "other") && t.tagSpan.start <= at && at < t.tagSpan.end);
|
|
480
|
+
}
|
|
481
|
+
/** True when every non-whitespace placeholder byte of the arm's body lies inside an `if` child
|
|
482
|
+
* region that has no `else` arm: nothing outside such regions, so the macro can render empty. */
|
|
483
|
+
function rendersToNothing(arm, placeholder) {
|
|
484
|
+
const optional = arm.children.filter((c) => c.kind === "if" && !c.arms.some((a) => a.keyword === "else"));
|
|
485
|
+
if (optional.length === 0)
|
|
486
|
+
return false;
|
|
487
|
+
const chars = placeholder.slice(arm.bodySpan.start, arm.bodySpan.end).split("");
|
|
488
|
+
for (const c of optional) {
|
|
489
|
+
for (let k = c.span.start; k < c.span.end; k++)
|
|
490
|
+
chars[k - arm.bodySpan.start] = " ";
|
|
491
|
+
}
|
|
492
|
+
return chars.join("").trim().length === 0;
|
|
304
493
|
}
|
|
305
494
|
/** The core build — total by construction (every composed piece is total). */
|
|
306
495
|
function build(text, dialect, provider) {
|
|
@@ -365,6 +554,12 @@ function build(text, dialect, provider) {
|
|
|
365
554
|
// disjoint (tag-contained SQL tokens were dropped), so a stable sort by start
|
|
366
555
|
// (stop as tiebreak) tiles the source.
|
|
367
556
|
const tokens = [...sqlTokens, ...jinjaTokens].sort((a, b) => a.start - b.start || a.stop - b.stop);
|
|
557
|
+
// Dead text with no carrier: the statically-dead loop arm above rides as trivia ONLY when an SQL
|
|
558
|
+
// token covers its blanked span. A dialect whose whitespace rule is `-> skip` (tsql) lexes no
|
|
559
|
+
// token over an all-space span, so the arm's true text (`union all`, a trailing `,`) would fall
|
|
560
|
+
// out of the stream. Every uncovered gap holding non-whitespace source text gets a synthesized
|
|
561
|
+
// hidden trivia token carrying that text, the same shape the carrier token takes elsewhere.
|
|
562
|
+
fillDeadGaps(tokens, text);
|
|
368
563
|
// Diagnostics: SQL + jinja, both already in document coordinates, source-ordered
|
|
369
564
|
// so squiggles line up with the merged stream. SQL diagnostics whose offending
|
|
370
565
|
// token is a placeholder fill are scrubbed first — the message quotes the ORIGINAL
|
|
@@ -384,7 +579,11 @@ function build(text, dialect, provider) {
|
|
|
384
579
|
// source, CTE list, select list; src/fragment.ts) and its own diagnostics replace whatever
|
|
385
580
|
// the statement parse reported inside that body. The IR and tokens stay the whole-file
|
|
386
581
|
// parse's: the fragment verdict rides the region as `body`.
|
|
387
|
-
|
|
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);
|
|
388
587
|
const { diagnostics: scrubbed, bySegment } = scrubPlaceholderDiagnostics(sqlDiagnostics, tagRanges, text, placeholder);
|
|
389
588
|
// Fold the scrubbed SQL diagnostics into the same per-tag map as the jinja ones
|
|
390
589
|
// (Task 10) — a tag's diagnostics are its own jinja parse errors PLUS whatever
|
|
@@ -407,6 +606,7 @@ function build(text, dialect, provider) {
|
|
|
407
606
|
tags,
|
|
408
607
|
regions,
|
|
409
608
|
symbols,
|
|
609
|
+
macros,
|
|
410
610
|
diagnostics,
|
|
411
611
|
placeholder,
|
|
412
612
|
tagOf: (node) => correlation.byNode.get(node),
|
|
@@ -436,6 +636,7 @@ export function parseTemplated(text, dialect, opts) {
|
|
|
436
636
|
tags: [],
|
|
437
637
|
regions: [],
|
|
438
638
|
symbols: [],
|
|
639
|
+
macros: [],
|
|
439
640
|
diagnostics: sql.diagnostics,
|
|
440
641
|
placeholder: text,
|
|
441
642
|
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
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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 =
|
|
616
|
+
shaped = fragmentFill(seg, candidate, info.isCall, slot);
|
|
605
617
|
}
|
|
606
|
-
|
|
607
|
-
|
|
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
|
package/dist/mysql/parse.js
CHANGED
|
@@ -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(),
|
package/dist/postgres/parse.js
CHANGED
|
@@ -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
|
|
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 } : {}),
|
package/dist/redshift/parse.js
CHANGED
|
@@ -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(),
|
package/dist/snowflake/parse.js
CHANGED
|
@@ -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(),
|
package/dist/sqlite/parse.js
CHANGED
|
@@ -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
|
/**
|
package/dist/trino/parse.js
CHANGED
|
@@ -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(),
|
package/dist/tsql/parse.js
CHANGED
|
@@ -80,9 +80,12 @@ 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
|
-
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()],
|
|
86
89
|
tableSource: (p) => p.table_source(),
|
|
87
90
|
cteList: separatedList((p) => p.common_table_expression(), TSqlLexer.COMMA),
|
|
88
91
|
selectList: (p) => p.select_list(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sqllens",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
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",
|