Skip to content

Commit 24ac9e7

Browse files
authored
Improve class member snippet completions and keyword completions interaction (#52525)
1 parent b14264a commit 24ac9e7

38 files changed

+779
-521
lines changed

src/compiler/factory/nodeFactory.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ import {
125125
getSyntheticLeadingComments,
126126
getSyntheticTrailingComments,
127127
getTextOfIdentifierOrLiteral,
128+
HasDecorators,
128129
hasInvalidEscape,
129130
HasModifiers,
130131
hasProperty,
@@ -1023,6 +1024,7 @@ export function createNodeFactory(flags: NodeFactoryFlags, baseFactory: BaseNode
10231024
liftToBlock,
10241025
mergeLexicalEnvironment,
10251026
updateModifiers,
1027+
updateModifierLike,
10261028
};
10271029

10281030
forEach(nodeFactoryPatchers, fn => fn(factory));
@@ -6989,6 +6991,18 @@ export function createNodeFactory(flags: NodeFactoryFlags, baseFactory: BaseNode
69896991
Debug.assertNever(node);
69906992
}
69916993

6994+
function updateModifierLike<T extends HasModifiers & HasDecorators>(node: T, modifiers: readonly ModifierLike[]): T;
6995+
function updateModifierLike(node: HasModifiers & HasDecorators, modifierArray: readonly ModifierLike[]) {
6996+
return isParameter(node) ? updateParameterDeclaration(node, modifierArray, node.dotDotDotToken, node.name, node.questionToken, node.type, node.initializer) :
6997+
isPropertyDeclaration(node) ? updatePropertyDeclaration(node, modifierArray, node.name, node.questionToken ?? node.exclamationToken, node.type, node.initializer) :
6998+
isMethodDeclaration(node) ? updateMethodDeclaration(node, modifierArray, node.asteriskToken, node.name, node.questionToken, node.typeParameters, node.parameters, node.type, node.body) :
6999+
isGetAccessorDeclaration(node) ? updateGetAccessorDeclaration(node, modifierArray, node.name, node.parameters, node.type, node.body) :
7000+
isSetAccessorDeclaration(node) ? updateSetAccessorDeclaration(node, modifierArray, node.name, node.parameters, node.body) :
7001+
isClassExpression(node) ? updateClassExpression(node, modifierArray, node.name, node.typeParameters, node.heritageClauses, node.members) :
7002+
isClassDeclaration(node) ? updateClassDeclaration(node, modifierArray, node.name, node.typeParameters, node.heritageClauses, node.members) :
7003+
Debug.assertNever(node);
7004+
}
7005+
69927006
function asNodeArray<T extends Node>(array: readonly T[]): NodeArray<T>;
69937007
function asNodeArray<T extends Node>(array: readonly T[] | undefined): NodeArray<T> | undefined;
69947008
function asNodeArray<T extends Node>(array: readonly T[] | undefined): NodeArray<T> | undefined {

src/compiler/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1335,7 +1335,7 @@ export type HasIllegalDecorators =
13351335
;
13361336

13371337
// NOTE: Changing the following list requires changes to:
1338-
// - `canHaveModifiers` in factory/utilities.ts
1338+
// - `canHaveModifiers` in factory/utilitiesPublic.ts
13391339
// - `updateModifiers` in factory/nodeFactory.ts
13401340
export type HasModifiers =
13411341
| TypeParameterDeclaration
@@ -9057,6 +9057,7 @@ export interface NodeFactory {
90579057
*/
90589058
cloneNode<T extends Node | undefined>(node: T): T;
90599059
/** @internal */ updateModifiers<T extends HasModifiers>(node: T, modifiers: readonly Modifier[] | ModifierFlags | undefined): T;
9060+
/** @internal */ updateModifierLike<T extends HasModifiers & HasDecorators>(node: T, modifierLike: readonly ModifierLike[] | undefined): T;
90609061
}
90619062

90629063
/** @internal */

src/harness/fourslashImpl.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,8 +1009,18 @@ export class TestState {
10091009
assert.equal<boolean | undefined>(actual.isPackageJsonImport, expected.isPackageJsonImport, `At entry ${actual.name}: Expected 'isPackageJsonImport' properties to match`);
10101010
}
10111011

1012-
assert.equal(actual.labelDetails?.description, expected.labelDetails?.description, `At entry ${actual.name}: Expected 'labelDetails.description' properties to match`);
1013-
assert.equal(actual.labelDetails?.detail, expected.labelDetails?.detail, `At entry ${actual.name}: Expected 'labelDetails.detail' properties to match`);
1012+
assert.equal(
1013+
actual.filterText,
1014+
expected.filterText,
1015+
`At entry ${actual.name}: Completion 'filterText' not match: ${showTextDiff(expected.filterText || "", actual.filterText || "")}`);
1016+
assert.equal(
1017+
actual.labelDetails?.description,
1018+
expected.labelDetails?.description,
1019+
`At entry ${actual.name}: Completion 'labelDetails.description' did not match: ${showTextDiff(expected.labelDetails?.description || "", actual.labelDetails?.description || "")}`);
1020+
assert.equal(
1021+
actual.labelDetails?.detail,
1022+
expected.labelDetails?.detail,
1023+
`At entry ${actual.name}: Completion 'labelDetails.detail' did not match: ${showTextDiff(expected.labelDetails?.detail || "", actual.labelDetails?.detail || "")}`);
10141024
assert.equal(actual.hasAction, expected.hasAction, `At entry ${actual.name}: Expected 'hasAction' properties to match`);
10151025
assert.equal(actual.isRecommended, expected.isRecommended, `At entry ${actual.name}: Expected 'isRecommended' properties to match'`);
10161026
assert.equal(actual.isSnippet, expected.isSnippet, `At entry ${actual.name}: Expected 'isSnippet' properties to match`);

src/harness/fourslashInterfaceImpl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1738,6 +1738,7 @@ export interface ExpectedCompletionEntryObject {
17381738
readonly name: string;
17391739
readonly source?: string;
17401740
readonly insertText?: string;
1741+
readonly filterText?: string;
17411742
readonly replacementSpan?: FourSlash.Range;
17421743
readonly hasAction?: boolean; // If not specified, will assert that this is false.
17431744
readonly isRecommended?: boolean; // If not specified, will assert that this is false.

src/server/protocol.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2335,6 +2335,11 @@ export interface CompletionEntry {
23352335
* coupled with `replacementSpan` to replace a dotted access with a bracket access.
23362336
*/
23372337
insertText?: string;
2338+
/**
2339+
* A string that should be used when filtering a set of
2340+
* completion items.
2341+
*/
2342+
filterText?: string;
23382343
/**
23392344
* `insertText` should be interpreted as a snippet if true.
23402345
*/

src/server/session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2267,6 +2267,7 @@ export class Session<TMessage = string> implements EventSender {
22672267
kindModifiers,
22682268
sortText,
22692269
insertText,
2270+
filterText,
22702271
replacementSpan,
22712272
hasAction,
22722273
source,
@@ -2285,6 +2286,7 @@ export class Session<TMessage = string> implements EventSender {
22852286
kindModifiers,
22862287
sortText,
22872288
insertText,
2289+
filterText,
22882290
replacementSpan: convertedSpan,
22892291
isSnippet,
22902292
hasAction: hasAction || undefined,

src/services/codefixes/helpers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,8 @@ export function addNewNodeForMemberSymbol(
194194
const kind = declaration?.kind ?? SyntaxKind.PropertySignature;
195195
const declarationName = getSynthesizedDeepClone(getNameOfDeclaration(declaration), /*includeTrivia*/ false) as PropertyName;
196196
const effectiveModifierFlags = declaration ? getEffectiveModifierFlags(declaration) : ModifierFlags.None;
197-
let modifierFlags =
197+
let modifierFlags = effectiveModifierFlags & ModifierFlags.Static;
198+
modifierFlags |=
198199
effectiveModifierFlags & ModifierFlags.Public ? ModifierFlags.Public :
199200
effectiveModifierFlags & ModifierFlags.Protected ? ModifierFlags.Protected :
200201
ModifierFlags.None;

src/services/completions.ts

Lines changed: 83 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
BindingPattern,
88
BreakOrContinueStatement,
99
CancellationToken,
10+
canHaveDecorators,
1011
canUsePropertyAccess,
1112
CaseBlock,
1213
cast,
@@ -44,6 +45,7 @@ import {
4445
createTextSpanFromRange,
4546
Debug,
4647
Declaration,
48+
Decorator,
4749
Diagnostics,
4850
diagnosticToString,
4951
displayPart,
@@ -91,6 +93,7 @@ import {
9193
getLineAndCharacterOfPosition,
9294
getLineStartPositionForPosition,
9395
getLocalSymbolForExportDefault,
96+
getModifiers,
9497
getNameOfDeclaration,
9598
getNameTable,
9699
getNewLineCharacter,
@@ -145,6 +148,7 @@ import {
145148
isConstructorDeclaration,
146149
isContextualKeyword,
147150
isDeclarationName,
151+
isDecorator,
148152
isDeprecatedDeclaration,
149153
isEntityName,
150154
isEnumMember,
@@ -276,6 +280,7 @@ import {
276280
memoizeOne,
277281
MethodDeclaration,
278282
ModifierFlags,
283+
ModifierLike,
279284
modifiersToFlags,
280285
ModifierSyntaxKind,
281286
modifierToFlag,
@@ -1609,6 +1614,7 @@ function createCompletionEntry(
16091614
includeSymbol: boolean
16101615
): CompletionEntry | undefined {
16111616
let insertText: string | undefined;
1617+
let filterText: string | undefined;
16121618
let replacementSpan = getReplacementSpanForContextToken(replacementToken);
16131619
let data: CompletionEntryData | undefined;
16141620
let isSnippet: true | undefined;
@@ -1682,12 +1688,16 @@ function createCompletionEntry(
16821688
completionKind === CompletionKind.MemberLike &&
16831689
isClassLikeMemberCompletion(symbol, location, sourceFile)) {
16841690
let importAdder;
1685-
({ insertText, isSnippet, importAdder, replacementSpan } =
1686-
getEntryForMemberCompletion(host, program, options, preferences, name, symbol, location, position, contextToken, formatContext));
1687-
sortText = SortText.ClassMemberSnippets; // sortText has to be lower priority than the sortText for keywords. See #47852.
1688-
if (importAdder?.hasFixes()) {
1689-
hasAction = true;
1690-
source = CompletionSource.ClassMemberSnippet;
1691+
const memberCompletionEntry = getEntryForMemberCompletion(host, program, options, preferences, name, symbol, location, position, contextToken, formatContext);
1692+
if (memberCompletionEntry) {
1693+
({ insertText, filterText, isSnippet, importAdder } = memberCompletionEntry);
1694+
if (importAdder?.hasFixes()) {
1695+
hasAction = true;
1696+
source = CompletionSource.ClassMemberSnippet;
1697+
}
1698+
}
1699+
else {
1700+
return undefined; // Skip this entry
16911701
}
16921702
}
16931703

@@ -1755,6 +1765,7 @@ function createCompletionEntry(
17551765
hasAction: hasAction ? true : undefined,
17561766
isRecommended: isRecommendedCompletionMatch(symbol, recommendedCompletion, typeChecker) || undefined,
17571767
insertText,
1768+
filterText,
17581769
replacementSpan,
17591770
sourceDisplay,
17601771
labelDetails,
@@ -1826,15 +1837,15 @@ function getEntryForMemberCompletion(
18261837
position: number,
18271838
contextToken: Node | undefined,
18281839
formatContext: formatting.FormatContext | undefined,
1829-
): { insertText: string, isSnippet?: true, importAdder?: codefix.ImportAdder, replacementSpan?: TextSpan } {
1840+
): { insertText: string, filterText?: string, isSnippet?: true, importAdder?: codefix.ImportAdder, eraseRange?: TextRange } | undefined {
18301841
const classLikeDeclaration = findAncestor(location, isClassLike);
18311842
if (!classLikeDeclaration) {
1832-
return { insertText: name };
1843+
return undefined; // This should never happen.
18331844
}
18341845

18351846
let isSnippet: true | undefined;
1836-
let replacementSpan: TextSpan | undefined;
18371847
let insertText: string = name;
1848+
const filterText: string = name;
18381849

18391850
const checker = program.getTypeChecker();
18401851
const sourceFile = location.getSourceFile();
@@ -1863,11 +1874,11 @@ function getEntryForMemberCompletion(
18631874
}
18641875

18651876
let modifiers = ModifierFlags.None;
1877+
const { modifiers: presentModifiers, range: eraseRange, decorators: presentDecorators } = getPresentModifiers(contextToken, sourceFile, position);
18661878
// Whether the suggested member should be abstract.
18671879
// e.g. in `abstract class C { abstract | }`, we should offer abstract method signatures at position `|`.
1868-
const { modifiers: presentModifiers, span: modifiersSpan } = getPresentModifiers(contextToken, sourceFile, position);
1869-
const isAbstract = !!(presentModifiers & ModifierFlags.Abstract);
1870-
const completionNodes: Node[] = [];
1880+
const isAbstract = presentModifiers & ModifierFlags.Abstract && classLikeDeclaration.modifierFlagsCache & ModifierFlags.Abstract;
1881+
let completionNodes: codefix.AddNode[] = [];
18711882
codefix.addNewNodeForMemberSymbol(
18721883
symbol,
18731884
classLikeDeclaration,
@@ -1896,20 +1907,49 @@ function getEntryForMemberCompletion(
18961907
// This is needed when we have overloaded signatures,
18971908
// so this callback will be called for multiple nodes/signatures,
18981909
// and we need to make sure the modifiers are uniform for all nodes/signatures.
1899-
modifiers = node.modifierFlagsCache | requiredModifiers | presentModifiers;
1910+
modifiers = node.modifierFlagsCache | requiredModifiers;
19001911
}
19011912
node = factory.updateModifiers(node, modifiers);
19021913
completionNodes.push(node);
19031914
},
19041915
body,
19051916
codefix.PreserveOptionalFlags.Property,
1906-
isAbstract);
1917+
!!isAbstract);
19071918

19081919
if (completionNodes.length) {
1920+
const isMethod = symbol.flags & SymbolFlags.Method;
1921+
let allowedModifiers = modifiers | ModifierFlags.Override | ModifierFlags.Public;
1922+
if (!isMethod) {
1923+
allowedModifiers |= ModifierFlags.Ambient | ModifierFlags.Readonly;
1924+
}
1925+
else {
1926+
allowedModifiers |= ModifierFlags.Async;
1927+
}
1928+
const allowedAndPresent = presentModifiers & allowedModifiers;
1929+
if (presentModifiers & (~allowedModifiers)) {
1930+
return undefined; // This completion entry will be filtered out.
1931+
}
1932+
// If the original member is protected, we allow it to change to public.
1933+
if (modifiers & ModifierFlags.Protected && allowedAndPresent & ModifierFlags.Public) {
1934+
modifiers &= ~ModifierFlags.Protected;
1935+
}
1936+
// `public` modifier is optional and can be dropped.
1937+
if (allowedAndPresent !== ModifierFlags.None && !(allowedAndPresent & ModifierFlags.Public)) {
1938+
modifiers &= ~ModifierFlags.Public;
1939+
}
1940+
modifiers |= allowedAndPresent;
1941+
completionNodes = completionNodes.map(node => factory.updateModifiers(node, modifiers));
1942+
// Add back the decorators that were already present.
1943+
if (presentDecorators?.length) {
1944+
const lastNode = completionNodes[completionNodes.length - 1];
1945+
if (canHaveDecorators(lastNode)) {
1946+
completionNodes[completionNodes.length - 1] = factory.updateModifierLike(lastNode, (presentDecorators as ModifierLike[]).concat(getModifiers(lastNode) || []));
1947+
}
1948+
}
1949+
19091950
const format = ListFormat.MultiLine | ListFormat.NoTrailingNewLine;
1910-
replacementSpan = modifiersSpan;
1911-
// If we have access to formatting settings, we print the nodes using the emitter,
1912-
// and then format the printed text.
1951+
// If we have access to formatting settings, we print the nodes using the emitter,
1952+
// and then format the printed text.
19131953
if (formatContext) {
19141954
insertText = printer.printAndFormatSnippetList(
19151955
format,
@@ -1925,21 +1965,22 @@ function getEntryForMemberCompletion(
19251965
}
19261966
}
19271967

1928-
return { insertText, isSnippet, importAdder, replacementSpan };
1968+
return { insertText, filterText, isSnippet, importAdder, eraseRange };
19291969
}
19301970

19311971
function getPresentModifiers(
19321972
contextToken: Node | undefined,
19331973
sourceFile: SourceFile,
1934-
position: number): { modifiers: ModifierFlags, span?: TextSpan } {
1974+
position: number): { modifiers: ModifierFlags, decorators?: Decorator[], range?: TextRange } {
19351975
if (!contextToken ||
19361976
getLineAndCharacterOfPosition(sourceFile, position).line
19371977
> getLineAndCharacterOfPosition(sourceFile, contextToken.getEnd()).line) {
19381978
return { modifiers: ModifierFlags.None };
19391979
}
19401980
let modifiers = ModifierFlags.None;
1941-
let span;
1981+
let decorators: Decorator[] | undefined;
19421982
let contextMod;
1983+
const range: TextRange = { pos: position, end: position };
19431984
/*
19441985
Cases supported:
19451986
In
@@ -1959,15 +2000,19 @@ function getPresentModifiers(
19592000
`location.parent` is property declaration ``protected override m``,
19602001
`location.parent.parent` is class declaration ``class C { ... }``.
19612002
*/
1962-
if (contextMod = isModifierLike(contextToken)) {
1963-
modifiers |= modifierToFlag(contextMod);
1964-
span = createTextSpanFromNode(contextToken);
1965-
}
1966-
if (isPropertyDeclaration(contextToken.parent)) {
2003+
if (isPropertyDeclaration(contextToken.parent) && contextToken.parent.modifiers) {
19672004
modifiers |= modifiersToFlags(contextToken.parent.modifiers) & ModifierFlags.Modifier;
1968-
span = createTextSpanFromNode(contextToken.parent);
2005+
decorators = contextToken.parent.modifiers.filter(isDecorator) || [];
2006+
range.pos = Math.min(range.pos, contextToken.parent.modifiers.pos);
2007+
}
2008+
if (contextMod = isModifierLike(contextToken)) {
2009+
const contextModifierFlag = modifierToFlag(contextMod);
2010+
if (!(modifiers & contextModifierFlag)) {
2011+
modifiers |= contextModifierFlag;
2012+
range.pos = Math.min(range.pos, contextToken.pos);
2013+
}
19692014
}
1970-
return { modifiers, span };
2015+
return { modifiers, decorators, range: range.pos !== position ? range : undefined };
19712016
}
19722017

19732018
function isModifierLike(node: Node): ModifierSyntaxKind | undefined {
@@ -2770,7 +2815,7 @@ function getCompletionEntryCodeActionsAndSourceDisplay(
27702815
}
27712816

27722817
if (source === CompletionSource.ClassMemberSnippet) {
2773-
const { importAdder } = getEntryForMemberCompletion(
2818+
const { importAdder, eraseRange } = getEntryForMemberCompletion(
27742819
host,
27752820
program,
27762821
compilerOptions,
@@ -2780,11 +2825,18 @@ function getCompletionEntryCodeActionsAndSourceDisplay(
27802825
location,
27812826
position,
27822827
contextToken,
2783-
formatContext);
2784-
if (importAdder) {
2828+
formatContext)!;
2829+
if (importAdder || eraseRange) {
27852830
const changes = textChanges.ChangeTracker.with(
27862831
{ host, formatContext, preferences },
2787-
importAdder.writeFixes);
2832+
tracker => {
2833+
if (importAdder) {
2834+
importAdder.writeFixes(tracker);
2835+
}
2836+
if (eraseRange) {
2837+
tracker.deleteRange(sourceFile, eraseRange);
2838+
}
2839+
});
27882840
return {
27892841
sourceDisplay: undefined,
27902842
codeActions: [{

src/services/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,6 +1406,7 @@ export interface CompletionEntry {
14061406
kindModifiers?: string; // see ScriptElementKindModifier, comma separated
14071407
sortText: string;
14081408
insertText?: string;
1409+
filterText?: string;
14091410
isSnippet?: true;
14101411
/**
14111412
* An optional span that indicates the text to be replaced by this completion item.

tests/baselines/reference/api/tsserverlibrary.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1829,6 +1829,11 @@ declare namespace ts {
18291829
* coupled with `replacementSpan` to replace a dotted access with a bracket access.
18301830
*/
18311831
insertText?: string;
1832+
/**
1833+
* A string that should be used when filtering a set of
1834+
* completion items.
1835+
*/
1836+
filterText?: string;
18321837
/**
18331838
* `insertText` should be interpreted as a snippet if true.
18341839
*/
@@ -10801,6 +10806,7 @@ declare namespace ts {
1080110806
kindModifiers?: string;
1080210807
sortText: string;
1080310808
insertText?: string;
10809+
filterText?: string;
1080410810
isSnippet?: true;
1080510811
/**
1080610812
* An optional span that indicates the text to be replaced by this completion item.

0 commit comments

Comments
 (0)