diff --git a/packages/unminify/README.md b/packages/unminify/README.md index 52d0e149..6b9a4928 100644 --- a/packages/unminify/README.md +++ b/packages/unminify/README.md @@ -18,6 +18,7 @@ It covers most of patterns that are used by the following tools: - [`un-typeof`](#un-typeof) - [`un-sequence-expression`](#un-sequence-expression) - [`un-variable-merging`](#un-variable-merging) + - [`un-assignment-expression`](#un-assignment-expression) - [`un-bracket-notation`](#un-bracket-notation) - [`un-while-loop`](#un-while-loop) - [`un-flip-comparisons`](#un-flip-comparisons) @@ -148,6 +149,17 @@ Separate variable declarators that are not used in for statements. + for (var j = 0, k = 0; j < 10; k++) {} ``` +### `un-assignment-expression` + +Separate chained assignment into multiple statements. + +```diff +- a = b = c = 1 ++ a = 1 ++ b = 1 ++ c = 1 +``` + ### `un-bracket-notation` Simplify bracket notation. diff --git a/packages/unminify/src/transformations/__tests__/un-assignment-merging.spec.ts b/packages/unminify/src/transformations/__tests__/un-assignment-merging.spec.ts new file mode 100644 index 00000000..f38a3855 --- /dev/null +++ b/packages/unminify/src/transformations/__tests__/un-assignment-merging.spec.ts @@ -0,0 +1,77 @@ +import { defineInlineTest } from '@wakaru/test-utils' +import transform from '../un-assignment-merging' + +const inlineTest = defineInlineTest(transform) + +inlineTest('chained assignment should be splitted', + ` +exports.foo = exports.bar = exports.baz = 1; +`, + ` +exports.foo = 1; +exports.bar = 1; +exports.baz = 1; +`, +) + +inlineTest('chained assignment should be splitted - allowed', + ` +a1 = a2 = 0; +b1 = b2 = 0n; +c1 = c2 = ''; +d1 = d2 = true; +e1 = e2 = null; +f1 = f2 = undefined; +g1 = g2 = foo; +`, + ` +a1 = 0; +a2 = 0; +b1 = 0n; +b2 = 0n; +c1 = ''; +c2 = ''; +d1 = true; +d2 = true; +e1 = null; +e2 = null; +f1 = undefined; +f2 = undefined; +g1 = foo; +g2 = foo; +`, +) + +inlineTest('chained assignment should be splitted - not allowed', + ` +a1 = a2 = \`template\${foo}\`; +b1 = b2 = Symbol(); +c1 = c2 = /regex/; +d1 = d2 = foo.bar; +f1 = f2 = fn(); +`, + ` +a1 = a2 = \`template\${foo}\`; +b1 = b2 = Symbol(); +c1 = c2 = /regex/; +d1 = d2 = foo.bar; +f1 = f2 = fn(); +`, +) + +inlineTest('chained assignment should be splitted - comments', + ` +// before +exports.foo = exports.bar = exports.baz = 1; +// after +`, + ` +// before +exports.foo = 1; + +exports.bar = 1; + +exports.baz = 1; +// after +`, +) diff --git a/packages/unminify/src/transformations/index.ts b/packages/unminify/src/transformations/index.ts index 8f536b66..24d1de90 100644 --- a/packages/unminify/src/transformations/index.ts +++ b/packages/unminify/src/transformations/index.ts @@ -3,6 +3,7 @@ import moduleMapping from './module-mapping' import prettier from './prettier' import smartInline from './smart-inline' import smartRename from './smart-rename' +import unAssignmentMerging from './un-assignment-merging' import unAsyncAwait from './un-async-await' import unBoolean from './un-boolean' import unBracketNotation from './un-bracket-notation' @@ -44,10 +45,11 @@ export const transformationMap: { 'un-curly-braces': unCurlyBraces, // add curly braces so that other transformations can works easier, but generally this is not required 'un-sequence-expression1': unSequenceExpression, // curly braces can bring out return sequence expression, so it runs before this 'un-variable-merging': unVariableMerging, + 'un-assignment-merging': unAssignmentMerging, // second stage - prepare the code for unminify 'un-runtime-helper': unRuntimeHelper, // eliminate helpers as early as possible - 'un-esm': unEsm, // relies on `un-runtime-helper` to eliminate helpers around `require` calls + 'un-esm': unEsm, // relies on `un-runtime-helper` to eliminate helpers around `require` calls, relies on `un-assignment-merging` to split exports 'un-enum': unEnum, // relies on `un-sequence-expression` // third stage - mostly one-to-one transformation diff --git a/packages/unminify/src/transformations/un-assignment-merging.ts b/packages/unminify/src/transformations/un-assignment-merging.ts new file mode 100644 index 00000000..c7c51427 --- /dev/null +++ b/packages/unminify/src/transformations/un-assignment-merging.ts @@ -0,0 +1,82 @@ +import { mergeComments } from '@wakaru/ast-utils' +import { isSimpleValue } from '../utils/checker' +import { replaceWithMultipleStatements } from '../utils/insert' +import wrap from '../wrapAstTransformation' +import type { ASTTransformation } from '../wrapAstTransformation' +import type { AssignmentExpression } from 'jscodeshift' + +/** + * Separate chained assignment into multiple statements. + * This rule is only applied to simple value assignment to + * avoid introducing behavior changes. + * + * Normally, this rule should assign the next variable to the + * previous one, which is also how the code is executed. + * + * For example: + * ```js + * exports.foo = exports.bar = 1 + * -> should be + * exports.bar = 1 + * exports.foo = exports.bar + * ``` + * + * But instead, in this rule, it is assigned to the original value + * to maximize the readability, and ease some edge cases that other + * rules might hit on. + * + * @example + * exports.foo = exports.bar = 1 + * -> + * exports.bar = 1 + * exports.foo = 1 + * + * @example + * foo = bar = baz = void 0 + * -> + * foo = void 0 + * bar = void 0 + * baz = void 0 + */ +export const transformAST: ASTTransformation = (context) => { + const { root, j } = context + + root + .find(j.ExpressionStatement, { + expression: { + type: 'AssignmentExpression', + operator: '=', + right: { + type: 'AssignmentExpression', + operator: '=', + }, + }, + }) + .forEach((p) => { + const { expression } = p.node + + let node = expression as AssignmentExpression + const assignments: AssignmentExpression[] = [node] + while (j.AssignmentExpression.check(node.right)) { + node = node.right + assignments.push(node) + } + + if (assignments.length < 2) return + + const valueNode = node.right + if ( + j.Identifier.check(valueNode) + || isSimpleValue(j, valueNode) + ) { + const replacements = assignments.map((assignment) => { + return j.expressionStatement(j.assignmentExpression('=', assignment.left, valueNode)) + }) + mergeComments(replacements, p.node.comments) + + replaceWithMultipleStatements(j, p, replacements) + } + }) +} + +export default wrap(transformAST) diff --git a/packages/unminify/src/utils/checker.ts b/packages/unminify/src/utils/checker.ts index 84fab672..d73a9908 100644 --- a/packages/unminify/src/utils/checker.ts +++ b/packages/unminify/src/utils/checker.ts @@ -4,6 +4,46 @@ export function areNodesEqual(j: JSCodeshift, node1: ASTNode, node2: ASTNode): b return j(node1).toSource() === j(node2).toSource() } +/** + * Check if node is a simple value. + * Meaning it's duplication won't cause any side effects. + * + * Includes: + * - string + * - number + * - bigInt + * - boolean + * - undefined + * - null + * + * Excludes: + * - template literal (might contain other expressions) + * - symbol ( Symbol() !== Symbol() ) + * - MemberExpression (might have side effects) + * - CallExpression (might have side effects) + */ +export function isSimpleValue(j: JSCodeshift, node: ASTNode): node is StringLiteral | NumericLiteral | BooleanLiteral | NullLiteral | UnaryExpression | Identifier | BigIntLiteral { + return ( + j.NullLiteral.check(node) + || j.StringLiteral.check(node) + || j.NumericLiteral.check(node) + || isUndefined(j, node) + || isLooseBoolean(j, node) + || j.BigIntLiteral.check(node) + ) +} + +/** + * Check if node is a loose boolean-like value. + * Includes: + * - Boolean + * - !0, !1 and all ![thing] + */ +export function isLooseBoolean(j: JSCodeshift, node: ASTNode): node is BooleanLiteral | UnaryExpression { + return j.BooleanLiteral.check(node) + || (j.UnaryExpression.check(node) && node.operator === '!') +} + /** * Check if node is `true` literal */