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
16 changes: 16 additions & 0 deletions src/Analyser/ExprHandler/ArrayDimFetchHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
use PHPStan\Analyser\ExpressionResultStorage;
use PHPStan\Analyser\ExprHandler;
use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper;
use PHPStan\Analyser\ExprHandler\Helper\IllegalOffsetTypeHelper;
use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper;
use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper;
use PHPStan\Analyser\Generics\TemplateArgumentFrame;
use PHPStan\Analyser\InternalThrowPoint;
use PHPStan\Analyser\IssetabilityDescriptor;
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
Expand All @@ -29,13 +31,15 @@
use PHPStan\Dependency\Dependencies;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Node\Expr\TypeExpr;
use PHPStan\Php\PhpVersion;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Turbo\ShadowedByTurboExtension;
use PHPStan\Type\ErrorType;
use PHPStan\Type\NeverType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use TypeError;
use function array_merge;
use function is_string;

Expand All @@ -52,6 +56,7 @@ public function __construct(
private DefaultNarrowingHelper $defaultNarrowingHelper,
private MethodThrowPointHelper $methodThrowPointHelper,
private MethodCallReturnTypeHelper $methodCallReturnTypeHelper,
private PhpVersion $phpVersion,
)
{
}
Expand Down Expand Up @@ -130,6 +135,17 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, Stmt $stmt,
$offsetGetCall = new MethodCall($expr->var, new Identifier('offsetGet'), [new Arg($expr->dim)], [TemplateArgumentFrame::SYNTHETIC_SITE_ATTRIBUTE => true]);
}

if (
$this->phpVersion->throwsTypeErrorForIllegalOffsets()
// only array and string offset reads throw TypeError for illegal offsets,
// reads on null and other scalars emit a warning, ArrayAccess objects go through offsetGet();
// isset() and empty() of a string offset are false instead
&& (!$varType->isArray()->no() || (!$context->isIssetTarget() && !$varType->isString()->no()))
&& IllegalOffsetTypeHelper::mayOffsetThrowTypeError($dimResult->getType())
) {
$throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(TypeError::class), $expr, false);
}

