From b8469744ef3a696bec17fcf45ccaae87283963b8 Mon Sep 17 00:00:00 2001 From: liuyi Date: Wed, 29 Jul 2026 17:07:05 +0800 Subject: [PATCH] fix: #239 correct suggestion word ranges --- src/parser/common/basicSQL.ts | 32 +++++++ src/parser/flink/index.ts | 7 +- src/parser/generic/index.ts | 7 +- src/parser/hive/index.ts | 7 +- src/parser/impala/index.ts | 7 +- src/parser/mysql/index.ts | 7 +- src/parser/postgresql/index.ts | 7 +- src/parser/spark/index.ts | 7 +- src/parser/trino/index.ts | 7 +- test/parser/syntaxSuggestionWordRange.test.ts | 95 +++++++++++++++++++ 10 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 test/parser/syntaxSuggestionWordRange.test.ts diff --git a/src/parser/common/basicSQL.ts b/src/parser/common/basicSQL.ts index 8d863890..df717fff 100644 --- a/src/parser/common/basicSQL.ts +++ b/src/parser/common/basicSQL.ts @@ -83,6 +83,38 @@ export abstract class BasicSQL< caretTokenIndex: number ): Suggestions; + protected getCandidateTokenRanges( + candidates: CandidatesCollection, + candidateStartTokenIndex: number, + allTokens: Token[], + caretTokenIndex: number + ): Token[] { + // antlr4-c3 may return both entity and alias candidates; use the nearest candidate's start index as boundary + const endTokenIndex = Array.from(candidates.rules.values()).reduce( + (nearestStartTokenIndex, candidateRule) => { + if (candidateRule.startTokenIndex <= candidateStartTokenIndex) { + return nearestStartTokenIndex; + } + return Math.min(nearestStartTokenIndex, candidateRule.startTokenIndex); + }, + caretTokenIndex + 1 + ); + const previousVisibleToken = allTokens + .slice(candidateStartTokenIndex, endTokenIndex) + .reverse() + .find((token) => token.channel === Token.DEFAULT_CHANNEL); + // look past hidden tokens to detect dot, preserving dot and following identifier in incomplete qualified names + const rangeEndTokenIndex = + endTokenIndex <= caretTokenIndex && + (allTokens[endTokenIndex]?.text === '.' || previousVisibleToken?.text === '.') + ? endTokenIndex + 1 + : endTokenIndex; + + return allTokens + .slice(candidateStartTokenIndex, rangeEndTokenIndex) + .filter((token) => token.channel === Token.DEFAULT_CHANNEL); + } + /** * Get a new splitListener instance. */ diff --git a/src/parser/flink/index.ts b/src/parser/flink/index.ts index f6247420..a5fc7771 100644 --- a/src/parser/flink/index.ts +++ b/src/parser/flink/index.ts @@ -93,7 +93,12 @@ export class FlinkSQL extends BasicSQL { for (const candidate of candidates.rules) { const [ruleType, candidateRule] = candidate; - const tokenRanges = allTokens.slice(candidateRule.startTokenIndex, caretTokenIndex + 1); + const tokenRanges = this.getCandidateTokenRanges( + candidates, + candidateRule.startTokenIndex, + allTokens, + caretTokenIndex + ); let syntaxContextType: EntityContextType | StmtContextType | undefined = void 0; switch (ruleType) { diff --git a/src/parser/postgresql/index.ts b/src/parser/postgresql/index.ts index 02944e39..4d5410e8 100644 --- a/src/parser/postgresql/index.ts +++ b/src/parser/postgresql/index.ts @@ -90,7 +90,12 @@ export class PostgreSQL extends BasicSQL; + +const parserFactories: Array<[string, () => SuggestionParser]> = [ + ['MySQL', () => new MySQL()], + ['FlinkSQL', () => new FlinkSQL()], + ['SparkSQL', () => new SparkSQL()], + ['HiveSQL', () => new HiveSQL()], + ['PostgreSQL', () => new PostgreSQL()], + ['TrinoSQL', () => new TrinoSQL()], + ['ImpalaSQL', () => new ImpalaSQL()], + ['GenericSQL', () => new GenericSQL()], +]; + +const scenarios = [ + { + name: 'exclude trailing whitespace from table word ranges', + sql: 'SELECT * FROM current_catalog_schema1 ', + expected: ['current_catalog_schema1'], + }, + { + name: 'exclude AS from table word ranges', + sql: 'SELECT * FROM current_catalog_schema1 as', + expected: ['current_catalog_schema1'], + }, + { + name: 'exclude alias from table word ranges', + sql: 'SELECT * FROM current_catalog_schema1 alias', + expected: ['current_catalog_schema1'], + }, + { + name: 'preserve an incomplete qualified table name', + sql: 'SELECT * FROM db.', + expected: ['db', '.'], + }, +]; + +describe.each(parserFactories)('%s syntax suggestion word ranges', (_name, createParser) => { + test.each(scenarios)('$name', ({ sql, expected }) => { + const tableSuggestion = createParser() + .getSuggestionAtCaretPosition(sql, { + lineNumber: 1, + column: sql.length + 1, + }) + ?.syntax.find((suggestion) => suggestion.syntaxContextType === EntityContextType.TABLE); + + expect(tableSuggestion).toBeDefined(); + expect(tableSuggestion?.wordRanges.map((wordRange) => wordRange.text)).toEqual(expected); + }); +}); + +test('SparkSQL preserves a qualified table name separated by hidden tokens', () => { + const sql = 'SELECT * FROM db. table'; + const tableSuggestion = new SparkSQL() + .getSuggestionAtCaretPosition(sql, { + lineNumber: 1, + column: sql.length + 1, + }) + ?.syntax.find((suggestion) => suggestion.syntaxContextType === EntityContextType.TABLE); + + expect(tableSuggestion).toBeDefined(); + expect(tableSuggestion?.wordRanges.map((wordRange) => wordRange.text)).toEqual([ + 'db', + '.', + 'table', + ]); +}); + +test('SparkSQL preserves a qualified table name separated by a comment', () => { + const sql = 'SELECT * FROM db./* comment */table'; + const tableSuggestion = new SparkSQL() + .getSuggestionAtCaretPosition(sql, { + lineNumber: 1, + column: sql.length + 1, + }) + ?.syntax.find((suggestion) => suggestion.syntaxContextType === EntityContextType.TABLE); + + expect(tableSuggestion).toBeDefined(); + expect(tableSuggestion?.wordRanges.map((wordRange) => wordRange.text)).toEqual([ + 'db', + '.', + 'table', + ]); +});