expectException(\Psalm\Exception\CodeException::class); $this->expectExceptionMessage('PossiblyUndefinedStringArrayOffset'); \Psalm\Config::getInstance()->ensure_array_string_offsets_exist = true; $this->addFile( 'somefile.php', ' $arr */ function takesArrayIteratorOfString(array $arr): void { echo $arr["hello"]; }' ); $this->analyzeFile('somefile.php', new \Psalm\Context()); } public function testEnsureArrayOffsetsExistWithIssetCheck(): void { \Psalm\Config::getInstance()->ensure_array_string_offsets_exist = true; $this->addFile( 'somefile.php', ' $arr */ function takesArrayIteratorOfString(array $arr): void { if (isset($arr["hello"])) { echo $arr["hello"]; } }' ); $this->analyzeFile('somefile.php', new \Psalm\Context()); } public function testDontEnsureArrayOffsetsExist(): void { \Psalm\Config::getInstance()->ensure_array_string_offsets_exist = false; $this->addFile( 'somefile.php', ' $arr */ function takesArrayIteratorOfString(array $arr): void { echo $arr["hello"]; }' ); $this->analyzeFile('somefile.php', new \Psalm\Context()); } public function testEnsureArrayOffsetsExistWithIssetCheckFollowedByIsArray(): void { \Psalm\Config::getInstance()->ensure_array_string_offsets_exist = true; $this->addFile( 'somefile.php', ' $s */ function foo(array $s) : void { if (isset($s["a"]) && \is_array($s["a"])) {} }' ); $this->analyzeFile('somefile.php', new \Psalm\Context()); } public function testComplainAfterFirstIsset(): void { $this->expectException(\Psalm\Exception\CodeException::class); $this->expectExceptionMessage('PossiblyUndefinedStringArrayOffset'); \Psalm\Config::getInstance()->ensure_array_string_offsets_exist = true; $this->addFile( 'somefile.php', 'analyzeFile('somefile.php', new \Psalm\Context()); } public function testEnsureArrayIntOffsetsExist(): void { $this->expectException(\Psalm\Exception\CodeException::class); $this->expectExceptionMessage('PossiblyUndefinedIntArrayOffset'); \Psalm\Config::getInstance()->ensure_array_int_offsets_exist = true; $this->addFile( 'somefile.php', ' $arr */ function takesArrayIteratorOfString(array $arr): void { echo $arr[4]; }' ); $this->analyzeFile('somefile.php', new \Psalm\Context()); } public function testNoIssueWhenUsingArrayValuesOnNonEmptyArray(): void { \Psalm\Config::getInstance()->ensure_array_int_offsets_exist = true; $this->addFile( 'somefile.php', 'analyzeFile('somefile.php', new \Psalm\Context()); } public function testNoIssueAfterManyIssets() : void { \Psalm\Config::getInstance()->ensure_array_int_offsets_exist = true; $this->addFile( 'somefile.php', 'analyzeFile('somefile.php', new \Psalm\Context()); } public function testEnsureListOffsetExistsNotEmpty(): void { \Psalm\Config::getInstance()->ensure_array_int_offsets_exist = true; $this->addFile( 'somefile.php', ' $arr */ function takesList(array $arr) : void { if ($arr) { echo $arr[0]; } }' ); $this->analyzeFile('somefile.php', new \Psalm\Context()); } public function testEnsureListOffsetExistsAfterArrayPop(): void { \Psalm\Config::getInstance()->ensure_array_int_offsets_exist = true; $this->expectException(\Psalm\Exception\CodeException::class); $this->expectExceptionMessage('PossiblyUndefinedIntArrayOffset'); $this->addFile( 'somefile.php', ' $arr */ function takesList(array $arr) : void { if ($arr) { echo $arr[0]; array_pop($arr); echo $arr[0]; } }' ); $this->analyzeFile('somefile.php', new \Psalm\Context()); } public function testEnsureOffsetExistsAfterArrayPush() : void { \Psalm\Config::getInstance()->ensure_array_int_offsets_exist = true; $this->addFile( 'somefile.php', 'analyzeFile('somefile.php', new \Psalm\Context()); } public function testEnsureOffsetExistsAfterNestedIsset(): void { \Psalm\Config::getInstance()->ensure_array_string_offsets_exist = true; $this->addFile( 'somefile.php', ' $value */ function test(array $value): int { return isset($value["a"]->foo) ? $value["a"]->foo : 0; }' ); $this->analyzeFile('somefile.php', new \Psalm\Context()); } public function testEnsureListOffsetExistsAfterCountValueInRange(): void { \Psalm\Config::getInstance()->ensure_array_int_offsets_exist = true; $this->addFile( 'somefile.php', ' $arr */ function takesList(array $arr) : void { if (count($arr) >= 3) { echo $arr[0]; echo $arr[1]; echo $arr[2]; } if (count($arr) > 2) { echo $arr[0]; echo $arr[1]; echo $arr[2]; } if (count($arr) === 3) { echo $arr[0]; echo $arr[1]; echo $arr[2]; } if (3 === count($arr)) { echo $arr[0]; echo $arr[1]; echo $arr[2]; } if (3 <= count($arr)) { echo $arr[0]; echo $arr[1]; echo $arr[2]; } if (2 < count($arr)) { echo $arr[0]; echo $arr[1]; echo $arr[2]; } }' ); $this->analyzeFile('somefile.php', new \Psalm\Context()); } public function testEnsureListOffsetExistsAfterCountValueOutOfRange(): void { \Psalm\Config::getInstance()->ensure_array_int_offsets_exist = true; $this->expectException(\Psalm\Exception\CodeException::class); $this->expectExceptionMessage('PossiblyUndefinedIntArrayOffset'); $this->addFile( 'somefile.php', ' $arr */ function takesList(array $arr) : void { if (count($arr) >= 2) { echo $arr[0]; echo $arr[1]; echo $arr[2]; } }' ); $this->analyzeFile('somefile.php', new \Psalm\Context()); } public function testEnsureListOffsetExistsAfterCountValueOutOfRangeSmallerThan(): void { \Psalm\Config::getInstance()->ensure_array_int_offsets_exist = true; $this->expectException(\Psalm\Exception\CodeException::class); $this->expectExceptionMessage('PossiblyUndefinedIntArrayOffset'); $this->addFile( 'somefile.php', ' $arr */ function takesList(array $arr) : void { if (2 <= count($arr)) { echo $arr[0]; echo $arr[1]; echo $arr[2]; } }' ); $this->analyzeFile('somefile.php', new \Psalm\Context()); } /** * @return iterable,error_levels?:string[]}> */ public function providerValidCodeParse(): iterable { return [ 'instanceOfStringOffset' => [ 'fooFoo(); } }', ], 'instanceOfIntOffset' => [ 'fooFoo(); } }', ], 'notEmptyStringOffset' => [ ' $a */ function bar (array $a): string { if ($a["bat"]) { return $a["bat"]; } return "blah"; }', ], 'issetPropertyStringOffset' => [ ' */ public $arr = []; } $a = new A(); if (!isset($a->arr["bat"]) || strlen($a->arr["bat"])) { }', ], 'issetPropertyStringOffsetUndefinedClass' => [ 'arr["bat"]) || strlen($a->arr["bat"])) { }', 'assertions' => [], 'error_levels' => ['MixedArgument', 'MixedArrayAccess'], ], 'notEmptyIntOffset' => [ ' $a */ function bar (array $a): string { if ($a[0]) { return $a[0]; } return "blah"; }', ], 'ignorePossiblyNullArrayAccess' => [ ' [], 'error_levels' => ['PossiblyNullArrayAccess'], ], 'ignoreEmptyArrayAccess' => [ ' [ '$x' => 'mixed', ], 'error_levels' => ['EmptyArrayAccess', 'MixedAssignment'], ], 'objectLikeWithoutKeys' => [ ' [ ' "01", "02" => "02"]; foreach ($array as $key => $value) { $len = strlen($key); }', ], 'listAssignmentKeyOffset' => [ ' [ ' [ ' "value"]; unset($x["a"]); $x[] = 5; takesInt($x[0]);', ], 'domNodeListAccessible' => [ 'loadXML(""); $e = $doc->getElementsByTagName("node")[0];', [ '$e' => 'DOMElement|null', ], ], 'getOnArrayAcccess' => [ ' $a */ function foo(ArrayAccess $a) : string { return $a[0]; }', ], 'mixedKeyMixedOffset' => [ ' [], 'error_levels' => ['MixedArgument', 'MixedArrayOffset', 'MissingParamType'], ], 'suppressPossiblyUndefinedStringArrayOffet' => [ ' $elt] = $entry; strlen($elt); strlen($entry["a"]);', 'assertions' => [], 'error_levels' => ['PossiblyUndefinedArrayOffset'], ], 'noRedundantConditionOnMixedArrayAccess' => [ ' */ $b = []; /** @var array */ $c = []; /** @var array */ $d = []; if (!empty($d[0]) && !isset($c[$d[0]])) { if (isset($b[$d[0]])) {} }', [], 'error_levels' => ['MixedArrayOffset'], ], 'noEmptyArrayAccessInLoop' => [ ' [ ' */ public $arr = []; } /** @var array */ $as = []; if (!$as || !$as[0] instanceof B || !$as[0]->arr ) { return null; } $b = $as[0]->arr;', ], 'arrayAccessAfterPassByref' => [ ' [ ' $_) {} if ($i === "hello") {} if ($i !== "hello") {} if ($i === 5) {} if ($i !== 5) {} if (is_string($i)) {} if (is_int($i)) {} foreach ($arr as $i => $_) {} if ($i === "hell") { $i = "hellp"; } if ($i === "hel") {} }', ], 'arrayKeyChecksAfterDefinition' => [ ' $_) {} if ($i === "hell") { $i = "hellp"; } if ($i === "hel") {} }', ], 'allowMixedTypeCoercionArrayKeyAccess' => [ ' $i * @param array $arr */ function foo(array $i, array $arr) : void { foreach ($i as $j => $k) { echo $arr[$j]; } }', 'assertions' => [], 'error_levels' => ['MixedArrayTypeCoercion'], ], 'allowNegativeStringOffset' => [ ' [ ' 1, "b" => [ "c" => "a", ] ); if (rand(0, 1)) { $params = getArray(); } echo $params["b"]["c"];', [], ['MixedArrayAccess', 'MixedArgument'], ], 'arrayAccessOnObjectWithNullGet' => [ ' */ protected $data = []; /** * @param array $array * @psalm-suppress MixedArgumentTypeCoercion */ final public function __construct(array $array) { foreach ($array as $key => $value) { if (is_array($value)) { $this->data[$key] = new static($value); } else { $this->data[$key] = $value; } } } /** * @param string $name * @return C|scalar */ public function offsetGet($name) { return $this->data[$name]; } /** * @param ?string $name * @param scalar|array $value * @psalm-suppress MixedArgumentTypeCoercion */ public function offsetSet($name, $value) : void { if (is_array($value)) { $value = new static($value); } if (null === $name) { $this->data[] = $value; } else { $this->data[$name] = $value; } } public function __isset(string $name) : bool { return isset($this->data[$name]); } public function __unset(string $name) : void { unset($this->data[$name]); } /** * @psalm-suppress MixedArgument */ public function offsetExists($offset) : bool { return $this->__isset($offset); } /** * @psalm-suppress MixedArgument */ public function offsetUnset($offset) : void { $this->__unset($offset); } }', [ '$c' => 'C|null|scalar', ] ], 'singleLetterOffset' => [ ' "str"]["str"[0]];', ], 'arrayAccessAfterByRefArrayOffsetAssignment' => [ ' &$ar]); $value = "foo"; if (isset($ar[$value])) { echo (string) $ar[$value]; }', [], ['MixedArrayAccess'], ], 'byRefArrayAccessWithoutKnownVarNoNotice' => [ 'foo->bar]);', ], 'accessOffsetOnList' => [ ' $arr */ function foo(array $arr) : void { echo $arr[3] ?? null; }', ], 'destructureMixed' => [ 'a) { return; } $popped = array_pop($this->a); /** @psalm-suppress MixedArrayAccess */ [$this->b, $this->c] = $popped; } }' ], 'simpleXmlArrayFetch' => [ ' [ 'children() as $img) { yield $img["src"] ?? ""; } }', ], 'assertOnArrayAccess' => [ 'data[$name]; } /** * @param string $name * @param mixed $value */ public function offsetSet($name, $value) : void { $this->data[$name] = $value; } public function __isset(string $name) : bool { return isset($this->data[$name]); } public function __unset(string $name) : void { unset($this->data[$name]); } /** * @psalm-suppress MixedArgument */ public function offsetExists($offset) : bool { return $this->__isset($offset); } /** * @psalm-suppress MixedArgument */ public function offsetUnset($offset) : void { $this->__unset($offset); } } $container = new C(); if ($container["a"] instanceof A) { $container["a"]->foo(); }' ], 'assignmentListCheckForNull' => [ ' $foo] = bar(0); if ($foo !== null) {}' ], 'accessKnownArrayWithPositiveInt' => [ ' $arr */ function foo(array $arr) : void { $o = [4, 15, 18, 21, 51]; $i = 0; foreach ($arr as $a) { if ($o[$i] === $a) {} $i++; } }' ], 'arrayAccessOnArraylikeObjectOrArray' => [ '|array $arr */ function test($arr): string { return $arr[0]; } test(["a", "b"]); test(new ArrayObject(["a", "b"]));' ], 'nullCoalesceArrayAccess' => [ ' $a */ function foo(?ArrayAccess $a) : void { echo $a[0] ?? "default"; }' ], 'allowUnsettingNested' => [ ' $test]; unset($a[$test->value]);' ], 'arrayAssertionShouldNotBeNull' => [ ' */ public function providerInvalidCodeParse(): iterable { return [ 'invalidArrayAccess' => [ ' 'InvalidArrayAccess', ], 'invalidArrayOffset' => [ ' 'InvalidArrayOffset', ], 'possiblyInvalidArrayOffsetWithInt' => [ ' 2 ? ["a" => 5] : "hello"; $y = $x[0];', 'error_message' => 'PossiblyInvalidArrayOffset', ], 'possiblyInvalidArrayOffsetWithString' => [ ' 2 ? ["a" => 5] : "hello"; $y = $x["a"];', 'error_message' => 'PossiblyInvalidArrayOffset', ], 'possiblyInvalidArrayAccessWithNestedArray' => [ '>|string */ function return_array() { return rand() % 5 > 3 ? [["key" => 3.5]] : "key:3.5"; } $result = return_array(); $v = $result[0]["key"];', 'error_message' => 'PossiblyInvalidArrayOffset', ], 'possiblyInvalidArrayAccess' => [ ' 5 ? 5 : ["hello"]; echo $a[0];', 'error_message' => 'PossiblyInvalidArrayAccess', ], 'mixedArrayAccess' => [ ' 'MixedArrayAccess', 'error_level' => ['MixedAssignment'], ], 'mixedArrayOffset' => [ ' 'MixedArrayOffset', 'error_level' => ['MixedAssignment'], ], 'nullArrayAccess' => [ ' 'NullArrayAccess', ], 'possiblyNullArrayAccess' => [ ' 'PossiblyNullArrayAccess', ], 'specificErrorMessage' => [ ' "value"]; echo $params["fieldName"];', 'error_message' => 'InvalidArrayOffset - src' . DIRECTORY_SEPARATOR . 'somefile.php:3:26 - Cannot access ' . 'value on variable $params using offset value of', ], 'missingArrayOffsetAfterUnset' => [ ' "value", "b" => "value"]; unset($x["a"]); echo $x["a"];', 'error_message' => 'InvalidArrayOffset', ], 'noImpossibleStringAccess' => [ ' 'InvalidArrayOffset', ], 'mixedKeyStdClassOffset' => [ ' 'InvalidArrayOffset', ], 'toStringOffset' => [ ' "bar"]; echo $a[new Foo];', 'error_message' => 'InvalidArrayOffset', ], 'possiblyUndefinedIntArrayOffet' => [ ' 'PossiblyUndefinedArrayOffset', ], 'possiblyUndefinedStringArrayOffet' => [ ' $elt] = $entry;', 'error_message' => 'PossiblyUndefinedArrayOffset', ], 'possiblyInvalidMixedArrayOffset' => [ ' 'PossiblyInvalidArrayOffset', ], 'arrayAccessOnIterable' => [ ' 'InvalidArrayAccess', ], 'arrayKeyCannotBeBool' => [ ' $_) {} if ($i === false) {} }', 'error_message' => 'TypeDoesNotContainType', ], 'arrayKeyCannotBeFloat' => [ ' $_) {} if ($i === 4.0) {} }', 'error_message' => 'TypeDoesNotContainType', ], 'arrayKeyCannotBeObject' => [ ' $_) {} if ($i === new stdClass) {} }', 'error_message' => 'TypeDoesNotContainType', ], 'forbidNegativeStringOffsetOutOfRange' => [ ' 'InvalidArrayOffset', ], 'emptyStringAccess' => [ ' 'InvalidArrayOffset', ], 'recogniseBadVar' => [ ' 'UndefinedGlobalVariable', ], 'unsetListElementShouldChangeToArray' => [ ' $arr * @return list */ function takesList(array $arr) : array { unset($arr[0]); return $arr; }', 'error_message' => 'LessSpecificReturnStatement', ], 'simpleXmlArrayFetchResultCannotEqualString' => [ ' 'TypeDoesNotContainType', ], 'undefinedTKeyedArrayOffset' => [ ' 'InvalidArrayOffset' ], 'destructureNullable' => [ ' 1]; } ["key" => $a] = maybeReturnArray();', 'error_message' => 'PossiblyNullArrayAccess', ], 'destructureTuple' => [ ' 'InvalidArrayOffset', ], 'negativeListAccess' => [ ' 'InvalidArrayOffset' ] ]; } }