@@ -2,6 +2,7 @@ import { type ILexingError, IToken, TokenType } from "chevrotain"
22import { parser } from "../parser/parser"
33import { QuestDBLexer } from "../parser/lexer"
44import { parseToAst } from "../index"
5+ import { IDENTIFIER_KEYWORD_TOKENS } from "./token-classification"
56
67// =============================================================================
78// Types
@@ -159,36 +160,65 @@ function extractTables(fullSql: string, tokens: IToken[]): TableRef[] {
159160 }
160161
161162 // Fallback: extract from tokens by looking for table name patterns
162- // Look for Identifier tokens that follow FROM or JOIN tokens
163+ // Handles malformed input where full AST extraction is not available.
163164 const tables : TableRef [ ] = [ ]
164- for ( let i = 0 ; i < tokens . length - 1 ; i ++ ) {
165- const tokenName = tokens [ i ] . tokenType . name
166- const nextToken = tokens [ i + 1 ]
167-
168- if (
169- ( tokenName === "From" ||
170- tokenName === "Join" ||
171- tokenName === "AsofJoin" ||
172- tokenName === "SpliceJoin" ||
173- tokenName === "LtJoin" ||
174- tokenName === "CrossJoin" ||
175- tokenName === "Update" ||
176- tokenName === "Into" ) &&
177- nextToken . tokenType . name === "Identifier"
165+ const TABLE_PREFIX_TOKENS = new Set ( [ "From" , "Join" , "Update" , "Into" ] )
166+ const isIdentifierLike = ( token : IToken | undefined ) : token is IToken =>
167+ ! ! token &&
168+ ( token . tokenType . name === "Identifier" ||
169+ token . tokenType . name === "QuotedIdentifier" ||
170+ IDENTIFIER_KEYWORD_TOKENS . has ( token . tokenType . name ) )
171+
172+ const tokenToNamePart = ( token : IToken ) : string => {
173+ if ( token . tokenType . name === "QuotedIdentifier" ) {
174+ return token . image . slice ( 1 , - 1 )
175+ }
176+ return token . image
177+ }
178+
179+ const readQualifiedName = (
180+ startIndex : number ,
181+ ) : { name : string ; nextIndex : number } | null => {
182+ const first = tokens [ startIndex ]
183+ if ( ! isIdentifierLike ( first ) ) return null
184+
185+ const parts = [ tokenToNamePart ( first ) ]
186+ let i = startIndex + 1
187+
188+ while (
189+ i + 1 < tokens . length &&
190+ tokens [ i ] . tokenType . name === "Dot" &&
191+ isIdentifierLike ( tokens [ i + 1 ] )
178192 ) {
179- const tableName = nextToken . image
180- // Check for alias (Identifier following the table name)
181- if ( i + 2 < tokens . length ) {
182- const maybeAlias = tokens [ i + 2 ]
183- if ( maybeAlias . tokenType . name === "Identifier" ) {
184- tables . push ( { table : tableName , alias : maybeAlias . image } )
185- } else {
186- tables . push ( { table : tableName } )
187- }
188- } else {
189- tables . push ( { table : tableName } )
190- }
193+ parts . push ( tokenToNamePart ( tokens [ i + 1 ] ) )
194+ i += 2
191195 }
196+
197+ return { name : parts . join ( "." ) , nextIndex : i }
198+ }
199+
200+ for ( let i = 0 ; i < tokens . length ; i ++ ) {
201+ if ( ! TABLE_PREFIX_TOKENS . has ( tokens [ i ] . tokenType . name ) ) continue
202+
203+ const tableNameResult = readQualifiedName ( i + 1 )
204+ if ( ! tableNameResult ) continue
205+
206+ let alias : string | undefined
207+ let aliasStart = tableNameResult . nextIndex
208+ if ( tokens [ aliasStart ] ?. tokenType . name === "As" ) {
209+ aliasStart ++
210+ }
211+ if ( isIdentifierLike ( tokens [ aliasStart ] ) ) {
212+ alias = tokenToNamePart ( tokens [ aliasStart ] )
213+ }
214+
215+ tables . push ( {
216+ table : tableNameResult . name ,
217+ alias,
218+ } )
219+
220+ // Continue from where we consumed table/alias to avoid duplicate captures.
221+ i = alias ? aliasStart : tableNameResult . nextIndex - 1
192222 }
193223
194224 return tables
@@ -227,18 +257,27 @@ function isImplicitStatementPath(
227257 *
228258 * Example: [SELECT, t, ., col] → [SELECT, col]
229259 */
260+ /**
261+ * Check if a token type name represents an identifier-like token.
262+ * This includes plain Identifier, QuotedIdentifier, and any keyword
263+ * that has the IdentifierKeyword category (e.g., Timestamp, Index, Type).
264+ */
265+ function isIdentifierLikeTokenName ( name : string ) : boolean {
266+ return (
267+ name === "Identifier" ||
268+ name === "QuotedIdentifier" ||
269+ name === "IdentifierKeyword" ||
270+ IDENTIFIER_KEYWORD_TOKENS . has ( name )
271+ )
272+ }
273+
230274function collapseTrailingQualifiedRef ( tokens : IToken [ ] ) : IToken [ ] | null {
231275 if ( tokens . length < 3 ) return null
232276
233277 // Walk backwards from end: expect Identifier (. Identifier)+ pattern
234278 const i = tokens . length - 1
235279 const lastToken = tokens [ i ]
236- const lastType = lastToken . tokenType . name
237- if (
238- lastType !== "Identifier" &&
239- lastType !== "QuotedIdentifier" &&
240- lastType !== "IdentifierKeyword"
241- ) {
280+ if ( ! isIdentifierLikeTokenName ( lastToken . tokenType . name ) ) {
242281 return null
243282 }
244283
@@ -248,13 +287,7 @@ function collapseTrailingQualifiedRef(tokens: IToken[]): IToken[] | null {
248287 const maybeDot = tokens [ start - 1 ]
249288 const maybeIdent = tokens [ start - 2 ]
250289 if ( maybeDot . tokenType . name !== "Dot" ) break
251- const identType = maybeIdent . tokenType . name
252- if (
253- identType !== "Identifier" &&
254- identType !== "QuotedIdentifier" &&
255- identType !== "IdentifierKeyword"
256- )
257- break
290+ if ( ! isIdentifierLikeTokenName ( maybeIdent . tokenType . name ) ) break
258291 start -= 2
259292 }
260293
@@ -350,6 +383,32 @@ function computeSuggestions(tokens: IToken[]): TokenType[] {
350383 return result
351384}
352385
386+ /**
387+ * When tablesInScope is empty (no FROM/JOIN yet), try to infer a table name
388+ * from a trailing qualified reference before the cursor.
389+ * e.g. "SELECT trades." → infer "trades" as table in scope.
390+ */
391+ function inferTableFromQualifiedRef (
392+ tokensBefore : IToken [ ] ,
393+ isMidWord : boolean ,
394+ ) : TableRef | null {
395+ // If mid-word, the last token is a partial column name — look before it.
396+ const effective = isMidWord ? tokensBefore . slice ( 0 , - 1 ) : tokensBefore
397+ const lastIdx = effective . length - 1
398+ if ( lastIdx < 1 ) return null
399+ if ( effective [ lastIdx ] . tokenType . name !== "Dot" ) return null
400+
401+ const tableToken = effective [ lastIdx - 1 ]
402+ if ( ! isIdentifierLikeTokenName ( tableToken . tokenType . name ) ) return null
403+
404+ const table =
405+ tableToken . tokenType . name === "QuotedIdentifier"
406+ ? tableToken . image . slice ( 1 , - 1 )
407+ : tableToken . image
408+
409+ return { table }
410+ }
411+
353412/**
354413 * Get content assist suggestions for a SQL string at a given cursor position
355414 *
@@ -424,6 +483,11 @@ export function getContentAssist(
424483 const fullTokens = QuestDBLexer . tokenize ( fullSql ) . tokens
425484 const tablesInScope = extractTables ( fullSql , fullTokens )
426485
486+ if ( tablesInScope . length === 0 ) {
487+ const inferred = inferTableFromQualifiedRef ( tokens , isMidWord )
488+ if ( inferred ) tablesInScope . push ( inferred )
489+ }
490+
427491 return {
428492 nextTokenTypes,
429493 tablesInScope,
0 commit comments