sqllens 1.9.0 → 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/fragment-grammar.d.ts +3 -1
- package/dist/fragment-grammar.js +50 -29
- package/dist/minijinja/parse.js +38 -11
- package/dist/tsql/parse.js +3 -1
- package/package.json +1 -1
|
@@ -35,8 +35,10 @@ export interface FragmentGrammarSpec<P extends Parser> {
|
|
|
35
35
|
lex?: (text: string) => FragmentLex;
|
|
36
36
|
newLexer: (input: CharStream) => Lexer;
|
|
37
37
|
newParser: (tokens: CommonTokenStream) => P;
|
|
38
|
+
/** One entry per kind, or several tried in order (tsql's `expression` is its scalar `expression`
|
|
39
|
+
* then `search_condition`: the grammar keeps comparisons out of the scalar rule). */
|
|
38
40
|
entries: {
|
|
39
|
-
readonly [K in FragmentKind]: FragmentEntry<P
|
|
41
|
+
readonly [K in FragmentKind]: FragmentEntry<P> | readonly FragmentEntry<P>[];
|
|
40
42
|
};
|
|
41
43
|
/** The list separator token type (COMMA). A list body (`cteList`/`selectList`) may end with
|
|
42
44
|
* one: a macro's CTE list often ends `),` because the caller appends more CTEs. */
|
package/dist/fragment-grammar.js
CHANGED
|
@@ -51,9 +51,13 @@ function trailingInput(parser) {
|
|
|
51
51
|
/** Bind a dialect's lexer, parser and fragment entry rules into a `FragmentGrammar`. */
|
|
52
52
|
export function defineFragmentGrammar(spec) {
|
|
53
53
|
const { newLexer, newParser, entries, separator, postParse } = spec;
|
|
54
|
+
const alternatives = (kind) => {
|
|
55
|
+
const e = entries[kind];
|
|
56
|
+
return Array.isArray(e) ? e : [e];
|
|
57
|
+
};
|
|
54
58
|
/** The entry, then a trailing separator on a list body is consumed rather than left over. */
|
|
55
|
-
const run = (parser, kind) => {
|
|
56
|
-
const tree =
|
|
59
|
+
const run = (parser, entry, kind) => {
|
|
60
|
+
const tree = entry(parser);
|
|
57
61
|
const input = parser.inputStream;
|
|
58
62
|
if (LIST_KINDS.has(kind) && input.LA(1) === separator && input.LA(2) === AntlrToken.EOF)
|
|
59
63
|
input.consume();
|
|
@@ -68,36 +72,53 @@ export function defineFragmentGrammar(spec) {
|
|
|
68
72
|
return {
|
|
69
73
|
lex,
|
|
70
74
|
parse(slice, kind, bail) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
75
|
+
// Each alternative gets a fresh parser over the same slice; bail mode returns the first
|
|
76
|
+
// clean one, LL mode the alternative that got furthest before its first error.
|
|
77
|
+
let best;
|
|
78
|
+
let bestAt = -1;
|
|
79
|
+
for (const entry of alternatives(kind)) {
|
|
80
|
+
const tokens = new CommonTokenStream(new ListTokenSource([...slice]));
|
|
81
|
+
const parser = newParser(tokens);
|
|
82
|
+
const collector = makeErrorCollector();
|
|
83
|
+
parser.removeErrorListeners();
|
|
84
|
+
parser.addErrorListener(collector.listener);
|
|
85
|
+
const sim = parser.interpreter;
|
|
86
|
+
if (bail) {
|
|
87
|
+
parser.errorHandler = new BailErrorStrategy();
|
|
88
|
+
sim.predictionMode = PredictionMode.SLL;
|
|
89
|
+
let tree;
|
|
90
|
+
try {
|
|
91
|
+
tree = run(parser, entry, kind);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// A grammar action can report through the listener without throwing (bigquery's
|
|
97
|
+
// join-balance check); that is not a clean parse either. Nor is leftover input.
|
|
98
|
+
if (collector.diagnostics.length > 0 || trailingInput(parser))
|
|
99
|
+
continue;
|
|
100
|
+
const post = postParse?.(tree) ?? [];
|
|
101
|
+
if (post.length === 0)
|
|
102
|
+
return { tree, diagnostics: [] };
|
|
103
|
+
continue;
|
|
83
104
|
}
|
|
84
|
-
|
|
85
|
-
|
|
105
|
+
sim.predictionMode = PredictionMode.LL;
|
|
106
|
+
const tree = run(parser, entry, kind);
|
|
107
|
+
const trailing = trailingInput(parser);
|
|
108
|
+
const diagnostics = [
|
|
109
|
+
...collector.diagnostics,
|
|
110
|
+
...(trailing ? [trailing] : []),
|
|
111
|
+
...(postParse?.(tree) ?? []),
|
|
112
|
+
];
|
|
113
|
+
if (diagnostics.length === 0)
|
|
114
|
+
return { tree, diagnostics };
|
|
115
|
+
const at = diagnostics[0].offset ?? Number.MAX_SAFE_INTEGER;
|
|
116
|
+
if (at > bestAt) {
|
|
117
|
+
bestAt = at;
|
|
118
|
+
best = { tree, diagnostics };
|
|
86
119
|
}
|
|
87
|
-
// A grammar action can report through the listener without throwing (bigquery's
|
|
88
|
-
// join-balance check); that is not a clean parse either. Nor is leftover input.
|
|
89
|
-
if (collector.diagnostics.length > 0 || trailingInput(parser))
|
|
90
|
-
return undefined;
|
|
91
|
-
const post = postParse?.(tree) ?? [];
|
|
92
|
-
return post.length === 0 ? { tree, diagnostics: [] } : undefined;
|
|
93
120
|
}
|
|
94
|
-
|
|
95
|
-
const tree = run(parser, kind);
|
|
96
|
-
const trailing = trailingInput(parser);
|
|
97
|
-
return {
|
|
98
|
-
tree,
|
|
99
|
-
diagnostics: [...collector.diagnostics, ...(trailing ? [trailing] : []), ...(postParse?.(tree) ?? [])],
|
|
100
|
-
};
|
|
121
|
+
return best;
|
|
101
122
|
},
|
|
102
123
|
};
|
|
103
124
|
}
|
package/dist/minijinja/parse.js
CHANGED
|
@@ -275,7 +275,7 @@ function scrubPlaceholderDiagnostics(diags, tagRanges, text, placeholder) {
|
|
|
275
275
|
* will), so "matches no known shape" is not evidence of invalid SQL (never-wrong). Mutates
|
|
276
276
|
* the regions' `body` field only. Files without a macro region are untouched.
|
|
277
277
|
*/
|
|
278
|
-
function reparseMacroBodies(regions, diagnostics, placeholder,
|
|
278
|
+
function reparseMacroBodies(regions, diagnostics, placeholder, fragments) {
|
|
279
279
|
const macros = [];
|
|
280
280
|
// Macros can sit under an if/for (a guarded definition); a macro inside a macro body is
|
|
281
281
|
// covered by the outer body's read and not visited on its own.
|
|
@@ -291,7 +291,6 @@ function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
|
|
|
291
291
|
visit(regions);
|
|
292
292
|
if (macros.length === 0)
|
|
293
293
|
return diagnostics;
|
|
294
|
-
const fragments = openFragments(placeholder, dialect);
|
|
295
294
|
const bodies = [];
|
|
296
295
|
for (const region of macros) {
|
|
297
296
|
const arm = region.arms[0];
|
|
@@ -304,7 +303,7 @@ function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
|
|
|
304
303
|
if (body.end <= body.start)
|
|
305
304
|
continue;
|
|
306
305
|
bodies.push(body);
|
|
307
|
-
const verdict = fragments.verdict([body]);
|
|
306
|
+
const verdict = fragments().verdict([body]);
|
|
308
307
|
if (verdict)
|
|
309
308
|
region.body = verdict;
|
|
310
309
|
}
|
|
@@ -318,7 +317,7 @@ function reparseMacroBodies(regions, diagnostics, placeholder, dialect) {
|
|
|
318
317
|
}
|
|
319
318
|
if (at < placeholder.length)
|
|
320
319
|
remainder.push({ start: at, end: placeholder.length });
|
|
321
|
-
const rest = fragments.parse(remainder, ["statement"]);
|
|
320
|
+
const rest = fragments().parse(remainder, ["statement"]);
|
|
322
321
|
return [...diagnostics.filter((d) => d.offset === undefined), ...(rest?.diagnostics ?? [])];
|
|
323
322
|
}
|
|
324
323
|
/** Insert a hidden WS-shaped token over every gap in the source-ordered stream whose original text
|
|
@@ -380,7 +379,7 @@ const DEFAULT_KEYWORD_SHAPE = {
|
|
|
380
379
|
* `else` arm, the macro can render to nothing → `nothing`, last.
|
|
381
380
|
* Anything else stays out (never-wrong): a hole with no visible default, a return-only body.
|
|
382
381
|
*/
|
|
383
|
-
function macroShapesOf(regions, tags, text, placeholder) {
|
|
382
|
+
function macroShapesOf(regions, tags, text, placeholder, fragments) {
|
|
384
383
|
const out = [];
|
|
385
384
|
const visit = (list) => {
|
|
386
385
|
for (const region of list) {
|
|
@@ -399,19 +398,28 @@ function macroShapesOf(regions, tags, text, placeholder) {
|
|
|
399
398
|
shapes.push(VERDICT_SHAPE[region.body]);
|
|
400
399
|
else {
|
|
401
400
|
// A body opening with a hole: `{{ name }}` / `{{ name|default('kw') }}`, `name` one of
|
|
402
|
-
// the macro's declared parameters
|
|
401
|
+
// the macro's declared parameters. The signature's args are `name` or `name=default`
|
|
402
|
+
// (the jinja-standard default spelling); the filter default wins over the signature's.
|
|
403
403
|
const lead = leadingHole(arm.bodySpan, tags, placeholder);
|
|
404
404
|
const holeText = lead ? text.slice(lead.tagSpan.start, lead.tagSpan.end) : "";
|
|
405
405
|
const bound = /^\{\{-?\s*([A-Za-z_]\w*)\s*(?:\||-?\}\})/.exec(holeText)?.[1];
|
|
406
|
-
const params = (open.calls[0]?.args ?? []).map((a) => text.slice(a.span.start, a.span.end)
|
|
407
|
-
const index = bound === undefined ? -1 : params.
|
|
408
|
-
const fallback = /\|\s*default\(\s*['"](\w+)['"]\s*\)/.exec(holeText)?.[1];
|
|
406
|
+
const params = (open.calls[0]?.args ?? []).map((a) => signatureParam(text.slice(a.span.start, a.span.end)));
|
|
407
|
+
const index = bound === undefined ? -1 : params.findIndex((p) => p?.name === bound);
|
|
408
|
+
const fallback = /\|\s*default\(\s*['"](\w+)['"]\s*\)/.exec(holeText)?.[1] ?? params[index]?.default;
|
|
409
409
|
if (bound !== undefined && index >= 0) {
|
|
410
410
|
keywordParam = { name: bound, index, ...(fallback !== undefined ? { default: fallback } : {}) };
|
|
411
411
|
}
|
|
412
412
|
const clause = fallback ? DEFAULT_KEYWORD_SHAPE[fallback.toLowerCase()] : undefined;
|
|
413
413
|
if (clause)
|
|
414
414
|
shapes.push(clause);
|
|
415
|
+
// A body that literally opens with the clause keyword (`and {{ c }} = 0`): the shape the
|
|
416
|
+
// same word resolves to through a hole, provided the rest reads as an expression.
|
|
417
|
+
if (!lead) {
|
|
418
|
+
const led = leadingKeyword(arm.bodySpan, placeholder);
|
|
419
|
+
if (led && fragments().verdict([{ start: led.end, end: arm.bodySpan.end }], ["expression"])) {
|
|
420
|
+
shapes.push(DEFAULT_KEYWORD_SHAPE[led.word]);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
415
423
|
}
|
|
416
424
|
if ((shapes.length > 0 || keywordParam) && rendersToNothing(arm, placeholder))
|
|
417
425
|
shapes.push("nothing");
|
|
@@ -445,6 +453,22 @@ export function shapesForCall(macro, call) {
|
|
|
445
453
|
const rest = macro.shapes.filter((s) => s !== "where-clause" && s !== "conjunct");
|
|
446
454
|
return clause ? [clause, ...rest] : rest;
|
|
447
455
|
}
|
|
456
|
+
/** One signature argument, `name` or `name=default` (a quoted string default is unquoted). */
|
|
457
|
+
function signatureParam(argText) {
|
|
458
|
+
const m = /^\s*([A-Za-z_]\w*)\s*(?:=\s*(.+?))?\s*$/s.exec(argText);
|
|
459
|
+
if (!m)
|
|
460
|
+
return undefined;
|
|
461
|
+
const raw = m[2];
|
|
462
|
+
const literal = raw === undefined ? undefined : /^(['\"])(.*)\1$/s.exec(raw)?.[2];
|
|
463
|
+
return { name: m[1], ...(literal !== undefined ? { default: literal } : {}) };
|
|
464
|
+
}
|
|
465
|
+
/** A clause keyword (`and`/`or`/`where`) opening the body, past whitespace and `--` comment lines:
|
|
466
|
+
* the word (lowercased) and the offset just past it. */
|
|
467
|
+
function leadingKeyword(body, placeholder) {
|
|
468
|
+
const slice = placeholder.slice(body.start, body.end);
|
|
469
|
+
const m = /^(?:\s|--[^\n]*\n)*(and|or|where)\b/i.exec(slice);
|
|
470
|
+
return m ? { word: m[1].toLowerCase(), end: body.start + m[0].length } : undefined;
|
|
471
|
+
}
|
|
448
472
|
/** The expression tag at the very start of a body (only whitespace, comments and control tags
|
|
449
473
|
* before it in the placeholder), or undefined when the body opens with SQL. */
|
|
450
474
|
function leadingHole(body, tags, placeholder) {
|
|
@@ -555,8 +579,11 @@ function build(text, dialect, provider) {
|
|
|
555
579
|
// source, CTE list, select list; src/fragment.ts) and its own diagnostics replace whatever
|
|
556
580
|
// the statement parse reported inside that body. The IR and tokens stay the whole-file
|
|
557
581
|
// parse's: the fragment verdict rides the region as `body`.
|
|
558
|
-
|
|
559
|
-
|
|
582
|
+
// One lex of the placeholder, opened on first use, shared by both macro passes.
|
|
583
|
+
let session;
|
|
584
|
+
const fragments = () => (session ??= openFragments(placeholder, dialect));
|
|
585
|
+
const sqlDiagnostics = reparseMacroBodies(regions, sqlResult.diagnostics, placeholder, fragments);
|
|
586
|
+
const macros = macroShapesOf(regions, tags, text, placeholder, fragments);
|
|
560
587
|
const { diagnostics: scrubbed, bySegment } = scrubPlaceholderDiagnostics(sqlDiagnostics, tagRanges, text, placeholder);
|
|
561
588
|
// Fold the scrubbed SQL diagnostics into the same per-tag map as the jinja ones
|
|
562
589
|
// (Task 10) — a tag's diagnostics are its own jinja parse errors PLUS whatever
|
package/dist/tsql/parse.js
CHANGED
|
@@ -83,7 +83,9 @@ export const fragmentGrammar = defineFragmentGrammar({
|
|
|
83
83
|
separator: TSqlLexer.COMMA,
|
|
84
84
|
entries: {
|
|
85
85
|
statement: (p) => p.tsql_file(),
|
|
86
|
-
expression
|
|
86
|
+
// T-SQL keeps comparisons out of the scalar `expression` rule (they are `search_condition`
|
|
87
|
+
// predicates), so an expression body is either.
|
|
88
|
+
expression: [(p) => p.expression(), (p) => p.search_condition()],
|
|
87
89
|
tableSource: (p) => p.table_source(),
|
|
88
90
|
cteList: separatedList((p) => p.common_table_expression(), TSqlLexer.COMMA),
|
|
89
91
|
selectList: (p) => p.select_list(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sqllens",
|
|
3
|
-
"version": "1.9.
|
|
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",
|