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
151 changes: 78 additions & 73 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,14 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult
$isUnsealed = $this->isUnsealed();
if (!$isUnsealed->yes()) {
if ($type instanceof self && count($this->keyTypes) === 0) {
return AcceptsResult::createFromBoolean(count($type->keyTypes) === 0);
if (count($type->keyTypes) === 0) {
return AcceptsResult::createYes();
}

return AcceptsResult::createNo($isUnsealed->no() ? array_map(
static fn (Type $extraKeyType): string => sprintf('Sealed array shape does not accept array with extra key %s.', $extraKeyType->describe(VerbosityLevel::precise())),
$type->keyTypes,
) : []);
}
}

Expand All @@ -537,19 +544,43 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult
return $result;
}

if ($result->no()) {
return $result;
}

[$unsealedKeyType, $unsealedValueType] = $this->unsealed;

if ($isUnsealed->no()) {
if (!$type->isConstantArray()->yes()) {
// Checked before bailing out on an already-failing $result: how the shape handles keys
// it does not declare explains the rejection on its own, and checkOurKeys() often
// returns "no" without giving any reason at all.
if (!$type->isArray()->no() && !$type->isConstantArray()->yes()) {
if ($isUnsealed->no()) {
return $result->and(AcceptsResult::createNo([
'Sealed array shape can only accept a constant array. Extra keys are not allowed.',
]));
}

$otherKeyType = $type->getIterableKeyType();
$otherValueType = $type->getIterableValueType();

return $result->and(self::decorateUnsealedReasons(
$unsealedKeyType->accepts($otherKeyType, $strictTypes),
static fn (): string => sprintf(
'Unsealed array key type %s does not accept key type %s',
$unsealedKeyType->describe(VerbosityLevel::value()),
$otherKeyType->describe(VerbosityLevel::value()),
),
))->and(self::decorateUnsealedReasons(
$unsealedValueType->accepts($otherValueType, $strictTypes),
static fn (): string => sprintf(
'Unsealed array value type %s does not accept value type %s',
$unsealedValueType->describe(VerbosityLevel::value()),
$otherValueType->describe(VerbosityLevel::value()),
),
));
}

if ($result->no()) {
return $result;
}

if ($isUnsealed->no()) {
$constantArrays = $type->getConstantArrays();
if (count($constantArrays) !== 1) {
throw new ShouldNotHappenException('Type with more than one constant array occurred, should have been eliminated with `instanceof CompoundType` above.');
Expand Down Expand Up @@ -579,11 +610,6 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult
return $result;
}

if (!$type->isConstantArray()->yes()) {
return $result->and($unsealedKeyType->accepts($type->getIterableKeyType(), $strictTypes))
->and($unsealedValueType->accepts($type->getIterableValueType(), $strictTypes));
}

$constantArrays = $type->getConstantArrays();
if (count($constantArrays) !== 1) {
throw new ShouldNotHappenException('Type with more than one constant array occurred, should have been eliminated with `instanceof CompoundType` above.');
Expand All @@ -600,94 +626,73 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult
}

foreach ($keys as [$i, $extraKeyType]) {
$acceptsKey = $unsealedKeyType->accepts($extraKeyType, $strictTypes)->decorateReasons(
static fn (string $reason) => sprintf(
'Unsealed array key type %s does not accept extra key type %s: %s',
$result = $result->and(self::decorateUnsealedReasons(
$unsealedKeyType->accepts($extraKeyType, $strictTypes),
static fn (): string => sprintf(
'Unsealed array key type %s does not accept extra key type %s',
$unsealedKeyType->describe(VerbosityLevel::value()),
$extraKeyType->describe(VerbosityLevel::value()),
$reason,
),
);
if (!$acceptsKey->yes() && count($acceptsKey->reasons) === 0) {
$acceptsKey = new AcceptsResult($acceptsKey->result, [
sprintf(
'Unsealed array key type %s does not accept extra key type %s.',
$unsealedKeyType->describe(VerbosityLevel::value()),
$extraKeyType->describe(VerbosityLevel::value()),
),
]);
}
$result = $result->and($acceptsKey);
));

$extraValueType = $constantArray->getValueTypes()[$i];
$acceptsValue = $unsealedValueType->accepts($extraValueType, $strictTypes)->decorateReasons(
static fn (string $reason) => sprintf(
'Unsealed array value type %s does not accept extra offset %s with value type %s: %s',
$result = $result->and(self::decorateUnsealedReasons(
$unsealedValueType->accepts($extraValueType, $strictTypes),
static fn (): string => sprintf(
'Unsealed array value type %s does not accept extra offset %s with value type %s',
$unsealedValueType->describe(VerbosityLevel::value()),
$extraKeyType->describe(VerbosityLevel::value()),
$extraValueType->describe(VerbosityLevel::value()),
$reason,
),
);
if (!$acceptsValue->yes() && count($acceptsValue->reasons) === 0) {
$acceptsValue = new AcceptsResult($acceptsValue->result, [
sprintf(
'Unsealed array value type %s does not accept extra offset %s with value type %s.',
$unsealedValueType->describe(VerbosityLevel::value()),
$extraKeyType->describe(VerbosityLevel::value()),
$extraValueType->describe(VerbosityLevel::value()),
),
]);
}
$result = $result->and($acceptsValue);
));
}

$otherUnsealed = $constantArray->unsealed;
if ($otherUnsealed !== null && !$constantArray->isUnsealed()->no()) {
[$otherUnsealedKeyType, $otherUnsealedValueType] = $otherUnsealed;

$acceptsUnsealedKey = $unsealedKeyType->accepts($otherUnsealedKeyType, $strictTypes)->decorateReasons(
static fn (string $reason) => sprintf(
'Unsealed array key type %s does not accept unsealed array key type %s: %s',
$result = $result->and(self::decorateUnsealedReasons(
$unsealedKeyType->accepts($otherUnsealedKeyType, $strictTypes),
static fn (): string => sprintf(
'Unsealed array key type %s does not accept unsealed array key type %s',
$unsealedKeyType->describe(VerbosityLevel::value()),
$otherUnsealedKeyType->describe(VerbosityLevel::value()),
$reason,
),
);
if (!$acceptsUnsealedKey->yes() && count($acceptsUnsealedKey->reasons) === 0) {
$acceptsUnsealedKey = new AcceptsResult($acceptsUnsealedKey->result, [
sprintf(
'Unsealed array key type %s does not accept unsealed array key type %s.',
$unsealedKeyType->describe(VerbosityLevel::value()),
$otherUnsealedKeyType->describe(VerbosityLevel::value()),
),
]);
}
$result = $result->and($acceptsUnsealedKey);
));

$acceptsUnsealedValue = $unsealedValueType->accepts($otherUnsealedValueType, $strictTypes)->decorateReasons(
static fn (string $reason) => sprintf(
'Unsealed array value type %s does not accept unsealed array value type %s: %s',
$result = $result->and(self::decorateUnsealedReasons(
$unsealedValueType->accepts($otherUnsealedValueType, $strictTypes),
static fn (): string => sprintf(
'Unsealed array value type %s does not accept unsealed array value type %s',
$unsealedValueType->describe(VerbosityLevel::value()),
$otherUnsealedValueType->describe(VerbosityLevel::value()),
$reason,
),
);
if (!$acceptsUnsealedValue->yes() && count($acceptsUnsealedValue->reasons) === 0) {
$acceptsUnsealedValue = new AcceptsResult($acceptsUnsealedValue->result, [
sprintf(
'Unsealed array value type %s does not accept unsealed array value type %s.',
$unsealedValueType->describe(VerbosityLevel::value()),
$otherUnsealedValueType->describe(VerbosityLevel::value()),
),
]);
}
$result = $result->and($acceptsUnsealedValue);
));
}

return $result;
}

/**
* Makes sure a failing unsealed-part check is never reported without an explanation:
* inner reasons are prefixed with $describeFailure, which also becomes the only reason
* when the inner check produced none.
*
* @param callable(): string $describeFailure
*/
private static function decorateUnsealedReasons(AcceptsResult $result, callable $describeFailure): AcceptsResult
{
if ($result->yes()) {
return $result;
}

if (count($result->reasons) === 0) {
return new AcceptsResult($result->result, [sprintf('%s.', $describeFailure())]);
}

return $result->decorateReasons(static fn (string $reason): string => sprintf('%s: %s', $describeFailure(), $reason));
}

private function checkOurKeys(Type $type, bool $strictTypes): AcceptsResult
{
$result = AcceptsResult::createYes();
Expand Down
21 changes: 21 additions & 0 deletions tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1372,4 +1372,25 @@ public function testBug12984(): void
$this->analyse([__DIR__ . '/data/bug-12984.php'], []);
}

public function testBug15234(): void
{
$this->analyse([__DIR__ . '/data/bug-15234.php'], [
[
'Method Bug15234\\SwitchApi::queryDeviceSignalStrength() should return array{eventTime: 0}|array{eventTime: int, indicatorType: string, signalLevel: string, signal: int} but returns array<string, mixed>.',
30,
"\u{2022} Type #1 from the union: Sealed array shape can only accept a constant array. Extra keys are not allowed.\n\u{2022} Type #2 from the union: Sealed array shape can only accept a constant array. Extra keys are not allowed.",
],
[
'Method Bug15234\\SwitchApi::queryDeviceProperties() should return array<string, array{custom_name: string, dp_id: int, time: int, type: string, value: bool|float|int|string}> but returns array<mixed, array<mixed, mixed>>.',
40,
'Sealed array shape can only accept a constant array. Extra keys are not allowed.',
],
[
'Method Bug15234\\SwitchApi::queryDeviceFunctions() should return array{category: string, functions: array{type: string, values: array<string, bool|float|int|list<string>|string>}} but returns non-empty-array<string, mixed>.',
62,
"\u{2022} Offset 'functions' (array{type: string, values: array<string, bool|float|int|list<string>|string>}) does not accept type array<mixed, array<mixed, mixed>>: Sealed array shape can only accept a constant array. Extra keys are not allowed.\n\u{2022} Sealed array shape can only accept a constant array. Extra keys are not allowed.",
],
]);
}

}
64 changes: 64 additions & 0 deletions tests/PHPStan/Rules/Methods/data/bug-15234.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php declare(strict_types = 1);

namespace Bug15234;

class SwitchApi
{
/**
* @return mixed
*/
private function jsonDecode(string $json)
{
return json_decode($json, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR);
}

/**
* @param 'get' $method
*
* @return array<string, mixed>
*/
public function sendRequest(string $method, string $path)
{
return [];
}

/**
* @return array{eventTime: 0}|array{eventTime: int, indicatorType: string, signalLevel: string, signal: int}
*/
public function queryDeviceSignalStrength(string $deviceId): array
{
return $this->sendRequest('get', '/v2.0/cloud/thing/' . $deviceId . '/WiFi/signal');
}

/**
* @return array<string, array{custom_name: string, dp_id: int, time: int, type: string, value: scalar}>
*/
public function queryDeviceProperties(string $deviceId): array
{
$response = $this->sendRequest('get', '/v2.0/cloud/thing/' . $deviceId . '/shadow/properties');

return array_combine(
array_map(static fn ($v) => $v['code'], $response['properties']),
array_map(static fn ($v) => array_diff_key($v, ['code' => true]), $response['properties'])
);
}

/**
* @return array{category: string, functions: array{type: string, values: array<string, scalar|list<string>>}}
*/
public function queryDeviceFunctions(string $deviceId): array
{
$response = $this->sendRequest('get', '/v1.0/iot-03/devices/' . $deviceId . '/functions');

$response['functions'] = array_combine(
array_map(static fn ($v) => $v['code'], $response['functions']),
array_map(function ($v) {
$v['values'] = $this->jsonDecode($v['values']);

return array_diff_key($v, ['code' => true, 'desc' => true, 'name' => true]);
}, $response['functions'])
);

return $response;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,7 @@ public function testBug13438d(): void
[
'Property Bug13438d\Test::$queue (array{}) does not accept array{1}.',
18,
'Sealed array shape does not accept array with extra key 0.',
],
]);
}
Expand All @@ -884,6 +885,7 @@ public function testBug13438e(): void
[
'Property Bug13438e\Test::$queue (array{}) does not accept array{1}.',
18,
'Sealed array shape does not accept array with extra key 0.',
],
]);
}
Expand Down
35 changes: 33 additions & 2 deletions tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use PHPStan\TrinaryLogic;
use PHPStan\Type\Accessory\HasOffsetType;
use PHPStan\Type\Accessory\HasOffsetValueType;
use PHPStan\Type\Accessory\NonEmptyArrayType;
use PHPStan\Type\ArrayType;
use PHPStan\Type\BooleanType;
use PHPStan\Type\CallableType;
Expand Down Expand Up @@ -460,7 +461,7 @@ public static function dataAcceptsWithBleedingEdge(): array
new ConstantArrayType([], []),
new ConstantArrayType([new ConstantStringType('a')], [new StringType()]),
TrinaryLogic::createNo(),
[],
['Sealed array shape does not accept array with extra key \'a\'.'],
],

