Skip to content

Commit

Permalink
Fix #1830 - infer key type after array_key_exists check
Browse files Browse the repository at this point in the history
  • Loading branch information
muglug committed Nov 10, 2019
1 parent 065653c commit 2fc7f5f
Show file tree
Hide file tree
Showing 4 changed files with 139 additions and 49 deletions.
47 changes: 47 additions & 0 deletions src/Psalm/Internal/Type/AssertionReconciler.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
use function strpos;
use function substr;
use Psalm\Issue\InvalidDocblock;
use Doctrine\Instantiator\Exception\UnexpectedValueException;

class AssertionReconciler extends \Psalm\Type\Reconciler
{
Expand Down Expand Up @@ -191,6 +192,14 @@ public static function reconcile(
return $existing_var_type;
}

if (substr($assertion, 0, 9) === 'in-array-') {
return self::reconcileInArray(
$codebase,
$existing_var_type,
substr($assertion, 9)
);
}

if ($assertion === 'falsy' || $assertion === 'empty') {
return self::reconcileFalsyOrEmpty(
$assertion,
Expand Down Expand Up @@ -1371,6 +1380,44 @@ private static function reconcileIterable(
return Type::getMixed();
}

/**
* @param string[] $suppressed_issues
* @param 0|1|2 $failed_reconciliation
*/
private static function reconcileInArray(
Codebase $codebase,
Union $existing_var_type,
string $assertion
) : Union {
if (strpos($assertion, '::')) {
list($fq_classlike_name, $const_name) = explode('::', $assertion);

$class_constant_type = $codebase->classlikes->getConstantForClass(
$fq_classlike_name,
$const_name,
\ReflectionProperty::IS_PRIVATE
);

if ($class_constant_type) {
foreach ($class_constant_type->getTypes() as $const_type_atomic) {
if ($const_type_atomic instanceof Type\Atomic\ObjectLike
|| $const_type_atomic instanceof Type\Atomic\TArray
) {
if ($const_type_atomic instanceof Type\Atomic\ObjectLike) {
$const_type_atomic = $const_type_atomic->getGenericArrayType();
}

return clone $const_type_atomic->type_params[0];
}
}
}
}

$existing_var_type->removeType('null');

return $existing_var_type;
}

/**
* @param string[] $suppressed_issues
* @param 0|1|2 $failed_reconciliation
Expand Down
2 changes: 2 additions & 0 deletions src/Psalm/Internal/Type/NegatedAssertionReconciler.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ public static function reconcile(
return Type::getNull();
} elseif ($assertion === 'array-key-exists') {
return Type::getEmpty();
} elseif (substr($assertion, 0, 9) === 'in-array-') {
return $existing_var_type;
}
}

Expand Down
111 changes: 62 additions & 49 deletions src/Psalm/Type/Reconciler.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,76 +84,89 @@ public static function reconcileKeyedTypes(
$suppressed_issues = $statements_analyzer->getSuppressedIssues();

foreach ($new_types as $nk => $type) {
if ((strpos($nk, '[') || strpos($nk, '->'))
&& ($type[0][0] === '=isset'
if (strpos($nk, '[') || strpos($nk, '->')) {
if ($type[0][0] === '=isset'
|| $type[0][0] === '!=empty'
|| $type[0][0] === 'isset'
|| $type[0][0] === '!empty')
) {
$isset_or_empty = $type[0][0] === 'isset' || $type[0][0] === '=isset'
? '=isset'
: '!=empty';
|| $type[0][0] === '!empty'
) {
$isset_or_empty = $type[0][0] === 'isset' || $type[0][0] === '=isset'
? '=isset'
: '!=empty';

$key_parts = Reconciler::breakUpPathIntoParts($nk);
$key_parts = Reconciler::breakUpPathIntoParts($nk);

if (!$key_parts) {
throw new \UnexpectedValueException('There should be some key parts');
}
if (!$key_parts) {
throw new \UnexpectedValueException('There should be some key parts');
}

$base_key = array_shift($key_parts);
$base_key = array_shift($key_parts);

if (!isset($existing_types[$base_key]) || $existing_types[$base_key]->isNullable()) {
if (!isset($new_types[$base_key])) {
$new_types[$base_key] = [['=isset']];
} else {
$new_types[$base_key][] = ['=isset'];
if (!isset($existing_types[$base_key]) || $existing_types[$base_key]->isNullable()) {
if (!isset($new_types[$base_key])) {
$new_types[$base_key] = [['=isset']];
} else {
$new_types[$base_key][] = ['=isset'];
}
}
}

while ($key_parts) {
$divider = array_shift($key_parts);
while ($key_parts) {
$divider = array_shift($key_parts);

if ($divider === '[') {
$array_key = array_shift($key_parts);
array_shift($key_parts);
if ($divider === '[') {
$array_key = array_shift($key_parts);
array_shift($key_parts);

$new_base_key = $base_key . '[' . $array_key . ']';
$new_base_key = $base_key . '[' . $array_key . ']';

if (strpos($array_key, '\'') !== false) {
$new_types[$base_key][] = ['=string-array-access'];
} else {
$new_types[$base_key][] = ['=int-or-string-array-access'];
if (strpos($array_key, '\'') !== false) {
$new_types[$base_key][] = ['=string-array-access'];
} else {
$new_types[$base_key][] = ['=int-or-string-array-access'];
}

$base_key = $new_base_key;

continue;
}

$base_key = $new_base_key;
if ($divider === '->') {
$property_name = array_shift($key_parts);
$new_base_key = $base_key . '->' . $property_name;

continue;
}
$base_key = $new_base_key;
} else {
throw new \InvalidArgumentException('Unexpected divider ' . $divider);
}

if ($divider === '->') {
$property_name = array_shift($key_parts);
$new_base_key = $base_key . '->' . $property_name;
if (!$key_parts) {
break;
}

$base_key = $new_base_key;
} else {
throw new \InvalidArgumentException('Unexpected divider ' . $divider);
if (!isset($new_types[$base_key])) {
$new_types[$base_key] = [['!~bool'], ['!~int'], ['=isset']];
} else {
$new_types[$base_key][] = ['!~bool'];
$new_types[$base_key][] = ['!~int'];
$new_types[$base_key][] = ['=isset'];
}
}

if (!$key_parts) {
break;
}
// replace with a less specific check
$new_types[$nk][0][0] = $isset_or_empty;
}

if (!isset($new_types[$base_key])) {
$new_types[$base_key] = [['!~bool'], ['!~int'], ['=isset']];
} else {
$new_types[$base_key][] = ['!~bool'];
$new_types[$base_key][] = ['!~int'];
$new_types[$base_key][] = ['=isset'];
if ($type[0][0] === 'array-key-exists') {
$key_parts = Reconciler::breakUpPathIntoParts($nk);

if (count($key_parts) === 4 && $key_parts[1] === '[') {
if (isset($new_types[$key_parts[2]])) {
$new_types[$key_parts[2]][] = ['=in-array-' . $key_parts[0]];
} else {
$new_types[$key_parts[2]] = [['=in-array-' . $key_parts[0]]];
}
}
}

// replace with a less specific check
$new_types[$nk][0][0] = $isset_or_empty;
}
}

Expand Down
28 changes: 28 additions & 0 deletions tests/AssertTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,34 @@ public function foo() : stdClass {
}
}'
],
'assertArrayKeyExistsRefinesType' => [
'<?php
class Foo {
/** @var array<int,string> */
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);
}
}'
],
];
}
}

0 comments on commit 2fc7f5f

Please sign in to comment.