Skip to content

Commit c63042e

Browse files
authored
Merge pull request #227 from liuxy0551/fix_165
fix: #165 disable completion in comments
2 parents f5378e1 + 596e5dc commit c63042e

4 files changed

Lines changed: 272 additions & 1 deletion

File tree

src/languageFeatures.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,33 @@ function toDiagnostics(_resource: Uri, diag: ParseError): editor.IMarkerData {
145145
};
146146
}
147147

148+
interface TokenizationTextModel {
149+
tokenization?: {
150+
tokenizeLinesAt?: (
151+
lineNumber: number,
152+
lines: string[]
153+
) => Array<{
154+
findTokenIndexAtOffset: (offset: number) => number;
155+
getStandardTokenType: (tokenIndex: number) => number;
156+
}> | null;
157+
};
158+
}
159+
160+
function isPositionInComment(model: editor.IReadOnlyModel, position: Position): boolean {
161+
const lineContent = model.getLineContent(position.lineNumber);
162+
const tokenizationModel = model as editor.IReadOnlyModel & TokenizationTextModel;
163+
// 在光标处追加哨兵字符,避免将刚结束的块注释误判为仍在注释中
164+
const tokenizedLine = tokenizationModel.tokenization?.tokenizeLinesAt?.(position.lineNumber, [
165+
`${lineContent.slice(0, position.column - 1)}x`
166+
])?.[0];
167+
if (!tokenizedLine) {
168+
return false;
169+
}
170+
171+
const tokenIndex = tokenizedLine.findTokenIndexAtOffset(position.column - 1);
172+
return tokenizedLine.getStandardTokenType(tokenIndex) === 1;
173+
}
174+
148175
export class CompletionAdapter<T extends BaseSQLWorker>
149176
implements languages.CompletionItemProvider
150177
{
@@ -165,6 +192,10 @@ export class CompletionAdapter<T extends BaseSQLWorker>
165192
context: languages.CompletionContext,
166193
_token: CancellationToken
167194
): Promise<languages.CompletionList> {
195+
if (isPositionInComment(model, position)) {
196+
return Promise.resolve({ suggestions: [] });
197+
}
198+
168199
const resource = model.uri;
169200
return this._worker(resource)
170201
.then((worker) => {

src/test/languageFeatures.test.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import * as assert from 'assert';
2+
3+
import { BaseSQLWorker } from '../baseSQLWorker';
4+
import { LanguageIdEnum } from '../common/constants';
5+
import {
6+
CancellationTokenSource,
7+
editor,
8+
languages,
9+
Position
10+
} from '../fillers/monaco-editor-core';
11+
import { CompletionAdapter, WorkerAccessor } from '../languageFeatures';
12+
import { language as flinkLanguage } from '../languages/flink/flink';
13+
import { language as genericLanguage } from '../languages/generic/generic';
14+
import { language as hiveLanguage } from '../languages/hive/hive';
15+
import { language as impalaLanguage } from '../languages/impala/impala';
16+
import { language as mysqlLanguage } from '../languages/mysql/mysql';
17+
import { language as pgsqlLanguage } from '../languages/pgsql/pgsql';
18+
import { language as sparkLanguage } from '../languages/spark/spark';
19+
import { language as trinoLanguage } from '../languages/trino/trino';
20+
import { LanguageServiceDefaultsImpl, modeConfigurationDefault } from '../monaco.contribution';
21+
22+
const SQL_DIALECTS = [
23+
{ name: LanguageIdEnum.FLINK, language: flinkLanguage },
24+
{ name: LanguageIdEnum.HIVE, language: hiveLanguage },
25+
{ name: LanguageIdEnum.MYSQL, language: mysqlLanguage },
26+
{ name: LanguageIdEnum.PG, language: pgsqlLanguage },
27+
{ name: LanguageIdEnum.SPARK, language: sparkLanguage },
28+
{ name: LanguageIdEnum.TRINO, language: trinoLanguage },
29+
{ name: LanguageIdEnum.IMPALA, language: impalaLanguage },
30+
{ name: LanguageIdEnum.GENERIC, language: genericLanguage }
31+
].map((dialect) => ({
32+
...dialect,
33+
languageId: `${dialect.name}-completion-test`
34+
}));
35+
const MYSQL_DIALECT = SQL_DIALECTS.find(({ name }) => name === LanguageIdEnum.MYSQL)!;
36+
37+
SQL_DIALECTS.forEach((dialect) => {
38+
languages.register({ id: dialect.languageId });
39+
languages.setMonarchTokensProvider(dialect.languageId, dialect.language);
40+
});
41+
42+
interface CompletionResult {
43+
suggestions: languages.CompletionItem[];
44+
workerCallCount: number;
45+
}
46+
47+
function getEndPosition(value: string): Position {
48+
const lines = value.split('\n');
49+
return new Position(lines.length, lines[lines.length - 1].length + 1);
50+
}
51+
52+
async function provideCompletionItems(
53+
languageId: string,
54+
value: string,
55+
position: Position = getEndPosition(value)
56+
): Promise<CompletionResult> {
57+
const model = editor.createModel(value, languageId);
58+
const cancellationTokenSource = new CancellationTokenSource();
59+
let workerCallCount = 0;
60+
const worker: WorkerAccessor<BaseSQLWorker> = async () => {
61+
workerCallCount++;
62+
return {
63+
doCompletionWithEntities: async () => ({
64+
suggestions: {
65+
syntax: [],
66+
keywords: ['SELECT']
67+
},
68+
allEntities: null,
69+
context: null
70+
})
71+
} as unknown as BaseSQLWorker;
72+
};
73+
const defaults = new LanguageServiceDefaultsImpl(languageId, modeConfigurationDefault);
74+
const adapter = new CompletionAdapter(worker, defaults);
75+
76+
try {
77+
const completionList = await adapter.provideCompletionItems(
78+
model,
79+
position,
80+
{ triggerKind: languages.CompletionTriggerKind.Invoke },
81+
cancellationTokenSource.token
82+
);
83+
84+
return {
85+
suggestions: completionList.suggestions,
86+
workerCallCount
87+
};
88+
} finally {
89+
cancellationTokenSource.dispose();
90+
model.dispose();
91+
}
92+
}
93+
94+
SQL_DIALECTS.forEach((dialect) => {
95+
test(`does not provide ${dialect.name} completion items inside a line comment`, async () => {
96+
const result = await provideCompletionItems(dialect.languageId, 'SELECT 1 -- comment');
97+
98+
assert.deepStrictEqual(result.suggestions, []);
99+
assert.strictEqual(result.workerCallCount, 0);
100+
});
101+
102+
test(`does not provide ${dialect.name} completion items inside a multiline block comment`, async () => {
103+
const result = await provideCompletionItems(
104+
dialect.languageId,
105+
'SELECT /* comment\nstill comment */',
106+
new Position(2, 6)
107+
);
108+
109+
assert.deepStrictEqual(result.suggestions, []);
110+
assert.strictEqual(result.workerCallCount, 0);
111+
});
112+
});
113+
114+
test('does not provide completion items after a line comment marker', async () => {
115+
const result = await provideCompletionItems(MYSQL_DIALECT.languageId, '--');
116+
117+
assert.deepStrictEqual(result.suggestions, []);
118+
assert.strictEqual(result.workerCallCount, 0);
119+
});
120+
121+
test('does not provide completion items inside a MySQL hash comment', async () => {
122+
const result = await provideCompletionItems(MYSQL_DIALECT.languageId, '# comment');
123+
124+
assert.deepStrictEqual(result.suggestions, []);
125+
assert.strictEqual(result.workerCallCount, 0);
126+
});
127+
128+
test('does not provide completion items inside a block comment', async () => {
129+
const result = await provideCompletionItems(
130+
MYSQL_DIALECT.languageId,
131+
'/* comment */',
132+
new Position(1, 4)
133+
);
134+
135+
assert.deepStrictEqual(result.suggestions, []);
136+
assert.strictEqual(result.workerCallCount, 0);
137+
});
138+
139+
test('provides completion items after a closed block comment', async () => {
140+
const result = await provideCompletionItems(MYSQL_DIALECT.languageId, 'SELECT /* comment */');
141+
142+
assert.deepStrictEqual(
143+
result.suggestions.map((item) => item.label),
144+
['SELECT']
145+
);
146+
assert.strictEqual(result.workerCallCount, 1);
147+
});
148+
149+
test('does not treat comment markers inside strings as comments', async () => {
150+
const result = await provideCompletionItems(MYSQL_DIALECT.languageId, "SELECT '--'");
151+
152+
assert.deepStrictEqual(
153+
result.suggestions.map((item) => item.label),
154+
['SELECT']
155+
);
156+
assert.strictEqual(result.workerCallCount, 1);
157+
});
158+
159+
test('does not treat a single minus sign as a comment', async () => {
160+
const result = await provideCompletionItems(MYSQL_DIALECT.languageId, '-');
161+
162+
assert.deepStrictEqual(
163+
result.suggestions.map((item) => item.label),
164+
['SELECT']
165+
);
166+
assert.strictEqual(result.workerCallCount, 1);
167+
});
168+
169+
test('checks comment state without reading the whole document prefix', async () => {
170+
const value = 'SELECT 1;\n-- comment';
171+
const model = editor.createModel(value, MYSQL_DIALECT.languageId);
172+
const position = getEndPosition(value);
173+
const originalGetValueInRange = model.getValueInRange.bind(model);
174+
const cancellationTokenSource = new CancellationTokenSource();
175+
const defaults = new LanguageServiceDefaultsImpl(
176+
MYSQL_DIALECT.languageId,
177+
modeConfigurationDefault
178+
);
179+
const adapter = new CompletionAdapter(
180+
async () =>
181+
({
182+
doCompletionWithEntities: async () => ({
183+
suggestions: {
184+
syntax: [],
185+
keywords: ['SELECT']
186+
},
187+
allEntities: null,
188+
context: null
189+
})
190+
}) as unknown as BaseSQLWorker,
191+
defaults
192+
);
193+
194+
model.getValueInRange = ((range, eol) => {
195+
if (
196+
range.startLineNumber === 1 &&
197+
range.startColumn === 1 &&
198+
(range.endLineNumber > 1 || range.endColumn > 1)
199+
) {
200+
throw new Error('comment detection should not read the whole document prefix');
201+
}
202+
203+
return originalGetValueInRange(range, eol);
204+
}) as typeof model.getValueInRange;
205+
206+
try {
207+
const completionList = await adapter.provideCompletionItems(
208+
model,
209+
position,
210+
{ triggerKind: languages.CompletionTriggerKind.Invoke },
211+
cancellationTokenSource.token
212+
);
213+
214+
assert.deepStrictEqual(completionList.suggestions, []);
215+
} finally {
216+
cancellationTokenSource.dispose();
217+
model.dispose();
218+
}
219+
});
220+
221+
test('keeps the existing completion flow for regular SQL', async () => {
222+
const result = await provideCompletionItems(MYSQL_DIALECT.languageId, 'SELECT ');
223+
224+
assert.deepStrictEqual(
225+
result.suggestions.map((item) => item.label),
226+
['SELECT']
227+
);
228+
assert.strictEqual(result.workerCallCount, 1);
229+
});

test/all.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ requirejs(
103103
function () {
104104
let files;
105105
try {
106-
files = glob.sync('out/amd/languages/*/*.test.js', {
106+
files = glob.sync(['out/amd/languages/*/*.test.js', 'out/amd/test/*.test.js'], {
107107
cwd: path.dirname(__dirname),
108108
dot: true
109109
});

test/setup.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ define('vs/nls', [], {
2929
}
3030
});
3131

32+
define('dt-sql-parser', [], {
33+
EntityContextType: {
34+
TABLE: 'table',
35+
TABLE_CREATE: 'tableCreate'
36+
}
37+
});
38+
39+
define('monaco-editor', ['vs/editor/editor.main'], function (api) {
40+
return api.m || api;
41+
});
42+
3243
define(['vs/editor/editor.main'], function (api) {
3344
// Monaco Editor 0.54.0+ exports as api.m instead of api directly
3445
const monaco = api.m || api;

0 commit comments

Comments
 (0)