sqllens 1.3.0 → 1.5.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.
Files changed (64) hide show
  1. package/README.md +19 -0
  2. package/dist/bigquery/behavior.js +2 -1
  3. package/dist/bigquery/fold.d.ts +6 -0
  4. package/dist/bigquery/fold.js +8 -0
  5. package/dist/bigquery/lower.js +37 -6
  6. package/dist/completion/complete.d.ts +1 -1
  7. package/dist/completion/complete.js +166 -20
  8. package/dist/completion/jinja-slot.d.ts +6 -0
  9. package/dist/completion/jinja-slot.js +12 -2
  10. package/dist/databricks/behavior.js +2 -1
  11. package/dist/databricks/fold.d.ts +4 -0
  12. package/dist/databricks/fold.js +6 -0
  13. package/dist/databricks/lower.js +10 -3
  14. package/dist/dialect-behavior/behavior.d.ts +5 -0
  15. package/dist/duckdb/behavior.js +2 -1
  16. package/dist/duckdb/fold.d.ts +4 -0
  17. package/dist/duckdb/fold.js +6 -0
  18. package/dist/duckdb/lower.js +39 -8
  19. package/dist/index.d.ts +1 -1
  20. package/dist/ir/ir.d.ts +16 -6
  21. package/dist/ir/qualified-name.d.ts +43 -0
  22. package/dist/ir/qualified-name.js +74 -0
  23. package/dist/lineage/hops.js +7 -3
  24. package/dist/lineage/lineage.js +2 -1
  25. package/dist/minijinja/apply-tags.js +43 -11
  26. package/dist/mysql/behavior.js +2 -1
  27. package/dist/mysql/fold.d.ts +4 -0
  28. package/dist/mysql/fold.js +6 -0
  29. package/dist/mysql/lower.js +19 -4
  30. package/dist/postgres/behavior.js +2 -1
  31. package/dist/postgres/fold.d.ts +5 -0
  32. package/dist/postgres/fold.js +7 -0
  33. package/dist/postgres/lower.js +21 -5
  34. package/dist/qualify/qualify.d.ts +1 -1
  35. package/dist/qualify/qualify.js +75 -50
  36. package/dist/qualify/schema-provider.d.ts +13 -0
  37. package/dist/qualify/schema.d.ts +18 -1
  38. package/dist/qualify/schema.js +60 -6
  39. package/dist/qualify/template-provider.d.ts +5 -2
  40. package/dist/qualify/template-provider.js +5 -2
  41. package/dist/redshift/behavior.js +2 -1
  42. package/dist/redshift/fold.d.ts +4 -0
  43. package/dist/redshift/fold.js +6 -0
  44. package/dist/redshift/lower.js +20 -4
  45. package/dist/scope/scope.d.ts +24 -2
  46. package/dist/scope/scope.js +65 -13
  47. package/dist/sema/resolve.js +21 -10
  48. package/dist/snowflake/behavior.js +2 -1
  49. package/dist/snowflake/fold.d.ts +4 -0
  50. package/dist/snowflake/fold.js +6 -0
  51. package/dist/snowflake/lower.js +23 -4
  52. package/dist/sqlite/behavior.js +2 -1
  53. package/dist/sqlite/fold.d.ts +4 -0
  54. package/dist/sqlite/fold.js +6 -0
  55. package/dist/sqlite/lower.js +11 -2
  56. package/dist/trino/behavior.js +2 -1
  57. package/dist/trino/fold.d.ts +3 -0
  58. package/dist/trino/fold.js +5 -0
  59. package/dist/trino/lower.js +22 -4
  60. package/dist/tsql/behavior.js +2 -1
  61. package/dist/tsql/fold.d.ts +5 -0
  62. package/dist/tsql/fold.js +7 -0
  63. package/dist/tsql/lower.js +18 -5
  64. package/package.json +1 -1
package/README.md CHANGED
@@ -17,6 +17,25 @@ The front end is error-tolerant and token-first, so the library drives editor
17
17
  features (completion, hover, diagnostics, go-to-definition) over incomplete,