$result = $this->expressionResultFactory->create(
$scope,
beforeScope: $beforeScope,
Expand Down
15 changes: 15 additions & 0 deletions src/Analyser/ExprHandler/ArrayHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
use PHPStan\Analyser\ExpressionResultFactory;
use PHPStan\Analyser\ExpressionResultStorage;
use PHPStan\Analyser\ExprHandler;
use PHPStan\Analyser\ExprHandler\Helper\IllegalOffsetTypeHelper;
use PHPStan\Analyser\Generics\ClosureSignatureInference;
use PHPStan\Analyser\InternalThrowPoint;
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
use PHPStan\Analyser\SpecifiedTypes;
Expand All @@ -27,6 +29,7 @@
use PHPStan\Node\LiteralArrayItem;
use PHPStan\Node\LiteralArrayNode;
use PHPStan\Node\Variable\VariableWrite;
use PHPStan\Php\PhpVersion;
use PHPStan\Reflection\InitializerExprTypeResolver;
use PHPStan\ShouldNotHappenException;
use PHPStan\Turbo\ShadowedByTurboExtension;
Expand All @@ -35,8 +38,10 @@
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\MixedType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use TypeError;
use function array_key_exists;
use function array_map;
use function array_merge;
Expand All @@ -57,6 +62,7 @@ final class ArrayHandler implements ExprHandler
public function __construct(
private InitializerExprTypeResolver $initializerExprTypeResolver,
private ExpressionResultFactory $expressionResultFactory,
private PhpVersion $phpVersion,
)
{
}
Expand Down Expand Up @@ -144,6 +150,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$impurePoints = array_merge($impurePoints, $valueResult->getImpurePoints());
$isAlwaysTerminating = $isAlwaysTerminating || $valueResult->isAlwaysTerminating();
$scope = $valueResult->getScope();
if (
$keyResult !== null
&& $this->phpVersion->throwsTypeErrorForIllegalOffsets()
&& IllegalOffsetTypeHelper::mayOffsetThrowTypeError($keyResult->getType())
) {
// an array or an object key throws TypeError once the value was evaluated
$throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(TypeError::class), $arrayItem->key, false);
$variableFlows[] = VariableFlow::throwing(new ObjectType(TypeError::class), true);
}
// the item's callback fires after its key and value were processed,
// with the item's entry scope - callback-side asks answer from the
// storage instead of re-walking the yet-unstored sub-expressions
Expand Down
52 changes: 52 additions & 0 deletions src/Analyser/ExprHandler/AssignHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
use PHPStan\Analyser\ExprHandler;
use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper;
use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper;
use PHPStan\Analyser\ExprHandler\Helper\IllegalOffsetTypeHelper;
use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper;
use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper;
use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper;
Expand Down Expand Up @@ -286,6 +287,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
VariableFlowBuilder::targetRead($expr->var, $storage, false),
$assignedExprResult->getVariableFlow(),
$valueFlowWrite !== null && $context->isValueConsumed() ? VariableFlow::inputs($valueFlowWrite->getId(), $context->getValueFlowTarget() !== null ? $context->getValueFlowTarget()->getId() : null) : null,
// an illegal offset throws before the write
count($target->getIllegalOffsetDimFetches()) > 0 ? VariableFlow::throwing(new ObjectType(TypeError::class), true) : null,
VariableFlowBuilder::targetWrite($expr->var, VariableWrite::KIND_ASSIGN, $scope, $storage, $redundantType),
);
if ($expr instanceof Assign && $expr->expr instanceof Expr\Array_ && self::hasArrayReference($expr->expr)) {
Expand Down Expand Up @@ -852,6 +855,35 @@ private function doPrepareTarget(
$nodeScopeResolver->callNodeCallback($nodeCallback, $dimFetch, $enterExpressionAssign ? $callbackScope->enterExpressionAssign($dimFetch) : $callbackScope, $storage);
}

// the links whose offset may throw TypeError - PHP throws only after
// evaluating the assigned value, so applyWrite() creates their throw points
$illegalOffsetDimFetches = [];
if ($this->phpVersion->throwsTypeErrorForIllegalOffsets()) {
foreach ($offsetTypes as $i => [$offsetType, $offsetDimFetch]) {
// $arr[] = ... never throws for the offset
if ($offsetType === null) {
continue;
}
if (!IllegalOffsetTypeHelper::mayOffsetThrowTypeError($offsetType)) {
continue;
}
// writes to ArrayAccess objects go through offsetSet() whose throw points applyWrite() models;
// the container is the previous chain link - the root for the first dimension
$containerResult = $i === 0 ? $varResult : $deferredDimFetchResults[$i - 1][1];
$containerType = $containerResult->getType();
if ((new ObjectType(ArrayAccess::class))->isSuperTypeOf($containerType)->yes()) {
continue;
}
// `$lvalue ??= ...` throws reading an array or a string container,
// before the assigned value is evaluated
if ($mode->issetSemanticsForRead() && ($containerType->isArray()->yes() || $containerType->isString()->yes())) {
$throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(TypeError::class), $offsetDimFetch, false);
continue;
}
$illegalOffsetDimFetches[] = $offsetDimFetch;
}
}

if ($mode->issetSemanticsForRead()) {
// `$lvalue ??= ...` reads the chain with isset() semantics. The root
// and dimensions were just walked, so each chain link's read is
Expand Down Expand Up @@ -931,6 +963,7 @@ private function doPrepareTarget(
targetReadResult: $targetReadResult,
targetChainResults: $targetChainResults,
dependencies: $targetDependencies,
illegalOffsetDimFetches: $illegalOffsetDimFetches,
);
}

Expand Down Expand Up @@ -1438,6 +1471,25 @@ public function applyWrite(
$varType = new ConstantArrayType([], []);
$varNativeType = new ConstantArrayType([], []);
}

// an illegal offset throws TypeError after the assigned value was
// evaluated and an undefined root was turned into an array
$illegalOffsetDimFetches = $target->getIllegalOffsetDimFetches();
if (count($illegalOffsetDimFetches) > 0) {
$throwPointScope = $scope;
if (!$isImplicitArrayCreation->no() && $var instanceof Variable && is_string($var->name)) {
$throwPointScope = $throwPointScope->assignVariable(
$var->name,
TypeCombinator::union($varType, new ConstantArrayType([], [])),
TypeCombinator::union($varNativeType, new ConstantArrayType([], [])),
TrinaryLogic::createYes(),
[],
);
}
foreach ($illegalOffsetDimFetches as $illegalOffsetDimFetch) {
$throwPoints[] = InternalThrowPoint::createExplicit($throwPointScope, new ObjectType(TypeError::class), $illegalOffsetDimFetch, false);
}
}
$offsetValueType = $varType;
$offsetNativeValueType = $varNativeType;

