/* * Copyright 2016 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.common.base.Preconditions; import com.google.common.collect.Iterables; import com.google.javascript.jscomp.NodeTraversal.AbstractShallowStatementCallback; import com.google.javascript.rhino.IR; import com.google.javascript.rhino.JSDocInfo; import com.google.javascript.rhino.JSDocInfo.Visibility; import com.google.javascript.rhino.JSDocInfoBuilder; import com.google.javascript.rhino.JSTypeExpression; import com.google.javascript.rhino.Node; import com.google.javascript.rhino.Token; import com.google.javascript.rhino.jstype.JSType; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import javax.annotation.Nullable; /** * The goal of this pass is to shrink the AST, preserving only typing, not behavior. * * <p>To do this, it does things like removing function/method bodies, rvalues that are not needed, * expressions that are not declarations, etc. * * <p>This is conceptually similar to the ijar tool[1] that bazel uses to shrink jars into minimal * versions that can be used equivalently for compilation of downstream dependencies. * [1] https://github.com/bazelbuild/bazel/blob/master/third_party/ijar/README.txt * * @author blickly@google.com (Ben Lickly) */ class ConvertToTypedInterface implements CompilerPass { static final DiagnosticType CONSTANT_WITHOUT_EXPLICIT_TYPE = DiagnosticType.warning( "JSC_CONSTANT_WITHOUT_EXPLICIT_TYPE", "/** @const */-annotated values in library API should have types explicitly specified."); static final DiagnosticType UNSUPPORTED_GOOG_SCOPE = DiagnosticType.warning( "JSC_UNSUPPORTED_GOOG_SCOPE", "goog.scope is not supported inside .i.js files."); private final AbstractCompiler compiler; ConvertToTypedInterface(AbstractCompiler compiler) { this.compiler = compiler; } @Override public void process(Node externs, Node root) { NodeTraversal.traverseEs6(compiler, root, new PropagateConstJsdoc()); new RemoveCode(compiler).process(externs, root); } private static class PropagateConstJsdoc extends NodeTraversal.AbstractPostOrderCallback { PropagateConstJsdoc() { } @Override public void visit(NodeTraversal t, Node n, Node parent) { switch (n.getToken()) { case EXPR_RESULT: if (NodeUtil.isExprAssign(n)) { Node expr = n.getFirstChild(); propagateJsdocAtName(t, expr.getFirstChild()); } break; case VAR: case CONST: case LET: if (n.hasOneChild()) { propagateJsdocAtName(t, n.getFirstChild()); } break; default: break; } } private void propagateJsdocAtName(NodeTraversal t, Node nameNode) { Node jsdocNode = NodeUtil.getBestJSDocInfoNode(nameNode); JSDocInfo jsdoc = jsdocNode.getJSDocInfo(); if (!isInferrableConst(jsdoc, nameNode, false)) { return; } Node rhs = NodeUtil.getRValueOfLValue(nameNode); if (rhs == null) { return; } JSDocInfo newJsdoc = getJSDocForRhs(t, rhs, jsdoc); if (newJsdoc != null) { jsdocNode.setJSDocInfo(newJsdoc); t.reportCodeChange(); } } private static JSDocInfo getJSDocForRhs(NodeTraversal t, Node rhs, JSDocInfo oldJSDoc) { switch (NodeUtil.getKnownValueType(rhs)) { case BOOLEAN: return getConstJSDoc(oldJSDoc, "boolean"); case NUMBER: return getConstJSDoc(oldJSDoc, "number"); case STRING: return getConstJSDoc(oldJSDoc, "string"); case NULL: return getConstJSDoc(oldJSDoc, "null"); case VOID: return getConstJSDoc(oldJSDoc, "void"); case OBJECT: if (rhs.isRegExp()) { return getConstJSDoc(oldJSDoc, new Node(Token.BANG, IR.string("RegExp"))); } break; case UNDETERMINED: if (rhs.isName()) { Var decl = t.getScope().getVar(rhs.getString()); return getJSDocForName(decl, oldJSDoc); } break; } return null; } private static JSDocInfo getJSDocForName(Var decl, JSDocInfo oldJSDoc) { if (decl == null) { return null; } JSTypeExpression expr = NodeUtil.getDeclaredTypeExpression(decl.getNameNode()); if (expr == null) { return null; } switch (expr.getRoot().getToken()) { case EQUALS: Node typeRoot = expr.getRoot().getFirstChild().cloneTree(); if (!decl.isDefaultParam()) { typeRoot = new Node(Token.PIPE, typeRoot, IR.string("undefined")); } expr = asTypeExpression(typeRoot); break; case ELLIPSIS: { Node type = new Node(Token.BANG); Node array = IR.string("Array"); type.addChildToBack(array); Node block = new Node(Token.BLOCK, expr.getRoot().getFirstChild().cloneTree()); array.addChildToBack(block); expr = asTypeExpression(type); break; } default: break; } return getConstJSDoc(oldJSDoc, expr); } } /** * Class to keep track of what has been seen so far in a given file. * * This is cleared after each file to make sure that the analysis is working on a per-file basis. */ private static class FileInfo { private final Set<String> providedNamespaces = new HashSet<>(); private final Set<String> requiredLocalNames = new HashSet<>(); private final Set<String> seenNames = new HashSet<>(); private final List<Node> constructorsToProcess = new ArrayList<>(); boolean isNameProcessed(String fullyQualifiedName) { return seenNames.contains(fullyQualifiedName); } boolean isPrefixProvided(String fullyQualifiedName) { for (String prefix : Iterables.concat(seenNames, providedNamespaces)) { if (fullyQualifiedName.startsWith(prefix)) { return true; } } return false; } boolean isRequiredName(String fullyQualifiedName) { return requiredLocalNames.contains(fullyQualifiedName); } void markConstructorToProcess(Node ctorNode) { Preconditions.checkArgument(ctorNode.isFunction()); constructorsToProcess.add(ctorNode); } void markNameProcessed(String fullyQualifiedName) { seenNames.add(fullyQualifiedName); } void markProvided(String providedName) { providedNamespaces.add(providedName); } void markImportedName(String requiredLocalName) { requiredLocalNames.add(requiredLocalName); } void clear() { providedNamespaces.clear(); seenNames.clear(); constructorsToProcess.clear(); } } private static class RemoveCode implements CompilerPass, NodeTraversal.Callback { private final AbstractCompiler compiler; private final FileInfo currentFile = new FileInfo(); RemoveCode(AbstractCompiler compiler) { this.compiler = compiler; } @Override public void process(Node externs, Node root) { NodeTraversal.traverseEs6(compiler, root, this); } private void processConstructors(List<Node> constructorNodes) { for (Node ctorNode : constructorNodes) { processConstructor(ctorNode); } } @Override public boolean shouldTraverse(NodeTraversal t, Node n, Node parent) { switch (n.getToken()) { case SCRIPT: currentFile.clear(); break; case CLASS: if (NodeUtil.isStatementParent(parent)) { currentFile.markNameProcessed(n.getFirstChild().getString()); } break; case FUNCTION: { if (parent.isCall()) { Preconditions.checkState( !parent.getFirstChild().matchesQualifiedName("goog.scope"), parent); } if (NodeUtil.isStatementParent(parent)) { currentFile.markNameProcessed(n.getFirstChild().getString()); } processFunctionParameters(n.getSecondChild()); Node body = n.getLastChild(); if (body.isNormalBlock() && body.hasChildren()) { if (isConstructor(n)) { currentFile.markConstructorToProcess(n); return false; } body.removeChildren(); compiler.reportChangeToEnclosingScope(body); } break; } case EXPR_RESULT: Node expr = n.getFirstChild(); switch (expr.getToken()) { case NUMBER: case STRING: n.detach(); t.reportCodeChange(); break; case CALL: Node callee = expr.getFirstChild(); if (callee.matchesQualifiedName("goog.scope")) { t.report(n, UNSUPPORTED_GOOG_SCOPE); return false; } else if (callee.matchesQualifiedName("goog.provide")) { currentFile.markProvided(expr.getLastChild().getString()); Node childBefore; while (null != (childBefore = n.getPrevious()) && childBefore.getBooleanProp(Node.IS_NAMESPACE)) { parent.removeChild(childBefore); t.reportCodeChange(); } } else if (callee.matchesQualifiedName("goog.define")) { expr.getLastChild().detach(); t.reportCodeChange(); } else if (callee.matchesQualifiedName("goog.require")) { processRequire(expr); } else if (!callee.matchesQualifiedName("goog.module")) { n.detach(); t.reportCodeChange(); } break; case ASSIGN: processName(t, expr.getFirstChild(), n); break; case GETPROP: processName(t, expr, n); break; default: if (expr.getJSDocInfo() == null) { n.detach(); t.reportCodeChange(); } break; } break; case VAR: case CONST: case LET: if (n.hasOneChild() && NodeUtil.isStatement(n)) { Node lhs = n.getFirstChild(); Node rhs = lhs.getLastChild(); if (rhs != null && isImportRhs(rhs)) { processRequire(rhs); } else { processName(t, lhs, n); } } break; case THROW: case RETURN: case BREAK: case CONTINUE: case DEBUGGER: NodeUtil.removeChild(parent, n); t.reportCodeChange(); break; default: break; } return true; } @Override public void visit(NodeTraversal t, Node n, Node parent) { switch (n.getToken()) { case SCRIPT: processConstructors(currentFile.constructorsToProcess); break; case TRY: case DEFAULT_CASE: parent.replaceChild(n, n.getFirstChild().detach()); t.reportCodeChange(); break; case IF: case SWITCH: case CASE: n.removeFirstChild(); Node children = n.removeChildren(); parent.addChildrenAfter(children, n); NodeUtil.removeChild(parent, n); t.reportCodeChange(); break; case FOR_OF: case DO: case WHILE: case FOR: case FOR_IN: { Node body = NodeUtil.getLoopCodeBlock(n); parent.addChildAfter(body.detach(), n); NodeUtil.removeChild(parent, n); Node initializer = NodeUtil.isAnyFor(n) ? n.getFirstChild() : IR.empty(); if (initializer.isVar() && initializer.hasOneChild()) { parent.addChildBefore(initializer.detach(), body); processName(t, initializer.getFirstChild(), initializer); } compiler.reportChangeToEnclosingScope(parent); break; } case LABEL: if (n.getParent() != null) { parent.replaceChild(n, n.getSecondChild().detach()); t.reportCodeChange(); } break; default: break; } } private void processRequire(Node requireNode) { Preconditions.checkArgument(requireNode.isCall()); Preconditions.checkArgument(requireNode.getLastChild().isString()); Node parent = requireNode.getParent(); if (parent.isExprResult()) { currentFile.markImportedName(requireNode.getLastChild().getString()); } else { for (Node importedName : NodeUtil.getLhsNodesOfDeclaration(parent.getParent())) { currentFile.markImportedName(importedName.getString()); } } } private void processFunctionParameters(Node paramList) { Preconditions.checkArgument(paramList.isParamList()); for (Node arg = paramList.getFirstChild(); arg != null; arg = arg.getNext()) { if (arg.isDefaultValue()) { Node replacement = arg.getFirstChild().detach(); arg.replaceWith(replacement); arg = replacement; compiler.reportChangeToEnclosingScope(replacement); } } } private void processConstructor(final Node function) { final String className = getClassName(function); if (className == null) { return; } final Node insertionPoint = NodeUtil.getEnclosingStatement(function); NodeTraversal.traverseEs6( compiler, function.getLastChild(), new AbstractShallowStatementCallback() { @Override public void visit(NodeTraversal t, Node n, Node parent) { if (n.isExprResult()) { Node expr = n.getFirstChild(); Node name = expr.isAssign() ? expr.getFirstChild() : expr; if (!name.isGetProp() || !name.getFirstChild().isThis()) { return; } String pname = name.getLastChild().getString(); String fullyQualifiedName = className + ".prototype." + pname; if (currentFile.isNameProcessed(fullyQualifiedName)) { return; } JSDocInfo jsdoc = NodeUtil.getBestJSDocInfo(name); if (jsdoc == null) { jsdoc = getAllTypeJSDoc(); } else if (isInferrableConst(jsdoc, name, false)) { jsdoc = pullJsdocTypeFromAst(compiler, jsdoc, name); } Node newProtoAssignStmt = NodeUtil.newQNameDeclaration(compiler, fullyQualifiedName, null, jsdoc); newProtoAssignStmt.useSourceInfoIfMissingFromForTree(expr); // TODO(blickly): Preserve the declaration order of the this properties. insertionPoint.getParent().addChildAfter(newProtoAssignStmt, insertionPoint); compiler.reportChangeToEnclosingScope(newProtoAssignStmt); currentFile.markNameProcessed(fullyQualifiedName); } } }); final Node functionBody = function.getLastChild(); Preconditions.checkState(functionBody.isNormalBlock()); functionBody.removeChildren(); compiler.reportChangeToEnclosingScope(functionBody); } enum RemovalType { PRESERVE_ALL, REMOVE_RHS, REMOVE_ALL, } private static boolean isImportRhs(Node rhs) { if (!rhs.isCall()) { return false; } Node callee = rhs.getFirstChild(); return callee.matchesQualifiedName("goog.require") || callee.matchesQualifiedName("goog.forwardDeclare"); } private static boolean isExportLhs(Node lhs) { return (lhs.isName() && lhs.matchesQualifiedName("exports")) || (lhs.isGetProp() && lhs.getFirstChild().matchesQualifiedName("exports")); } private RemovalType shouldRemove(Node nameNode) { Node jsdocNode = NodeUtil.getBestJSDocInfoNode(nameNode); JSDocInfo jsdoc = jsdocNode.getJSDocInfo(); Node rhs = NodeUtil.getRValueOfLValue(nameNode); boolean isExport = isExportLhs(nameNode); if (rhs == null || rhs.isFunction() || rhs.isClass() || NodeUtil.isCallTo(rhs, "goog.defineClass") || isImportRhs(rhs) || (isExport && (rhs.isQualifiedName() || rhs.isObjectLit())) || (rhs.isQualifiedName() && rhs.matchesQualifiedName("goog.abstractMethod")) || (rhs.isQualifiedName() && rhs.matchesQualifiedName("goog.nullFunction")) || (jsdoc != null && jsdoc.isConstructor() && rhs.isQualifiedName()) || (rhs.isObjectLit() && !rhs.hasChildren() && (jsdoc == null || !hasAnnotatedType(jsdoc)))) { return RemovalType.PRESERVE_ALL; } if (!isExport && (jsdoc == null || !jsdoc.containsDeclaration())) { String fullyQualifiedName = nameNode.getQualifiedName(); if (currentFile.isNameProcessed(fullyQualifiedName)) { return RemovalType.REMOVE_ALL; } if (isDeclaration(nameNode) || currentFile.isPrefixProvided(fullyQualifiedName)) { jsdocNode.setJSDocInfo(getAllTypeJSDoc()); return RemovalType.REMOVE_RHS; } return RemovalType.REMOVE_ALL; } if (isInferrableConst(jsdoc, nameNode, isExport)) { if (rhs.isQualifiedName() && currentFile.isRequiredName(rhs.getQualifiedName())) { return RemovalType.PRESERVE_ALL; } jsdocNode.setJSDocInfo(pullJsdocTypeFromAst(compiler, jsdoc, nameNode)); } return RemovalType.REMOVE_RHS; } private void processName(NodeTraversal t, Node nameNode, Node statement) { Preconditions.checkState(NodeUtil.isStatement(statement), statement); if (!nameNode.isQualifiedName()) { // We don't track these. We can just remove them. removeNode(statement); return; } Node jsdocNode = NodeUtil.getBestJSDocInfoNode(nameNode); switch (shouldRemove(nameNode)) { case REMOVE_ALL: removeNode(statement); break; case PRESERVE_ALL: break; case REMOVE_RHS: maybeRemoveRhs(t, nameNode, statement, jsdocNode.getJSDocInfo()); break; } currentFile.markNameProcessed(nameNode.getQualifiedName()); } private void removeNode(Node n) { compiler.reportChangeToEnclosingScope(n); if (NodeUtil.isStatement(n)) { n.detach(); } else { n.replaceWith(IR.empty().srcref(n)); } } private void maybeRemoveRhs(NodeTraversal t, Node nameNode, Node statement, JSDocInfo jsdoc) { if (jsdoc != null && jsdoc.hasEnumParameterType()) { removeEnumValues(t, NodeUtil.getRValueOfLValue(nameNode)); return; } if (nameNode.matchesQualifiedName("exports")) { replaceRhsWithUnknown(nameNode); t.reportCodeChange(); return; } Node newStatement = NodeUtil.newQNameDeclaration(compiler, nameNode.getQualifiedName(), null, jsdoc); newStatement.useSourceInfoIfMissingFromForTree(nameNode); statement.replaceWith(newStatement); t.reportCodeChange(); } private void removeEnumValues(NodeTraversal t, Node objLit) { if (objLit.isObjectLit() && objLit.hasChildren()) { for (Node key : objLit.children()) { Node value = key.getFirstChild(); Node replacementValue = IR.number(0).srcrefTree(value); key.replaceChild(value, replacementValue); } t.reportCodeChange(); } } } private static void replaceRhsWithUnknown(Node lhs) { Node rhs = NodeUtil.getRValueOfLValue(lhs); rhs.replaceWith(IR.cast(IR.number(0), getQmarkTypeJSDoc()).srcrefTree(rhs)); } // TODO(blickly): Move to NodeUtil if it makes more sense there. private static boolean isDeclaration(Node nameNode) { Preconditions.checkArgument(nameNode.isQualifiedName()); Node parent = nameNode.getParent(); switch (parent.getToken()) { case VAR: case LET: case CONST: case CLASS: case FUNCTION: return true; default: return false; } } private static boolean isInferrableConst(JSDocInfo jsdoc, Node nameNode, boolean isImpliedConst) { boolean isConst = isImpliedConst || nameNode.getParent().isConst() || (jsdoc != null && jsdoc.hasConstAnnotation()); return isConst && !hasAnnotatedType(jsdoc) && !NodeUtil.isNamespaceDecl(nameNode); } private static boolean hasAnnotatedType(JSDocInfo jsdoc) { if (jsdoc == null) { return false; } return jsdoc.hasType() || jsdoc.hasReturnType() || jsdoc.getParameterCount() > 0 || jsdoc.isConstructorOrInterface() || jsdoc.hasThisType() || jsdoc.hasEnumParameterType(); } private static boolean isClassMemberFunction(Node functionNode) { Preconditions.checkArgument(functionNode.isFunction()); Node parent = functionNode.getParent(); if (parent.isMemberFunctionDef() && parent.getParent().isClassMembers()) { // ES6 class return true; } // goog.defineClass return parent.isStringKey() && parent.getParent().isObjectLit() && parent.getGrandparent().isCall() && parent.getGrandparent().getFirstChild().matchesQualifiedName("goog.defineClass"); } private static String getClassName(Node functionNode) { if (isClassMemberFunction(functionNode)) { Node parent = functionNode.getParent(); if (parent.isMemberFunctionDef()) { // ES6 class Node classNode = functionNode.getGrandparent().getParent(); Preconditions.checkState(classNode.isClass()); return NodeUtil.getName(classNode); } // goog.defineClass Preconditions.checkState(parent.isStringKey()); Node defineClassCall = parent.getGrandparent(); Preconditions.checkState(defineClassCall.isCall()); return NodeUtil.getBestLValue(defineClassCall).getQualifiedName(); } return NodeUtil.getName(functionNode); } private static boolean isConstructor(Node functionNode) { if (isClassMemberFunction(functionNode)) { return "constructor".equals(functionNode.getParent().getString()); } JSDocInfo jsdoc = NodeUtil.getBestJSDocInfo(functionNode); return jsdoc != null && jsdoc.isConstructor(); } private static JSDocInfo pullJsdocTypeFromAst( AbstractCompiler compiler, JSDocInfo oldJSDoc, Node nameNode) { Preconditions.checkArgument(nameNode.isQualifiedName()); JSType type = nameNode.getJSType(); if (type == null) { if (!nameNode.isFromExterns() && !isPrivate(oldJSDoc)) { compiler.report(JSError.make(nameNode, CONSTANT_WITHOUT_EXPLICIT_TYPE)); } return getConstJSDoc(oldJSDoc, new Node(Token.STAR)); } else { return getConstJSDoc(oldJSDoc, type.toNonNullAnnotationString()); } } private static boolean isPrivate(@Nullable JSDocInfo jsdoc) { return jsdoc != null && jsdoc.getVisibility().equals(Visibility.PRIVATE); } private static JSDocInfo getAllTypeJSDoc() { return getConstJSDoc(null, new Node(Token.STAR)); } private static JSDocInfo getQmarkTypeJSDoc() { return getConstJSDoc(null, new Node(Token.QMARK)); } private static JSTypeExpression asTypeExpression(Node typeAst) { return new JSTypeExpression(typeAst, "<synthetic>"); } private static JSDocInfo getConstJSDoc(JSDocInfo oldJSDoc, String contents) { return getConstJSDoc(oldJSDoc, Node.newString(contents)); } private static JSDocInfo getConstJSDoc(JSDocInfo oldJSDoc, Node typeAst) { return getConstJSDoc(oldJSDoc, asTypeExpression(typeAst)); } private static JSDocInfo getConstJSDoc(JSDocInfo oldJSDoc, JSTypeExpression newType) { JSDocInfoBuilder builder = JSDocInfoBuilder.maybeCopyFrom(oldJSDoc); builder.recordType(newType); builder.recordConstancy(); return builder.build(); } }