Skip to content

Commit 2fa14f6

Browse files
committed
Account for PREG_OFFSET_CAPTURE and PREG_UNMATCHED_AS_NULL flags in preg_replace_callback and preg_replace_callback_array callback signatures
Fixes phpstan/phpstan#10396
1 parent 106fc93 commit 2fa14f6

7 files changed

Lines changed: 358 additions & 0 deletions

File tree

src/Analyser/MutatingScope.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
use PHPStan\Parser\ImmediatelyInvokedClosureVisitor;
6161
use PHPStan\Parser\NewAssignedToPropertyVisitor;
6262
use PHPStan\Parser\Parser;
63+
use PHPStan\Parser\PregReplaceCallbackArgVisitor;
6364
use PHPStan\Php\PhpVersion;
6465
use PHPStan\Php\PhpVersionFactory;
6566
use PHPStan\Php\PhpVersions;
@@ -102,6 +103,7 @@
102103
use PHPStan\Type\BooleanType;
103104
use PHPStan\Type\ClosureType;
104105
use PHPStan\Type\ConditionalTypeForParameter;
106+
use PHPStan\Type\Constant\ConstantArrayType;
105107
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
106108
use PHPStan\Type\Constant\ConstantBooleanType;
107109
use PHPStan\Type\Constant\ConstantFloatType;
@@ -177,6 +179,8 @@
177179
use const PHP_INT_MAX;
178180
use const PHP_INT_MIN;
179181
use const PHP_VERSION_ID;
182+
use const PREG_OFFSET_CAPTURE;
183+
use const PREG_UNMATCHED_AS_NULL;
180184

