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
118 changes: 118 additions & 0 deletions src/Analyser/ConditionalExpressionHolderRecipe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PhpParser\Node\Expr;
use PHPStan\Type\NeverType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function array_key_exists;

/**
* A deferred description of the boolean-decomposition conditional holders
* (`&&` asserted false, `||` asserted true): the raw narrowing entries of the
* condition side and the holder side, captured where the boolean narrowing was
* composed. The state-dependent math - the condition complements against the
* current type, the holder target types, the vacuity checks - runs in
* evaluate() against the scope the narrowing is applied to
* (MutatingScope::filterBySpecifiedTypes()), never the scope the composition ran
* on.
*/
final class ConditionalExpressionHolderRecipe
{

/**
* @param list<array{string, Expr, bool, Type}> $conditionEntries [exprString, expr, fromSureTypes, type]
* @param list<array{string, Expr, Type, ?Type}> $holderEntries [exprString, expr, type, target type pinned at compose time (null = read the applying scope)]
*/
public function __construct(
private array $conditionEntries,
private array $holderEntries,
private bool $holdersFromSureTypes,
)
{
}

/**
* @return array<string, ConditionalExpressionHolder[]>
*/
public function evaluate(MutatingScope $scope): array
{
$conditionExpressionTypes = [];
$droppedNoOpConditions = [];
// the unnarrowed type of each condition expression, for the
// dropped-self-condition complement below
$conditionOriginalTypes = [];
foreach ($this->conditionEntries as [$exprString, $expr, $fromSureTypes, $type]) {
$scopeType = $scope->getType($expr);
$conditionType = $fromSureTypes
? TypeCombinator::remove($scopeType, $type)
: TypeCombinator::intersect($scopeType, $type);
if ($scopeType->equals($conditionType)) {
$droppedNoOpConditions[$exprString] = true;
continue;
}

$conditionExpressionTypes[$exprString] = ExpressionTypeHolder::createYes($expr, $conditionType);
$conditionOriginalTypes[$exprString] = $scopeType;
}

if ($conditionExpressionTypes === []) {
return [];
}

$holders = [];
foreach ($this->holderEntries as [$exprString, $expr, $type, $pinnedTargetType]) {
// The target's only link to the antecedent was a no-op relation (e.g.
// `$a === $b`) that got dropped, so the antecedent no longer constrains
// it. Projecting a consequent onto it would fire unsoundly. Skip it.
if (array_key_exists($exprString, $droppedNoOpConditions)) {
continue;
}

$conditions = $conditionExpressionTypes;
$droppedSelfCondition = null;
if (isset($conditions[$exprString])) {
$droppedSelfCondition = $conditions[$exprString];
unset($conditions[$exprString]);
}

if ($conditions === []) {
continue;
}

$targetType = $pinnedTargetType ?? $scope->getType($expr);
$holderType = $this->holdersFromSureTypes
? TypeCombinator::intersect($targetType, $type)
: TypeCombinator::remove($targetType, $type);

// The dropped self-condition narrowed the target; without it the
// holder must allow the values it excluded, or it over-narrows when
// only the remaining conditions hold. So union back the complement.
if ($droppedSelfCondition !== null) {
$complement = TypeCombinator::remove($conditionOriginalTypes[$exprString], $droppedSelfCondition->getType());
if (!$complement instanceof NeverType) {
$holderType = TypeCombinator::union($holderType, $complement);
}
}

// These boolean-decomposition holders only refine an expression's
// type in a future scope; they must never collapse it to never and
// thereby mark the whole scope unreachable. A never result is an
// artifact (e.g. removing a non-nullable property's full type after
// swapping isset() narrowing), not a real contradiction.
if ($holderType instanceof NeverType && !$targetType instanceof NeverType) {
continue;
}
$holder = new ConditionalExpressionHolder(
$conditions,
ExpressionTypeHolder::createYes($expr, $holderType),
);
$holders[$exprString] ??= [];
$holders[$exprString][$holder->getKey()] = $holder;
}

return $holders;
}

}
17 changes: 17 additions & 0 deletions src/Analyser/DeferredSpecifiedTypesAugment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

/**
* A state-dependent augmentation of a SpecifiedTypes, deferred to the
* application point: MutatingScope::applySpecifiedTypes() evaluates it against
* the applying scope and unions the produced entries into the applied batch.
* The composition captures only position-fixed facts (operand-walk reads);
* everything that must reflect the current state runs in evaluate().
*/
interface DeferredSpecifiedTypesAugment
{

public function evaluate(MutatingScope $scope): ?SpecifiedTypes;

}
72 changes: 72 additions & 0 deletions src/Analyser/DisjunctionBranchUnionAugment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PhpParser\Node\Expr;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeUtils;

