1
0
mirror of https://github.com/danog/psalm.git synced 2025-01-22 05:41:20 +01:00

Merge pull request #9656 from boesing/bugfix/issue-8981

(re-)implement object-shape assertions
This commit is contained in:
orklah 2023-04-16 21:17:53 +02:00 committed by GitHub
commit a9bc87e729
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 171 additions and 42 deletions

View File

@ -125,7 +125,19 @@ class AtomicPropertyFetchAnalyzer
$statements_analyzer->node_data->setType(
$stmt,
Type::combineUnionTypes(
$lhs_type_part->properties[$prop_name],
TypeExpander::expandUnion(
$statements_analyzer->getCodebase(),
$lhs_type_part->properties[$prop_name],
null,
null,
null,
true,
true,
false,
true,
true,
true,
),
$stmt_type,
),
);
@ -133,11 +145,19 @@ class AtomicPropertyFetchAnalyzer
return;
}
$intersection_types = [];
if (!$lhs_type_part instanceof TObject) {
$intersection_types = $lhs_type_part->getIntersectionTypes();
}
// stdClass and SimpleXMLElement are special cases where we cannot infer the return types
// but we don't want to throw an error
// Hack has a similar issue: https://github.com/facebook/hhvm/issues/5164
if ($lhs_type_part instanceof TObject
|| in_array(strtolower($lhs_type_part->value), Config::getInstance()->getUniversalObjectCrates(), true)
|| (
in_array(strtolower($lhs_type_part->value), Config::getInstance()->getUniversalObjectCrates(), true)
&& $intersection_types === []
)
) {
$statements_analyzer->node_data->setType($stmt, Type::getMixed());
@ -149,8 +169,6 @@ class AtomicPropertyFetchAnalyzer
return;
}
$intersection_types = $lhs_type_part->getIntersectionTypes() ?: [];
$fq_class_name = $lhs_type_part->value;
$override_property_visibility = false;
@ -237,39 +255,60 @@ class AtomicPropertyFetchAnalyzer
// add method before changing fq_class_name
$get_method_id = new MethodIdentifier($fq_class_name, '__get');
if (!$naive_property_exists
&& $class_storage->namedMixins
) {
foreach ($class_storage->namedMixins as $mixin) {
$new_property_id = $mixin->value . '::$' . $prop_name;
if (!$naive_property_exists) {
if ($class_storage->namedMixins) {
foreach ($class_storage->namedMixins as $mixin) {
$new_property_id = $mixin->value . '::$' . $prop_name;
try {
$new_class_storage = $codebase->classlike_storage_provider->get($mixin->value);
} catch (InvalidArgumentException $e) {
$new_class_storage = null;
}
if ($new_class_storage
&& ($codebase->properties->propertyExists(
$new_property_id,
!$in_assignment,
$statements_analyzer,
$context,
$codebase->collect_locations
? new CodeLocation($statements_analyzer->getSource(), $stmt)
: null,
)
|| isset($new_class_storage->pseudo_property_get_types['$' . $prop_name]))
) {
$fq_class_name = $mixin->value;
$lhs_type_part = $mixin;
$class_storage = $new_class_storage;
if (!isset($new_class_storage->pseudo_property_get_types['$' . $prop_name])) {
$naive_property_exists = true;
try {
$new_class_storage = $codebase->classlike_storage_provider->get($mixin->value);
} catch (InvalidArgumentException $e) {
$new_class_storage = null;
}
$property_id = $new_property_id;
if ($new_class_storage
&& ($codebase->properties->propertyExists(
$new_property_id,
!$in_assignment,
$statements_analyzer,
$context,
$codebase->collect_locations
? new CodeLocation($statements_analyzer->getSource(), $stmt)
: null,
)
|| isset($new_class_storage->pseudo_property_get_types['$' . $prop_name]))
) {
$fq_class_name = $mixin->value;
$lhs_type_part = $mixin;
$class_storage = $new_class_storage;
if (!isset($new_class_storage->pseudo_property_get_types['$' . $prop_name])) {
$naive_property_exists = true;
}
$property_id = $new_property_id;
}
}
} elseif ($intersection_types !== [] && !$class_storage->final) {
foreach ($intersection_types as $intersection_type) {
self::analyze(
$statements_analyzer,
$stmt,
$context,
$in_assignment,
$var_id,
$stmt_var_id,
$stmt_var_type,
$intersection_type,
$prop_name,
$has_valid_fetch_type,
$invalid_fetch_types,
$is_static_access,
);
if ($has_valid_fetch_type) {
return;
}
}
}
}

View File

@ -28,6 +28,7 @@ use Psalm\Storage\Assertion\NonEmpty;
use Psalm\Storage\Assertion\NonEmptyCountable;
use Psalm\Storage\Assertion\Truthy;
use Psalm\Type;
use Psalm\Type\Atomic;
use Psalm\Type\Atomic\Scalar;
use Psalm\Type\Atomic\TArray;
use Psalm\Type\Atomic\TArrayKey;
@ -292,7 +293,9 @@ class SimpleAssertionReconciler extends Reconciler
if ($assertion_type instanceof TObject) {
return self::reconcileObject(
$codebase,
$assertion,
$assertion_type,
$existing_var_type,
$key,
$negated,
@ -1580,7 +1583,9 @@ class SimpleAssertionReconciler extends Reconciler
* @param Reconciler::RECONCILIATION_* $failed_reconciliation
*/
private static function reconcileObject(
Codebase $codebase,
Assertion $assertion,
TObject $assertion_type,
Union $existing_var_type,
?string $key,
bool $negated,
@ -1600,7 +1605,17 @@ class SimpleAssertionReconciler extends Reconciler
$redundant = true;
foreach ($existing_var_atomic_types as $type) {
if ($type->isObjectType()) {
if (Type::isIntersectionType($assertion_type)
&& self::areIntersectionTypesAllowed($codebase, $type)
) {
$object_types[] = $type->addIntersectionType($assertion_type);
$redundant = false;
} elseif ($type instanceof TNamedObject
&& $codebase->classlike_storage_provider->has($type->value)
&& $codebase->classlike_storage_provider->get($type->value)->final
) {
$redundant = false;
} elseif ($type->isObjectType()) {
$object_types[] = $type;
} elseif ($type instanceof TCallable) {
$callable_object = new TCallableObject($type->from_docblock, $type);
@ -1614,8 +1629,17 @@ class SimpleAssertionReconciler extends Reconciler
$redundant = false;
} elseif ($type instanceof TTemplateParam) {
if ($type->as->hasObject() || $type->as->hasMixed()) {
$type = $type->replaceAs(self::reconcileObject(
/**
* @psalm-suppress PossiblyInvalidArgument This looks wrong, psalm assumes that $assertion_type
* can contain TNamedObject due to the reconciliation above
* regarding {@see Type::isIntersectionType}. Due to the
* native argument type `TObject`, the variable object will
* never be `TNamedObject`.
*/
$reconciled_type = self::reconcileObject(
$codebase,
$assertion,
$assertion_type,
$type->as,
null,
false,
@ -1623,7 +1647,8 @@ class SimpleAssertionReconciler extends Reconciler
$suppressed_issues,
$failed_reconciliation,
$is_equality,
));
);
$type = $type->replaceAs($reconciled_type);
$object_types[] = $type;
}
@ -2920,4 +2945,22 @@ class SimpleAssertionReconciler extends Reconciler
return TypeCombiner::combine(array_values($matched_class_constant_types), $codebase);
}
/**
* @psalm-assert-if-true TCallableObject|TObjectWithProperties|TNamedObject $type
*/
private static function areIntersectionTypesAllowed(Codebase $codebase, Atomic $type): bool
{
if ($type instanceof TObjectWithProperties || $type instanceof TCallableObject) {
return true;
}
if (!$type instanceof TNamedObject || !$codebase->classlike_storage_provider->has($type->value)) {
return false;
}
$class_storage = $codebase->classlike_storage_provider->get($type->value);
return !$class_storage->final;
}
}

View File

@ -15,6 +15,7 @@ use Psalm\Type\Atomic;
use Psalm\Type\Atomic\TArray;
use Psalm\Type\Atomic\TArrayKey;
use Psalm\Type\Atomic\TBool;
use Psalm\Type\Atomic\TCallableObject;
use Psalm\Type\Atomic\TClassString;
use Psalm\Type\Atomic\TClosure;
use Psalm\Type\Atomic\TFalse;
@ -962,10 +963,18 @@ abstract class Type
private static function hasIntersection(Atomic $type): bool
{
return ($type instanceof TIterable
|| $type instanceof TNamedObject
|| $type instanceof TTemplateParam
|| $type instanceof TObjectWithProperties
) && $type->extra_types;
return self::isIntersectionType($type) && $type->extra_types;
}
/**
* @psalm-assert-if-true TNamedObject|TTemplateParam|TIterable|TObjectWithProperties|TCallableObject $type
*/
public static function isIntersectionType(Atomic $type): bool
{
return $type instanceof TNamedObject
|| $type instanceof TTemplateParam
|| $type instanceof TIterable
|| $type instanceof TObjectWithProperties
|| $type instanceof TCallableObject;
}
}

View File

@ -916,6 +916,27 @@ class FunctionTemplateAssertTest extends TestCase
$numbersT->assert($mixed);
acceptsArray($mixed);',
],
'assertObjectShape' => [
'code' => '<?php
final class Foo
{
public const STATUS_OK = "ok";
public const STATUS_FAIL = "fail";
}
$foo = new stdClass();
/** @psalm-assert object{status: Foo::STATUS_*} $bar */
function assertObjectShape(object $bar): void {
}
assertObjectShape($foo);
$status = $foo->status;
',
'assertions' => [
'$status===' => "'fail'|'ok'",
],
],
];
}
@ -1196,6 +1217,23 @@ class FunctionTemplateAssertTest extends TestCase
}',
'error_message' => 'InvalidDocblock',
],
'assertObjectShapeOnFinalClass' => [
'code' => '<?php
final class Foo
{
}
$foo = new Foo();
/** @psalm-assert object{status: string} $bar */
function assertObjectShape(object $bar): void {
}
assertObjectShape($foo);
$status = $foo->status;
',
'error_message' => 'Type Foo for $foo is never',
],
];
}
}