GenEx: add $<LIST:SORT,...,COMPARATOR,body> comparator

Add a COMPARATOR form to $<LIST:SORT> that orders the list by a caller-defined
rule: a <body> evaluated per comparison with the two elements bound to $<_0>
and $<_1>, yielding "1" when the first should sort before the second.  This
brings the custom ordering of list(SORT ... COMPARATOR) to generate time, so
elements can be ordered by target properties or any other generator expression.
CASE: and ORDER: still apply, while COMPARE: is rejected because the body
defines the ordering.

Fixes: #27892
This commit is contained in:
Mickaël Germain
2026-06-30 08:19:41 -07:00
parent c418a3dc85
commit 4d86ef5e86
23 changed files with 339 additions and 0 deletions
@@ -1098,6 +1098,24 @@ List Ordering
$<LIST:SORT,list,CASE:SENSITIVE,COMPARE:STRING,ORDER:DESCENDING>
.. versionadded:: 4.5
A ``COMPARATOR`` option sorts using a generator-expression ``body`` instead
of a built-in ordering:
.. code-block:: cmake
$<LIST:SORT,list,COMPARATOR,body[,ORDER:ASCENDING|DESCENDING][,CASE:SENSITIVE|INSENSITIVE]>
``body`` is evaluated once per comparison with the two items being compared
bound to :genex:`$<_0>` and :genex:`$<_1>`. It must evaluate to exactly
``0`` or ``1``; ``1`` means :genex:`$<_0>` sorts before :genex:`$<_1>`.
``COMPARATOR`` is incompatible with ``COMPARE:``. ``ORDER:DESCENDING``
reverses the comparator and ``CASE:INSENSITIVE`` case-folds the values the
body sees, both as in the configure-time :command:`list(SORT)`. The body
must induce a `strict weak ordering
<https://en.wikipedia.org/wiki/Weak_ordering#Strict_weak_orderings>`_.
.. _GenEx Bound Operands:
Bound Operands
@@ -1117,6 +1135,20 @@ Bound Operands
``$<_0>`` is only valid inside the body of a binding operation. Using it
anywhere else is an error.
.. genex:: $<_1>
.. versionadded:: 4.5
The second *bound operand* of a *binding operation* that binds at least two
operands, expanding to the second supplied value.
For example, :genex:`$<LIST:SORT,...,COMPARATOR,body>` evaluates ``body``
once per comparison with :genex:`$<_0>` and ``$<_1>`` bound to the two items
being compared.
``$<_1>`` is only valid inside the body of a binding operation that binds at
least two operands. Using it anywhere else is an error.
Path Expressions
----------------
@@ -0,0 +1,7 @@
genex-list-sort-comparator
--------------------------
* The :genex:`LIST` generator expression's ``SORT`` operation gained a
``COMPARATOR`` option that orders items using an arbitrary generator
expression evaluated once per comparison, with ``$<_0>`` and ``$<_1>``
referring to the two items being compared.
+99
View File
@@ -2220,6 +2220,88 @@ SortOptionResult ParseSortOption(std::string const& arg,
return SortOptionResult::NotRecognized;
}
// $<LIST:SORT,...,COMPARATOR,body>: sort with a per-comparison genex body, the
// two elements bound to $<_0> and $<_1>; body must yield "0" or "1".
std::string EvaluateSortComparator(std::vector<std::string> const& parameters,
std::size_t comparatorIndex,
cm::GenEx::Evaluation* eval,
GeneratorExpressionContent const* content,
cmGeneratorExpressionDAGChecker* dagChecker)
{
if (comparatorIndex + 1 >= parameters.size()) {
reportError(eval, content->GetOriginalExpression(),
"sub-command SORT, COMPARATOR expects a <body> argument.");
return std::string();
}
cmGeneratorExpressionEvaluatorVector const& bodyExpr =
content->GetParamChildren()[comparatorIndex + 1];
using SortConfig = cmList::SortConfiguration;
SortConfig sortConfig;
sortConfig.Compare = SortConfig::CompareMethod::COMPARATOR;
for (std::size_t i = 2; i < parameters.size(); ++i) {
if (i == comparatorIndex || i == comparatorIndex + 1) {
continue; // COMPARATOR keyword + its (empty) body slot
}
std::string const& arg = parameters[i];
// COMPARATOR defines the ordering, so reject COMPARE:; CASE:/ORDER: are
// accepted as in list(SORT ... COMPARATOR) (CASE: folds the body
// operands).
if (cmHasPrefix(arg, "COMPARE:"_s)) {
reportError(eval, content->GetOriginalExpression(),
"sub-command SORT, option \"COMPARE\" is incompatible with "
"\"COMPARATOR\".");
return std::string();
}
switch (ParseSortOption(arg, sortConfig, eval, content)) {
case SortOptionResult::Parsed:
break;
case SortOptionResult::Error:
return std::string();
case SortOptionResult::NotRecognized:
reportError(
eval, content->GetOriginalExpression(),
cmStrCat("sub-command SORT, option \"", arg, "\" is invalid."));
return std::string();
}
}
cmList list = GetList(parameters[1]);
if (list.size() < 2) {
return list.to_string();
}
// The strict-weak-ordering guard in cmList::sort may call this twice per
// pair, so the body can be evaluated up to twice per comparison.
auto comparator = [&](std::string const& a, std::string const& b) -> bool {
std::string r =
EvaluateBodyWithBoundOperands(bodyExpr, { a, b }, eval, dagChecker);
if (eval->HadError) {
throw cmList::transform_error(std::string{}); // body already reported
}
if (r == "1") {
return true;
}
if (r == "0") {
return false;
}
throw cmList::transform_error(
cmStrCat("sub-command SORT, COMPARATOR body must evaluate to \"0\" or "
"\"1\", but evaluated to \"",
r, "\"."));
};
try {
list.sort(sortConfig, comparator);
} catch (std::invalid_argument& e) {
if (!eval->HadError) {
reportError(eval, content->GetOriginalExpression(), e.what());
}
return std::string();
}
return list.to_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
@@ -2363,6 +2445,12 @@ static const struct ListNode : public cmGeneratorExpressionNode
return false;
}
}
// Leave the SORT COMPARATOR <body> unevaluated; a bare COMPARATOR token is
// unambiguous since SORT's other options are colon-style.
if (parameters.size() >= 3 && parameters[0] == "SORT" &&
parameters.back() == "COMPARATOR") {
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" &&
@@ -2482,6 +2570,17 @@ static const struct ListNode : public cmGeneratorExpressionNode
.to_string();
}
// SORT COMPARATOR is handled here, not the listCommands SORT lambda,
// because the body needs the DAG checker.
if (parameters.size() >= 3 && parameters[0] == "SORT") {
for (std::size_t i = 2; i < parameters.size(); ++i) {
if (parameters[i] == "COMPARATOR") {
return EvaluateSortComparator(parameters, i, eval, content,
dagChecker);
}
}
}
static std::unordered_map<
cm::string_view,
std::function<std::string(cm::GenEx::Evaluation*,
+1
View File
@@ -17,6 +17,7 @@ set(CMakeLib_TESTS
testGenExBoundOperand.cxx
testGenExTransformApply.cxx
testGenExListPredicate.cxx
testGenExListSortComparator.cxx
testJSONHelpers.cxx
testRST.cxx
testRange.cxx
@@ -0,0 +1,157 @@
/* 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 testSortNumericAscending()
{
GenExFixture fx;
return expectEq(
"testSortNumericAscending",
fx.Eval("$<LIST:SORT,3;1;2,COMPARATOR,$<STRLESS:$<_0>,$<_1>>>"), "1;2;3");
}
static bool testSortByExtension()
{
GenExFixture fx;
return expectEq("testSortByExtension",
fx.Eval("$<LIST:SORT,c.z;a.a;b.m,COMPARATOR,"
"$<STRLESS:$<PATH:GET_EXTENSION,$<_0>>,"
"$<PATH:GET_EXTENSION,$<_1>>>>"),
"a.a;b.m;c.z");
}
static bool testSortDescending()
{
GenExFixture fx;
return expectEq(
"testSortDescending",
fx.Eval(
"$<LIST:SORT,3;1;2,COMPARATOR,$<STRLESS:$<_0>,$<_1>>,ORDER:DESCENDING>"),
"3;2;1");
}
static bool testSortNestedBinding()
{
GenExFixture fx;
// Nested binding: the inner APPLY rebinds $<_0> but the outer $<_1> is
// restored after it, so this reduces to STRLESS(a, b) ascending.
return expectEq(
"testSortNestedBinding",
fx.Eval("$<LIST:SORT,y;x,COMPARATOR,"
"$<STRLESS:$<LIST:TRANSFORM,$<_0>,APPLY,$<_0>>,$<_1>>>"),
"x;y");
}
static bool testSortEmpty()
{
GenExFixture fx;
return expectEq("testSortEmpty",
fx.Eval("$<LIST:SORT,,COMPARATOR,$<STRLESS:$<_0>,$<_1>>>"),
"");
}
static bool testSortSingle()
{
GenExFixture fx;
return expectEq("testSortSingle",
fx.Eval("$<LIST:SORT,x,COMPARATOR,$<STRLESS:$<_0>,$<_1>>>"),
"x");
}
static bool testSortEqualElements()
{
GenExFixture fx;
// Equal elements are FALSE both ways, so the strict-weak-ordering guard must
// not trip and the duplicates are preserved.
return expectEq(
"testSortEqualElements",
fx.Eval("$<LIST:SORT,b;a;b,COMPARATOR,$<STRLESS:$<_0>,$<_1>>>"), "a;b;b");
}
static bool testSortCaseInsensitive()
{
// CASE:INSENSITIVE case-folds the body's operands, so B;a;C orders as a;B;C
// (elements keep their original case).
GenExFixture fx;
return expectEq(
"testSortCaseInsensitive",
fx.Eval(
"$<LIST:SORT,B;a;C,COMPARATOR,$<STRLESS:$<_0>,$<_1>>,CASE:INSENSITIVE>"),
"a;B;C");
}
int testGenExListSortComparator(int /*argc*/, char* /*argv*/[])
{
if (!testSortNumericAscending()) {
return 1;
}
if (!testSortByExtension()) {
return 1;
}
if (!testSortDescending()) {
return 1;
}
if (!testSortNestedBinding()) {
return 1;
}
if (!testSortEmpty()) {
return 1;
}
if (!testSortSingle()) {
return 1;
}
if (!testSortEqualElements()) {
return 1;
}
if (!testSortCaseInsensitive()) {
return 1;
}
return 0;
}
@@ -0,0 +1 @@
option "COMPARE" is incompatible with "COMPARATOR"
@@ -0,0 +1,2 @@
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/x.txt"
CONTENT "$<LIST:SORT,a;b,COMPARATOR,$<STRLESS:$<_0>,$<_1>>,COMPARE:STRING>")
@@ -0,0 +1 @@
option "BOGUS:X" is invalid
@@ -0,0 +1,4 @@
# An unrecognized trailing option on the COMPARATOR path goes through the
# NotRecognized branch of the shared option parser.
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/x.txt"
CONTENT "$<LIST:SORT,a;b,COMPARATOR,$<STRLESS:$<_0>,$<_1>>,BOGUS:X>")
@@ -0,0 +1 @@
sub-command SORT, COMPARATOR expects a <body> argument
@@ -0,0 +1,2 @@
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/x.txt"
CONTENT "$<LIST:SORT,a;b,COMPARATOR>")
@@ -0,0 +1 @@
COMPARATOR body must evaluate to "0" or "1"
@@ -0,0 +1,2 @@
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/x.txt"
CONTENT "$<LIST:SORT,a;b,COMPARATOR,maybe>")
@@ -0,0 +1 @@
does not induce a strict weak
@@ -0,0 +1,2 @@
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/x.txt"
CONTENT "$<LIST:SORT,a;b;c,COMPARATOR,1>")
@@ -0,0 +1,5 @@
file(READ "${RunCMake_TEST_BINARY_DIR}/out.txt" actual)
string(STRIP "${actual}" actual)
if(NOT actual STREQUAL "b;c;a")
set(RunCMake_TEST_FAILED "unexpected output: [${actual}]")
endif()
@@ -0,0 +1,10 @@
add_custom_target(a)
add_custom_target(b)
add_custom_target(c)
set_property(TARGET a PROPERTY MY_RANK 3)
set_property(TARGET b PROPERTY MY_RANK 1)
set_property(TARGET c PROPERTY MY_RANK 2)
# Sort the target names by their MY_RANK property, ascending.
file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/out.txt"
CONTENT "$<LIST:SORT,a;b;c,COMPARATOR,$<STRLESS:$<TARGET_PROPERTY:$<_0>,MY_RANK>,$<TARGET_PROPERTY:$<_1>,MY_RANK>>>\n")
@@ -73,6 +73,12 @@ run_cmake(ListFilterPredicateMissingBody)
run_cmake(ListFilterPredicateNonBool)
run_cmake(BoundOperandOutsideBinding)
run_cmake(BoundOperand1OutsideBinding)
run_cmake(ListSortComparatorNonBool)
run_cmake(ListSortComparatorNotStrictWeak)
run_cmake(ListSortComparatorCompareConflict)
run_cmake(ListSortComparatorInvalidOption)
run_cmake(ListSortComparatorMissingBody)
run_cmake(ListSortComparatorTargetProperty)
function(run_cmake_build test)
set(RunCMake_TEST_BINARY_DIR ${RunCMake_BINARY_DIR}/${test}-build)