Skip to content

Commit

Permalink
Add has-property- assertion
Browse files Browse the repository at this point in the history
On itself it doesn't do much good as property fetch analyzer doesn't
support object intersections yet. But it's a groundwork for future
enhancements.
  • Loading branch information
weirdan committed Feb 17, 2023
1 parent 485e7b7 commit 427045c
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 0 deletions.
16 changes: 16 additions & 0 deletions src/Psalm/Codebase.php
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,22 @@ public function getFunctionLikeStorage(
return $this->functions->getStorage($statements_analyzer, strtolower($function_id));
}

/**
* Whether or not a given property exists
*/
public function propertyExists(
string $property_id,
?CodeLocation $code_location = null
): bool {
return $this->properties->propertyExists(
$property_id,
true, // $read_mode, not sure about this
null,
null,
$code_location,
);
}

/**
* Whether or not a given method exists
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
use Psalm\Storage\Assertion\HasAtLeastCount;
use Psalm\Storage\Assertion\HasExactCount;
use Psalm\Storage\Assertion\HasMethod;
use Psalm\Storage\Assertion\HasProperty;
use Psalm\Storage\Assertion\InArray;
use Psalm\Storage\Assertion\IsAClass;
use Psalm\Storage\Assertion\IsClassEqual;
Expand Down Expand Up @@ -948,6 +949,14 @@ public static function processFunctionCall(
if ($first_var_name) {
$if_types[$first_var_name] = [[new HasMethod($expr->getArgs()[1]->value->value)]];
}
} elseif ($expr->name instanceof PhpParser\Node\Name
&& strtolower($expr->name->parts[0]) === 'property_exists'
&& isset($expr->getArgs()[1])
&& $expr->getArgs()[1]->value instanceof PhpParser\Node\Scalar\String_
) {
if ($first_var_name) {
$if_types[$first_var_name] = [[new HasProperty($expr->getArgs()[1]->value->value)]];
}
} elseif (self::hasInArrayCheck($expr) && $source instanceof StatementsAnalyzer) {
return self::getInarrayAssertions($expr, $source, $first_var_name);
} elseif (self::hasArrayKeyExistsCheck($expr)) {
Expand Down
128 changes: 128 additions & 0 deletions src/Psalm/Internal/Type/SimpleAssertionReconciler.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Psalm\Storage\Assertion\HasExactCount;
use Psalm\Storage\Assertion\HasIntOrStringArrayAccess;
use Psalm\Storage\Assertion\HasMethod;
use Psalm\Storage\Assertion\HasProperty;
use Psalm\Storage\Assertion\HasStringArrayAccess;
use Psalm\Storage\Assertion\InArray;
use Psalm\Storage\Assertion\IsCountable;
Expand Down Expand Up @@ -286,6 +287,19 @@ public static function reconcile(
);
}

if ($assertion instanceof HasProperty) {
return self::reconcileHasProperty(
$assertion,
$codebase,
$existing_var_type,
$key,
$negated,
$code_location,
$suppressed_issues,
$failed_reconciliation,
);
}

$assertion_type = $assertion->getAtomicType();

if ($assertion_type instanceof TObject) {
Expand Down Expand Up @@ -967,6 +981,120 @@ private static function reconcileHasMethod(
: Type::getNever();
}

/**
* @param string[] $suppressed_issues
* @param Reconciler::RECONCILIATION_* $failed_reconciliation
*/
private static function reconcileHasProperty(
HasProperty $assertion,
Codebase $codebase,
Union $existing_var_type,
?string $key,
bool $negated,
?CodeLocation $code_location,
array $suppressed_issues,
int &$failed_reconciliation
): Union {
$property_name = $assertion->property_name;

$old_var_type_string = $existing_var_type->getId();
$existing_var_atomic_types = $existing_var_type->getAtomicTypes();

$object_types = [];
$redundant = true;

foreach ($existing_var_atomic_types as $type) {
if ($type instanceof TNamedObject
&& $codebase->classOrInterfaceExists($type->value)
) {
if (!$codebase->propertyExists($type->value . '::$' . $property_name)) {
$match_found = false;

$extra_types = $type->extra_types;
foreach ($type->extra_types as $k => $extra_type) {
if ($extra_type instanceof TNamedObject
&& $codebase->classOrInterfaceExists($extra_type->value)
&& $codebase->propertyExists($extra_type->value . '::$' . $property_name)
) {
$match_found = true;
} elseif ($extra_type instanceof TObjectWithProperties) {
$match_found = true;

if (!isset($extra_type->properties[$property_name])) {
unset($extra_types[$k]);
$extra_type = $extra_type->setProperties(array_merge(
$extra_type->properties,
[$property_name => Type::getMixed()],
));
$extra_types[$extra_type->getKey()] = $extra_type;
$redundant = false;
}
}
}

if (!$match_found) {
$extra_type = new TObjectWithProperties(
[$property_name => Type::getMixed()],
);
$extra_types[$extra_type->getKey()] = $extra_type;
$redundant = false;
}

$type = $type->setIntersectionTypes($extra_types);
}
$object_types[] = $type;
} elseif ($type instanceof TObjectWithProperties) {
if (!isset($type->properties[$property_name])) {
$type = $type->setProperties(array_merge(
$type->properties,
[$property_name => Type::getMixed()],
));
$redundant = false;
}
$object_types[] = $type;
} elseif ($type instanceof TObject || $type instanceof TMixed) {
$object_types[] = new TObjectWithProperties(
[$property_name => Type::getMixed()],
);
$redundant = false;
} elseif ($type instanceof TString) {
// we don’t know
$object_types[] = $type;
$redundant = false;
} elseif ($type instanceof TTemplateParam) {
$object_types[] = $type;
$redundant = false;
} else {
$redundant = false;
}
}

if (!$object_types || $redundant) {
if ($key && $code_location) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$key,
$assertion,
$redundant,
$negated,
$code_location,
$suppressed_issues,
);
}
}

