Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README-zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,22 @@ npm install monaco-sql-languages
completionItems: {
enable: true,
triggerCharacters: [' ', '.'],
// 选中补全项后自动触发下一轮补全
continuousCompletion: true,
completionService: //... ,
}
});
```

默认情况下,自动补全功能只提供关键字自动补全与内置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 {
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,22 @@ npm install monaco-sql-languages
completionItems: {
enable: true,
triggerCharacters: [' ', '.'],
// Automatically trigger the next completion after a completion item is accepted
continuousCompletion: true,
completionService: //... ,
}
});
```

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 {
Expand Down
104 changes: 95 additions & 9 deletions src/languageFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/** 字段名 */
Expand Down Expand Up @@ -197,6 +201,7 @@ export class CompletionAdapter<T extends BaseSQLWorker>
}

const resource = model.uri;
let hasFunctionContext = false;
return this._worker(resource)
.then((worker) => {
let code = editor.getModel(resource)?.getValue() || '';
Expand All @@ -206,14 +211,16 @@ export class CompletionAdapter<T extends BaseSQLWorker>
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) => ({
...item,
insertText: typeof item.body === 'string' ? item.body : item.body.join('\n')
}));
}

return this._defaults.completionService(
model,
position,
Expand All @@ -234,14 +241,17 @@ export class CompletionAdapter<T extends BaseSQLWorker>
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 {
Expand All @@ -252,6 +262,82 @@ export class CompletionAdapter<T extends BaseSQLWorker>
});
}
}

/**
* 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里用 hasFunctionContext 判定具体补全项存在作用域混淆的问题。

hasFunctionContext 代表的是“当前光标位置语法上是否允许出现函数”。在 SQL 中,几乎所有核心表达式位置(如 SELECT | 投影列、WHERE | 条件、ON | 连接条件等)都允许函数调用,因此该标志在这些位置恒为 true。

如果直接用 item.insertText === undefined && isKeyword && hasFunctionContext 判定,会导致在上述位置,所有普通关键字(例如 DISTINCT、CASE、AND、OR 等)都被判定为 isParenFunction = true,从而全部丢失了尾随空格。

更严重的是,下方的 advancesContext 依然判定 (item.insertText === undefined && isKeyword) 为 true 并挂载了 retriggerSuggestCommand,导致用户选中 DISTINCT 后插入无空格裸词并立即在原地重新触发建议框,建议框过滤出 DISTINCT 再次弹窗,导致无法通过回车/Tab 结束补全,陷入死循环。

建议:

  1. 区分真正的函数项与普通关键字(不能仅凭光标位置语法上下文)。若为函数关键字,应转换为带参片段(如 ${name}($1))并标记为 snippet;
  2. 对于不带空格和括号的裸词,advancesContext 绝不能挂载 retriggerSuggestCommand,避免原地重复触发。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个后续配合dt-sql-parser提取Function后再做


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.
**/
Expand Down
23 changes: 22 additions & 1 deletion src/monaco.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -240,7 +260,8 @@ export const modeConfigurationDefault: Required<ModeConfiguration> = {
snippets: [],
enable: true,
completionService: defaultCompletionService,
triggerCharacters: ['.', ' ']
triggerCharacters: ['.', ' '],
continuousCompletion: false
},
diagnostics: true,
definitions: false,
Expand Down
10 changes: 9 additions & 1 deletion src/setupLanguageFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -248,7 +255,8 @@ function processConfiguration(
enable: completionEnable,
completionService,
triggerCharacters,
snippets
snippets,
continuousCompletion
},
references,
definitions,
Expand Down
Loading
Loading