• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/* @internal */
2namespace ts.refactor {
3    const refactorName = "Extract type";
4
5    const extractToTypeAliasAction = {
6        name: "Extract to type alias",
7        description: getLocaleSpecificMessage(Diagnostics.Extract_to_type_alias),
8        kind: "refactor.extract.type",
9    };
10    const extractToInterfaceAction = {
11        name: "Extract to interface",
12        description: getLocaleSpecificMessage(Diagnostics.Extract_to_interface),
13        kind: "refactor.extract.interface",
14    };
15    const extractToTypeDefAction = {
16        name: "Extract to typedef",
17        description: getLocaleSpecificMessage(Diagnostics.Extract_to_typedef),
18        kind: "refactor.extract.typedef"
19    };
20
21    registerRefactor(refactorName, {
22        kinds: [
23            extractToTypeAliasAction.kind,
24            extractToInterfaceAction.kind,
25            extractToTypeDefAction.kind
26        ],
27        getAvailableActions: function getRefactorActionsToExtractType(context): readonly ApplicableRefactorInfo[] {
28            const info = getRangeToExtract(context, context.triggerReason === "invoked");
29            if (!info) return emptyArray;
30
31            if (!isRefactorErrorInfo(info)) {
32                return [{
33                    name: refactorName,
34                    description: getLocaleSpecificMessage(Diagnostics.Extract_type),
35                    actions: info.isJS ?
36                        [extractToTypeDefAction] : append([extractToTypeAliasAction], info.typeElements && extractToInterfaceAction)
37                }];
38            }
39
40            if (context.preferences.provideRefactorNotApplicableReason) {
41                return [{
42                    name: refactorName,
43                    description: getLocaleSpecificMessage(Diagnostics.Extract_type),
44                    actions: [
45                        { ...extractToTypeDefAction, notApplicableReason: info.error },
46                        { ...extractToTypeAliasAction, notApplicableReason: info.error },
47                        { ...extractToInterfaceAction, notApplicableReason: info.error },
48                    ]
49                }];
50            }
51
52            return emptyArray;
53        },
54        getEditsForAction: function getRefactorEditsToExtractType(context, actionName): RefactorEditInfo {
55            const { file, } = context;
56            const info = getRangeToExtract(context);
57            Debug.assert(info && !isRefactorErrorInfo(info), "Expected to find a range to extract");
58
59            const name = getUniqueName("NewType", file);
60            const edits = textChanges.ChangeTracker.with(context, changes => {
61                switch (actionName) {
62                    case extractToTypeAliasAction.name:
63                        Debug.assert(!info.isJS, "Invalid actionName/JS combo");
64                        return doTypeAliasChange(changes, file, name, info);
65                    case extractToTypeDefAction.name:
66                        Debug.assert(info.isJS, "Invalid actionName/JS combo");
67                        return doTypedefChange(changes, file, name, info);
68                    case extractToInterfaceAction.name:
69                        Debug.assert(!info.isJS && !!info.typeElements, "Invalid actionName/JS combo");
70                        return doInterfaceChange(changes, file, name, info as InterfaceInfo);
71                    default:
72                        Debug.fail("Unexpected action name");
73                }
74            });
75
76            const renameFilename = file.fileName;
77            const renameLocation = getRenameLocation(edits, renameFilename, name, /*preferLastLocation*/ false);
78            return { edits, renameFilename, renameLocation };
79        }
80    });
81
82    interface TypeAliasInfo {
83        isJS: boolean; selection: TypeNode; firstStatement: Statement; typeParameters: readonly TypeParameterDeclaration[]; typeElements?: readonly TypeElement[];
84    }
85
86    interface InterfaceInfo {
87        isJS: boolean; selection: TypeNode; firstStatement: Statement; typeParameters: readonly TypeParameterDeclaration[]; typeElements: readonly TypeElement[];
88    }
89
90    type ExtractInfo = TypeAliasInfo | InterfaceInfo;
91
92    function getRangeToExtract(context: RefactorContext, considerEmptySpans = true): ExtractInfo | RefactorErrorInfo | undefined {
93        const { file, startPosition } = context;
94        const isJS = isSourceFileJS(file);
95        const current = getTokenAtPosition(file, startPosition);
96        const range = createTextRangeFromSpan(getRefactorContextSpan(context));
97        const cursorRequest = range.pos === range.end && considerEmptySpans;
98
99        const selection = findAncestor(current, (node => node.parent && isTypeNode(node) && !rangeContainsSkipTrivia(range, node.parent, file) &&
100            (cursorRequest || nodeOverlapsWithStartEnd(current, file, range.pos, range.end))));
101        if (!selection || !isTypeNode(selection)) return { error: getLocaleSpecificMessage(Diagnostics.Selection_is_not_a_valid_type_node) };
102
103        const checker = context.program.getTypeChecker();
104        const firstStatement = Debug.checkDefined(findAncestor(selection, isStatement), "Should find a statement");
105        const typeParameters = collectTypeParameters(checker, selection, firstStatement, file);
106        if (!typeParameters) return { error: getLocaleSpecificMessage(Diagnostics.No_type_could_be_extracted_from_this_type_node) };
107
108        const typeElements = flattenTypeLiteralNodeReference(checker, selection);
109        return { isJS, selection, firstStatement, typeParameters, typeElements };
110    }
111
112    function flattenTypeLiteralNodeReference(checker: TypeChecker, node: TypeNode | undefined): readonly TypeElement[] | undefined {
113        if (!node) return undefined;
114        if (isIntersectionTypeNode(node)) {
115            const result: TypeElement[] = [];
116            const seen = new Map<string, true>();
117            for (const type of node.types) {
118                const flattenedTypeMembers = flattenTypeLiteralNodeReference(checker, type);
119                if (!flattenedTypeMembers || !flattenedTypeMembers.every(type => type.name && addToSeen(seen, getNameFromPropertyName(type.name) as string))) {
120                    return undefined;
121                }
122
123                addRange(result, flattenedTypeMembers);
124            }
125            return result;
126        }
127        else if (isParenthesizedTypeNode(node)) {
128            return flattenTypeLiteralNodeReference(checker, node.type);
129        }
130        else if (isTypeLiteralNode(node)) {
131            return node.members;
132        }
133        return undefined;
134    }
135
136    function rangeContainsSkipTrivia(r1: TextRange, node: Node, file: SourceFile): boolean {
137        return rangeContainsStartEnd(r1, skipTrivia(file.text, node.pos), node.end);
138    }
139
140    function collectTypeParameters(checker: TypeChecker, selection: TypeNode, statement: Statement, file: SourceFile): TypeParameterDeclaration[] | undefined {
141        const result: TypeParameterDeclaration[] = [];
142        return visitor(selection) ? undefined : result;
143
144        function visitor(node: Node): true | undefined {
145            if (isTypeReferenceNode(node)) {
146                if (isIdentifier(node.typeName)) {
147                    const typeName = node.typeName;
148                    const symbol = checker.resolveName(typeName.text, typeName, SymbolFlags.TypeParameter, /* excludeGlobals */ true);
149                    for (const decl of symbol?.declarations || emptyArray) {
150                        if (isTypeParameterDeclaration(decl) && decl.getSourceFile() === file) {
151                            // skip extraction if the type node is in the range of the type parameter declaration.
152                            // function foo<T extends { a?: /**/T }>(): void;
153                            if (decl.name.escapedText === typeName.escapedText && rangeContainsSkipTrivia(decl, selection, file)) {
154                                return true;
155                            }
156
157                            if (rangeContainsSkipTrivia(statement, decl, file) && !rangeContainsSkipTrivia(selection, decl, file)) {
158                                pushIfUnique(result, decl);
159                                break;
160                            }
161                        }
162                    }
163                }
164            }
165            else if (isInferTypeNode(node)) {
166                const conditionalTypeNode = findAncestor(node, n => isConditionalTypeNode(n) && rangeContainsSkipTrivia(n.extendsType, node, file));
167                if (!conditionalTypeNode || !rangeContainsSkipTrivia(selection, conditionalTypeNode, file)) {
168                    return true;
169                }
170            }
171            else if ((isTypePredicateNode(node) || isThisTypeNode(node))) {
172                const functionLikeNode = findAncestor(node.parent, isFunctionLike);
173                if (functionLikeNode && functionLikeNode.type && rangeContainsSkipTrivia(functionLikeNode.type, node, file) && !rangeContainsSkipTrivia(selection, functionLikeNode, file)) {
174                    return true;
175                }
176            }
177            else if (isTypeQueryNode(node)) {
178                if (isIdentifier(node.exprName)) {
179                    const symbol = checker.resolveName(node.exprName.text, node.exprName, SymbolFlags.Value, /* excludeGlobals */ false);
180                    if (symbol?.valueDeclaration && rangeContainsSkipTrivia(statement, symbol.valueDeclaration, file) && !rangeContainsSkipTrivia(selection, symbol.valueDeclaration, file)) {
181                        return true;
182                    }
183                }
184                else {
185                    if (isThisIdentifier(node.exprName.left) && !rangeContainsSkipTrivia(selection, node.parent, file)) {
186                        return true;
187                    }
188                }
189            }
190
191            if (file && isTupleTypeNode(node) && (getLineAndCharacterOfPosition(file, node.pos).line === getLineAndCharacterOfPosition(file, node.end).line)) {
192                setEmitFlags(node, EmitFlags.SingleLine);
193            }
194
195            return forEachChild(node, visitor);
196        }
197    }
198
199    function doTypeAliasChange(changes: textChanges.ChangeTracker, file: SourceFile, name: string, info: TypeAliasInfo) {
200        const { firstStatement, selection, typeParameters } = info;
201
202        const newTypeNode = factory.createTypeAliasDeclaration(
203            /* modifiers */ undefined,
204            name,
205            typeParameters.map(id => factory.updateTypeParameterDeclaration(id, id.modifiers, id.name, id.constraint, /* defaultType */ undefined)),
206            selection
207        );
208        changes.insertNodeBefore(file, firstStatement, ignoreSourceNewlines(newTypeNode), /* blankLineBetween */ true);
209        changes.replaceNode(file, selection, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /* typeArguments */ undefined))), { leadingTriviaOption: textChanges.LeadingTriviaOption.Exclude, trailingTriviaOption: textChanges.TrailingTriviaOption.ExcludeWhitespace });
210    }
211
212    function doInterfaceChange(changes: textChanges.ChangeTracker, file: SourceFile, name: string, info: InterfaceInfo) {
213        const { firstStatement, selection, typeParameters, typeElements } = info;
214
215        const newTypeNode = factory.createInterfaceDeclaration(
216            /* modifiers */ undefined,
217            name,
218            typeParameters,
219            /* heritageClauses */ undefined,
220            typeElements
221        );
222        setTextRange(newTypeNode, typeElements[0]?.parent);
223        changes.insertNodeBefore(file, firstStatement, ignoreSourceNewlines(newTypeNode), /* blankLineBetween */ true);
224        changes.replaceNode(file, selection, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /* typeArguments */ undefined))), { leadingTriviaOption: textChanges.LeadingTriviaOption.Exclude, trailingTriviaOption: textChanges.TrailingTriviaOption.ExcludeWhitespace });
225    }
226
227    function doTypedefChange(changes: textChanges.ChangeTracker, file: SourceFile, name: string, info: ExtractInfo) {
228        const { firstStatement, selection, typeParameters } = info;
229
230        setEmitFlags(selection, EmitFlags.NoComments | EmitFlags.NoNestedComments);
231
232        const node = factory.createJSDocTypedefTag(
233            factory.createIdentifier("typedef"),
234            factory.createJSDocTypeExpression(selection),
235            factory.createIdentifier(name));
236
237        const templates: JSDocTemplateTag[] = [];
238        forEach(typeParameters, typeParameter => {
239            const constraint = getEffectiveConstraintOfTypeParameter(typeParameter);
240            const parameter = factory.createTypeParameterDeclaration(/*modifiers*/ undefined, typeParameter.name);
241            const template = factory.createJSDocTemplateTag(
242                factory.createIdentifier("template"),
243                constraint && cast(constraint, isJSDocTypeExpression),
244                [parameter]
245            );
246            templates.push(template);
247        });
248
249        changes.insertNodeBefore(file, firstStatement, factory.createJSDocComment(/* comment */ undefined, factory.createNodeArray(concatenate<JSDocTag>(templates, [node]))), /* blankLineBetween */ true);
250        changes.replaceNode(file, selection, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /* typeArguments */ undefined))));
251    }
252}
253