Skip to content

Commit 1b75edc

Browse files
authored
fix(51053): Extract type on JSDoc causes an assertion failure (#51056)
* fix(51053): allow extract type from the jsdoc without a host * change diagnostic message
1 parent 7b7f6a7 commit 1b75edc

File tree

2 files changed

+60
-18
lines changed

2 files changed

+60
-18
lines changed

src/services/refactors/extractType.ts

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
getLineAndCharacterOfPosition,
1919
getLocaleSpecificMessage,
2020
getNameFromPropertyName,
21+
getNewLineCharacter,
22+
getPrecedingNonSpaceCharacterPosition,
2123
getRefactorContextSpan,
2224
getRenameLocation,
2325
getTokenAtPosition,
@@ -28,6 +30,7 @@ import {
2830
isIdentifier,
2931
isInferTypeNode,
3032
isIntersectionTypeNode,
33+
isJSDoc,
3134
isJSDocTypeExpression,
3235
isParenthesizedTypeNode,
3336
isSourceFileJS,
@@ -45,6 +48,7 @@ import {
4548
JSDocTemplateTag,
4649
Node,
4750
nodeOverlapsWithStartEnd,
51+
Program,
4852
pushIfUnique,
4953
rangeContainsStartEnd,
5054
RefactorContext,
@@ -53,7 +57,6 @@ import {
5357
setTextRange,
5458
skipTrivia,
5559
SourceFile,
56-
Statement,
5760
SymbolFlags,
5861
textChanges,
5962
TextRange,
@@ -120,7 +123,7 @@ registerRefactor(refactorName, {
120123
return emptyArray;
121124
},
122125
getEditsForAction: function getRefactorEditsToExtractType(context, actionName): RefactorEditInfo {
123-
const { file, } = context;
126+
const { file, program } = context;
124127
const info = getRangeToExtract(context);
125128
Debug.assert(info && !isRefactorErrorInfo(info), "Expected to find a range to extract");
126129

@@ -132,7 +135,7 @@ registerRefactor(refactorName, {
132135
return doTypeAliasChange(changes, file, name, info);
133136
case extractToTypeDefAction.name:
134137
Debug.assert(info.isJS, "Invalid actionName/JS combo");
135-
return doTypedefChange(changes, file, name, info);
138+
return doTypedefChange(changes, program, file, name, info);
136139
case extractToInterfaceAction.name:
137140
Debug.assert(!info.isJS && !!info.typeElements, "Invalid actionName/JS combo");
138141
return doInterfaceChange(changes, file, name, info as InterfaceInfo);
@@ -148,11 +151,11 @@ registerRefactor(refactorName, {
148151
});
149152

150153
interface TypeAliasInfo {
151-
isJS: boolean; selection: TypeNode; firstStatement: Statement; typeParameters: readonly TypeParameterDeclaration[]; typeElements?: readonly TypeElement[];
154+
isJS: boolean; selection: TypeNode; enclosingNode: Node; typeParameters: readonly TypeParameterDeclaration[]; typeElements?: readonly TypeElement[];
152155
}
153156

154157
interface InterfaceInfo {
155-
isJS: boolean; selection: TypeNode; firstStatement: Statement; typeParameters: readonly TypeParameterDeclaration[]; typeElements: readonly TypeElement[];
158+
isJS: boolean; selection: TypeNode; enclosingNode: Node; typeParameters: readonly TypeParameterDeclaration[]; typeElements: readonly TypeElement[];
156159
}
157160

158161
type ExtractInfo = TypeAliasInfo | InterfaceInfo;
@@ -169,12 +172,14 @@ function getRangeToExtract(context: RefactorContext, considerEmptySpans = true):
169172
if (!selection || !isTypeNode(selection)) return { error: getLocaleSpecificMessage(Diagnostics.Selection_is_not_a_valid_type_node) };
170173

171174
const checker = context.program.getTypeChecker();
172-
const firstStatement = Debug.checkDefined(findAncestor(selection, isStatement), "Should find a statement");
173-
const typeParameters = collectTypeParameters(checker, selection, firstStatement, file);
175+
const enclosingNode = getEnclosingNode(selection, isJS);
176+
if (enclosingNode === undefined) return { error: getLocaleSpecificMessage(Diagnostics.No_type_could_be_extracted_from_this_type_node) };
177+
178+
const typeParameters = collectTypeParameters(checker, selection, enclosingNode, file);
174179
if (!typeParameters) return { error: getLocaleSpecificMessage(Diagnostics.No_type_could_be_extracted_from_this_type_node) };
175180

176181
const typeElements = flattenTypeLiteralNodeReference(checker, selection);
177-
return { isJS, selection, firstStatement, typeParameters, typeElements };
182+
return { isJS, selection, enclosingNode, typeParameters, typeElements };
178183
}
179184

180185
function flattenTypeLiteralNodeReference(checker: TypeChecker, node: TypeNode | undefined): readonly TypeElement[] | undefined {
@@ -205,7 +210,7 @@ function rangeContainsSkipTrivia(r1: TextRange, node: Node, file: SourceFile): b
205210
return rangeContainsStartEnd(r1, skipTrivia(file.text, node.pos), node.end);
206211
}
207212

208-
function collectTypeParameters(checker: TypeChecker, selection: TypeNode, statement: Statement, file: SourceFile): TypeParameterDeclaration[] | undefined {
213+
function collectTypeParameters(checker: TypeChecker, selection: TypeNode, enclosingNode: Node, file: SourceFile): TypeParameterDeclaration[] | undefined {
209214
const result: TypeParameterDeclaration[] = [];
210215
return visitor(selection) ? undefined : result;
211216

@@ -222,7 +227,7 @@ function collectTypeParameters(checker: TypeChecker, selection: TypeNode, statem
222227
return true;
223228
}
224229

225-
if (rangeContainsSkipTrivia(statement, decl, file) && !rangeContainsSkipTrivia(selection, decl, file)) {
230+
if (rangeContainsSkipTrivia(enclosingNode, decl, file) && !rangeContainsSkipTrivia(selection, decl, file)) {
226231
pushIfUnique(result, decl);
227232
break;
228233
}
@@ -245,7 +250,7 @@ function collectTypeParameters(checker: TypeChecker, selection: TypeNode, statem
245250
else if (isTypeQueryNode(node)) {
246251
if (isIdentifier(node.exprName)) {
247252
const symbol = checker.resolveName(node.exprName.text, node.exprName, SymbolFlags.Value, /* excludeGlobals */ false);
248-
if (symbol?.valueDeclaration && rangeContainsSkipTrivia(statement, symbol.valueDeclaration, file) && !rangeContainsSkipTrivia(selection, symbol.valueDeclaration, file)) {
253+
if (symbol?.valueDeclaration && rangeContainsSkipTrivia(enclosingNode, symbol.valueDeclaration, file) && !rangeContainsSkipTrivia(selection, symbol.valueDeclaration, file)) {
249254
return true;
250255
}
251256
}
@@ -265,20 +270,20 @@ function collectTypeParameters(checker: TypeChecker, selection: TypeNode, statem
265270
}
266271

267272
function doTypeAliasChange(changes: textChanges.ChangeTracker, file: SourceFile, name: string, info: TypeAliasInfo) {
268-
const { firstStatement, selection, typeParameters } = info;
273+
const { enclosingNode, selection, typeParameters } = info;
269274

270275
const newTypeNode = factory.createTypeAliasDeclaration(
271276
/* modifiers */ undefined,
272277
name,
273278
typeParameters.map(id => factory.updateTypeParameterDeclaration(id, id.modifiers, id.name, id.constraint, /* defaultType */ undefined)),
274279
selection
275280
);
276-
changes.insertNodeBefore(file, firstStatement, ignoreSourceNewlines(newTypeNode), /* blankLineBetween */ true);
281+
changes.insertNodeBefore(file, enclosingNode, ignoreSourceNewlines(newTypeNode), /* blankLineBetween */ true);
277282
changes.replaceNode(file, selection, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /* typeArguments */ undefined))), { leadingTriviaOption: textChanges.LeadingTriviaOption.Exclude, trailingTriviaOption: textChanges.TrailingTriviaOption.ExcludeWhitespace });
278283
}
279284

280285
function doInterfaceChange(changes: textChanges.ChangeTracker, file: SourceFile, name: string, info: InterfaceInfo) {
281-
const { firstStatement, selection, typeParameters, typeElements } = info;
286+
const { enclosingNode, selection, typeParameters, typeElements } = info;
282287

283288
const newTypeNode = factory.createInterfaceDeclaration(
284289
/* modifiers */ undefined,
@@ -288,12 +293,12 @@ function doInterfaceChange(changes: textChanges.ChangeTracker, file: SourceFile,
288293
typeElements
289294
);
290295
setTextRange(newTypeNode, typeElements[0]?.parent);
291-
changes.insertNodeBefore(file, firstStatement, ignoreSourceNewlines(newTypeNode), /* blankLineBetween */ true);
296+
changes.insertNodeBefore(file, enclosingNode, ignoreSourceNewlines(newTypeNode), /* blankLineBetween */ true);
292297
changes.replaceNode(file, selection, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /* typeArguments */ undefined))), { leadingTriviaOption: textChanges.LeadingTriviaOption.Exclude, trailingTriviaOption: textChanges.TrailingTriviaOption.ExcludeWhitespace });
293298
}
294299

295-
function doTypedefChange(changes: textChanges.ChangeTracker, file: SourceFile, name: string, info: ExtractInfo) {
296-
const { firstStatement, selection, typeParameters } = info;
300+
function doTypedefChange(changes: textChanges.ChangeTracker, program: Program, file: SourceFile, name: string, info: ExtractInfo) {
301+
const { enclosingNode, selection, typeParameters } = info;
297302

298303
setEmitFlags(selection, EmitFlags.NoComments | EmitFlags.NoNestedComments);
299304

@@ -314,6 +319,20 @@ function doTypedefChange(changes: textChanges.ChangeTracker, file: SourceFile, n
314319
templates.push(template);
315320
});
316321

317-
changes.insertNodeBefore(file, firstStatement, factory.createJSDocComment(/* comment */ undefined, factory.createNodeArray(concatenate<JSDocTag>(templates, [node]))), /* blankLineBetween */ true);
322+
const jsDoc = factory.createJSDocComment(/* comment */ undefined, factory.createNodeArray(concatenate<JSDocTag>(templates, [node])));
323+
if (isJSDoc(enclosingNode)) {
324+
const pos = enclosingNode.getStart(file);
325+
const newLineCharacter = getNewLineCharacter(program.getCompilerOptions());
326+
changes.insertNodeAt(file, enclosingNode.getStart(file), jsDoc, {
327+
suffix: newLineCharacter + newLineCharacter + file.text.slice(getPrecedingNonSpaceCharacterPosition(file.text, pos - 1), pos)
328+
});
329+
}
330+
else {
331+
changes.insertNodeBefore(file, enclosingNode, jsDoc, /* blankLineBetween */ true);
332+
}
318333
changes.replaceNode(file, selection, factory.createTypeReferenceNode(name, typeParameters.map(id => factory.createTypeReferenceNode(id.name, /* typeArguments */ undefined))));
319334
}
335+
336+
function getEnclosingNode(node: Node, isJS: boolean) {
337+
return findAncestor(node, isStatement) || (isJS ? findAncestor(node, isJSDoc) : undefined);
338+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
// @allowJs: true
4+
// @Filename: a.js
5+
/////**
6+
//// * @type {/*a*/Foo/*b*/}
7+
//// */
8+
9+
goTo.file('a.js')
10+
goTo.select("a", "b");
11+
edit.applyRefactor({
12+
refactorName: "Extract type",
13+
actionName: "Extract to typedef",
14+
actionDescription: "Extract to typedef",
15+
newContent:
16+
`/**
17+
* @typedef {Foo} /*RENAME*/NewType
18+
*/
19+
20+
/**
21+
* @type {NewType}
22+
*/`,
23+
});

0 commit comments

Comments
 (0)