diff --git a/src/services/refactors/extractType.ts b/src/services/refactors/extractType.ts index 205a2334593..f8ddcaedbf0 100644 --- a/src/services/refactors/extractType.ts +++ b/src/services/refactors/extractType.ts @@ -25,6 +25,7 @@ import { getTokenAtPosition, getUniqueName, ignoreSourceNewlines, + isArray, isConditionalTypeNode, isFunctionLike, isIdentifier, @@ -44,6 +45,7 @@ import { isTypePredicateNode, isTypeQueryNode, isTypeReferenceNode, + isUnionTypeNode, JSDocTag, JSDocTemplateTag, Node, @@ -59,6 +61,7 @@ import { SymbolFlags, textChanges, TextRange, + toArray, TypeChecker, TypeElement, TypeNode, @@ -151,7 +154,7 @@ registerRefactor(refactorName, { interface TypeAliasInfo { isJS: boolean; - selection: TypeNode; + selection: TypeNode | TypeNode[]; enclosingNode: Node; typeParameters: readonly TypeParameterDeclaration[]; typeElements?: readonly TypeElement[]; @@ -159,7 +162,7 @@ interface TypeAliasInfo { interface InterfaceInfo { isJS: boolean; - selection: TypeNode; + selection: TypeNode | TypeNode[]; enclosingNode: Node; typeParameters: readonly TypeParameterDeclaration[]; typeElements: readonly TypeElement[]; @@ -173,16 +176,32 @@ function getRangeToExtract(context: RefactorContext, considerEmptySpans = true): const current = getTokenAtPosition(file, startPosition); const range = createTextRangeFromSpan(getRefactorContextSpan(context)); const cursorRequest = range.pos === range.end && considerEmptySpans; + const overlappingRange = nodeOverlapsWithStartEnd(current, file, range.pos, range.end); - const selection = findAncestor(current, node => + const firstType = findAncestor(current, node => node.parent && isTypeNode(node) && !rangeContainsSkipTrivia(range, node.parent, file) && - (cursorRequest || nodeOverlapsWithStartEnd(current, file, range.pos, range.end))); - if (!selection || !isTypeNode(selection)) return { error: getLocaleSpecificMessage(Diagnostics.Selection_is_not_a_valid_type_node) }; + (cursorRequest || overlappingRange)); + if (!firstType || !isTypeNode(firstType)) return { error: getLocaleSpecificMessage(Diagnostics.Selection_is_not_a_valid_type_node) }; const checker = context.program.getTypeChecker(); - const enclosingNode = getEnclosingNode(selection, isJS); + const enclosingNode = getEnclosingNode(firstType, isJS); if (enclosingNode === undefined) return { error: getLocaleSpecificMessage(Diagnostics.No_type_could_be_extracted_from_this_type_node) }; + const expandedFirstType = getExpandedSelectionNode(firstType, enclosingNode); + if (!isTypeNode(expandedFirstType)) return { error: getLocaleSpecificMessage(Diagnostics.Selection_is_not_a_valid_type_node) }; + + const typeList: TypeNode[] = []; + if ((isUnionTypeNode(expandedFirstType.parent) || isIntersectionTypeNode(expandedFirstType.parent)) && range.end > firstType.end) { + // the only extraction cases in which multiple nodes may need to be selected to capture the entire type are union and intersection types + addRange( + typeList, + expandedFirstType.parent.types.filter(type => { + return nodeOverlapsWithStartEnd(type, file, range.pos, range.end); + }), + ); + } + const selection = typeList.length > 1 ? typeList : expandedFirstType; + const typeParameters = collectTypeParameters(checker, selection, enclosingNode, file); if (!typeParameters) return { error: getLocaleSpecificMessage(Diagnostics.No_type_could_be_extracted_from_this_type_node) }; @@ -190,12 +209,21 @@ function getRangeToExtract(context: RefactorContext, considerEmptySpans = true): return { isJS, selection, enclosingNode, typeParameters, typeElements }; } -function flattenTypeLiteralNodeReference(checker: TypeChecker, node: TypeNode | undefined): readonly TypeElement[] | undefined { - if (!node) return undefined; - if (isIntersectionTypeNode(node)) { +function flattenTypeLiteralNodeReference(checker: TypeChecker, selection: TypeNode | TypeNode[] | undefined): readonly TypeElement[] | undefined { + if (!selection) return undefined; + if (isArray(selection)) { + const result: TypeElement[] = []; + for (const type of selection) { + const flattenedTypeMembers = flattenTypeLiteralNodeReference(checker, type); + if (!flattenedTypeMembers) return undefined; + addRange(result, flattenedTypeMembers); + } + return result; + } + if (isIntersectionTypeNode(selection)) { const result: TypeElement[] = []; const seen = new Map(); - for (const type of node.types) { + for (const type of selection.types) { const flattenedTypeMembers = flattenTypeLiteralNodeReference(checker, type); if (!flattenedTypeMembers || !flattenedTypeMembers.every(type => type.name && addToSeen(seen, getNameFromPropertyName(type.name) as string))) { return undefined; @@ -205,22 +233,27 @@ function flattenTypeLiteralNodeReference(checker: TypeChecker, node: TypeNode | } return result; } - else if (isParenthesizedTypeNode(node)) { - return flattenTypeLiteralNodeReference(checker, node.type); + else if (isParenthesizedTypeNode(selection)) { + return flattenTypeLiteralNodeReference(checker, selection.type); } - else if (isTypeLiteralNode(node)) { - return node.members; + else if (isTypeLiteralNode(selection)) { + return selection.members; } return undefined; } -function rangeContainsSkipTrivia(r1: TextRange, node: Node, file: SourceFile): boolean { +function rangeContainsSkipTrivia(r1: TextRange, node: TextRange, file: SourceFile): boolean { return rangeContainsStartEnd(r1, skipTrivia(file.text, node.pos), node.end); } -function collectTypeParameters(checker: TypeChecker, selection: TypeNode, enclosingNode: Node, file: SourceFile): TypeParameterDeclaration[] | undefined { +function collectTypeParameters(checker: TypeChecker, selection: TypeNode | TypeNode[], enclosingNode: Node, file: SourceFile): TypeParameterDeclaration[] | undefined { const result: TypeParameterDeclaration[] = []; - return visitor(selection) ? undefined : result; + const selectionArray = toArray(selection); + const selectionRange = { pos: selectionArray[0].pos, end: selectionArray[selectionArray.length - 1].end }; + for (const t of selectionArray) { + if (visitor(t)) return undefined; + } + return result; function visitor(node: Node): true | undefined { if (isTypeReferenceNode(node)) { @@ -231,11 +264,11 @@ function collectTypeParameters(checker: TypeChecker, selection: TypeNode, enclos if (isTypeParameterDeclaration(decl) && decl.getSourceFile() === file) { // skip extraction if the type node is in the range of the type parameter declaration. // function foo(): void; - if (decl.name.escapedText === typeName.escapedText && rangeContainsSkipTrivia(decl, selection, file)) { + if (decl.name.escapedText === typeName.escapedText && rangeContainsSkipTrivia(decl, selectionRange, file)) { return true; } - if (rangeContainsSkipTrivia(enclosingNode, decl, file) && !rangeContainsSkipTrivia(selection, decl, file)) { + if (rangeContainsSkipTrivia(enclosingNode, decl, file) && !rangeContainsSkipTrivia(selectionRange, decl, file)) { pushIfUnique(result, decl); break; } @@ -245,25 +278,25 @@ function collectTypeParameters(checker: TypeChecker, selection: TypeNode, enclos } else if (isInferTypeNode(node)) { const conditionalTypeNode = findAncestor(node, n => isConditionalTypeNode(n) && rangeContainsSkipTrivia(n.extendsType, node, file)); - if (!conditionalTypeNode || !rangeContainsSkipTrivia(selection, conditionalTypeNode, file)) { + if (!conditionalTypeNode || !rangeContainsSkipTrivia(selectionRange, conditionalTypeNode, file)) { return true; } } else if ((isTypePredicateNode(node) || isThisTypeNode(node))) { const functionLikeNode = findAncestor(node.parent, isFunctionLike); - if (functionLikeNode && functionLikeNode.type && rangeContainsSkipTrivia(functionLikeNode.type, node, file) && !rangeContainsSkipTrivia(selection, functionLikeNode, file)) { + if (functionLikeNode && functionLikeNode.type && rangeContainsSkipTrivia(functionLikeNode.type, node, file) && !rangeContainsSkipTrivia(selectionRange, functionLikeNode, file)) { return true; } } else if (isTypeQueryNode(node)) { if (isIdentifier(node.exprName)) { const symbol = checker.resolveName(node.exprName.text, node.exprName, SymbolFlags.Value, /*excludeGlobals*/ false); - if (symbol?.valueDeclaration && rangeContainsSkipTrivia(enclosingNode, symbol.valueDeclaration, file) && !rangeContainsSkipTrivia(selection, symbol.valueDeclaration, file)) { + if (symbol?.valueDeclaration && rangeContainsSkipTrivia(enclosingNode, symbol.valueDeclaration, file) && !rangeContainsSkipTrivia(selectionRange, symbol.valueDeclaration, file)) { return true; } } else { - if (isThisIdentifier(node.exprName.left) && !rangeContainsSkipTrivia(selection, node.parent, file)) { + if (isThisIdentifier(node.exprName.left) && !rangeContainsSkipTrivia(selectionRange, node.parent, file)) { return true; } } @@ -278,20 +311,20 @@ function collectTypeParameters(checker: TypeChecker, selection: TypeNode, enclos } function doTypeAliasChange(changes: textChanges.ChangeTracker, file: SourceFile, name: string, info: TypeAliasInfo) { - const { enclosingNode, selection, typeParameters } = info; - - const newTypeNode = factory.createTypeAliasDeclaration( + const { enclosingNode, typeParameters } = info; + const { firstTypeNode, lastTypeNode, newTypeNode } = getNodesToEdit(info); + const newTypeDeclaration = factory.createTypeAliasDeclaration( /*modifiers*/ undefined, name, typeParameters.map(id => factory.updateTypeParameterDeclaration(id, id.modifiers, id.name, id.constraint, /*defaultType*/ undefined)), - selection, + newTypeNode, ); - changes.insertNodeBefore(file, enclosingNode, ignoreSourceNewlines(newTypeNode), /*blankLineBetween*/ true); - changes.replaceNode(file, selection, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /*typeArguments*/ undefined))), { leadingTriviaOption: textChanges.LeadingTriviaOption.Exclude, trailingTriviaOption: textChanges.TrailingTriviaOption.ExcludeWhitespace }); + changes.insertNodeBefore(file, enclosingNode, ignoreSourceNewlines(newTypeDeclaration), /*blankLineBetween*/ true); + 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 }); } function doInterfaceChange(changes: textChanges.ChangeTracker, file: SourceFile, name: string, info: InterfaceInfo) { - const { enclosingNode, selection, typeParameters, typeElements } = info; + const { enclosingNode, typeParameters, typeElements } = info; const newTypeNode = factory.createInterfaceDeclaration( /*modifiers*/ undefined, @@ -302,17 +335,21 @@ function doInterfaceChange(changes: textChanges.ChangeTracker, file: SourceFile, ); setTextRange(newTypeNode, typeElements[0]?.parent); changes.insertNodeBefore(file, enclosingNode, ignoreSourceNewlines(newTypeNode), /*blankLineBetween*/ true); - changes.replaceNode(file, selection, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /*typeArguments*/ undefined))), { leadingTriviaOption: textChanges.LeadingTriviaOption.Exclude, trailingTriviaOption: textChanges.TrailingTriviaOption.ExcludeWhitespace }); + + const { firstTypeNode, lastTypeNode } = getNodesToEdit(info); + 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 }); } function doTypedefChange(changes: textChanges.ChangeTracker, context: RefactorContext, file: SourceFile, name: string, info: ExtractInfo) { - const { enclosingNode, selection, typeParameters } = info; - - setEmitFlags(selection, EmitFlags.NoComments | EmitFlags.NoNestedComments); + toArray(info.selection).forEach(typeNode => { + setEmitFlags(typeNode, EmitFlags.NoComments | EmitFlags.NoNestedComments); + }); + const { enclosingNode, typeParameters } = info; + const { firstTypeNode, lastTypeNode, newTypeNode } = getNodesToEdit(info); const node = factory.createJSDocTypedefTag( factory.createIdentifier("typedef"), - factory.createJSDocTypeExpression(selection), + factory.createJSDocTypeExpression(newTypeNode), factory.createIdentifier(name), ); @@ -339,9 +376,36 @@ function doTypedefChange(changes: textChanges.ChangeTracker, context: RefactorCo else { changes.insertNodeBefore(file, enclosingNode, jsDoc, /*blankLineBetween*/ true); } - changes.replaceNode(file, selection, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /*typeArguments*/ undefined)))); + changes.replaceNodeRange(file, firstTypeNode, lastTypeNode, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /*typeArguments*/ undefined)))); +} + +function getNodesToEdit(info: ExtractInfo) { + if (isArray(info.selection)) { + return { + firstTypeNode: info.selection[0], + lastTypeNode: info.selection[info.selection.length - 1], + newTypeNode: isUnionTypeNode(info.selection[0].parent) ? factory.createUnionTypeNode(info.selection) : factory.createIntersectionTypeNode(info.selection), + }; + } + return { + firstTypeNode: info.selection, + lastTypeNode: info.selection, + newTypeNode: info.selection, + }; } function getEnclosingNode(node: Node, isJS: boolean) { return findAncestor(node, isStatement) || (isJS ? findAncestor(node, isJSDoc) : undefined); } + +function getExpandedSelectionNode(firstType: Node, enclosingNode: Node) { + // intended to capture the entire type in cases where the user selection is not exactly the entire type + // currently only implemented for union and intersection types + return findAncestor(firstType, node => { + if (node === enclosingNode) return "quit"; + if (isUnionTypeNode(node.parent) || isIntersectionTypeNode(node.parent)) { + return true; + } + return false; + }) ?? firstType; +} diff --git a/tests/cases/fourslash/refactorExtractType78.ts b/tests/cases/fourslash/refactorExtractType78.ts new file mode 100644 index 00000000000..c2b0dd8b976 --- /dev/null +++ b/tests/cases/fourslash/refactorExtractType78.ts @@ -0,0 +1,18 @@ +/// + +//// type A = { a: string } | /*1*/{ b: string } | { c: string }/*2*/ | { d: string }; + +goTo.select("1", "2"); +edit.applyRefactor({ + refactorName: "Extract type", + actionName: "Extract to type alias", + actionDescription: "Extract to type alias", + newContent: +`type /*RENAME*/NewType = { + b: string; +} | { + c: string; +}; + +type A = { a: string } | NewType | { d: string };`, +}); diff --git a/tests/cases/fourslash/refactorExtractType79.ts b/tests/cases/fourslash/refactorExtractType79.ts new file mode 100644 index 00000000000..8cc3503f7d1 --- /dev/null +++ b/tests/cases/fourslash/refactorExtractType79.ts @@ -0,0 +1,18 @@ +/// + +//// type B = string; +//// type C = number; +//// type A = { a: string } | /*1*/B | C/*2*/; + +goTo.select("1", "2"); +edit.applyRefactor({ + refactorName: "Extract type", + actionName: "Extract to type alias", + actionDescription: "Extract to type alias", + newContent: +`type B = string; +type C = number; +type /*RENAME*/NewType = B | C; + +type A = { a: string } | NewType;`, +}); diff --git a/tests/cases/fourslash/refactorExtractType80.ts b/tests/cases/fourslash/refactorExtractType80.ts new file mode 100644 index 00000000000..2650a32bca5 --- /dev/null +++ b/tests/cases/fourslash/refactorExtractType80.ts @@ -0,0 +1,24 @@ +/// + +//// type B = string; +//// type C = number; +//// +//// export function foo(x: T): T { +//// return x; +//// } + +goTo.select("1", "2"); +edit.applyRefactor({ + refactorName: "Extract type", + actionName: "Extract to type alias", + actionDescription: "Extract to type alias", + newContent: +`type B = string; +type C = number; + +type /*RENAME*/NewType = B | C; + +export function foo(x: T): T { + return x; +}`, +}); diff --git a/tests/cases/fourslash/refactorExtractType81.ts b/tests/cases/fourslash/refactorExtractType81.ts new file mode 100644 index 00000000000..83e41edfeb8 --- /dev/null +++ b/tests/cases/fourslash/refactorExtractType81.ts @@ -0,0 +1,17 @@ +/// + +//// type A = { a: string } & /*1*/{ b: string } & { c: string }/*2*/; + +goTo.select("1", "2"); +edit.applyRefactor({ + refactorName: "Extract type", + actionName: "Extract to interface", + actionDescription: "Extract to interface", + newContent: +`interface /*RENAME*/NewType { + b: string; + c: string; +} + +type A = { a: string } & NewType;`, +}); diff --git a/tests/cases/fourslash/refactorExtractType82.ts b/tests/cases/fourslash/refactorExtractType82.ts new file mode 100644 index 00000000000..da1cdbbfca6 --- /dev/null +++ b/tests/cases/fourslash/refactorExtractType82.ts @@ -0,0 +1,20 @@ +/// + +//// type A = /*1*/{ a: string } | { b: T } | { c: string }/*2*/ | { d: string } | S; + +goTo.select("1", "2"); +edit.applyRefactor({ + refactorName: "Extract type", + actionName: "Extract to type alias", + actionDescription: "Extract to type alias", + newContent: +`type /*RENAME*/NewType = { + a: string; +} | { + b: T; +} | { + c: string; +}; + +type A = NewType | { d: string } | S;`, +}); diff --git a/tests/cases/fourslash/refactorExtractType83.ts b/tests/cases/fourslash/refactorExtractType83.ts new file mode 100644 index 00000000000..7a18527ae77 --- /dev/null +++ b/tests/cases/fourslash/refactorExtractType83.ts @@ -0,0 +1,20 @@ +/// + +//// type A = { a: str/*1*/ing } | { b: string } | { c: string }/*2*/; + +goTo.select("1", "2"); +edit.applyRefactor({ + refactorName: "Extract type", + actionName: "Extract to type alias", + actionDescription: "Extract to type alias", + newContent: +`type /*RENAME*/NewType = { + a: string; +} | { + b: string; +} | { + c: string; +}; + +type A = NewType;`, +}); diff --git a/tests/cases/fourslash/refactorExtractType84.ts b/tests/cases/fourslash/refactorExtractType84.ts new file mode 100644 index 00000000000..20551aaf1f7 --- /dev/null +++ b/tests/cases/fourslash/refactorExtractType84.ts @@ -0,0 +1,20 @@ +/// + +//// type A = { a: string /*1*/} | { b: string } | { c: string }/*2*/; + +goTo.select("1", "2"); +edit.applyRefactor({ + refactorName: "Extract type", + actionName: "Extract to type alias", + actionDescription: "Extract to type alias", + newContent: +`type /*RENAME*/NewType = { + a: string; +} | { + b: string; +} | { + c: string; +}; + +type A = NewType;`, +}); diff --git a/tests/cases/fourslash/refactorExtractType85.ts b/tests/cases/fourslash/refactorExtractType85.ts new file mode 100644 index 00000000000..091ec3bd773 --- /dev/null +++ b/tests/cases/fourslash/refactorExtractType85.ts @@ -0,0 +1,20 @@ +/// + +//// type A = { a: string } /*1*/| { b: string } | { c: string }/*2*/; + +goTo.select("1", "2"); +edit.applyRefactor({ + refactorName: "Extract type", + actionName: "Extract to type alias", + actionDescription: "Extract to type alias", + newContent: +`type /*RENAME*/NewType = { + a: string; +} | { + b: string; +} | { + c: string; +}; + +type A = NewType;`, +}); diff --git a/tests/cases/fourslash/refactorExtractType86.ts b/tests/cases/fourslash/refactorExtractType86.ts new file mode 100644 index 00000000000..520f39972ae --- /dev/null +++ b/tests/cases/fourslash/refactorExtractType86.ts @@ -0,0 +1,20 @@ +/// + +//// type A = {/*1*/ a: string } | { b: string } | { /*2*/c: string }; + +goTo.select("1", "2"); +edit.applyRefactor({ + refactorName: "Extract type", + actionName: "Extract to type alias", + actionDescription: "Extract to type alias", + newContent: +`type /*RENAME*/NewType = { + a: string; +} | { + b: string; +} | { + c: string; +}; + +type A = NewType;`, +}); diff --git a/tests/cases/fourslash/refactorExtractType87.ts b/tests/cases/fourslash/refactorExtractType87.ts new file mode 100644 index 00000000000..caf501d6f3d --- /dev/null +++ b/tests/cases/fourslash/refactorExtractType87.ts @@ -0,0 +1,18 @@ +/// + +//// type A = /*1*/{ a: string } | { b: string } |/*2*/ { c: string }; + +goTo.select("1", "2"); +edit.applyRefactor({ + refactorName: "Extract type", + actionName: "Extract to type alias", + actionDescription: "Extract to type alias", + newContent: +`type /*RENAME*/NewType = { + a: string; +} | { + b: string; +}; + +type A = NewType | { c: string };`, +}); diff --git a/tests/cases/fourslash/refactorExtractType88.ts b/tests/cases/fourslash/refactorExtractType88.ts new file mode 100644 index 00000000000..5658a5da59e --- /dev/null +++ b/tests/cases/fourslash/refactorExtractType88.ts @@ -0,0 +1,22 @@ +/// + +//// type B = { b: string }; +//// type C = { c: number }; +//// +//// interface X {} + +goTo.select("1", "2"); +verify.not.refactorAvailable("Extract type"); + +goTo.select("3", "4"); +edit.applyRefactor({ + refactorName: "Extract type", + actionName: "Extract to type alias", + actionDescription: "Extract to type alias", + newContent: `type B = { b: string }; +type C = { c: number }; + +type /*RENAME*/NewType = B | C; + +interface X {}`, +}); diff --git a/tests/cases/fourslash/refactorExtractType_js9.ts b/tests/cases/fourslash/refactorExtractType_js9.ts new file mode 100644 index 00000000000..8da9614fe36 --- /dev/null +++ b/tests/cases/fourslash/refactorExtractType_js9.ts @@ -0,0 +1,20 @@ +/// + +// @allowJs: true +// @Filename: a.js +//// /** @type { /*a*/string | number/*b*/ | boolean } */ +//// var x; + +goTo.file('a.js') +goTo.select("a", "b"); +edit.applyRefactor({ + refactorName: "Extract type", + actionName: "Extract to typedef", + actionDescription: "Extract to typedef", + newContent: `/** + * @typedef {string | number} /*RENAME*/NewType + */ + +/** @type { NewType | boolean } */ +var x;`, +});