blob: 5b8d0ed0f904f990b730a39772ab3b869af20993 [file] [log] [blame]
// Copyright 2014 The Bazel Authors. All rights reserved.
//
// 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.devtools.build.lib.syntax;
import com.google.common.base.Preconditions;
import com.google.devtools.build.lib.events.Event;
import com.google.devtools.build.lib.events.Location;
import com.google.devtools.build.lib.util.SpellChecker;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nullable;
/**
* A class for doing static checks on files, before evaluating them.
*
* <p>We implement the semantics discussed in
* https://github.com/bazelbuild/proposals/blob/master/docs/2018-06-18-name-resolution.md
*
* <p>When a variable is defined, it is visible in the entire block. For example, a global variable
* is visible in the entire file; a variable in a function is visible in the entire function block
* (even on the lines before its first assignment).
*
* <p>Validation is a mutation of the syntax tree, as it attaches scope information to Identifier
* nodes. (In the future, it will attach additional information to functions to support lexical
* scope, and even compilation of the trees to bytecode.) Validation errors are reported in the
* analogous manner to scan/parse errors: for a StarlarkFile, they are appended to {@code
* StarlarkFile.errors}; for an expression they are reported by an SyntaxError exception. It is
* legal to validate a file that already contains scan/parse errors, though it may lead to secondary
* validation errors.
*/
// TODO(adonovan): make this class private. Call it through the EvalUtils facade.
public final class ValidationEnvironment extends NodeVisitor {
enum Scope {
/** Symbols defined inside a function or a comprehension. */
Local("local"),
/** Symbols defined at a module top-level, e.g. functions, loaded symbols. */
Module("global"),
/** Predefined symbols (builtins) */
Universe("builtin");
private final String qualifier;
private Scope(String qualifier) {
this.qualifier = qualifier;
}
String getQualifier() {
return qualifier;
}
}
/**
* Module is a static abstraction of a Starlark module. It describes the set of variable names for
* use during name resolution.
*/
public interface Module {
// TODO(adonovan): opt: for efficiency, turn this into a predicate, not an enumerable set,
// and look up bindings as they are needed, not preemptively.
// Otherwise we must do work proportional to the number of bindings in the
// environment, not the number of free variables of the file/expression.
//
// A single method will then suffice:
// Scope resolve(String name) throws Undeclared
// This requires that the Module retain its semantics.
/** Returns the set of names defined by this module. The caller must not modify the set. */
Set<String> getNames();
/**
* Returns (optionally) a more specific error for an undeclared name than the generic message.
* This hook allows the module to implement "semantics-restricted" names without any knowledge
* in this file.
*/
@Nullable
String getUndeclaredNameError(StarlarkSemantics semantics, String name);
}
private static final Identifier PREDECLARED = new Identifier("");
private static class Block {
private final Map<String, Identifier> variables = new HashMap<>();
private final Scope scope;
@Nullable private final Block parent;
Block(Scope scope, @Nullable Block parent) {
this.scope = scope;
this.parent = parent;
}
}
private final List<Event> errors;
private final StarlarkSemantics semantics;
private final Module module;
private Block block;
private int loopCount;
// In BUILD files, we have a slightly different behavior for legacy reasons.
// TODO(adonovan): eliminate isBuildFile. It is necessary because the prelude is implemented
// by inserting shared Statements, which must not be mutated, into each StarlarkFile.
// Instead, we should implement the prelude by executing it like a .bzl module
// and putting its members in the initial environment of the StarlarkFile.
// In the meantime, let's move this flag into Module (GlobalFrame).
private final boolean isBuildFile;
private ValidationEnvironment(
List<Event> errors, Module module, StarlarkSemantics semantics, boolean isBuildFile) {
this.errors = errors;
this.module = module;
this.semantics = semantics;
this.isBuildFile = isBuildFile;
block = new Block(Scope.Universe, null);
for (String name : module.getNames()) {
block.variables.put(name, PREDECLARED);
}
}
void addError(Location loc, String message) {
errors.add(Event.error(loc, message));
}
/**
* First pass: add all definitions to the current block. This is done because symbols are
* sometimes used before their definition point (e.g. a functions are not necessarily declared in
* order).
*/
private void collectDefinitions(Iterable<Statement> stmts) {
for (Statement stmt : stmts) {
collectDefinitions(stmt);
}
}
private void collectDefinitions(Statement stmt) {
switch (stmt.kind()) {
case ASSIGNMENT:
collectDefinitions(((AssignmentStatement) stmt).getLHS());
break;
case IF:
IfStatement ifStmt = (IfStatement) stmt;
collectDefinitions(ifStmt.getThenBlock());
if (ifStmt.getElseBlock() != null) {
collectDefinitions(ifStmt.getElseBlock());
}
break;
case FOR:
ForStatement forStmt = (ForStatement) stmt;
collectDefinitions(forStmt.getLHS());
collectDefinitions(forStmt.getBlock());
break;
case DEF:
DefStatement def = (DefStatement) stmt;
declare(def.getIdentifier());
break;
case LOAD:
LoadStatement load = (LoadStatement) stmt;
// The global reassignment check is not yet enabled for BUILD files,
// but we apply it to load statements as a special case.
// Because (for now) its error message is better than the general
// message emitted by 'declare', we'll apply it to non-BUILD files too.
Set<String> names = new HashSet<>();
for (LoadStatement.Binding b : load.getBindings()) {
if (!names.add(b.getLocalName().getName())) {
addError(
b.getLocalName().getStartLocation(),
String.format(
"load statement defines '%s' more than once", b.getLocalName().getName()));
}
}
for (LoadStatement.Binding b : load.getBindings()) {
declare(b.getLocalName());
}
break;
case EXPRESSION:
case FLOW:
case RETURN:
// nothing to declare
}
}
private void collectDefinitions(Expression lhs) {
for (Identifier id : Identifier.boundIdentifiers(lhs)) {
declare(id);
}
}
private void assign(Expression lhs) {
if (lhs instanceof Identifier) {
if (!isBuildFile) {
((Identifier) lhs).setScope(block.scope);
}
// no-op
} else if (lhs instanceof IndexExpression) {
visit(lhs);
} else if (lhs instanceof ListExpression) {
for (Expression elem : ((ListExpression) lhs).getElements()) {
assign(elem);
}
} else {
addError(lhs.getStartLocation(), "cannot assign to '" + lhs + "'");
}
}
@Override
public void visit(Identifier node) {
String name = node.getName();
@Nullable Block b = blockThatDefines(name);
if (b == null) {
// The identifier might not exist because it was restricted (hidden) by the current semantics.
// If this is the case, output a more helpful error message than 'not found'.
String error = module.getUndeclaredNameError(semantics, name);
if (error == null) {
// generic error
error = createInvalidIdentifierException(node.getName(), getAllSymbols());
}
addError(node.getStartLocation(), error);
return;
}
// TODO(laurentlb): In BUILD files, calling setScope will throw an exception. This happens
// because some AST nodes are shared across multipe ASTs (due to the prelude file).
if (!isBuildFile) {
node.setScope(b.scope);
}
}
private static String createInvalidIdentifierException(String name, Set<String> candidates) {
if (name.equals("$error$")) {
return "contains syntax error(s)";
}
String error = getErrorForObsoleteThreadLocalVars(name);
if (error != null) {
return error;
}
String suggestion = SpellChecker.didYouMean(name, candidates);
return "name '" + name + "' is not defined" + suggestion;
}
static String getErrorForObsoleteThreadLocalVars(String name) {
if (name.equals("PACKAGE_NAME")) {
return "The value 'PACKAGE_NAME' has been removed in favor of 'package_name()', "
+ "please use the latter ("
+ "https://docs.bazel.build/versions/master/skylark/lib/native.html#package_name). ";
}
if (name.equals("REPOSITORY_NAME")) {
return "The value 'REPOSITORY_NAME' has been removed in favor of 'repository_name()', please"
+ " use the latter ("
+ "https://docs.bazel.build/versions/master/skylark/lib/native.html#repository_name).";
}
return null;
}
@Override
public void visit(ReturnStatement node) {
if (block.scope != Scope.Local) {
addError(node.getStartLocation(), "return statements must be inside a function");
}
super.visit(node);
}
@Override
public void visit(ForStatement node) {
if (block.scope != Scope.Local) {
addError(
node.getStartLocation(),
"for loops are not allowed at the top level. You may move it inside a function "
+ "or use a comprehension, [f(x) for x in sequence]");
}
loopCount++;
visit(node.getCollection());
assign(node.getLHS());
visitBlock(node.getBlock());
Preconditions.checkState(loopCount > 0);
loopCount--;
}
@Override
public void visit(LoadStatement node) {
if (block.scope == Scope.Local) {
addError(node.getStartLocation(), "load statement not at top level");
}
super.visit(node);
}
@Override
public void visit(FlowStatement node) {
if (node.getKind() != TokenKind.PASS && loopCount <= 0) {
addError(node.getStartLocation(), node.getKind() + " statement must be inside a for loop");
}
super.visit(node);
}
@Override
public void visit(DotExpression node) {
visit(node.getObject());
// Do not visit the field.
}
@Override
public void visit(Comprehension node) {
openBlock(Scope.Local);
for (Comprehension.Clause clause : node.getClauses()) {
if (clause instanceof Comprehension.For) {
Comprehension.For forClause = (Comprehension.For) clause;
collectDefinitions(forClause.getVars());
}
}
// TODO(adonovan): opt: combine loops
for (Comprehension.Clause clause : node.getClauses()) {
if (clause instanceof Comprehension.For) {
Comprehension.For forClause = (Comprehension.For) clause;
visit(forClause.getIterable());
assign(forClause.getVars());
} else {
Comprehension.If ifClause = (Comprehension.If) clause;
visit(ifClause.getCondition());
}
}
visit(node.getBody());
closeBlock();
}
@Override
public void visit(DefStatement node) {
if (block.scope == Scope.Local) {
addError(
node.getStartLocation(),
"nested functions are not allowed. Move the function to the top level.");
}
for (Parameter param : node.getParameters()) {
if (param instanceof Parameter.Optional) {
visit(param.getDefaultValue());
}
}
openBlock(Scope.Local);
for (Parameter param : node.getParameters()) {
if (param.getIdentifier() != null) {
declare(param.getIdentifier());
}
}
collectDefinitions(node.getStatements());
visitAll(node.getStatements());
closeBlock();
}
@Override
public void visit(IfStatement node) {
if (block.scope != Scope.Local) {
addError(
node.getStartLocation(),
"if statements are not allowed at the top level. You may move it inside a function "
+ "or use an if expression (x if condition else y).");
}
super.visit(node);
}
@Override
public void visit(AssignmentStatement node) {
visit(node.getRHS());
// Disallow: [e, ...] += rhs
// Other bad cases are handled in assign.
if (node.isAugmented() && node.getLHS() instanceof ListExpression) {
addError(
node.getStartLocation(),
"cannot perform augmented assignment on a list or tuple expression");
}
assign(node.getLHS());
}
/** Declare a variable and add it to the environment. */
private void declare(Identifier id) {
Identifier prev = block.variables.putIfAbsent(id.getName(), id);
// Symbols defined in the module scope cannot be reassigned.
// TODO(laurentlb): Forbid reassignment in BUILD files too.
if (prev != null && block.scope == Scope.Module && !isBuildFile) {
addError(
id.getStartLocation(),
String.format(
"cannot reassign global '%s' (read more at"
+ " https://bazel.build/versions/master/docs/skylark/errors/read-only-variable.html)",
id.getName()));
if (prev != PREDECLARED) {
addError(
prev.getStartLocation(), String.format("'%s' previously declared here", id.getName()));
}
}
}
/** Returns the nearest Block that defines a symbol. */
private Block blockThatDefines(String varname) {
for (Block b = block; b != null; b = b.parent) {
if (b.variables.containsKey(varname)) {
return b;
}
}
return null;
}
/** Returns the set of all accessible symbols (both local and global) */
private Set<String> getAllSymbols() {
Set<String> all = new HashSet<>();
for (Block b = block; b != null; b = b.parent) {
all.addAll(b.variables.keySet());
}
return all;
}
// Report an error if a load statement appears after another kind of statement.
private void checkLoadAfterStatement(List<Statement> statements) {
Location firstStatement = null;
for (Statement statement : statements) {
// Ignore string literals (e.g. docstrings).
if (statement instanceof ExpressionStatement
&& ((ExpressionStatement) statement).getExpression() instanceof StringLiteral) {
continue;
}
if (statement instanceof LoadStatement) {
if (firstStatement == null) {
continue;
}
addError(
statement.getStartLocation(),
"load() statements must be called before any other statement. "
+ "First non-load() statement appears at "
+ firstStatement
+ ". Use --incompatible_bzl_disallow_load_after_statement=false to temporarily "
+ "disable this check.");
}
if (firstStatement == null) {
firstStatement = statement.getStartLocation();
}
}
}
private void validateToplevelStatements(List<Statement> statements) {
// Check that load() statements are on top.
if (!isBuildFile && semantics.incompatibleBzlDisallowLoadAfterStatement()) {
checkLoadAfterStatement(statements);
}
openBlock(Scope.Module);
// Add each variable defined by statements, not including definitions that appear in
// sub-scopes of the given statements (function bodies and comprehensions).
collectDefinitions(statements);
// Second pass: ensure that all symbols have been defined.
visitAll(statements);
closeBlock();
}
/**
* Performs static checks, including resolution of identifiers in {@code file} in the environment
* defined by {@code module}. The StarlarkFile is mutated. Errors are appended to {@link
* StarlarkFile#errors}. {@code isBuildFile} enables Bazel's legacy mode for BUILD files in which
* reassignment at top-level is permitted.
*/
public static void validateFile(
StarlarkFile file, Module module, StarlarkSemantics semantics, boolean isBuildFile) {
ValidationEnvironment venv =
new ValidationEnvironment(file.errors, module, semantics, isBuildFile);
if (semantics.incompatibleRestrictStringEscapes()) {
file.addStringEscapeEvents();
}
venv.validateToplevelStatements(file.getStatements());
// Check that no closeBlock was forgotten.
Preconditions.checkState(venv.block.parent == null);
}
/**
* Performs static checks, including resolution of identifiers in {@code expr} in the environment
* defined by {@code module}. This operation mutates the Expression.
*/
public static void validateExpr(Expression expr, Module module, StarlarkSemantics semantics)
throws SyntaxError {
List<Event> errors = new ArrayList<>();
ValidationEnvironment venv =
new ValidationEnvironment(errors, module, semantics, /*isBuildFile=*/ false);
venv.visit(expr);
if (!errors.isEmpty()) {
throw new SyntaxError(errors);
}
}
/** Open a new lexical block that will contain the future declarations. */
private void openBlock(Scope scope) {
block = new Block(scope, block);
}
/** Close a lexical block (and lose all declarations it contained). */
private void closeBlock() {
block = Preconditions.checkNotNull(block.parent);
}
}