Skip to content
25 changes: 19 additions & 6 deletions src/Rules/Arrays/OffsetAccessValueAssignmentRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,28 @@ public function processNode(Node $node, Scope $scope): array
$assignedValueType = $scope->getType($node);
}

// The offset type (null for an append) lets method-level template types and conditional types of offsetSet()
// (e.g. `@param T[TOffset] $value`) resolve against the written key. An offset that is not accepted is reported
// by OffsetAccessAssignmentRule, so only the value is checked here.
$dimType = $arrayDimFetch->dim !== null ? $scope->getType($arrayDimFetch->dim) : null;
$acceptsValue = static function (Type $varType) use ($dimType, $assignedValueType): bool {
if (!$varType->setOffsetValueType($dimType, $assignedValueType) instanceof ErrorType) {
return true;
}

// The write is rejected. If the offset alone is accepted, the value is what is rejected.
if (!$varType->setOffsetValueType($dimType, new MixedType()) instanceof ErrorType) {
return false;
}

return !$varType->setOffsetValueType(new MixedType(), $assignedValueType) instanceof ErrorType;
};

$arrayTypeResult = $this->ruleLevelHelper->findTypeToCheck(
$scope,
$arrayDimFetch->var,
'',
static function (Type $varType) use ($assignedValueType): bool {
$result = $varType->setOffsetValueType(new MixedType(), $assignedValueType);
return !($result instanceof ErrorType);
},
$acceptsValue,
);
$arrayType = $arrayTypeResult->getType();
if ($arrayType instanceof ErrorType) {
Expand All @@ -81,8 +95,7 @@ static function (Type $varType) use ($assignedValueType): bool {
if (!$isOffsetAccessible->yes()) {
return [];
}
$resultType = $arrayType->setOffsetValueType(new MixedType(), $assignedValueType);
if (!$resultType instanceof ErrorType) {
if ($acceptsValue($arrayType)) {
return [];
}

Expand Down
35 changes: 28 additions & 7 deletions src/Type/ObjectType.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
use PHPStan\Reflection\Dummy\DummyPropertyReflection;
use PHPStan\Reflection\ExtendedMethodReflection;
use PHPStan\Reflection\ExtendedPropertyReflection;
use PHPStan\Reflection\ParametersAcceptor;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Reflection\Php\UniversalObjectCratesClassReflectionExtension;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Reflection\ReflectionProviderStaticAccessor;
Expand Down Expand Up @@ -1563,7 +1565,7 @@ public function hasOffsetValueType(Type $offsetType): TrinaryLogic
public function getOffsetValueType(Type $offsetType): Type
{
if ($this->isInstanceOf(ArrayAccess::class)->yes()) {
return RecursionGuard::run($this, fn (): Type => $this->getMethod('offsetGet', new OutOfClassScope())->getOnlyVariant()->getReturnType());
return RecursionGuard::run($this, fn (): Type => $this->selectArrayAccessVariant('offsetGet', [$offsetType])->getReturnType());
}

if (!$this->isExtraOffsetAccessibleClass()->no()) {
Expand All @@ -1573,16 +1575,39 @@ public function getOffsetValueType(Type $offsetType): Type
return new ErrorType();
}

/**
* The variant of an ArrayAccess method (offsetGet() or offsetSet()) for an offset access,
* with its method-level template types (e.g. `@template TOffset of key-of<T>`) resolved
* against the offset (and written value) types, as for an explicit method call.
*
* @param list<Type> $argTypes
*/
private function selectArrayAccessVariant(string $methodName, array $argTypes): ParametersAcceptor
{
$variants = $this->getMethod($methodName, new OutOfClassScope())->getVariants();

// Nothing to resolve, so skip the resolution on this hot path.
if (count($variants) === 1 && !ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableType($variants[0])) {
return $variants[0];
}

return ParametersAcceptorSelector::selectFromTypes($argTypes, $variants, false);
}

public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type
{
if ($this->isOffsetAccessible()->no()) {
return new ErrorType();
}

if ($this->isInstanceOf(ArrayAccess::class)->yes()) {
if ($offsetType === null) {
$offsetType = new NullType();
}

$acceptedValueType = new NeverType();
$acceptedOffsetType = RecursionGuard::run($this, function () use (&$acceptedValueType): Type {
$parameters = $this->getMethod('offsetSet', new OutOfClassScope())->getOnlyVariant()->getParameters();
$acceptedOffsetType = RecursionGuard::run($this, function () use ($offsetType, $valueType, &$acceptedValueType): Type {
$parameters = $this->selectArrayAccessVariant('offsetSet', [$offsetType, $valueType])->getParameters();
if (count($parameters) < 2) {
throw new ShouldNotHappenException(sprintf(
'Method %s::%s() has less than 2 parameters.',
Expand All @@ -1597,10 +1622,6 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni
return $offsetParameter->getType();
});

if ($offsetType === null) {
$offsetType = new NullType();
}

if (
(!$offsetType instanceof MixedType && !$acceptedOffsetType->isSuperTypeOf($offsetType)->yes())
|| (!$valueType instanceof MixedType && !$acceptedValueType->isSuperTypeOf($valueType)->yes())
Expand Down
66 changes: 66 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-8549.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php // lint >= 8.0

namespace Bug8549Types;

use function PHPStan\Testing\assertType;

/**
* @template TArray of array
* @extends \ArrayAccess<key-of<TArray>, value-of<TArray>>
*/
interface Options extends \ArrayAccess
{

/**
* @param key-of<TArray> $offset
*/
public function offsetExists(mixed $offset): bool;

/**
* @template TOffset of key-of<TArray>
* @param TOffset $offset
* @return TArray[TOffset]
*/
public function offsetGet(mixed $offset);

/**
* @template TOffset of key-of<TArray>
* @param TOffset $offset
* @param TArray[TOffset] $value
*/
public function offsetSet(mixed $offset, mixed $value): void;

/**
* @template TOffset of key-of<TArray>
* @param TOffset $offset
*/
public function offsetUnset(mixed $offset): void;

}

class Foo
{

/**
* @param Options<array{name: string, age: int}> $options
*/
public function read(Options $options): void
{
assertType('string', $options['name']);
assertType('int', $options['age']);
assertType('int', $options['age'] ??= 5);
assertType('string', $options['name'] ??= 'x');
assertType('int', $options['age'] += 1);
assertType('non-falsy-string', $options['name'] .= 'x');
}

/**
* @param Options<array{name: string, age: int}> $options
* @param 'name'|'age' $key
*/
public function unionKey(Options $options, string $key): void
{
assertType('int|string', $options[$key]);
}

}
7 changes: 7 additions & 0 deletions tests/PHPStan/Rules/Arrays/OffsetAccessAssignOpRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,11 @@ public function testRuleWithNullsafeVariant(): void
$this->analyse([__DIR__ . '/data/offset-access-assignop-nullsafe.php'], []);
}

#[RequiresPhp('>= 8.0.0')]
public function testBug8549(): void
{
$this->checkUnions = true;
$this->analyse([__DIR__ . '/data/bug-8549.php'], []);
}

}
24 changes: 24 additions & 0 deletions tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,4 +235,28 @@ public function testAppendToArrayWithPhpIntMaxKey(): void
]);
}

#[RequiresPhp('>= 8.0.0')]
public function testBug8549(): void
{
$this->checkUnionTypes = true;
$this->analyse([__DIR__ . '/data/bug-8549.php'], [
[
'Cannot assign offset \'nope\' to Bug8549\Options<array<string, int|string>>.',
64,
],
[
'Cannot assign new offset to Bug8549\Options<array<string, int|string>>.',
65,
],
[
'Cannot assign offset 1 to Bug8549\Options<array<string, mixed>>.',
75,
],
[
'Cannot assign offset \'name\'|\'nope\' to Bug8549\Options<array<string, int|string>>.',
110,
],
]);
}

}
38 changes: 38 additions & 0 deletions tests/PHPStan/Rules/Arrays/OffsetAccessValueAssignmentRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,42 @@ public function testBug10783(): void
$this->analyse([__DIR__ . '/../Variables/data/bug-10783.php'], []);
}

#[RequiresPhp('>= 8.0.0')]
public function testBug8549(): void
{
$this->analyse([__DIR__ . '/data/bug-8549.php'], [
[
'Bug8549\Options<array{name: string}> does not accept int.',
49,
],
[
'Bug8549\Options<array{name: string, age: int}> does not accept string.',
63,
],
[
'Bug8549\Options<array{name: string, age: int}> does not accept string.',
92,
],
[
'Bug8549\Options<array{name: string, age: int}> does not accept float.',
109,
],
]);
}

#[RequiresPhp('>= 8.0.0')]
public function testAppendResolvesOffsetSetAgainstNull(): void
{
$this->analyse([__DIR__ . '/data/offset-access-value-assignment-append.php'], [
[
'OffsetAccessValueAssignmentAppend\Storage does not accept string.',
35,
],
[
'OffsetAccessValueAssignmentAppend\Storage does not accept int.',
38,
],
]);
}

}
113 changes: 113 additions & 0 deletions tests/PHPStan/Rules/Arrays/data/bug-8549.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php // lint >= 8.0

namespace Bug8549;

// @link https://github.com/symfony/symfony/blob/6.2/src/Symfony/Component/OptionsResolver/Options.php

/**
* @template TArray of array
* @extends \ArrayAccess<key-of<TArray>, value-of<TArray>>
*/
interface Options extends \ArrayAccess {

/**
* @param key-of<TArray> $offset
*/
public function offsetExists(mixed $offset): bool;

/**
* @template TOffset of key-of<TArray>
* @param TOffset $offset
* @return TArray[TOffset]
*/
public function offsetGet(mixed $offset);

/**
* @template TOffset of key-of<TArray>
* @param TOffset $offset
* @param TArray[TOffset] $value
*/
public function offsetSet(mixed $offset, mixed $value): void;

/**
* @template TOffset of key-of<TArray>
* @param TOffset $offset
*/
public function offsetUnset(mixed $offset): void;

}

class HelloWorld
{
/**
* @param Options<array{name: string}> $options
*/
public function sayHello(Options $options): void
{
\PHPStan\dumpType($options['name']);
$options['name'] = "foo";
$options['name'] = 1;
}
}

class MoreCases
{

/**
* @param Options<array{name: string, age: int}> $options
*/
public function shape(Options $options): void
{
$options['name'] = 'foo';
$options['age'] = 1;
$options['age'] = 'foo';
$options['nope'] = 'foo';
$options[] = 'foo';
}

/**
* @param Options<array<string, mixed>> $options
*/
public function map(Options $options, mixed $value): void
{
$options['context'] = 'edit';
$options['_fields'] = $value;
$options[1] = 'edit';
}

}

class CompoundAssignments
{

/**
* @param Options<array{name: string, age: int}> $options
*/
public function compound(Options $options): void
{
$options['age'] ??= 5;
$options['name'] ??= 'x';
$options['age'] += 1;
$options['name'] .= 'x';
$options['age'] .= 'x';
}

}

class UnionKeys
{

/**
* @param Options<array{name: string, age: int}> $options
* @param 'name'|'age' $key
* @param 'name'|'nope' $partlyUnknownKey
*/
public function union(Options $options, string $key, string $partlyUnknownKey): void
{
$options[$key] = 'foo';
$options[$key] = 1;
$options[$key] = 1.5;
$options[$partlyUnknownKey] = 'foo';
}

}
Loading
Loading