Files
CMake/Tests/CMakeLib/testListFileCoverage.cxx
Mickaël Germain 37b38a29b6 cmake: Add CMAKE_LISTFILE_COVERAGE_DIR to record listfile coverage
Projects test their CMake code, but no tool tells them which parts of
that code their tests run.  Add a `CMAKE_LISTFILE_COVERAGE_DIR`
environment variable.  When it names a directory, each `cmake` process
that runs CMake language code records which lines of each listfile ran
and how many times each function and macro was called.  At exit the
process writes one lcov tracefile, `<pid>-<token>.info`, into that
directory.  An empty value means off.  A relative path, or a directory
that cannot be written, is an error at startup.

Configure, script mode, `cmake --install`, `try_compile` projects, and
a re-run of configure from the build system record coverage.
`cmake --build`, `cmake -E`, `--help`, `--version`, and a
`--check-build-system` step that does not re-run configure do not
record, because they run no user code.

The collector registers each file when CMake parses it, and counts a
hit for each command that `cmMakefile::ExecuteCommand` runs.  A command
counts on the line where it starts.  `else()` and the `end*()` commands
do not count.  `elseif()` counts each time its condition is evaluated,
through a hook in `cmIfCommand`.  Function and macro bodies count on
their lines in the file that defines them, and each definition gets an
lcov `FN` record with the number of calls.  If one file defines the
same name more than once, each record name gets an `@<line>` suffix.
Commands that another command runs on the same line, such as those of
`cmake_language(CALL)` and `list()` callbacks, add no hit.  Code from
`cmake_language(EVAL CODE)` is left out, because it has no file.

Add `cmLcovTracefile` to read, merge, and write lcov tracefiles, so
that later changes can merge per-process files in ctest and read them
in `ctest_coverage()`.

Issue: #28192
2026-11-01 22:42:38 -08:00

251 lines
7.1 KiB
C++

