Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/src/Progmasoft
diff options
context:
space:
mode:
authorLeitwolf2026-11-14 16:46:04 +0300
committerGitHub2026-11-14 16:46:04 +0300
commit94a996a3ceafa9f0004ffc28a7de5c543cdb51c2 (patch)
treee8db06877788de36d3fd4166472c4511f0c0412a /src/Progmasoft
parent586a42e35788d269871dcadbed2ec59b6b8a9203 (diff)
parent5ad02bccfcb925df02d1273901b6e248d477c5fa (diff)
downloadcatch3-94a996a3ceafa9f0004ffc28a7de5c543cdb51c2.tar.gz
Merge pull request #39 from Progmasoft/feature/per-test-coverage
Keep per-test coverage with --coverage (roadmap step 4)
Diffstat (limited to 'src/Progmasoft')
-rw-r--r--src/Progmasoft/Catch3/Results.hpp3
-rw-r--r--src/Progmasoft/Catch3/meson.build1
-rw-r--r--src/Progmasoft/Runner/CoverageReport.cpp686
-rw-r--r--src/Progmasoft/Runner/CoverageReport.hpp79
-rw-r--r--src/Progmasoft/Runner/Options.cpp20
-rw-r--r--src/Progmasoft/Runner/Options.hpp4
-rw-r--r--src/Progmasoft/Runner/Process.cpp24
-rw-r--r--src/Progmasoft/Runner/ResultsJson.cpp2
-rw-r--r--src/Progmasoft/Runner/Runner.cpp35
-rw-r--r--src/Progmasoft/Runner/Scheduler.cpp61
-rw-r--r--src/Progmasoft/Runner/SchedulerModel.hpp4
11 files changed, 914 insertions, 5 deletions
diff --git a/src/Progmasoft/Catch3/Results.hpp b/src/Progmasoft/Catch3/Results.hpp
index 4c6bb1ce..bff2a72e 100644
--- a/src/Progmasoft/Catch3/Results.hpp
+++ b/src/Progmasoft/Catch3/Results.hpp
@@ -153,6 +153,9 @@ namespace Progmasoft::Catch3
/// The directory the test had for its temporary files; kept when the
/// attempt failed, so its contents can be inspected.
std::string temporaryDirectory;
+ /// In a coverage run, where the profile of this attempt went,
+ /// without its extension (`.profraw` or `.gcov`).
+ std::string coverageProfile;
};
/// One test of the run and everything the runner learned about it.
diff --git a/src/Progmasoft/Catch3/meson.build b/src/Progmasoft/Catch3/meson.build
index 8420003b..4bbab28e 100644
--- a/src/Progmasoft/Catch3/meson.build
+++ b/src/Progmasoft/Catch3/meson.build
@@ -288,6 +288,7 @@ runner_sources = files(
'../Runner/Console.cpp',
'../Runner/Execution.cpp',
'../Runner/Files.cpp',
+ '../Runner/CoverageReport.cpp',
'../Runner/Git.cpp',
'../Runner/Hash.cpp',
'../Runner/History.cpp',
diff --git a/src/Progmasoft/Runner/CoverageReport.cpp b/src/Progmasoft/Runner/CoverageReport.cpp
new file mode 100644
index 00000000..ccdd0538
--- /dev/null
+++ b/src/Progmasoft/Runner/CoverageReport.cpp
@@ -0,0 +1,686 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
+#include <algorithm>
+#include <atomic>
+#include <cstdlib>
+#include <initializer_list>
+#include <optional>
+#include <string_view>
+#include <system_error>
+#include <thread>
+
+#include "CoverageReport.hpp"
+#include "Files.hpp"
+#include "Json.hpp"
+#include "Process.hpp"
+
+namespace Progmasoft::Catch3::Runner
+{
+ namespace
+ {
+ /// Reads a non-negative decimal number; nothing for anything else,
+ /// such as the `-` LCOV writes for a branch never reached.
+ std::optional<std::uint64_t>
+ Number(std::string_view text)
+ {
+ if (text.empty() || text.size() > 19)
+ {
+ return std::nullopt;
+ }
+ std::uint64_t value = 0;
+ for (const char character : text)
+ {
+ if (character < '0' || character > '9')
+ {
+ return std::nullopt;
+ }
+ value
+ = value * 10 + static_cast<std::uint64_t>(character - '0');
+ }
+ return value;
+ }
+
+ std::vector<std::string_view>
+ Split(std::string_view text, char separator)
+ {
+ std::vector<std::string_view> parts;
+ std::size_t start = 0;
+ while (true)
+ {
+ const auto end = text.find(separator, start);
+ parts.push_back(text.substr(start, end - start));
+ if (end == std::string_view::npos)
+ {
+ return parts;
+ }
+ start = end + 1;
+ }
+ }
+
+ /// A tool of a coverage format: the program, or the command that
+ /// runs it, such as `xcrun llvm-cov` on macOS.
+ using Tool = std::vector<std::string>;
+
+ std::optional<Tool>
+ FindTool(const char *variable, const std::string &name)
+ {
+ std::vector<Tool> candidates;
+ if (auto configured = ReadEnvironment(variable);
+ !configured.empty())
+ {
+ candidates.push_back({ std::move(configured) });
+ }
+ candidates.push_back({ name });
+#if defined(__APPLE__)
+ candidates.push_back({ "xcrun", name });
+#endif
+ for (auto &candidate : candidates)
+ {
+ ChildOptions options;
+ options.arguments = candidate;
+ options.arguments.emplace_back("--version");
+ options.searchPath = true;
+ if (auto run = RunAndCapture(options);
+ run && run->status.Succeeded())
+ {
+ return std::move(candidate);
+ }
+ }
+ return std::nullopt;
+ }
+
+ /// Runs `tool` with `arguments`; its output, or why it failed.
+ Expected<std::string>
+ RunTool(const Tool &tool,
+ const std::vector<std::string> &arguments,
+ const std::filesystem::path &directory = {})
+ {
+ ChildOptions options;
+ options.arguments = tool;
+ options.arguments.insert(options.arguments.end(),
+ arguments.begin(),
+ arguments.end());
+ options.searchPath = true;
+ options.workingDirectory = directory;
+ auto run = RunAndCapture(options);
+ if (!run)
+ {
+ return run.GetError();
+ }
+ if (!run->status.Succeeded())
+ {
+ std::string message = run->errors;
+ while (!message.empty()
+ && (message.back() == '\n' || message.back() == '\r'))
+ {
+ message.pop_back();
+ }
+ return Error{ tool.back() + " failed: " + message };
+ }
+ return std::move(run->output);
+ }
+
+ void
+ Add(CoverageMap &total, const CoverageMap &part)
+ {
+ for (const auto &[file, coverage] : part)
+ {
+ FileCoverage &into = total[file];
+ for (const auto &[line, count] : coverage.lines)
+ {
+ into.lines[line] += count;
+ }
+ for (const auto &[branch, count] : coverage.branches)
+ {
+ into.branches[branch] += count;
+ }
+ }
+ }
+
+ /// What one test reached: the lines it ran and, per line, how many
+ /// of its branches it took.
+ JsonValue
+ ReachedJson(const CoverageMap &map)
+ {
+ JsonValue files = JsonValue::Array();
+ for (const auto &[file, coverage] : map)
+ {
+ JsonValue lines = JsonValue::Array();
+ for (const auto &[line, count] : coverage.lines)
+ {
+ if (count > 0)
+ {
+ lines.Append(line);
+ }
+ }
+ JsonValue branches = JsonValue::Array();
+ std::map<std::size_t, std::pair<std::size_t, std::size_t>>
+ perLine;
+ for (const auto &[branch, count] : coverage.branches)
+ {
+ auto &[taken, total] = perLine[branch.first];
+ ++total;
+ if (count > 0)
+ {
+ ++taken;
+ }
+ }
+ for (const auto &[line, counts] : perLine)
+ {
+ if (counts.first == 0)
+ {
+ continue;
+ }
+ branches.Append(
+ JsonValue::Object({ { "line", line },
+ { "taken", counts.first },
+ { "total", counts.second } }));
+ }
+ if (lines.Items().empty())
+ {
+ continue;
+ }
+ files.Append(
+ JsonValue::Object({ { "file", file },
+ { "lines", std::move(lines) },
+ { "branches", std::move(branches) } }));
+ }
+ return files;
+ }
+
+ /// The .gcda files gcov wrote below `prefix`, each with the path the
+ /// object was built at, where its .gcno is.
+ std::vector<std::pair<std::filesystem::path, std::filesystem::path>>
+ GcdaFiles(const std::filesystem::path &prefix)
+ {
+ std::vector<std::pair<std::filesystem::path, std::filesystem::path>>
+ files;
+ std::error_code error;
+ for (auto entry
+ = std::filesystem::recursive_directory_iterator(prefix, error);
+ !error
+ && entry != std::filesystem::recursive_directory_iterator();
+ entry.increment(error))
+ {
+ if (!entry->is_regular_file(error)
+ || entry->path().extension() != ".gcda")
+ {
+ continue;
+ }
+ // GCOV_PREFIX is put before the absolute path of the object.
+ const auto relative
+ = std::filesystem::relative(entry->path(), prefix, error);
+ std::filesystem::path original
+ = std::filesystem::path{ "/" } / relative;
+ files.emplace_back(entry->path(), original);
+ }
+ std::sort(files.begin(), files.end());
+ return files;
+ }
+
+ Expected<CoverageMap>
+ GcovCoverage(const Tool &gcov, const std::filesystem::path &prefix)
+ {
+ CoverageMap map;
+ for (const auto &[gcda, original] : GcdaFiles(prefix))
+ {
+ auto gcno = original;
+ gcno.replace_extension(".gcno");
+ auto beside = gcda;
+ beside.replace_extension(".gcno");
+ std::error_code error;
+ std::filesystem::copy_file(
+ gcno,
+ beside,
+ std::filesystem::copy_options::overwrite_existing,
+ error);
+ if (error)
+ {
+ return Error{ "the notes file " + gcno.string()
+ + " could not be read: " + error.message() };
+ }
+ // The data file by its absolute path, so that gcov needs no
+ // working directory: profiles are read on several threads,
+ // and a working directory is the whole process's.
+ // Without --branch-probabilities the JSON has no branches.
+ auto json
+ = RunTool(gcov,
+ { "--json-format",
+ "--branch-probabilities",
+ "--stdout",
+ std::filesystem::absolute(gcda).string() });
+ if (!json)
+ {
+ return json.GetError();
+ }
+ // One JSON document per data file, one per line.
+ for (const auto line : Split(*json, '\n'))
+ {
+ if (!line.empty())
+ {
+ AddGcovJson(std::string{ line }, map);
+ }
+ }
+ }
+ return map;
+ }
+ } // namespace
+
+ CoverageMap
+ ParseLcov(const std::string &text)
+ {
+ CoverageMap map;
+ FileCoverage *current = nullptr;
+ for (auto line : Split(text, '\n'))
+ {
+ if (!line.empty() && line.back() == '\r')
+ {
+ line.remove_suffix(1);
+ }
+ if (line.substr(0, 3) == "SF:")
+ {
+ current = &map[std::string{ line.substr(3) }];
+ }
+ else if (line == "end_of_record")
+ {
+ current = nullptr;
+ }
+ else if (current != nullptr && line.substr(0, 3) == "DA:")
+ {
+ const auto fields = Split(line.substr(3), ',');
+ const auto number = Number(fields[0]);
+ const auto count
+ = fields.size() > 1 ? Number(fields[1]) : std::nullopt;
+ if (number && count)
+ {
+ current->lines[static_cast<std::size_t>(*number)] += *count;
+ }
+ }
+ else if (current != nullptr && line.substr(0, 5) == "BRDA:")
+ {
+ const auto fields = Split(line.substr(5), ',');
+ if (fields.size() != 4)
+ {
+ continue;
+ }
+ const auto parsed = Number(fields[0]);
+ if (!parsed)
+ {
+ continue;
+ }
+ const auto number = static_cast<std::size_t>(*parsed);
+ // The branches of a line are numbered in the order they come;
+ // `-` counts as never taken.
+ std::size_t index = 0;
+ for (auto it = current->branches.lower_bound({ number, 0 });
+ it != current->branches.end()
+ && it->first.first == number;
+ ++it)
+ {
+ ++index;
+ }
+ current->branches[{ number, index }]
+ += Number(fields[3]).value_or(0);
+ }
+ }
+ return map;
+ }
+
+ void
+ AddGcovJson(const std::string &text, CoverageMap &map)
+ {
+ auto document = ParseJson(text);
+ if (!document)
+ {
+ return;
+ }
+ for (const auto &file : (*document)["files"].Items())
+ {
+ FileCoverage &coverage = map[file["file"].AsString()];
+ for (const auto &line : file["lines"].Items())
+ {
+ const auto number = static_cast<std::size_t>(
+ line["line_number"].AsUnsigned());
+ coverage.lines[number] += line["count"].AsUnsigned();
+ std::size_t index = 0;
+ for (const auto &branch : line["branches"].Items())
+ {
+ coverage.branches[{ number, index++ }]
+ += branch["count"].AsUnsigned();
+ }
+ }
+ }
+ }
+
+ std::string
+ ToLcov(const CoverageMap &map)
+ {
+ std::string text;
+ for (const auto &[file, coverage] : map)
+ {
+ text += "SF:" + file + "\n";
+ std::size_t linesHit = 0;
+ for (const auto &[line, count] : coverage.lines)
+ {
+ text += "DA:" + std::to_string(line) + ","
+ + std::to_string(count) + "\n";
+ linesHit += count > 0 ? 1 : 0;
+ }
+ std::size_t branchesHit = 0;
+ for (const auto &[branch, count] : coverage.branches)
+ {
+ text += "BRDA:" + std::to_string(branch.first) + ",0,"
+ + std::to_string(branch.second) + ","
+ + (count > 0 ? std::to_string(count) : "-") + "\n";
+ branchesHit += count > 0 ? 1 : 0;
+ }
+ text += "LF:" + std::to_string(coverage.lines.size()) + "\n";
+ text += "LH:" + std::to_string(linesHit) + "\n";
+ text += "BRF:" + std::to_string(coverage.branches.size()) + "\n";
+ text += "BRH:" + std::to_string(branchesHit) + "\n";
+ text += "end_of_record\n";
+ }
+ return text;
+ }
+
+ Status
+ PrepareCoverageDirectory(const std::filesystem::path &directory)
+ {
+ std::error_code error;
+ std::filesystem::remove_all(directory / "profiles", error);
+ if (error)
+ {
+ return Error{ "the profiles of an earlier run in "
+ + directory.string()
+ + " could not be removed: " + error.message() };
+ }
+ for (const char *report :
+ { "coverage.json", "coverage.lcov", "coverage.profdata" })
+ {
+ std::filesystem::remove(directory / report, error);
+ if (error)
+ {
+ return Error{ "the report " + (directory / report).string()
+ + " of an earlier run could not be removed: "
+ + error.message() };
+ }
+ }
+ std::filesystem::create_directories(directory / "profiles", error);
+ if (error)
+ {
+ return Error{ "the coverage directory " + directory.string()
+ + " could not be created: " + error.message() };
+ }
+ return Status::Ok();
+ }
+
+ const Attempt *
+ ProfiledAttempt(const TestResult &test, const std::string &suffix)
+ {
+ for (auto attempt = test.attempts.rbegin();
+ attempt != test.attempts.rend();
+ ++attempt)
+ {
+ std::error_code error;
+ if (!attempt->coverageProfile.empty()
+ && std::filesystem::exists(attempt->coverageProfile + suffix,
+ error))
+ {
+ return &*attempt;
+ }
+ }
+ return nullptr;
+ }
+
+ namespace
+ {
+ /// The coverage tools of one profile format.
+ struct CoverageTools final
+ {
+ bool llvm = false;
+ std::optional<Tool> profdata;
+ std::optional<Tool> cov;
+ std::optional<Tool> gcov;
+ };
+
+ /// What the profile of one test gave.
+ struct TestCoverage final
+ {
+ const TestResult *test = nullptr;
+ std::string profile;
+ std::optional<CoverageMap> reached;
+ std::string failure;
+ };
+
+ TestCoverage
+ ReadTestCoverage(const TestResult &test,
+ const Attempt &attempt,
+ const CoverageTools &tools,
+ const std::string &program)
+ {
+ TestCoverage result;
+ result.test = &test;
+ const std::string base = attempt.coverageProfile;
+ if (tools.llvm)
+ {
+ result.profile = base + ".profraw";
+ auto merged = RunTool(*tools.profdata,
+ { "merge",
+ "-sparse",
+ result.profile,
+ "-o",
+ base + ".profdata" });
+ if (!merged)
+ {
+ result.failure = merged.GetError().Message();
+ return result;
+ }
+ auto lcov = RunTool(*tools.cov,
+ { "export",
+ "-format=lcov",
+ "-instr-profile=" + base + ".profdata",
+ program });
+ if (!lcov)
+ {
+ result.failure = lcov.GetError().Message();
+ return result;
+ }
+ result.reached = ParseLcov(*lcov);
+ return result;
+ }
+ result.profile = base + ".gcov";
+ auto reached = GcovCoverage(*tools.gcov, result.profile);
+ if (!reached)
+ {
+ result.failure = reached.GetError().Message();
+ return result;
+ }
+ result.reached = std::move(*reached);
+ return result;
+ }
+
+ /// Reads the profile of each of `tests`, up to `jobs` at a time;
+ /// the results keep the order of `tests`.
+ std::vector<TestCoverage>
+ ReadAll(
+ const std::vector<std::pair<const TestResult *, const Attempt *>>
+ &tests,
+ const CoverageTools &tools,
+ const std::string &program,
+ std::size_t jobs)
+ {
+ std::vector<TestCoverage> results(tests.size());
+ std::atomic<std::size_t> next{ 0 };
+ const auto work = [&] {
+ for (std::size_t index = next++; index < tests.size();
+ index = next++)
+ {
+ results[index] = ReadTestCoverage(*tests[index].first,
+ *tests[index].second,
+ tools,
+ program);
+ }
+ };
+ const std::size_t threads
+ = std::min(std::max<std::size_t>(jobs, 1), tests.size());
+ std::vector<std::thread> pool;
+ for (std::size_t thread = 1; thread < threads; ++thread)
+ {
+ pool.emplace_back(work);
+ }
+ work();
+ for (auto &thread : pool)
+ {
+ thread.join();
+ }
+ return results;
+ }
+ } // namespace
+
+ CoverageReports
+ WriteCoverageReports(const std::filesystem::path &directory,
+ const RunResult &run,
+ const std::string &program,
+ std::size_t jobs)
+ {
+ CoverageReports reports;
+ std::vector<std::pair<const TestResult *, const Attempt *>> llvmTests;
+ std::vector<std::pair<const TestResult *, const Attempt *>> gcovTests;
+ for (const auto &test : run.tests)
+ {
+ if (const Attempt *attempt = ProfiledAttempt(test, ".profraw"))
+ {
+ llvmTests.emplace_back(&test, attempt);
+ }
+ if (const Attempt *attempt = ProfiledAttempt(test, ".gcov"))
+ {
+ gcovTests.emplace_back(&test, attempt);
+ }
+ }
+ if (llvmTests.empty() && gcovTests.empty())
+ {
+ reports.notes.emplace_back(
+ "--coverage: the tests wrote no coverage; build them with "
+ "-fprofile-instr-generate -fcoverage-mapping (Clang) or "
+ "--coverage (GCC)");
+ return reports;
+ }
+ CoverageTools tools;
+ tools.llvm = llvmTests.size() >= gcovTests.size();
+ const auto &profiled = tools.llvm ? llvmTests : gcovTests;
+ if (tools.llvm)
+ {
+ tools.profdata = FindTool("CATCH3_LLVM_PROFDATA", "llvm-profdata");
+ tools.cov = FindTool("CATCH3_LLVM_COV", "llvm-cov");
+ }
+ else
+ {
+ tools.gcov = FindTool("CATCH3_GCOV", "gcov");
+ }
+ if (tools.llvm ? (!tools.profdata || !tools.cov) : !tools.gcov)
+ {
+ reports.notes.push_back(
+ "--coverage: " + std::to_string(profiled.size())
+ + " profile(s) in " + (directory / "profiles").string() + "; "
+ + (tools.llvm ? "llvm-profdata and llvm-cov were" : "gcov was")
+ + " not found, so there is no map of what each test reached");
+ return reports;
+ }
+
+ CoverageMap combined;
+ std::vector<std::string> raw;
+ JsonValue tests = JsonValue::Array();
+ for (auto &read : ReadAll(profiled, tools, program, jobs))
+ {
+ if (!read.reached)
+ {
+ reports.failures.push_back(read.test->name + ": "
+ + read.failure);
+ continue;
+ }
+ if (tools.llvm)
+ {
+ raw.push_back(read.profile);
+ }
+ else
+ {
+ Add(combined, *read.reached);
+ }
+ tests.Append(JsonValue::Object(
+ { { "name", read.test->name },
+ { "outcome", OutcomeName(read.test->outcome) },
+ { "profile", read.profile },
+ { "files", ReachedJson(*read.reached) } }));
+ }
+
+ std::string lcovText;
+ if (tools.llvm && !raw.empty())
+ {
+ std::vector<std::string> arguments{ "merge", "-sparse" };
+ arguments.insert(arguments.end(), raw.begin(), raw.end());
+ const std::string all = (directory / "coverage.profdata").string();
+ arguments.emplace_back("-o");
+ arguments.push_back(all);
+ auto merged = RunTool(*tools.profdata, arguments);
+ auto exported = merged ? RunTool(*tools.cov,
+ { "export",
+ "-format=lcov",
+ "-instr-profile=" + all,
+ program })
+ : Expected<std::string>{ merged.GetError() };
+ if (exported)
+ {
+ lcovText = *exported;
+ }
+ else
+ {
+ reports.failures.push_back("the combined report: "
+ + exported.GetError().Message());
+ }
+ }
+ else
+ {
+ lcovText = ToLcov(combined);
+ }
+
+ JsonValue report
+ = JsonValue::Object({ { "version", 1 },
+ { "format", tools.llvm ? "llvm" : "gcov" },
+ { "binary", program },
+ { "tests", std::move(tests) } });
+ const auto json = directory / "coverage.json";
+ const auto lcov = directory / "coverage.lcov";
+ const auto jsonWritten
+ = WriteFileAtomically(json, report.ToPrettyString());
+ if (!jsonWritten)
+ {
+ reports.failures.push_back(jsonWritten.GetError().Message());
+ }
+ const auto lcovWritten = WriteFileAtomically(lcov, lcovText);
+ if (!lcovWritten)
+ {
+ reports.failures.push_back(lcovWritten.GetError().Message());
+ }
+ std::string written;
+ if (jsonWritten && lcovWritten)
+ {
+ written = "; what each test reached is in " + json.string()
+ + ", the run combined in " + lcov.string();
+ }
+ else if (jsonWritten)
+ {
+ written = "; what each test reached is in " + json.string();
+ }
+ else if (lcovWritten)
+ {
+ written = "; the run combined is in " + lcov.string();
+ }
+ reports.notes.push_back("--coverage: " + std::to_string(profiled.size())
+ + " test profile(s)" + written);
+ for (auto &failure : reports.failures)
+ {
+ failure = "--coverage: " + failure;
+ }
+ return reports;
+ }
+} // namespace Progmasoft::Catch3::Runner
diff --git a/src/Progmasoft/Runner/CoverageReport.hpp b/src/Progmasoft/Runner/CoverageReport.hpp
new file mode 100644
index 00000000..35c3b1e5
--- /dev/null
+++ b/src/Progmasoft/Runner/CoverageReport.hpp
@@ -0,0 +1,79 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-2.0
+#pragma once
+
+#include <Progmasoft/Catch3/Results.hpp>
+#include <cstdint>
+#include <filesystem>
+#include <map>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "Expected.hpp"
+
+namespace Progmasoft::Catch3::Runner
+{
+ /// What one test, or the whole run, reached in one source file.
+ struct FileCoverage final
+ {
+ /// Execution count of each instrumented line.
+ std::map<std::size_t, std::uint64_t> lines;
+ /// Count of each branch, by line and by its position on the line.
+ std::map<std::pair<std::size_t, std::size_t>, std::uint64_t> branches;
+ };
+
+ /// Coverage by source file.
+ using CoverageMap = std::map<std::string, FileCoverage>;
+
+ /// Reads an LCOV tracefile: `SF`, `DA` and `BRDA` records.
+ [[nodiscard]] CoverageMap
+ ParseLcov(const std::string &text);
+
+ /// Reads the JSON that `gcov --json-format` writes, adding to `map`.
+ void
+ AddGcovJson(const std::string &text, CoverageMap &map);
+
+ /// Writes `map` as an LCOV tracefile.
+ [[nodiscard]] std::string
+ ToLcov(const CoverageMap &map);
+
+ /// Prepares the directory of a coverage run: the profiles and reports of
+ /// an earlier run below it are removed, because gcov adds to the .gcda
+ /// files it finds instead of replacing them, and because a run that
+ /// writes no reports must not leave the earlier ones to be read as its
+ /// own.
+ Status
+ PrepareCoverageDirectory(const std::filesystem::path &directory);
+
+ /// The last attempt of `test` whose profile, its base name followed by
+ /// `suffix` (`.profraw` or `.gcov`), exists: an attempt that crashed or
+ /// timed out before writing one is passed over for an earlier one.
+ [[nodiscard]] const Attempt *
+ ProfiledAttempt(const TestResult &test, const std::string &suffix);
+
+ /// What writing the coverage reports gave.
+ struct CoverageReports final
+ {
+ /// What the run should be told: how many profiles there were and
+ /// where the reports are, or why there are none.
+ std::vector<std::string> notes;
+ /// What failed: a profile that could not be read, a report that
+ /// could not be written. A run with failures is missing a report.
+ std::vector<std::string> failures;
+ };
+
+ /// Turns the profiles a coverage run left in `directory` into
+ /// `directory/coverage.json`, the lines and branches each test reached,
+ /// and `directory/coverage.lcov`, the run combined. LLVM profiles are
+ /// read with `llvm-profdata` and `llvm-cov`, gcov data with `gcov`,
+ /// found on PATH or named by CATCH3_LLVM_PROFDATA, CATCH3_LLVM_COV and
+ /// CATCH3_GCOV; the profiles of the tests are read up to `jobs` at a
+ /// time. A run whose tests wrote no profiles, or whose tools are
+ /// missing, gets a note and no reports, not a failure.
+ [[nodiscard]] CoverageReports
+ WriteCoverageReports(const std::filesystem::path &directory,
+ const RunResult &run,
+ const std::string &program,
+ std::size_t jobs);
+} // namespace Progmasoft::Catch3::Runner