mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-02 00:09:30 +01:00
Merge pull request #294777 from microsoft/pierceboggan/fix-@
@ can be used to reference context, just like #
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Position } from '../../../../../../../editor/common/core/position.js';
|
||||
import { Range } from '../../../../../../../editor/common/core/range.js';
|
||||
import { IWordAtPosition, getWordAtText } from '../../../../../../../editor/common/core/wordHelper.js';
|
||||
import { ITextModel } from '../../../../../../../editor/common/model.js';
|
||||
|
||||
export function escapeForCharClass(text: string): string {
|
||||
return text.replace(/[-\\^\]]/g, '\\$&');
|
||||
}
|
||||
|
||||
export interface IChatCompletionRangeResult {
|
||||
insert: Range;
|
||||
replace: Range;
|
||||
varWord: IWordAtPosition | null;
|
||||
}
|
||||
|
||||
export function computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp, onlyOnWordStart = false): IChatCompletionRangeResult | undefined {
|
||||
const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0);
|
||||
if (!varWord && model.getWordUntilPosition(position).word) {
|
||||
// inside a "normal" word
|
||||
return;
|
||||
}
|
||||
|
||||
if (!varWord && position.column > 1) {
|
||||
const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column));
|
||||
if (textBefore !== ' ') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (varWord && onlyOnWordStart) {
|
||||
const wordBefore = model.getWordUntilPosition({ lineNumber: position.lineNumber, column: varWord.startColumn });
|
||||
if (wordBefore.word) {
|
||||
// inside a word
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let insert: Range;
|
||||
let replace: Range;
|
||||
if (!varWord) {
|
||||
insert = replace = Range.fromPositions(position);
|
||||
} else {
|
||||
insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column);
|
||||
replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn);
|
||||
}
|
||||
|
||||
return { insert, replace, varWord };
|
||||
}
|
||||
|
||||
export function isEmptyUpToCompletionWord(model: ITextModel, rangeResult: IChatCompletionRangeResult): boolean {
|
||||
const startToCompletionWordStart = new Range(1, 1, rangeResult.replace.startLineNumber, rangeResult.replace.startColumn);
|
||||
return !!model.getValueInRange(startToCompletionWordStart).match(/^\s*$/);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { ICodeEditor, getCodeEditor, isCodeEditor } from '../../../../../../../e
|
||||
import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js';
|
||||
import { Position } from '../../../../../../../editor/common/core/position.js';
|
||||
import { Range } from '../../../../../../../editor/common/core/range.js';
|
||||
import { IWordAtPosition, getWordAtText } from '../../../../../../../editor/common/core/wordHelper.js';
|
||||
import { IWordAtPosition } from '../../../../../../../editor/common/core/wordHelper.js';
|
||||
import { CompletionContext, CompletionItem, CompletionItemKind, CompletionItemProvider, CompletionList, DocumentSymbol, Location, ProviderResult, SymbolKind, SymbolKinds } from '../../../../../../../editor/common/languages.js';
|
||||
import { ITextModel } from '../../../../../../../editor/common/model.js';
|
||||
import { ILanguageFeaturesService } from '../../../../../../../editor/common/services/languageFeatures.js';
|
||||
@@ -68,6 +68,7 @@ import { IChatDebugService } from '../../../../common/chatDebugService.js';
|
||||
import { createDebugEventsAttachment } from '../../../chatDebug/chatDebugAttachment.js';
|
||||
import { getPromptFileType } from '../../../../common/promptSyntax/config/promptFileLocations.js';
|
||||
import { getChatSessionType } from '../../../../common/model/chatUri.js';
|
||||
import { computeCompletionRanges, escapeForCharClass, IChatCompletionRangeResult, isEmptyUpToCompletionWord } from './chatInputCompletionUtils.js';
|
||||
import { getAgentSessionProviderIcon, AgentSessionProviders } from '../../../agentSessions/agentSessions.js';
|
||||
|
||||
/**
|
||||
@@ -858,7 +859,7 @@ interface IVariableCompletionsDetails {
|
||||
class BuiltinDynamicCompletions extends Disposable {
|
||||
private static readonly addReferenceCommand = '_addReferenceCmd';
|
||||
private static readonly addDebugEventsSnapshotCommand = '_addDebugEventsSnapshotCmd';
|
||||
private static readonly VariableNameDef = new RegExp(`${chatVariableLeader}[\\w:-]*`, 'g'); // MUST be using `g`-flag
|
||||
private static readonly VariableNameDef = new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][\\w:-]*`, 'g'); // MUST be using `g`-flag
|
||||
|
||||
|
||||
constructor(
|
||||
@@ -880,7 +881,7 @@ class BuiltinDynamicCompletions extends Disposable {
|
||||
super();
|
||||
|
||||
// File/Folder completions in one go and m
|
||||
const fileWordPattern = new RegExp(`${chatVariableLeader}[^\\s]*`, 'g');
|
||||
const fileWordPattern = new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][^\\s]*`, 'g');
|
||||
this.registerVariableCompletions('fileAndFolder', async ({ widget, range }, token) => {
|
||||
if (!widget.supportsFileReferences) {
|
||||
return;
|
||||
@@ -921,15 +922,16 @@ class BuiltinDynamicCompletions extends Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
const typedLeader = range.varWord?.word?.charAt(0) === chatAgentLeader ? chatAgentLeader : chatVariableLeader;
|
||||
const basename = this.labelService.getUriBasenameLabel(currentResource);
|
||||
const text = `${chatVariableLeader}file:${basename}:${currentSelection.startLineNumber}-${currentSelection.endLineNumber}`;
|
||||
const text = `${typedLeader}file:${basename}:${currentSelection.startLineNumber}-${currentSelection.endLineNumber}`;
|
||||
const fullRangeText = `:${currentSelection.startLineNumber}:${currentSelection.startColumn}-${currentSelection.endLineNumber}:${currentSelection.endColumn}`;
|
||||
const description = this.labelService.getUriLabel(currentResource, { relative: true }) + fullRangeText;
|
||||
|
||||
const result: CompletionList = { suggestions: [] };
|
||||
result.suggestions.push({
|
||||
label: { label: `${chatVariableLeader}selection`, description },
|
||||
filterText: `${chatVariableLeader}selection`,
|
||||
label: { label: `${typedLeader}selection`, description },
|
||||
filterText: `${typedLeader}selection`,
|
||||
insertText: range.varWord?.endColumn === range.replace.endColumn ? `${text} ` : text,
|
||||
range,
|
||||
kind: CompletionItemKind.Text,
|
||||
@@ -953,7 +955,7 @@ class BuiltinDynamicCompletions extends Disposable {
|
||||
}
|
||||
|
||||
const result: CompletionList = { suggestions: [] };
|
||||
const range2 = computeCompletionRanges(model, position, new RegExp(`${chatVariableLeader}[^\\s]*`, 'g'), true);
|
||||
const range2 = computeCompletionRanges(model, position, new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][^\\s]*`, 'g'), true);
|
||||
if (range2) {
|
||||
this.addSymbolEntries(widget, result, range2, token);
|
||||
}
|
||||
@@ -1103,7 +1105,7 @@ class BuiltinDynamicCompletions extends Disposable {
|
||||
private registerVariableCompletions(debugName: string, provider: (details: IVariableCompletionsDetails, token: CancellationToken) => ProviderResult<CompletionList>, wordPattern: RegExp = BuiltinDynamicCompletions.VariableNameDef) {
|
||||
this._register(this.languageFeaturesService.completionProvider.register({ scheme: Schemas.vscodeChatInput, hasAccessToAllModels: true }, {
|
||||
_debugDisplayName: `chatVarCompletions-${debugName}`,
|
||||
triggerCharacters: [chatVariableLeader],
|
||||
triggerCharacters: [chatVariableLeader, chatAgentLeader],
|
||||
provideCompletionItems: async (model: ITextModel, position: Position, context: CompletionContext, token: CancellationToken) => {
|
||||
const widget = this.chatWidgetService.getWidgetByInputUri(model.uri);
|
||||
if (!widget) {
|
||||
@@ -1124,9 +1126,11 @@ class BuiltinDynamicCompletions extends Disposable {
|
||||
|
||||
private async addFileAndFolderEntries(widget: IChatWidget, result: CompletionList, info: { insert: Range; replace: Range; varWord: IWordAtPosition | null }, token: CancellationToken) {
|
||||
|
||||
const typedLeader = info.varWord?.word?.charAt(0) === chatAgentLeader ? chatAgentLeader : chatVariableLeader;
|
||||
|
||||
const makeCompletionItem = (resource: URI, kind: FileKind, description?: string, boostPriority?: boolean): CompletionItem => {
|
||||
const basename = this.labelService.getUriBasenameLabel(resource);
|
||||
const text = `${chatVariableLeader}file:${basename}`;
|
||||
const text = `${typedLeader}file:${basename}`;
|
||||
const uriLabel = this.labelService.getUriLabel(resource, { relative: true });
|
||||
const labelDescription = description
|
||||
? localize('fileEntryDescription', '{0} ({1})', uriLabel, description)
|
||||
@@ -1136,7 +1140,7 @@ class BuiltinDynamicCompletions extends Disposable {
|
||||
|
||||
return {
|
||||
label: { label: basename, description: labelDescription },
|
||||
filterText: `${chatVariableLeader}${basename}`,
|
||||
filterText: `${basename} ${typedLeader}${basename} ${uriLabel}`,
|
||||
insertText: info.varWord?.endColumn === info.replace.endColumn ? `${text} ` : text,
|
||||
range: info,
|
||||
kind: kind === FileKind.FILE ? CompletionItemKind.File : CompletionItemKind.Folder,
|
||||
@@ -1154,8 +1158,8 @@ class BuiltinDynamicCompletions extends Disposable {
|
||||
};
|
||||
|
||||
let pattern: string | undefined;
|
||||
if (info.varWord?.word && info.varWord.word.startsWith(chatVariableLeader)) {
|
||||
pattern = info.varWord.word.toLowerCase().slice(1); // remove leading #
|
||||
if (info.varWord?.word && (info.varWord.word.startsWith(chatVariableLeader) || info.varWord.word.startsWith(chatAgentLeader))) {
|
||||
pattern = info.varWord.word.toLowerCase().slice(1); // remove leading # or @
|
||||
}
|
||||
|
||||
const seen = new ResourceSet();
|
||||
@@ -1172,8 +1176,10 @@ class BuiltinDynamicCompletions extends Disposable {
|
||||
|
||||
if (pattern) {
|
||||
// use pattern if available
|
||||
const uriLabel = this.labelService.getUriLabel(resource, { relative: true }).toLowerCase();
|
||||
const basename = this.labelService.getUriBasenameLabel(resource).toLowerCase();
|
||||
if (!isPatternInWord(pattern, 0, pattern.length, basename, 0, basename.length)) {
|
||||
const combined = `${basename} ${uriLabel}`;
|
||||
if (!isPatternInWord(pattern, 0, pattern.length, combined, 0, combined.length)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -1218,15 +1224,17 @@ class BuiltinDynamicCompletions extends Disposable {
|
||||
const timeoutMs = 100;
|
||||
const stopwatch = new StopWatch();
|
||||
|
||||
const typedLeader = info.varWord?.word?.charAt(0) === chatAgentLeader ? chatAgentLeader : chatVariableLeader;
|
||||
|
||||
const makeSymbolCompletionItem = (symbolItem: { name: string; location: Location; kind: SymbolKind }, pattern: string): CompletionItem => {
|
||||
const text = `${chatVariableLeader}sym:${symbolItem.name}`;
|
||||
const text = `${typedLeader}sym:${symbolItem.name}`;
|
||||
const resource = symbolItem.location.uri;
|
||||
const uriLabel = this.labelService.getUriLabel(resource, { relative: true });
|
||||
const sortText = pattern ? '{' /* after z */ : '|' /* after { */;
|
||||
|
||||
return {
|
||||
label: { label: symbolItem.name, description: uriLabel },
|
||||
filterText: `${chatVariableLeader}${symbolItem.name}`,
|
||||
filterText: `${typedLeader}${symbolItem.name}`,
|
||||
insertText: info.varWord?.endColumn === info.replace.endColumn ? `${text} ` : text,
|
||||
range: info,
|
||||
kind: SymbolKinds.toCompletionKind(symbolItem.kind),
|
||||
@@ -1244,8 +1252,8 @@ class BuiltinDynamicCompletions extends Disposable {
|
||||
};
|
||||
|
||||
let pattern: string | undefined;
|
||||
if (info.varWord?.word && info.varWord.word.startsWith(chatVariableLeader)) {
|
||||
pattern = info.varWord.word.toLowerCase().slice(1); // remove leading #
|
||||
if (info.varWord?.word && (info.varWord.word.startsWith(chatVariableLeader) || info.varWord.word.startsWith(chatAgentLeader))) {
|
||||
pattern = info.varWord.word.toLowerCase().slice(1); // remove leading # or @
|
||||
}
|
||||
|
||||
const symbolsToAdd: { symbol: DocumentSymbol; uri: URI }[] = [];
|
||||
@@ -1295,54 +1303,9 @@ class BuiltinDynamicCompletions extends Disposable {
|
||||
|
||||
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(BuiltinDynamicCompletions, LifecyclePhase.Eventually);
|
||||
|
||||
export interface IChatCompletionRangeResult {
|
||||
insert: Range;
|
||||
replace: Range;
|
||||
varWord: IWordAtPosition | null;
|
||||
}
|
||||
|
||||
export function computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp, onlyOnWordStart = false): IChatCompletionRangeResult | undefined {
|
||||
const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0);
|
||||
if (!varWord && model.getWordUntilPosition(position).word) {
|
||||
// inside a "normal" word
|
||||
return;
|
||||
}
|
||||
|
||||
if (!varWord && position.column > 1) {
|
||||
const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column));
|
||||
if (textBefore !== ' ') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (varWord && onlyOnWordStart) {
|
||||
const wordBefore = model.getWordUntilPosition({ lineNumber: position.lineNumber, column: varWord.startColumn });
|
||||
if (wordBefore.word) {
|
||||
// inside a word
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let insert: Range;
|
||||
let replace: Range;
|
||||
if (!varWord) {
|
||||
insert = replace = Range.fromPositions(position);
|
||||
} else {
|
||||
insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column);
|
||||
replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn);
|
||||
}
|
||||
|
||||
return { insert, replace, varWord };
|
||||
}
|
||||
|
||||
function isEmptyUpToCompletionWord(model: ITextModel, rangeResult: IChatCompletionRangeResult): boolean {
|
||||
const startToCompletionWordStart = new Range(1, 1, rangeResult.replace.startLineNumber, rangeResult.replace.startColumn);
|
||||
return !!model.getValueInRange(startToCompletionWordStart).match(/^\s*$/);
|
||||
}
|
||||
|
||||
class ToolCompletions extends Disposable {
|
||||
|
||||
private static readonly VariableNameDef = new RegExp(`(?<=^|\\s)${chatVariableLeader}\\w*`, 'g'); // MUST be using `g`-flag
|
||||
private static readonly VariableNameDef = new RegExp(`(?<=^|\\s)[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}]\\w*`, 'g'); // MUST be using `g`-flag
|
||||
|
||||
constructor(
|
||||
@ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService,
|
||||
@@ -1353,7 +1316,7 @@ class ToolCompletions extends Disposable {
|
||||
|
||||
this._register(this.languageFeaturesService.completionProvider.register({ scheme: Schemas.vscodeChatInput, hasAccessToAllModels: true }, {
|
||||
_debugDisplayName: 'chatVariables',
|
||||
triggerCharacters: [chatVariableLeader],
|
||||
triggerCharacters: [chatVariableLeader, chatAgentLeader],
|
||||
provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => {
|
||||
const widget = this.chatWidgetService.getWidgetByInputUri(model.uri);
|
||||
if (!widget) {
|
||||
@@ -1383,6 +1346,8 @@ class ToolCompletions extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
const typedLeader = range.varWord?.word?.charAt(0) === chatAgentLeader ? chatAgentLeader : chatVariableLeader;
|
||||
const pattern = range.varWord?.word ? range.varWord.word.toLowerCase().slice(1) : '';
|
||||
const suggestions: CompletionItem[] = [];
|
||||
|
||||
|
||||
@@ -1412,12 +1377,20 @@ class ToolCompletions extends Disposable {
|
||||
continue;
|
||||
}
|
||||
|
||||
const withLeader = `${chatVariableLeader}${name}`;
|
||||
if (pattern) {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (!isPatternInWord(pattern, 0, pattern.length, lowerName, 0, lowerName.length)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const withLeader = `${typedLeader}${name}`;
|
||||
suggestions.push({
|
||||
label: withLeader,
|
||||
range,
|
||||
detail,
|
||||
documentation,
|
||||
filterText: `${typedLeader}${name}`,
|
||||
insertText: withLeader + ' ',
|
||||
kind: CompletionItemKind.Tool,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { URI } from '../../../../../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../../base/test/common/utils.js';
|
||||
import { Position } from '../../../../../../../../editor/common/core/position.js';
|
||||
import { Range } from '../../../../../../../../editor/common/core/range.js';
|
||||
import { createTextModel } from '../../../../../../../../editor/test/common/testTextModel.js';
|
||||
import { DisposableStore } from '../../../../../../../../base/common/lifecycle.js';
|
||||
import { computeCompletionRanges, escapeForCharClass } from '../../../../../browser/widget/input/editor/chatInputCompletionUtils.js';
|
||||
import { chatAgentLeader, chatVariableLeader } from '../../../../../common/requestParser/chatParserTypes.js';
|
||||
|
||||
suite('escapeForCharClass', () => {
|
||||
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('passes through simple characters unchanged', () => {
|
||||
assert.strictEqual(escapeForCharClass('a'), 'a');
|
||||
assert.strictEqual(escapeForCharClass('#'), '#');
|
||||
assert.strictEqual(escapeForCharClass('@'), '@');
|
||||
});
|
||||
|
||||
test('escapes backslash', () => {
|
||||
assert.strictEqual(escapeForCharClass('\\'), '\\\\');
|
||||
});
|
||||
|
||||
test('escapes closing bracket', () => {
|
||||
assert.strictEqual(escapeForCharClass(']'), '\\]');
|
||||
});
|
||||
|
||||
test('escapes caret', () => {
|
||||
assert.strictEqual(escapeForCharClass('^'), '\\^');
|
||||
});
|
||||
|
||||
test('escapes hyphen', () => {
|
||||
assert.strictEqual(escapeForCharClass('-'), '\\-');
|
||||
});
|
||||
|
||||
test('escapes multiple special chars in one string', () => {
|
||||
assert.strictEqual(escapeForCharClass('-^]\\'), '\\-\\^\\]\\\\');
|
||||
});
|
||||
|
||||
test('is safe to use for chatVariableLeader and chatAgentLeader', () => {
|
||||
// These are the actual values used in the product code
|
||||
const escaped = `[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}]`;
|
||||
const re = new RegExp(escaped);
|
||||
assert.ok(re.test('#'));
|
||||
assert.ok(re.test('@'));
|
||||
assert.ok(!re.test('a'));
|
||||
assert.ok(!re.test('/'));
|
||||
});
|
||||
});
|
||||
|
||||
suite('computeCompletionRanges', () => {
|
||||
|
||||
let store: DisposableStore;
|
||||
|
||||
setup(() => {
|
||||
store = new DisposableStore();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
// Helper: builds the same regex patterns used in the product code
|
||||
function variableNameDef() {
|
||||
return new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][\\w:-]*`, 'g');
|
||||
}
|
||||
|
||||
function fileWordPattern() {
|
||||
return new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][^\\s]*`, 'g');
|
||||
}
|
||||
|
||||
function toolVariableNameDef() {
|
||||
return new RegExp(`(?<=^|\\s)[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}]\\w*`, 'g');
|
||||
}
|
||||
|
||||
// --- VariableNameDef pattern tests ---
|
||||
|
||||
suite('with VariableNameDef regex', () => {
|
||||
|
||||
test('matches #variable at start of line', () => {
|
||||
const model = store.add(createTextModel('#file', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 6), variableNameDef());
|
||||
assert.ok(result);
|
||||
assert.deepStrictEqual(result, {
|
||||
insert: new Range(1, 1, 1, 6),
|
||||
replace: new Range(1, 1, 1, 6),
|
||||
varWord: { word: '#file', startColumn: 1, endColumn: 6 },
|
||||
});
|
||||
});
|
||||
|
||||
test('matches @variable at start of line', () => {
|
||||
const model = store.add(createTextModel('@file', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 6), variableNameDef());
|
||||
assert.ok(result);
|
||||
assert.deepStrictEqual(result, {
|
||||
insert: new Range(1, 1, 1, 6),
|
||||
replace: new Range(1, 1, 1, 6),
|
||||
varWord: { word: '@file', startColumn: 1, endColumn: 6 },
|
||||
});
|
||||
});
|
||||
|
||||
test('matches #variable mid-line after space', () => {
|
||||
const model = store.add(createTextModel('hello #file', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 12), variableNameDef());
|
||||
assert.ok(result);
|
||||
assert.deepStrictEqual(result, {
|
||||
insert: new Range(1, 7, 1, 12),
|
||||
replace: new Range(1, 7, 1, 12),
|
||||
varWord: { word: '#file', startColumn: 7, endColumn: 12 },
|
||||
});
|
||||
});
|
||||
|
||||
test('matches @variable mid-line after space', () => {
|
||||
const model = store.add(createTextModel('hello @file', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 12), variableNameDef());
|
||||
assert.ok(result);
|
||||
assert.deepStrictEqual(result, {
|
||||
insert: new Range(1, 7, 1, 12),
|
||||
replace: new Range(1, 7, 1, 12),
|
||||
varWord: { word: '@file', startColumn: 7, endColumn: 12 },
|
||||
});
|
||||
});
|
||||
|
||||
test('matches # alone (just the leader)', () => {
|
||||
const model = store.add(createTextModel('#', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 2), variableNameDef());
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord?.word, '#');
|
||||
});
|
||||
|
||||
test('matches @ alone (just the leader)', () => {
|
||||
const model = store.add(createTextModel('@', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 2), variableNameDef());
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord?.word, '@');
|
||||
});
|
||||
|
||||
test('matches variable with colons and hyphens', () => {
|
||||
const model = store.add(createTextModel('#file:test-1', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 13), variableNameDef());
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord?.word, '#file:test-1');
|
||||
});
|
||||
|
||||
test('cursor in middle of variable produces partial insert range', () => {
|
||||
const model = store.add(createTextModel('@selection', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 5), variableNameDef());
|
||||
assert.ok(result);
|
||||
assert.deepStrictEqual(result, {
|
||||
insert: new Range(1, 1, 1, 5),
|
||||
replace: new Range(1, 1, 1, 11),
|
||||
varWord: { word: '@selection', startColumn: 1, endColumn: 11 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- fileWordPattern tests ---
|
||||
|
||||
suite('with fileWordPattern regex', () => {
|
||||
|
||||
test('matches #file:path/to/file.ts', () => {
|
||||
const model = store.add(createTextModel('#file:path/to/file.ts', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 22), fileWordPattern());
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord?.word, '#file:path/to/file.ts');
|
||||
});
|
||||
|
||||
test('matches @file:path/to/file.ts', () => {
|
||||
const model = store.add(createTextModel('@file:path/to/file.ts', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 22), fileWordPattern());
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord?.word, '@file:path/to/file.ts');
|
||||
});
|
||||
|
||||
test('stops at whitespace', () => {
|
||||
const model = store.add(createTextModel('#file:test rest', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 11), fileWordPattern());
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord?.word, '#file:test');
|
||||
});
|
||||
});
|
||||
|
||||
// --- toolVariableNameDef tests ---
|
||||
|
||||
suite('with toolVariableNameDef regex', () => {
|
||||
|
||||
test('matches #tool at start of line', () => {
|
||||
const model = store.add(createTextModel('#tool', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 6), toolVariableNameDef());
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord?.word, '#tool');
|
||||
});
|
||||
|
||||
test('matches @tool at start of line', () => {
|
||||
const model = store.add(createTextModel('@tool', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 6), toolVariableNameDef());
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord?.word, '@tool');
|
||||
});
|
||||
|
||||
test('matches #tool after space', () => {
|
||||
const model = store.add(createTextModel('use #fetch', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 11), toolVariableNameDef());
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord?.word, '#fetch');
|
||||
});
|
||||
|
||||
test('matches @tool after space', () => {
|
||||
const model = store.add(createTextModel('use @fetch', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 11), toolVariableNameDef());
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord?.word, '@fetch');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Edge cases ---
|
||||
|
||||
suite('edge cases', () => {
|
||||
|
||||
test('returns undefined inside a normal word', () => {
|
||||
const model = store.add(createTextModel('hello', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 3), variableNameDef());
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
|
||||
test('returns undefined when no space before cursor mid-line', () => {
|
||||
const model = store.add(createTextModel('ab', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 3), variableNameDef());
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
|
||||
test('returns empty range at blank position after space', () => {
|
||||
const model = store.add(createTextModel('hello ', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 7), variableNameDef());
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord, null);
|
||||
assert.deepStrictEqual(result.insert, Range.fromPositions(new Position(1, 7)));
|
||||
});
|
||||
|
||||
test('returns empty range at start of empty line', () => {
|
||||
const model = store.add(createTextModel('', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 1), variableNameDef());
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord, null);
|
||||
});
|
||||
|
||||
test('onlyOnWordStart=true rejects variable preceded by a word', () => {
|
||||
const model = store.add(createTextModel('abc#file', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 9), variableNameDef(), true);
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
|
||||
test('onlyOnWordStart=true accepts variable after space', () => {
|
||||
const model = store.add(createTextModel('abc #file', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 10), variableNameDef(), true);
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord?.word, '#file');
|
||||
});
|
||||
|
||||
test('onlyOnWordStart=true accepts @variable after space', () => {
|
||||
const model = store.add(createTextModel('abc @file', null, undefined, URI.parse('test:input')));
|
||||
const result = computeCompletionRanges(model, new Position(1, 10), variableNameDef(), true);
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.varWord?.word, '@file');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ import { IEditorService } from '../../../../../services/editor/common/editorServ
|
||||
import { IChatWidget, IChatWidgetService } from '../../../../chat/browser/chat.js';
|
||||
import { IChatContextPicker, IChatContextPickerItem, IChatContextPickerPickItem, IChatContextPickService } from '../../../../chat/browser/attachments/chatContextPickService.js';
|
||||
import { ChatDynamicVariableModel } from '../../../../chat/browser/attachments/chatDynamicVariables.js';
|
||||
import { computeCompletionRanges } from '../../../../chat/browser/widget/input/editor/chatInputCompletions.js';
|
||||
import { computeCompletionRanges } from '../../../../chat/browser/widget/input/editor/chatInputCompletionUtils.js';
|
||||
import { IChatAgentService } from '../../../../chat/common/participants/chatAgents.js';
|
||||
import { ChatContextKeys } from '../../../../chat/common/actions/chatContextKeys.js';
|
||||
import { chatVariableLeader } from '../../../../chat/common/requestParser/chatParserTypes.js';
|
||||
|
||||
Reference in New Issue
Block a user