diff --git a/src/main/java/com/google/devtools/build/lib/runtime/BlazeCommandDispatcher.java b/src/main/java/com/google/devtools/build/lib/runtime/BlazeCommandDispatcher.java
index 475850e4f6ac0a..76d28bd5a74c23 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/BlazeCommandDispatcher.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/BlazeCommandDispatcher.java
@@ -67,6 +67,7 @@
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.io.PrintWriter;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
@@ -371,6 +372,12 @@ private BlazeCommandResult execExclusively(
             message, FailureDetails.Command.Code.STARLARK_CPU_PROFILING_INITIALIZATION_FAILURE);
       }
     }
+    if (!commonOptions.starlarkCoverageReport.isEmpty()) {
+      // Record coverage for all `.bzl` files, excluding the built-ins, which don't have paths that
+      // could be resolved when a human-readable coverage report is generated.
+      Starlark.startCoverageCollection(
+          path -> !path.startsWith("/virtual_builtins_bzl") && path.endsWith(".bzl"));
+    }
 
     BlazeCommandResult result =
         createDetailedCommandResult(
@@ -603,6 +610,18 @@ private BlazeCommandResult execExclusively(
           }
         }
       }
+      if (!commonOptions.starlarkCoverageReport.isEmpty()) {
+        FileOutputStream out;
+        try {
+          out = new FileOutputStream(commonOptions.starlarkCoverageReport);
+        } catch (IOException ex) {
+          String message = "Starlark coverage recorder: " + ex.getMessage();
+          outErr.printErrLn(message);
+          return createDetailedCommandResult(
+              message, FailureDetails.Command.Code.STARLARK_COVERAGE_REPORT_DUMP_FAILURE);
+        }
+        Starlark.dumpCoverage(new PrintWriter(out));
+      }
 
       needToCallAfterCommand = false;
       return runtime.afterCommand(env, result);
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/CommonCommandOptions.java b/src/main/java/com/google/devtools/build/lib/runtime/CommonCommandOptions.java
index 006ba66192ad96..a7415355b7e6c9 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/CommonCommandOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/CommonCommandOptions.java
@@ -293,6 +293,15 @@ public String getTypeDescription() {
       help = "Writes into the specified file a pprof profile of CPU usage by all Starlark threads.")
   public String starlarkCpuProfile;
 
+  @Option(
+      name = "starlark_coverage_report",
+      defaultValue = "",
+      documentationCategory = OptionDocumentationCategory.LOGGING,
+      effectTags = {OptionEffectTag.BAZEL_MONITORING},
+      help = "Writes into the specified file an LCOV coverage report for all Starlark files "
+          + "executed for the requested Bazel command.")
+  public String starlarkCoverageReport;
+
   @Option(
       name = "record_full_profiler_data",
       defaultValue = "false",
diff --git a/src/main/java/net/starlark/java/eval/Eval.java b/src/main/java/net/starlark/java/eval/Eval.java
index 594afd97f7705a..71dcdb7f00ef8e 100644
--- a/src/main/java/net/starlark/java/eval/Eval.java
+++ b/src/main/java/net/starlark/java/eval/Eval.java
@@ -29,6 +29,7 @@
 import net.starlark.java.syntax.CallExpression;
 import net.starlark.java.syntax.Comprehension;
 import net.starlark.java.syntax.ConditionalExpression;
+import net.starlark.java.syntax.CoverageRecorder;
 import net.starlark.java.syntax.DefStatement;
 import net.starlark.java.syntax.DictExpression;
 import net.starlark.java.syntax.DotExpression;
@@ -45,6 +46,7 @@
 import net.starlark.java.syntax.ListExpression;
 import net.starlark.java.syntax.LoadStatement;
 import net.starlark.java.syntax.Location;
+import net.starlark.java.syntax.Parameter;
 import net.starlark.java.syntax.Resolver;
 import net.starlark.java.syntax.ReturnStatement;
 import net.starlark.java.syntax.SliceExpression;
@@ -131,6 +133,7 @@ private static TokenKind execFor(StarlarkThread.Frame fr, ForStatement node)
             continue;
           case BREAK:
             // Finish loop, execute next statement after loop.
+            CoverageRecorder.getInstance().recordVirtualJump(node);
             return TokenKind.PASS;
           case RETURN:
             // Finish loop, return from function.
@@ -145,6 +148,7 @@ private static TokenKind execFor(StarlarkThread.Frame fr, ForStatement node)
     } finally {
       EvalUtils.removeIterator(seq);
     }
+    CoverageRecorder.getInstance().recordVirtualJump(node);
     return TokenKind.PASS;
   }
 
