Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
use PHPStan\Parser\ImmediatelyInvokedClosureVisitor;
use PHPStan\Parser\NewAssignedToPropertyVisitor;
use PHPStan\Parser\Parser;
use PHPStan\Parser\PregReplaceCallbackArgVisitor;
use PHPStan\Php\PhpVersion;
use PHPStan\Php\PhpVersionFactory;
use PHPStan\Php\PhpVersions;
Expand Down Expand Up @@ -102,6 +103,7 @@
use PHPStan\Type\BooleanType;
use PHPStan\Type\ClosureType;
use PHPStan\Type\ConditionalTypeForParameter;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\Constant\ConstantFloatType;
Expand Down Expand Up @@ -177,6 +179,8 @@
use const PHP_INT_MAX;
use const PHP_INT_MIN;
use const PHP_VERSION_ID;
use const PREG_OFFSET_CAPTURE;
use const PREG_UNMATCHED_AS_NULL;

class MutatingScope implements Scope, NodeCallbackInvoker
{
Expand Down Expand Up @@ -5681,6 +5685,38 @@ private function getClosureType(Expr\Closure|Expr\ArrowFunction $node): ClosureT
foreach ($immediatelyInvokedArgs as $immediatelyInvokedArg) {
$callableParameters[] = new DummyParameter('item', $this->getType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null);
}
} elseif ($node->getAttribute(PregReplaceCallbackArgVisitor::ATTRIBUTE_NAME) instanceof Node\Expr) {
$pregFlagsExpr = $node->getAttribute(PregReplaceCallbackArgVisitor::ATTRIBUTE_NAME);
$flagsType = $this->getType($pregFlagsExpr);
if ($flagsType instanceof ConstantIntegerType) {
$flags = $flagsType->getValue();
$offsetCapture = ($flags & PREG_OFFSET_CAPTURE) !== 0;
$unmatchedAsNull = ($flags & PREG_UNMATCHED_AS_NULL) !== 0;

$matchValueType = new StringType();
if ($unmatchedAsNull) {
$matchValueType = TypeCombinator::addNull($matchValueType);
}
if ($offsetCapture) {
$matchValueType = new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[$matchValueType, IntegerRangeType::fromInterval(-1, null)],
[2],
isList: TrinaryLogic::createYes(),
);
}

$callableParameters = [
new DummyParameter(
'matches',
new ArrayType(new UnionType([new IntegerType(), new StringType()]), $matchValueType),
false,
PassedByReference::createNo(),
false,
null,
),
];
}
} else {
$inFunctionCallsStackCount = count($this->inFunctionCallsStack);
if ($inFunctionCallsStackCount > 0) {
Expand Down
40 changes: 40 additions & 0 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
use PHPStan\Parser\ImmediatelyInvokedClosureVisitor;
use PHPStan\Parser\LineAttributesVisitor;
use PHPStan\Parser\Parser;
use PHPStan\Parser\PregReplaceCallbackArgVisitor;
use PHPStan\Parser\ReversePipeTransformerVisitor;
use PHPStan\Php\PhpVersion;
use PHPStan\PhpDoc\PhpDocInheritanceResolver;
Expand All @@ -156,6 +157,7 @@
use PHPStan\Reflection\ParameterReflection;
use PHPStan\Reflection\ParametersAcceptor;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Reflection\PassedByReference;
use PHPStan\Reflection\Php\PhpFunctionFromParserNodeReflection;
use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection;
use PHPStan\Reflection\Php\PhpMethodReflection;
Expand Down Expand Up @@ -229,6 +231,8 @@
use function trim;
use function usort;
use const PHP_VERSION_ID;
use const PREG_OFFSET_CAPTURE;
use const PREG_UNMATCHED_AS_NULL;
use const SORT_NUMERIC;

#[AutowiredService]
Expand Down Expand Up @@ -5340,6 +5344,42 @@ public function createCallableParameters(Scope $scope, Expr $closureExpr, ?array
}
}

if ($callableParameters === null) {
$pregFlagsExpr = $closureExpr->getAttribute(PregReplaceCallbackArgVisitor::ATTRIBUTE_NAME);
if ($pregFlagsExpr instanceof Node\Expr) {
$flagsType = $scope->getType($pregFlagsExpr);
if ($flagsType instanceof ConstantIntegerType) {
$flags = $flagsType->getValue();
$offsetCapture = ($flags & PREG_OFFSET_CAPTURE) !== 0;
$unmatchedAsNull = ($flags & PREG_UNMATCHED_AS_NULL) !== 0;

$matchValueType = new StringType();
if ($unmatchedAsNull) {
$matchValueType = TypeCombinator::addNull($matchValueType);
}
if ($offsetCapture) {
$matchValueType = new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[$matchValueType, IntegerRangeType::fromInterval(-1, null)],
[2],
isList: TrinaryLogic::createYes(),
);
}

$callableParameters = [
new NativeParameterReflection(
'matches',
false,
new ArrayType(new UnionType([new IntegerType(), new StringType()]), $matchValueType),
PassedByReference::createNo(),
false,
null,
),
];
}
}
}

