Skip to content

Commit 89ceb4b

Browse files
author
Andy
authored
Support completions that require changing from dot to bracket access (#20547)
* Support completions that require changing from dot to bracket access * Use insertText and replacementSpan * Rename includeBracketCompletions to includeInsertTextCompletions * Don't add completions that start with space
1 parent 73e3e8d commit 89ceb4b

19 files changed

+343
-290
lines changed

src/harness/fourslash.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@ namespace FourSlash {
511511
}
512512
}
513513

514-
private raiseError(message: string) {
514+
private raiseError(message: string): never {
515515
throw new Error(this.messageAtLastKnownMarker(message));
516516
}
517517

@@ -848,10 +848,10 @@ namespace FourSlash {
848848
}
849849
}
850850

851-
public verifyCompletionsAt(markerName: string, expected: string[], options?: FourSlashInterface.CompletionsAtOptions) {
851+
public verifyCompletionsAt(markerName: string, expected: ReadonlyArray<FourSlashInterface.ExpectedCompletionEntry>, options?: FourSlashInterface.CompletionsAtOptions) {
852852
this.goToMarker(markerName);
853853

854-
const actualCompletions = this.getCompletionListAtCaret();
854+
const actualCompletions = this.getCompletionListAtCaret(options);
855855
if (!actualCompletions) {
856856
this.raiseError(`No completions at position '${this.currentCaretPosition}'.`);
857857
}
@@ -867,9 +867,20 @@ namespace FourSlash {
867867
}
868868

869869
ts.zipWith(actual, expected, (completion, expectedCompletion, index) => {
870-
if (completion.name !== expectedCompletion) {
870+
const { name, insertText, replacementSpan } = typeof expectedCompletion === "string" ? { name: expectedCompletion, insertText: undefined, replacementSpan: undefined } : expectedCompletion;
871+
if (completion.name !== name) {
871872
this.raiseError(`Expected completion at index ${index} to be ${expectedCompletion}, got ${completion.name}`);
872873
}
874+
if (completion.insertText !== insertText) {
875+
this.raiseError(`Expected completion insert text at index ${index} to be ${insertText}, got ${completion.insertText}`);
876+
}
877+
const convertedReplacementSpan = replacementSpan && textSpanFromRange(replacementSpan);
878+
try {
879+
assert.deepEqual(completion.replacementSpan, convertedReplacementSpan);
880+
}
881+
catch {
882+
this.raiseError(`Expected completion replacementSpan at index ${index} to be ${stringify(convertedReplacementSpan)}, got ${stringify(completion.replacementSpan)}`);
883+
}
873884
});
874885
}
875886

@@ -1808,7 +1819,7 @@ Actual: ${stringify(fullActual)}`);
18081819
}
18091820
else if (prevChar === " " && /A-Za-z_/.test(ch)) {
18101821
/* Completions */
1811-
this.languageService.getCompletionsAtPosition(this.activeFile.fileName, offset, { includeExternalModuleExports: false });
1822+
this.languageService.getCompletionsAtPosition(this.activeFile.fileName, offset, { includeExternalModuleExports: false, includeInsertTextCompletions: false });
18121823
}
18131824

18141825
if (i % checkCadence === 0) {
@@ -2383,7 +2394,8 @@ Actual: ${stringify(fullActual)}`);
23832394
public applyCodeActionFromCompletion(markerName: string, options: FourSlashInterface.VerifyCompletionActionOptions) {
23842395
this.goToMarker(markerName);
23852396

2386-
const actualCompletion = this.getCompletionListAtCaret({ includeExternalModuleExports: true }).entries.find(e => e.name === options.name && e.source === options.source);
2397+
const actualCompletion = this.getCompletionListAtCaret({ includeExternalModuleExports: true, includeInsertTextCompletions: false }).entries.find(e =>
2398+
e.name === options.name && e.source === options.source);
23872399

23882400
if (!actualCompletion.hasAction) {
23892401
this.raiseError(`Completion for ${options.name} does not have an associated action.`);
@@ -3195,8 +3207,7 @@ Actual: ${stringify(fullActual)}`);
31953207
private getTextSpanForRangeAtIndex(index: number): ts.TextSpan {
31963208
const ranges = this.getRanges();
31973209
if (ranges && ranges.length > index) {
3198-
const range = ranges[index];
3199-
return { start: range.start, length: range.end - range.start };
3210+
return textSpanFromRange(ranges[index]);
32003211
}
32013212
else {
32023213
this.raiseError("Supplied span index: " + index + " does not exist in range list of size: " + (ranges ? 0 : ranges.length));
@@ -3226,6 +3237,10 @@ Actual: ${stringify(fullActual)}`);
32263237
}
32273238
}
32283239

