// Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package com.google.javascript.jscomp; import com.google.javascript.jscomp.NodeTraversal.AbstractPostOrderCallback; import com.google.javascript.rhino.IR; 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 java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; /** * Compiler pass for Chrome-specific needs. It handles the following Chrome JS features: * * *

For the details, see tests inside ChromePassTest.java. */ public class ChromePass extends AbstractPostOrderCallback implements CompilerPass { final AbstractCompiler compiler; private Set createdObjects; private static final String CR_DEFINE = "cr.define"; private static final String CR_EXPORT_PATH = "cr.exportPath"; private static final String OBJECT_DEFINE_PROPERTY = "Object.defineProperty"; private static final String CR_DEFINE_PROPERTY = "cr.defineProperty"; private static final String CR_MAKE_PUBLIC = "cr.makePublic"; private static final String CR_DEFINE_COMMON_EXPLANATION = "It should be called like this:" + " cr.define('name.space', function() '{ ... return {Export: Internal}; }');"; static final DiagnosticType CR_DEFINE_WRONG_NUMBER_OF_ARGUMENTS = DiagnosticType.error("JSC_CR_DEFINE_WRONG_NUMBER_OF_ARGUMENTS", "cr.define() should have exactly 2 arguments. " + CR_DEFINE_COMMON_EXPLANATION); static final DiagnosticType CR_EXPORT_PATH_WRONG_NUMBER_OF_ARGUMENTS = DiagnosticType.error("JSC_CR_EXPORT_PATH_WRONG_NUMBER_OF_ARGUMENTS", "cr.exportPath() should have exactly 1 argument: namespace name."); static final DiagnosticType CR_DEFINE_INVALID_FIRST_ARGUMENT = DiagnosticType.error("JSC_CR_DEFINE_INVALID_FIRST_ARGUMENT", "Invalid first argument for cr.define(). " + CR_DEFINE_COMMON_EXPLANATION); static final DiagnosticType CR_DEFINE_INVALID_SECOND_ARGUMENT = DiagnosticType.error("JSC_CR_DEFINE_INVALID_SECOND_ARGUMENT", "Invalid second argument for cr.define(). " + CR_DEFINE_COMMON_EXPLANATION); static final DiagnosticType CR_DEFINE_INVALID_RETURN_IN_FUNCTION = DiagnosticType.error("JSC_CR_DEFINE_INVALID_RETURN_IN_SECOND_ARGUMENT", "Function passed as second argument of cr.define() should return the" + " dictionary in its last statement. " + CR_DEFINE_COMMON_EXPLANATION); static final DiagnosticType CR_DEFINE_PROPERTY_INVALID_PROPERTY_KIND = DiagnosticType.error("JSC_CR_DEFINE_PROPERTY_INVALID_PROPERTY_KIND", "Invalid cr.PropertyKind passed to cr.defineProperty(): expected ATTR," + " BOOL_ATTR or JS, found \"{0}\"."); static final DiagnosticType CR_MAKE_PUBLIC_HAS_NO_JSDOC = DiagnosticType.error("JSC_CR_MAKE_PUBLIC_HAS_NO_JSDOC", "Private method exported by cr.makePublic() has no JSDoc."); static final DiagnosticType CR_MAKE_PUBLIC_MISSED_DECLARATION = DiagnosticType.error("JSC_CR_MAKE_PUBLIC_MISSED_DECLARATION", "Method \"{1}_\" exported by cr.makePublic() on \"{0}\" has no declaration."); static final DiagnosticType CR_MAKE_PUBLIC_INVALID_SECOND_ARGUMENT = DiagnosticType.error("JSC_CR_MAKE_PUBLIC_INVALID_SECOND_ARGUMENT", "Invalid second argument passed to cr.makePublic(): should be array of " + "strings."); public ChromePass(AbstractCompiler compiler) { this.compiler = compiler; // The global variable "cr" is declared in ui/webui/resources/js/cr.js. this.createdObjects = new HashSet<>(Arrays.asList("cr")); } @Override public void process(Node externs, Node root) { NodeTraversal.traverse(compiler, root, this); } @Override public void visit(NodeTraversal t, Node node, Node parent) { if (node.isCall()) { Node callee = node.getFirstChild(); if (callee.matchesQualifiedName(CR_DEFINE)) { visitNamespaceDefinition(node, parent); compiler.reportCodeChange(); } else if (callee.matchesQualifiedName(CR_EXPORT_PATH)) { visitExportPath(node, parent); compiler.reportCodeChange(); } else if (callee.matchesQualifiedName(OBJECT_DEFINE_PROPERTY) || callee.matchesQualifiedName(CR_DEFINE_PROPERTY)) { visitPropertyDefinition(node, parent); compiler.reportCodeChange(); } else if (callee.matchesQualifiedName(CR_MAKE_PUBLIC)) { if (visitMakePublic(node, parent)) { compiler.reportCodeChange(); } } } } private void visitPropertyDefinition(Node call, Node parent) { Node callee = call.getFirstChild(); String target = call.getChildAtIndex(1).getQualifiedName(); if (callee.matchesQualifiedName(CR_DEFINE_PROPERTY) && !target.endsWith(".prototype")) { target += ".prototype"; } Node property = call.getChildAtIndex(2); Node getPropNode = NodeUtil.newQualifiedNameNode(compiler.getCodingConvention(), target + "." + property.getString()).srcrefTree(call); if (callee.matchesQualifiedName(CR_DEFINE_PROPERTY)) { setJsDocWithType(getPropNode, getTypeByCrPropertyKind(call.getChildAtIndex(3))); } else { setJsDocWithType(getPropNode, new Node(Token.QMARK)); } Node definitionNode = IR.exprResult(getPropNode).srcref(parent); parent.getParent().addChildAfter(definitionNode, parent); } private Node getTypeByCrPropertyKind(Node propertyKind) { if (propertyKind == null || propertyKind.matchesQualifiedName("cr.PropertyKind.JS")) { return new Node(Token.QMARK); } if (propertyKind.matchesQualifiedName("cr.PropertyKind.ATTR")) { return IR.string("string"); } if (propertyKind.matchesQualifiedName("cr.PropertyKind.BOOL_ATTR")) { return IR.string("boolean"); } compiler.report(JSError.make(propertyKind, CR_DEFINE_PROPERTY_INVALID_PROPERTY_KIND, propertyKind.getQualifiedName())); return null; } private void setJsDocWithType(Node target, Node type) { JSDocInfoBuilder builder = new JSDocInfoBuilder(false); builder.recordType(new JSTypeExpression(type, "")); target.setJSDocInfo(builder.build(target)); } private boolean visitMakePublic(Node call, Node exprResult) { boolean changesMade = false; Node scope = exprResult.getParent(); String className = call.getChildAtIndex(1).getQualifiedName(); String prototype = className + ".prototype"; Node methods = call.getChildAtIndex(2); if (methods == null || !methods.isArrayLit()) { compiler.report(JSError.make(exprResult, CR_MAKE_PUBLIC_INVALID_SECOND_ARGUMENT)); return changesMade; } Set methodNames = new HashSet<>(); for (Node methodName: methods.children()) { if (!methodName.isString()) { compiler.report(JSError.make(methodName, CR_MAKE_PUBLIC_INVALID_SECOND_ARGUMENT)); return changesMade; } methodNames.add(methodName.getString()); } for (Node child: scope.children()) { if (isAssignmentToPrototype(child, prototype)) { Node objectLit = child.getFirstChild().getChildAtIndex(1); for (Node stringKey : objectLit.children()) { String field = stringKey.getString(); changesMade |= maybeAddPublicDeclaration(field, methodNames, className, stringKey, scope, exprResult); } } else if (isAssignmentToPrototypeMethod(child, prototype)) { Node assignNode = child.getFirstChild(); String qualifiedName = assignNode.getFirstChild().getQualifiedName(); String field = qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1); changesMade |= maybeAddPublicDeclaration(field, methodNames, className, assignNode, scope, exprResult); } else if (isDummyPrototypeMethodDeclaration(child, prototype)) { String qualifiedName = child.getFirstChild().getQualifiedName(); String field = qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1); changesMade |= maybeAddPublicDeclaration(field, methodNames, className, child.getFirstChild(), scope, exprResult); } } for (String missedDeclaration : methodNames) { compiler.report(JSError.make(exprResult, CR_MAKE_PUBLIC_MISSED_DECLARATION, className, missedDeclaration)); } return changesMade; } private boolean isAssignmentToPrototype(Node node, String prototype) { Node assignNode; return node.isExprResult() && (assignNode = node.getFirstChild()).isAssign() && assignNode.getFirstChild().getQualifiedName().equals(prototype); } private boolean isAssignmentToPrototypeMethod(Node node, String prototype) { Node assignNode; return node.isExprResult() && (assignNode = node.getFirstChild()).isAssign() && assignNode.getFirstChild().getQualifiedName().startsWith(prototype + "."); } private boolean isDummyPrototypeMethodDeclaration(Node node, String prototype) { Node getPropNode; return node.isExprResult() && (getPropNode = node.getFirstChild()).isGetProp() && getPropNode.getQualifiedName().startsWith(prototype + "."); } private boolean maybeAddPublicDeclaration(String field, Set publicAPIStrings, String className, Node jsDocSourceNode, Node scope, Node exprResult) { boolean changesMade = false; if (field.endsWith("_")) { String publicName = field.substring(0, field.length() - 1); if (publicAPIStrings.contains(publicName)) { Node methodDeclaration = NodeUtil.newQualifiedNameNode( compiler.getCodingConvention(), className + "." + publicName); if (jsDocSourceNode.getJSDocInfo() != null) { methodDeclaration.setJSDocInfo(jsDocSourceNode.getJSDocInfo()); scope.addChildBefore( IR.exprResult(methodDeclaration).srcrefTree(exprResult), exprResult); changesMade = true; } else { compiler.report(JSError.make(jsDocSourceNode, CR_MAKE_PUBLIC_HAS_NO_JSDOC)); } publicAPIStrings.remove(publicName); } } return changesMade; } private void visitExportPath(Node crExportPathNode, Node parent) { if (crExportPathNode.getChildCount() != 2) { compiler.report(JSError.make(crExportPathNode, CR_EXPORT_PATH_WRONG_NUMBER_OF_ARGUMENTS)); return; } createAndInsertObjectsForQualifiedName(parent, crExportPathNode.getChildAtIndex(1).getString()); } private void createAndInsertObjectsForQualifiedName(Node scriptChild, String namespace) { List objectsForQualifiedName = createObjectsForQualifiedName(namespace); for (Node n : objectsForQualifiedName) { scriptChild.getParent().addChildBefore(n, scriptChild); } } private void visitNamespaceDefinition(Node crDefineCallNode, Node parent) { if (crDefineCallNode.getChildCount() != 3) { compiler.report(JSError.make(crDefineCallNode, CR_DEFINE_WRONG_NUMBER_OF_ARGUMENTS)); } Node namespaceArg = crDefineCallNode.getChildAtIndex(1); Node function = crDefineCallNode.getChildAtIndex(2); if (!namespaceArg.isString()) { compiler.report(JSError.make(namespaceArg, CR_DEFINE_INVALID_FIRST_ARGUMENT)); return; } // TODO(vitalyp): Check namespace name for validity here. It should be a valid chain of // identifiers. String namespace = namespaceArg.getString(); createAndInsertObjectsForQualifiedName(parent, namespace); if (!function.isFunction()) { compiler.report(JSError.make(namespaceArg, CR_DEFINE_INVALID_SECOND_ARGUMENT)); return; } Node returnNode, objectLit; Node functionBlock = function.getLastChild(); if ((returnNode = functionBlock.getLastChild()) == null || !returnNode.isReturn() || (objectLit = returnNode.getFirstChild()) == null || !objectLit.isObjectLit()) { compiler.report(JSError.make(namespaceArg, CR_DEFINE_INVALID_RETURN_IN_FUNCTION)); return; } Map exports = objectLitToMap(objectLit); NodeTraversal.traverse(compiler, functionBlock, new RenameInternalsToExternalsCallback( namespace, exports, functionBlock)); } private Map objectLitToMap(Node objectLit) { Map res = new HashMap(); for (Node keyNode : objectLit.children()) { String key = keyNode.getString(); // TODO(vitalyp): Can dict value be other than a simple NAME? What if NAME doesn't // refer to a function/constructor? String value = keyNode.getFirstChild().getString(); res.put(value, key); } return res; } /** * For a string "a.b.c" produce the following JS IR: * *

     * var a = a || {};
     * a.b = a.b || {};
     * a.b.c = a.b.c || {};
*/ private List createObjectsForQualifiedName(String namespace) { List objects = new ArrayList<>(); String[] parts = namespace.split("\\."); createObjectIfNew(objects, parts[0], true); if (parts.length >= 2) { StringBuilder currPrefix = new StringBuilder().append(parts[0]); for (int i = 1; i < parts.length; ++i) { currPrefix.append(".").append(parts[i]); createObjectIfNew(objects, currPrefix.toString(), false); } } return objects; } private void createObjectIfNew(List objects, String name, boolean needVar) { if (!createdObjects.contains(name)) { objects.add(createJsNode((needVar ? "var " : "") + name + " = " + name + " || {};")); createdObjects.add(name); } } private Node createJsNode(String code) { // The parent node after parseSyntheticCode() is SCRIPT node, we need to get rid of it. return compiler.parseSyntheticCode(code).removeFirstChild(); } private class RenameInternalsToExternalsCallback extends AbstractPostOrderCallback { private final String namespaceName; private final Map exports; private final Node namespaceBlock; public RenameInternalsToExternalsCallback(String namespaceName, Map exports, Node namespaceBlock) { this.namespaceName = namespaceName; this.exports = exports; this.namespaceBlock = namespaceBlock; } @Override public void visit(NodeTraversal t, Node n, Node parent) { if (n.isFunction() && parent == this.namespaceBlock && this.exports.containsKey(n.getFirstChild().getString())) { // It's a top-level function/constructor definition. // // Change // // /** Some doc */ // function internalName() {} // // to // // /** Some doc */ // my.namespace.name.externalName = function internalName() {}; // // by looking up in this.exports for internalName to find the correspondent // externalName. Node functionTree = n.cloneTree(); Node exprResult = IR.exprResult( IR.assign(buildQualifiedName(n.getFirstChild()), functionTree).srcref(n) ).srcref(n); if (n.getJSDocInfo() != null) { exprResult.getFirstChild().setJSDocInfo(n.getJSDocInfo()); functionTree.removeProp(Node.JSDOC_INFO_PROP); } this.namespaceBlock.replaceChild(n, exprResult); } else if (n.isName() && this.exports.containsKey(n.getString()) && !parent.isFunction()) { if (parent.isVar()) { if (parent.getParent() == this.namespaceBlock) { // It's a top-level exported variable definition (maybe without an // assignment). // Change // // var enum = { 'one': 1, 'two': 2 }; // // to // // my.namespace.name.enum = { 'one': 1, 'two': 2 }; Node varContent = n.removeFirstChild(); Node exprResult; if (varContent == null) { exprResult = IR.exprResult(buildQualifiedName(n)).srcref(parent); } else { exprResult = IR.exprResult( IR.assign(buildQualifiedName(n), varContent).srcref(parent) ).srcref(parent); } if (parent.getJSDocInfo() != null) { exprResult.getFirstChild().setJSDocInfo(parent.getJSDocInfo().clone()); } this.namespaceBlock.replaceChild(parent, exprResult); } } else { // It's a local name referencing exported entity. Change to its global name. Node newNode = buildQualifiedName(n); if (n.getJSDocInfo() != null) { newNode.setJSDocInfo(n.getJSDocInfo().clone()); } // If we alter the name of a called function, then it gets an explicit "this" // value. if (parent.isCall()) { parent.putBooleanProp(Node.FREE_CALL, false); } parent.replaceChild(n, newNode); } } } private Node buildQualifiedName(Node internalName) { String externalName = this.exports.get(internalName.getString()); return NodeUtil.newQualifiedNameNode(compiler.getCodingConvention(), this.namespaceName + "." + externalName).srcrefTree(internalName); } } }