Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci-rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,14 @@ jobs:
grep -Eq '^\s+lib\.rs [1-9][0-9]*/' cover.log
env:
VERITAS_NO_HOOK: "1"

- name: Raw lcov export smoke test (functions and branches)
run: |
./pleasew --plain_output cover //test/rust/branches:branches_test 2>&1 | tee cover.log
f="plz-out/bin/test/rust/branches/branches_test.lcov"
grep -q '^SF:test/rust/branches/branches\.rs$' "$f"
grep -Eq '^FNDA:0,.*unused$' "$f"
grep -q '^BRDA:12,0,0,0$' "$f"
grep -Eq '^FNDA:2,.*classify$' "$f"
env:
VERITAS_NO_HOOK: "1"
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.6.0] - 2026-10-03

### Added

- `rust_test` keeps the raw lcov report as a declared test output,
`plz-out/bin/<package>/<name>.lcov`, with repository-relative paths, function
records (`FN`/`FNDA`, rustc v0-mangled names) and, for lines that ran only
partly, synthesized branch records (`BRDA`) derived from `llvm-cov`'s sub-line
regions: stable Rust emits no branch regions, so this covers cases such as the
right-hand side of `a && b` or a one-line `if` body that never ran. It is
written under `plz cover` and is empty otherwise.
- `please_rust test-runner --lcov-file` flag.
- Branchy coverage fixture (`test/rust/branches:branches_test`) and a CI smoke
test asserting the exported functions and branches.

### Fixed

- Raw profiles were merged twice under Please (the test directory is also the
working directory), doubling every execution count in the lcov report. They
are now listed once.

- `rust_test` ignored `LlvmProfdataTool`, `LlvmCovTool` and `Coverage` (plugin
config was read with `CONFIG.get`, which does not see it), so `llvm-profdata`
was taken from the host. With an older host LLVM the merge failed
Expand All @@ -21,6 +38,8 @@ and this project adheres to

- Documented the Rust coverage report location, format and contents, and the
silent `No data` result when `[cover] fileextension = .rs` is missing.
- Documented the raw lcov export: location, contents, how partial lines are
derived, lifetime.
- Added a CI coverage smoke test.

