diff --git a/Help/command/list.rst b/Help/command/list.rst index 43ae893ce2..a84e2cbd78 100644 --- a/Help/command/list.rst +++ b/Help/command/list.rst @@ -19,7 +19,7 @@ Synopsis `Modification`_ list(`APPEND`_ [...]) - list(`FILTER`_ {INCLUDE | EXCLUDE} REGEX ) + list(`FILTER`_ ) list(`INSERT`_ [...]) list(`POP_BACK`_ [...]) list(`POP_FRONT`_ [...]) @@ -120,15 +120,54 @@ Modification that empty list. .. signature:: - list(FILTER REGEX ) + list(FILTER ) .. versionadded:: 3.6 Includes or removes items from the list that match the mode's pattern. - In ``REGEX`` mode, items will be matched against the given regular expression. - For more information on regular expressions look under - :ref:`string(REGEX) `. + ```` must be one of the following: + + ``REGEX`` + Items will be matched against the given regular expression. + + .. code-block:: cmake + + list(FILTER REGEX ) + + For more information on regular expressions look under + :ref:`string(REGEX) `. + + ``PREDICATE`` + Specify a user-defined callable as a predicate. + + .. code-block:: cmake + + list(FILTER PREDICATE ) + + .. versionadded:: 4.4 + + ```` is a user-defined :command:`function` that acts as a + unary predicate. The callable must accept exactly two parameters: the + input value and the name of an output variable. The callable must set the + output variable to a boolean value in the calling scope. + The output variable is interpreted using standard CMake boolean evaluation. + If the callable does not set the output variable, it is an error. + + Example: + + .. code-block:: cmake + + function(file_exists path result) + if(EXISTS "${path}") + set(${result} TRUE PARENT_SCOPE) + else() + set(${result} FALSE PARENT_SCOPE) + endif() + endfunction() + + set(candidate_files main.c missing.c utils.c) + list(FILTER candidate_files INCLUDE PREDICATE file_exists) .. signature:: list(INSERT [ ...]) diff --git a/Help/release/dev/list-PREDICATE.rst b/Help/release/dev/list-PREDICATE.rst new file mode 100644 index 0000000000..324bd07339 --- /dev/null +++ b/Help/release/dev/list-PREDICATE.rst @@ -0,0 +1,10 @@ +list-PREDICATE +-------------- + +* The :command:`list(TRANSFORM)` command gained a new ``PREDICATE`` selector + that invokes a user-defined callable to decide which elements are + transformed. + +* The :command:`list(FILTER)` command gained a new ``PREDICATE`` mode + that invokes a user-defined callable to decide which elements are + included or excluded, complementing the existing ``REGEX`` mode. diff --git a/Source/cmList.cxx b/Source/cmList.cxx index c907b971b2..4dfeaa251c 100644 --- a/Source/cmList.cxx +++ b/Source/cmList.cxx @@ -82,15 +82,26 @@ private: bool const IncludeMatches; }; +// Hash of call site (FilePath:Line) for unique variable names across recursive +// calls. +std::string OutputVarFor(cm::string_view prefix, cmMakefile& makefile) +{ + cmListFileContext context = makefile.GetBacktrace().Top(); + std::size_t hash = + std::hash{}(cmStrCat(context.FilePath, ":", context.Line)); + return cmStrCat(prefix, hash, "_"); +} + class PredicateEvaluator { public: - PredicateEvaluator(std::string const& functionName, cmMakefile& makefile, + PredicateEvaluator(std::string functionName, cmMakefile& makefile, std::string errorPrefix = "sub-command TRANSFORM, " "selector PREDICATE") - : FunctionName(functionName) + : FunctionName(std::move(functionName)) , Makefile(&makefile) , ErrorPrefix(std::move(errorPrefix)) + , OutputVar(OutputVarFor("_cmake_predicate_out_", makefile)) { if (!makefile.GetState()->GetCommand(this->FunctionName)) { throw cmList::transform_error(cmStrCat(this->ErrorPrefix, @@ -101,13 +112,13 @@ public: bool operator()(std::string const& value) { - std::string const outputVar = "_list_predicate_out_"; - this->Makefile->RemoveDefinition(outputVar); + this->Makefile->RemoveDefinition(this->OutputVar); cmListFileContext context = this->Makefile->GetBacktrace().Top(); std::vector funcArgs; funcArgs.emplace_back(value, cmListFileArgument::Quoted, context.Line); - funcArgs.emplace_back(outputVar, cmListFileArgument::Quoted, context.Line); + funcArgs.emplace_back(this->OutputVar, cmListFileArgument::Quoted, + context.Line); cmListFileFunction func{ this->FunctionName, context.Line, context.Line, std::move(funcArgs) }; @@ -119,7 +130,7 @@ public: "\" failed during execution.")); } - cmValue result = this->Makefile->GetDefinition(outputVar); + cmValue result = this->Makefile->GetDefinition(this->OutputVar); if (!result) { throw cmList::transform_error( cmStrCat(this->ErrorPrefix, ": function \"", this->FunctionName, @@ -127,7 +138,7 @@ public: } bool boolResult = cmIsOn(*result); - this->Makefile->RemoveDefinition(outputVar); + this->Makefile->RemoveDefinition(this->OutputVar); return boolResult; } @@ -135,6 +146,26 @@ private: std::string FunctionName; cmMakefile* Makefile = nullptr; std::string ErrorPrefix; + std::string OutputVar; +}; + +class MatchesPredicate +{ +public: + MatchesPredicate(PredicateEvaluator& evaluator, cmList::FilterMode mode) + : Evaluator(evaluator) + , IncludeMatches(mode == cmList::FilterMode::INCLUDE) + { + } + + bool operator()(std::string const& target) + { + return this->Evaluator(target) ^ this->IncludeMatches; + } + +private: + PredicateEvaluator& Evaluator; + bool IncludeMatches; }; } @@ -154,6 +185,23 @@ cmList& cmList::filter(cm::string_view pattern, FilterMode mode) return *this; } +cmList& cmList::filter(std::string const& functionName, FilterMode mode, + cmMakefile& makefile) +{ + try { + PredicateEvaluator evaluator(functionName, makefile, + "sub-command FILTER, mode PREDICATE"); + + auto it = std::remove_if(this->Values.begin(), this->Values.end(), + MatchesPredicate{ evaluator, mode }); + this->Values.erase(it, this->Values.end()); + } catch (transform_error& e) { + throw std::invalid_argument(e.what()); + } + + return *this; +} + namespace { class StringSorter { @@ -655,6 +703,7 @@ public: TransformAction::Initialize(selector); this->FunctionName = functionName; this->Makefile = &makefile; + this->OutputVar = OutputVarFor("_cmake_transform_apply_out_", makefile); // Validate: command must exist if (!makefile.GetState()->GetCommand(this->FunctionName)) { @@ -678,17 +727,15 @@ public: return s; } - // Use a unique output variable name to avoid collisions - std::string const outputVar = "_list_transform_apply_out_"; - // Unset the output variable before calling - this->Makefile->RemoveDefinition(outputVar); + this->Makefile->RemoveDefinition(this->OutputVar); // Build the function call: functionName(s, outputVar) cmListFileContext context = this->Makefile->GetBacktrace().Top(); std::vector funcArgs; funcArgs.emplace_back(s, cmListFileArgument::Quoted, context.Line); - funcArgs.emplace_back(outputVar, cmListFileArgument::Quoted, context.Line); + funcArgs.emplace_back(this->OutputVar, cmListFileArgument::Quoted, + context.Line); cmListFileFunction func{ this->FunctionName, context.Line, context.Line, std::move(funcArgs) }; @@ -701,7 +748,7 @@ public: } // Read back the output variable - cmValue result = this->Makefile->GetDefinition(outputVar); + cmValue result = this->Makefile->GetDefinition(this->OutputVar); if (!result) { throw transform_error( cmStrCat("sub-command TRANSFORM, action APPLY: function \"", @@ -713,7 +760,7 @@ public: std::string output = *result; // Clean up - this->Makefile->RemoveDefinition(outputVar); + this->Makefile->RemoveDefinition(this->OutputVar); return output; } @@ -721,6 +768,7 @@ public: private: std::string FunctionName; cmMakefile* Makefile = nullptr; + std::string OutputVar; }; // Descriptor of action @@ -980,16 +1028,19 @@ cmList& cmList::transform(TransformAction action, std::string const& arg, cmMakefile& makefile, std::unique_ptr selector) { - auto descriptor = TransformConfigure(action, selector, 1); + // Validate action and arity via the static registry. + TransformConfigure(action, selector, 1); - auto* applyAction = - static_cast(descriptor->Transform.get()); - applyAction->Initialize(static_cast<::TransformSelector*>(selector.get()), - arg, makefile); + // Create a local instance rather than reusing the singleton from + // Descriptors. A user function invoked by APPLY may itself call + // list(TRANSFORM ... APPLY ...), which would clobber a shared instance. + TransformActionApply applyAction; + applyAction.Initialize(static_cast<::TransformSelector*>(selector.get()), + arg, makefile); static_cast<::TransformSelector&>(*selector).Transform( - this->Values, [&descriptor](std::string const& s) -> std::string { - return (*descriptor->Transform)(s); + this->Values, [&applyAction](std::string const& s) -> std::string { + return applyAction(s); }); return *this; diff --git a/Source/cmList.h b/Source/cmList.h index d861d2e24a..b34a6ca78d 100644 --- a/Source/cmList.h +++ b/Source/cmList.h @@ -821,8 +821,11 @@ public: EXCLUDE }; // Includes or removes items from the list - // Throw std::invalid_argument if regular expression is invalid + // Throw std::invalid_argument if regular expression is invalid or predicate + // function is unknown / does not set its output variable cmList& filter(cm::string_view regex, FilterMode mode); + cmList& filter(std::string const& functionName, FilterMode mode, + cmMakefile& makefile); cmList& reverse() { @@ -912,6 +915,9 @@ public: template static std::unique_ptr New(std::string&&); + // NewPREDICATE is public (unlike NewAT/NewFOR/NewREGEX) because it takes + // a cmMakefile& parameter that cannot be dispatched through the existing + // New() templates. static std::unique_ptr NewPREDICATE( std::string const& functionName, cmMakefile& makefile); diff --git a/Source/cmListCommand.cxx b/Source/cmListCommand.cxx index 5a68019bf2..e5338d229d 100644 --- a/Source/cmListCommand.cxx +++ b/Source/cmListCommand.cxx @@ -968,25 +968,46 @@ bool HandleFilterCommand(std::vector const& args, } std::string const& mode = args[3]; - if (mode != "REGEX") { - status.SetError("sub-command FILTER does not recognize mode " + mode); - return false; - } - if (args.size() != 5) { - status.SetError("sub-command FILTER, mode REGEX " - "requires five arguments."); - return false; - } - std::string const& pattern = args[4]; + if (mode == "REGEX") { + if (args.size() != 5) { + status.SetError("sub-command FILTER, mode REGEX " + "requires five arguments."); + return false; + } + std::string const& pattern = args[4]; - try { - status.GetMakefile().AddDefinition( - listName, list->filter(pattern, filterMode).to_string()); - return true; - } catch (std::invalid_argument& e) { - status.SetError(e.what()); - return false; + try { + status.GetMakefile().AddDefinition( + listName, list->filter(pattern, filterMode).to_string()); + return true; + } catch (std::invalid_argument& e) { + status.SetError(e.what()); + return false; + } } + + if (mode == "PREDICATE") { + if (args.size() != 5) { + status.SetError("sub-command FILTER, mode PREDICATE " + "requires five arguments."); + return false; + } + std::string const& functionName = args[4]; + + try { + status.GetMakefile().AddDefinition( + listName, + list->filter(functionName, filterMode, status.GetMakefile()) + .to_string()); + return true; + } catch (std::invalid_argument& e) { + status.SetError(e.what()); + return false; + } + } + + status.SetError("sub-command FILTER does not recognize mode " + mode); + return false; } } // namespace diff --git a/Tests/RunCMake/list/FILTER-PREDICATE-Exclude-stderr.txt b/Tests/RunCMake/list/FILTER-PREDICATE-Exclude-stderr.txt new file mode 100644 index 0000000000..d9ba38dbcc --- /dev/null +++ b/Tests/RunCMake/list/FILTER-PREDICATE-Exclude-stderr.txt @@ -0,0 +1,2 @@ +^mylist was: FILTER_THIS_BIT;DO_NOT_FILTER_THIS;thisisanitem;FILTER_THIS_THING +mylist is: DO_NOT_FILTER_THIS;thisisanitem$ diff --git a/Tests/RunCMake/list/FILTER-PREDICATE-Exclude.cmake b/Tests/RunCMake/list/FILTER-PREDICATE-Exclude.cmake new file mode 100644 index 0000000000..7b1f3492ee --- /dev/null +++ b/Tests/RunCMake/list/FILTER-PREDICATE-Exclude.cmake @@ -0,0 +1,50 @@ +# Predicate: returns TRUE for items starting with "FILTER_THIS_" +function(starts_with_filter input result) + if(input MATCHES "^FILTER_THIS_") + set(${result} TRUE PARENT_SCOPE) + else() + set(${result} FALSE PARENT_SCOPE) + endif() +endfunction() + +set(mylist FILTER_THIS_BIT DO_NOT_FILTER_THIS thisisanitem FILTER_THIS_THING) +message("mylist was: ${mylist}") +list(FILTER mylist EXCLUDE PREDICATE starts_with_filter) +message("mylist is: ${mylist}") + +# EXCLUDE with macro predicate +macro(is_short input result) + string(LENGTH "${input}" _len) + if(_len LESS 6) + set(${result} TRUE) + else() + set(${result} FALSE) + endif() +endmacro() + +set(mylist ab cdefgh ij klmnop qr) +list(FILTER mylist EXCLUDE PREDICATE is_short) +if(NOT mylist STREQUAL "cdefgh;klmnop") + message(FATAL_ERROR "FILTER(EXCLUDE PREDICATE macro) is \"${mylist}\", expected \"cdefgh;klmnop\"") +endif() + +# EXCLUDE on empty list +set(empty_list "") +list(FILTER empty_list EXCLUDE PREDICATE starts_with_filter) +if(NOT empty_list STREQUAL "") + message(FATAL_ERROR "FILTER(EXCLUDE PREDICATE empty) is \"${empty_list}\", expected \"\"") +endif() + +# EXCLUDE where nothing matches (all elements kept) +set(mylist alpha bravo charlie) +list(FILTER mylist EXCLUDE PREDICATE starts_with_filter) +if(NOT mylist STREQUAL "alpha;bravo;charlie") + message(FATAL_ERROR "FILTER(EXCLUDE PREDICATE no-match) is \"${mylist}\", expected \"alpha;bravo;charlie\"") +endif() + +# EXCLUDE where everything matches (all elements removed) +set(mylist FILTER_THIS_A FILTER_THIS_B FILTER_THIS_C) +list(FILTER mylist EXCLUDE PREDICATE starts_with_filter) +if(NOT mylist STREQUAL "") + message(FATAL_ERROR "FILTER(EXCLUDE PREDICATE all-match) is \"${mylist}\", expected \"\"") +endif() diff --git a/Tests/RunCMake/list/FILTER-PREDICATE-Include-stderr.txt b/Tests/RunCMake/list/FILTER-PREDICATE-Include-stderr.txt new file mode 100644 index 0000000000..5e5280d307 --- /dev/null +++ b/Tests/RunCMake/list/FILTER-PREDICATE-Include-stderr.txt @@ -0,0 +1,2 @@ +^mylist was: FILTER_THIS_BIT;DO_NOT_FILTER_THIS;thisisanitem;FILTER_THIS_THING +mylist is: FILTER_THIS_BIT;FILTER_THIS_THING$ diff --git a/Tests/RunCMake/list/FILTER-PREDICATE-Include.cmake b/Tests/RunCMake/list/FILTER-PREDICATE-Include.cmake new file mode 100644 index 0000000000..412ff25cf0 --- /dev/null +++ b/Tests/RunCMake/list/FILTER-PREDICATE-Include.cmake @@ -0,0 +1,51 @@ +# Predicate: returns TRUE for items starting with "FILTER_THIS_" +function(starts_with_filter input result) + if(input MATCHES "^FILTER_THIS_") + set(${result} TRUE PARENT_SCOPE) + else() + set(${result} FALSE PARENT_SCOPE) + endif() +endfunction() + +# Predicate as macro +macro(is_short input result) + string(LENGTH "${input}" _len) + if(_len LESS 6) + set(${result} TRUE) + else() + set(${result} FALSE) + endif() +endmacro() + +set(mylist FILTER_THIS_BIT DO_NOT_FILTER_THIS thisisanitem FILTER_THIS_THING) +message("mylist was: ${mylist}") +list(FILTER mylist INCLUDE PREDICATE starts_with_filter) +message("mylist is: ${mylist}") + +# INCLUDE with macro predicate +set(mylist ab cdefgh ij klmnop qr) +list(FILTER mylist INCLUDE PREDICATE is_short) +if(NOT mylist STREQUAL "ab;ij;qr") + message(FATAL_ERROR "FILTER(INCLUDE PREDICATE macro) is \"${mylist}\", expected \"ab;ij;qr\"") +endif() + +# INCLUDE on empty list +set(empty_list "") +list(FILTER empty_list INCLUDE PREDICATE starts_with_filter) +if(NOT empty_list STREQUAL "") + message(FATAL_ERROR "FILTER(INCLUDE PREDICATE empty) is \"${empty_list}\", expected \"\"") +endif() + +# INCLUDE where nothing matches (all elements removed) +set(mylist alpha bravo charlie) +list(FILTER mylist INCLUDE PREDICATE starts_with_filter) +if(NOT mylist STREQUAL "") + message(FATAL_ERROR "FILTER(INCLUDE PREDICATE no-match) is \"${mylist}\", expected \"\"") +endif() + +# INCLUDE where everything matches +set(mylist FILTER_THIS_A FILTER_THIS_B FILTER_THIS_C) +list(FILTER mylist INCLUDE PREDICATE starts_with_filter) +if(NOT mylist STREQUAL "FILTER_THIS_A;FILTER_THIS_B;FILTER_THIS_C") + message(FATAL_ERROR "FILTER(INCLUDE PREDICATE all-match) is \"${mylist}\", expected \"FILTER_THIS_A;FILTER_THIS_B;FILTER_THIS_C\"") +endif() diff --git a/Tests/RunCMake/list/FILTER-PREDICATE-NoOutput-result.txt b/Tests/RunCMake/list/FILTER-PREDICATE-NoOutput-result.txt new file mode 100644 index 0000000000..d00491fd7e --- /dev/null +++ b/Tests/RunCMake/list/FILTER-PREDICATE-NoOutput-result.txt @@ -0,0 +1 @@ +1 diff --git a/Tests/RunCMake/list/FILTER-PREDICATE-NoOutput-stderr.txt b/Tests/RunCMake/list/FILTER-PREDICATE-NoOutput-stderr.txt new file mode 100644 index 0000000000..2fbc148b92 --- /dev/null +++ b/Tests/RunCMake/list/FILTER-PREDICATE-NoOutput-stderr.txt @@ -0,0 +1,5 @@ +^CMake Error at FILTER-PREDICATE-NoOutput\.cmake:6 \(list\): + list sub-command FILTER, mode PREDICATE: function "bad_predicate" did not + set the output variable\. +Call Stack \(most recent call first\): + CMakeLists\.txt:3 \(include\)$ diff --git a/Tests/RunCMake/list/FILTER-PREDICATE-NoOutput.cmake b/Tests/RunCMake/list/FILTER-PREDICATE-NoOutput.cmake new file mode 100644 index 0000000000..f1f46217e4 --- /dev/null +++ b/Tests/RunCMake/list/FILTER-PREDICATE-NoOutput.cmake @@ -0,0 +1,6 @@ +function(bad_predicate in out) + # Deliberately does NOT set ${out} +endfunction() + +set(mylist alpha bravo charlie) +list(FILTER mylist INCLUDE PREDICATE bad_predicate) diff --git a/Tests/RunCMake/list/FILTER-PREDICATE-TooManyArguments-result.txt b/Tests/RunCMake/list/FILTER-PREDICATE-TooManyArguments-result.txt new file mode 100644 index 0000000000..d00491fd7e --- /dev/null +++ b/Tests/RunCMake/list/FILTER-PREDICATE-TooManyArguments-result.txt @@ -0,0 +1 @@ +1 diff --git a/Tests/RunCMake/list/FILTER-PREDICATE-TooManyArguments-stderr.txt b/Tests/RunCMake/list/FILTER-PREDICATE-TooManyArguments-stderr.txt new file mode 100644 index 0000000000..e3c7a10805 --- /dev/null +++ b/Tests/RunCMake/list/FILTER-PREDICATE-TooManyArguments-stderr.txt @@ -0,0 +1,4 @@ +^CMake Error at FILTER-PREDICATE-TooManyArguments\.cmake:6 \(list\): + list sub-command FILTER, mode PREDICATE requires five arguments\. +Call Stack \(most recent call first\): + CMakeLists\.txt:3 \(include\)$ diff --git a/Tests/RunCMake/list/FILTER-PREDICATE-TooManyArguments.cmake b/Tests/RunCMake/list/FILTER-PREDICATE-TooManyArguments.cmake new file mode 100644 index 0000000000..87a853247d --- /dev/null +++ b/Tests/RunCMake/list/FILTER-PREDICATE-TooManyArguments.cmake @@ -0,0 +1,6 @@ +function(my_predicate in out) + set(${out} TRUE PARENT_SCOPE) +endfunction() + +set(mylist alpha bravo charlie) +list(FILTER mylist INCLUDE PREDICATE my_predicate extra_arg) diff --git a/Tests/RunCMake/list/FILTER-PREDICATE-UnknownFunction-result.txt b/Tests/RunCMake/list/FILTER-PREDICATE-UnknownFunction-result.txt new file mode 100644 index 0000000000..d00491fd7e --- /dev/null +++ b/Tests/RunCMake/list/FILTER-PREDICATE-UnknownFunction-result.txt @@ -0,0 +1 @@ +1 diff --git a/Tests/RunCMake/list/FILTER-PREDICATE-UnknownFunction-stderr.txt b/Tests/RunCMake/list/FILTER-PREDICATE-UnknownFunction-stderr.txt new file mode 100644 index 0000000000..2b6234bcd5 --- /dev/null +++ b/Tests/RunCMake/list/FILTER-PREDICATE-UnknownFunction-stderr.txt @@ -0,0 +1,5 @@ +^CMake Error at FILTER-PREDICATE-UnknownFunction\.cmake:2 \(list\): + list sub-command FILTER, mode PREDICATE: unknown function + "no_such_function"\. +Call Stack \(most recent call first\): + CMakeLists\.txt:3 \(include\)$ diff --git a/Tests/RunCMake/list/FILTER-PREDICATE-UnknownFunction.cmake b/Tests/RunCMake/list/FILTER-PREDICATE-UnknownFunction.cmake new file mode 100644 index 0000000000..48461bd0ea --- /dev/null +++ b/Tests/RunCMake/list/FILTER-PREDICATE-UnknownFunction.cmake @@ -0,0 +1,2 @@ +set(mylist alpha bravo charlie) +list(FILTER mylist INCLUDE PREDICATE no_such_function) diff --git a/Tests/RunCMake/list/RunCMakeTest.cmake b/Tests/RunCMake/list/RunCMakeTest.cmake index 94632cc7b4..1fd86a9231 100644 --- a/Tests/RunCMake/list/RunCMakeTest.cmake +++ b/Tests/RunCMake/list/RunCMakeTest.cmake @@ -37,6 +37,11 @@ run_cmake(FILTER-REGEX-InvalidMode) run_cmake(FILTER-REGEX-InvalidOperator) run_cmake(FILTER-REGEX-Valid0) run_cmake(FILTER-REGEX-Valid1) +run_cmake(FILTER-PREDICATE-UnknownFunction) +run_cmake(FILTER-PREDICATE-NoOutput) +run_cmake(FILTER-PREDICATE-TooManyArguments) +run_cmake(FILTER-PREDICATE-Include) +run_cmake(FILTER-PREDICATE-Exclude) run_cmake(JOIN-NoArguments) run_cmake(JOIN-NoVariable)