Skip to content

Commit 6061069

Browse files
authored
fix54035, extractType now allows parts of union and intersection types to be extracted (#56131)
1 parent f25f2bb commit 6061069

13 files changed

+337
-36
lines changed

src/services/refactors/extractType.ts

Lines changed: 100 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
getTokenAtPosition,
2626
getUniqueName,
2727
ignoreSourceNewlines,
28+
isArray,
2829
isConditionalTypeNode,
2930
isFunctionLike,
3031
isIdentifier,
@@ -44,6 +45,7 @@ import {
4445
isTypePredicateNode,
4546
isTypeQueryNode,
4647
isTypeReferenceNode,
48+
isUnionTypeNode,
4749
JSDocTag,
4850
JSDocTemplateTag,
4951
Node,
@@ -59,6 +61,7 @@ import {
5961
SymbolFlags,
6062
textChanges,
6163
TextRange,
64+
toArray,
6265
TypeChecker,
6366
TypeElement,
6467
TypeNode,
@@ -151,15 +154,15 @@ registerRefactor(refactorName, {
151154

152155
interface TypeAliasInfo {
153156
isJS: boolean;
154-
selection: TypeNode;
157+
selection: TypeNode | TypeNode[];
155158
enclosingNode: Node;
156159
typeParameters: readonly TypeParameterDeclaration[];
157160
typeElements?: readonly TypeElement[];
158161
}
159162

160163
interface InterfaceInfo {
161164
isJS: boolean;
162-
selection: TypeNode;
165+
selection: TypeNode | TypeNode[];
163166
enclosingNode: Node;
164167
typeParameters: readonly TypeParameterDeclaration[];
165168
typeElements: readonly TypeElement[];
@@ -173,29 +176,54 @@ function getRangeToExtract(context: RefactorContext, considerEmptySpans = true):
173176
const current = getTokenAtPosition(file, startPosition);
174177
const range = createTextRangeFromSpan(getRefactorContextSpan(context));
175178
const cursorRequest = range.pos === range.end && considerEmptySpans;
179+
const overlappingRange = nodeOverlapsWithStartEnd(current, file, range.pos, range.end);
176180

177-
const selection = findAncestor(current, node =>
181+
const firstType = findAncestor(current, node =>
178182
node.parent && isTypeNode(node) && !rangeContainsSkipTrivia(range, node.parent, file) &&
179-
(cursorRequest || nodeOverlapsWithStartEnd(current, file, range.pos, range.end)));
180-
if (!selection || !isTypeNode(selection)) return { error: getLocaleSpecificMessage(Diagnostics.Selection_is_not_a_valid_type_node) };
183+
(cursorRequest || overlappingRange));
184+
if (!firstType || !isTypeNode(firstType)) return { error: getLocaleSpecificMessage(Diagnostics.Selection_is_not_a_valid_type_node) };
181185

182186
const checker = context.program.getTypeChecker();
183-
const enclosingNode = getEnclosingNode(selection, isJS);
187+
const enclosingNode = getEnclosingNode(firstType, isJS);
184188
if (enclosingNode === undefined) return { error: getLocaleSpecificMessage(Diagnostics.No_type_could_be_extracted_from_this_type_node) };
185189

190+
const expandedFirstType = getExpandedSelectionNode(firstType, enclosingNode);
191+
if (!isTypeNode(expandedFirstType)) return { error: getLocaleSpecificMessage(Diagnostics.Selection_is_not_a_valid_type_node) };
192+
193+
const typeList: TypeNode[] = [];
194+
if ((isUnionTypeNode(expandedFirstType.parent) || isIntersectionTypeNode(expandedFirstType.parent)) && range.end > firstType.end) {
195+
// the only extraction cases in which multiple nodes may need to be selected to capture the entire type are union and intersection types
196+
addRange(
197+
typeList,
198+
expandedFirstType.parent.types.filter(type => {
199+
return nodeOverlapsWithStartEnd(type, file, range.pos, range.end);
200+
}),
201+
);
202+
}
203+
const selection = typeList.length > 1 ? typeList : expandedFirstType;
204+
186205
const typeParameters = collectTypeParameters(checker, selection, enclosingNode, file);
187206
if (!typeParameters) return { error: getLocaleSpecificMessage(Diagnostics.No_type_could_be_extracted_from_this_type_node) };
188207

189208
const typeElements = flattenTypeLiteralNodeReference(checker, selection);
190209
return { isJS, selection, enclosingNode, typeParameters, typeElements };
191210
}
192211

193-
function flattenTypeLiteralNodeReference(checker: TypeChecker, node: TypeNode | undefined): readonly TypeElement[] | undefined {
194-
if (!node) return undefined;
195-
if (isIntersectionTypeNode(node)) {
212+
function flattenTypeLiteralNodeReference(checker: TypeChecker, selection: TypeNode | TypeNode[] | undefined): readonly TypeElement[] | undefined {
213+
if (!selection) return undefined;
214+
if (isArray(selection)) {
215+
const result: TypeElement[] = [];
216+
for (const type of selection) {
217+
const flattenedTypeMembers = flattenTypeLiteralNodeReference(checker, type);
218+
if (!flattenedTypeMembers) return undefined;
219+
addRange(result, flattenedTypeMembers);
220+
}
221+
return result;
222+
}
223+
if (isIntersectionTypeNode(selection)) {
196224
const result: TypeElement[] = [];
197225
const seen = new Map<string, true>();
198-
for (const type of node.types) {
226+
for (const type of selection.types) {
199227
const flattenedTypeMembers = flattenTypeLiteralNodeReference(checker, type);
200228
if (!flattenedTypeMembers || !flattenedTypeMembers.every(type => type.name && addToSeen(seen, getNameFromPropertyName(type.name) as string))) {
201229
return undefined;
@@ -205,22 +233,27 @@ function flattenTypeLiteralNodeReference(checker: TypeChecker, node: TypeNode |
205233
}
206234
return result;
207235
}
208-
else if (isParenthesizedTypeNode(node)) {
209-
return flattenTypeLiteralNodeReference(checker, node.type);
236+
else if (isParenthesizedTypeNode(selection)) {
237+
return flattenTypeLiteralNodeReference(checker, selection.type);
210238
}
211-
else if (isTypeLiteralNode(node)) {
212-
return node.members;
239+
else if (isTypeLiteralNode(selection)) {
240+
return selection.members;
213241
}
214242
return undefined;
215243
}
216244

217-
function rangeContainsSkipTrivia(r1: TextRange, node: Node, file: SourceFile): boolean {
245+
function rangeContainsSkipTrivia(r1: TextRange, node: TextRange, file: SourceFile): boolean {
218246
return rangeContainsStartEnd(r1, skipTrivia(file.text, node.pos), node.end);
219247
}
220248

221-
function collectTypeParameters(checker: TypeChecker, selection: TypeNode, enclosingNode: Node, file: SourceFile): TypeParameterDeclaration[] | undefined {
249+
function collectTypeParameters(checker: TypeChecker, selection: TypeNode | TypeNode[], enclosingNode: Node, file: SourceFile): TypeParameterDeclaration[] | undefined {
222250
const result: TypeParameterDeclaration[] = [];
223-
return visitor(selection) ? undefined : result;
251+
const selectionArray = toArray(selection);
252+
const selectionRange = { pos: selectionArray[0].pos, end: selectionArray[selectionArray.length - 1].end };
253+
for (const t of selectionArray) {
254+
if (visitor(t)) return undefined;
255+
}
256+
return result;
224257

225258
function visitor(node: Node): true | undefined {
226259
if (isTypeReferenceNode(node)) {
@@ -231,11 +264,11 @@ function collectTypeParameters(checker: TypeChecker, selection: TypeNode, enclos
231264
if (isTypeParameterDeclaration(decl) && decl.getSourceFile() === file) {
232265
// skip extraction if the type node is in the range of the type parameter declaration.
233266
// function foo<T extends { a?: /**/T }>(): void;
234-
if (decl.name.escapedText === typeName.escapedText && rangeContainsSkipTrivia(decl, selection, file)) {
267+
if (decl.name.escapedText === typeName.escapedText && rangeContainsSkipTrivia(decl, selectionRange, file)) {
235268
return true;
236269
}
237270

238-
if (rangeContainsSkipTrivia(enclosingNode, decl, file) && !rangeContainsSkipTrivia(selection, decl, file)) {
271+
if (rangeContainsSkipTrivia(enclosingNode, decl, file) && !rangeContainsSkipTrivia(selectionRange, decl, file)) {
239272
pushIfUnique(result, decl);
240273
break;
241274
}
@@ -245,25 +278,25 @@ function collectTypeParameters(checker: TypeChecker, selection: TypeNode, enclos
245278
}
246279
else if (isInferTypeNode(node)) {
247280
const conditionalTypeNode = findAncestor(node, n => isConditionalTypeNode(n) && rangeContainsSkipTrivia(n.extendsType, node, file));
248-
if (!conditionalTypeNode || !rangeContainsSkipTrivia(selection, conditionalTypeNode, file)) {
281+
if (!conditionalTypeNode || !rangeContainsSkipTrivia(selectionRange, conditionalTypeNode, file)) {
249282
return true;
250283
}
251284
}
252285
else if ((isTypePredicateNode(node) || isThisTypeNode(node))) {
253286
const functionLikeNode = findAncestor(node.parent, isFunctionLike);
254-
if (functionLikeNode && functionLikeNode.type && rangeContainsSkipTrivia(functionLikeNode.type, node, file) && !rangeContainsSkipTrivia(selection, functionLikeNode, file)) {
287+
if (functionLikeNode && functionLikeNode.type && rangeContainsSkipTrivia(functionLikeNode.type, node, file) && !rangeContainsSkipTrivia(selectionRange, functionLikeNode, file)) {
255288
return true;
256289
}
257290
}
258291
else if (isTypeQueryNode(node)) {
259292
if (isIdentifier(node.exprName)) {
260293
const symbol = checker.resolveName(node.exprName.text, node.exprName, SymbolFlags.Value, /*excludeGlobals*/ false);
261-
if (symbol?.valueDeclaration && rangeContainsSkipTrivia(enclosingNode, symbol.valueDeclaration, file) && !rangeContainsSkipTrivia(selection, symbol.valueDeclaration, file)) {
294+
if (symbol?.valueDeclaration && rangeContainsSkipTrivia(enclosingNode, symbol.valueDeclaration, file) && !rangeContainsSkipTrivia(selectionRange, symbol.valueDeclaration, file)) {
262295
return true;
263296
}
264297
}
265298
else {
266-
if (isThisIdentifier(node.exprName.left) && !rangeContainsSkipTrivia(selection, node.parent, file)) {
299+
if (isThisIdentifier(node.exprName.left) && !rangeContainsSkipTrivia(selectionRange, node.parent, file)) {
267300
return true;
268301
}
269302
}
@@ -278,20 +311,20 @@ function collectTypeParameters(checker: TypeChecker, selection: TypeNode, enclos
278311
}
279312

280313
function doTypeAliasChange(changes: textChanges.ChangeTracker, file: SourceFile, name: string, info: TypeAliasInfo) {
281-
const { enclosingNode, selection, typeParameters } = info;
282-
283-
const newTypeNode = factory.createTypeAliasDeclaration(
314+
const { enclosingNode, typeParameters } = info;
315+
const { firstTypeNode, lastTypeNode, newTypeNode } = getNodesToEdit(info);
316+
const newTypeDeclaration = factory.createTypeAliasDeclaration(
284317
/*modifiers*/ undefined,
285318
name,
286319
typeParameters.map(id => factory.updateTypeParameterDeclaration(id, id.modifiers, id.name, id.constraint, /*defaultType*/ undefined)),
287-
selection,
320+
newTypeNode,
288321
);
289-
changes.insertNodeBefore(file, enclosingNode, ignoreSourceNewlines(newTypeNode), /*blankLineBetween*/ true);
290-
changes.replaceNode(file, selection, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /*typeArguments*/ undefined))), { leadingTriviaOption: textChanges.LeadingTriviaOption.Exclude, trailingTriviaOption: textChanges.TrailingTriviaOption.ExcludeWhitespace });
322+
changes.insertNodeBefore(file, enclosingNode, ignoreSourceNewlines(newTypeDeclaration), /*blankLineBetween*/ true);
323+
changes.replaceNodeRange(file, firstTypeNode, lastTypeNode, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /*typeArguments*/ undefined))), { leadingTriviaOption: textChanges.LeadingTriviaOption.Exclude, trailingTriviaOption: textChanges.TrailingTriviaOption.ExcludeWhitespace });
291324
}
292325

293326
function doInterfaceChange(changes: textChanges.ChangeTracker, file: SourceFile, name: string, info: InterfaceInfo) {
294-
const { enclosingNode, selection, typeParameters, typeElements } = info;
327+
const { enclosingNode, typeParameters, typeElements } = info;
295328

296329
const newTypeNode = factory.createInterfaceDeclaration(
297330
/*modifiers*/ undefined,
@@ -302,17 +335,21 @@ function doInterfaceChange(changes: textChanges.ChangeTracker, file: SourceFile,
302335
);
303336
setTextRange(newTypeNode, typeElements[0]?.parent);
304337
changes.insertNodeBefore(file, enclosingNode, ignoreSourceNewlines(newTypeNode), /*blankLineBetween*/ true);
305-
changes.replaceNode(file, selection, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /*typeArguments*/ undefined))), { leadingTriviaOption: textChanges.LeadingTriviaOption.Exclude, trailingTriviaOption: textChanges.TrailingTriviaOption.ExcludeWhitespace });
338+
339+
const { firstTypeNode, lastTypeNode } = getNodesToEdit(info);
340+
changes.replaceNodeRange(file, firstTypeNode, lastTypeNode, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /*typeArguments*/ undefined))), { leadingTriviaOption: textChanges.LeadingTriviaOption.Exclude, trailingTriviaOption: textChanges.TrailingTriviaOption.ExcludeWhitespace });
306341
}
307342

308343
function doTypedefChange(changes: textChanges.ChangeTracker, context: RefactorContext, file: SourceFile, name: string, info: ExtractInfo) {
309-
const { enclosingNode, selection, typeParameters } = info;
310-
311-
setEmitFlags(selection, EmitFlags.NoComments | EmitFlags.NoNestedComments);
344+
toArray(info.selection).forEach(typeNode => {
345+
setEmitFlags(typeNode, EmitFlags.NoComments | EmitFlags.NoNestedComments);
346+
});
347+
const { enclosingNode, typeParameters } = info;
348+
const { firstTypeNode, lastTypeNode, newTypeNode } = getNodesToEdit(info);
312349

313350
const node = factory.createJSDocTypedefTag(
314351
factory.createIdentifier("typedef"),
315-
factory.createJSDocTypeExpression(selection),
352+
factory.createJSDocTypeExpression(newTypeNode),
316353
factory.createIdentifier(name),
317354
);
318355

@@ -339,9 +376,36 @@ function doTypedefChange(changes: textChanges.ChangeTracker, context: RefactorCo
339376
else {
340377
changes.insertNodeBefore(file, enclosingNode, jsDoc, /*blankLineBetween*/ true);
341378
}
342-
changes.replaceNode(file, selection, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /*typeArguments*/ undefined))));
379+
changes.replaceNodeRange(file, firstTypeNode, lastTypeNode, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /*typeArguments*/ undefined))));
380+
}
381+
382+
function getNodesToEdit(info: ExtractInfo) {
383+
if (isArray(info.selection)) {
384+
return {
385+
firstTypeNode: info.selection[0],
386+
lastTypeNode: info.selection[info.selection.length - 1],
387+
newTypeNode: isUnionTypeNode(info.selection[0].parent) ? factory.createUnionTypeNode(info.selection) : factory.createIntersectionTypeNode(info.selection),
388+
};
389+
}
390+
return {
391+
firstTypeNode: info.selection,
392+
lastTypeNode: info.selection,
393+
newTypeNode: info.selection,
394+
};
343395
}
344396

345397
function getEnclosingNode(node: Node, isJS: boolean) {
346398
return findAncestor(node, isStatement) || (isJS ? findAncestor(node, isJSDoc) : undefined);
347399
}
400+
401+
function getExpandedSelectionNode(firstType: Node, enclosingNode: Node) {
402+
// intended to capture the entire type in cases where the user selection is not exactly the entire type
403+
// currently only implemented for union and intersection types
404+
return findAncestor(firstType, node => {
405+
if (node === enclosingNode) return "quit";
406+
if (isUnionTypeNode(node.parent) || isIntersectionTypeNode(node.parent)) {
407+
return true;
408+
}
409+
return false;
410+
}) ?? firstType;
411+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
//// type A = { a: string } | /*1*/{ b: string } | { c: string }/*2*/ | { d: string };
4+
5+
goTo.select("1", "2");
6+
edit.applyRefactor({
7+
refactorName: "Extract type",
8+
actionName: "Extract to type alias",
9+
actionDescription: "Extract to type alias",
10+
newContent:
11+
`type /*RENAME*/NewType = {
12+
b: string;
13+
} | {
14+
c: string;
15+
};
16+
17+
type A = { a: string } | NewType | { d: string };`,
18+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
//// type B = string;
4+
//// type C = number;
5+
//// type A = { a: string } | /*1*/B | C/*2*/;
6+
7+
goTo.select("1", "2");
8+
edit.applyRefactor({
9+
refactorName: "Extract type",
10+
actionName: "Extract to type alias",
11+
actionDescription: "Extract to type alias",
12+
newContent:
13+
`type B = string;
14+
type C = number;
15+
type /*RENAME*/NewType = B | C;
16+
17+
type A = { a: string } | NewType;`,
18+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
//// type B = string;
4+
//// type C = number;
5+
////
6+
//// export function foo<T extends boolean | /*1*/B | C/*2*/>(x: T): T {
7+
//// return x;
8+
//// }
9+
10+
goTo.select("1", "2");
11+
edit.applyRefactor({
12+
refactorName: "Extract type",
13+
actionName: "Extract to type alias",
14+
actionDescription: "Extract to type alias",
15+
newContent:
16+
`type B = string;
17+
type C = number;
18+
19+
type /*RENAME*/NewType = B | C;
20+
21+
export function foo<T extends boolean | NewType>(x: T): T {
22+
return x;
23+
}`,
24+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
//// type A = { a: string } & /*1*/{ b: string } & { c: string }/*2*/;
4+
5+
goTo.select("1", "2");
6+
edit.applyRefactor({
7+
refactorName: "Extract type",
8+
actionName: "Extract to interface",
9+
actionDescription: "Extract to interface",
10+
newContent:
11+
`interface /*RENAME*/NewType {
12+
b: string;
13+
c: string;
14+
}
15+
16+
type A = { a: string } & NewType;`,
17+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
//// type A<T,S> = /*1*/{ a: string } | { b: T } | { c: string }/*2*/ | { d: string } | S;
4+
5+
goTo.select("1", "2");
6+
edit.applyRefactor({
7+
refactorName: "Extract type",
8+
actionName: "Extract to type alias",
9+
actionDescription: "Extract to type alias",
10+
newContent:
11+
`type /*RENAME*/NewType<T> = {
12+
a: string;
13+
} | {
14+
b: T;
15+
} | {
16+
c: string;
17+
};
18+
19+
type A<T,S> = NewType<T> | { d: string } | S;`,
20+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
//// type A = { a: str/*1*/ing } | { b: string } | { c: string }/*2*/;
4+
5+
goTo.select("1", "2");
6+
edit.applyRefactor({
7+
refactorName: "Extract type",
8+
actionName: "Extract to type alias",
9+
actionDescription: "Extract to type alias",
10+
newContent:
11+
`type /*RENAME*/NewType = {
12+
a: string;
13+
} | {
14+
b: string;
15+
} | {
16+
c: string;
17+
};
18+
19+
type A = NewType;`,
20+
});

0 commit comments

Comments
 (0)