// non-empty array (sealed) does not accept extra keys
Expand All @@ -485,6 +486,26 @@ public static function dataAcceptsWithBleedingEdge(): array
['Sealed array shape can only accept a constant array. Extra keys are not allowed.'],
],

// sealed array does not accept general array even when an offset already mismatches
[
new ConstantArrayType([new ConstantStringType('a')], [new StringType()]),
new ArrayType(new StringType(), new IntegerType()),
TrinaryLogic::createNo(),
['Sealed array shape can only accept a constant array. Extra keys are not allowed.'],
],

// sealed array does not accept general array with a known offset
[
new ConstantArrayType([new ConstantStringType('a')], [new StringType()]),
new IntersectionType([
new ArrayType(new StringType(), new IntegerType()),
new HasOffsetValueType(new ConstantStringType('a'), new IntegerType()),
new NonEmptyArrayType(),
]),
TrinaryLogic::createNo(),
['Sealed array shape can only accept a constant array. Extra keys are not allowed.'],
],

// sealed array does not accept unsealed array
[
new ConstantArrayType([new ConstantStringType('a')], [new StringType()]),
Expand All @@ -493,6 +514,14 @@ public static function dataAcceptsWithBleedingEdge(): array
['Sealed array shape does not accept unsealed array shape.'],
],

// unsealed array does not accept general array with an incompatible key type
[
new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new IntegerType(), new StringType()]),
new ArrayType(new StringType(), new StringType()),
TrinaryLogic::createNo(),
['Unsealed array key type int does not accept key type string.'],
],

// unsealed array accepts compatible general array
[
new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]),
Expand Down Expand Up @@ -523,7 +552,9 @@ public static function dataAcceptsWithBleedingEdge(): array
new HasOffsetValueType(new ConstantStringType('a'), new StringType()),
]),
TrinaryLogic::createNo(),
[],
[
'Unsealed array value type int does not accept value type string.',
],
],

// unsealed array must check extra keys against its own unsealed types
Expand Down
Loading