diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json index 82f3cb4ce579b..230150480ee25 100644 --- a/src/compiler/diagnosticMessages.json +++ b/src/compiler/diagnosticMessages.json @@ -5316,6 +5316,14 @@ "category": "Message", "code": 90053 }, + "Export '{0}' from module '{1}'": { + "category": "Message", + "code": 90054 + }, + "Add all missing exports": { + "category": "Message", + "code": 90055 + }, "Convert function to an ES2015 class": { "category": "Message", "code": 95001 diff --git a/src/services/codefixes/fixImportNonExportedMember.ts b/src/services/codefixes/fixImportNonExportedMember.ts new file mode 100644 index 0000000000000..10c3590273334 --- /dev/null +++ b/src/services/codefixes/fixImportNonExportedMember.ts @@ -0,0 +1,110 @@ +/* @internal */ +namespace ts.codefix { + const fixId = "importNonExportedMember"; + const errorCodes = [ + Diagnostics.Module_0_declares_1_locally_but_it_is_not_exported.code + ]; + registerCodeFix({ + errorCodes, + getCodeActions(context) { + const { sourceFile } = context; + const info = getInfo(sourceFile, context, context.span.start); + if (!info || info.originSourceFile.isDeclarationFile) { + return undefined; + } + const changes = textChanges.ChangeTracker.with(context, t => doChange(t, info.originSourceFile, info.node)); + return [createCodeFixAction(fixId, changes, [Diagnostics.Export_0_from_module_1, info.node.text, showModuleSpecifier(info.importDecl)], fixId, Diagnostics.Add_all_missing_exports)]; + }, + fixIds: [fixId], + getAllCodeActions: context => codeFixAll(context, errorCodes, (changes, diag) => { + const info = getInfo(diag.file, context, diag.start); + if (info) doChange(changes, info.originSourceFile, info.node); + }), + }); + + interface Info { + readonly node: Identifier; + readonly importDecl: ImportDeclaration; + readonly originSourceFile: SourceFile + } + + function getInfo(sourceFile: SourceFile, context: CodeFixContext | CodeFixAllContext, pos: number): Info | undefined { + const node = getTokenAtPosition(sourceFile, pos); + if (node && isIdentifier(node)) { + const importDecl = findAncestor(node, isImportDeclaration); + if (!importDecl || !isStringLiteralLike(importDecl.moduleSpecifier)) { + return undefined; + } + const resolvedModule = getResolvedModule(sourceFile, importDecl.moduleSpecifier.text); + const originSourceFile = resolvedModule && context.program.getSourceFile(resolvedModule.resolvedFileName); + if (!originSourceFile) { + return undefined; + } + return { node, importDecl, originSourceFile }; + } + } + + function getNamedExportDeclaration(sourceFile: SourceFile): ExportDeclaration | undefined { + let namedExport; + for (const statement of sourceFile.statements) { + if (isExportDeclaration(statement) && statement.exportClause && + isNamedExports(statement.exportClause)) { + namedExport = statement; + } + } + return namedExport; + } + + function compareIdentifiers(s1: Identifier, s2: Identifier) { + return compareStringsCaseInsensitive(s1.text, s2.text); + } + + function sortSpecifiers(specifiers: ExportSpecifier[]): readonly ExportSpecifier[] { + return stableSort(specifiers, (s1, s2) => { + return compareIdentifiers(s1.propertyName || s1.name, s2.propertyName || s2.name); + }); + } + + function doChange(changes: textChanges.ChangeTracker, sourceFile: SourceFile, node: Identifier): void { + const moduleSymbol = sourceFile.localSymbol || sourceFile.symbol; + const localSymbol = moduleSymbol.valueDeclaration.locals?.get(node.escapedText); + if (!localSymbol) { + return; + } + if (isFunctionSymbol(localSymbol)) { + const start = localSymbol.valueDeclaration.pos; + changes.insertExportModifierAt(sourceFile, start ? start + 1 : 0); + return; + } + + const current: VariableDeclarationList | Node = localSymbol.valueDeclaration.parent; + if (isVariableDeclarationList(current) && current.declarations.length <= 1) { + const start = localSymbol.valueDeclaration.parent.pos; + changes.insertExportModifierAt(sourceFile, start ? start + 1 : 0); + return; + } + + const namedExportDeclaration = getNamedExportDeclaration(sourceFile); + const exportSpecifier = factory.createExportSpecifier(/*propertyName*/ undefined, node); + if (namedExportDeclaration?.exportClause && isNamedExports(namedExportDeclaration.exportClause)) { + const sortedExportSpecifiers = sortSpecifiers(namedExportDeclaration.exportClause.elements.concat(exportSpecifier)); + return changes.replaceNode(sourceFile, namedExportDeclaration, factory.updateExportDeclaration( + namedExportDeclaration, + /*decorators*/ undefined, + /*modifiers*/ undefined, + /*isTypeOnly*/ false, + factory.updateNamedExports(namedExportDeclaration.exportClause, sortedExportSpecifiers), + /*moduleSpecifier*/ undefined + )); + } + else { + return changes.insertNodeAtEndOfScope(sourceFile, sourceFile, factory.createExportDeclaration( + /*decorators*/ undefined, + /*modifiers*/ undefined, + /*isTypeOnly*/ false, + factory.createNamedExports([exportSpecifier]), + /*moduleSpecifier*/ undefined + )); + } + } +} diff --git a/src/services/textChanges.ts b/src/services/textChanges.ts index 023a34c05663f..f8f4ba2a1a4a2 100644 --- a/src/services/textChanges.ts +++ b/src/services/textChanges.ts @@ -686,7 +686,11 @@ namespace ts.textChanges { } public insertExportModifier(sourceFile: SourceFile, node: DeclarationStatement | VariableStatement): void { - this.insertText(sourceFile, node.getStart(sourceFile), "export "); + this.insertExportModifierAt(sourceFile, node.getStart(sourceFile)); + } + + public insertExportModifierAt(sourceFile: SourceFile, position: number): void { + this.insertText(sourceFile, position, "export "); } /** diff --git a/src/services/tsconfig.json b/src/services/tsconfig.json index cc24ac7f9c405..56fca5181a1ad 100644 --- a/src/services/tsconfig.json +++ b/src/services/tsconfig.json @@ -79,6 +79,7 @@ "codefixes/fixPropertyAssignment.ts", "codefixes/fixExtendsInterfaceBecomesImplements.ts", "codefixes/fixForgottenThisPropertyAccess.ts", + "codefixes/fixImportNonExportedMember.ts", "codefixes/fixInvalidJsxCharacters.ts", "codefixes/fixUnusedIdentifier.ts", "codefixes/fixUnreachableCode.ts", diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember1.ts b/tests/cases/fourslash/codeFixImportNonExportedMember1.ts new file mode 100644 index 0000000000000..ef8eb3365ef7d --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember1.ts @@ -0,0 +1,29 @@ +/// +// @Filename: /a.ts +////declare function zoo(): any; +////export { zoo }; + +// @Filename: /b.ts +////declare function foo(): any; +////function bar(): any; +////export { foo }; + +// @Filename: /c.ts +////import { zoo } from "./a"; +////import { bar } from "./b"; + +goTo.file("/c.ts"); +verify.codeFixAvailable([ + { description: `Export 'bar' from module './b'` }, + { description: `Remove import from './a'` }, + { description: `Remove import from './b'` }, +]); +verify.codeFix({ + index: 0, + description: `Export 'bar' from module './b'`, + newFileContent: { + '/b.ts': `declare function foo(): any; +export function bar(): any; +export { foo };` + } +}); diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember2.ts b/tests/cases/fourslash/codeFixImportNonExportedMember2.ts new file mode 100644 index 0000000000000..d6cda9317e92b --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember2.ts @@ -0,0 +1,45 @@ +/// + +// @Filename: /a.ts +////let a = 1, b = 2, c = 3; +////export function whatever() { +////} + +// @Filename: /b.ts +////let d = 4; +////export function whatever2() { +////} + +// @Filename: /c.ts +////import { a } from "./a" +////import { d } from "./b" + +goTo.file("/c.ts"); +verify.codeFixAvailable([ + { description: `Export 'a' from module './a'` }, + { description: `Export 'd' from module './b'` }, + { description: `Remove import from './a'` }, + { description: `Remove import from './b'` }, +]); +verify.codeFix({ + index: 0, + description: `Export 'a' from module './a'`, + newFileContent: { + '/a.ts': `let a = 1, b = 2, c = 3; +export function whatever() { +} + +export { a }; +` + } +}); + +verify.codeFix({ + index: 1, + description: `Export 'd' from module './b'`, + newFileContent: { + '/b.ts': `export let d = 4; +export function whatever2() { +}` + } +}); diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember3.ts b/tests/cases/fourslash/codeFixImportNonExportedMember3.ts new file mode 100644 index 0000000000000..2927ba48cf6d4 --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember3.ts @@ -0,0 +1,27 @@ +/// +// @Filename: /a.ts +////let a = 1, b = 2, c = 3; +////let d = 4; +////export function whatever() { +////} +////export { d } + +// @Filename: /b.ts +////import { a, d } from "./a" + +goTo.file("/b.ts"); +verify.codeFixAvailable([ + { description: `Export 'a' from module './a'` }, + { description: `Remove import from './a'` }, +]); +verify.codeFix({ + index: 0, + description: `Export 'a' from module './a'`, + newFileContent: { + '/a.ts': `let a = 1, b = 2, c = 3; +let d = 4; +export function whatever() { +} +export { a, d };` + } +}); diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember4.ts b/tests/cases/fourslash/codeFixImportNonExportedMember4.ts new file mode 100644 index 0000000000000..381a0e0d380b7 --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember4.ts @@ -0,0 +1,9 @@ +/// +// @Filename: /node_modules/foo/index.d.ts +////let a = 0 +////module.exports = 0; + +// @Filename: /a.ts +////import { a } from "foo"; + +verify.not.codeFixAvailable(); \ No newline at end of file diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember_all.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_all.ts new file mode 100644 index 0000000000000..8486a0680ae36 --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_all.ts @@ -0,0 +1,22 @@ +/// + +// @Filename: /a.ts +////declare function foo(): any; +////declare function bar(): any; +////declare function zoo(): any; +////export { zoo } + +// @Filename: /b.ts +////import { foo, bar } from "./a"; + +goTo.file("/b.ts"); +verify.codeFixAll({ + fixId: "importNonExportedMember", + fixAllDescription: ts.Diagnostics.Add_all_missing_exports.message, + newFileContent: { + '/a.ts': `export declare function foo(): any; +export declare function bar(): any; +declare function zoo(): any; +export { zoo }` + } +});