bazel syntax: fine-grained syntax locations

This change improves the precision with which the locations
of source tokens are recorded in the syntax tree. Prior to
this change, every Node held a single LexerLocation object
that recorded the start and end offsets of the node, plus
a reference to the shared LineNumberTable (LNT), that maps
these offsets to Locations. This had a cost of one reference
and one LexerLocation object per node.

This change causes every Node to record the offsets only of
its salient tokens, plus a reference to the LNT. For example,
in the expression "1 + 2", the only salient token is the plus
operator; the start and end offsets can be computed inductively
by delegating to x.getStartLocation and y.getEndLocation.
Similarly, in f(x), the salient tokens are '(' and ')'.
This has a cost of 1 word plus approximately 1 int per Node.
Consequently, we can record the exact position of operators
that fail, and do so using less memory than before.

Now, when an expression such as 'f().g() + 1' fails,
the location in the error message will refer to the '+'
operator or one of the two '(' tokens. Before, all
three errors would be wrongly reported at the same place:
f, since it is the start of all three subexpressions.

Overview:
- Every Node has a reference to the LNT, set immediately
  after construction. (Morally it is part of the constructor
  but it's fussy to set it that way.)
- Every node defines getStartOffset and getEndOffset,
  typically by delegating to its left and right subtrees.
- Node end offsets are exclusive again. CL 170723732 was a mistake:
  half-open intervals are mathematically simpler.
  A client that wants to subtract one may do that.
  But there are none.
- Comprehension.{For,If} are now true Nodes.
- StarlarkFile's extent is now (correctly) the entire file,
  not just the range from the first statement to the last.
- The parser provides offsets of salient tokens to the Node constructors.
- IntegerLiteral now retains the raw token text in addition to the value.
- Token is gone. Its four fields are now embedded in the Lexer.
- Eval uses the following token positions in run-time error messages:

     x+y   f(x)   x[i]   x.y   x[i:j]   k: v
      ^     ^      ^      ^     ^        ^

- Location is final. LexerLocation and LineAndColumn are gone.
- Misparsed source represented as an Identifier now has the text of the
  source instead of "$error$". This is more faithful and causes
  the offsets to be correct.
- The offsets of the orig Identifier in load("module", local="orig")
  coincide with the text 'orig', sans quotation marks.

Benchmark: saves about 65MB (1% of live RAM) retained by the
Usual Benchmark, a deps query.

RELNOTES: N/A
PiperOrigin-RevId: 305803031
diff --git a/src/main/java/com/google/devtools/build/lib/packages/PackageFactory.java b/src/main/java/com/google/devtools/build/lib/packages/PackageFactory.java
index 2c4d394..79846fd 100644
--- a/src/main/java/com/google/devtools/build/lib/packages/PackageFactory.java
+++ b/src/main/java/com/google/devtools/build/lib/packages/PackageFactory.java
@@ -937,8 +937,7 @@
                   && arg.getName().equals("name")
                   && arg.getValue() instanceof StringLiteral) {
                 generatorNameByLocation.put(
-                    // TODO(adonovan): use lparen location
-                    call.getStartLocation(), ((StringLiteral) arg.getValue()).getValue());
+                    call.getLparenLocation(), ((StringLiteral) arg.getValue()).getValue());
               }
             }
           }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Argument.java b/src/main/java/com/google/devtools/build/lib/syntax/Argument.java
