TEST_INCLUDE_FILES: Evaluate generator expressions in include paths

The TEST_INCLUDE_FILE(S) directory properties were written verbatim into
CTestTestfile.cmake, so a $<CONFIG>-parameterized include path could not
select a per-configuration script and failed at ctest time.

Evaluate generator expressions per include entry in
cmLocalGenerator::GenerateTestFiles():

* Entries without a generator expression are emitted unchanged.
* On single-config generators a genex entry is evaluated once and
  emitted as one unconditional include.
* On multi-config generators it is evaluated for every configuration;
  a config-independent result collapses to one unconditional include,
  otherwise each non-empty per-config result is guarded by a
  CTEST_CONFIGURATION_TYPE branch, mirroring add_test().  An entry that
  evaluates to empty adds no include.

Promote cmScriptGenerator::CreateConfigTest to a public static helper so
the include guards reuse the exact add_test() config-test encoding; the
instance overloads now delegate to it.  Evaluated results are quoted via
cmScriptGenerator::Quote, while plain entries keep their raw
serialization.

Fixes: #27941
This commit is contained in:
Daksh Mamodiya
2026-07-23 18:58:04 +02:00
parent 3eda12235e
commit 63316be823
17 changed files with 391 additions and 6 deletions
+6
View File
@@ -12,3 +12,9 @@ included and processed when ``ctest`` is run on the directory.
If both the ``TEST_INCLUDE_FILE`` and :prop_dir:`TEST_INCLUDE_FILES` directory
properties are set, the script specified in ``TEST_INCLUDE_FILE`` is included
first, followed by the scripts listed in ``TEST_INCLUDE_FILES``.
.. versionadded:: 4.5
The include path may use :manual:`generator expressions
<cmake-generator-expressions(7)>`, with the same per-configuration semantics
as :prop_dir:`TEST_INCLUDE_FILES`.
+30
View File
@@ -13,6 +13,19 @@ were CTest dashboard scripts. It is common to generate such scripts dynamically
since many variables and commands available during configuration are not
accessible at test phase.
.. versionadded:: 4.5
Include paths may use :manual:`generator expressions
<cmake-generator-expressions(7)>`. This is most useful with
:prop_gbl:`multi-config generators <GENERATOR_IS_MULTI_CONFIG>`, where a
``$<CONFIG>``-parameterized path selects a different script per
configuration. On multi-config generators, an entry whose evaluated result
varies by configuration is emitted under a per-configuration guard, so it is
included only for the matching ``ctest -C <config>`` run. A path whose result
is the same for all configurations is included unconditionally.
No build dependency is added for targets referenced in an include path.
Examples
^^^^^^^^
@@ -38,3 +51,20 @@ Setting this directory property to append one or more CMake scripts:
execute_process(
COMMAND "@CMAKE_COMMAND@" -E echo "script.cmake executed during CTest"
)
Selecting a per-configuration script with a generator expression:
.. code-block:: cmake
:caption: CMakeLists.txt
file(GENERATE
OUTPUT "props_$<CONFIG>.cmake"
CONTENT "set_tests_properties(some.test PROPERTIES LABELS \"$<CONFIG>\")\n"
)
set_property(
DIRECTORY
APPEND
PROPERTY TEST_INCLUDE_FILES
"${CMAKE_CURRENT_BINARY_DIR}/props_$<CONFIG>.cmake"
)
@@ -0,0 +1,8 @@
test-include-files-genex
------------------------
* The :prop_dir:`TEST_INCLUDE_FILES` and :prop_dir:`TEST_INCLUDE_FILE`
directory properties now support :manual:`generator expressions
<cmake-generator-expressions(7)>` in the include path. On multi-config
generators a ``$<CONFIG>``-parameterized path can select a different script
per configuration.
+77 -2
View File
@@ -386,16 +386,91 @@ void cmLocalGenerator::GenerateTestFiles()
fout << "set(CTEST_RESOURCE_SPEC_FILE \"" << resourceSpecFile << "\")\n";
}
auto writeTestIncludeFile = [this, &fout, &configurationTypes,
&config](std::string const& entry) {
// Entries without a generator expression are emitted verbatim,
// preserving CTest-time ${VAR} expansion of the path.
if (cmGeneratorExpression::Find(entry) == std::string::npos) {
fout << "include(\"" << entry << "\")\n";
return;
}
// Emit one include() per path in an evaluated result. As for list-valued
// properties, the result is split on ';', and each path is quoted so that
// any '"', '$', or '\' cannot break the include() argument or trigger a
// second-stage CTest ${...} expansion. An empty result adds no include.
auto writeIncludes = [&fout](std::vector<std::string> const& paths,
char const* indent) {
for (std::string const& path : paths) {
fout << indent << "include(" << cmScriptGenerator::Quote(path)
<< ")\n";
}
};
if (!this->GlobalGenerator->IsMultiConfig() ||
configurationTypes.empty()) {
// Single-config generator, or a multi-config generator with no
// configuration types: evaluate once with the default configuration
// and emit unconditional includes.
std::vector<std::string> paths;
cmExpandList(cmGeneratorExpression::Evaluate(entry, this, config),
paths);
writeIncludes(paths, "");
return;
}
// Multi-config generator: evaluate the entry for every configuration.
std::vector<std::string> results;
results.reserve(configurationTypes.size());
bool allSame = true;
for (std::string const& ct : configurationTypes) {
results.emplace_back(cmGeneratorExpression::Evaluate(entry, this, ct));
if (results.back() != results.front()) {
allSame = false;
}
}
// A config-independent result collapses to unconditional includes, so it
// still runs when ctest is invoked without -C.
if (allSame) {
std::vector<std::string> paths;
cmExpandList(results.front(), paths);
writeIncludes(paths, "");
return;
}
// Otherwise guard each per-config result to match the config guards used
// for add_test(). A configuration whose result yields no include is
// skipped so no empty guard is emitted.
bool first = true;
for (size_t i = 0; i < configurationTypes.size(); ++i) {
std::vector<std::string> paths;
cmExpandList(results[i], paths);
if (paths.empty()) {
continue;
}
fout << (first ? "if(" : "elseif(")
<< cmScriptGenerator::CreateConfigTest("CTEST_CONFIGURATION_TYPE",
configurationTypes[i])
<< ")\n";
writeIncludes(paths, " ");
first = false;
}
if (!first) {
fout << "endif()\n";
}
};
cmValue testIncludeFile = this->Makefile->GetProperty("TEST_INCLUDE_FILE");
if (testIncludeFile) {
fout << "include(\"" << *testIncludeFile << "\")\n";
writeTestIncludeFile(*testIncludeFile);
}
cmValue testIncludeFiles = this->Makefile->GetProperty("TEST_INCLUDE_FILES");
if (testIncludeFiles) {
cmList includesList{ *testIncludeFiles };
for (std::string const& i : includesList) {
fout << "include(\"" << i << "\")\n";
writeTestIncludeFile(i);
}
}
+18 -4
View File
@@ -100,9 +100,10 @@ static void cmScriptGeneratorEncodeConfig(std::string const& config,
}
}
std::string cmScriptGenerator::CreateConfigTest(std::string const& config)
std::string cmScriptGenerator::CreateConfigTest(std::string const& configVar,
std::string const& config)
{
std::string result = cmStrCat(this->RuntimeConfigVariable, " MATCHES \"^(");
std::string result = cmStrCat(configVar, " MATCHES \"^(");
if (!config.empty()) {
cmScriptGeneratorEncodeConfig(config, result);
}
@@ -111,9 +112,9 @@ std::string cmScriptGenerator::CreateConfigTest(std::string const& config)
}
std::string cmScriptGenerator::CreateConfigTest(
std::vector<std::string> const& configs)
std::string const& configVar, std::vector<std::string> const& configs)
{
std::string result = cmStrCat(this->RuntimeConfigVariable, " MATCHES \"^(");
std::string result = cmStrCat(configVar, " MATCHES \"^(");
char const* sep = "";
for (std::string const& config : configs) {
result += sep;
@@ -124,6 +125,19 @@ std::string cmScriptGenerator::CreateConfigTest(
return result;
}
std::string cmScriptGenerator::CreateConfigTest(std::string const& config)
{
return cmScriptGenerator::CreateConfigTest(this->RuntimeConfigVariable,
config);
}
std::string cmScriptGenerator::CreateConfigTest(
std::vector<std::string> const& configs)
{
return cmScriptGenerator::CreateConfigTest(this->RuntimeConfigVariable,
configs);
}
void cmScriptGenerator::GenerateScript(std::ostream& os)
{
// Track indentation.
+5
View File
@@ -78,6 +78,11 @@ public:
static cmScriptGeneratorQuoted Quote(cm::string_view value);
static std::string CreateConfigTest(std::string const& configVar,
std::string const& config);
static std::string CreateConfigTest(std::string const& configVar,
std::vector<std::string> const& configs);
protected:
using Indent = cmScriptGeneratorIndent;
virtual void GenerateScript(std::ostream& os);
@@ -19,3 +19,28 @@ function(run_TID)
endfunction()
run_TID()
function(run_TIDGenex)
# Generator expressions in TEST_INCLUDE_FILE(S) include paths.
set(RunCMake_TEST_BINARY_DIR ${RunCMake_BINARY_DIR}/TIDGenex-build)
set(RunCMake_TEST_NO_CLEAN 1)
if(NOT RunCMake_GENERATOR_IS_MULTI_CONFIG)
set(RunCMake_TEST_OPTIONS -DCMAKE_BUILD_TYPE=Debug)
endif()
file(REMOVE_RECURSE "${RunCMake_TEST_BINARY_DIR}")
file(MAKE_DIRECTORY "${RunCMake_TEST_BINARY_DIR}")
run_cmake(TIDGenex)
run_cmake_command(TIDGenex-ctest-noC ${CMAKE_CTEST_COMMAND})
run_cmake_command(TIDGenex-ctest-Debug ${CMAKE_CTEST_COMMAND} -C Debug)
if(RunCMake_GENERATOR_IS_MULTI_CONFIG)
run_cmake_command(TIDGenex-ctest-Release ${CMAKE_CTEST_COMMAND} -C Release)
endif()
endfunction()
run_TIDGenex()
# Serialization/quoting rules for genex-evaluated include paths (configure only).
run_cmake(TIDGenexQuoting)
# An unknown generator expression must fail at generate time.
run_cmake(TIDGenexBadGenex)
@@ -0,0 +1,49 @@
# Assert the serialization of the generated CTestTestfile.cmake for the
# TEST_INCLUDE_FILE(S) generator-expression feature.
set(ctf "${RunCMake_TEST_BINARY_DIR}/CTestTestfile.cmake")
if(NOT EXISTS "${ctf}")
set(RunCMake_TEST_FAILED "CTestTestfile.cmake not found:\n ${ctf}")
return()
endif()
file(READ "${ctf}" content)
# Plain (singular) entry stays a bare, double-quoted include (regression).
if(NOT content MATCHES "include\\(\"[^\n]*plain[.]cmake\"\\)")
string(APPEND RunCMake_TEST_FAILED
"plain include not emitted as a bare double-quoted include\n")
endif()
# Config-independent genex collapses to one unconditional bare include.
if(NOT content MATCHES "include\\(\"[^\n]*indep[.]cmake\"\\)")
string(APPEND RunCMake_TEST_FAILED
"config-independent genex not collapsed to one unconditional include\n")
endif()
# A conditional-empty genex must never emit an empty include().
if(content MATCHES "include\\(\"\"\\)")
string(APPEND RunCMake_TEST_FAILED "empty include() emitted\n")
endif()
if(RunCMake_GENERATOR_IS_MULTI_CONFIG)
if(NOT content MATCHES "CTEST_CONFIGURATION_TYPE MATCHES \"\\^\\(\\[Dd\\]\\[Ee\\]\\[Bb\\]\\[Uu\\]\\[Gg\\]\\)[$]\"")
string(APPEND RunCMake_TEST_FAILED
"expected per-config Debug guard not found on multi-config\n")
endif()
if(NOT content MATCHES "include\\(\"[^\n]*props_Release[.]cmake\"\\)")
string(APPEND RunCMake_TEST_FAILED
"expected props_Release.cmake per-config branch not found\n")
endif()
else()
if(NOT content MATCHES "include\\(\"[^\n]*props_Debug[.]cmake\"\\)")
string(APPEND RunCMake_TEST_FAILED
"expected unconditional props_Debug.cmake include not found\n")
endif()
if(NOT content MATCHES "include\\(\"[^\n]*dbgonly[.]cmake\"\\)")
string(APPEND RunCMake_TEST_FAILED
"expected unconditional dbgonly.cmake include not found\n")
endif()
if(content MATCHES "CTEST_CONFIGURATION_TYPE MATCHES")
string(APPEND RunCMake_TEST_FAILED
"unexpected per-config guard on single-config generator\n")
endif()
endif()
@@ -0,0 +1,19 @@
# ctest -C Debug: the Debug per-config include and the Debug-only conditional
# include both run; the Release per-config include does not.
if(NOT actual_stdout MATCHES "plain_test")
string(APPEND RunCMake_TEST_FAILED "plain_test missing with -C Debug\n")
endif()
if(NOT actual_stdout MATCHES "indep_test")
string(APPEND RunCMake_TEST_FAILED "indep_test missing with -C Debug\n")
endif()
if(NOT actual_stdout MATCHES "config_Debug")
string(APPEND RunCMake_TEST_FAILED "config_Debug missing with -C Debug\n")
endif()
if(NOT actual_stdout MATCHES "dbgonly_test")
string(APPEND RunCMake_TEST_FAILED "dbgonly_test missing with -C Debug\n")
endif()
if(RunCMake_GENERATOR_IS_MULTI_CONFIG)
if(actual_stdout MATCHES "config_Release")
string(APPEND RunCMake_TEST_FAILED "config_Release ran under -C Debug\n")
endif()
endif()
@@ -0,0 +1,17 @@
# ctest -C Release (multi-config only): the Release per-config include runs;
# the Debug per-config include and the Debug-only conditional include do not.
if(NOT actual_stdout MATCHES "plain_test")
string(APPEND RunCMake_TEST_FAILED "plain_test missing with -C Release\n")
endif()
if(NOT actual_stdout MATCHES "indep_test")
string(APPEND RunCMake_TEST_FAILED "indep_test missing with -C Release\n")
endif()
if(NOT actual_stdout MATCHES "config_Release")
string(APPEND RunCMake_TEST_FAILED "config_Release missing with -C Release\n")
endif()
if(actual_stdout MATCHES "config_Debug")
string(APPEND RunCMake_TEST_FAILED "config_Debug ran under -C Release\n")
endif()
if(actual_stdout MATCHES "dbgonly_test")
string(APPEND RunCMake_TEST_FAILED "dbgonly_test ran under -C Release\n")
endif()
@@ -0,0 +1,27 @@
# ctest invoked WITHOUT -C. Unconditional includes always run; guarded
# per-config includes run only on single-config (where they collapse).
if(NOT actual_stdout MATCHES "plain_test")
string(APPEND RunCMake_TEST_FAILED "plain_test missing without -C\n")
endif()
if(NOT actual_stdout MATCHES "indep_test")
string(APPEND RunCMake_TEST_FAILED "indep_test missing without -C\n")
endif()
if(RunCMake_GENERATOR_IS_MULTI_CONFIG)
if(actual_stdout MATCHES "config_Debug|config_Release")
string(APPEND RunCMake_TEST_FAILED
"guarded config-dependent test ran without -C on multi-config\n")
endif()
if(actual_stdout MATCHES "dbgonly_test")
string(APPEND RunCMake_TEST_FAILED
"dbgonly_test ran without -C on multi-config\n")
endif()
else()
if(NOT actual_stdout MATCHES "config_Debug")
string(APPEND RunCMake_TEST_FAILED
"config_Debug missing on single-config without -C\n")
endif()
if(NOT actual_stdout MATCHES "dbgonly_test")
string(APPEND RunCMake_TEST_FAILED
"dbgonly_test missing on single-config without -C\n")
endif()
endif()
@@ -0,0 +1,30 @@
enable_testing()
set(gen_dir "${CMAKE_CURRENT_BINARY_DIR}")
# Plain include script (no generator expression in the path); used via the
# deprecated singular TEST_INCLUDE_FILE to also cover singular-first ordering.
file(GENERATE OUTPUT "${gen_dir}/plain.cmake"
CONTENT "add_test(plain_test \"${CMAKE_COMMAND}\" -E true)\n")
# Per-configuration script selected by $<CONFIG> in the include path.
file(GENERATE OUTPUT "${gen_dir}/props_$<CONFIG>.cmake"
CONTENT "add_test(config_$<CONFIG> \"${CMAKE_COMMAND}\" -E true)\n")
# Config-independent generator expression in the path: collapses to a single
# unconditional include (still runs without -C).
file(GENERATE OUTPUT "${gen_dir}/indep.cmake"
CONTENT "add_test(indep_test \"${CMAKE_COMMAND}\" -E true)\n")
# Debug-only include via a conditional-empty generator expression.
file(GENERATE OUTPUT "${gen_dir}/dbgonly.cmake"
CONTENT "add_test(dbgonly_test \"${CMAKE_COMMAND}\" -E true)\n")
set_property(DIRECTORY PROPERTY TEST_INCLUDE_FILE
"${gen_dir}/plain.cmake")
set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES
"${gen_dir}/props_$<CONFIG>.cmake")
set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES
"$<1:${gen_dir}/indep.cmake>")
set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES
"$<$<CONFIG:Debug>:${gen_dir}/dbgonly.cmake>")
@@ -0,0 +1 @@
1
@@ -0,0 +1,2 @@
Error evaluating generator expression:.*
.*NOTAGENEX
@@ -0,0 +1,6 @@
enable_testing()
# An unknown generator expression in the include path must fail at generate
# time (diagnostics are best-effort: they need not name the set_property call).
set_property(DIRECTORY PROPERTY TEST_INCLUDE_FILE
"${CMAKE_CURRENT_BINARY_DIR}/x_$<NOTAGENEX>.cmake")
@@ -0,0 +1,44 @@
set(ctf "${RunCMake_TEST_BINARY_DIR}/CTestTestfile.cmake")
if(NOT EXISTS "${ctf}")
set(RunCMake_TEST_FAILED "CTestTestfile.cmake not found:\n ${ctf}")
return()
endif()
file(READ "${ctf}" content)
# (1) ';' result: split into two separate double-quoted includes (fan-out),
# not one combined include.
if(NOT content MATCHES "include\\(\"[^\n]*/a\"\\)")
string(APPEND RunCMake_TEST_FAILED
"semicolon result first element not emitted as its own include\n")
endif()
if(NOT content MATCHES "include\\(\"b[.]cmake\"\\)")
string(APPEND RunCMake_TEST_FAILED
"semicolon result second element not emitted as its own include\n")
endif()
if(content MATCHES "include\\(\"[^\n]*a;b[.]cmake\"\\)")
string(APPEND RunCMake_TEST_FAILED
"semicolon result was not split into multiple includes\n")
endif()
# (2) space result: double-quoted.
if(NOT content MATCHES "include\\(\"[^\n]*a b[.]cmake\"\\)")
string(APPEND RunCMake_TEST_FAILED "space result not double-quoted\n")
endif()
# (3) '$' result: bracket form, and NOT double-quoted.
if(NOT content MATCHES "include\\(\\[=*\\[[^\n]*x[$]y[.]cmake[^\n]*\\]=*\\]\\)")
string(APPEND RunCMake_TEST_FAILED "dollar result not emitted in bracket form\n")
endif()
if(content MATCHES "include\\(\"[^\n]*x[$]y[.]cmake")
string(APPEND RunCMake_TEST_FAILED
"dollar result was double-quoted (should be bracketed)\n")
endif()
# (4) plain ${VAR} entry preserved raw and bare (double-quoted, not bracketed).
if(NOT content MATCHES "include\\(\"[^\n]*lit_[^\n]*MY_VAR[^\n]*[.]cmake\"\\)")
string(APPEND RunCMake_TEST_FAILED "plain \${VAR} entry not preserved raw\n")
endif()
if(content MATCHES "include\\(\\[=*\\[[^\n]*MY_VAR")
string(APPEND RunCMake_TEST_FAILED
"plain \${VAR} entry was bracketed (should stay raw double-quoted)\n")
endif()
@@ -0,0 +1,27 @@
enable_testing()
set(d "${CMAKE_CURRENT_BINARY_DIR}")
# This case only asserts serialization of the generated CTestTestfile.cmake;
# ctest is never run, so the referenced paths need not exist.
# (1) A genex result containing ';' is split into multiple includes (list
# semantics, as for usage-requirement properties). $<SEMICOLON> keeps the
# ';' out of the pre-eval list split so it appears only in the evaluated
# result, which is then re-split into one include() per element.
set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES
"$<1:${d}/a$<SEMICOLON>b.cmake>")
# (2) A genex result containing a space stays double-quoted.
set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES
"$<1:${d}/a b.cmake>")
# (3) A genex result containing '$' is forced to bracket form so CTest does not
# perform a second-stage ${...} expansion. $<1:$> composes a literal '$'.
set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES
"$<1:${d}/x$<1:$>y.cmake>")
# (4) A plain entry containing ${VAR} stays a raw, bare double-quoted include so
# CTest still expands it at test time (must NOT be routed through Quote).
set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES
"${d}/lit_\${MY_VAR}.cmake")