diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json index 9b30a51a07b2a..16dac1ac07c36 100644 --- a/src/compiler/diagnosticMessages.json +++ b/src/compiler/diagnosticMessages.json @@ -6598,7 +6598,14 @@ "category": "Message", "code": 90058 }, - + "Export '{0}' from module '{1}'": { + "category": "Message", + "code": 90059 + }, + "Export all missing members from modules": { + "category": "Message", + "code": 90060 + }, "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..6c957a41900fc --- /dev/null +++ b/src/services/codefixes/fixImportNonExportedMember.ts @@ -0,0 +1,204 @@ +/* @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( + /*fixName*/ fixId, + changes, + /*description*/ [ + Diagnostics.Export_0_from_module_1, + info.node.text, + showModuleSpecifier(info.importDecl), + ], + fixId, + /*fixAllDescription*/ Diagnostics.Export_all_missing_members_from_modules + ), + ]; + }, + + 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 (!isIdentifier(node)) { + return; + } + + const importDecl = findAncestor(node, isImportDeclaration); + + if (!importDecl || !isStringLiteralLike(importDecl.moduleSpecifier)) { + return undefined; + } + + const resolvedModule = getResolvedModule( + sourceFile, + /*moduleName*/ importDecl.moduleSpecifier.text, + /*mode*/ undefined + ); + + if (!resolvedModule) { + return undefined; + } + + const originSourceFile = context.program.getSourceFile( + resolvedModule.resolvedFileName + ); + + if (!originSourceFile) { + return undefined; + } + + return { node, importDecl, originSourceFile }; + } + + function sortSpecifiers( + specifiers: ExportSpecifier[] + ): readonly ExportSpecifier[] { + return stableSort(specifiers, (s1, s2) => + compareStringsCaseInsensitive( + (s1.propertyName || s1.name).text, + (s2.propertyName || s2.name).text + ) + ); + } + + const isVariableDeclarationListWith1Element = ( + node: Node + ): node is VariableDeclarationList => + isVariableDeclarationList(node) && node.declarations.length === 1; + + 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 === undefined) { + return; + } + + // Node we need to export is a function, class, or variable declaration which can have `export` prepended + if ( + localSymbol.valueDeclaration !== undefined && + (isDeclarationStatement(localSymbol.valueDeclaration) || + isVariableStatement(localSymbol.valueDeclaration)) + ) { + const node = localSymbol.valueDeclaration; + + return changes.insertExportModifier(sourceFile, node); + } + else if ( + localSymbol.valueDeclaration && + isVariableDeclarationListWith1Element( + localSymbol.valueDeclaration.parent + ) + ) { + const node = localSymbol.valueDeclaration.parent; + + return changes.insertExportModifier(sourceFile, node); + } + + // In all other cases the node should be exported via `export {a}` + // Search for an export statement we can use + for (const namedExportDeclaration of sourceFile.statements) { + if ( + isExportDeclaration(namedExportDeclaration) && + namedExportDeclaration.exportClause && + isNamedExports(namedExportDeclaration.exportClause) && + !namedExportDeclaration.isTypeOnly && // don't use `export type {...}` + namedExportDeclaration.moduleSpecifier === undefined // don't use `export {...} from` + ) { + return changes.replaceNode( + sourceFile, + namedExportDeclaration, + factory.updateExportDeclaration( + namedExportDeclaration, + /*modifiers*/ undefined, + /*isTypeOnly*/ false, + /*exportClause*/ factory.updateNamedExports( + namedExportDeclaration.exportClause, + /*elements*/ sortSpecifiers( + namedExportDeclaration.exportClause.elements.concat( + factory.createExportSpecifier( + /*isTypeOnly*/ false, + /*propertyName*/ undefined, + node + ) + ) + ) + ), + /*moduleSpecifier*/ undefined, + /*assertClause*/ undefined + ) + ); + } + } + + // If we won't find an existing `export` statement we can use, create one + return changes.insertNodeAtEndOfScope( + sourceFile, + sourceFile, + factory.createExportDeclaration( + /*modifiers*/ undefined, + /*isTypeOnly*/ false, + /*exportClause*/ factory.createNamedExports([ + factory.createExportSpecifier( + /*isTypeOnly*/ false, + /*propertyName*/ undefined, + node + ), + ]), + /*moduleSpecifier*/ undefined + ) + ); + } +} diff --git a/src/services/textChanges.ts b/src/services/textChanges.ts index 59f22c8a9fbe0..ff7abe3973636 100644 --- a/src/services/textChanges.ts +++ b/src/services/textChanges.ts @@ -780,8 +780,20 @@ namespace ts.textChanges { } } - public insertExportModifier(sourceFile: SourceFile, node: DeclarationStatement | VariableStatement): void { - this.insertText(sourceFile, node.getStart(sourceFile), "export "); + public insertExportModifier( + sourceFile: SourceFile, + node: DeclarationStatement | VariableStatement | VariableDeclarationList + ): void { + Debug.assert( + /*expression*/ !(isVariableDeclarationList(node) && node.declarations.length !== 1), + /*message*/ "Only allow adding export modifier to variable lists with 1 element" + ); + + this.insertText( + sourceFile, + node.getStart(sourceFile, /*includeJsDocComment*/ false), + "export " + ); } public insertImportSpecifierAtIndex(sourceFile: SourceFile, importSpecifier: ImportSpecifier, namedImports: NamedImports, index: number) { diff --git a/src/services/tsconfig.json b/src/services/tsconfig.json index cef6b9139698a..87b6c01ffc754 100644 --- a/src/services/tsconfig.json +++ b/src/services/tsconfig.json @@ -115,6 +115,7 @@ "codefixes/convertConstToLet.ts", "codefixes/fixExpectedComma.ts", "codefixes/fixAddVoidToPromise.ts", + "codefixes/fixImportNonExportedMember.ts", "refactors/convertExport.ts", "refactors/convertImport.ts", "refactors/convertToOptionalChainExpression.ts", @@ -134,6 +135,6 @@ "transform.ts", "shims.ts", "globalThisShim.ts", - "exportAsModule.ts" + "exportAsModule.ts", ] } diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember_all.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_all.ts new file mode 100644 index 0000000000000..87fe4311a9cc6 --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_all.ts @@ -0,0 +1,23 @@ +/// + +// @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.Export_all_missing_members_from_modules.message, + newFileContent: { + "/a.ts": `export declare function foo(): any; +export declare function bar(): any; +declare function zoo(): any; +export { zoo }`, + }, +}); diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember_class_inAList.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_class_inAList.ts new file mode 100644 index 0000000000000..88faee12ce68b --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_class_inAList.ts @@ -0,0 +1,24 @@ +/// +// @Filename: /a.ts +////class a{}, class b{}; +////export let c = 3; + +// @Filename: /b.ts +////import { a, b, c } from "./a" + +goTo.file("/b.ts"); +verify.codeFixAvailable([ + { description: `Export 'a' from module './a'` }, + { description: `Export 'b' from module './a'` }, + { description: `Remove import from './a'` }, +]); + +// Can export class in list +verify.codeFix({ + index: 1, + description: `Export 'b' from module './a'`, + newFileContent: { + "/a.ts": `class a{}, export class b{}; +export let c = 3;`, + }, +}); diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember_class_onItsOwn.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_class_onItsOwn.ts new file mode 100644 index 0000000000000..c369c0013c4df --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_class_onItsOwn.ts @@ -0,0 +1,22 @@ +/// +// @Filename: /a.ts +////class a{}; +////export let b = 2; + +// @Filename: /b.ts +////import { a, b } from "./a" + +goTo.file("/b.ts"); +verify.codeFixAvailable([ + { description: `Export 'a' from module './a'` }, + { description: `Remove import from './a'` }, +]); +// Can export a class +verify.codeFix({ + index: 0, + description: `Export 'a' from module './a'`, + newFileContent: { + "/a.ts": `export class a{}; +export let b = 2;`, + }, +}); diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember_function_withDeclare.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_function_withDeclare.ts new file mode 100644 index 0000000000000..740b7c6e56446 --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_function_withDeclare.ts @@ -0,0 +1,31 @@ +/// +// @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"); +// Recognises that importing from a file with a `declare` is ok if its exported +verify.codeFixAvailable([ + { description: `Export 'bar' from module './b'` }, + { description: `Remove import from './a'` }, + { description: `Remove import from './b'` }, +]); +// Exports a function with a `declare` correctly +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/codeFixImportNonExportedMember_jsdoc_onClass.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_jsdoc_onClass.ts new file mode 100644 index 0000000000000..d590b39f174fe --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_jsdoc_onClass.ts @@ -0,0 +1,29 @@ +/// +// @Filename: /a.ts +/////** +//// * baz +//// */ +////class a{}; +////export let b = 3; + +// @Filename: /b.ts +////import { a, b } from "./a" + +goTo.file("/b.ts"); +verify.codeFixAvailable([ + { description: `Export 'a' from module './a'` }, + { description: `Remove import from './a'` }, +]); + +// Doesn't clobber jsdoc on classes +verify.codeFix({ + index: 0, + description: `Export 'a' from module './a'`, + newFileContent: { + "/a.ts": `/** + * baz + */ +export class a{}; +export let b = 3;`, + }, +}); diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember_jsdoc_onFunction.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_jsdoc_onFunction.ts new file mode 100644 index 0000000000000..e85b1685f188f --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_jsdoc_onFunction.ts @@ -0,0 +1,29 @@ +/// +// @Filename: /a.ts +/////** +//// * foo +//// */ +////function a(){}; +////export let b = 3; + +// @Filename: /b.ts +////import { a, b } from "./a" + +goTo.file("/b.ts"); +verify.codeFixAvailable([ + { description: `Export 'a' from module './a'` }, + { description: `Remove import from './a'` }, +]); + +// Doesn't clobber jsdoc on functions +verify.codeFix({ + index: 0, + description: `Export 'a' from module './a'`, + newFileContent: { + "/a.ts": `/** + * foo + */ +export function a(){}; +export let b = 3;`, + }, +}); \ No newline at end of file diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember_jsdoc_onVariable.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_jsdoc_onVariable.ts new file mode 100644 index 0000000000000..fe8cd30bb1795 --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_jsdoc_onVariable.ts @@ -0,0 +1,30 @@ +/// +// @Filename: /a.ts +/////** +//// * bar +//// */ +////let a = 4; +////export let b = 3; + +// @Filename: /b.ts +////import { a, b } from "./a" + +goTo.file("/b.ts"); +verify.codeFixAvailable([ + { description: `Export 'a' from module './a'` }, + { description: `Remove import from './a'` }, +]); + + +// Doesn't clobber jsdoc on variables +verify.codeFix({ + index: 0, + description: `Export 'a' from module './a'`, + newFileContent: { + "/a.ts": `/** + * bar + */ +export let a = 4; +export let b = 3;`, + }, +}); diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember_variable_inAList.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_variable_inAList.ts new file mode 100644 index 0000000000000..103e29ec6db05 --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_variable_inAList.ts @@ -0,0 +1,27 @@ +/// + +// @Filename: /a.ts +////let a = 1, b = 2, c = 3; +////export let d = 4; + +// @Filename: /b.ts +////import { a } from "./a" + +goTo.file("/b.ts"); +verify.codeFixAvailable([ + { description: `Export 'a' from module './a'` }, + { description: `Remove import from './a'` }, +]); + +// Can fix a variable in a list (adds a named export) +verify.codeFix({ + index: 0, + description: `Export 'a' from module './a'`, + newFileContent: { + "/a.ts": `let a = 1, b = 2, c = 3; +export let d = 4; + +export { a }; +`, + }, +}); diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember_variable_onItsOwn.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_variable_onItsOwn.ts new file mode 100644 index 0000000000000..fef6d78bb68f7 --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_variable_onItsOwn.ts @@ -0,0 +1,24 @@ +/// +// @Filename: /a.ts +////let a = 4; +////export let b = 2; + +// @Filename: /b.ts +////import { a, b } from "./a" + + +goTo.file("/b.ts"); +verify.codeFixAvailable([ + { description: `Export 'a' from module './a'` }, + { description: `Remove import from './a'` }, +]); + +// Can fix a single variable +verify.codeFix({ + index: 0, + description: `Export 'a' from module './a'`, + newFileContent: { + "/a.ts": `export let a = 4; +export let b = 2;`, + }, +}); diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember_will_useExistingExportStatement.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_will_useExistingExportStatement.ts new file mode 100644 index 0000000000000..669f228cdb0e2 --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_will_useExistingExportStatement.ts @@ -0,0 +1,28 @@ +/// +// @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'` }, +]); +// Can export from list using an existing export +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/codeFixImportNonExportedMember_wont_applyToModuleDotExports.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_wont_applyToModuleDotExports.ts new file mode 100644 index 0000000000000..89bee7093b507 --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_wont_applyToModuleDotExports.ts @@ -0,0 +1,10 @@ +/// +// @Filename: /node_modules/foo/index.d.ts +////let a = 0 +////module.exports = 0; + +// @Filename: /a.ts +////import { a } from "foo"; + +// Won't apply fix if `module.exports` is used +verify.not.codeFixAvailable(); diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember_wont_clobberExportAs.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_wont_clobberExportAs.ts new file mode 100644 index 0000000000000..86d95aa8b6a44 --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_wont_clobberExportAs.ts @@ -0,0 +1,28 @@ +/// +// @Filename: /a.ts +////export let a = 1; + +// @Filename: /b.ts +////let b = 2, c = 3; +////export * as a from "./a"; + +// @Filename: /c.ts +////import { a, b } from "./b" + +// Doesn't use/clobber `export * as a from` +goTo.file("/c.ts"); +verify.codeFixAvailable([ + { description: `Export 'b' from module './b'` }, + { description: `Remove import from './b'` }, +]); +verify.codeFix({ + index: 0, + description: `Export 'b' from module './b'`, + newFileContent: { + "/b.ts": `let b = 2, c = 3; +export * as a from "./a"; + +export { b }; +`, + }, +}); diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember_wont_clobberExportFrom.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_wont_clobberExportFrom.ts new file mode 100644 index 0000000000000..8e13d92c97922 --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_wont_clobberExportFrom.ts @@ -0,0 +1,28 @@ +/// +// @Filename: /a.ts +////export let a = 1; + +// @Filename: /b.ts +////let b = 2, c = 3; +////export { a } from "./a"; + +// @Filename: /c.ts +////import { a, b } from "./b" + +// Doesn't use/clobber `export {...} from` +goTo.file("/c.ts"); +verify.codeFixAvailable([ + { description: `Export 'b' from module './b'` }, + { description: `Remove import from './b'` }, +]); +verify.codeFix({ + index: 0, + description: `Export 'b' from module './b'`, + newFileContent: { + "/b.ts": `let b = 2, c = 3; +export { a } from "./a"; + +export { b }; +`, + }, +}); diff --git a/tests/cases/fourslash/codeFixImportNonExportedMember_wont_clobberExportType.ts b/tests/cases/fourslash/codeFixImportNonExportedMember_wont_clobberExportType.ts new file mode 100644 index 0000000000000..f2a33b00f0821 --- /dev/null +++ b/tests/cases/fourslash/codeFixImportNonExportedMember_wont_clobberExportType.ts @@ -0,0 +1,27 @@ +/// +// @Filename: /a.ts +////let a = 1, b = 2; +////type c = {foo: string}; +////export type { c }; + +// @Filename: /b.ts +////import { a } from "./a" + +// Doesn't use/clobber `export type {...}` +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; +type c = {foo: string}; +export type { c }; + +export { a }; +`, + }, +});