blob: 8ff3fb0a1405133424da35b7d6b6e67e5e8fc1de [file] [log] [blame]
// 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_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}\".");
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();
}
}
}
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 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();
// 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:
*
* <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.
// Change
//
// var enum = { 'one': 1, 'two': 2 };
//
// to
//
// my.namespace.name.enum = { 'one': 1, 'two': 2 };
Node varContent = n.removeFirstChild();
Node 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);
}
}
}