if: Add PATH_IS_PREFIX operator

Testing whether one path is a prefix of another is possible today with
cmake_path(IS_PREFIX), but the idiom needs a separate statement plus a
scratch variable and takes the prefix as a variable name, so projects
reach for if(path MATCHES "^${prefix}") instead.  That is wrong whenever
the prefix contains a regex metacharacter, and it accepts siblings,
because '^/a/b' matches '/a/bc'.

Add a binary operator where the left operand is the candidate prefix and
the right is the path, matching the operand order of cmake_path(IS_PREFIX)
and $<PATH:IS_PREFIX> so that the same operation reads the same way on all
three surfaces.  Neither operand is normalized, matching the default of
cmake_path(IS_PREFIX) and the existing PATH_EQUAL operator, so '.' and
'..' are compared as ordinary components.  The test is non-strict, purely
lexical, and applies no relative-to-absolute reconciliation.

Add policy CMP0222 for compatibility, modeled on CMP0139.  The keyword
is consumed as an operator only when it appears in the second argument
slot, so a variable named PATH_IS_PREFIX keeps working in unary and
left-operand position.  What the policy covers is such a variable
appearing after another token, as in if(NOT PATH_IS_PREFIX AND other),
which becomes a configure error under NEW.

Since the operator is an if() spelling of cmake_path(IS_PREFIX) and shares
its implementation, test it by asserting the two agree over a corpus of
inputs rather than by restating expected values.  Those are pinned by the
cmake_path(IS_PREFIX) test, so the operator's tests stay limited to what
has no command equivalent, and no platform branching is needed because
parity holds whatever the host path model answers.

