Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
3 changes: 3 additions & 0 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -4141,6 +4141,7 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto
if (
$stmt->expr instanceof FuncCall
&& $stmt->expr->name instanceof Name
&& !$stmt->expr->isFirstClassCallable()
&& $stmt->expr->name->toLowerString() === 'array_keys'
&& $stmt->valueVar instanceof Variable
) {
Expand Down Expand Up @@ -4816,6 +4817,7 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin
&& $lastCondExpr->left instanceof Variable
&& $lastCondExpr->right instanceof FuncCall
&& $lastCondExpr->right->name instanceof Name
&& !$lastCondExpr->right->isFirstClassCallable()
&& in_array($lastCondExpr->right->name->toLowerString(), ['count', 'sizeof'], true)
&& count($lastCondExpr->right->getArgs()) > 0
&& $lastCondExpr->right->getArgs()[0]->value instanceof Variable
Expand All @@ -4840,6 +4842,7 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin
&& $lastCondExpr->right instanceof Variable
&& $lastCondExpr->left instanceof FuncCall
&& $lastCondExpr->left->name instanceof Name
&& !$lastCondExpr->left->isFirstClassCallable()
&& in_array($lastCondExpr->left->name->toLowerString(), ['count', 'sizeof'], true)
&& count($lastCondExpr->left->getArgs()) > 0
&& $lastCondExpr->left->getArgs()[0]->value instanceof Variable
Expand Down
21 changes: 20 additions & 1 deletion src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ public function specifyTypesInCondition(
if (
$expr->left instanceof FuncCall
&& $expr->left->name instanceof Name
&& !$expr->left->isFirstClassCallable()
&& in_array(strtolower((string) $expr->left->name), ['count', 'sizeof', 'strlen', 'mb_strlen', 'preg_match'], true)
&& count($expr->left->getArgs()) >= 1
&& (
Expand Down Expand Up @@ -275,6 +276,7 @@ public function specifyTypesInCondition(
!$context->null()
&& $expr->right instanceof FuncCall
&& $expr->right->name instanceof Name
&& !$expr->right->isFirstClassCallable()
&& in_array(strtolower((string) $expr->right->name), ['count', 'sizeof'], true)
&& count($expr->right->getArgs()) >= 1
&& $leftType->isInteger()->yes()
Expand Down Expand Up @@ -378,6 +380,7 @@ public function specifyTypesInCondition(
&& $expr->right instanceof Expr\BinaryOp\Minus
&& $expr->right->left instanceof FuncCall
&& $expr->right->left->name instanceof Name
&& !$expr->right->left->isFirstClassCallable()
&& in_array(strtolower((string) $expr->right->left->name), ['count', 'sizeof'], true)
&& count($expr->right->left->getArgs()) >= 1
// constant offsets are handled via HasOffsetType/HasOffsetValueType
Expand All @@ -404,6 +407,7 @@ public function specifyTypesInCondition(
!$context->null()
&& $expr->right instanceof FuncCall
&& $expr->right->name instanceof Name
&& !$expr->right->isFirstClassCallable()
&& in_array(strtolower((string) $expr->right->name), ['preg_match'], true)
&& count($expr->right->getArgs()) >= 3
&& (
Expand All @@ -421,6 +425,7 @@ public function specifyTypesInCondition(
!$context->null()
&& $expr->right instanceof FuncCall
&& $expr->right->name instanceof Name
&& !$expr->right->isFirstClassCallable()
&& in_array(strtolower((string) $expr->right->name), ['strlen', 'mb_strlen'], true)
Comment thread
staabm marked this conversation as resolved.
&& count($expr->right->getArgs()) === 1
&& $leftType->isInteger()->yes()
Expand Down Expand Up @@ -825,6 +830,7 @@ public function specifyTypesInCondition(
if (
$expr->expr instanceof FuncCall
&& $expr->expr->name instanceof Name
&& !$expr->expr->isFirstClassCallable()
&& in_array($expr->expr->name->toLowerString(), ['array_key_first', 'array_key_last'], true)
&& count($expr->expr->getArgs()) >= 1
) {
Expand Down Expand Up @@ -881,6 +887,7 @@ public function specifyTypesInCondition(
if (
$expr->expr instanceof FuncCall
&& $expr->expr->name instanceof Name
&& !$expr->expr->isFirstClassCallable()
&& in_array($expr->expr->name->toLowerString(), ['array_rand'], true)
&& count($expr->expr->getArgs()) >= 1
) {
Expand Down Expand Up @@ -911,6 +918,7 @@ public function specifyTypesInCondition(
$expr->expr instanceof Expr\BinaryOp\Minus
&& $expr->expr->left instanceof FuncCall
&& $expr->expr->left->name instanceof Name
&& !$expr->expr->left->isFirstClassCallable()
&& $expr->expr->right instanceof Node\Scalar\Int_
&& $expr->expr->right->value === 1
&& in_array($expr->expr->left->name->toLowerString(), ['count', 'sizeof'], true)
Expand Down Expand Up @@ -938,6 +946,7 @@ public function specifyTypesInCondition(
if (
$expr->expr instanceof FuncCall
&& $expr->expr->name instanceof Name
&& !$expr->expr->isFirstClassCallable()
&& $expr->expr->name->toLowerString() === 'array_search'
&& count($expr->expr->getArgs()) >= 2
) {
Expand Down Expand Up @@ -1596,6 +1605,7 @@ private function specifyTypesForConstantStringBinaryExpression(
if (
$exprNode instanceof FuncCall
&& $exprNode->name instanceof Name
&& !$exprNode->isFirstClassCallable()
&& strtolower($exprNode->name->toString()) === 'gettype'
&& isset($exprNode->getArgs()[0])
) {
Expand Down Expand Up @@ -1636,6 +1646,7 @@ private function specifyTypesForConstantStringBinaryExpression(
$context->true()
&& $exprNode instanceof FuncCall
&& $exprNode->name instanceof Name
&& !$exprNode->isFirstClassCallable()
&& strtolower((string) $exprNode->name) === 'get_parent_class'
&& isset($exprNode->getArgs()[0])
) {
Expand Down Expand Up @@ -1673,6 +1684,7 @@ private function specifyTypesForConstantStringBinaryExpression(
$context->false()
&& $exprNode instanceof FuncCall
&& $exprNode->name instanceof Name
&& !$exprNode->isFirstClassCallable()
&& in_array(strtolower((string) $exprNode->name), [
Comment thread
staabm marked this conversation as resolved.
'trim', 'ltrim', 'rtrim', 'chop',
'mb_trim', 'mb_ltrim', 'mb_rtrim',
Expand Down Expand Up @@ -2750,6 +2762,7 @@ public function resolveEqual(Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecif
if (
$exprNode instanceof FuncCall
&& $exprNode->name instanceof Name
&& !$exprNode->isFirstClassCallable()
&& in_array(strtolower($exprNode->name->toString()), ['gettype', 'get_class', 'get_debug_type'], true)
Comment thread
staabm marked this conversation as resolved.
&& isset($exprNode->getArgs()[0])
&& $constantType->isString()->yes()
Expand Down Expand Up @@ -2897,6 +2910,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
if (
!$context->null()
&& $unwrappedLeftExpr instanceof FuncCall
&& !$unwrappedLeftExpr->isFirstClassCallable()
&& count($unwrappedLeftExpr->getArgs()) >= 1
&& $unwrappedLeftExpr->name instanceof Name
&& in_array(strtolower((string) $unwrappedLeftExpr->name), ['count', 'sizeof'], true)
Expand All @@ -2907,6 +2921,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
$context->true()
&& $unwrappedRightExpr instanceof FuncCall
&& $unwrappedRightExpr->name instanceof Name
&& !$unwrappedRightExpr->isFirstClassCallable()
&& in_array($unwrappedRightExpr->name->toLowerString(), ['count', 'sizeof'], true)
&& count($unwrappedRightExpr->getArgs()) >= 1
) {
Expand Down Expand Up @@ -2981,9 +2996,10 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
if (
!$context->null()
&& $unwrappedLeftExpr instanceof FuncCall
&& count($unwrappedLeftExpr->getArgs()) === 1
&& $unwrappedLeftExpr->name instanceof Name
&& !$unwrappedLeftExpr->isFirstClassCallable()
&& in_array(strtolower((string) $unwrappedLeftExpr->name), ['strlen', 'mb_strlen'], true)
&& count($unwrappedLeftExpr->getArgs()) === 1
&& $rightType->isInteger()->yes()
) {
if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) {
Expand Down Expand Up @@ -3019,6 +3035,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
if (
$unwrappedLeftExpr instanceof FuncCall
&& $unwrappedLeftExpr->name instanceof Name
&& !$unwrappedLeftExpr->isFirstClassCallable()
&& in_array($unwrappedLeftExpr->name->toLowerString(), ['array_key_first', 'array_key_last'], true)
&& isset($unwrappedLeftExpr->getArgs()[0])
&& $rightType->isNull()->yes()
Expand Down Expand Up @@ -3050,6 +3067,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
$context->true()
&& $unwrappedLeftExpr instanceof FuncCall
&& $unwrappedLeftExpr->name instanceof Name
&& !$unwrappedLeftExpr->isFirstClassCallable()
&& in_array(strtolower($unwrappedLeftExpr->name->toString()), ['get_class', 'get_debug_type'], true)
&& isset($unwrappedLeftExpr->getArgs()[0])
) {
Expand All @@ -3075,6 +3093,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
$context->truthy()
&& $unwrappedLeftExpr instanceof FuncCall
&& $unwrappedLeftExpr->name instanceof Name
&& !$unwrappedLeftExpr->isFirstClassCallable()
&& in_array(strtolower($unwrappedLeftExpr->name->toString()), [
'substr', 'strstr', 'stristr', 'strchr', 'strrchr', 'strtolower', 'strtoupper', 'ucfirst', 'lcfirst',
'mb_substr', 'mb_strstr', 'mb_stristr', 'mb_strchr', 'mb_strrchr', 'mb_strtolower', 'mb_strtoupper', 'mb_ucfirst', 'mb_lcfirst',
Expand Down
160 changes: 160 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14550.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<?php // lint >= 8.1

declare(strict_types = 1);

namespace Bug14550;

use function PHPStan\Testing\assertType;

// Standalone assignments trigger TypeSpecifier via NodeScopeResolver null-context call
function testArrayKeyFirstAssign(): void
{
$fn = array_key_first(...);
assertType('Closure(array): (int|string|null)', $fn);
}

function testArrayKeyLastAssign(): void
{
$fn = array_key_last(...);
assertType('Closure(array): (int|string|null)', $fn);
}

function testArrayRandAssign(): void
{
$fn = array_rand(...);
assertType('(Closure(non-empty-array): (int|string))|(Closure(non-empty-array, int<1, max>): (array<int, int|string>|int|string))', $fn);
}

function testCountMinusOneAssign(): void
{
$idx = count(...) - 1;
assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...));
}

// array_search guard needs true context, so it must be in a condition
function testArraySearchInCondition(): void
{
if ($key = array_search(...)) {
assertType('Closure(mixed, array, bool=): (int|string|false)', $key);
}
}

// Comparison guards in TypeSpecifier (Smaller/SmallerOrEqual)
function testCountInComparisons(): void
{
if (count(...) < 1) {}
if (0 < count(...)) {}
assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...));
}

function testSizeofInComparisons(): void
{
if (sizeof(...) < 1) {}
if (0 < sizeof(...)) {}
assertType('Closure(array|Countable, int=): int', sizeof(...));
}

function testCountMinusOneInComparison(): void
{
$i = 0;
if ($i < count(...) - 1) {}
assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...));
}

function testStrlenInComparisons(): void
{
if (strlen(...) < 1) {}
if (0 < strlen(...)) {}
assertType('Closure(string): int<0, max>', strlen(...));
}

function testMbStrlenInComparisons(): void
{
if (mb_strlen(...) < 1) {}
if (0 < mb_strlen(...)) {}
assertType('Closure(string, string|null=): int<0, max>', mb_strlen(...));
}

function testPregMatchInComparisons(): void
{
if (preg_match(...) < 1) {}
if (0 < preg_match(...)) {}
assertType('Closure(string, string, array<string>|null=, TFlags=, int=): (0|1|false)', preg_match(...));
}

// Identical/NotIdentical guards in resolveNormalizedIdentical
function testCountIdentical(): void
{
if (count(...) === 0) {}
assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...));
}

function testStrlenIdentical(): void
{
if (strlen(...) === 0) {}
if (mb_strlen(...) === 0) {}
assertType('Closure(string): int<0, max>', strlen(...));
}

function testArrayKeyFirstNullComparison(): void
{
if (array_key_first(...) !== null) {}
if (array_key_last(...) !== null) {}
assertType('Closure(array): (int|string|null)', array_key_first(...));
}

function testGetClassIdentical(): void
{
if (get_class(...) === 'stdClass') {}
if (get_debug_type(...) === 'string') {}
assertType('Closure(object=): class-string', get_class(...));
}

function testStringFuncIdentical(): void
{
if (strtolower(...) === 'test') {}
assertType('Closure(string): lowercase-string', strtolower(...));
}

// String equality guards in specifyTypesForConstantStringBinaryExpression
function testGettypeEquality(): void
{
if (gettype(...) === 'string') {}
if (gettype(...) == 'string') {}
assertType('Closure(mixed): string', gettype(...));
}

function testGetClassEquality(): void
{
if (get_class(...) == 'stdClass') {}
if (get_debug_type(...) == 'string') {}
assertType('Closure(object=): class-string', get_class(...));
}

function testGetParentClassEquality(): void
{
if (get_parent_class(...) === 'stdClass') {}
assertType('Closure(object|string=): (class-string|false)', get_parent_class(...));
}

function testTrimEquality(): void
{
if (trim(...) !== '') {}
if (ltrim(...) !== '') {}
if (rtrim(...) !== '') {}
assertType('Closure(string, string=): string', trim(...));
}

// NodeScopeResolver guards
function testArrayKeysInForeach(): void
{
foreach (array_keys(...) as $key) {}
assertType('Closure(array, mixed=, bool=): list<int|string>', array_keys(...));
}

function testCountInForLoop(): void
{
for ($i = 0; $i < count(...); $i++) {}
for ($i = 0; count(...) > $i; $i++) {}
assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...));
}
Loading