diff --git a/codemods/rimraf/index.js b/codemods/rimraf/index.js index 1e2f9a5d..c58ea75d 100644 --- a/codemods/rimraf/index.js +++ b/codemods/rimraf/index.js @@ -1,4 +1,9 @@ import { ts } from '@ast-grep/napi'; +import { + findDefaultImports, + findNamedImports, + generateImport, +} from '../shared-ast-grep.js'; const MODULE_NAME = 'rimraf'; @@ -9,19 +14,6 @@ const MODULE_NAME = 'rimraf'; */ const defaultOptions = `{recursive: true, force: true}`; -/** - * @param {boolean} useRequire - * @param {string} quoteType - * @param {string[]} names - * @param {string} source - * @returns {string} - */ -const computeImport = (useRequire, quoteType, names, source) => { - if (useRequire) { - return `const {${names.join(', ')}} = require(${quoteType}${source}${quoteType});`; - } - return `import {${names.join(', ')}} from ${quoteType}${source}${quoteType};`; -}; /** * @param {CodemodOptions} [options] @@ -34,129 +26,61 @@ export default function (options) { transform: ({ file }) => { const ast = ts.parse(file.source); const root = ast.root(); - const imports = root.findAll({ + const { imports: namedImports, localNames: namedLocals } = + findNamedImports(root, MODULE_NAME); + const { imports: defaultImports, localNames: defaultLocals } = + findDefaultImports(root, MODULE_NAME); + + const namespaceImports = root.findAll({ rule: { - any: [ - { - pattern: { - context: "import * as $NAME from 'rimraf'", - strictness: 'relaxed', - }, - }, - { - pattern: { - context: "import {$$$NAMES} from 'rimraf'", - strictness: 'relaxed', - }, - }, - { - pattern: { - context: "import $NAME from 'rimraf'", - strictness: 'relaxed', - }, - }, - { - pattern: { - context: "const {$$$NAMES} = require('rimraf')", - strictness: 'relaxed', - }, - }, - { - pattern: { - context: "const $NAME = require('rimraf')", - strictness: 'relaxed', - }, - }, - ], + pattern: { + context: `import * as $NAME from '${MODULE_NAME}'`, + strictness: 'relaxed', + }, }, }); + const imports = [...namedImports, ...defaultImports, ...namespaceImports]; + if (imports.length === 0) { return file.source; } /** @type {Edit[]} */ const edits = []; - let quoteType = "'"; + const quoteType = imports.some((imp) => imp.text().includes('"')) + ? '"' + : "'"; + const isCommonJS = imports.some( + (imp) => imp.find('require($SOURCE)') !== null, + ); + /** @type {string[]} */ const localNames = []; - let isCommonJS = false; - - for (const imp of imports) { - const importSource = imp.field('source'); - const requireCall = imp.find('require($SOURCE)'); - let source = null; - - if (importSource) { - // ESM - source = importSource.text(); - } else { - // CJS - source = requireCall?.getMatch('SOURCE')?.text(); - } - - if (!source) { - continue; - } - - if (!isCommonJS) { - isCommonJS = requireCall !== null; - } - - if (source?.startsWith('"')) { - quoteType = '"'; - } - const importedNames = imp.getMultipleMatches('NAMES'); - const importedName = imp.getMatch('NAME'); - - // Its a default or namespace import - if (importedName) { - const importedNameText = importedName.text(); - localNames.push( - importedNameText, - `${importedNameText}.$_METHOD`, - `${importedNameText}.$_METHOD.sync`, - ); - } - - for (const importSpecifier of importedNames) { - const importedName = importSpecifier.field('name'); - const value = importSpecifier.field('value'); - - let localNameText; - - if (importedName) { - // ESM - const localName = importSpecifier.field('alias') ?? importedName; - localNameText = localName.text(); - } else if (value) { - // CJS - localNameText = value.text(); - } else { - localNameText = importSpecifier.text(); - } - - localNames.push(localNameText, `${localNameText}.sync`); + for (const name of namedLocals) { + localNames.push(name, `${name}.sync`); + } + for (const name of defaultLocals) { + localNames.push(name, `${name}.$_METHOD`, `${name}.$_METHOD.sync`); + } + for (const imp of namespaceImports) { + const name = imp.getMatch('NAME')?.text(); + if (name) { + localNames.push(name, `${name}.$_METHOD`, `${name}.$_METHOD.sync`); } } const usagePatterns = []; for (const name of localNames) { usagePatterns.push( - { - pattern: `${name}($PATH, $OPTIONS)`, - }, - { - pattern: `${name}($PATH)`, - }, + { pattern: `${name}($PATH, $OPTIONS)` }, + { pattern: `${name}($PATH)` }, ); } const usages = root.findAll({ - rule: { - any: usagePatterns, - }, + rule: { any: usagePatterns }, }); let seenSync = false; @@ -240,19 +164,19 @@ Promise.all( if (seenAsync) { replacedImports.push( - computeImport(isCommonJS, quoteType, ['rm'], 'node:fs/promises'), + generateImport(isCommonJS, quoteType, 'rm', 'node:fs/promises'), ); } if (seenSync) { replacedImports.push( - computeImport(isCommonJS, quoteType, ['rmSync'], 'node:fs'), + generateImport(isCommonJS, quoteType, 'rmSync', 'node:fs'), ); } if (seenGlob) { replacedImports.push( - computeImport(isCommonJS, quoteType, ['glob'], 'tinyglobby'), + generateImport(isCommonJS, quoteType, 'glob', 'tinyglobby'), ); } diff --git a/codemods/shared-ast-grep.js b/codemods/shared-ast-grep.js index 99f47450..a80b5612 100644 --- a/codemods/shared-ast-grep.js +++ b/codemods/shared-ast-grep.js @@ -98,7 +98,7 @@ export function removeImport(root, moduleName) { const localNames = []; // 1. Default imports/requires and assignments - const { imports, localNames: defaultLocalNames } = findNamedDefaultImports( + const { imports, localNames: defaultLocalNames } = findDefaultImports( root, moduleName, ); @@ -158,11 +158,17 @@ export function removeImport(root, moduleName) { /** * Find all default imports/requires for a package and extract common metadata. * + * Handles: + * - `import X from 'pkg'` + * - `const/var X = require('pkg')` + * - `const/var X = require('pkg').method(...)` + * - `X = require('pkg')` + * * @param {SgNode} root - The root of the AST. * @param {string} moduleName - The package to find imports for. * @returns {{ imports: SgNode[], localNames: string[], quoteType: string }} */ -function findNamedDefaultImports(root, moduleName) { +export function findDefaultImports(root, moduleName) { const imports = root.findAll({ rule: { any: [ @@ -243,7 +249,7 @@ function findNamedDefaultImports(root, moduleName) { * @returns {{ imports: SgNode[], identifierName: string | null }} */ export function findDefaultImportIdentifier(root, moduleName) { - const { imports } = findNamedDefaultImports(root, moduleName); + const { imports } = findDefaultImports(root, moduleName); const identifierName = imports[0]?.getMatch('NAME')?.text() ?? null; return { imports, identifierName }; } @@ -364,7 +370,7 @@ export function replaceDefaultImport( toIdentifier, asNamespace = false, ) { - const { imports, localNames, quoteType } = findNamedDefaultImports( + const { imports, localNames, quoteType } = findDefaultImports( root, fromPackage, ); @@ -379,25 +385,46 @@ export function replaceDefaultImport( const identifier = toIdentifier || nameMatch.text(); const isCommonJS = imp.find('require($SOURCE)') !== null; - if (isCommonJS) { - edits.push( - imp.replace( - `const ${identifier} = require(${quoteType}${toPackage}${quoteType});`, - ), - ); - } else { - const prefix = asNamespace ? 'import * as ' : 'import '; - edits.push( - imp.replace( - `${prefix}${identifier} from ${quoteType}${toPackage}${quoteType};`, - ), - ); - } + const kind = asNamespace ? 'namespace' : 'default'; + edits.push( + imp.replace( + generateImport(isCommonJS, quoteType, identifier, toPackage, kind), + ), + ); } return { edits, localNames, quoteType }; } +/** + * Generate an import or require statement as a string. + * + * @param {boolean} useRequire - Use require() instead of import. + * @param {string} quoteType - Quote character (" or '). + * @param {string} name - The local identifier or import name. + * @param {string} source - Module specifier. + * @param {'named' | 'default' | 'namespace'} [kind='named'] - Import kind. + * @returns {string} + */ +export function generateImport( + useRequire, + quoteType, + name, + source, + kind = 'named', +) { + const from = `${quoteType}${source}${quoteType}`; + const specifier = + kind === 'named' + ? `{ ${name} }` + : kind === 'namespace' + ? `* as ${name}` + : name; + return useRequire + ? `const ${specifier} = require(${from});` + : `import ${specifier} from ${from};`; +} + /** * Remove the import of a polyfill module and replace all references to its * default import identifier with the given replacement string. @@ -462,7 +489,7 @@ export function replaceDefaultWithNamedImport( toPackage, namedImport, ) { - const { imports, localNames, quoteType } = findNamedDefaultImports( + const { imports, localNames, quoteType } = findDefaultImports( root, fromPackage, ); @@ -476,19 +503,11 @@ export function replaceDefaultWithNamedImport( const isCommonJS = imp.find('require($SOURCE)') !== null; - if (isCommonJS) { - edits.push( - imp.replace( - `const { ${namedImport} } = require(${quoteType}${toPackage}${quoteType});`, - ), - ); - } else { - edits.push( - imp.replace( - `import { ${namedImport} } from ${quoteType}${toPackage}${quoteType};`, - ), - ); - } + edits.push( + imp.replace( + generateImport(isCommonJS, quoteType, namedImport, toPackage), + ), + ); } const namespaceImports = root.findAll({ @@ -522,9 +541,7 @@ export function replaceDefaultWithNamedImport( } edits.push( - imp.replace( - `import { ${namedImport} } from ${quoteType}${toPackage}${quoteType};`, - ), + imp.replace(generateImport(false, quoteType, namedImport, toPackage)), ); } diff --git a/test/fixtures/rimraf/cjs/after.js b/test/fixtures/rimraf/cjs/after.js index 432d1e70..da2fadbe 100644 --- a/test/fixtures/rimraf/cjs/after.js +++ b/test/fixtures/rimraf/cjs/after.js @@ -1,5 +1,5 @@ -const {rm} = require('node:fs/promises'); -const {rmSync} = require('node:fs'); +const { rm } = require('node:fs/promises'); +const { rmSync } = require('node:fs'); await rm('./dist', {recursive: true, force: true}); diff --git a/test/fixtures/rimraf/cjs/result.js b/test/fixtures/rimraf/cjs/result.js index 432d1e70..da2fadbe 100644 --- a/test/fixtures/rimraf/cjs/result.js +++ b/test/fixtures/rimraf/cjs/result.js @@ -1,5 +1,5 @@ -const {rm} = require('node:fs/promises'); -const {rmSync} = require('node:fs'); +const { rm } = require('node:fs/promises'); +const { rmSync } = require('node:fs'); await rm('./dist', {recursive: true, force: true}); diff --git a/test/fixtures/rimraf/esm/after.js b/test/fixtures/rimraf/esm/after.js index a15fe357..fa887ae7 100644 --- a/test/fixtures/rimraf/esm/after.js +++ b/test/fixtures/rimraf/esm/after.js @@ -1,5 +1,5 @@ -import {rm} from 'node:fs/promises'; -import {glob} from 'tinyglobby'; +import { rm } from 'node:fs/promises'; +import { glob } from 'tinyglobby'; await rm('./dist', {recursive: true, force: true}); diff --git a/test/fixtures/rimraf/esm/result.js b/test/fixtures/rimraf/esm/result.js index a15fe357..fa887ae7 100644 --- a/test/fixtures/rimraf/esm/result.js +++ b/test/fixtures/rimraf/esm/result.js @@ -1,5 +1,5 @@ -import {rm} from 'node:fs/promises'; -import {glob} from 'tinyglobby'; +import { rm } from 'node:fs/promises'; +import { glob } from 'tinyglobby'; await rm('./dist', {recursive: true, force: true}); diff --git a/test/fixtures/rimraf/legacy-cjs/after.js b/test/fixtures/rimraf/legacy-cjs/after.js index 432d1e70..da2fadbe 100644 --- a/test/fixtures/rimraf/legacy-cjs/after.js +++ b/test/fixtures/rimraf/legacy-cjs/after.js @@ -1,5 +1,5 @@ -const {rm} = require('node:fs/promises'); -const {rmSync} = require('node:fs'); +const { rm } = require('node:fs/promises'); +const { rmSync } = require('node:fs'); await rm('./dist', {recursive: true, force: true}); diff --git a/test/fixtures/rimraf/legacy-cjs/result.js b/test/fixtures/rimraf/legacy-cjs/result.js index 432d1e70..da2fadbe 100644 --- a/test/fixtures/rimraf/legacy-cjs/result.js +++ b/test/fixtures/rimraf/legacy-cjs/result.js @@ -1,5 +1,5 @@ -const {rm} = require('node:fs/promises'); -const {rmSync} = require('node:fs'); +const { rm } = require('node:fs/promises'); +const { rmSync } = require('node:fs'); await rm('./dist', {recursive: true, force: true}); diff --git a/test/fixtures/rimraf/legacy-esm/after.js b/test/fixtures/rimraf/legacy-esm/after.js index 9cddb85c..aeec9531 100644 --- a/test/fixtures/rimraf/legacy-esm/after.js +++ b/test/fixtures/rimraf/legacy-esm/after.js @@ -1,5 +1,5 @@ -import {rm} from 'node:fs/promises'; -import {rmSync} from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { rmSync } from 'node:fs'; await rm('./dist', {recursive: true, force: true}); diff --git a/test/fixtures/rimraf/legacy-esm/result.js b/test/fixtures/rimraf/legacy-esm/result.js index 9cddb85c..aeec9531 100644 --- a/test/fixtures/rimraf/legacy-esm/result.js +++ b/test/fixtures/rimraf/legacy-esm/result.js @@ -1,5 +1,5 @@ -import {rm} from 'node:fs/promises'; -import {rmSync} from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { rmSync } from 'node:fs'; await rm('./dist', {recursive: true, force: true}); diff --git a/types/codemods/shared-ast-grep.d.ts b/types/codemods/shared-ast-grep.d.ts index 40432595..f9a48e62 100644 --- a/types/codemods/shared-ast-grep.d.ts +++ b/types/codemods/shared-ast-grep.d.ts @@ -37,6 +37,24 @@ export function removeImport(root: SgNode, moduleName: string): { edits: Edit[]; localNames: string[]; }; +/** + * Find all default imports/requires for a package and extract common metadata. + * + * Handles: + * - `import X from 'pkg'` + * - `const/var X = require('pkg')` + * - `const/var X = require('pkg').method(...)` + * - `X = require('pkg')` + * + * @param {SgNode} root - The root of the AST. + * @param {string} moduleName - The package to find imports for. + * @returns {{ imports: SgNode[], localNames: string[], quoteType: string }} + */ +export function findDefaultImports(root: SgNode, moduleName: string): { + imports: SgNode[]; + localNames: string[]; + quoteType: string; +}; /** * Find default imports of a module and resolve the local identifier name. * @@ -95,6 +113,17 @@ export function replaceDefaultImport(root: SgNode, fromPackage: string, toPackag localNames: string[]; quoteType: string; }; +/** + * Generate an import or require statement as a string. + * + * @param {boolean} useRequire - Use require() instead of import. + * @param {string} quoteType - Quote character (" or '). + * @param {string} name - The local identifier or import name. + * @param {string} source - Module specifier. + * @param {'named' | 'default' | 'namespace'} [kind='named'] - Import kind. + * @returns {string} + */ +export function generateImport(useRequire: boolean, quoteType: string, name: string, source: string, kind?: "named" | "default" | "namespace"): string; /** * Remove the import of a polyfill module and replace all references to its * default import identifier with the given replacement string.