Fixes: #28040
This commit is contained in:
Mickaël Germain
2026-09-10 07:09:47 -04:00
committed by Brad King
parent 3d53f7901b
commit 860583ceac
24 changed files with 302 additions and 0 deletions
+34
View File
@@ -429,6 +429,40 @@ Path Comparisons
See :command:`cmake_path(COMPARE)` for more details.
.. signature:: if(<variable|string> PATH_IS_PREFIX <variable|string>)
:target: PATH_IS_PREFIX
.. versionadded:: 4.5
True if the path on the left is a prefix of the path on the right.
.. code-block:: cmake
# comparison is TRUE
if ("/a/b" PATH_IS_PREFIX "/a/b/c")
...
endif()
# comparison is FALSE: '/a/bc' is a sibling, not a child
if ("/a/b" PATH_IS_PREFIX "/a/bc")
...
endif()
Component-wise comparison is superior to a regular expression match
against the start of the path. The ``if (<path> MATCHES "^<prefix>")``
idiom mishandles regex metacharacters in ``<prefix>`` and accepts
siblings.
The test is lexical, not a containment check. No
:ref:`path normalization <Normalization>` is performed, so a ``..``
escape still tests true: ``"/a/b" PATH_IS_PREFIX "/a/b/../../etc"``.
Normalize with :command:`cmake_path(NORMAL_PATH)` first if that must be
rejected.
Equivalent to :command:`cmake_path(IS_PREFIX)` and
``$<PATH:IS_PREFIX>`` without their ``NORMALIZE`` option. See
:command:`cmake_path(IS_PREFIX)` for more details.
Variable Expansion
^^^^^^^^^^^^^^^^^^
+1
View File
@@ -100,6 +100,7 @@ Policies Introduced by CMake 4.5
.. toctree::
:maxdepth: 1
CMP0222: The if() command supports path prefix tests using PATH_IS_PREFIX operator. </policy/CMP0222>
CMP0221: cmake_host_system_information() DISTRIB_* queries read the host os-release. </policy/CMP0221>
CMP0220: Languages enabled in subdirectories propagate to the top-level directory. </policy/CMP0220>
+17
View File
@@ -0,0 +1,17 @@
CMP0222
-------
.. versionadded:: 4.5
The :command:`if` command supports path prefix tests using
``PATH_IS_PREFIX`` operator.
The ``OLD`` behavior for this policy is to ignore the ``PATH_IS_PREFIX``
operator. The ``NEW`` behavior is to interpret the ``PATH_IS_PREFIX``
operator.
.. |INTRODUCED_IN_CMAKE_VERSION| replace:: 4.5
.. |WARNS_OR_DOES_NOT_WARN| replace:: warns
.. include:: include/STANDARD_ADVICE.rst
.. include:: include/DEPRECATED.rst
+6
View File
@@ -0,0 +1,6 @@
if-PATH_IS_PREFIX
-----------------
* The :command:`if` command gained a ``PATH_IS_PREFIX`` operator to test
whether one path is a prefix of another without accessing the filesystem.
See policy :policy:`CMP0222`.
+22
View File
@@ -66,6 +66,7 @@ auto const keyVERSION_GREATER_EQUAL = "VERSION_GREATER_EQUAL"_s;
auto const keyVERSION_LESS = "VERSION_LESS"_s;
auto const keyVERSION_LESS_EQUAL = "VERSION_LESS_EQUAL"_s;
auto const keyPATH_EQUAL = "PATH_EQUAL"_s;
auto const keyPATH_IS_PREFIX = "PATH_IS_PREFIX"_s;
cmSystemTools::CompareOp const MATCH2CMPOP[5] = {
cmSystemTools::OP_LESS, cmSystemTools::OP_LESS_EQUAL,
@@ -222,6 +223,7 @@ cmConditionEvaluator::cmConditionEvaluator(cmMakefile& makefile,
: Makefile(makefile)
, Backtrace(std::move(bt))
, Policy139Status(makefile.GetPolicyStatus(cmPolicies::CMP0139))
, Policy222Status(makefile.GetPolicyStatus(cmPolicies::CMP0222))
{
}
@@ -679,6 +681,26 @@ bool cmConditionEvaluator::HandleLevel2(cmArgumentList& newArgs,
"Since the policy is not set the OLD behavior will be used."_s);
}
}
else if (this->IsKeyword(keyPATH_IS_PREFIX, *args.next)) {
if (this->Policy222Status != cmPolicies::OLD &&
this->Policy222Status != cmPolicies::WARN) {
cmValue lhs = this->GetVariableOrString(*args.current);
cmValue rhs = this->GetVariableOrString(*args.nextnext);
auto const result = cmCMakePath{ *lhs }.IsPrefix(cmCMakePath{ *rhs });
newArgs.ReduceTwoArgs(result, args);
}
else if (this->Policy222Status == cmPolicies::WARN) {
this->Makefile.IssuePolicyWarning(
cmPolicies::CMP0222, {},
"PATH_IS_PREFIX will be interpreted as an operator "
"when the policy is set to NEW. "
"Since the policy is not set the OLD behavior will be used."_s);
}
}
}
return true;
}
+1
View File
@@ -67,4 +67,5 @@ private:
cmMakefile& Makefile;
cmListFileBacktrace Backtrace;
cmPolicies::PolicyStatus Policy139Status;
cmPolicies::PolicyStatus Policy222Status;
};
+4
View File
@@ -666,6 +666,10 @@ class cmMakefile;
SELECT(POLICY, CMP0221, \
"cmake_host_system_information() DISTRIB_* queries read the host " \
"os-release.", \
4, 5, 0, WARN) \
SELECT(POLICY, CMP0222, \
"The if() command supports path prefix tests using " \
"PATH_IS_PREFIX operator.", \
4, 5, 0, WARN)
#define CM_SELECT_ID(F, A1, A2, A3, A4, A5, A6) F(A1)
@@ -0,0 +1 @@
1
@@ -0,0 +1,8 @@
^CMake Error at CMP0222-NEW-churn\.cmake:[0-9]+ \(if\):
if given arguments:
"NOT" "PATH_IS_PREFIX" "STREQUAL" "yes"
Unknown arguments specified
Call Stack \(most recent call first\):
CMakeLists\.txt:[0-9]+ \(include\)$
@@ -0,0 +1,8 @@
cmake_policy(SET CMP0222 NEW)
# The compatibility break the policy covers: under NEW the keyword is
# consumed as an operator, so a variable of that name is a hard error.
set(PATH_IS_PREFIX "yes")
if(NOT PATH_IS_PREFIX STREQUAL "yes")
message(SEND_ERROR "unreachable")
endif()
+13
View File
@@ -0,0 +1,13 @@
cmake_policy(SET CMP0222 NEW)
# Under NEW the keyword is interpreted as an operator and evaluated. The
# operator's own behavior is covered by the RunCMake.if PathIsPrefix case,
# which outlives this directory: the OLD and WARN cases here are removed
# once support for the OLD behavior is dropped.
if(NOT "/a/b" PATH_IS_PREFIX "/a/b/c")
message(SEND_ERROR "if(PATH_IS_PREFIX): '/a/b' not a prefix of '/a/b/c'")
endif()
if("/a/b" PATH_IS_PREFIX "/a/bc")
message(SEND_ERROR "if(PATH_IS_PREFIX): '/a/b' wrongly a prefix of '/a/bc'")
endif()
@@ -0,0 +1 @@
1
@@ -0,0 +1,8 @@
^CMake Error at CMP0222-OLD\.cmake:[0-9]+ \(if\):
if given arguments:
"/path1" "PATH_IS_PREFIX" "/path2"
Unknown arguments specified
Call Stack \(most recent call first\):
CMakeLists\.txt:[0-9]+ \(include\)$
+13
View File
@@ -0,0 +1,13 @@
cmake_policy(SET CMP0222 OLD)
# Still usable as a value under OLD. Must come first: the case below is
# fatal.
set(PATH_IS_PREFIX "value")
if(PATH_IS_PREFIX STREQUAL "value")
else()
message(SEND_ERROR "PATH_IS_PREFIX not usable as a value under OLD")
endif()
if("/path1" PATH_IS_PREFIX "/path2")
message("PATH_IS_PREFIX recognized")
endif()
@@ -0,0 +1,12 @@
CMake Warning \(policy\) at CMP0222-WARN-churn\.cmake:[0-9]+ \(if\):
Policy CMP0222 is not set: The if\(\) command supports path prefix tests
using PATH_IS_PREFIX operator\. Run "cmake --help-policy CMP0222" for
policy details\. Use the cmake_policy command to set the policy and
suppress this warning\.
PATH_IS_PREFIX will be interpreted as an operator when the policy is set to
NEW\. Since the policy is not set the OLD behavior will be used\.
Call Stack \(most recent call first\):
CMakeLists\.txt:[0-9]+ \(include\)
This warning is for project developers\. Use -Wno-author or -Wno-policy to
suppress it\.
@@ -0,0 +1,5 @@
set(PATH_IS_PREFIX "yes")
set(other "yes")
if(NOT PATH_IS_PREFIX AND other)
message(SEND_ERROR "condition should be false")
endif()
@@ -0,0 +1 @@
1
@@ -0,0 +1,21 @@
CMake Warning \(policy\) at CMP0222-WARN\.cmake:[0-9]+ \(if\):
Policy CMP0222 is not set: The if\(\) command supports path prefix tests
using PATH_IS_PREFIX operator\. Run "cmake --help-policy CMP0222" for
policy details\. Use the cmake_policy command to set the policy and
suppress this warning\.
PATH_IS_PREFIX will be interpreted as an operator when the policy is set to
NEW\. Since the policy is not set the OLD behavior will be used\.
Call Stack \(most recent call first\):
CMakeLists\.txt:[0-9]+ \(include\)
This warning is for project developers\. Use -Wno-author or -Wno-policy to
suppress it\.
CMake Error at CMP0222-WARN\.cmake:[0-9]+ \(if\):
if given arguments:
"/path1" "PATH_IS_PREFIX" "/path2"
Unknown arguments specified
Call Stack \(most recent call first\):
CMakeLists\.txt:[0-9]+ \(include\)
@@ -0,0 +1,3 @@
if("/path1" PATH_IS_PREFIX "/path2")
message("PATH_IS_PREFIX recognized")
endif()
+3
View File
@@ -0,0 +1,3 @@
cmake_minimum_required(VERSION 3.23)
project(${RunCMake_TEST} NONE)
include(${RunCMake_TEST}.cmake)
@@ -0,0 +1,7 @@
include(RunCMake)
run_cmake(CMP0222-OLD)
run_cmake(CMP0222-WARN)
run_cmake(CMP0222-WARN-churn)
run_cmake(CMP0222-NEW)
run_cmake(CMP0222-NEW-churn)
+1
View File
@@ -186,6 +186,7 @@ if(WIN32)
endif()
add_RunCMake_test(CMP0217)
add_RunCMake_test(CMP0219)
add_RunCMake_test(CMP0222)
if(CMAKE_C_COMPILER_ID STREQUAL "MSVC")
add_RunCMake_test(CMP0194 -DCMAKE_C_COMPILER_VERSION=${CMAKE_C_COMPILER_VERSION})
+111
View File
@@ -0,0 +1,111 @@
cmake_policy(SET CMP0222 NEW)
# The operator is an if() spelling of cmake_path(IS_PREFIX), so assert that
# the two agree rather than repeating a table of expected values here. What
# each input should evaluate to is pinned by the cmake_path(IS_PREFIX) test;
# this asserts only that the two surfaces cannot drift apart. Neither
# normalizes, so the comparison is against the plain form of the command.
#
# Because nothing below states an expected value, the host path model needs
# no branching: parity has to hold on every platform whatever the answer is.
function(assert_parity prefix path)
set(prefix_var "${prefix}")
cmake_path(IS_PREFIX prefix_var "${path}" expected)
if(expected)
set(expected TRUE)
else()
set(expected FALSE)
endif()
if(prefix PATH_IS_PREFIX path)
set(actual TRUE)
else()
set(actual FALSE)
endif()
if(NOT actual STREQUAL expected)
message(SEND_ERROR
"if('${prefix}' PATH_IS_PREFIX '${path}') is ${actual}, but "
"cmake_path(IS_PREFIX) says ${expected}")
endif()
endfunction()
# Each pair appears in both roles, so a reversed call cannot pass.
assert_parity("/a/b" "/a/b/c")
assert_parity("/a/b/c" "/a/b")
assert_parity("/a/b" "/a/b")
# Trailing separators, which are significant and asymmetric.
assert_parity("/a/b" "/a/b/")
assert_parity("/a/b/" "/a/b")
assert_parity("/a/b/" "/a/b/c")
# Without NORMALIZE, '.' and '..' are ordinary components.
assert_parity("/a/b" "/a/b/../../etc")
assert_parity("/a/b" "/a/./b//c")
assert_parity("." "./a/b")
assert_parity("." "a/b")
# Duplicate separators are not components.
assert_parity("a/b" "a///b")
# Sibling sharing a textual prefix.
assert_parity("/a/b" "/a/bc")
# Relative and absolute paths are not reconciled.
assert_parity("/a" "b/c")
assert_parity("b" "b/c")
assert_parity("/" "b/c")
assert_parity("/" "/a/b")
# Empty operands in each position.
assert_parity("" "/a/b")
assert_parity("/a" "")
assert_parity("" "")
# Host path model: backslashes, case, drive-relative paths, UNC root-names.
assert_parity("C:\\a" "C:\\a\\b")
assert_parity("c:/a" "C:/A/b")
assert_parity("C:/" "C:foo")
assert_parity("//host/share" "//host/share/a")
assert_parity("/a" "//a/b")
# Literal operands, not just variables.
if(NOT "/a/b" PATH_IS_PREFIX "/a/b/c")
message(SEND_ERROR "if(PATH_IS_PREFIX): literal operands rejected")
endif()
# NOT composes with the operator, which is the motivating guard idiom.
set(guard_ok FALSE)
if(NOT "/a/b" PATH_IS_PREFIX "/a/bc")
set(guard_ok TRUE)
endif()
if(NOT guard_ok)
message(SEND_ERROR "if(NOT <prefix> PATH_IS_PREFIX <path>) did not compose")
endif()
# Works in an elseif() chain, which a helper function could not.
if("/a" PATH_IS_PREFIX "/x/y")
message(SEND_ERROR "if(PATH_IS_PREFIX): '/a' matched '/x/y'")
elseif("/a/b" PATH_IS_PREFIX "/a/b/c")
else()
message(SEND_ERROR "elseif(<prefix> PATH_IS_PREFIX <path>) did not match")
endif()
# The motivating case: a prefix that if(MATCHES) cannot express.
set(prefix "/proj/lib+ssl(v2)/inc.d")
if(NOT "${prefix}" PATH_IS_PREFIX "${prefix}/f.h")
message(SEND_ERROR "if(PATH_IS_PREFIX): regex metacharacters in prefix")
endif()
# The keyword is consumed as an operator only with both a preceding and a
# following token, so it stays usable as a variable otherwise. Neither
# condition may be written with a leading NOT: that would put the keyword in
# the operator slot and make these a hard error.
set(PATH_IS_PREFIX "yes")
if(PATH_IS_PREFIX)
else()
message(SEND_ERROR "if(PATH_IS_PREFIX): unary use broken")
endif()
if(PATH_IS_PREFIX STREQUAL "yes")
else()
message(SEND_ERROR "if(PATH_IS_PREFIX STREQUAL ...): left-operand use broken")
endif()
+1
View File
@@ -13,6 +13,7 @@ if(NOT MSYS)
endif()
run_cmake(IsDirectory)
run_cmake(IsDirectoryLong)
run_cmake(PathIsPrefix)
run_cmake(duplicate-deep-else)
run_cmake(duplicate-else)
run_cmake(duplicate-else-after-elseif)