GenEx: add $<LIST:TRANSFORM,...,APPLY,body> action

Add an APPLY action to $<LIST:TRANSFORM> that evaluates an arbitrary <body>
once per selected element, with $<_0> bound to the element, so a list can be
mapped through any generator expression at generate time.  Unlike the
configure-time list(TRANSFORM ... APPLY <function>) command, the genex form has
no side effects and returns the body's value directly, and a list-valued result
expands into multiple elements.

The body evaluates in its own binding scope, so nested APPLY actions can shadow
$<_0>, and context-sensitive state it observes (such as target dependencies)
still propagates to the enclosing expression.  APPLY accepts the same
AT/FOR/REGEX selectors as the canned actions.

Issue: #27892
This commit is contained in:
Mickaël Germain
2026-06-17 20:21:24 -07:00
parent c0a0b7fdd9
commit 0f6e8ded1d
25 changed files with 450 additions and 1 deletions

View File

@@ -933,6 +933,23 @@ List Transformations
element instead of the beginning of each repeated search.
See policy :policy:`CMP0186`.
``APPLY``
Transform each selected element by evaluating an arbitrary generator
expression ``<body>`` once per element. Within ``<body>``, the bound
operand ``$<_0>`` expands to the current element. Unlike the
configure-time ``list(TRANSFORM ... APPLY <function>)`` command, the
genex form returns the body's value directly and has no side effects. A
list-valued body result expands into multiple elements, like any other
list-valued generator expression.
.. code-block:: cmake
$<LIST:TRANSFORM,list,APPLY,body[,SELECTOR]>
See ``$<_0>`` below for the bound operand.
.. versionadded:: 4.5
``SELECTOR`` determines which items of the list will be transformed.
Only one type of selector can be specified at a time. When given,
``SELECTOR`` must be one of the following:
@@ -1061,6 +1078,9 @@ Bound Operands
generator expression that evaluates a ``<body>`` once for each value it
supplies, with ``$<_0>`` expanding to that value.
For example, ``$<LIST:TRANSFORM,...,APPLY,body>`` evaluates ``body`` once per
list element with ``$<_0>`` bound to the current element.
``$<_0>`` is only valid inside the body of a binding operation. Using it
anywhere else is an error.

View File

@@ -0,0 +1,6 @@
genex-list-transform-apply
--------------------------
* The :genex:`LIST` generator expression's ``TRANSFORM`` operation gained an
``APPLY`` action that evaluates an arbitrary generator expression for each
element, with ``$<_0>`` referring to the current element.

View File