index cc54b58..37d2738 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/Argument.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/Argument.java
@@ -24,7 +24,7 @@
  */
 public abstract class Argument extends Node {
 
-  private final Expression value;
+  protected final Expression value;
 
   Argument(Expression value) {
     this.value = Preconditions.checkNotNull(value);
@@ -34,6 +34,11 @@
     return value;
   }
 
+  @Override
+  public int getEndOffset() {
+    return value.getEndOffset();
+  }
+
   /** Return the name of this argument's parameter, or null if it is not a Keyword argument. */
   @Nullable
   public String getName() {
@@ -45,6 +50,11 @@
     Positional(Expression value) {
       super(value);
     }
+
+    @Override
+    public int getStartOffset() {
+      return value.getStartOffset();
+    }
   }
 
   /** Syntax node for a keyword argument, {@code f(id=expr)}. */
@@ -53,34 +63,55 @@
     // Unlike in Python, keyword arguments in Bazel BUILD files
     // are about 10x more numerous than positional arguments.
 
-    final Identifier identifier;
+    final Identifier id;
 
-    Keyword(Identifier identifier, Expression value) {
+    Keyword(Identifier id, Expression value) {
       super(value);
-      this.identifier = identifier;
+      this.id = id;
     }
 
     public Identifier getIdentifier() {
-      return identifier;
+      return id;
     }
 
     @Override
     public String getName() {
-      return identifier.getName();
+      return id.getName();
+    }
+
+    @Override
+    public int getStartOffset() {
+      return id.getStartOffset();
     }
   }
 
   /** Syntax node for an argument of the form {@code f(*expr)}. */
   public static final class Star extends Argument {
-    Star(Expression value) {
+    private final int starOffset;
+
+    Star(int starOffset, Expression value) {
       super(value);
+      this.starOffset = starOffset;
+    }
+
+    @Override
+    public int getStartOffset() {
+      return starOffset;
     }
   }
 
   /** Syntax node for an argument of the form {@code f(**expr)}. */
   public static final class StarStar extends Argument {
-    StarStar(Expression value) {
+    private final int starStarOffset;
+
+    StarStar(int starStarOffset, Expression value) {
       super(value);
+      this.starStarOffset = starStarOffset;
+    }
+
+    @Override
+    public int getStartOffset() {
+      return starStarOffset;
     }
   }
 
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/AssignmentStatement.java b/src/main/java/com/google/devtools/build/lib/syntax/AssignmentStatement.java
index d750ac7..2b56c7c 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/AssignmentStatement.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/AssignmentStatement.java
@@ -23,7 +23,8 @@
 public final class AssignmentStatement extends Statement {
 
   private final Expression lhs; // = IDENTIFIER | DOT | INDEX | LIST_EXPR
-  @Nullable private final TokenKind op;
+  @Nullable private final TokenKind op; // TODO(adonovan): make this mandatory even when '='.
+  private final int opOffset;
   private final Expression rhs;
 
   /**
@@ -32,9 +33,10 @@
    * {@code (e, ...)}, where x, i, and e are arbitrary expressions. For an augmented assignment, the
    * list and tuple forms are disallowed.
    */
-  AssignmentStatement(Expression lhs, @Nullable TokenKind op, Expression rhs) {
+  AssignmentStatement(Expression lhs, @Nullable TokenKind op, int opOffset, Expression rhs) {
     this.lhs = lhs;
     this.op = op;
+    this.opOffset = opOffset;
     this.rhs = rhs;
   }
 
@@ -49,6 +51,21 @@
     return op;
   }
 
+  /** Returns the location of the assignment operator. */
+  public Location getOperatorLocation() {
+    return lnt.getLocation(opOffset);
+  }
+
+  @Override
+  public int getStartOffset() {
+    return lhs.getStartOffset();
+  }
+
+  @Override
+  public int getEndOffset() {
+    return rhs.getEndOffset();
+  }
+
   /** Reports whether this is an augmented assignment ({@code getOperator() != null}). */
   public boolean isAugmented() {
     return op != null;
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/BUILD b/src/main/java/com/google/devtools/build/lib/syntax/BUILD
index c301fbb..9833bde 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/syntax/BUILD
@@ -66,7 +66,6 @@
         "Statement.java",
         "StringLiteral.java",
         "SyntaxError.java",
-        "Token.java",
         "TokenKind.java",
         "UnaryOperatorExpression.java",
         "ValidationEnvironment.java",
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/BinaryOperatorExpression.java b/src/main/java/com/google/devtools/build/lib/syntax/BinaryOperatorExpression.java
index fef702d..aa0de06 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/BinaryOperatorExpression.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/BinaryOperatorExpression.java
@@ -20,6 +20,7 @@
 
   private final Expression x;
   private final TokenKind op; // one of 'operators'
+  private final int opOffset;
   private final Expression y;
 
   /** operators is the set of valid binary operators. */
@@ -43,9 +44,10 @@
           TokenKind.PIPE,
           TokenKind.STAR);
 
-  BinaryOperatorExpression(Expression x, TokenKind op, Expression y) {
+  BinaryOperatorExpression(Expression x, TokenKind op, int opOffset, Expression y) {
     this.x = x;
     this.op = op;
+    this.opOffset = opOffset;
     this.y = y;
   }
 
@@ -59,12 +61,26 @@
     return op;
   }
 
+  public Location getOperatorLocation() {
+    return lnt.getLocation(opOffset);
+  }
+
   /** Returns the right operand. */
   public Expression getY() {
     return y;
   }
 
   @Override
+  public int getStartOffset() {
+    return x.getStartOffset();
+  }
+
+  @Override
+  public int getEndOffset() {
+    return y.getEndOffset();
+  }
+
+  @Override
   public String toString() {
     // This omits the parentheses for brevity, but is not correct in general due to operator
     // precedence rules.
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/CallExpression.java b/src/main/java/com/google/devtools/build/lib/syntax/CallExpression.java
index 699bbff..64af972 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/CallExpression.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/CallExpression.java
@@ -21,12 +21,21 @@
 public final class CallExpression extends Expression {
 
   private final Expression function;
+  private final Location lparenLocation;
   private final ImmutableList<Argument> arguments;
+  private final int rparenOffset;
+
   private final int numPositionalArgs;
 
-  CallExpression(Expression function, ImmutableList<Argument> arguments) {
+  CallExpression(
+      Expression function,
+      Location lparenLocation,
+      ImmutableList<Argument> arguments,
+      int rparenOffset) {
     this.function = Preconditions.checkNotNull(function);
+    this.lparenLocation = lparenLocation;
     this.arguments = arguments;
+    this.rparenOffset = rparenOffset;
 
     int n = 0;
     for (Argument arg : arguments) {
@@ -53,6 +62,25 @@
   }
 
   @Override
+  public int getStartOffset() {
+    return function.getStartOffset();
+  }
+
+  @Override
+  public int getEndOffset() {
+    return rparenOffset + 1;
+  }
+
+  public Location getLparenLocation() {
+    // Unlike all other getXXXLocation methods, this one returns a reference to
+    // a previously materialized Location. getLparenLocation is unique among
+    // locations because the tree-walking evaluator needs it frequently even
+    // in the absence of errors. When we switch to a compiled representation
+    // we can dispense with this optimization.
+    return lparenLocation;
+  }
+
+  @Override
   public String toString() {
     StringBuilder buf = new StringBuilder();
     buf.append(function);
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Comment.java b/src/main/java/com/google/devtools/build/lib/syntax/Comment.java
index 161c7a4..ee7d6a7 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/Comment.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/Comment.java
@@ -17,14 +17,26 @@
 /** Syntax node for comments. */
 public final class Comment extends Node {
 
-  protected final String value;
+  private final int offset;
+  private final String text;
 
-  public Comment(String value) {
-    this.value = value;
+  Comment(int offset, String text) {
+    this.offset = offset;
+    this.text = text;
   }
 
-  public String getValue() {
-    return value;
+  public String getText() {
+    return text;
+  }
+
+  @Override
+  public int getStartOffset() {
+    return offset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return offset + text.length();
   }
 
   @Override
@@ -34,6 +46,6 @@
 
   @Override
   public String toString() {
-    return value;
+    return text;
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Comprehension.java b/src/main/java/com/google/devtools/build/lib/syntax/Comprehension.java
index 771196e..4549bbc 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/Comprehension.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/Comprehension.java
@@ -37,14 +37,16 @@
 public final class Comprehension extends Expression {
 
   /** For or If */
-  public abstract static class Clause {}
+  public abstract static class Clause extends Node {}
 
   /** A for clause in a comprehension, e.g. "for a in b" in the example above. */
   public static final class For extends Clause {
+    private final int forOffset;
     private final Expression vars;
     private final Expression iterable;
 
-    For(Expression vars, Expression iterable) {
+    For(int forOffset, Expression vars, Expression iterable) {
+      this.forOffset = forOffset;
       this.vars = vars;
       this.iterable = iterable;
     }
@@ -56,29 +58,70 @@
     public Expression getIterable() {
       return iterable;
     }
+
+    @Override
+    public int getStartOffset() {
+      return forOffset;
+    }
+
+    @Override
+    public int getEndOffset() {
+      return iterable.getEndOffset();
+    }
+
+    @Override
+    public void accept(NodeVisitor visitor) {
+      visitor.visit(this);
+    }
   }
 
   /** A if clause in a comprehension, e.g. "if c" in the example above. */
   public static final class If extends Clause {
+    private final int ifOffset;
     private final Expression condition;
 
-    If(Expression condition) {
+    If(int ifOffset, Expression condition) {
+      this.ifOffset = ifOffset;
       this.condition = condition;
     }
 
     public Expression getCondition() {
       return condition;
     }
+
+    @Override
+    public int getStartOffset() {
+      return ifOffset;
+    }
+
+    @Override
+    public int getEndOffset() {
+      return condition.getEndOffset();
+    }
+
+    @Override
+    public void accept(NodeVisitor visitor) {
+      visitor.visit(this);
+    }
   }
 
   private final boolean isDict; // {k: v for vars in iterable}
+  private final int lbracketOffset;
   private final Node body; // Expression or DictExpression.Entry
   private final ImmutableList<Clause> clauses;
+  private final int rbracketOffset;
 
-  Comprehension(boolean isDict, Node body, ImmutableList<Clause> clauses) {
+  Comprehension(
+      boolean isDict,
+      int lbracketOffset,
+      Node body,
+      ImmutableList<Clause> clauses,
+      int rbracketOffset) {
     this.isDict = isDict;
+    this.lbracketOffset = lbracketOffset;
     this.body = body;
     this.clauses = clauses;
+    this.rbracketOffset = rbracketOffset;
   }
 
   public boolean isDict() {
@@ -98,6 +141,16 @@
   }
 
   @Override
+  public int getStartOffset() {
+    return lbracketOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return rbracketOffset + 1;
+  }
+
+  @Override
   public void accept(NodeVisitor visitor) {
     visitor.visit(this);
   }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/ConditionalExpression.java b/src/main/java/com/google/devtools/build/lib/syntax/ConditionalExpression.java
index ffeb9ea..8085d55 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/ConditionalExpression.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/ConditionalExpression.java
@@ -13,25 +13,40 @@
 // limitations under the License.
 package com.google.devtools.build.lib.syntax;
 
-
-/** Syntax node for an if/else expression. */
+/** Syntax node for an expression of the form {@code t if cond else f}. */
 public final class ConditionalExpression extends Expression {
 
-  // Python conditional expressions: $thenCase if $condition else $elseCase
-  // https://docs.python.org/3.5/reference/expressions.html#conditional-expressions
-  private final Expression thenCase;
-  private final Expression condition;
-  private final Expression elseCase;
+  private final Expression t;
+  private final Expression cond;
+  private final Expression f;
 
-  public Expression getThenCase() { return thenCase; }
-  public Expression getCondition() { return condition; }
-  public Expression getElseCase() { return elseCase; }
+  public Expression getThenCase() {
+    return t;
+  }
+
+  public Expression getCondition() {
+    return cond;
+  }
+
+  public Expression getElseCase() {
+    return f;
+  }
 
   /** Constructor for a conditional expression */
-  ConditionalExpression(Expression thenCase, Expression condition, Expression elseCase) {
-    this.thenCase = thenCase;
-    this.condition = condition;
-    this.elseCase = elseCase;
+  ConditionalExpression(Expression t, Expression cond, Expression f) {
+    this.t = t;
+    this.cond = cond;
+    this.f = f;
+  }
+
+  @Override
+  public int getStartOffset() {
+    return t.getStartOffset();
+  }
+
+  @Override
+  public int getEndOffset() {
+    return f.getEndOffset();
   }
 
   @Override
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/DefStatement.java b/src/main/java/com/google/devtools/build/lib/syntax/DefStatement.java
index 1fe77f5..d128c0e 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/DefStatement.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/DefStatement.java
@@ -19,20 +19,23 @@
 /** Syntax node for a 'def' statement, which defines a function. */
 public final class DefStatement extends Statement {
 
+  private final int defOffset;
   private final Identifier identifier;
   private final FunctionSignature signature;
-  private final ImmutableList<Statement> statements;
+  private final ImmutableList<Statement> body; // non-empty if well formed
   private final ImmutableList<Parameter> parameters;
 
   DefStatement(
+      int defOffset,
       Identifier identifier,
       ImmutableList<Parameter> parameters,
       FunctionSignature signature,
-      ImmutableList<Statement> statements) {
+      ImmutableList<Statement> body) {
+    this.defOffset = defOffset;
     this.identifier = identifier;
     this.parameters = Preconditions.checkNotNull(parameters);
-    this.signature = signature;
-    this.statements = Preconditions.checkNotNull(statements);
+    this.signature = Preconditions.checkNotNull(signature);
+    this.body = Preconditions.checkNotNull(body);
   }
 
   @Override
@@ -48,8 +51,9 @@
     return identifier;
   }
 
+  // TODO(adonovan): rename to getBody.
   public ImmutableList<Statement> getStatements() {
-    return statements;
+    return body;
   }
 
   public ImmutableList<Parameter> getParameters() {
@@ -61,6 +65,18 @@
   }
 
   @Override
+  public int getStartOffset() {
+    return defOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return body.isEmpty()
+        ? identifier.getEndOffset() // wrong, but tree is ill formed
+        : body.get(body.size() - 1).getEndOffset();
+  }
+
+  @Override
   public void accept(NodeVisitor visitor) {
     visitor.visit(this);
   }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/DictExpression.java b/src/main/java/com/google/devtools/build/lib/syntax/DictExpression.java
index e0b1fa3..c144d4a7 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/DictExpression.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/DictExpression.java
@@ -23,10 +23,12 @@
   public static final class Entry extends Node {
 
     private final Expression key;
+    private final int colonOffset;
     private final Expression value;
 
-    Entry(Expression key, Expression value) {
+    Entry(Expression key, int colonOffset, Expression value) {
       this.key = key;
+      this.colonOffset = colonOffset;
       this.value = value;
     }
 
@@ -39,15 +41,43 @@
     }
 
     @Override
+    public int getStartOffset() {
+      return key.getStartOffset();
+    }
+
+    @Override
+    public int getEndOffset() {
+      return value.getEndOffset();
+    }
+
+    public Location getColonLocation() {
+      return lnt.getLocation(colonOffset);
+    }
+
+    @Override
     public void accept(NodeVisitor visitor) {
       visitor.visit(this);
     }
   }
 
+  private final int lbraceOffset;
   private final ImmutableList<Entry> entries;
+  private final int rbraceOffset;
 
-  DictExpression(List<Entry> entries) {
+  DictExpression(int lbraceOffset, List<Entry> entries, int rbraceOffset) {
+    this.lbraceOffset = lbraceOffset;
     this.entries = ImmutableList.copyOf(entries);
+    this.rbraceOffset = rbraceOffset;
+  }
+
+  @Override
+  public int getStartOffset() {
+    return lbraceOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return rbraceOffset + 1;
   }
 
   @Override
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/DotExpression.java b/src/main/java/com/google/devtools/build/lib/syntax/DotExpression.java
index 102f1c3..65b2048 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/DotExpression.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/DotExpression.java
@@ -18,11 +18,12 @@
 public final class DotExpression extends Expression {
 
   private final Expression object;
-
+  private final int dotOffset;
   private final Identifier field;
 
-  DotExpression(Expression object, Identifier field) {
+  DotExpression(Expression object, int dotOffset, Identifier field) {
     this.object = object;
+    this.dotOffset = dotOffset;
     this.field = field;
   }
 
@@ -35,6 +36,20 @@
   }
 
   @Override
+  public int getStartOffset() {
+    return object.getStartOffset();
+  }
+
+  @Override
+  public int getEndOffset() {
+    return field.getEndOffset();
+  }
+
+  public Location getDotLocation() {
+    return lnt.getLocation(dotOffset);
+  }
+
+  @Override
   public void accept(NodeVisitor visitor) {
     visitor.visit(this);
   }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Eval.java b/src/main/java/com/google/devtools/build/lib/syntax/Eval.java
index d979b9b..fd245e8 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/Eval.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/Eval.java
@@ -81,8 +81,7 @@
       try {
         assign(fr, node.getLHS(), rvalue);
       } catch (EvalException ex) {
-        // TODO(adonovan): use location of = operator.
-        throw ex.ensureLocation(node.getStartLocation());
+        throw ex.ensureLocation(node.getOperatorLocation());
       }
     }
   }
@@ -113,7 +112,7 @@
         }
       }
     } catch (EvalException ex) {
-      throw ex.ensureLocation(node.getLHS().getStartLocation());
+      throw ex.ensureLocation(node.getStartLocation());
     } finally {
       EvalUtils.removeIterator(o);
     }
@@ -173,7 +172,7 @@
       StarlarkThread.Extension module = fr.thread.getExtension(moduleName);
       if (module == null) {
         throw new EvalException(
-            node.getImport().getStartLocation(),
+            node.getStartLocation(),
             String.format(
                 "file '%s' was not correctly loaded. "
                     + "Make sure the 'load' statement appears in the global scope in your file",
@@ -220,7 +219,7 @@
   private static TokenKind exec(StarlarkThread.Frame fr, Statement st)
       throws EvalException, InterruptedException {
     if (fr.dbg != null) {
-      Location loc = st.getStartLocation();
+      Location loc = st.getStartLocation(); // not very precise
       fr.setLocation(loc);
       fr.dbg.before(fr.thread, loc); // location is now redundant since it's in the thread
     }
@@ -385,19 +384,18 @@
       try {
         EvalUtils.setIndex(object, key, z);
       } catch (EvalException ex) {
-        Location loc = stmt.getStartLocation(); // TODO(adonovan): use operator location
-        throw ex.ensureLocation(loc);
+        throw ex.ensureLocation(stmt.getOperatorLocation());
       }
 
     } else if (lhs instanceof ListExpression) {
       // TODO(adonovan): make this a static error.
-      Location loc = stmt.getStartLocation(); // TODO(adonovan): use operator location
-      throw new EvalException(loc, "cannot perform augmented assignment on a list literal");
+      throw new EvalException(
+          stmt.getOperatorLocation(), "cannot perform augmented assignment on a list literal");
 
     } else {
       // Not possible for validated ASTs.
-      Location loc = stmt.getStartLocation(); // TODO(adonovan): use operator location
-      throw new EvalException(loc, "cannot perform augmented assignment on '" + lhs + "'");
+      throw new EvalException(
+          stmt.getOperatorLocation(), "cannot perform augmented assignment on '" + lhs + "'");
     }
   }
 
@@ -445,8 +443,7 @@
                 return EvalUtils.binaryOp(
                     binop.getOperator(), x, y, fr.thread.getSemantics(), fr.thread.mutability());
               } catch (EvalException ex) {
-                // TODO(adonovan): use operator location
-                ex.ensureLocation(binop.getStartLocation());
+                ex.ensureLocation(binop.getOperatorLocation());
                 throw ex;
               }
           }
@@ -473,13 +470,11 @@
             try {
               dict.put(k, v, (Location) null);
             } catch (EvalException ex) {
-              // TODO(adonovan): use colon location
-              throw ex.ensureLocation(entry.getKey().getStartLocation());
+              throw ex.ensureLocation(entry.getColonLocation());
             }
             if (dict.size() == before) {
-              // TODO(adonovan): use colon location
               throw new EvalException(
-                  entry.getKey().getStartLocation(),
+                  entry.getColonLocation(),
                   "Duplicated key " + Starlark.repr(k) + " when creating dictionary");
             }
           }
@@ -498,7 +493,7 @@
             }
             return result;
           } catch (EvalException ex) {
-            throw ex.ensureLocation(dot.getStartLocation()); // TODO(adonovan): use dot token
+            throw ex.ensureLocation(dot.getDotLocation());
           }
         }
 
@@ -582,7 +577,7 @@
             }
           }
 
-          Location loc = call.getStartLocation(); // TODO(adonovan): use call lparen
+          Location loc = call.getLparenLocation(); // (Location is prematerialized)
           fr.setLocation(loc);
           try {
             return Starlark.fastcall(fr.thread, fn, positional, named);
@@ -657,8 +652,7 @@
           try {
             return EvalUtils.index(fr.thread.mutability(), fr.thread.getSemantics(), object, key);
           } catch (EvalException ex) {
-            // TODO(adonovan): use location of lbracket token
-            throw ex.ensureLocation(index.getStartLocation());
+            throw ex.ensureLocation(index.getLbracketLocation());
           }
         }
 
@@ -688,8 +682,7 @@
           try {
             return Starlark.slice(fr.thread.mutability(), x, start, stop, step);
           } catch (EvalException ex) {
-            // TODO(adonovan): use lbracket location
-            throw ex.ensureLocation(slice.getStartLocation());
+            throw ex.ensureLocation(slice.getLbracketLocation());
           }
         }
 
@@ -754,8 +747,7 @@
                 execClauses(index + 1);
               }
             } catch (EvalException ex) {
-              // TODO(adonovan): use location of 'for' token
-              throw ex.ensureLocation(comp.getStartLocation());
+              throw ex.ensureLocation(forClause.getStartLocation());
             } finally {
               EvalUtils.removeIterator(iterable);
             }
@@ -778,8 +770,7 @@
           try {
             dict.put(k, v, (Location) null);
           } catch (EvalException ex) {
-            // TODO(adonovan): use colon location
-            throw ex.ensureLocation(comp.getStartLocation());
+            throw ex.ensureLocation(body.getColonLocation());
           }
         } else {
           list.add(eval(fr, ((Expression) comp.getBody())));
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/EvalExceptionWithStackTrace.java b/src/main/java/com/google/devtools/build/lib/syntax/EvalExceptionWithStackTrace.java
index 90edb7c..ee64493 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/EvalExceptionWithStackTrace.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/EvalExceptionWithStackTrace.java
@@ -18,9 +18,8 @@
 import java.util.Deque;
 import java.util.LinkedList;
 
-/**
- * EvalException with a stack trace.
- */
+/** EvalException with a stack trace. */
+// TODO(adonovan): get rid of this. Every EvalException should record the stack.
 public class EvalExceptionWithStackTrace extends EvalException {
 
   private StackFrame mostRecentElement;
@@ -45,11 +44,17 @@
    */
   private static Location extractLocation(Exception original, Node culprit) {
     if (culprit != null) {
-      return culprit.getStartLocation();
+      return nodeLocation(culprit);
     }
     return original instanceof EvalException ? ((EvalException) original).getLocation() : null;
   }
 
+  private static Location nodeLocation(Node node) {
+    return node instanceof CallExpression
+        ? ((CallExpression) node).getLparenLocation()
+        : node.getStartLocation();
+  }
+
   /**
    * Returns the "real" cause of this exception.
    *
@@ -61,8 +66,8 @@
   }
 
   /** Adds an entry for the given {@code Node} to the stack trace. */
-  public void registerNode(Node node) {
-    addStackFrame(node.toString().trim(), node.getStartLocation());
+  void registerNode(Node node) {
+    addStackFrame(node.toString().trim(), nodeLocation(node));
   }
 
   /**
@@ -109,12 +114,24 @@
   /** Adds a line for the given frame. */
   private void addStackFrame(String label, Location location, boolean canPrint) {
     // TODO(bazel-team): This check was originally created to weed out duplicates in case the same
-    // node is added twice, but it's not clear if that is still a possibility. In any case, it would
-    // be better to eliminate the check and not create unwanted duplicates in the first place.
+    // node is added twice, but it's not clear if that is still a possibility.
     //
-    // The check is problematic because it suppresses tracebacks in the REPL, where line numbers
-    // can be reset within a single session.
-    if (mostRecentElement != null && location.equals(mostRecentElement.getLocation())) {
+    // [I suspect the real reason it was added is not because of duplicate nodes,
+    // but because the stack corresponds to the stack of expressions in the tree-walking
+    // evaluator's recursion, which often includes several subexpressions within
+    // the same line, e.g. f().g()+1. If the stack had one entry per function call,
+    // like StarlarkThread.CallStack, there would be no problem.
+    // This was revealed when we started recording operator positions precisely,
+    // causing the f(), .g(), and + operations in the example above to have different
+    // locations within the same line. --adonovan]
+    //
+    // In any case, it would be better to eliminate the check and not create unwanted duplicates in
+    // the first place.
+    // The check is problematic because it suppresses tracebacks in the REPL,
+    // where line numbers can be reset within a single session.
+    if (mostRecentElement != null
+        && location.file().equals(mostRecentElement.getLocation().file())
+        && location.line() == mostRecentElement.getLocation().line()) {
       return;
     }
     mostRecentElement = new StackFrame(label, location, mostRecentElement, canPrint);
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/EvalUtils.java b/src/main/java/com/google/devtools/build/lib/syntax/EvalUtils.java
index 8ba6bb1..8250178 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/EvalUtils.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/EvalUtils.java
@@ -782,7 +782,7 @@
             expr.getStartLocation(),
             FunctionSignature.NOARGS,
             /*defaultValues=*/ Tuple.empty(),
-            ImmutableList.<Statement>of(new ReturnStatement(expr)),
+            ImmutableList.<Statement>of(ReturnStatement.make(expr)),
             module);
 
     return Starlark.fastcall(thread, fn, NOARGS, NOARGS);
@@ -816,7 +816,7 @@
       stmts =
           ImmutableList.<Statement>builder()
               .addAll(stmts.subList(0, n - 1))
-              .add(new ReturnStatement(expr))
+              .add(ReturnStatement.make(expr))
               .build();
     }
 
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/ExpressionStatement.java b/src/main/java/com/google/devtools/build/lib/syntax/ExpressionStatement.java
index 1ff2b21..8e54ff0 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/ExpressionStatement.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/ExpressionStatement.java
@@ -14,8 +14,7 @@
 
 package com.google.devtools.build.lib.syntax;
 
-
-/** Syntax node for a function call statement. Used for build rules. */
+/** Syntax node for a statement consisting of an expression evaluated for effect. */
 public final class ExpressionStatement extends Statement {
 
   private final Expression expression;
@@ -34,6 +33,16 @@
   }
 
   @Override
+  public int getStartOffset() {
+    return expression.getStartOffset();
+  }
+
+  @Override
+  public int getEndOffset() {
+    return expression.getEndOffset();
+  }
+
+  @Override
   public Kind kind() {
     return Kind.EXPRESSION;
   }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/FlowStatement.java b/src/main/java/com/google/devtools/build/lib/syntax/FlowStatement.java
index b24a672..1637934 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/FlowStatement.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/FlowStatement.java
@@ -18,10 +18,12 @@
 public final class FlowStatement extends Statement {
 
   private final TokenKind kind; // BREAK | CONTINUE | PASS
+  private final int offset;
 
   /** @param kind The label of the statement (break, continue, or pass) */
-  FlowStatement(TokenKind kind) {
+  FlowStatement(TokenKind kind, int offset) {
     this.kind = kind;
+    this.offset = offset;
   }
 
   public TokenKind getKind() {
@@ -34,6 +36,16 @@
   }
 
   @Override
+  public int getStartOffset() {
+    return offset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return offset + kind.toString().length();
+  }
+
+  @Override
   public void accept(NodeVisitor visitor) {
     visitor.visit(this);
   }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/ForStatement.java b/src/main/java/com/google/devtools/build/lib/syntax/ForStatement.java
index 5b43f75..9b3fdb9 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/ForStatement.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/ForStatement.java
@@ -20,17 +20,20 @@
 /** Syntax node for a for loop statement. */
 public final class ForStatement extends Statement {
 
+  private final int forOffset;
   private final Expression lhs;
   private final Expression collection;
-  private final ImmutableList<Statement> block;
+  private final ImmutableList<Statement> block; // non-empty if well formed
 
   /** Constructs a for loop statement. */
-  ForStatement(Expression lhs, Expression collection, List<Statement> block) {
+  ForStatement(int forOffset, Expression lhs, Expression collection, List<Statement> block) {
+    this.forOffset = forOffset;
     this.lhs = Preconditions.checkNotNull(lhs);
     this.collection = Preconditions.checkNotNull(collection);
     this.block = ImmutableList.copyOf(block);
   }
 
+  // TODO(adonovan): rename to getVars.
   public Expression getLHS() {
     return lhs;
   }
@@ -47,6 +50,18 @@
   }
 
   @Override
+  public int getStartOffset() {
+    return forOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return block.isEmpty()
+        ? collection.getEndOffset() // wrong, but tree is ill formed
+        : block.get(block.size() - 1).getEndOffset();
+  }
+
+  @Override
   public String toString() {
     return "for " + lhs + " in " + collection + ": ...\n";
   }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Identifier.java b/src/main/java/com/google/devtools/build/lib/syntax/Identifier.java
index 76af45b..70af64e 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/Identifier.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/Identifier.java
@@ -27,16 +27,30 @@
 public final class Identifier extends Expression {
 
   private final String name;
+  private final int nameOffset;
+
   // The scope of the variable. The value is set when the AST has been analysed by
   // ValidationEnvironment.
   @Nullable private ValidationEnvironment.Scope scope;
 
-  Identifier(String name) {
+  Identifier(String name, int nameOffset) {
     this.name = name;
+    this.nameOffset = nameOffset;
+  }
+
+  @Override
+  public int getStartOffset() {
+    return nameOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return nameOffset + name.length();
   }
 
   /**
-   *  Returns the name of the Identifier.
+   * Returns the name of the Identifier. If there were parse errors, misparsed regions may be
+   * represented as an Identifier for which {@code !isValid(getName())}.
    */
   public String getName() {
     return name;
@@ -65,32 +79,19 @@
     return Kind.IDENTIFIER;
   }
 
-  /** @return The {@link Identifier} of the provided name. */
-  static Identifier of(String name) {
-    return new Identifier(name);
-  }
-
-  /** Returns true if the string is a syntactically valid identifier. */
+  /** Reports whether the string is a valid identifier. */
   public static boolean isValid(String name) {
-    // TODO(laurentlb): Handle Unicode characters.
-    if (name.isEmpty()) {
-      return false;
-    }
+    // Keep consistent with Lexer.scanIdentifier.
     for (int i = 0; i < name.length(); i++) {
       char c = name.charAt(i);
-      if ((c >= 'a' && c <= 'z')
-          || (c >= 'A' && c <= 'Z')
-          || (c >= '0' && c <= '9')
-          || (c == '_')) {
-        continue;
+      if (!(('a' <= c && c <= 'z')
+          || ('A' <= c && c <= 'Z')
+          || (i > 0 && '0' <= c && c <= '9')
+          || (c == '_'))) {
+        return false;
       }
-      return false;
     }
-    if (name.charAt(0) >= '0' && name.charAt(0) <= '9') {
-      return false;
-    }
-
-    return true;
+    return !name.isEmpty();
   }
 
   /**
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/IfStatement.java b/src/main/java/com/google/devtools/build/lib/syntax/IfStatement.java
index 378bfbd..5769057 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/IfStatement.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/IfStatement.java
@@ -21,12 +21,15 @@
 public final class IfStatement extends Statement {
 
   private final TokenKind token; // IF or ELIF
+  private final int ifOffset;
   private final Expression condition;
+  // These blocks may be non-null but empty after a misparse:
   private final ImmutableList<Statement> thenBlock; // non-empty
   @Nullable ImmutableList<Statement> elseBlock; // non-empty if non-null; set after construction
 
-  IfStatement(TokenKind token, Expression condition, List<Statement> thenBlock) {
+  IfStatement(TokenKind token, int ifOffset, Expression condition, List<Statement> thenBlock) {
     this.token = token;
+    this.ifOffset = ifOffset;
     this.condition = condition;
     this.thenBlock = ImmutableList.copyOf(thenBlock);
   }
@@ -59,6 +62,19 @@
   }
 
   @Override
+  public int getStartOffset() {
+    return ifOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    List<Statement> body = elseBlock != null ? elseBlock : thenBlock;
+    return body.isEmpty()
+        ? condition.getEndOffset() // wrong, but tree is ill formed
+        : body.get(body.size() - 1).getEndOffset();
+  }
+
+  @Override
   public String toString() {
     return String.format("if %s: ...\n", condition);
   }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/IndexExpression.java b/src/main/java/com/google/devtools/build/lib/syntax/IndexExpression.java
index e3517d6..c5c9c8c 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/IndexExpression.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/IndexExpression.java
@@ -22,12 +22,15 @@
 public final class IndexExpression extends Expression {
 
   private final Expression object;
-
+  private final int lbracketOffset;
   private final Expression key;
+  private final int rbracketOffset;
 
-  IndexExpression(Expression object, Expression key) {
+  IndexExpression(Expression object, int lbracketOffset, Expression key, int rbracketOffset) {
     this.object = object;
+    this.lbracketOffset = lbracketOffset;
     this.key = key;
+    this.rbracketOffset = rbracketOffset;
   }
 
   public Expression getObject() {
@@ -39,6 +42,20 @@
   }
 
   @Override
+  public int getStartOffset() {
+    return object.getStartOffset();
+  }
+
+  @Override
+  public int getEndOffset() {
+    return rbracketOffset + 1;
+  }
+
+  public Location getLbracketLocation() {
+    return lnt.getLocation(lbracketOffset);
+  }
+
+  @Override
   public void accept(NodeVisitor visitor) {
     visitor.visit(this);
   }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/IntegerLiteral.java b/src/main/java/com/google/devtools/build/lib/syntax/IntegerLiteral.java
index e909536..d5ae4ec 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/IntegerLiteral.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/IntegerLiteral.java
@@ -16,9 +16,13 @@
 
 /** Syntax node for an integer literal. */
 public final class IntegerLiteral extends Expression {
+  private final String raw;
+  private final int tokenOffset;
   private final int value;
 
-  IntegerLiteral(int value) {
+  IntegerLiteral(String raw, int tokenOffset, int value) {
+    this.raw = raw;
+    this.tokenOffset = tokenOffset;
     this.value = value;
   }
 
@@ -26,6 +30,21 @@
     return value;
   }
 
+  /** Returns the raw source text of the literal. */
+  public String getRaw() {
+    return raw;
+  }
+
+  @Override
+  public int getStartOffset() {
+    return tokenOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return tokenOffset + raw.length();
+  }
+
   @Override
   public void accept(NodeVisitor visitor) {
     visitor.visit(this);
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Lexer.java b/src/main/java/com/google/devtools/build/lib/syntax/Lexer.java
index 34d78d3..9ec6be3 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/Lexer.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/Lexer.java
@@ -16,8 +16,6 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableMap;
-import com.google.devtools.build.lib.concurrent.ThreadSafety.Immutable;
-import com.google.devtools.build.lib.skyframe.serialization.autocodec.AutoCodec;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -27,6 +25,47 @@
 /** A scanner for Starlark. */
 final class Lexer {
 
+  // --- These fields are accessed directly by the parser: ---
+
+  // Mapping from file offsets to Locations.
+  final LineNumberTable lnt;
+
+  // Information about current token. Updated by nextToken.
+  // raw and value are defined only for STRING, INT, IDENTIFIER, and COMMENT.
+  // TODO(adonovan): rename s/xyz/tokenXyz/
+  TokenKind kind;
+  int left; // start offset TODO(adonovan): rename to start, end
+  int right; // end offset
+  String raw; // source text of token
+  Object value; // String or Integer value of token
+
+  // --- end of parser-visible fields ---
+
+  private final List<SyntaxError> errors;
+
+  // Input buffer and position
+  private final char[] buffer;
+  private int pos;
+
+  private final FileOptions options;
+
+  // The stack of enclosing indentation levels in spaces.
+  // The first (outermost) element is always zero.
+  private final Stack<Integer> indentStack = new Stack<>();
+
+  private final List<Comment> comments;
+
+  // The number of unclosed open-parens ("(", '{', '[') at the current point in
+  // the stream. Whitespace is handled differently when this is nonzero.
+  private int openParenStackDepth = 0;
+
+  // True after a NEWLINE token. In other words, we are outside an
+  // expression and we have to check the indentation.
+  private boolean checkIndentation;
+
+  // Number of saved INDENT (>0) or OUTDENT (<0) tokens detected but not yet returned.
+  private int dents;
+
   // Characters that can come immediately prior to an '=' character to generate
   // a different token
   private static final ImmutableMap<Character, TokenKind> EQUAL_TOKENS =
@@ -45,43 +84,8 @@
           .put('|', TokenKind.PIPE_EQUALS)
           .build();
 
-  // Input buffer and position
-  private final char[] buffer;
-  private int pos;
-
-  private final FileOptions options;
-
-  private final LineNumberTable lnt; // maps offsets to Locations
-
-  // The stack of enclosing indentation levels; always contains '0' at the
-  // bottom.
-  private final Stack<Integer> indentStack = new Stack<>();
-
-  /**
-   * Token to return. This token is mutated in-place. Its kind is set to
-   * null to indicate the intermediate state, where the new token has not
-   * been scanned yet.
-   */
-  private final Token token;
-
-  private final List<Comment> comments;
-
-  // The number of unclosed open-parens ("(", '{', '[') at the current point in
-  // the stream. Whitespace is handled differently when this is nonzero.
-  private int openParenStackDepth = 0;
-
-  // List of errors appended to by Lexer and Parser.
-  private final List<SyntaxError> errors;
-
-  /**
-   * True after a NEWLINE token.
-   * In other words, we are outside an expression and we have to check the indentation.
-   */
-  private boolean checkIndentation;
-
-  private int dents; // number of saved INDENT (>0) or OUTDENT (<0) tokens to return
-
-  /** Constructs a lexer which tokenizes the parser input. Errors are appended to {@code errors}. */
+  // Constructs a lexer which tokenizes the parser input.
+  // Errors are appended to errors.
   Lexer(ParserInput input, FileOptions options, List<SyntaxError> errors) {
     this.lnt = LineNumberTable.create(input.getContent(), input.getFile());
     this.options = options;
@@ -91,7 +95,6 @@
     this.checkIndentation = true;
     this.comments = new ArrayList<>();
     this.dents = 0;
-    this.token = new Token(null, -1, -1);
 
     indentStack.push(0);
   }
@@ -100,104 +103,47 @@
     return comments;
   }
 
-  /** Returns the apparent name of the lexer's input file. */
-  String getFile() {
-    return lnt.getFile();
-  }
-
   /**
-   * Returns the next token, or EOF if it is the end of the file. It is an error to call nextToken()
-   * after EOF has been returned.
+   * Reads the next token, updating the Lexer's token fields. It is an error to call nextToken after
+   * an EOF token.
    */
-  Token nextToken() {
-    boolean afterNewline = token.kind == TokenKind.NEWLINE;
-    token.kind = null;
+  void nextToken() {
+    boolean afterNewline = kind == TokenKind.NEWLINE;
     tokenize();
-    Preconditions.checkState(token.kind != null);
+    Preconditions.checkState(kind != null);
 
     // Like Python, always end with a NEWLINE token, even if no '\n' in input:
-    if (token.kind == TokenKind.EOF && !afterNewline) {
-      token.kind = TokenKind.NEWLINE;
+    if (kind == TokenKind.EOF && !afterNewline) {
+      kind = TokenKind.NEWLINE;
     }
-    return token;
   }
 
   private void popParen() {
     if (openParenStackDepth == 0) {
-      error("indentation error");
+      // TODO(adonovan): fix: the input ')' should not report an indentation error.
+      error("indentation error", pos - 1);
     } else {
       openParenStackDepth--;
     }
   }
 
-  private void error(String message) {
-     error(message, pos - 1, pos - 1);
+  private void error(String message, int pos) {
+    errors.add(new SyntaxError(lnt.getLocation(pos), message));
   }
 
-  private void error(String message, int start, int end) {
-    errors.add(new SyntaxError(createLocation(start, end), message));
-  }
-
-  LexerLocation createLocation(int start, int end) {
-    return new LexerLocation(lnt, start, end);
-  }
-
-  // A LexerLocation records the span (both start and end) of a token or grammar production.
-  // It implements Location by describing the start position,
-  // but it also exposes the end location through getEndLocation.
-  // This class will be merged with Location and eliminated when we make the Parser
-  // record token offsets in the syntax tree, and create Locations on demand.
-  @AutoCodec
-  @Immutable
-  static final class LexerLocation extends Location {
-    private final LineNumberTable lineNumberTable;
-    final int startOffset;
-    final int endOffset;
-
-    LexerLocation(LineNumberTable lineNumberTable, int startOffset, int endOffset) {
-      this.startOffset = startOffset;
-      this.endOffset = endOffset;
-      this.lineNumberTable = lineNumberTable;
-    }
-
-    @Override
-    public String file() {
-      return lineNumberTable.getFile();
-    }
-
-    @Override
-    public LineAndColumn getLineAndColumn() {
-      return lineNumberTable.getLineAndColumn(startOffset);
-    }
-
-    // For Node.getEndLocation. This is a temporary measure.
-    Location getEndLocation() {
-      // The end offset is the location *past* the actual end position --> subtract 1:
-      // TODO(adonovan): use half-open intervals again. CL 170723732 was a mistake.
-      int endOffset = this.endOffset - 1;
-      if (endOffset < 0) {
-        endOffset = 0;
-      }
-      LineAndColumn linecol = lineNumberTable.getLineAndColumn(endOffset);
-      return Location.fromFileLineColumn(file(), linecol.line, linecol.column);
-    }
-  }
-
-  /** invariant: symbol positions are half-open intervals. */
   private void setToken(TokenKind kind, int left, int right) {
-    Preconditions.checkState(token.kind == null);
-    token.kind = kind;
-    token.left = left;
-    token.right = right;
-    token.value = null;
+    this.kind = kind;
+    this.left = left;
+    this.right = right;
+    this.value = null;
+    this.raw = null;
   }
 
-  private void setToken(TokenKind kind, int left, int right, Object value) {
-    Preconditions.checkState(token.kind == null);
-    token.kind = kind;
-    token.left = left;
-    token.right = right;
-    token.value = value;
+  // setValue sets the value associated with a STRING, FLOAT, INT,
+  // IDENTIFIER, or COMMENT token, and records the raw text of the token.
+  private void setValue(Object value) {
+    this.value = value;
+    this.raw = bufferSlice(left, right);
   }
 
   /**
@@ -240,7 +186,7 @@
       } else if (c == '\t') {
         indentLen++;
         pos++;
-        error("Tab characters are not allowed for indentation. Use spaces instead.");
+        error("Tab characters are not allowed for indentation. Use spaces instead.", pos);
       } else if (c == '\n') { // entirely blank line: discard
         indentLen = 0;
         pos++;
@@ -249,7 +195,7 @@
         while (pos < buffer.length && c != '\n') {
           c = buffer[pos++];
         }
-        makeComment(oldPos, pos - 1, bufferSlice(oldPos, pos - 1));
+        addComment(oldPos, pos - 1);
         indentLen = 0;
       } else { // printing character
         break;
@@ -273,7 +219,7 @@
       }
 
       if (peekedIndent < indentLen) {
-        error("indentation error");
+        error("indentation error", pos - 1);
       }
     }
   }
@@ -313,14 +259,16 @@
             literal.append(c);
             break;
           } else {
-            error("unterminated string literal at eol", literalStartPos, pos);
-            setToken(TokenKind.STRING, literalStartPos, pos, literal.toString());
+            error("unterminated string literal at eol", literalStartPos);
+            setToken(TokenKind.STRING, literalStartPos, pos);
+            setValue(literal.toString());
             return;
           }
         case '\\':
           if (pos == buffer.length) {
-            error("unterminated string literal at eof", literalStartPos, pos);
-            setToken(TokenKind.STRING, literalStartPos, pos, literal.toString());
+            error("unterminated string literal at eof", literalStartPos);
+            setToken(TokenKind.STRING, literalStartPos, pos);
+            setValue(literal.toString());
             return;
           }
           if (isRaw) {
@@ -395,7 +343,7 @@
                   }
                 }
                 if (octal > 0xff) {
-                  error("octal escape sequence out of range (maximum is \\377)");
+                  error("octal escape sequence out of range (maximum is \\377)", pos - 1);
                 }
                 literal.append((char) (octal & 0xff));
                 break;
@@ -409,7 +357,7 @@
             case 'v':
             case 'x':
               // exists in Python but not implemented in Blaze => error
-              error("invalid escape sequence: \\" + c, literalStartPos, pos);
+              error("invalid escape sequence: \\" + c, pos - 1);
               break;
             default:
               // unknown char escape => "\literal"
@@ -419,8 +367,7 @@
                         + c
                         + ". You can enable unknown escape sequences by passing the flag"
                         + " --incompatible_restrict_string_escapes=false",
-                    pos - 1,
-                    pos);
+                    pos - 1);
               }
               literal.append('\\');
               literal.append(c);
@@ -434,7 +381,8 @@
             literal.append(c);
           } else {
             // Matching close-delimiter, all done.
-            setToken(TokenKind.STRING, literalStartPos, pos, literal.toString());
+            setToken(TokenKind.STRING, literalStartPos, pos);
+            setValue(literal.toString());
             return;
           }
           break;
@@ -443,8 +391,9 @@
           break;
       }
     }
-    error("unterminated string literal at eof", literalStartPos, pos);
-    setToken(TokenKind.STRING, literalStartPos, pos, literal.toString());
+    error("unterminated string literal at eof", literalStartPos);
+    setToken(TokenKind.STRING, literalStartPos, pos);
+    setValue(literal.toString());
   }
 
   /**
@@ -474,8 +423,9 @@
       char c = buffer[pos++];
       switch (c) {
         case '\n':
-          error("unterminated string literal at eol", literalStartPos, pos);
-          setToken(TokenKind.STRING, literalStartPos, pos, bufferSlice(contentStartPos, pos - 1));
+          error("unterminated string literal at eol", literalStartPos);
+          setToken(TokenKind.STRING, literalStartPos, pos);
+          setValue(bufferSlice(contentStartPos, pos - 1));
           return;
         case '\\':
           if (isRaw) {
@@ -498,8 +448,8 @@
         case '"':
           if (c == quot) {
             // close-quote, all done.
-            setToken(
-                TokenKind.STRING, literalStartPos, pos, bufferSlice(contentStartPos, pos - 1));
+            setToken(TokenKind.STRING, literalStartPos, pos);
+            setValue(bufferSlice(contentStartPos, pos - 1));
             return;
           }
           break;
@@ -513,8 +463,9 @@
       pos = buffer.length;
     }
 
-    error("unterminated string literal at eof", literalStartPos, pos);
-    setToken(TokenKind.STRING, literalStartPos, pos, bufferSlice(contentStartPos, pos));
+    error("unterminated string literal at eof", literalStartPos);
+    setToken(TokenKind.STRING, literalStartPos, pos);
+    setValue(bufferSlice(contentStartPos, pos));
   }
 
   private static final Map<String, TokenKind> keywordMap = new HashMap<>();
@@ -566,13 +517,16 @@
     String id = scanIdentifier();
     TokenKind kind = keywordMap.get(id);
     if (kind == null) {
-      setToken(TokenKind.IDENTIFIER, oldPos, pos, id);
+      setToken(TokenKind.IDENTIFIER, oldPos, pos);
+      setValue(id);
     } else {
-      setToken(kind, oldPos, pos, null);
+      setToken(kind, oldPos, pos);
     }
   }
 
   private String scanIdentifier() {
+    // Keep consistent with Identifier.isValid.
+    // TODO(laurentlb): Handle Unicode letters.
     int oldPos = pos - 1;
     while (pos < buffer.length) {
       switch (buffer[pos]) {
@@ -629,10 +583,6 @@
           break loop;
       }
     }
-    // TODO(bazel-team): (2009) to do roundtripping when we evaluate the integer
-    // constants, we must save the actual text of the tokens, not just their
-    // integer value.
-
     return bufferSlice(oldPos, pos);
   }
 
@@ -657,7 +607,7 @@
     } else if (literal.startsWith("0") && literal.length() > 1) {
       radix = 8;
       substring = literal.substring(1);
-      error("invalid octal value `" + literal + "`, should be: `0o" + substring + "`");
+      error("invalid octal value `" + literal + "`, should be: `0o" + substring + "`", oldPos);
     } else {
       radix = 10;
       substring = literal;
@@ -667,10 +617,11 @@
     try {
       value = Integer.parseInt(substring, radix);
     } catch (NumberFormatException e) {
-      error("invalid base-" + radix + " integer constant: " + literal);
+      error("invalid base-" + radix + " integer constant: " + literal, oldPos);
     }
 
-    setToken(TokenKind.INT, oldPos, pos, value);
+    setToken(TokenKind.INT, oldPos, pos);
+    setValue(value);
   }
 
   /**
@@ -724,6 +675,9 @@
       return;
     }
 
+    // TODO(adonovan): cleanup: replace break after setToken with return,
+    // and eliminate null-check of this.kind.
+    kind = null;
     while (pos < buffer.length) {
       if (tokenizeTwoChars()) {
         pos += 2;
@@ -841,7 +795,8 @@
           } else if (lookaheadIs(0, '\r') && lookaheadIs(1, '\n')) {
             pos += 2; // skip the CRLF at the end of line
           } else {
-            setToken(TokenKind.ILLEGAL, pos - 1, pos, Character.toString(c));
+            setToken(TokenKind.ILLEGAL, pos - 1, pos);
+            setValue(Character.toString(c));
           }
           break;
         case '\n':
@@ -857,7 +812,7 @@
               pos++;
             }
           }
-          makeComment(oldPos, pos, bufferSlice(oldPos, pos));
+          addComment(oldPos, pos);
           break;
         case '\'':
         case '\"':
@@ -877,11 +832,11 @@
           } else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_') {
             identifierOrKeyword();
           } else {
-            error("invalid character: '" + c + "'");
+            error("invalid character: '" + c + "'", pos - 1);
           }
           break;
       } // switch
-      if (token.kind != null) { // stop here if we scanned a token
+      if (kind != null) { // stop here if we scanned a token
         return;
       }
     } // while
@@ -905,11 +860,15 @@
    * @param end the offset immediately following the slice
    * @return the text at offset start with length end - start
    */
-  private String bufferSlice(int start, int end) {
+  String bufferSlice(int start, int end) {
     return new String(this.buffer, start, end - start);
   }
 
-  private void makeComment(int start, int end, String content) {
-    comments.add(Node.setLocation(createLocation(start, end), new Comment(content)));
+  // TODO(adonovan): don't retain comments unconditionally.
+  private void addComment(int start, int end) {
+    String content = bufferSlice(start, end);
+    Comment c = new Comment(start, content);
+    c.lnt = lnt;
+    comments.add(c);
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/LineNumberTable.java b/src/main/java/com/google/devtools/build/lib/syntax/LineNumberTable.java
index 1967437..9c89581 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/LineNumberTable.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/LineNumberTable.java
@@ -22,13 +22,14 @@
 import java.util.Objects;
 
 /**
- * A table to keep track of line numbers in source files. The client creates a LineNumberTable for
- * their buffer using {@link #create}. The client can then ask for the line and column given a
- * position using ({@link #getLineAndColumn(int)}).
+ * A LineNumberTable maps each UTF-16 index within a source file to its (file, line, column) triple.
+ * An offset is valid if {@code 0 <= offset <= size}.
  */
 @AutoCodec
 @Immutable
+// TODO(adonovan): rename to FileLocations.
 final class LineNumberTable {
+
   private static final Interner<LineNumberTable> LINE_NUMBER_TABLE_INTERNER =
       BlazeInterners.newWeakInterner();
 
@@ -36,21 +37,21 @@
   private final int[] linestart;
 
   private final String file;
-  private final int bufferLength;
+  private final int size; // size of file in chars
 
   private LineNumberTable(char[] buffer, String file) {
     this(computeLinestart(buffer), file, buffer.length);
   }
 
-  private LineNumberTable(int[] linestart, String file, int bufferLength) {
+  private LineNumberTable(int[] linestart, String file, int size) {
     this.linestart = linestart;
     this.file = file;
-    this.bufferLength = bufferLength;
+    this.size = size;
   }
 
   @AutoCodec.Instantiator
-  static LineNumberTable createForSerialization(int[] linestart, String file, int bufferLength) {
-    return LINE_NUMBER_TABLE_INTERNER.intern(new LineNumberTable(linestart, file, bufferLength));
+  static LineNumberTable createForSerialization(int[] linestart, String file, int size) {
+    return LINE_NUMBER_TABLE_INTERNER.intern(new LineNumberTable(linestart, file, size));
   }
 
   static LineNumberTable create(char[] buffer, String file) {
@@ -58,7 +59,7 @@
   }
 
   private int getLineAt(int offset) {
-    if (offset < 0) {
+    if (offset < 0 || offset > size) {
       throw new IllegalStateException("Illegal position: " + offset);
     }
     int lowBoundary = 1;
@@ -80,19 +81,19 @@
     }
   }
 
-  Location.LineAndColumn getLineAndColumn(int offset) {
+  Location getLocation(int offset) {
     int line = getLineAt(offset);
     int column = offset - linestart[line] + 1;
-    return new Location.LineAndColumn(line, column);
+    return new Location(file, line, column);
   }
 
-  String getFile() {
-    return file;
+  int size() {
+    return size;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(Arrays.hashCode(linestart), file, bufferLength);
+    return Objects.hash(Arrays.hashCode(linestart), file, size);
   }
 
   @Override
@@ -101,7 +102,7 @@
       return false;
     }
     LineNumberTable that = (LineNumberTable) other;
-    return this.bufferLength == that.bufferLength
+    return this.size == that.size
         && Arrays.equals(this.linestart, that.linestart)
         && this.file.equals(that.file);
   }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/ListExpression.java b/src/main/java/com/google/devtools/build/lib/syntax/ListExpression.java
index 9fd22ba..7a4ef85 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/ListExpression.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/ListExpression.java
@@ -13,18 +13,30 @@
 // limitations under the License.
 package com.google.devtools.build.lib.syntax;
 
+import com.google.common.base.Preconditions;
 import java.util.List;
 
 /** Syntax node for list and tuple expressions. */
 public final class ListExpression extends Expression {
 
   // TODO(adonovan): split class into {List,Tuple}Expression, as a tuple may have no parens.
-  private final boolean isTuple;
-  private final List<Expression> elements;
+  // Materialize all source-level expressions as a separate ParenExpression so that we can roundtrip
+  // faithfully.
 
-  ListExpression(boolean isTuple, List<Expression> elements) {
+  private final boolean isTuple;
+  private final int lbracketOffset; // -1 => unparenthesized non-empty tuple
+  private final List<Expression> elements;
+  private final int rbracketOffset; // -1 => unparenthesized non-empty tuple
+
+  ListExpression(
+      boolean isTuple, int lbracketOffset, List<Expression> elements, int rbracketOffset) {
+    // An unparenthesized tuple must be non-empty.
+    Preconditions.checkArgument(
+        !elements.isEmpty() || (lbracketOffset >= 0 && rbracketOffset >= 0));
+    this.lbracketOffset = lbracketOffset;
     this.isTuple = isTuple;
     this.elements = elements;
+    this.rbracketOffset = rbracketOffset;
   }
 
   public List<Expression> getElements() {
@@ -37,6 +49,19 @@
   }
 
   @Override
+  public int getStartOffset() {
+    return lbracketOffset < 0 ? elements.get(0).getStartOffset() : lbracketOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    // Unlike Python, trailing commas are not allowed in unparenthesized tuples.
+    return rbracketOffset < 0
+        ? elements.get(elements.size() - 1).getEndOffset()
+        : rbracketOffset + 1;
+  }
+
+  @Override
   public String toString() {
     // Print [a, b, c, ...] up to a maximum of 4 elements or 32 chars.
     StringBuilder buf = new StringBuilder();
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/LoadStatement.java b/src/main/java/com/google/devtools/build/lib/syntax/LoadStatement.java
index a73af95..765f923 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/LoadStatement.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/LoadStatement.java
@@ -41,19 +41,17 @@
     }
   }
 
-  private final ImmutableList<Binding> bindings;
+  private final int loadOffset;
   private final StringLiteral module;
+  private final ImmutableList<Binding> bindings;
+  private final int rparenOffset;
 
-  /**
-   * Constructs an import statement.
-   *
-   * <p>{@code bindings} maps a symbol to the original name under which it was defined in the bzl
-   * file that should be loaded. If aliasing is used, the value differs from its key's {@code
-   * symbol.getName()}. Otherwise, both values are identical.
-   */
-  LoadStatement(StringLiteral module, ImmutableList<Binding> bindings) {
+  LoadStatement(
+      int loadOffset, StringLiteral module, ImmutableList<Binding> bindings, int rparenOffset) {
+    this.loadOffset = loadOffset;
     this.module = module;
     this.bindings = bindings;
+    this.rparenOffset = rparenOffset;
   }
 
   public ImmutableList<Binding> getBindings() {
@@ -65,6 +63,16 @@
   }
 
   @Override
+  public int getStartOffset() {
+    return loadOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return rparenOffset + 1;
+  }
+
+  @Override
   public void accept(NodeVisitor visitor) {
     visitor.visit(this);
   }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Location.java b/src/main/java/com/google/devtools/build/lib/syntax/Location.java
index cf4ae62..b453f22 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/Location.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/Location.java
@@ -18,7 +18,6 @@
 import com.google.devtools.build.lib.concurrent.ThreadSafety.Immutable;
 import com.google.devtools.build.lib.skyframe.serialization.autocodec.AutoCodec;
 import java.io.Serializable;
-import java.util.Objects;
 
 /**
  * A Location denotes a position within a Starlark file.
@@ -29,31 +28,33 @@
  * If the line number is also zero, it too is not displayed; in this case, the location denotes the
  * file as a whole.
  */
-public abstract class Location implements Serializable, Comparable<Location> {
+@AutoCodec
+@Immutable
+public final class Location implements Serializable, Comparable<Location> {
 
-  // TODO(adonovan): merge with Lexer.LexerLocation and make this the only implementation of
-  // Location, once the parser no longer eagerly creates Locations but instead
-  // records token offsets and the LineNumberTable in the tree.
-  @AutoCodec
-  @Immutable
-  static class FileLineColumn extends Location {
-    final String file;
-    final LineAndColumn linecol;
+  private final String file;
+  private final int line;
+  private final int column;
 
-    FileLineColumn(String file, LineAndColumn linecol) {
-      this.file = Preconditions.checkNotNull(file);
-      this.linecol = linecol;
-    }
+  public Location(String file, int line, int column) {
+    this.file = Preconditions.checkNotNull(file);
+    this.line = line;
+    this.column = column;
+  }
 
-    @Override
-    public String file() {
-      return file;
-    }
+  /** Returns the name of the file containing this location. */
+  public String file() {
+    return file;
+  }
 
-    @Override
-    protected LineAndColumn getLineAndColumn() {
-      return linecol;
-    }
+  /** Returns the line number of this location. */
+  public int line() {
+    return line;
+  }
+
+  /** Returns the column number of this location. */
+  public int column() {
+    return column;
   }
 
   /**
@@ -62,29 +63,12 @@
    */
   public static Location fromFileLineColumn(String file, int line, int column) {
     Preconditions.checkArgument(line != 0 || column == 0, "non-zero column but no line number");
-    return new FileLineColumn(file, new LineAndColumn(line, column));
+    return new Location(file, line, column);
   }
 
   /** Returns a Location for the file as a whole. */
   public static Location fromFile(String file) {
-    return new FileLineColumn(file, ZERO);
-  }
-
-  private static final LineAndColumn ZERO = new LineAndColumn(0, 0);
-
-  /** Returns the name of the file containing this location. */
-  public abstract String file();
-
-  protected abstract LineAndColumn getLineAndColumn();
-
-  /** Returns the line number of this location. */
-  public final int line() {
-    return getLineAndColumn().line;
-  }
-
-  /** Returns the column number of this location. */
-  public final int column() {
-    return getLineAndColumn().column;
+    return new Location(file, 0, 0);
   }
 
   /**
@@ -94,54 +78,16 @@
   @Override
   public String toString() {
     StringBuilder buf = new StringBuilder();
-    buf.append(file());
-    LineAndColumn linecol = getLineAndColumn();
-    if (linecol.line != 0) {
-      buf.append(':').append(linecol.line);
-      if (linecol.column != 0) {
-        buf.append(':').append(linecol.column);
+    buf.append(file);
+    if (line != 0) {
+      buf.append(':').append(line);
+      if (column != 0) {
+        buf.append(':').append(column);
       }
     }
     return buf.toString();
   }
 
-  /** A value class that describes the line and column of a location. */
-  // TODO(adonovan): make private when we combine with LexerLocation.
-  @AutoCodec
-  @Immutable
-  public static final class LineAndColumn {
-
-    public final int line;
-    public final int column;
-
-    public LineAndColumn(int line, int column) {
-      this.line = line;
-      this.column = column;
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      if (o == this) {
-        return true;
-      }
-      if (!(o instanceof LineAndColumn)) {
-        return false;
-      }
-      LineAndColumn lac = (LineAndColumn) o;
-      return lac.line == line && lac.column == column;
-    }
-
-    @Override
-    public int hashCode() {
-      return line * 41 + column;
-    }
-
-    @Override
-    public String toString() {
-      return line + ":" + column;
-    }
-  }
-
   /** Returns a three-valued lexicographical comparison of two Locations. */
   @Override
   public final int compareTo(Location that) {
@@ -149,22 +95,22 @@
     if (cmp != 0) {
       return cmp;
     }
-    LineAndColumn x = this.getLineAndColumn();
-    LineAndColumn y = that.getLineAndColumn();
-    return Long.compare(((long) x.line << 32) | x.column, ((long) y.line << 32) | y.column);
+    return Long.compare(
+        ((long) this.line << 32) | this.column, ((long) that.line << 32) | that.column);
   }
 
   @Override
-  public final int hashCode() {
-    return Objects.hash(file(), getLineAndColumn());
+  public int hashCode() {
+    return 97 * file.hashCode() + 37 * line + column;
   }
 
   @Override
-  public final boolean equals(Object that) {
+  public boolean equals(Object that) {
     return this == that
-        || that instanceof Location
-            && this.file().equals(((Location) that).file())
-            && this.getLineAndColumn().equals(((Location) that).getLineAndColumn());
+        || (that instanceof Location
+            && this.file.equals(((Location) that).file)
+            && this.line == ((Location) that).line
+            && this.column == ((Location) that).column);
   }
 
   /** A location for built-in functions. */
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Node.java b/src/main/java/com/google/devtools/build/lib/syntax/Node.java
index f6b0e0a..b761590 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/Node.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/Node.java
@@ -14,8 +14,17 @@
 
 package com.google.devtools.build.lib.syntax;
 
-
-/** A Node is a node in a Starlark syntax tree. */
+/**
+ * A Node is a node in a Starlark syntax tree.
+ *
+ * <p>Nodes may be constructed only by the parser.
+ *
+ * <p>The syntax tree records the offsets within the file of all salient tokens, such as brackets
+ * that mark the beginning or end of an expression, or operators whose position may be needed in a
+ * run-time error message. The start and end offsets of each Node are computed inductively from
+ * their tokens and subexpressions. Offsets are converted to Locations on demand in methods such as
+ * {@link #getStartLocation}.
+ */
 public abstract class Node {
 
   // Use these typical node distributions in Bazel files
@@ -50,42 +59,31 @@
   //   1.0% Comprehension
   //   6  % all others
 
-  // TODO(adonovan): instead of creating Locations during parsing.
-  // record the LineNumberTable and the offsets of key tokens,
-  // then create Locations on demand for the node start and end
-  // and for key tokens.
-  private Lexer.LexerLocation location;
+  // The LNT holds the file name and a compressed mapping from
+  // token char offsets to Locations. It is shared by all nodes
+  // from the same file. For convenience of the parser, it is
+  // set immediately after construction.
+  protected LineNumberTable lnt;
 
   Node() {}
 
-  final void setLocation(Lexer.LexerLocation location) {
-    this.location = location;
-  }
+  /**
+   * Returns the node's start offset, as a char index (zero-based count of UTF-16 codes) from the
+   * start of the file.
+   */
+  public abstract int getStartOffset();
 
-  /** @return the same node with its location set, in a slightly more fluent style */
-  static <NodeT extends Node> NodeT setLocation(Lexer.LexerLocation location, NodeT node) {
-    node.setLocation(location);
-    return node;
-  }
-
-  /** Returns the location of the start of this node. */
+  /** Returns the location of the start of this syntax node. */
   public final Location getStartLocation() {
-    return location;
+    return lnt.getLocation(getStartOffset());
   }
 
-  /** Returns the char offset of the start of this node within its file. */
-  public final int getStartOffset() {
-    return location.startOffset;
-  }
+  /** Returns the char offset of the source position immediately after this syntax node. */
+  public abstract int getEndOffset();
 
-  /** Returns the char offset of the end of this node within its file. */
-  public final int getEndOffset() {
-    return location.endOffset;
-  }
-
-  /** Returns the Location of the end of this node. */
+  /** Returns the location of the source position immediately after this syntax node. */
   public final Location getEndLocation() {
-    return location.getEndLocation();
+    return lnt.getLocation(getEndOffset());
   }
 
   /**
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/NodePrinter.java b/src/main/java/com/google/devtools/build/lib/syntax/NodePrinter.java
index cf0b068..934d3b2 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/NodePrinter.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/NodePrinter.java
@@ -56,7 +56,7 @@
       // it on a single line.
       printIndent();
       buf.append("# ");
-      buf.append(comment.getValue());
+      buf.append(comment.getText());
 
     } else if (n instanceof Argument) {
       printArgument((Argument) n);
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/NodeVisitor.java b/src/main/java/com/google/devtools/build/lib/syntax/NodeVisitor.java
index c0d7405..bdccb35 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/NodeVisitor.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/NodeVisitor.java
@@ -72,22 +72,28 @@
     visitAll(node.getArguments());
   }
 
-  public void visit(@SuppressWarnings("unused") Identifier node) {}
+  public void visit(Identifier node) {}
 
   public void visit(Comprehension node) {
     for (Comprehension.Clause clause : node.getClauses()) {
       if (clause instanceof Comprehension.For) {
-        Comprehension.For forClause = (Comprehension.For) clause;
-        visit(forClause.getVars());
-        visit(forClause.getIterable());
+        visit((Comprehension.For) clause);
       } else {
-        Comprehension.If ifClause = (Comprehension.If) clause;
-        visit(ifClause.getCondition());
+        visit((Comprehension.If) clause);
       }
     }
     visit(node.getBody());
   }
 
+  public void visit(Comprehension.For node) {
+    visit(node.getVars());
+    visit(node.getIterable());
+  }
+
+  public void visit(Comprehension.If node) {
+    visit(node.getCondition());
+  }
+
   public void visit(ForStatement node) {
     visit(node.getCollection());
     visit(node.getLHS());
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Parameter.java b/src/main/java/com/google/devtools/build/lib/syntax/Parameter.java
index 19cf096..21422d5 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/Parameter.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/Parameter.java
@@ -29,20 +29,20 @@
  */
 public abstract class Parameter extends Node {
 
-  @Nullable private final Identifier identifier;
+  @Nullable private final Identifier id;
 
-  private Parameter(@Nullable Identifier identifier) {
-    this.identifier = identifier;
+  private Parameter(@Nullable Identifier id) {
+    this.id = id;
   }
 
   @Nullable
   public String getName() {
-    return identifier != null ? identifier.getName() : null;
+    return id != null ? id.getName() : null;
   }
 
   @Nullable
   public Identifier getIdentifier() {
-    return identifier;
+    return id;
   }
 
   @Nullable
@@ -55,8 +55,18 @@
    * depending on its position.
    */
   public static final class Mandatory extends Parameter {
-    Mandatory(Identifier identifier) {
-      super(identifier);
+    Mandatory(Identifier id) {
+      super(id);
+    }
+
+    @Override
+    public int getStartOffset() {
+      return getIdentifier().getStartOffset();
+    }
+
+    @Override
+    public int getEndOffset() {
+      return getIdentifier().getEndOffset();
     }
   }
 
@@ -68,8 +78,8 @@
 
     public final Expression defaultValue;
 
-    Optional(Identifier identifier, @Nullable Expression defaultValue) {
-      super(identifier);
+    Optional(Identifier id, @Nullable Expression defaultValue) {
+      super(id);
       this.defaultValue = defaultValue;
     }
 
@@ -80,22 +90,58 @@
     }
 
     @Override
+    public int getStartOffset() {
+      return getIdentifier().getStartOffset();
+    }
+
+    @Override
+    public int getEndOffset() {
+      return getDefaultValue().getEndOffset();
+    }
+
+    @Override
     public String toString() {
       return getName() + "=" + defaultValue;
     }
   }
 
-  /** Syntax node for a star parameter, {@code f(*identifier)} or or {@code f(..., *, ...)}. */
+  /** Syntax node for a star parameter, {@code f(*id)} or or {@code f(..., *, ...)}. */
   public static final class Star extends Parameter {
-    Star(@Nullable Identifier identifier) {
-      super(identifier);
+    private final int starOffset;
+
+    Star(int starOffset, @Nullable Identifier id) {
+      super(id);
+      this.starOffset = starOffset;
+    }
+
+    @Override
+    public int getStartOffset() {
+      return starOffset;
+    }
+
+    @Override
+    public int getEndOffset() {
+      return getIdentifier().getEndOffset();
     }
   }
 
-  /** Syntax node for a parameter of the form {@code f(**identifier)}. */
+  /** Syntax node for a parameter of the form {@code f(**id)}. */
   public static final class StarStar extends Parameter {
-    StarStar(Identifier identifier) {
-      super(identifier);
+    private final int starStarOffset;
+
+    StarStar(int starStarOffset, Identifier id) {
+      super(id);
+      this.starStarOffset = starStarOffset;
+    }
+
+    @Override
+    public int getStartOffset() {
+      return starStarOffset;
+    }
+
+    @Override
+    public int getEndOffset() {
+      return getIdentifier().getEndOffset();
     }
   }
 
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Parser.java b/src/main/java/com/google/devtools/build/lib/syntax/Parser.java
index 3ab5148..62e2e7e 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/Parser.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/Parser.java
@@ -14,11 +14,9 @@
 
 package com.google.devtools.build.lib.syntax;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
 import com.google.devtools.build.lib.profiler.Profiler;
 import com.google.devtools.build.lib.profiler.ProfilerTask;
 import com.google.devtools.build.lib.profiler.SilentCloseable;
@@ -29,39 +27,34 @@
 import java.util.Map;
 import javax.annotation.Nullable;
 
-/**
- * Recursive descent parser for LL(2) BUILD language. Loosely based on Python 2 grammar. See
- * https://docs.python.org/2/reference/grammar.html
- */
-// TODO(adonovan): break syntax->event.Event dependency.
-@VisibleForTesting
+/** Parser is a recursive-descent parser for Starlark. */
 final class Parser {
 
   /** Combines the parser result into a single value object. */
   static final class ParseResult {
+    // Maps char offsets in the file to Locations.
+    final LineNumberTable lnt;
+
     /** The statements (rules, basically) from the parsed file. */
     final List<Statement> statements;
 
     /** The comments from the parsed file. */
     final List<Comment> comments;
 
-    /** Represents every statement in the file. */
-    final Lexer.LexerLocation location;
-
     // Errors encountered during scanning or parsing.
     // These lists are ultimately owned by StarlarkFile.
     final List<SyntaxError> errors;
 
     ParseResult(
+        LineNumberTable lnt,
         List<Statement> statements,
         List<Comment> comments,
-        Lexer.LexerLocation location,
         List<SyntaxError> errors) {
+      this.lnt = lnt;
       // No need to copy here; when the object is created, the parser instance is just about to go
       // out of scope and be garbage collected.
       this.statements = Preconditions.checkNotNull(statements);
       this.comments = Preconditions.checkNotNull(comments);
-      this.location = location;
       this.errors = errors;
     }
   }
@@ -99,7 +92,7 @@
           TokenKind.SLASH);
 
   /** Current lookahead token. May be mutated by the parser. */
-  private Token token;
+  private final Lexer token; // token.kind is a prettier alias for lexer.kind
 
   private static final boolean DEBUGGING = false;
 
@@ -156,6 +149,7 @@
   private Parser(Lexer lexer, List<SyntaxError> errors) {
     this.lexer = lexer;
     this.errors = errors;
+    this.token = lexer;
     nextToken();
   }
 
@@ -164,15 +158,11 @@
     return prev != null ? prev : s;
   }
 
-  private static Lexer.LexerLocation locationFromStatements(
-      Lexer lexer, List<Statement> statements) {
-    int start = 0;
-    int end = 0;
-    if (!statements.isEmpty()) {
-      start = statements.get(0).getStartOffset();
-      end = Iterables.getLast(statements).getEndOffset();
-    }
-    return lexer.createLocation(start, end);
+  // Returns a token's string form as used in error messages.
+  private static String tokenString(TokenKind kind, @Nullable Object value) {
+    return kind == TokenKind.STRING
+        ? "\"" + value + "\"" // TODO(adonovan): do proper quotation
+        : value == null ? kind.toString() : value.toString();
   }
 
   // Main entry point for parsing a file.
@@ -185,14 +175,13 @@
         Profiler.instance().profile(ProfilerTask.STARLARK_PARSER, input.getFile())) {
       statements = parser.parseFileInput();
     }
-    return new ParseResult(
-        statements, lexer.getComments(), locationFromStatements(lexer, statements), errors);
+    return new ParseResult(lexer.lnt, statements, lexer.getComments(), errors);
   }
 
-  // stmt ::= simple_stmt
-  //        | def_stmt
-  //        | for_stmt
-  //        | if_stmt
+  // stmt = simple_stmt
+  //      | def_stmt
+  //      | for_stmt
+  //      | if_stmt
   private void parseStatement(List<Statement> list) {
     if (token.kind == TokenKind.DEF) {
       list.add(parseDefStatement());
@@ -222,77 +211,68 @@
     return result;
   }
 
-  private Expression parseExpression() {
-    return parseExpression(false);
-  }
-
   // Equivalent to 'testlist' rule in Python grammar. It can parse every kind of
-  // expression. In many cases, we need to use parseNonTupleExpression to avoid ambiguity:
+  // expression. In many cases, we need to use parseTest to avoid ambiguity:
   //   e.g. fct(x, y)  vs  fct((x, y))
   //
-  // Tuples can have a trailing comma only when insideParens is true. This prevents bugs
-  // where a one-element tuple is surprisingly created:
-  //   e.g.  foo = f(x),
-  private Expression parseExpression(boolean insideParens) {
-    int start = token.left;
-    Expression expression = parseNonTupleExpression();
+  // A trailing comma is disallowed in an unparenthesized tuple.
+  // This prevents bugs where a one-element tuple is surprisingly created:
+  //   e.g. foo = f(x),
+  private Expression parseExpression() {
+    Expression e = parseTest();
     if (token.kind != TokenKind.COMMA) {
-      return expression;
+      return e;
     }
 
-    // It's a tuple
-    List<Expression> tuple = parseExprList(insideParens);
-    tuple.add(0, expression); // add the first expression to the front of the tuple
-    return setLocation(
-        new ListExpression(/*isTuple=*/ true, tuple), start, Iterables.getLast(tuple));
+    // unparenthesized tuple
+    List<Expression> elems = new ArrayList<>();
+    elems.add(e);
+    parseExprList(elems, /*trailingCommaAllowed=*/ false);
+    return setLNT(new ListExpression(/*isTuple=*/ true, -1, elems, -1));
   }
 
-  private void reportError(Location location, String message) {
+  private void reportError(int offset, String message) {
     errorsCount++;
     // Limit the number of reported errors to avoid spamming output.
     if (errorsCount <= 5) {
+      Location location = lexer.lnt.getLocation(offset);
       errors.add(new SyntaxError(location, message));
     }
   }
 
   private void syntaxError(String message) {
     if (!recoveryMode) {
-      String msg = token.kind == TokenKind.INDENT
-          ? "indentation error"
-          : "syntax error at '" + token + "': " + message;
-      reportError(lexer.createLocation(token.left, token.right), msg);
+      String msg =
+          token.kind == TokenKind.INDENT
+              ? "indentation error"
+              : "syntax error at '" + tokenString(token.kind, token.value) + "': " + message;
+      reportError(token.left, msg);
       recoveryMode = true;
     }
   }
 
-  /**
-   * Consumes the current token. If it is not of the specified (expected)
-   * kind, reports a syntax error.
-   */
-  private boolean expect(TokenKind kind) {
-    boolean expected = token.kind == kind;
-    if (!expected) {
+  // Consumes the current token and returns its position, like nextToken.
+  // Reports a syntax error if the new token is not of the expected kind.
+  private int expect(TokenKind kind) {
+    if (token.kind != kind) {
       syntaxError("expected " + kind);
     }
-    nextToken();
-    return expected;
+    return nextToken();
   }
 
-  /**
-   * Same as expect, but stop the recovery mode if the token was expected.
-   */
-  private void expectAndRecover(TokenKind kind) {
-    if (expect(kind)) {
+  // Like expect, but stops recovery mode if the token was expected.
+  private int expectAndRecover(TokenKind kind) {
+    if (token.kind != kind) {
+      syntaxError("expected " + kind);
+    } else {
       recoveryMode = false;
     }
+    return nextToken();
   }
 
-  /**
-   * Consume tokens past the first token that has a kind that is in the set of
-   * terminatingTokens.
-   * @param terminatingTokens
-   * @return the end offset of the terminating token.
-   */
+  // Consumes tokens past the first token belonging to terminatingTokens.
+  // It returns the end offset of the terminating token.
+  // TODO(adonovan): always used with makeErrorExpression. Combine and simplify.
   private int syncPast(EnumSet<TokenKind> terminatingTokens) {
     Preconditions.checkState(terminatingTokens.contains(TokenKind.EOF));
     while (!terminatingTokens.contains(token.kind)) {
@@ -366,119 +346,121 @@
         error = "keyword '" + token.kind + "' not supported";
         break;
     }
-    reportError(lexer.createLocation(token.left, token.right), error);
+    reportError(token.left, error);
   }
 
-  private void nextToken() {
-    if (token == null || token.kind != TokenKind.EOF) {
-      token = lexer.nextToken();
+  private int nextToken() {
+    int prev = token.left;
+    if (token.kind != TokenKind.EOF) {
+      lexer.nextToken();
     }
     checkForbiddenKeywords();
+    // TODO(adonovan): move this to lexer so we see the first token too.
     if (DEBUGGING) {
-      System.err.print(token);
+      System.err.print(tokenString(token.kind, token.value));
     }
+    return prev;
   }
 
+  // Returns an "Identifier" whose content is the input from start to end.
   private Identifier makeErrorExpression(int start, int end) {
     // It's tempting to define a dedicated BadExpression type,
     // but it is convenient for parseIdent to return an Identifier
     // even when it fails.
-    return setLocation(Identifier.of("$error$"), start, end);
+    return setLNT(new Identifier(lexer.bufferSlice(start, end), start));
   }
 
-  // Convenience wrapper method around Node.setLocation
-  private <NodeT extends Node> NodeT setLocation(NodeT node, int startOffset, int endOffset) {
-    return Node.setLocation(lexer.createLocation(startOffset, endOffset), node);
+  // setLNT sets the LineNumberTable associated with a newly created Node.
+  // TODO(adonovan): experiment with making this an explicit parameter
+  // to all Node subclass constructors.
+  private <N extends Node> N setLNT(N n) {
+    n.lnt = lexer.lnt;
+    return n;
   }
 
-  // Convenience method that uses end offset from the last node.
-  private <NodeT extends Node> NodeT setLocation(NodeT node, int startOffset, Node lastNode) {
-    Preconditions.checkNotNull(lastNode, "can't extract end offset from a null node");
-    return setLocation(node, startOffset, lastNode.getEndOffset());
-  }
-
-  // arg ::= IDENTIFIER '=' nontupleexpr
-  //       | expr
-  //       | *args
-  //       | **kwargs
+  // arg = IDENTIFIER '=' test
+  //     | expr
+  //     | *args
+  //     | **kwargs
   private Argument parseArgument() {
-    final int start = token.left;
     Expression expr;
+
     // parse **expr
     if (token.kind == TokenKind.STAR_STAR) {
-      nextToken();
-      expr = parseNonTupleExpression();
-      return setLocation(new Argument.StarStar(expr), start, expr);
-    }
-    // parse *expr
-    if (token.kind == TokenKind.STAR) {
-      nextToken();
-      expr = parseNonTupleExpression();
-      return setLocation(new Argument.Star(expr), start, expr);
+      int starStarOffset = nextToken();
+      expr = parseTest();
+      return setLNT(new Argument.StarStar(starStarOffset, expr));
     }
 
-    expr = parseNonTupleExpression();
+    // parse *expr
+    if (token.kind == TokenKind.STAR) {
+      int starOffset = nextToken();
+      expr = parseTest();
+      return setLNT(new Argument.Star(starOffset, expr));
+    }
+
+    // IDENTIFIER  or  IDENTIFIER = test
+    expr = parseTest();
     if (expr instanceof Identifier) {
+      Identifier id = (Identifier) expr;
       // parse a named argument
       if (token.kind == TokenKind.EQUALS) {
         nextToken();
-        Expression val = parseNonTupleExpression();
-        return setLocation(new Argument.Keyword(((Identifier) expr), val), start, val);
+        Expression arg = parseTest();
+        return setLNT(new Argument.Keyword(id, arg));
       }
     }
 
     // parse a positional argument
-    return setLocation(new Argument.Positional(expr), start, expr);
+    return setLNT(new Argument.Positional(expr));
   }
 
-  // arg ::= IDENTIFIER '=' nontupleexpr
-  //       | IDENTIFIER
+  // arg = IDENTIFIER '=' test
+  //     | IDENTIFIER
   private Parameter parseFunctionParameter() {
-    int start = token.left;
-    if (token.kind == TokenKind.STAR_STAR) { // kwarg
-      nextToken();
-      Identifier ident = parseIdent();
-      return setLocation(new Parameter.StarStar(ident), start, ident);
-    } else if (token.kind == TokenKind.STAR) { // stararg
-      int end = token.right;
-      nextToken();
-      if (token.kind == TokenKind.IDENTIFIER) {
-        Identifier ident = parseIdent();
-        return setLocation(new Parameter.Star(ident), start, ident);
-      } else {
-        return setLocation(new Parameter.Star(null), start, end);
-      }
-    } else {
-      Identifier ident = parseIdent();
-      if (token.kind == TokenKind.EQUALS) { // there's a default value
-        nextToken();
-        Expression expr = parseNonTupleExpression();
-        return setLocation(new Parameter.Optional(ident, expr), start, expr);
-      } else {
-        return setLocation(new Parameter.Mandatory(ident), start, ident);
-      }
+    // **kwargs
+    if (token.kind == TokenKind.STAR_STAR) {
+      int starStarOffset = nextToken();
+      Identifier id = parseIdent();
+      return setLNT(new Parameter.StarStar(starStarOffset, id));
     }
+
+    // * or *args
+    if (token.kind == TokenKind.STAR) {
+      int starOffset = nextToken();
+      if (token.kind == TokenKind.IDENTIFIER) {
+        Identifier id = parseIdent();
+        return setLNT(new Parameter.Star(starOffset, id));
+      }
+      return setLNT(new Parameter.Star(starOffset, null));
+    }
+
+    // name=default
+    Identifier id = parseIdent();
+    if (token.kind == TokenKind.EQUALS) {
+      nextToken(); // TODO: save token pos?
+      Expression expr = parseTest();
+      return setLNT(new Parameter.Optional(id, expr));
+    }
+
+    // name
+    return setLNT(new Parameter.Mandatory(id));
   }
 
-  // call_suffix ::= '(' arg_list? ')'
-  private Expression parseCallSuffix(int start, Expression function) {
+  // call_suffix = '(' arg_list? ')'
+  private Expression parseCallSuffix(Expression fn) {
     ImmutableList<Argument> args = ImmutableList.of();
-    expect(TokenKind.LPAREN);
-    int end;
-    if (token.kind == TokenKind.RPAREN) {
-      end = token.right;
-      nextToken(); // RPAREN
-    } else {
+    int lparenOffset = expect(TokenKind.LPAREN);
+    if (token.kind != TokenKind.RPAREN) {
       args = parseArguments(); // (includes optional trailing comma)
-      end = token.right;
-      expect(TokenKind.RPAREN);
     }
-    return setLocation(new CallExpression(function, args), start, end);
+    int rparenOffset = expect(TokenKind.RPAREN);
+    return setLNT(new CallExpression(fn, lexer.lnt.getLocation(lparenOffset), args, rparenOffset));
   }
 
   // Parse a list of call arguments.
   //
-  // arg_list ::= ( (arg ',')* arg ','? )?
+  // arg_list = ( (arg ',')* arg ','? )?
   private ImmutableList<Argument> parseArguments() {
     boolean hasArgs = false;
     boolean hasStarStar = false;
@@ -494,9 +476,7 @@
       }
       if (hasStarStar) {
         // TODO(adonovan): move this to validation pass too.
-        reportError(
-            lexer.createLocation(token.left, token.right),
-            "unexpected tokens after **kwargs argument");
+        reportError(token.left, "unexpected tokens after **kwargs argument");
         break;
       }
       Argument arg = parseArgument();
@@ -538,66 +518,64 @@
     }
 
     Argument arg = arguments.get(i);
-    Location loc = arg.getStartLocation();
     if (arg instanceof Argument.Positional) {
-      reportError(loc, "positional argument is misplaced (positional arguments come first)");
+      reportError(
+          arg.getStartOffset(),
+          "positional argument is misplaced (positional arguments come first)");
       return;
     }
 
     if (arg instanceof Argument.Keyword) {
       reportError(
-          loc,
+          arg.getStartOffset(),
           "keyword argument is misplaced (keyword arguments must be before any *arg or **kwarg)");
       return;
     }
 
     if (i < len && arg instanceof Argument.Star) {
-      reportError(loc, "*arg argument is misplaced");
+      reportError(arg.getStartOffset(), "*arg argument is misplaced");
       return;
     }
 
     if (i < len && arg instanceof Argument.StarStar) {
-      reportError(loc, "**kwarg argument is misplaced (there can be only one)");
+      reportError(arg.getStartOffset(), "**kwarg argument is misplaced (there can be only one)");
       return;
     }
   }
 
-  // selector_suffix ::= '.' IDENTIFIER
-  private Expression parseSelectorSuffix(int start, Expression receiver) {
-    expect(TokenKind.DOT);
+  // selector_suffix = '.' IDENTIFIER
+  private Expression parseSelectorSuffix(Expression e) {
+    int dotOffset = expect(TokenKind.DOT);
     if (token.kind == TokenKind.IDENTIFIER) {
-      Identifier ident = parseIdent();
-      return setLocation(new DotExpression(receiver, ident), start, ident);
-    } else {
-      syntaxError("expected identifier after dot");
-      int end = syncTo(EXPR_TERMINATOR_SET);
-      return makeErrorExpression(start, end);
+      Identifier id = parseIdent();
+      return setLNT(new DotExpression(e, dotOffset, id));
     }
+
+    syntaxError("expected identifier after dot");
+    syncTo(EXPR_TERMINATOR_SET);
+    return e;
   }
 
   // expr_list parses a comma-separated list of expression. It assumes that the
   // first expression was already parsed, so it starts with a comma.
   // It is used to parse tuples and list elements.
-  // expr_list ::= ( ',' expr )* ','?
-  private List<Expression> parseExprList(boolean trailingColonAllowed) {
-    List<Expression> list = new ArrayList<>();
+  //
+  // expr_list = ( ',' expr )* ','?
+  private void parseExprList(List<Expression> list, boolean trailingCommaAllowed) {
     //  terminating tokens for an expression list
     while (token.kind == TokenKind.COMMA) {
       expect(TokenKind.COMMA);
       if (EXPR_LIST_TERMINATOR_SET.contains(token.kind)) {
-        if (!trailingColonAllowed) {
-          reportError(
-              lexer.createLocation(token.left, token.right),
-              "Trailing comma is allowed only in parenthesized tuples.");
+        if (!trailingCommaAllowed) {
+          reportError(token.left, "Trailing comma is allowed only in parenthesized tuples.");
         }
         break;
       }
-      list.add(parseNonTupleExpression());
+      list.add(parseTest());
     }
-    return list;
   }
 
-  // dict_entry_list ::= ( (dict_entry ',')* dict_entry ','? )?
+  // dict_entry_list = ( (dict_entry ',')* dict_entry ','? )?
   private List<DictExpression.Entry> parseDictEntryList() {
     List<DictExpression.Entry> list = new ArrayList<>();
     // the terminating token for a dict entry list
@@ -612,101 +590,103 @@
     return list;
   }
 
-  // dict_entry ::= nontupleexpr ':' nontupleexpr
+  // dict_entry = test ':' test
   private DictExpression.Entry parseDictEntry() {
-    int start = token.left;
-    Expression key = parseNonTupleExpression();
-    expect(TokenKind.COLON);
-    Expression value = parseNonTupleExpression();
-    return setLocation(new DictExpression.Entry(key, value), start, value);
+    Expression key = parseTest();
+    int colonOffset = expect(TokenKind.COLON);
+    Expression value = parseTest();
+    return setLNT(new DictExpression.Entry(key, colonOffset, value));
   }
 
-  /**
-   * Parse a String literal value, e.g. "str".
-   */
+  // expr = STRING
   private StringLiteral parseStringLiteral() {
     Preconditions.checkState(token.kind == TokenKind.STRING);
-    int end = token.right;
     StringLiteral literal =
-        setLocation(new StringLiteral(intern((String) token.value)), token.left, end);
+        setLNT(new StringLiteral(token.left, intern((String) token.value), token.right));
     nextToken();
     if (token.kind == TokenKind.STRING) {
-      reportError(lexer.createLocation(end, token.left),
-          "Implicit string concatenation is forbidden, use the + operator");
+      reportError(token.left, "Implicit string concatenation is forbidden, use the + operator");
     }
     return literal;
   }
 
-  //  primary ::= INTEGER
-  //            | STRING
-  //            | IDENTIFIER
-  //            | list_expression
-  //            | '(' ')'                    // a tuple with zero elements
-  //            | '(' expr ')'               // a parenthesized expression
-  //            | dict_expression
-  //            | '-' primary_with_suffix
+  //  primary = INTEGER
+  //          | STRING
+  //          | IDENTIFIER
+  //          | list_expression
+  //          | '(' ')'                    // a tuple with zero elements
+  //          | '(' expr ')'               // a parenthesized expression
+  //          | dict_expression
+  //          | '-' primary_with_suffix
   private Expression parsePrimary() {
-    int start = token.left;
     switch (token.kind) {
       case INT:
         {
-          IntegerLiteral literal = new IntegerLiteral((Integer) token.value);
-          setLocation(literal, start, token.right);
+          IntegerLiteral literal =
+              setLNT(new IntegerLiteral(token.raw, token.left, (Integer) token.value));
           nextToken();
           return literal;
         }
+
       case STRING:
         return parseStringLiteral();
+
       case IDENTIFIER:
         return parseIdent();
-      case LBRACKET: // it's a list
+
+      case LBRACKET: // [...]
         return parseListMaker();
-      case LBRACE: // it's a dictionary
+
+      case LBRACE: // {...}
         return parseDictExpression();
+
       case LPAREN:
         {
-          nextToken();
-          // check for the empty tuple literal
+          int lparenOffset = nextToken();
+
+          // empty tuple: ()
           if (token.kind == TokenKind.RPAREN) {
-            ListExpression tuple = new ListExpression(/*isTuple=*/ true, ImmutableList.of());
-            setLocation(tuple, start, token.right);
-            nextToken();
-            return tuple;
+            int rparen = nextToken();
+            return setLNT(
+                new ListExpression(/*isTuple=*/ true, lparenOffset, ImmutableList.of(), rparen));
           }
-          // parse the first expression
-          Expression expression = parseExpression(true);
-          setLocation(expression, start, token.right);
+
+          Expression e = parseTest();
+
+          // parenthesized expression: (e)
+          // TODO(adonovan): materialize paren expressions (for fidelity).
           if (token.kind == TokenKind.RPAREN) {
             nextToken();
-            return expression;
+            return e;
           }
+
+          // non-empty tuple: (e,) or (e, ..., e)
+          if (token.kind == TokenKind.COMMA) {
+            List<Expression> elems = new ArrayList<>();
+            elems.add(e);
+            parseExprList(elems, /*trailingCommaAllowed=*/ true);
+            int rparenOffset = expect(TokenKind.RPAREN);
+            return setLNT(new ListExpression(/*isTuple=*/ true, lparenOffset, elems, rparenOffset));
+          }
+
           expect(TokenKind.RPAREN);
           int end = syncTo(EXPR_TERMINATOR_SET);
-          return makeErrorExpression(start, end);
+          return makeErrorExpression(lparenOffset, end);
         }
+
       case MINUS:
-        {
-          nextToken();
-          Expression expr = parsePrimaryWithSuffix();
-          UnaryOperatorExpression minus = new UnaryOperatorExpression(TokenKind.MINUS, expr);
-          return setLocation(minus, start, expr);
-        }
       case PLUS:
-        {
-          nextToken();
-          Expression expr = parsePrimaryWithSuffix();
-          UnaryOperatorExpression plus = new UnaryOperatorExpression(TokenKind.PLUS, expr);
-          return setLocation(plus, start, expr);
-        }
       case TILDE:
         {
-          nextToken();
-          Expression expr = parsePrimaryWithSuffix();
-          UnaryOperatorExpression tilde = new UnaryOperatorExpression(TokenKind.TILDE, expr);
-          return setLocation(tilde, start, expr);
+          TokenKind op = token.kind;
+          int offset = nextToken();
+          Expression x = parsePrimaryWithSuffix();
+          return setLNT(new UnaryOperatorExpression(op, offset, x));
         }
+
       default:
         {
+          int start = token.left;
           syntaxError("expected expression");
           int end = syncTo(EXPR_TERMINATOR_SET);
           return makeErrorExpression(start, end);
@@ -714,203 +694,186 @@
     }
   }
 
-  // primary_with_suffix ::= primary (selector_suffix | substring_suffix | call_suffix)*
+  // primary_with_suffix = primary (selector_suffix | slice_suffix | call_suffix)*
   private Expression parsePrimaryWithSuffix() {
-    int start = token.left;
-    Expression receiver = parsePrimary();
+    Expression e = parsePrimary();
     while (true) {
       if (token.kind == TokenKind.DOT) {
-        receiver = parseSelectorSuffix(start, receiver);
+        e = parseSelectorSuffix(e);
       } else if (token.kind == TokenKind.LBRACKET) {
-        receiver = parseSubstringSuffix(start, receiver);
+        e = parseSliceSuffix(e);
       } else if (token.kind == TokenKind.LPAREN) {
-        receiver = parseCallSuffix(start, receiver);
+        e = parseCallSuffix(e);
       } else {
-        break;
+        return e;
       }
     }
-    return receiver;
   }
 
-  // substring_suffix ::= '[' expression? ':' expression?  ':' expression? ']'
-  //                    | '[' expression? ':' expression? ']'
-  //                    | '[' expression ']'
-  private Expression parseSubstringSuffix(int start, Expression receiver) {
-    Expression startExpr;
+  // slice_suffix = '[' expr? ':' expr?  ':' expr? ']'
+  //              | '[' expr? ':' expr? ']'
+  //              | '[' expr ']'
+  private Expression parseSliceSuffix(Expression e) {
+    int lbracketOffset = expect(TokenKind.LBRACKET);
+    Expression start = null;
+    Expression end = null;
+    Expression step = null;
 
-    expect(TokenKind.LBRACKET);
-    if (token.kind == TokenKind.COLON) {
-      startExpr = null;
-    } else {
-      startExpr = parseExpression();
-    }
-    // This is an index/key access
-    if (token.kind == TokenKind.RBRACKET) {
-      Expression expr = setLocation(new IndexExpression(receiver, startExpr), start, token.right);
-      expect(TokenKind.RBRACKET);
-      return expr;
-    }
-    // This is a slice (or substring)
-    Expression endExpr = parseSliceArgument();
-    Expression stepExpr = parseSliceArgument();
-    Expression expr =
-        setLocation(
-            new SliceExpression(receiver, startExpr, endExpr, stepExpr), start, token.right);
-    expect(TokenKind.RBRACKET);
-    return expr;
-  }
+    if (token.kind != TokenKind.COLON) {
+      start = parseExpression();
 
-  /**
-   * Parses {@code [':' [expr]]} which can either be the end or the step argument of a slice
-   * operation. If no such expression is found, this method returns null.
-   */
-  private @Nullable Expression parseSliceArgument() {
-    // There has to be a colon before any end or slice argument.
-    // However, if the next token thereafter is another colon or a right bracket, no argument value
-    // was specified.
+      // index x[i]
+      if (token.kind == TokenKind.RBRACKET) {
+        int rbracketOffset = expect(TokenKind.RBRACKET);
+        return setLNT(new IndexExpression(e, lbracketOffset, start, rbracketOffset));
+      }
+    }
+
+    // slice or substring x[i:j] or x[i:j:k]
+    expect(TokenKind.COLON);
+    if (token.kind != TokenKind.COLON && token.kind != TokenKind.RBRACKET) {
+      end = parseTest();
+    }
     if (token.kind == TokenKind.COLON) {
       expect(TokenKind.COLON);
-      if (token.kind != TokenKind.COLON && token.kind != TokenKind.RBRACKET) {
-        return parseNonTupleExpression();
+      if (token.kind != TokenKind.RBRACKET) {
+        step = parseTest();
       }
     }
-    return null;
+    int rbracketOffset = expect(TokenKind.RBRACKET);
+    return setLNT(new SliceExpression(e, lbracketOffset, start, end, step, rbracketOffset));
   }
 
   // Equivalent to 'exprlist' rule in Python grammar.
-  // loop_variables ::= primary_with_suffix ( ',' primary_with_suffix )* ','?
+  // loop_variables = primary_with_suffix ( ',' primary_with_suffix )* ','?
   private Expression parseForLoopVariables() {
     // We cannot reuse parseExpression because it would parse the 'in' operator.
     // e.g.  "for i in e: pass"  -> we want to parse only "i" here.
-    int start = token.left;
     Expression e1 = parsePrimaryWithSuffix();
     if (token.kind != TokenKind.COMMA) {
       return e1;
     }
 
-    // It's a tuple
-    List<Expression> tuple = new ArrayList<>();
-    tuple.add(e1);
+    // unparenthesized tuple
+    List<Expression> elems = new ArrayList<>();
+    elems.add(e1);
     while (token.kind == TokenKind.COMMA) {
       expect(TokenKind.COMMA);
       if (EXPR_LIST_TERMINATOR_SET.contains(token.kind)) {
         break;
       }
-      tuple.add(parsePrimaryWithSuffix());
+      elems.add(parsePrimaryWithSuffix());
     }
-    return setLocation(
-        new ListExpression(/*isTuple=*/ true, tuple), start, Iterables.getLast(tuple));
+    return setLNT(new ListExpression(/*isTuple=*/ true, -1, elems, -1));
   }
 
-  // comprehension_suffix ::= 'FOR' loop_variables 'IN' expr comprehension_suffix
-  //                        | 'IF' expr comprehension_suffix
-  //                        | ']' | '}'
-  private Expression parseComprehensionSuffix(Node body, TokenKind closingBracket, int offset) {
+  // comprehension_suffix = 'FOR' loop_variables 'IN' expr comprehension_suffix
+  //                      | 'IF' expr comprehension_suffix
+  //                      | ']' | '}'
+  private Expression parseComprehensionSuffix(int loffset, Node body, TokenKind closingBracket) {
     ImmutableList.Builder<Comprehension.Clause> clauses = ImmutableList.builder();
     while (true) {
       if (token.kind == TokenKind.FOR) {
-        nextToken();
+        int forOffset = nextToken();
         Expression vars = parseForLoopVariables();
         expect(TokenKind.IN);
         // The expression cannot be a ternary expression ('x if y else z') due to
         // conflicts in Python grammar ('if' is used by the comprehension).
-        Expression seq = parseNonTupleExpression(0);
-        clauses.add(new Comprehension.For(vars, seq));
+        Expression seq = parseTest(0);
+        clauses.add(setLNT(new Comprehension.For(forOffset, vars, seq)));
       } else if (token.kind == TokenKind.IF) {
-        nextToken();
+        int ifOffset = nextToken();
         // [x for x in li if 1, 2]  # parse error
         // [x for x in li if (1, 2)]  # ok
-        Expression cond = parseNonTupleExpression(0);
-        clauses.add(new Comprehension.If(cond));
+        Expression cond = parseTest(0);
+        clauses.add(setLNT(new Comprehension.If(ifOffset, cond)));
       } else if (token.kind == closingBracket) {
         break;
       } else {
         syntaxError("expected '" + closingBracket + "', 'for' or 'if'");
-        syncPast(LIST_TERMINATOR_SET);
-        return makeErrorExpression(offset, token.right);
+        int end = syncPast(LIST_TERMINATOR_SET);
+        return makeErrorExpression(loffset, end);
       }
     }
+
     boolean isDict = closingBracket == TokenKind.RBRACE;
-    Comprehension comp = new Comprehension(isDict, body, clauses.build());
-    setLocation(comp, offset, token.right);
-    nextToken();
-    return comp;
+    int roffset = expect(closingBracket);
+    return setLNT(new Comprehension(isDict, loffset, body, clauses.build(), roffset));
   }
 
-  // list_maker ::= '[' ']'
-  //               |'[' expr ']'
-  //               |'[' expr expr_list ']'
-  //               |'[' expr comprehension_suffix ']'
+  // list_maker = '[' ']'
+  //            | '[' expr ']'
+  //            | '[' expr expr_list ']'
+  //            | '[' expr comprehension_suffix ']'
   private Expression parseListMaker() {
-    int start = token.left;
-    expect(TokenKind.LBRACKET);
+    int lbracketOffset = expect(TokenKind.LBRACKET);
     if (token.kind == TokenKind.RBRACKET) { // empty List
-      ListExpression list = new ListExpression(/*isTuple=*/ false, ImmutableList.of());
-      setLocation(list, start, token.right);
-      nextToken();
-      return list;
+      int rbracketOffset = nextToken();
+      return setLNT(
+          new ListExpression(
+              /*isTuple=*/ false, lbracketOffset, ImmutableList.of(), rbracketOffset));
     }
-    Expression expression = parseNonTupleExpression();
-    Preconditions.checkNotNull(expression,
-        "null element in list in AST at %s:%s", token.left, token.right);
+
+    Expression expression = parseTest();
     switch (token.kind) {
-      case RBRACKET: // singleton list
+      case RBRACKET:
+        // [e], singleton list
         {
-          ListExpression list =
-              new ListExpression(/*isTuple=*/ false, ImmutableList.of(expression));
-          setLocation(list, start, token.right);
-          nextToken();
-          return list;
+          int rbracketOffset = nextToken();
+          return setLNT(
+              new ListExpression(
+                  /*isTuple=*/ false,
+                  lbracketOffset,
+                  ImmutableList.of(expression),
+                  rbracketOffset));
         }
+
       case FOR:
-        { // list comprehension
-          return parseComprehensionSuffix(expression, TokenKind.RBRACKET, start);
-        }
+        // [e for x in y], list comprehension
+        return parseComprehensionSuffix(lbracketOffset, expression, TokenKind.RBRACKET);
+
       case COMMA:
+        // [e, ...], list expression
         {
-          List<Expression> elems = parseExprList(true);
-          Preconditions.checkState(
-              !elems.contains(null),
-              "null element in list in AST at %s:%s",
-              token.left,
-              token.right);
-          elems.add(0, expression); // TODO(adonovan): opt: don't do this
+          List<Expression> elems = new ArrayList<>();
+          elems.add(expression);
+          parseExprList(elems, /*trailingCommaAllowed=*/ true);
           if (token.kind == TokenKind.RBRACKET) {
-            ListExpression list = new ListExpression(/*isTuple=*/ false, elems);
-            setLocation(list, start, token.right);
-            nextToken();
-            return list;
+            int rbracketOffset = nextToken();
+            return setLNT(
+                new ListExpression(/*isTuple=*/ false, lbracketOffset, elems, rbracketOffset));
           }
+
           expect(TokenKind.RBRACKET);
           int end = syncPast(LIST_TERMINATOR_SET);
-          return makeErrorExpression(start, end);
+          return makeErrorExpression(lbracketOffset, end);
         }
+
       default:
         {
           syntaxError("expected ',', 'for' or ']'");
           int end = syncPast(LIST_TERMINATOR_SET);
-          return makeErrorExpression(start, end);
+          return makeErrorExpression(lbracketOffset, end);
         }
     }
   }
 
-  // dict_expression ::= '{' '}'
-  //                    |'{' dict_entry_list '}'
-  //                    |'{' dict_entry comprehension_suffix '}'
+  // dict_expression = '{' '}'
+  //                 | '{' dict_entry_list '}'
+  //                 | '{' dict_entry comprehension_suffix '}'
   private Expression parseDictExpression() {
-    int start = token.left;
-    expect(TokenKind.LBRACE);
+    int lbraceOffset = expect(TokenKind.LBRACE);
     if (token.kind == TokenKind.RBRACE) { // empty Dict
-      DictExpression literal = new DictExpression(ImmutableList.of());
-      setLocation(literal, start, token.right);
-      nextToken();
-      return literal;
+      int rbraceOffset = nextToken();
+      return setLNT(new DictExpression(lbraceOffset, ImmutableList.of(), rbraceOffset));
     }
+
     DictExpression.Entry entry = parseDictEntry();
     if (token.kind == TokenKind.FOR) {
       // Dict comprehension
-      return parseComprehensionSuffix(entry, TokenKind.RBRACE, start);
+      return parseComprehensionSuffix(lbraceOffset, entry, TokenKind.RBRACE);
     }
+
     List<DictExpression.Entry> entries = new ArrayList<>();
     entries.add(entry);
     if (token.kind == TokenKind.COMMA) {
@@ -918,34 +881,33 @@
       entries.addAll(parseDictEntryList());
     }
     if (token.kind == TokenKind.RBRACE) {
-      DictExpression dict = new DictExpression(entries);
-      setLocation(dict, start, token.right);
-      nextToken();
-      return dict;
+      int rbraceOffset = nextToken();
+      return setLNT(new DictExpression(lbraceOffset, entries, rbraceOffset));
     }
+
     expect(TokenKind.RBRACE);
     int end = syncPast(DICT_TERMINATOR_SET);
-    return makeErrorExpression(start, end);
+    return makeErrorExpression(lbraceOffset, end);
   }
 
   private Identifier parseIdent() {
     if (token.kind != TokenKind.IDENTIFIER) {
-      expect(TokenKind.IDENTIFIER);
-      return makeErrorExpression(token.left, token.right);
+      int start = token.left;
+      int end = expect(TokenKind.IDENTIFIER);
+      return makeErrorExpression(start, end);
     }
-    Identifier ident = Identifier.of(((String) token.value));
-    setLocation(ident, token.left, token.right);
-    nextToken();
-    return ident;
+
+    String name = (String) token.value;
+    int offset = nextToken();
+    return setLNT(new Identifier(name, offset));
   }
 
-  // binop_expression ::= binop_expression OP binop_expression
-  //                    | parsePrimaryWithSuffix
+  // binop_expression = binop_expression OP binop_expression
+  //                  | parsePrimaryWithSuffix
   // This function takes care of precedence between operators (see operatorPrecedence for
   // the order), and it assumes left-to-right associativity.
   private Expression parseBinOpExpression(int prec) {
-    int start = token.left;
-    Expression expr = parseNonTupleExpression(prec + 1);
+    Expression x = parseTest(prec + 1);
     // The loop is not strictly needed, but it prevents risks of stack overflow. Depth is
     // limited to number of different precedence levels (operatorPrecedence.size()).
     TokenKind lastOp = null;
@@ -962,22 +924,21 @@
 
       TokenKind op = token.kind;
       if (!operatorPrecedence.get(prec).contains(op)) {
-        return expr;
+        return x;
       }
 
       // Operator '==' and other operators of the same precedence (e.g. '<', 'in')
       // are not associative.
       if (lastOp != null && operatorPrecedence.get(prec).contains(TokenKind.EQUALS_EQUALS)) {
         reportError(
-            lexer.createLocation(token.left, token.right),
+            token.left,
             String.format(
                 "Operator '%s' is not associative with operator '%s'. Use parens.", lastOp, op));
       }
 
-      nextToken();
-      Expression secondary = parseNonTupleExpression(prec + 1);
-      expr = optimizeBinOpExpression(expr, op, secondary);
-      setLocation(expr, start, secondary);
+      int opOffset = nextToken();
+      Expression y = parseTest(prec + 1);
+      x = optimizeBinOpExpression(x, op, opOffset, y);
       lastOp = op;
     }
   }
@@ -985,39 +946,38 @@
   // Optimize binary expressions.
   // string literal + string literal can be concatenated into one string literal
   // so we don't have to do the expensive string concatenation at runtime.
-  private Expression optimizeBinOpExpression(Expression x, TokenKind op, Expression y) {
-    if (op == TokenKind.PLUS) {
-      if (x instanceof StringLiteral && y instanceof StringLiteral) {
-        StringLiteral left = (StringLiteral) x;
-        StringLiteral right = (StringLiteral) y;
-        return new StringLiteral(intern(left.getValue() + right.getValue()));
-      }
+  private Expression optimizeBinOpExpression(
+      Expression x, TokenKind op, int opOffset, Expression y) {
+    if (op == TokenKind.PLUS && x instanceof StringLiteral && y instanceof StringLiteral) {
+      return setLNT(
+          new StringLiteral(
+              x.getStartOffset(),
+              intern(((StringLiteral) x).getValue() + ((StringLiteral) y).getValue()),
+              y.getEndOffset()));
     }
-    return new BinaryOperatorExpression(x, op, y);
+    return setLNT(new BinaryOperatorExpression(x, op, opOffset, y));
   }
 
-  // Equivalent to 'test' rule in Python grammar.
-  private Expression parseNonTupleExpression() {
+  // Parses a non-tuple expression ("test" in Python terminology).
+  private Expression parseTest() {
     int start = token.left;
-    Expression expr = parseNonTupleExpression(0);
+    Expression expr = parseTest(0);
     if (token.kind == TokenKind.IF) {
       nextToken();
-      Expression condition = parseNonTupleExpression(0);
+      Expression condition = parseTest(0);
       if (token.kind == TokenKind.ELSE) {
         nextToken();
-        Expression elseClause = parseNonTupleExpression();
-        return setLocation(new ConditionalExpression(expr, condition, elseClause),
-            start, elseClause);
+        Expression elseClause = parseTest();
+        return setLNT(new ConditionalExpression(expr, condition, elseClause));
       } else {
-        reportError(lexer.createLocation(start, token.left),
-            "missing else clause in conditional expression or semicolon before if");
+        reportError(start, "missing else clause in conditional expression or semicolon before if");
         return expr; // Try to recover from error: drop the if and the expression after it. Ouch.
       }
     }
     return expr;
   }
 
-  private Expression parseNonTupleExpression(int prec) {
+  private Expression parseTest(int prec) {
     if (prec >= operatorPrecedence.size()) {
       return parsePrimaryWithSuffix();
     }
@@ -1027,16 +987,14 @@
     return parseBinOpExpression(prec);
   }
 
-  // not_expr :== 'not' expr
+  // not_expr = 'not' expr
   private Expression parseNotExpression(int prec) {
-    int start = token.left;
-    expect(TokenKind.NOT);
-    Expression expression = parseNonTupleExpression(prec);
-    UnaryOperatorExpression not = new UnaryOperatorExpression(TokenKind.NOT, expression);
-    return setLocation(not, start, expression);
+    int notOffset = expect(TokenKind.NOT);
+    Expression x = parseTest(prec);
+    return setLNT(new UnaryOperatorExpression(TokenKind.NOT, notOffset, x));
   }
 
-  // file_input ::= ('\n' | stmt)* EOF
+  // file_input = ('\n' | stmt)* EOF
   private List<Statement> parseFileInput() {
     List<Statement> list =  new ArrayList<>();
     while (token.kind != TokenKind.EOF) {
@@ -1056,19 +1014,19 @@
 
   // load '(' STRING (COMMA [IDENTIFIER EQUALS] STRING)+ COMMA? ')'
   private Statement parseLoadStatement() {
-    int start = token.left;
-    expect(TokenKind.LOAD);
+    int loadOffset = expect(TokenKind.LOAD);
     expect(TokenKind.LPAREN);
     if (token.kind != TokenKind.STRING) {
-      StringLiteral module = setLocation(new StringLiteral(""), token.left, token.right);
+      // error: module is not a string literal.
+      StringLiteral module = setLNT(new StringLiteral(token.left, "", token.right));
       expect(TokenKind.STRING);
-      return setLocation(new LoadStatement(module, ImmutableList.of()), start, token.right);
+      return setLNT(new LoadStatement(loadOffset, module, ImmutableList.of(), token.right));
     }
 
-    StringLiteral importString = parseStringLiteral();
+    StringLiteral module = parseStringLiteral();
     if (token.kind == TokenKind.RPAREN) {
       syntaxError("expected at least one symbol to load");
-      return setLocation(new LoadStatement(importString, ImmutableList.of()), start, token.right);
+      return setLNT(new LoadStatement(loadOffset, module, ImmutableList.of(), token.right));
     }
     expect(TokenKind.COMMA);
 
@@ -1084,10 +1042,8 @@
       parseLoadSymbol(bindings);
     }
 
-    LoadStatement stmt =
-        setLocation(new LoadStatement(importString, bindings.build()), start, token.right);
-    expect(TokenKind.RPAREN);
-    return stmt;
+    int rparen = expect(TokenKind.RPAREN);
+    return setLNT(new LoadStatement(loadOffset, module, bindings.build(), rparen));
   }
 
   /**
@@ -1104,7 +1060,8 @@
     }
 
     String name = (String) token.value;
-    Identifier local = setLocation(Identifier.of(name), token.left, token.right);
+    int nameOffset = token.left + (token.kind == TokenKind.STRING ? 1 : 0);
+    Identifier local = setLNT(new Identifier(name, nameOffset));
 
     Identifier original;
     if (token.kind == TokenKind.STRING) {
@@ -1112,19 +1069,22 @@
       original = local;
     } else {
       // load(..., local = "orig")
+      // The name "orig" is morally an identifier but, for legacy reasons (specifically,
+      // a partial implementation of Starlark embedded in a Python interpreter used by
+      // tests of Blaze), it must be a quoted string literal.
       expect(TokenKind.IDENTIFIER);
       expect(TokenKind.EQUALS);
       if (token.kind != TokenKind.STRING) {
         syntaxError("expected string");
         return;
       }
-      original = setLocation(Identifier.of((String) token.value), token.left, token.right);
+      original = setLNT(new Identifier((String) token.value, token.left + 1));
     }
     nextToken();
     symbols.add(new LoadStatement.Binding(local, original));
   }
 
-  // simple_stmt ::= small_stmt (';' small_stmt)* ';'? NEWLINE
+  // simple_stmt = small_stmt (';' small_stmt)* ';'? NEWLINE
   private void parseSimpleStatement(List<Statement> list) {
     list.add(parseSmallStatement());
 
@@ -1138,25 +1098,32 @@
     expectAndRecover(TokenKind.NEWLINE);
   }
 
-  //     small_stmt ::= assign_stmt
-  //                  | expr
-  //                  | load_stmt
-  //                  | return_stmt
-  //                  | BREAK | CONTINUE | PASS
-  //     assign_stmt ::= expr ('=' | augassign) expr
-  //     augassign ::= '+=' | '-=' | '*=' | '/=' | '%=' | '//=' | '&=' | '|=' | '^=' |'<<=' | '>>='
+  //     small_stmt = assign_stmt
+  //                | expr
+  //                | load_stmt
+  //                | return_stmt
+  //                | BREAK | CONTINUE | PASS
+  //
+  //     assign_stmt = expr ('=' | augassign) expr
+  //
+  //     augassign = '+=' | '-=' | '*=' | '/=' | '%=' | '//=' | '&=' | '|=' | '^=' |'<<=' | '>>='
   private Statement parseSmallStatement() {
-    int start = token.left;
+    // return
     if (token.kind == TokenKind.RETURN) {
       return parseReturnStatement();
-    } else if (token.kind == TokenKind.BREAK
+    }
+
+    // control flow
+    if (token.kind == TokenKind.BREAK
         || token.kind == TokenKind.CONTINUE
         || token.kind == TokenKind.PASS) {
       TokenKind kind = token.kind;
-      int end = token.right;
-      expect(kind);
-      return setLocation(new FlowStatement(kind), start, end);
-    } else if (token.kind == TokenKind.LOAD) {
+      int offset = nextToken();
+      return setLNT(new FlowStatement(kind, offset));
+    }
+
+    // load
+    if (token.kind == TokenKind.LOAD) {
       return parseLoadStatement();
     }
 
@@ -1165,32 +1132,29 @@
     // lhs = rhs  or  lhs += rhs
     TokenKind op = augmentedAssignments.get(token.kind);
     if (token.kind == TokenKind.EQUALS || op != null) {
-      nextToken();
+      int opOffset = nextToken();
       Expression rhs = parseExpression();
-      // op == null for ordinary assignment.
-      return setLocation(new AssignmentStatement(lhs, op, rhs), start, rhs);
+      // op == null for ordinary assignment. TODO(adonovan): represent as EQUALS.
+      return setLNT(new AssignmentStatement(lhs, op, opOffset, rhs));
     } else {
-      return setLocation(new ExpressionStatement(lhs), start, lhs);
+      return setLNT(new ExpressionStatement(lhs));
     }
   }
 
-  // if_stmt ::= IF expr ':' suite [ELIF expr ':' suite]* [ELSE ':' suite]?
+  // if_stmt = IF expr ':' suite [ELIF expr ':' suite]* [ELSE ':' suite]?
   private IfStatement parseIfStatement() {
-    List<Integer> startOffsets = new ArrayList<>();
-    startOffsets.add(token.left);
-    expect(TokenKind.IF);
-    Expression cond = parseNonTupleExpression();
+    int ifOffset = expect(TokenKind.IF);
+    Expression cond = parseTest();
     expect(TokenKind.COLON);
     List<Statement> body = parseSuite();
-    IfStatement ifStmt = new IfStatement(TokenKind.IF, cond, body);
+    IfStatement ifStmt = setLNT(new IfStatement(TokenKind.IF, ifOffset, cond, body));
     IfStatement tail = ifStmt;
     while (token.kind == TokenKind.ELIF) {
-      startOffsets.add(token.left);
-      expect(TokenKind.ELIF);
-      cond = parseNonTupleExpression();
+      int elifOffset = expect(TokenKind.ELIF);
+      cond = parseTest();
       expect(TokenKind.COLON);
       body = parseSuite();
-      IfStatement elif = new IfStatement(TokenKind.ELIF, cond, body);
+      IfStatement elif = setLNT(new IfStatement(TokenKind.ELIF, elifOffset, cond, body));
       tail.setElseBlock(ImmutableList.of(elif));
       tail = elif;
     }
@@ -1200,41 +1164,23 @@
       body = parseSuite();
       tail.setElseBlock(body);
     }
-
-    // Because locations are allocated and stored redundantly rather
-    // than computed on demand from token offsets in the tree, we must
-    // wait till the end of the chain, after all setElseBlock calls,
-    // before setting the end location of each IfStatement.
-    // Body may be empty after a parse error.
-    int end = (body.isEmpty() ? tail.getCondition() : Iterables.getLast(body)).getEndOffset();
-    IfStatement s = ifStmt;
-    setLocation(s, startOffsets.get(0), end);
-    for (int i = 1; i < startOffsets.size(); i++) {
-      s = (IfStatement) s.elseBlock.get(0);
-      setLocation(s, startOffsets.get(i), end);
-    }
-
     return ifStmt;
   }
 
-  // for_stmt ::= FOR IDENTIFIER IN expr ':' suite
+  // for_stmt = FOR IDENTIFIER IN expr ':' suite
   private ForStatement parseForStatement() {
-    int start = token.left;
-    expect(TokenKind.FOR);
+    int forOffset = expect(TokenKind.FOR);
     Expression lhs = parseForLoopVariables();
     expect(TokenKind.IN);
     Expression collection = parseExpression();
     expect(TokenKind.COLON);
     List<Statement> block = parseSuite();
-    ForStatement stmt = new ForStatement(lhs, collection, block);
-    int end = block.isEmpty() ? token.left : Iterables.getLast(block).getEndOffset();
-    return setLocation(stmt, start, end);
+    return setLNT(new ForStatement(forOffset, lhs, collection, block));
   }
 
-  // def_stmt ::= DEF IDENTIFIER '(' arguments ')' ':' suite
+  // def_stmt = DEF IDENTIFIER '(' arguments ')' ':' suite
   private DefStatement parseDefStatement() {
-    int start = token.left;
-    expect(TokenKind.DEF);
+    int defOffset = expect(TokenKind.DEF);
     Identifier ident = parseIdent();
     expect(TokenKind.LPAREN);
     ImmutableList<Parameter> params = parseParameters();
@@ -1243,7 +1189,7 @@
     try {
       signature = FunctionSignature.fromParameters(params);
     } catch (FunctionSignature.SignatureException e) {
-      reportError(e.getParameter().getStartLocation(), e.getMessage());
+      reportError(e.getParameter().getStartOffset(), e.getMessage());
       // bogus empty signature
       signature = FunctionSignature.of();
     }
@@ -1251,9 +1197,7 @@
     expect(TokenKind.RPAREN);
     expect(TokenKind.COLON);
     ImmutableList<Statement> block = ImmutableList.copyOf(parseSuite());
-    DefStatement stmt = new DefStatement(ident, params, signature, block);
-    int end = block.isEmpty() ? token.left : Iterables.getLast(block).getEndOffset();
-    return setLocation(stmt, start, end);
+    return setLNT(new DefStatement(defOffset, ident, params, signature, block));
   }
 
   // Parse a list of function parameters.
@@ -1276,7 +1220,7 @@
       }
       if (hasStarStar) {
         // TODO(adonovan): move this to validation pass too.
-        reportError(lexer.createLocation(token.left, token.right), "unexpected tokens after kwarg");
+        reportError(token.left, "unexpected tokens after kwarg");
         break;
       }
 
@@ -1291,15 +1235,14 @@
   }
 
   // suite is typically what follows a colon (e.g. after def or for).
-  // suite ::= simple_stmt
-  //         | NEWLINE INDENT stmt+ OUTDENT
+  // suite = simple_stmt
+  //       | NEWLINE INDENT stmt+ OUTDENT
   private List<Statement> parseSuite() {
     List<Statement> list = new ArrayList<>();
     if (token.kind == TokenKind.NEWLINE) {
       expect(TokenKind.NEWLINE);
       if (token.kind != TokenKind.INDENT) {
-        reportError(lexer.createLocation(token.left, token.right),
-                    "expected an indented block");
+        reportError(token.left, "expected an indented block");
         return list;
       }
       expect(TokenKind.INDENT);
@@ -1313,18 +1256,14 @@
     return list;
   }
 
-  // return_stmt ::= RETURN [expr]
+  // return_stmt = RETURN [expr]
   private ReturnStatement parseReturnStatement() {
-    int start = token.left;
-    int end = token.right;
-    expect(TokenKind.RETURN);
+    int returnOffset = expect(TokenKind.RETURN);
 
-    Expression expression = null;
+    Expression result = null;
     if (!STATEMENT_TERMINATOR_SET.contains(token.kind)) {
-      expression = parseExpression();
-      end = expression.getEndOffset();
+      result = parseExpression();
     }
-    return setLocation(new ReturnStatement(expression), start, end);
+    return setLNT(new ReturnStatement(returnOffset, result));
   }
-
 }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/ReturnStatement.java b/src/main/java/com/google/devtools/build/lib/syntax/ReturnStatement.java
index 0f04b04..3ba54ae 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/ReturnStatement.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/ReturnStatement.java
@@ -13,20 +13,45 @@
 // limitations under the License.
 package com.google.devtools.build.lib.syntax;
 
+import com.google.common.base.Preconditions;
 import javax.annotation.Nullable;
 
-/** A wrapper Statement class for return expressions. */
+/** A syntax node for return statements. */
 public final class ReturnStatement extends Statement {
 
-  @Nullable private final Expression returnExpression;
+  private final int returnOffset;
+  @Nullable private final Expression result;
 
-  ReturnStatement(@Nullable Expression returnExpression) {
-    this.returnExpression = returnExpression;
+  ReturnStatement(int returnOffset, @Nullable Expression result) {
+    this.returnOffset = returnOffset;
+    this.result = result;
   }
 
+  /**
+   * Returns a new return statement that returns expr. It has a dummy file offset and line number
+   * table. It is provided only for use by the evaluator, and will be removed when it switches to a
+   * compiled representation.
+   */
+  public static ReturnStatement make(Expression expr) {
+    ReturnStatement stmt = new ReturnStatement(0, expr);
+    stmt.lnt = Preconditions.checkNotNull(expr.lnt);
+    return stmt;
+  }
+
+  // TODO(adonovan): rename to getResult.
   @Nullable
   public Expression getReturnExpression() {
-    return returnExpression;
+    return result;
+  }
+
+  @Override
+  public int getStartOffset() {
+    return returnOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return result != null ? result.getEndOffset() : returnOffset + "return".length();
   }
 
   @Override
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/SliceExpression.java b/src/main/java/com/google/devtools/build/lib/syntax/SliceExpression.java
index 7353e1b..d78a850 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/SliceExpression.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/SliceExpression.java
@@ -19,15 +19,25 @@
 public final class SliceExpression extends Expression {
 
   private final Expression object;
+  private final int lbracketOffset;
   @Nullable private final Expression start;
   @Nullable private final Expression stop;
   @Nullable private final Expression step;
+  private final int rbracketOffset;
 
-  SliceExpression(Expression object, Expression start, Expression stop, Expression step) {
+  SliceExpression(
+      Expression object,
+      int lbracketOffset,
+      Expression start,
+      Expression stop,
+      Expression step,
+      int rbracketOffset) {
     this.object = object;
+    this.lbracketOffset = lbracketOffset;
     this.start = start;
     this.stop = stop;
     this.step = step;
+    this.rbracketOffset = rbracketOffset;
   }
 
   public Expression getObject() {
@@ -50,6 +60,20 @@
   }
 
   @Override
+  public int getStartOffset() {
+    return object.getStartOffset();
+  }
+
+  @Override
+  public int getEndOffset() {
+    return rbracketOffset + 1;
+  }
+
+  public Location getLbracketLocation() {
+    return lnt.getLocation(lbracketOffset);
+  }
+
+  @Override
   public void accept(NodeVisitor visitor) {
     visitor.visit(this);
   }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/StarlarkFile.java b/src/main/java/com/google/devtools/build/lib/syntax/StarlarkFile.java
index b5d222d..237cd19 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/StarlarkFile.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/StarlarkFile.java
@@ -34,46 +34,59 @@
   final List<SyntaxError> errors; // appended to by ValidationEnvironment
   @Nullable private final String contentHashCode;
 
+  @Override
+  public int getStartOffset() {
+    return 0;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return lnt.size();
+  }
+
   private StarlarkFile(
       ImmutableList<Statement> statements,
       FileOptions options,
       ImmutableList<Comment> comments,
       List<SyntaxError> errors,
-      String contentHashCode,
-      Lexer.LexerLocation location) {
+      String contentHashCode) {
     this.statements = statements;
     this.options = options;
     this.comments = comments;
     this.errors = errors;
     this.contentHashCode = contentHashCode;
-    this.setLocation(location);
   }
 
   // Creates a StarlarkFile from the given effective list of statements,
   // which may include the prelude.
   private static StarlarkFile create(
+      LineNumberTable lnt,
       ImmutableList<Statement> statements,
       FileOptions options,
       Parser.ParseResult result,
       String contentHashCode) {
-    return new StarlarkFile(
-        statements,
-        options,
-        ImmutableList.copyOf(result.comments),
-        result.errors,
-        contentHashCode,
-        result.location);
+    StarlarkFile file =
+        new StarlarkFile(
+            statements,
+            options,
+            ImmutableList.copyOf(result.comments),
+            result.errors,
+            contentHashCode);
+    file.lnt = lnt;
+    return file;
   }
 
   /** Extract a subtree containing only statements from i (included) to j (excluded). */
   public StarlarkFile subTree(int i, int j) {
-    return new StarlarkFile(
-        this.statements.subList(i, j),
-        this.options,
-        /*comments=*/ ImmutableList.of(),
-        errors,
-        /*contentHashCode=*/ null,
-        (Lexer.LexerLocation) this.statements.get(i).getStartLocation());
+    StarlarkFile file =
+        new StarlarkFile(
+            this.statements.subList(i, j),
+            this.options,
+            /*comments=*/ ImmutableList.of(),
+            errors,
+            /*contentHashCode=*/ null);
+    file.lnt = this.lnt;
+    return file;
   }
 
   /**
@@ -121,7 +134,7 @@
     stmts.addAll(prelude);
     stmts.addAll(result.statements);
 
-    return create(stmts.build(), options, result, /*contentHashCode=*/ null);
+    return create(result.lnt, stmts.build(), options, result, /*contentHashCode=*/ null);
   }
 
   // TODO(adonovan): make the digest publicly settable, and delete this.
@@ -129,6 +142,7 @@
       throws IOException {
     Parser.ParseResult result = Parser.parseFile(input, options);
     return create(
+        result.lnt,
         ImmutableList.copyOf(result.statements),
         options,
         result,
@@ -152,7 +166,11 @@
   public static StarlarkFile parse(ParserInput input, FileOptions options) {
     Parser.ParseResult result = Parser.parseFile(input, options);
     return create(
-        ImmutableList.copyOf(result.statements), options, result, /*contentHashCode=*/ null);
+        result.lnt,
+        ImmutableList.copyOf(result.statements),
+        options,
+        result,
+        /*contentHashCode=*/ null);
   }
 
   /** Parse a Starlark file with default options. */
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/StringLiteral.java b/src/main/java/com/google/devtools/build/lib/syntax/StringLiteral.java
index d38f85d..8ca6f1c 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/StringLiteral.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/StringLiteral.java
@@ -24,10 +24,14 @@
 /** Syntax node for a string literal. */
 public final class StringLiteral extends Expression {
 
+  private final int startOffset;
   private final String value;
+  private final int endOffset;
 
-  StringLiteral(String value) {
+  StringLiteral(int startOffset, String value, int endOffset) {
+    this.startOffset = startOffset;
     this.value = value;
+    this.endOffset = endOffset;
   }
 
   /** Returns the value denoted by the string literal */
@@ -35,6 +39,24 @@
     return value;
   }
 
+  public Location getLocation() {
+    return lnt.getLocation(startOffset);
+  }
+
+  @Override
+  public int getStartOffset() {
+    return startOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    // TODO(adonovan): when we switch to compilation,
+    // making syntax trees ephemeral, we can afford to
+    // record the raw literal. This becomes:
+    //   return startOffset + raw.length().
+    return endOffset;
+  }
+
   @Override
   public void accept(NodeVisitor visitor) {
     visitor.visit(this);
@@ -52,26 +74,29 @@
     }
 
     @Override
-    public void serialize(
-        SerializationContext context, StringLiteral stringLiteral, CodedOutputStream codedOut)
+    public void serialize(SerializationContext context, StringLiteral lit, CodedOutputStream out)
         throws SerializationException, IOException {
       // The String instances referred to by StringLiterals are deduped by Parser, so therefore
       // memoization is guaranteed to be profitable.
       context.serializeWithAdHocMemoizationStrategy(
-          stringLiteral.getValue(), MemoizationStrategy.MEMOIZE_AFTER, codedOut);
-      context.serialize(stringLiteral.getStartLocation(), codedOut);
+          lit.getValue(), MemoizationStrategy.MEMOIZE_AFTER, out);
+      out.writeInt32NoTag(lit.startOffset);
+      out.writeInt32NoTag(lit.endOffset);
+      context.serializeWithAdHocMemoizationStrategy(
+          lit.lnt, MemoizationStrategy.MEMOIZE_AFTER, out);
     }
 
     @Override
-    public StringLiteral deserialize(DeserializationContext context, CodedInputStream codedIn)
+    public StringLiteral deserialize(DeserializationContext context, CodedInputStream in)
         throws SerializationException, IOException {
       String value =
-          context.deserializeWithAdHocMemoizationStrategy(
-              codedIn, MemoizationStrategy.MEMOIZE_AFTER);
-      Lexer.LexerLocation location = context.deserialize(codedIn);
-      StringLiteral stringLiteral = new StringLiteral(value);
-      stringLiteral.setLocation(location);
-      return stringLiteral;
+          context.deserializeWithAdHocMemoizationStrategy(in, MemoizationStrategy.MEMOIZE_AFTER);
+      int startOffset = in.readInt32();
+      int endOffset = in.readInt32();
+      StringLiteral lit = new StringLiteral(startOffset, value, endOffset);
+      lit.lnt =
+          context.deserializeWithAdHocMemoizationStrategy(in, MemoizationStrategy.MEMOIZE_AFTER);
+      return lit;
     }
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/Token.java b/src/main/java/com/google/devtools/build/lib/syntax/Token.java
deleted file mode 100644
index e836f992..0000000
--- a/src/main/java/com/google/devtools/build/lib/syntax/Token.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// 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 javax.annotation.Nullable;
-
-/**
- * A Token represents an actual lexeme; that is, a lexical unit, its location in
- * the input text, its lexical kind (TokenKind) and any associated value.
- */
-class Token {
-
-  TokenKind kind;
-  int left;
-  int right;
-  /**
-   * value is an Integer if the kind is INT.
-   * It is a String if the kind is STRING, IDENTIFIER, or COMMENT.
-   * It is null otherwise.
-   */
-  @Nullable Object value;
-
-  Token(TokenKind kind, int left, int right) {
-    this(kind, left, right, null);
-  }
-
-  Token(TokenKind kind, int left, int right, Object value) {
-    this.kind = kind;
-    this.left = left;
-    this.right = right;
-    this.value = value;
-  }
-
-  Token copy() {
-    return new Token(kind, left, right, value);
-  }
-
-  /**
-   * Constructs an easy-to-read string representation of token, suitable for use
-   * in user error messages.
-   */
-  @Override
-  public String toString() {
-    // TODO(bazel-team): do proper escaping of string literals
-    return kind == TokenKind.STRING
-        ? ("\"" + value + "\"")
-        : value == null ? kind.toString() : value.toString();
-  }
-
-}
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/TokenKind.java b/src/main/java/com/google/devtools/build/lib/syntax/TokenKind.java
index aca3523..e6fc450 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/TokenKind.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/TokenKind.java
@@ -14,9 +14,7 @@
 
 package com.google.devtools.build.lib.syntax;
 
-/**
- * A TokenKind is an enumeration of each different kind of lexical symbol.
- */
+/** A TokenKind represents the kind of a lexical token. */
 public enum TokenKind {
   AMPERSAND("&"),
   AMPERSAND_EQUALS("&="),
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/UnaryOperatorExpression.java b/src/main/java/com/google/devtools/build/lib/syntax/UnaryOperatorExpression.java
index 4a58e94..59d387a 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/UnaryOperatorExpression.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/UnaryOperatorExpression.java
@@ -18,10 +18,12 @@
 public final class UnaryOperatorExpression extends Expression {
 
   private final TokenKind op; // NOT, TILDE, MINUS or PLUS
+  private final int opOffset;
   private final Expression x;
 
-  UnaryOperatorExpression(TokenKind op, Expression x) {
+  UnaryOperatorExpression(TokenKind op, int opOffset, Expression x) {
     this.op = op;
+    this.opOffset = opOffset;
     this.x = x;
   }
 
@@ -30,6 +32,16 @@
     return op;
   }
 
+  @Override
+  public int getStartOffset() {
+    return opOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return x.getEndOffset();
+  }
+
   /** Returns the operand. */
   public Expression getX() {
     return x;
diff --git a/src/main/java/com/google/devtools/build/lib/syntax/ValidationEnvironment.java b/src/main/java/com/google/devtools/build/lib/syntax/ValidationEnvironment.java
index 5392647..8e42109 100644
--- a/src/main/java/com/google/devtools/build/lib/syntax/ValidationEnvironment.java
+++ b/src/main/java/com/google/devtools/build/lib/syntax/ValidationEnvironment.java
@@ -90,7 +90,7 @@
     String getUndeclaredNameError(String name);
   }
 
-  private static final Identifier PREDECLARED = new Identifier("");
+  private static final Identifier PREDECLARED = new Identifier("", 0);
 
   private static class Block {
     private final Map<String, Identifier> variables = new HashMap<>();
@@ -119,7 +119,13 @@
     }
   }
 
-  void addError(Location loc, String message) {
+  // Reports an error at the start of the specified node.
+  private void addError(Node node, String message) {
+    addError(node.getStartLocation(), message);
+  }
+
+  // Reports an error at the specified location.
+  private void addError(Location loc, String message) {
     errors.add(new SyntaxError(loc, message));
   }
 
@@ -162,9 +168,7 @@
           // Reject load('...', '_private').
           Identifier orig = b.getOriginalName();
           if (orig.isPrivate() && !options.allowLoadPrivateSymbols()) {
-            addError(
-                orig.getStartLocation(),
-                "symbol '" + orig.getName() + "' is private and cannot be imported.");
+            addError(orig, "symbol '" + orig.getName() + "' is private and cannot be imported.");
           }
 
           // The allowToplevelRebinding check is not applied to all files
@@ -172,7 +176,7 @@
           // and emit a better error message than the generic check.
           if (!names.add(b.getLocalName().getName())) {
             addError(
-                b.getLocalName().getStartLocation(),
+                b.getLocalName(),
                 String.format(
                     "load statement defines '%s' more than once", b.getLocalName().getName()));
           }
@@ -213,7 +217,7 @@
         assign(elem);
       }
     } else {
-      addError(lhs.getStartLocation(), "cannot assign to '" + lhs + "'");
+      addError(lhs, "cannot assign to '" + lhs + "'");
     }
   }
 
@@ -229,7 +233,7 @@
         // generic error
         error = createInvalidIdentifierException(node.getName(), getAllSymbols());
       }
-      addError(node.getStartLocation(), error);
+      addError(node, error);
       return;
     }
     if (options.recordScope()) {
@@ -238,7 +242,8 @@
   }
 
   private static String createInvalidIdentifierException(String name, Set<String> candidates) {
-    if (name.equals("$error$")) {
+    if (!Identifier.isValid(name)) {
+      // Identifier was created by Parser.makeErrorExpression and contains misparsed text.
       return "contains syntax error(s)";
     }
 
@@ -251,6 +256,7 @@
     return "name '" + name + "' is not defined" + suggestion;
   }
 
+  // TODO(adonovan): delete this. It's been long enough.
   static String getErrorForObsoleteThreadLocalVars(String name) {
     if (name.equals("PACKAGE_NAME")) {
       return "The value 'PACKAGE_NAME' has been removed in favor of 'package_name()', "
@@ -268,7 +274,7 @@
   @Override
   public void visit(ReturnStatement node) {
     if (block.scope != Scope.Local) {
-      addError(node.getStartLocation(), "return statements must be inside a function");
+      addError(node, "return statements must be inside a function");
     }
     super.visit(node);
   }
@@ -277,7 +283,7 @@
   public void visit(ForStatement node) {
     if (block.scope != Scope.Local) {
       addError(
-          node.getStartLocation(),
+          node,
           "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]");
     }
@@ -292,7 +298,7 @@
   @Override
   public void visit(LoadStatement node) {
     if (block.scope == Scope.Local) {
-      addError(node.getStartLocation(), "load statement not at top level");
+      addError(node, "load statement not at top level");
     }
     super.visit(node);
   }
@@ -300,7 +306,7 @@
   @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");
+      addError(node, node.getKind() + " statement must be inside a for loop");
     }
     super.visit(node);
   }
@@ -338,9 +344,7 @@
   @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.");
+      addError(node, "nested functions are not allowed. Move the function to the top level.");
     }
     for (Parameter param : node.getParameters()) {
       if (param instanceof Parameter.Optional) {
@@ -362,7 +366,7 @@
   public void visit(IfStatement node) {
     if (block.scope != Scope.Local) {
       addError(
-          node.getStartLocation(),
+          node,
           "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).");
     }
@@ -377,7 +381,7 @@
     // Other bad cases are handled in assign.
     if (node.isAugmented() && node.getLHS() instanceof ListExpression) {
       addError(
-          node.getStartLocation(),
+          node.getOperatorLocation(),
           "cannot perform augmented assignment on a list or tuple expression");
     }
 
@@ -391,14 +395,13 @@
     // Symbols defined in the module scope cannot be reassigned.
     if (prev != null && block.scope == Scope.Module && !options.allowToplevelRebinding()) {
       addError(
-          id.getStartLocation(),
+          id,
           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()));
+        addError(prev, String.format("'%s' previously declared here", id.getName()));
       }
     }
   }
@@ -424,7 +427,7 @@
 
   // Report an error if a load statement appears after another kind of statement.
   private void checkLoadAfterStatement(List<Statement> statements) {
-    Location firstStatement = null;
+    Statement firstStatement = null;
 
     for (Statement statement : statements) {
       // Ignore string literals (e.g. docstrings).
@@ -437,15 +440,12 @@
         if (firstStatement == null) {
           continue;
         }
-        addError(
-            statement.getStartLocation(),
-            "load() statements must be called before any other statement. "
-                + "First non-load() statement appears at "
-                + firstStatement);
+        addError(statement, "load statements must appear before any other statement");
+        addError(firstStatement, "\tfirst non-load statement appears here");
       }
 
       if (firstStatement == null) {
-        firstStatement = statement.getStartLocation();
+        firstStatement = statement;
       }
     }
   }
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/BuildViewTest.java b/src/test/java/com/google/devtools/build/lib/analysis/BuildViewTest.java
index 9df6b28..8758dc7 100644
--- a/src/test/java/com/google/devtools/build/lib/analysis/BuildViewTest.java
+++ b/src/test/java/com/google/devtools/build/lib/analysis/BuildViewTest.java
@@ -189,7 +189,7 @@
     scratch.file("foo/BUILD", "load(':rule.bzl', 'gen')", "gen(name = 'a')");
 
     update("//foo:a");
-    assertContainsEvent("DEBUG /workspace/foo/rule.bzl:3:3: f owner is //foo:a");
+    assertContainsEvent("DEBUG /workspace/foo/rule.bzl:3:8: f owner is //foo:a");
   }
 
   @Test
@@ -518,7 +518,7 @@
     reporter.setOutputFilter(RegexOutputFilter.forPattern(Pattern.compile("^//java/a")));
 
     update("//java/a:a");
-    assertContainsEvent("DEBUG /workspace/java/b/rules.bzl:2:3: debug in b");
+    assertContainsEvent("DEBUG /workspace/java/b/rules.bzl:2:8: debug in b");
   }
 
   @Test
@@ -1249,7 +1249,7 @@
 
     update("//foo");
     assertContainsEvent(
-        "WARNING /workspace/foo/BUILD:6:1: in deps attribute of custom_rule rule "
+        "WARNING /workspace/foo/BUILD:6:12: in deps attribute of custom_rule rule "
             + "//foo:foo: genrule rule '//foo:genlib' is unexpected here (expected java_library or "
             + "java_binary); continuing anyway");
   }
@@ -1312,7 +1312,7 @@
 
     update("//foo");
     assertContainsEvent(
-        "WARNING /workspace/foo/BUILD:6:1: in deps attribute of custom_rule rule "
+        "WARNING /workspace/foo/BUILD:6:12: in deps attribute of custom_rule rule "
             + "//foo:foo: genrule rule '//foo:genlib' is unexpected here; continuing anyway");
   }
 
@@ -1328,8 +1328,8 @@
         "print(existing_rule('bar'))");
     reporter.setOutputFilter(RegexOutputFilter.forPattern(Pattern.compile("^//pkg")));
     update("//pkg:foo");
-    assertContainsEvent("DEBUG /workspace/pkg/BUILD:5:1: genrule");
-    assertContainsEvent("DEBUG /workspace/pkg/BUILD:6:1: None");
+    assertContainsEvent("DEBUG /workspace/pkg/BUILD:5:6: genrule");
+    assertContainsEvent("DEBUG /workspace/pkg/BUILD:6:6: None");
   }
 
   @Test
@@ -1343,7 +1343,7 @@
         "print(existing_rules().keys())");
     reporter.setOutputFilter(RegexOutputFilter.forPattern(Pattern.compile("^//pkg")));
     update("//pkg:foo");
-    assertContainsEvent("DEBUG /workspace/pkg/BUILD:5:1: [\"foo\"]");
+    assertContainsEvent("DEBUG /workspace/pkg/BUILD:5:6: [\"foo\"]");
   }
 
   /** Runs the same test with trimmed configurations. */
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/CircularDependencyTest.java b/src/test/java/com/google/devtools/build/lib/analysis/CircularDependencyTest.java
index bb9fed6..77fdbda 100644
--- a/src/test/java/com/google/devtools/build/lib/analysis/CircularDependencyTest.java
+++ b/src/test/java/com/google/devtools/build/lib/analysis/CircularDependencyTest.java
@@ -98,7 +98,7 @@
       }
     }
     assertThat(foundEvent).isNotNull();
-    assertThat(foundEvent.getLocation().toString()).isEqualTo("/workspace/cycle/BUILD:3:1");
+    assertThat(foundEvent.getLocation().toString()).isEqualTo("/workspace/cycle/BUILD:3:14");
   }
 
   /**
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/RuleConfiguredTargetTest.java b/src/test/java/com/google/devtools/build/lib/analysis/RuleConfiguredTargetTest.java
index 8c72537..9c6714c 100644
--- a/src/test/java/com/google/devtools/build/lib/analysis/RuleConfiguredTargetTest.java
+++ b/src/test/java/com/google/devtools/build/lib/analysis/RuleConfiguredTargetTest.java
@@ -279,7 +279,7 @@
             "# blank line",
             "cc_library(name = 'x',",
             "           srcs = ['a.java'])");
-    assertThat(e.getLocation().toString()).isEqualTo("/workspace/x/BUILD:2:1");
+    assertThat(e.getLocation().toString()).isEqualTo("/workspace/x/BUILD:2:11");
   }
 
   @Test
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/constraints/ConstraintsTest.java b/src/test/java/com/google/devtools/build/lib/analysis/constraints/ConstraintsTest.java
index b0603ca..37cc534 100644
--- a/src/test/java/com/google/devtools/build/lib/analysis/constraints/ConstraintsTest.java
+++ b/src/test/java/com/google/devtools/build/lib/analysis/constraints/ConstraintsTest.java
@@ -1081,14 +1081,15 @@
     reporter.removeHandler(failFastHandler);
     // Invalid because "--define mode=a" refines :lib to "compatible_with = []" (empty).
     assertThat(getConfiguredTarget("//hello:lib")).isNull();
-    assertContainsEvent(""
-        + "//hello:lib: the current command line flags disqualify all supported environments "
-        + "because of incompatible select() paths:\n"
-        + " \n"
-        + "  environment: //buildenv/foo:b\n"
-        + "    removed by: //hello:lib (/workspace/hello/BUILD:1:1)\n"
-        + "    which has a select() that chooses dep: //deps:dep_a\n"
-        + "    which lacks: //buildenv/foo:b");
+    assertContainsEvent(
+        ""
+            + "//hello:lib: the current command line flags disqualify all supported environments "
+            + "because of incompatible select() paths:\n"
+            + " \n"
+            + "  environment: //buildenv/foo:b\n"
+            + "    removed by: //hello:lib (/workspace/hello/BUILD:1:11)\n"
+            + "    which has a select() that chooses dep: //deps:dep_a\n"
+            + "    which lacks: //buildenv/foo:b");
   }
 
   @Test
@@ -1136,14 +1137,14 @@
     reporter.removeHandler(failFastHandler);
     // Invalid because "--define mode=a" refines :lib to "compatible_with = ['//buildenv/foo:a']".
     assertThat(getConfiguredTarget("//hello:depender")).isNull();
-    assertContainsEvent(""
-        + "//hello:depender: the current command line flags disqualify all supported environments "
-        + "because of incompatible select() paths:\n"
-        + " \n"
-        + "  environment: //buildenv/foo:b\n"
-        + "    removed by: //hello:lib (/workspace/hello/BUILD:1:1)\n"
-        + "    which has a select() that chooses dep: //deps:dep_a\n"
-        + "    which lacks: //buildenv/foo:b");
+    assertContainsEvent(
+        "//hello:depender: the current command line flags disqualify all supported environments"
+            + " because of incompatible select() paths:\n"
+            + " \n"
+            + "  environment: //buildenv/foo:b\n"
+            + "    removed by: //hello:lib (/workspace/hello/BUILD:1:11)\n"
+            + "    which has a select() that chooses dep: //deps:dep_a\n"
+            + "    which lacks: //buildenv/foo:b");
   }
 
   @Test
@@ -1176,14 +1177,14 @@
     reporter.removeHandler(failFastHandler);
     // Invalid because "--define mode=a" refines :lib to "compatible_with = ['//buildenv/foo:a']".
     assertThat(getConfiguredTarget("//hello:depender")).isNull();
-    assertContainsEvent(""
-        + "//hello:depender: the current command line flags disqualify all supported environments "
-        + "because of incompatible select() paths:\n"
-        + " \n"
-        + "  environment: //buildenv/foo:b\n"
-        + "    removed by: //hello:lib2 (/workspace/hello/BUILD:1:1)\n"
-        + "    which has a select() that chooses dep: //deps:dep_a\n"
-        + "    which lacks: //buildenv/foo:b");
+    assertContainsEvent(
+        "//hello:depender: the current command line flags disqualify all supported environments"
+            + " because of incompatible select() paths:\n"
+            + " \n"
+            + "  environment: //buildenv/foo:b\n"
+            + "    removed by: //hello:lib2 (/workspace/hello/BUILD:1:11)\n"
+            + "    which has a select() that chooses dep: //deps:dep_a\n"
+            + "    which lacks: //buildenv/foo:b");
   }
 
   @Test
@@ -1203,14 +1204,15 @@
     // Invalid because :lib has an implicit default of ['//buildenv/foo:b'] and "--define mode=a"
     // refines it to "compatible_with = []" (empty).
     assertThat(getConfiguredTarget("//hello:lib")).isNull();
-    assertContainsEvent(""
-        + "//hello:lib: the current command line flags disqualify all supported environments "
-        + "because of incompatible select() paths:\n"
-        + " \n"
-        + "  environment: //buildenv/foo:b\n"
-        + "    removed by: //hello:lib (/workspace/hello/BUILD:1:1)\n"
-        + "    which has a select() that chooses dep: //deps:dep_a\n"
-        + "    which lacks: //buildenv/foo:b");
+    assertContainsEvent(
+        ""
+            + "//hello:lib: the current command line flags disqualify all supported environments "
+            + "because of incompatible select() paths:\n"
+            + " \n"
+            + "  environment: //buildenv/foo:b\n"
+            + "    removed by: //hello:lib (/workspace/hello/BUILD:1:11)\n"
+            + "    which has a select() that chooses dep: //deps:dep_a\n"
+            + "    which lacks: //buildenv/foo:b");
   }
 
   @Test
@@ -1240,14 +1242,15 @@
     // Invalid because while the //buildenv/foo refinement successfully refines :lib to
     // ['//buildenv/foo:a'], the bar refinement refines it to [].
     assertThat(getConfiguredTarget("//hello:lib")).isNull();
-    assertContainsEvent(""
-        + "//hello:lib: the current command line flags disqualify all supported environments "
-        + "because of incompatible select() paths:\n"
-        + " \n"
-        + "  environment: //buildenv/bar:c\n"
-        + "    removed by: //hello:lib (/workspace/hello/BUILD:1:1)\n"
-        + "    which has a select() that chooses dep: //deps:dep_a\n"
-        + "    which lacks: //buildenv/bar:c");
+    assertContainsEvent(
+        ""
+            + "//hello:lib: the current command line flags disqualify all supported environments "
+            + "because of incompatible select() paths:\n"
+            + " \n"
+            + "  environment: //buildenv/bar:c\n"
+            + "    removed by: //hello:lib (/workspace/hello/BUILD:1:11)\n"
+            + "    which has a select() that chooses dep: //deps:dep_a\n"
+            + "    which lacks: //buildenv/bar:c");
   }
 
   /**
@@ -1278,23 +1281,24 @@
     useConfiguration("--define", "mode=a");
     reporter.removeHandler(failFastHandler);
     assertThat(getConfiguredTarget("//hello:lib")).isNull();
-    assertContainsEvent(""
-        + "//hello:lib: the current command line flags disqualify all supported environments "
-        + "because of incompatible select() paths:\n"
-        + " \n"
-        + "environment group: //buildenv/foo:foo:\n"
-        + " \n"
-        + "  environment: //buildenv/foo:a\n"
-        + "    removed by: //hello:lib (/workspace/hello/BUILD:9:1)\n"
-        + "    which has a select() that chooses dep: //hello:all_groups_gone\n"
-        + "    which lacks: //buildenv/foo:a\n"
-        + " \n"
-        + "environment group: //buildenv/bar:bar:\n"
-        + " \n"
-        + "  environment: //buildenv/bar:c\n"
-        + "    removed by: //hello:lib (/workspace/hello/BUILD:9:1)\n"
-        + "    which has a select() that chooses dep: //hello:all_groups_gone\n"
-        + "    which lacks: //buildenv/bar:c");
+    assertContainsEvent(
+        ""
+            + "//hello:lib: the current command line flags disqualify all supported environments "
+            + "because of incompatible select() paths:\n"
+            + " \n"
+            + "environment group: //buildenv/foo:foo:\n"
+            + " \n"
+            + "  environment: //buildenv/foo:a\n"
+            + "    removed by: //hello:lib (/workspace/hello/BUILD:9:11)\n"
+            + "    which has a select() that chooses dep: //hello:all_groups_gone\n"
+            + "    which lacks: //buildenv/foo:a\n"
+            + " \n"
+            + "environment group: //buildenv/bar:bar:\n"
+            + " \n"
+            + "  environment: //buildenv/bar:c\n"
+            + "    removed by: //hello:lib (/workspace/hello/BUILD:9:11)\n"
+            + "    which has a select() that chooses dep: //hello:all_groups_gone\n"
+            + "    which lacks: //buildenv/bar:c");
   }
 
   private void writeRulesForRefiningSubsetTests(String topLevelRestrictedTo) throws Exception {
diff --git a/src/test/java/com/google/devtools/build/lib/buildtool/NoOutputActionTest.java b/src/test/java/com/google/devtools/build/lib/buildtool/NoOutputActionTest.java
index 30f7786..ca66e1b 100644
--- a/src/test/java/com/google/devtools/build/lib/buildtool/NoOutputActionTest.java
+++ b/src/test/java/com/google/devtools/build/lib/buildtool/NoOutputActionTest.java
@@ -46,7 +46,7 @@
         assertThrows(BuildFailedException.class, () -> buildTarget("//nooutput"));
     assertThat(e)
         .hasMessageThat()
-        .contains("nooutput/BUILD:1:1 not all outputs were created or valid");
+        .contains("nooutput/BUILD:1:8 not all outputs were created or valid");
     events.assertContainsError("declared output 'nooutput/out1' was not created by genrule");
     events.assertContainsError("declared output 'nooutput/out2' was not created by genrule");
   }
diff --git a/src/test/java/com/google/devtools/build/lib/packages/PackageFactoryTest.java b/src/test/java/com/google/devtools/build/lib/packages/PackageFactoryTest.java
index d3fc968..aecc48a 100644
--- a/src/test/java/com/google/devtools/build/lib/packages/PackageFactoryTest.java
+++ b/src/test/java/com/google/devtools/build/lib/packages/PackageFactoryTest.java
@@ -680,7 +680,7 @@
                     /*includes=*/ ImmutableList.of("W*", "subdir"),
                     /*excludes=*/ ImmutableList.<String>of(),
                     /* excludeDirs= */ true));
-    assertThat(e).hasMessageThat().isEqualTo("ERROR /globs/BUILD:2:73: incorrect glob result");
+    assertThat(e).hasMessageThat().isEqualTo("ERROR /globs/BUILD:2:77: incorrect glob result");
   }
 
   @Test
diff --git a/src/test/java/com/google/devtools/build/lib/query2/cquery/BuildOutputFormatterCallbackTest.java b/src/test/java/com/google/devtools/build/lib/query2/cquery/BuildOutputFormatterCallbackTest.java
index 674382f..85848e8 100644
--- a/src/test/java/com/google/devtools/build/lib/query2/cquery/BuildOutputFormatterCallbackTest.java
+++ b/src/test/java/com/google/devtools/build/lib/query2/cquery/BuildOutputFormatterCallbackTest.java
@@ -103,7 +103,7 @@
     getHelper().useConfiguration("--test_arg=cat");
     assertThat(getOutput("//test:my_rule"))
         .containsExactly(
-            "# /workspace/test/BUILD:1:1",
+            "# /workspace/test/BUILD:1:8",
             "my_rule(",
             "  name = \"my_rule\",",
             "  deps = [\"//test:lasagna.java\", \"//test:naps.java\"],",
@@ -113,7 +113,7 @@
     getHelper().useConfiguration("--test_arg=hound");
     assertThat(getOutput("//test:my_rule"))
         .containsExactly(
-            "# /workspace/test/BUILD:1:1",
+            "# /workspace/test/BUILD:1:8",
             "my_rule(",
             "  name = \"my_rule\",",
             "  deps = [\"//test:mondays.java\"],",
@@ -143,7 +143,7 @@
 
     assertThat(getOutput("//test:my_alias"))
         .containsExactly(
-            "# /workspace/test/BUILD:12:1",
+            "# /workspace/test/BUILD:12:6",
             "alias(",
             "  name = \"my_alias\",",
             "  actual = \"//test:my_rule\",",
@@ -178,7 +178,7 @@
     getHelper().useConfiguration("--test_arg=cat");
     assertThat(getOutput("//test:my_alias"))
         .containsExactly(
-            "# /workspace/test/BUILD:13:1",
+            "# /workspace/test/BUILD:13:6",
             "alias(",
             "  name = \"my_alias\",",
             "  actual = \"//test:my_first_rule\",",
@@ -188,7 +188,7 @@
     getHelper().useConfiguration("--test_arg=hound");
     assertThat(getOutput("//test:my_alias"))
         .containsExactly(
-            "# /workspace/test/BUILD:13:1",
+            "# /workspace/test/BUILD:13:6",
             "alias(",
             "  name = \"my_alias\",",
             "  actual = \"//test:my_second_rule\",",
@@ -235,7 +235,7 @@
     getHelper().useConfiguration("--test_arg=cat");
     assertThat(getOutput("//test:output.txt"))
         .containsExactly(
-            "# /workspace/test/BUILD:1:1",
+            "# /workspace/test/BUILD:1:8",
             "my_rule(",
             "  name = \"my_rule\",",
             "  deps = [\"//test:lasagna.java\", \"//test:naps.java\"],",
@@ -246,7 +246,7 @@
     getHelper().useConfiguration("--test_arg=hound");
     assertThat(getOutput("//test:output.txt"))
         .containsExactly(
-            "# /workspace/test/BUILD:1:1",
+            "# /workspace/test/BUILD:1:8",
             "my_rule(",
             "  name = \"my_rule\",",
             "  deps = [\"//test:mondays.java\"],",
diff --git a/src/test/java/com/google/devtools/build/lib/rules/config/ConfigSettingTest.java b/src/test/java/com/google/devtools/build/lib/rules/config/ConfigSettingTest.java
index 4e3f7d4..03a58f1 100644
--- a/src/test/java/com/google/devtools/build/lib/rules/config/ConfigSettingTest.java
+++ b/src/test/java/com/google/devtools/build/lib/rules/config/ConfigSettingTest.java
@@ -342,7 +342,7 @@
     checkError(
         "foo",
         "none",
-        "ERROR /workspace/foo/BUILD:1:1: //foo:none: "
+        "ERROR /workspace/foo/BUILD:1:15: //foo:none: "
             + "expected value of type 'string' for dict value element, but got None (NoneType)",
         "config_setting(",
         "    name = 'none',",
diff --git a/src/test/java/com/google/devtools/build/lib/rules/python/PyInfoTest.java b/src/test/java/com/google/devtools/build/lib/rules/python/PyInfoTest.java
index 824b0ea..33f8731 100644
--- a/src/test/java/com/google/devtools/build/lib/rules/python/PyInfoTest.java
+++ b/src/test/java/com/google/devtools/build/lib/rules/python/PyInfoTest.java
@@ -96,7 +96,7 @@
         "    has_py3_only_sources = True,",
         ")");
     PyInfo info = (PyInfo) lookup("info");
-    assertThat(info.getCreationLoc().toString()).isEqualTo(":1:8");
+    assertThat(info.getCreationLoc().toString()).isEqualTo(":1:14");
     assertHasOrderAndContainsExactly(
         info.getTransitiveSources().getSet(Artifact.class), Order.STABLE_ORDER, dummyArtifact);
     assertThat(info.getUsesSharedLibraries()).isTrue();
@@ -110,7 +110,7 @@
   public void starlarkConstructorDefaults() throws Exception {
     exec("info = PyInfo(transitive_sources = depset(direct=[dummy_file]))");
     PyInfo info = (PyInfo) lookup("info");
-    assertThat(info.getCreationLoc().toString()).isEqualTo(":1:8");
+    assertThat(info.getCreationLoc().toString()).isEqualTo(":1:14");
     assertHasOrderAndContainsExactly(
         info.getTransitiveSources().getSet(Artifact.class), Order.STABLE_ORDER, dummyArtifact);
     assertThat(info.getUsesSharedLibraries()).isFalse();
diff --git a/src/test/java/com/google/devtools/build/lib/rules/python/PyRuntimeInfoTest.java b/src/test/java/com/google/devtools/build/lib/rules/python/PyRuntimeInfoTest.java
index ecdd4c8..1964ee9 100644
--- a/src/test/java/com/google/devtools/build/lib/rules/python/PyRuntimeInfoTest.java
+++ b/src/test/java/com/google/devtools/build/lib/rules/python/PyRuntimeInfoTest.java
@@ -91,7 +91,7 @@
         "    python_version = 'PY2',",
         ")");
     PyRuntimeInfo info = (PyRuntimeInfo) lookup("info");
-    assertThat(info.getCreationLoc().toString()).isEqualTo(":1:8");
+    assertThat(info.getCreationLoc().toString()).isEqualTo(":1:21");
     assertThat(info.getInterpreterPath()).isNull();
     assertThat(info.getInterpreter()).isEqualTo(dummyInterpreter);
     assertHasOrderAndContainsExactly(info.getFiles(), Order.STABLE_ORDER, dummyFile);
@@ -106,7 +106,7 @@
         "    python_version = 'PY2',",
         ")");
     PyRuntimeInfo info = (PyRuntimeInfo) lookup("info");
-    assertThat(info.getCreationLoc().toString()).isEqualTo(":1:8");
+    assertThat(info.getCreationLoc().toString()).isEqualTo(":1:21");
     assertThat(info.getInterpreterPath()).isEqualTo(PathFragment.create("/system/interpreter"));
     assertThat(info.getInterpreter()).isNull();
     assertThat(info.getFiles()).isNull();
diff --git a/src/test/java/com/google/devtools/build/lib/skylark/SkylarkDefinedAspectsTest.java b/src/test/java/com/google/devtools/build/lib/skylark/SkylarkDefinedAspectsTest.java
index 069659e..73bddfa 100644
--- a/src/test/java/com/google/devtools/build/lib/skylark/SkylarkDefinedAspectsTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skylark/SkylarkDefinedAspectsTest.java
@@ -643,7 +643,7 @@
       // expected
     }
 
-    assertContainsEvent("ERROR /workspace/test/aspect.bzl:11:23");
+    assertContainsEvent("ERROR /workspace/test/aspect.bzl:11:38");
     assertContainsEvent("Aspects should be top-level values in extension files that define them.");
   }
 
@@ -680,7 +680,7 @@
       // expected
     }
 
-    assertContainsEvent("ERROR /workspace/test/rule.bzl:7:23");
+    assertContainsEvent("ERROR /workspace/test/rule.bzl:7:38");
     assertContainsEvent(
         "Providers should be top-level values in extension files that define them.");
   }
@@ -839,7 +839,7 @@
       // expect to fail.
     }
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:1:1: in "
+        "ERROR /workspace/test/BUILD:1:13: in "
             + "//test:aspect.bzl%MyAspect aspect on java_library rule //test:xxx: \n"
             + "Traceback (most recent call last):"
             + LINE_SEPARATOR
@@ -897,7 +897,7 @@
       // expect to fail.
     }
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:1:1: in "
+        "ERROR /workspace/test/BUILD:1:13: in "
             + "//test:aspect.bzl%MyAspect aspect on java_library rule //test:xxx: \n"
             + "\n"
             + "\n"
@@ -989,7 +989,7 @@
     } catch (ViewCreationFailedException e) {
       // expect to fail.
     }
-    assertContainsEvent("ERROR /workspace/test/BUILD:3:1: Output group duplicate provided twice");
+    assertContainsEvent("ERROR /workspace/test/BUILD:3:6: Output group duplicate provided twice");
   }
 
   @Test
@@ -1170,7 +1170,7 @@
     } catch (ViewCreationFailedException e) {
       // expected.
     }
-    assertContainsEvent("ERROR /workspace/test/BUILD:3:1: Output group a1_group provided twice");
+    assertContainsEvent("ERROR /workspace/test/BUILD:3:9: Output group a1_group provided twice");
   }
 
   private static Iterable<String> getOutputGroupContents(
@@ -1207,7 +1207,7 @@
     } catch (ViewCreationFailedException e) {
       // expect to fail.
     }
-    assertContainsEvent("ERROR /workspace/test/BUILD:3:1: Provider duplicate provided twice");
+    assertContainsEvent("ERROR /workspace/test/BUILD:3:6: Provider duplicate provided twice");
   }
 
   @Test
@@ -1383,7 +1383,7 @@
       // expect to fail.
     }
     assertContainsEvent(
-        "ERROR /workspace/test/aspect.bzl:5:22: "
+        "ERROR /workspace/test/aspect.bzl:5:28: "
             + "Aspect parameter attribute 'my_attr' has a bad default value: has to be one of 'a' "
             + "instead of 'b'");
   }
@@ -1406,7 +1406,9 @@
         "              'my_attr' : attr.string() },",
         ")");
     scratch.file(
-        "test/BUILD", "load('//test:aspect.bzl', 'my_rule')", "my_rule(name = 'xxx', my_attr='b')");
+        "test/BUILD", //
+        "load('//test:aspect.bzl', 'my_rule')",
+        "my_rule(name = 'xxx', my_attr='b')");
 
     reporter.removeHandler(failFastHandler);
     try {
@@ -1417,7 +1419,7 @@
       // expect to fail.
     }
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:2:1: //test:xxx: invalid value in 'my_attr' "
+        "ERROR /workspace/test/BUILD:2:8: //test:xxx: invalid value in 'my_attr' "
             + "attribute: has to be one of 'a' instead of 'b'");
   }
 
@@ -2242,7 +2244,7 @@
       // expected
     }
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:3:1: Aspect //test:aspect.bzl%a2 is"
+        "ERROR /workspace/test/BUILD:3:3: Aspect //test:aspect.bzl%a2 is"
             + " applied twice, both before and after aspect //test:aspect.bzl%a1 "
             + "(when propagating to //test:r1)");
   }
@@ -2287,7 +2289,7 @@
       // expected
     }
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:3:1: Aspect //test:aspect.bzl%a2 is"
+        "ERROR /workspace/test/BUILD:3:3: Aspect //test:aspect.bzl%a2 is"
             + " applied twice, both before and after aspect //test:aspect.bzl%a1 "
             + "(when propagating to //test:r1)");
   }
diff --git a/src/test/java/com/google/devtools/build/lib/skylark/SkylarkIntegrationTest.java b/src/test/java/com/google/devtools/build/lib/skylark/SkylarkIntegrationTest.java
index 8394263..a4cc719 100644
--- a/src/test/java/com/google/devtools/build/lib/skylark/SkylarkIntegrationTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skylark/SkylarkIntegrationTest.java
@@ -240,7 +240,7 @@
     AttributeContainer withMacro = getContainerForTarget("macro_target");
     assertThat(withMacro.getAttr("generator_name")).isEqualTo("macro_target");
     assertThat(withMacro.getAttr("generator_function")).isEqualTo("macro");
-    assertThat(withMacro.getAttr("generator_location")).isEqualTo("test/skylark/BUILD:3:1");
+    assertThat(withMacro.getAttr("generator_location")).isEqualTo("test/skylark/BUILD:3:11");
 
     // Attributes are only set when the rule was created by a macro
     AttributeContainer noMacro = getContainerForTarget("no_macro_target");
@@ -251,7 +251,7 @@
     AttributeContainer nativeMacro = getContainerForTarget("native_macro_target_suffix");
     assertThat(nativeMacro.getAttr("generator_name")).isEqualTo("native_macro_target");
     assertThat(nativeMacro.getAttr("generator_function")).isEqualTo("native_macro");
-    assertThat(nativeMacro.getAttr("generator_location")).isEqualTo("test/skylark/BUILD:5:1");
+    assertThat(nativeMacro.getAttr("generator_location")).isEqualTo("test/skylark/BUILD:5:18");
 
     AttributeContainer ccTarget = getContainerForTarget("cc_target");
     assertThat(ccTarget.getAttr("generator_name")).isEqualTo("");
@@ -1731,7 +1731,7 @@
 
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("//test:xxx");
-    assertContainsEvent("ERROR /workspace/test/BUILD:2:1: in my_rule rule //test:xxx: ");
+    assertContainsEvent("ERROR /workspace/test/BUILD:2:8: in my_rule rule //test:xxx: ");
     assertContainsEvent("The following files have no generating action:");
     assertContainsEvent("test/xxx");
   }
@@ -1777,10 +1777,11 @@
     );
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("//test:xxx");
-    assertContainsEvent("ERROR /workspace/test/BUILD:2:1: in my_rule rule //test:xxx: ");
-    assertContainsEvent("/workspace/test/rule.bzl:5:12: The rule 'my_rule' both accesses "
-        + "'ctx.outputs.executable' and provides a different executable 'test/x.sh'. "
-        + "Do not use 'ctx.output.executable'.");
+    assertContainsEvent("ERROR /workspace/test/BUILD:2:8: in my_rule rule //test:xxx: ");
+    assertContainsEvent(
+        "/workspace/test/rule.bzl:5:23: The rule 'my_rule' both accesses "
+            + "'ctx.outputs.executable' and provides a different executable 'test/x.sh'. "
+            + "Do not use 'ctx.output.executable'.");
   }
 
 
@@ -1855,7 +1856,7 @@
 
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("//test:yyy");
-    assertContainsEvent("ERROR /workspace/test/BUILD:3:1: in my_dep_rule rule //test:yyy: ");
+    assertContainsEvent("ERROR /workspace/test/BUILD:3:12: in my_dep_rule rule //test:yyy: ");
     assertContainsEvent("File \"/workspace/test/rule.bzl\", line 8, in _dep_impl");
     assertContainsEvent("ctx.attr.dep[PInfo].outputs.executable");
     assertContainsEvent("cannot access outputs of rule '//test:xxx' outside "
@@ -1918,7 +1919,7 @@
 
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("//test:xxx");
-    assertContainsEvent("ERROR /workspace/test/BUILD:2:1: in my_rule rule //test:xxx: ");
+    assertContainsEvent("ERROR /workspace/test/BUILD:2:8: in my_rule rule //test:xxx: ");
     assertContainsEvent("/rule.bzl:1:5: The rule 'my_rule' is executable. "
         + "It needs to create an executable File and pass it as the 'executable' "
         + "parameter to the DefaultInfo it returns.");
@@ -1951,7 +1952,7 @@
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("//src:r_tools");
     assertContainsEvent(
-        "/workspace/src/rulez.bzl:2:12: 'executable' provided by an executable"
+        "/workspace/src/rulez.bzl:2:23: 'executable' provided by an executable"
             + " rule 'r' should be created by the same rule.");
   }
 
@@ -1971,10 +1972,16 @@
         "      'out': 'foo/bar/baz',",
         "    },",
         ")");
-    scratch.file("BUILD", "load(':ext.bzl', 'extrule')", "", "extrule(", "    name = 'test'", ")");
+    scratch.file(
+        "BUILD", //
+        "load(':ext.bzl', 'extrule')",
+        "",
+        "extrule(",
+        "    name = 'test'",
+        ")");
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("//:test");
-    assertContainsEvent("ERROR /workspace/BUILD:3:1: in extrule rule //:test:");
+    assertContainsEvent("ERROR /workspace/BUILD:3:8: in extrule rule //:test:");
     assertContainsEvent("he following directories were also declared as files:");
     assertContainsEvent("foo/bar/baz");
   }
diff --git a/src/test/java/com/google/devtools/build/lib/skylark/SkylarkRuleContextTest.java b/src/test/java/com/google/devtools/build/lib/skylark/SkylarkRuleContextTest.java
index 57d4724..e87f704 100644
--- a/src/test/java/com/google/devtools/build/lib/skylark/SkylarkRuleContextTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skylark/SkylarkRuleContextTest.java
@@ -193,7 +193,7 @@
     setUpAttributeErrorTest();
     assertThrows(Exception.class, () -> createRuleContext("//test:m_skylark"));
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:4:1: in deps attribute of skylark_rule rule "
+        "ERROR /workspace/test/BUILD:4:19: in deps attribute of skylark_rule rule "
             + "//test:m_skylark: '//test:jlib' does not have mandatory providers:"
             + " 'some_provider'. "
             + "Since this rule was created by the macro 'macro_skylark_rule', the error might "
@@ -215,7 +215,7 @@
     setUpAttributeErrorTest();
     assertThrows(Exception.class, () -> createRuleContext("//test:skyrule"));
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:10:1: in deps attribute of "
+        "ERROR /workspace/test/BUILD:10:13: in deps attribute of "
             + "skylark_rule rule //test:skyrule: '//test:jlib' does not have mandatory providers: "
             + "'some_provider'");
   }
@@ -256,7 +256,7 @@
 
     assertThrows(Exception.class, () -> createRuleContext("//test:skyrule2"));
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:8:1: in deps attribute of "
+        "ERROR /workspace/test/BUILD:8:13: in deps attribute of "
             + "skylark_rule rule //test:skyrule2: '//test:my_other_lib' does not have "
             + "mandatory providers: 'a' or 'c'");
   }
@@ -288,7 +288,7 @@
 
     assertThrows(Exception.class, () -> createRuleContext("//test:skyrule2"));
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:8:1: in deps attribute of "
+        "ERROR /workspace/test/BUILD:8:37: in deps attribute of "
             + "testing_rule_for_mandatory_providers rule //test:skyrule2: '//test:my_other_lib' "
             + "does not have mandatory providers: 'a' or 'c'");
   }
@@ -303,7 +303,7 @@
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("//test:cclib");
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:1:1: Label '//test:sub/my_sub_lib.h' is invalid because "
+        "ERROR /workspace/test/BUILD:1:11: Label '//test:sub/my_sub_lib.h' is invalid because "
             + "'test/sub' is a subpackage; perhaps you meant to put the colon here: "
             + "'//test/sub:my_sub_lib.h'?");
   }
@@ -328,7 +328,7 @@
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("//test:skyrule");
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:2:1: Label '//test:sub/my_sub_lib.h' is invalid because "
+        "ERROR /workspace/test/BUILD:2:13: Label '//test:sub/my_sub_lib.h' is invalid because "
             + "'test/sub' is a subpackage; perhaps you meant to put the colon here: "
             + "'//test/sub:my_sub_lib.h'?");
   }
@@ -355,7 +355,7 @@
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("//test:m_skylark");
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:2:1: Label '//test:sub/my_sub_lib.h' is invalid because"
+        "ERROR /workspace/test/BUILD:2:19: Label '//test:sub/my_sub_lib.h' is invalid because"
             + " 'test/sub' is a subpackage; perhaps you meant to put the colon here: "
             + "'//test/sub:my_sub_lib.h'?");
   }
@@ -377,7 +377,7 @@
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("//:cclib");
     assertContainsEvent(
-        "/workspace/BUILD:1:1: Label '//:r/my_sub_lib.h' is invalid because "
+        "/workspace/BUILD:1:11: Label '//:r/my_sub_lib.h' is invalid because "
             + "'@r//' is a subpackage");
   }
 
@@ -396,7 +396,7 @@
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("@r//:cclib");
     assertContainsEvent(
-        "/external/r/BUILD:1:1: Label '@r//:sub/my_sub_lib.h' is invalid because "
+        "/external/r/BUILD:1:11: Label '@r//:sub/my_sub_lib.h' is invalid because "
             + "'@r//sub' is a subpackage; perhaps you meant to put the colon here: "
             + "'@r//sub:my_sub_lib.h'?");
   }
@@ -431,7 +431,7 @@
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("//test:m_skylark");
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:2:1: Label '//test:sub/my_sub_lib.h' "
+        "ERROR /workspace/test/BUILD:2:19: Label '//test:sub/my_sub_lib.h' "
             + "is invalid because 'test/sub' is a subpackage");
   }
 
@@ -449,7 +449,7 @@
     reporter.removeHandler(failFastHandler);
     getConfiguredTarget("//test:m_native");
     assertContainsEvent(
-        "ERROR /workspace/test/BUILD:2:1: Label '//test:sub/my_sub_lib.h' "
+        "ERROR /workspace/test/BUILD:2:18: Label '//test:sub/my_sub_lib.h' "
             + "is invalid because 'test/sub' is a subpackage");
   }
 
diff --git a/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/SkylarkDebugServerTest.java b/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/SkylarkDebugServerTest.java
index c571553..9e52524 100644
--- a/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/SkylarkDebugServerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/SkylarkDebugServerTest.java
@@ -467,7 +467,7 @@
                 Location.newBuilder()
                     .setPath("/a/build/file/test.bzl")
                     .setLineNumber(7)
-                    .setColumnNumber(1))
+                    .setColumnNumber(3))
             .addScope(
                 Scope.newBuilder()
                     .setName("global")
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/ExceptionTest.java b/src/test/java/com/google/devtools/build/lib/syntax/ExceptionTest.java
index 3b5ee29..a8ba83af 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/ExceptionTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/ExceptionTest.java
@@ -24,12 +24,12 @@
  */
 @RunWith(JUnit4.class)
 public class ExceptionTest {
-  private static final Node DUMMY_NODE = Identifier.of("DUMMY");
 
   @Test
   public void testEmptyMessage() throws Exception {
+    Node dummyNode = Expression.parse(ParserInput.fromLines("DUMMY"));
     EvalExceptionWithStackTrace ex =
-        new EvalExceptionWithStackTrace(new NullPointerException(), DUMMY_NODE);
+        new EvalExceptionWithStackTrace(new NullPointerException(), dummyNode);
     assertThat(ex)
         .hasMessageThat()
         .contains("Null Pointer: ExceptionTest.testEmptyMessage() in ExceptionTest.java:");
@@ -44,8 +44,10 @@
     runExceptionTest(ee, iae);
   }
 
-  private void runExceptionTest(Exception toThrow, Exception expectedCause) {
-    EvalExceptionWithStackTrace ex = new EvalExceptionWithStackTrace(toThrow, DUMMY_NODE);
+  private static void runExceptionTest(Exception toThrow, Exception expectedCause)
+      throws Exception {
+    Node dummyNode = Expression.parse(ParserInput.fromLines("DUMMY"));
+    EvalExceptionWithStackTrace ex = new EvalExceptionWithStackTrace(toThrow, dummyNode);
     assertThat(ex).hasCauseThat().isEqualTo(expectedCause);
   }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/LexerTest.java b/src/test/java/com/google/devtools/build/lib/syntax/LexerTest.java
index 831052c..616a85c 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/LexerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/LexerTest.java
@@ -16,7 +16,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.base.Joiner;
-import com.google.devtools.build.lib.skyframe.serialization.testutils.SerializationTester;
 import java.util.ArrayList;
 import java.util.List;
 import org.junit.Test;
@@ -45,13 +44,31 @@
     return new Lexer(inputSource, FileOptions.DEFAULT, errors);
   }
 
+  private static class Token {
+    TokenKind kind;
+    int left;
+    int right;
+    Object value;
+
+    @Override
+    public String toString() {
+      return kind == TokenKind.STRING
+          ? "\"" + value + "\""
+          : value == null ? kind.toString() : value.toString();
+    }
+  }
+
   private ArrayList<Token> allTokens(Lexer lexer) {
     ArrayList<Token> result = new ArrayList<>();
-    Token tok;
     do {
-      tok = lexer.nextToken();
-      result.add(tok.copy());
-    } while (tok.kind != TokenKind.EOF);
+      lexer.nextToken();
+      Token tok = new Token();
+      tok.kind = lexer.kind;
+      tok.left = lexer.left;
+      tok.right = lexer.right;
+      tok.value = lexer.value;
+      result.add(tok);
+    } while (lexer.kind != TokenKind.EOF);
 
     for (SyntaxError error : errors) {
       lastError = error.location().file() + ":" + error.location().line() + ": " + error.message();
@@ -76,7 +93,7 @@
       if (buf.length() > 0) {
         buf.append(' ');
       }
-      int line = lexer.createLocation(tok.left, tok.left).line();
+      int line = lexer.lnt.getLocation(tok.left).line();
       buf.append(line);
     }
     return buf.toString();
@@ -511,11 +528,6 @@
                 + " instead.");
   }
 
-  @Test
-  public void testLexerLocationCodec() throws Exception {
-    new SerializationTester(createLexer("foo").createLocation(0, 2)).runTests();
-  }
-
   /**
    * Returns the first error whose string form contains the specified substring, or throws an
    * informative AssertionError if there is none.
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/LineNumberTableTest.java b/src/test/java/com/google/devtools/build/lib/syntax/LineNumberTableTest.java
index 70d76f4..6116743 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/LineNumberTableTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/LineNumberTableTest.java
@@ -13,43 +13,51 @@
 // limitations under the License.
 package com.google.devtools.build.lib.syntax;
 
-import static com.google.common.truth.Truth.assertThat;
 
 import com.google.devtools.build.lib.skyframe.serialization.testutils.SerializationTester;
-import com.google.devtools.build.lib.syntax.Location.LineAndColumn;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
 
-/**
- * Tests for {@link LineNumberTable}.
- */
+/** Tests for {@link LineNumberTable}. */
+// TODO(adonovan): express this test in terms of the public API.
 @RunWith(JUnit4.class)
 public class LineNumberTableTest {
 
-  private LineNumberTable create(String buffer) {
+  private static LineNumberTable create(String buffer) {
     return LineNumberTable.create(buffer.toCharArray(), "/fake/file");
   }
 
+  // Asserts that the specified offset results in a line/column pair of the form "1:2".
+  private static void checkOffset(LineNumberTable table, int offset, String wantLineCol) {
+    Location loc = table.getLocation(offset);
+    String got = String.format("%d:%d", loc.line(), loc.column());
+    if (!got.equals(wantLineCol)) {
+      throw new AssertionError(
+          String.format("location(%d) = %s, want %s", offset, got, wantLineCol));
+    }
+  }
+
   @Test
   public void testEmpty() {
     LineNumberTable table = create("");
-    assertThat(table.getLineAndColumn(0)).isEqualTo(new LineAndColumn(1, 1));
+    checkOffset(table, 0, "1:1");
   }
 
   @Test
   public void testNewline() {
     LineNumberTable table = create("\n");
-    assertThat(table.getLineAndColumn(0)).isEqualTo(new LineAndColumn(1, 1));
-    assertThat(table.getLineAndColumn(1)).isEqualTo(new LineAndColumn(2, 1));
+    checkOffset(table, 0, "1:1");
+    checkOffset(table, 1, "2:1"); // EOF
   }
 
   @Test
   public void testOneLiner() {
     LineNumberTable table = create("foo");
-    assertThat(table.getLineAndColumn(0)).isEqualTo(new LineAndColumn(1, 1));
-    assertThat(table.getLineAndColumn(1)).isEqualTo(new LineAndColumn(1, 2));
-    assertThat(table.getLineAndColumn(2)).isEqualTo(new LineAndColumn(1, 3));
+    checkOffset(table, 0, "1:1");
+    checkOffset(table, 1, "1:2");
+    checkOffset(table, 2, "1:3");
+    checkOffset(table, 3, "1:4"); // EOF
   }
 
   @Test
@@ -57,24 +65,27 @@
     LineNumberTable table = create("\ntwo\nthree\n\nfive\n");
 
     // \n
-    assertThat(table.getLineAndColumn(0)).isEqualTo(new LineAndColumn(1, 1));
+    checkOffset(table, 0, "1:1");
 
     // two\n
-    assertThat(table.getLineAndColumn(1)).isEqualTo(new LineAndColumn(2, 1));
-    assertThat(table.getLineAndColumn(2)).isEqualTo(new LineAndColumn(2, 2));
-    assertThat(table.getLineAndColumn(3)).isEqualTo(new LineAndColumn(2, 3));
-    assertThat(table.getLineAndColumn(4)).isEqualTo(new LineAndColumn(2, 4));
+    checkOffset(table, 1, "2:1");
+    checkOffset(table, 2, "2:2");
+    checkOffset(table, 3, "2:3");
+    checkOffset(table, 4, "2:4");
 
     // three\n
-    assertThat(table.getLineAndColumn(5)).isEqualTo(new LineAndColumn(3, 1));
-    assertThat(table.getLineAndColumn(10)).isEqualTo(new LineAndColumn(3, 6));
+    checkOffset(table, 5, "3:1");
+    checkOffset(table, 10, "3:6");
 
     // \n
-    assertThat(table.getLineAndColumn(11)).isEqualTo(new LineAndColumn(4, 1));
+    checkOffset(table, 11, "4:1");
 
     // five\n
-    assertThat(table.getLineAndColumn(12)).isEqualTo(new LineAndColumn(5, 1));
-    assertThat(table.getLineAndColumn(16)).isEqualTo(new LineAndColumn(5, 5));
+    checkOffset(table, 12, "5:1");
+    checkOffset(table, 16, "5:5");
+
+    // start of final empty line
+    checkOffset(table, 17, "6:1"); // EOF
   }
 
   @Test
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/ParserTest.java b/src/test/java/com/google/devtools/build/lib/syntax/ParserTest.java
index ec8e418..77ad9dd 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/ParserTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/ParserTest.java
@@ -362,7 +362,8 @@
 
     assertContainsError("syntax error at 'foo'");
 
-    // Test that the actual parameters are: (1, $error$, 3):
+    // Test that the arguments are (1, '[x for foo foo foo foo]', 3),
+    // where the second, errant one is represented as an Identifier.
 
     Identifier ident = (Identifier) e.getFunction();
     assertThat(ident.getName()).isEqualTo("f");
@@ -375,9 +376,9 @@
 
     Argument arg1 = e.getArguments().get(1);
     Identifier arg1val = ((Identifier) arg1.getValue());
-    assertThat(arg1val.getName()).isEqualTo("$error$");
+    assertThat(arg1val.getName()).isEqualTo("[x for foo foo foo foo]");
 
-    assertLocation(5, 29, arg1val);
+    assertLocation(5, 28, arg1val);
     assertThat(src.substring(5, 28)).isEqualTo("[x for foo foo foo foo]");
     assertThat(arg1val.getEndLocation().column()).isEqualTo(29);
 
@@ -399,14 +400,6 @@
   }
 
   @Test
-  public void testSecondaryLocation() throws SyntaxError.Exception {
-    String expr = "f(1 % 2)";
-    CallExpression call = (CallExpression) parseExpression(expr);
-    Argument arg = call.getArguments().get(0);
-    assertThat(arg.getEndLocation()).isLessThan(call.getEndLocation());
-  }
-
-  @Test
   public void testPrimaryLocation() throws SyntaxError.Exception {
     String expr = "f(1 + 2)";
     CallExpression call = (CallExpression) parseExpression(expr);
@@ -494,10 +487,11 @@
   }
 
   @Test
-  public void testEndLineAndColumnIsInclusive() // <-- this behavior is a mistake
-      throws Exception {
+  public void testEndLineAndColumnIsExclusive() throws Exception {
+    // The behavior was 'inclusive' for a couple of years (see CL 170723732),
+    // but this was a mistake. Arithmetic on half-open intervals is much simpler.
     AssignmentStatement stmt = (AssignmentStatement) parseStatement("a = b");
-    assertThat(stmt.getLHS().getEndLocation().toString()).isEqualTo(":1:1");
+    assertThat(stmt.getLHS().getEndLocation().toString()).isEqualTo(":1:2");
   }
 
   @Test
@@ -549,9 +543,11 @@
     String input = "for a,b in []: pass";
     ForStatement stmt = (ForStatement) parseStatement(input);
     assertThat(getText(input, stmt.getLHS())).isEqualTo("a,b");
+
     input = "for (a,b) in []: pass";
     stmt = (ForStatement) parseStatement(input);
     assertThat(getText(input, stmt.getLHS())).isEqualTo("(a,b)");
+
     assertExpressionLocationCorrect("a, b");
     assertExpressionLocationCorrect("(a, b)");
   }
@@ -1099,14 +1095,13 @@
 
   @Test
   public void testLoadOneSymbol() throws Exception {
-    List<Statement> statements = parseStatements("load('//foo/bar:file.bzl', 'fun_test')\n");
+    String text = "load('//foo/bar:file.bzl', 'fun_test')\n";
+    List<Statement> statements = parseStatements(text);
     LoadStatement stmt = (LoadStatement) statements.get(0);
     assertThat(stmt.getImport().getValue()).isEqualTo("//foo/bar:file.bzl");
     assertThat(stmt.getBindings()).hasSize(1);
     Identifier sym = stmt.getBindings().get(0).getLocalName();
-    int startOffset = sym.getStartOffset();
-    assertWithMessage("getStartOffset()").that(startOffset).isEqualTo(27);
-    assertWithMessage("getEndOffset()").that(sym.getEndOffset()).isEqualTo(startOffset + 10);
+    assertThat(getText(text, sym)).isEqualTo("fun_test"); // apparent location within string literal
   }
 
   @Test
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/PrettyPrintTest.java b/src/test/java/com/google/devtools/build/lib/syntax/PrettyPrintTest.java
index cf420bb..6293505 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/PrettyPrintTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/PrettyPrintTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.base.Joiner;
-import com.google.common.collect.ImmutableList;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -292,14 +291,9 @@
 
   @Test
   public void flowStatement() throws SyntaxError.Exception {
-    // The parser would complain if we tried to construct them from source.
-    Node breakNode = new FlowStatement(TokenKind.BREAK);
-    assertIndentedPrettyMatches(breakNode, "  break\n");
-    assertTostringMatches(breakNode, "break\n");
-
-    Node continueNode = new FlowStatement(TokenKind.CONTINUE);
-    assertIndentedPrettyMatches(continueNode, "  continue\n");
-    assertTostringMatches(continueNode, "continue\n");
+    assertStmtIndentedPrettyMatches(
+        join("def f():", "     pass", "     continue", "     break"),
+        join("  def f():", "    pass", "    continue", "    break", ""));
   }
 
   @Test
@@ -366,41 +360,28 @@
 
   @Test
   public void loadStatement() throws SyntaxError.Exception {
-    // load("foo.bzl", a="A", "B")
-    Node loadStatement =
-        new LoadStatement(
-            new StringLiteral("foo.bzl"),
-            ImmutableList.of(
-                new LoadStatement.Binding(Identifier.of("a"), Identifier.of("A")),
-                new LoadStatement.Binding(Identifier.of("B"), Identifier.of("B"))));
-    assertIndentedPrettyMatches(
-        loadStatement,
-        "  load(\"foo.bzl\", a=\"A\", \"B\")\n");
-    assertTostringMatches(
-        loadStatement,
-        "load(\"foo.bzl\", a=\"A\", \"B\")\n");
+    assertStmtIndentedPrettyMatches(
+        "load(\"foo.bzl\", a=\"A\", \"B\")", "  load(\"foo.bzl\", a=\"A\", \"B\")\n");
+    assertStmtTostringMatches(
+        "load(\"foo.bzl\", a=\"A\", \"B\")\n", "load(\"foo.bzl\", a=\"A\", \"B\")\n");
   }
 
   @Test
   public void returnStatement() throws SyntaxError.Exception {
-    assertIndentedPrettyMatches(
-        new ReturnStatement(new StringLiteral("foo")),
-        "  return \"foo\"\n");
-    assertTostringMatches(
-        new ReturnStatement(new StringLiteral("foo")),
-        "return \"foo\"\n");
+    assertStmtIndentedPrettyMatches("return \"foo\"", "  return \"foo\"\n");
+    assertStmtTostringMatches("return \"foo\"", "return \"foo\"\n");
 
-    assertIndentedPrettyMatches(new ReturnStatement(Identifier.of("None")), "  return None\n");
-    assertTostringMatches(new ReturnStatement(Identifier.of("None")), "return None\n");
+    assertStmtIndentedPrettyMatches("return None", "  return None\n");
+    assertStmtTostringMatches("return None", "return None\n");
 
-    assertIndentedPrettyMatches(new ReturnStatement(null), "  return\n");
-    assertTostringMatches(new ReturnStatement(null), "return\n");
+    assertStmtIndentedPrettyMatches("return", "  return\n");
+    assertStmtTostringMatches("return", "return\n");
   }
 
   // Miscellaneous.
 
   @Test
-  public void buildFileAST() throws SyntaxError.Exception {
+  public void file() throws SyntaxError.Exception {
     Node node = parseFile("print(x)\nprint(y)");
     assertIndentedPrettyMatches(
         node,
@@ -412,7 +393,7 @@
 
   @Test
   public void comment() throws SyntaxError.Exception {
-    Comment node = new Comment("foo");
+    Comment node = new Comment(0, "foo");
     assertIndentedPrettyMatches(node, "  # foo");
     assertTostringMatches(node, "foo");
   }
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkFileTest.java b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkFileTest.java
index 7ffd09a..28b1bbf 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkFileTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkFileTest.java
@@ -91,11 +91,12 @@
 
   @Test
   public void testImplicitStringConcatenationFails() throws Exception {
+    // TODO(adonovan): move to ParserTest.
     StarlarkFile file = parseFile("a = 'foo' 'bar'");
     SyntaxError error =
         LexerTest.assertContainsError(
             file.errors(), "Implicit string concatenation is forbidden, use the + operator");
-    assertThat(error.location().toString()).isEqualTo("foo.star:1:10");
+    assertThat(error.location().toString()).isEqualTo("foo.star:1:11"); // start of 'bar'
   }
 
   @Test
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadDebuggingTest.java b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadDebuggingTest.java
index 83df6ce..de3442e 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadDebuggingTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadDebuggingTest.java
@@ -109,16 +109,16 @@
     assertThat(buf.toString())
         .isEqualTo(
             ""
-                // location is start of g(4, 5, 6) call:
-                + "<toplevel> @ main.star:3:1 local={}\n"
-                // location is start of "f()" call:
-                + "g @ main.star:2:3 local={a=4, y=5, z=6}\n"
+                // location is paren of g(4, 5, 6) call:
+                + "<toplevel> @ main.star:3:2 local={}\n"
+                // location is paren of "f()" call:
+                + "g @ main.star:2:4 local={a=4, y=5, z=6}\n"
                 // location is "current PC" in f.
                 + "f @ builtin:12 local={}\n");
 
     // Same, with "lite" stack API.
     assertThat(result[1].toString()) // an ImmutableList<StarlarkThread.CallStackEntry>
-        .isEqualTo("[<toplevel>@main.star:3:1, g@main.star:2:3, f@builtin:12]");
+        .isEqualTo("[<toplevel>@main.star:3:2, g@main.star:2:4, f@builtin:12]");
 
     // TODO(adonovan): more tests:
     // - a stack containing functions defined in different modules.
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/ValidationTest.java b/src/test/java/com/google/devtools/build/lib/syntax/ValidationTest.java
index 402dff6..747691d 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/ValidationTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/ValidationTest.java
@@ -13,7 +13,6 @@
 // limitations under the License.
 package com.google.devtools.build.lib.syntax;
 
-import static com.google.common.truth.Truth.assertThat;
 import static com.google.devtools.build.lib.syntax.LexerTest.assertContainsError;
 
 import java.util.List;
@@ -80,10 +79,9 @@
   @Test
   public void testLoadAfterStatement() throws Exception {
     options.requireLoadStatementsFirst(true);
-    assertInvalid(
-        "load() statements must be called before any other statement", //
-        "a = 5",
-        "load(':b.bzl', 'c')");
+    List<SyntaxError> errors = getValidationErrors("a = 5", "load(':b.bzl', 'c')");
+    assertContainsError(errors, ":2:1: load statements must appear before any other statement");
+    assertContainsError(errors, ":1:1: \tfirst non-load statement appears here");
   }
 
   @Test
@@ -304,23 +302,6 @@
   }
 
   @Test
-  public void testDollarErrorDoesNotLeak() throws Exception {
-    List<SyntaxError> errors =
-        getValidationErrors(
-            "def GenerateMapNames():", //
-            "  a = 2",
-            "  b = [3, 4]",
-            "  if a not b:",
-            "    print(a)");
-    assertContainsError(errors, "syntax error at 'b': expected 'in'");
-    // Parser uses "$error" symbol for error recovery.
-    // It should not be used in error messages.
-    for (SyntaxError event : errors) {
-      assertThat(event.message()).doesNotContain("$error$");
-    }
-  }
-
-  @Test
   public void testPositionalAfterStarArg() throws Exception {
     assertInvalid(
         "positional argument is misplaced (positional arguments come first)", //
diff --git a/src/test/shell/bazel/bazel_workspaces_test.sh b/src/test/shell/bazel/bazel_workspaces_test.sh
index 5a97570..527fe07 100755
--- a/src/test/shell/bazel/bazel_workspaces_test.sh
+++ b/src/test/shell/bazel/bazel_workspaces_test.sh
@@ -88,11 +88,11 @@
   set_workspace_command 'repository_ctx.execute(["echo", "testing!"])'
   build_and_process_log
 
-  ensure_contains_exactly "location: .*repos.bzl:2:3" 1
+  ensure_contains_exactly "location: .*repos.bzl:2:25" 1
 
   # Cached executions are not replayed
   build_and_process_log
-  ensure_contains_exactly "location: .*repos.bzl:2:3" 0
+  ensure_contains_exactly "location: .*repos.bzl:2:25" 0
 }
 
 # The workspace is set up so that the function is interrupted and re-executed.
@@ -139,7 +139,7 @@
 
   build_and_process_log
 
-  ensure_contains_atleast "location: .*repos.bzl:2:3" 2
+  ensure_contains_atleast "location: .*repos.bzl:2:" 2
 }
 
 
@@ -149,7 +149,7 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:2:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:2:25' 1
   ensure_contains_exactly 'arguments: "echo"' 1
   ensure_contains_exactly 'arguments: "test_contents"' 1
   ensure_contains_exactly 'timeout_seconds: 21' 1
@@ -165,7 +165,7 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:2:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:2:25' 1
   ensure_contains_exactly 'arguments: "echo"' 1
   ensure_contains_exactly 'arguments: "test2"' 1
   ensure_contains_exactly 'timeout_seconds: 32' 1
@@ -193,7 +193,7 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:2:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:2:26' 1
   ensure_contains_atleast 'rule: "//external:repo"' 1
   ensure_contains_exactly 'download_event' 1
   ensure_contains_exactly "url: \"http://localhost:${fileserver_port}/file.txt\"" 1
@@ -221,7 +221,7 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:2:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:2:26' 1
   ensure_contains_atleast 'rule: "//external:repo"' 1
   ensure_contains_exactly 'download_event' 1
   ensure_contains_exactly "url: \"http://localhost:${fileserver_port}/file1.txt\"" 1
@@ -248,7 +248,7 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:2:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:2:26' 1
   ensure_contains_atleast 'rule: "//external:repo"' 1
   ensure_contains_exactly 'download_event' 1
   ensure_contains_exactly "url: \"http://localhost:${fileserver_port}/file.txt\"" 1
@@ -277,7 +277,7 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:2:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:2:26' 1
   ensure_contains_atleast 'rule: "//external:repo"' 1
   ensure_contains_exactly 'download_event' 1
   ensure_contains_exactly "url: \"http://localhost:${fileserver_port}/file.txt\"" 1
@@ -337,8 +337,8 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:3:3' 1
-  ensure_contains_exactly 'location: .*repos.bzl:4:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:3:26' 1
+  ensure_contains_exactly 'location: .*repos.bzl:4:25' 1
   ensure_contains_atleast 'rule: "//external:repo"' 2
   ensure_contains_exactly 'download_event' 1
   ensure_contains_exactly "url: \"http://localhost:${fileserver_port}/download_then_extract.zip\"" 1
@@ -374,8 +374,8 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:3:3' 1
-  ensure_contains_exactly 'location: .*repos.bzl:4:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:3:26' 1
+  ensure_contains_exactly 'location: .*repos.bzl:4:25' 1
   ensure_contains_atleast 'rule: "//external:repo"' 2
   ensure_contains_exactly 'download_event' 1
   ensure_contains_exactly "url: \"http://localhost:${fileserver_port}/download_then_extract.tar.gz\"" 1
@@ -408,7 +408,7 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:2:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:2:38' 1
   ensure_contains_atleast 'rule: "//external:repo"' 1
   ensure_contains_exactly 'download_and_extract_event' 1
   ensure_contains_exactly "url: \"http://localhost:${fileserver_port}/download_and_extract.zip\"" 1
@@ -425,7 +425,7 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:2:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:2:22' 1
   ensure_contains_atleast 'rule: "//external:repo"' 1
 
   # There are 3 file_event in external:repo as it is currently set up
@@ -440,7 +440,7 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:2:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:2:22' 1
   ensure_contains_atleast 'rule: "//external:repo"' 1
 
   # There are 3 file_event in external:repo as it is currently set up
@@ -471,8 +471,8 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:4:3' 1
-  ensure_contains_exactly 'location: .*repos.bzl:5:17' 1
+  ensure_contains_exactly 'location: .*repos.bzl:4:22' 1
+  ensure_contains_exactly 'location: .*repos.bzl:5:36' 1
   ensure_contains_atleast 'rule: "//external:repo"' 2
 
   ensure_contains_exactly 'read_event' 1
@@ -521,7 +521,7 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:3:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:2:22' 1
   ensure_contains_atleast 'rule: "//external:repo"' 1
   ensure_contains_exactly 'symlink_event' 1
   ensure_contains_exactly 'target: ".*symlink.txt"' 1
@@ -534,7 +534,7 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:3:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:2:22' 1
   ensure_contains_atleast 'rule: "//external:repo"' 1
   ensure_contains_exactly 'template_event' 1
   ensure_contains_exactly 'path: ".*template_out.txt"' 1
@@ -549,7 +549,7 @@
 
   build_and_process_log --exclude_rule "//external:local_config_cc"
 
-  ensure_contains_exactly 'location: .*repos.bzl:2:9' 1
+  ensure_contains_exactly 'location: .*repos.bzl:2:29' 1
   ensure_contains_atleast 'rule: "//external:repo"' 1
   ensure_contains_exactly 'which_event' 1
   ensure_contains_exactly 'program: "which_prog"' 1
diff --git a/src/test/shell/bazel/external_integration_test.sh b/src/test/shell/bazel/external_integration_test.sh
index 43532a1..63ca019 100755
--- a/src/test/shell/bazel/external_integration_test.sh
+++ b/src/test/shell/bazel/external_integration_test.sh
@@ -2325,11 +2325,11 @@
 
   # We expect to find the call stack for the definition of the repositories
   # a and b
-  expect_log "WORKSPACE:4:1"
-  expect_log "foo.bzl:4:3"
+  expect_log "WORKSPACE:4:4"
+  expect_log "foo.bzl:4:15"
 
-  expect_log "WORKSPACE:5:1"
-  expect_log "bar.bzl:4:3"
+  expect_log "WORKSPACE:5:4"
+  expect_log "bar.bzl:4:15"
 }
 
 function test_missing_repo_reported() {
diff --git a/src/test/shell/integration/ui_test.sh b/src/test/shell/integration/ui_test.sh
index 1c29872..c875880 100755
--- a/src/test/shell/integration/ui_test.sh
+++ b/src/test/shell/integration/ui_test.sh
@@ -545,11 +545,11 @@
 
   bazel build --attempt_to_print_relative_paths=false \
       error:failwitherror > "${TEST_log}" 2>&1 && fail "expected failure"
-  expect_log "^ERROR: $(pwd)/error/BUILD:1:1: Executing genrule"
+  expect_log "^ERROR: $(pwd)/error/BUILD:1:8: Executing genrule"
 
   bazel build --attempt_to_print_relative_paths=true \
       error:failwitherror > "${TEST_log}" 2>&1 && fail "expected failure"
-  expect_log "^ERROR: error/BUILD:1:1: Executing genrule"
+  expect_log "^ERROR: error/BUILD:1:8: Executing genrule"
   expect_not_log "$(pwd)/error/BUILD"
 }
 
diff --git a/src/tools/skylark/java/com/google/devtools/skylark/common/LocationRange.java b/src/tools/skylark/java/com/google/devtools/skylark/common/LocationRange.java
index 29d731f..e541416 100644
--- a/src/tools/skylark/java/com/google/devtools/skylark/common/LocationRange.java
+++ b/src/tools/skylark/java/com/google/devtools/skylark/common/LocationRange.java
@@ -15,8 +15,6 @@
 package com.google.devtools.skylark.common;
 
 import com.google.common.base.Preconditions;
-import com.google.devtools.build.lib.syntax.Location.LineAndColumn;
-import javax.annotation.Nullable;
 
 /**
  * Location range of a linter warning.
@@ -61,14 +59,6 @@
       this.column = column;
     }
 
-    public static Location from(@Nullable LineAndColumn lac) {
-      // LineAndColumn may be null, e.g. if a StarlarkFile contains no statements:
-      if (lac == null) {
-        return new Location(1, 1);
-      }
-      return new Location(lac.line, lac.column);
-    }
-
     public static int compare(Location l1, Location l2) {
       int cmp = l1.line - l2.line;
       if (cmp != 0) {