From db6df75e156570f4c898f59ce85bc12b0d7cfb37 Mon Sep 17 00:00:00 2001 From: Walter Pinto Date: Sat, 3 Oct 2026 14:08:07 +0100 Subject: [PATCH] feat(rust): export the raw lcov report of rust_test targets Keep the llvm-cov report as a declared test output, .lcov: repository-relative paths, function records and, from the JSON export's sub-line regions, synthesized branch records for partly executed lines. Also stop merging raw profiles twice under Please, which doubled every execution count. Phase 2 of #46 for Rust. Adds a branchy fixture, unit tests, docs, a changelog entry for 0.6.0 and a CI smoke test. Refs #46 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci-rust.yml | 11 ++ CHANGELOG.md | 21 ++- build_defs/rust/rust.build_defs | 3 +- docs/rust/usage.md | 33 ++++- test/rust/branches/BUILD | 15 +++ test/rust/branches/branches.rs | 17 +++ test/rust/branches/branches_test.rs | 12 ++ tools/please_rust/commands.go | 2 + tools/please_rust/testrunner/BUILD | 12 +- tools/please_rust/testrunner/lcov_export.go | 55 ++++++++ .../testrunner/lcov_export_test.go | 123 ++++++++++++++++++ tools/please_rust/testrunner/runner.go | 62 ++++++++- tools/please_rust/testrunner/runner_test.go | 20 +++ 13 files changed, 374 insertions(+), 12 deletions(-) create mode 100644 test/rust/branches/BUILD create mode 100644 test/rust/branches/branches.rs create mode 100644 test/rust/branches/branches_test.rs create mode 100644 tools/please_rust/testrunner/lcov_export.go create mode 100644 tools/please_rust/testrunner/lcov_export_test.go diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 3c664bb..f84e357 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c6ab7..da643f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//.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 @@ -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 diff --git a/build_defs/rust/rust.build_defs b/build_defs/rust/rust.build_defs index 2792b1e..0631f5a 100644 --- a/build_defs/rust/rust.build_defs +++ b/build_defs/rust/rust.build_defs @@ -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}" @@ -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, diff --git a/docs/rust/usage.md b/docs/rust/usage.md index e8cd5ab..723d7db 100644 --- a/docs/rust/usage.md +++ b/docs/rust/usage.md @@ -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//.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 `; 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: diff --git a/test/rust/branches/BUILD b/test/rust/branches/BUILD new file mode 100644 index 0000000..6fbbc73 --- /dev/null +++ b/test/rust/branches/BUILD @@ -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"], +) diff --git a/test/rust/branches/branches.rs b/test/rust/branches/branches.rs new file mode 100644 index 0000000..bb1ad67 --- /dev/null +++ b/test/rust/branches/branches.rs @@ -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 +} diff --git a/test/rust/branches/branches_test.rs b/test/rust/branches/branches_test.rs new file mode 100644 index 0000000..a7ef43f --- /dev/null +++ b/test/rust/branches/branches_test.rs @@ -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)); +} diff --git a/tools/please_rust/commands.go b/tools/please_rust/commands.go index 6b0a6ea..fcffe4a 100644 --- a/tools/please_rust/commands.go +++ b/tools/please_rust/commands.go @@ -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 { @@ -145,6 +146,7 @@ func handleTestRunner(args []string) error { ResultsFile: *resultsFile, CoverageActive: *coverage, CoverageFile: *coverageFile, + LcovFile: *lcovFile, LlvmProfdata: *llvmProfdata, LlvmCov: *llvmCov, }) diff --git a/tools/please_rust/testrunner/BUILD b/tools/please_rust/testrunner/BUILD index 6d487dc..e0331fc 100644 --- a/tools/please_rust/testrunner/BUILD +++ b/tools/please_rust/testrunner/BUILD @@ -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/..."], ) @@ -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", + ], ) diff --git a/tools/please_rust/testrunner/lcov_export.go b/tools/please_rust/testrunner/lcov_export.go new file mode 100644 index 0000000..a0bbb43 --- /dev/null +++ b/tools/please_rust/testrunner/lcov_export.go @@ -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) +} diff --git a/tools/please_rust/testrunner/lcov_export_test.go b/tools/please_rust/testrunner/lcov_export_test.go new file mode 100644 index 0000000..c4f9d70 --- /dev/null +++ b/tools/please_rust/testrunner/lcov_export_test.go @@ -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) + } +} diff --git a/tools/please_rust/testrunner/runner.go b/tools/please_rust/testrunner/runner.go index 28cfcf9..8939d06 100644 --- a/tools/please_rust/testrunner/runner.go +++ b/tools/please_rust/testrunner/runner.go @@ -20,6 +20,7 @@ type RunOptions struct { ResultsFile string CoverageActive bool CoverageFile string + LcovFile string // raw lcov export (functions and branches), written empty without coverage LlvmProfdata string LlvmCov string } @@ -53,6 +54,28 @@ func resolveTmpDir() string { return os.TempDir() } +// findProfrawFiles lists the raw profiles in tmpDir and the working directory, once +// each. Under Please the two are the same directory, and merging a profile twice +// doubles its execution counts. +func findProfrawFiles(tmpDir string) []string { + var files []string + seen := make(map[string]bool) + for _, pattern := range []string{filepath.Join(tmpDir, "*.profraw"), "*.profraw"} { + matches, _ := filepath.Glob(pattern) + for _, m := range matches { + key := m + if abs, err := filepath.Abs(m); err == nil { + key = abs + } + if !seen[key] { + seen[key] = true + files = append(files, m) + } + } + } + return files +} + func cleanProfrawFiles(patterns ...string) { for _, pat := range patterns { if files, err := filepath.Glob(pat); err == nil { @@ -96,6 +119,12 @@ func RunWithOptions(opts RunOptions) error { coverageActive, coverageFile := resolveCoverageConfig(opts) tmpDir := resolveTmpDir() + // The raw lcov export is a declared test output: create it up front so it exists + // (empty) when coverage is off or the report cannot be produced. + if err := writeRawLcov(opts.LcovFile, nil); err != nil { + return fmt.Errorf("failed to reset lcov file: %w", err) + } + var buf bytes.Buffer cmd := prepareCommand(opts, tmpDir, coverageActive, &buf) @@ -140,6 +169,12 @@ func mergeProfdata(profdataPath, tmpDir string, profrawFiles []string) (string, } func exportLcov(covPath, testBinary, mergedProfdata string) ([]byte, error) { + return exportCoverage("lcov", covPath, testBinary, mergedProfdata) +} + +// exportCoverage runs `llvm-cov export` in the given format ("lcov" or "text" for +// the JSON export). +func exportCoverage(format, covPath, testBinary, mergedProfdata string) ([]byte, error) { if covPath == "" { covPath = os.Getenv("TOOLS_LLVM_COV") } @@ -153,7 +188,7 @@ func exportLcov(covPath, testBinary, mergedProfdata string) ([]byte, error) { args := []string{ "export", - "--format=lcov", + "--format=" + format, "--instr-profile=" + mergedProfdata, testBinary, "--ignore-filename-regex=/rustc/|/.cargo/", @@ -168,6 +203,21 @@ func exportLcov(covPath, testBinary, mergedProfdata string) ([]byte, error) { return lcovBuf.Bytes(), nil } +// exportRawLcov writes the raw lcov report. The JSON export adds branch records for +// partly executed lines; if it fails the report is still written without them. +func exportRawLcov(opts RunOptions, lcovBytes []byte, mergedProfdata, repoRoot string) error { + llvmJSON, err := exportCoverage("text", opts.LlvmCov, opts.TestBinary, mergedProfdata) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: no sub-line regions for the raw lcov export: %v\n", err) + llvmJSON = nil + } + data, err := buildRawLcov(lcovBytes, llvmJSON, repoRoot) + if err != nil { + return err + } + return writeRawLcov(opts.LcovFile, data) +} + func detectRepoRoot() string { if root := os.Getenv("REPO_ROOT"); root != "" { return root @@ -207,9 +257,7 @@ func writeCoverageFiles(normLcov, gcovData []byte, coverageFile string) error { } func collectAndProcessCoverage(opts RunOptions, tmpDir string, coverageFile string) error { - profrawFiles, _ := filepath.Glob(filepath.Join(tmpDir, "*.profraw")) - cwdFiles, _ := filepath.Glob("*.profraw") - profrawFiles = append(profrawFiles, cwdFiles...) + profrawFiles := findProfrawFiles(tmpDir) if len(profrawFiles) == 0 { return nil @@ -231,6 +279,12 @@ func collectAndProcessCoverage(opts RunOptions, tmpDir string, coverageFile stri return fmt.Errorf("failed to process coverage output: %w", err) } + if opts.LcovFile != "" { + if err := exportRawLcov(opts, lcovBytes, mergedProfdata, repoRoot); err != nil { + fmt.Fprintf(os.Stderr, "raw lcov export error: %v\n", err) + } + } + return writeCoverageFiles(normLcov, gcovData, coverageFile) } diff --git a/tools/please_rust/testrunner/runner_test.go b/tools/please_rust/testrunner/runner_test.go index 68fba92..0dd8c9d 100644 --- a/tools/please_rust/testrunner/runner_test.go +++ b/tools/please_rust/testrunner/runner_test.go @@ -69,3 +69,23 @@ func TestRunWithOptions_CoverageActive(t *testing.T) { t.Errorf("expected results.xml to exist, got %v", err) } } + +func TestFindProfrawFilesListsEachFileOnce(t *testing.T) { + dir := t.TempDir() + for _, n := range []string{"default_1_10.profraw", "default_1_11.profraw", "other.txt"} { + if err := os.WriteFile(filepath.Join(dir, n), nil, 0644); err != nil { + t.Fatal(err) + } + } + old, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(old) + + // TMP_DIR is the working directory under Please: the same files match twice. + got := findProfrawFiles(dir) + if len(got) != 2 { + t.Errorf("findProfrawFiles = %v, want the two profiles once each", got) + } +}