list(SORT): Add COMPARATOR option for user-defined comparisons

Allow users to provide a custom comparison function for `list(SORT)`.
The comparator is validated for strict weak ordering at runtime to
produce a clear CMake error rather than a platform-dependent abort.

Closes: #27761
This commit is contained in:
Mickaël Germain authored and Brad King committed 2026-05-06 15:09:06 -04:00
1 parent bfeef792bb
commit 3dc32ac62b
31 files changed
+440

No files matched your search

+45
View File
@@ -399,6 +399,7 @@ Ordering
.. signature::
list(SORT <list> [COMPARE <compare>] [CASE <case>] [ORDER <order>])
list(SORT <list> COMPARATOR <function> [CASE <case>] [ORDER <order>])
Sorts the list in-place alphabetically.
@@ -447,3 +448,47 @@ Ordering
``DESCENDING``
Sorts the list in descending order.
Instead of the built-in ``COMPARE`` methods, a user-defined comparison
function may be used with the ``COMPARATOR`` keyword.
.. versionadded:: 4.4
``COMPARATOR`` is mutually exclusive with ``COMPARE``. The ``CASE``
and ``ORDER`` options may still be used alongside ``COMPARATOR``:
the ``CASE`` filter is applied to both values before they are passed to
the callable, and ``ORDER DESCENDING`` reverses the comparison by
swapping the two arguments.
The callable must accept exactly three parameters: two input values and
the name of an output variable. It must set the output variable to a
boolean value (``TRUE`` if the first value should come before the second,
``FALSE`` otherwise) in the calling scope.
If the callable does not set the output variable, it is an error.
The comparator must define a
`strict weak ordering <https://en.wikipedia.org/wiki/Weak_ordering#Strict_weak_orderings>`_.
In particular:
* It must return ``FALSE`` when comparing an element to itself
(irreflexivity).
* If it returns ``TRUE`` for ``(a, b)``, it must return ``FALSE``
for ``(b, a)`` (asymmetry).
An error is raised if the comparator violates these requirements.
Example:
.. code-block:: cmake
function(version_less a b result)
if("${a}" VERSION_LESS "${b}")
set(${result} TRUE PARENT_SCOPE)
else()
set(${result} FALSE PARENT_SCOPE)
endif()
endfunction()
set(versions 3.1 1.2 2.0 1.10)
list(SORT versions COMPARATOR version_less)
# versions is now: 1.2;1.10;2.0;3.1
@@ -0,0 +1,6 @@
list-SORT-COMPARATOR
--------------------
* The :command:`list(SORT)` command gained a new ``COMPARATOR`` keyword
that invokes a user-defined :command:`function` to compare elements, complementing
the built-in ``COMPARE`` methods.
+93
View File
@@ -167,6 +167,60 @@ private:
PredicateEvaluator& Evaluator;
bool IncludeMatches;
};
class ComparatorEvaluator
{
public:
ComparatorEvaluator(std::string functionName, cmMakefile& makefile)
: FunctionName(std::move(functionName))
, Makefile(&makefile)
, OutputVar(OutputVarFor("_cmake_comparator_out_", makefile))
{
if (!makefile.GetState()->GetCommand(this->FunctionName)) {
throw cmList::transform_error(
cmStrCat("sub-command SORT, COMPARATOR: unknown function \"",
this->FunctionName, "\"."));
}
}
bool operator()(std::string const& a, std::string const& b)
{
this->Makefile->RemoveDefinition(this->OutputVar);
cmListFileContext context = this->Makefile->GetBacktrace().Top();
std::vector<cmListFileArgument> funcArgs;
funcArgs.emplace_back(a, cmListFileArgument::Quoted, context.Line);
funcArgs.emplace_back(b, cmListFileArgument::Quoted, context.Line);
funcArgs.emplace_back(this->OutputVar, cmListFileArgument::Quoted,
context.Line);
cmListFileFunction func{ this->FunctionName, context.Line, context.Line,
std::move(funcArgs) };
cmExecutionStatus status(*this->Makefile);
if (!this->Makefile->ExecuteCommand(func, status) ||
status.GetNestedError()) {
throw cmList::transform_error(
cmStrCat("sub-command SORT, COMPARATOR: function \"",
this->FunctionName, "\" failed during execution."));
}
cmValue result = this->Makefile->GetDefinition(this->OutputVar);
if (!result) {
throw cmList::transform_error(
cmStrCat("sub-command SORT, COMPARATOR: function \"",
this->FunctionName, "\" did not set the output variable."));
}
bool boolResult = cmIsOn(*result);
this->Makefile->RemoveDefinition(this->OutputVar);
return boolResult;
}
private:
std::string FunctionName;
cmMakefile* Makefile = nullptr;
std::string OutputVar;
};
}
cmList& cmList::filter(cm::string_view pattern, FilterMode mode)
@@ -249,6 +303,13 @@ public:
{
}
StringSorter(cmList::SortConfiguration config, ComparisonFunction comparator)
: Filters{ nullptr, this->GetCaseFilter(config.Case) }
, SortMethod(std::move(comparator))
, Descending(config.Order == OrderMode::DESCENDING)
{
}
std::string ApplyFilter(std::string const& argument)
{
std::string result = argument;
@@ -308,6 +369,38 @@ cmList& cmList::sort(SortConfiguration cfg)
return *this;
}
cmList& cmList::sort(SortConfiguration cfg, cmMakefile& makefile)
{
SortConfiguration config{ cfg };
if (config.Order == SortConfiguration::OrderMode::DEFAULT) {
config.Order = SortConfiguration::OrderMode::ASCENDING;
}
if (config.Case == SortConfiguration::CaseSensitivity::DEFAULT) {
config.Case = SortConfiguration::CaseSensitivity::SENSITIVE;
}
try {
ComparatorEvaluator evaluator(config.ComparatorFunction, makefile);
StringSorter sorter(
config, [&evaluator](std::string const& a, std::string const& b) {
bool result = evaluator(a, b);
if (result && evaluator(b, a)) {
throw cmList::transform_error(
"sub-command SORT, COMPARATOR: function does not induce a strict "
"weak ordering. The comparator returned TRUE for both (a, b) and "
"(b, a).");
}
return result;
});
std::sort(this->Values.begin(), this->Values.end(), sorter);
} catch (transform_error& e) {
throw std::invalid_argument(e.what());
}
return *this;
}
namespace {
using transform_type = std::function<std::string(std::string const&)>;
using transform_error = cmList::transform_error;
+4
View File
@@ -848,6 +848,7 @@ public:
STRING,
FILE_BASENAME,
NATURAL,
COMPARATOR,
} Compare = CompareMethod::DEFAULT;
enum class CaseSensitivity
@@ -857,6 +858,8 @@ public:
INSENSITIVE,
} Case = CaseSensitivity::DEFAULT;
std::string ComparatorFunction;
// declare the default constructor to work-around clang bug
SortConfiguration();
@@ -869,6 +872,7 @@ public:
}
};
cmList& sort(SortConfiguration config = SortConfiguration{});
cmList& sort(SortConfiguration config, cmMakefile& makefile);
// exception raised on error during transform operations
class transform_error : public std::runtime_error
+39
View File
@@ -726,6 +726,7 @@ bool HandleSortCommand(std::vector<std::string> const& args,
using SortConfig = cmList::SortConfiguration;
SortConfig sortConfig;
bool hasComparator = false;
size_t argumentIndex = 2;
std::string const messageHint = "sub-command SORT ";
@@ -733,6 +734,12 @@ bool HandleSortCommand(std::vector<std::string> const& args,
while (argumentIndex < args.size()) {
std::string const& option = args[argumentIndex++];
if (option == "COMPARE") {
if (hasComparator) {
status.SetError(cmStrCat(messageHint,
"option \"COMPARE\" is incompatible "
"with \"COMPARATOR\"."));
return false;
}
if (sortConfig.Compare != SortConfig::CompareMethod::DEFAULT) {
std::string error = cmStrCat(messageHint, "option \"", option,
"\" has been specified multiple times.");
@@ -806,6 +813,27 @@ bool HandleSortCommand(std::vector<std::string> const& args,
option, "\"."));
return false;
}
} else if (option == "COMPARATOR") {
if (hasComparator) {
status.SetError(cmStrCat(messageHint, "option \"", option,
"\" has been specified multiple times."));
return false;
}
if (sortConfig.Compare != SortConfig::CompareMethod::DEFAULT) {
status.SetError(cmStrCat(messageHint,
"option \"COMPARATOR\" is incompatible "
"with \"COMPARE\"."));
return false;
}
if (argumentIndex < args.size()) {
sortConfig.ComparatorFunction = args[argumentIndex++];
sortConfig.Compare = SortConfig::CompareMethod::COMPARATOR;
hasComparator = true;
} else {
status.SetError(cmStrCat(messageHint, "missing argument for option \"",
option, "\"."));
return false;
}
} else {
status.SetError(
cmStrCat(messageHint, "option \"", option, "\" is unknown."));
@@ -821,6 +849,17 @@ bool HandleSortCommand(std::vector<std::string> const& args,
return true;
}
if (hasComparator) {
try {
status.GetMakefile().AddDefinition(
listName, list->sort(sortConfig, status.GetMakefile()).to_string());
return true;
} catch (std::invalid_argument& e) {
status.SetError(e.what());
return false;
}
}
status.GetMakefile().AddDefinition(listName,
list->sort(sortConfig).to_string());
return true;
+9
View File
@@ -114,9 +114,18 @@ run_cmake(SORT-DuplicateOrderOption)
run_cmake(SORT-DuplicateCompareOption)
run_cmake(SORT-DuplicateCaseOption)
run_cmake(SORT-NoCaseOption)
run_cmake(SORT-COMPARATOR-UnknownFunction)
run_cmake(SORT-COMPARATOR-NoOutput)
run_cmake(SORT-COMPARATOR-CompareConflict)
run_cmake(SORT-COMPARATOR-CompareConflictReverse)
run_cmake(SORT-COMPARATOR-NoFunction)
run_cmake(SORT-COMPARATOR-DuplicateOption)
run_cmake(SORT-COMPARATOR-NotStrictWeak)
run_cmake(SORT-COMPARATOR-NotStrictWeakLate)
# Successful tests
run_cmake(SORT)
run_cmake(SORT-COMPARATOR)
# argument tests
run_cmake(PREPEND-NoArgs)
@@ -0,0 +1 @@
1
@@ -0,0 +1,4 @@
^CMake Error at SORT-COMPARATOR-CompareConflict\.cmake:10 \(list\):
list sub-command SORT option "COMPARATOR" is incompatible with "COMPARE"\.
Call Stack \(most recent call first\):
CMakeLists\.txt:3 \(include\)$
@@ -0,0 +1,10 @@
function(my_cmp a b out)
if("${a}" STRLESS "${b}")
set(${out} TRUE PARENT_SCOPE)
else()
set(${out} FALSE PARENT_SCOPE)
endif()
endfunction()
set(mylist c a b)
list(SORT mylist COMPARE STRING COMPARATOR my_cmp)
@@ -0,0 +1,4 @@
^CMake Error at SORT-COMPARATOR-CompareConflictReverse\.cmake:10 \(list\):
list sub-command SORT option "COMPARE" is incompatible with "COMPARATOR"\.
Call Stack \(most recent call first\):
CMakeLists\.txt:3 \(include\)$
@@ -0,0 +1,10 @@
function(my_cmp a b out)
if("${a}" STRLESS "${b}")
set(${out} TRUE PARENT_SCOPE)
else()
set(${out} FALSE PARENT_SCOPE)
endif()
endfunction()
set(mylist c a b)
list(SORT mylist COMPARATOR my_cmp COMPARE STRING)
@@ -0,0 +1 @@
1
@@ -0,0 +1,5 @@
^CMake Error at SORT-COMPARATOR-DuplicateOption\.cmake:10 \(list\):
list sub-command SORT option "COMPARATOR" has been specified multiple
times\.
Call Stack \(most recent call first\):
CMakeLists\.txt:3 \(include\)$
@@ -0,0 +1,10 @@
function(my_cmp a b out)
if("${a}" STRLESS "${b}")
set(${out} TRUE PARENT_SCOPE)
else()
set(${out} FALSE PARENT_SCOPE)
endif()
endfunction()
set(mylist c a b)
list(SORT mylist COMPARATOR my_cmp COMPARATOR my_cmp)
@@ -0,0 +1 @@
1
@@ -0,0 +1,4 @@
^CMake Error at SORT-COMPARATOR-NoFunction\.cmake:2 \(list\):
list sub-command SORT missing argument for option "COMPARATOR"\.
Call Stack \(most recent call first\):
CMakeLists\.txt:3 \(include\)$
@@ -0,0 +1,2 @@
set(mylist a b c)
list(SORT mylist COMPARATOR)
@@ -0,0 +1 @@
1
@@ -0,0 +1,5 @@
^CMake Error at SORT-COMPARATOR-NoOutput\.cmake:6 \(list\):
list sub-command SORT, COMPARATOR: function "bad_comparator" did not set
the output variable\.
Call Stack \(most recent call first\):
CMakeLists\.txt:3 \(include\)$
@@ -0,0 +1,6 @@
function(bad_comparator a b out)
# Deliberately does NOT set ${out}
endfunction()
set(mylist c a b)
list(SORT mylist COMPARATOR bad_comparator)
@@ -0,0 +1 @@
1
@@ -0,0 +1,5 @@
^CMake Error at SORT-COMPARATOR-NotStrictWeak\.cmake:6 \(list\):
list sub-command SORT, COMPARATOR: function does not induce a strict weak
ordering\. The comparator returned TRUE for both \(a, b\) and \(b, a\)\.
Call Stack \(most recent call first\):
CMakeLists\.txt:3 \(include\)$
@@ -0,0 +1,6 @@
function(reflexive_comparator a b out)
set(${out} TRUE PARENT_SCOPE)
endfunction()
set(mylist c a b)
list(SORT mylist COMPARATOR reflexive_comparator)
@@ -0,0 +1 @@
1
@@ -0,0 +1,5 @@
^CMake Error at SORT-COMPARATOR-NotStrictWeakLate\.cmake:19 \(list\):
list sub-command SORT, COMPARATOR: function does not induce a strict weak
ordering\. The comparator returned TRUE for both \(a, b\) and \(b, a\)\.
Call Stack \(most recent call first\):
CMakeLists\.txt:3 \(include\)$
@@ -0,0 +1,19 @@
# Comparator that is correct for most pairs but violates strict weak ordering
# when both elements start with "b".
function(late_violation a b result)
string(SUBSTRING "${a}" 0 1 a_first)
string(SUBSTRING "${b}" 0 1 b_first)
if(a_first STREQUAL "b" AND b_first STREQUAL "b")
# Violates asymmetry: always returns TRUE for b-vs-b pairs
set(${result} TRUE PARENT_SCOPE)
else()
if("${a}" STRLESS "${b}")
set(${result} TRUE PARENT_SCOPE)
else()
set(${result} FALSE PARENT_SCOPE)
endif()
endif()
endfunction()
set(mylist "a" "c" "bb" "ba")
list(SORT mylist COMPARATOR late_violation)
@@ -0,0 +1 @@
1
@@ -0,0 +1,4 @@
^CMake Error at SORT-COMPARATOR-UnknownFunction\.cmake:2 \(list\):
list sub-command SORT, COMPARATOR: unknown function "no_such_function"\.
Call Stack \(most recent call first\):
CMakeLists\.txt:3 \(include\)$
@@ -0,0 +1,2 @@
set(mylist a b c)
list(SORT mylist COMPARATOR no_such_function)
+135
View File
@@ -0,0 +1,135 @@
# Comparator: returns TRUE if a < b (string comparison)
function(string_less a b result)
if("${a}" STRLESS "${b}")
set(${result} TRUE PARENT_SCOPE)
else()
set(${result} FALSE PARENT_SCOPE)
endif()
endfunction()
# Comparator as macro
macro(string_less_macro a b result)
if(${a} STRLESS ${b})
set(${result} TRUE)
else()
set(${result} FALSE)
endif()
endmacro()
# Comparator: string length comparison
function(shorter_first a b result)
string(LENGTH "${a}" len_a)
string(LENGTH "${b}" len_b)
if(len_a LESS len_b)
set(${result} TRUE PARENT_SCOPE)
else()
set(${result} FALSE PARENT_SCOPE)
endif()
endfunction()
## Basic COMPARATOR with function
set(mylist c a b)
list(SORT mylist COMPARATOR string_less)
if(NOT mylist STREQUAL "a;b;c")
message(FATAL_ERROR "SORT(COMPARATOR function) is \"${mylist}\", expected \"a;b;c\"")
endif()
## COMPARATOR with macro
set(mylist c a b)
list(SORT mylist COMPARATOR string_less_macro)
if(NOT mylist STREQUAL "a;b;c")
message(FATAL_ERROR "SORT(COMPARATOR macro) is \"${mylist}\", expected \"a;b;c\"")
endif()
## COMPARATOR with ORDER DESCENDING
set(mylist c a b)
list(SORT mylist COMPARATOR string_less ORDER DESCENDING)
if(NOT mylist STREQUAL "c;b;a")
message(FATAL_ERROR "SORT(COMPARATOR ORDER DESCENDING) is \"${mylist}\", expected \"c;b;a\"")
endif()
## COMPARATOR with CASE INSENSITIVE
# With CASE INSENSITIVE, values are lowercased before being passed to the comparator
set(mylist "C" "a" "B")
list(SORT mylist COMPARATOR string_less CASE INSENSITIVE)
if(NOT mylist STREQUAL "a;B;C")
message(FATAL_ERROR "SORT(COMPARATOR CASE INSENSITIVE) is \"${mylist}\", expected \"a;B;C\"")
endif()
## COMPARATOR with CASE INSENSITIVE and ORDER DESCENDING
set(mylist "C" "a" "B")
list(SORT mylist COMPARATOR string_less CASE INSENSITIVE ORDER DESCENDING)
if(NOT mylist STREQUAL "C;B;a")
message(FATAL_ERROR "SORT(COMPARATOR CASE INSENSITIVE ORDER DESCENDING) is \"${mylist}\", expected \"C;B;a\"")
endif()
## Custom comparator (sort by string length)
set(mylist "bbb" "a" "cc")
list(SORT mylist COMPARATOR shorter_first)
if(NOT mylist STREQUAL "a;cc;bbb")
message(FATAL_ERROR "SORT(COMPARATOR shorter_first) is \"${mylist}\", expected \"a;cc;bbb\"")
endif()
## Custom comparator with ORDER DESCENDING (sort by length descending)
set(mylist "bbb" "a" "cc")
list(SORT mylist COMPARATOR shorter_first ORDER DESCENDING)
if(NOT mylist STREQUAL "bbb;cc;a")
message(FATAL_ERROR "SORT(COMPARATOR shorter_first ORDER DESCENDING) is \"${mylist}\", expected \"bbb;cc;a\"")
endif()
## Empty list (no-op)
set(empty_list "")
list(SORT empty_list COMPARATOR string_less)
if(NOT empty_list STREQUAL "")
message(FATAL_ERROR "SORT(COMPARATOR empty) is \"${empty_list}\", expected \"\"")
endif()
## Single element list (no-op)
set(mylist "only")
list(SORT mylist COMPARATOR string_less)
if(NOT mylist STREQUAL "only")
message(FATAL_ERROR "SORT(COMPARATOR single) is \"${mylist}\", expected \"only\"")
endif()
## Already sorted list
set(mylist a b c)
list(SORT mylist COMPARATOR string_less)
if(NOT mylist STREQUAL "a;b;c")
message(FATAL_ERROR "SORT(COMPARATOR already-sorted) is \"${mylist}\", expected \"a;b;c\"")
endif()
## Recursive COMPARATOR - verify inner and outer output variables are distinct
# Inner comparator: checks that neither element equals its own output variable
# name (which would mean the outer and inner names collided), then performs a
# valid string comparison to satisfy strict weak ordering.
function(check_outer_name a b out)
if("${a}" STREQUAL "${out}")
message(FATAL_ERROR "Recursive COMPARATOR: inner and outer output variable names are the same: ${out}")
endif()
if("${b}" STREQUAL "${out}")
message(FATAL_ERROR "Recursive COMPARATOR: inner and outer output variable names are the same: ${out}")
endif()
if("${a}" STRLESS "${b}")
set(${out} TRUE PARENT_SCOPE)
else()
set(${out} FALSE PARENT_SCOPE)
endif()
endfunction()
# Outer comparator: passes its own output variable name into a nested sort
# as a list element, so the inner comparator can compare the two names.
function(nested_comparator a b out)
set(_inner "${out}" dummy)
list(SORT _inner COMPARATOR check_outer_name)
if("${a}" STRLESS "${b}")
set(${out} TRUE PARENT_SCOPE)
else()
set(${out} FALSE PARENT_SCOPE)
endif()
endfunction()
set(mylist c a b)
list(SORT mylist COMPARATOR nested_comparator)
if(NOT mylist STREQUAL "a;b;c")
message(FATAL_ERROR "SORT(COMPARATOR nested) is \"${mylist}\", expected \"a;b;c\"")
endif()