diff --git a/src/com/google/javascript/jscomp/AbstractPeepholeOptimization.java b/src/com/google/javascript/jscomp/AbstractPeepholeOptimization.java index 2d8c3ea82ed..1eee6c14664 100644 --- a/src/com/google/javascript/jscomp/AbstractPeepholeOptimization.java +++ b/src/com/google/javascript/jscomp/AbstractPeepholeOptimization.java @@ -23,9 +23,13 @@ import com.google.javascript.jscomp.base.Tri; import com.google.javascript.jscomp.parsing.parser.FeatureSet; import com.google.javascript.jscomp.parsing.parser.FeatureSet.Feature; +import com.google.javascript.rhino.IR; import com.google.javascript.rhino.Node; +import com.google.javascript.rhino.Token; import java.math.BigInteger; +import java.util.ArrayDeque; import java.util.LinkedHashSet; +import org.jspecify.annotations.Nullable; /** * An abstract class whose implementations run peephole optimizations: @@ -213,6 +217,239 @@ protected boolean nodeTypeMayHaveSideEffects(Node n) { return astAnalyzer.nodeTypeMayHaveSideEffects(n); } + /** + * Replaces an {@code expression} with a provided {@code value}, which must have been obtained by + * evaluating the expression. The present function ensures that the side effects of the {@code + * expression} is preserved after the replacement. + * + *

This is achieved by substituting the original expression with the comma node (expr, value) + * and then simplify the first child using {@link trySimplifyUnusedResult()}. + * + *

If the provided expression is a call such as `fn(innerExpr)`, we require that fn has no side + * effects. This is because if fn had side effects, no simplifications can occur and one ends up + * with (fn(innerExpr), value), which is less optimized than the initial expression. + * + * @return The replacement expression, which can be of the form `(..., value)` or just `value`. + */ + protected final Node replaceExpressionWithEvalResult(Node expr, Node value) { + checkState(expr.hasParent(), expr); + checkState(!value.hasParent(), value); + if (expr.isCall() || expr.isOptChainCall()) { + checkState(expr.isNoSideEffectsCall(), expr); + } + Node comma = new Node(Token.COMMA, value.srcref(expr)); + expr.replaceWith(comma); + comma.addChildToFront(expr); // comma = (expr, value) + expr = trySimplifyUnusedResult(expr); + if (expr == null || !mayHaveSideEffects(expr)) { + value.detach(); + comma.replaceWith(value); + if (expr != null) { + markFunctionsDeleted(expr); + } + expr = value; // expr = value + } else { + expr = comma; // expr = (..., value) + } + reportChangeToEnclosingScope(expr); + return expr; + } + + /** + * Replaces {@code expression} with an expression that contains only side-effects of the original. + * + *

This replacement is made under the assumption that the result of {@code expression} is + * unused and therefore it is correct to eliminate non-side-effectful nodes. + * + * @return The replacement expression, or {@code null} if there were no side-effects to preserve. + */ + protected final @Nullable Node trySimplifyUnusedResult(Node expression) { + ArrayDeque sideEffectRoots = new ArrayDeque<>(); + boolean atFixedPoint = trySimplifyUnusedResultInternal(expression, sideEffectRoots); + + if (atFixedPoint) { + // `expression` is in a form that cannot be further optimized. + return expression; + } else if (sideEffectRoots.isEmpty()) { + deleteNode(expression); + return null; + } else if (sideEffectRoots.peekFirst() == expression) { + // Expression was a conditional that was transformed. There can't be any other side-effects, + // but we also can't detach the transformed root. + checkState(sideEffectRoots.size() == 1, sideEffectRoots); + reportChangeToEnclosingScope(expression); + return expression; + } else { + Node sideEffects = asDetachedExpression(sideEffectRoots.pollFirst()); + + // Assemble a tree of comma expressions for all the side-effects. The tree must execute the + // side-effects in FIFO order with respect to the queue. It must also be left leaning to match + // the parser's preferred structure. + while (!sideEffectRoots.isEmpty()) { + Node next = asDetachedExpression(sideEffectRoots.pollFirst()); + sideEffects = IR.comma(sideEffects, next).srcref(next); + } + + sideEffects.insertBefore(expression); + deleteNode(expression); + return sideEffects; + } + } + + /** + * Collects any potentially side-effectful subtrees within {@code tree} into {@code + * sideEffectRoots}. + * + *

When a node is determined to have side-effects its descendants are not explored. This method + * assumes the entire subtree of such a node must be preserved. As a corollary, the contents of + * {@code sideEffectRoots} are a forest. + * + *

This operation generally does not mutate {@code tree}; however, exceptions are made for + * expressions that alter control-flow. Such expression will be pruned of their side-effectless + * branches. Even in this case, {@code tree} is never detached. + * + * @param sideEffectRoots The roots of subtrees determined to have side-effects, in execution + * order. + * @return {@code true} iff there is no code to be removed from within {@code tree}; it is already + * at a fixed point for code removal. + */ + private boolean trySimplifyUnusedResultInternal(Node tree, ArrayDeque sideEffectRoots) { + // Special cases for conditional expressions that may be using results. + switch (tree.getToken()) { + case HOOK -> { + // Try to remove one or more of the conditional children and transform the HOOK to an + // equivalent operation. Remember that if either value branch still exists, the result of + // the predicate expression is being used, and so cannot be removed. + // x() ? foo() : 1 --> x() && foo() + // x() ? 1 : foo() --> x() || foo() + // x() ? 1 : 1 --> x() + // x ? 1 : 1 --> null + Node trueNode = trySimplifyUnusedResult(tree.getSecondChild()); + Node falseNode = trySimplifyUnusedResult(tree.getLastChild()); + if (trueNode == null && falseNode != null) { + checkState(tree.hasTwoChildren(), tree); + + tree.setToken(Token.OR); + sideEffectRoots.addLast(tree); + return false; // The node type was changed. + } else if (trueNode != null && falseNode == null) { + checkState(tree.hasTwoChildren(), tree); + + tree.setToken(Token.AND); + sideEffectRoots.addLast(tree); + return false; // The node type was changed. + } else if (trueNode == null && falseNode == null) { + // Don't bother adding true and false branch children to make the AST valid; this HOOK is + // going to be deleted. We just need to collect any side-effects from the predicate + // expression. + trySimplifyUnusedResultInternal(tree.getOnlyChild(), sideEffectRoots); + return false; // This HOOK must be cleaned up. + } else { + sideEffectRoots.addLast(tree); + return hasFixedPointParent(tree); + } + } + case AND, OR, COALESCE -> { + // Try to remove the second operand from a AND, OR, and COALESCE operations. Remember that + // if the second + // child still exists, the result of the first expression is being used, and so cannot be + // removed. + // x() ?? f --> x() + // x() || f --> x() + // x() && f --> x() + Node conditionalResultNode = trySimplifyUnusedResult(tree.getLastChild()); + if (conditionalResultNode == null) { + // Don't bother adding a second child to make the AST valid; this op is going to be + // deleted. We just need to collect any side-effects from the predicate first child. + trySimplifyUnusedResultInternal(tree.getOnlyChild(), sideEffectRoots); + return false; // This op must be cleaned up. + } else { + sideEffectRoots.addLast(tree); + return hasFixedPointParent(tree); + } + } + case FUNCTION -> { + // Functions that aren't being invoked are dead. If they were invoked we'd see the CALL + // before arriving here. We don't want to look at any children since they'll never execute. + return false; + } + default -> { + // This is the meat of this function. It covers the general case of nodes which are unused + if (nodeTypeMayHaveSideEffects(tree)) { + sideEffectRoots.addLast(tree); + return hasFixedPointParent(tree); + } else if (!tree.hasChildren()) { + return false; // A node must have children or side-effects to be at fixed-point. + } + + boolean atFixedPoint = hasFixedPointParent(tree); + for (Node child = tree.getFirstChild(); child != null; child = child.getNext()) { + atFixedPoint &= trySimplifyUnusedResultInternal(child, sideEffectRoots); + } + return atFixedPoint; + } + } + } + + /** + * Returns an expression executing {@code expr} which is legal in any expression context. + * + * @param expr An attached expression + * @return A detached expression + */ + protected static Node asDetachedExpression(Node expr) { + switch (expr.getToken()) { + case ITER_SPREAD, OBJECT_SPREAD -> { + switch (expr.getParent().getToken()) { + case ARRAYLIT: + case NEW: + case CALL: // `Math.sin(...c)` + case OPTCHAIN_CALL: // `Math?.sin(...c)` + expr = IR.arraylit(expr.detach()).srcref(expr); + break; + case OBJECTLIT: + expr = IR.objectlit(expr.detach()).srcref(expr); + break; + default: + throw new IllegalStateException(expr.toStringTree()); + } + } + default -> {} + } + + if (expr.hasParent()) { + expr.detach(); + } + + checkState(IR.mayBeExpression(expr), expr); + return expr; + } + + /** + * Returns {@code true} iff {@code expr} is parented such that it is valid in a fixed-point + * representation of an unused expression tree. + * + *

A fixed-point representation is one in which no further nodes should be changed or removed + * when removing unused code. This method assumes that the expression tree in question is unused, + * so only side-effects are relevant. + */ + private static boolean hasFixedPointParent(Node expr) { + // Most kinds of nodes shouldn't be branches in the fixed-point tree of an unused + // expression. Those listed below are the only valid kinds. + return switch (expr.getParent().getToken()) { + case AND, COMMA, HOOK, OR, COALESCE -> true; + case ARRAYLIT, OBJECTLIT -> + // Make a special allowance for SPREADs so they remain in a legal context. Parent types + // other than ARRAYLIT and OBJECTLIT are not fixed-point because they are the tersest + // legal + // parents and are known to be side-effect free. + expr.isSpread(); + default -> + // Statments are always fixed-point parents. All other expressions are not. + NodeUtil.isStatement(expr.getParent()); + }; + } + /** * Returns whether the output language is ECMAScript 5 or later. Workarounds for quirks in * browsers that do not support ES5 can be ignored when this is true. diff --git a/src/com/google/javascript/jscomp/PeepholeFoldConstants.java b/src/com/google/javascript/jscomp/PeepholeFoldConstants.java index f6a8086a47b..4f320a173ac 100644 --- a/src/com/google/javascript/jscomp/PeepholeFoldConstants.java +++ b/src/com/google/javascript/jscomp/PeepholeFoldConstants.java @@ -28,6 +28,8 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.javascript.jscomp.NodeUtil.ValueType; import com.google.javascript.jscomp.base.Tri; +import com.google.javascript.jscomp.colors.Color; +import com.google.javascript.jscomp.colors.ColorId; import com.google.javascript.jscomp.colors.StandardColors; import com.google.javascript.rhino.IR; import com.google.javascript.rhino.Node; @@ -48,7 +50,6 @@ class PeepholeFoldConstants extends AbstractPeepholeOptimization { DiagnosticType.warning("JSC_FRACTIONAL_BITWISE_OPERAND", "Fractional bitwise operand: {0}"); private static final double MAX_FOLD_NUMBER = Math.pow(2, 53); - private final boolean late; private final boolean shouldUseTypes; @@ -239,46 +240,77 @@ private void tryConvertToNumber(Node n) { reportChangeToEnclosingScope(replacement); } - /** - * Folds 'typeof(foo)' if foo is a literal, e.g. typeof("bar") --> "string" typeof(6) --> "number" - */ - private Node tryFoldTypeof(Node originalTypeofNode) { - checkArgument(originalTypeofNode.isTypeOf()); - - Node argumentNode = originalTypeofNode.getFirstChild(); - if (argumentNode == null || !NodeUtil.isLiteralValue(argumentNode, true)) { - return originalTypeofNode; + private @Nullable String tryEvalTypeof(Node typeofExpr) { + Node operand = typeofExpr.getOnlyChild(); + switch (operand.getToken()) { + case FUNCTION, CLASS -> { + return "function"; + } + default -> {} } + return switch (NodeUtil.getKnownValueType(operand)) { + case STRING -> "string"; + case NUMBER -> "number"; + case BIGINT -> "bigint"; + case BOOLEAN -> "boolean"; + case NULL, OBJECT -> "object"; + case VOID -> "undefined"; + default -> shouldUseTypes ? tryEvalTypeofFromColor(operand.getColor()) : null; + }; + } - String typeNameString = null; - - switch (argumentNode.getToken()) { - case FUNCTION -> typeNameString = "function"; - case STRINGLIT -> typeNameString = "string"; - case NUMBER -> typeNameString = "number"; - case TRUE, FALSE -> typeNameString = "boolean"; - case NULL, OBJECTLIT, ARRAYLIT -> typeNameString = "object"; - case VOID -> typeNameString = "undefined"; - case NAME -> { - // We assume here that programs don't change the value of the - // keyword undefined to something other than the value undefined. - if (argumentNode.getString().equals("undefined")) { - typeNameString = "undefined"; + private static @Nullable String tryEvalTypeofFromColor(@Nullable Color color) { + if (color == null) { + return null; + } + if (color.isUnion()) { + String unionResult = null; + for (Color element : color.getUnionElements()) { + String elementResult = tryEvalTypeofFromColor(element); + if (elementResult == null) { + return null; + } + if (unionResult == null) { + unionResult = elementResult; + } else if (!unionResult.equals(elementResult)) { + return null; } } - default -> {} + return unionResult; + } else if (color.isConstructor()) { + return "function"; + } + ColorId colorId = color.getId(); + if (color.isPrimitive()) { + if (colorId.equals(StandardColors.STRING.getId())) { + return "string"; + } else if (colorId.equals(StandardColors.NUMBER.getId())) { + return "number"; + } else if (colorId.equals(StandardColors.BIGINT.getId())) { + return "bigint"; + } else if (colorId.equals(StandardColors.BOOLEAN.getId())) { + return "boolean"; + } else if (colorId.equals(StandardColors.SYMBOL.getId())) { + return "symbol"; + } else { + return null; + } } + return (colorId.equals(StandardColors.TOP_OBJECT.getId()) + || colorId.equals(StandardColors.UNKNOWN.getId())) + ? null + : "object"; + } - if (typeNameString != null) { - Node newNode = IR.string(typeNameString); - reportChangeToEnclosingScope(originalTypeofNode); - originalTypeofNode.replaceWith(newNode); - markFunctionsDeleted(originalTypeofNode); + /** Folds 'typeof foo' when the runtime result of {@code foo} is known. */ + private Node tryFoldTypeof(Node typeOfNode) { + checkArgument(typeOfNode.isTypeOf()); - return newNode; + String typeName = tryEvalTypeof(typeOfNode); + if (typeName != null) { + typeOfNode = replaceExpressionWithEvalResult(typeOfNode, IR.string(typeName)); } - - return originalTypeofNode; + return typeOfNode; } private Node tryFoldUnaryOperator(Node n) { @@ -1758,8 +1790,9 @@ private Node tryFoldSpread(Node spread) { parent.addChildrenAfter(child.removeChildren(), spread); spread.detach(); reportChangeToEnclosingScope(parent); + return parent; } - return parent; + return spread; } /** diff --git a/src/com/google/javascript/jscomp/PeepholeRemoveDeadCode.java b/src/com/google/javascript/jscomp/PeepholeRemoveDeadCode.java index 1083d7fb73b..cc8a13ad334 100644 --- a/src/com/google/javascript/jscomp/PeepholeRemoveDeadCode.java +++ b/src/com/google/javascript/jscomp/PeepholeRemoveDeadCode.java @@ -26,7 +26,6 @@ import com.google.javascript.rhino.IR; import com.google.javascript.rhino.Node; import com.google.javascript.rhino.Token; -import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; import org.jspecify.annotations.Nullable; @@ -310,201 +309,6 @@ private Node tryFoldExpr(Node subtree) { return subtree; } - /** - * Replaces {@code expression} with an expression that contains only side-effects of the original. - * - *

This replacement is made under the assumption that the result of {@code expression} is - * unused and therefore it is correct to eliminate non-side-effectful nodes. - * - * @return The replacement expression, or {@code null} if there were no side-effects to preserve. - */ - private @Nullable Node trySimplifyUnusedResult(Node expression) { - ArrayDeque sideEffectRoots = new ArrayDeque<>(); - boolean atFixedPoint = trySimplifyUnusedResultInternal(expression, sideEffectRoots); - - if (atFixedPoint) { - // `expression` is in a form that cannot be further optimized. - return expression; - } else if (sideEffectRoots.isEmpty()) { - deleteNode(expression); - return null; - } else if (sideEffectRoots.peekFirst() == expression) { - // Expression was a conditional that was transformed. There can't be any other side-effects, - // but we also can't detach the transformed root. - checkState(sideEffectRoots.size() == 1, sideEffectRoots); - reportChangeToEnclosingScope(expression); - return expression; - } else { - Node sideEffects = asDetachedExpression(sideEffectRoots.pollFirst()); - - // Assemble a tree of comma expressions for all the side-effects. The tree must execute the - // side-effects in FIFO order with respect to the queue. It must also be left leaning to match - // the parser's preferred structure. - while (!sideEffectRoots.isEmpty()) { - Node next = asDetachedExpression(sideEffectRoots.pollFirst()); - sideEffects = IR.comma(sideEffects, next).srcref(next); - } - - sideEffects.insertBefore(expression); - deleteNode(expression); - return sideEffects; - } - } - - /** - * Collects any potentially side-effectful subtrees within {@code tree} into {@code - * sideEffectRoots}. - * - *

When a node is determined to have side-effects its descendants are not explored. This method - * assumes the entire subtree of such a node must be preserved. As a corollary, the contents of - * {@code sideEffectRoots} are a forest. - * - *

This operation generally does not mutate {@code tree}; however, exceptions are made for - * expressions that alter control-flow. Such expression will be pruned of their side-effectless - * branches. Even in this case, {@code tree} is never detached. - * - * @param sideEffectRoots The roots of subtrees determined to have side-effects, in execution - * order. - * @return {@code true} iff there is no code to be removed from within {@code tree}; it is already - * at a fixed point for code removal. - */ - private boolean trySimplifyUnusedResultInternal(Node tree, ArrayDeque sideEffectRoots) { - // Special cases for conditional expressions that may be using results. - switch (tree.getToken()) { - case HOOK -> { - // Try to remove one or more of the conditional children and transform the HOOK to an - // equivalent operation. Remember that if either value branch still exists, the result of - // the predicate expression is being used, and so cannot be removed. - // x() ? foo() : 1 --> x() && foo() - // x() ? 1 : foo() --> x() || foo() - // x() ? 1 : 1 --> x() - // x ? 1 : 1 --> null - Node trueNode = trySimplifyUnusedResult(tree.getSecondChild()); - Node falseNode = trySimplifyUnusedResult(tree.getLastChild()); - if (trueNode == null && falseNode != null) { - checkState(tree.hasTwoChildren(), tree); - - tree.setToken(Token.OR); - sideEffectRoots.addLast(tree); - return false; // The node type was changed. - } else if (trueNode != null && falseNode == null) { - checkState(tree.hasTwoChildren(), tree); - - tree.setToken(Token.AND); - sideEffectRoots.addLast(tree); - return false; // The node type was changed. - } else if (trueNode == null && falseNode == null) { - // Don't bother adding true and false branch children to make the AST valid; this HOOK is - // going to be deleted. We just need to collect any side-effects from the predicate - // expression. - trySimplifyUnusedResultInternal(tree.getOnlyChild(), sideEffectRoots); - return false; // This HOOK must be cleaned up. - } else { - sideEffectRoots.addLast(tree); - return hasFixedPointParent(tree); - } - } - case AND, OR, COALESCE -> { - // Try to remove the second operand from a AND, OR, and COALESCE operations. Remember that - // if the second - // child still exists, the result of the first expression is being used, and so cannot be - // removed. - // x() ?? f --> x() - // x() || f --> x() - // x() && f --> x() - Node conditionalResultNode = trySimplifyUnusedResult(tree.getLastChild()); - if (conditionalResultNode == null) { - // Don't bother adding a second child to make the AST valid; this op is going to be - // deleted. We just need to collect any side-effects from the predicate first child. - trySimplifyUnusedResultInternal(tree.getOnlyChild(), sideEffectRoots); - return false; // This op must be cleaned up. - } else { - sideEffectRoots.addLast(tree); - return hasFixedPointParent(tree); - } - } - case FUNCTION -> { - // Functions that aren't being invoked are dead. If they were invoked we'd see the CALL - // before arriving here. We don't want to look at any children since they'll never execute. - return false; - } - default -> { - // This is the meat of this function. It covers the general case of nodes which are unused - if (nodeTypeMayHaveSideEffects(tree)) { - sideEffectRoots.addLast(tree); - return hasFixedPointParent(tree); - } else if (!tree.hasChildren()) { - return false; // A node must have children or side-effects to be at fixed-point. - } - - boolean atFixedPoint = hasFixedPointParent(tree); - for (Node child = tree.getFirstChild(); child != null; child = child.getNext()) { - atFixedPoint &= trySimplifyUnusedResultInternal(child, sideEffectRoots); - } - return atFixedPoint; - } - } - } - - /** - * Returns an expression executing {@code expr} which is legal in any expression context. - * - * @param expr An attached expression - * @return A detached expression - */ - private static Node asDetachedExpression(Node expr) { - switch (expr.getToken()) { - case ITER_SPREAD, OBJECT_SPREAD -> { - switch (expr.getParent().getToken()) { - case ARRAYLIT: - case NEW: - case CALL: // `Math.sin(...c)` - case OPTCHAIN_CALL: // `Math?.sin(...c)` - expr = IR.arraylit(expr.detach()).srcref(expr); - break; - case OBJECTLIT: - expr = IR.objectlit(expr.detach()).srcref(expr); - break; - default: - throw new IllegalStateException(expr.toStringTree()); - } - } - default -> {} - } - - if (expr.hasParent()) { - expr.detach(); - } - - checkState(IR.mayBeExpression(expr), expr); - return expr; - } - - /** - * Returns {@code true} iff {@code expr} is parented such that it is valid in a fixed-point - * representation of an unused expression tree. - * - *

A fixed-point representation is one in which no further nodes should be changed or removed - * when removing unused code. This method assumes that the expression tree in question is unused, - * so only side-effects are relevant. - */ - private static boolean hasFixedPointParent(Node expr) { - // Most kinds of nodes shouldn't be branches in the fixed-point tree of an unused - // expression. Those listed below are the only valid kinds. - return switch (expr.getParent().getToken()) { - case AND, COMMA, HOOK, OR, COALESCE -> true; - case ARRAYLIT, OBJECTLIT -> - // Make a special allowance for SPREADs so they remain in a legal context. Parent types - // other than ARRAYLIT and OBJECTLIT are not fixed-point because they are the tersest - // legal - // parents and are known to be side-effect free. - expr.isSpread(); - default -> - // Statments are always fixed-point parents. All other expressions are not. - NodeUtil.isStatement(expr.getParent()); - }; - } - /** A predicate for matching anything except function nodes. */ private static class MatchUnnamedBreak implements Predicate { @Override diff --git a/src/com/google/javascript/jscomp/PeepholeReplaceKnownMethods.java b/src/com/google/javascript/jscomp/PeepholeReplaceKnownMethods.java index 05325aaf688..799baf3474d 100644 --- a/src/com/google/javascript/jscomp/PeepholeReplaceKnownMethods.java +++ b/src/com/google/javascript/jscomp/PeepholeReplaceKnownMethods.java @@ -27,6 +27,10 @@ import com.google.common.base.Ascii; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.javascript.jscomp.base.Tri; +import com.google.javascript.jscomp.colors.Color; +import com.google.javascript.jscomp.colors.ColorId; import com.google.javascript.jscomp.colors.StandardColors; import com.google.javascript.rhino.IR; import com.google.javascript.rhino.Node; @@ -36,8 +40,27 @@ import java.util.Locale; import org.jspecify.annotations.Nullable; -/** Just to fold known methods when they are called with constants. */ +/** + * Folds known methods when the return value can be determined at compile time. This can happen when + * the parameters are constants or the method result depends on the parameters only through their + * color and we know the parameter color. + * + *

Example: + * + *

{@code
+ * Number.parseFloat("0.1") -> 0.1
+ * "(".charCodeAt(0)        -> 40
+ * ['a', 'b', 'c'].join('') -> 'abc'
+ * Array.isArray([1, 2])    -> true
+ * Array.isArray(x.sort())  -> x.sort(), true
+ * }
+ */ class PeepholeReplaceKnownMethods extends AbstractPeepholeOptimization { + private static final ImmutableSet DEFINITELY_ARRAY_COLOR_IDS = + ImmutableSet.of( + StandardColors.ARRAY_ID, + StandardColors.READONLY_ARRAY_ID, + StandardColors.I_TEMPLATE_ARRAY_ID); private final boolean late; private final boolean useTypes; @@ -110,17 +133,72 @@ private Node tryFoldKnownArrayMethods(Node subtree, Node callTarget) { // Method node might not be a string if callTarget is a GETELEM. // e.g. Array[something]() - if (!callTarget.getString().equals("of")) { - return subtree; + switch (callTarget.getString()) { + case "isArray" -> { + Tri isArray = tryEvalArrayIsArray(subtree); + if (isArray == Tri.UNKNOWN) { + return subtree; + } + subtree.setSideEffectFlags(Node.SideEffectFlags.NO_SIDE_EFFECTS); + return replaceExpressionWithEvalResult( + subtree, NodeUtil.booleanNode(isArray.toBoolean(false))); + } + case "of" -> { + subtree.removeFirstChild(); + + Node arraylit = new Node(Token.ARRAYLIT); + arraylit.addChildrenToBack(subtree.removeChildren()); + subtree.replaceWith(arraylit); + reportChangeToEnclosingScope(arraylit); + return arraylit; + } + default -> { + return subtree; + } } + } - subtree.removeFirstChild(); - - Node arraylit = new Node(Token.ARRAYLIT); - arraylit.addChildrenToBack(subtree.removeChildren()); - subtree.replaceWith(arraylit); - reportChangeToEnclosingScope(arraylit); - return arraylit; + /** + * Given an Array.isArray(arg, ...) expression, tries to evaluate its return value by inspecting + * the color of arg. + * + *

If the return value can be determined with certainty, returns Tri.TRUE or Tri.FALSE; + * otherwise returns Tri.UNKNOWN. + * + *

While JSType's can lead to more refined evaluation, since they are not available at + * peepholes in most common pass configs, we rely on colors only. + */ + private Tri tryEvalArrayIsArray(Node expr) { + Node arg = expr.getSecondChild(); + if (arg == null) { + return Tri.FALSE; + } + if (arg.isSpread()) { + return Tri.UNKNOWN; + } + if (arg.isArrayLit()) { + return Tri.TRUE; + } + Color color = useTypes ? arg.getColor() : null; + if (color == null) { + return Tri.UNKNOWN; + } + if (DEFINITELY_ARRAY_COLOR_IDS.contains(color.getId())) { + return Tri.TRUE; + } + if (color.isUnion()) { + boolean allArrays = true; + boolean allPrimitives = true; + for (Color element : color.getUnionElements()) { + allArrays &= DEFINITELY_ARRAY_COLOR_IDS.contains(element.getId()); + allPrimitives &= element.isPrimitive(); + if (!allArrays && !allPrimitives) { + return Tri.UNKNOWN; + } + } + return Tri.forBoolean(allArrays); + } + return color.isPrimitive() ? Tri.FALSE : Tri.UNKNOWN; } private Node tryFoldKnownNumberMethods(Node subtree, Node callTarget) { diff --git a/src/com/google/javascript/jscomp/testing/TestExternsBuilder.java b/src/com/google/javascript/jscomp/testing/TestExternsBuilder.java index beec6e0e088..2884a5dec3f 100644 --- a/src/com/google/javascript/jscomp/testing/TestExternsBuilder.java +++ b/src/com/google/javascript/jscomp/testing/TestExternsBuilder.java @@ -518,8 +518,9 @@ function ReadonlyArray(var_args) {} * @param {...*} var_args * @return {!Array} * @this {*} + * @nosideeffects */ - ReadonlyArray.prototype.concat; + ReadonlyArray.prototype.concat = function(var_args) {}; /** * @param {?number=} begin Zero-based index at which to begin extraction. * @param {?number=} end Zero-based index at which to end extraction. slice @@ -610,6 +611,7 @@ function Array(var_args) {} * @param {...*} var_args * @return {!Array} * @this {*} + * @nosideeffects */ Array.prototype.concat = function(var_args) {}; /** diff --git a/test/com/google/javascript/jscomp/PeepholeFoldConstantsTest.java b/test/com/google/javascript/jscomp/PeepholeFoldConstantsTest.java index c67ab886ec1..e3980bdeb1f 100644 --- a/test/com/google/javascript/jscomp/PeepholeFoldConstantsTest.java +++ b/test/com/google/javascript/jscomp/PeepholeFoldConstantsTest.java @@ -1545,21 +1545,58 @@ public void testFoldStringLength() { @Test public void testFoldTypeof() { - test("x = typeof 1", "x = \"number\""); - test("x = typeof 'foo'", "x = \"string\""); - test("x = typeof true", "x = \"boolean\""); - test("x = typeof false", "x = \"boolean\""); - test("x = typeof null", "x = \"object\""); - test("x = typeof undefined", "x = \"undefined\""); - test("x = typeof void 0", "x = \"undefined\""); - test("x = typeof []", "x = \"object\""); - test("x = typeof [1]", "x = \"object\""); - test("x = typeof [1,[]]", "x = \"object\""); - test("x = typeof {}", "x = \"object\""); + test("x = typeof 1", "x = 'number'"); + test("x = typeof 'foo'", "x = 'string'"); + test("x = typeof true", "x = 'boolean'"); + test("x = typeof false", "x = 'boolean'"); + test("x = typeof null", "x = 'object'"); + test("x = typeof undefined", "x = 'undefined'"); + test("x = typeof void 0", "x = 'undefined'"); + test("x = typeof []", "x = 'object'"); + test("x = typeof [1]", "x = 'object'"); + test("x = typeof [1,[]]", "x = 'object'"); + test("x = typeof {}", "x = 'object'"); test("x = typeof function() {}", "x = 'function'"); - testSame("x = typeof[1,[foo()]]"); - testSame("x = typeof{bathwater:baby()}"); + test("x = typeof[1,[foo()]]", "x = (foo(), 'object')"); + test("x = typeof{bathwater:baby()}", "x = (baby(), 'object')"); + + test("x = typeof(a = 1)", "x = (a = 1, 'number')"); + test("x = typeof(foo(), null)", "x = (foo(), 'object')"); + test("x = typeof(foo() ? 'a' : 'b')", "x = (foo(), 'string')"); + test("x = typeof void bar()", "x = (bar(), 'undefined')"); + } + + @Test + public void testFoldTypeofFromColor() { + enableTypeCheck(); + replaceTypesWithColors(); + disableCompareJsDoc(); + + test( + "/** @constructor */ function Foo() {} function f(/** !Foo */ x) { return typeof x; }", + "/** @constructor */ function Foo() {} function f(/** !Foo */ x) { return 'object'; }"); + test( + "/** @constructor */ function Foo() {} function f() { return typeof Foo; }", + "/** @constructor */ function Foo() {} function f() { return 'function'; }"); + test( + "/** @constructor */ function Foo() {} function f() { return typeof new Foo(); }", + "/** @constructor */ function Foo() {} function f() { return new Foo(), 'object'; }"); + test( + "/** @constructor */ function Foo() {} function f(/** typeof Foo */ x) { return typeof x;" + + " }", + "/** @constructor */ function Foo() {} function f(/** typeof Foo */ x) { return 'function';" + + " }"); + test( + "function f(/** string */ x) { return typeof (x || 'fallback'); }", + "function f(/** string */ x) { return 'string'; }"); + test( + "/** @constructor */ function Foo() {}" + + " function f(/** (!Foo|undefined) */ x, /** !Foo */ y) { return typeof (x ?? y); }", + "/** @constructor */ function Foo() {}" + + " function f(/** (!Foo|undefined) */ x, /** !Foo */ y) { return 'object'; }"); + testSame("function f(x) { return x; }; typeof f;"); + testSame("function f(/** function(): number */ x) { return typeof x; }"); } @Test diff --git a/test/com/google/javascript/jscomp/PeepholeReplaceKnownMethodsTest.java b/test/com/google/javascript/jscomp/PeepholeReplaceKnownMethodsTest.java index 58287deb180..3d79b4ca078 100644 --- a/test/com/google/javascript/jscomp/PeepholeReplaceKnownMethodsTest.java +++ b/test/com/google/javascript/jscomp/PeepholeReplaceKnownMethodsTest.java @@ -764,13 +764,13 @@ public void testReplaceWithCharAt() { foldSame( """ /** @constructor */ function A() {}; - A.prototype.substring = function(begin$jscomp$1, end$jscomp$1) {}; + A.prototype.substring = function(begin$jscomp$2, end$jscomp$2) {}; function f(/** !A */ a) { a.substring(0, 1); } """); foldSame( """ /** @constructor */ function A() {}; - A.prototype.slice = function(begin$jscomp$1, end$jscomp$1) {}; + A.prototype.slice = function(begin$jscomp$2, end$jscomp$2) {}; function f(/** !A */ a) { a.slice(0, 1); } """); @@ -780,6 +780,108 @@ public void testReplaceWithCharAt() { foldSameStringTyped("''.substring(i, i + 1)"); } + @Test + public void testFoldArrayIsArray() { + enableTypeCheck(); + replaceTypesWithColors(); + disableCompareJsDoc(); + + foldArrayIsArrayTyped("!Array", "true"); + foldArrayIsArrayTyped("!Array", "true"); + foldArrayIsArrayTyped("!Array>", "true"); + foldArrayIsArrayTyped("!ReadonlyArray", "true"); + foldArrayIsArrayTyped("!ReadonlyArray|!Array", "true"); + + foldArrayIsArrayTyped("number", "false"); + foldArrayIsArrayTyped("?number", "false"); + foldArrayIsArrayTyped("symbol", "false"); + foldArrayIsArrayTyped("number|bigint|?string", "false"); + + foldSameArrayIsArrayTyped("{ length: number }"); + foldSameArrayIsArrayTyped("{ 1: number, 2: number, length: number }"); + foldSameArrayIsArrayTyped("{ a: !Array }"); + foldSameArrayIsArrayTyped("Array"); + foldSameArrayIsArrayTyped("!Array|number"); + } + + @Test + public void testFoldArrayIsArrayPreservesObservableArguments() { + ignoreWarnings(TypeCheck.WRONG_ARGUMENT_COUNT); + enableTypeCheck(); + + fold( + """ + function f(/** number */ x) { + return Array.isArray(x++); + } + """, + """ + function f(x) { + return x++, false; + } + """); + fold( + """ + function f(/** !Array */ a) { + return Array.isArray(a = []); + } + """, + """ + function f(a) { + return a = [], true; + } + """); + fold( + """ + /** + * @param {!Array} a + * @param {number} i + * @param {number} j + */ + function f(a, i, j) { + return Array.isArray(a, i++, --j); + } + """, + """ + function f(a, i, j) { + return i++, --j, true; + } + """); + fold( + """ + function f(/** !Array */ a, /** !Array */ b) { + return Array.isArray(a, ...b); + } + """, + """ + function f(a, b) { + return [...b], true; + } + """); + fold( + """ + function f(/** !Array */ a) { + return Array.isArray(a, foo() + bar()); + } + """, + """ + function f(a) { + return foo(), bar(), true; + } + """); + fold( + """ + function f(/** !Array */ a) { + return Array.isArray((a.shift(), a)); + } + """, + """ + function f(a) { + return a.shift(), true; + } + """); + } + @Test public void testFoldConcatChaining() { enableTypeCheck(); @@ -869,6 +971,16 @@ private void foldSameStringTyped(String js) { foldStringTyped(js, js); } + private void foldSameArrayIsArrayTyped(String type) { + foldArrayIsArrayTyped(type, "Array.isArray(a)"); + } + + private void foldArrayIsArrayTyped(String type, String expected) { + test( + "function f(/** " + type + " */ a) { return Array.isArray(a); }", + "function f(/** " + type + " */ a) { return " + expected + "; }"); + } + private void foldStringTyped(String js, String expected) { test( "function f(/** string */ a) {" + js + "}", diff --git a/test/com/google/javascript/jscomp/PeepholeTypePredicatesIntegrationTest.java b/test/com/google/javascript/jscomp/PeepholeTypePredicatesIntegrationTest.java new file mode 100644 index 00000000000..5218b0b195b --- /dev/null +++ b/test/com/google/javascript/jscomp/PeepholeTypePredicatesIntegrationTest.java @@ -0,0 +1,310 @@ +/* + * Copyright 2026 The Closure Compiler Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.javascript.jscomp; + +import com.google.javascript.rhino.Node; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class PeepholeTypePredicatesIntegrationTest extends CompilerTestCase { + private static final String MATH = + """ + /** @const */ + const Math = {}; + /** @nosideeffects */ + Math.random = function(){}; + /** + * @param {number} radians + * @return {number} + * @nosideeffects + */ + Math.sin = function(radians) {}; + """; + + public PeepholeTypePredicatesIntegrationTest() { + super(CompilerTypeTestCase.DEFAULT_EXTERNS + MATH); + } + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + + enableNormalize(); + disableCompareJsDoc(); + enableGatherExternProperties(); + enableTypeCheck(); + } + + @Override + protected CompilerPass getProcessor(Compiler compiler) { + return new CompilerPass() { + @Override + public void process(Node externs, Node root) { + compiler.getOptions().setUseTypesForLocalOptimization(true); + new PureFunctionIdentifier.Driver(compiler).process(externs, root); + new PeepholeOptimizationsPass( + compiler, + getName(), + new PeepholeFoldConstants(false, true), + new PeepholeReplaceKnownMethods(false, true)) + .process(externs, root); + } + }; + } + + @Test + public void testFoldArrayIsArray() { + replaceTypesWithColors(); + + foldArrayIsArrayTyped("!Array", "true"); + foldArrayIsArrayTyped("!ReadonlyArray>", "true"); + foldArrayIsArrayTyped("!ReadonlyArray|!Array", "true"); + + foldArrayIsArrayTyped("string", "false"); + foldArrayIsArrayTyped("?bigint", "false"); + foldArrayIsArrayTyped("symbol", "false"); + foldArrayIsArrayTyped("?number|string|(bigint|number)", "false"); + + foldSameArrayIsArrayTyped("?Array"); + foldSameArrayIsArrayTyped("Array"); + } + + @Test + public void testFoldArrayIsArrayPreservesObservableArguments() { + ignoreWarnings(TypeCheck.WRONG_ARGUMENT_COUNT); + + fold( + """ + function /** boolean */ f(/** number */ x) { + return Array.isArray(x++); + } + """, + """ + function f(x) { + return x++, false; + } + """); + fold( + """ + function f(/** !Array */ a) { + return Array.isArray(a = []); + } + """, + """ + function f(a) { + return a = [], true; + } + """); + fold( + """ + function f() { + return Array.isArray([Math.random(), Math.sin(2)]); + } + """, + """ + function f() { + return true; + } + """); + fold( + """ + function f(/** !Array */ a, /** !Array */ b) { + return Array.isArray(a, ...b); + } + """, + """ + function f(a, b) { + return [...b], true; + } + """); + fold( + """ + function f(/** !Array */ a) { + return Array.isArray(a, foo() + bar()); + } + """, + """ + function f(a) { + return foo(), bar(), true; + } + """); + fold( + """ + /** @param {!Array=} a */ + function f(a = [1, 2]) { + return Array.isArray(a.concat(3, 4)); + } + """, + """ + function f(a = [1, 2]) { + return true; + } + """); + fold( + """ + /** + * @param {!Array} a + * @return {boolean} + */ + function f(a) { + return Array.isArray((a.shift(), a)); + } + """, + """ + function f(a) { + return a.shift(), true; + } + """); + } + + @Test + public void testFoldTypeofPreservesObservableEffects() { + fold( + """ + function f() { + return typeof (Math.random(), Math.sin(2), null); + } + """, + """ + function f() { + return 'object'; + } + """); + fold( + """ + function f() { + return typeof (foo(), Math.sin(2), null); + } + """, + """ + function f() { + return foo(), 'object'; + } + """); + + replaceTypesWithColors(); + + fold( + """ + /** @constructor */ function Foo() {} + function f() { + return typeof (Math.random(), Math.sin(2), Foo); + } + """, + """ + /** @constructor */ function Foo() {} + function f() { + return 'function'; + } + """); + fold( + """ + /** @constructor */ function Foo() {} + function f() { + return typeof (foo(), Math.sin(2), Foo); + } + """, + """ + /** @constructor */ function Foo() {} + function f() { + return foo(), 'function'; + } + """); + fold( + """ + /** @constructor */ function Foo() {} + function f(/** !Foo */ x) { + return typeof (Math.random(), Math.sin(2), x); + } + """, + """ + /** @constructor */ function Foo() {} + function f(x) { + return 'object'; + } + """); + fold( + """ + /** @constructor */ function Foo() {} + function f(/** !Foo */ x) { + return typeof (foo(), Math.sin(2), x); + } + """, + """ + /** @constructor */ function Foo() {} + function f(x) { + return foo(), 'object'; + } + """); + } + + @Test + public void testArrayIsArrayPreservesNarrowing() { + foldSame( + """ + /** + * @param {!Array|string} s + * @return {number} + */ + function f(s) { + if (Array.isArray(s)) { + return 0; + } else { + return s.charCodeAt(0); + } + }; + """); + foldSame( + """ + /** + * @param {string|!Array|number} s + * @return {number} + */ + function f(s) { + if (typeof s == 'string') { + return s.toLowerCase().indexOf('a'); + } else if (Array.isArray(s)) { + return s.length; + } else { + return s.toFixed(0).length; + } + }; + """); + } + + protected void foldSame(String js) { + testSame(js); + } + + protected void fold(String js, String expected) { + test(js, expected); + } + + private void foldSameArrayIsArrayTyped(String type) { + foldArrayIsArrayTyped(type, "Array.isArray(a)"); + } + + private void foldArrayIsArrayTyped(String type, String expected) { + test( + "function f(/** " + type + " */ a) { return Array.isArray(a); }", + "function f(/** " + type + " */ a) { return " + expected + "; }"); + } +}