|
| 1 | +/* |
| 2 | + * Copyright 2020 The Closure Compiler Authors. |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + * you may not use this file except in compliance with the License. |
| 6 | + * You may obtain a copy of the License at |
| 7 | + * |
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | + * See the License for the specific language governing permissions and |
| 14 | + * limitations under the License. |
| 15 | + */ |
| 16 | +package com.google.javascript.jscomp; |
| 17 | + |
| 18 | +import static com.google.common.base.Preconditions.checkArgument; |
| 19 | +import static com.google.common.base.Preconditions.checkNotNull; |
| 20 | +import static com.google.common.base.Preconditions.checkState; |
| 21 | + |
| 22 | +import com.google.javascript.jscomp.parsing.parser.FeatureSet.Feature; |
| 23 | +import com.google.javascript.rhino.IR; |
| 24 | +import com.google.javascript.rhino.Node; |
| 25 | +import com.google.javascript.rhino.Token; |
| 26 | +import java.util.ArrayDeque; |
| 27 | + |
| 28 | +/** |
| 29 | + * Rewrites a single optional chain as one or more nested hook expressions. |
| 30 | + * |
| 31 | + * <p>Optional chains contained in OPTCHAIN_GETELEM indices or OPTCHAIN_CALL arguments are not |
| 32 | + * rewritten. |
| 33 | + * |
| 34 | + * <p>Example: |
| 35 | + * |
| 36 | + * <pre><code> |
| 37 | + * a?.b[obj?.index]?.c?.(obj?.arg) |
| 38 | + * // becomes |
| 39 | + * (tmp0 = a) == null |
| 40 | + * ? void 0 |
| 41 | + * : (tmp1 = tmp0.b[obj?.index]) == null |
| 42 | + * ? void 0 |
| 43 | + * : (tmp2 = tmp1.c) == null |
| 44 | + * ? void 0 |
| 45 | + * : tmp2.call(tmp1, obj?.arg); |
| 46 | + * </code></pre> |
| 47 | + * |
| 48 | + * <p>The unit tests for this class are in RewriteOptionalChainingOperatorTest, because it's most |
| 49 | + * convenient to test this class as part of the transpilation pass that uses it. |
| 50 | + */ |
| 51 | +class OptionalChainRewriter { |
| 52 | + final AbstractCompiler compiler; |
| 53 | + final AstFactory astFactory; |
| 54 | + final TmpVarNameCreator tmpVarNameCreator; |
| 55 | + final Node chainParent; |
| 56 | + final Node wholeChain; |
| 57 | + final Node enclosingStatement; |
| 58 | + |
| 59 | + /** Creates unique names to be used for temporary variables. */ |
| 60 | + interface TmpVarNameCreator { |
| 61 | + |
| 62 | + /** Creates a unique temporary variable name each time it is called. */ |
| 63 | + String createTmpVarName(); |
| 64 | + } |
| 65 | + |
| 66 | + static class Builder { |
| 67 | + final AbstractCompiler compiler; |
| 68 | + final AstFactory astFactory; |
| 69 | + TmpVarNameCreator tmpVarNameCreator; |
| 70 | + |
| 71 | + private Builder(AbstractCompiler compiler) { |
| 72 | + this.compiler = checkNotNull(compiler); |
| 73 | + this.astFactory = compiler.createAstFactory(); |
| 74 | + } |
| 75 | + |
| 76 | + Builder setTmpVarNameCreator(TmpVarNameCreator tmpVarNameCreator) { |
| 77 | + this.tmpVarNameCreator = checkNotNull(tmpVarNameCreator); |
| 78 | + return this; |
| 79 | + } |
| 80 | + |
| 81 | + /** @param wholeChain The last Node in the optional chain. Parent of all the rest. */ |
| 82 | + OptionalChainRewriter build(Node wholeChain) { |
| 83 | + return new OptionalChainRewriter(this, wholeChain); |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + static Builder builder(AbstractCompiler compiler) { |
| 88 | + return new Builder(compiler); |
| 89 | + } |
| 90 | + |
| 91 | + private OptionalChainRewriter(Builder builder, Node wholeChain) { |
| 92 | + // This class will only operate on an entire chain. |
| 93 | + checkArgument(NodeUtil.isEndOfFullOptChain(wholeChain), wholeChain); |
| 94 | + this.compiler = builder.compiler; |
| 95 | + this.astFactory = builder.astFactory; |
| 96 | + this.tmpVarNameCreator = checkNotNull(builder.tmpVarNameCreator); |
| 97 | + this.wholeChain = wholeChain; |
| 98 | + this.chainParent = checkNotNull(wholeChain.getParent(), wholeChain); |
| 99 | + this.enclosingStatement = NodeUtil.getEnclosingStatement(wholeChain); |
| 100 | + } |
| 101 | + |
| 102 | + /** Rewrites the optional chain as a hook with temporary variables introduced as needed. */ |
| 103 | + void rewrite() { |
| 104 | + checkState(NodeUtil.isOptChainNode(wholeChain), "already rewritten: %s", wholeChain); |
| 105 | + |
| 106 | + // `first?.start.second?.start` |
| 107 | + // We search from the end of the chain and push the start nodes onto the stack, so the first |
| 108 | + // one ends up on top. |
| 109 | + ArrayDeque<Node> startNodeStack = new ArrayDeque<>(); |
| 110 | + Node subchainEnd = wholeChain; |
| 111 | + while (NodeUtil.isOptChainNode(subchainEnd)) { |
| 112 | + final Node subchainStart = NodeUtil.getStartOfOptChainSegment(subchainEnd); |
| 113 | + startNodeStack.push(subchainStart); |
| 114 | + subchainEnd = subchainStart.getFirstChild(); |
| 115 | + } |
| 116 | + |
| 117 | + checkState(!startNodeStack.isEmpty()); |
| 118 | + // Each time we rewrite the initial segment of the chain, the remaining chain gets wrapped |
| 119 | + // in a hook statement like `(tmp0 = a.b) == null ? void 0 : tmp0.rest?.of.chain?.()`, |
| 120 | + // So wholeChain ends up more deeply nested on each rewrite. |
| 121 | + // We only care about the top-most replacement here. |
| 122 | + final Node optChainReplacement = rewriteInitialSegment(startNodeStack.pop(), wholeChain); |
| 123 | + while (!startNodeStack.isEmpty()) { |
| 124 | + rewriteInitialSegment(startNodeStack.pop(), wholeChain); |
| 125 | + } |
| 126 | + |
| 127 | + // Handle a non-optional call to an optional chain that ends in an element or property |
| 128 | + // access. |
| 129 | + // `(a?.optional.chain)(arg1)` |
| 130 | + // Writing JavaScript code like this is a bad idea, but it might get automatically |
| 131 | + // generated, so we must handle it. |
| 132 | + // The optional chain could evaluate to `undefined`, which we then try to call as a |
| 133 | + // function. However, if it isn't undefined, we have to preserve the correct `this` value |
| 134 | + // for the call. |
| 135 | + if (chainParent.isCall() |
| 136 | + // The chain will have been replaced by optChainReplacement during the rewriting above. |
| 137 | + && optChainReplacement.isFirstChildOf(chainParent) |
| 138 | + // The wholeChain variable will still point to the rewritten final Node of the |
| 139 | + // chain. It will no longer be optional. |
| 140 | + && NodeUtil.isNormalGet(wholeChain)) { |
| 141 | + final Node thisValue = wholeChain.getFirstChild(); |
| 142 | + final Node tmpThisNode = getSubExprNameNode(thisValue); |
| 143 | + optChainReplacement.detach(); |
| 144 | + chainParent.addChildToFront(tmpThisNode); |
| 145 | + final Node dotCallNode = |
| 146 | + astFactory |
| 147 | + .createGetProp(optChainReplacement, "call") |
| 148 | + .useSourceInfoIfMissingFromForTree(optChainReplacement); |
| 149 | + chainParent.addChildToFront(dotCallNode); |
| 150 | + } |
| 151 | + |
| 152 | + // Transpilation of the optional chain adds `let` declarations for temporary variables. |
| 153 | + // NOTE: If this class is being used before transpilation, it's OK to use `let`, since it will |
| 154 | + // be transpiled away, if necessary. If it is being used after transpilation, then using `let` |
| 155 | + // must be OK, because optional chains weren't transpiled away and `let` existed before they |
| 156 | + // did. |
| 157 | + final Node enclosingScript = NodeUtil.getEnclosingScript(enclosingStatement); |
| 158 | + NodeUtil.addFeatureToScript(enclosingScript, Feature.LET_DECLARATIONS, compiler); |
| 159 | + compiler.reportChangeToChangeScope(enclosingScript); |
| 160 | + } |
| 161 | + |
| 162 | + /** |
| 163 | + * Rewrites the first part of a possibly-multi-part optional chain. |
| 164 | + * |
| 165 | + * <p>e.g. |
| 166 | + * |
| 167 | + * <pre>{@code |
| 168 | + * a()?.b.c?.d; |
| 169 | + * // becomes |
| 170 | + * let tmp0; |
| 171 | + * (tmp0 = a()) == null |
| 172 | + * ? void 0 |
| 173 | + * : tmp0.b.c?d; |
| 174 | + * }</pre> |
| 175 | + * |
| 176 | + * @param fullChainStart The very first `?.` node |
| 177 | + * @param fullChainEnd The very last optional chain node. |
| 178 | + * @return The hook expression that replaced the chain. |
| 179 | + */ |
| 180 | + Node rewriteInitialSegment(final Node fullChainStart, final Node fullChainEnd) { |
| 181 | + // `receiverNode?.restOfChain` |
| 182 | + Node receiverNode = fullChainStart.getFirstChild(); |
| 183 | + // for `a?.b.c?.d`, this will be `a?.b.c`, because the NodeUtil method finds the end |
| 184 | + // of the sub-chain, not the full chain. |
| 185 | + final Node initialChainEnd = NodeUtil.getEndOfOptChainSegment(fullChainStart); |
| 186 | + |
| 187 | + // If the receiver is an optional chain, we weren't really given the start of a full |
| 188 | + // chain. |
| 189 | + checkArgument(!NodeUtil.isOptChainNode(receiverNode), receiverNode); |
| 190 | + |
| 191 | + // change the initial chain's nodes to be non-optional |
| 192 | + convertToNonOptionalChainSegment(initialChainEnd); |
| 193 | + |
| 194 | + final Node placeholder = IR.empty(); |
| 195 | + fullChainEnd.replaceWith(placeholder); |
| 196 | + // NOTE: convertToNonOptionalChain() above will have made the chain start |
| 197 | + // and all the other nodes in the first segment of the chain non-optional, |
| 198 | + // so fullChainStart.isCall() is the right test here. |
| 199 | + if (NodeUtil.isNormalGet(receiverNode) && fullChainStart.isCall()) { |
| 200 | + // `expr.prop?.(x).y` |
| 201 | + // Needs to become |
| 202 | + // `(t1 = (t0 = expr).prop) == null ? void 0 : t1.call(t0, x).y` |
| 203 | + final Node thisValue = receiverNode.getFirstChild(); |
| 204 | + final Node tmpThisNode = getSubExprNameNode(thisValue); |
| 205 | + final Node tmpReceiverNode = getSubExprNameNode(receiverNode); |
| 206 | + receiverNode = fullChainStart.getFirstChild().detach(); |
| 207 | + fullChainStart.addChildToFront(tmpThisNode); |
| 208 | + fullChainStart.addChildToFront( |
| 209 | + astFactory |
| 210 | + .createGetProp(tmpReceiverNode, "call") |
| 211 | + .useSourceInfoIfMissingFromForTree(receiverNode)); |
| 212 | + } else { |
| 213 | + // `expr?.x.y` |
| 214 | + // needs to become |
| 215 | + // `((t0 = expr) == null) ? void 0 : t0.x.y` |
| 216 | + final Node tmpReceiverNode = getSubExprNameNode(receiverNode); |
| 217 | + receiverNode = fullChainStart.getFirstChild(); |
| 218 | + receiverNode.replaceWith(tmpReceiverNode); |
| 219 | + } |
| 220 | + final Node optChainReplacement = |
| 221 | + astFactory |
| 222 | + .createHook( |
| 223 | + astFactory.createEq(receiverNode, astFactory.createNull()), |
| 224 | + astFactory.createUndefinedValue(), |
| 225 | + fullChainEnd) |
| 226 | + .useSourceInfoIfMissingFromForTree(fullChainEnd); |
| 227 | + placeholder.replaceWith(optChainReplacement); |
| 228 | + |
| 229 | + return optChainReplacement; |
| 230 | + } |
| 231 | + |
| 232 | + /** |
| 233 | + * Given an expression node, declare a temporary variable to hold that expression and replace the |
| 234 | + * expression with `(tmp = expr)`. |
| 235 | + * |
| 236 | + * <p>e.g. `subExpr.moreExpr` becomes `(tmp = subExpr).moreExpr`, and `let tmp;` gets inserted |
| 237 | + * before the enclosing statement of this optional chain. |
| 238 | + * |
| 239 | + * @param subExpr The sub expression Node |
| 240 | + * @return A detached NAME node for the temporary variable name and with source info and type |
| 241 | + * matching `subExpr`, that may be inserted where needed. |
| 242 | + */ |
| 243 | + Node getSubExprNameNode(Node subExpr) { |
| 244 | + String tempVarName = declareTempVarName(subExpr); |
| 245 | + Node placeholder = IR.empty(); |
| 246 | + subExpr.replaceWith(placeholder); |
| 247 | + Node replacement = |
| 248 | + astFactory.createAssign(tempVarName, subExpr).useSourceInfoIfMissingFromForTree(subExpr); |
| 249 | + placeholder.replaceWith(replacement); |
| 250 | + return replacement.getFirstChild().cloneNode(); |
| 251 | + } |
| 252 | + |
| 253 | + /** |
| 254 | + * Declare a temporary variable name that will be used to hold the given value. |
| 255 | + * |
| 256 | + * <p>The generated declaration has no assignment, it's just `let tmp;`. |
| 257 | + * |
| 258 | + * @param valueNode A node from which to copy the source info and type to be used for the new |
| 259 | + * variable. |
| 260 | + * @return the name used for the new temporary variable. |
| 261 | + */ |
| 262 | + String declareTempVarName(Node valueNode) { |
| 263 | + String tempVarName = tmpVarNameCreator.createTmpVarName(); |
| 264 | + Node declarationStatement = |
| 265 | + astFactory.createSingleLetNameDeclaration(tempVarName).srcrefTree(valueNode); |
| 266 | + enclosingStatement.getParent().addChildBefore(declarationStatement, enclosingStatement); |
| 267 | + return tempVarName; |
| 268 | + } |
| 269 | + |
| 270 | + /** |
| 271 | + * Given the end of an optional chain segment. Change all nodes from the end down to the start |
| 272 | + * into non-optional nodes. |
| 273 | + */ |
| 274 | + private static void convertToNonOptionalChainSegment(Node endOfOptChainSegment) { |
| 275 | + // Since part of changing the nodes removes the isOptionalChainStart() marker we look for to |
| 276 | + // know we're done, this logic is easier to read if we just find all the nodes first, then |
| 277 | + // change them. |
| 278 | + final ArrayDeque<Node> segmentNodes = new ArrayDeque<>(); |
| 279 | + Node segmentNode = endOfOptChainSegment; |
| 280 | + while (true) { |
| 281 | + checkState(NodeUtil.isOptChainNode(segmentNode), segmentNode); |
| 282 | + segmentNodes.add(segmentNode); |
| 283 | + if (segmentNode.isOptionalChainStart()) { |
| 284 | + break; |
| 285 | + } else { |
| 286 | + segmentNode = segmentNode.getFirstChild(); |
| 287 | + } |
| 288 | + } |
| 289 | + for (Node n : segmentNodes) { |
| 290 | + n.setIsOptionalChainStart(false); |
| 291 | + n.setToken(getNonOptChainToken(n.getToken())); |
| 292 | + } |
| 293 | + } |
| 294 | + |
| 295 | + private static Token getNonOptChainToken(Token optChainToken) { |
| 296 | + switch (optChainToken) { |
| 297 | + case OPTCHAIN_CALL: |
| 298 | + return Token.CALL; |
| 299 | + case OPTCHAIN_GETELEM: |
| 300 | + return Token.GETELEM; |
| 301 | + case OPTCHAIN_GETPROP: |
| 302 | + return Token.GETPROP; |
| 303 | + default: |
| 304 | + throw new IllegalStateException("Should be an OPTCHAIN token: " + optChainToken); |
| 305 | + } |
| 306 | + } |
| 307 | +} |
0 commit comments