diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json index 8b6f0edb05914..a74d39fd07d3d 100644 --- a/src/compiler/diagnosticMessages.json +++ b/src/compiler/diagnosticMessages.json @@ -3826,6 +3826,10 @@ "category": "Suggestion", "code": 80002 }, + "Import may be converted to a default import.": { + "category": "Suggestion", + "code": 80003 + }, "Add missing 'super()' call": { "category": "Message", diff --git a/src/services/codeFixProvider.ts b/src/services/codeFixProvider.ts index 502f8457d8c4a..0bfab4eedfcdc 100644 --- a/src/services/codeFixProvider.ts +++ b/src/services/codeFixProvider.ts @@ -96,7 +96,7 @@ namespace ts { } function eachDiagnostic({ program, sourceFile }: CodeFixAllContext, errorCodes: number[], cb: (diag: Diagnostic) => void): void { - for (const diag of program.getSemanticDiagnostics(sourceFile)) { + for (const diag of program.getSemanticDiagnostics(sourceFile).concat(computeSuggestionDiagnostics(sourceFile, program))) { if (contains(errorCodes, diag.code)) { cb(diag); } diff --git a/src/services/codefixes/convertToEs6Module.ts b/src/services/codefixes/convertToEs6Module.ts index 2ce8430e8b469..fcde80e81fbf0 100644 --- a/src/services/codefixes/convertToEs6Module.ts +++ b/src/services/codefixes/convertToEs6Module.ts @@ -495,9 +495,13 @@ namespace ts.codefix { : makeImport(/*name*/ undefined, [makeImportSpecifier(propertyName, localName)], moduleSpecifier); } - function makeImport(name: Identifier | undefined, namedImports: ReadonlyArray, moduleSpecifier: string): ImportDeclaration { + function makeImport(name: Identifier | undefined, namedImports: ReadonlyArray | undefined, moduleSpecifier: string): ImportDeclaration { + return makeImportDeclaration(name, namedImports, createLiteral(moduleSpecifier)); + } + + export function makeImportDeclaration(name: Identifier, namedImports: ReadonlyArray | undefined, moduleSpecifier: Expression) { const importClause = (name || namedImports) && createImportClause(name, namedImports && createNamedImports(namedImports)); - return createImportDeclaration(/*decorators*/ undefined, /*modifiers*/ undefined, importClause, createLiteral(moduleSpecifier)); + return createImportDeclaration(/*decorators*/ undefined, /*modifiers*/ undefined, importClause, moduleSpecifier); } function makeImportSpecifier(propertyName: string | undefined, name: string): ImportSpecifier { diff --git a/src/services/codefixes/fixInvalidImportSyntax.ts b/src/services/codefixes/fixInvalidImportSyntax.ts index e6f18cf5bd7c6..0939475b8350c 100644 --- a/src/services/codefixes/fixInvalidImportSyntax.ts +++ b/src/services/codefixes/fixInvalidImportSyntax.ts @@ -26,12 +26,7 @@ namespace ts.codefix { const variations: CodeAction[] = []; // import Bluebird from "bluebird"; - variations.push(createAction(context, sourceFile, node, createImportDeclaration( - /*decorators*/ undefined, - /*modifiers*/ undefined, - createImportClause(namespace.name, /*namedBindings*/ undefined), - node.moduleSpecifier - ))); + variations.push(createAction(context, sourceFile, node, makeImportDeclaration(namespace.name, /*namedImports*/ undefined, node.moduleSpecifier))); if (getEmitModuleKind(opts) === ModuleKind.CommonJS) { // import Bluebird = require("bluebird"); diff --git a/src/services/codefixes/fixes.ts b/src/services/codefixes/fixes.ts index 27435a56dbf59..43313011bd22c 100644 --- a/src/services/codefixes/fixes.ts +++ b/src/services/codefixes/fixes.ts @@ -20,3 +20,4 @@ /// /// /// +/// diff --git a/src/services/codefixes/useDefaultImport.ts b/src/services/codefixes/useDefaultImport.ts new file mode 100644 index 0000000000000..bd0797953b96a --- /dev/null +++ b/src/services/codefixes/useDefaultImport.ts @@ -0,0 +1,43 @@ +/* @internal */ +namespace ts.codefix { + const fixId = "useDefaultImport"; + const errorCodes = [Diagnostics.Import_may_be_converted_to_a_default_import.code]; + registerCodeFix({ + errorCodes, + getCodeActions(context) { + const { sourceFile, span: { start } } = context; + const info = getInfo(sourceFile, start); + if (!info) return undefined; + const description = getLocaleSpecificMessage(Diagnostics.Convert_to_default_import); + const changes = textChanges.ChangeTracker.with(context, t => doChange(t, sourceFile, info)); + return [{ description, changes, fixId }]; + }, + fixIds: [fixId], + getAllCodeActions: context => codeFixAll(context, errorCodes, (changes, diag) => { + const info = getInfo(diag.file!, diag.start!); + if (info) doChange(changes, diag.file!, info); + }), + }); + + interface Info { + readonly importNode: AnyImportSyntax; + readonly name: Identifier; + readonly moduleSpecifier: Expression; + } + function getInfo(sourceFile: SourceFile, pos: number): Info | undefined { + const name = getTokenAtPosition(sourceFile, pos, /*includeJsDocComment*/ false); + if (!isIdentifier(name)) return undefined; // bad input + const { parent } = name; + if (isImportEqualsDeclaration(parent) && isExternalModuleReference(parent.moduleReference)) { + return { importNode: parent, name, moduleSpecifier: parent.moduleReference.expression }; + } + else if (isNamespaceImport(parent)) { + const importNode = parent.parent.parent; + return { importNode, name, moduleSpecifier: importNode.moduleSpecifier }; + } + } + + function doChange(changes: textChanges.ChangeTracker, sourceFile: SourceFile, info: Info): void { + changes.replaceNode(sourceFile, info.importNode, makeImportDeclaration(info.name, /*namedImports*/ undefined, info.moduleSpecifier)); + } +} diff --git a/src/services/refactors/refactors.ts b/src/services/refactors/refactors.ts index c63f66dbbb8e6..0e5158563c83a 100644 --- a/src/services/refactors/refactors.ts +++ b/src/services/refactors/refactors.ts @@ -1,3 +1,2 @@ /// /// -/// diff --git a/src/services/refactors/useDefaultImport.ts b/src/services/refactors/useDefaultImport.ts deleted file mode 100644 index 080092e16ab6a..0000000000000 --- a/src/services/refactors/useDefaultImport.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* @internal */ -namespace ts.refactor.installTypesForPackage { - const actionName = "Convert to default import"; - const description = getLocaleSpecificMessage(Diagnostics.Convert_to_default_import); - registerRefactor(actionName, { getEditsForAction, getAvailableActions }); - - function getAvailableActions(context: RefactorContext): ApplicableRefactorInfo[] | undefined { - const { file, startPosition, program } = context; - - if (!getAllowSyntheticDefaultImports(program.getCompilerOptions())) { - return undefined; - } - - const importInfo = getConvertibleImportAtPosition(file, startPosition); - if (!importInfo) { - return undefined; - } - - const module = getResolvedModule(file, importInfo.moduleSpecifier.text); - const resolvedFile = module && program.getSourceFile(module.resolvedFileName); - if (!(resolvedFile && resolvedFile.externalModuleIndicator && isExportAssignment(resolvedFile.externalModuleIndicator) && resolvedFile.externalModuleIndicator.isExportEquals)) { - return undefined; - } - - return [ - { - name: actionName, - description, - actions: [ - { - description, - name: actionName, - }, - ], - }, - ]; - } - - function getEditsForAction(context: RefactorContext, _actionName: string): RefactorEditInfo | undefined { - const { file, startPosition } = context; - Debug.assertEqual(actionName, _actionName); - const importInfo = getConvertibleImportAtPosition(file, startPosition); - if (!importInfo) { - return undefined; - } - const { importStatement, name, moduleSpecifier } = importInfo; - const newImportClause = createImportClause(name, /*namedBindings*/ undefined); - const newImportStatement = createImportDeclaration(/*decorators*/ undefined, /*modifiers*/ undefined, newImportClause, moduleSpecifier); - return { - edits: textChanges.ChangeTracker.with(context, t => t.replaceNode(file, importStatement, newImportStatement)), - renameFilename: undefined, - renameLocation: undefined, - }; - } - - function getConvertibleImportAtPosition( - file: SourceFile, - startPosition: number, - ): { importStatement: AnyImportSyntax, name: Identifier, moduleSpecifier: StringLiteral } | undefined { - let node = getTokenAtPosition(file, startPosition, /*includeJsDocComment*/ false); - while (true) { - switch (node.kind) { - case SyntaxKind.ImportEqualsDeclaration: - const eq = node as ImportEqualsDeclaration; - const { moduleReference } = eq; - return moduleReference.kind === SyntaxKind.ExternalModuleReference && isStringLiteral(moduleReference.expression) - ? { importStatement: eq, name: eq.name, moduleSpecifier: moduleReference.expression } - : undefined; - case SyntaxKind.ImportDeclaration: - const d = node as ImportDeclaration; - const { importClause } = d; - return importClause && !importClause.name && importClause.namedBindings.kind === SyntaxKind.NamespaceImport && isStringLiteral(d.moduleSpecifier) - ? { importStatement: d, name: importClause.namedBindings.name, moduleSpecifier: d.moduleSpecifier } - : undefined; - // For known child node kinds of convertible imports, try again with parent node. - case SyntaxKind.NamespaceImport: - case SyntaxKind.ExternalModuleReference: - case SyntaxKind.ImportKeyword: - case SyntaxKind.Identifier: - case SyntaxKind.StringLiteral: - case SyntaxKind.AsteriskToken: - break; - default: - return undefined; - } - node = node.parent; - } - } -} diff --git a/src/services/suggestionDiagnostics.ts b/src/services/suggestionDiagnostics.ts index bb6e510d2fdea..602b7e2529783 100644 --- a/src/services/suggestionDiagnostics.ts +++ b/src/services/suggestionDiagnostics.ts @@ -25,6 +25,30 @@ namespace ts { check(sourceFile); } + if (getAllowSyntheticDefaultImports(program.getCompilerOptions())) { + for (const importNode of sourceFile.imports) { + const name = importNameForConvertToDefaultImport(importNode.parent); + if (!name) continue; + const module = getResolvedModule(sourceFile, importNode.text); + const resolvedFile = module && program.getSourceFile(module.resolvedFileName); + if (resolvedFile && resolvedFile.externalModuleIndicator && isExportAssignment(resolvedFile.externalModuleIndicator) && resolvedFile.externalModuleIndicator.isExportEquals) { + diags.push(createDiagnosticForNode(name, Diagnostics.Import_may_be_converted_to_a_default_import)); + } + } + } + return diags.concat(checker.getSuggestionDiagnostics(sourceFile)); } + + function importNameForConvertToDefaultImport(node: Node): Identifier | undefined { + if (isExternalModuleReference(node)) { + return node.parent.name; + } + if (isImportDeclaration(node)) { + const { importClause, moduleSpecifier } = node; + return importClause && !importClause.name && importClause.namedBindings.kind === SyntaxKind.NamespaceImport && isStringLiteral(moduleSpecifier) + ? importClause.namedBindings.name + : undefined; + } + } } diff --git a/tests/cases/fourslash/codeFixUseDefaultImport.ts b/tests/cases/fourslash/codeFixUseDefaultImport.ts new file mode 100644 index 0000000000000..74a7181000572 --- /dev/null +++ b/tests/cases/fourslash/codeFixUseDefaultImport.ts @@ -0,0 +1,39 @@ +/// + +// @allowSyntheticDefaultImports: true + +// @Filename: /a.d.ts +////declare const x: number; +////export = x; + +// @Filename: /b.ts +////import * as [|a|] from "./a"; + +// @Filename: /c.ts +////import [|a|] = require("./a"); + +// @Filename: /d.ts +////import "./a"; + +// @Filename: /e.ts +////import * as n from "./non-existant"; + +for (const file of ["/b.ts", "/c.ts"]) { + goTo.file(file); + + verify.getSuggestionDiagnostics([{ + message: "Import may be converted to a default import.", + range: test.ranges().find(r => r.fileName === file), + code: 80003, + }]); + + verify.codeFix({ + description: "Convert to default import", + newFileContent: `import a from "./a";`, + }); +} + +for (const file of ["/d.ts", "/e.ts"]) { + goTo.file(file); + verify.getSuggestionDiagnostics([]); +} diff --git a/tests/cases/fourslash/codeFixUseDefaultImport_all.ts b/tests/cases/fourslash/codeFixUseDefaultImport_all.ts new file mode 100644 index 0000000000000..0d02eb9b38996 --- /dev/null +++ b/tests/cases/fourslash/codeFixUseDefaultImport_all.ts @@ -0,0 +1,18 @@ +/// + +// @allowSyntheticDefaultImports: true + +// @Filename: /a.d.ts +////declare const x: number; +////export = x; + +// @Filename: /b.ts +////import * as [|a1|] from "./a"; +////import [|a2|] = require("./a"); + +goTo.file("/b.ts"); +verify.codeFixAll({ + fixId: "useDefaultImport", + // TODO: GH#22337 + newFileContent: `import a1 from "./a";import a2 from "./a";`, +}); diff --git a/tests/cases/fourslash/convertFunctionToEs6Class1.ts b/tests/cases/fourslash/convertFunctionToEs6Class1.ts index d14ca4f23bd00..a0046fc4e9088 100644 --- a/tests/cases/fourslash/convertFunctionToEs6Class1.ts +++ b/tests/cases/fourslash/convertFunctionToEs6Class1.ts @@ -13,7 +13,6 @@ verify.getSuggestionDiagnostics([{ message: "This constructor function may be converted to a class declaration.", - category: "suggestion", code: 80002, }]); diff --git a/tests/cases/fourslash/refactorConvertToEs6Module_export_named.ts b/tests/cases/fourslash/refactorConvertToEs6Module_export_named.ts index fc910965aad0e..6f4c3ecbde4d0 100644 --- a/tests/cases/fourslash/refactorConvertToEs6Module_export_named.ts +++ b/tests/cases/fourslash/refactorConvertToEs6Module_export_named.ts @@ -9,7 +9,6 @@ verify.getSuggestionDiagnostics([{ message: "File is a CommonJS module; it may be converted to an ES6 module.", - category: "suggestion", code: 80001, }]); diff --git a/tests/cases/fourslash/refactorUseDefaultImport.ts b/tests/cases/fourslash/refactorUseDefaultImport.ts deleted file mode 100644 index 3846c1d5c1e81..0000000000000 --- a/tests/cases/fourslash/refactorUseDefaultImport.ts +++ /dev/null @@ -1,41 +0,0 @@ -/// - -// @allowSyntheticDefaultImports: true - -// @Filename: /a.d.ts -////declare const x: number; -////export = x; - -// @Filename: /b.ts -/////*b0*/import * as a from "./a";/*b1*/ - -// @Filename: /c.ts -/////*c0*/import a = require("./a");/*c1*/ - -// @Filename: /d.ts -/////*d0*/import "./a";/*d1*/ - -// @Filename: /e.ts -/////*e0*/import * as n from "./non-existant";/*e1*/ - -goTo.select("b0", "b1"); -edit.applyRefactor({ - refactorName: "Convert to default import", - actionName: "Convert to default import", - actionDescription: "Convert to default import", - newContent: 'import a from "./a";', -}); - -goTo.select("c0", "c1"); -edit.applyRefactor({ - refactorName: "Convert to default import", - actionName: "Convert to default import", - actionDescription: "Convert to default import", - newContent: 'import a from "./a";', -}); - -goTo.select("d0", "d1"); -verify.not.applicableRefactorAvailableAtMarker("d0"); - -goTo.select("e0", "e1"); -verify.not.applicableRefactorAvailableAtMarker("e0"); \ No newline at end of file