GenEx: add PREDICATE selector to $<LIST:TRANSFORM>

Add a PREDICATE selector to $<LIST:TRANSFORM> that chooses which elements to
transform by evaluating a <body> per element (with $<_0> bound) and acting on
those for which it yields "1".  This selects by computed condition instead of
the fixed AT/FOR/REGEX positions, so any generator expression -- including
target queries -- can decide where an action applies.  It works with both the
canned actions and APPLY.

Issue: #27892
This commit is contained in:
Mickaël Germain
2026-06-23 21:31:20 -07:00
parent 6024f624b0
commit d2ad140ef9
17 changed files with 383 additions and 0 deletions
@@ -977,6 +977,22 @@ List Transformations
$<LIST:TRANSFORM,list,ACTION,REGEX,regular_expression>
``PREDICATE``
Specify a generator expression ``body`` evaluated once per item with the
bound operand :genex:`$<_0>` expanding to the current item. Only items
whose body evaluates to ``1`` are transformed; the body must evaluate to
exactly ``0`` or ``1``. ``PREDICATE`` may be combined with any action,
including ``APPLY`` (in which case both bodies bind :genex:`$<_0>`
independently).
Like all selectors, only one selector may be given; ``PREDICATE`` cannot
be combined with ``AT``, ``FOR``, or ``REGEX``.
.. code-block:: cmake
$<LIST:TRANSFORM,list,ACTION,PREDICATE,body>
.. versionadded:: 4.5
.. genex:: $<JOIN:list,glue>
Joins the ``list`` with the content of the ``glue`` string inserted between
@@ -0,0 +1,7 @@
genex-list-filter-transform-predicate
-------------------------------------
* The :genex:`LIST` generator expression's ``TRANSFORM`` operation gained a
``PREDICATE`` selector that chooses the items to transform by evaluating an
arbitrary generator expression once per item, with ``$<_0>`` referring to the
current item.
+173
View File
@@ -149,6 +149,41 @@ static std::string EvaluateBodyWithBoundOperand(
return result;
}
// Evaluate `predicateBody` once per element of `list`, binding `$<_0>` to the
// element (reusing EvaluateBodyWithBoundOperand). Each result must be exactly
// "0" or "1". Returns the per-element boolean mask, or cm::nullopt after
// reporting an error (non-boolean result, or a failure inside the body).
static cm::optional<std::vector<bool>> EvaluatePredicateMask(
cmGeneratorExpressionEvaluatorVector const& predicateBody,
cmList const& list, cm::string_view subCommand, cm::GenEx::Evaluation* eval,
GeneratorExpressionContent const* content,
cmGeneratorExpressionDAGChecker* dagChecker)
{
std::vector<bool> mask;
mask.reserve(list.size());
for (auto const& element : list) {
std::string r =
EvaluateBodyWithBoundOperand(predicateBody, element, eval, dagChecker);
if (eval->HadError) {
return cm::nullopt;
}
if (r == "1") {
mask.push_back(true);
} else if (r == "0") {
mask.push_back(false);
} else {
reportError(
eval, content->GetOriginalExpression(),
cmStrCat("sub-command ", subCommand,
", PREDICATE body must evaluate to \"0\" or \"1\", but "
"evaluated to \"",
r, "\"."));
return cm::nullopt;
}
}
return mask;
}
static const struct ZeroNode : public cmGeneratorExpressionNode
{
ZeroNode() {} // NOLINT(modernize-use-equals-default)
@@ -1948,6 +1983,123 @@ cm::optional<TransformActionDescriptor> FindTransformActionDescriptor(
return it->second;
}
// Index in `parameters` at which a TRANSFORM action's selector region begins,
// or nullopt if this is not a TRANSFORM or the action is unknown. For the
// APPLY action the <body> occupies slot 3, so the selector starts at slot 4.
cm::optional<std::size_t> TransformSelectorStart(
std::vector<std::string> const& parameters)
{
if (parameters.size() < 3 || parameters[0] != "TRANSFORM") {
return cm::nullopt;
}
std::string const& action = parameters[2];
if (action == "APPLY") {
return std::size_t{ 4 };
}
if (auto d = FindTransformActionDescriptor(action)) {
return std::size_t{ 3 } + static_cast<std::size_t>(d->Arity);
}
return cm::nullopt;
}
// Handle $<LIST:TRANSFORM,...,PREDICATE,<body>>. `predIndex` is the index of
// the PREDICATE token in `parameters`; the <body> follows it. PREDICATE is
// the sole selector: only elements whose predicate is "1" are transformed.
std::string EvaluateTransformPredicate(
std::vector<std::string> const& parameters, std::size_t predIndex,
cm::GenEx::Evaluation* eval, GeneratorExpressionContent const* content,
cmGeneratorExpressionDAGChecker* dagChecker)
{
// PREDICATE must take exactly one <body> and not be combined with another
// selector (AT/FOR/REGEX) or trailing tokens.
if (parameters.size() < predIndex + 2) {
reportError(eval, content->GetOriginalExpression(),
"sub-command TRANSFORM, selector PREDICATE expects a <body> "
"argument.");
return std::string();
}
if (parameters.size() > predIndex + 2) {
reportError(eval, content->GetOriginalExpression(),
"sub-command TRANSFORM, selector PREDICATE expects a single "
"<body> argument and cannot be combined with another "
"selector.");
return std::string();
}
cmList list = GetList(parameters[1]);
if (list.empty()) {
return std::string();
}
cmGeneratorExpressionEvaluatorVector const& predicateBody =
content->GetParamChildren()[predIndex + 1];
auto mask = EvaluatePredicateMask(predicateBody, list, "TRANSFORM"_s, eval,
content, dagChecker);
if (!mask) {
return std::string();
}
if (parameters[2] == "APPLY") {
cmGeneratorExpressionEvaluatorVector const& applyBody =
content->GetParamChildren()[3];
std::vector<std::string> out;
out.reserve(list.size());
std::size_t i = 0;
for (auto const& element : list) {
if ((*mask)[i]) {
out.push_back(
EvaluateBodyWithBoundOperand(applyBody, element, eval, dagChecker));
if (eval->HadError) {
return std::string();
}
} else {
out.push_back(element);
}
++i;
}
return cmList{ out.begin(), out.end(), cmList::ExpandElements::No,
cmList::EmptyElements::Yes }
.to_string();
}
std::string const& action = parameters[2];
auto descriptor = FindTransformActionDescriptor(action);
if (!descriptor) {
reportError(
eval, content->GetOriginalExpression(),
cmStrCat(" sub-command TRANSFORM, ", action, " invalid action."));
return std::string();
}
// Action arguments occupy parameters[3 .. predIndex); TransformSelectorStart
// guarantees there are exactly descriptor->Arity of them.
std::vector<std::string> arguments(parameters.begin() + 3,
parameters.begin() + predIndex);
std::vector<cmList::index_type> indices;
for (std::size_t i = 0; i < mask->size(); ++i) {
if ((*mask)[i]) {
indices.push_back(static_cast<cmList::index_type>(i));
}
}
if (indices.empty()) {
// No element selected: TRANSFORM is a no-op.
return list.to_string();
}
auto selector =
cmList::TransformSelector::New<cmList::TransformSelector::AT>(
std::move(indices));
selector->Makefile = eval->Context.LG->GetMakefile();
try {
return list.transform(descriptor->Action, arguments, std::move(selector))
.to_string();
} catch (cmList::transform_error& e) {
reportError(eval, content->GetOriginalExpression(), e.what());
return std::string();
}
}
// Parse the optional trailing selector of a $<LIST:TRANSFORM,...> action
// (AT <i>... / FOR <start> <stop> [<step>] / REGEX <re>) into a
// cmList::TransformSelector. Returns nullptr (after reporting via `eval`) on
@@ -2076,6 +2228,15 @@ static const struct ListNode : public cmGeneratorExpressionNode
bool ShouldEvaluateNextParameter(std::vector<std::string> const& parameters,
std::string&) const override
{
// Leave a TRANSFORM PREDICATE selector's <body> unevaluated. PREDICATE is
// the selector keyword only when it sits exactly at the selector position
// (not when it is a literal action argument such as APPEND PREDICATE).
if (auto start = TransformSelectorStart(parameters)) {
if (parameters.size() == *start + 1 &&
parameters.back() == "PREDICATE") {
return false;
}
}
// Skip the APPLY <body> (4th parameter) so $<_0> is not evaluated unbound;
// selector args (5th+) evaluate normally.
return !(parameters.size() == 3 && parameters[0] == "TRANSFORM" &&
@@ -2087,6 +2248,18 @@ static const struct ListNode : public cmGeneratorExpressionNode
GeneratorExpressionContent const* content,
cmGeneratorExpressionDAGChecker* dagChecker) const override
{
// TRANSFORM ... PREDICATE <body>: genex-native predicate selector, usable
// with any action (canned or APPLY). Handled here (not in the
// listCommands lambda) because the predicate <body> needs the DAG checker.
if (parameters.size() >= 3 && parameters[0] == "TRANSFORM") {
if (auto start = TransformSelectorStart(parameters)) {
if (*start < parameters.size() && parameters[*start] == "PREDICATE") {
return EvaluateTransformPredicate(parameters, *start, eval, content,
dagChecker);
}
}
}
if (parameters.size() >= 3 && parameters[0] == "TRANSFORM" &&
parameters[2] == "APPLY") {
if (parameters.size() < 4) {
+1
View File
@@ -16,6 +16,7 @@ set(CMakeLib_TESTS
testGeneratedFileStream.cxx
testGenExBoundOperand.cxx
testGenExTransformApply.cxx
testGenExListPredicate.cxx
testJSONHelpers.cxx
testRST.cxx
testRange.cxx
+130
View File
@@ -0,0 +1,130 @@
/* 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");
}
};
bool expectEq(char const* name, std::string const& got,
std::string const& want)
{
if (got != want) {
std::cerr << name << ": expected '" << want << "', got '" << got << "'\n";
return false;
}
return true;
}
}
static bool testCannedTransformStillWorks()
{
GenExFixture fx;
// Existing canned action + REGEX selector must be unaffected by the
// refactor.
return expectEq("testCannedTransformStillWorks",
fx.Eval("$<LIST:TRANSFORM,foo;bar;baz,TOUPPER,REGEX,^ba>"),
"foo;BAR;BAZ");
}
static bool testTransformPredicateCanned()
{
GenExFixture fx;
// PREPEND "X-" only to elements equal to "a"; others pass through.
return expectEq(
"testTransformPredicateCanned",
fx.Eval(
"$<LIST:TRANSFORM,a;b;a,PREPEND,X-,PREDICATE,$<STREQUAL:$<_0>,a>>"),
"X-a;b;X-a");
}
static bool testTransformPredicateNoneSelected()
{
GenExFixture fx;
// No element matches: the list is returned unchanged.
return expectEq("testTransformPredicateNoneSelected",
fx.Eval("$<LIST:TRANSFORM,a;b;c,TOUPPER,PREDICATE,0>"),
"a;b;c");
}
static bool testTransformPredicateApply()
{
GenExFixture fx;
// Upper-case only elements equal to "a"; "b" passes through unchanged.
return expectEq("testTransformPredicateApply",
fx.Eval("$<LIST:TRANSFORM,a;b;a,APPLY,$<UPPER_CASE:$<_0>>,"
"PREDICATE,$<STREQUAL:$<_0>,a>>"),
"A;b;A");
}
static bool testTransformPredicateApplyShadowing()
{
GenExFixture fx;
// The apply body and predicate body each independently bind $<_0>.
return expectEq("testTransformPredicateApplyShadowing",
fx.Eval("$<LIST:TRANSFORM,a;bb,APPLY,$<_0>$<_0>,PREDICATE,"
"$<STREQUAL:$<_0>,a>>"),
"aa;bb");
}
static bool testTransformPredicateEmptyList()
{
GenExFixture fx;
return expectEq("testTransformPredicateEmptyList",
fx.Eval("$<LIST:TRANSFORM,,TOUPPER,PREDICATE,1>"), "");
}
int testGenExListPredicate(int /*argc*/, char* /*argv*/[])
{
if (!testCannedTransformStillWorks()) {
return 1;
}
if (!testTransformPredicateCanned()) {
return 1;
}
if (!testTransformPredicateNoneSelected()) {
return 1;
}
if (!testTransformPredicateApply()) {
return 1;
}
if (!testTransformPredicateApplyShadowing()) {
return 1;
}
if (!testTransformPredicateEmptyList()) {
return 1;
}
return 0;
}
@@ -0,0 +1 @@
selector PREDICATE expects a single <body> argument
@@ -0,0 +1,2 @@
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/x.txt"
CONTENT "$<LIST:TRANSFORM,a;b,TOUPPER,PREDICATE,1,AT,0>")
@@ -0,0 +1,12 @@
file(READ "${RunCMake_TEST_BINARY_DIR}/filtered.txt" actual)
file(READ "${RunCMake_TEST_BINARY_DIR}/expected.txt" expected)
string(STRIP "${actual}" actual)
string(STRIP "${expected}" expected)
# PREDICATE over app's LINK_LIBRARIES must prefix only STATIC_LIBRARY targets
# and leave INTERFACE_LIBRARY targets unchanged.
if(NOT actual STREQUAL expected)
set(RunCMake_TEST_FAILED
"PREDICATE-over-LINK_LIBRARIES output does not match the expected list:\n"
" actual: [${actual}]\n"
" expected: [${expected}]")
endif()
@@ -0,0 +1,28 @@
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 with mixed library types:
# app -> { netlib, plugin }; netlib -> { ssl, zlib }
# netlib, ssl, zlib are STATIC_LIBRARY; plugin is INTERFACE_LIBRARY.
add_library(ssl STATIC empty.c)
add_library(zlib STATIC empty.c)
add_library(plugin INTERFACE)
add_library(netlib STATIC empty.c)
target_link_libraries(netlib PUBLIC ssl zlib)
add_library(app STATIC empty.c)
target_link_libraries(app PRIVATE netlib plugin)
# PREDICATE body reads each element's TYPE via TARGET_PROPERTY, demonstrating
# that the body is evaluated in a target context (context-sensitivity). Only
# STATIC_LIBRARY targets receive the PREPEND; INTERFACE_LIBRARY targets are
# left unchanged.
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/filtered.txt"
CONTENT "$<LIST:TRANSFORM,$<TARGET_PROPERTY:app,LINK_LIBRARIES>,PREPEND,lib:,PREDICATE,$<STREQUAL:$<TARGET_PROPERTY:$<_0>,TYPE>,STATIC_LIBRARY>>\n")
# Exact reference: direct deps first (netlib, plugin), then netlib's transitive
# deps (ssl, zlib); only the three STATIC targets gain the "lib:" prefix.
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/expected.txt"
CONTENT "lib:netlib;plugin;lib:ssl;lib:zlib\n")
@@ -0,0 +1 @@
selector PREDICATE expects a <body> argument
@@ -0,0 +1,2 @@
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/x.txt"
CONTENT "$<LIST:TRANSFORM,a;b,TOUPPER,PREDICATE>")
@@ -0,0 +1 @@
PREDICATE body must evaluate to "0" or "1"
@@ -0,0 +1,2 @@
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/x.txt"
CONTENT "$<LIST:TRANSFORM,a;b,TOUPPER,PREDICATE,maybe>")
@@ -64,6 +64,10 @@ run_cmake(ListTransformApplyNested)
run_cmake(ListTransformApplyBadSelector)
run_cmake(ListTransformApplyBodyError)
run_cmake(ListTransformApplyMissingBody)
run_cmake(ListTransformPredicateNonBool)
run_cmake(ListTransformPredicateCombined)
run_cmake(ListTransformPredicateMissingBody)
run_cmake(ListTransformPredicateLinkLibraries)
run_cmake(BoundOperandOutsideBinding)
function(run_cmake_build test)