sqllens 1.7.0 → 1.8.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.
@@ -4,6 +4,7 @@ import { GoogleSQLParser } from "../generated/bigquery/GoogleSQLParser.js";
4
4
  import { dotPathTokenSource } from "./dot-path.js";
5
5
  import { postParseDiagnostics } from "./post-validate.js";
6
6
  import { makeErrorCollector } from "../parse-diagnostics.js";
7
+ import { CONSUMED_AS_RULES, deriveConsumedAs } from "../token/consumed-as.js";
7
8
  import { mapTokens } from "../token/map.js";
8
9
  /**
9
10
  * Lex + parse BigQuery / GoogleSQL (one statement or a `;`-separated batch). Two-stage parsing:
@@ -35,7 +36,7 @@ export function parseBigQuery(sql) {
35
36
  const withTokens = (base) => {
36
37
  let cached;
37
38
  return Object.defineProperty(base, "tokens", {
38
- get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "bigquery")),
39
+ get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "bigquery", deriveConsumedAs(base.tree, CONSUMED_AS_RULES.bigquery))),
39
40
  enumerable: true,
40
41
  configurable: true,
41
42
  });
@@ -2,6 +2,7 @@ import { BailErrorStrategy, CharStream, CommonTokenStream, PredictionMode, } fro
2
2
  import { DatabricksLexer } from "../generated/databricks/DatabricksLexer.js";
3
3
  import { DatabricksParser } from "../generated/databricks/DatabricksParser.js";
4
4
  import { makeErrorCollector } from "../parse-diagnostics.js";
5
+ import { CONSUMED_AS_RULES, deriveConsumedAs } from "../token/consumed-as.js";
5
6
  import { mapTokens } from "../token/map.js";
6
7
  /**
7
8
  * Lex + parse one Databricks SQL statement. Two-stage parsing: try the fast SLL
@@ -29,7 +30,7 @@ export function parseDatabricks(sql) {
29
30
  const withTokens = (base) => {
30
31
  let cached;
31
32
  return Object.defineProperty(base, "tokens", {
32
- get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "databricks")),
33
+ get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "databricks", deriveConsumedAs(base.tree, CONSUMED_AS_RULES.databricks))),
33
34
  enumerable: true,
34
35
  configurable: true,
35
36
  });
@@ -2,6 +2,7 @@ import { BailErrorStrategy, CharStream, CommonTokenStream, PredictionMode, } fro
2
2
  import { DuckdbLexer } from "../generated/duckdb/DuckdbLexer.js";
3
3
  import { DuckdbParser } from "../generated/duckdb/DuckdbParser.js";
4
4
  import { makeErrorCollector } from "../parse-diagnostics.js";
5
+ import { CONSUMED_AS_RULES, deriveConsumedAs } from "../token/consumed-as.js";
5
6
  import { mapTokens } from "../token/map.js";
6
7
  /**
7
8
  * Lex + parse DuckDB SQL (one statement or a `;`-separated batch). Two-stage parsing:
@@ -22,7 +23,7 @@ export function parseDuckdb(sql) {
22
23
  const withTokens = (base) => {
23
24
  let cached;
24
25
  return Object.defineProperty(base, "tokens", {
25
- get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "duckdb")),
26
+ get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "duckdb", deriveConsumedAs(base.tree, CONSUMED_AS_RULES.duckdb))),
26
27
  enumerable: true,
27
28
  configurable: true,
28
29
  });
@@ -2,6 +2,7 @@ import { BailErrorStrategy, CharStream, CommonTokenStream, PredictionMode, } fro
2
2
  import { MysqlLexer } from "../generated/mysql/MysqlLexer.js";
3
3
  import { MysqlParser } from "../generated/mysql/MysqlParser.js";
4
4
  import { makeErrorCollector } from "../parse-diagnostics.js";
5
+ import { CONSUMED_AS_RULES, deriveConsumedAs } from "../token/consumed-as.js";
5
6
  import { mapTokens } from "../token/map.js";
6
7
  /**
7
8
  * Lex + parse MySQL SQL (one statement or a `;`-separated batch). Two-stage parsing:
@@ -28,7 +29,7 @@ export function parseMysql(sql) {
28
29
  const withTokens = (base) => {
29
30
  let cached;
30
31
  return Object.defineProperty(base, "tokens", {
31
- get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "mysql")),
32
+ get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "mysql", deriveConsumedAs(base.tree, CONSUMED_AS_RULES.mysql))),
32
33
  enumerable: true,
33
34
  configurable: true,
34
35
  });
@@ -2,6 +2,7 @@ import { BailErrorStrategy, CharStream, CommonTokenStream, PredictionMode, } fro
2
2
  import { PostgresLexer } from "../generated/postgres/PostgresLexer.js";
3
3
  import { PostgresParser } from "../generated/postgres/PostgresParser.js";
4
4
  import { makeErrorCollector } from "../parse-diagnostics.js";
5
+ import { CONSUMED_AS_RULES, deriveConsumedAs } from "../token/consumed-as.js";
5
6
  import { mapTokens } from "../token/map.js";
6
7
  /**
7
8
  * Lex + parse PostgreSQL SQL (one statement or a `;`-separated batch). Two-stage parsing:
@@ -27,7 +28,7 @@ export function parsePostgres(sql) {
27
28
  const withTokens = (base) => {
28
29
  let cached;
29
30
  return Object.defineProperty(base, "tokens", {
30
- get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "postgres")),
31
+ get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "postgres", deriveConsumedAs(base.tree, CONSUMED_AS_RULES.postgres))),
31
32
  enumerable: true,
32
33
  configurable: true,
33
34
  });
@@ -2,6 +2,7 @@ import { BailErrorStrategy, CharStream, CommonTokenStream, PredictionMode, } fro
2
2
  import { RedshiftLexer } from "../generated/redshift/RedshiftLexer.js";
3
3
  import { RedshiftParser } from "../generated/redshift/RedshiftParser.js";
4
4
  import { makeErrorCollector } from "../parse-diagnostics.js";
5
+ import { CONSUMED_AS_RULES, deriveConsumedAs } from "../token/consumed-as.js";
5
6
  import { mapTokens } from "../token/map.js";
6
7
  /**
7
8
  * Lex + parse Amazon Redshift SQL (one statement or a `;`-separated batch). Two-stage parsing:
@@ -28,7 +29,7 @@ export function parseRedshift(sql) {
28
29
  const withTokens = (base) => {
29
30
  let cached;
30
31
  return Object.defineProperty(base, "tokens", {
31
- get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "redshift")),
32
+ get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "redshift", deriveConsumedAs(base.tree, CONSUMED_AS_RULES.redshift))),
32
33
  enumerable: true,
33
34
  configurable: true,
34
35
  });
@@ -142,11 +142,36 @@ function joinAnchor(coded, offset) {
142
142
  return undefined;
143
143
  return spanFromTokens(coded.tokens[idx], coded.tokens[end - 1]);
144
144
  }
145
- /** `phrase` immediately preceding the token starting at `contentStart` (e.g. content-start = the
146
- * first GROUP BY expression's own start the two tokens right before it must read "GROUP","BY"). */
145
+ /** The index of the first `coded.tokens` entry whose `start >= offset` (coded.tokens is
146
+ * source-ordered), or `coded.tokens.length` when none qualifies. Whenever a token really does
147
+ * start exactly at `offset` this is that token's own index — identical to an exact
148
+ * `indexByStart` lookup for the overwhelmingly common case. It also degrades correctly when NO
149
+ * token starts exactly at `offset`: a templated FROM/JOIN source's content starts at the
150
+ * placeholder fill's position, but that fill is entirely CHANNEL-2 jinja tokens in the merged
151
+ * stream (src/minijinja/parse.ts's clipToTagBoundaries drops the placeholder's own channel-0
152
+ * token wholesale), so no channel-0 token starts there. Using the next real token as the
153
+ * reference point still finds the true keyword immediately before it: nothing real (only the
154
+ * clipped tag) sits between the keyword and the content in that case. */
155
+ function indexAtOrAfter(coded, offset) {
156
+ let lo = 0;
157
+ let hi = coded.tokens.length;
158
+ while (lo < hi) {
159
+ const mid = (lo + hi) >>> 1;
160
+ if (coded.tokens[mid].start < offset)
161
+ lo = mid + 1;
162
+ else
163
+ hi = mid;
164
+ }
165
+ return lo;
166
+ }
167
+ /** `phrase` immediately preceding the content that starts at `contentStart` (e.g. content-start =
168
+ * the first GROUP BY expression's own start ⇒ the two tokens right before it must read "GROUP",
169
+ * "BY"). Uses `indexAtOrAfter` rather than an exact `indexByStart` lookup so a templated FROM
170
+ * fill (whose own token is missing from this channel-0 view, see `indexAtOrAfter`) still anchors
171
+ * on the real keyword preceding it, instead of the whole clause silently vanishing. */
147
172
  function matchBackward(coded, contentStart, phrase) {
148
- const idx = coded.indexByStart.get(contentStart);
149
- if (idx === undefined || idx < phrase.length)
173
+ const idx = indexAtOrAfter(coded, contentStart);
174
+ if (idx < phrase.length)
150
175
  return undefined;
151
176
  const from = idx - phrase.length;
152
177
  for (let i = 0; i < phrase.length; i++) {
@@ -2,6 +2,7 @@ import { BailErrorStrategy, CharStream, CommonTokenStream, PredictionMode, } fro
2
2
  import { SnowflakeLexer } from "../generated/snowflake/SnowflakeLexer.js";
3
3
  import { SnowflakeParser } from "../generated/snowflake/SnowflakeParser.js";
4
4
  import { makeErrorCollector } from "../parse-diagnostics.js";
5
+ import { CONSUMED_AS_RULES, deriveConsumedAs } from "../token/consumed-as.js";
5
6
  import { mapTokens } from "../token/map.js";
6
7
  /**
7
8
  * Lex + parse Snowflake SQL (one statement or a `;`-separated batch). Two-stage parsing:
@@ -28,7 +29,7 @@ export function parseSnowflake(sql) {
28
29
  const withTokens = (base) => {
29
30
  let cached;
30
31
  return Object.defineProperty(base, "tokens", {
31
- get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "snowflake")),
32
+ get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "snowflake", deriveConsumedAs(base.tree, CONSUMED_AS_RULES.snowflake))),
32
33
  enumerable: true,
33
34
  configurable: true,
34
35
  });
@@ -2,6 +2,7 @@ import { BailErrorStrategy, CharStream, CommonTokenStream, PredictionMode, } fro
2
2
  import { SqliteLexer } from "../generated/sqlite/SqliteLexer.js";
3
3
  import { SqliteParser } from "../generated/sqlite/SqliteParser.js";
4
4
  import { makeErrorCollector } from "../parse-diagnostics.js";
5
+ import { CONSUMED_AS_RULES, deriveConsumedAs } from "../token/consumed-as.js";
5
6
  import { mapTokens } from "../token/map.js";
6
7
  /**
7
8
  * Lex + parse SQLite SQL (one statement or a `;`-separated batch). Two-stage parsing:
@@ -28,7 +29,7 @@ export function parseSqlite(sql) {
28
29
  const withTokens = (base) => {
29
30
  let cached;
30
31
  return Object.defineProperty(base, "tokens", {
31
- get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "sqlite")),
32
+ get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "sqlite", deriveConsumedAs(base.tree, CONSUMED_AS_RULES.sqlite))),
32
33
  enumerable: true,
33
34
  configurable: true,
34
35
  });
@@ -0,0 +1,27 @@
1
+ import { ParserRuleContext } from "antlr4ng";
2
+ import type { Dialect } from "../dialect.js";
3
+ /** The three verdicts `deriveConsumedAs` ever stamps (matches `Token.consumedAs`'s type). */
4
+ export type ConsumedAs = "keyword" | "identifier" | "type";
5
+ export interface ConsumedAsRules {
6
+ /** Rule indices that realize "this token is a name": non-reserved-word wrappers and every
7
+ * rule strictly between one and the keyword terminal for a real grammar path. */
8
+ identifierRules: ReadonlySet<number>;
9
+ /** Rule indices that realize "this token is a type name": data-type productions and every
10
+ * rule strictly between one and the keyword terminal. Undefined when the dialect's type
11
+ * grammar does not cleanly separate from generic identifier/keyword use (documented per
12
+ * dialect below); those dialects only ever produce "identifier" | "keyword". */
13
+ typeRules?: ReadonlySet<number>;
14
+ }
15
+ /**
16
+ * Walk `tree` once and return every ordinary (non-error-recovery) consumed terminal's antlr
17
+ * `tokenIndex` mapped to its verdict. A tokenIndex absent from the map was never consumed as
18
+ * a genuine part of the parse (error-recovery skip, or the caller passed a hidden-channel
19
+ * token that could never reach the tree); callers read that absence as "no verdict", not
20
+ * "keyword" (see token.ts's `consumedAs` doc). Every entry is one of the three verdicts;
21
+ * whether the caller SHOWS "keyword" (vs. leaving the field off) is the caller's call, gated
22
+ * on the token's role. This function has no notion of token role at all.
23
+ */
24
+ export declare function deriveConsumedAs(tree: ParserRuleContext, rules: ConsumedAsRules): Map<number, ConsumedAs>;
25
+ /** Per-dialect rule config for `deriveConsumedAs`. `typeRules` is absent for sqlite (see its note
26
+ * above); every other dialect's type grammar was clean enough to enumerate. */
27
+ export declare const CONSUMED_AS_RULES: Record<Dialect, ConsumedAsRules>;
@@ -0,0 +1,280 @@
1
+ // ---------------------------------------------------------------------------
2
+ // consumedAs derivation: the post-parse "how was this keyword actually used"
3
+ // classifier (Token.consumedAs; see token.ts's field doc for the full contract).
4
+ //
5
+ // Method: every dialect's grammar admits SOME keywords as bare identifiers through a
6
+ // non-reserved-word wrapper rule (the same fact the reserved/soft split in
7
+ // tools/gen-reserved.ts probes black-box; this derives the same truth structurally, from
8
+ // the CST the parser actually built), and several also admit them through a data-type
9
+ // production. `deriveConsumedAs` walks the parse tree ONCE, top-down, carrying a verdict
10
+ // down from whichever ancestor rule first establishes one:
11
+ //
12
+ // - entering a rule that is NOT in either rule set resets the carried verdict to
13
+ // undefined (this subtree is not name/type territory, whatever the parent was);
14
+ // - entering a rule that IS in one of the sets keeps the carried verdict if the parent
15
+ // already had one (the OUTERMOST matching rule wins), or starts a fresh one (own
16
+ // class) otherwise;
17
+ // - a terminal (an ordinary consumed token, not an error-recovery node) is stamped with
18
+ // whatever verdict is carried into it, defaulting to `"keyword"` when nothing matched.
19
+ //
20
+ // The "outermost wins, but only across an unbroken run of matching rules" rule matters
21
+ // because some dialects fold a type production INTO their identifier wrapper (Snowflake's
22
+ // `id_` lists `data_type` as one of the ways to spell an object name, so a column
23
+ // literally named `varchar` reads VARCHAR through `data_type` nested inside `id_`; the
24
+ // outer `id_` match must win, giving "identifier"), while others fold the identifier
25
+ // wrapper INTO their type production (T-SQL's `data_type` bottoms out at `unscaled_type =
26
+ // id_`, so `CAST(x AS INT)` reads INT through `id_`/`keyword` nested inside `data_type`;
27
+ // there the outer `data_type` match must win, giving "type"). Both directions are handled
28
+ // by the same single rule (the parent's carried verdict beats the child's own), so no
29
+ // dialect-specific priority logic is needed in the algorithm itself, only in which rules
30
+ // are enumerated per dialect below.
31
+ //
32
+ // Every rule strictly between the establishing wrapper and the actual keyword terminal
33
+ // must itself be listed in one of the two sets (either one: once a carried verdict
34
+ // exists, a listed-but-differently-classed rule does not overwrite it, it just keeps
35
+ // propagation alive); an unlisted rule on that path is a genuine reset and breaks the
36
+ // chain, which is exactly the behavior wanted for a rule that legitimately introduces an
37
+ // unrelated name (MySQL's `dataType ... COLLATE collationName`: `collationName` is left
38
+ // unlisted on purpose, so a collation name nested inside a type production still reads as
39
+ // "identifier", not "type").
40
+ //
41
+ // Linear: one DFS over the CST (an explicit stack, not per-terminal ancestor re-walks), a
42
+ // Set lookup per rule context, a Map.set per ordinary terminal. O(nodes in the tree).
43
+ // ---------------------------------------------------------------------------
44
+ import { ErrorNode, ParserRuleContext, TerminalNode } from "antlr4ng";
45
+ import { DatabricksParser } from "../generated/databricks/DatabricksParser.js";
46
+ import { TSqlParser } from "../generated/tsql/TSqlParser.js";
47
+ import { SnowflakeParser } from "../generated/snowflake/SnowflakeParser.js";
48
+ import { GoogleSQLParser } from "../generated/bigquery/GoogleSQLParser.js";
49
+ import { RedshiftParser } from "../generated/redshift/RedshiftParser.js";
50
+ import { PostgresParser } from "../generated/postgres/PostgresParser.js";
51
+ import { DuckdbParser } from "../generated/duckdb/DuckdbParser.js";
52
+ import { TrinoParser } from "../generated/trino/TrinoParser.js";
53
+ import { SqliteParser } from "../generated/sqlite/SqliteParser.js";
54
+ import { MysqlParser } from "../generated/mysql/MysqlParser.js";
55
+ /**
56
+ * Walk `tree` once and return every ordinary (non-error-recovery) consumed terminal's antlr
57
+ * `tokenIndex` mapped to its verdict. A tokenIndex absent from the map was never consumed as
58
+ * a genuine part of the parse (error-recovery skip, or the caller passed a hidden-channel
59
+ * token that could never reach the tree); callers read that absence as "no verdict", not
60
+ * "keyword" (see token.ts's `consumedAs` doc). Every entry is one of the three verdicts;
61
+ * whether the caller SHOWS "keyword" (vs. leaving the field off) is the caller's call, gated
62
+ * on the token's role. This function has no notion of token role at all.
63
+ */
64
+ export function deriveConsumedAs(tree, rules) {
65
+ const out = new Map();
66
+ const classOf = (ruleIndex) => {
67
+ if (rules.typeRules?.has(ruleIndex))
68
+ return "type";
69
+ if (rules.identifierRules.has(ruleIndex))
70
+ return "identifier";
71
+ return undefined;
72
+ };
73
+ const stack = [
74
+ { ctx: tree, carried: classOf(tree.ruleIndex) },
75
+ ];
76
+ while (stack.length > 0) {
77
+ const top = stack.pop();
78
+ if (!top)
79
+ break;
80
+ const { ctx, carried } = top;
81
+ const n = ctx.getChildCount();
82
+ for (let i = 0; i < n; i++) {
83
+ const child = ctx.getChild(i);
84
+ if (child === null || child instanceof ErrorNode)
85
+ continue; // resync-inserted node: no real ancestry
86
+ if (child instanceof TerminalNode) {
87
+ const idx = child.symbol.tokenIndex;
88
+ if (idx >= 0)
89
+ out.set(idx, carried ?? "keyword");
90
+ }
91
+ else if (child instanceof ParserRuleContext) {
92
+ const own = classOf(child.ruleIndex);
93
+ stack.push({ ctx: child, carried: own !== undefined ? (carried ?? own) : undefined });
94
+ }
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+ // Per-dialect rule config. Grammar citations are in each dialect's .g4 (see the
100
+ // identifier/data_type rule names below); this table only records WHICH rule indices realize
101
+ // each verdict, not why. That reasoning is in the CLAUDE.md task history and the .g4 files
102
+ // themselves.
103
+ const DATABRICKS_IDENTIFIER = new Set([
104
+ DatabricksParser.RULE_identifier,
105
+ DatabricksParser.RULE_simpleIdentifier,
106
+ DatabricksParser.RULE_strictIdentifier,
107
+ DatabricksParser.RULE_simpleStrictIdentifier,
108
+ DatabricksParser.RULE_nonReserved,
109
+ DatabricksParser.RULE_ansiNonReserved,
110
+ DatabricksParser.RULE_strictNonReserved,
111
+ ]);
112
+ const DATABRICKS_TYPE = new Set([
113
+ DatabricksParser.RULE_dataType,
114
+ DatabricksParser.RULE_primitiveType,
115
+ DatabricksParser.RULE_nonTrivialPrimitiveType,
116
+ DatabricksParser.RULE_trivialPrimitiveType,
117
+ ]);
118
+ const TSQL_IDENTIFIER = new Set([TSqlParser.RULE_id_, TSqlParser.RULE_simple_id, TSqlParser.RULE_keyword]);
119
+ const TSQL_TYPE = new Set([TSqlParser.RULE_data_type]);
120
+ // Snowflake: `id_` is the one identifier-realization rule (SnowflakeParser.g4's own comment: "id_ is
121
+ // used for object name. Snowflake is very permissive so we could use nearly all keyword as object
122
+ // name"). Its own alternatives (keyword / non_reserved_words / object_type_plural / the
123
+ // builtin-function-name families / pivot_unpivot_word) must all be listed too so the carried
124
+ // "identifier" verdict survives down through them to the actual keyword terminal.
125
+ const SNOWFLAKE_IDENTIFIER = new Set([
126
+ SnowflakeParser.RULE_id_,
127
+ SnowflakeParser.RULE_keyword,
128
+ SnowflakeParser.RULE_non_reserved_words,
129
+ SnowflakeParser.RULE_object_type_plural,
130
+ SnowflakeParser.RULE_pivot_unpivot_word,
131
+ SnowflakeParser.RULE_builtin_function,
132
+ SnowflakeParser.RULE_unary_or_binary_builtin_function,
133
+ SnowflakeParser.RULE_binary_builtin_function,
134
+ SnowflakeParser.RULE_binary_or_ternary_builtin_function,
135
+ SnowflakeParser.RULE_ternary_builtin_function,
136
+ ]);
137
+ // `data_type` also being one of id_'s own alternatives is exactly the identifier-wraps-type
138
+ // crossover the module header describes (a column literally named VARCHAR reads VARCHAR through
139
+ // data_type nested inside id_): the outer id_ match wins there by construction, and `data_type`
140
+ // reached directly (a real CAST target) still gets "type" on its own.
141
+ const SNOWFLAKE_TYPE = new Set([SnowflakeParser.RULE_data_type]);
142
+ // Postgres / Redshift / DuckDB (TVL-lineage forks, same colid/collabel/type_function_name/... etc.
143
+ // identifier-realization family and typename/simpletypename/... etc. type family in every one; see
144
+ // PostgresParser.g4's `colid`/`collabel`/`typename` neighborhood). `reserved_keyword` is included
145
+ // even though 2 of its non-collabel call sites (`def_arg`, `option_value`, PL/pgSQL config values)
146
+ // use the keyword as a bare value rather than strictly a name; still "not the keyword's own
147
+ // meaning", so "identifier" is the honest, non-wrong call there too.
148
+ const PG_FAMILY_IDENTIFIER_BASE = [
149
+ "identifier",
150
+ "colid",
151
+ "table_alias",
152
+ "type_function_name",
153
+ "nonreservedword",
154
+ "collabel",
155
+ "unreserved_keyword",
156
+ "col_name_keyword",
157
+ "type_func_name_keyword",
158
+ "plsql_unreserved_keyword",
159
+ "reserved_keyword",
160
+ ];
161
+ const PG_FAMILY_TYPE_BASE = [
162
+ "typename",
163
+ "simpletypename",
164
+ "consttypename",
165
+ "generictype",
166
+ "numeric",
167
+ "bit",
168
+ "constbit",
169
+ "bitwithlength",
170
+ "bitwithoutlength",
171
+ "character",
172
+ "constcharacter",
173
+ "character_c",
174
+ "constdatetime",
175
+ "constinterval",
176
+ ];
177
+ const ruleSet = (parser, names) => new Set(names.map((name) => parser[`RULE_${name}`]));
178
+ const POSTGRES_IDENTIFIER = ruleSet(PostgresParser, [
179
+ ...PG_FAMILY_IDENTIFIER_BASE,
180
+ "bare_col_label",
181
+ "bare_label_keyword",
182
+ ]);
183
+ const POSTGRES_TYPE = ruleSet(PostgresParser, [...PG_FAMILY_TYPE_BASE, "jsontype"]);
184
+ const REDSHIFT_IDENTIFIER = ruleSet(RedshiftParser, [...PG_FAMILY_IDENTIFIER_BASE]);
185
+ const REDSHIFT_TYPE = ruleSet(RedshiftParser, [...PG_FAMILY_TYPE_BASE]);
186
+ const DUCKDB_IDENTIFIER = ruleSet(DuckdbParser, [
187
+ ...PG_FAMILY_IDENTIFIER_BASE,
188
+ "bare_colid",
189
+ "bare_table_alias",
190
+ "bare_col_label",
191
+ "bare_label_keyword",
192
+ "non_join_unreserved_keyword",
193
+ ]);
194
+ const DUCKDB_TYPE = ruleSet(DuckdbParser, [...PG_FAMILY_TYPE_BASE, "jsontype"]);
195
+ // Trino (first-party SqlBase.g4 split): `identifier`/`nonReserved` are the whole family. `type` is a
196
+ // single flat rule covering both the direct keyword alternatives (ROW/INTERVAL/TIMESTAMP/TIME/
197
+ // DOUBLE/ARRAY/MAP) and the generic `identifier`-wrapped alternative (#genericType), so the same
198
+ // "outer wins" crossover applies here too, in the type-wraps-identifier direction.
199
+ const TRINO_IDENTIFIER = new Set([TrinoParser.RULE_identifier, TrinoParser.RULE_nonReserved]);
200
+ const TRINO_TYPE = new Set([TrinoParser.RULE_type]);
201
+ // BigQuery / GoogleSQL (Bytebase fork): scalar type names (INT64, STRING, BOOL, etc.) are not
202
+ // distinct keyword tokens at all in this grammar. `type_name: path_expression | INTERVAL_SYMBOL`
203
+ // reads them as plain identifiers, so they never reach this classifier (their role is already
204
+ // "identifier"). The templated compound-type keywords (ARRAY/STRUCT/MAP/RANGE/FUNCTION) ARE
205
+ // distinct keyword tokens and ARE cleanly enumerable (`raw_type`'s direct alternatives), so "type"
206
+ // is modeled for those. `type_name`'s OTHER alternative, `path_expression` (a schema-qualified
207
+ // custom type name), is deliberately left off `identifierRules`/`typeRules`: it's the same rule
208
+ // used pervasively for ordinary column/table path references, so marking it either way would be
209
+ // wrong somewhere. A keyword reached that way (e.g. `CAST(x AS someKeyword)`) falls back to
210
+ // "identifier" rather than a forced, possibly-wrong "type": the honest call per the never-guess
211
+ // contract.
212
+ const BIGQUERY_IDENTIFIER = new Set([
213
+ GoogleSQLParser.RULE_identifier,
214
+ GoogleSQLParser.RULE_keyword_as_identifier,
215
+ GoogleSQLParser.RULE_common_keyword_as_identifier,
216
+ ]);
217
+ const BIGQUERY_TYPE = new Set([
218
+ GoogleSQLParser.RULE_type,
219
+ GoogleSQLParser.RULE_raw_type,
220
+ GoogleSQLParser.RULE_array_type,
221
+ GoogleSQLParser.RULE_struct_type,
222
+ GoogleSQLParser.RULE_map_type,
223
+ GoogleSQLParser.RULE_range_type,
224
+ GoogleSQLParser.RULE_function_type,
225
+ GoogleSQLParser.RULE_type_name,
226
+ ]);
227
+ // SQLite (grammars-v4 fork): `type_name` is a bare repetition of `name` (SqliteParser.g4:
228
+ // `type_name: name+? (...)?`), its own universal identifier wrapper (`name -> any_name ->
229
+ // fallback`). There is no distinct type-keyword rule at all (matches SQLite's column-affinity
230
+ // model, where a declared type is informational free text, not a fixed vocabulary), so there is
231
+ // nothing to widen: a keyword landing in a CAST or column-type slot is "identifier", the same as
232
+ // anywhere else. No `typeRules`.
233
+ const SQLITE_IDENTIFIER = new Set([
234
+ SqliteParser.RULE_name,
235
+ SqliteParser.RULE_any_name,
236
+ SqliteParser.RULE_any_name_excluding_raise,
237
+ SqliteParser.RULE_any_name_excluding_joins,
238
+ SqliteParser.RULE_any_name_excluding_string,
239
+ SqliteParser.RULE_fallback,
240
+ SqliteParser.RULE_fallback_excluding_conflicts,
241
+ SqliteParser.RULE_join_keyword,
242
+ ]);
243
+ // MySQL (grammars-v4 Positive-Technologies fork): `uid`/`simpleId` are the identifier-realization
244
+ // family (simpleId's own alternatives, charsetNameBase/transactionLevelBase/engineNameBase/
245
+ // privilegesBase/intervalTypeBase/dataTypeBase/keywordsCanBeId/scalarFunctionName, all listed so
246
+ // propagation survives down to the terminal). `dataType`/`convertedDataType` are separate, disjoint
247
+ // productions (not nested inside uid/simpleId) with genuine literal type keywords, including the
248
+ // INT4/INT8/FLOAT4/FLOAT8 family: the module header's `int4` example is literal here. Their
249
+ // sub-productions charSet/charsetName/collationName are deliberately left off both sets: they are
250
+ // genuinely a different name (a charset/collation identifier), not the type name itself, so a
251
+ // keyword landing there via `uid` correctly resets to "identifier" rather than inheriting "type".
252
+ const MYSQL_IDENTIFIER = new Set([
253
+ MysqlParser.RULE_uid,
254
+ MysqlParser.RULE_simpleId,
255
+ MysqlParser.RULE_fullId,
256
+ MysqlParser.RULE_dottedId,
257
+ MysqlParser.RULE_keywordsCanBeId,
258
+ MysqlParser.RULE_charsetNameBase,
259
+ MysqlParser.RULE_transactionLevelBase,
260
+ MysqlParser.RULE_engineNameBase,
261
+ MysqlParser.RULE_privilegesBase,
262
+ MysqlParser.RULE_intervalTypeBase,
263
+ MysqlParser.RULE_dataTypeBase,
264
+ MysqlParser.RULE_scalarFunctionName,
265
+ ]);
266
+ const MYSQL_TYPE = new Set([MysqlParser.RULE_dataType, MysqlParser.RULE_convertedDataType]);
267
+ /** Per-dialect rule config for `deriveConsumedAs`. `typeRules` is absent for sqlite (see its note
268
+ * above); every other dialect's type grammar was clean enough to enumerate. */
269
+ export const CONSUMED_AS_RULES = {
270
+ databricks: { identifierRules: DATABRICKS_IDENTIFIER, typeRules: DATABRICKS_TYPE },
271
+ tsql: { identifierRules: TSQL_IDENTIFIER, typeRules: TSQL_TYPE },
272
+ snowflake: { identifierRules: SNOWFLAKE_IDENTIFIER, typeRules: SNOWFLAKE_TYPE },
273
+ bigquery: { identifierRules: BIGQUERY_IDENTIFIER, typeRules: BIGQUERY_TYPE },
274
+ redshift: { identifierRules: REDSHIFT_IDENTIFIER, typeRules: REDSHIFT_TYPE },
275
+ postgres: { identifierRules: POSTGRES_IDENTIFIER, typeRules: POSTGRES_TYPE },
276
+ duckdb: { identifierRules: DUCKDB_IDENTIFIER, typeRules: DUCKDB_TYPE },
277
+ trino: { identifierRules: TRINO_IDENTIFIER, typeRules: TRINO_TYPE },
278
+ sqlite: { identifierRules: SQLITE_IDENTIFIER },
279
+ mysql: { identifierRules: MYSQL_IDENTIFIER, typeRules: MYSQL_TYPE },
280
+ };
@@ -1,9 +1,16 @@
1
1
  import { Token as AntlrToken, type Lexer } from "antlr4ng";
2
2
  import type { Dialect } from "../dialect.js";
3
+ import type { ConsumedAs } from "./consumed-as.js";
3
4
  import type { Token } from "./token.js";
4
5
  /**
5
6
  * Map a list of antlr tokens to neutral `Token`s for the given dialect.
6
7
  * Order is preserved, trivia is kept, and the EOF sentinel is skipped. Spans
7
8
  * (start/stop/line/column) are copied verbatim so positions round-trip exactly.
9
+ *
10
+ * `consumedAs` is the tokenIndex -> verdict map `deriveConsumedAs` (consumed-as.ts) built from
11
+ * this same parse's CST. Omitted entirely by `tokenize()` (lexer-only, no parse ran, no CST to
12
+ * derive from): every `Token.consumedAs` field stays absent in that case. When present, only
13
+ * KEYWORD-role tokens ever get the field set; every other role is untouched, matching
14
+ * `Token.consumedAs`'s doc.
8
15
  */
9
- export declare function mapTokens(lexer: Lexer, antlrTokens: AntlrToken[], dialect: Dialect): Token[];
16
+ export declare function mapTokens(lexer: Lexer, antlrTokens: AntlrToken[], dialect: Dialect, consumedAs?: Map<number, ConsumedAs>): Token[];
package/dist/token/map.js CHANGED
@@ -13,8 +13,14 @@ import { classifyToken } from "./classify.js";
13
13
  * Map a list of antlr tokens to neutral `Token`s for the given dialect.
14
14
  * Order is preserved, trivia is kept, and the EOF sentinel is skipped. Spans
15
15
  * (start/stop/line/column) are copied verbatim so positions round-trip exactly.
16
+ *
17
+ * `consumedAs` is the tokenIndex -> verdict map `deriveConsumedAs` (consumed-as.ts) built from
18
+ * this same parse's CST. Omitted entirely by `tokenize()` (lexer-only, no parse ran, no CST to
19
+ * derive from): every `Token.consumedAs` field stays absent in that case. When present, only
20
+ * KEYWORD-role tokens ever get the field set; every other role is untouched, matching
21
+ * `Token.consumedAs`'s doc.
16
22
  */
17
- export function mapTokens(lexer, antlrTokens, dialect) {
23
+ export function mapTokens(lexer, antlrTokens, dialect, consumedAs) {
18
24
  const out = [];
19
25
  for (const tok of antlrTokens) {
20
26
  if (tok.type === AntlrToken.EOF)
@@ -24,7 +30,8 @@ export function mapTokens(lexer, antlrTokens, dialect) {
24
30
  const name = lexer.vocabulary.getSymbolicName(tok.type) ?? lexer.vocabulary.getDisplayName(tok.type) ?? String(tok.type);
25
31
  const text = tok.text ?? "";
26
32
  const end = endPosition(tok.line, tok.column, text);
27
- out.push({
33
+ const role = classifyToken(lexer, tok.type, dialect);
34
+ const neutral = {
28
35
  type: tok.type,
29
36
  name,
30
37
  text,
@@ -35,8 +42,14 @@ export function mapTokens(lexer, antlrTokens, dialect) {
35
42
  endLine: end.endLine,
36
43
  endColumn: end.endColumn,
37
44
  channel: tok.channel,
38
- role: classifyToken(lexer, tok.type, dialect),
39
- });
45
+ role,
46
+ };
47
+ if (consumedAs && role === "keyword") {
48
+ const verdict = consumedAs.get(tok.tokenIndex);
49
+ if (verdict)
50
+ neutral.consumedAs = verdict;
51
+ }
52
+ out.push(neutral);
40
53
  }
41
54
  return out;
42
55
  }
@@ -26,4 +26,17 @@ export interface Token {
26
26
  /** 0 = default, 1 = HIDDEN. */
27
27
  channel: number;
28
28
  role: TokenRole;
29
+ /**
30
+ * How a KEYWORD-role token was actually consumed by the parse, derived post-parse from the CST
31
+ * (see `consumed-as.ts`): `"identifier"` when the parser's grammar absorbed it through a
32
+ * non-reserved-word / name-wrapper rule (a keyword used as a bare column/table/alias name),
33
+ * `"type"` when absorbed through a data-type production (a keyword used as a type name, only
34
+ * for the dialects where that grammar cleanly separates from identifier use; see the per-dialect
35
+ * notes next to `CONSUMED_AS_RULES`), `"keyword"` when neither: the token's ordinary keyword
36
+ * sense. ABSENT (no field) for: every non-keyword-role token, `tokenize()`'s lexer-only stream (no
37
+ * parse ran), a keyword-role token the parse never actually consumed (error-recovery skipped
38
+ * regions; hidden-channel tokens never reach the parser to begin with), and any case with no
39
+ * clean verdict. Honest absence, never a guess.
40
+ */
41
+ consumedAs?: "keyword" | "identifier" | "type";
29
42
  }
@@ -2,6 +2,7 @@ import { BailErrorStrategy, CharStream, CommonTokenStream, PredictionMode, } fro
2
2
  import { TrinoLexer } from "../generated/trino/TrinoLexer.js";
3
3
  import { TrinoParser } from "../generated/trino/TrinoParser.js";
4
4
  import { makeErrorCollector } from "../parse-diagnostics.js";
5
+ import { CONSUMED_AS_RULES, deriveConsumedAs } from "../token/consumed-as.js";
5
6
  import { mapTokens } from "../token/map.js";
6
7
  /**
7
8
  * Lex + parse Trino SQL (one statement or a `;`-separated batch). Two-stage parsing:
@@ -25,7 +26,7 @@ export function parseTrino(sql) {
25
26
  const withTokens = (base) => {
26
27
  let cached;
27
28
  return Object.defineProperty(base, "tokens", {
28
- get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "trino")),
29
+ get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "trino", deriveConsumedAs(base.tree, CONSUMED_AS_RULES.trino))),
29
30
  enumerable: true,
30
31
  configurable: true,
31
32
  });
@@ -2,6 +2,7 @@ import { BailErrorStrategy, CharStream, CommonTokenStream, PredictionMode, } fro
2
2
  import { TSqlLexer } from "../generated/tsql/TSqlLexer.js";
3
3
  import { TSqlParser } from "../generated/tsql/TSqlParser.js";
4
4
  import { makeErrorCollector } from "../parse-diagnostics.js";
5
+ import { CONSUMED_AS_RULES, deriveConsumedAs } from "../token/consumed-as.js";
5
6
  import { mapTokens } from "../token/map.js";
6
7
  /**
7
8
  * Lex + parse a T-SQL input via the grammar's full-file rule (`tsql_file` — `batch* EOF`), the same
@@ -30,7 +31,7 @@ export function parseTSql(sql) {
30
31
  const withTokens = (base) => {
31
32
  let cached;
32
33
  return Object.defineProperty(base, "tokens", {
33
- get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "tsql")),
34
+ get: () => (cached ??= mapTokens(lexer, tokens.getTokens(), "tsql", deriveConsumedAs(base.tree, CONSUMED_AS_RULES.tsql))),
34
35
  enumerable: true,
35
36
  configurable: true,
36
37
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqllens",
3
- "version": "1.7.0",
3
+ "version": "1.8.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",