diff --git a/README.md b/README.md index 2f8469b..4e65bb3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Node.js tool for parsing imports to change ESM and CJS [specifiers](https://node - Rewrite specifier values. - Read metadata about a specifier's [AST](https://www.npmjs.com/package/oxc-parser) node. - Updates files or strings. -- Parses `import`, `import()`, `import.meta.resolve()`, `export`, `require`, and `require.resolve()`. +- Parses `import`, `import()`, `import.meta.resolve()`, `export`, `require`, `require.resolve()`, and `createRequire()` aliases. ## Example diff --git a/codecov.yml b/codecov.yml index 8b1a595..ca73595 100644 --- a/codecov.yml +++ b/codecov.yml @@ -6,4 +6,5 @@ coverage: threshold: 0.5 patch: default: - threshold: 1.0 + target: 80.0 + threshold: 0.5 diff --git a/package-lock.json b/package-lock.json index 7e1a649..74571cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@knighted/specifier", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@knighted/specifier", - "version": "3.0.0", + "version": "3.1.0", "license": "MIT", "dependencies": { "magic-string": "^0.30.21", diff --git a/package.json b/package.json index 3869759..cee1294 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/specifier", - "version": "3.0.0", + "version": "3.1.0", "description": "Node.js tool for updating your ES module and CommonJS specifiers.", "type": "module", "main": "dist", diff --git a/src/format.ts b/src/format.ts index bbcd088..cce2225 100644 --- a/src/format.ts +++ b/src/format.ts @@ -6,11 +6,21 @@ import type { BinaryExpression, ImportExpression, CallExpression, + ImportDeclaration, + VariableDeclarator, } from 'oxc-parser' import { Visitor } from 'oxc-parser' import type { Callback } from './types.js' +type BindingKind = 'other' | 'createRequireFactory' | 'requireAlias' +type Scope = { + parent: Scope | null + functionScope: boolean + bindings: Map +} +type UnknownRecord = Record + const isStringLiteral = (node: Node): node is StringLiteral => { return node.type === 'Literal' && typeof node.value === 'string' } @@ -21,8 +31,216 @@ const isBinaryExpression = (node: Node): node is BinaryExpression => { const isCallExpression = (node: Node): node is CallExpression => { return node.type === 'CallExpression' && node.callee !== undefined } +const isRecord = (value: unknown): value is UnknownRecord => { + return typeof value === 'object' && value !== null +} +const isIdentifier = (value: unknown): value is { type: 'Identifier'; name: string } => { + return isRecord(value) && value.type === 'Identifier' && typeof value.name === 'string' +} +const isModuleCreateRequireSource = (value: string) => { + return value === 'module' || value === 'node:module' +} +const isRequireCallForModule = (node: CallExpression) => { + if ( + node.callee.type !== 'Identifier' || + node.callee.name !== 'require' || + node.arguments.length === 0 + ) { + return false + } + + const first = node.arguments[0] + + return isStringLiteral(first) && isModuleCreateRequireSource(first.value) +} +const collectBindingNames = (target: unknown): string[] => { + if (!isRecord(target)) { + return [] + } + + const nodeType = target.type + + if (isIdentifier(target)) { + return [target.name] + } + + const record = target + + if (nodeType === 'AssignmentPattern') { + return collectBindingNames(record.left) + } + + if (nodeType === 'RestElement') { + return collectBindingNames(record.argument) + } + + if (nodeType === 'ArrayPattern') { + const elements = Array.isArray(record.elements) ? record.elements : [] + return elements.flatMap(element => collectBindingNames(element)) + } + + if (nodeType === 'ObjectPattern') { + const properties = (Array.isArray(record.properties) ? record.properties : []).filter( + isRecord, + ) + + return properties.flatMap(property => { + const propertyType = property.type + + const propertyRecord = property + + if (propertyType === 'Property') { + return collectBindingNames(propertyRecord.value) + } + + if (propertyType === 'RestElement') { + return collectBindingNames(propertyRecord.argument) + } + + return [] + }) + } + + return [] +} + const format = async (src: string, ast: ParseResult, cb: Callback) => { const code = new MagicString(src) + let scope: Scope = { + parent: null, + functionScope: true, + bindings: new Map(), + } + const variableDeclarationKinds: string[] = [] + const pushScope = (options?: { functionScope?: boolean }) => { + scope = { + parent: scope, + functionScope: options?.functionScope ?? false, + bindings: new Map(), + } + } + const popScope = () => { + if (scope.parent) { + scope = scope.parent + } + } + const getNearestFunctionScope = () => { + let cursor: Scope | null = scope + + while (cursor) { + if (cursor.functionScope) { + return cursor + } + + cursor = cursor.parent + } + + return scope + } + const defineBinding = ( + name: string, + kind: BindingKind = 'other', + options?: { varScoped?: boolean }, + ) => { + const targetScope = options?.varScoped ? getNearestFunctionScope() : scope + targetScope.bindings.set(name, kind) + } + const getBindingScope = (name: string) => { + let cursor: Scope | null = scope + + while (cursor) { + if (cursor.bindings.has(name)) { + return cursor + } + + cursor = cursor.parent + } + + return null + } + const getBindingKind = (name: string) => { + const bindingScope = getBindingScope(name) + + if (!bindingScope) { + return null + } + + return bindingScope.bindings.get(name) ?? null + } + const updateBinding = (name: string, kind: BindingKind) => { + const bindingScope = getBindingScope(name) + + if (!bindingScope) { + return false + } + + bindingScope.bindings.set(name, kind) + return true + } + const isCreateRequireCall = (node: CallExpression) => { + if (!isIdentifier(node.callee)) { + return false + } + + return getBindingKind(node.callee.name) === 'createRequireFactory' + } + const isRequireLikeIdentifier = (name: string) => { + const kind = getBindingKind(name) + + if (kind === 'requireAlias') { + return true + } + + return name === 'require' && kind === null + } + const markModuleImportCreateRequire = (node: ImportDeclaration) => { + if (!isModuleCreateRequireSource(node.source.value)) { + return + } + + for (const specifier of node.specifiers) { + if (specifier.type !== 'ImportSpecifier') { + continue + } + + if ( + !isIdentifier(specifier.imported) || + specifier.imported.name !== 'createRequire' + ) { + continue + } + + defineBinding(specifier.local.name, 'createRequireFactory') + } + } + const markRequireDestructureCreateRequire = ( + node: VariableDeclarator, + options?: { varScoped?: boolean }, + ) => { + if (node.id.type !== 'ObjectPattern' || node.init?.type !== 'CallExpression') { + return + } + + if (!isRequireCallForModule(node.init)) { + return + } + + const properties = Array.isArray(node.id.properties) ? node.id.properties : [] + + for (const property of properties) { + if (property.type !== 'Property') { + continue + } + + if (!isIdentifier(property.key) || property.key.name !== 'createRequire') { + continue + } + + for (const name of collectBindingNames(property.value)) { + defineBinding(name, 'createRequireFactory', options) + } + } + } const formatExpression = (expression: ImportExpression | CallExpression) => { /** * When using require(), require.resolve(), or import.meta.resolve() @@ -111,6 +329,144 @@ const format = async (src: string, ast: ParseResult, cb: Callback) => { } const visitor = new Visitor({ + Program() { + pushScope({ functionScope: true }) + }, + 'Program:exit'() { + popScope() + }, + FunctionDeclaration(node) { + if (node.id && node.id.type === 'Identifier') { + defineBinding(node.id.name) + } + + pushScope({ functionScope: true }) + + for (const parameter of node.params) { + for (const name of collectBindingNames(parameter)) { + defineBinding(name) + } + } + }, + 'FunctionDeclaration:exit'() { + popScope() + }, + FunctionExpression(node) { + pushScope({ functionScope: true }) + + if (node.id && node.id.type === 'Identifier') { + defineBinding(node.id.name) + } + + for (const parameter of node.params) { + for (const name of collectBindingNames(parameter)) { + defineBinding(name) + } + } + }, + 'FunctionExpression:exit'() { + popScope() + }, + ArrowFunctionExpression(node) { + pushScope({ functionScope: true }) + + for (const parameter of node.params) { + for (const name of collectBindingNames(parameter)) { + defineBinding(name) + } + } + + const { body } = node + + if (body.type === 'ImportExpression') { + formatExpression(body) + } + }, + 'ArrowFunctionExpression:exit'() { + popScope() + }, + BlockStatement() { + pushScope() + }, + 'BlockStatement:exit'() { + popScope() + }, + CatchClause(node) { + pushScope() + + if (node.param) { + for (const name of collectBindingNames(node.param)) { + defineBinding(name) + } + } + }, + 'CatchClause:exit'() { + popScope() + }, + ImportDeclaration(node) { + for (const specifier of node.specifiers) { + if ('local' in specifier && specifier.local.type === 'Identifier') { + defineBinding(specifier.local.name) + } + } + + markModuleImportCreateRequire(node) + + const { source } = node + const { start, end, value } = source + const updated = cb({ + type: 'StringLiteral', + node: source, + parent: node, + start, + end, + value, + }) + + if (typeof updated === 'string') { + code.update(start + 1, end - 1, updated) + } + }, + VariableDeclaration(node) { + variableDeclarationKinds.push(node.kind) + }, + 'VariableDeclaration:exit'() { + variableDeclarationKinds.pop() + }, + VariableDeclarator(node) { + const varScoped = variableDeclarationKinds.at(-1) === 'var' + const names = collectBindingNames(node.id) + + for (const name of names) { + defineBinding(name, 'other', { varScoped }) + } + + markRequireDestructureCreateRequire(node, { varScoped }) + + if (node.id.type !== 'Identifier' || node.init?.type !== 'CallExpression') { + return + } + + if (isCreateRequireCall(node.init)) { + defineBinding(node.id.name, 'requireAlias', { varScoped }) + } + }, + AssignmentExpression(node) { + if (node.left.type !== 'Identifier') { + return + } + + const { name } = node.left + + if (node.right.type === 'CallExpression' && isCreateRequireCall(node.right)) { + updateBinding(name, 'requireAlias') + return + } + + if (getBindingKind(name) === 'requireAlias') { + updateBinding(name, 'other') + } + }, ExpressionStatement(node) { const { expression } = node @@ -125,15 +481,14 @@ const format = async (src: string, ast: ParseResult, cb: Callback) => { * require() * require.resolve() * import.meta.resolve() - * - * Omitted: - * const require = createRequire(import.meta.url) + * createRequire-backed aliases (including `require`) */ if ( - (node.callee.type === 'Identifier' && node.callee.name === 'require') || + (node.callee.type === 'Identifier' && + isRequireLikeIdentifier(node.callee.name)) || (node.callee.type === 'MemberExpression' && node.callee.object.type === 'Identifier' && - node.callee.object.name === 'require' && + isRequireLikeIdentifier(node.callee.object.name) && node.callee.property.type === 'Identifier' && node.callee.property.name === 'resolve') || (node.callee.type === 'MemberExpression' && @@ -145,21 +500,6 @@ const format = async (src: string, ast: ParseResult, cb: Callback) => { formatExpression(node) } }, - ArrowFunctionExpression(node) { - const { body } = node - - if (body.type === 'ImportExpression') { - formatExpression(body) - } - - if ( - body.type === 'CallExpression' && - body.callee.type === 'Identifier' && - body.callee.name === 'require' - ) { - formatExpression(body) - } - }, MemberExpression(node) { if ( node.object.type === 'ImportExpression' && @@ -185,22 +525,6 @@ const format = async (src: string, ast: ParseResult, cb: Callback) => { code.update(start + 1, end - 1, updated) } }, - ImportDeclaration(node) { - const { source } = node - const { start, end, value } = source - const updated = cb({ - type: 'StringLiteral', - node: source, - parent: node, - start, - end, - value, - }) - - if (typeof updated === 'string') { - code.update(start + 1, end - 1, updated) - } - }, ExportNamedDeclaration(node) { if (!node.source) { return diff --git a/test/updateSrc.ts b/test/updateSrc.ts index 87376b9..aa08dfe 100644 --- a/test/updateSrc.ts +++ b/test/updateSrc.ts @@ -94,4 +94,239 @@ describe('updateSrc', () => { assert.ok(update.indexOf('preact') > -1) assert.ok(update.indexOf("import 'react'") === -1) }) + + it('updates createRequire alias calls', async () => { + const update = await specifier.updateSrc( + ` + import { createRequire } from 'node:module' + const loader = createRequire(import.meta.url) + loader('./some/file.js') + `, + 'js', + spec => { + return spec.value.replace('.js', '.mjs') + }, + ) + + assert.ok(update.indexOf("loader('./some/file.mjs')") > -1) + }) + + it('updates renamed createRequire alias calls', async () => { + const update = await specifier.updateSrc( + ` + import { createRequire as makeRequire } from 'module' + const load = makeRequire(import.meta.url) + load('./renamed.js') + `, + 'js', + spec => { + return spec.value.replace('.js', '.mjs') + }, + ) + + assert.ok(update.indexOf("load('./renamed.mjs')") > -1) + }) + + it('updates createRequire resolve calls', async () => { + const update = await specifier.updateSrc( + ` + import { createRequire } from 'node:module' + const loader = createRequire(import.meta.url) + loader.resolve('./resolve.js') + `, + 'js', + spec => { + return spec.value.replace('.js', '.mjs') + }, + ) + + assert.ok(update.indexOf("loader.resolve('./resolve.mjs')") > -1) + }) + + it('does not rewrite shadowed createRequire aliases', async () => { + const update = await specifier.updateSrc( + ` + import { createRequire } from 'node:module' + const loader = createRequire(import.meta.url) + { + const loader = value => value + loader('./skip.js') + } + loader('./keep.js') + `, + 'js', + spec => { + return spec.value.replace('.js', '.mjs') + }, + ) + + assert.ok(update.indexOf("loader('./skip.js')") > -1) + assert.ok(update.indexOf("loader('./keep.mjs')") > -1) + }) + + it('stops rewriting after createRequire alias reassignment', async () => { + const update = await specifier.updateSrc( + ` + import { createRequire } from 'node:module' + let loader = createRequire(import.meta.url) + loader('./before.js') + loader = value => value + loader('./after.js') + `, + 'js', + spec => { + return spec.value.replace('.js', '.mjs') + }, + ) + + assert.ok(update.indexOf("loader('./before.mjs')") > -1) + assert.ok(update.indexOf("loader('./after.js')") > -1) + }) + + it('updates require identifier created from createRequire', async () => { + const update = await specifier.updateSrc( + ` + import { createRequire } from 'node:module' + let require = createRequire(import.meta.url) + require('./before.js') + require = value => value + require('./after.js') + `, + 'js', + spec => { + return spec.value.replace('.js', '.mjs') + }, + ) + + assert.ok(update.indexOf("require('./before.mjs')") > -1) + assert.ok(update.indexOf("require('./after.js')") > -1) + }) + + it('updates require.resolve when require is created from createRequire', async () => { + const update = await specifier.updateSrc( + ` + import { createRequire } from 'node:module' + const require = createRequire(import.meta.url) + require.resolve('./resolve-before.js') + `, + 'js', + spec => { + return spec.value.replace('.js', '.mjs') + }, + ) + + assert.ok(update.indexOf("require.resolve('./resolve-before.mjs')") > -1) + }) + + it('covers createRequire tracking with mixed declarations and scopes', async () => { + const update = await specifier.updateSrc( + ` + import moduleDefault, { createRequire as cr, syncBuiltinESMExports } from 'node:module' + import * as moduleNS from 'module' + + let declaredWithoutInit + let loader + loader = cr(import.meta.url) + loader.resolve('./assign.js') + + // Exercise updateBinding(false) path for undeclared assignment target. + globalLoader = cr(import.meta.url) + + const notTracked = (0, cr)(import.meta.url) + notTracked('./skip.js') + + const { createRequire: fromRequire } = require('module') + const reqFromRequire = fromRequire(import.meta.url) + reqFromRequire('./from-require.js') + + const { createRequire: ignoredFromOtherFactory } = createFactory() + void ignoredFromOtherFactory + + const namedExpression = function namedExpressionFn( + [head, ...tail], + { nested: { value }, ...restObject } = {}, + ) { + return head ?? tail ?? value ?? restObject + } + + try { + throw { message: 'oops', code: 1 } + } catch ({ message, ...restError }) { + void message + void restError + } + + void moduleDefault + void moduleNS + void syncBuiltinESMExports + void declaredWithoutInit + void namedExpression + `, + 'js', + spec => { + return spec.value.replace('.js', '.mjs') + }, + ) + + assert.ok(update.indexOf("loader.resolve('./assign.mjs')") > -1) + assert.ok(update.indexOf("reqFromRequire('./from-require.mjs')") > -1) + assert.ok(update.indexOf("notTracked('./skip.js')") > -1) + }) + + it('covers function-declaration bindings and non-createRequire object keys', async () => { + const update = await specifier.updateSrc( + ` + function useBindings([, first], { nested: { value } = {}, ...restObj } = {}) { + const { notCreateRequire: localFactory, ...otherExports } = require('module') + const local = localFactory(import.meta.url) + local('./should-skip.js') + return [first, value, restObj, otherExports] + } + + void useBindings + `, + 'js', + spec => { + return spec.value.replace('.js', '.mjs') + }, + ) + + assert.ok(update.indexOf("local('./should-skip.js')") > -1) + }) + + it('does not rewrite shadowed require in arrow expression body', async () => { + const update = await specifier.updateSrc( + ` + const callWithShadowedRequire = require => require('./skip.js') + require('./rewrite.js') + `, + 'js', + spec => { + return spec.value.replace('.js', '.mjs') + }, + ) + + assert.ok(update.indexOf("require('./skip.js')") > -1) + assert.ok(update.indexOf("require('./rewrite.mjs')") > -1) + }) + + it('hoists var createRequire aliases out of block scope', async () => { + const update = await specifier.updateSrc( + ` + import { createRequire } from 'node:module' + + { + var loader = createRequire(import.meta.url) + } + + loader('./outside.js') + `, + 'js', + spec => { + return spec.value.replace('.js', '.mjs') + }, + ) + + assert.ok(update.indexOf("loader('./outside.mjs')") > -1) + }) })