Skip to content

Commit b2e134b

Browse files
committed
added Strings::match/matchAll/split() return type narrowing based on boolean arguments
1 parent 4825505 commit b2e134b

5 files changed

Lines changed: 248 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ Extensions for specific Nette packages use dedicated namespaces: `Nette\PHPStan\
5757

5858
`RemoveFailingReturnTypeExtension` (`ExpressionTypeResolverExtension`) removes `|false` or `|null` from return types of native PHP functions and methods where the error return value is trivial or outdated. It handles `FuncCall`, `MethodCall`, and `StaticCall` in a single class. Configuration uses a flat list in NEON — plain names for functions (`json_encode`), `Class::method` notation for methods (`Normalizer::normalize`). It runs before all `DynamicReturnTypeExtension` implementations, delegates to them via `DynamicReturnTypeExtensionRegistry`, and strips `|false` from the result. For `preg_replace`, `preg_replace_callback`, `preg_replace_callback_array`, and `preg_filter` it strips `|null` instead (these return null on PCRE error). For `preg_replace_callback_array` pattern validation checks array keys. Config: `extension-php.neon`.
5959

60+
### StringsReturnTypeExtension
61+
62+
`StringsReturnTypeExtension` (`DynamicStaticMethodReturnTypeExtension`) narrows return types of `Strings::match()`, `matchAll()` and `split()` based on boolean arguments. It resolves `captureOffset`, `unmatchedAsNull`, `patternOrder`, and `lazy` to constant booleans and constructs the precise return type — e.g. `match()` with `captureOffset: true` returns `array<array{string, int<0, max>}>|null` instead of `?array`. When a boolean argument is not a constant, falls back to the declared return type. Config: `extension-nette.neon`.
63+
6064
### AssertTypeNarrowingExtension
6165

6266
`AssertTypeNarrowingExtension` (`StaticMethodTypeSpecifyingExtension` + `TypeSpecifierAwareExtension`) narrows variable types after `Tester\Assert` assertion calls. Each assertion method is mapped to an equivalent PHP expression that PHPStan already understands, then delegated to `TypeSpecifier::specifyTypesInCondition()`. Supported methods: `null`, `notNull`, `true`, `false`, `truthy`, `falsey`, `same`, `notSame`, and `type` (with built-in type strings like `'string'`, `'int'`, etc. and class/interface names). Config: `extension-nette.neon`.

extension-nette.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,8 @@ services:
2020
-
2121
create: Nette\PHPStan\Tester\AssertTypeNarrowingExtension
2222
tags: [phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension]
23+
24+
# nette/utils
25+
-
26+
create: Nette\PHPStan\Utils\StringsReturnTypeExtension
27+
tags: [phpstan.broker.dynamicStaticMethodReturnTypeExtension]
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nette\PHPStan\Utils;
4+
5+
use Nette\Utils\Strings;
6+
use PhpParser\Node\Expr\StaticCall;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Reflection\MethodReflection;
9+
use PHPStan\Type\Accessory\AccessoryArrayListType;
10+
use PHPStan\Type\ArrayType;
11+
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
12+
use PHPStan\Type\Constant\ConstantIntegerType;
13+
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
14+
use PHPStan\Type\Generic\GenericObjectType;
15+
use PHPStan\Type\IntegerRangeType;
16+
use PHPStan\Type\IntegerType;
17+
use PHPStan\Type\IntersectionType;
18+
use PHPStan\Type\MixedType;
19+
use PHPStan\Type\StringType;
20+
use PHPStan\Type\Type;
21+
use PHPStan\Type\TypeCombinator;
22+
use function in_array;
23+
24+
25+
/**
26+
* Narrows return types of Strings::match(), matchAll() and split()
27+
* based on boolean arguments like captureOffset, unmatchedAsNull, etc.
28+
*/
29+
class StringsReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension
30+
{
31+
public function getClass(): string
32+
{
33+
return Strings::class;
34+
}
35+
36+
37+
public function isStaticMethodSupported(MethodReflection $methodReflection): bool
38+
{
39+
return in_array($methodReflection->getName(), ['match', 'matchAll', 'split'], true);
40+
}
41+
42+
43+
public function getTypeFromStaticMethodCall(
44+
MethodReflection $methodReflection,
45+
StaticCall $methodCall,
46+
Scope $scope,
47+
): ?Type
48+
{
49+
return match ($methodReflection->getName()) {
50+
'match' => $this->resolveMatch($methodCall, $scope),
51+
'matchAll' => $this->resolveMatchAll($methodCall, $scope),
52+
'split' => $this->resolveSplit($methodCall, $scope),
53+
default => null,
54+
};
55+
}
56+
57+
58+
private function resolveMatch(StaticCall $call, Scope $scope): ?Type
59+
{
60+
$captureOffset = $this->resolveBool($call, $scope, 'captureOffset', 2);
61+
$unmatchedAsNull = $this->resolveBool($call, $scope, 'unmatchedAsNull', 4);
62+
if ($captureOffset === null || $unmatchedAsNull === null) {
63+
return null;
64+
}
65+
66+
$elementType = $this->buildElementType($captureOffset, $unmatchedAsNull);
67+
return TypeCombinator::addNull(
68+
new ArrayType(new MixedType, $elementType),
69+
);
70+
}
71+
72+
73+
private function resolveMatchAll(StaticCall $call, Scope $scope): ?Type
74+
{
75+
$captureOffset = $this->resolveBool($call, $scope, 'captureOffset', 2);
76+
$unmatchedAsNull = $this->resolveBool($call, $scope, 'unmatchedAsNull', 4);
77+
$patternOrder = $this->resolveBool($call, $scope, 'patternOrder', 5);
78+
$lazy = $this->resolveBool($call, $scope, 'lazy', 7);
79+
if ($captureOffset === null || $unmatchedAsNull === null || $patternOrder === null || $lazy === null) {
80+
return null;
81+
}
82+
83+
$elementType = $this->buildElementType($captureOffset, $unmatchedAsNull);
84+
85+
if ($lazy) {
86+
return new GenericObjectType(\Generator::class, [
87+
new IntegerType,
88+
new ArrayType(new MixedType, $elementType),
89+
new MixedType,
90+
new MixedType,
91+
]);
92+
}
93+
94+
if ($patternOrder) {
95+
return new ArrayType(
96+
new MixedType,
97+
self::buildListType($elementType),
98+
);
99+
}
100+
101+
return self::buildListType(
102+
new ArrayType(new MixedType, $elementType),
103+
);
104+
}
105+
106+
107+
private function resolveSplit(StaticCall $call, Scope $scope): ?Type
108+
{
109+
$captureOffset = $this->resolveBool($call, $scope, 'captureOffset', 2);
110+
if ($captureOffset === null) {
111+
return null;
112+
}
113+
114+
$elementType = $captureOffset
115+
? self::buildOffsetTuple(new StringType)
116+
: new StringType;
117+
118+
return self::buildListType($elementType);
119+
}
120+
121+
122+
private function buildElementType(bool $captureOffset, bool $unmatchedAsNull): Type
123+
{
124+
$stringType = $unmatchedAsNull
125+
? TypeCombinator::addNull(new StringType)
126+
: new StringType;
127+
128+
return $captureOffset
129+
? self::buildOffsetTuple($stringType)
130+
: $stringType;
131+
}
132+
133+
134+
private static function buildOffsetTuple(Type $stringType): Type
135+
{
136+
$builder = ConstantArrayTypeBuilder::createEmpty();
137+
$builder->setOffsetValueType(new ConstantIntegerType(0), $stringType);
138+
$builder->setOffsetValueType(new ConstantIntegerType(1), IntegerRangeType::fromInterval(0, null));
139+
return $builder->getArray();
140+
}
141+
142+
143+
private static function buildListType(Type $valueType): Type
144+
{
145+
return new IntersectionType([
146+
new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $valueType),
147+
new AccessoryArrayListType,
148+
]);
149+
}
150+
151+
152+
/**
153+
* Resolves a boolean argument by parameter name (named arg) or positional index.
154+
* Returns the default (false) when the argument is not provided.
155+
*/
156+
private function resolveBool(StaticCall $call, Scope $scope, string $name, int $position): ?bool
157+
{
158+
$args = $call->getArgs();
159+
160+
foreach ($args as $arg) {
161+
if ($arg->name !== null && $arg->name->toString() === $name) {
162+
return self::extractBool($scope->getType($arg->value));
163+
}
164+
}
165+
166+
if (isset($args[$position]) && $args[$position]->name === null) {
167+
return self::extractBool($scope->getType($args[$position]->value));
168+
}
169+
170+
return false;
171+
}
172+
173+
174+
private static function extractBool(Type $type): ?bool
175+
{
176+
if ($type->isTrue()->yes()) {
177+
return true;
178+
} elseif ($type->isFalse()->yes()) {
179+
return false;
180+
}
181+
182+
return null;
183+
}
184+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php declare(strict_types=1);
2+
3+
use Nette\Utils\Strings;
4+
use function PHPStan\Testing\assertType;
5+
6+
7+
// match() — defaults
8+
assertType('array<string>|null', Strings::match('subject', '#pattern#'));
9+
10+
// match() — captureOffset
11+
assertType('array<array{string, int<0, max>}>|null', Strings::match('subject', '#pattern#', captureOffset: true));
12+
13+
// match() — unmatchedAsNull
14+
assertType('array<string|null>|null', Strings::match('subject', '#pattern#', unmatchedAsNull: true));
15+
16+
// match() — captureOffset + unmatchedAsNull
17+
assertType('array<array{string|null, int<0, max>}>|null', Strings::match('subject', '#pattern#', captureOffset: true, unmatchedAsNull: true));
18+
19+
20+
// matchAll() — defaults (PREG_SET_ORDER)
21+
assertType('list<array<string>>', Strings::matchAll('subject', '#pattern#'));
22+
23+
// matchAll() — captureOffset
24+
assertType('list<array<array{string, int<0, max>}>>', Strings::matchAll('subject', '#pattern#', captureOffset: true));
25+
26+
// matchAll() — unmatchedAsNull
27+
assertType('list<array<string|null>>', Strings::matchAll('subject', '#pattern#', unmatchedAsNull: true));
28+
29+
// matchAll() — captureOffset + unmatchedAsNull
30+
assertType('list<array<array{string|null, int<0, max>}>>', Strings::matchAll('subject', '#pattern#', captureOffset: true, unmatchedAsNull: true));
31+
32+
// matchAll() — lazy
33+
assertType('Generator<int, array<string>, mixed, mixed>', Strings::matchAll('subject', '#pattern#', lazy: true));
34+
35+
// matchAll() — lazy + captureOffset
36+
assertType('Generator<int, array<array{string, int<0, max>}>, mixed, mixed>', Strings::matchAll('subject', '#pattern#', captureOffset: true, lazy: true));
37+
38+
// matchAll() — lazy + unmatchedAsNull
39+
assertType('Generator<int, array<string|null>, mixed, mixed>', Strings::matchAll('subject', '#pattern#', unmatchedAsNull: true, lazy: true));
40+
41+
// matchAll() — patternOrder
42+
assertType('array<list<string>>', Strings::matchAll('subject', '#pattern#', patternOrder: true));
43+
44+
// matchAll() — patternOrder + captureOffset
45+
assertType('array<list<array{string, int<0, max>}>>', Strings::matchAll('subject', '#pattern#', captureOffset: true, patternOrder: true));
46+
47+
48+
// split() — defaults
49+
assertType('list<string>', Strings::split('subject', '#pattern#'));
50+
51+
// split() — captureOffset
52+
assertType('list<array{string, int<0, max>}>', Strings::split('subject', '#pattern#', captureOffset: true));

tests/extensions.phpt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@ TypeAssert::assertTypes(__DIR__ . '/Schema/expect-array-return-type.php');
1616
TypeAssert::assertTypes(__DIR__ . '/Tester/assert-type-narrowing.php');
1717
TypeAssert::assertTypes(__DIR__ . '/Tester/assert-in-function.php');
1818
TypeAssert::assertTypes(__DIR__ . '/Tester/assert-type-with-custom-class.php');
19+
20+
// Utils
21+
TypeAssert::assertTypes(__DIR__ . '/Utils/strings-return-type.php');

0 commit comments

Comments
 (0)