18
18
  mid-edit text. See [Editor / language tooling](#editor--language-tooling).
19
19
 
20
+ ## Guiding principles
21
+
22
+ Two principles govern every layer of this library, and they are duals of each other:
23
+
24
+ - **Never wrong.** A wrong answer is worse than no answer. A name, type, or binding
25
+ that cannot be derived from a documented source stays absent or `unknown`; it is
26
+ never guessed. Where a function's return type depends on an argument's value, the
27
+ answer is `unknown`, not a plausible guess. Where a relation cannot be resolved,
28
+ its name is the text the user wrote, never something the library invented.
29
+ - **Lossless.** Parsing never discards information the input carried. Every token
30
+ survives with its exact source span (even on broken input), identifiers keep their
31
+ as-written spelling and quoting alongside their folded identity, and what the parse
32
+ knew structurally the IR carries, so no downstream layer has to re-derive it by
33
+ heuristic.
34
+
35
+ Never wrong constrains what the library claims; lossless constrains what it keeps.
36
+ Together they are why analysis results can be trusted in an editor: what you are
37
+ shown is derived, and what you wrote is still there.
38
+
20
39
  ```bash
21
40
  npm install sqllens
22
41
  ```
@@ -1,13 +1,14 @@
1
1
  import { acceptsFor } from "../dialect-behavior/coerce-rules.js";
2
2
  import { likePatternToRegExp } from "../scope/like-pattern.js";
3
3
  import { SIGNATURES } from "../signature/signatures.js";
4
- import { displayName, fold, foldTableName, matchesSourceKey } from "./fold.js";
4
+ import { displayName, fold, foldTableName, matchesSourceKey, BIGQUERY_NAME_CONFIG } from "./fold.js";
5
5
  import { bigqueryLiteral, bigqueryParseType, bigquerySpecial, BIGQUERY_FUNCTION_RETURNS } from "./infer.js";
6
6
  export const bigqueryBehavior = {
7
7
  fold,
8
8
  displayName,
9
9
  foldTableName,
10
10
  matchesSourceKey,
11
+ nameConfig: BIGQUERY_NAME_CONFIG,
11
12
  likeMatch: (pattern, value) => likePatternToRegExp(pattern).test(value),
12
13
  literal: bigqueryLiteral,
13
14
  parseType: bigqueryParseType,
@@ -1,5 +1,11 @@
1
1
  import { type FoldRule, type IdentKind } from "../ident/fold.js";
2
+ import type { QualifiedNameConfig } from "../ir/qualified-name.js";
2
3
  export declare const BIGQUERY_FOLD_RULE: FoldRule;
4
+ /** project.dataset.table (cloud.google.com/bigquery/docs — "Qualifying table names").
5
+ * Normalized vocabulary: catalog = project, schema = dataset. Relation-path parts keep case in
6
+ * the identity key (this rule's tableCase: "preserve" — dataset and table names are
7
+ * case-sensitive). */
8
+ export declare const BIGQUERY_NAME_CONFIG: QualifiedNameConfig;
3
9
  /** Fold an identifier to its BigQuery identity key. */
4
10
  export declare function fold(raw: string, kind?: IdentKind): string;
5
11
  /** Presentation twin: strip delimiters, no case change. */
@@ -23,6 +23,14 @@ export const BIGQUERY_FOLD_RULE = {
23
23
  tableCase: "preserve",
24
24
  escapeStyle: "backslash",
25
25
  };
26
+ /** project.dataset.table (cloud.google.com/bigquery/docs — "Qualifying table names").
27
+ * Normalized vocabulary: catalog = project, schema = dataset. Relation-path parts keep case in
28
+ * the identity key (this rule's tableCase: "preserve" — dataset and table names are
29
+ * case-sensitive). */
30
+ export const BIGQUERY_NAME_CONFIG = {
31
+ roles: ["catalog", "schema"],
32
+ rule: BIGQUERY_FOLD_RULE,
33
+ };
26
34
  /** Fold an identifier to its BigQuery identity key. */
27
35
  export function fold(raw, kind = "other") {
28
36
  return foldWith(BIGQUERY_FOLD_RULE, raw, kind);
@@ -3,6 +3,17 @@ import { GoogleSQLParser as P } from "../generated/bigquery/GoogleSQLParser.js";
3
3
  import { keywordCategory, swallowedCategories, swallowedStatements } from "../ir/statement.js";
4
4
  import { partSpanOf, partSpansOf } from "../ir/part-span.js";
5
5
  import { freezeIR } from "../ir/freeze.js";
6
+ import { synthesizedQualifiedName } from "../ir/qualified-name.js";
7
+ import { BIGQUERY_NAME_CONFIG } from "./fold.js";
8
+ /** The structured name for a table source's parts (issue #38). BigQuery's lower() strips
9
+ * backtick delimiters from every identifier (the documented exception in
10
+ * docs/identifier-delimiter-contract.md), so parts arrive BARE: the synthesized builder is the
11
+ * correct one (identity is unaffected — quoted and unquoted fold identically in this dialect),
12
+ * and `fqn` re-renders quoting only where a part needs it. The byte-exact written form stays
13
+ * recoverable via namePartSpans, per the contract's own remedy. */
14
+ function relationOf(rawParts) {
15
+ return synthesizedQualifiedName(rawParts, BIGQUERY_NAME_CONFIG);
16
+ }
6
17
  // ---------------------------------------------------------------------------
7
18
  // Lowering — BigQuery / GoogleSQL (forked bytebase/parser googlesql/) CST ->
8
19
  // the shared, dialect-neutral IR (src/ir/ir.ts). The semantic layer runs on
@@ -224,10 +235,11 @@ function lowerQueryPrimary(primary) {
224
235
  // `TABLE name` ≡ `SELECT * FROM name`.
225
236
  const path = directChildrenOfRule(primary, P.RULE_path_expression)[0];
226
237
  if (path) {
238
+ const name = pathParts(path);
227
239
  return {
228
240
  kind: "select",
229
241
  projections: [implicitStar(primary)],
230
- from: [{ kind: "table", name: pathParts(path), namePartSpans: pathPartSpans(path), cst: path }],
242
+ from: [{ kind: "table", relation: relationOf(name), namePartSpans: pathPartSpans(path), cst: path }],
231
243
  columns: [],
232
244
  aggregated: false,
233
245
  cst: primary,
@@ -374,10 +386,20 @@ function lowerPipeSetOperand(operand) {
374
386
  if (tc) {
375
387
  // table_clause: TABLE path_expression | TABLE tvf — `TABLE name` ≡ `SELECT * FROM name`.
376
388
  const path = directChildrenOfRule(tc, P.RULE_path_expression)[0];
389
+ const name = path ? pathParts(path) : undefined;
377
390
  const body = {
378
391
  kind: "select",
379
392
  projections: path ? [implicitStar(tc)] : [],
380
- from: path ? [{ kind: "table", name: pathParts(path), namePartSpans: pathPartSpans(path), cst: path }] : [],
393
+ from: path && name
394
+ ? [
395
+ {
396
+ kind: "table",
397
+ relation: relationOf(name),
398
+ namePartSpans: pathPartSpans(path),
399
+ cst: path,
400
+ },
401
+ ]
402
+ : [],
381
403
  columns: [],
382
404
  aggregated: false,
383
405
  cst: tc,
@@ -414,7 +436,7 @@ function lowerPipeJoin(join, cst) {
414
436
  const tp = directChildrenOfRule(join, P.RULE_table_primary)[0];
415
437
  if (tp)
416
438
  collectTablePrimary(tp, out, unsupported);
417
- const source = out[0] ?? { kind: "table", name: [], cst: join };
439
+ const source = out[0] ?? { kind: "table", relation: relationOf([]), cst: join };
418
440
  const joinConditions = [];
419
441
  const columns = [];
420
442
  const onUsing = directChildrenOfRule(join, P.RULE_on_or_using_clause)[0];
@@ -917,16 +939,18 @@ function buildSource(tp, unsupported) {
917
939
  if (tvf) {
918
940
  const path = firstOfRule(tvf, P.RULE_path_expression);
919
941
  const aliasInfo = aliasOf(directChildrenOfRule(tvf, P.RULE_pivot_or_unpivot_clause_and_aliases)[0]);
942
+ const name = path ? pathParts(path) : [tp.getText()];
920
943
  return {
921
944
  kind: "table",
922
- name: path ? pathParts(path) : [tp.getText()],
945
+ relation: relationOf(name),
923
946
  namePartSpans: path ? pathPartSpans(path) : undefined,
924
947
  alias: aliasInfo?.alias,
925
948
  aliasCst: aliasInfo?.cst,
926
949
  cst: tp,
927
950
  };
928
951
  }
929
- return { kind: "table", name: [stripBackticks(tp.getText())], cst: tp };
952
+ const name = [stripBackticks(tp.getText())];
953
+ return { kind: "table", relation: relationOf(name), cst: tp };
930
954
  }
931
955
  // --- graph / GQL -----------------------------------------------------------------
932
956
  // GRAPH_TABLE(graph MATCH … COLUMNS(…)) in FROM, and the standalone `GRAPH g … RETURN …` statement.
@@ -1072,7 +1096,14 @@ function buildPathSource(pathExpr) {
1072
1096
  const path = base ? firstOfRule(base, P.RULE_path_expression) : undefined;
1073
1097
  const name = path ? pathParts(path) : base ? dashedPathParts(base) : [stripBackticks(pathExpr.getText())];
1074
1098
  const namePartSpans = path ? pathPartSpans(path) : undefined;
1075
- return { kind: "table", name, namePartSpans, alias: aliasInfo?.alias, aliasCst: aliasInfo?.cst, cst: pathExpr };
1099
+ return {
1100
+ kind: "table",
1101
+ relation: relationOf(name),
1102
+ namePartSpans,
1103
+ alias: aliasInfo?.alias,
1104
+ aliasCst: aliasInfo?.cst,
1105
+ cst: pathExpr,
1106
+ };
1076
1107
  }
1077
1108
  /** table_path_alias_or_qualify / table_path_pivot_suffix / pivot_or_unpivot_clause_and_aliases → its leading identifier. */
1078
1109
  function aliasOf(node) {
@@ -5,7 +5,7 @@ import type { SchemaProvider } from "../qualify/schema-provider.js";
5
5
  * `"template"` kind is a host candidate for a jinja call slot (a dbt model for a ref's arg). */
6
6
  export interface Completion {
7
7
  label: string;
8
- kind: "keyword" | "column" | "table" | "function" | "template";
8
+ kind: "keyword" | "column" | "table" | "cte" | "namespace" | "function" | "template";
9
9
  /** Extra display info, e.g. a column's type when the schema knows it. */
10
10
  detail?: string;
11
11
  }
@@ -20,6 +20,7 @@ import { nodeAt } from "../document/node-at.js";
20
20
  import { resolveBehavior } from "../dialect-behavior/registry.js";
21
21
  import { DefaultTemplateProvider } from "../qualify/template-provider.js";
22
22
  import { callOf } from "../minijinja/apply-tags.js";
23
+ import { sourcesMatchingQualifier } from "../scope/scope.js";
23
24
  import { collectCandidates } from "./atn-walk.js";
24
25
  import { jinjaSlotAt } from "./jinja-slot.js";
25
26
  import { COMPLETION_CONFIG } from "./config.js";
@@ -33,8 +34,10 @@ export function completeAt(doc, offset, schema) {
33
34
  try {
34
35
  return collect(doc, offset, schema);
35
36
  }
36
- catch {
37
+ catch (e) {
37
38
  // Total by contract: a walk/parse hiccup must not surface to the editor.
39
+ if (process.env.SQLLENS_DEBUG_COMPLETE)
40
+ throw e;
38
41
  return [];
39
42
  }
40
43
  }
@@ -98,27 +101,62 @@ function collect(doc, offset, schema) {
98
101
  if (label)
99
102
  add({ label, kind: "keyword" });
100
103
  }
104
+ // A RELATION PATH position (#38 stage 6): a dotted chain right before the caret whose anchor
105
+ // token is FROM/JOIN-family (cfg.relationKeywordTokens — the same per-dialect set the
106
+ // broken-input fallback uses). Mid-path the ATN reports a generic identifier slot, so the
107
+ // anchor, not the rule set, is the discriminator. The candidates are the typed prefix's NEXT
108
+ // SEGMENTS (segment labels only — a client replaces the caret token, so a full path would
109
+ // double-insert), never CTEs, and never the column/function noise the identifier slot would
110
+ // otherwise pour in.
111
+ const path = dottedPrefixAt(walkTokens, caretIdx);
112
+ const atRelationPath = path.parts.length > 0 && path.anchorIdx >= 0 && cfg.relationKeywordTokens.has(walkTokens[path.anchorIdx].type);
101
113
  const atTable = intersects(cand.rules, cfg.tableRules);
102
114
  const atColumn = intersects(cand.rules, cfg.columnRules);
103
- // tables — relation-name slot, and only when a schema lists them.
104
- if (atTable && schema) {
105
- for (const t of schema.tables(dialect))
106
- add({ label: t, kind: "table" });
115
+ if (atRelationPath) {
116
+ if (schema?.childrenOf) {
117
+ for (const child of schema.childrenOf(path.parts, dialect))
118
+ add({ label: child.name, kind: child.kind });
119
+ }
107
120
  }
108
- // columns value/column slot: the columns visible from the enclosing scope, plus a broken-input
109
- // fallback that reads FROM/JOIN relation names straight off the token stream (the document's batch
110
- // parse mis-reads a mid-edit `SELECT FROM t` see the fallback's comment).
111
- if (atColumn) {
112
- for (const c of visibleColumns(cellScopes, cellAst, dialect, cellOffset, schema))
121
+ else if (path.parts.length > 0) {
122
+ // A qualified MEMBER position (`o.|`, `gold.orders.|`) anvil item 2: only the columns of
123
+ // the source the qualifier matches (the same validated any-depth primitive binding uses),
124
+ // no function/keyword noise. Deliberately NOT gated on the walk's rules: at a dangling dot
125
+ // mid-edit the walk often reports nothing, but the dot chain itself is the member-position
126
+ // evidence (it is the completion trigger character), and an unmatched qualifier answers [].
127
+ const scoped = qualifiedSourceColumns(cellScopes, cellAst, cellOffset, path.parts, dialect, schema);
128
+ for (const c of scoped)
113
129
  add(c);
114
- if (schema)
115
- for (const c of fromRelationColumns(walkTokens, cfg, schema, dialect, doc.templated?.tags, doc.text))
130
+ // Mid-edit the dangling dot often breaks the FROM parse and the scope is EMPTY — the same
131
+ // failure the bare-slot fallback covers. Its member twin: read `FROM/JOIN name [alias]`
132
+ // pairs off the token stream and answer the matching relation's schema columns.
133
+ if (scoped.length === 0 && schema) {
134
+ for (const c of qualifiedFallbackColumns(walkTokens, cfg, path.parts, schema, dialect))
116
135
  add(c);
136
+ }
117
137
  }
118
- // functions — value/column slot: the dialect's inference-registry function names.
119
- if (atColumn) {
120
- for (const fn of Object.keys(resolveBehavior(dialect).functions))
121
- add({ label: fn, kind: "function" });
138
+ else {
139
+ if (atTable) {
140
+ // Bare relation slot: in-scope CTE names FIRST (they shadow same-named catalog tables),
141
+ // then the catalog's tables.
142
+ for (const name of visibleCteNames(cellScopes, cellAst, cellOffset))
143
+ add({ label: name, kind: "cte" });
144
+ if (schema)
145
+ for (const t of schema.tables(dialect))
146
+ add({ label: t, kind: "table" });
147
+ }
148
+ // columns — value/column slot: the columns visible from the enclosing scope, plus a
149
+ // broken-input fallback reading FROM/JOIN relation names straight off the token stream.
150
+ if (atColumn) {
151
+ for (const c of visibleColumns(cellScopes, cellAst, dialect, cellOffset, schema))
152
+ add(c);
153
+ if (schema)
154
+ for (const c of fromRelationColumns(walkTokens, cfg, schema, dialect, doc.templated?.tags, doc.text))
155
+ add(c);
156
+ // functions — value/column slot: the dialect's inference-registry function names.
157
+ for (const fn of Object.keys(resolveBehavior(dialect).functions))
158
+ add({ label: fn, kind: "function" });
159
+ }
122
160
  }
123
161
  return out;
124
162
  }
@@ -130,20 +168,70 @@ function collect(doc, offset, schema) {
130
168
  function templateCompletions(slot, schema) {
131
169
  if (!(schema instanceof DefaultTemplateProvider))
132
170
  return [];
133
- return schema.templateCandidates(slot.callee, slot.argIndex, slot.packageName).map((c) => ({
171
+ return schema.templateCandidates(slot.call, slot.argIndex).map((c) => ({
134
172
  label: c.label,
135
173
  kind: "template",
136
174
  ...(c.detail !== undefined ? { detail: c.detail } : {}),
137
175
  }));
138
176
  }
139
- /** The walk's caret token index: the first default-channel token whose `.start >= offset`; for an
140
- * end-of-input caret that is the EOF sentinel's index (last entry). Mirrors Task 10's tests' caret
141
- * helper. `toks` is the document's own token stream (doc coordinates) with the EOF sentinel appended. */
177
+ /** The dotted qualifier immediately before the caret (#38): `analytics.` ["analytics"],
178
+ * `analytics.sales.` ["analytics","sales"], `analytics.sa|` (typing a segment) ["analytics"].
179
+ * Reads the walk's own token stream backwards from the caret token: an optional partial segment,
180
+ * then (DOT ident)+ chains. `anchorIdx` is the default-channel token BEFORE the whole chain
181
+ * (-1 at document start) — its type says what the chain qualifies (FROM/JOIN → a relation path).
182
+ * parts: [] when no dot chain precedes the caret. Raw texts, delimiters intact — the schema
183
+ * folds. */
184
+ function dottedPrefixAt(toks, caretIdx) {
185
+ const prev = (i) => {
186
+ for (let j = i - 1; j >= 0; j--)
187
+ if (toks[j].channel === Token.DEFAULT_CHANNEL)
188
+ return j;
189
+ return -1;
190
+ };
191
+ let i = caretIdx;
192
+ // A word-like caret token is the partial segment being typed — the chain sits before it.
193
+ if (toks[i] && /^\w/.test(toks[i].text))
194
+ i = prev(i);
195
+ // `i` is now the DOT itself (partial-segment case) or the caret slot (then look back one).
196
+ let d = toks[i]?.text === "." ? i : prev(i);
197
+ const parts = [];
198
+ let anchorIdx = d;
199
+ while (d >= 0 && toks[d]?.text === ".") {
200
+ const ident = prev(d);
201
+ if (ident < 0 || !/^[\w"`[\]]/.test(toks[ident].text))
202
+ break;
203
+ parts.unshift(toks[ident].text);
204
+ anchorIdx = prev(ident);
205
+ d = anchorIdx;
206
+ }
207
+ return { parts, anchorIdx };
208
+ }
209
+ /** The CTE names visible from the caret's enclosing scope, as declared (display text). */
210
+ function visibleCteNames(scopes, ast, offset) {
211
+ const scope = enclosingScope(scopes, ast, offset);
212
+ const out = [];
213
+ for (let s = scope; s; s = s.parent)
214
+ for (const cte of s.ctes.values())
215
+ out.push(cte.def.name);
216
+ return out;
217
+ }
218
+ /** The walk's caret token index. Two rules, in order (anvil 2026-07-15; antlr4-c3's own caret
219
+ * convention):
220
+ * 1. the token being TYPED — a word-like token whose span CONTAINS the caret (start < offset <=
221
+ * end). A caret at the end of `ifn` completes `ifn`; it does not mean the slot is filled.
222
+ * Word-like only: punctuation is never partially typed, so `abs(|` keeps rule 2.
223
+ * 2. between tokens — the first default-channel token whose `.start >= offset`; for an
224
+ * end-of-input caret that is the EOF sentinel's index (last entry).
225
+ * `toks` is the document's own token stream (doc coordinates) with the EOF sentinel appended.
226
+ * Source order makes one pass sufficient: a containing token starts before any `.start >= offset`
227
+ * token, so rule 1 fires first whenever it applies. */
142
228
  function caretTokenIndex(toks, offset) {
143
229
  for (let i = 0; i < toks.length; i++) {
144
230
  const t = toks[i];
145
231
  if (!t || t.channel !== Token.DEFAULT_CHANNEL)
146
232
  continue;
233
+ if (/^\w/.test(t.text) && t.start < offset && offset <= t.start + t.text.length)
234
+ return i;
147
235
  if (t.start >= offset)
148
236
  return i;
149
237
  }
@@ -212,6 +300,64 @@ function fromRelationColumns(walkTokens, cfg, schema, dialect, tags, text) {
212
300
  }
213
301
  return out;
214
302
  }
303
+ /** The columns of the ONE source a dotted qualifier matches from the caret's scope (anvil item 2):
304
+ * `o.|` answers o's columns only. Matching is sourcesMatchingQualifier — the same validated,
305
+ * any-depth primitive column BINDING uses — walking enclosing scopes nearest-first. Ambiguous or
306
+ * unmatched qualifiers answer nothing (never a fabricated union). */
307
+ function qualifiedSourceColumns(scopes, ast, offset, qualParts, dialect, schema) {
308
+ const scope = enclosingScope(scopes, ast, offset);
309
+ if (!scope)
310
+ return [];
311
+ const behavior = resolveBehavior(dialect);
312
+ for (let s = scope; s; s = s.parent) {
313
+ const matches = sourcesMatchingQualifier(s, qualParts);
314
+ if (matches.length > 1)
315
+ return [];
316
+ if (matches.length === 1) {
317
+ return columnsOf(matches[0], dialect, schema).map((c) => ({ ...c, label: behavior.displayName(c.label) }));
318
+ }
319
+ }
320
+ return [];
321
+ }
322
+ /** The member-position twin of `fromRelationColumns` (#38): when the scope is empty (the dangling
323
+ * dot broke the FROM parse), read `FROM/JOIN name(.name)* [AS] [alias]` off the token stream and
324
+ * answer the columns of the ONE relation the qualifier matches — the alias when present, else the
325
+ * name's own trailing parts. No match (or several) answers [] — never a fabricated union. */
326
+ function qualifiedFallbackColumns(walkTokens, cfg, qualParts, schema, dialect) {
327
+ if (cfg.relationKeywordTokens.size === 0)
328
+ return [];
329
+ const b = resolveBehavior(dialect);
330
+ const toks = walkTokens.filter((t) => t.channel === Token.DEFAULT_CHANNEL);
331
+ const hits = [];
332
+ for (let i = 0; i + 1 < toks.length; i++) {
333
+ if (!cfg.relationKeywordTokens.has(toks[i].type))
334
+ continue;
335
+ let j = i + 1;
336
+ if (!toks[j] || !cfg.nameTokens.has(toks[j].type))
337
+ continue;
338
+ const parts = [toks[j].text];
339
+ j++;
340
+ while (toks[j]?.text === "." && toks[j + 1] && cfg.nameTokens.has(toks[j + 1].type)) {
341
+ parts.push(toks[j + 1].text);
342
+ j += 2;
343
+ }
344
+ let alias;
345
+ if (toks[j] && b.fold(toks[j].text) === "as" && toks[j + 1] && cfg.nameTokens.has(toks[j + 1].type))
346
+ j++;
347
+ if (toks[j] && cfg.nameTokens.has(toks[j].type))
348
+ alias = toks[j].text;
349
+ const matches = alias
350
+ ? qualParts.length === 1 && b.fold(qualParts[0]) === b.fold(alias)
351
+ : qualParts.length <= parts.length &&
352
+ qualParts.every((p, k) => b.fold(p, "table") === b.fold(parts[parts.length - qualParts.length + k], "table"));
353
+ if (!matches)
354
+ continue;
355
+ const cols = schema.columnsFor(parts, dialect);
356
+ if (cols)
357
+ hits.push(cols.map((c) => ({ label: c.name, kind: "column", detail: c.type })));
358
+ }
359
+ return hits.length === 1 ? hits[0] : [];
360
+ }
215
361
  /** The columns visible from the scope enclosing `offset` (a CELL-relative offset into `scopes`).
216
362
  * Derived sources / CTEs expose their own output column names; base-table sources get their columns
217
363
  * (and types) from the schema. */
@@ -1,3 +1,4 @@
1
+ import type { TemplateCall } from "../qualify/template-provider.js";
1
2
  import type { TagNode } from "../minijinja/tag-ast.js";
2
3
  /** Where the caret sits inside a jinja call tag. NEUTRAL, the callee is a bare string; the dbt
3
4
  * meaning of the slot (ref arg0 = a model) is the consumer's to apply. */
@@ -6,6 +7,11 @@ export interface JinjaSlot {
6
7
  callee: string;
7
8
  /** Dotted package before the callee (`dbt_utils` in `dbt_utils.star(...)`). */
8
9
  packageName?: string;
10
+ /** The WHOLE parsed call (issue #37): name, packageParts and every sibling arg's literal value
11
+ * (null where computed) — the same TemplateCall shape every provider method receives. A slot's
12
+ * candidates can depend on the other args (source('raw', '|')'s candidates are the tables OF
13
+ * raw), so the provider callback gets the call, not just the callee name. */
14
+ call: TemplateCall;
9
15
  /** 0-based index of the positional arg the caret is in. The callee-name slot (caret still in the
10
16
  * callee identifier, `{{ my_mac|`) is `-1`. */
11
17
  argIndex: number;
@@ -18,6 +18,7 @@
18
18
  // Reuses the parse: it reads the tags the document already produced, never re-parses.
19
19
  // Total: returns undefined off any jinja completion slot; never throws.
20
20
  // ---------------------------------------------------------------------------
21
+ import { callOf } from "../minijinja/apply-tags.js";
21
22
  /**
22
23
  * The jinja completion slot at `offset`, or undefined when the caret is not in a completable jinja
23
24
  * position. `tags` is `parseTemplated(...).tags` (or `doc.templated.tags`); `text` is the document
@@ -82,7 +83,15 @@ function macroHit(c) {
82
83
  }
83
84
  /** The slot for a caret inside a resolved call: the callee name, or the positional argument. */
84
85
  function slotFromCall(c, text, offset) {
85
- const base = { callee: c.name, ...(c.packageName !== undefined ? { packageName: c.packageName } : {}) };
86
+ // The whole call rides the slot (#37): callOf reads name + literal args off the source text,
87
+ // the same extraction apply-tags feeds the provider everywhere else.
88
+ const call = callOf({
89
+ name: c.name,
90
+ nameSpan: c.nameSpan,
91
+ args: c.args,
92
+ ...(c.packageName !== undefined ? { packageName: c.packageName } : {}),
93
+ }, text);
94
+ const base = { callee: c.name, call, ...(c.packageName !== undefined ? { packageName: c.packageName } : {}) };
86
95
  // Callee-name slot: the caret is still within (or right at the end of) the callee identifier,
87
96
  // before the open paren, the user is typing the macro name itself.
88
97
  if (offset <= c.nameSpan.end) {
@@ -122,7 +131,8 @@ function bareCalleeSlot(tags, text, offset) {
122
131
  const m = /^\s*([A-Za-z_]\w*)$/.exec(before);
123
132
  if (!m)
124
133
  return undefined;
125
- return { callee: m[1], argIndex: -1, prefix: m[1], incomplete: true };
134
+ // No parsed call exists yet (`{{ re`) the slot's call is the bare callee with no args.
135
+ return { callee: m[1], call: { name: m[1], args: [] }, argIndex: -1, prefix: m[1], incomplete: true };
126
136
  }
127
137
  /** Drop a single leading quote from a partial string arg (`'cu` -> `cu`) so the prefix is the value
128
138
  * the consumer filters by. Leaves a non-string arg untouched. */
@@ -1,13 +1,14 @@
1
1
  import { acceptsFor } from "../dialect-behavior/coerce-rules.js";
2
2
  import { likePatternToRegExp } from "../scope/like-pattern.js";
3
3
  import { SIGNATURES } from "../signature/signatures.js";
4
- import { displayName, fold, foldTableName, matchesSourceKey } from "./fold.js";
4
+ import { displayName, fold, foldTableName, matchesSourceKey, DATABRICKS_NAME_CONFIG } from "./fold.js";
5
5
  import { databricksLiteral, databricksParseType, DATABRICKS_FUNCTION_RETURNS } from "./infer.js";
6
6
  export const databricksBehavior = {
7
7
  fold,
8
8
  displayName,
9
9
  foldTableName,
10
10
  matchesSourceKey,
11
+ nameConfig: DATABRICKS_NAME_CONFIG,
11
12
  likeMatch: (pattern, value) => likePatternToRegExp(pattern).test(value),
12
13
  literal: databricksLiteral,
13
14
  parseType: databricksParseType,
@@ -1,5 +1,9 @@
1
1
  import { type FoldRule, type IdentKind } from "../ident/fold.js";
2
+ import type { QualifiedNameConfig } from "../ir/qualified-name.js";
2
3
  export declare const DATABRICKS_FOLD_RULE: FoldRule;
4
+ /** Unity Catalog's three-level namespace: catalog.schema.object
5
+ * (docs.databricks.com/en/data-governance/unity-catalog — "three-level namespace"). */
6
+ export declare const DATABRICKS_NAME_CONFIG: QualifiedNameConfig;
3
7
  /** Fold an identifier to its Databricks identity key. */
4
8
  export declare function fold(raw: string, kind?: IdentKind): string;
5
9
  /** Presentation twin: strip delimiters, no case change. */
@@ -11,6 +11,12 @@ export const DATABRICKS_FOLD_RULE = {
11
11
  unquoted: "lower",
12
12
  quoted: "lower",
13
13
  };
14
+ /** Unity Catalog's three-level namespace: catalog.schema.object
15
+ * (docs.databricks.com/en/data-governance/unity-catalog — "three-level namespace"). */
16
+ export const DATABRICKS_NAME_CONFIG = {
17
+ roles: ["catalog", "schema"],
18
+ rule: DATABRICKS_FOLD_RULE,
19
+ };
14
20
  /** Fold an identifier to its Databricks identity key. */
15
21
  export function fold(raw, kind = "other") {
16
22
  return foldWith(DATABRICKS_FOLD_RULE, raw, kind);
@@ -3,6 +3,13 @@ import { ArithmeticBinaryContext, ArithmeticUnaryContext, CastByColonContext, Ca
3
3
  import { keywordCategory, swallowedCategories, swallowedStatements } from "../ir/statement.js";
4
4
  import { partSpansOf } from "../ir/part-span.js";
5
5
  import { freezeIR } from "../ir/freeze.js";
6
+ import { qualifiedNameOf } from "../ir/qualified-name.js";
7
+ import { DATABRICKS_NAME_CONFIG } from "./fold.js";
8
+ /** The structured name for a table source's raw parts (issue #38) — role assignment + identity
9
+ * key + fqn happen HERE, at lowering, where the dialect's namespace shape is known. */
10
+ function relationOf(rawParts) {
11
+ return qualifiedNameOf(rawParts, DATABRICKS_NAME_CONFIG);
12
+ }
6
13
  // ---------------------------------------------------------------------------
7
14
  // CST navigation helpers
8
15
  // ---------------------------------------------------------------------------
@@ -367,7 +374,7 @@ function lowerSparkPipeRhs(rhs) {
367
374
  const join = directChildrenOfRule(rhs, P.RULE_joinRelation)[0];
368
375
  if (join) {
369
376
  const rel = directChildrenOfRule(join, P.RULE_relationPrimary)[0];
370
- const source = rel ? buildSource(rel) : { kind: "table", name: [], cst: rhs };
377
+ const source = rel ? buildSource(rel) : { kind: "table", relation: relationOf([]), cst: rhs };
371
378
  const joinConditions = [];
372
379
  const columns = [];
373
380
  const crit = directChildrenOfRule(join, P.RULE_joinCriteria)[0];
@@ -491,7 +498,7 @@ function buildTableShorthand(queryPrimary) {
491
498
  return {
492
499
  kind: "select",
493
500
  projections: [{ isStar: true, expr: star, cst: queryPrimary }],
494
- from: [{ kind: "table", name, namePartSpans, cst: queryPrimary }],
501
+ from: [{ kind: "table", relation: relationOf(name), namePartSpans, cst: queryPrimary }],
495
502
  columns: [],
496
503
  aggregated: false,
497
504
  cst: queryPrimary,
@@ -1438,7 +1445,7 @@ function buildSource(relationPrimary) {
1438
1445
  const namePartSpans = partNodes.length ? partSpansOf(partNodes) : multipart ? partSpansOf([multipart]) : undefined;
1439
1446
  return {
1440
1447
  kind: "table",
1441
- name: parts,
1448
+ relation: relationOf(parts),
1442
1449
  namePartSpans,
1443
1450
  alias,
1444
1451
  aliasCst,
@@ -1,4 +1,5 @@
1
1
  import type { IdentKind } from "../ident/fold.js";
2
+ import type { QualifiedNameConfig } from "../ir/qualified-name.js";
2
3
  import type { FnRule } from "../infer/functions.js";
3
4
  import type { Type } from "../infer/types.js";
4
5
  import type { Expr } from "../ir/ir.js";
@@ -8,6 +9,10 @@ export interface DialectBehavior {
8
9
  displayName(raw: string): string;
9
10
  foldTableName(parts: string[]): string[];
10
11
  matchesSourceKey(key: string, rawPart: string): boolean;
12
+ /** The dialect's namespace shape + fold rule for building QualifiedNames outside lower()
13
+ * (shared layers synthesizing sources, apply-tags renaming templated ones). Same object the
14
+ * dialect's own lower() uses; declared in src/<dialect>/fold.ts (issue #38). */
15
+ nameConfig: QualifiedNameConfig;
11
16
  likeMatch(pattern: string, name: string): boolean;
12
17
  literal(text: string): Type;
13
18
  parseType(text: string): Type;
@@ -1,7 +1,7 @@
1
1
  import { acceptsFor } from "../dialect-behavior/coerce-rules.js";
2
2
  import { likePatternToRegExp } from "../scope/like-pattern.js";
3
3
  import { SIGNATURES } from "../signature/signatures.js";
4
- import { displayName, fold, foldTableName, matchesSourceKey } from "./fold.js";
4
+ import { displayName, fold, foldTableName, matchesSourceKey, DUCKDB_NAME_CONFIG } from "./fold.js";
5
5
  import { duckdbLiteral, duckdbParseType, DUCKDB_FUNCTION_RETURNS } from "./infer.js";
6
6
  // DuckDB implicit coercion: a quoted constant is initially UNKNOWN and coerces to whatever the call
7
7
  // needs (str->num), no bool<->num.
@@ -11,6 +11,7 @@ export const duckdbBehavior = {
11
11
  displayName,
12
12
  foldTableName,
13
13
  matchesSourceKey,
14
+ nameConfig: DUCKDB_NAME_CONFIG,
14
15
  likeMatch: (pattern, value) => likePatternToRegExp(pattern).test(value),
15
16
  literal: duckdbLiteral,
16
17
  parseType: duckdbParseType,
@@ -1,5 +1,9 @@
1
1
  import { type FoldRule, type IdentKind } from "../ident/fold.js";
2
+ import type { QualifiedNameConfig } from "../ir/qualified-name.js";
2
3
  export declare const DUCKDB_FOLD_RULE: FoldRule;
4
+ /** catalog.schema.table — an attached database is a catalog
5
+ * (duckdb.org/docs/current/sql/statements/attach). */
6
+ export declare const DUCKDB_NAME_CONFIG: QualifiedNameConfig;
3
7
  /** Fold an identifier to its DuckDB identity key. */
4
8
  export declare function fold(raw: string, kind?: IdentKind): string;
5
9
  /** Presentation twin: strip delimiters, no case change. */
@@ -15,6 +15,12 @@ export const DUCKDB_FOLD_RULE = {
15
15
  unquoted: "ascii-lower",
16
16
  quoted: "ascii-lower",
17
17
  };
18
+ /** catalog.schema.table — an attached database is a catalog
19
+ * (duckdb.org/docs/current/sql/statements/attach). */
20
+ export const DUCKDB_NAME_CONFIG = {
21
+ roles: ["catalog", "schema"],
22
+ rule: DUCKDB_FOLD_RULE,
23
+ };
18
24
  /** Fold an identifier to its DuckDB identity key. */
19
25
  export function fold(raw, kind = "other") {
20
26
  return foldWith(DUCKDB_FOLD_RULE, raw, kind);