diff --git a/src/converter/convertFile.test.ts b/src/converter/convertFile.test.ts index a1799ba..53b0ef0 100644 --- a/src/converter/convertFile.test.ts +++ b/src/converter/convertFile.test.ts @@ -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'); }); }); }); diff --git a/src/converter/convertFile.ts b/src/converter/convertFile.ts index 92beced..7609ba6 100644 --- a/src/converter/convertFile.ts +++ b/src/converter/convertFile.ts @@ -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. @@ -10,11 +11,17 @@ 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) { @@ -22,13 +29,13 @@ export function convertFile(sourceFile: SourceFile) { } // 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; } diff --git a/src/converter/replacer/addFileExtensions.ts b/src/converter/replacer/addFileExtensions.ts new file mode 100644 index 0000000..2bb3f02 --- /dev/null +++ b/src/converter/replacer/addFileExtensions.ts @@ -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; +} diff --git a/src/converter/replacer/replaceDynamicImports.ts b/src/converter/replacer/replaceDynamicImports.ts new file mode 100644 index 0000000..de2e824 --- /dev/null +++ b/src/converter/replacer/replaceDynamicImports.ts @@ -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; +} diff --git a/src/converter/replacer/replaceRequire.ts b/src/converter/replacer/replaceRequiresAndShebang.ts similarity index 88% rename from src/converter/replacer/replaceRequire.ts rename to src/converter/replacer/replaceRequiresAndShebang.ts index 6e19ec5..907e9ce 100644 --- a/src/converter/replacer/replaceRequire.ts +++ b/src/converter/replacer/replaceRequiresAndShebang.ts @@ -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; @@ -52,7 +52,7 @@ 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 @@ -60,7 +60,7 @@ export function replaceRequires(sourceFile: SourceFile) { 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; @@ -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; } }); diff --git a/src/test/fixtures/dynamic-imports/src/main.snap.ts b/src/test/fixtures/dynamic-imports/src/main.snap.ts new file mode 100644 index 0000000..8f60bc2 --- /dev/null +++ b/src/test/fixtures/dynamic-imports/src/main.snap.ts @@ -0,0 +1,2 @@ +// @ts-ignore +const module = await import('./my-function.function.js'); diff --git a/src/test/fixtures/dynamic-imports/src/main.ts b/src/test/fixtures/dynamic-imports/src/main.ts new file mode 100644 index 0000000..c65bf52 --- /dev/null +++ b/src/test/fixtures/dynamic-imports/src/main.ts @@ -0,0 +1,2 @@ +// @ts-ignore +const module = await import('./my-function.function'); diff --git a/src/test/fixtures/dynamic-imports/src/my-function.function.ts b/src/test/fixtures/dynamic-imports/src/my-function.function.ts new file mode 100644 index 0000000..3d6fbe1 --- /dev/null +++ b/src/test/fixtures/dynamic-imports/src/my-function.function.ts @@ -0,0 +1,3 @@ +export function hereIAm() { + console.log('test'); +} diff --git a/src/test/fixtures/dynamic-imports/tsconfig.json b/src/test/fixtures/dynamic-imports/tsconfig.json new file mode 100644 index 0000000..8cc300e --- /dev/null +++ b/src/test/fixtures/dynamic-imports/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "module": "node16", + "skipLibCheck": true, + "strict": true, + "target": "ES2020" + } +} diff --git a/src/converter/replacer/replaceFileExtensions.ts b/src/util/replaceModulePath.ts similarity index 58% rename from src/converter/replacer/replaceFileExtensions.ts rename to src/util/replaceModulePath.ts index 21abfd5..eff24cb 100644 --- a/src/converter/replacer/replaceFileExtensions.ts +++ b/src/util/replaceModulePath.ts @@ -1,57 +1,24 @@ -import {SourceFile, SyntaxKind} from 'ts-morph'; -import {ProjectUtil} from '../../util/ProjectUtil.js'; -import {StringLiteral} from 'ts-morph'; -import {ModuleInfo, parseInfo} from '../../parser/InfoParser.js'; -import {toImport, toImportAttribute} from '../ImportConverter.js'; +import {SourceFile, StringLiteral} from 'ts-morph'; +import {ModuleInfo, parseInfo} from '../parser/InfoParser.js'; +import {ProjectUtil} from './ProjectUtil.js'; +import {toImport, toImportAttribute} from '../converter/ImportConverter.js'; +import {getNormalizedPath, isNodeModuleRoot} from './PathUtil.js'; import path from 'node:path'; -import {PathFinder} from '../../util/PathFinder.js'; -import {getNormalizedPath, isNodeModuleRoot} from '../../util/PathUtil.js'; +import {PathFinder} from './PathFinder.js'; -export function replaceFileExtensions(sourceFile: SourceFile, type: 'import' | 'export') { - let madeChanges: boolean = false; - - const paths = ProjectUtil.getPaths(sourceFile.getProject()); - const tsConfigFilePath = ProjectUtil.getTsConfigFilePath(sourceFile); - const projectDirectory = ProjectUtil.getRootDirectory(tsConfigFilePath); - 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, - paths, - projectDirectory, - sourceFilePath: sourceFile.getFilePath(), - stringLiteral, - }); - if (adjustedImport) { - madeChanges = true; - } - }); - } catch (error: unknown) { - console.error(` There was an issue with "${sourceFile.getFilePath()}":`, error); - } - }); - - return madeChanges; -} - -function replaceModulePath({ +export function replaceModulePath({ hasAttributesClause, - paths, - projectDirectory, - sourceFilePath, stringLiteral, + sourceFile, }: { hasAttributesClause: boolean; - paths: Record | undefined; - projectDirectory: string; - sourceFilePath: string; stringLiteral: StringLiteral; + sourceFile: SourceFile; }) { - const info = parseInfo(sourceFilePath, stringLiteral, paths); + const paths = ProjectUtil.getPaths(sourceFile.getProject()); + const tsConfigFilePath = ProjectUtil.getTsConfigFilePath(sourceFile); + const projectDirectory = ProjectUtil.getRootDirectory(tsConfigFilePath); + const info = parseInfo(sourceFile.getFilePath(), stringLiteral, paths); const replacement = createReplacementPath({hasAttributesClause, info, paths, projectDirectory}); if (replacement) { stringLiteral.replaceWithText(replacement);