@@ -159,7 +163,9 @@ private static StarlarkFunction newFunction(StarlarkThread.Frame fr, Resolver.Fu
     int nparams =
         rfn.getParameters().size() - (rfn.hasKwargs() ? 1 : 0) - (rfn.hasVarargs() ? 1 : 0);
     for (int i = 0; i < nparams; i++) {
-      Expression expr = rfn.getParameters().get(i).getDefaultValue();
+      Parameter parameter = rfn.getParameters().get(i);
+      CoverageRecorder.getInstance().recordCoverage(parameter.getIdentifier());
+      Expression expr = parameter.getDefaultValue();
       if (expr == null && defaults == null) {
         continue; // skip prefix of required parameters
       }
@@ -169,6 +175,10 @@ private static StarlarkFunction newFunction(StarlarkThread.Frame fr, Resolver.Fu
       defaults[i - (nparams - defaults.length)] =
           expr == null ? StarlarkFunction.MANDATORY : eval(fr, expr);
     }
+    // Visit kwargs and varargs for coverage.
+    for (int i = nparams; i < rfn.getParameters().size(); i++) {
+      CoverageRecorder.getInstance().recordCoverage(rfn.getParameters().get(i).getIdentifier());
+    }
     if (defaults == null) {
       defaults = EMPTY;
     }
@@ -206,6 +216,7 @@ private static TokenKind execIf(StarlarkThread.Frame fr, IfStatement node)
     } else if (node.getElseBlock() != null) {
       return execStatements(fr, node.getElseBlock(), /*indented=*/ true);
     }
+    CoverageRecorder.getInstance().recordVirtualJump(node);
     return TokenKind.PASS;
   }
 
@@ -262,6 +273,7 @@ private static TokenKind exec(StarlarkThread.Frame fr, Statement st)
     if (++fr.thread.steps >= fr.thread.stepLimit) {
       throw new EvalException("Starlark computation cancelled: too many steps");
     }
