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
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -972,7 +972,7 @@ parameters:
-
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.'
identifier: phpstanApi.instanceofType
count: 5
count: 6
path: src/Type/Constant/ConstantArrayType.php

-
Expand Down
95 changes: 91 additions & 4 deletions src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
use function count;
use function get_class;
use function in_array;
use function is_string;
use function strtolower;
use function substr;
use const COUNT_NORMAL;
Expand Down Expand Up @@ -612,11 +613,23 @@
!$context->null()
&& $expr instanceof Expr\BinaryOp\Coalesce
) {
if (
($context->true() && $type->isSuperTypeOf($scope->getType($expr->right))->no())
|| ($context->false() && $type->isSuperTypeOf($scope->getType($expr->right))->yes())
) {
$rightIsSuperType = $type->isSuperTypeOf($scope->getType($expr->right));

Check warning on line 616 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\IsSuperTypeOfCalleeAndArgumentMutator": @@ @@ !$context->null() && $expr instanceof Expr\BinaryOp\Coalesce ) { - $rightIsSuperType = $type->isSuperTypeOf($scope->getType($expr->right)); + $rightIsSuperType = $scope->getType($expr->right)->isSuperTypeOf($type); if ($context->true() && $rightIsSuperType->no()) { $expr = $expr->left; } elseif ($context->false()) {

Check warning on line 616 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\IsSuperTypeOfCalleeAndArgumentMutator": @@ @@ !$context->null() && $expr instanceof Expr\BinaryOp\Coalesce ) { - $rightIsSuperType = $type->isSuperTypeOf($scope->getType($expr->right)); + $rightIsSuperType = $scope->getType($expr->right)->isSuperTypeOf($type); if ($context->true() && $rightIsSuperType->no()) { $expr = $expr->left; } elseif ($context->false()) {
if ($context->true() && $rightIsSuperType->no()) {

Check warning on line 617 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ && $expr instanceof Expr\BinaryOp\Coalesce ) { $rightIsSuperType = $type->isSuperTypeOf($scope->getType($expr->right)); - if ($context->true() && $rightIsSuperType->no()) { + if ($context->true() && !$rightIsSuperType->yes()) { $expr = $expr->left; } elseif ($context->false()) { if ($rightIsSuperType->yes()) {

Check warning on line 617 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrueTruthyFalseFalseyTypeSpecifierContextMutator": @@ @@ && $expr instanceof Expr\BinaryOp\Coalesce ) { $rightIsSuperType = $type->isSuperTypeOf($scope->getType($expr->right)); - if ($context->true() && $rightIsSuperType->no()) { + if ($context->truthy() && $rightIsSuperType->no()) { $expr = $expr->left; } elseif ($context->false()) { if ($rightIsSuperType->yes()) {

Check warning on line 617 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ && $expr instanceof Expr\BinaryOp\Coalesce ) { $rightIsSuperType = $type->isSuperTypeOf($scope->getType($expr->right)); - if ($context->true() && $rightIsSuperType->no()) { + if ($context->true() && !$rightIsSuperType->yes()) { $expr = $expr->left; } elseif ($context->false()) { if ($rightIsSuperType->yes()) {

Check warning on line 617 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrueTruthyFalseFalseyTypeSpecifierContextMutator": @@ @@ && $expr instanceof Expr\BinaryOp\Coalesce ) { $rightIsSuperType = $type->isSuperTypeOf($scope->getType($expr->right)); - if ($context->true() && $rightIsSuperType->no()) { + if ($context->truthy() && $rightIsSuperType->no()) { $expr = $expr->left; } elseif ($context->false()) { if ($rightIsSuperType->yes()) {
$expr = $expr->left;
} elseif ($context->false()) {

Check warning on line 619 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrueTruthyFalseFalseyTypeSpecifierContextMutator": @@ @@ $rightIsSuperType = $type->isSuperTypeOf($scope->getType($expr->right)); if ($context->true() && $rightIsSuperType->no()) { $expr = $expr->left; - } elseif ($context->false()) { + } elseif ($context->falsey()) { if ($rightIsSuperType->yes()) { // The default value would be removed too, so the left side is // guaranteed to be set and can be narrowed directly.

Check warning on line 619 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrueTruthyFalseFalseyTypeSpecifierContextMutator": @@ @@ $rightIsSuperType = $type->isSuperTypeOf($scope->getType($expr->right)); if ($context->true() && $rightIsSuperType->no()) { $expr = $expr->left; - } elseif ($context->false()) { + } elseif ($context->falsey()) { if ($rightIsSuperType->yes()) { // The default value would be removed too, so the left side is // guaranteed to be set and can be narrowed directly.
if ($rightIsSuperType->yes()) {

Check warning on line 620 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ($context->true() && $rightIsSuperType->no()) { $expr = $expr->left; } elseif ($context->false()) { - if ($rightIsSuperType->yes()) { + if (!$rightIsSuperType->no()) { // The default value would be removed too, so the left side is // guaranteed to be set and can be narrowed directly. $expr = $expr->left;

Check warning on line 620 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ($context->true() && $rightIsSuperType->no()) { $expr = $expr->left; } elseif ($context->false()) { - if ($rightIsSuperType->yes()) { + if (!$rightIsSuperType->no()) { // The default value would be removed too, so the left side is // guaranteed to be set and can be narrowed directly. $expr = $expr->left;
// The default value would be removed too, so the left side is
// guaranteed to be set and can be narrowed directly.
$expr = $expr->left;
} else {
// The default value survives, so the left side may still be
// absent (e.g. an optional array offset). Narrow its value
// without asserting that it exists.
$narrowed = $this->narrowCoalesceLeftPreservingExistence($expr->left, $type, $scope);
if ($narrowed !== null) {
return $narrowed;
}
}
}
}

Expand Down Expand Up @@ -738,6 +751,80 @@
return $types;
}

/**
* Narrows the value of a null-coalescing left side (e.g. `$a['k']` in
* `($a['k'] ?? $default) !== $type`) by removing $type from it, while
* preserving whether the offset actually exists. Returns null when this
* cannot be done without asserting the existence of a preceding offset,
* which would defeat the purpose of the `??` access.
*/
private function narrowCoalesceLeftPreservingExistence(Expr $leftExpr, Type $type, Scope $scope): ?SpecifiedTypes
{
if (!$leftExpr instanceof Expr\ArrayDimFetch || $leftExpr->dim === null) {
return null;
}

// Overwriting the container is only safe when doing so does not imply
// that a preceding offset exists. A variable or a property access is the
// safe case; a nested offset (`$a[$x]['k']`) would memorize `$a[$x]` as
// existing, which is exactly what the `??` access guards against.
$containerExpr = $leftExpr->var;
if (!$this->isSafeCoalesceContainerToOverwrite($containerExpr)) {
return null;
}

$containerType = $scope->getType($containerExpr);
if (!$containerType->isConstantArray()->yes()) {

Check warning on line 777 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } $containerType = $scope->getType($containerExpr); - if (!$containerType->isConstantArray()->yes()) { + if ($containerType->isConstantArray()->no()) { return null; }

Check warning on line 777 in src/Analyser/TypeSpecifier.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } $containerType = $scope->getType($containerExpr); - if (!$containerType->isConstantArray()->yes()) { + if ($containerType->isConstantArray()->no()) { return null; }
return null;
}

$dimType = $scope->getType($leftExpr->dim);

$newParts = [];
foreach ($containerType->getConstantArrays() as $constantArray) {
$narrowedValueType = TypeCombinator::remove($constantArray->getOffsetValueType($dimType), $type);
if ($narrowedValueType instanceof NeverType) {
return null;
}

$newParts[] = $constantArray->replaceOffsetValueTypePreservingOptionality($dimType, $narrowedValueType);
}

if ($newParts === []) {
return null;
}

$exprString = $this->exprPrinter->printExpr($containerExpr);

return (new SpecifiedTypes(
[$exprString => [$containerExpr, TypeCombinator::union(...$newParts)]],
[],
))->setAlwaysOverwriteTypes();
}

/**
* Whether the container of a null-coalescing offset access can have its type
* overwritten without implying that a preceding array offset exists. Plain
* variables and property accesses reaching down to one are safe; array
* offsets and dynamic accesses in the chain are not.
*/
private function isSafeCoalesceContainerToOverwrite(Expr $expr): bool
{
if ($expr instanceof Expr\Variable && is_string($expr->name)) {
return true;
}

if ($expr instanceof PropertyFetch && $expr->name instanceof Node\Identifier) {
return $this->isSafeCoalesceContainerToOverwrite($expr->var);
}

if ($expr instanceof Expr\StaticPropertyFetch && $expr->name instanceof Node\VarLikeIdentifier) {
return $expr->class instanceof Name || $this->isSafeCoalesceContainerToOverwrite($expr->class);
}

return false;
}

private function createNullsafeTypes(Expr $expr, Scope $scope, TypeSpecifierContext $context, ?Type $type): SpecifiedTypes
{
if ($expr instanceof Expr\NullsafePropertyFetch) {
Expand Down
27 changes: 27 additions & 0 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -1311,6 +1311,33 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T
return $builder->getArray();
}

/**
* Replaces the value type of an already-present offset, keeping the key's
* existing optional status untouched. Unlike setExistingOffsetValueType(),
* this never promotes an optional key to a required one. No-op when the
* offset is not part of the shape.
*/
public function replaceOffsetValueTypePreservingOptionality(Type $offsetType, Type $valueType): self
{
$offsetType = $offsetType->toArrayKey();
if (!$offsetType instanceof ConstantIntegerType && !$offsetType instanceof ConstantStringType) {
return $this;
}

$valueTypes = $this->valueTypes;
foreach ($this->keyTypes as $i => $keyType) {
if ($keyType->getValue() !== $offsetType->getValue()) {
continue;
}

$valueTypes[$i] = $valueType;

return new self($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $this->unsealed);
}

return $this;
}

/**
* Removes or marks as optional the key(s) matching the given offset type from this constant array.
*
Expand Down
102 changes: 102 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-12057.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

namespace Bug12057;

use function PHPStan\Testing\assertType;

/** @param array{foo?: mixed} $options */
function earlyReturn(array $options): void
{
if (($options['foo'] ?? null) === false) {
return;
}

// The offset may still be absent, but if present it is not false.
assertType('array{foo?: mixed~false}', $options);
}

/** @param array{foo?: mixed} $options */
function identical(array $options): void
{
if (($options['foo'] ?? null) === false) {
assertType('array{foo: false}', $options);
} else {
assertType('array{foo?: mixed~false}', $options);
}
}

/** @param array{foo?: mixed} $options */
function notIdentical(array $options): void
{
if (($options['foo'] ?? null) !== false) {
assertType('array{foo?: mixed~false}', $options);
} else {
assertType('array{foo: false}', $options);
}
}

/** @param array{foo?: int|false} $options */
function narrowUnionValue(array $options): void
{
if (($options['foo'] ?? null) !== false) {
assertType('array{foo?: int}', $options);
}
}

/**
* The same narrowing applies to any compared constant, not just false.
*
* @param array{foo?: 1|2|3} $options
*/
function narrowConstantUnionValue(array $options): void
{
if (($options['foo'] ?? null) !== 2) {
assertType('array{foo?: 1|3}', $options);
}
}

class Config
{

/** @var array{foo?: mixed} */
public array $options = [];

}

function propertyContainer(Config $c): void
{
if (($c->options['foo'] ?? null) === false) {
return;
}

assertType('array{foo?: mixed~false}', $c->options);
}

/**
* A nested offset must NOT be memorized as existing: when the outer key is
* missing, the whole coalesce expression is the default and the condition
* can still be satisfied.
*
* @param array<string, array{foo?: mixed}> $rows
*/
function nestedOffsetKeepsExistenceUncertain(array $rows, string $key): void
{
if (($rows[$key]['foo'] ?? null) === false) {
return;
}

assertType('array<string, array{foo?: mixed}>', $rows);
}

/**
* When the default value would itself be removed, the left side is guaranteed
* to be set and is narrowed to a required, non-null offset.
*
* @param array{foo?: int|null} $options
*/
function defaultRemovedForcesExistence(array $options): void
{
if (($options['foo'] ?? null) !== null) {
assertType('array{foo: int}', $options);
}
}
Loading