diff --git a/lib/util/Graph.test.ts b/lib/util/Graph.test.ts new file mode 100644 index 0000000..e52880c --- /dev/null +++ b/lib/util/Graph.test.ts @@ -0,0 +1,47 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { Graph } from './Graph.js'; + +describe('Graph', () => { + it('should add edges correctly', () => { + const graph = new Graph(); + graph.addEdge('A', 'B'); + graph.addEdge('A', 'C'); + + assert.equal(graph.vertexes.size, 3); + assert.deepEqual(graph.vertexes.get('A')?.to, new Set(['B', 'C'])); + assert.deepEqual(graph.vertexes.get('B')?.from, new Set(['A'])); + assert.deepEqual(graph.vertexes.get('C')?.from, new Set(['A'])); + }); + + it('should delete vertex correctly', () => { + const graph = new Graph(); + graph.addEdge('A', 'B'); + graph.addEdge('A', 'C'); + graph.addEdge('B', 'C'); + + graph.deleteVertex('A'); + + assert.equal(graph.vertexes.size, 2); + assert.equal(graph.vertexes.has('A'), false); + assert.deepEqual(graph.vertexes.get('B')?.to, new Set(['C'])); + assert.deepEqual(graph.vertexes.get('C')?.from, new Set(['B'])); + }); + + it('should remove vertexes without any edges', () => { + const graph = new Graph(); + graph.addEdge('A', 'B'); + graph.addEdge('A', 'C'); + graph.addEdge('B', 'C'); + graph.deleteVertex('B'); + + assert.equal(graph.vertexes.size, 2); + assert.equal(graph.vertexes.has('B'), false); + assert.equal(graph.vertexes.has('A'), true); + assert.equal(graph.vertexes.has('C'), true); + assert.deepEqual(graph.vertexes.get('A')?.to, new Set(['C'])); + assert.deepEqual(graph.vertexes.get('A')?.from.size, 0); + assert.equal(graph.vertexes.get('C')?.to.size, 0); + assert.deepEqual(graph.vertexes.get('C')?.from, new Set(['A'])); + }); +}); diff --git a/lib/util/Graph.ts b/lib/util/Graph.ts new file mode 100644 index 0000000..70e58a8 --- /dev/null +++ b/lib/util/Graph.ts @@ -0,0 +1,61 @@ +export class Graph { + vertexes = new Map; from: Set }>(); + + private addVertex(vertex: string) { + const selected = this.vertexes.get(vertex); + if (selected) { + return selected; + } + + const created = { to: new Set(), from: new Set() }; + + this.vertexes.set(vertex, created); + + return created; + } + + deleteVertex(vertex: string) { + const selected = this.vertexes.get(vertex); + + if (!selected) { + return; + } + + for (const v of selected.to) { + const target = this.vertexes.get(v); + + if (!target) { + continue; + } + + target.from.delete(vertex); + + if (target.from.size === 0 && target.to.size === 0) { + this.vertexes.delete(v); + } + } + + for (const v of selected.from) { + const target = this.vertexes.get(v); + + if (!target) { + continue; + } + + target.to.delete(vertex); + + if (target.from.size === 0 && target.to.size === 0) { + this.vertexes.delete(v); + } + } + + this.vertexes.delete(vertex); + } + + addEdge(source: string, destination: string): void { + const s = this.addVertex(source); + const d = this.addVertex(destination); + s.to.add(destination); + d.from.add(source); + } +} diff --git a/lib/util/collectDynamicImports.test.ts b/lib/util/collectDynamicImports.test.ts new file mode 100644 index 0000000..9fa26a3 --- /dev/null +++ b/lib/util/collectDynamicImports.test.ts @@ -0,0 +1,58 @@ +import { describe, it } from 'node:test'; +import { setup } from '../../test/helpers/setup.js'; +import { collectDynamicImports } from './collectDynamicImports.js'; +import ts from 'typescript'; +import assert from 'node:assert/strict'; + +const getProgram = (languageService: ts.LanguageService) => { + const program = languageService.getProgram(); + + if (!program) { + throw new Error('Program not found'); + } + + return program; +}; + +describe('collectDynamicImports', () => { + it('should return a graph of dynamic imports', () => { + const { languageService, fileService } = setup(); + fileService.set('/app/main.ts', `import('./a.js');`); + fileService.set('/app/a.ts', `export const a = 'a';`); + + const program = getProgram(languageService); + + const graph = collectDynamicImports({ + fileService, + program, + }); + + assert.equal(graph.vertexes.size, 2); + assert.equal(graph.vertexes.has('/app/main.ts'), true); + assert.equal(graph.vertexes.has('/app/a.ts'), true); + assert.equal(graph.vertexes.get('/app/main.ts')?.to.size, 1); + assert.equal(graph.vertexes.get('/app/main.ts')?.to.has('/app/a.ts'), true); + assert.equal(graph.vertexes.get('/app/main.ts')?.from.size, 0); + assert.equal(graph.vertexes.get('/app/a.ts')?.from.size, 1); + assert.equal( + graph.vertexes.get('/app/a.ts')?.from.has('/app/main.ts'), + true, + ); + assert.equal(graph.vertexes.get('/app/a.ts')?.to.size, 0); + }); + + it('should return an empty graph if no dynamic imports are found', () => { + const { languageService, fileService } = setup(); + fileService.set('/app/main.ts', `import { a } from './a.js';`); + fileService.set('/app/a.ts', `export const a = 'a';`); + + const program = getProgram(languageService); + + const graph = collectDynamicImports({ + fileService, + program, + }); + + assert.equal(graph.vertexes.size, 0); + }); +}); diff --git a/lib/util/collectDynamicImports.ts b/lib/util/collectDynamicImports.ts new file mode 100644 index 0000000..927e38d --- /dev/null +++ b/lib/util/collectDynamicImports.ts @@ -0,0 +1,50 @@ +import ts from 'typescript'; +import { getFileFromModuleSpecifierText } from './getFileFromModuleSpecifierText.js'; +import { FileService } from './FileService.js'; +import { Graph } from './Graph.js'; + +export const collectDynamicImports = ({ + program, + fileService, +}: { + program: ts.Program; + fileService: FileService; +}) => { + const graph = new Graph(); + const files = fileService.getFileNames(); + for (const file of files) { + const sourceFile = program.getSourceFile(file); + + if (!sourceFile) { + continue; + } + + const visit = (node: ts.Node) => { + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments[0] && + ts.isStringLiteral(node.arguments[0]) + ) { + const file = getFileFromModuleSpecifierText({ + specifier: node.arguments[0].text, + program, + fileService, + fileName: sourceFile.fileName, + }); + + if (file) { + graph.addEdge(sourceFile.fileName, file); + } + + return; + } + + node.forEachChild(visit); + }; + + sourceFile.forEachChild(visit); + } + + return graph; +}; diff --git a/lib/util/getFileFromModuleSpecifierText.ts b/lib/util/getFileFromModuleSpecifierText.ts new file mode 100644 index 0000000..d326920 --- /dev/null +++ b/lib/util/getFileFromModuleSpecifierText.ts @@ -0,0 +1,22 @@ +import ts from 'typescript'; +import { FileService } from './FileService.js'; + +export const getFileFromModuleSpecifierText = ({ + specifier, + fileName, + program, + fileService, +}: { + specifier: string; + fileName: string; + program: ts.Program; + fileService: FileService; +}) => + ts.resolveModuleName(specifier, fileName, program.getCompilerOptions(), { + fileExists(fileName) { + return fileService.exists(fileName); + }, + readFile(fileName) { + return fileService.get(fileName); + }, + }).resolvedModule?.resolvedFileName; diff --git a/lib/util/removeUnusedExport.test.ts b/lib/util/removeUnusedExport.test.ts index e7648cd..8c55000 100644 --- a/lib/util/removeUnusedExport.test.ts +++ b/lib/util/removeUnusedExport.test.ts @@ -1,36 +1,7 @@ import { describe, it } from 'node:test'; -import { MemoryFileService } from './MemoryFileService.js'; -import ts from 'typescript'; import assert from 'node:assert/strict'; import { removeUnusedExport } from './removeUnusedExport.js'; - -const setup = () => { - const fileService = new MemoryFileService(); - - const languageService = ts.createLanguageService({ - getCompilationSettings() { - return {}; - }, - getScriptFileNames() { - return fileService.getFileNames(); - }, - getScriptVersion(fileName) { - return fileService.getVersion(fileName); - }, - getScriptSnapshot(fileName) { - return ts.ScriptSnapshot.fromString(fileService.get(fileName)); - }, - getCurrentDirectory: () => '.', - - getDefaultLibFileName(options) { - return ts.getDefaultLibFileName(options); - }, - fileExists: (name) => fileService.exists(name), - readFile: (name) => fileService.get(name), - }); - - return { languageService, fileService }; -}; +import { setup } from '../../test/helpers/setup.js'; describe('removeUnusedExport', () => { describe('variable statement', () => { @@ -832,6 +803,28 @@ const b: B = {};`, ); }); + describe('dynamic import', () => { + it('should not remove export if its used in dynamic import', () => { + const { languageService, fileService } = setup(); + fileService.set( + '/app/main.ts', + `import('./a.js'); +import('./b.js');`, + ); + fileService.set('/app/a.ts', `export const a = 'a';`); + fileService.set('/app/b.ts', `export default 'b';`); + + removeUnusedExport({ + languageService, + fileService, + targetFile: ['/app/a.ts', '/app/b.ts'], + }); + + assert.equal(fileService.get('/app/a.ts'), `export const a = 'a';`); + assert.equal(fileService.get('/app/b.ts'), `export default 'b';`); + }); + }); + describe('deleteUnusedFile', () => { it('should not remove file if some exports are used in other files', () => { const { languageService, fileService } = setup(); diff --git a/lib/util/removeUnusedExport.ts b/lib/util/removeUnusedExport.ts index 2fdb376..50033e5 100644 --- a/lib/util/removeUnusedExport.ts +++ b/lib/util/removeUnusedExport.ts @@ -7,6 +7,8 @@ import { fixIdDeleteImports, } from './applyCodeFix.js'; import { EditTracker } from './EditTracker.js'; +import { getFileFromModuleSpecifierText } from './getFileFromModuleSpecifierText.js'; +import { collectDynamicImports } from './collectDynamicImports.js'; const findFirstNodeOfKind = (root: ts.Node, kind: ts.SyntaxKind) => { let result: ts.Node | undefined; @@ -151,26 +153,6 @@ const getReexportInFile = (file: ts.SourceFile) => { return result; }; -const getFileFromModuleSpecifierText = ({ - specifier, - fileName, - program, - fileService, -}: { - specifier: string; - fileName: string; - program: ts.Program; - fileService: FileService; -}) => - ts.resolveModuleName(specifier, fileName, program.getCompilerOptions(), { - fileExists(fileName) { - return fileService.exists(fileName); - }, - readFile(fileName) { - return fileService.get(fileName); - }, - }).resolvedModule?.resolvedFileName; - const getAncestorFiles = ( node: ts.ExportSpecifier, references: ts.ReferencedSymbol[], @@ -546,6 +528,9 @@ export const removeUnusedExport = ({ throw new Error('program not found'); } + // because ts.LanguageService.findReferences doesn't work with dynamic imports, we need to collect them manually + const dynamicImports = collectDynamicImports({ program, fileService }); + for (const file of Array.isArray(targetFile) ? targetFile : [targetFile]) { const sourceFile = program.getSourceFile(file); @@ -555,6 +540,13 @@ export const removeUnusedExport = ({ editTracker.start(file, sourceFile.getFullText()); + const dynamicImport = dynamicImports.vertexes.get(file); + + if (dynamicImport && dynamicImport.from.size > 0) { + editTracker.end(file); + continue; + } + let content = fileService.get(file); let isUsed = false; @@ -581,6 +573,7 @@ export const removeUnusedExport = ({ if (!isUsed && deleteUnusedFile) { fileService.delete(file); editTracker.delete(file); + dynamicImports.deleteVertex(file); continue; } diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts new file mode 100644 index 0000000..ea6f005 --- /dev/null +++ b/test/helpers/setup.ts @@ -0,0 +1,30 @@ +import ts from 'typescript'; +import { MemoryFileService } from '../../lib/util/MemoryFileService.js'; + +export const setup = () => { + const fileService = new MemoryFileService(); + + const languageService = ts.createLanguageService({ + getCompilationSettings() { + return {}; + }, + getScriptFileNames() { + return fileService.getFileNames(); + }, + getScriptVersion(fileName) { + return fileService.getVersion(fileName); + }, + getScriptSnapshot(fileName) { + return ts.ScriptSnapshot.fromString(fileService.get(fileName)); + }, + getCurrentDirectory: () => '.', + + getDefaultLibFileName(options) { + return ts.getDefaultLibFileName(options); + }, + fileExists: (name) => fileService.exists(name), + readFile: (name) => fileService.get(name), + }); + + return { languageService, fileService }; +}; diff --git a/tsconfig.lib.json b/tsconfig.lib.json index 4fa1c1f..e8a1adc 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -16,5 +16,5 @@ "noUncheckedIndexedAccess": true }, "include": ["lib"], - "exclude": ["lib/**/*.test.ts"] + "exclude": ["lib/**/*.test.ts", "test"] } diff --git a/tsconfig.test.json b/tsconfig.test.json index 8a98aa4..9f02184 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -15,6 +15,7 @@ "outDir": "./node_modules/.tmp", "noUncheckedIndexedAccess": true }, - "include": ["**/*.test.ts"], + "include": ["**/*.test.ts", "test"], + "exclude": ["test/fixtures"], "references": [{ "path": "./tsconfig.lib.json" }] }