Skip to content

Commit

Permalink
Merge pull request #34 from line/fix/dynamic-imports
Browse files Browse the repository at this point in the history
fix: add support for dynamic imports
  • Loading branch information
kazushisan authored Sep 22, 2024
2 parents 66aaed3 + 763071c commit 876b8fa
Show file tree
Hide file tree
Showing 10 changed files with 307 additions and 52 deletions.
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

0 comments on commit 876b8fa

Please sign in to comment.