Expand Down
4 changes: 4 additions & 0 deletions src/Analyser/ExprHandler/AssignOpHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
use PHPStan\Type\ObjectType;
use PHPStan\Type\StaticTypeFactory;
use PHPStan\Type\Type;
use TypeError;
use function array_merge;
use function count;
use function get_class;
use function is_string;
use function sprintf;
Expand Down Expand Up @@ -296,6 +298,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$writeFlow = VariableFlow::sequence(
$rhsResult->getVariableFlow(),
$valueFlowWrite !== null && $context->isValueConsumed() ? VariableFlow::inputs($valueFlowWrite->getId(), $context->getValueFlowTarget() !== null ? $context->getValueFlowTarget()->getId() : null) : null,
// an illegal offset throws before the write
count($target->getIllegalOffsetDimFetches()) > 0 ? VariableFlow::throwing(new ObjectType(TypeError::class), true) : null,
VariableFlowBuilder::targetWrite($expr->var, VariableWrite::KIND_READ_MODIFY_WRITE, $scope, $storage),
);
$variableFlow = VariableFlow::sequence(
Expand Down
2 changes: 1 addition & 1 deletion src/Analyser/ExprHandler/EmptyHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
$beforeScope = $scope;
$nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->expr);
$scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->expr);
$exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep());
$exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()->enterIssetTarget());
$scope = $exprResult->getScope();
$scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions());
$scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $expr->expr);
Expand Down
22 changes: 22 additions & 0 deletions src/Analyser/ExprHandler/Helper/IllegalOffsetTypeHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser\ExprHandler\Helper;

use PHPStan\Turbo\ShadowedByTurboExtension;
use PHPStan\Type\Type;

#[ShadowedByTurboExtension(implementation: __DIR__ . '/../../../../turbo-ext/src/IllegalOffsetTypeHelper.cpp')]
final class IllegalOffsetTypeHelper
{

/**
* On PHP 8.0+ using an array or an object as an array/string offset throws TypeError
* (https://wiki.php.net/rfc/engine_warnings). Float, bool and null keys are coerced
* with at most a deprecation, resource keys with a warning - no TypeError.
*/
public static function mayOffsetThrowTypeError(Type $offsetType): bool
{
return !$offsetType->isArray()->no() || !$offsetType->isObject()->no();

Check warning on line 19 in src/Analyser/ExprHandler/Helper/IllegalOffsetTypeHelper.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ */ public static function mayOffsetThrowTypeError(Type $offsetType): bool { - return !$offsetType->isArray()->no() || !$offsetType->isObject()->no(); + return !$offsetType->isArray()->no() || $offsetType->isObject()->yes(); } }

Check warning on line 19 in src/Analyser/ExprHandler/Helper/IllegalOffsetTypeHelper.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ */ public static function mayOffsetThrowTypeError(Type $offsetType): bool { - return !$offsetType->isArray()->no() || !$offsetType->isObject()->no(); + return $offsetType->isArray()->yes() || !$offsetType->isObject()->no(); } }

Check warning on line 19 in src/Analyser/ExprHandler/Helper/IllegalOffsetTypeHelper.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ */ public static function mayOffsetThrowTypeError(Type $offsetType): bool { - return !$offsetType->isArray()->no() || !$offsetType->isObject()->no(); + return !$offsetType->isArray()->no() || $offsetType->isObject()->yes(); } }

