mirror of
https://github.com/danog/psalm.git
synced 2024-12-02 09:37:59 +01:00
6e2effaf9a
Fixes vimeo/psalm#10346
625 lines
24 KiB
PHP
625 lines
24 KiB
PHP
<?php
|
|
|
|
namespace Psalm\Tests\TypeReconciliation;
|
|
|
|
use Psalm\Config;
|
|
use Psalm\Context;
|
|
use Psalm\Tests\TestCase;
|
|
use Psalm\Tests\Traits\InvalidCodeAnalysisTestTrait;
|
|
use Psalm\Tests\Traits\ValidCodeAnalysisTestTrait;
|
|
|
|
class ArrayKeyExistsTest extends TestCase
|
|
{
|
|
use ValidCodeAnalysisTestTrait;
|
|
use InvalidCodeAnalysisTestTrait;
|
|
|
|
public function providerValidCodeParse(): iterable
|
|
{
|
|
return [
|
|
'arrayKeyExistsOnStringArrayShouldInformArrayness' => [
|
|
'code' => '<?php
|
|
/**
|
|
* @param string[] $a
|
|
* @return array{b: string, ...}
|
|
*/
|
|
function foo(array $a) {
|
|
if (array_key_exists("b", $a)) {
|
|
return $a;
|
|
}
|
|
|
|
throw new \Exception("bad");
|
|
}',
|
|
],
|
|
'arrayKeyExistsThrice' => [
|
|
'code' => '<?php
|
|
function three(array $a): void {
|
|
if (!array_key_exists("a", $a)
|
|
|| !array_key_exists("b", $a)
|
|
|| !array_key_exists("c", $a)
|
|
|| (!is_string($a["a"]) && !is_int($a["a"]))
|
|
|| (!is_string($a["b"]) && !is_int($a["b"]))
|
|
|| (!is_string($a["c"]) && !is_int($a["c"]))
|
|
) {
|
|
throw new \Exception();
|
|
}
|
|
|
|
echo $a["a"];
|
|
echo $a["b"];
|
|
}',
|
|
],
|
|
'arrayKeyExistsNegation' => [
|
|
'code' => '<?php
|
|
function getMethodName(array $data = []): void {
|
|
if (\array_key_exists("custom_name", $data) && $data["custom_name"] !== null) {
|
|
}
|
|
/** @psalm-check-type-exact $data = array<array-key, mixed> */
|
|
}
|
|
',
|
|
],
|
|
'arrayKeyExistsNoSideEffects' => [
|
|
'code' => '<?php
|
|
function getMethodName(array $ddata = []): void {
|
|
if (\array_key_exists("redirect", $ddata)) {
|
|
return;
|
|
}
|
|
if (random_int(0, 1)) {
|
|
$ddata["type"] = "test";
|
|
}
|
|
/** @psalm-check-type-exact $ddata = array<array-key, mixed> */
|
|
}
|
|
',
|
|
],
|
|
'arrayKeyExistsTwice' => [
|
|
'code' => '<?php
|
|
function two(array $a): void {
|
|
if (!array_key_exists("a", $a) || !(is_string($a["a"]) || is_int($a["a"])) ||
|
|
!array_key_exists("b", $a) || !(is_string($a["b"]) || is_int($a["b"]))
|
|
) {
|
|
throw new \Exception();
|
|
}
|
|
|
|
echo $a["a"];
|
|
echo $a["b"];
|
|
}',
|
|
],
|
|
'assertConstantOffsetsInMethod' => [
|
|
'code' => '<?php
|
|
class C {
|
|
public const ARR = [
|
|
"a" => ["foo" => true],
|
|
"b" => []
|
|
];
|
|
|
|
public function bar(string $key): bool {
|
|
if (!array_key_exists($key, self::ARR) || !array_key_exists("foo", self::ARR[$key])) {
|
|
return false;
|
|
}
|
|
|
|
return self::ARR[$key]["foo"];
|
|
}
|
|
}',
|
|
'assertions' => [],
|
|
'ignored_issues' => ['MixedReturnStatement', 'MixedInferredReturnType'],
|
|
],
|
|
'assertSelfClassConstantOffsetsInFunction' => [
|
|
'code' => '<?php
|
|
namespace Ns;
|
|
|
|
class C {
|
|
public const ARR = [
|
|
"a" => ["foo" => true],
|
|
"b" => []
|
|
];
|
|
|
|
public function bar(?string $key): bool {
|
|
if ($key === null || !array_key_exists($key, self::ARR) || !array_key_exists("foo", self::ARR[$key])) {
|
|
return false;
|
|
}
|
|
|
|
return self::ARR[$key]["foo"];
|
|
}
|
|
}',
|
|
'assertions' => [],
|
|
'ignored_issues' => ['MixedReturnStatement', 'MixedInferredReturnType'],
|
|
],
|
|
'assertNamedClassConstantOffsetsInFunction' => [
|
|
'code' => '<?php
|
|
namespace Ns;
|
|
|
|
class C {
|
|
public const ARR = [
|
|
"a" => ["foo" => true],
|
|
"b" => [],
|
|
];
|
|
}
|
|
|
|
function bar(?string $key): bool {
|
|
if ($key === null || !array_key_exists($key, C::ARR) || !array_key_exists("foo", C::ARR[$key])) {
|
|
return false;
|
|
}
|
|
|
|
return C::ARR[$key]["foo"];
|
|
}',
|
|
'assertions' => [],
|
|
'ignored_issues' => ['MixedReturnStatement', 'MixedInferredReturnType'],
|
|
],
|
|
'possiblyUndefinedArrayAccessWithArrayKeyExists' => [
|
|
'code' => '<?php
|
|
if (rand(0,1)) {
|
|
$a = ["a" => 1];
|
|
} else {
|
|
$a = [2, 3];
|
|
}
|
|
|
|
if (array_key_exists(0, $a)) {
|
|
echo $a[0];
|
|
}',
|
|
],
|
|
'arrayKeyExistsShoudldNotModifyIntType' => [
|
|
'code' => '<?php
|
|
class HttpError {
|
|
const ERRS = [
|
|
403 => "a",
|
|
404 => "b",
|
|
500 => "c"
|
|
];
|
|
}
|
|
|
|
function init(string $code) : string {
|
|
if (array_key_exists($code, HttpError::ERRS)) {
|
|
return $code;
|
|
}
|
|
|
|
return "";
|
|
}',
|
|
],
|
|
'arrayKeyExistsWithClassConst' => [
|
|
'code' => '<?php
|
|
class C {}
|
|
class D {}
|
|
|
|
class A {
|
|
const FLAGS = [
|
|
0 => [C::class => "foo"],
|
|
1 => [D::class => "bar"],
|
|
];
|
|
|
|
private function foo(int $i) : void {
|
|
if (array_key_exists(C::class, self::FLAGS[$i])) {
|
|
echo self::FLAGS[$i][C::class];
|
|
}
|
|
}
|
|
}',
|
|
],
|
|
'constantArrayKeyExistsWithClassConstant' => [
|
|
'code' => '<?php
|
|
class Foo {
|
|
public const F = "key";
|
|
}
|
|
|
|
/** @param array{key?: string} $a */
|
|
function one(array $a): void {
|
|
if (array_key_exists(Foo::F, $a)) {
|
|
echo $a[Foo::F];
|
|
}
|
|
}',
|
|
],
|
|
'assertTypeNarrowedByNestedIsset' => [
|
|
'code' => '<?php
|
|
/**
|
|
* @psalm-suppress MixedMethodCall
|
|
* @psalm-suppress MixedArgument
|
|
*/
|
|
function foo(array $array = []): void {
|
|
if (array_key_exists("a", $array)) {
|
|
echo $array["a"];
|
|
}
|
|
|
|
if (array_key_exists("b", $array)) {
|
|
echo $array["b"]->format("Y-m-d");
|
|
}
|
|
}',
|
|
],
|
|
'assertArrayKeyExistsRefinesType' => [
|
|
'code' => '<?php
|
|
class Foo {
|
|
public const DAYS = [
|
|
1 => "mon",
|
|
2 => "tue",
|
|
3 => "wed",
|
|
4 => "thu",
|
|
5 => "fri",
|
|
6 => "sat",
|
|
7 => "sun",
|
|
];
|
|
|
|
/** @param key-of<self::DAYS> $dayNum*/
|
|
private static function doGetDayName(int $dayNum): string {
|
|
return self::DAYS[$dayNum];
|
|
}
|
|
|
|
/** @throws LogicException */
|
|
public static function getDayName(int $dayNum): string {
|
|
if (! array_key_exists($dayNum, self::DAYS)) {
|
|
throw new \LogicException();
|
|
}
|
|
return self::doGetDayName($dayNum);
|
|
}
|
|
}',
|
|
],
|
|
'arrayKeyExistsInferString' => [
|
|
'code' => '<?php
|
|
function foo(mixed $file) : string {
|
|
/** @psalm-suppress MixedArgument */
|
|
if (array_key_exists($file, ["a" => 1, "b" => 2])) {
|
|
return $file;
|
|
}
|
|
|
|
return "";
|
|
}',
|
|
'assertions' => [],
|
|
'ignored_issues' => [],
|
|
'php_version' => '8.0',
|
|
],
|
|
'arrayKeyExistsComplex' => [
|
|
'code' => '<?php
|
|
class A {
|
|
private const MAP = [
|
|
"a" => 1,
|
|
"b" => 2,
|
|
"c" => 3,
|
|
"d" => 4,
|
|
"e" => 5,
|
|
"f" => 6,
|
|
"g" => 7,
|
|
"h" => 8,
|
|
"i" => 9,
|
|
"j" => 10,
|
|
"k" => 11,
|
|
];
|
|
|
|
public function doWork(string $a): void {
|
|
if (!array_key_exists($a, self::MAP)) {}
|
|
}
|
|
}',
|
|
],
|
|
'arrayKeyExistsAccess' => [
|
|
'code' => '<?php
|
|
/** @param array<int, string> $arr */
|
|
function foo(array $arr) : void {
|
|
if (array_key_exists(1, $arr)) {
|
|
$a = ($arr[1] === "b") ? true : false;
|
|
}
|
|
}',
|
|
],
|
|
'arrayKeyExistsVariable' => [
|
|
'code' => '<?php
|
|
class pony
|
|
{
|
|
}
|
|
/**
|
|
* @param array{0?: string, test?: string, pony?: string} $params
|
|
* @return string|null
|
|
*/
|
|
function a(array $params = [])
|
|
{
|
|
foreach ([0, "test", pony::class] as $key) {
|
|
if (\array_key_exists($key, $params)) {
|
|
return $params[$key];
|
|
}
|
|
}
|
|
}',
|
|
],
|
|
'noCrashOnArrayKeyExistsBracket' => [
|
|
'code' => '<?php
|
|
class MyCollection {
|
|
/**
|
|
* @param int $commenter
|
|
* @param int $numToGet
|
|
* @return int[]
|
|
*/
|
|
public function getPosters($commenter, $numToGet=10) {
|
|
$posters = array();
|
|
$count = 0;
|
|
$a = new ArrayObject([[1234]]);
|
|
$iter = $a->getIterator();
|
|
while ($iter->valid() && $count < $numToGet) {
|
|
$value = $iter->current();
|
|
if ($value[0] != $commenter) {
|
|
if (!array_key_exists($value[0], $posters)) {
|
|
$posters[$value[0]] = 1;
|
|
$count++;
|
|
}
|
|
}
|
|
$iter->next();
|
|
}
|
|
return array_keys($posters);
|
|
}
|
|
}',
|
|
'assertions' => [],
|
|
'ignored_issues' => [
|
|
'MixedArrayAccess', 'MixedAssignment', 'MixedArrayOffset',
|
|
'MixedArgument',
|
|
],
|
|
],
|
|
'arrayKeyExistsTwoVars' => [
|
|
'code' => '<?php
|
|
/**
|
|
* @param array{a: string, b: string, c?: string} $info
|
|
*/
|
|
function getReason(array $info, string $key, string $value): bool {
|
|
if (array_key_exists($key, $info) && $info[$key] === $value) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}',
|
|
],
|
|
'allowIntKeysToo' => [
|
|
'code' => '<?php
|
|
/**
|
|
* @param array<1|2|3, string> $arr
|
|
* @return 1|2|3
|
|
*/
|
|
function checkArrayKeyExistsInt(array $arr, int $int): int
|
|
{
|
|
if (array_key_exists($int, $arr)) {
|
|
return $int;
|
|
}
|
|
|
|
return 1;
|
|
}',
|
|
],
|
|
'comparesStringAndAllIntKeysCorrectly' => [
|
|
'code' => '<?php
|
|
/**
|
|
* @param array<1|2|3, string> $arr
|
|
* @return bool
|
|
*/
|
|
function checkArrayKeyExistsComparison(array $arr, string $key): bool
|
|
{
|
|
if (array_key_exists($key, $arr)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}',
|
|
],
|
|
'comparesStringAndAllIntKeysCorrectlyNegated' => [
|
|
'code' => '<?php
|
|
/**
|
|
* @param array<1|2|3, string> $arr
|
|
* @return bool
|
|
*/
|
|
function checkArrayKeyExistsComparisonNegated(array $arr, string $key): bool
|
|
{
|
|
if (!array_key_exists($key, $arr)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}',
|
|
],
|
|
'arrayKeyExistsAssertBothWays' => [
|
|
'code' => '<?php
|
|
class a {
|
|
const STATE_A = 0;
|
|
const STATE_B = 1;
|
|
const STATE_C = 2;
|
|
/**
|
|
* @return array<self::STATE_*, non-empty-string>
|
|
* @psalm-pure
|
|
*/
|
|
public static function getStateLabels(): array {
|
|
return [
|
|
self::STATE_A => "A",
|
|
self::STATE_B => "B",
|
|
self::STATE_C => "C",
|
|
];
|
|
}
|
|
/**
|
|
* @param self::STATE_* $state
|
|
*/
|
|
public static function getStateLabelIf(int $state): string {
|
|
$states = self::getStateLabels();
|
|
if (array_key_exists($state, $states)) {
|
|
return $states[$state];
|
|
}
|
|
return "";
|
|
}
|
|
}',
|
|
],
|
|
'arrayKeyExistsComplex2' => [
|
|
'code' => '<?php
|
|
/** @var array{
|
|
* address_components: list<array{
|
|
* long_name: string,
|
|
* short_name: string,
|
|
* types: list<("accounting"|"administrative_area_level_1"|"administrative_area_level_2"|"administrative_area_level_3"|
|
|
* "administrative_area_level_4"|"administrative_area_level_5"|"airport"|"amusement_park"|"art_gallery"|"bar"|"bus_station"|"cafe"|
|
|
* "campground"|"car_rental"|"cemetery"|"colloquial_area"|"continent"|"country"|"courthouse"|"embassy"|"establishment"|"finance"|
|
|
* "floor"|"food"|"funeral_home"|"general_contractor"|"gym"|"health"|"hospital"|"intersection"|"lawyer"|"light_rail_station"|
|
|
* "local_government_office"|"locality"|"lodging"|"moving_company"|"museum"|"natural_feature"|"neighborhood"|"night_club"|"park"|
|
|
* "parking"|"plus_code"|"point_of_interest"|"police"|"political"|"post_box"|"post_office"|"postal_code"|"postal_code_prefix"|
|
|
* "postal_code_suffix"|"postal_town"|"premise"|"real_estate_agency"|"restaurant"|"route"|"rv_park"|"school"|"spa"|"storage"|"store"|
|
|
* "street_address"|"street_number"|"sublocality"|"sublocality_level_1"|"sublocality_level_2"|"sublocality_level_3"|
|
|
* "sublocality_level_4"|"sublocality_level_5"|"subpremise"|"subway_station"|"tourist_attraction"|"town_square"|"train_station"|
|
|
* "transit_station"|"travel_agency"|"university"|"ward"|"zoo")>
|
|
* }>,
|
|
* formatted_address: string,
|
|
* geometry: array{
|
|
* location: array{ lat: float, lng: float },
|
|
* location_type: string,
|
|
* viewport: array{
|
|
* northeast: array{ lat: float, lng: float },
|
|
* southwest: array{ lat: float, lng: float }
|
|
* }
|
|
* },
|
|
* partial_match: bool,
|
|
* types: list<string>
|
|
* }
|
|
*/
|
|
$data = [];
|
|
$cmp = [];
|
|
foreach ($data["address_components"] as $component) {
|
|
foreach ($component["types"] as $type) {
|
|
$cmp[$type] = $component["long_name"];
|
|
}
|
|
}
|
|
|
|
if (!\array_key_exists("locality", $cmp)) {
|
|
$cmp["locality"] = "";
|
|
}
|
|
|
|
if (!\array_key_exists("administrative_area_level_1", $cmp)) {
|
|
$cmp["administrative_area_level_1"] = "";
|
|
}
|
|
if ($cmp["administrative_area_level_1"] === "test") {
|
|
$cmp["administrative_area_level_1"] = "";
|
|
}',
|
|
],
|
|
'arrayKeyExistsPoorPerformance' => [
|
|
'code' => '<?php
|
|
class A {
|
|
private const CRITICAL_ERRORS = [
|
|
"category" => [],
|
|
"name" => [],
|
|
"geo" => [],
|
|
"city" => [],
|
|
"url" => [],
|
|
"comment_critical" => [],
|
|
"place" => [],
|
|
"price" => [],
|
|
"robot_error" => [],
|
|
"manual" => [],
|
|
"contacts" => [],
|
|
"not_confirmed_by_other_source" => [],
|
|
];
|
|
|
|
|
|
public function isCriticalError(int|string $key): bool {
|
|
if (!\array_key_exists($key, A::CRITICAL_ERRORS)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}',
|
|
'assertions' => [],
|
|
'ignored_issues' => [],
|
|
'php_version' => '8.0',
|
|
],
|
|
'keyExistsAsAliasForArrayKeyExists' => [
|
|
'code' => <<<'PHP'
|
|
<?php
|
|
/**
|
|
* @param array<string, string> $arr
|
|
*/
|
|
function foo(array $arr): void {
|
|
if (key_exists("a", $arr)) {
|
|
echo $arr["a"];
|
|
}
|
|
}
|
|
PHP,
|
|
],
|
|
];
|
|
}
|
|
|
|
public function providerInvalidCodeParse(): iterable
|
|
{
|
|
return [
|
|
'possiblyUndefinedArrayAccessWithArrayKeyExistsOnWrongKey' => [
|
|
'code' => '<?php
|
|
if (rand(0,1)) {
|
|
$a = ["a" => 1];
|
|
} else {
|
|
$a = [2, 3];
|
|
}
|
|
|
|
if (array_key_exists("a", $a)) {
|
|
echo $a[0];
|
|
}',
|
|
'error_message' => 'PossiblyUndefinedArrayOffset',
|
|
],
|
|
'possiblyUndefinedArrayAccessWithArrayKeyExistsOnMissingKey' => [
|
|
'code' => '<?php
|
|
if (rand(0,1)) {
|
|
$a = ["a" => 1];
|
|
} else {
|
|
$a = [2, 3];
|
|
}
|
|
|
|
if (array_key_exists("b", $a)) {
|
|
echo $a[0];
|
|
}',
|
|
'error_message' => 'PossiblyUndefinedArrayOffset',
|
|
],
|
|
'dontCreateWeirdString' => [
|
|
'code' => '<?php
|
|
/**
|
|
* @psalm-param array{inner:string} $options
|
|
*/
|
|
function go(array $options): void {
|
|
if (!array_key_exists(\'size\', $options)) {
|
|
throw new Exception(\'bad\');
|
|
}
|
|
|
|
/** @psalm-suppress MixedArgument */
|
|
echo $options[\'\\\'size\\\'\'];
|
|
}',
|
|
'error_message' => 'InvalidArrayOffset',
|
|
],
|
|
];
|
|
}
|
|
|
|
public function testAllowPropertyFetchAsNeedle(): void
|
|
{
|
|
Config::getInstance()->ensure_array_int_offsets_exist = true;
|
|
|
|
$this->addFile(
|
|
'somefile.php',
|
|
'<?php
|
|
class Foo {
|
|
/** @var self::STATE_* $status */
|
|
public int $status = self::STATE_A;
|
|
public const STATE_A = 0;
|
|
public const STATE_B = 1;
|
|
}
|
|
|
|
$foo = new Foo;
|
|
|
|
/** @var array<string> $bar */
|
|
$bar = [];
|
|
|
|
if (array_key_exists($foo->status, $bar)) {
|
|
echo $bar[$foo->status];
|
|
}',
|
|
);
|
|
|
|
$this->analyzeFile('somefile.php', new Context());
|
|
}
|
|
|
|
public function testAllowStaticPropertyFetchAsNeedle(): void
|
|
{
|
|
Config::getInstance()->ensure_array_int_offsets_exist = true;
|
|
|
|
$this->addFile(
|
|
'somefile.php',
|
|
'<?php
|
|
class Foo {
|
|
/** @var self::STATE_* $status */
|
|
public static int $status = self::STATE_A;
|
|
public const STATE_A = 0;
|
|
public const STATE_B = 1;
|
|
}
|
|
|
|
/** @var array<string> $bar */
|
|
$bar = [];
|
|
|
|
if (array_key_exists(Foo::$status, $bar)) {
|
|
echo $bar[Foo::$status];
|
|
}',
|
|
);
|
|
|
|
$this->analyzeFile('somefile.php', new Context());
|
|
}
|
|
}
|