| // 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: |
| * <ul> |
| * <li>namespace declaration using {@code cr.define()}, |
| * <li>unquoted property declaration using {@code {cr|Object}.defineProperty()}. |
| * </ul> |
| * |
| * <p>For the details, see tests inside ChromePassTest.java. |
| */ |
| public class ChromePass extends AbstractPostOrderCallback implements CompilerPass { |
| final AbstractCompiler compiler; |
| |
| private Set<String> 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.newQName( |
| compiler, 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<String> 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<String> 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.newQName(compiler, 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<Node> 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<String, String> exports = objectLitToMap(objectLit); |
| |
| NodeTraversal.traverse(compiler, functionBlock, new RenameInternalsToExternalsCallback( |
| namespace, exports, functionBlock)); |
| } |
| |
| private Map<String, String> objectLitToMap(Node objectLit) { |
| Map<String, String> res = new HashMap<String, String>(); |
| |
| for (Node keyNode : objectLit.children()) { |
| String key = keyNode.getString(); |
| |
| Node valueNode = keyNode.getFirstChild(); |
| if (valueNode.isName()) { |
| String value = keyNode.getFirstChild().getString(); |
| res.put(value, key); |
| } |
| } |
| |
| return res; |
| } |
| |
| /** |
| * For a string "a.b.c" produce the following JS IR: |
| * |
| * <p><pre> |
| * var a = a || {}; |
| * a.b = a.b || {}; |
| * a.b.c = a.b.c || {};</pre> |
| */ |
| private List<Node> createObjectsForQualifiedName(String namespace) { |
| List<Node> 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<Node> 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<String, String> exports; |
| private final Node namespaceBlock; |
| |
| public RenameInternalsToExternalsCallback(String namespaceName, |
| Map<String, String> 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.newQName(compiler, this.namespaceName + "." + externalName).srcrefTree( |
| internalName); |
| } |
| } |
| } |