ChromePass.java revision 03b57e008b61dfcb1fbad3aea950ae0e001748b0
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
5package com.google.javascript.jscomp;
6
7import com.google.javascript.jscomp.NodeTraversal.AbstractPostOrderCallback;
8import com.google.javascript.rhino.IR;
9import com.google.javascript.rhino.JSDocInfoBuilder;
10import com.google.javascript.rhino.JSTypeExpression;
11import com.google.javascript.rhino.Node;
12import com.google.javascript.rhino.Token;
13
14import java.util.ArrayList;
15import java.util.Arrays;
16import java.util.HashMap;
17import java.util.HashSet;
18import java.util.List;
19import java.util.Map;
20import 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 */
31public 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
41    private static final String CR_DEFINE_COMMON_EXPLANATION = "It should be called like this:"
42            + " cr.define('name.space', function() '{ ... return {Export: Internal}; }');";
43
44    static final DiagnosticType CR_DEFINE_WRONG_NUMBER_OF_ARGUMENTS =
45            DiagnosticType.error("JSC_CR_DEFINE_WRONG_NUMBER_OF_ARGUMENTS",
46                    "cr.define() should have exactly 2 arguments. " + CR_DEFINE_COMMON_EXPLANATION);
47
48    static final DiagnosticType CR_EXPORT_PATH_WRONG_NUMBER_OF_ARGUMENTS =
49            DiagnosticType.error("JSC_CR_EXPORT_PATH_WRONG_NUMBER_OF_ARGUMENTS",
50                    "cr.exportPath() should have exactly 1 argument: namespace name.");
51
52    static final DiagnosticType CR_DEFINE_INVALID_FIRST_ARGUMENT =
53            DiagnosticType.error("JSC_CR_DEFINE_INVALID_FIRST_ARGUMENT",
54                    "Invalid first argument for cr.define(). " + CR_DEFINE_COMMON_EXPLANATION);
55
56    static final DiagnosticType CR_DEFINE_INVALID_SECOND_ARGUMENT =
57            DiagnosticType.error("JSC_CR_DEFINE_INVALID_SECOND_ARGUMENT",
58                    "Invalid second argument for cr.define(). " + CR_DEFINE_COMMON_EXPLANATION);
59
60    static final DiagnosticType CR_DEFINE_INVALID_RETURN_IN_FUNCTION =
61            DiagnosticType.error("JSC_CR_DEFINE_INVALID_RETURN_IN_SECOND_ARGUMENT",
62                    "Function passed as second argument of cr.define() should return the"
63                    + " dictionary in its last statement. " + CR_DEFINE_COMMON_EXPLANATION);
64
65    static final DiagnosticType CR_DEFINE_PROPERTY_INVALID_PROPERTY_KIND =
66            DiagnosticType.error("JSC_CR_DEFINE_PROPERTY_INVALID_PROPERTY_KIND",
67                    "Invalid cr.PropertyKind passed to cr.defineProperty(): expected ATTR,"
68                    + " BOOL_ATTR or JS, found \"{0}\".");
69
70    public ChromePass(AbstractCompiler compiler) {
71        this.compiler = compiler;
72        // The global variable "cr" is declared in ui/webui/resources/js/cr.js.
73        this.createdObjects = new HashSet<>(Arrays.asList("cr"));
74    }
75
76    @Override
77    public void process(Node externs, Node root) {
78        NodeTraversal.traverse(compiler, root, this);
79    }
80
81    @Override
82    public void visit(NodeTraversal t, Node node, Node parent) {
83        if (node.isCall()) {
84            Node callee = node.getFirstChild();
85            if (callee.matchesQualifiedName(CR_DEFINE)) {
86                visitNamespaceDefinition(node, parent);
87                compiler.reportCodeChange();
88            } else if (callee.matchesQualifiedName(CR_EXPORT_PATH)) {
89                visitExportPath(node, parent);
90                compiler.reportCodeChange();
91            } else if (callee.matchesQualifiedName(OBJECT_DEFINE_PROPERTY) ||
92                    callee.matchesQualifiedName(CR_DEFINE_PROPERTY)) {
93                visitPropertyDefinition(node, parent);
94                compiler.reportCodeChange();
95            }
96        }
97    }
98
99    private void visitPropertyDefinition(Node call, Node parent) {
100        Node callee = call.getFirstChild();
101        String target = call.getChildAtIndex(1).getQualifiedName();
102        if (callee.matchesQualifiedName(CR_DEFINE_PROPERTY) && !target.endsWith(".prototype")) {
103            target += ".prototype";
104        }
105
106        Node property = call.getChildAtIndex(2);
107
108        Node getPropNode = NodeUtil.newQualifiedNameNode(compiler.getCodingConvention(),
109                target + "." + property.getString()).srcrefTree(call);
110
111        if (callee.matchesQualifiedName(CR_DEFINE_PROPERTY)) {
112            setJsDocWithType(getPropNode, getTypeByCrPropertyKind(call.getChildAtIndex(3)));
113        } else {
114            setJsDocWithType(getPropNode, new Node(Token.QMARK));
115        }
116
117        Node definitionNode = IR.exprResult(getPropNode).srcref(parent);
118
119        parent.getParent().addChildAfter(definitionNode, parent);
120    }
121
122    private Node getTypeByCrPropertyKind(Node propertyKind) {
123        if (propertyKind == null || propertyKind.matchesQualifiedName("cr.PropertyKind.JS")) {
124            return new Node(Token.QMARK);
125        }
126        if (propertyKind.matchesQualifiedName("cr.PropertyKind.ATTR")) {
127            return IR.string("string");
128        }
129        if (propertyKind.matchesQualifiedName("cr.PropertyKind.BOOL_ATTR")) {
130            return IR.string("boolean");
131        }
132        compiler.report(JSError.make(propertyKind, CR_DEFINE_PROPERTY_INVALID_PROPERTY_KIND,
133                propertyKind.getQualifiedName()));
134        return null;
135    }
136
137    private void setJsDocWithType(Node target, Node type) {
138        JSDocInfoBuilder builder = new JSDocInfoBuilder(false);
139        builder.recordType(new JSTypeExpression(type, ""));
140        target.setJSDocInfo(builder.build(target));
141    }
142
143    private void visitExportPath(Node crExportPathNode, Node parent) {
144        if (crExportPathNode.getChildCount() != 2) {
145            compiler.report(JSError.make(crExportPathNode,
146                    CR_EXPORT_PATH_WRONG_NUMBER_OF_ARGUMENTS));
147            return;
148        }
149
150        createAndInsertObjectsForQualifiedName(parent,
151                crExportPathNode.getChildAtIndex(1).getString());
152    }
153
154    private void createAndInsertObjectsForQualifiedName(Node scriptChild, String namespace) {
155        List<Node> objectsForQualifiedName = createObjectsForQualifiedName(namespace);
156        for (Node n : objectsForQualifiedName) {
157            scriptChild.getParent().addChildBefore(n, scriptChild);
158        }
159    }
160
161    private void visitNamespaceDefinition(Node crDefineCallNode, Node parent) {
162        if (crDefineCallNode.getChildCount() != 3) {
163            compiler.report(JSError.make(crDefineCallNode, CR_DEFINE_WRONG_NUMBER_OF_ARGUMENTS));
164        }
165
166        Node namespaceArg = crDefineCallNode.getChildAtIndex(1);
167        Node function = crDefineCallNode.getChildAtIndex(2);
168
169        if (!namespaceArg.isString()) {
170            compiler.report(JSError.make(namespaceArg, CR_DEFINE_INVALID_FIRST_ARGUMENT));
171            return;
172        }
173
174        // TODO(vitalyp): Check namespace name for validity here. It should be a valid chain of
175        // identifiers.
176        String namespace = namespaceArg.getString();
177
178        createAndInsertObjectsForQualifiedName(parent, namespace);
179
180        if (!function.isFunction()) {
181            compiler.report(JSError.make(namespaceArg, CR_DEFINE_INVALID_SECOND_ARGUMENT));
182            return;
183        }
184
185        Node returnNode, objectLit;
186        Node functionBlock = function.getLastChild();
187        if ((returnNode = functionBlock.getLastChild()) == null ||
188                !returnNode.isReturn() ||
189                (objectLit = returnNode.getFirstChild()) == null ||
190                !objectLit.isObjectLit()) {
191            compiler.report(JSError.make(namespaceArg, CR_DEFINE_INVALID_RETURN_IN_FUNCTION));
192            return;
193        }
194
195        Map<String, String> exports = objectLitToMap(objectLit);
196
197        NodeTraversal.traverse(compiler, functionBlock, new RenameInternalsToExternalsCallback(
198                namespace, exports, functionBlock));
199    }
200
201    private Map<String, String> objectLitToMap(Node objectLit) {
202        Map<String, String> res = new HashMap<String, String>();
203
204        for (Node keyNode : objectLit.children()) {
205            String key = keyNode.getString();
206
207            // TODO(vitalyp): Can dict value be other than a simple NAME? What if NAME doesn't
208            // refer to a function/constructor?
209            String value = keyNode.getFirstChild().getString();
210
211            res.put(value, key);
212        }
213
214        return res;
215    }
216
217    /**
218     * For a string "a.b.c" produce the following JS IR:
219     *
220     * <p><pre>
221     * var a = a || {};
222     * a.b = a.b || {};
223     * a.b.c = a.b.c || {};</pre>
224     */
225    private List<Node> createObjectsForQualifiedName(String namespace) {
226        List<Node> objects = new ArrayList<>();
227        String[] parts = namespace.split("\\.");
228
229        createObjectIfNew(objects, parts[0], true);
230
231        if (parts.length >= 2) {
232            StringBuilder currPrefix = new StringBuilder().append(parts[0]);
233            for (int i = 1; i < parts.length; ++i) {
234                currPrefix.append(".").append(parts[i]);
235                createObjectIfNew(objects, currPrefix.toString(), false);
236            }
237        }
238
239        return objects;
240    }
241
242    private void createObjectIfNew(List<Node> objects, String name, boolean needVar) {
243        if (!createdObjects.contains(name)) {
244            objects.add(createJsNode((needVar ? "var " : "") + name + " = " + name + " || {};"));
245            createdObjects.add(name);
246        }
247    }
248
249    private Node createJsNode(String code) {
250        // The parent node after parseSyntheticCode() is SCRIPT node, we need to get rid of it.
251        return compiler.parseSyntheticCode(code).removeFirstChild();
252    }
253
254    private class RenameInternalsToExternalsCallback extends AbstractPostOrderCallback {
255        private final String namespaceName;
256        private final Map<String, String> exports;
257        private final Node namespaceBlock;
258
259        public RenameInternalsToExternalsCallback(String namespaceName,
260                Map<String, String> exports, Node namespaceBlock) {
261            this.namespaceName = namespaceName;
262            this.exports = exports;
263            this.namespaceBlock = namespaceBlock;
264        }
265
266        @Override
267        public void visit(NodeTraversal t, Node n, Node parent) {
268            if (n.isFunction() && parent == this.namespaceBlock &&
269                    this.exports.containsKey(n.getFirstChild().getString())) {
270                // It's a top-level function/constructor definition.
271                //
272                // Change
273                //
274                //   /** Some doc */
275                //   function internalName() {}
276                //
277                // to
278                //
279                //   /** Some doc */
280                //   my.namespace.name.externalName = function internalName() {};
281                //
282                // by looking up in this.exports for internalName to find the correspondent
283                // externalName.
284                Node functionTree = n.cloneTree();
285                Node exprResult = IR.exprResult(
286                            IR.assign(buildQualifiedName(n.getFirstChild()), functionTree).srcref(n)
287                        ).srcref(n);
288
289                if (n.getJSDocInfo() != null) {
290                    exprResult.getFirstChild().setJSDocInfo(n.getJSDocInfo());
291                    functionTree.removeProp(Node.JSDOC_INFO_PROP);
292                }
293                this.namespaceBlock.replaceChild(n, exprResult);
294            } else if (n.isName() && this.exports.containsKey(n.getString()) &&
295                    !parent.isFunction()) {
296                if (parent.isVar()) {
297                    if (parent.getParent() == this.namespaceBlock) {
298                        // It's a top-level exported variable definition (maybe without an
299                        // assignment).
300                        // Change
301                        //
302                        //   var enum = { 'one': 1, 'two': 2 };
303                        //
304                        // to
305                        //
306                        //   my.namespace.name.enum = { 'one': 1, 'two': 2 };
307                        Node varContent = n.removeFirstChild();
308                        Node exprResult;
309                        if (varContent == null) {
310                            exprResult = IR.exprResult(buildQualifiedName(n)).srcref(parent);
311                        } else {
312                            exprResult = IR.exprResult(
313                                        IR.assign(buildQualifiedName(n), varContent).srcref(parent)
314                                    ).srcref(parent);
315                        }
316                        if (parent.getJSDocInfo() != null) {
317                            exprResult.getFirstChild().setJSDocInfo(parent.getJSDocInfo().clone());
318                        }
319                        this.namespaceBlock.replaceChild(parent, exprResult);
320                    }
321                } else {
322                    // It's a local name referencing exported entity. Change to its global name.
323                    Node newNode = buildQualifiedName(n);
324                    if (n.getJSDocInfo() != null) {
325                        newNode.setJSDocInfo(n.getJSDocInfo().clone());
326                    }
327
328                    // If we alter the name of a called function, then it gets an explicit "this"
329                    // value.
330                    if (parent.isCall()) {
331                        parent.putBooleanProp(Node.FREE_CALL, false);
332                    }
333
334                    parent.replaceChild(n, newNode);
335                }
336            }
337        }
338
339        private Node buildQualifiedName(Node internalName) {
340            String externalName = this.exports.get(internalName.getString());
341            return NodeUtil.newQualifiedNameNode(compiler.getCodingConvention(),
342                    this.namespaceName + "." + externalName).srcrefTree(internalName);
343        }
344    }
345
346}
347