181185
class MutatingScope implements Scope, NodeCallbackInvoker
182186
{
@@ -5681,6 +5685,38 @@ private function getClosureType(Expr\Closure|Expr\ArrowFunction $node): ClosureT
56815685
foreach ($immediatelyInvokedArgs as $immediatelyInvokedArg) {
56825686
$callableParameters[] = new DummyParameter('item', $this->getType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
56835687
}
5688+
} elseif ($node->getAttribute(PregReplaceCallbackArgVisitor::ATTRIBUTE_NAME) instanceof Node\Expr) {
5689+
$pregFlagsExpr = $node->getAttribute(PregReplaceCallbackArgVisitor::ATTRIBUTE_NAME);
5690+
$flagsType = $this->getType($pregFlagsExpr);
5691+
if ($flagsType instanceof ConstantIntegerType) {
5692+
$flags = $flagsType->getValue();
5693+
$offsetCapture = ($flags & PREG_OFFSET_CAPTURE) !== 0;
5694+
$unmatchedAsNull = ($flags & PREG_UNMATCHED_AS_NULL) !== 0;
5695+
5696+
$matchValueType = new StringType();
5697+
if ($unmatchedAsNull) {
5698+
$matchValueType = TypeCombinator::addNull($matchValueType);
5699+
}
5700+
if ($offsetCapture) {
5701+
$matchValueType = new ConstantArrayType(
5702+
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
5703+
[$matchValueType, IntegerRangeType::fromInterval(-1, null)],
5704+
[2],
5705+
isList: TrinaryLogic::createYes(),
5706+
);
5707+
}
5708+
5709+
$callableParameters = [
5710+
new DummyParameter(
5711+
'matches',
5712+
new ArrayType(new UnionType([new IntegerType(), new StringType()]), $matchValueType),
5713+
false,
5714+
PassedByReference::createNo(),
5715+
false,
5716+
null,
5717+
),
5718+
];
5719+
}
56845720
} else {
56855721
$inFunctionCallsStackCount = count($this->inFunctionCallsStack);
56865722
if ($inFunctionCallsStackCount > 0) {

src/Analyser/NodeScopeResolver.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@
133133
use PHPStan\Parser\ImmediatelyInvokedClosureVisitor;
134134
use PHPStan\Parser\LineAttributesVisitor;
135135
use PHPStan\Parser\Parser;
136+
use PHPStan\Parser\PregReplaceCallbackArgVisitor;
136137
use PHPStan\Parser\ReversePipeTransformerVisitor;
137138
use PHPStan\Php\PhpVersion;
138139
use PHPStan\PhpDoc\PhpDocInheritanceResolver;
@@ -156,6 +157,7 @@
156157
use PHPStan\Reflection\ParameterReflection;
157158
use PHPStan\Reflection\ParametersAcceptor;
158159
use PHPStan\Reflection\ParametersAcceptorSelector;
160+
use PHPStan\Reflection\PassedByReference;
159161
use PHPStan\Reflection\Php\PhpFunctionFromParserNodeReflection;
160162
use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection;
161163
use PHPStan\Reflection\Php\PhpMethodReflection;
@@ -229,6 +231,8 @@
229231
use function trim;
230232
use function usort;
231233
use const PHP_VERSION_ID;
234+
use const PREG_OFFSET_CAPTURE;
235+
use const PREG_UNMATCHED_AS_NULL;
232236
use const SORT_NUMERIC;
233237

234238
#[AutowiredService]
@@ -5340,6 +5344,42 @@ public function createCallableParameters(Scope $scope, Expr $closureExpr, ?array
53405344
}
53415345
}
53425346

5347+
if ($callableParameters === null) {
5348+
$pregFlagsExpr = $closureExpr->getAttribute(PregReplaceCallbackArgVisitor::ATTRIBUTE_NAME);
5349+
if ($pregFlagsExpr instanceof Node\Expr) {
5350+
$flagsType = $scope->getType($pregFlagsExpr);
5351+
if ($flagsType instanceof ConstantIntegerType) {
5352+
$flags = $flagsType->getValue();
5353+
$offsetCapture = ($flags & PREG_OFFSET_CAPTURE) !== 0;
5354+
$unmatchedAsNull = ($flags & PREG_UNMATCHED_AS_NULL) !== 0;
5355+
5356+
$matchValueType = new StringType();
5357+
if ($unmatchedAsNull) {
5358+
$matchValueType = TypeCombinator::addNull($matchValueType);
5359+
}
5360+
if ($offsetCapture) {
5361+
$matchValueType = new ConstantArrayType(
5362+
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
5363+
[$matchValueType, IntegerRangeType::fromInterval(-1, null)],
5364+
[2],
5365+
isList: TrinaryLogic::createYes(),
5366+
);
5367+
}
5368+
5369+
$callableParameters = [
5370+
new NativeParameterReflection(
5371+
'matches',
5372+
false,
5373+
new ArrayType(new UnionType([new IntegerType(), new StringType()]), $matchValueType),
5374+
PassedByReference::createNo(),
5375+
false,
5376+
null,
5377+
),
5378+
];
5379+
}
5380+
}
5381+
}
5382+
53435383
return $callableParameters;
53445384
}
53455385

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Parser;
4+
5+
use Override;
6+
use PhpParser\Node;
7+
use PhpParser\NodeVisitorAbstract;
8+
use PHPStan\DependencyInjection\AutowiredService;
9+
10+
#[AutowiredService]
11+
final class PregReplaceCallbackArgVisitor extends NodeVisitorAbstract
12+
{
13+
14+
public const ATTRIBUTE_NAME = 'pregReplaceCallbackFlags';
15+
16+
#[Override]
17+
public function enterNode(Node $node): ?Node
18+
{
19+
if (!$node instanceof Node\Expr\FuncCall || !$node->name instanceof Node\Name || $node->isFirstClassCallable()) {
20+
return null;
21+
}
22+
23+
$functionName = $node->name->toLowerString();
24+
25+
if ($functionName === 'preg_replace_callback') {
26+
$args = $node->getArgs();
27+
if (isset($args[1]) && isset($args[5])) {
28+
$args[1]->setAttribute(self::ATTRIBUTE_NAME, $args[5]->value);
29+
}
30+
} elseif ($functionName === 'preg_replace_callback_array') {
31+
$args = $node->getArgs();
32+
if (!isset($args[0]) || !isset($args[4])) {
33+
return null;
34+
}
35+
$args[0]->setAttribute(self::ATTRIBUTE_NAME, $args[4]->value);
36+
37+
// Also set the attribute on closures/arrow functions inside the array values
38+
$arrayArg = $args[0]->value;
39+
if ($arrayArg instanceof Node\Expr\Array_) {
40+
foreach ($arrayArg->items as $item) {
41+
if (!($item->value instanceof Node\Expr\Closure) && !($item->value instanceof Node\Expr\ArrowFunction)) {
42+
continue;
43+
}
44+
45+
$item->value->setAttribute(self::ATTRIBUTE_NAME, $args[4]->value);
46+
}
47+
}
48+
}
49+
50+
return null;
51+
}
52+
53+
}

src/Reflection/ParametersAcceptorSelector.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use PHPStan\Parser\CurlSetOptArgVisitor;
1818
use PHPStan\Parser\CurlSetOptArrayArgVisitor;
1919
use PHPStan\Parser\ImplodeArgVisitor;
20+
use PHPStan\Parser\PregReplaceCallbackArgVisitor;
2021
use PHPStan\Reflection\Callables\CallableParametersAcceptor;
2122
use PHPStan\Reflection\Native\NativeParameterReflection;
2223
use PHPStan\Reflection\Php\DummyParameter;
@@ -27,10 +28,12 @@
2728
use PHPStan\Type\ArrayType;
2829
use PHPStan\Type\BooleanType;
2930
use PHPStan\Type\CallableType;
31+
use PHPStan\Type\Constant\ConstantArrayType;
3032
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
3133
use PHPStan\Type\Constant\ConstantIntegerType;
3234
use PHPStan\Type\Generic\TemplateTypeMap;
3335
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
36+
use PHPStan\Type\IntegerRangeType;
3437
use PHPStan\Type\IntegerType;
3538
use PHPStan\Type\IntersectionType;
3639
use PHPStan\Type\MixedType;
@@ -59,6 +62,8 @@
5962
use const ARRAY_FILTER_USE_KEY;
6063
use const CURLOPT_SHARE;
6164
use const CURLOPT_SSL_VERIFYHOST;
65+
use const PREG_OFFSET_CAPTURE;
66+
use const PREG_UNMATCHED_AS_NULL;
6267

6368
/**
6469
* @api
@@ -372,6 +377,82 @@ public static function selectFromArgs(
372377
}
373378
}
374379

380+
foreach ([1, 0] as $pregArgIndex) {
381+
if (!isset($args[$pregArgIndex])) {
382+
continue;
383+
}
384+
385+
$pregFlagsExpr = $args[$pregArgIndex]->getAttribute(PregReplaceCallbackArgVisitor::ATTRIBUTE_NAME);
386+
if (!$pregFlagsExpr instanceof Node\Expr) {
387+
continue;
388+
}
389+
390+
$flagsType = $scope->getType($pregFlagsExpr);
391+
if (!$flagsType instanceof ConstantIntegerType) {
392+
break;
393+
}
394+
395+
$flags = $flagsType->getValue();
396+
$offsetCapture = ($flags & PREG_OFFSET_CAPTURE) !== 0;
397+
$unmatchedAsNull = ($flags & PREG_UNMATCHED_AS_NULL) !== 0;
398+
399+
if (!$offsetCapture && !$unmatchedAsNull) {
400+
break;
401+
}
402+
403+
$matchValueType = new StringType();
404+
if ($unmatchedAsNull) {
405+
$matchValueType = TypeCombinator::addNull($matchValueType);
406+
}
407+
if ($offsetCapture) {
408+
$matchValueType = new ConstantArrayType(
409+
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
410+
[$matchValueType, IntegerRangeType::fromInterval(-1, null)],
411+
[2],
412+
isList: TrinaryLogic::createYes(),
413+
);
414+
}
415+
416+
$callbackParameter = new DummyParameter(
417+
'matches',
418+
new ArrayType(new UnionType([new IntegerType(), new StringType()]), $matchValueType),
419+
optional: false,
420+
passedByReference: PassedByReference::createNo(),
421+
variadic: false,
422+
defaultValue: null,
423+
);
424+
$callbackType = new CallableType([$callbackParameter], new StringType(), false);
425+
426+
$acceptor = $parametersAcceptors[0];
427+
$parameters = $acceptor->getParameters();
428+
if (isset($parameters[$pregArgIndex])) {
429+
$pregReplaceCallbackIsArray = $pregArgIndex === 0;
430+
$newParamType = $pregReplaceCallbackIsArray
431+
? new ArrayType(new StringType(), $callbackType)
432+
: $callbackType;
433+
$parameters[$pregArgIndex] = new NativeParameterReflection(
434+
$parameters[$pregArgIndex]->getName(),
435+
$parameters[$pregArgIndex]->isOptional(),
436+
$newParamType,
437+
$parameters[$pregArgIndex]->passedByReference(),
438+
$parameters[$pregArgIndex]->isVariadic(),
439+
$parameters[$pregArgIndex]->getDefaultValue(),
440+
);
441+
$parametersAcceptors = [
442+
new FunctionVariant(
443+
$acceptor->getTemplateTypeMap(),
444+
$acceptor->getResolvedTemplateTypeMap(),
445+
$parameters,
446+
$acceptor->isVariadic(),
447+
$acceptor->getReturnType(),
448+
$acceptor instanceof ExtendedParametersAcceptor ? $acceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(),
449+
),
450+
];
451+
}
452+
453+
break;
454+
}
455+
375456
$closureBindToVar = $args[0]->getAttribute(ClosureBindToVarVisitor::ATTRIBUTE_NAME);
376457
if (
377458
$closureBindToVar instanceof Node\Expr\Variable
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php // lint >= 7.4
2+
3+
namespace Bug10396;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
// preg_replace_callback_array - without flags
8+
function testCallbackArrayNoFlags(string $s): void {
9+
preg_replace_callback_array(
10+
[
11+
'/(foo)(bar)/' => function ($matches) {
12+
assertType("mixed", $matches); // no flags, no attribute set
13+
return '';
14+
},
15+
],
16+
$s
17+
);
18+
}
19+
20+
// preg_replace_callback_array with PREG_UNMATCHED_AS_NULL
21+
function testCallbackArrayUnmatchedAsNull(string $s): void {
22+
preg_replace_callback_array(
23+
[
24+
'/(foo)?(bar)/' => function ($matches) {
25+
assertType("array<int|string, string|null>", $matches);
26+
return '';
27+
},
28+
],
29+
$s,
30+
-1,
31+
$count,
32+
PREG_UNMATCHED_AS_NULL
33+
);
34+
}
35+
36+
// preg_replace_callback_array with PREG_OFFSET_CAPTURE
37+
function testCallbackArrayOffsetCapture(string $s): void {
38+
preg_replace_callback_array(
39+
[
40+
'/(foo)(bar)/' => function ($matches) {
41+
assertType("array<int|string, array{string, int<-1, max>}>", $matches);
42+
return '';
43+
},
44+
],
45+
$s,
46+
-1,
47+
$count,
48+
PREG_OFFSET_CAPTURE
49+
);
50+
}
51+
52+
// preg_replace_callback_array with both flags
53+
function testCallbackArrayBothFlags(string $s): void {
54+
preg_replace_callback_array(
55+
[
56+
'/(foo)?(bar)/' => function ($matches) {
57+
assertType("array<int|string, array{string|null, int<-1, max>}>", $matches);
58+
return '';
59+
},
60+
],
61+
$s,
62+
-1,
63+
$count,
64+
PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL
65+
);
66+
}
67+
68+
// preg_replace_callback_array with arrow function
69+
function testCallbackArrayArrowFunction(string $s): void {
70+
preg_replace_callback_array(
71+
[
72+
'/(foo)(bar)/' => fn ($matches) => assertType("array<int|string, array{string, int<-1, max>}>", $matches) ? '' : '',
73+
],
74+
$s,
75+
-1,
76+
$count,
77+
PREG_OFFSET_CAPTURE
78+
);
79+
}

tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,11 @@ public function testPregReplaceCallback(): void
742742
]);
743743
}
744744

745+
public function testBug10396(): void
746+
{
747+
$this->analyse([__DIR__ . '/data/bug-10396.php'], []);
748+
}
749+
745750
public function testMbEregReplaceCallback(): void
746751
{
747752
$this->analyse([__DIR__ . '/data/mb_ereg_replace_callback.php'], [

0 commit comments

Comments
 (0)