Skip to content

Commit 5a422b0

Browse files
emrberkclaude
andcommitted
Narrow autocomplete suggestions by position kind
Categorize functions into scalar / aggregate / window / tableValued buckets in grammar/functions.ts (removing 28 entries that are SQL operators or absent from the runtime). Extend classifyIdentifierPath to a PositionKind enum and dispatch function emission per category so WHERE/GROUP BY/VALUES/SET only surface scalars, FROM/JOIN only surface table-valued meta fns, and LIMIT surfaces nothing. Drop the implicit-select / pivot ruleStack filter: category gating now covers the noise it was hiding, which also lets PIVOT and bare-table shorthand surface at statement start and after clauses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f08b111 commit 5a422b0

6 files changed

Lines changed: 1260 additions & 270 deletions

File tree

src/autocomplete/content-assist.ts

Lines changed: 214 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ export interface ContentAssistResult {
8383
suggestColumns: boolean
8484
/** Whether the grammar context expects table names (tableName positions, or expression context) */
8585
suggestTables: boolean
86+
/** Whether scalar functions are valid at this position (any expression context) */
87+
suggestScalarFunctions: boolean
88+
/** Whether aggregate functions are valid at this position (SELECT/ORDER BY/HAVING) */
89+
suggestAggregateFunctions: boolean
90+
/** Whether window functions are valid at this position (SELECT/ORDER BY) */
91+
suggestWindowFunctions: boolean
92+
/** Whether table-valued functions are valid at this position (FROM/JOIN) */
93+
suggestTableValuedFunctions: boolean
8694
/**
8795
* Bare column names (lowercase) referenced before the cursor in expression
8896
* context. Used by the provider to boost tables containing all these columns.
@@ -594,27 +602,6 @@ function extractTables(fullSql: string, tokens: IToken[]): ExtractResult {
594602
// Main API
595603
// =============================================================================
596604

597-
/**
598-
* Check if a ruleStack path represents the catch-all implicit SELECT or pivot
599-
* at the top-level statement dispatch. These are BACKTRACK alternatives that
600-
* computeContentAssist explores unconditionally, producing noise when a
601-
* specific statement rule already matched.
602-
*
603-
* Detects: statement → implicitSelectBody / implicitSelectStatement / pivotStatement (top-level catch-all)
604-
* Does NOT match nested uses (e.g., cteDefinition → implicitSelectBody).
605-
*/
606-
function isImplicitStatementPath(
607-
ruleStack: string[],
608-
implicitRules: Set<string>,
609-
): boolean {
610-
for (let i = 0; i < ruleStack.length - 1; i++) {
611-
if (ruleStack[i] === "statement" && implicitRules.has(ruleStack[i + 1])) {
612-
return true
613-
}
614-
}
615-
return false
616-
}
617-
618605
/**
619606
* Collapse a trailing qualified reference (ident.ident...ident) in the token
620607
* stream into a single identifier token. This allows re-computing suggestions
@@ -664,30 +651,182 @@ function collapseTrailingQualifiedRef(tokens: IToken[]): IToken[] | null {
664651
return [...tokens.slice(0, start), lastToken]
665652
}
666653

654+
/**
655+
* Position categories used to drive context-aware suggestion emission.
656+
*
657+
* - "newName" — name being defined (CREATE TABLE <name>): suppress
658+
* all schema/function suggestions.
659+
* - "expression" — expression position where aggregates and windows
660+
* are syntactically valid: SELECT items, ORDER BY.
661+
* - "restrictedExpression"— any expression where aggregates and windows are
662+
* NOT valid: WHERE / GROUP BY / JOIN ON predicates,
663+
* UPDATE SET RHS, INSERT VALUES rows, DECLARE
664+
* assignment RHS.
665+
* - "columnReference" — bare column-name reference with no surrounding
666+
* expression context: ALTER TABLE … DROP COLUMN
667+
* <name>, ALTER TABLE … RENAME COLUMN <name>.
668+
* Columns suggested; functions never valid here.
669+
* - "tableSource" — FROM/JOIN positions: tables + table-valued fns.
670+
* - "tableName" — DROP TABLE / INSERT INTO / UPDATE / TRUNCATE
671+
* TABLE / RENAME TABLE: tables only, no functions.
672+
* - "numeric" — LIMIT / OFFSET: numeric literal expected; nothing
673+
* useful to suggest from schema or functions.
674+
*/
675+
export type PositionKind =
676+
| "newName"
677+
| "expression"
678+
| "restrictedExpression"
679+
| "columnReference"
680+
| "tableSource"
681+
| "tableName"
682+
| "numeric"
683+
684+
// Marker rules that, when encountered before any "fresh expression" marker
685+
// while walking the ruleStack innermost-to-outermost, restrict the position
686+
// to scalar-only (no aggregates, no windows). These are positions where SQL
687+
// disallows aggregation: WHERE/GROUP BY/JOIN ON predicates, UPDATE SET, INSERT
688+
// VALUES rows, DECLARE assignments.
689+
const RESTRICTED_EXPRESSION_RULES = new Set([
690+
"whereClause",
691+
"groupByClause",
692+
"setClause", // UPDATE x SET col = <expr>
693+
"valuesList", // INSERT INTO t VALUES (<expr>, ...)
694+
"joinClause", // ... JOIN ... ON <expr>
695+
"declareAssignment", // DECLARE @x := <expr>
696+
])
697+
698+
// Marker rules that introduce a fresh expression context. When walking the
699+
// ruleStack innermost-to-outermost, hitting one of these BEFORE a restriction
700+
// marker means we're inside a nested expression where aggregates/windows are
701+
// valid again — e.g. `WHERE id IN (SELECT count(*) FROM ...)`. The inner
702+
// selectItem overrides the outer whereClause restriction.
703+
const FRESH_EXPRESSION_RULES = new Set(["selectItem"])
704+
667705
/**
668706
* Classify an identifier suggestion path based on its ruleStack.
669-
* - "column": identifierExpression or columnRef → suggest columns + tables
670-
* - "table": tableName rule → suggest tables only
671-
* - "newName": everything else (CREATE TABLE name, user names, etc.) → no suggestions
707+
* Walks ruleStack markers to map a Chevrotain parse position onto a semantic
708+
* PositionKind that the suggestion-builder can dispatch on.
672709
*/
673-
function classifyIdentifierPath(
674-
ruleStack: string[],
675-
): "column" | "table" | "newName" {
676-
if (ruleStack.includes("valuesClause")) return "newName"
677-
if (
710+
function classifyIdentifierPath(ruleStack: string[]): PositionKind {
711+
if (ruleStack.includes("limitClause")) return "numeric"
712+
713+
// Full expression context: the parser explicitly entered identifierExpression
714+
// (the productive expression rule) OR qualifiedStar (`t.*` — only legal in
715+
// selectItem position). Both allow aggregates/windows where applicable.
716+
const hasExpressionMarker =
678717
ruleStack.includes("identifierExpression") ||
679-
ruleStack.includes("columnRef") ||
680718
ruleStack.includes("qualifiedStar")
681-
)
682-
return "column"
683-
if (ruleStack.includes("tableName")) return "table"
719+
if (hasExpressionMarker) {
720+
// Walk innermost-to-outermost. The first scope marker we hit wins:
721+
// - selectItem → fresh expression context, aggregates allowed (handles
722+
// subqueries inside WHERE/SET/JOIN-ON without inheriting restriction)
723+
// - whereClause/groupByClause/etc. → restricted, scalar only
724+
for (let i = ruleStack.length - 1; i >= 0; i--) {
725+
const r = ruleStack[i]
726+
if (FRESH_EXPRESSION_RULES.has(r)) return "expression"
727+
if (RESTRICTED_EXPRESSION_RULES.has(r)) return "restrictedExpression"
728+
}
729+
return "expression"
730+
}
731+
732+
// columnRef without an enclosing expression rule = bare column-name reference.
733+
// E.g. ALTER TABLE x DROP COLUMN <name>, CREATE INDEX ON x(<name>).
734+
// Columns are valid; functions and other tables are not.
735+
if (ruleStack.includes("columnRef")) return "columnReference"
736+
737+
if (ruleStack.includes("tableName")) {
738+
// tableSource = under fromClause / joinClause / tableRef (functions OK).
739+
// Anything else (DROP/INSERT INTO/UPDATE/etc.) is a bare table reference
740+
// where table-valued functions don't apply.
741+
if (
742+
ruleStack.includes("fromClause") ||
743+
ruleStack.includes("joinClause") ||
744+
ruleStack.includes("tableRef")
745+
) {
746+
return "tableSource"
747+
}
748+
return "tableName"
749+
}
750+
684751
return "newName"
685752
}
686753

687-
interface ComputeResult {
688-
nextTokenTypes: TokenType[]
754+
/**
755+
* Set of category flags that the suggestion-builder consults to decide which
756+
* function and schema buckets to emit.
757+
*/
758+
export interface CategoryFlags {
689759
suggestColumns: boolean
690760
suggestTables: boolean
761+
suggestScalarFunctions: boolean
762+
suggestAggregateFunctions: boolean
763+
suggestWindowFunctions: boolean
764+
suggestTableValuedFunctions: boolean
765+
}
766+
767+
/** All flags off — starting point for OR-accumulation across ruleStacks. */
768+
function emptyCategoryFlags(): CategoryFlags {
769+
return {
770+
suggestColumns: false,
771+
suggestTables: false,
772+
suggestScalarFunctions: false,
773+
suggestAggregateFunctions: false,
774+
suggestWindowFunctions: false,
775+
suggestTableValuedFunctions: false,
776+
}
777+
}
778+
779+
/**
780+
* Map a PositionKind to the set of category flags valid at that position.
781+
* The suggestion-builder unions flags from every ruleStack the parser explored.
782+
*/
783+
function flagsForPosition(kind: PositionKind): Partial<CategoryFlags> {
784+
switch (kind) {
785+
case "expression":
786+
return {
787+
suggestColumns: true,
788+
suggestTables: true,
789+
suggestScalarFunctions: true,
790+
suggestAggregateFunctions: true,
791+
suggestWindowFunctions: true,
792+
}
793+
case "restrictedExpression":
794+
return {
795+
suggestColumns: true,
796+
suggestTables: true,
797+
suggestScalarFunctions: true,
798+
}
799+
case "columnReference":
800+
// Bare column name (e.g. ALTER DROP COLUMN <name>) — only columns of
801+
// the relevant table. No functions, no other tables.
802+
return {
803+
suggestColumns: true,
804+
}
805+
case "tableSource":
806+
return {
807+
suggestTables: true,
808+
suggestTableValuedFunctions: true,
809+
}
810+
case "tableName":
811+
return {
812+
suggestTables: true,
813+
}
814+
case "numeric":
815+
case "newName":
816+
return {}
817+
}
818+
}
819+
820+
/** Accumulate (OR) the flags for one PositionKind into the running set. */
821+
function accumulateFlags(target: CategoryFlags, kind: PositionKind): void {
822+
const partial = flagsForPosition(kind)
823+
for (const key of Object.keys(partial) as (keyof CategoryFlags)[]) {
824+
if (partial[key]) target[key] = true
825+
}
826+
}
827+
828+
interface ComputeResult extends CategoryFlags {
829+
nextTokenTypes: TokenType[]
691830
isConditionContext: boolean
692831
}
693832

@@ -698,73 +837,53 @@ interface ComputeResult {
698837
* the insertStatement path when WITH is present, missing selectStatement and
699838
* updateStatement paths. This function detects that case and merges suggestions
700839
* from all WITH-capable statement types.
840+
*
841+
* Note on the implicit-SELECT / pivot backtrack paths: a previous version of
842+
* this file filtered ruleStacks that traversed `implicitSelectBody` /
843+
* `implicitSelectStatement` / `pivotStatement` to suppress noise when a
844+
* specific statement rule had matched. That filter was removed because the
845+
* category flags below already gate function emission per PositionKind, so
846+
* the implicit/pivot paths can only contribute tableSource-style identifier
847+
* suggestions — which is the desired behaviour for shorthand table queries.
701848
*/
702849
function computeSuggestions(tokens: IToken[]): ComputeResult {
703850
const ruleName = tokens.some((t) => t.tokenType.name === "Semicolon")
704851
? "statements"
705852
: "statement"
706853
const suggestions = parser.computeContentAssist(ruleName, tokens)
854+
const result = suggestions.map((s) => s.nextTokenType)
707855

708-
// Filter out noise from implicit SELECT / pivot catch-all paths.
709-
const IMPLICIT_RULES = new Set([
710-
"implicitSelectBody",
711-
"implicitSelectStatement",
712-
"pivotStatement",
713-
])
714-
const specific = suggestions.filter(
715-
(s) => !isImplicitStatementPath(s.ruleStack, IMPLICIT_RULES),
716-
)
717-
const effectiveSuggestions = specific.length > 0 ? specific : suggestions
718-
const result = effectiveSuggestions.map((s) => s.nextTokenType)
719-
720-
// Classify each IdentifierKeyword path to determine whether columns/tables
721-
// should be suggested, based on the grammar rule that expects the identifier.
722-
let suggestColumns = false
723-
let suggestTables = false
724-
for (const s of effectiveSuggestions) {
856+
// Walk every IdentifierKeyword path and union the category flags valid at
857+
// that position. Multiple paths may be alive simultaneously (Chevrotain
858+
// explores all reachable alternatives) — OR-ing covers all valid contexts.
859+
const flags = emptyCategoryFlags()
860+
for (const s of suggestions) {
725861
if (s.nextTokenType.name === "IdentifierKeyword") {
726-
const cls = classifyIdentifierPath(s.ruleStack)
727-
if (cls === "column") {
728-
suggestColumns = true
729-
suggestTables = true
730-
} else if (cls === "table") {
731-
suggestTables = true
732-
}
862+
accumulateFlags(flags, classifyIdentifierPath(s.ruleStack))
733863
}
734864
}
735865

736866
// qualifiedStar fix: When computeContentAssist finds the qualifiedStar
737867
// path in selectItem (suggesting just Dot), the expression path is missed.
738-
// Detect this by checking if the *specific* (non-catch-all) suggestions are
739-
// all from qualifiedStar, then re-compute with the qualified reference
740-
// collapsed to a single identifier to get expression-path suggestions.
868+
// Detect this by checking if the suggestions are all from qualifiedStar,
869+
// then re-compute with the qualified reference collapsed to a single
870+
// identifier to get expression-path suggestions.
741871
if (
742-
effectiveSuggestions.length > 0 &&
743-
effectiveSuggestions.every((s) => s.ruleStack.includes("qualifiedStar"))
872+
suggestions.length > 0 &&
873+
suggestions.every((s) => s.ruleStack.includes("qualifiedStar"))
744874
) {
745875
const collapsed = collapseTrailingQualifiedRef(tokens)
746876
if (collapsed) {
747877
try {
748878
const extra = parser.computeContentAssist(ruleName, collapsed)
749-
const filteredExtra = extra.filter(
750-
(s) => !isImplicitStatementPath(s.ruleStack, IMPLICIT_RULES),
751-
)
752-
const extraEffective = filteredExtra.length > 0 ? filteredExtra : extra
753879
const seen = new Set(result.map((t) => t.name))
754-
for (const s of extraEffective) {
880+
for (const s of extra) {
755881
if (!seen.has(s.nextTokenType.name)) {
756882
seen.add(s.nextTokenType.name)
757883
result.push(s.nextTokenType)
758884
}
759-
// Classify extra paths too
760885
if (s.nextTokenType.name === "IdentifierKeyword") {
761-
const cls = classifyIdentifierPath(s.ruleStack)
762-
if (cls === "column") {
763-
suggestColumns = true
764-
suggestTables = true
765-
} else if (cls === "table") {
766-
suggestTables = true
767-
}
886+
accumulateFlags(flags, classifyIdentifierPath(s.ruleStack))
768887
}
769888
}
770889
} catch (e) {
@@ -776,16 +895,15 @@ function computeSuggestions(tokens: IToken[]): ComputeResult {
776895
// Check if an expression operator's ruleStack includes "whereClause".
777896
// Must check operators specifically — Chevrotain explores ahead into
778897
// not-yet-started WHERE paths even from JOIN ON positions.
779-
const isConditionContext = effectiveSuggestions.some(
898+
const isConditionContext = suggestions.some(
780899
(s) =>
781900
EXPRESSION_OPERATORS.has(s.nextTokenType.name) &&
782901
s.ruleStack.includes("whereClause"),
783902
)
784903

785904
return {
786905
nextTokenTypes: result,
787-
suggestColumns,
788-
suggestTables,
906+
...flags,
789907
isConditionContext,
790908
}
791909
}
@@ -921,6 +1039,10 @@ export function getContentAssist(
9211039
lexErrors: [],
9221040
suggestColumns: false,
9231041
suggestTables: false,
1042+
suggestScalarFunctions: false,
1043+
suggestAggregateFunctions: false,
1044+
suggestWindowFunctions: false,
1045+
suggestTableValuedFunctions: false,
9241046
referencedColumns: new Set(),
9251047
isConditionContext: false,
9261048
}
@@ -951,12 +1073,20 @@ export function getContentAssist(
9511073
let nextTokenTypes: TokenType[] = []
9521074
let suggestColumns = false
9531075
let suggestTables = false
1076+
let suggestScalarFunctions = false
1077+
let suggestAggregateFunctions = false
1078+
let suggestWindowFunctions = false
1079+
let suggestTableValuedFunctions = false
9541080
let isConditionContext = false
9551081
try {
9561082
const computed = computeSuggestions(tokensForAssist)
9571083
nextTokenTypes = computed.nextTokenTypes
9581084
suggestColumns = computed.suggestColumns
9591085
suggestTables = computed.suggestTables
1086+
suggestScalarFunctions = computed.suggestScalarFunctions
1087+
suggestAggregateFunctions = computed.suggestAggregateFunctions
1088+
suggestWindowFunctions = computed.suggestWindowFunctions
1089+
suggestTableValuedFunctions = computed.suggestTableValuedFunctions
9601090
isConditionContext = computed.isConditionContext
9611091
} catch (e) {
9621092
// If content assist fails, return empty suggestions
@@ -1043,6 +1173,10 @@ export function getContentAssist(
10431173
qualifiedTableRef: qualifiedRef?.table,
10441174
suggestColumns,
10451175
suggestTables,
1176+
suggestScalarFunctions,
1177+
suggestAggregateFunctions,
1178+
suggestWindowFunctions,
1179+
suggestTableValuedFunctions,
10461180
referencedColumns,
10471181
isConditionContext,
10481182
}

0 commit comments

Comments
 (0)