diff --git a/README-zh_CN.md b/README-zh_CN.md index 7e58ffe8..6f344646 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -89,6 +89,8 @@ npm install monaco-sql-languages completionItems: { enable: true, triggerCharacters: [' ', '.'], + // 选中补全项后自动触发下一轮补全 + continuousCompletion: true, completionService: //... , } }); @@ -96,6 +98,13 @@ npm install monaco-sql-languages 默认情况下,自动补全功能只提供关键字自动补全与内置SQL代码片段补全, 但你可以通过设置 `completionService` 自定义自动补全项。 + 将 `continuousCompletion` 设置为 `true` 可以开启连续补全:当选中一个补全项后,只要插入行为把光标带入了新的语法上下文,就会自动再次触发建议框,从而无需再次输入即可展示下一组建议。具体来说: + + - 未显式设置 `insertText` 的**关键字**补全项会带一个尾随空格插入(如 `SELECT `),从而立即给出下一组建议(列名、`*` 等); + - 当解析器报告当前位置处于 `function` 语法上下文时,关键字补全项会按原样插入(不带尾随空格),且不会再次触发建议框,方便你自行输入函数括号; + - `insertText` 以触发字符结尾的补全项会再次触发补全——例如自定义 `completionService` 可以让数据库项以 `insertText: 'my_database.'` 插入,这样选中数据库名后会立即提示该库下的表; + - 自定义了 `command` 的补全项不会被修改;普通的实体项(表、列等)不会再次触发,因为接受它们并没有改变补全上下文。 + ```typescript import { languages } from 'monaco-editor/esm/vs/editor/editor.api'; import { diff --git a/README.md b/README.md index 0f433a5b..aae43433 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ npm install monaco-sql-languages completionItems: { enable: true, triggerCharacters: [' ', '.'], + // Automatically trigger the next completion after a completion item is accepted + continuousCompletion: true, completionService: //... , } }); @@ -96,6 +98,13 @@ npm install monaco-sql-languages By default, Monaco SQL Languages only provides keyword autocompletion and built-in SQL snippets, and you can customize your completionItem list via `completionService`. + Set `continuousCompletion` to `true` to enable continuous completion: after a completion item is accepted, the suggest widget is triggered again automatically whenever the insertion moves the caret into a new syntactic context, so the next set of suggestions is offered without re-typing. Concretely: + + - **keyword** items without an explicit `insertText` are inserted with a trailing space (e.g. `SELECT `), so the next suggestions (columns, `*`, ...) are offered immediately; + - when the parser reports that the current position is within a `function` syntactic context, keyword items are inserted as-is (without a trailing space) and do not re-trigger the suggest widget, so you can type the function's parentheses yourself; + - items whose `insertText` ends with a trigger character re-trigger completion — e.g. a custom `completionService` can emit database items with `insertText: 'my_database.'`, so that tables inside the database are suggested right after the database name is accepted; + - items that define their own `command` are left untouched, and plain entity items (tables, columns, ...) do not re-trigger, since accepting them does not change the completion context. + ```typescript import { languages } from 'monaco-editor/esm/vs/editor/editor.api'; import { diff --git a/package.json b/package.json index 627692b1..d941f3cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "monaco-sql-languages", - "version": "1.2.1", + "version": "1.2.2-beta.0", "description": "SQL languages for the Monaco Editor, based on monaco-languages.", "scripts": { "prepublishOnly": "npm run build", @@ -104,6 +104,6 @@ ] }, "dependencies": { - "dt-sql-parser": "4.5.1" + "dt-sql-parser": "4.5.2-beta.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8701c328..3979c5a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: dt-sql-parser: - specifier: 4.5.1 - version: 4.5.1(antlr4ng-cli@1.0.7) + specifier: 4.5.2-beta.1 + version: 4.5.2-beta.1(antlr4ng-cli@1.0.7) devDependencies: '@commitlint/cli': specifier: ^17.7.2 @@ -723,8 +723,8 @@ packages: resolution: {integrity: sha512-sCm11ak2oY6DglEPpCB8TixLjWAxd3kJTs6UIcSasNYxXdFPV+YKlye92c8H4kKFqV5qYMIh7d+cYecEg0dIkA==} engines: {node: '>=6'} - dt-sql-parser@4.5.1: - resolution: {integrity: sha512-D1B4z2m6D1jkfruPo42x81jJ5uyuRH5lEx41K449DHvtVPSK+jYHWEUvgBixFaOBdP0ONyEnYpfzSUzRbgF1JA==} + dt-sql-parser@4.5.2-beta.1: + resolution: {integrity: sha512-lT2DUitA1gmR+eJIdrzWdX677vDks+YV+2Banxqw56NdbG8uCnCFGekUVG81mxLUN94QezGgiw4gUocSN4SNrA==} engines: {node: '>=18'} eastasianwidth@0.2.0: @@ -2769,7 +2769,7 @@ snapshots: find-up: 3.0.0 minimatch: 3.1.2 - dt-sql-parser@4.5.1(antlr4ng-cli@1.0.7): + dt-sql-parser@4.5.2-beta.1(antlr4ng-cli@1.0.7): dependencies: antlr4-c3: 3.3.7(antlr4ng-cli@1.0.7) antlr4ng: 2.0.11(antlr4ng-cli@1.0.7) diff --git a/src/languageFeatures.ts b/src/languageFeatures.ts index cf8c74c0..d823e1cc 100644 --- a/src/languageFeatures.ts +++ b/src/languageFeatures.ts @@ -16,7 +16,11 @@ import { Range, Uri } from './fillers/monaco-editor-core'; -import type { CompletionSnippet, LanguageServiceDefaults } from './monaco.contribution'; +import type { + CompletionSnippet, + ICompletionItem, + LanguageServiceDefaults +} from './monaco.contribution'; export interface ColumnInfo { /** 字段名 */ @@ -197,6 +201,7 @@ export class CompletionAdapter } const resource = model.uri; + let hasFunctionContext = false; return this._worker(resource) .then((worker) => { let code = editor.getModel(resource)?.getValue() || ''; @@ -206,6 +211,9 @@ export class CompletionAdapter return worker.doCompletionWithEntities(code, position); }) .then(({ suggestions, allEntities, context: semanticContext }) => { + hasFunctionContext = !!suggestions?.syntax?.some( + (syntax) => syntax.syntaxContextType === EntityContextType.FUNCTION + ); let snippets: CompletionSnippet[] = []; if (semanticContext?.isStatementBeginning) { snippets = this._defaults.completionSnippets.map((item) => ({ @@ -213,7 +221,6 @@ export class CompletionAdapter insertText: typeof item.body === 'string' ? item.body : item.body.join('\n') })); } - return this._defaults.completionService( model, position, @@ -234,14 +241,17 @@ export class CompletionAdapter const unwrappedCompletions = Array.isArray(completions) ? completions : completions.suggestions; + const continuousCompletion = + this._defaults.modeConfiguration.completionItems.continuousCompletion; const completionItems: languages.CompletionItem[] = unwrappedCompletions.map( - (item) => ({ - ...item, - insertText: - item.insertText ?? - (typeof item.label === 'string' ? item.label : item.label.label), - range: item.range ?? wordRange - }) + (item) => + applyContinuousCompletion( + item, + wordRange, + continuousCompletion, + this.triggerCharacters, + hasFunctionContext + ) ); return { @@ -252,6 +262,82 @@ export class CompletionAdapter }); } } + +/** + * The command attached to completion items so the suggest widget is triggered again + * right after they are accepted (see https://github.com/DTStack/monaco-sql-languages/issues/184). + */ +const retriggerSuggestCommand: languages.Command = { + id: 'editor.action.triggerSuggest', + title: 'Trigger Suggest' +}; + +/** + * Normalize a completion item for continuous completion. + * + * A completion item should re-trigger the suggest widget only when accepting it moves the + * caret into a genuinely *new* syntactic context; otherwise the re-triggered widget would be + * filtered by the word just inserted and would re-offer the very same item, which cannot be + * dismissed without pressing Escape. Concretely, an item continues the completion session when: + * + * - it is a keyword without an explicit `insertText`: it is inserted with a trailing space + * (e.g. `SELECT `) so the caret lands on a word separator and the parser can compute the + * next context (columns, `*`, ...). Exception: in a function syntax context the keyword is + * inserted as-is (no trailing space) and does not re-trigger, so the user can continue + * typing the function's parentheses themselves; + * - its explicit `insertText` ends with a trigger character (e.g. a completionService that + * inserts a database as `my_database.` so that tables inside it are offered next). + * + * Plain entity items (tables, columns, ...) and items defining their own `command` are left + * untouched: the former stay on the same context, the latter control their continuation + * themselves. + */ +function applyContinuousCompletion( + item: ICompletionItem, + wordRange: Range, + continuousCompletion: boolean | undefined, + triggerCharacters: string[], + hasFunctionContext: boolean +): languages.CompletionItem { + const range = item.range ?? wordRange; + const labelText = typeof item.label === 'string' ? item.label : item.label.label; + + if (!continuousCompletion || item.command) { + return { + ...item, + insertText: item.insertText ?? labelText, + range + }; + } + + const isKeyword = item.kind === languages.CompletionItemKind.Keyword; + const isParenFunction = item.insertText === undefined && isKeyword && hasFunctionContext; + + let insertText = item.insertText; + const insertTextRules = item.insertTextRules; + if (insertText === undefined && isKeyword) { + if (!isParenFunction) { + // Continuous completion needs the caret to land on a word separator + // (e.g. a space after `SELECT`), otherwise the next completion + // context cannot be computed correctly. + insertText = `${labelText} `; + } + } + + const advancesContext = + !isParenFunction && + ((item.insertText === undefined && isKeyword) || + (item.insertText !== undefined && + triggerCharacters.some((ch) => ch.length > 0 && item.insertText!.endsWith(ch)))); + + return { + ...item, + insertText: insertText ?? labelText, + insertTextRules, + range, + command: advancesContext ? retriggerSuggestCommand : undefined + }; +} /** * The adapter is for the definition of the symbol at the given position and document. **/ diff --git a/src/monaco.contribution.ts b/src/monaco.contribution.ts index b88f7a93..72407f0d 100644 --- a/src/monaco.contribution.ts +++ b/src/monaco.contribution.ts @@ -48,6 +48,26 @@ export interface CompletionOptions { completionService: CompletionService; triggerCharacters: string[]; snippets: CompletionSnippetOption[]; + /** + * Whether to continuously trigger completion after a completion item is accepted. + * + * If enabled, a command re-triggering the suggest widget is attached to completion items + * whose insertion moves the caret into a new syntactic context: + * - keyword items without an explicit `insertText` are inserted with a trailing space + * (e.g. `SELECT `), so the next context (columns, `*`, ...) is offered automatically. + * In a function syntax context, keyword items are inserted as-is (no trailing space) + * and do not re-trigger, so the user can type the function's parentheses themselves; + * - items whose `insertText` ends with a trigger character re-trigger completion, e.g. a + * custom `completionService` can insert a database as `my_database.` so that tables + * inside it are suggested right away. + * + * Items defining their own `command` are left untouched, and plain entity items (tables, + * columns, ...) do not re-trigger: their context hasn't changed, so the re-triggered + * widget would just re-offer the item that was just accepted. + * + * Defaults to false. + */ + continuousCompletion?: boolean; } export interface CompletionSnippet { @@ -240,7 +260,8 @@ export const modeConfigurationDefault: Required = { snippets: [], enable: true, completionService: defaultCompletionService, - triggerCharacters: ['.', ' '] + triggerCharacters: ['.', ' '], + continuousCompletion: false }, diagnostics: true, definitions: false, diff --git a/src/setupLanguageFeatures.ts b/src/setupLanguageFeatures.ts index e9568fa2..1ee3c8fa 100644 --- a/src/setupLanguageFeatures.ts +++ b/src/setupLanguageFeatures.ts @@ -195,6 +195,13 @@ function processConfiguration( ? configuration.completionItems!.triggerCharacters : (defaults?.modeConfiguration.completionItems.triggerCharacters ?? modeConfigurationDefault.completionItems.triggerCharacters); + + const continuousCompletion = + typeof configuration.completionItems !== 'boolean' && + typeof configuration.completionItems?.continuousCompletion === 'boolean' + ? configuration.completionItems.continuousCompletion + : (defaults?.modeConfiguration.completionItems.continuousCompletion ?? + modeConfigurationDefault.completionItems.continuousCompletion); const references = typeof configuration.references === 'boolean' ? configuration.references @@ -248,7 +255,8 @@ function processConfiguration( enable: completionEnable, completionService, triggerCharacters, - snippets + snippets, + continuousCompletion }, references, definitions, diff --git a/src/test/completionAdapter.test.ts b/src/test/completionAdapter.test.ts new file mode 100644 index 00000000..71d43dea --- /dev/null +++ b/src/test/completionAdapter.test.ts @@ -0,0 +1,220 @@ +import * as assert from 'assert'; +import { editor, languages, Position } from '../fillers/monaco-editor-core'; +import { CompletionAdapter } from '../languageFeatures'; +import { + CompletionOptions, + LanguageServiceDefaultsImpl, + modeConfigurationDefault +} from '../monaco.contribution'; + +const mockWorkerResult = { + suggestions: { + keywords: ['SELECT'], + syntax: [] + }, + allEntities: [], + context: { + isStatementBeginning: false + } +}; + +function createWorkerAccessor() { + return () => + Promise.resolve({ + doCompletionWithEntities: () => Promise.resolve(mockWorkerResult) + } as any); +} + +function createDefaults(completionItems: Partial) { + return new LanguageServiceDefaultsImpl('mysql', { + ...modeConfigurationDefault, + completionItems: { + ...modeConfigurationDefault.completionItems, + ...completionItems + } + }); +} + +suite('CompletionAdapter - continuousCompletion', () => { + const model = editor.createModel('sel', 'mysql'); + const position = new Position(1, 4); + + test('no command is attached and no trailing space by default', async () => { + const adapter = new CompletionAdapter(createWorkerAccessor(), createDefaults({})); + const result = await adapter.provideCompletionItems(model, position, {} as any, {} as any); + assert.strictEqual(result.suggestions.length, 1); + assert.strictEqual(result.suggestions[0].command, undefined); + assert.strictEqual(result.suggestions[0].insertText, 'SELECT'); + }); + + test('keyword items get a trailing space and triggerSuggest command when enabled', async () => { + const adapter = new CompletionAdapter( + createWorkerAccessor(), + createDefaults({ continuousCompletion: true }) + ); + const result = await adapter.provideCompletionItems(model, position, {} as any, {} as any); + assert.strictEqual(result.suggestions.length, 1); + assert.strictEqual(result.suggestions[0].insertText, 'SELECT '); + assert.strictEqual(result.suggestions[0].command?.id, 'editor.action.triggerSuggest'); + }); + + test('keyword items still re-trigger when triggerCharacters are customized', async () => { + const adapter = new CompletionAdapter( + createWorkerAccessor(), + createDefaults({ continuousCompletion: true, triggerCharacters: ['.'] }) + ); + const result = await adapter.provideCompletionItems(model, position, {} as any, {} as any); + assert.strictEqual(result.suggestions[0].insertText, 'SELECT '); + assert.strictEqual(result.suggestions[0].command?.id, 'editor.action.triggerSuggest'); + }); + + test('custom command and insertText from completionService are respected', async () => { + const completionService = async () => [ + { + label: 'my_db', + kind: languages.CompletionItemKind.Field, + insertText: 'my_db.', + command: { id: 'my.customCommand', title: 'custom' } + } + ]; + const adapter = new CompletionAdapter( + createWorkerAccessor(), + createDefaults({ completionService, continuousCompletion: true }) + ); + const result = await adapter.provideCompletionItems(model, position, {} as any, {} as any); + assert.strictEqual(result.suggestions[0].insertText, 'my_db.'); + assert.strictEqual(result.suggestions[0].command?.id, 'my.customCommand'); + }); + + test('entity items do NOT get triggerSuggest even when continuousCompletion is enabled', async () => { + const completionService = async () => [ + { + label: 'my_schema', + kind: languages.CompletionItemKind.Field, + detail: 'Remote: schema' + } + ]; + const adapter = new CompletionAdapter( + createWorkerAccessor(), + createDefaults({ completionService, continuousCompletion: true }) + ); + const result = await adapter.provideCompletionItems(model, position, {} as any, {} as any); + assert.strictEqual(result.suggestions[0].insertText, 'my_schema'); + assert.strictEqual(result.suggestions[0].command, undefined); + }); + + test('items whose insertText ends with a trigger character re-trigger suggest', async () => { + const completionService = async () => [ + { + label: 'my_db', + kind: languages.CompletionItemKind.Field, + detail: 'Remote: database', + insertText: 'my_db.' + } + ]; + const adapter = new CompletionAdapter( + createWorkerAccessor(), + createDefaults({ completionService, continuousCompletion: true }) + ); + const result = await adapter.provideCompletionItems(model, position, {} as any, {} as any); + assert.strictEqual(result.suggestions[0].insertText, 'my_db.'); + assert.strictEqual(result.suggestions[0].command?.id, 'editor.action.triggerSuggest'); + }); + + test('keyword items with an explicit plain insertText do NOT re-trigger', async () => { + const completionService = async () => [ + { + label: 'SELECT', + kind: languages.CompletionItemKind.Keyword, + insertText: 'SELECT' + } + ]; + const adapter = new CompletionAdapter( + createWorkerAccessor(), + createDefaults({ completionService, continuousCompletion: true }) + ); + const result = await adapter.provideCompletionItems(model, position, {} as any, {} as any); + assert.strictEqual(result.suggestions[0].insertText, 'SELECT'); + assert.strictEqual(result.suggestions[0].command, undefined); + }); + + suite('function keywords', () => { + function createFunctionWorkerAccessor(keywords: string[], hasFunctionContext: boolean) { + return () => + Promise.resolve({ + doCompletionWithEntities: () => + Promise.resolve({ + suggestions: { + keywords, + syntax: hasFunctionContext + ? [{ syntaxContextType: 'function', wordRanges: [] }] + : [] + }, + allEntities: [], + context: { isStatementBeginning: false } + }) + } as any); + } + + test('fall back to trailing space without a function context', async () => { + const adapter = new CompletionAdapter( + createFunctionWorkerAccessor(['SUM'], false), + createDefaults({ continuousCompletion: true }) + ); + const result = await adapter.provideCompletionItems( + model, + position, + {} as any, + {} as any + ); + assert.strictEqual(result.suggestions[0].insertText, 'SUM '); + assert.strictEqual(result.suggestions[0].kind, languages.CompletionItemKind.Keyword); + }); + + test('ambiguous words like LEFT stay plain keywords without a function context', async () => { + const adapter = new CompletionAdapter( + createFunctionWorkerAccessor(['LEFT'], false), + createDefaults({ continuousCompletion: true }) + ); + const result = await adapter.provideCompletionItems( + model, + position, + {} as any, + {} as any + ); + assert.strictEqual(result.suggestions[0].insertText, 'LEFT '); + assert.strictEqual(result.suggestions[0].kind, languages.CompletionItemKind.Keyword); + }); + + test('ambiguous words like LEFT are inserted as-is in a function context', async () => { + const adapter = new CompletionAdapter( + createFunctionWorkerAccessor(['LEFT'], true), + createDefaults({ continuousCompletion: true }) + ); + const result = await adapter.provideCompletionItems( + model, + position, + {} as any, + {} as any + ); + assert.strictEqual(result.suggestions[0].insertText, 'LEFT'); + assert.strictEqual(result.suggestions[0].command, undefined); + }); + + test('non-function keywords like CASE are also inserted as-is in a function context', async () => { + const adapter = new CompletionAdapter( + createFunctionWorkerAccessor(['CASE'], true), + createDefaults({ continuousCompletion: true }) + ); + const result = await adapter.provideCompletionItems( + model, + position, + {} as any, + {} as any + ); + assert.strictEqual(result.suggestions[0].insertText, 'CASE'); + assert.strictEqual(result.suggestions[0].kind, languages.CompletionItemKind.Keyword); + assert.strictEqual(result.suggestions[0].command, undefined); + }); + }); +}); diff --git a/test/setup.js b/test/setup.js index 2bdda08b..4d9d06a5 100644 --- a/test/setup.js +++ b/test/setup.js @@ -32,7 +32,8 @@ define('vs/nls', [], { define('dt-sql-parser', [], { EntityContextType: { TABLE: 'table', - TABLE_CREATE: 'tableCreate' + TABLE_CREATE: 'tableCreate', + FUNCTION: 'function' } }); diff --git a/website/src/extensions/view/index.ts b/website/src/extensions/view/index.ts index 3a13f97b..f33935c3 100644 --- a/website/src/extensions/view/index.ts +++ b/website/src/extensions/view/index.ts @@ -7,7 +7,12 @@ export const viewExt: IExtension = { activate(molecule) { view.get().then((viewState) => { if (viewState?.explorer) { - molecule.explorer.setActive(viewState.explorer.active); + // Molecule Collapse.performSizes crashes if active contains ids + // that are no longer in explorer.data (stale IndexedDB after panel list changes). + const validIds = new Set(molecule.explorer.getAll().map((item) => item.id)); + molecule.explorer.setActive( + (viewState.explorer.active || []).filter((id) => validIds.has(id)) + ); } if (viewState?.layout) { molecule.layout.setState(viewState.layout); diff --git a/website/src/languages/helpers/completionService.ts b/website/src/languages/helpers/completionService.ts index 7ae47675..787bf0fb 100644 --- a/website/src/languages/helpers/completionService.ts +++ b/website/src/languages/helpers/completionService.ts @@ -254,6 +254,32 @@ const getPureEntityText = (originEntityText: string) => { return parseEntityText(originEntityText).pureEntityText; }; +/** + * Check whether an entity's text can actually be a name. + * + * Entity text is a raw slice of the source, so when the SQL has a syntax error + * the error recovery can stretch a context across a whole clause: `FROM` + * followed by `JOIN myOwn` used to yield the table entity + * `JOIN myOwn\nORDER BY 1`, which then became a completion label. + * + * `parseEntityText` keeps a backticked identifier whole when its content is not + * a bare identifier, so `` `my table` `` arrives here with its backticks and + * must stay valid. Anything else has to be made of identifier characters only. + * @param name - The entity text to check + * @returns Whether the text is identifier-shaped + */ +const isEntityName = (name: string | undefined): name is string => { + if (!name) return false; + // Split like parseEntityText: keep each `...` segment whole so multi-part + // names such as `db`.`tbl` / db.`tbl` stay valid. A bare segment must be + // identifier characters only (rejects error-recovery spans with spaces/keywords). + const segments = name.match(/`[^`]*`|[^.]+/g); + if (!segments || segments.join('.') !== name) return false; + return segments.every( + (segment) => /^`[^`]*`$/.test(segment) || /^[A-Za-z0-9_$]+$/.test(segment) + ); +}; + /** * Remove backticks from text for filter matching * @param text - The text that may contain backticks @@ -379,6 +405,10 @@ const getColumnCompletions = async ( sourceTables.forEach((sourceTable) => { const realTablePath = sourceTable.text; const displayAlias = tableNameAliasMap[sourceTable.text]; + // A CTE definition and its FROM/JOIN reference share `text`, so the map + // above gives both the same alias. Expand derived columns only from the + // entity that actually owns the alias. + const derivedAlias = sourceTable[AttrName.alias]?.text; const tableColumns = [ ...getSpecificTableColumns( @@ -386,7 +416,11 @@ const getColumnCompletions = async ( realTablePath, displayAlias ), - ...getSpecificDerivedTableColumns(derivedTableEntities, displayAlias), + ...getSpecificDerivedTableColumns( + derivedTableEntities, + derivedAlias, + realTablePath + ), ...getSpecificCTASTableColumns( ctasTableDefinitionEntities, realTablePath, @@ -423,21 +457,30 @@ const getColumnCompletions = async ( result.push(...sourceTableColumns); - // Also suggest tables when inputting column + // Also suggest tables when inputting column. + // A CTE definition and an unaliased reference share the same label; keep one. + const seenTableLabels = new Set(); const tableCompletionItems = sourceTables.length > 1 - ? sourceTables.map((tb) => { - const tableName = tb[AttrName.alias]?.text ?? getPureEntityText(tb.text); - return { - label: tableName, - filterText: removeBackticks(tableName), - kind: languages.CompletionItemKind.Field, - detail: - tb.declareType === TableDeclareType.LITERAL - ? 'table' - : 'derived table', - sortText: '1' + tableName - }; + ? sourceTables.flatMap((tb) => { + const alias = tb[AttrName.alias]?.text; + // CTE names are suggested separately. An unaliased reference would repeat that name. + if (tb.declareType === TableDeclareType.EXPRESSION && !alias) return []; + const tableName = alias ?? getPureEntityText(tb.text); + if (!isEntityName(tableName) || seenTableLabels.has(tableName)) return []; + seenTableLabels.add(tableName); + return [ + { + label: tableName, + filterText: removeBackticks(tableName), + kind: languages.CompletionItemKind.Field, + detail: + tb.declareType === TableDeclareType.LITERAL + ? 'table' + : 'derived table', + sortText: '1' + tableName + } + ]; }) : []; @@ -473,7 +516,7 @@ const getColumnCompletions = async ( const localTableColumns = [ ...getSpecificTableColumns(sourceTableDefinitionEntities, realTablePath, displayAlias), - ...getSpecificDerivedTableColumns(derivedTableEntities, displayAlias), + ...getSpecificDerivedTableColumns(derivedTableEntities, displayAlias, realTablePath), ...getSpecificCTASTableColumns(ctasTableDefinitionEntities, realTablePath, displayAlias) ]; @@ -519,10 +562,13 @@ const getSpecificTableColumns = ( }) .map((tb) => { const tableName = displayAlias || getPureEntityText(tb.text); + if (!isEntityName(tableName)) return []; return ( tb.columns?.map((column) => { const columnName = column[AttrName.alias]?.text || getPureEntityText(column.text); + // Not `isEntityName`: an unaliased column is often an expression + // (`UPPER(name)`, `amount * 2`) and is still a usable label. if (!columnName) return null; const label = columnName + @@ -551,42 +597,58 @@ const getSpecificTableColumns = ( */ const getSpecificDerivedTableColumns = ( derivedTableEntities: CommonEntityContext[], - displayAlias?: string + displayAlias?: string, + tableText?: string ): any[] => { - return derivedTableEntities - .filter((tb) => { - return displayAlias ? tb[AttrName.alias]?.text === displayAlias : false; - }) - .map((tb) => { - const derivedTableQueryResult = tb.relatedEntities?.find( - (entity) => entity.entityContextType === EntityContextType.QUERY_RESULT - ) as CommonEntityContext | undefined; - - const tableName = - displayAlias || tb[AttrName.alias]?.text || getPureEntityText(tb.text); - - return ( - derivedTableQueryResult?.columns - ?.filter((column) => column.declareType !== ColumnDeclareType.ALL) - .map((column) => { - const columnName = - column[AttrName.alias]?.text || getPureEntityText(column.text); - if (!columnName) return null; - return { - label: columnName, - filterText: removeBackticks(columnName), - insertText: columnName, - kind: languages.CompletionItemKind.EnumMember, - detail: `\`${tableName}\`'s column`, - sortText: '0' + tableName + columnName, - _columnText: columnName, - _tableName: tableName - }; - }) || [] - ); - }) - .flat() - .filter(Boolean); + const matched = derivedTableEntities.filter((tb) => { + if (displayAlias) { + return tb[AttrName.alias]?.text === displayAlias; + } + if (!tableText) return false; + return tb.text === tableText || getPureEntityText(tb.text) === getPureEntityText(tableText); + }); + // Prefer the CTE/subquery definition that owns QUERY_RESULT; the reference + // entity may also match by text/alias but usually has no relatedEntities. + const tb = + matched.find((entity) => + entity.relatedEntities?.some( + (r) => r.entityContextType === EntityContextType.QUERY_RESULT + ) + ) ?? matched[0]; + if (!tb) return []; + + const derivedTableQueryResult = tb.relatedEntities?.find( + (entity) => entity.entityContextType === EntityContextType.QUERY_RESULT + ) as CommonEntityContext | undefined; + + const tableName = + displayAlias || + (tableText ? getPureEntityText(tableText) : undefined) || + tb[AttrName.alias]?.text || + getPureEntityText(tb.text); + + if (!isEntityName(tableName)) return []; + + return ( + derivedTableQueryResult?.columns + ?.filter((column) => column.declareType !== ColumnDeclareType.ALL) + .map((column) => { + const columnName = column[AttrName.alias]?.text || getPureEntityText(column.text); + // Not `isEntityName`: an unaliased column is often an expression. + if (!columnName) return null; + return { + label: columnName, + filterText: removeBackticks(columnName), + insertText: columnName, + kind: languages.CompletionItemKind.EnumMember, + detail: `\`${tableName}\`'s column`, + sortText: '0' + tableName + columnName, + _columnText: columnName, + _tableName: tableName + }; + }) + .filter(Boolean) || [] + ); }; /** @@ -611,6 +673,7 @@ const getSpecificCTASTableColumns = ( ) as CommonEntityContext | undefined; const tableName = displayAlias || getPureEntityText(tb.text); + if (!isEntityName(tableName)) return []; return ( ctasQueryResult?.columns @@ -618,6 +681,7 @@ const getSpecificCTASTableColumns = ( .map((column) => { const columnName = column[AttrName.alias]?.text || getPureEntityText(column.text); + // Not `isEntityName`: an unaliased column is often an expression. if (!columnName) return null; const label = columnName + @@ -641,6 +705,33 @@ const getSpecificCTASTableColumns = ( .filter(Boolean); }; +/** Visible CTE names. Definitions and references share `text`; keep one. */ +const getCteTableCompletions = (entities: EntityContext[] | null): ICompletionItem[] => { + const seen = new Set(); + const items: ICompletionItem[] = []; + for (const entity of entities ?? []) { + if ( + entity.entityContextType !== EntityContextType.TABLE || + !entity.isAccessible || + !('declareType' in entity) || + entity.declareType !== TableDeclareType.EXPRESSION + ) { + continue; + } + const tableName = getPureEntityText(entity.text); + if (!isEntityName(tableName) || seen.has(tableName.toLowerCase())) continue; + seen.add(tableName.toLowerCase()); + items.push({ + label: tableName, + filterText: removeBackticks(tableName), + kind: languages.CompletionItemKind.Field, + detail: 'derived table', + sortText: '1' + tableName + }); + } + return items; +}; + const getSyntaxCompletionItems = async ( languageId: string, syntax: Suggestions['syntax'], @@ -680,26 +771,68 @@ const getSyntaxCompletionItems = async ( tracker.markAsCompleted('db_objects'); } - // Add table completions from table entities created in context + // WITH names are TABLE + EXPRESSION. Suggest them wherever a table or a + // join condition can start, including an unfinished FROM / JOIN / ON. + if ( + (syntaxContextType === EntityContextType.TABLE || + syntaxContextType === EntityContextType.COLUMN) && + words.length <= 1 && + !tracker.hasCompletionType('cte_tables') + ) { + syntaxCompletionItems = syntaxCompletionItems.concat(getCteTableCompletions(entities)); + tracker.markAsCompleted('cte_tables'); + } + + // Add table completions from table entities created in context. + // COLUMN is included so a join condition (`JOIN t ON |`) sees them too. + // Skip a name that an accessible source table already shows, so + // `FROM orders JOIN t1 ON` does not list orders and t1 twice. if ( - syntaxContextType === EntityContextType.TABLE && + (syntaxContextType === EntityContextType.TABLE || + syntaxContextType === EntityContextType.COLUMN) && words.length <= 1 && !tracker.hasCompletionType('created_tables') ) { + const sourceTableLabels = new Set( + (entities ?? []) + .filter( + (entity): entity is CommonEntityContext => + entity.entityContextType === EntityContextType.TABLE && + !!entity.isAccessible && + 'declareType' in entity + ) + .map((tb) => { + const alias = tb[AttrName.alias]?.text; + if (tb.declareType === TableDeclareType.EXPRESSION && !alias) { + return getPureEntityText(tb.text); + } + return alias ?? getPureEntityText(tb.text); + }) + .filter(isEntityName) + .map((name) => name.toLowerCase()) + ); const createTables = entities ?.filter( (entity) => entity.entityContextType === EntityContextType.TABLE_CREATE ) - .map((tb) => { + .flatMap((tb) => { const tableName = getPureEntityText(tb.text); - return { - label: tableName, - filterText: removeBackticks(tableName), - kind: languages.CompletionItemKind.Field, - detail: 'table', - sortText: '1' + tableName - }; + if ( + !isEntityName(tableName) || + sourceTableLabels.has(tableName.toLowerCase()) + ) { + return []; + } + return [ + { + label: tableName, + filterText: removeBackticks(tableName), + kind: languages.CompletionItemKind.Field, + detail: 'table', + sortText: '1' + tableName + } + ]; }) || []; syntaxCompletionItems = syntaxCompletionItems.concat(createTables); diff --git a/website/src/languages/index.ts b/website/src/languages/index.ts index 9471d3f7..f33531c3 100644 --- a/website/src/languages/index.ts +++ b/website/src/languages/index.ts @@ -77,7 +77,8 @@ const preprocessCodeHive = (code: string, mark?: string): string => { setupLanguageFeatures(LanguageIdEnum.FLINK, { completionItems: { enable: true, - completionService + completionService, + continuousCompletion: true }, references: true, definitions: true, @@ -89,7 +90,8 @@ setupLanguageFeatures(LanguageIdEnum.FLINK, { setupLanguageFeatures(LanguageIdEnum.SPARK, { completionItems: { enable: true, - completionService + completionService, + continuousCompletion: true }, references: true, definitions: true, @@ -101,7 +103,8 @@ setupLanguageFeatures(LanguageIdEnum.SPARK, { setupLanguageFeatures(LanguageIdEnum.HIVE, { completionItems: { enable: true, - completionService + completionService, + continuousCompletion: true }, references: true, definitions: true, @@ -113,7 +116,8 @@ setupLanguageFeatures(LanguageIdEnum.HIVE, { setupLanguageFeatures(LanguageIdEnum.MYSQL, { completionItems: { enable: true, - completionService + completionService, + continuousCompletion: true }, references: true, definitions: true, @@ -125,7 +129,8 @@ setupLanguageFeatures(LanguageIdEnum.MYSQL, { setupLanguageFeatures(LanguageIdEnum.TRINO, { completionItems: { enable: true, - completionService + completionService, + continuousCompletion: true }, references: true, definitions: true, @@ -137,7 +142,8 @@ setupLanguageFeatures(LanguageIdEnum.TRINO, { setupLanguageFeatures(LanguageIdEnum.PG, { completionItems: { enable: true, - completionService + completionService, + continuousCompletion: true }, references: true, definitions: true, @@ -149,7 +155,8 @@ setupLanguageFeatures(LanguageIdEnum.PG, { setupLanguageFeatures(LanguageIdEnum.IMPALA, { completionItems: { enable: true, - completionService + completionService, + continuousCompletion: true }, references: true, definitions: true, diff --git a/website/vite.config.ts b/website/vite.config.ts index d2eada99..d4c8abbe 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -30,6 +30,11 @@ export default defineConfig({ outDir: resolve(__dirname, '../docs') }, server: { + // monaco-editor is aliased to ../node_modules; Vite blocks @fs outside the + // website root by default, which 403s codicon.ttf and similar assets. + fs: { + allow: [resolve(__dirname, '..')] + }, proxy: { '^/api': 'http://example.com/' }