// Copyright (C) 2007 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.caja.parser.quasiliteral;
import static com.google.caja.parser.quasiliteral.QuasiBuilder.substV;
import com.google.caja.lexer.FilePosition;
import com.google.caja.lexer.Keyword;
import com.google.caja.parser.ParseTreeNode;
import com.google.caja.parser.ParseTreeNodeContainer;
import com.google.caja.parser.js.Block;
import com.google.caja.parser.js.CatchStmt;
import com.google.caja.parser.js.Declaration;
import com.google.caja.parser.js.DirectivePrologue;
import com.google.caja.parser.js.FunctionConstructor;
import com.google.caja.parser.js.FunctionDeclaration;
import com.google.caja.parser.js.Identifier;
import com.google.caja.parser.js.MultiDeclaration;
import com.google.caja.parser.js.Operation;
import com.google.caja.parser.js.Operator;
import com.google.caja.parser.js.Reference;
import com.google.caja.parser.js.Statement;
import com.google.caja.parser.js.UncajoledModule;
import com.google.caja.parser.js.scope.ScopeType;
import com.google.caja.reporting.Message;
import com.google.caja.reporting.MessageLevel;
import com.google.caja.reporting.MessagePart;
import com.google.caja.reporting.MessageQueue;
import com.google.caja.reporting.MessageType;
import com.google.caja.util.Pair;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* A scope analysis of a {@link com.google.caja.parser.ParseTreeNode}.
*
* @author ihab.awad@gmail.com (Ihab Awad)
*/
public class Scope {
private enum LocalType {
/**
* A named function value.
* Examples: "foo" in the following --
*
* <pre>
* var y = function foo() { };
* zip(function foo() { });
* </pre>
*/
FUNCTION(),
/**
* A function declaration, visible in its enclosing scope.
* Example: "foo" in the following --
*
* <pre>
* function foo() { }
* </pre>
*/
DECLARED_FUNCTION(FUNCTION),
/**
* A variable containing arbitrary data (including functions).
* Examples: "x", "y", "z" and "t" in the following --
*
* <pre>
* var x;
* var y = 3;
* var z = function() { };
* var t = function foo() { };
* </pre>
*/
DATA,
/**
* A variable defined in a catch block.
* Example: "foo" in the following --
*
* <pre>
* catch (foo) { this.x = 3; }
* </pre>
*/
CAUGHT_EXCEPTION,
;
private final Set<LocalType> implications = Sets.newHashSet();
private LocalType(LocalType... implications) {
this.implications.add(this);
for (LocalType implication : implications) {
this.implications.addAll(implication.implications);
}
}
public boolean implies(LocalType type) {
return implications.contains(type);
}
}
private final Scope parent;
private final MessageQueue mq;
private final ScopeType type;
private boolean hasFreeThis = false;
private boolean containsArguments = false;
private int tempVariableCounter = 0;
private final Map<String, Pair<LocalType, FilePosition>> locals
= Maps.newLinkedHashMap();
private final List<Statement> startStatements = Lists.newArrayList();
// TODO(ihab.awad): importedVariables is only used by the root-most scope; it
// is empty everywhere else. Define subclasses of Scope so that this confusing
// overlapping of instance variables does not occur.
private final Set<String> importedVariables = Sets.<String>newTreeSet();
public static Scope fromProgram(Block root, MessageQueue mq) {
Scope s = new Scope(ScopeType.PROGRAM, mq);
walkBlock(s, root);
return s;
}
public static Scope fromPlainBlock(Scope parent) {
return new Scope(ScopeType.BLOCK, parent);
}
public static Scope fromCatchStmt(Scope parent, CatchStmt root) {
Scope s = new Scope(ScopeType.CATCH, parent);
declare(s, root.getException().getIdentifier(),
LocalType.CAUGHT_EXCEPTION);
return s;
}
public static Scope fromParseTreeNodeContainer(
Scope parent, ParseTreeNodeContainer root) {
Scope s = new Scope(ScopeType.BLOCK, parent);
walkBlock(s, root);
return s;
}
public static Scope fromFunctionConstructor(
Scope parent, FunctionConstructor root) {
Scope s = new Scope(ScopeType.FUNCTION, parent);
// A function's name is bound to it in its body. After executing
// var g = function f() { return f; };
// the following is true
// typeof f === 'undefined' && g === g()
if (root.getIdentifierName() != null) {
declare(s, root.getIdentifier(), LocalType.FUNCTION);
}
for (ParseTreeNode n : root.getParams()) {
walkBlock(s, n);
}
walkBlock(s, root.getBody());
return s;
}
private Scope(ScopeType type, MessageQueue mq) {
this.type = type;
this.parent = null;
this.mq = mq;
}
private Scope(ScopeType type, Scope parent) {
this.type = type;
this.parent = parent;
this.mq = parent.mq;
}
/**
* The parent of this scope.
*
* @return a {@code Scope} or {@code null}.
*/
public Scope getParent() {
return parent;
}
/**
* Determines whether this is an outer scope. A scope is outer if it is not
* (transitively) contained in any function scopes. Any declarations in this
* scope are therefore visible throughout the program.
*/
public boolean isOuter() {
if (type == ScopeType.FUNCTION) { return false; }
if (parent == null) return true;
return parent.isOuter();
}
/**
* When a Scope is used for recursively processing a parse tree, steps taken
* on nodes contained within the node of this Scope sometimes add statements
* (e.g., variable declarations) that need to be rendered in the result before
* these nodes are rendered. These statements are the Scope's
* "start statements". After processing of subordinate nodes, these statements
* are to be found by calling this method.
*
* @return the statements that recursive processing of enclosed nodes has
* determined should be rendered at the start of this Scope.
*/
public List<Statement> getStartStatements() {
for (Statement stmt : startStatements) {
recursiveImmutable(stmt);
}
return Collections.unmodifiableList(startStatements);
}
private static void recursiveImmutable(ParseTreeNode node) {
if (node instanceof Statement) {
node.makeImmutable();
for (ParseTreeNode child : node.children()) {
recursiveImmutable(child);
}
}
}
public void addStartStatement(Statement s) {
int pos = startStatements.size();
// Group certain kinds of statements
if (s.getClass() == Declaration.class) {
Declaration d = (Declaration) s;
if (d.getInitializer() == null) {
int i = 0;
if (i < pos && startStatements.get(i) instanceof DirectivePrologue) {
++i;
}
if (i < pos) {
Statement si = startStatements.get(i);
if (si instanceof MultiDeclaration) {
((MultiDeclaration) si).appendChild(d);
return;
} else if (si.getClass() == Declaration.class) {
startStatements.set(
i,
new MultiDeclaration(
FilePosition.UNKNOWN, Arrays.asList((Declaration) si, d)));
return;
}
}
pos = i;
}
} else if (s instanceof DirectivePrologue) {
if (0 < pos && startStatements.get(0) instanceof DirectivePrologue) {
((DirectivePrologue) startStatements.get(0)).createMutation()
.appendChildren(((DirectivePrologue) s).children());
return;
}
pos = 0;
}
startStatements.add(pos, s);
}
public Set<String> getImportedVariables() {
return importedVariables;
}
public Iterable<String> getLocals() {
return Collections.unmodifiableSet(locals.keySet());
}
public FilePosition getLocationOfDeclaration(String localName) {
return locals.get(localName).b;
}
/**
* Add a start statement to the closest enclosing true Scope (i.e., a Scope
* that can contain unique 'var' declarations).
*
* @param s a Statement.
* @see #getStartStatements()
*/
public void addStartOfScopeStatement(Statement s) {
getClosestDeclarationContainer().addStartStatement(s);
}
/**
* Add a temporary variable declaration to the start of the closest enclosing true
* scope, and return the name of the declared variable.
*
* @return the identifier for the newly declared variable.
* @see #addStartOfScopeStatement(com.google.caja.parser.js.Statement)
*/
public Identifier declareStartOfScopeTempVariable() {
Scope s = getClosestDeclarationContainer();
// TODO(ihab.awad): Uses private access to 's' which is of same class but
// distinct instance. Violates capability discipline; kittens unduly
// sacrificed. Refactor.
Identifier id = new Identifier(
FilePosition.UNKNOWN, "temp" + (s.tempVariableCounter++) + "_");
s.addStartOfScopeStatement((Statement) substV(
"var @id;",
"id", id));
return id;
}
public Reference declareStartOfScopeTemp() {
return new Reference(declareStartOfScopeTempVariable());
}
/**
* Add a variable declaration to the start of the closest enclosing true
* scope.
*
* @see #addStartOfScopeStatement(com.google.caja.parser.js.Statement)
*/
public void declareStartOfScopeVariable(Identifier id) {
Scope s = getClosestDeclarationContainer();
// TODO(ihab.awad): Uses private access to 's' which is of same class but
// distinct instance. Violates capability discipline; kittens unduly
// sacrificed. Refactor.
s.addStartOfScopeStatement((Statement)substV(
"var @id;",
"id", id));
}
public Scope getClosestDeclarationContainer() {
if (!type.isDeclarationContainer) {
assert(parent != null);
return parent.getClosestDeclarationContainer();
}
return this;
}
/**
* Does this scope mention non-synthetic "this" freely?
*
* <p>If "this" is only mentioned within a function definition within
* this scope, then the result is <tt>false</tt>, since that "this"
* isn't a free occurrence.
*
* @return whether this block has a free "this".
*/
public boolean hasFreeThis() {
return hasFreeThis;
}
/**
* Does this scope mention "arguments" freely?
*
* <p>If "arguments" is only mentioned within a function definition
* within this scope, then the result is <tt>false</tt>, since that
* "arguments" isn't a free occurrence.
*
* @return whether this block has a free "arguments".
*/
public boolean hasFreeArguments() {
return containsArguments;
}
/**
* Does this scope or some enclosing scope define a name?
*
* @param name an identifier.
* @return whether 'name' is defined within this scope.
*/
public boolean isDefined(String name) {
return getType(name) != null;
}
/**
* Returns the scope that defines the given name or null if none.
*/
public Scope thatDefines(String name) {
boolean isThis = "this".equals(name);
boolean isArguments = "arguments".equals(name);
boolean isThisOrArguments = isThis || isArguments;
for (Scope s = this; s != null; s = s.parent) {
if (s.locals.containsKey(name)) { return s; }
if (isThisOrArguments) {
if (s.type == ScopeType.FUNCTION) { return s; }
if (s.type == ScopeType.PROGRAM && isThis) { return s; }
}
}
return null;
}
public ScopeType getType() { return type; }
private boolean isDefinedAs(String name, LocalType type) {
return isDefined(name) && getType(name).implies(type);
}
/**
* In this scope or some enclosing scope, is a given name
* defined as a function?
*
* @param name an identifier.
* @return whether 'name' is defined as a function within this
* scope. If 'name' is not defined, return false.
*/
public boolean isFunction(String name) {
return isDefinedAs(name, LocalType.FUNCTION);
}
/**
* True if name is the name of the variable that a {@code catch} block's
* exception is bound to.
*
* @param name an identifier.
* @return whether 'name' is defined as the exception variable of
* a {@code catch} block.
*/
public boolean isException(String name) {
return isDefinedAs(name, LocalType.CAUGHT_EXCEPTION);
}
/**
* In this scope or some enclosing scope, is a given name
* defined as a declared function?
*
* @param node an identifier.
* @return whether 'name' is defined as a declared function within this
* scope. If 'name' is not defined, return false.
*/
public boolean isDeclaredFunctionReference(ParseTreeNode node) {
return node instanceof Reference &&
isDeclaredFunction(((Reference)node).getIdentifierName());
}
/**
* In this scope or some enclosing scope, is a given name
* defined as a declared function?
*
* @param name an identifier.
* @return whether 'name' is defined as a declared function within this
* scope. If 'name' is not defined, return false.
*/
public boolean isDeclaredFunction(String name) {
return isDefinedAs(name, LocalType.DECLARED_FUNCTION);
}
/**
* In this scope or some enclosing scope, is a given name
* defined as data via a local "var" or formal parameter declaration?
*
* @param name an identifier.
* @return whether 'name' is defined as a declared function within this
* scope. If 'name' is not defined, return false.
*/
public boolean isData(String name) {
return isDefinedAs(name, LocalType.DATA);
}
/**
* Is a given symbol imported by this module?
*
* @param name an identifier.
* @return whether 'name' is a free variable of the enclosing module.
*/
public boolean isImported(String name) {
if (locals.containsKey(name)) return false;
if (parent == null) { return importedVariables.contains(name); }
return parent.isImported(name);
}
/**
* Is a given symbol an outer in this Valija code?
*
* @param name an identifier.
* @return whether 'name' is (a free variable or declared at the top level scope) or not.
*/
public boolean isOuter(String name) {
if (parent == null) { return true;}
if (locals.containsKey(name)) return false;
if (type == ScopeType.FUNCTION
&& ("this".equals(name) || "arguments".equals(name))) {
return false;
} else if (type == ScopeType.PROGRAM && "this".equals(name)) {
return false;
}
return parent.isOuter(name);
}
private LocalType getType(String name) {
Scope current = this;
do {
Pair<LocalType, FilePosition> symbolDefinition = current.locals.get(name);
if (symbolDefinition != null) { return symbolDefinition.a; }
current = current.parent;
} while (current != null);
return null;
}
private static void addImportedVariable(Scope s, String name) {
Scope target = s;
while (target.getParent() != null) { target = target.getParent(); }
if (target.importedVariables.contains(name)) { return; }
target.importedVariables.add(name);
}
private static LocalType computeDeclarationType(Declaration decl) {
return decl instanceof FunctionDeclaration ?
LocalType.DECLARED_FUNCTION : LocalType.DATA;
}
private static void walkBlock(final Scope s, ParseTreeNode root) {
SymbolHarvestVisitor v = new SymbolHarvestVisitor();
v.visit(root);
// Record in this scope all the declarations that have been harvested
// by the visitor.
for (Declaration decl : v.getDeclarations()) {
declare(s, decl.getIdentifier(), computeDeclarationType(decl));
}
// Now resolve all the references harvested by the visitor. If they have
// not been defined in the scope chain (including the declarations we just
// harvested), then they must be free variables, so record them as such.
for (Reference ref : v.getReferences()) {
String name = ref.getIdentifierName();
if ("arguments".equals(name)) { // JS magic identifier
s.containsArguments = true;
} else if (Keyword.THIS.toString().equals(name)) {
s.hasFreeThis = true;
} else if (!s.isDefined(name)) {
addImportedVariable(s, name);
}
}
}
// A SymbolHarvestVisitor traverses a parse tree node tree and harvests
// declarations and references for scope analysis. It stops the traversal
// at the right places according to JavaScript scoping rules.
//
// TODO(ihab.awad): Refactor to use standard Caja Visitor. Currently not
// using it because, due to the MEMBER_ACCESS case, we need more control
// over when to stop traversing the children of a node.
private static class SymbolHarvestVisitor {
private final List<Reference> references = Lists.newArrayList();
private final List<Declaration> declarations = Lists.newArrayList();
private final List<String> exceptionVariables = Lists.newArrayList();
public List<Reference> getReferences() { return references; }
public List<Declaration> getDeclarations() { return declarations; }
public void visit(ParseTreeNode node) {
// Dispatch to methods for specific node types of interest
if (node instanceof FunctionConstructor) {
visitFunctionConstructor((FunctionConstructor)node);
} else if (node instanceof CatchStmt) {
visitCatchStmt((CatchStmt)node);
} else if (node instanceof Declaration) {
visitDeclaration((Declaration)node);
} else if (node instanceof Operation) {
visitOperation((Operation)node);
} else if (node instanceof Reference) {
visitReference((Reference) node);
} else if (node instanceof UncajoledModule) {
visitModuleEnvelope((UncajoledModule) node);
} else {
visitChildren(node);
}
}
private void visitChildren(ParseTreeNode node) {
for (ParseTreeNode c : node.children()) { visit(c); }
}
private void visitFunctionConstructor(FunctionConstructor node) {
// Stuff inside a nested function is not part of this scope,
// so stop the traversal.
}
private void visitCatchStmt(CatchStmt node) {
// Skip the CatchStmt's exception variable -- that is only defined
// within the CatchStmt's body -- but dig into the body itself to grab
// all the declarations within it, which *are* hoisted into this scope.
exceptionVariables.add(node.getException().getIdentifierName());
visit(node.getBody());
exceptionVariables.remove(exceptionVariables.size() - 1);
}
private void visitDeclaration(Declaration node) {
declarations.add(node);
if (node.getInitializer() != null) {
visit(node.getInitializer());
}
}
// TODO(ihab.awad): Change the ParseTreeNode type for the right hand sides
// of a member access to be a StringLiteral, so we can eliminate the special
// case here. Also collapse MEMBER_ACCESS and SQUARE_BRACKET and make the
// form of the output a rendering decision.
private void visitOperation(Operation node) {
if (node.getOperator() == Operator.MEMBER_ACCESS) {
visit(node.children().get(0));
} else {
visitChildren(node);
}
}
private void visitReference(Reference node) {
if (!exceptionVariables.contains(node.getIdentifierName())) {
references.add(node);
}
}
/** @param node unused */
private void visitModuleEnvelope(UncajoledModule node) {
// don't look inside a module envelope
}
}
/**
* JavaScript identifiers where masking may change the behavior of synthetic
* code or cause lots of confusion.
*/
public static final Set<String> UNMASKABLE_IDENTIFIERS = ImmutableSet.of(
"Array", // Masking Array can change the behavior of [0, 1, ...]
"Infinity",
"NaN",
"Object", // Masking Object can change the behavior of { k: v }
"String", // Masking these can change behavior of casts
"Boolean",
"Number",
"RegExp", // /foo/ becomes new RegExp.new___('foo')
"arguments", // Can muck with arguments to synthetic values.
"eval" // Can't assign to eval in strict mode.
);
/**
* Add a symbol to the symbol table for this scope with the given type.
* If this symbol redefines another symbol with a different type, or masks
* an exception, then a warning will be added to this Scope's MessageQueue.
*/
private static void declare(Scope s, Identifier ident, LocalType type) {
String name = ident.getName();
if (UNMASKABLE_IDENTIFIERS.contains(name)) {
s.mq.addMessage(
RewriterMessageType.CANNOT_MASK_IDENTIFIER,
ident.getFilePosition(), MessagePart.Factory.valueOf(name));
}
Pair<LocalType, FilePosition> oldDefinition = s.locals.get(name);
if (oldDefinition != null) {
LocalType oldType = oldDefinition.a;
if (oldType != type
|| oldType.implies(LocalType.FUNCTION)
|| type.implies(LocalType.FUNCTION)) {
s.mq.getMessages().add(new Message(
MessageType.SYMBOL_REDEFINED,
MessageLevel.LINT,
ident.getFilePosition(),
MessagePart.Factory.valueOf(name),
oldDefinition.b));
}
}
for (Scope ancestor = s.parent; ancestor != null;
ancestor = ancestor.parent) {
Pair<LocalType, FilePosition> maskedDefinition
= ancestor.locals.get(name);
if (maskedDefinition == null) { continue; }
LocalType maskedType = maskedDefinition.a;
// Do not generate a LINT error in the case where a function masks
// itself. We recognize a self-mask when we come across a "new"
// function in the same scope as a declared function or constructor.
if (maskedType != type
&& !(maskedType == LocalType.DECLARED_FUNCTION
&& type == LocalType.FUNCTION)) {
// This used to treat masking catch variables as errors, because
// of IE<=8 behavior, but masking is unfortunately common, and
// the IE<=8 bug doesn't appears to be a security issue.
// http://code.google.com/p/google-caja/issues/detail?id=1456
if (ident.getFilePosition() != null) {
s.mq.getMessages().add(new Message(
MessageType.MASKING_SYMBOL,
MessageLevel.LINT,
ident.getFilePosition(),
MessagePart.Factory.valueOf(name),
maskedDefinition.b));
}
}
break;
}
s.locals.put(name, Pair.pair(type, ident.getFilePosition()));
}
}