@@ -107,6 +107,48 @@ std::string cmGeneratorExpressionNode::EvaluateDependentExpression(
return result;
}
// Re-evaluate the unevaluated <body> subtree of a binding operation with
// `$<_0>` bound to the given operand. A fresh Evaluation is built from a
// copied, mutated Context so that nested binding operations can shadow `$<_0>`
// and restore it on exit.
static std::string EvaluateBodyWithBoundOperand(
cmGeneratorExpressionEvaluatorVector const& bodyExpr,
std::string const& operand, cm::GenEx::Evaluation* eval,
cmGeneratorExpressionDAGChecker* dagChecker)
{
cm::GenEx::Context elemContext = eval->Context; // copy
elemContext.SetBoundOperand(operand);
cm::GenEx::Evaluation elemEval(
elemContext, eval->Quiet, eval->HeadTarget, eval->CurrentTarget,
eval->EvaluateForBuildsystem, eval->Backtrace);
std::string result;
for (auto const& pExprEval : bodyExpr) {
result += pExprEval->Evaluate(&elemEval, dagChecker);
if (elemEval.HadError) {
eval->HadError = true;
return std::string{};
}
}
eval->HadContextSensitiveCondition |= elemEval.HadContextSensitiveCondition;
eval->HadHeadSensitiveCondition |= elemEval.HadHeadSensitiveCondition;
eval->HadLinkLanguageSensitiveCondition |=
elemEval.HadLinkLanguageSensitiveCondition;
eval->DependTargets.insert(elemEval.DependTargets.begin(),
elemEval.DependTargets.end());
eval->AllTargets.insert(elemEval.AllTargets.begin(),
elemEval.AllTargets.end());
eval->SeenTargetProperties.insert(elemEval.SeenTargetProperties.begin(),
elemEval.SeenTargetProperties.end());
eval->SourceSensitiveTargets.insert(elemEval.SourceSensitiveTargets.begin(),
elemEval.SourceSensitiveTargets.end());
for (auto const& entry : elemEval.MaxLanguageStandard) {
eval->MaxLanguageStandard[entry.first].insert(entry.second.begin(),
entry.second.end());
}
return result;
}
static const struct ZeroNode : public cmGeneratorExpressionNode
{
ZeroNode() {} // NOLINT(modernize-use-equals-default)
@@ -2005,11 +2047,74 @@ static const struct ListNode : public cmGeneratorExpressionNode
bool AcceptsArbitraryContentParameter() const override { return true; }
bool ShouldEvaluateNextParameter(std::vector<std::string> const& parameters,
std::string&) const override
{
// Skip the APPLY <body> (4th parameter) so $<_0> is not evaluated unbound;
// selector args (5th+) evaluate normally.
return !(parameters.size() == 3 && parameters[0] == "TRANSFORM" &&
parameters[2] == "APPLY");
}
std::string Evaluate(
std::vector<std::string> const& parameters, cm::GenEx::Evaluation* eval,
GeneratorExpressionContent const* content,
cmGeneratorExpressionDAGChecker* /*dagChecker*/) const override
cmGeneratorExpressionDAGChecker* dagChecker) const override
{
if (parameters.size() >= 3 && parameters[0] == "TRANSFORM" &&
parameters[2] == "APPLY") {
if (parameters.size() < 4) {
reportError(
eval, content->GetOriginalExpression(),
"sub-command TRANSFORM, action APPLY expects a <body> argument.");
return std::string();
}
cmList list = GetList(parameters[1]);
if (list.empty()) {
return std::string{};
}
cmGeneratorExpressionEvaluatorVector const& bodyExpr =
content->GetParamChildren()[3];
std::vector<std::string> const selectorTokens(parameters.begin() + 4,
parameters.end());
std::unique_ptr<cmList::TransformSelector> selector =
ParseTransformSelector(selectorTokens, eval, content);
if (!selector) {
return std::string();
}
// selector->Makefile is left unset: the REGEX/AT/FOR selectors never
// consult it, and the only one that does (list()'s PREDICATE) cannot
// reach here.
std::vector<bool> selected;
try {
selected = list.GetTransformSelection(*selector);
} catch (cmList::transform_error& e) {
reportError(eval, content->GetOriginalExpression(), e.what());
return std::string();
}
std::vector<std::string> out;
out.reserve(list.size());
std::size_t i = 0;
for (auto const& element : list) {
if (i < selected.size() && selected[i]) {
out.push_back(
EvaluateBodyWithBoundOperand(bodyExpr, element, eval, dagChecker));
if (eval->HadError) {
return std::string();
}
} else {
out.push_back(element);
}
++i;
}
// Join per-element results with ';'; keep empty elements so a body that
// yields "" is not dropped.
return cmList{ out.begin(), out.end(), cmList::ExpandElements::No,
cmList::EmptyElements::Yes }
.to_string();
}
static std::unordered_map<
cm::string_view,
std::function<std::string(cm::GenEx::Evaluation*,

View File

@@ -15,6 +15,7 @@ set(CMakeLib_TESTS
testGccDepfileReader.cxx
testGeneratedFileStream.cxx
testGenExBoundOperand.cxx
testGenExTransformApply.cxx
testJSONHelpers.cxx
testRST.cxx
testRange.cxx

View File

@@ -0,0 +1,174 @@
/* Distributed under the OSI-approved BSD 3-Clause License. See accompanying
file LICENSE.rst or https://cmake.org/licensing for details. */
#include <iostream>
#include <string>
#include <cm/memory>
#include "cmGeneratorExpression.h"
#include "cmGlobalGenerator.h"
#include "cmLocalGenerator.h"
#include "cmMakefile.h"
#include "cmState.h"
#include "cmStateDirectory.h"
#include "cmStateSnapshot.h"
#include "cmake.h"
namespace {
struct GenExFixture
{
cmake CMake{ cmState::Role::Project };
std::unique_ptr<cmGlobalGenerator> GG;
std::unique_ptr<cmMakefile> MF;
std::unique_ptr<cmLocalGenerator> LG;
GenExFixture()
{
this->GG = cm::make_unique<cmGlobalGenerator>(&this->CMake);
cmStateSnapshot snapshot = this->CMake.GetCurrentSnapshot();
snapshot.GetDirectory().SetCurrentBinary(".");
snapshot.GetDirectory().SetCurrentSource(".");
this->MF = cm::make_unique<cmMakefile>(this->GG.get(), snapshot);
this->LG = this->GG->CreateLocalGenerator(this->MF.get());
}
std::string Eval(std::string const& expr)
{
return cmGeneratorExpression::Evaluate(expr, this->LG.get(), "Debug");
}
};
}
static bool testApplyBasic()
{
GenExFixture fx;
return fx.Eval("$<LIST:TRANSFORM,net;audio,APPLY,$<UPPER_CASE:$<_0>>>") ==
"NET;AUDIO";
}
static bool testApplyConstantBody()
{
// The body need not reference the operand at all.
GenExFixture fx;
return fx.Eval("$<LIST:TRANSFORM,a;b;c,APPLY,X>") == "X;X;X";
}
static bool testApplyOperandUsedTwice()
{
// The operand may be substituted more than once in a single body.
GenExFixture fx;
return fx.Eval("$<LIST:TRANSFORM,a;b,APPLY,$<_0>$<_0>>") == "aa;bb";
}
static bool testApplyFlatMapExpands()
{
GenExFixture fx;
// A list-valued body expands: a -> "x;y", b -> "z" => 3 items.
std::string out =
fx.Eval("$<LIST:TRANSFORM,a;b,APPLY,$<IF:$<STREQUAL:$<_0>,a>,x;y,z>>");
std::string len = fx.Eval("$<LIST:LENGTH,$<LIST:TRANSFORM,a;b,APPLY,"
"$<IF:$<STREQUAL:$<_0>,a>,x;y,z>>>");
return out == "x;y;z" && len == "3";
}
static bool testApplySelector()
{
GenExFixture fx;
return fx.Eval(
"$<LIST:TRANSFORM,a;b;c;d,APPLY,$<UPPER_CASE:$<_0>>,AT,0,2>") ==
"A;b;C;d";
}
static bool testApplyNestedShadowing()
{
GenExFixture fx;
return fx.Eval(
"$<LIST:TRANSFORM,a;b,APPLY,"
"$<LIST:TRANSFORM,$<_0>1;$<_0>2,APPLY,$<UPPER_CASE:$<_0>>>>") ==
"A1;A2;B1;B2";
}
static bool testApplyEmptyList()
{
GenExFixture fx;
// An empty list produces an empty string without error (matches canned
// TRANSFORM behavior).
std::string got = fx.Eval("$<LIST:TRANSFORM,,APPLY,x$<_0>y>");
if (!got.empty()) {
std::cerr << "testApplyEmptyList: expected empty string, got '" << got
<< "'\n";
return false;
}
return true;
}
static bool testApplyEmptyElement()
{
GenExFixture fx;
// Every element is selected, including the leading empty one, so the body
// wraps each: "" -> "xy", "b" -> "xby".
return fx.Eval("$<LIST:TRANSFORM,;b,APPLY,x$<_0>y>") == "xy;xby";
}
static bool testApplyEmptyPassthrough()
{
GenExFixture fx;
// The list ";b" has two elements: "" (index 0, unselected) and "b"
// (index 1, selected). The unselected empty element must pass through
// verbatim, not be silently dropped.
std::string got = fx.Eval("$<LIST:TRANSFORM,;b,APPLY,X$<_0>Y,AT,1>");
if (got != ";XbY") {
std::cerr << "testApplyEmptyPassthrough: expected ';XbY', got '" << got
<< "'\n";
return false;
}
return true;
}
static bool testApplyEmptyResult()
{
GenExFixture fx;
// Body yields "" for "a" and "z" for "b". The empty element produced by
// the body must not be dropped.
std::string got =
fx.Eval("$<LIST:TRANSFORM,a;b,APPLY,$<IF:$<STREQUAL:$<_0>,a>,,z>>");
if (got != ";z") {
std::cerr << "testApplyEmptyResult: expected ';z', got '" << got << "'\n";
return false;
}
return true;
}
int testGenExTransformApply(int /*argc*/, char* /*argv*/[])
{
if (!testApplyBasic()) {
return 1;
}
if (!testApplyConstantBody()) {
return 1;
}
if (!testApplyOperandUsedTwice()) {
return 1;
}
if (!testApplyFlatMapExpands()) {
return 1;
}
if (!testApplySelector()) {
return 1;
}
if (!testApplyNestedShadowing()) {
return 1;
}
if (!testApplyEmptyList()) {
return 1;
}
if (!testApplyEmptyElement()) {
return 1;
}
if (!testApplyEmptyPassthrough()) {
return 1;
}
if (!testApplyEmptyResult()) {
return 1;
}
return 0;
}

View File

@@ -0,0 +1,5 @@
file(READ "${RunCMake_TEST_BINARY_DIR}/out.txt" actual)
string(STRIP "${actual}" actual)
if(NOT actual STREQUAL "NET;AUDIO;VIDEO")
set(RunCMake_TEST_FAILED "unexpected APPLY output: [${actual}]")
endif()

View File

@@ -0,0 +1,3 @@
set(input "net;audio;video")
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/out.txt"
CONTENT "$<LIST:TRANSFORM,${input},APPLY,$<UPPER_CASE:$<_0>>>\n")

View File

@@ -0,0 +1 @@
sub-command TRANSFORM, selector AT expects at least one numeric value

View File

@@ -0,0 +1,4 @@
# 'AT' with no following index is a malformed selector; APPLY must report the
# same diagnostic the canned TRANSFORM actions do.
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/x.txt"
CONTENT "$<LIST:TRANSFORM,a;b,APPLY,$<_0>,AT>")

View File

@@ -0,0 +1 @@
index: 9 out of range

View File

@@ -0,0 +1,5 @@
# A body that errors for an element must fail the whole expression, not be
# silently dropped. $<LIST:GET,...,9> is out of range for a single-element
# operand.
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/x.txt"
CONTENT "$<LIST:TRANSFORM,a;b,APPLY,$<LIST:GET,$<_0>,9>>")

