From 5f51c6d2f1ac1502c89ec1875597be696939f35e Mon Sep 17 00:00:00 2001
From: gwosti <322952417+gwosti@users.noreply.github.com>
Date: Tue, 1 Sep 2026 07:43:34 +0200
Subject: [PATCH] Infer non-negative offsets for mandatory regex groups
Amp-Thread-ID: https://ampcode.com/threads/T-01a19219-4d78-7363-95de-11322f00b310
---
src/Type/Php/RegexArrayShapeMatcher.php | 17 ++++++++++-------
src/Type/Regex/RegexCapturingGroup.php | 9 +++++++--
tests/PHPStan/Analyser/nsrt/bug-11311.php | 2 +-
tests/PHPStan/Analyser/nsrt/bug-14959.php | 11 +++++++++++
.../nsrt/by-ref-writeback-native-type.php | 4 ++--
.../Analyser/nsrt/preg_match_all_shapes.php | 14 +++++++-------
.../PHPStan/Analyser/nsrt/preg_match_shapes.php | 10 +++++-----
.../Analyser/nsrt/preg_match_shapes_php80.php | 4 ++--
.../nsrt/preg_replace_callback_shapes.php | 4 ++--
.../CallToFunctionParametersRuleTest.php | 4 ++--
10 files changed, 49 insertions(+), 30 deletions(-)
create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14959.php
diff --git a/src/Type/Php/RegexArrayShapeMatcher.php b/src/Type/Php/RegexArrayShapeMatcher.php
index 6d5bfbab630..4d645a8c422 100644
--- a/src/Type/Php/RegexArrayShapeMatcher.php
+++ b/src/Type/Php/RegexArrayShapeMatcher.php
@@ -380,10 +380,10 @@ private function isSubjectOptional(TrinaryLogic $wasMatched, bool $matchesAll):
*/
private function createSubjectValueType(Type $baseType, int $flags, bool $matchesAll, TrinaryLogic $wasMatched): Type
{
- $subjectValueType = TypeCombinator::removeNull($this->getValueType($baseType, $flags, $matchesAll));
+ $subjectValueType = TypeCombinator::removeNull($this->getValueType($baseType, $flags, $matchesAll, false));
if ($matchesAll) {
- $subjectValueType = TypeCombinator::removeNull($this->getValueType(new StringType(), $flags, $matchesAll));
+ $subjectValueType = TypeCombinator::removeNull($this->getValueType(new StringType(), $flags, $matchesAll, false));
if ($this->containsPatternOrder($flags)) {
$accessoryTypes = [
@@ -428,6 +428,8 @@ private function isGroupOptional(RegexCapturingGroup $captureGroup, TrinaryLogic
private function createGroupValueType(RegexCapturingGroup $captureGroup, TrinaryLogic $wasMatched, int $flags, bool $isTrailingOptional, bool $isLastGroup, bool $matchesAll): Type
{
+ $canBeUnmatched = $captureGroup->isOptional() || $captureGroup->isForcedUnmatched();
+
if ($matchesAll) {
if (
(
@@ -448,10 +450,11 @@ private function createGroupValueType(RegexCapturingGroup $captureGroup, Trinary
TypeCombinator::union($captureGroup->getType(), new ConstantStringType('')),
$flags,
$matchesAll,
+ $canBeUnmatched,
);
$groupValueType = TypeCombinator::removeNull($groupValueType);
} else {
- $groupValueType = $this->getValueType($captureGroup->getType(), $flags, $matchesAll);
+ $groupValueType = $this->getValueType($captureGroup->getType(), $flags, $matchesAll, $canBeUnmatched);
}
if (!$isTrailingOptional && $this->containsUnmatchedAsNull($flags, $matchesAll) && !$captureGroup->isOptional()) {
@@ -475,9 +478,10 @@ private function createGroupValueType(RegexCapturingGroup $captureGroup, Trinary
TypeCombinator::union($captureGroup->getType(), $this->getUnmatchedGroupType($flags)),
$flags,
$matchesAll,
+ $canBeUnmatched,
);
} else {
- $groupValueType = $this->getValueType($captureGroup->getType(), $flags, $matchesAll);
+ $groupValueType = $this->getValueType($captureGroup->getType(), $flags, $matchesAll, $canBeUnmatched);
}
if ($wasMatched->yes()) {
@@ -540,12 +544,11 @@ private function getKeyType(int|string $key): Type
return new ConstantIntegerType($key);
}
- private function getValueType(Type $baseType, int $flags, bool $matchesAll): Type
+ private function getValueType(Type $baseType, int $flags, bool $matchesAll, bool $canBeUnmatched): Type
{
$valueType = $baseType;
- // unmatched groups return -1 as offset
- $offsetType = IntegerRangeType::fromInterval(-1, null);
+ $offsetType = IntegerRangeType::fromInterval($canBeUnmatched ? -1 : 0, null);
if ($this->containsUnmatchedAsNull($flags, $matchesAll)) {
$valueType = TypeCombinator::addNull($valueType);
}
diff --git a/src/Type/Regex/RegexCapturingGroup.php b/src/Type/Regex/RegexCapturingGroup.php
index b33d620db48..8d448f4db9f 100644
--- a/src/Type/Regex/RegexCapturingGroup.php
+++ b/src/Type/Regex/RegexCapturingGroup.php
@@ -68,9 +68,9 @@ public function forceType(Type $type): self
$this->alternation,
$this->inOptionalQuantification,
$this->parent,
- $type,
+ $this->type,
$this->forceNonOptional,
- $this->forceType,
+ $type,
$this->forceAlternationBranchMatched,
);
}
@@ -150,6 +150,11 @@ private function isOptionalWithinAlternationBranch(): bool
return false;
}
+ public function isForcedUnmatched(): bool
+ {
+ return $this->forceType !== null;
+ }
+
public function inOptionalQuantification(): bool
{
return $this->inOptionalQuantification;
diff --git a/tests/PHPStan/Analyser/nsrt/bug-11311.php b/tests/PHPStan/Analyser/nsrt/bug-11311.php
index 79fbf3aca44..f6fb2bc26b5 100644
--- a/tests/PHPStan/Analyser/nsrt/bug-11311.php
+++ b/tests/PHPStan/Analyser/nsrt/bug-11311.php
@@ -210,7 +210,7 @@ function (string $s): void {
function (string $s): void {
if (preg_match('~a|(\d)|(\s)~', $s, $matches, PREG_UNMATCHED_AS_NULL|PREG_OFFSET_CAPTURE) === 1) {
- assertType("array{array{non-empty-string|null, int<-1, max>}, array{numeric-string|null, int<-1, max>}, array{non-empty-string|null, int<-1, max>}}", $matches);
+ assertType("array{array{non-empty-string|null, int<0, max>}, array{numeric-string|null, int<-1, max>}, array{non-empty-string|null, int<-1, max>}}", $matches);
}
};
diff --git a/tests/PHPStan/Analyser/nsrt/bug-14959.php b/tests/PHPStan/Analyser/nsrt/bug-14959.php
new file mode 100644
index 00000000000..eca5c66752f
--- /dev/null
+++ b/tests/PHPStan/Analyser/nsrt/bug-14959.php
@@ -0,0 +1,11 @@
+]+?)?/', '
hello world
', $matches, PREG_OFFSET_CAPTURE)) {
+ assertType('int<0, max>', $matches[0][1]);
+ assertType('int<0, max>', $matches[1][1]);
+ assertType('int<-1, max>|null', $matches[2][1] ?? null);
+}
diff --git a/tests/PHPStan/Analyser/nsrt/by-ref-writeback-native-type.php b/tests/PHPStan/Analyser/nsrt/by-ref-writeback-native-type.php
index 6ebab6d0b0d..a8e0b66ec4c 100644
--- a/tests/PHPStan/Analyser/nsrt/by-ref-writeback-native-type.php
+++ b/tests/PHPStan/Analyser/nsrt/by-ref-writeback-native-type.php
@@ -11,8 +11,8 @@ function offsetCapture(string $s): void
return;
}
- assertType("array{0: array{non-falsy-string, int<-1, max>}, 1: array{'a', int<-1, max>}, 2?: array{'b', int<-1, max>}}", $m);
- assertNativeType("array{0: array{non-falsy-string, int<-1, max>}, 1: array{'a', int<-1, max>}, 2?: array{'b', int<-1, max>}}", $m);
+ assertType("array{0: array{non-falsy-string, int<0, max>}, 1: array{'a', int<0, max>}, 2?: array{'b', int<-1, max>}}", $m);
+ assertNativeType("array{0: array{non-falsy-string, int<0, max>}, 1: array{'a', int<0, max>}, 2?: array{'b', int<-1, max>}}", $m);
}
function noFlags(string $s): void
diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_all_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_all_shapes.php
index 93099323c1b..2e7bc6ac835 100644
--- a/tests/PHPStan/Analyser/nsrt/preg_match_all_shapes.php
+++ b/tests/PHPStan/Analyser/nsrt/preg_match_all_shapes.php
@@ -112,25 +112,25 @@ function (string $size): void {
function (string $size): void {
if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches, PREG_SET_ORDER|PREG_OFFSET_CAPTURE)) {
- assertType("non-empty-list}, num: array{numeric-string, int<-1, max>}, 1: array{numeric-string, int<-1, max>}, suffix?: array{'ab', int<-1, max>}, 2?: array{'ab', int<-1, max>}}>", $matches);
+ assertType("non-empty-list}, num: array{numeric-string, int<0, max>}, 1: array{numeric-string, int<0, max>}, suffix?: array{'ab', int<-1, max>}, 2?: array{'ab', int<-1, max>}}>", $matches);
}
};
function (string $size): void {
if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches, PREG_PATTERN_ORDER|PREG_OFFSET_CAPTURE)) {
- assertType("array{0: non-empty-list}>, num: non-empty-list}>, 1: non-empty-list}>, suffix: non-empty-list}>, 2: non-empty-list}>}", $matches);
+ assertType("array{0: non-empty-list}>, num: non-empty-list}>, 1: non-empty-list}>, suffix: non-empty-list}>, 2: non-empty-list}>}", $matches);
}
};
function (string $size): void {
if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches, PREG_UNMATCHED_AS_NULL|PREG_SET_ORDER|PREG_OFFSET_CAPTURE)) {
- assertType("non-empty-list}, num: array{numeric-string|null, int<-1, max>}, 1: array{numeric-string|null, int<-1, max>}, suffix: array{'ab'|null, int<-1, max>}, 2: array{'ab'|null, int<-1, max>}}>", $matches);
+ assertType("non-empty-list}, num: array{numeric-string|null, int<0, max>}, 1: array{numeric-string|null, int<0, max>}, suffix: array{'ab'|null, int<-1, max>}, 2: array{'ab'|null, int<-1, max>}}>", $matches);
}
};
function (string $size): void {
if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches, PREG_UNMATCHED_AS_NULL|PREG_PATTERN_ORDER|PREG_OFFSET_CAPTURE)) {
- assertType("array{0: non-empty-list}>, num: non-empty-list}>, 1: non-empty-list}>, suffix: non-empty-list}>, 2: non-empty-list}>}", $matches);
+ assertType("array{0: non-empty-list}>, num: non-empty-list}>, 1: non-empty-list}>, suffix: non-empty-list}>, 2: non-empty-list}>}", $matches);
}
};
@@ -142,7 +142,7 @@ public function sayHello(string $content): void
return;
}
- assertType('array{list}>}', $matches);
+ assertType('array{list}>}', $matches);
}
public function sayFoo(string $content): void
@@ -165,13 +165,13 @@ public function sayBar(string $content): void
function doFoobar(string $s): void {
if (preg_match_all('/(foo)?(bar)?(baz)?/', $s, $matches, PREG_OFFSET_CAPTURE)) {
- assertType("array{non-empty-list}>, non-empty-list}>, non-empty-list}>, non-empty-list}>}", $matches);
+ assertType("array{non-empty-list}>, non-empty-list}>, non-empty-list}>, non-empty-list}>}", $matches);
}
}
function doFoobarNull(string $s): void {
if (preg_match_all('/(foo)?(bar)?(baz)?/', $s, $matches, PREG_OFFSET_CAPTURE|PREG_UNMATCHED_AS_NULL)) {
- assertType("array{non-empty-list}>, non-empty-list}>, non-empty-list}>, non-empty-list}>}", $matches);
+ assertType("array{non-empty-list}>, non-empty-list}>, non-empty-list}>, non-empty-list}>}", $matches);
}
}
}
diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php
index d8a68124cc7..f55980b8959 100644
--- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php
+++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php
@@ -115,9 +115,9 @@ function doNamedSubpattern(string $s): void {
function doOffsetCapture(string $s): void {
if (preg_match('/(foo)(bar)(baz)/', $s, $matches, PREG_OFFSET_CAPTURE)) {
- assertType("array{array{non-falsy-string, int<-1, max>}, array{'foo', int<-1, max>}, array{'bar', int<-1, max>}, array{'baz', int<-1, max>}}", $matches);
+ assertType("array{array{non-falsy-string, int<0, max>}, array{'foo', int<0, max>}, array{'bar', int<0, max>}, array{'baz', int<0, max>}}", $matches);
}
- assertType("array{}|array{array{non-falsy-string, int<-1, max>}, array{'foo', int<-1, max>}, array{'bar', int<-1, max>}, array{'baz', int<-1, max>}}", $matches);
+ assertType("array{}|array{array{non-falsy-string, int<0, max>}, array{'foo', int<0, max>}, array{'bar', int<0, max>}, array{'baz', int<0, max>}}", $matches);
}
function doUnknownFlags(string $s, int $flags): void {
@@ -679,13 +679,13 @@ function (string $s): void {
function (string $value): void
{
if (preg_match('/^(x)*$/', $value, $matches, PREG_OFFSET_CAPTURE)) {
- assertType("array{0: array{string, int<-1, max>}, 1?: array{non-empty-string, int<-1, max>}}", $matches);
+ assertType("array{0: array{string, int<0, max>}, 1?: array{non-empty-string, int<0, max>}}", $matches);
}
};
function (string $value): void {
if (preg_match('/^(?:(x)|(y))*$/', $value, $matches, PREG_OFFSET_CAPTURE)) {
- assertType("array{0: array{string, int<-1, max>}, 1?: array{non-empty-string, int<-1, max>}}|array{array{string, int<-1, max>}, array{'', int<-1, max>}, array{non-empty-string, int<-1, max>}}", $matches);
+ assertType("array{0: array{string, int<0, max>}, 1?: array{non-empty-string, int<0, max>}}|array{array{string, int<0, max>}, array{'', int<-1, max>}, array{non-empty-string, int<0, max>}}", $matches);
}
};
@@ -724,7 +724,7 @@ function (string $s): void {
function (string $s): void {
if (preg_match('~a|(\d)|(\s)~', $s, $matches, PREG_OFFSET_CAPTURE) === 1) {
- assertType("array{0: array{non-empty-string, int<-1, max>}, 1?: array{numeric-string, int<-1, max>}}|array{array{non-empty-string, int<-1, max>}, array{'', int<-1, max>}, array{non-empty-string, int<-1, max>}}", $matches);
+ assertType("array{0: array{non-empty-string, int<0, max>}, 1?: array{numeric-string, int<0, max>}}|array{array{non-empty-string, int<0, max>}, array{'', int<-1, max>}, array{non-empty-string, int<0, max>}}", $matches);
}
};
diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php
index 4620565210c..176294dd896 100644
--- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php
+++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php
@@ -7,9 +7,9 @@
function doOffsetCaptureWithUnmatchedNull(string $s): void {
// see https://3v4l.org/07rBO#v8.2.9
if (preg_match('/(foo)(bar)(baz)/', $s, $matches, PREG_OFFSET_CAPTURE|PREG_UNMATCHED_AS_NULL)) {
- assertType("array{array{non-falsy-string|null, int<-1, max>}, array{'foo'|null, int<-1, max>}, array{'bar'|null, int<-1, max>}, array{'baz'|null, int<-1, max>}}", $matches);
+ assertType("array{array{non-falsy-string|null, int<0, max>}, array{'foo'|null, int<0, max>}, array{'bar'|null, int<0, max>}, array{'baz'|null, int<0, max>}}", $matches);
}
- assertType("array{}|array{array{non-falsy-string|null, int<-1, max>}, array{'foo'|null, int<-1, max>}, array{'bar'|null, int<-1, max>}, array{'baz'|null, int<-1, max>}}", $matches);
+ assertType("array{}|array{array{non-falsy-string|null, int<0, max>}, array{'foo'|null, int<0, max>}, array{'bar'|null, int<0, max>}, array{'baz'|null, int<0, max>}}", $matches);
}
function doNonAutoCapturingModifier(string $s): void {
diff --git a/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php
index 9f8c608d471..4d64450cead 100644
--- a/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php
+++ b/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php
@@ -23,7 +23,7 @@ function (string $s): void {
preg_replace_callback(
'/(foo)?(bar)?(baz)?/',
function ($matches) {
- assertType("list{0: array{string, int<-1, max>}, 1?: array{''|'foo', int<-1, max>}, 2?: array{''|'bar', int<-1, max>}, 3?: array{'baz', int<-1, max>}}", $matches);
+ assertType("list{0: array{string, int<0, max>}, 1?: array{''|'foo', int<-1, max>}, 2?: array{''|'bar', int<-1, max>}, 3?: array{'baz', int<-1, max>}}", $matches);
return '';
},
$s,
@@ -37,7 +37,7 @@ function (string $s): void {
preg_replace_callback(
'/(foo)?(bar)?(baz)?/',
function ($matches) {
- assertType("array{array{string|null, int<-1, max>}, array{'foo'|null, int<-1, max>}, array{'bar'|null, int<-1, max>}, array{'baz'|null, int<-1, max>}}", $matches);
+ assertType("array{array{string|null, int<0, max>}, array{'foo'|null, int<-1, max>}, array{'bar'|null, int<-1, max>}, array{'baz'|null, int<-1, max>}}", $matches);
return '';
},
$s,
diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php
index 67b5837fe6d..509c60970ee 100644
--- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php
+++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php
@@ -3009,11 +3009,11 @@ public function testConstantParameterCheck(): void
110,
],
[
- 'Parameter #2 $callback of function preg_replace_callback expects callable(array): string, Closure(mixed): array{non-falsy-string, int<-1, max>} given.',
+ 'Parameter #2 $callback of function preg_replace_callback expects callable(array): string, Closure(mixed): array{non-falsy-string, int<0, max>} given.',
113,
],
[
- 'Parameter #2 $callback of function preg_replace_callback expects callable(array): string, Closure(mixed): array{non-falsy-string|null, int<-1, max>} given.',
+ 'Parameter #2 $callback of function preg_replace_callback expects callable(array): string, Closure(mixed): array{non-falsy-string|null, int<0, max>} given.',
116,
],
[