3240+
function textSpanFromRange(range: FourSlash.Range): ts.TextSpan {
3241+
return ts.createTextSpanFromBounds(range.start, range.end);
3242+
}
3243+
32293244
export function runFourSlashTest(basePath: string, testType: FourSlashTestType, fileName: string) {
32303245
const content = Harness.IO.readFile(fileName);
32313246
runFourSlashTestContent(basePath, testType, content, fileName);
@@ -3967,7 +3982,7 @@ namespace FourSlashInterface {
39673982
super(state);
39683983
}
39693984

3970-
public completionsAt(markerName: string, completions: string[], options?: CompletionsAtOptions) {
3985+
public completionsAt(markerName: string, completions: ReadonlyArray<ExpectedCompletionEntry>, options?: CompletionsAtOptions) {
39713986
this.state.verifyCompletionsAt(markerName, completions, options);
39723987
}
39733988

@@ -4591,6 +4606,7 @@ namespace FourSlashInterface {
45914606
newContent: string;
45924607
}
45934608

4609+
export type ExpectedCompletionEntry = string | { name: string, insertText?: string, replacementSpan?: FourSlash.Range };
45944610
export interface CompletionsAtOptions extends ts.GetCompletionsAtPositionOptions {
45954611
isNewIdentifierLocation?: boolean;
45964612
}

src/harness/unittests/tsserverProjectSystem.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,13 +1302,13 @@ namespace ts.projectSystem {
13021302
service.checkNumberOfProjects({ externalProjects: 1 });
13031303
checkProjectActualFiles(service.externalProjects[0], [f1.path, f2.path, libFile.path]);
13041304

1305-
const completions1 = service.externalProjects[0].getLanguageService().getCompletionsAtPosition(f1.path, 2, { includeExternalModuleExports: false });
1305+
const completions1 = service.externalProjects[0].getLanguageService().getCompletionsAtPosition(f1.path, 2, { includeExternalModuleExports: false, includeInsertTextCompletions: false });
13061306
// should contain completions for string
13071307
assert.isTrue(completions1.entries.some(e => e.name === "charAt"), "should contain 'charAt'");
13081308
assert.isFalse(completions1.entries.some(e => e.name === "toExponential"), "should not contain 'toExponential'");
13091309

13101310
service.closeClientFile(f2.path);
1311-
const completions2 = service.externalProjects[0].getLanguageService().getCompletionsAtPosition(f1.path, 2, { includeExternalModuleExports: false });
1311+
const completions2 = service.externalProjects[0].getLanguageService().getCompletionsAtPosition(f1.path, 2, { includeExternalModuleExports: false, includeInsertTextCompletions: false });
13121312
// should contain completions for string
13131313
assert.isFalse(completions2.entries.some(e => e.name === "charAt"), "should not contain 'charAt'");
13141314
assert.isTrue(completions2.entries.some(e => e.name === "toExponential"), "should contain 'toExponential'");
@@ -1334,11 +1334,11 @@ namespace ts.projectSystem {
13341334
service.checkNumberOfProjects({ externalProjects: 1 });
13351335
checkProjectActualFiles(service.externalProjects[0], [f1.path, f2.path, libFile.path]);
13361336

1337-
const completions1 = service.externalProjects[0].getLanguageService().getCompletionsAtPosition(f1.path, 0, { includeExternalModuleExports: false });
1337+
const completions1 = service.externalProjects[0].getLanguageService().getCompletionsAtPosition(f1.path, 0, { includeExternalModuleExports: false, includeInsertTextCompletions: false });
13381338
assert.isTrue(completions1.entries.some(e => e.name === "somelongname"), "should contain 'somelongname'");
13391339

13401340
service.closeClientFile(f2.path);
1341-
const completions2 = service.externalProjects[0].getLanguageService().getCompletionsAtPosition(f1.path, 0, { includeExternalModuleExports: false });
1341+
const completions2 = service.externalProjects[0].getLanguageService().getCompletionsAtPosition(f1.path, 0, { includeExternalModuleExports: false, includeInsertTextCompletions: false });
13421342
assert.isFalse(completions2.entries.some(e => e.name === "somelongname"), "should not contain 'somelongname'");
13431343
const sf2 = service.externalProjects[0].getLanguageService().getProgram().getSourceFile(f2.path);
13441344
assert.equal(sf2.text, "");
@@ -1943,7 +1943,7 @@ namespace ts.projectSystem {
19431943

19441944
// Check identifiers defined in HTML content are available in .ts file
19451945
const project = configuredProjectAt(projectService, 0);
1946-
let completions = project.getLanguageService().getCompletionsAtPosition(file1.path, 1, { includeExternalModuleExports: false });
1946+
let completions = project.getLanguageService().getCompletionsAtPosition(file1.path, 1, { includeExternalModuleExports: false, includeInsertTextCompletions: false });
19471947
assert(completions && completions.entries[0].name === "hello", `expected entry hello to be in completion list`);
19481948

19491949
// Close HTML file
@@ -1957,7 +1957,7 @@ namespace ts.projectSystem {
19571957
checkProjectActualFiles(configuredProjectAt(projectService, 0), [file1.path, file2.path, config.path]);
19581958

19591959
// Check identifiers defined in HTML content are not available in .ts file
1960-
completions = project.getLanguageService().getCompletionsAtPosition(file1.path, 5, { includeExternalModuleExports: false });
1960+
completions = project.getLanguageService().getCompletionsAtPosition(file1.path, 5, { includeExternalModuleExports: false, includeInsertTextCompletions: false });
19611961
assert(completions && completions.entries[0].name !== "hello", `unexpected hello entry in completion list`);
19621962
});
19631963

src/server/protocol.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,6 +1693,11 @@ namespace ts.server.protocol {
16931693
* This affects lone identifier completions but not completions on the right hand side of `obj.`.
16941694
*/
16951695
includeExternalModuleExports: boolean;
1696+
/**
1697+
* If enabled, the completion list will include completions with invalid identifier names.
1698+
* For those entries, The `insertText` and `replacementSpan` properties will be set to change from `.x` property access to `["x"]`.
1699+
*/
1700+
includeInsertTextCompletions: boolean;
16961701
}
16971702

16981703
/**
@@ -1768,6 +1773,12 @@ namespace ts.server.protocol {
17681773
* is often the same as the name but may be different in certain circumstances.
17691774
*/
17701775
sortText: string;
1776+
/**
1777+
* Text to insert instead of `name`.
1778+
* This is used to support bracketed completions; If `name` might be "a-b" but `insertText` would be `["a-b"]`,
1779+
* coupled with `replacementSpan` to replace a dotted access with a bracket access.
1780+
*/
1781+
insertText?: string;
17711782
/**
17721783
* An optional span that indicates the text to be replaced by this completion item.
17731784
* If present, this span should be used instead of the default one.

src/server/session.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,10 +1207,10 @@ namespace ts.server {
12071207
if (simplifiedResult) {
12081208
return mapDefined<CompletionEntry, protocol.CompletionEntry>(completions && completions.entries, entry => {
12091209
if (completions.isMemberCompletion || startsWith(entry.name.toLowerCase(), prefix.toLowerCase())) {
1210-
const { name, kind, kindModifiers, sortText, replacementSpan, hasAction, source, isRecommended } = entry;
1210+
const { name, kind, kindModifiers, sortText, insertText, replacementSpan, hasAction, source, isRecommended } = entry;
12111211
const convertedSpan = replacementSpan ? this.toLocationTextSpan(replacementSpan, scriptInfo) : undefined;
12121212
// Use `hasAction || undefined` to avoid serializing `false`.
1213-
return { name, kind, kindModifiers, sortText, replacementSpan: convertedSpan, hasAction: hasAction || undefined, source, isRecommended };
1213+
return { name, kind, kindModifiers, sortText, insertText, replacementSpan: convertedSpan, hasAction: hasAction || undefined, source, isRecommended };
12141214
}
12151215
}).sort((a, b) => compareStringsCaseSensitiveUI(a.name, b.name));
12161216
}

0 commit comments

Comments
 (0)