## [0.5.0] - 2026-09-19
Expand Down
3 changes: 2 additions & 1 deletion build_defs/rust/rust.build_defs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ def rust_test(
profdata_arg = f"--llvm-profdata '{llvm_profdata}'" if llvm_profdata and not (llvm_profdata.startswith("//") or llvm_profdata.startswith("///") or llvm_profdata.startswith(":")) else '${TOOLS_LLVM_PROFDATA:+--llvm-profdata "$TOOLS_LLVM_PROFDATA"}'
cov_arg = f"--llvm-cov '{llvm_cov}'" if llvm_cov and not (llvm_cov.startswith("//") or llvm_cov.startswith("///") or llvm_cov.startswith(":")) else '${TOOLS_LLVM_COV:+--llvm-cov "$TOOLS_LLVM_COV"}'

test_cmd = f'TOOL="${{TOOLS_TOOL:-$TOOL}}" && $TOOL test-runner --pkg "{crate}" {profdata_arg} {cov_arg} "$TEST" --nocapture'
test_cmd = f'TOOL="${{TOOLS_TOOL:-$TOOL}}" && $TOOL test-runner --pkg "{crate}" {profdata_arg} {cov_arg} --lcov-file "{name}.lcov" "$TEST" --nocapture'
if env:
test_env_exports = " && ".join([f'export {k}="{v}"' for k, v in env.items()])
test_cmd = f"{test_env_exports} && {test_cmd}"
Expand All @@ -225,6 +225,7 @@ def rust_test(
outs = [name],
cmd = cmd,
test_cmd = test_cmd,
test_outputs = [f"{name}.lcov"],
binary = True,
test = True,
needs_transitive_deps = True,
Expand Down
33 changes: 29 additions & 4 deletions docs/rust/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,13 +313,38 @@ Display detailed, line-by-line covered source listings:
- **Location**: Please merges the per-test results into
`plz-out/log/coverage.xml` (Cobertura XML) and `plz-out/log/coverage.json`
(per-file line markers plus per-file and per-directory percentages).
- **Contents**: line coverage only. Branch coverage and per-function data are
not emitted, so consumers cannot join coverage with per-function complexity
from this report.
- **Contents**: line coverage only. Function and branch data are in the raw lcov
export described below.
- **Missing `[cover]` setting**: without `fileextension = .rs` the report is
silently `Total coverage: No data`.

### 5. Per-Rule Coverage Control
### 5. Raw lcov Export (functions and branches)

Each `rust_test` also keeps the `llvm-cov` report as lcov, with the function
(`FN`/`FNDA`) and branch (`BRDA`) records that `coverage.xml` and
`coverage.json` do not carry:

- **Location**: `plz-out/bin/<package>/<target name>.lcov`, for example
`plz-out/bin/test/rust/branches/branches_test.lcov`. It is declared as a test
output, so it is written next to the test binary.
- **Content**: lcov with repository-relative paths; toolchain and registry
sources are excluded. The file is empty unless the test ran under `plz cover`.
- **Functions**: `FN`/`FNDA` records. Names are rustc v0-mangled (`_R...`);
demangle them with `rustfilt` for display.
- **Partial lines**: stable `rustc` emits no branch regions, so `llvm-cov` has
no branch records of its own. Instead, a `BRDA` record is synthesized for
every line that ran but contains a sub-line region that never did, such as the
right-hand side of `a && b` or the body of a one-line `if`: one arm per region
starting on the line, in column order, with its count. This is a heuristic,
not source-level branches. If the arms of a branch are on separate lines (a
normal multi-line `if`/`else`), the untaken side shows up as an uncovered
line, not as a partial one. Lines whose sub-line regions all ran carry no
branch record.
- **Lifetime**: the file reflects the last test run that actually executed. Read
it right after `./pleasew cover --rerun <target>`; do not assume it is
recreated when Please reuses a cached result.

### 6. Per-Rule Coverage Control

You can enable or disable coverage instrumentation on individual rules:

Expand Down
15 changes: 15 additions & 0 deletions test/rust/branches/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
subinclude("//build_defs/rust:rust")

rust_library(
name = "branches",
srcs = ["branches.rs"],
crate_name = "mybranches",
visibility = ["PUBLIC"],
)

rust_test(
name = "branches_test",
srcs = ["branches_test.rs"],
crate_name = "mybranches_test",
deps = [":branches"],
)
17 changes: 17 additions & 0 deletions test/rust/branches/branches.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
pub fn classify(n: i32) -> &'static str {
if n < 0 {
"neg"
} else if n == 0 {
"zero"
} else {
"pos"
}
}

pub fn both(a: bool, b: bool) -> bool {
a && b
}

pub fn unused() -> i32 {
42
}
12 changes: 12 additions & 0 deletions test/rust/branches/branches_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
use mybranches::{both, classify};

#[test]
fn test_classify() {
assert_eq!(classify(-1), "neg");
assert_eq!(classify(5), "pos");
}

#[test]
fn test_both() {
assert!(!both(false, true));
}
2 changes: 2 additions & 0 deletions tools/please_rust/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ func handleTestRunner(args []string) error {
coverageFile := cmd.String("coverage-file", "", "Path to write coverage report (default: $COVERAGE_FILE or test.coverage)")
llvmProfdata := cmd.String("llvm-profdata", "", "Path to llvm-profdata executable")
llvmCov := cmd.String("llvm-cov", "", "Path to llvm-cov executable")
lcovFile := cmd.String("lcov-file", "", "Path to write the raw lcov report (functions and branches)")
coverage := cmd.Bool("coverage", false, "Force coverage collection")

if err := cmd.Parse(args); err != nil {
Expand All @@ -145,6 +146,7 @@ func handleTestRunner(args []string) error {
ResultsFile: *resultsFile,
CoverageActive: *coverage,
CoverageFile: *coverageFile,
LcovFile: *lcovFile,
LlvmProfdata: *llvmProfdata,
LlvmCov: *llvmCov,
})
Expand Down
12 changes: 10 additions & 2 deletions tools/please_rust/testrunner/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ go_library(
srcs = [
"coverage.go",
"junit.go",
"lcov_export.go",
"runner.go",
],
deps = ["//tools/please_rust/toolchain"],
deps = [
"//tools/common/lcov",
"//tools/please_rust/toolchain",
],
visibility = ["//tools/please_rust/..."],
)

Expand All @@ -16,7 +20,11 @@ go_test(
srcs = [
"coverage_test.go",
"junit_test.go",
"lcov_export_test.go",
"runner_test.go",
],
deps = [":testrunner"],
deps = [
":testrunner",
"//tools/common/lcov",
],
)
55 changes: 55 additions & 0 deletions tools/please_rust/testrunner/lcov_export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package testrunner

import (
"bytes"
"fmt"
"os"
"path/filepath"

"tools/common/lcov"
)

// buildRawLcov turns llvm-cov's lcov export into the raw report kept for consumers:
// repository-relative paths, external and toolchain files dropped, and, from the
// JSON export (llvmJSON, may be empty), synthesized branch records for lines that
// ran only partly. Stable Rust emits no branch regions, so the lcov export itself
// has function records but no branches.
func buildRawLcov(lcovData, llvmJSON []byte, repoRoot string) ([]byte, error) {
report, err := lcov.Parse(bytes.NewReader(lcovData))
if err != nil {
return nil, fmt.Errorf("parsing lcov report: %w", err)
}
normalize := func(p string) string { return normalizePath(p, repoRoot) }

kept := report.Files[:0]
for _, f := range report.Files {
if normalize(f.Path) != "" {
kept = append(kept, f)
}
}
report.Files = kept
report.NormalizePaths(normalize)

if len(llvmJSON) > 0 {
if err := lcov.AddRegionBranches(report, bytes.NewReader(llvmJSON), normalize); err != nil {
return nil, err
}
}

var out bytes.Buffer
if err := report.Write(&out); err != nil {
return nil, err
}
return out.Bytes(), nil
}

// writeRawLcov writes data to path. An empty path disables the export.
func writeRawLcov(path string, data []byte) error {
if path == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
123 changes: 123 additions & 0 deletions tools/please_rust/testrunner/lcov_export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package testrunner

import (
"os"
"path/filepath"
"strings"
"testing"

"tools/common/lcov"
)

// llvm-cov lcov export (stable rustc, no branch regions) for a small fixture:
// classify(-1), classify(5) and both(false, true) ran; unused never did. The file is
// in the build sandbox, and a toolchain file is included too.
const rustLcovExport = `SF:/work/plz-out/tmp/pkg/lib._build/pkg/lib.rs
FN:11,both
FN:1,classify
FN:15,unused
FNDA:1,both
FNDA:2,classify
FNDA:0,unused
FNF:3
FNH:2
DA:1,2
DA:2,2
DA:3,1
DA:4,1
DA:5,0
DA:7,1
DA:11,1
DA:12,1
DA:15,0
LF:9
LH:7
BRF:0
BRH:0
end_of_record
SF:/rustc/abc123/library/core/src/fmt/mod.rs
FN:1,fmt
FNDA:3,fmt
DA:1,3
end_of_record
`

// The matching JSON export: line 12 is ` a && b`, whose right side never ran.
const rustJSONExport = `{"data":[{"files":[{"filename":"/work/plz-out/tmp/pkg/lib._build/pkg/lib.rs","segments":[
[1,1,2,true,true,false],[2,13,0,false,false,false],[3,9,1,true,true,false],[3,14,0,false,false,false],
[4,15,1,true,true,false],[4,21,0,false,false,false],[5,9,0,true,true,false],[5,15,0,false,false,false],
[7,9,1,true,true,false],[7,14,0,false,false,false],[9,1,2,true,true,false],[9,2,0,false,false,false],
[11,1,1,true,true,false],[12,6,0,false,false,false],[12,10,0,true,true,false],[12,11,0,false,false,false],
[13,1,1,true,true,false],[13,2,0,false,false,false],[15,1,0,true,true,false],[17,2,0,false,false,false]]}]}]}`

func TestBuildRawLcov(t *testing.T) {
data, err := buildRawLcov([]byte(rustLcovExport), []byte(rustJSONExport), "")
if err != nil {
t.Fatal(err)
}
report, err := lcov.Parse(strings.NewReader(string(data)))
if err != nil {
t.Fatal(err)
}
if len(report.Files) != 1 {
t.Fatalf("files = %d, want the toolchain file dropped", len(report.Files))
}
f := report.File("pkg/lib.rs")
if f == nil {
t.Fatalf("pkg/lib.rs missing, got %q", report.Files[0].Path)
}
if len(f.Functions) != 3 || f.FunctionsHit() != 2 {
t.Errorf("functions = %v", f.Functions)
}
for line, want := range map[int]lcov.Status{
12: lcov.Partial, // right side of && never ran
5: lcov.Uncovered, // body of the untaken arm
15: lcov.Uncovered, // function that never ran
2: lcov.Covered,
} {
if got := f.LineStatus(line); got != want {
t.Errorf("LineStatus(%d) = %v, want %v", line, got, want)
}
}
}

func TestBuildRawLcovWithoutRegionData(t *testing.T) {
data, err := buildRawLcov([]byte(rustLcovExport), nil, "")
if err != nil {
t.Fatal(err)
}
report, _ := lcov.Parse(strings.NewReader(string(data)))
f := report.File("pkg/lib.rs")
if f == nil || len(f.Branches) != 0 || f.LineStatus(12) != lcov.Covered {
t.Errorf("without the JSON export the report must still be written, lines only: %+v", f)
}
}

func TestBuildRawLcovInvalidInput(t *testing.T) {
if _, err := buildRawLcov([]byte("DA:1,1\n"), nil, ""); err == nil {
t.Error("expected an error for a record outside an SF section")
}
if _, err := buildRawLcov([]byte(rustLcovExport), []byte("not json"), ""); err == nil {
t.Error("expected an error for an invalid JSON export")
}
}

func TestWriteRawLcov(t *testing.T) {
path := filepath.Join(t.TempDir(), "sub", "t.lcov")
if err := writeRawLcov(path, []byte("SF:a\n")); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(path); string(got) != "SF:a\n" {
t.Errorf("got %q", got)
}
if err := writeRawLcov("", []byte("x")); err != nil {
t.Errorf("an empty path must be a no-op, got %v", err)
}
// Resetting to empty, as RunWithOptions does before the test runs.
if err := writeRawLcov(path, nil); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(path); len(got) != 0 {
t.Errorf("expected an empty file, got %q", got)
}
}
Loading
Loading