Home | History | Annotate | Download | only in jscomp
      1 // Copyright 2014 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 package com.google.javascript.jscomp;
      6 
      7 import com.google.javascript.jscomp.NodeTraversal.AbstractPostOrderCallback;
      8 import com.google.javascript.rhino.IR;
      9 import com.google.javascript.rhino.JSDocInfoBuilder;
     10 import com.google.javascript.rhino.JSTypeExpression;
     11 import com.google.javascript.rhino.Node;
     12 import com.google.javascript.rhino.Token;
     13 
     14 import java.util.ArrayList;
     15 import java.util.Arrays;
     16 import java.util.HashMap;
     17 import java.util.HashSet;
     18 import java.util.List;
     19 import java.util.Map;
     20 import java.util.Set;
     21 
     22 /**
     23  * Compiler pass for Chrome-specific needs. It handles the following Chrome JS features:
     24  * <ul>
     25  * <li>namespace declaration using {@code cr.define()},
     26  * <li>unquoted property declaration using {@code {cr|Object}.defineProperty()}.
     27  * </ul>
     28  *
     29  * <p>For the details, see tests inside ChromePassTest.java.
     30  */
     31 public class ChromePass extends AbstractPostOrderCallback implements CompilerPass {
     32     final AbstractCompiler compiler;
     33 
     34     private Set<String> createdObjects;
     35 
     36     private static final String CR_DEFINE = "cr.define";
     37     private static final String CR_EXPORT_PATH = "cr.exportPath";
     38     private static final String OBJECT_DEFINE_PROPERTY = "Object.defineProperty";
     39     private static final String CR_DEFINE_PROPERTY = "cr.defineProperty";
     40     private static final String CR_MAKE_PUBLIC = "cr.makePublic";
     41 
     42     private static final String CR_DEFINE_COMMON_EXPLANATION = "It should be called like this:"
     43             + " cr.define('name.space', function() '{ ... return {Export: Internal}; }');";
     44 
     45     static final DiagnosticType CR_DEFINE_WRONG_NUMBER_OF_ARGUMENTS =
     46             DiagnosticType.error("JSC_CR_DEFINE_WRONG_NUMBER_OF_ARGUMENTS",
     47                     "cr.define() should have exactly 2 arguments. " + CR_DEFINE_COMMON_EXPLANATION);
     48 
     49     static final DiagnosticType CR_EXPORT_PATH_WRONG_NUMBER_OF_ARGUMENTS =
     50             DiagnosticType.error("JSC_CR_EXPORT_PATH_WRONG_NUMBER_OF_ARGUMENTS",
     51                     "cr.exportPath() should have exactly 1 argument: namespace name.");
     52 
     53     static final DiagnosticType CR_DEFINE_INVALID_FIRST_ARGUMENT =
     54             DiagnosticType.error("JSC_CR_DEFINE_INVALID_FIRST_ARGUMENT",
     55                     "Invalid first argument for cr.define(). " + CR_DEFINE_COMMON_EXPLANATION);
     56 
     57     static final DiagnosticType CR_DEFINE_INVALID_SECOND_ARGUMENT =
     58             DiagnosticType.error("JSC_CR_DEFINE_INVALID_SECOND_ARGUMENT",
     59                     "Invalid second argument for cr.define(). " + CR_DEFINE_COMMON_EXPLANATION);
     60 
     61     static final DiagnosticType CR_DEFINE_INVALID_RETURN_IN_FUNCTION =
     62             DiagnosticType.error("JSC_CR_DEFINE_INVALID_RETURN_IN_SECOND_ARGUMENT",
     63                     "Function passed as second argument of cr.define() should return the"
     64                     + " dictionary in its last statement. " + CR_DEFINE_COMMON_EXPLANATION);
     65 
     66     static final DiagnosticType CR_DEFINE_PROPERTY_INVALID_PROPERTY_KIND =
     67             DiagnosticType.error("JSC_CR_DEFINE_PROPERTY_INVALID_PROPERTY_KIND",
     68                     "Invalid cr.PropertyKind passed to cr.defineProperty(): expected ATTR,"
     69                     + " BOOL_ATTR or JS, found \"{0}\".");
     70 
     71     static final DiagnosticType CR_MAKE_PUBLIC_HAS_NO_JSDOC =
     72             DiagnosticType.error("JSC_CR_MAKE_PUBLIC_HAS_NO_JSDOC",
     73                     "Private method exported by cr.makePublic() has no JSDoc.");
     74 
     75     static final DiagnosticType CR_MAKE_PUBLIC_MISSED_DECLARATION =
     76             DiagnosticType.error("JSC_CR_MAKE_PUBLIC_MISSED_DECLARATION",
     77                     "Method \"{1}_\" exported by cr.makePublic() on \"{0}\" has no declaration.");
     78 
     79     static final DiagnosticType CR_MAKE_PUBLIC_INVALID_SECOND_ARGUMENT =
     80             DiagnosticType.error("JSC_CR_MAKE_PUBLIC_INVALID_SECOND_ARGUMENT",
     81                     "Invalid second argument passed to cr.makePublic(): should be array of " +
     82                     "strings.");
     83 
     84     public ChromePass(AbstractCompiler compiler) {
     85         this.compiler = compiler;
     86         // The global variable "cr" is declared in ui/webui/resources/js/cr.js.
     87         this.createdObjects = new HashSet<>(Arrays.asList("cr"));
     88     }
     89 
     90     @Override
     91     public void process(Node externs, Node root) {
     92         NodeTraversal.traverse(compiler, root, this);
     93     }
     94 
     95     @Override
     96     public void visit(NodeTraversal t, Node node, Node parent) {
     97         if (node.isCall()) {
     98             Node callee = node.getFirstChild();
     99             if (callee.matchesQualifiedName(CR_DEFINE)) {
    100                 visitNamespaceDefinition(node, parent);
    101                 compiler.reportCodeChange();
    102             } else if (callee.matchesQualifiedName(CR_EXPORT_PATH)) {
    103                 visitExportPath(node, parent);
    104                 compiler.reportCodeChange();
    105             } else if (callee.matchesQualifiedName(OBJECT_DEFINE_PROPERTY) ||
    106                     callee.matchesQualifiedName(CR_DEFINE_PROPERTY)) {
    107                 visitPropertyDefinition(node, parent);
    108                 compiler.reportCodeChange();
    109             } else if (callee.matchesQualifiedName(CR_MAKE_PUBLIC)) {
    110                 if (visitMakePublic(node, parent)) {
    111                     compiler.reportCodeChange();
    112                 }
    113             }
    114         }
    115     }
    116 
    117     private void visitPropertyDefinition(Node call, Node parent) {
    118         Node callee = call.getFirstChild();
    119         String target = call.getChildAtIndex(1).getQualifiedName();
    120         if (callee.matchesQualifiedName(CR_DEFINE_PROPERTY) && !target.endsWith(".prototype")) {
    121             target += ".prototype";
    122         }
    123 
    124         Node property = call.getChildAtIndex(2);
    125 
    126         Node getPropNode = NodeUtil.newQualifiedNameNode(compiler.getCodingConvention(),
    127                 target + "." + property.getString()).srcrefTree(call);
    128 
    129         if (callee.matchesQualifiedName(CR_DEFINE_PROPERTY)) {
    130             setJsDocWithType(getPropNode, getTypeByCrPropertyKind(call.getChildAtIndex(3)));
    131         } else {
    132             setJsDocWithType(getPropNode, new Node(Token.QMARK));
    133         }
    134 
    135         Node definitionNode = IR.exprResult(getPropNode).srcref(parent);
    136 
    137         parent.getParent().addChildAfter(definitionNode, parent);
    138     }
    139 
    140     private Node getTypeByCrPropertyKind(Node propertyKind) {
    141         if (propertyKind == null || propertyKind.matchesQualifiedName("cr.PropertyKind.JS")) {
    142             return new Node(Token.QMARK);
    143         }
    144         if (propertyKind.matchesQualifiedName("cr.PropertyKind.ATTR")) {
    145             return IR.string("string");
    146         }
    147         if (propertyKind.matchesQualifiedName("cr.PropertyKind.BOOL_ATTR")) {
    148             return IR.string("boolean");
    149         }
    150         compiler.report(JSError.make(propertyKind, CR_DEFINE_PROPERTY_INVALID_PROPERTY_KIND,
    151                 propertyKind.getQualifiedName()));
    152         return null;
    153     }
    154 
    155     private void setJsDocWithType(Node target, Node type) {
    156         JSDocInfoBuilder builder = new JSDocInfoBuilder(false);
    157         builder.recordType(new JSTypeExpression(type, ""));
    158         target.setJSDocInfo(builder.build(target));
    159     }
    160 
    161     private boolean visitMakePublic(Node call, Node exprResult) {
    162         boolean changesMade = false;
    163         Node scope = exprResult.getParent();
    164         String className = call.getChildAtIndex(1).getQualifiedName();
    165         String prototype = className  + ".prototype";
    166         Node methods = call.getChildAtIndex(2);
    167 
    168         if (methods == null || !methods.isArrayLit()) {
    169             compiler.report(JSError.make(exprResult, CR_MAKE_PUBLIC_INVALID_SECOND_ARGUMENT));
    170             return changesMade;
    171         }
    172 
    173         Set<String> methodNames = new HashSet<>();
    174         for (Node methodName: methods.children()) {
    175             if (!methodName.isString()) {
    176                 compiler.report(JSError.make(methodName, CR_MAKE_PUBLIC_INVALID_SECOND_ARGUMENT));
    177                 return changesMade;
    178             }
    179             methodNames.add(methodName.getString());
    180         }
    181 
    182         for (Node child: scope.children()) {
    183             if (isAssignmentToPrototype(child, prototype)) {
    184                 Node objectLit = child.getFirstChild().getChildAtIndex(1);
    185                 for (Node stringKey : objectLit.children()) {
    186                     String field = stringKey.getString();
    187                     changesMade |= maybeAddPublicDeclaration(field, methodNames, className,
    188                                                              stringKey, scope, exprResult);
    189                 }
    190             } else if (isAssignmentToPrototypeMethod(child, prototype)) {
    191                 Node assignNode = child.getFirstChild();
    192                 String qualifiedName = assignNode.getFirstChild().getQualifiedName();
    193                 String field = qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1);
    194                 changesMade |= maybeAddPublicDeclaration(field, methodNames, className,
    195                                                          assignNode, scope, exprResult);
    196             } else if (isDummyPrototypeMethodDeclaration(child, prototype)) {
    197                 String qualifiedName = child.getFirstChild().getQualifiedName();
    198                 String field = qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1);
    199                 changesMade |= maybeAddPublicDeclaration(field, methodNames, className,
    200                                                          child.getFirstChild(), scope, exprResult);
    201             }
    202         }
    203 
    204         for (String missedDeclaration : methodNames) {
    205             compiler.report(JSError.make(exprResult, CR_MAKE_PUBLIC_MISSED_DECLARATION, className,
    206                     missedDeclaration));
    207         }
    208 
    209         return changesMade;
    210     }
    211 
    212     private boolean isAssignmentToPrototype(Node node, String prototype) {
    213         Node assignNode;
    214         return node.isExprResult() && (assignNode = node.getFirstChild()).isAssign() &&
    215                 assignNode.getFirstChild().getQualifiedName().equals(prototype);
    216     }
    217 
    218     private boolean isAssignmentToPrototypeMethod(Node node, String prototype) {
    219         Node assignNode;
    220         return node.isExprResult() && (assignNode = node.getFirstChild()).isAssign() &&
    221                 assignNode.getFirstChild().getQualifiedName().startsWith(prototype + ".");
    222     }
    223 
    224     private boolean isDummyPrototypeMethodDeclaration(Node node, String prototype) {
    225         Node getPropNode;
    226         return node.isExprResult() && (getPropNode = node.getFirstChild()).isGetProp() &&
    227                 getPropNode.getQualifiedName().startsWith(prototype + ".");
    228     }
    229 
    230     private boolean maybeAddPublicDeclaration(String field, Set<String> publicAPIStrings,
    231             String className, Node jsDocSourceNode, Node scope, Node exprResult) {
    232         boolean changesMade = false;
    233         if (field.endsWith("_")) {
    234             String publicName = field.substring(0, field.length() - 1);
    235             if (publicAPIStrings.contains(publicName)) {
    236                 Node methodDeclaration = NodeUtil.newQualifiedNameNode(
    237                         compiler.getCodingConvention(), className + "." + publicName);
    238                 if (jsDocSourceNode.getJSDocInfo() != null) {
    239                     methodDeclaration.setJSDocInfo(jsDocSourceNode.getJSDocInfo());
    240                     scope.addChildBefore(
    241                             IR.exprResult(methodDeclaration).srcrefTree(exprResult),
    242                             exprResult);
    243                     changesMade = true;
    244                 } else {
    245                     compiler.report(JSError.make(jsDocSourceNode, CR_MAKE_PUBLIC_HAS_NO_JSDOC));
    246                 }
    247                 publicAPIStrings.remove(publicName);
    248             }
    249         }
    250         return changesMade;
    251     }
    252 
    253     private void visitExportPath(Node crExportPathNode, Node parent) {
    254         if (crExportPathNode.getChildCount() != 2) {
    255             compiler.report(JSError.make(crExportPathNode,
    256                     CR_EXPORT_PATH_WRONG_NUMBER_OF_ARGUMENTS));
    257             return;
    258         }
    259 
    260         createAndInsertObjectsForQualifiedName(parent,
    261                 crExportPathNode.getChildAtIndex(1).getString());
    262     }
    263 
    264     private void createAndInsertObjectsForQualifiedName(Node scriptChild, String namespace) {
    265         List<Node> objectsForQualifiedName = createObjectsForQualifiedName(namespace);
    266         for (Node n : objectsForQualifiedName) {
    267             scriptChild.getParent().addChildBefore(n, scriptChild);
    268         }
    269     }
    270 
    271     private void visitNamespaceDefinition(Node crDefineCallNode, Node parent) {
    272         if (crDefineCallNode.getChildCount() != 3) {
    273             compiler.report(JSError.make(crDefineCallNode, CR_DEFINE_WRONG_NUMBER_OF_ARGUMENTS));
    274         }
    275 
    276         Node namespaceArg = crDefineCallNode.getChildAtIndex(1);
    277         Node function = crDefineCallNode.getChildAtIndex(2);
    278 
    279         if (!namespaceArg.isString()) {
    280             compiler.report(JSError.make(namespaceArg, CR_DEFINE_INVALID_FIRST_ARGUMENT));
    281             return;
    282         }
    283 
    284         // TODO(vitalyp): Check namespace name for validity here. It should be a valid chain of
    285         // identifiers.
    286         String namespace = namespaceArg.getString();
    287 
    288         createAndInsertObjectsForQualifiedName(parent, namespace);
    289 
    290         if (!function.isFunction()) {
    291             compiler.report(JSError.make(namespaceArg, CR_DEFINE_INVALID_SECOND_ARGUMENT));
    292             return;
    293         }
    294 
    295         Node returnNode, objectLit;
    296         Node functionBlock = function.getLastChild();
    297         if ((returnNode = functionBlock.getLastChild()) == null ||
    298                 !returnNode.isReturn() ||
    299                 (objectLit = returnNode.getFirstChild()) == null ||
    300                 !objectLit.isObjectLit()) {
    301             compiler.report(JSError.make(namespaceArg, CR_DEFINE_INVALID_RETURN_IN_FUNCTION));
    302             return;
    303         }
    304 
    305         Map<String, String> exports = objectLitToMap(objectLit);
    306 
    307         NodeTraversal.traverse(compiler, functionBlock, new RenameInternalsToExternalsCallback(
    308                 namespace, exports, functionBlock));
    309     }
    310 
    311     private Map<String, String> objectLitToMap(Node objectLit) {
    312         Map<String, String> res = new HashMap<String, String>();
    313 
    314         for (Node keyNode : objectLit.children()) {
    315             String key = keyNode.getString();
    316 
    317             // TODO(vitalyp): Can dict value be other than a simple NAME? What if NAME doesn't
    318             // refer to a function/constructor?
    319             String value = keyNode.getFirstChild().getString();
    320 
    321             res.put(value, key);
    322         }
    323 
    324         return res;
    325     }
    326 
    327     /**
    328      * For a string "a.b.c" produce the following JS IR:
    329      *
    330      * <p><pre>
    331      * var a = a || {};
    332      * a.b = a.b || {};
    333      * a.b.c = a.b.c || {};</pre>
    334      */
    335     private List<Node> createObjectsForQualifiedName(String namespace) {
    336         List<Node> objects = new ArrayList<>();
    337         String[] parts = namespace.split("\\.");
    338 
    339         createObjectIfNew(objects, parts[0], true);
    340 
    341         if (parts.length >= 2) {
    342             StringBuilder currPrefix = new StringBuilder().append(parts[0]);
    343             for (int i = 1; i < parts.length; ++i) {
    344                 currPrefix.append(".").append(parts[i]);
    345                 createObjectIfNew(objects, currPrefix.toString(), false);
    346             }
    347         }
    348 
    349         return objects;
    350     }
    351 
    352     private void createObjectIfNew(List<Node> objects, String name, boolean needVar) {
    353         if (!createdObjects.contains(name)) {
    354             objects.add(createJsNode((needVar ? "var " : "") + name + " = " + name + " || {};"));
    355             createdObjects.add(name);
    356         }
    357     }
    358 
    359     private Node createJsNode(String code) {
    360         // The parent node after parseSyntheticCode() is SCRIPT node, we need to get rid of it.
    361         return compiler.parseSyntheticCode(code).removeFirstChild();
    362     }
    363 
    364     private class RenameInternalsToExternalsCallback extends AbstractPostOrderCallback {
    365         private final String namespaceName;
    366         private final Map<String, String> exports;
    367         private final Node namespaceBlock;
    368 
    369         public RenameInternalsToExternalsCallback(String namespaceName,
    370                 Map<String, String> exports, Node namespaceBlock) {
    371             this.namespaceName = namespaceName;
    372             this.exports = exports;
    373             this.namespaceBlock = namespaceBlock;
    374         }
    375 
    376         @Override
    377         public void visit(NodeTraversal t, Node n, Node parent) {
    378             if (n.isFunction() && parent == this.namespaceBlock &&
    379                     this.exports.containsKey(n.getFirstChild().getString())) {
    380                 // It's a top-level function/constructor definition.
    381                 //
    382                 // Change
    383                 //
    384                 //   /** Some doc */
    385                 //   function internalName() {}
    386                 //
    387                 // to
    388                 //
    389                 //   /** Some doc */
    390                 //   my.namespace.name.externalName = function internalName() {};
    391                 //
    392                 // by looking up in this.exports for internalName to find the correspondent
    393                 // externalName.
    394                 Node functionTree = n.cloneTree();
    395                 Node exprResult = IR.exprResult(
    396                             IR.assign(buildQualifiedName(n.getFirstChild()), functionTree).srcref(n)
    397                         ).srcref(n);
    398 
    399                 if (n.getJSDocInfo() != null) {
    400                     exprResult.getFirstChild().setJSDocInfo(n.getJSDocInfo());
    401                     functionTree.removeProp(Node.JSDOC_INFO_PROP);
    402                 }
    403                 this.namespaceBlock.replaceChild(n, exprResult);
    404             } else if (n.isName() && this.exports.containsKey(n.getString()) &&
    405                     !parent.isFunction()) {
    406                 if (parent.isVar()) {
    407                     if (parent.getParent() == this.namespaceBlock) {
    408                         // It's a top-level exported variable definition (maybe without an
    409                         // assignment).
    410                         // Change
    411                         //
    412                         //   var enum = { 'one': 1, 'two': 2 };
    413                         //
    414                         // to
    415                         //
    416                         //   my.namespace.name.enum = { 'one': 1, 'two': 2 };
    417                         Node varContent = n.removeFirstChild();
    418                         Node exprResult;
    419                         if (varContent == null) {
    420                             exprResult = IR.exprResult(buildQualifiedName(n)).srcref(parent);
    421                         } else {
    422                             exprResult = IR.exprResult(
    423                                         IR.assign(buildQualifiedName(n), varContent).srcref(parent)
    424                                     ).srcref(parent);
    425                         }
    426                         if (parent.getJSDocInfo() != null) {
    427                             exprResult.getFirstChild().setJSDocInfo(parent.getJSDocInfo().clone());
    428                         }
    429                         this.namespaceBlock.replaceChild(parent, exprResult);
    430                     }
    431                 } else {
    432                     // It's a local name referencing exported entity. Change to its global name.
    433                     Node newNode = buildQualifiedName(n);
    434                     if (n.getJSDocInfo() != null) {
    435                         newNode.setJSDocInfo(n.getJSDocInfo().clone());
    436                     }
    437 
    438                     // If we alter the name of a called function, then it gets an explicit "this"
    439                     // value.
    440                     if (parent.isCall()) {
    441                         parent.putBooleanProp(Node.FREE_CALL, false);
    442                     }
    443 
    444                     parent.replaceChild(n, newNode);
    445                 }
    446             }
    447         }
    448 
    449         private Node buildQualifiedName(Node internalName) {
    450             String externalName = this.exports.get(internalName.getString());
    451             return NodeUtil.newQualifiedNameNode(compiler.getCodingConvention(),
    452                     this.namespaceName + "." + externalName).srcrefTree(internalName);
    453         }
    454     }
    455 }
    456