View File

@@ -0,0 +1,6 @@
/* A real symbol so the static archive is non-empty (some ranlib versions warn
on an archive with no global symbols). */
int cmListTransformApplyProvider(void)
{
return 0;
}

View File

@@ -0,0 +1,18 @@
enable_language(C)
# `provider` is EXCLUDE_FROM_ALL, so it builds only when another target declares
# a build-order dependency on it.
add_library(provider STATIC ListTransformApplyDependTarget.c)
set_property(TARGET provider PROPERTY EXCLUDE_FROM_ALL 1)
# `consumer`'s only reference to `provider` is the $<TARGET_FILE:$<_0>> in the
# APPLY body. CMake derives the build-order dependency from that reference only
# if APPLY propagates the target back out (DependTargets). If that regresses,
# `provider` is never built and the copy below fails for lack of the archive, so
# a green build of `consumer` alone is the regression guard.
add_custom_target(consumer
COMMAND "${CMAKE_COMMAND}" -E copy
"$<LIST:TRANSFORM,provider,APPLY,$<TARGET_FILE:$<_0>>>"
"${CMAKE_CURRENT_BINARY_DIR}/copied.out"
VERBATIM
)

View File

@@ -0,0 +1,12 @@
file(READ "${RunCMake_TEST_BINARY_DIR}/ll.txt" actual)
file(READ "${RunCMake_TEST_BINARY_DIR}/expected.txt" expected)
string(STRIP "${actual}" actual)
string(STRIP "${expected}" expected)
# APPLY over app's LINK_LIBRARIES must equal the explicit per-library
# $<TARGET_FILE_NAME> expansion exactly.
if(NOT actual STREQUAL expected)
set(RunCMake_TEST_FAILED
"APPLY-over-LINK_LIBRARIES output does not match the explicit expansion:\n"
" actual: [${actual}]\n"
" expected: [${expected}]")
endif()

