Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: add support for dynamic imports #34

Merged
merged 8 commits into from
Sep 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions lib/util/Graph.test.ts
Original file line number Diff line number Diff line change
@@ -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']));
});
});
61 changes: 61 additions & 0 deletions lib/util/Graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
export class Graph {
vertexes = new Map<string, { to: Set<string>; from: Set<string> }>();

private addVertex(vertex: string) {
const selected = this.vertexes.get(vertex);
if (selected) {
return selected;
}

const created = { to: new Set<string>(), from: new Set<string>() };

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);
}
}
58 changes: 58 additions & 0 deletions lib/util/collectDynamicImports.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
50 changes: 50 additions & 0 deletions lib/util/collectDynamicImports.ts
Original file line number Diff line number Diff line change
@@ -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;
};
22 changes: 22 additions & 0 deletions lib/util/getFileFromModuleSpecifierText.ts
Original file line number Diff line number Diff line change
@@ -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;
53 changes: 23 additions & 30 deletions lib/util/removeUnusedExport.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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();
Expand Down
33 changes: 13 additions & 20 deletions lib/util/removeUnusedExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[],
Expand Down Expand Up @@ -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);

Expand All @@ -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;

Expand All @@ -581,6 +573,7 @@ export const removeUnusedExport = ({
if (!isUsed && deleteUnusedFile) {
fileService.delete(file);
editTracker.delete(file);
dynamicImports.deleteVertex(file);

continue;
}
Expand Down
Loading
Loading