Skip to content
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
124 changes: 34 additions & 90 deletions src/converter/convertFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,124 +3,68 @@ import {ProjectUtil} from '../util/ProjectUtil.js';
import {convertFile} from './convertFile.js';

describe('convertFile', () => {
const fixtures = path.join(process.cwd(), 'src', 'test', 'fixtures');
function testFileConversion(directoryName: string, fileName = 'main', extension = 'ts') {
const fixtures = path.join(process.cwd(), 'src', 'test', 'fixtures');
const projectDir = path.join(fixtures, directoryName);
const projectConfig = path.join(projectDir, 'tsconfig.json');
const project = ProjectUtil.getProject(projectConfig);

const snapshot = path.join(projectDir, 'src', `${fileName}.snap.${extension}`);
const sourceFileName = `${fileName}.${extension}`;
const sourceFile = project.getSourceFile(sourceFileName);
if (!sourceFile) {
throw new Error(`File "${sourceFileName}" not found.`);
}
const modifiedFile = convertFile(sourceFile);
if (!modifiedFile) {
throw new Error(`File "${sourceFileName}" was not converted.`);
}
return expect(modifiedFile.getFullText()).toMatchFileSnapshot(snapshot);
}

describe('imports', () => {
it('fixes imports from index files', async () => {
const projectDir = path.join(fixtures, 'index-import');
const projectConfig = path.join(projectDir, 'tsconfig.json');
const project = ProjectUtil.getProject(projectConfig);

const snapshot = path.join(projectDir, 'src', 'main.snap.ts');
const sourceFile = project.getSourceFile('main.ts')!;
const modifiedFile = convertFile(sourceFile);

await expect(modifiedFile?.getFullText()).toMatchFileSnapshot(snapshot);
it('converts imports from index files', async () => {
await testFileConversion('index-import');
});

it('fixes imports when tsconfig has an "include" property', async () => {
const projectDir = path.join(fixtures, 'tsconfig-include');
const projectConfig = path.join(projectDir, 'tsconfig.json');
const project = ProjectUtil.getProject(projectConfig);

const snapshot = path.join(projectDir, 'src', 'consumer.snap.ts');
const sourceFile = project.getSourceFile('consumer.ts')!;
const modifiedFile = convertFile(sourceFile);

await expect(modifiedFile?.getFullText()).toMatchFileSnapshot(snapshot);
it('converts imports when tsconfig has an "include" property', async () => {
await testFileConversion('tsconfig-include', 'consumer');
});

it('turns CJS require statements into ESM imports', async () => {
const projectDir = path.join(fixtures, 'require-import');
const projectConfig = path.join(projectDir, 'tsconfig.json');
const project = ProjectUtil.getProject(projectConfig);

const snapshot = path.join(projectDir, 'src', 'main.snap.ts');
const sourceFile = project.getSourceFile('main.ts')!;
const modifiedFile = convertFile(sourceFile);
it('converts CJS require statements into ESM imports', async () => {
await testFileConversion('require-import');
});

await expect(modifiedFile?.getFullText()).toMatchFileSnapshot(snapshot);
it('converts dynamic imports', async () => {
await testFileConversion('dynamic-imports');
});

it('handles index files referenced with a trailing slash', async () => {
const projectDir = path.join(fixtures, 'trailing-slash');
const projectConfig = path.join(projectDir, 'tsconfig.json');
const project = ProjectUtil.getProject(projectConfig);

const snapshot = path.join(projectDir, 'src', 'main.snap.ts');
const sourceFile = project.getSourceFile('main.ts')!;
const modifiedFile = convertFile(sourceFile);

await expect(modifiedFile?.getFullText()).toMatchFileSnapshot(snapshot);
await testFileConversion('trailing-slash');
});

it('handles files with a Shebang (#!) at the beginning', async () => {
const projectDir = path.join(fixtures, 'cjs-shebang');
const projectConfig = path.join(projectDir, 'tsconfig.json');
const project = ProjectUtil.getProject(projectConfig);

const snapshot = path.join(projectDir, 'src', 'main.snap.ts');
const sourceFile = project.getSourceFile('main.ts')!;
const modifiedFile = convertFile(sourceFile);

await expect(modifiedFile?.getFullText()).toMatchFileSnapshot(snapshot);
await testFileConversion('cjs-shebang');
});

it('handles named imports from require statements', async () => {
const projectDir = path.join(fixtures, 'cjs-destructuring');
const projectConfig = path.join(projectDir, 'tsconfig.json');
const project = ProjectUtil.getProject(projectConfig);

const snapshot = path.join(projectDir, 'src', 'main.snap.ts');
const sourceFile = project.getSourceFile('main.ts')!;
const modifiedFile = convertFile(sourceFile);

await expect(modifiedFile?.getFullText()).toMatchFileSnapshot(snapshot);
await testFileConversion('cjs-destructuring');
});
});

describe('exports', () => {
describe('CJS (module.exports) to ESM', () => {
it('converts default and named exports', async () => {
const projectDir = path.join(fixtures, 'module-exports');
const projectConfig = path.join(projectDir, 'tsconfig.json');
const project = ProjectUtil.getProject(projectConfig);

const snapshot = path.join(projectDir, 'src', 'main.snap.ts');
const sourceFile = project.getSourceFile('main.ts')!;
const modifiedFile = convertFile(sourceFile);

await expect(modifiedFile?.getFullText()).toMatchFileSnapshot(snapshot);
await testFileConversion('module-exports');
});

it('converts multiple named exports', async () => {
const projectDir = path.join(fixtures, 'module-exports');
const projectConfig = path.join(projectDir, 'tsconfig.json');
const project = ProjectUtil.getProject(projectConfig);

const snapshot = path.join(projectDir, 'src', 'multiple-named-exports.snap.ts');
const sourceFile = project.getSourceFile('multiple-named-exports.ts')!;
const modifiedFile = convertFile(sourceFile);

await expect(modifiedFile?.getFullText()).toMatchFileSnapshot(snapshot);
await testFileConversion('module-exports', 'multiple-named-exports');
});

it('handles functions exported as default from plain JavaScript files', async () => {
const projectDir = path.join(fixtures, 'module-exports-function-js');
const projectConfig = path.join(projectDir, 'tsconfig.json');
const project = ProjectUtil.getProject(projectConfig);

const snapshot = path.join(projectDir, 'src', 'build-example-index.snap.js');
const snapshot2 = path.join(projectDir, 'src', 'build-example-index-markdown.snap.js');

const sourceFile = project.getSourceFile('build-example-index.js')!;
const modifiedFile = convertFile(sourceFile);

const sourceFile2 = project.getSourceFile('build-example-index-markdown.js')!;
const modifiedFile2 = convertFile(sourceFile2);

await expect(modifiedFile?.getFullText()).toMatchFileSnapshot(snapshot);
await expect(modifiedFile2?.getFullText()).toMatchFileSnapshot(snapshot2);
await testFileConversion('module-exports-function-js', 'build-example-index', 'js');
await testFileConversion('module-exports-function-js', 'build-example-index-markdown', 'js');
});
});
});
Expand Down
17 changes: 12 additions & 5 deletions src/converter/convertFile.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {SourceFile} from 'ts-morph';
import {replaceFileExtensions} from './replacer/replaceFileExtensions.js';
import {addFileExtensions} from './replacer/addFileExtensions.js';
import {replaceModuleExports} from './replacer/replaceModuleExports.js';
import {replaceRequires} from './replacer/replaceRequire.js';
import {replaceRequiresAndShebang} from './replacer/replaceRequiresAndShebang.js';
import {replaceDynamicImports} from './replacer/replaceDynamicImports.js';

/**
* Returns the source file ONLY if it was modified.
Expand All @@ -10,25 +11,31 @@ export function convertFile(sourceFile: SourceFile) {
let madeChanges: boolean = false;

// Update "require" statements to "import" statements
const updatedRequires = replaceRequires(sourceFile);
const updatedRequires = replaceRequiresAndShebang(sourceFile);
if (updatedRequires) {
madeChanges = true;
}

// Update "await import" statements
const updatedDynamicImports = replaceDynamicImports(sourceFile);
if (updatedDynamicImports) {
madeChanges = true;
}

// Update "module.exports" statements to "export" statements
const replacedModuleExports = replaceModuleExports(sourceFile);
if (replacedModuleExports) {
madeChanges = true;
}

// Add explicit file extensions to imports
const replacedImportFileExtensions = replaceFileExtensions(sourceFile, 'import');
const replacedImportFileExtensions = addFileExtensions(sourceFile, 'import');
if (replacedImportFileExtensions) {
madeChanges = true;
}

// Add explicit file extensions to exports
const replacedExportFileExtensions = replaceFileExtensions(sourceFile, 'export');
const replacedExportFileExtensions = addFileExtensions(sourceFile, 'export');
if (replacedExportFileExtensions) {
madeChanges = true;
}
Expand Down
27 changes: 27 additions & 0 deletions src/converter/replacer/addFileExtensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {SourceFile, SyntaxKind} from 'ts-morph';
import {replaceModulePath} from '../../util/replaceModulePath.js';

export function addFileExtensions(sourceFile: SourceFile, type: 'import' | 'export') {
let madeChanges: boolean = false;
const identifier = type === 'import' ? 'getImportDeclarations' : 'getExportDeclarations';

sourceFile[identifier]().forEach(declaration => {
try {
declaration.getDescendantsOfKind(SyntaxKind.StringLiteral).forEach(stringLiteral => {
const hasAttributesClause = !!declaration.getAttributes();
const adjustedImport = replaceModulePath({
hasAttributesClause,
sourceFile,
stringLiteral,
});
if (adjustedImport) {
madeChanges = true;
}
});
} catch (error: unknown) {
console.error(` There was an issue with "${sourceFile.getFilePath()}":`, error);
}
});

return madeChanges;
}
29 changes: 29 additions & 0 deletions src/converter/replacer/replaceDynamicImports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {SourceFile, SyntaxKind} from 'ts-morph';
import {replaceModulePath} from '../../util/replaceModulePath.js';

export function replaceDynamicImports(sourceFile: SourceFile) {
let madeChanges: boolean = false;

sourceFile.getVariableStatements().forEach(statement => {
statement.getDeclarations().forEach(declaration => {
const initializer = declaration.getInitializerIfKind(SyntaxKind.AwaitExpression);
const callExpression = initializer?.getExpressionIfKind(SyntaxKind.CallExpression);
const importExpression = callExpression?.getExpressionIfKind(SyntaxKind.ImportKeyword);
if (importExpression) {
const literals = initializer?.getDescendantsOfKind(SyntaxKind.StringLiteral);
literals?.forEach(stringLiteral => {
const adjustedImport = replaceModulePath({
hasAttributesClause: false,
sourceFile,
stringLiteral,
});
if (adjustedImport) {
madeChanges = true;
}
});
}
});
});

return madeChanges;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,19 @@ function replaceRequire(sourceFile: SourceFile, statement: VariableStatement) {

// Get call expression from variable declaration
// @see https://github.com/dsherret/ts-morph/issues/682#issuecomment-520246214
const initializer = declaration.getInitializerIfKind(SyntaxKind.CallExpression);
if (!initializer) {
const callExpression = declaration.getInitializerIfKind(SyntaxKind.CallExpression);
if (!callExpression) {
return false;
}

// Verify that we have a "require" call
const identifier = initializer.getExpression().asKind(SyntaxKind.Identifier);
const identifier = callExpression.getExpressionIfKind(SyntaxKind.Identifier);
if (identifier?.getText() !== 'require') {
return false;
}

// Extract the argument passed to "require" and use its value
const requireArguments = initializer.getArguments();
const requireArguments = callExpression.getArguments();
const packageName = requireArguments[0];
if (!packageName) {
return false;
Expand All @@ -52,15 +52,15 @@ function replaceRequire(sourceFile: SourceFile, statement: VariableStatement) {
return true;
}

export function replaceRequires(sourceFile: SourceFile) {
export function replaceRequiresAndShebang(sourceFile: SourceFile) {
let madeChanges: boolean = false;

// Handle files with "#! /usr/bin/env node" pragma
const firstStatement = sourceFile.getStatements()[0];
const hasShebang = firstStatement && firstStatement?.getFullText().startsWith('#!');
let shebangText = '';
if (hasShebang) {
// The full text contains both comments and the following statment,
// The full text contains both comments and the following statement,
// so we are separating the statement into comments and the instruction that follow on the next line.
const {statement: lineAfterShebang, comment} = NodeUtil.extractComment(firstStatement);
shebangText = comment;
Expand All @@ -73,11 +73,15 @@ export function replaceRequires(sourceFile: SourceFile) {
sourceFile.getVariableStatements().forEach(statement => {
try {
const updatedRequire = replaceRequire(sourceFile, statement);

if (updatedRequire) {
madeChanges = true;
}

return madeChanges;
} catch (error: unknown) {
console.error(` There was an issue with "${sourceFile.getFilePath()}":`, error);
return false;
}
});

Expand Down
2 changes: 2 additions & 0 deletions src/test/fixtures/dynamic-imports/src/main.snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// @ts-ignore
const module = await import('./my-function.function.js');
2 changes: 2 additions & 0 deletions src/test/fixtures/dynamic-imports/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// @ts-ignore
const module = await import('./my-function.function');
3 changes: 3 additions & 0 deletions src/test/fixtures/dynamic-imports/src/my-function.function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function hereIAm() {
console.log('test');
}
10 changes: 10 additions & 0 deletions src/test/fixtures/dynamic-imports/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"module": "node16",
"skipLibCheck": true,
"strict": true,
"target": "ES2020"
}
}
Loading