View File

@@ -0,0 +1,30 @@
enable_language(C)
# Evaluate LINK_LIBRARIES transitively (CMP0189, CMake 4.1+); the test dir's
# cmake_minimum_required would otherwise leave this OLD.
cmake_policy(SET CMP0189 NEW)
# A dependency tree, linked PUBLIC so each library's dependencies propagate
# through INTERFACE_LINK_LIBRARIES:
# app -> { net, audio }; net -> { ssl, zlib }; audio -> codec
add_library(ssl STATIC empty.c)
add_library(zlib STATIC empty.c)
add_library(codec STATIC empty.c)
add_library(net STATIC empty.c)
target_link_libraries(net PUBLIC ssl zlib)
add_library(audio STATIC empty.c)
target_link_libraries(audio PUBLIC codec)
add_library(app STATIC empty.c)
target_link_libraries(app PRIVATE net audio)
# app's LINK_LIBRARIES now evaluates to the transitive closure
# (net;audio;ssl;zlib;codec); map each linked library to its on-disk file name.
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/ll.txt"
CONTENT "$<LIST:TRANSFORM,$<TARGET_PROPERTY:app,LINK_LIBRARIES>,APPLY,$<TARGET_FILE_NAME:$<_0>>>\n")
# Exact, platform-independent reference: the explicit per-library expansion in
# the transitive-closure order. The APPLY output must be byte-identical.
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/expected.txt"
CONTENT "$<TARGET_FILE_NAME:net>;$<TARGET_FILE_NAME:audio>;$<TARGET_FILE_NAME:ssl>;$<TARGET_FILE_NAME:zlib>;$<TARGET_FILE_NAME:codec>\n")