+    CoverageRecorder.getInstance().recordCoverage(st);
 
     switch (st.kind()) {
       case ASSIGNMENT:
@@ -330,6 +342,7 @@ private static void assign(StarlarkThread.Frame fr, Expression lhs, Object value
 
   private static void assignIdentifier(StarlarkThread.Frame fr, Identifier id, Object value)
       throws EvalException {
+    CoverageRecorder.getInstance().recordCoverage(id);
     Resolver.Binding bind = id.getBinding();
     switch (bind.getScope()) {
       case LOCAL:
@@ -477,6 +490,7 @@ private static Object eval(StarlarkThread.Frame fr, Expression expr)
     if (++fr.thread.steps >= fr.thread.stepLimit) {
       throw new EvalException("Starlark computation cancelled: too many steps");
     }
+    CoverageRecorder.getInstance().recordCoverage(expr);
 
     // The switch cases have been split into separate functions
     // to reduce the stack usage during recursion, which is
@@ -533,9 +547,17 @@ private static Object evalBinaryOperator(StarlarkThread.Frame fr, BinaryOperator
     // AND and OR require short-circuit evaluation.
     switch (binop.getOperator()) {
       case AND:
-        return Starlark.truth(x) ? eval(fr, binop.getY()) : x;
+        if (Starlark.truth(x)) {
+          return eval(fr, binop.getY());
+        }
+        CoverageRecorder.getInstance().recordVirtualJump(binop);
+        return x;
       case OR:
-        return Starlark.truth(x) ? x : eval(fr, binop.getY());
+        if (Starlark.truth(x)) {
+          CoverageRecorder.getInstance().recordVirtualJump(binop);
+          return x;
+        }
+        return eval(fr, binop.getY());
       default:
         Object y = eval(fr, binop.getY());
         try {
@@ -577,7 +599,9 @@ private static Object evalDict(StarlarkThread.Frame fr, DictExpression dictexpr)
   private static Object evalDot(StarlarkThread.Frame fr, DotExpression dot)
       throws EvalException, InterruptedException {
     Object object = eval(fr, dot.getObject());
-    String name = dot.getField().getName();
+    Identifier field = dot.getField();
+    CoverageRecorder.getInstance().recordCoverage(field);
+    String name = field.getName();
     try {
       return Starlark.getattr(
           fr.thread.mutability(), fr.thread.getSemantics(), object, name, /*defaultValue=*/ null);
@@ -627,6 +651,7 @@ private static Object evalCall(StarlarkThread.Frame fr, CallExpression call)
     Object[] positional = npos == 0 ? EMPTY : new Object[npos];
     for (i = 0; i < npos; i++) {
       Argument arg = arguments.get(i);
+      CoverageRecorder.getInstance().recordCoverage(arg);
       Object value = eval(fr, arg.getValue());
       positional[i] = value;
     }
@@ -635,6 +660,7 @@ private static Object evalCall(StarlarkThread.Frame fr, CallExpression call)
     Object[] named = n == npos ? EMPTY : new Object[2 * (n - npos)];
     for (int j = 0; i < n; i++) {
       Argument.Keyword arg = (Argument.Keyword) arguments.get(i);
+      CoverageRecorder.getInstance().recordCoverage(arg);
       Object value = eval(fr, arg.getValue());
       named[j++] = arg.getName();
       named[j++] = value;
@@ -642,6 +668,7 @@ private static Object evalCall(StarlarkThread.Frame fr, CallExpression call)
 
     // f(*args) -- varargs
     if (star != null) {
+      CoverageRecorder.getInstance().recordCoverage(star);
       Object value = eval(fr, star.getValue());
       if (!(value instanceof StarlarkIterable)) {
         fr.setErrorLocation(star.getStartLocation());
@@ -656,6 +683,7 @@ private static Object evalCall(StarlarkThread.Frame fr, CallExpression call)
 
     // f(**kwargs)
     if (starstar != null) {
+      CoverageRecorder.getInstance().recordCoverage(starstar);
       Object value = eval(fr, starstar.getValue());
       if (!(value instanceof Dict)) {
         fr.setErrorLocation(starstar.getStartLocation());
@@ -791,6 +819,7 @@ void execClauses(int index) throws EvalException, InterruptedException {
                 assign(fr, forClause.getVars(), elem);
                 execClauses(index + 1);
               }
+              CoverageRecorder.getInstance().recordVirtualJump(clause);
             } catch (EvalException ex) {
               fr.setErrorLocation(forClause.getStartLocation());
               throw ex;
@@ -802,12 +831,15 @@ void execClauses(int index) throws EvalException, InterruptedException {
             Comprehension.If ifClause = (Comprehension.If) clause;
             if (Starlark.truth(eval(fr, ifClause.getCondition()))) {
               execClauses(index + 1);
+            } else {
+              CoverageRecorder.getInstance().recordVirtualJump(clause);
             }
           }
           return;
         }
 
         // base case: evaluate body and add to result.
+        CoverageRecorder.getInstance().recordCoverage(comp.getBody());
         if (dict != null) {
           DictExpression.Entry body = (DictExpression.Entry) comp.getBody();
           Object k = eval(fr, body.getKey());
diff --git a/src/main/java/net/starlark/java/eval/Starlark.java b/src/main/java/net/starlark/java/eval/Starlark.java
index c014a11ece578c..8d2e813794e108 100644
--- a/src/main/java/net/starlark/java/eval/Starlark.java
+++ b/src/main/java/net/starlark/java/eval/Starlark.java
@@ -23,6 +23,7 @@
 import com.google.errorprone.annotations.FormatMethod;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.io.PrintWriter;
 import java.lang.reflect.Method;
 import java.math.BigInteger;
 import java.time.Duration;
@@ -30,12 +31,15 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.function.Function;
+import java.util.regex.Pattern;
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.Immutable;
 import net.starlark.java.annot.StarlarkAnnotations;
 import net.starlark.java.annot.StarlarkBuiltin;
 import net.starlark.java.annot.StarlarkMethod;
 import net.starlark.java.spelling.SpellChecker;
+import net.starlark.java.syntax.CoverageRecorder;
 import net.starlark.java.syntax.Expression;
 import net.starlark.java.syntax.FileOptions;
 import net.starlark.java.syntax.ParserInput;
@@ -970,4 +974,12 @@ public static boolean startCpuProfile(OutputStream out, Duration period) {
   public static void stopCpuProfile() throws IOException {
     CpuProfiler.stop();
   }
+
+  public static void startCoverageCollection(Function<String, Boolean> filenameMatcher) {
+    CoverageRecorder.startCoverageCollection(filenameMatcher);
+  }
+
+  public static void dumpCoverage(PrintWriter out) {
+    CoverageRecorder.getInstance().dump(out);
+  }
 }
diff --git a/src/main/java/net/starlark/java/syntax/BUILD b/src/main/java/net/starlark/java/syntax/BUILD
index 967fb3210b414d..88c857eaa096c7 100644
--- a/src/main/java/net/starlark/java/syntax/BUILD
+++ b/src/main/java/net/starlark/java/syntax/BUILD
@@ -21,6 +21,8 @@ java_library(
         "Comment.java",
         "Comprehension.java",
         "ConditionalExpression.java",
+        "CoverageRecorder.java",
+        "CoverageVisitor.java",
         "DefStatement.java",
         "DictExpression.java",
         "DotExpression.java",
diff --git a/src/main/java/net/starlark/java/syntax/CoverageRecorder.java b/src/main/java/net/starlark/java/syntax/CoverageRecorder.java
new file mode 100644
index 00000000000000..0264c611de53cf
--- /dev/null
+++ b/src/main/java/net/starlark/java/syntax/CoverageRecorder.java
@@ -0,0 +1,334 @@
+// Copyright 2022 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 net.starlark.java.syntax;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.LongAdder;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import javax.annotation.Nullable;
+
+public interface CoverageRecorder {
+
+  void register(Program program);
+
+  void recordCoverage(Node node);
+
+  void recordVirtualJump(Node node);
+
+  void dump(PrintWriter out);
+
+  static CoverageRecorder getInstance() {
+    return CoverageRecorderHolder.INSTANCE;
+  }
+
+  /**
+   * Collect coverage for all {@link Program}s compiled after the call whose
+   * {@link Program#getFilename()} matches {@code filenameMatcher}.
+   */
+  static void startCoverageCollection(Function<String, Boolean> filenameMatcher) {
+    CoverageRecorderHolder.INSTANCE = new LcovCoverageRecorder(filenameMatcher);
+  }
+
+  class CoverageRecorderHolder {
+
+    private static CoverageRecorder INSTANCE = new NoopCoverageRecorder();
+
+    private CoverageRecorderHolder() {
+    }
+  }
+}
+
+final class NoopCoverageRecorder implements CoverageRecorder {
+
+  @Override
+  public void register(Program program) {
+  }
+
+  @Override
+  public void recordCoverage(Node node) {
+  }
+
+  @Override
+  public void recordVirtualJump(Node node) {
+  }
+
+  @Override
+  public void dump(PrintWriter out) {
+  }
+}
+
+/**
+ * A {@link CoverageRecorder} that records function, line, and branch coverage for all Starlark
+ * {@link Program}s matching the provided {@code filenameMatcher}. Calling
+ * {@link LcovCoverageRecorder#dump(PrintWriter)} emits LCOV records for all matched files.
+ */
+final class LcovCoverageRecorder implements CoverageRecorder {
+
+  private final Function<String, Boolean> filenameMatcher;
+
+  /**
+   * Tracks the number of times a given {@link Node} has been executed.
+   */
+  private final ConcurrentHashMap<Node, LongAdder> counts = new ConcurrentHashMap<>();
+
+  /**
+   * Tracks the number of times a conditional jump without a syntax tree representation has been
+   * executed which is associated with the given {@link Node}. Examples: - The "condition not
+   * satisfied" jump of an {@code if} without an {@code else}. - The "short-circuit" jump of an
+   * {@code and} or {@code or}.
+   */
+  private final ConcurrentHashMap<Node, LongAdder> virtualJumpCounts = new ConcurrentHashMap<>();
+
+  private final Set<Program> registeredPrograms = ConcurrentHashMap.newKeySet();
+
+  LcovCoverageRecorder(Function<String, Boolean> filenameMatcher) {
+    this.filenameMatcher = filenameMatcher;
+  }
+
+  @Override
+  public void register(Program program) {
+    if (!filenameMatcher.apply(program.getFilename())) {
+      return;
+    }
+    registeredPrograms.add(program);
+    for (Statement statement : program.getResolvedFunction().getBody()) {
+      statement.accept(new CoverageVisitor() {
+        @Override
+        protected void visitFunction(String identifier, Node defStatement,
+            Node firstBodyStatement) {
+        }
+
+        @Override
+        protected void visitBranch(Node owner, Node condition, Node positiveUniqueSuccessor,
+            @Nullable Node negativeUniqueSuccessor) {
+          if (negativeUniqueSuccessor == null) {
+            virtualJumpCounts.put(owner, new LongAdder());
+          }
+          // positiveUniqueSuccessor will be registered via a call to visitCode.
+        }
+
+        @Override
+        protected void visitCode(Node node) {
+          counts.put(node, new LongAdder());
+        }
+      });
+    }
+  }
+
+  @Override
+  public void recordCoverage(Node node) {
+    LongAdder counter = counts.get(node);
+    if (counter == null) {
+      return;
+    }
+    counter.increment();
+  }
+
+  @Override
+  public void recordVirtualJump(Node node) {
+    LongAdder counter = virtualJumpCounts.get(node);
+    if (counter == null) {
+      return;
+    }
+    counter.increment();
+  }
+
+  @Override
+  public void dump(PrintWriter out) {
+    registeredPrograms.stream()
+        .sorted(Comparator.comparing(Program::getFilename))
+        .forEachOrdered(program -> {
+          CoverageNodeVisitor visitor = new CoverageNodeVisitor(program);
+          visitor.visitAll(program.getResolvedFunction().getBody());
+          visitor.dump(out);
+        });
+    out.close();
+  }
+
+  class CoverageNodeVisitor extends CoverageVisitor {
+
+    private final String filename;
+    private final List<FunctionInfo> functions = new ArrayList<>();
+    private final List<BranchInfo> branches = new ArrayList<>();
+    private final Map<Integer, Long> lines = new HashMap<>();
+
+    CoverageNodeVisitor(Program program) {
+      filename = program.getFilename();
+    }
+
+    @Override
+    protected void visitFunction(String identifier, Node defStatement, Node firstBodyStatement) {
+      functions.add(new FunctionInfo(identifier, defStatement.getStartLocation().line(),
+          counts.get(firstBodyStatement).sum()));
+    }
+
+    @Override
+    protected void visitBranch(Node owner, Node condition, Node positiveUniqueSuccessor,
+        @Nullable Node negativeUniqueSuccessor) {
+      int ownerLine = owner.getStartLocation().line();
+      if (counts.get(condition).sum() == 0) {
+        // The branch condition has never been executed.
+        branches.add(new BranchInfo(ownerLine, null, null));
+      } else {
+        branches.add(new BranchInfo(ownerLine,
+            counts.get(positiveUniqueSuccessor).sum(),
+            negativeUniqueSuccessor != null
+                ? counts.get(negativeUniqueSuccessor).sum()
+                : virtualJumpCounts.get(owner).sum()));
+      }
+    }
+
+    @Override
+    protected void visitCode(Node node) {
+      // Update the coverage count for the lines spanned by this node. This is correct since the
+      // CoverageVisitor visits nodes from outermost to innermost lexical scope.
+      linesToMarkCovered(node).forEach(line -> lines.put(line, counts.get(node).sum()));
+    }
+
+    void dump(PrintWriter out) {
+      out.println(String.format("SF:%s", filename));
+
+      List<FunctionInfo> sortedFunctions = functions.stream()
+          .sorted(Comparator.<FunctionInfo>comparingInt(fi -> fi.line)
+              .thenComparing(fi -> fi.identifier))
+          .collect(Collectors.toList());
+      for (FunctionInfo info : sortedFunctions) {
+        out.println(String.format("FN:%d,%s", info.line, info.identifier));
+      }
+      int numExecutedFunctions = 0;
+      for (FunctionInfo info : sortedFunctions) {
+        if (info.count > 0) {
+          numExecutedFunctions++;
+        }
+        out.println(String.format("FNDA:%d,%s", info.count, info.identifier));
+      }
+      out.println(String.format("FNF:%d", functions.size()));
+      out.println(String.format("FNH:%d", numExecutedFunctions));
+
+      branches.sort(Comparator.comparing(lc -> lc.ownerLine));
+      int numExecutedBranches = 0;
+      for (int id = 0; id < branches.size(); id++) {
+        BranchInfo info = branches.get(id);
+        if (info.positiveCount != null && info.positiveCount > 0) {
+          numExecutedBranches++;
+        }
+        if (info.negativeCount != null && info.negativeCount > 0) {
+          numExecutedBranches++;
+        }
+        // By assigning the same block id to both branches, the coverage viewer will know to group
+        // them together.
+        out.println(String.format("BRDA:%d,%d,%d,%s",
+            info.ownerLine,
+            id,
+            0,
+            info.positiveCount == null ? "-" : info.positiveCount));
+        out.println(String.format("BRDA:%d,%d,%d,%s",
+            info.ownerLine,
+            id,
+            1,
+            info.negativeCount == null ? "-" : info.negativeCount));
+      }
+      out.println(String.format("BRF:%d", branches.size()));
+      out.println(String.format("BRH:%d", numExecutedBranches));
+
+      List<Integer> sortedLines = lines.keySet().stream().sorted().collect(Collectors.toList());
+      int numExecutedLines = 0;
+      for (int line : sortedLines) {
+        long count = lines.get(line);
+        if (count > 0) {
+          numExecutedLines++;
+        }
+        out.println(String.format("DA:%d,%d", line, count));
+      }
+      out.println(String.format("LF:%d", lines.size()));
+      out.println(String.format("LH:%d", numExecutedLines));
+
+      out.println("end_of_record");
+    }
+
+    /**
+     * Given a node in the AST, returns an {@link IntStream} that yields all source file lines to
+     * which the coverage information of {@code node} should be propagated.
+     * <p>
+     * This usually returns all lines between the start and end location of {@code node}, but may
+     * return fewer lines for block statements such as {@code if}.
+     */
+    private IntStream linesToMarkCovered(Node node) {
+      if (!(node instanceof Statement)) {
+        return IntStream.rangeClosed(node.getStartLocation().line(), node.getEndLocation().line());
+      }
+      // Handle block statements specially so that they don't mark their entire scope as covered,
+      // which would also include comments and empty lines.
+      switch (((Statement) node).kind()) {
+        case IF:
+          return IntStream.rangeClosed(node.getStartLocation().line(),
+              ((IfStatement) node).getCondition().getEndLocation().line());
+        case FOR:
+          return IntStream.rangeClosed(node.getStartLocation().line(),
+              ((ForStatement) node).getCollection().getEndLocation().line());
+        case DEF:
+          DefStatement defStatement = (DefStatement) node;
+          if (defStatement.getParameters().isEmpty()) {
+            return IntStream.of(node.getStartLocation().line());
+          }
+          Parameter lastParam = defStatement.getParameters()
+              .get(defStatement.getParameters().size() - 1);
+          return IntStream.rangeClosed(defStatement.getStartLocation().line(),
+              lastParam.getEndLocation().line());
+        default:
+          return IntStream.rangeClosed(node.getStartLocation().line(),
+              node.getEndLocation().line());
+      }
+    }
+
+    private class FunctionInfo {
+
+      final String identifier;
+      final int line;
+      final long count;
+
+      FunctionInfo(String identifier, int line, long count) {
+        this.identifier = identifier;
+        this.line = line;
+        this.count = count;
+      }
+    }
+
+    private class BranchInfo {
+
+      final int ownerLine;
+      // Both positiveCount and negativeCount are null if the branch condition hasn't been executed.
+      // Otherwise, they give the number of times the positive case jump resp. the negative case
+      // jump was taken (and are in particular not null).
+      final Long positiveCount;
+      final Long negativeCount;
+
+      BranchInfo(int ownerLine, @Nullable Long positiveCount, @Nullable Long negativeCount) {
+        this.ownerLine = ownerLine;
+        this.positiveCount = positiveCount;
+        this.negativeCount = negativeCount;
+      }
+    }
+  }
+}
diff --git a/src/main/java/net/starlark/java/syntax/CoverageVisitor.java b/src/main/java/net/starlark/java/syntax/CoverageVisitor.java
new file mode 100644
index 00000000000000..e22312c3388b24
--- /dev/null
+++ b/src/main/java/net/starlark/java/syntax/CoverageVisitor.java
@@ -0,0 +1,331 @@
+// Copyright 2022 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 net.starlark.java.syntax;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+
+abstract class CoverageVisitor extends NodeVisitor {
+
+  private static class FunctionFrame {
+
+    final String name;
+    int lambdaCount;
+
+    FunctionFrame(String name) {
+      this.name = name;
+      this.lambdaCount = 0;
+    }
+  }
+
+  private final List<FunctionFrame> functionStack = new ArrayList<>();
+
+  CoverageVisitor() {
+    functionStack.add(new FunctionFrame("<top-level>"));
+  }
+
+  /**
+   * Called for every (possibly nested, possibly lambda) function.
+   *
+   * @param identifier         a human-readable identifier for the function that is unique within
+   *                           the entire source file
+   * @param defStatement       the {@link Node} representing the function definition
+   * @param firstBodyStatement the {@link Node} representing the first statement of the function's
+   *                           body, which can be used to track how often the function has been
+   *                           executed
+   */
+  abstract protected void visitFunction(String identifier, Node defStatement,
+      Node firstBodyStatement);
+
+  /**
+   * Called for every conditional jump to either one of two successor nodes depending on a
+   * condition.
+   * <p>
+   * Note: Any conditional branch in Starlark always has two successors, never more.
+   *
+   * @param owner                   the {@code Node} at whose location the branch should be
+   *                                reported
+   * @param condition               the {@code Node} representing the branch condition
+   * @param positiveUniqueSuccessor a {@code Node} that is executed if and only if the "positive"
+   *                                branch has been taken (e.g., the if condition was satisfied or
+   *                                the iterable in a for loop has more elements). The node must not
+   *                                be executed in any other situation.
+   * @param negativeUniqueSuccessor a {@code Node} that is executed if and only if the "negative"
+   *                                branch has been taken (e.g., the if condition was not satisfied
+   *                                or the iterable in a for loop contains no more elements). The
+   *                                node must not be executed in any other situation. May be
+   *                                {@code null}, in which case the branch has to be marked as
+   *                                executed manually via a call to
+   *                                {@link CoverageRecorder#recordVirtualJump(Node)} with the
+   *                                argument {@code owner}.
+   */
+  abstract protected void visitBranch(Node owner, Node condition, Node positiveUniqueSuccessor,
+      @Nullable Node negativeUniqueSuccessor);
+
+  /**
+   * Called for every {@code Node} that corresponds to executable code. If node A contains node B in
+   * its lexical scope, then {@code visitCode(A)} is called before {@code visitCode(B)}.
+   */
+  abstract protected void visitCode(Node node);
+
+  private String enterFunction(Identifier identifier) {
+    String name = identifier != null
+        ? identifier.getName()
+        : "lambda " + functionStack.get(functionStack.size() - 1).lambdaCount++;
+    functionStack.add(new FunctionFrame(name));
+    return functionStack.stream().skip(1).map(f -> f.name).collect(Collectors.joining(" > "));
+  }
+
+  private void leaveFunction() {
+    functionStack.remove(functionStack.size() - 1);
+  }
+
+  @Override
+  final public void visit(Argument node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(Parameter node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(@Nullable Identifier node) {
+    // node can be null in the case of an anonymous vararg parameter, e.g.:
+    //  def f(..., *, ...): ...
+    if (node != null) {
+      visitCode(node);
+    }
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(BinaryOperatorExpression node) {
+    visitCode(node);
+    if (node.getOperator() == TokenKind.AND || node.getOperator() == TokenKind.OR) {
+      // Manually track the short-circuit case.
+      visitBranch(node, node.getX(), node.getY(), null);
+    }
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(CallExpression node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  private Node getClauseCondition(Node clause) {
+    return clause instanceof Comprehension.If
+        ? ((Comprehension.If) clause).getCondition()
+        : ((Comprehension.For) clause).getIterable();
+  }
+
+  private void visitClauseBranches(Node clause, Node successor) {
+    Node condition = getClauseCondition(clause);
+    visitBranch(clause, condition, successor, null);
+  }
+
+  @Override
+  final public void visit(Comprehension node) {
+    Comprehension.Clause lastClause = null;
+    for (Comprehension.Clause clause : node.getClauses()) {
+      visitCode(clause);
+      if (lastClause != null) {
+        visitClauseBranches(lastClause, getClauseCondition(clause));
+      }
+      lastClause = clause;
+      visit(clause);
+    }
+    if (lastClause != null) {
+      visitClauseBranches(lastClause, node.getBody());
+    }
+    visit(node.getBody());
+  }
+
+  @Override
+  final public void visit(Comprehension.For node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(Comprehension.If node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(ForStatement node) {
+    visitCode(node);
+    visitBranch(node, node.getCollection(), node.getBody().get(0), null);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(ListExpression node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(IntLiteral node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(FloatLiteral node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(StringLiteral node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(AssignmentStatement node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(ExpressionStatement node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(IfStatement node) {
+    visitCode(node);
+    visitBranch(node,
+        node.getCondition(),
+        node.getThenBlock().get(0),
+        node.getElseBlock() != null ? node.getElseBlock().get(0) : null);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(DefStatement node) {
+    visitCode(node);
+    visitFunction(enterFunction(node.getIdentifier()), node, node.getBody().get(0));
+    super.visit(node);
+    leaveFunction();
+  }
+
+  @Override
+  final public void visit(ReturnStatement node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(FlowStatement node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(DictExpression node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(DictExpression.Entry node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(UnaryOperatorExpression node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(DotExpression node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(IndexExpression node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(LambdaExpression node) {
+    visitCode(node);
+    visitFunction(enterFunction(null), node, node.getBody());
+    super.visit(node);
+    leaveFunction();
+  }
+
+  @Override
+  final public void visit(SliceExpression node) {
+    visitCode(node);
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(ConditionalExpression node) {
+    visitCode(node);
+    visitBranch(node, node.getCondition(), node.getThenCase(), node.getElseCase());
+    super.visit(node);
+  }
+
+  // The following functions intentionally do not call visitCode as their nodes do not correspond to
+  // executable code or they already delegate to functions that do.
+
+  @Override
+  final public void visit(LoadStatement node) {
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(Comment node) {
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(Node node) {
+    super.visit(node);
+  }
+
+  @Override
+  final public void visit(StarlarkFile node) {
+    super.visit(node);
+  }
+
+  @Override
+  final public void visitAll(List<? extends Node> nodes) {
+    super.visitAll(nodes);
+  }
+
+  @Override
+  final public void visitBlock(List<Statement> statements) {
+    super.visitBlock(statements);
+  }
+}
diff --git a/src/main/java/net/starlark/java/syntax/NodePrinter.java b/src/main/java/net/starlark/java/syntax/NodePrinter.java
index 7d6cebf8d3f501..4fcf4f26af5478 100644
--- a/src/main/java/net/starlark/java/syntax/NodePrinter.java
+++ b/src/main/java/net/starlark/java/syntax/NodePrinter.java
@@ -41,6 +41,9 @@ void printNode(Node n) {
     } else if (n instanceof Statement) {
       printStmt((Statement) n);
 
+    } else if (n instanceof Comprehension.Clause) {
+      printClause((Comprehension.Clause) n);
+
     } else if (n instanceof StarlarkFile) {
       StarlarkFile file = (StarlarkFile) n;
       // Only statements are printed, not comments.
@@ -277,17 +280,7 @@ private void printExpr(Expression expr) {
           printNode(comp.getBody()); // Expression or DictExpression.Entry
           for (Comprehension.Clause clause : comp.getClauses()) {
             buf.append(' ');
-            if (clause instanceof Comprehension.For) {
-              Comprehension.For forClause = (Comprehension.For) clause;
-              buf.append("for ");
-              printExpr(forClause.getVars());
-              buf.append(" in ");
-              printExpr(forClause.getIterable());
-            } else {
-              Comprehension.If ifClause = (Comprehension.If) clause;
-              buf.append("if ");
-              printExpr(ifClause.getCondition());
-            }
+            printClause(clause);
           }
           buf.append(comp.isDict() ? '}' : ']');
           break;
@@ -482,4 +475,18 @@ private void printExpr(Expression expr) {
         }
     }
   }
+
+  private void printClause(Comprehension.Clause clause) {
+    if (clause instanceof Comprehension.For) {
+      Comprehension.For forClause = (Comprehension.For) clause;
+      buf.append("for ");
+      printExpr(forClause.getVars());
+      buf.append(" in ");
+      printExpr(forClause.getIterable());
+    } else {
+      Comprehension.If ifClause = (Comprehension.If) clause;
+      buf.append("if ");
+      printExpr(ifClause.getCondition());
+    }
+  }
 }
diff --git a/src/main/java/net/starlark/java/syntax/NodeVisitor.java b/src/main/java/net/starlark/java/syntax/NodeVisitor.java
index 096f9506a46f8a..e7ba3b81375b85 100644
--- a/src/main/java/net/starlark/java/syntax/NodeVisitor.java
+++ b/src/main/java/net/starlark/java/syntax/NodeVisitor.java
@@ -174,9 +174,7 @@ public void visit(@SuppressWarnings("unused") Comment node) {}
   public void visit(ConditionalExpression node) {
     visit(node.getCondition());
     visit(node.getThenCase());
-    if (node.getElseCase() != null) {
-      visit(node.getElseCase());
-    }
+    visit(node.getElseCase());
   }
 
   // methods dealing with sequences of nodes
diff --git a/src/main/java/net/starlark/java/syntax/Parameter.java b/src/main/java/net/starlark/java/syntax/Parameter.java
index 8b7da9b1a42fa0..516670a4624fa3 100644
--- a/src/main/java/net/starlark/java/syntax/Parameter.java
+++ b/src/main/java/net/starlark/java/syntax/Parameter.java
@@ -116,6 +116,9 @@ public int getStartOffset() {
 
     @Override
     public int getEndOffset() {
+      if (getIdentifier() == null) {
+        return starOffset + 1;
+      }
       return getIdentifier().getEndOffset();
     }
   }
diff --git a/src/main/java/net/starlark/java/syntax/Program.java b/src/main/java/net/starlark/java/syntax/Program.java
index 1bf6547584f57d..9172957a0007b5 100644
--- a/src/main/java/net/starlark/java/syntax/Program.java
+++ b/src/main/java/net/starlark/java/syntax/Program.java
@@ -85,7 +85,9 @@ public static Program compileFile(StarlarkFile file, Resolver.Module env)
       }
     }
 
-    return new Program(file.getResolvedFunction(), loads.build(), loadLocations.build());
+    Program program = new Program(file.getResolvedFunction(), loads.build(), loadLocations.build());
+    CoverageRecorder.getInstance().register(program);
+    return program;
   }
 
   /**
diff --git a/src/main/protobuf/failure_details.proto b/src/main/protobuf/failure_details.proto
index c8a87214765368..6f475f47baf43b 100644
--- a/src/main/protobuf/failure_details.proto
+++ b/src/main/protobuf/failure_details.proto
@@ -533,6 +533,7 @@ message Command {
     NOT_IN_WORKSPACE = 12 [(metadata) = { exit_code: 2 }];
     SPACES_IN_WORKSPACE_PATH = 13 [(metadata) = { exit_code: 36 }];
     IN_OUTPUT_DIRECTORY = 14 [(metadata) = { exit_code: 2 }];
+    STARLARK_COVERAGE_REPORT_DUMP_FAILURE = 15 [(metadata) = { exit_code: 36 }];
   }
 
   Code code = 1;