if ($object_types) {
return new Union($object_types);
}

$failed_reconciliation = Reconciler::RECONCILIATION_EMPTY;

return $existing_var_type->from_docblock
? new Union([new TEmptyMixed()])
: Type::getNever();
}

/**
* @param string[] $suppressed_issues
* @param Reconciler::RECONCILIATION_* $failed_reconciliation
Expand Down
33 changes: 33 additions & 0 deletions src/Psalm/Storage/Assertion/DoesNotHaveProperty.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Psalm\Storage\Assertion;

use Psalm\Storage\Assertion;

/**
* @psalm-immutable
*/
final class DoesNotHaveProperty extends Assertion
{
public string $property_name;

public function __construct(string $property_name)
{
$this->property_name = $property_name;
}

public function getNegation(): Assertion
{
return new HasProperty($this->property_name);
}

public function __toString(): string
{
return '!has-property-' . $this->property_name;
}

public function isNegationOf(Assertion $assertion): bool
{
return $assertion instanceof HasProperty && $this->property_name === $assertion->property_name;
}
}
33 changes: 33 additions & 0 deletions src/Psalm/Storage/Assertion/HasProperty.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Psalm\Storage\Assertion;

use Psalm\Storage\Assertion;

/**
* @psalm-immutable
*/
final class HasProperty extends Assertion
{
public string $property_name;

public function __construct(string $property_name)
{
$this->property_name = $property_name;
}

public function getNegation(): Assertion
{
return new DoesNotHaveProperty($this->property_name);
}

public function __toString(): string
{
return 'has-property-' . $this->property_name;
}

public function isNegationOf(Assertion $assertion): bool
{
return $assertion instanceof DoesNotHaveProperty && $this->property_name === $assertion->property_name;
}
}
15 changes: 15 additions & 0 deletions tests/PropertyTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2687,6 +2687,21 @@ class Baz
'ignored_issues' => [],
'php_version' => '8.1',
],
'propertyExists' => [
'code' => <<<'PHP'
<?php
class A {}
$a = new A;
$z = null;
if (property_exists($a, "foo")) {
$z = $a;
}
PHP,
'assertions' => [
'$z===' => 'A&object{foo:mixed}|null',
],
],
];
}

Expand Down

0 comments on commit 427045c

Please sign in to comment.