Check warning on line 19 in src/Analyser/ExprHandler/Helper/IllegalOffsetTypeHelper.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ */ public static function mayOffsetThrowTypeError(Type $offsetType): bool { - return !$offsetType->isArray()->no() || !$offsetType->isObject()->no(); + return $offsetType->isArray()->yes() || !$offsetType->isObject()->no(); } }
}

}
2 changes: 1 addition & 1 deletion src/Analyser/ExprHandler/IssetHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
foreach ($expr->vars as $var) {
$nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var);
$scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var);
$varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep());
$varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep()->enterIssetTarget());
$varResults[] = $varResult;
$scope = $varResult->getScope();
$hasYield = $hasYield || $varResult->hasYield();
Expand Down
21 changes: 18 additions & 3 deletions src/Analyser/ExpressionContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ private function __construct(
private ?bool $valueConsumed = null,
private ?Type $passedToType = null,
private ?Type $nativePassedToType = null,
private bool $issetTarget = false,
)
{
}
Expand All @@ -50,7 +51,7 @@ public static function createDeep(bool $resolveTemplateArguments = true): self
*/
public function enterDeep(): self
{
if ($this->isDeep && $this->valueFlowTarget === null && !$this->arrayDimFetchRoot && !$this->unsetTarget && $this->passedToType === null && $this->nativePassedToType === null) {
if ($this->isDeep && $this->valueFlowTarget === null && !$this->arrayDimFetchRoot && !$this->unsetTarget && !$this->issetTarget && $this->passedToType === null && $this->nativePassedToType === null) {
return $this;
}

Expand Down Expand Up @@ -78,7 +79,7 @@ public function enterDeepKeepingValueFlow(): self
*/
public function withoutValueFlow(): self
{
if ($this->valueFlowTarget === null && !$this->arrayDimFetchRoot && !$this->unsetTarget && $this->passedToType === null && $this->nativePassedToType === null) {
if ($this->valueFlowTarget === null && !$this->arrayDimFetchRoot && !$this->unsetTarget && !$this->issetTarget && $this->passedToType === null && $this->nativePassedToType === null) {
return $this;
}

Expand Down Expand Up @@ -136,7 +137,7 @@ public function withoutTemplateArgumentResolution(): self
return $this;
}

return new self($this->isDeep, $this->inAssignRightSideVariableName, $this->inAssignRightSideExpr, $this->inThrow, $this->inAssignRightSideType, $this->inAssignRightSideNativeType, false, $this->valueFlowTarget, $this->valueFlowDirect, $this->arrayDimFetchRoot, $this->unsetTarget, $this->valueConsumed);
return new self($this->isDeep, $this->inAssignRightSideVariableName, $this->inAssignRightSideExpr, $this->inThrow, $this->inAssignRightSideType, $this->inAssignRightSideNativeType, false, $this->valueFlowTarget, $this->valueFlowDirect, $this->arrayDimFetchRoot, $this->unsetTarget, $this->valueConsumed, issetTarget: $this->issetTarget);
}

public function enterThrow(): self
Expand Down Expand Up @@ -243,4 +244,18 @@ public function isUnsetTarget(): bool
return $this->unsetTarget;
}

/**
* The expression is an isset() or empty() subject: an illegal offset of
* a string container makes it false instead of throwing.
*/
public function enterIssetTarget(): self
{
return new self($this->isDeep, $this->inAssignRightSideVariableName, $this->inAssignRightSideExpr, $this->inThrow, $this->inAssignRightSideType, $this->inAssignRightSideNativeType, $this->resolveTemplateArguments, issetTarget: true);
}

public function isIssetTarget(): bool
{
return $this->issetTarget;
}

}
14 changes: 14 additions & 0 deletions src/Analyser/PreparedAssignTarget.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ final class PreparedAssignTarget
* @param non-empty-list<array{Type, ExistingArrayDimFetch}>|null $existingOffsetTypes
* @param non-empty-list<array{Type, ExistingArrayDimFetch}>|null $existingOffsetNativeTypes
* @param ExpressionResult[] $targetChainResults
* @param list<ArrayDimFetch> $illegalOffsetDimFetches
*/
public function __construct(
private string $kind,
Expand Down Expand Up @@ -71,6 +72,7 @@ public function __construct(
private array $targetChainResults = [],
private ?ExpressionResult $variableNameResult = null,
private ?Dependencies $dependencies = null,
private array $illegalOffsetDimFetches = [],
)
{
}
Expand Down Expand Up @@ -302,4 +304,16 @@ public function getVariableNameResult(): ?ExpressionResult
return $this->variableNameResult;
}

/**
* The links of an offset write whose offset may be an array or an object,
* which throws TypeError on PHP 8+ - after the assigned value was evaluated
* and an undefined root was turned into an array.
*
* @return list<ArrayDimFetch>
*/
public function getIllegalOffsetDimFetches(): array
{
return $this->illegalOffsetDimFetches;
}

}
6 changes: 6 additions & 0 deletions src/Php/PhpVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,12 @@ public function throwsValueErrorForInternalFunctions(): bool
return $this->versionId >= 80000;
}

// see https://wiki.php.net/rfc/engine_warnings - "Illegal offset type" family
public function throwsTypeErrorForIllegalOffsets(): bool
{
return $this->versionId >= 80000;
}

public function supportsHhPrintfSpecifier(): bool
{
return $this->versionId >= 80000;
Expand Down
2 changes: 1 addition & 1 deletion src/Turbo/TurboExtensionEnabler.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
final class TurboExtensionEnabler
{

public const EXPECTED_EXTENSION_VERSION = '28c29b8';
public const EXPECTED_EXTENSION_VERSION = '33375c3';

private static bool $active = false;

Expand Down
Loading
Loading