/**
* The either-branch union recovery: an expression the exact merge left
* unconstrained, but that both branch scopes narrow (through fired conditional
* holders or sibling assignments the operands' SpecifiedTypes cannot see),
* is narrowed to the union of its branch types. The branch types are
* position-fixed operand-walk facts captured at compose time; whether the
* union actually narrows anything depends on the expression's current type,
* so those gates run against the applying scope.
*/
final class DisjunctionBranchUnionAugment implements DeferredSpecifiedTypesAugment
{

/**
* @param list<array{Expr, Type, Type}> $candidates [target expr, left branch type, right branch type]
*/
public function __construct(
private TypeSpecifier $typeSpecifier,
private array $candidates,
)
{
}

public function evaluate(MutatingScope $scope): ?SpecifiedTypes
{
$result = null;
foreach ($this->candidates as [$targetExpr, $leftType, $rightType]) {
if (!$scope->hasExpressionType($targetExpr)->yes()) {
continue;
}

// the guard above pins the target as tracked on the applying scope
$originalType = $scope->getType($targetExpr);
// re-pinning eagerly priced branch forms of a template-typed subject
// stacks the template inside its own bound (`T of T of ...` - the
// pin intersects with the declared template); its narrowing already
// flows through the operands' exact merge
if (TypeUtils::containsTemplateType($originalType)) {
continue;
}
if ($leftType->equals($originalType) || !$originalType->isSuperTypeOf($leftType)->yes()) {
continue;
}

if ($rightType->equals($originalType) || !$originalType->isSuperTypeOf($rightType)->yes()) {
continue;
}

$unionType = TypeCombinator::union($leftType, $rightType);
// a union that covers the whole original type gains no narrowing -
// pinning it would only stack a redundant intersection on the
// expression (e.g. re-wrapping a template type in its own bound)
if ($unionType->isSuperTypeOf($originalType)->yes()) {
continue;
}

$created = $this->typeSpecifier->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope);
$result = $result === null ? $created : $result->unionWith($created);
}

return $result;
}

}
103 changes: 103 additions & 0 deletions src/Analyser/DisjunctionHolderProjectionAugment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PHPStan\Type\TypeCombinator;
use function array_key_first;

/**
* The disjunction-truthy projection of conditional-holder narrowings: an
* expression that registered conditional holders (on the applying scope or on
* the left-falsey walk scope) and that both operands' truthy scopes narrow -
* through those holders firing - is narrowed to the union of its branch
* types. Candidate discovery and the does-it-actually-narrow gates run
* against the applying scope; the branch reads use the operand-walk truthy
* scopes captured at compose time.
*/
final class DisjunctionHolderProjectionAugment implements DeferredSpecifiedTypesAugment
{

/**
* The operand truthy scopes are thunks resolved only when a candidate
* passes the applying-scope gates - deriving them per level of a deep
* boolean chain is quadratic.
*
* @param callable(): MutatingScope $leftTruthyScope
* @param callable(): MutatingScope $rightTruthyScope
* @param array<string, true> $alternativeKeys expressions the exact either-branch
* merge already constrains - the weaker branch-scope union must not
* be added on top
*/
public function __construct(
private TypeSpecifier $typeSpecifier,
private $leftTruthyScope,
private MutatingScope $leftFalseyScope,
private $rightTruthyScope,
private array $alternativeKeys,
)
{
}

public function evaluate(MutatingScope $scope): ?SpecifiedTypes
{
$result = null;
$seen = [];
$leftTruthyScope = null;
$rightTruthyScope = null;
foreach ([$scope, $this->leftFalseyScope] as $sourceScope) {
foreach ($sourceScope->getConditionalExpressions() as $rootExprString => $holders) {
if (isset($seen[$rootExprString])) {
continue;
}
if ($holders === []) {
continue;
}
$seen[$rootExprString] = true;
$targetExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr();

if (isset($this->alternativeKeys[$rootExprString])) {
continue;
}

// Only project when the target stays Yes-defined in the original
// scope and in both filtered branches. A sure type implicitly
// raises certainty to Yes, which would wrongly upgrade Maybe-defined
// variables — `if (empty($a['bar']))` for instance leaves `$a`
// Maybe-defined because `empty()` tolerates undefined offsets.
if (!$scope->hasExpressionType($targetExpr)->yes()) {
continue;
}
$leftTruthyScope ??= ($this->leftTruthyScope)();
$rightTruthyScope ??= ($this->rightTruthyScope)();

// the guard above pins the target as tracked on the applying
// scope; the branch scopes are its own filtered views, so their
// reads answer from state (or price the same tracked state)
$origType = $scope->getType($targetExpr);

$leftType = $leftTruthyScope->getType($targetExpr);
$leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes();
if (!$leftNarrowed) {
continue;
}

$rightType = $rightTruthyScope->getType($targetExpr);
$rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes();
if (!$rightNarrowed) {
continue;
}

$unionType = TypeCombinator::union($leftType, $rightType);
if ($unionType->equals($origType)) {
continue;
}

$created = $this->typeSpecifier->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope);
$result = $result === null ? $created : $result->unionWith($created);
}
}

return $result;
}

}
Loading
Loading