/* Distributed under the OSI-approved BSD 3-Clause License. See accompanying
file LICENSE.rst or https://cmake.org/licensing for details. */
#include <sstream>
#include <string>
#include <vector>
#include "cmsys/Directory.hxx"
#include "cmLcovTracefile.h"
#include "cmListFileCache.h"
#include "cmListFileCoverage.h"
#include "cmSystemTools.h"
#include "testCommon.h"
#include "testConfig.h"
namespace {
std::vector<cmListFileFunction> parse(std::string const& text)
{
cmListFile lf;
if (!lf.ParseString(text, "test.cmake", nullptr, cmListFileBacktrace())) {
std::cout << "ParseString failed\n";
}
return lf.Functions;
}
std::string writeText(cmLcovTracefile const& t)
{
std::ostringstream os;
t.Write(os);
return os.str();
}
bool contains(std::string const& text, std::string const& part)
{
if (text.find(part) == std::string::npos) {
std::cout << "Missing '" << part << "' in:\n" << text;
return false;
}
return true;
}
// Turns coverage off on scope exit, so a failed ASSERT never leaves it on
// for the next test.
struct FinishGuard
{
FinishGuard() = default;
FinishGuard(FinishGuard const&) = delete;
FinishGuard& operator=(FinishGuard const&) = delete;
~FinishGuard() { cmListFileCoverage::Finish(); }
};
// A fresh directory, removed on scope exit. Declare it before the
// FinishGuard, so that the tracefile is written before the removal.
struct TempDir
{
TempDir()
: Path(BUILD_DIR "/testListFileCoverage-XXXXXX")
{
this->Ok = static_cast<bool>(cmSystemTools::MakeTempDirectory(this->Path));
if (!this->Ok) {
std::cout << "Cannot create temporary directory " << this->Path << '\n';
}
}
TempDir(TempDir const&) = delete;
TempDir& operator=(TempDir const&) = delete;
~TempDir()
{
if (this->Ok) {
cmSystemTools::RemoveADirectory(this->Path);
}
}
std::string Path;
bool Ok = false;
};
bool startCoverageIn(std::string const& dir)
{
cmSystemTools::PutEnv("CMAKE_LISTFILE_COVERAGE_DIR=" + dir);
std::string error;
bool const ok = cmListFileCoverage::Start(error);
cmSystemTools::UnPutEnv("CMAKE_LISTFILE_COVERAGE_DIR");
if (!ok) {
std::cout << "Start failed: " << error << '\n';
}
return ok;
}
unsigned long countEntries(std::string const& dir)
{
cmsys::Directory d;
if (!d.Load(dir)) {
return 0;
}
unsigned long n = 0;
for (unsigned long i = 0; i < d.GetNumberOfFiles(); ++i) {
std::string const name = d.GetFileName(i);
if (name != "." && name != "..") {
++n;
}
}
return n;
}
bool testExecutableLines()
{
std::vector<long> const lines =
cmListFileCoverage::ExecutableLines(parse("set(a 1)\n"
"if(a)\n"
"else()\n"
"endif()\n"
"function(f)\n"
"endfunction()\n"
"set(b\n"
" 2)\n"));
std::vector<long> const expected = { 1, 2, 5, 7 };
ASSERT_TRUE(lines == expected);
return true;
}
bool testRecording()
{
TempDir dir;
ASSERT_TRUE(dir.Ok);
FinishGuard guard;
ASSERT_TRUE(startCoverageIn(dir.Path));
cmListFileCoverage* cov = cmListFileCoverage::Get();
ASSERT_TRUE(cov != nullptr);
std::vector<cmListFileFunction> const functions = parse("function(f)\n"
" set(x 1)\n"
"endfunction()\n"
"f()\n");
cov->FileParsed("/s/a.cmake", functions);
cov->CommandRan(functions[0], "/s/a.cmake", 1);
cov->CommandRan(functions[3], "/s/a.cmake", 4);
cov->CommandRan(functions[1], "/s/a.cmake", 2);
cov->FunctionCalled("/s/a.cmake", 1);
// Ignored: the file is not registered.
cov->CommandRan(functions[0], "/s/other.cmake", 1);
// Ignored: endfunction is not an executable line.
cov->CommandRan(functions[2], "/s/a.cmake", 3);
// Ignored: no function is defined on this line.
cov->FunctionCalled("/s/a.cmake", 99);
std::string const text = writeText(cov->ToTracefile());
ASSERT_TRUE(contains(text, "SF:/s/a.cmake\n"));
ASSERT_TRUE(contains(text, "FN:1,f\n"));
ASSERT_TRUE(contains(text, "FNDA:1,f\n"));
ASSERT_TRUE(contains(text, "DA:1,1\nDA:2,1\nDA:4,1\nLF:3\nLH:3\n"));
ASSERT_TRUE(text.find("other.cmake") == std::string::npos);
ASSERT_TRUE(text.find("DA:3,") == std::string::npos);
ASSERT_TRUE(text.find("FN:99") == std::string::npos);
return true;
}
bool testFileParsedTwice()
{
TempDir dir;
ASSERT_TRUE(dir.Ok);
FinishGuard guard;
ASSERT_TRUE(startCoverageIn(dir.Path));
cmListFileCoverage* cov = cmListFileCoverage::Get();
ASSERT_TRUE(cov != nullptr);
std::vector<cmListFileFunction> const first = parse("set(a 1)\n");
cov->FileParsed("/s/a.cmake", first);
cov->CommandRan(first[0], "/s/a.cmake", 1);
cov->FileParsed("/s/a.cmake", parse("set(a 1)\nset(b 2)\n"));
std::string const text = writeText(cov->ToTracefile());
ASSERT_TRUE(contains(text, "DA:1,1\nDA:2,0\nLF:2\nLH:1\n"));
return true;
}
bool testSyntheticCommand()
{
TempDir dir;
ASSERT_TRUE(dir.Ok);
FinishGuard guard;
ASSERT_TRUE(startCoverageIn(dir.Path));
cmListFileCoverage* cov = cmListFileCoverage::Get();
ASSERT_TRUE(cov != nullptr);
cov->FileParsed("/s/a.cmake", parse("set(a 1)\n"));
cmListFileFunction const synthetic("set", 1, 1, {});
cmListFileFunction const real("set", 1, 1, {});
{
cmListFileCoverage::SyntheticCommandScope scope(synthetic);
cov->CommandRan(synthetic, "/s/a.cmake", 1);
cov->CommandRan(real, "/s/a.cmake", 1);
}
// The scope has ended, so the object counts again.
cov->CommandRan(synthetic, "/s/a.cmake", 1);
std::string const text = writeText(cov->ToTracefile());
ASSERT_TRUE(contains(text, "DA:1,2\n"));
return true;
}
bool testStartOffWhenUnset()
{
FinishGuard guard;
cmSystemTools::UnPutEnv("CMAKE_LISTFILE_COVERAGE_DIR");
std::string error;
ASSERT_TRUE(cmListFileCoverage::Start(error));
ASSERT_TRUE(cmListFileCoverage::Get() == nullptr);
return true;
}
bool testFinishWithNothingRegistered()
{
TempDir dir;
ASSERT_TRUE(dir.Ok);
FinishGuard guard;
ASSERT_TRUE(startCoverageIn(dir.Path));
cmListFileCoverage::Finish();
ASSERT_TRUE(cmListFileCoverage::Get() == nullptr);
ASSERT_EQUAL(countEntries(dir.Path), 0);
return true;
}
bool testFinishWithNoExecutableLines()
{
TempDir dir;
ASSERT_TRUE(dir.Ok);
{
FinishGuard guard;
ASSERT_TRUE(startCoverageIn(dir.Path));
cmListFileCoverage* cov = cmListFileCoverage::Get();
ASSERT_TRUE(cov != nullptr);
cov->FileParsed("/s/comments.cmake", parse("# Only a comment.\n"));
}
ASSERT_EQUAL(countEntries(dir.Path), 0);
return true;
}
}
int testListFileCoverage(int /*unused*/, char* /*unused*/[])
{
return runTests({ testExecutableLines, testRecording, testFileParsedTwice,
testSyntheticCommand, testStartOffWhenUnset,
testFinishWithNothingRegistered,
testFinishWithNoExecutableLines },
false);
}