View File

@@ -0,0 +1 @@
sub-command TRANSFORM, action APPLY expects a <body> argument

View File

@@ -0,0 +1,3 @@
# APPLY with no body argument is an error.
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/x.txt"
CONTENT "$<LIST:TRANSFORM,a;b,APPLY>")

View File

@@ -0,0 +1,12 @@
file(READ "${RunCMake_TEST_BINARY_DIR}/nested.txt" actual)
file(READ "${RunCMake_TEST_BINARY_DIR}/expected.txt" expected)
string(STRIP "${actual}" actual)
string(STRIP "${expected}" expected)
# The nested APPLY (outer binds each target, inner uppercases each include dir)
# must equal the explicit per-target expansion exactly.
if(NOT actual STREQUAL expected)
set(RunCMake_TEST_FAILED
"nested APPLY output does not match the explicit expansion:\n"
" actual: [${actual}]\n"
" expected: [${expected}]")
endif()

View File

@@ -0,0 +1,17 @@
add_library(libA INTERFACE)
target_include_directories(libA INTERFACE /a/one /a/two)
add_library(libB INTERFACE)
target_include_directories(libB INTERFACE /b/one)
# Nested APPLY: the outer APPLY runs over targets; its body is an APPLY over
# each target's include directories that uppercases them. The outer $<_0> (a
# target) feeds the inner list arg $<TARGET_PROPERTY:...>; the inner $<_0> (a
# directory) feeds $<UPPER_CASE> under the inner binding, while the outer
# binding stays intact.
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/nested.txt"
CONTENT "$<LIST:TRANSFORM,libA;libB,APPLY,$<LIST:TRANSFORM,$<TARGET_PROPERTY:$<_0>,INTERFACE_INCLUDE_DIRECTORIES>,APPLY,$<UPPER_CASE:$<_0>>>>\n")
# Exact reference: the same result without the inner APPLY (uppercase each
# target's include dirs directly). The nested APPLY must match it.
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/expected.txt"
CONTENT "$<UPPER_CASE:$<TARGET_PROPERTY:libA,INTERFACE_INCLUDE_DIRECTORIES>>;$<UPPER_CASE:$<TARGET_PROPERTY:libB,INTERFACE_INCLUDE_DIRECTORIES>>\n")

View File

@@ -0,0 +1,7 @@
file(READ "${RunCMake_TEST_BINARY_DIR}/tp.txt" actual)
string(STRIP "${actual}" actual)
# flatMap: libA -> /a/inc (1 dir), libB -> /b/inc1;/b/inc2 (2 dirs) => 3 items.
if(NOT actual STREQUAL "/a/inc;/b/inc1;/b/inc2")
set(RunCMake_TEST_FAILED
"unexpected APPLY target-property output: [${actual}]")
endif()

View File

@@ -0,0 +1,6 @@
add_library(libA INTERFACE)
target_include_directories(libA INTERFACE /a/inc)
add_library(libB INTERFACE)
target_include_directories(libB INTERFACE /b/inc1 /b/inc2)
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/tp.txt"
CONTENT "$<LIST:TRANSFORM,libA;libB,APPLY,$<TARGET_PROPERTY:$<_0>,INTERFACE_INCLUDE_DIRECTORIES>>\n")

View File

@@ -57,6 +57,13 @@ run_cmake(FILTER-InvalidOperator)
run_cmake(FILTER-Exclude)
run_cmake(FILTER-Include)
run_cmake(LIST-edgecases)
run_cmake(ListTransformApply)
run_cmake(ListTransformApplyTargetProperty)
run_cmake(ListTransformApplyLinkLibraries)
run_cmake(ListTransformApplyNested)
run_cmake(ListTransformApplyBadSelector)
run_cmake(ListTransformApplyBodyError)
run_cmake(ListTransformApplyMissingBody)
run_cmake(BoundOperandOutsideBinding)
function(run_cmake_build test)
@@ -151,6 +158,8 @@ unset(RunCMake_TEST_OPTIONS)
run_cmake_build_target(COMPILE_ONLY-custom-target-deps consumer)
run_cmake_build_target(ListTransformApplyDependTarget consumer)
run_cmake(CMP0199-WARN)
run_cmake_build(CMP0199-OLD)
run_cmake_build(CMP0199-NEW)