return $callableParameters;
}

Expand Down
53 changes: 53 additions & 0 deletions src/Parser/PregReplaceCallbackArgVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php declare(strict_types = 1);

namespace PHPStan\Parser;

use Override;
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
use PHPStan\DependencyInjection\AutowiredService;

#[AutowiredService]
final class PregReplaceCallbackArgVisitor extends NodeVisitorAbstract
{

public const ATTRIBUTE_NAME = 'pregReplaceCallbackFlags';

#[Override]
public function enterNode(Node $node): ?Node
{
if (!$node instanceof Node\Expr\FuncCall || !$node->name instanceof Node\Name || $node->isFirstClassCallable()) {
return null;
}

$functionName = $node->name->toLowerString();

if ($functionName === 'preg_replace_callback') {
$args = $node->getArgs();
if (isset($args[1]) && isset($args[5])) {
$args[1]->setAttribute(self::ATTRIBUTE_NAME, $args[5]->value);
}
} elseif ($functionName === 'preg_replace_callback_array') {
$args = $node->getArgs();
if (!isset($args[0]) || !isset($args[4])) {
return null;
}
$args[0]->setAttribute(self::ATTRIBUTE_NAME, $args[4]->value);

// Also set the attribute on closures/arrow functions inside the array values
$arrayArg = $args[0]->value;
if ($arrayArg instanceof Node\Expr\Array_) {
foreach ($arrayArg->items as $item) {
if (!($item->value instanceof Node\Expr\Closure) && !($item->value instanceof Node\Expr\ArrowFunction)) {
continue;
}

$item->value->setAttribute(self::ATTRIBUTE_NAME, $args[4]->value);
}
}
}

return null;
}

}
81 changes: 81 additions & 0 deletions src/Reflection/ParametersAcceptorSelector.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use PHPStan\Parser\CurlSetOptArgVisitor;
use PHPStan\Parser\CurlSetOptArrayArgVisitor;
use PHPStan\Parser\ImplodeArgVisitor;
use PHPStan\Parser\PregReplaceCallbackArgVisitor;
use PHPStan\Reflection\Callables\CallableParametersAcceptor;
use PHPStan\Reflection\Native\NativeParameterReflection;
use PHPStan\Reflection\Php\DummyParameter;
Expand All @@ -27,10 +28,12 @@
use PHPStan\Type\ArrayType;
use PHPStan\Type\BooleanType;
use PHPStan\Type\CallableType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Generic\TemplateTypeMap;
use PHPStan\Type\Generic\TemplateTypeVarianceMap;
use PHPStan\Type\IntegerRangeType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\MixedType;
Expand Down Expand Up @@ -59,6 +62,8 @@
use const ARRAY_FILTER_USE_KEY;
use const CURLOPT_SHARE;
use const CURLOPT_SSL_VERIFYHOST;
use const PREG_OFFSET_CAPTURE;
use const PREG_UNMATCHED_AS_NULL;

