diff --git a/README.md b/README.md index a07da21..f908f8d 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,17 @@ function add(a, b) { API --------------------------------------- -unassert package exports three functions. [`unassertAst`](https://github.com/unassert-js/unassert#const-modifiedast--unassertastast-options) is the main function. [`createVisitor`](https://github.com/unassert-js/unassert#const-visitor--createvisitoroptions) and [`defaultOptions`](https://github.com/unassert-js/unassert#const-options--defaultoptions) are for customization. +unassert package exports four functions. +Main functions: + +* [`unassertAst`](https://github.com/unassert-js/unassert#const-modifiedast--unassertastast-options) +* [`unassertCode`](https://github.com/unassert-js/unassert#const-unasserted--unassertcodecodeast-options) + +For customization: + +* [`createVisitor`](https://github.com/unassert-js/unassert#const-visitor--createvisitoroptions) +* [`defaultOptions`](https://github.com/unassert-js/unassert#const-options--defaultoptions) ### const modifiedAst = unassertAst(ast, options) @@ -128,6 +137,7 @@ For example, the default target modules are as follows. 'node:assert', 'node:assert/strict' ] +} ``` In this case, unassert will remove assert variable declarations such as, @@ -201,6 +211,41 @@ unassert removes all `strictAssert`, `ok`, `eq` calls. Please see [customization example](https://github.com/unassert-js/unassert#example-1) for more details. +### const unasserted = unassertCode(code, ast, options) + +```javascript +const { unassertCode } = require('unassert') +``` + +```javascript +import { unassertCode } from 'unassert' +``` + +| return type | +|:--------------------------------------------------------------| +| `{ code: string; map: SourceMap | null; }` | + +Remove assertion calls from the code. Default behaviour can be customized by `options`. +Note that the `ast` is manipulated directly. + +#### MagicString + +If a [MagicString](https://www.npmjs.com/package/magic-string) is passed instead of a normal string, +this function will simply modify that string and return it. + +#### options + +Object for configuration options. passed `options` is `Object.assign`ed with default options. If not passed, default options will be used. + +##### options.modules + +The same as for [`unassertAst`](#optionsmodules). + +##### options.sourceMap + +If `true`, a sourcemap of the changes will be generated in addition to the code. + +You can alternatively specify [sourcemap options](https://github.com/rich-harris/magic-string#sgeneratemap-options-) if you which to customize how the sourcemap is generated. ### const visitor = createVisitor(options) @@ -218,6 +263,18 @@ import { createVisitor } from 'unassert' Create visitor object to be used with `estraverse.replace`. Visitor can be customized by `options`. +#### options + +Object for configuration options. passed `options` is `Object.assign`ed with default options. If not passed, default options will be used. + +##### options.modules + +The same as for [`unassertAst`](#optionsmodules). + +##### options.code + +A [MagicString](https://www.npmjs.com/package/magic-string) of the code the ast represents. +If given, this code will be updated with the changes made to the ast. ### const options = defaultOptions() diff --git a/dist/index.cjs b/dist/index.cjs index cc45827..f7973af 100644 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -1,6 +1,7 @@ 'use strict'; const estraverse = require('estraverse'); +const MagicString = require('magic-string'); /** * unassert @@ -13,31 +14,99 @@ const estraverse = require('estraverse'); * https://github.com/unassert-js/unassert/blob/master/LICENSE */ +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('acorn').Node} + */ +function isAcornNode (node) { + return typeof node === 'object' && node !== null && typeof node.start === 'number' && typeof node.end === 'number'; +} + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').Literal} + */ function isLiteral (node) { - return node && node.type === 'Literal'; + return node?.type === 'Literal'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').Identifier} + */ function isIdentifier (node) { - return node && node.type === 'Identifier'; + return node?.type === 'Identifier'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').ObjectPattern} + */ function isObjectPattern (node) { - return node && node.type === 'ObjectPattern'; + return node?.type === 'ObjectPattern'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').MemberExpression} + */ function isMemberExpression (node) { - return node && node.type === 'MemberExpression'; + return node?.type === 'MemberExpression'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').CallExpression} + */ function isCallExpression (node) { - return node && node.type === 'CallExpression'; + return node?.type === 'CallExpression'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').ExpressionStatement} + */ function isExpressionStatement (node) { - return node && node.type === 'ExpressionStatement'; + return node?.type === 'ExpressionStatement'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').IfStatement} + */ function isIfStatement (node) { - return node && node.type === 'IfStatement'; + return node?.type === 'IfStatement'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').ImportDeclaration} + */ function isImportDeclaration (node) { - return node && node.type === 'ImportDeclaration'; + return node?.type === 'ImportDeclaration'; +} + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').Property} + */ +function isProperty (node) { + return node?.type === 'Property'; } +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').VariableDeclarator} + */ +function isVariableDeclarator (node) { + return node?.type === 'VariableDeclarator'; +} + +/** + * @param {import('estree').Node | undefined | null} node + * @param {string | number} key + * @returns {boolean} + */ function isBodyOfNodeHavingNonBlockStatementAsBody (node, key) { if (!node) { return false; @@ -58,28 +127,67 @@ function isBodyOfNodeHavingNonBlockStatementAsBody (node, key) { return false; } +/** + * @param {import('estree').Node | undefined | null} node + * @param {string | number} key + * @returns {boolean} + */ function isBodyOfIfStatement (node, key) { return isIfStatement(node) && (key === 'consequent' || key === 'alternate'); } - +/** + * @param {import('estree').Node} currentNode + * @param {import('estree').Node} parentNode + * @param {string | number} key + * @returns {boolean} + */ function isNonBlockChildOfParentNode (currentNode, parentNode, key) { return isExpressionStatement(currentNode) && isCallExpression(currentNode.expression) && (isBodyOfIfStatement(parentNode, key) || isBodyOfNodeHavingNonBlockStatementAsBody(parentNode, key)); } +/** + * @param {import('./index.mjs').CreateVisitorOptions} [options] + * @returns {import('estraverse').Visitor} + */ function createVisitor (options) { const config = Object.assign(defaultOptions(), options); const targetModules = new Set(config.modules); const targetVariables = new Set(config.variables); + const { code } = config; + + /** + * @type {WeakMap< + * import('estree').Node, + * | { + * code: string; + * node: import('estree').Node + * } + * | null + * >} + */ + const nodeUpdates = new WeakMap(); + /** + * @param {import('estree').Node} lit + * @returns {boolean} + */ function isAssertionModuleName (lit) { - return isLiteral(lit) && targetModules.has(lit.value); + return isLiteral(lit) && targetModules.has(/** @type {string} */ (lit.value)); } + /** + * @param {import('estree').Node} id + * @returns {boolean} + */ function isAssertionVariableName (id) { return isIdentifier(id) && targetVariables.has(id.name); } + /** + * @param {import('estree').Expression | import('estree').Super} callee + * @returns {boolean} + */ function isAssertionMethod (callee) { if (!isMemberExpression(callee)) { return false; @@ -92,10 +200,18 @@ function createVisitor (options) { } } + /** + * @param {import('estree').Expression | import('estree').Super} callee + * @returns {boolean} + */ function isAssertionFunction (callee) { return isAssertionVariableName(callee); } + /** + * @param {import('estree').Expression | import('estree').Super} callee + * @returns {boolean} + */ function isConsoleAssert (callee) { if (!isMemberExpression(callee)) { return false; @@ -105,24 +221,42 @@ function createVisitor (options) { isIdentifier(prop) && prop.name === 'assert'; } + /** + * @param {import('estree').Node} id + * @returns {void} + */ function registerIdentifierAsAssertionVariable (id) { if (isIdentifier(id)) { targetVariables.add(id.name); } } + /** + * @param {import('estree').ObjectPattern} objectPattern + * @returns {void} + */ function handleDestructuredAssertionAssignment (objectPattern) { - for (const { value } of objectPattern.properties) { - registerIdentifierAsAssertionVariable(value); + for (const property of objectPattern.properties) { + if (isProperty(property)) { + registerIdentifierAsAssertionVariable(property.value); + } } } + /** + * @param {import('estree').ImportDeclaration} importDeclaration + * @returns {void} + */ function handleImportSpecifiers (importDeclaration) { for (const { local } of importDeclaration.specifiers) { registerIdentifierAsAssertionVariable(local); } } + /** + * @param {import('estree').Node} node + * @returns {void} + */ function registerAssertionVariables (node) { if (isIdentifier(node)) { registerIdentifierAsAssertionVariable(node); @@ -133,6 +267,11 @@ function createVisitor (options) { } } + /** + * @param {import('estree').Pattern} id + * @param {import('estree').Expression | import('estree').Super | undefined | null} init + * @returns {boolean} + */ function isRequireAssert (id, init) { if (!isCallExpression(init)) { return false; @@ -148,6 +287,11 @@ function createVisitor (options) { return isIdentifier(id) || isObjectPattern(id); } + /** + * @param {import('estree').Pattern} id + * @param {import('estree').Expression | import('estree').Super | undefined | null} init + * @returns {boolean} + */ function isRequireAssertDotStrict (id, init) { if (!isMemberExpression(init)) { return false; @@ -162,121 +306,334 @@ function createVisitor (options) { return prop.name === 'strict'; } + /** + * @param {import('estree').Pattern} id + * @param {import('estree').Expression | import('estree').Super | undefined | null} init + * @returns {boolean} + */ function isRemovalTargetRequire (id, init) { return isRequireAssert(id, init) || isRequireAssertDotStrict(id, init); } + /** + * @param {import('estree').Expression | import('estree').Super} callee + * @returns {boolean} + */ function isRemovalTargetAssertion (callee) { return isAssertionFunction(callee) || isAssertionMethod(callee) || isConsoleAssert(callee); } - const nodeToRemove = new WeakSet(); + /** + * @param {import('estree').Node} node + * @returns {void} + */ + function removeNode (node) { + nodeUpdates.set(node, null); + } + + /** + * @param {import('estree').Node} node + * @returns {void} + */ + function replaceNode (node, replacement) { + nodeUpdates.set(node, replacement); + } + + /** + * @param {import('acorn').Node} node + * @param {string} code + * @returns {{start: number; end: number;}} + */ + function getStartAndEnd (node, code) { + let { start, end } = node; + while (/\s/.test(code[start - 1])) { + start -= 1; + } + if (isVariableDeclarator(node)) { + let newEnd = end; + while (/\s/.test(code[newEnd])) { + newEnd += 1; + } + if (/,/.test(code[newEnd])) { + end = newEnd + 1; + } + } + return { start, end }; + } + + /** + * @returns {{ + * code: string; + * node: import('estree').Expression + * }} + */ + function createNoopExpression () { + return { + code: '(void 0)', + node: { + type: 'UnaryExpression', + operator: 'void', + prefix: true, + argument: { + type: 'Literal', + value: 0, + raw: '0' + } + } + }; + } + + /** + * @returns {{ + * code: string; + * node: import('estree').BlockStatement + * }} + */ + function createNoopStatement () { + return { + code: '{}', + node: { + type: 'BlockStatement', + body: [] + } + }; + } + + /** + * @this {import('estraverse').Controller} + * @param {import('estree').ImportDeclaration} currentNode + */ + function unassertImportDeclaration (currentNode) { + const source = currentNode.source; + if (!(isAssertionModuleName(source))) { + return; + } + // remove current ImportDeclaration + removeNode(currentNode); + this.skip(); + // register local identifier(s) as assertion variable + registerAssertionVariables(currentNode); + } + + /** + * @this {import('estraverse').Controller} + * @param {import('estree').VariableDeclarator} currentNode + * @param {import('estree').VariableDeclaration} parentNode + */ + function unassertVariableDeclarator (currentNode, parentNode) { + if (isRemovalTargetRequire(currentNode.id, currentNode.init)) { + if (parentNode.declarations.length === 1) { + // remove parent VariableDeclaration + removeNode(parentNode); + } else { + // single var pattern + // remove current VariableDeclarator + removeNode(currentNode); + } + this.skip(); + // register local identifier(s) as assertion variable + registerAssertionVariables(currentNode.id); + } + } + + /** + * @this {import('estraverse').Controller} + * @param {import('estree').AssignmentExpression} currentNode + * @param {import('estree').Node} parentNode + */ + function unassertAssignmentExpression (currentNode, parentNode) { + if (currentNode.operator !== '=') { + return; + } + if (!isExpressionStatement(parentNode)) { + return; + } + if (isRemovalTargetRequire(currentNode.left, currentNode.right)) { + // remove parent ExpressionStatement + removeNode(parentNode); + this.skip(); + // register local identifier(s) as assertion variable + registerAssertionVariables(currentNode.left); + } + } + + /** + * @this {import('estraverse').Controller} + * @param {import('estree').CallExpression} currentNode + * @param {import('estree').Node} parentNode + */ + function unassertCallExpression (currentNode, parentNode) { + const callee = currentNode.callee; + if (!isRemovalTargetAssertion(callee)) { + return; + } + + switch (parentNode.type) { + case 'ExpressionStatement': { + // remove parent ExpressionStatement + removeNode(parentNode); + this.skip(); + break; + } + case 'SequenceExpression': { + // replace the asserstion with essentially nothing + replaceNode(currentNode, createNoopExpression()); + break; + } + } + } + + /** + * @this {import('estraverse').Controller} + * @param {import('estree').AwaitExpression} currentNode + * @param {import('estree').Node} parentNode + */ + function unassertAwaitExpression (currentNode, parentNode) { + const childNode = currentNode.argument; + if (isExpressionStatement(parentNode) && isCallExpression(childNode)) { + const callee = childNode.callee; + if (isRemovalTargetAssertion(callee)) { + // remove parent ExpressionStatement + removeNode(parentNode); + this.skip(); + } + } + } return { enter: function (currentNode, parentNode) { + if (code && isAcornNode(currentNode)) { + code.addSourcemapLocation(currentNode.start); + code.addSourcemapLocation(currentNode.end); + } + switch (currentNode.type) { case 'ImportDeclaration': { - const source = currentNode.source; - if (!(isAssertionModuleName(source))) { - return; - } - // remove current ImportDeclaration - nodeToRemove.add(currentNode); - this.skip(); - // register local identifier(s) as assertion variable - registerAssertionVariables(currentNode); + unassertImportDeclaration.bind(this)(currentNode, parentNode); break; } case 'VariableDeclarator': { - if (isRemovalTargetRequire(currentNode.id, currentNode.init)) { - if (parentNode.declarations.length === 1) { - // remove parent VariableDeclaration - nodeToRemove.add(parentNode); - } else { - // single var pattern - // remove current VariableDeclarator - nodeToRemove.add(currentNode); - } - this.skip(); - // register local identifier(s) as assertion variable - registerAssertionVariables(currentNode.id); - } + unassertVariableDeclarator.bind(this)(currentNode, parentNode); break; } case 'AssignmentExpression': { - if (currentNode.operator !== '=') { - return; - } - if (!isExpressionStatement(parentNode)) { - return; - } - if (isRemovalTargetRequire(currentNode.left, currentNode.right)) { - // remove parent ExpressionStatement - nodeToRemove.add(parentNode); - this.skip(); - // register local identifier(s) as assertion variable - registerAssertionVariables(currentNode.left); - } + unassertAssignmentExpression.bind(this)(currentNode, parentNode); break; } case 'CallExpression': { - if (!isExpressionStatement(parentNode)) { - return; - } - const callee = currentNode.callee; - if (isRemovalTargetAssertion(callee)) { - // remove parent ExpressionStatement - nodeToRemove.add(parentNode); - this.skip(); - } + unassertCallExpression.bind(this)(currentNode, parentNode); break; } case 'AwaitExpression': { - const childNode = currentNode.argument; - if (isExpressionStatement(parentNode) && isCallExpression(childNode)) { - const callee = childNode.callee; - if (isRemovalTargetAssertion(callee)) { - // remove parent ExpressionStatement - nodeToRemove.add(parentNode); - this.skip(); - } - } + unassertAwaitExpression.bind(this)(currentNode, parentNode); break; } } }, leave: function (currentNode, parentNode) { - switch (currentNode.type) { - case 'ImportDeclaration': - case 'VariableDeclarator': - case 'VariableDeclaration': - case 'ExpressionStatement': - break; - default: - return undefined; + const update = nodeUpdates.get(currentNode); + + if (update === undefined) { + return undefined; } - if (nodeToRemove.has(currentNode)) { + + if (update === null) { if (isExpressionStatement(currentNode)) { const path = this.path(); - const key = path[path.length - 1]; - if (isNonBlockChildOfParentNode(currentNode, parentNode, key)) { - return { - type: 'BlockStatement', - body: [] - }; + if (path) { + const key = path[path.length - 1]; + if (parentNode && isNonBlockChildOfParentNode(currentNode, parentNode, key)) { + const replacement = createNoopStatement(); + if (code && isAcornNode(currentNode)) { + const { start, end } = getStartAndEnd(currentNode, code.toString()); + code.overwrite(start, end, replacement.code); + } + return replacement.node; + } } } + + if (code && isAcornNode(currentNode)) { + const { start, end } = getStartAndEnd(currentNode, code.toString()); + code.remove(start, end); + } + this.remove(); + return undefined; } - return undefined; + + if (code && isAcornNode(currentNode)) { + const { start, end } = getStartAndEnd(currentNode, code.toString()); + code.overwrite(start, end, update.code); + } + + return update.node; } }; } +/** + * @param {import('estree').Node} ast + * @param {import('./index.mjs').UnassertAstOptions} [options] + * @returns {import('estree').Node} + */ function unassertAst (ast, options) { return estraverse.replace(ast, createVisitor(options)); } +/** + * @overload + * @param {string} code + * @param {import('estree').Node} ast + * @param {import('./index.mjs').UnassertCodeOptions} [options] + * @returns {import('./index.mjs').UnassertCodeResult} + */ + +/** + * @overload + * @param {MagicString} code + * @param {import('estree').Node} ast + * @param {import('./index.mjs').UnassertCodeOptions} [options] + * @returns {MagicString} + */ + +/** + * @param {string|MagicString} code + * @param {import('estree').Node} ast + * @param {import('./index.mjs').UnassertCodeOptions} [options] + * @returns {import('./index.mjs').UnassertCodeResult | MagicString} + */ +function unassertCode (code, ast, options) { + const { + sourceMap, + ...traverseOptions + } = options ?? {}; + const usingMagic = code instanceof MagicString; + const magicCode = usingMagic ? code : new MagicString(code); + + estraverse.traverse(ast, createVisitor({ + ...traverseOptions, + code: magicCode + })); + + if (usingMagic) { + return magicCode; + } + + const unassertedCode = magicCode.toString(); + const map = sourceMap + ? magicCode.generateMap(sourceMap === true ? undefined : sourceMap) + : null; + + return { + code: unassertedCode, + map + }; +} + +/** + * @returns {import('./index.mjs').UnassertAstOptions} + */ function defaultOptions () { return { modules: [ @@ -291,3 +648,4 @@ function defaultOptions () { exports.createVisitor = createVisitor; exports.defaultOptions = defaultOptions; exports.unassertAst = unassertAst; +exports.unassertCode = unassertCode; diff --git a/package-lock.json b/package-lock.json index 1feeb18..30254c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "2.0.2", "license": "MIT", "dependencies": { - "estraverse": "^5.0.0" + "estraverse": "^5.0.0", + "magic-string": "^0.30.0" }, "devDependencies": { "@twada/benchmark-commits": "^0.1.0", @@ -231,6 +232,11 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" + }, "node_modules/@twada/benchmark-commits": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@twada/benchmark-commits/-/benchmark-commits-0.1.0.tgz", @@ -2442,6 +2448,17 @@ "node": ">=10" } }, + "node_modules/magic-string": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", + "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.13" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/minimatch": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", @@ -3977,6 +3994,11 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" + }, "@twada/benchmark-commits": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@twada/benchmark-commits/-/benchmark-commits-0.1.0.tgz", @@ -5606,6 +5628,14 @@ "yallist": "^4.0.0" } }, + "magic-string": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", + "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", + "requires": { + "@jridgewell/sourcemap-codec": "^1.4.13" + } + }, "minimatch": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", diff --git a/package.json b/package.json index e7a7882..e2c26c4 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ } ], "dependencies": { - "estraverse": "^5.0.0" + "estraverse": "^5.0.0", + "magic-string": "^0.30.0" }, "devDependencies": { "@twada/benchmark-commits": "^0.1.0", diff --git a/src/index.d.mts b/src/index.d.mts new file mode 100644 index 0000000..b284148 --- /dev/null +++ b/src/index.d.mts @@ -0,0 +1,46 @@ +import type MagicString from "magic-string"; +import type { SourceMap, SourceMapOptions } from "magic-string"; +import type { Node } from "acorn"; +import type { Visitor } from "estraverse"; + +export type UnassertAstOptions = Partial<{ + modules: string[]; + variables: string[]; +}>; + +export type UnassertCodeOptions = UnassertAstOptions & + Partial<{ + sourceMap: boolean | SourceMapOptions; + }>; + +export type CreateVisitorOptions = UnassertAstOptions & + Partial<{ + code: MagicString; + }>; + +export type UnassertCodeResult = { + code: string; + map: SourceMap | null; +}; + +export function unassertAst(ast: Node, options?: UnassertAstOptions): Node; + +export function unassertCode( + code: string, + ast: Node, + options?: UnassertCodeOptions +): UnassertCodeResult; +export function unassertCode( + code: MagicString, + ast: Node, + options?: UnassertCodeOptions +): MagicString; +export function unassertCode( + code: string | MagicString, + ast: Node, + options?: UnassertCodeOptions +): UnassertCodeResult | MagicString; + +export function defaultOptions(): UnassertAstOptions; + +export function createVisitor(options?: CreateVisitorOptions): Visitor; diff --git a/src/index.mjs b/src/index.mjs index f614bd2..efef718 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -8,33 +8,102 @@ * Licensed under the MIT license. * https://github.com/unassert-js/unassert/blob/master/LICENSE */ -import { replace } from 'estraverse'; +import { replace, traverse } from 'estraverse'; +import MagicString from 'magic-string'; +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('acorn').Node} + */ +function isAcornNode (node) { + return typeof node === 'object' && node !== null && typeof node.start === 'number' && typeof node.end === 'number'; +} + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').Literal} + */ function isLiteral (node) { - return node && node.type === 'Literal'; + return node?.type === 'Literal'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').Identifier} + */ function isIdentifier (node) { - return node && node.type === 'Identifier'; + return node?.type === 'Identifier'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').ObjectPattern} + */ function isObjectPattern (node) { - return node && node.type === 'ObjectPattern'; + return node?.type === 'ObjectPattern'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').MemberExpression} + */ function isMemberExpression (node) { - return node && node.type === 'MemberExpression'; + return node?.type === 'MemberExpression'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').CallExpression} + */ function isCallExpression (node) { - return node && node.type === 'CallExpression'; + return node?.type === 'CallExpression'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').ExpressionStatement} + */ function isExpressionStatement (node) { - return node && node.type === 'ExpressionStatement'; + return node?.type === 'ExpressionStatement'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').IfStatement} + */ function isIfStatement (node) { - return node && node.type === 'IfStatement'; + return node?.type === 'IfStatement'; } + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').ImportDeclaration} + */ function isImportDeclaration (node) { - return node && node.type === 'ImportDeclaration'; + return node?.type === 'ImportDeclaration'; +} + +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').Property} + */ +function isProperty (node) { + return node?.type === 'Property'; } +/** + * @param {import('estree').Node | undefined | null} node + * @returns {node is import('estree').VariableDeclarator} + */ +function isVariableDeclarator (node) { + return node?.type === 'VariableDeclarator'; +} + +/** + * @param {import('estree').Node | undefined | null} node + * @param {string | number} key + * @returns {boolean} + */ function isBodyOfNodeHavingNonBlockStatementAsBody (node, key) { if (!node) { return false; @@ -55,28 +124,67 @@ function isBodyOfNodeHavingNonBlockStatementAsBody (node, key) { return false; } +/** + * @param {import('estree').Node | undefined | null} node + * @param {string | number} key + * @returns {boolean} + */ function isBodyOfIfStatement (node, key) { return isIfStatement(node) && (key === 'consequent' || key === 'alternate'); } - +/** + * @param {import('estree').Node} currentNode + * @param {import('estree').Node} parentNode + * @param {string | number} key + * @returns {boolean} + */ function isNonBlockChildOfParentNode (currentNode, parentNode, key) { return isExpressionStatement(currentNode) && isCallExpression(currentNode.expression) && (isBodyOfIfStatement(parentNode, key) || isBodyOfNodeHavingNonBlockStatementAsBody(parentNode, key)); } +/** + * @param {import('./index.mjs').CreateVisitorOptions} [options] + * @returns {import('estraverse').Visitor} + */ function createVisitor (options) { const config = Object.assign(defaultOptions(), options); const targetModules = new Set(config.modules); const targetVariables = new Set(config.variables); + const { code } = config; + + /** + * @type {WeakMap< + * import('estree').Node, + * | { + * code: string; + * node: import('estree').Node + * } + * | null + * >} + */ + const nodeUpdates = new WeakMap(); + /** + * @param {import('estree').Node} lit + * @returns {boolean} + */ function isAssertionModuleName (lit) { - return isLiteral(lit) && targetModules.has(lit.value); + return isLiteral(lit) && targetModules.has(/** @type {string} */ (lit.value)); } + /** + * @param {import('estree').Node} id + * @returns {boolean} + */ function isAssertionVariableName (id) { return isIdentifier(id) && targetVariables.has(id.name); } + /** + * @param {import('estree').Expression | import('estree').Super} callee + * @returns {boolean} + */ function isAssertionMethod (callee) { if (!isMemberExpression(callee)) { return false; @@ -89,10 +197,18 @@ function createVisitor (options) { } } + /** + * @param {import('estree').Expression | import('estree').Super} callee + * @returns {boolean} + */ function isAssertionFunction (callee) { return isAssertionVariableName(callee); } + /** + * @param {import('estree').Expression | import('estree').Super} callee + * @returns {boolean} + */ function isConsoleAssert (callee) { if (!isMemberExpression(callee)) { return false; @@ -102,24 +218,44 @@ function createVisitor (options) { isIdentifier(prop) && prop.name === 'assert'; } + /** + * @param {import('estree').Node} id + * @returns {void} + */ function registerIdentifierAsAssertionVariable (id) { if (isIdentifier(id)) { targetVariables.add(id.name); } } + /** + * @param {import('estree').ObjectPattern} objectPattern + * @returns {void} + */ function handleDestructuredAssertionAssignment (objectPattern) { - for (const { value } of objectPattern.properties) { - registerIdentifierAsAssertionVariable(value); + for (const property of objectPattern.properties) { + if (isProperty(property)) { + registerIdentifierAsAssertionVariable(property.value); + } else { + // TODO: handle rest element. + } } } + /** + * @param {import('estree').ImportDeclaration} importDeclaration + * @returns {void} + */ function handleImportSpecifiers (importDeclaration) { for (const { local } of importDeclaration.specifiers) { registerIdentifierAsAssertionVariable(local); } } + /** + * @param {import('estree').Node} node + * @returns {void} + */ function registerAssertionVariables (node) { if (isIdentifier(node)) { registerIdentifierAsAssertionVariable(node); @@ -130,6 +266,11 @@ function createVisitor (options) { } } + /** + * @param {import('estree').Pattern} id + * @param {import('estree').Expression | import('estree').Super | undefined | null} init + * @returns {boolean} + */ function isRequireAssert (id, init) { if (!isCallExpression(init)) { return false; @@ -145,6 +286,11 @@ function createVisitor (options) { return isIdentifier(id) || isObjectPattern(id); } + /** + * @param {import('estree').Pattern} id + * @param {import('estree').Expression | import('estree').Super | undefined | null} init + * @returns {boolean} + */ function isRequireAssertDotStrict (id, init) { if (!isMemberExpression(init)) { return false; @@ -159,121 +305,334 @@ function createVisitor (options) { return prop.name === 'strict'; } + /** + * @param {import('estree').Pattern} id + * @param {import('estree').Expression | import('estree').Super | undefined | null} init + * @returns {boolean} + */ function isRemovalTargetRequire (id, init) { return isRequireAssert(id, init) || isRequireAssertDotStrict(id, init); } + /** + * @param {import('estree').Expression | import('estree').Super} callee + * @returns {boolean} + */ function isRemovalTargetAssertion (callee) { return isAssertionFunction(callee) || isAssertionMethod(callee) || isConsoleAssert(callee); } - const nodeToRemove = new WeakSet(); + /** + * @param {import('estree').Node} node + * @returns {void} + */ + function removeNode (node) { + nodeUpdates.set(node, null); + } + + /** + * @param {import('estree').Node} node + * @returns {void} + */ + function replaceNode (node, replacement) { + nodeUpdates.set(node, replacement); + } + + /** + * @param {import('acorn').Node} node + * @param {string} code + * @returns {{start: number; end: number;}} + */ + function getStartAndEnd (node, code) { + let { start, end } = node; + while (/\s/.test(code[start - 1])) { + start -= 1; + } + if (isVariableDeclarator(node)) { + let newEnd = end; + while (/\s/.test(code[newEnd])) { + newEnd += 1; + } + if (/,/.test(code[newEnd])) { + end = newEnd + 1; + } + } + return { start, end }; + } + + /** + * @returns {{ + * code: string; + * node: import('estree').Expression + * }} + */ + function createNoopExpression () { + return { + code: '(void 0)', + node: { + type: 'UnaryExpression', + operator: 'void', + prefix: true, + argument: { + type: 'Literal', + value: 0, + raw: '0' + } + } + }; + } + + /** + * @returns {{ + * code: string; + * node: import('estree').BlockStatement + * }} + */ + function createNoopStatement () { + return { + code: '{}', + node: { + type: 'BlockStatement', + body: [] + } + }; + } + + /** + * @this {import('estraverse').Controller} + * @param {import('estree').ImportDeclaration} currentNode + */ + function unassertImportDeclaration (currentNode) { + const source = currentNode.source; + if (!(isAssertionModuleName(source))) { + return; + } + // remove current ImportDeclaration + removeNode(currentNode); + this.skip(); + // register local identifier(s) as assertion variable + registerAssertionVariables(currentNode); + } + + /** + * @this {import('estraverse').Controller} + * @param {import('estree').VariableDeclarator} currentNode + * @param {import('estree').VariableDeclaration} parentNode + */ + function unassertVariableDeclarator (currentNode, parentNode) { + if (isRemovalTargetRequire(currentNode.id, currentNode.init)) { + if (parentNode.declarations.length === 1) { + // remove parent VariableDeclaration + removeNode(parentNode); + } else { + // single var pattern + // remove current VariableDeclarator + removeNode(currentNode); + } + this.skip(); + // register local identifier(s) as assertion variable + registerAssertionVariables(currentNode.id); + } + } + + /** + * @this {import('estraverse').Controller} + * @param {import('estree').AssignmentExpression} currentNode + * @param {import('estree').Node} parentNode + */ + function unassertAssignmentExpression (currentNode, parentNode) { + if (currentNode.operator !== '=') { + return; + } + if (!isExpressionStatement(parentNode)) { + return; + } + if (isRemovalTargetRequire(currentNode.left, currentNode.right)) { + // remove parent ExpressionStatement + removeNode(parentNode); + this.skip(); + // register local identifier(s) as assertion variable + registerAssertionVariables(currentNode.left); + } + } + + /** + * @this {import('estraverse').Controller} + * @param {import('estree').CallExpression} currentNode + * @param {import('estree').Node} parentNode + */ + function unassertCallExpression (currentNode, parentNode) { + const callee = currentNode.callee; + if (!isRemovalTargetAssertion(callee)) { + return; + } + + switch (parentNode.type) { + case 'ExpressionStatement': { + // remove parent ExpressionStatement + removeNode(parentNode); + this.skip(); + break; + } + case 'SequenceExpression': { + // replace the asserstion with essentially nothing + replaceNode(currentNode, createNoopExpression()); + break; + } + } + } + + /** + * @this {import('estraverse').Controller} + * @param {import('estree').AwaitExpression} currentNode + * @param {import('estree').Node} parentNode + */ + function unassertAwaitExpression (currentNode, parentNode) { + const childNode = currentNode.argument; + if (isExpressionStatement(parentNode) && isCallExpression(childNode)) { + const callee = childNode.callee; + if (isRemovalTargetAssertion(callee)) { + // remove parent ExpressionStatement + removeNode(parentNode); + this.skip(); + } + } + } return { enter: function (currentNode, parentNode) { + if (code && isAcornNode(currentNode)) { + code.addSourcemapLocation(currentNode.start); + code.addSourcemapLocation(currentNode.end); + } + switch (currentNode.type) { case 'ImportDeclaration': { - const source = currentNode.source; - if (!(isAssertionModuleName(source))) { - return; - } - // remove current ImportDeclaration - nodeToRemove.add(currentNode); - this.skip(); - // register local identifier(s) as assertion variable - registerAssertionVariables(currentNode); + unassertImportDeclaration.bind(this)(currentNode, parentNode); break; } case 'VariableDeclarator': { - if (isRemovalTargetRequire(currentNode.id, currentNode.init)) { - if (parentNode.declarations.length === 1) { - // remove parent VariableDeclaration - nodeToRemove.add(parentNode); - } else { - // single var pattern - // remove current VariableDeclarator - nodeToRemove.add(currentNode); - } - this.skip(); - // register local identifier(s) as assertion variable - registerAssertionVariables(currentNode.id); - } + unassertVariableDeclarator.bind(this)(currentNode, parentNode); break; } case 'AssignmentExpression': { - if (currentNode.operator !== '=') { - return; - } - if (!isExpressionStatement(parentNode)) { - return; - } - if (isRemovalTargetRequire(currentNode.left, currentNode.right)) { - // remove parent ExpressionStatement - nodeToRemove.add(parentNode); - this.skip(); - // register local identifier(s) as assertion variable - registerAssertionVariables(currentNode.left); - } + unassertAssignmentExpression.bind(this)(currentNode, parentNode); break; } case 'CallExpression': { - if (!isExpressionStatement(parentNode)) { - return; - } - const callee = currentNode.callee; - if (isRemovalTargetAssertion(callee)) { - // remove parent ExpressionStatement - nodeToRemove.add(parentNode); - this.skip(); - } + unassertCallExpression.bind(this)(currentNode, parentNode); break; } case 'AwaitExpression': { - const childNode = currentNode.argument; - if (isExpressionStatement(parentNode) && isCallExpression(childNode)) { - const callee = childNode.callee; - if (isRemovalTargetAssertion(callee)) { - // remove parent ExpressionStatement - nodeToRemove.add(parentNode); - this.skip(); - } - } + unassertAwaitExpression.bind(this)(currentNode, parentNode); break; } } }, leave: function (currentNode, parentNode) { - switch (currentNode.type) { - case 'ImportDeclaration': - case 'VariableDeclarator': - case 'VariableDeclaration': - case 'ExpressionStatement': - break; - default: - return undefined; + const update = nodeUpdates.get(currentNode); + + if (update === undefined) { + return undefined; } - if (nodeToRemove.has(currentNode)) { + + if (update === null) { if (isExpressionStatement(currentNode)) { const path = this.path(); - const key = path[path.length - 1]; - if (isNonBlockChildOfParentNode(currentNode, parentNode, key)) { - return { - type: 'BlockStatement', - body: [] - }; + if (path) { + const key = path[path.length - 1]; + if (parentNode && isNonBlockChildOfParentNode(currentNode, parentNode, key)) { + const replacement = createNoopStatement(); + if (code && isAcornNode(currentNode)) { + const { start, end } = getStartAndEnd(currentNode, code.toString()); + code.overwrite(start, end, replacement.code); + } + return replacement.node; + } } } + + if (code && isAcornNode(currentNode)) { + const { start, end } = getStartAndEnd(currentNode, code.toString()); + code.remove(start, end); + } + this.remove(); + return undefined; } - return undefined; + + if (code && isAcornNode(currentNode)) { + const { start, end } = getStartAndEnd(currentNode, code.toString()); + code.overwrite(start, end, update.code); + } + + return update.node; } }; } +/** + * @param {import('estree').Node} ast + * @param {import('./index.mjs').UnassertAstOptions} [options] + * @returns {import('estree').Node} + */ function unassertAst (ast, options) { return replace(ast, createVisitor(options)); } +/** + * @overload + * @param {string} code + * @param {import('estree').Node} ast + * @param {import('./index.mjs').UnassertCodeOptions} [options] + * @returns {import('./index.mjs').UnassertCodeResult} + */ + +/** + * @overload + * @param {MagicString} code + * @param {import('estree').Node} ast + * @param {import('./index.mjs').UnassertCodeOptions} [options] + * @returns {MagicString} + */ + +/** + * @param {string|MagicString} code + * @param {import('estree').Node} ast + * @param {import('./index.mjs').UnassertCodeOptions} [options] + * @returns {import('./index.mjs').UnassertCodeResult | MagicString} + */ +function unassertCode (code, ast, options) { + const { + sourceMap, + ...traverseOptions + } = options ?? {}; + const usingMagic = code instanceof MagicString; + const magicCode = usingMagic ? code : new MagicString(code); + + traverse(ast, createVisitor({ + ...traverseOptions, + code: magicCode + })); + + if (usingMagic) { + return magicCode; + } + + const unassertedCode = magicCode.toString(); + const map = sourceMap + ? magicCode.generateMap(sourceMap === true ? undefined : sourceMap) + : null; + + return { + code: unassertedCode, + map + }; +} + +/** + * @returns {import('./index.mjs').UnassertAstOptions} + */ function defaultOptions () { return { modules: [ @@ -287,6 +646,7 @@ function defaultOptions () { export { unassertAst, + unassertCode, defaultOptions, createVisitor }; diff --git a/test/fixtures/non_block_statement/expected.js b/test/fixtures/non_block_statement/expected.js index 35d7f53..6ab002e 100644 --- a/test/fixtures/non_block_statement/expected.js +++ b/test/fixtures/non_block_statement/expected.js @@ -15,5 +15,5 @@ function add(a, b) { b ]) { } - return a + b; + return a + (void 0, b); } diff --git a/test/fixtures/non_block_statement/fixture.js b/test/fixtures/non_block_statement/fixture.js index aa96aab..9fd439d 100644 --- a/test/fixtures/non_block_statement/fixture.js +++ b/test/fixtures/non_block_statement/fixture.js @@ -21,5 +21,5 @@ function add (a, b) { for (const i of [a, b]) assert (0 < i); - return a + b; + return a + (assert(a > b), b); } diff --git a/test/test.mjs b/test/test.mjs index f82b758..160e8ac 100644 --- a/test/test.mjs +++ b/test/test.mjs @@ -1,10 +1,11 @@ -import { unassertAst, createVisitor } from '../src/index.mjs'; +import { unassertAst, unassertCode, createVisitor } from '../src/index.mjs'; import { strict as assert } from 'assert'; import { resolve, dirname } from 'path'; import { readFileSync, existsSync } from 'fs'; import { parse } from 'acorn'; import { generate } from 'escodegen'; -import { replace } from 'estraverse'; +import { replace, traverse } from 'estraverse'; +import MagicString from 'magic-string'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -12,8 +13,12 @@ function parseFixture (filepath) { return parse(readFileSync(filepath), { sourceType: 'module', ecmaVersion: '2022' }); } -function createFixture ({ code, postlude, prelude }) { - return parse(prelude + '\n' + code + '\n' + postlude, { sourceType: 'module', ecmaVersion: '2022' }); +function createFixtureCode ({ code, postlude, prelude }) { + return prelude + '\n' + code + '\n' + postlude; +} + +function parseFixtureCode (code) { + return parse(code, { sourceType: 'module', ecmaVersion: '2022' }); } function testWithGeneratedFixture (ext, code) { @@ -22,18 +27,26 @@ function testWithGeneratedFixture (ext, code) { const postludeFilepath = resolve(__dirname, 'fixtures', ext, `postlude.${ext}`); const postlude = readFileSync(postludeFilepath).toString(); const expectedFilepath = resolve(__dirname, 'fixtures', ext, `expected.${ext}`); - const expected = readFileSync(expectedFilepath).toString(); + const expected = generate(parseFixtureCode(readFileSync(expectedFilepath).toString())); function deftest (name, fun) { it(`${code} : ${name}`, () => { - const ast = createFixture({ code, postlude, prelude }); + const fixCode = createFixtureCode({ code, postlude, prelude }); + const ast = parseFixtureCode(fixCode); const modifiedAst = fun(ast); const actual = generate(modifiedAst); - assert.equal(actual + '\n', expected); + assert.equal(actual, expected); }); } deftest('unassertAst', (ast) => unassertAst(ast)); deftest('createVisitor', (ast) => replace(ast, createVisitor())); + + it(`${code} : unassertCode`, () => { + const fixtureCode = createFixtureCode({ code, postlude, prelude }); + const ast = parseFixtureCode(fixtureCode); + const actual = generate(parseFixtureCode(unassertCode(fixtureCode, ast).code)); + assert.equal(actual, expected); + }); } function testESM (code) { @@ -47,18 +60,42 @@ function testCJS (code) { function testWithFixture (fixtureName, options) { const fixtureFilepath = resolve(__dirname, 'fixtures', fixtureName, 'fixture.js'); const expectedFilepath = resolve(__dirname, 'fixtures', fixtureName, 'expected.js'); - const expected = readFileSync(expectedFilepath).toString(); + const code = readFileSync(fixtureFilepath).toString(); + const expected = generate(parseFixtureCode(readFileSync(expectedFilepath).toString())); function deftest (name, fun) { it(`${fixtureName} : ${name}`, () => { const ast = parseFixture(fixtureFilepath); const modifiedAst = fun(ast); const actual = generate(modifiedAst); - assert.equal(actual + '\n', expected); + assert.equal(actual, expected); }); } deftest('unassertAst', (ast) => unassertAst(ast, options)); deftest('createVisitor', (ast) => replace(ast, createVisitor(options))); + + it(`${fixtureName} : unassertCode`, () => { + const ast = parseFixture(fixtureFilepath); + const actual = generate(parseFixtureCode(unassertCode(code, ast, options).code)); + assert.equal(actual, expected); + }); + + it(`${fixtureName} : unassertCode MagicString`, () => { + const ast = parseFixture(fixtureFilepath); + const magicCode = new MagicString(code); + const result = unassertCode(magicCode, ast, options); + const actual = generate(parseFixtureCode(magicCode.toString())); + assert.equal(actual, expected); + assert.equal(result, magicCode); + }); + + it(`${fixtureName} : createVisitor to update code`, () => { + const ast = parseFixture(fixtureFilepath); + const magicCode = new MagicString(code); + traverse(ast, createVisitor({ ...options, code: magicCode })); + const actual = generate(parseFixtureCode(magicCode.toString())); + assert.equal(actual, expected); + }); } describe('with default options', () => {