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