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