/**
* @api
Expand Down Expand Up @@ -372,6 +377,82 @@ public static function selectFromArgs(
}
}

foreach ([1, 0] as $pregArgIndex) {
if (!isset($args[$pregArgIndex])) {
continue;
}

$pregFlagsExpr = $args[$pregArgIndex]->getAttribute(PregReplaceCallbackArgVisitor::ATTRIBUTE_NAME);
if (!$pregFlagsExpr instanceof Node\Expr) {
continue;
}

$flagsType = $scope->getType($pregFlagsExpr);
if (!$flagsType instanceof ConstantIntegerType) {
break;
}

$flags = $flagsType->getValue();
$offsetCapture = ($flags & PREG_OFFSET_CAPTURE) !== 0;
$unmatchedAsNull = ($flags & PREG_UNMATCHED_AS_NULL) !== 0;

if (!$offsetCapture && !$unmatchedAsNull) {
break;
}

$matchValueType = new StringType();
if ($unmatchedAsNull) {
$matchValueType = TypeCombinator::addNull($matchValueType);
}
if ($offsetCapture) {
$matchValueType = new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[$matchValueType, IntegerRangeType::fromInterval(-1, null)],
[2],
isList: TrinaryLogic::createYes(),
);
}

$callbackParameter = new DummyParameter(
'matches',
new ArrayType(new UnionType([new IntegerType(), new StringType()]), $matchValueType),
optional: false,
passedByReference: PassedByReference::createNo(),
variadic: false,
defaultValue: null,
);
$callbackType = new CallableType([$callbackParameter], new StringType(), false);

$acceptor = $parametersAcceptors[0];
$parameters = $acceptor->getParameters();
if (isset($parameters[$pregArgIndex])) {
$pregReplaceCallbackIsArray = $pregArgIndex === 0;
$newParamType = $pregReplaceCallbackIsArray
? new ArrayType(new StringType(), $callbackType)
: $callbackType;
$parameters[$pregArgIndex] = new NativeParameterReflection(
$parameters[$pregArgIndex]->getName(),
$parameters[$pregArgIndex]->isOptional(),
$newParamType,
$parameters[$pregArgIndex]->passedByReference(),
$parameters[$pregArgIndex]->isVariadic(),
$parameters[$pregArgIndex]->getDefaultValue(),
);
$parametersAcceptors = [
new FunctionVariant(
$acceptor->getTemplateTypeMap(),
$acceptor->getResolvedTemplateTypeMap(),
$parameters,
$acceptor->isVariadic(),
$acceptor->getReturnType(),
$acceptor instanceof ExtendedParametersAcceptor ? $acceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(),
),
];
}

break;
}

$closureBindToVar = $args[0]->getAttribute(ClosureBindToVarVisitor::ATTRIBUTE_NAME);
if (
$closureBindToVar instanceof Node\Expr\Variable
Expand Down
79 changes: 79 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-10396.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php // lint >= 7.4

namespace Bug10396;

use function PHPStan\Testing\assertType;

// preg_replace_callback_array - without flags
function testCallbackArrayNoFlags(string $s): void {
preg_replace_callback_array(
[
'/(foo)(bar)/' => function ($matches) {
assertType("mixed", $matches); // no flags, no attribute set
return '';
},
],
$s
);
}

// preg_replace_callback_array with PREG_UNMATCHED_AS_NULL
function testCallbackArrayUnmatchedAsNull(string $s): void {
preg_replace_callback_array(
[
'/(foo)?(bar)/' => function ($matches) {
assertType("array<int|string, string|null>", $matches);
return '';
},
],
$s,
-1,
$count,
PREG_UNMATCHED_AS_NULL
);
}

// preg_replace_callback_array with PREG_OFFSET_CAPTURE
function testCallbackArrayOffsetCapture(string $s): void {
preg_replace_callback_array(
[
'/(foo)(bar)/' => function ($matches) {
assertType("array<int|string, array{string, int<-1, max>}>", $matches);
return '';
},
],
$s,
-1,
$count,
PREG_OFFSET_CAPTURE
);
}

// preg_replace_callback_array with both flags
function testCallbackArrayBothFlags(string $s): void {
preg_replace_callback_array(
[
'/(foo)?(bar)/' => function ($matches) {
assertType("array<int|string, array{string|null, int<-1, max>}>", $matches);
return '';
},
],
$s,
-1,
$count,
PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL
);
}

// preg_replace_callback_array with arrow function
function testCallbackArrayArrowFunction(string $s): void {
preg_replace_callback_array(
[
'/(foo)(bar)/' => fn ($matches) => assertType("array<int|string, array{string, int<-1, max>}>", $matches) ? '' : '',
],
$s,
-1,
$count,
PREG_OFFSET_CAPTURE
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,11 @@ public function testPregReplaceCallback(): void
]);
}

public function testBug10396(): void
{
$this->analyse([__DIR__ . '/data/bug-10396.php'], []);
}

public function testMbEregReplaceCallback(): void
{
$this->analyse([__DIR__ . '/data/mb_ereg_replace_callback.php'], [
Expand Down
Loading
Loading