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    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