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/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/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,