From c196d2083d2513019b5e89c4642295ff05f92c95 Mon Sep 17 00:00:00 2001 From: CEL Dev Team Date: Thu, 12 Feb 2026 08:46:48 -0800 Subject: [PATCH] Add API for exporting line coverage in LCOV format. PiperOrigin-RevId: 869237566 --- testing/testrunner/BUILD | 1 + testing/testrunner/coverage_index.cc | 35 ++++++++++++ testing/testrunner/coverage_index.h | 3 + testing/testrunner/coverage_index_test.cc | 67 +++++++++++++++++++++++ 4 files changed, 106 insertions(+) diff --git a/testing/testrunner/BUILD b/testing/testrunner/BUILD index e1b3139cc..b80167487 100644 --- a/testing/testrunner/BUILD +++ b/testing/testrunner/BUILD @@ -216,6 +216,7 @@ cc_test( "//runtime:standard_runtime_builder_factory", "@com_google_absl//absl/status:status_matchers", "@com_google_absl//absl/status:statusor", + "@com_google_absl//absl/strings", "@com_google_absl//absl/strings:string_view", "@com_google_cel_spec//proto/cel/expr:syntax_cc_proto", "@com_google_protobuf//:protobuf", diff --git a/testing/testrunner/coverage_index.cc b/testing/testrunner/coverage_index.cc index 2746e3528..57baff593 100644 --- a/testing/testrunner/coverage_index.cc +++ b/testing/testrunner/coverage_index.cc @@ -14,7 +14,11 @@ #include "testing/testrunner/coverage_index.h" +#include #include +#include +#include +#include #include #include @@ -170,6 +174,13 @@ void TraverseAndCalculateCoverage( } } +int32_t GetLineNumber(const cel::expr::SourceInfo& source_info, + int32_t offset) { + auto line_it = std::upper_bound(source_info.line_offsets().begin(), + source_info.line_offsets().end(), offset); + return std::distance(source_info.line_offsets().begin(), line_it) + 1; +} + } // namespace void CoverageIndex::RecordCoverage(int64_t node_id, const cel::Value& value) { @@ -211,6 +222,30 @@ CoverageIndex::CoverageReport CoverageIndex::GetCoverageReport() const { return report; } +void CoverageIndex::WriteLCOV(absl::string_view path) { + std::ofstream file(std::string(path).c_str()); + if (!file.is_open()) { + return; + } + + // Maps instrumented line numbers to whether they are covered. + std::map lines; + const auto& positions = checked_expr_.source_info().positions(); + for (const auto& [node_id, stats] : node_coverage_stats_) { + auto it = positions.find(node_id); + if (it == positions.end()) continue; + int line_num = GetLineNumber(checked_expr_.source_info(), it->second); + bool& covered = lines[line_num]; + covered = covered || stats.covered; + } + + file << "SF:" << checked_expr_.source_info().location() << "\n"; + for (auto& [line_num, covered] : lines) { + file << "DA:" << line_num << "," << (covered ? 1 : 0) << "\n"; + } + file << "end_of_record\n"; +} + InstrumentationFactory InstrumentationFactoryForCoverage( CoverageIndex& coverage_index) { return [&](const cel::Ast& ast) -> Instrumentation { diff --git a/testing/testrunner/coverage_index.h b/testing/testrunner/coverage_index.h index ae9cf902f..746281494 100644 --- a/testing/testrunner/coverage_index.h +++ b/testing/testrunner/coverage_index.h @@ -91,6 +91,9 @@ class CoverageIndex { // Returns a coverage report for the given checked expression. CoverageReport GetCoverageReport() const; + // Writes the coverage in LCOV format to the given path. + void WriteLCOV(absl::string_view path); + private: absl::flat_hash_map node_coverage_stats_; NavigableProtoAst navigable_ast_; diff --git a/testing/testrunner/coverage_index_test.cc b/testing/testrunner/coverage_index_test.cc index 32417b2e9..6e9e2b0d3 100644 --- a/testing/testrunner/coverage_index_test.cc +++ b/testing/testrunner/coverage_index_test.cc @@ -13,12 +13,16 @@ // limitations under the License. #include "testing/testrunner/coverage_index.h" +#include #include +#include +#include #include #include "cel/expr/syntax.pb.h" #include "absl/status/status_matchers.h" #include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" #include "absl/strings/string_view.h" #include "checker/type_checker_builder.h" #include "checker/validation_result.h" @@ -89,5 +93,68 @@ TEST(CoverageIndexTest, RecordCoverageWithErrorDoesNotCrash) { EXPECT_TRUE(result.IsError()); } +TEST(CoverageIndexTest, WriteLCOV) { + ASSERT_OK_AND_ASSIGN( + std::unique_ptr compiler_builder, + cel::NewCompilerBuilder(cel::internal::GetTestingDescriptorPool())); + ASSERT_THAT(compiler_builder->AddLibrary(cel::StandardCompilerLibrary()), + IsOk()); + ASSERT_THAT(compiler_builder->GetCheckerBuilder().AddVariable( + cel::MakeVariableDecl("x", cel::BoolType())), + IsOk()); + ASSERT_OK_AND_ASSIGN(std::unique_ptr compiler, + std::move(compiler_builder)->Build()); + const absl::string_view kSrc = R"(x ? +true : +false +)"; + ASSERT_OK_AND_ASSIGN(cel::ValidationResult validation_result, + compiler->Compile(kSrc)); + CheckedExpr checked_expr; + ASSERT_THAT(cel::AstToCheckedExpr(*validation_result.GetAst(), &checked_expr), + IsOk()); + checked_expr.mutable_source_info()->set_location("test.cel"); + + CoverageIndex coverage_index; + coverage_index.Init(checked_expr); + + ASSERT_OK_AND_ASSIGN(std::unique_ptr runtime, + CreateTestRuntime()); + ASSERT_THAT(EnableCoverageInRuntime(*const_cast(runtime.get()), + coverage_index), + IsOk()); + ASSERT_OK_AND_ASSIGN(std::unique_ptr ast, + cel::CreateAstFromCheckedExpr(checked_expr)); + ASSERT_OK_AND_ASSIGN(auto program, runtime->CreateProgram(std::move(ast))); + + cel::Activation activation; + activation.InsertOrAssignValue("x", cel::BoolValue(true)); + google::protobuf::Arena arena; + ASSERT_OK_AND_ASSIGN(cel::Value result, + program->Evaluate(&arena, activation)); + EXPECT_TRUE(result.GetBool().NativeValue()); + + std::string temp_file = absl::StrCat(testing::TempDir(), "/coverage.lcov"); + coverage_index.WriteLCOV(temp_file); + + std::ifstream f(temp_file); + std::stringstream buffer; + buffer << f.rdbuf(); + std::string content = buffer.str(); + + // Verify content. + // We expect "test.cel" to be the source file. + EXPECT_THAT(content, testing::HasSubstr("SF:test.cel")); + // Line 1 (x ?) should be covered. + EXPECT_THAT(content, testing::HasSubstr("DA:1,1")); + // Line 2 (true) should be covered. + EXPECT_THAT(content, testing::HasSubstr("DA:2,1")); + // Line 3 (false) should be uncovered. + EXPECT_THAT(content, testing::HasSubstr("DA:3,0")); + // Line 4 (empty) should not be instrumented. + EXPECT_THAT(content, testing::Not(testing::HasSubstr("DA:4,"))); + EXPECT_THAT(content, testing::HasSubstr("end_of_record")); +} + } // namespace } // namespace cel::test