diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json index 02620ab7620b1..f582f36df63ac 100644 --- a/src/compiler/diagnosticMessages.json +++ b/src/compiler/diagnosticMessages.json @@ -6694,7 +6694,15 @@ "category": "Suggestion", "code": 80008 }, - + "JSDoc typedef may be converted to TypeScript type.": { + "category": "Suggestion", + "code": 80009 + }, + "JSDoc typedefs may be converted to TypeScript types.": { + "category": "Suggestion", + "code": 80010 + }, + "Add missing 'super()' call": { "category": "Message", "code": 90001 @@ -7552,6 +7560,14 @@ "category": "Message", "code": 95175 }, + "Convert typedef to TypeScript type.": { + "category": "Message", + "code": 95176 + }, + "Convert all typedef to TypeScript types.": { + "category": "Message", + "code": 95177 + }, "No value exists in scope for the shorthand property '{0}'. Either declare one or provide an initializer.": { "category": "Error", diff --git a/src/services/_namespaces/ts.codefix.ts b/src/services/_namespaces/ts.codefix.ts index 5270a0c664e26..3bf0c18cb9e5a 100644 --- a/src/services/_namespaces/ts.codefix.ts +++ b/src/services/_namespaces/ts.codefix.ts @@ -17,6 +17,7 @@ export * from "../codefixes/convertToEsModule"; export * from "../codefixes/correctQualifiedNameToIndexedAccessType"; export * from "../codefixes/convertToTypeOnlyExport"; export * from "../codefixes/convertToTypeOnlyImport"; +export * from "../codefixes/convertTypedefToType"; export * from "../codefixes/convertLiteralTypeToMappedType"; export * from "../codefixes/fixClassIncorrectlyImplementsInterface"; export * from "../codefixes/importFixes"; diff --git a/src/services/codefixes/convertTypedefToType.ts b/src/services/codefixes/convertTypedefToType.ts new file mode 100644 index 0000000000000..5cf04b1aaa82f --- /dev/null +++ b/src/services/codefixes/convertTypedefToType.ts @@ -0,0 +1,178 @@ +import { + Diagnostics, + factory, + forEach, + getSynthesizedDeepClone, + getTokenAtPosition, + hasJSDocNodes, + InterfaceDeclaration, + isJSDocTypedefTag, + isJSDocTypeLiteral, + JSDocPropertyLikeTag, + JSDocTypedefTag, + JSDocTypeExpression, + JSDocTypeLiteral, + mapDefined, + Node, + PropertySignature, + some, + SourceFile, + SyntaxKind, + textChanges, + TypeAliasDeclaration, +} from "../_namespaces/ts"; +import { codeFixAll, createCodeFixAction, registerCodeFix } from "../_namespaces/ts.codefix"; + +const fixId = "convertTypedefToType"; +const errorCodes = [Diagnostics.JSDoc_typedef_may_be_converted_to_TypeScript_type.code]; +registerCodeFix({ + fixIds: [fixId], + errorCodes, + getCodeActions(context) { + const node = getTokenAtPosition( + context.sourceFile, + context.span.start + ); + if (!node) return; + const changes = textChanges.ChangeTracker.with(context, t => doChange(t, node, context.sourceFile)); + + if (changes.length > 0) { + return [ + createCodeFixAction( + fixId, + changes, + Diagnostics.Convert_typedef_to_TypeScript_type, + fixId, + Diagnostics.Convert_all_typedef_to_TypeScript_types, + ), + ]; + } + }, + getAllCodeActions: context => codeFixAll(context, errorCodes, (changes, diag) => { + const node = getTokenAtPosition(diag.file, diag.start); + if (node) doChange(changes, node, diag.file); + }) +}); + +function doChange(changes: textChanges.ChangeTracker, node: Node, sourceFile: SourceFile) { + if (isJSDocTypedefTag(node)) { + fixSingleTypeDef(changes, node, sourceFile); + } +} + +function fixSingleTypeDef( + changes: textChanges.ChangeTracker, + typeDefNode: JSDocTypedefTag | undefined, + sourceFile: SourceFile, +) { + if (!typeDefNode) return; + + const declaration = createDeclaration(typeDefNode); + if (!declaration) return; + + const comment = typeDefNode.parent; + + changes.replaceNode( + sourceFile, + comment, + declaration + ); +} + +function createDeclaration(tag: JSDocTypedefTag): InterfaceDeclaration | TypeAliasDeclaration | undefined { + const { typeExpression } = tag; + if (!typeExpression) return; + const typeName = tag.name?.getText(); + if (!typeName) return; + + // For use case @typedef {object}Foo @property{bar}number + // But object type can be nested, meaning the value in the k/v pair can be object itself + if (typeExpression.kind === SyntaxKind.JSDocTypeLiteral) { + return createInterfaceForTypeLiteral(typeName, typeExpression); + } + // for use case @typedef {(number|string|undefined)} Foo or @typedef {number|string|undefined} Foo + // In this case, we reach the leaf node of AST. + if (typeExpression.kind === SyntaxKind.JSDocTypeExpression) { + return createTypeAliasForTypeExpression(typeName, typeExpression); + } +} + +function createInterfaceForTypeLiteral( + typeName: string, + typeLiteral: JSDocTypeLiteral +): InterfaceDeclaration | undefined { + const propertySignatures = createSignatureFromTypeLiteral(typeLiteral); + if (!some(propertySignatures)) return; + const interfaceDeclaration = factory.createInterfaceDeclaration( + /*modifiers*/ undefined, + typeName, + /*typeParameters*/ undefined, + /*heritageClauses*/ undefined, + propertySignatures, + ); + return interfaceDeclaration; +} + +function createTypeAliasForTypeExpression( + typeName: string, + typeExpression: JSDocTypeExpression +): TypeAliasDeclaration | undefined { + const typeReference = getSynthesizedDeepClone(typeExpression.type); + if (!typeReference) return; + const declaration = factory.createTypeAliasDeclaration( + /*modifiers*/ undefined, + factory.createIdentifier(typeName), + /*typeParameters*/ undefined, + typeReference + ); + return declaration; +} + +function createSignatureFromTypeLiteral(typeLiteral: JSDocTypeLiteral): PropertySignature[] | undefined { + const propertyTags = typeLiteral.jsDocPropertyTags; + if (!some(propertyTags)) return; + + const getSignature = (tag: JSDocPropertyLikeTag) => { + const name = getPropertyName(tag); + const type = tag.typeExpression?.type; + const isOptional = tag.isBracketed; + let typeReference; + + // Recursively handle nested object type + if (type && isJSDocTypeLiteral(type)) { + const signatures = createSignatureFromTypeLiteral(type); + typeReference = factory.createTypeLiteralNode(signatures); + } + // Leaf node, where type.kind === SyntaxKind.JSDocTypeExpression + else if (type) { + typeReference = getSynthesizedDeepClone(type); + } + + if (typeReference && name) { + const questionToken = isOptional ? factory.createToken(SyntaxKind.QuestionToken) : undefined; + const prop = factory.createPropertySignature( + /*modifiers*/ undefined, + name, + questionToken, + typeReference + ); + + return prop; + } + }; + + const props = mapDefined(propertyTags, getSignature); + return props; +} + +function getPropertyName(tag: JSDocPropertyLikeTag): string | undefined { + return tag.name.kind === SyntaxKind.Identifier ? tag.name.text : tag.name.right.text; +} + +/** @internal */ +export function getJSDocTypedefNode(node: Node): JSDocTypedefTag | undefined { + if (hasJSDocNodes(node)) { + return forEach(node.jsDoc, (node) => node.tags?.find(isJSDocTypedefTag)); + } + return undefined; +} diff --git a/src/services/suggestionDiagnostics.ts b/src/services/suggestionDiagnostics.ts index 87cf371041f37..3b277ed4f5e91 100644 --- a/src/services/suggestionDiagnostics.ts +++ b/src/services/suggestionDiagnostics.ts @@ -117,6 +117,11 @@ export function computeSuggestionDiagnostics(sourceFile: SourceFile, program: Pr } } + const jsdocTypedefNode = codefix.getJSDocTypedefNode(node); + if (jsdocTypedefNode) { + diags.push(createDiagnosticForNode(jsdocTypedefNode, Diagnostics.JSDoc_typedef_may_be_converted_to_TypeScript_type)); + } + if (codefix.parameterShouldGetTypeFromJSDoc(node)) { diags.push(createDiagnosticForNode(node.name || node, Diagnostics.JSDoc_types_may_be_moved_to_TypeScript_types)); } diff --git a/tests/baselines/reference/tsserver/plugins/getSupportedCodeFixes-can-be-proxied.js b/tests/baselines/reference/tsserver/plugins/getSupportedCodeFixes-can-be-proxied.js index 7382a77537341..fae6f950a030e 100644 --- a/tests/baselines/reference/tsserver/plugins/getSupportedCodeFixes-can-be-proxied.js +++ b/tests/baselines/reference/tsserver/plugins/getSupportedCodeFixes-can-be-proxied.js @@ -309,6 +309,7 @@ Info 32 [00:01:13.000] response: "2713", "1205", "1371", + "80009", "2690", "2420", "2720", diff --git a/tests/cases/fourslash/codeFixConvertTypedefToType1.ts b/tests/cases/fourslash/codeFixConvertTypedefToType1.ts new file mode 100644 index 0000000000000..453986a6b882f --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertTypedefToType1.ts @@ -0,0 +1,18 @@ +/// + +//// +//// /** +//// * @typedef {Object}Foo +//// * @property {number}bar +//// */ +//// + +verify.codeFix({ + description: ts.Diagnostics.Convert_typedef_to_TypeScript_type.message, + index: 0, + newFileContent: ` +interface Foo { + bar: number; +} +`, +}); \ No newline at end of file diff --git a/tests/cases/fourslash/codeFixConvertTypedefToType2.ts b/tests/cases/fourslash/codeFixConvertTypedefToType2.ts new file mode 100644 index 0000000000000..e0746b69052de --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertTypedefToType2.ts @@ -0,0 +1,15 @@ +/// + +//// +//// /** +//// * @typedef {(number|string|undefined)} Foo +//// */ +//// + +verify.codeFix({ + description: ts.Diagnostics.Convert_typedef_to_TypeScript_type.message, + index: 0, + newFileContent: ` +type Foo = (number | string | undefined); +`, +}); \ No newline at end of file diff --git a/tests/cases/fourslash/codeFixConvertTypedefToType3.ts b/tests/cases/fourslash/codeFixConvertTypedefToType3.ts new file mode 100644 index 0000000000000..cbab2306424bf --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertTypedefToType3.ts @@ -0,0 +1,23 @@ +/// + +//// +//// /** +//// * @typedef Foo +//// * type {object} +//// * @property {string} id - person's ID +//// * @property name {string} // person's name +//// * @property {number|undefined} age - person's age +//// */ +//// + +verify.codeFix({ + description: ts.Diagnostics.Convert_typedef_to_TypeScript_type.message, + index: 0, + newFileContent: ` +interface Foo { + id: string; + name: string; + age: number | undefined; +} +`, +}); \ No newline at end of file diff --git a/tests/cases/fourslash/codeFixConvertTypedefToType4.ts b/tests/cases/fourslash/codeFixConvertTypedefToType4.ts new file mode 100644 index 0000000000000..a84c1f9fda9dd --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertTypedefToType4.ts @@ -0,0 +1,30 @@ +/// + +//// +//// /** +//// * @typedef {object} Person +//// * @property {object} data +//// * @property {string} data.name +//// * @property {number} data.age +//// * @property {object} data.contact +//// * @property {string} data.contact.address +//// * @property {string} [data.contact.phone] +//// */ +//// + +verify.codeFix({ + description: ts.Diagnostics.Convert_typedef_to_TypeScript_type.message, + index: 0, + newFileContent: ` +interface Person { + data: { + name: string; + age: number; + contact: { + address: string; + phone?: string; + }; + }; +} +`, +}); \ No newline at end of file diff --git a/tests/cases/fourslash/codeFixConvertTypedefToType5.ts b/tests/cases/fourslash/codeFixConvertTypedefToType5.ts new file mode 100644 index 0000000000000..08d68d9aef729 --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertTypedefToType5.ts @@ -0,0 +1,15 @@ +/// + +//// +//// /** +//// * @typedef {number} Foo +//// */ +//// + +verify.codeFix({ + description: ts.Diagnostics.Convert_typedef_to_TypeScript_type.message, + index: 0, + newFileContent: ` +type Foo = number; +`, +}); \ No newline at end of file