Skip to content
Merged
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
11 changes: 10 additions & 1 deletion src/Type/Php/PregMatchParameterOutTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\ParameterReflection;
use PHPStan\TrinaryLogic;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\FunctionParameterOutTypeExtension;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function in_array;

final class PregMatchParameterOutTypeExtension implements FunctionParameterOutTypeExtension
Expand Down Expand Up @@ -50,7 +52,14 @@ public function getParameterOutTypeFromFunctionCall(FunctionReflection $function
}

if ($functionReflection->getName() === 'Safe\preg_match') {
return $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope);
$matchedType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createYes(), $scope);
if ($matchedType === null) {
return null;
}
return TypeCombinator::union(
ConstantArrayTypeBuilder::createEmpty()->getArray(),
$matchedType,
);
}
return $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope);
}
Expand Down
12 changes: 10 additions & 2 deletions src/Type/Php/PregMatchTypeSpecifyingExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,18 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n
$flagsType = $scope->getType($flagsArg->value);
}

if ($context->true() && $context->falsey()) {
$wasMatched = TrinaryLogic::createMaybe();
} elseif ($context->true()) {
$wasMatched = TrinaryLogic::createYes();
} else {
$wasMatched = TrinaryLogic::createNo();
}

if ($functionReflection->getName() === 'Safe\preg_match') {
$matchedType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope);
$matchedType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, $wasMatched, $scope);
} else {
$matchedType = $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope);
$matchedType = $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, $wasMatched, $scope);
}
if ($matchedType === null) {
return new SpecifiedTypes();
Expand Down
1 change: 1 addition & 0 deletions tests/Type/Php/TypeAssertionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public static function dataFileAsserts(): iterable
yield from self::gatherAssertTypes(__DIR__ . '/data/preg_match_checked.php');
yield from self::gatherAssertTypes(__DIR__ . '/data/preg_replace_return.php');
yield from self::gatherAssertTypes(__DIR__ . '/data/json_decode_return.php');
yield from self::gatherAssertTypes(__DIR__ . '/data/preg_match_identity_check.php');
}

/**
Expand Down
31 changes: 31 additions & 0 deletions tests/Type/Php/data/preg_match_identity_check.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace TheCodingMachine\Safe\PHPStan\Type\Php\data;

use function Safe\preg_match;

// PHPStan bug report: Safe\preg_match with "1 ===" identity check does NOT narrow $matches,
// even though plain truthy if (preg_match(...)) works correctly.
//
// Root cause: PHPStan's TypeSpecifier.php has a hardcoded literal-name check for 'preg_match'
// in resolveNormalizedIdentical(). This triggers specifyTypesInCondition() on the FuncCall, but
// the FuncCall is wrapped in AlwaysRememberedExpr at that point, so FunctionTypeSpecifyingExtension
// is never called and $matches is not narrowed.
//
// This affects ANY extension-based preg_match wrapper (e.g. Safe\preg_match) — not only native.

$pattern = '/H(.)ll(o) (World)?/';
$string = 'Hello World';

// This is what SHOULD happen (same as the truthy check in preg_match_checked.php):
$expectedType = "array{0: non-falsy-string, 1: non-empty-string, 2: 'o', 3?: 'World'}";

// BUG: $matches is NOT narrowed — actual type is array{}|array{...} instead of array{...}
if (1 === preg_match($pattern, $string, $matches)) {
\PHPStan\Testing\assertSuperType($expectedType, $matches);
}

// BUG: same issue via FQCN
if (1 === \Safe\preg_match($pattern, $string, $matches2)) {
\PHPStan\Testing\assertSuperType($expectedType, $matches2);
}