diff --git a/lib/util/edit.test.ts b/lib/util/edit.test.ts index 27b5423..7f1d038 100644 --- a/lib/util/edit.test.ts +++ b/lib/util/edit.test.ts @@ -1130,6 +1130,26 @@ export const b = 'b';`, assert.equal(fileService.exists('/app/a_reexport.ts'), false); assert.equal(fileService.exists('/app/a.ts'), false); }); + + it('should look for deeply nested whole re-export without removing files', () => { + const fileService = new MemoryFileService(); + fileService.set('/app/main.ts', `import { c } from './a';`); + fileService.set('/app/a.ts', `export * from './b';`); + fileService.set('/app/b.ts', `export * from './c';`); + fileService.set('/app/c.ts', `export const c = 'c';`); + + edit({ + fileService, + recursive, + deleteUnusedFile: true, + entrypoints: ['/app/main.ts'], + }); + + assert.equal(fileService.get('/app/main.ts'), `import { c } from './a';`); + assert.equal(fileService.get('/app/a.ts'), `export * from './b';`); + assert.equal(fileService.get('/app/b.ts'), `export * from './c';`); + assert.equal(fileService.get('/app/c.ts'), `export const c = 'c';`); + }); }); describe('namespace export declaration', () => { diff --git a/lib/util/edit.ts b/lib/util/edit.ts index 2ec5266..a2fd7e5 100644 --- a/lib/util/edit.ts +++ b/lib/util/edit.ts @@ -12,6 +12,12 @@ import { MemoryFileService } from './MemoryFileService.js'; import { findFileUsage } from './findFileUsage.js'; import { parseFile } from './parseFile.js'; import { Output } from './Output.js'; +import { + WholeExportDeclarationWithFile, + isWholeExportDeclarationWithFile, + isNamedExport, + isWholeExportDeclaration, +} from './export.js'; const transform = ( source: string, @@ -148,7 +154,7 @@ const updateExportDeclaration = (code: string, unused: string[]) => { const printer = ts.createPrinter(); const printed = result ? printer.printFile(result).replace(/\n$/, '') : ''; - const leading = code.match(/^([\s]+)/)?.[0] || ''; + const leading = code.match(/^(\s+)/)?.[0] || ''; return `${leading}${printed}`; }; @@ -181,6 +187,76 @@ const getSpecifierPosition = (exportDeclaration: string) => { return result; }; +/** + * Retrieves the names of the exports from a whole export declaration. + * For each whole export declaration, it will recursively get the names of the exports from the file it points to. + */ +const deeplyGetExportNames = ({ + item, + files, + fileNames, + options, +}: { + item: WholeExportDeclarationWithFile; + files: Map; + fileNames: Set; + options: ts.CompilerOptions; +}): string[] => { + const filesAlreadyVisited = new Set(); + + return innerDeeplyGetExportNames({ + item, + files, + fileNames, + options, + filesAlreadyVisited, + }); +}; + +const innerDeeplyGetExportNames = ({ + item, + files, + fileNames, + options, + filesAlreadyVisited, +}: { + item: WholeExportDeclarationWithFile; + files: Map; + fileNames: Set; + options: ts.CompilerOptions; + filesAlreadyVisited: Set; +}): string[] => { + if (filesAlreadyVisited.has(item.file)) { + return []; + } + + const parsed = parseFile({ + file: item.file, + content: files.get(item.file) || '', + options, + destFiles: fileNames, + }); + + const deepExportNames = parsed.exports + .filter( + (v) => isWholeExportDeclaration(v) && isWholeExportDeclarationWithFile(v), + ) + .flatMap((v) => + innerDeeplyGetExportNames({ + item: v, + files, + fileNames, + options, + filesAlreadyVisited: filesAlreadyVisited.add(item.file), + }), + ); + + return parsed.exports + .filter(isNamedExport) + .flatMap((v) => v.name) + .concat(deepExportNames); +}; + const processFile = ({ targetFile, files, @@ -424,23 +500,19 @@ const processFile = ({ break; } case 'whole': { - if (!item.file) { + if (!isWholeExportDeclarationWithFile(item)) { // whole export is directed towards a file that is not in the project break; } - const parsed = parseFile({ - file: item.file, - content: files.get(item.file) || '', + const exportNames = deeplyGetExportNames({ + item, + files, + fileNames, options, - destFiles: fileNames, }); - const exported = parsed.exports.flatMap((v) => - 'name' in v ? v.name : [], - ); - - if (exported.some((v) => usage.has(v))) { + if (exportNames.some((v) => usage.has(v))) { break; } @@ -582,13 +654,11 @@ export {};\n`, fileService.set(targetFile, content); - const result = { + return { operation: 'edit' as const, content: fileService.get(targetFile), removedExports: logs, }; - - return result; }; export const edit = ({ diff --git a/lib/util/export.ts b/lib/util/export.ts new file mode 100644 index 0000000..61b925d --- /dev/null +++ b/lib/util/export.ts @@ -0,0 +1,187 @@ +import ts from 'typescript'; + +type ClassDeclaration = { + kind: ts.SyntaxKind.ClassDeclaration; + name: string; + change: { + code: string; + isUnnamedDefaultExport?: boolean; + span: { + start: number; + length: number; + }; + }; + skip: boolean; + start: number; +}; + +type EnumDeclaration = { + kind: ts.SyntaxKind.EnumDeclaration; + name: string; + change: { + code: string; + span: { + start: number; + length: number; + }; + }; + skip: boolean; + start: number; +}; + +type ExportAssignment = { + kind: ts.SyntaxKind.ExportAssignment; + name: 'default'; + change: { + code: string; + span: { + start: number; + length: number; + }; + }; + skip: boolean; + start: number; +}; + +type FunctionDeclaration = { + kind: ts.SyntaxKind.FunctionDeclaration; + name: string; + change: { + code: string; + isUnnamedDefaultExport?: boolean; + span: { + start: number; + length: number; + }; + }; + skip: boolean; + start: number; +}; + +type InterfaceDeclaration = { + kind: ts.SyntaxKind.InterfaceDeclaration; + name: string; + change: { + code: string; + span: { + start: number; + length: number; + }; + }; + skip: boolean; + start: number; +}; + +type NameExportDeclaration = { + kind: ts.SyntaxKind.ExportDeclaration; + type: 'named'; + name: string[]; + skip: boolean; + change: { + code: string; + span: { + start: number; + length: number; + }; + }; + start: number; +}; + +type NamespaceExportDeclaration = { + kind: ts.SyntaxKind.ExportDeclaration; + type: 'namespace'; + name: string; + start: number; + change: { + code: string; + span: { + start: number; + length: number; + }; + }; +}; + +type TypeAliasDeclaration = { + kind: ts.SyntaxKind.TypeAliasDeclaration; + name: string; + change: { + code: string; + span: { + start: number; + length: number; + }; + }; + skip: boolean; + start: number; +}; + +type VariableStatement = { + kind: ts.SyntaxKind.VariableStatement; + name: string[]; + change: { + code: string; + span: { + start: number; + length: number; + }; + }; + skip: boolean; + start: number; +}; + +type NamedExport = + | ClassDeclaration + | EnumDeclaration + | ExportAssignment + | FunctionDeclaration + | InterfaceDeclaration + | NameExportDeclaration + | NamespaceExportDeclaration + | TypeAliasDeclaration + | VariableStatement; + +type WholeExportDeclarationBase = { + kind: ts.SyntaxKind.ExportDeclaration; + type: 'whole'; + specifier: string; + start: number; + change: { + code: string; + span: { + start: number; + length: number; + }; + }; +}; + +/** + * Whole export when the file is found within the destFiles + */ +export type WholeExportDeclarationWithFile = WholeExportDeclarationBase & { + file: string; +}; + +/** + * Whole export when the file is not found within the destFiles, i.e. the file is not part of the project + */ +type WholeExportDeclarationWithoutFile = WholeExportDeclarationBase & { + file: null; +}; + +type WholeExportDeclaration = + | WholeExportDeclarationWithFile + | WholeExportDeclarationWithoutFile; + +export const isWholeExportDeclarationWithFile = ( + exportDeclaration: WholeExportDeclaration, +): exportDeclaration is WholeExportDeclarationWithFile => + exportDeclaration.file !== null; + +export type Export = NamedExport | WholeExportDeclaration; + +export const isNamedExport = (v: Export): v is NamedExport => 'name' in v; + +export const isWholeExportDeclaration = ( + v: Export, +): v is WholeExportDeclaration => + v.kind === ts.SyntaxKind.ExportDeclaration && v.type === 'whole'; diff --git a/lib/util/findFileUsage.ts b/lib/util/findFileUsage.ts index 1047465..3e79482 100644 --- a/lib/util/findFileUsage.ts +++ b/lib/util/findFileUsage.ts @@ -2,7 +2,8 @@ import ts from 'typescript'; import { Vertexes } from './DependencyGraph.js'; import { parseFile } from './parseFile.js'; -const ALL_EXPORTS_OF_UNKNOWN_FILE = '__all_exports_of_unknown_file__'; +const ALL_EXPORTS_OF_UNKNOWN_FILE = '#all_exports_of_unknown_file#'; +const CIRCULAR_DEPENDENCY = '#circular_dependency#'; const getExportsOfFile = ({ targetFile, @@ -17,6 +18,7 @@ const getExportsOfFile = ({ }) => { const result: string[] = []; + const alreadyVisited = new Set(); const stack = [targetFile]; while (stack.length) { @@ -26,6 +28,13 @@ const getExportsOfFile = ({ break; } + if (alreadyVisited.has(item)) { + result.push(CIRCULAR_DEPENDENCY); + continue; + } + + alreadyVisited.add(item); + const { exports } = parseFile({ file: item, content: files.get(item) || '', @@ -81,7 +90,7 @@ export const findFileUsage = ({ ); while (stack.length) { - const item = stack.pop()!; + const item = stack.pop(); if (!item) { break; diff --git a/lib/util/parseFile.ts b/lib/util/parseFile.ts index bdf1cda..7be7c47 100644 --- a/lib/util/parseFile.ts +++ b/lib/util/parseFile.ts @@ -1,6 +1,7 @@ import ts from 'typescript'; import { memoize } from './memoize.js'; import { namespaceUsage } from './namespaceUsage.js'; +import { Export } from './export.js'; const getLeadingComment = (node: ts.Node) => { const fullText = node.getSourceFile().getFullText(); @@ -128,143 +129,6 @@ const getChange = ( }; }; -type Export = - | { - kind: ts.SyntaxKind.VariableStatement; - name: string[]; - change: { - code: string; - span: { - start: number; - length: number; - }; - }; - skip: boolean; - start: number; - } - | { - kind: ts.SyntaxKind.FunctionDeclaration; - name: string; - change: { - code: string; - isUnnamedDefaultExport?: boolean; - span: { - start: number; - length: number; - }; - }; - skip: boolean; - start: number; - } - | { - kind: ts.SyntaxKind.InterfaceDeclaration; - name: string; - change: { - code: string; - span: { - start: number; - length: number; - }; - }; - skip: boolean; - start: number; - } - | { - kind: ts.SyntaxKind.TypeAliasDeclaration; - name: string; - change: { - code: string; - span: { - start: number; - length: number; - }; - }; - skip: boolean; - start: number; - } - | { - kind: ts.SyntaxKind.ExportAssignment; - name: 'default'; - change: { - code: string; - span: { - start: number; - length: number; - }; - }; - skip: boolean; - start: number; - } - | { - kind: ts.SyntaxKind.ExportDeclaration; - type: 'named'; - name: string[]; - skip: boolean; - change: { - code: string; - span: { - start: number; - length: number; - }; - }; - start: number; - } - | { - kind: ts.SyntaxKind.ExportDeclaration; - type: 'namespace'; - name: string; - start: number; - change: { - code: string; - span: { - start: number; - length: number; - }; - }; - } - | { - kind: ts.SyntaxKind.ExportDeclaration; - type: 'whole'; - // will be null if the file is not found within the destFiles, i.e. the file is not part of the project - file: string | null; - specifier: string; - start: number; - change: { - code: string; - span: { - start: number; - length: number; - }; - }; - } - | { - kind: ts.SyntaxKind.ClassDeclaration; - name: string; - change: { - code: string; - isUnnamedDefaultExport?: boolean; - span: { - start: number; - length: number; - }; - }; - skip: boolean; - start: number; - } - | { - kind: ts.SyntaxKind.EnumDeclaration; - name: string; - change: { - code: string; - span: { - start: number; - length: number; - }; - }; - skip: boolean; - start: number; - }; - type AmbientDeclaration = { kind: ts.SyntaxKind.ModuleDeclaration; };