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
19 changes: 17 additions & 2 deletions src/Analyser/ExprHandler/ArrayDimFetchHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, Stmt $stmt,
$impurePoints = array_merge($dimResult->getImpurePoints(), $varResult->getImpurePoints());

$varType = $varResult->getType();
// an offset read that is a link in a nullsafe chain may never run - see
// MethodCallHandler::processExpr(). The dimension is walked BEFORE the
// receiver here, so the short-circuited world is the pre-dimension scope.
$mayShortCircuit = $varResult->containsNullsafe() && TypeCombinator::containsNull($varType);
if ($mayShortCircuit) {
$scope = $scope->mergeWith($beforeScope);
}
$offsetGetCall = null;
if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) {
$throwPoints = array_merge($throwPoints, $this->methodThrowPointHelper->getThrowPointsForCallOnType(
Expand All @@ -123,9 +130,17 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, Stmt $stmt,
$scope,
beforeScope: $beforeScope,
expr: $expr,
variableFlow: VariableFlow::sequence($varResult->getVariableFlow(), $dimResult->getVariableFlow(), self::offsetRead($expr, $dimResult, $context), VariableFlowBuilder::throws($expr, $throwPoints)),
variableFlow: VariableFlow::sequence(
$varResult->getVariableFlow(),
// the dimension was not evaluated in the short-circuited world
$mayShortCircuit
? VariableFlow::choice($dimResult->getVariableFlow(), null)
: $dimResult->getVariableFlow(),
self::offsetRead($expr, $dimResult, $context),
VariableFlowBuilder::throws($expr, $throwPoints),
),
hasYield: $dimResult->hasYield() || $varResult->hasYield(),
isAlwaysTerminating: $dimResult->isAlwaysTerminating() || $varResult->isAlwaysTerminating(),
isAlwaysTerminating: (!$mayShortCircuit && $dimResult->isAlwaysTerminating()) || $varResult->isAlwaysTerminating(),
throwPoints: $throwPoints,
impurePoints: $impurePoints,
containsNullsafe: $varResult->containsNullsafe(),
Expand Down
17 changes: 17 additions & 0 deletions src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,23 @@ public function createNullsafeReceiverOnlyTypes(MutatingScope $s, Expr $subject,
return $this->createFirstNullsafeReceiverTypes($s, $subject) ?? new SpecifiedTypes([], []);
}

/**
* Whether a call on a `?->` chain may have been skipped in the branch
* $context describes, so nothing the callee declares narrows there.
*/
public function callMayHaveBeenSkipped(?ExpressionResult $receiverResult, Type $receiverType, TypeSpecifierContext $context): bool
{
if ($receiverResult === null || !$receiverResult->containsNullsafe()) {
return false;
}

if (!$context->null() && !$context->falseyButNotFalse()) {
return false;
}

return TypeCombinator::containsNull($receiverType);
}

/**
* Whether the constraint (or the subject's own type) rules the nullsafe
* short-circuit null out, so the chain's receivers can narrow not-null.
Expand Down
29 changes: 23 additions & 6 deletions src/Analyser/ExprHandler/MethodCallHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
// the var was processed above as the receiver; read its already-computed
// result instead of re-walking via Scope::getType().
$calledOnType = $varResult->getType();
// A plain call that is a link in a nullsafe chain may never run: the chain
// short-circuits to null before the arguments are evaluated and before any
// of the call's effects happen. NullsafeMethodCallHandler does the same for
// the `?->` it owns; here the `?->` sits below a plain `->`.
$mayShortCircuit = $varResult->containsNullsafe() && TypeCombinator::containsNull($calledOnType);
// A call configured as early-terminating never returns: give it an explicit
// never so the statement's exit point follows from the result type, instead of
// NodeScopeResolver re-deriving it via Scope::getType().
$isEarlyTerminating = $expr->name instanceof Identifier
$isEarlyTerminating = !$mayShortCircuit
&& $expr->name instanceof Identifier
&& $this->earlyTerminatingHelper->isEarlyTerminatingMethodCall($expr->name->name, $calledOnType);
$isAlwaysTerminating = $isAlwaysTerminating || $isEarlyTerminating;
if ($expr->name instanceof Identifier) {
Expand All @@ -155,7 +161,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
if ($parametersAcceptor !== null) {
$normalizedExpr = ArgumentsNormalizer::reorderMethodArguments($parametersAcceptor, $expr) ?? $expr;
$returnType = $parametersAcceptor->getReturnType();
$isAlwaysTerminating = $isAlwaysTerminating || ($returnType instanceof NeverType && $returnType->isExplicit());
$isAlwaysTerminating = $isAlwaysTerminating || (!$mayShortCircuit && $returnType instanceof NeverType && $returnType->isExplicit());
}

$scopeBeforeArgs = $scope;
Expand Down Expand Up @@ -286,7 +292,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
// return type; a conditional-return never (e.g. `($x is Foo ? never :
// string)`) only resolves to never once the actual argument types are
// folded in by the type-driven resolved acceptor.
if ($resolvedParametersAcceptor !== null) {
if ($resolvedParametersAcceptor !== null && !$mayShortCircuit) {
$resolvedReturnType = $resolvedParametersAcceptor->getReturnType();
$isAlwaysTerminating = $isAlwaysTerminating || ($resolvedReturnType instanceof NeverType && $resolvedReturnType->isExplicit());
}
Expand Down Expand Up @@ -355,16 +361,24 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$hasYield = $hasYield || $argsResult->hasYield();
$throwPoints = array_merge($throwPoints, $argsResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints());
$isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating();
$isAlwaysTerminating = $isAlwaysTerminating || (!$mayShortCircuit && $argsResult->isAlwaysTerminating());

$argumentsFlow = VariableFlowBuilder::arguments($expr, $argsResult, $storage);
$variableFlow = VariableFlow::sequence(
$varResult->getVariableFlow(),
$nameResult !== null ? $nameResult->getVariableFlow() : null,
VariableFlowBuilder::arguments($expr, $argsResult, $storage),
// the short-circuited world evaluates none of the arguments
$mayShortCircuit ? VariableFlow::choice($argumentsFlow, null) : $argumentsFlow,
VariableFlowBuilder::throws($expr, $throwPoints),
$isAlwaysTerminating ? VariableFlow::exit(VariableFlow::STOP) : null,
);

// the call's scope effects (@param-out, @phpstan-self-out, invalidations)
// only happened in the world where the chain did not short-circuit
if ($mayShortCircuit) {
$scope = $scope->mergeWith($scopeBeforeArgs);
}

$result = $preliminaryResult->finalize($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints, $variableFlow);

// the var was processed above as the receiver; read its already-computed
Expand Down Expand Up @@ -492,7 +506,10 @@ private function specifyTypes(MutatingScope $scope, Expr $expr, Expr $normalized
// result instead of re-walking via Scope::getType().
$methodCalledOnType = $varResult->getTypeOnScope($scope, $scope->nativeTypesPromoted);
$methodReflection = $scope->getMethodReflection($methodCalledOnType, $expr->name->name);
if ($methodReflection !== null) {
// a call on a nullsafe chain may never have run - the branches that still
// admit the short-circuit's null get no callee-derived narrowing at all
$mayHaveBeenSkipped = $this->defaultNarrowingHelper->callMayHaveBeenSkipped($varResult, $methodCalledOnType, $context);
if ($methodReflection !== null && !$mayHaveBeenSkipped) {
$args = $expr->getArgs();

$referencedClasses = $methodCalledOnType->getObjectClassNames();
Expand Down
20 changes: 18 additions & 2 deletions src/Analyser/ExprHandler/PropertyFetchHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, PropertyFetc
$impurePoints = $varResult->getImpurePoints();
$isAlwaysTerminating = $varResult->isAlwaysTerminating();
$scope = $varResult->getScope();
$mayShortCircuit = false;
if ($expr->name instanceof Identifier) {
if ($this->phpVersion->supportsPropertyHooks()) {
$propertyName = $expr->name->toString();
Expand All @@ -97,11 +98,20 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, PropertyFetc
}
}
} elseif ($nameResult !== null) {
// a fetch that is a link in a nullsafe chain may never run - see
// MethodCallHandler::processExpr(). Only the dynamic name is skipped
// with it, so an Identifier name never has to resolve the receiver type.
$mayShortCircuit = $varResult->containsNullsafe() && TypeCombinator::containsNull($varResult->getType());
$hasYield = $hasYield || $nameResult->hasYield();
$throwPoints = array_merge($throwPoints, $nameResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $nameResult->getImpurePoints());
$isAlwaysTerminating = $isAlwaysTerminating || $nameResult->isAlwaysTerminating();
$isAlwaysTerminating = $isAlwaysTerminating || (!$mayShortCircuit && $nameResult->isAlwaysTerminating());
$scope = $nameResult->getScope();
if ($mayShortCircuit) {
// the dynamic name expression was not evaluated in the
// short-circuited world
$scope = $scope->mergeWith($varResult->getScope());
}
if ($this->phpVersion->supportsPropertyHooks()) {
$throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr);
}
Expand All @@ -111,7 +121,13 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, PropertyFetc
$scope,
beforeScope: $beforeScope,
expr: $expr,
variableFlow: VariableFlow::sequence($varResult->getVariableFlow(), $nameResult !== null ? $nameResult->getVariableFlow() : null, VariableFlowBuilder::throws($expr, $throwPoints)),
variableFlow: VariableFlow::sequence(
$varResult->getVariableFlow(),
$nameResult !== null && $mayShortCircuit
? VariableFlow::choice($nameResult->getVariableFlow(), null)
: ($nameResult !== null ? $nameResult->getVariableFlow() : null),
VariableFlowBuilder::throws($expr, $throwPoints),
),
hasYield: $hasYield,
isAlwaysTerminating: $isAlwaysTerminating,
throwPoints: $throwPoints,
Expand Down
29 changes: 23 additions & 6 deletions src/Analyser/ExprHandler/StaticCallHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$containsNullsafe = $classResult->containsNullsafe();
}

// `$a?->b::c()` is a link in a nullsafe chain and may never run: the chain
// short-circuits to null before the arguments are evaluated and before any
// of the call's effects happen. See MethodCallHandler::processExpr().
$mayShortCircuit = $classResult !== null
&& $containsNullsafe
&& TypeCombinator::containsNull($classResult->getType());
// A static call configured as early-terminating never returns: give it an
// explicit never so the statement's exit point follows from the result type,
// instead of NodeScopeResolver re-deriving it via Scope::getType().
$isEarlyTerminating = false;
if ($expr->name instanceof Identifier) {
if ($expr->name instanceof Identifier && !$mayShortCircuit) {
$earlyTerminatingClassType = $expr->class instanceof Name
? $scope->resolveTypeByName($expr->class)
: $classResult->getType();
Expand Down Expand Up @@ -247,7 +253,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
if ($parametersAcceptor !== null) {
$normalizedExpr = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $expr) ?? $expr;
$returnType = $parametersAcceptor->getReturnType();
$isAlwaysTerminating = $isAlwaysTerminating || ($returnType instanceof NeverType && $returnType->isExplicit());
$isAlwaysTerminating = $isAlwaysTerminating || (!$mayShortCircuit && $returnType instanceof NeverType && $returnType->isExplicit());
}
$scopeBeforeArgs = $scope;
if ($parametersAcceptor !== null && $context->getInAssignRightSideExpr() === $expr) {
Expand Down Expand Up @@ -281,7 +287,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
// type; a conditional-return never (e.g. `($x is Foo ? never : string)`)
// only resolves to never once the actual argument types are folded in by the
// type-driven resolved acceptor.
if ($resolvedParametersAcceptor !== null) {
if ($resolvedParametersAcceptor !== null && !$mayShortCircuit) {
$resolvedReturnType = $resolvedParametersAcceptor->getReturnType();
$isAlwaysTerminating = $isAlwaysTerminating || ($resolvedReturnType instanceof NeverType && $resolvedReturnType->isExplicit());
}
Expand Down Expand Up @@ -429,16 +435,24 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$hasYield = $hasYield || $argsResult->hasYield();
$throwPoints = array_merge($throwPoints, $argsResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints());
$isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating();
$isAlwaysTerminating = $isAlwaysTerminating || (!$mayShortCircuit && $argsResult->isAlwaysTerminating());

$argumentsFlow = VariableFlowBuilder::arguments($expr, $argsResult, $storage);
$variableFlow = VariableFlow::sequence(
$classResult !== null ? $classResult->getVariableFlow() : null,
$nameResult !== null ? $nameResult->getVariableFlow() : null,
VariableFlowBuilder::arguments($expr, $argsResult, $storage),
// the short-circuited world evaluates none of the arguments
$mayShortCircuit ? VariableFlow::choice($argumentsFlow, null) : $argumentsFlow,
VariableFlowBuilder::throws($expr, $throwPoints),
$isAlwaysTerminating ? VariableFlow::exit(VariableFlow::STOP) : null,
);

// the call's scope effects (@param-out, invalidations) only happened in the
// world where the chain did not short-circuit
if ($mayShortCircuit) {
$scope = $scope->mergeWith($scopeBeforeArgs);
}

return $preliminaryResult->finalize($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints, $variableFlow);
}

Expand Down Expand Up @@ -571,7 +585,10 @@ private function specifyTypes(MutatingScope $scope, Expr $expr, Expr $normalized
}

$staticMethodReflection = $scope->getMethodReflection($calleeType, $expr->name->name);
if ($staticMethodReflection !== null) {
// see MethodCallHandler::specifyTypes() - `$a?->b::c()` short-circuits too,
// so the branches admitting its null get no callee-derived narrowing
$mayHaveBeenSkipped = $this->defaultNarrowingHelper->callMayHaveBeenSkipped($classResult, $calleeType, $context);
if ($staticMethodReflection !== null && !$mayHaveBeenSkipped) {
$args = $expr->getArgs();

$referencedClasses = $calleeType->getObjectClassNames();
Expand Down
22 changes: 20 additions & 2 deletions src/Analyser/ExprHandler/StaticPropertyFetchHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,19 +98,37 @@ public function composeResult(StaticPropertyFetch $expr, ?ExpressionResult $clas
$isAlwaysTerminating = $classResult->isAlwaysTerminating();
$scope = $classResult->getScope();
}
$mayShortCircuit = false;
if ($nameResult !== null) {
// `$a?->b::$$name` is a link in a nullsafe chain and may never run - see
// MethodCallHandler::processExpr(). Only the dynamic name is skipped with
// it, so a plain `::$name` never has to resolve the class expression type.
$mayShortCircuit = $classResult !== null
&& $classResult->containsNullsafe()
&& TypeCombinator::containsNull($classResult->getType());
$hasYield = $hasYield || $nameResult->hasYield();
$throwPoints = array_merge($throwPoints, $nameResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $nameResult->getImpurePoints());
$isAlwaysTerminating = $isAlwaysTerminating || $nameResult->isAlwaysTerminating();
$isAlwaysTerminating = $isAlwaysTerminating || (!$mayShortCircuit && $nameResult->isAlwaysTerminating());
$scope = $nameResult->getScope();
if ($mayShortCircuit) {
// the dynamic name expression was not evaluated in the
// short-circuited world
$scope = $scope->mergeWith($classResult->getScope());
}
}

return $this->expressionResultFactory->create(
$scope,
beforeScope: $beforeScope,
expr: $expr,
variableFlow: VariableFlow::sequence($classResult !== null ? $classResult->getVariableFlow() : null, $nameResult !== null ? $nameResult->getVariableFlow() : null, VariableFlowBuilder::throws($expr, $throwPoints)),
variableFlow: VariableFlow::sequence(
$classResult !== null ? $classResult->getVariableFlow() : null,
$nameResult !== null && $mayShortCircuit
? VariableFlow::choice($nameResult->getVariableFlow(), null)
: ($nameResult !== null ? $nameResult->getVariableFlow() : null),
VariableFlowBuilder::throws($expr, $throwPoints),
),
hasYield: $hasYield,
isAlwaysTerminating: $isAlwaysTerminating,
throwPoints: $throwPoints,
Expand Down
6 changes: 6 additions & 0 deletions src/Analyser/TypeSpecifierContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ public function falsey(): bool
return $this->value !== null && (bool) ($this->value & self::CONTEXT_FALSEY);
}

/** Whether the branch admits falsey values other than `false`, e.g. `null`. */
public function falseyButNotFalse(): bool
Comment thread
VincentLanglet marked this conversation as resolved.
{
return $this->value !== null && (bool) ($this->value & self::CONTEXT_FALSEY_BUT_NOT_FALSE);
}

public function null(): bool
{
return $this->value === null;
Expand Down
9 changes: 5 additions & 4 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,11 @@ public static function clearCache(): void

public static function addNull(Type $type): Type
{
$nullType = new NullType();

if ($nullType->isSuperTypeOf($type)->no()) {
return self::union($type, $nullType);
// asking the type itself is both cheaper than NullType::isSuperTypeOf()
// (UnionType memoizes isNull(), isSubTypeOf() recomputes) and right for
// `never`, of which null is a supertype without never containing it.
if ($type->isNull()->no()) {
return self::union($type, new NullType());
}

return $type;
Expand Down
25 changes: 25 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-12925.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php // lint >= 8.0

namespace Bug12925;

use function PHPStan\Testing\assertType;

/**
* @template TIsZero of bool = bool
*/
final class Decimal
{
/** @param numeric-string $value */
public function __construct(private string $value) {}
/**
* @phpstan-assert-if-true self<true> $this
* @phpstan-assert-if-false self<false> $this
*/
public function isZero(): bool { return bccomp($this->value, '0', 2) === 0; }
}
class C { public function __construct(public Decimal $p) {} }
$c = rand() ? new C(new Decimal((string)rand())) : null;

assertType('Bug12925\C|null', $c);
echo $c?->p->isZero() ? 'Free' : 'Buying';
assertType('Bug12925\C|null', $c);
Loading
Loading