diff --git a/.github/workflows/analyzers.yaml b/.github/workflows/analyzers.yaml index d9f1846..f7f3aea 100644 --- a/.github/workflows/analyzers.yaml +++ b/.github/workflows/analyzers.yaml @@ -12,7 +12,7 @@ jobs: strategy: matrix: operating-system: [ubuntu-latest] - php-versions: ['8.1', '8.2', '8.3', '8.4'] + php-versions: ['8.2', '8.3', '8.4'] composer-options: ['--ignore-platform-req=php+'] fail-fast: false name: PHP ${{ matrix.php-versions }} @ ${{ matrix.operating-system }} diff --git a/src/Psalm/Plugin.php b/src/Psalm/Plugin.php index aacedec..a79294c 100644 --- a/src/Psalm/Plugin.php +++ b/src/Psalm/Plugin.php @@ -28,6 +28,8 @@ private function getHooks(): iterable { yield Iso\Provider\ComposeProvider::class; yield Lens\Provider\ComposeProvider::class; + yield Reflect\Provider\PropertiesSetProvider::class; + yield Reflect\Provider\PropertiesGetProvider::class; yield Reflect\Provider\PropertyGetProvider::class; yield Reflect\Provider\PropertySetProvider::class; } diff --git a/src/Psalm/Reflect/Infer/PropertiesValuesType.php b/src/Psalm/Reflect/Infer/PropertiesValuesType.php new file mode 100644 index 0000000..ac9c447 --- /dev/null +++ b/src/Psalm/Reflect/Infer/PropertiesValuesType.php @@ -0,0 +1,46 @@ +value); + $properties = $class->properties(); + + return new Union([ + new TKeyedArray( + map( + $properties, + static fn (ReflectedProperty $prop) => Reflection::getPsalmTypeFromReflectionType($prop->apply( + static fn (ReflectionProperty $reflected) => $reflected->getType() + ))->setPossiblyUndefined($partial), + ), + fallback_params: $class->isDynamic() ? [Type::getArrayKey(), Type::getMixed()] : null, + ) + ]); + } +} diff --git a/src/Psalm/Reflect/Provider/PropertiesGetProvider.php b/src/Psalm/Reflect/Provider/PropertiesGetProvider.php new file mode 100644 index 0000000..e7e5777 --- /dev/null +++ b/src/Psalm/Reflect/Provider/PropertiesGetProvider.php @@ -0,0 +1,49 @@ + + */ + public static function getFunctionIds(): array + { + return ['veewee\reflecta\reflect\properties_get']; + } + + public static function getFunctionStorage(DynamicFunctionStorageProviderEvent $event): ?DynamicFunctionStorage + { + $args = $event->getArgs(); + $inferrer = $event->getArgTypeInferer(); + + $objectType = ObjectType::infer($inferrer, $args[0]); + $hasPredicate = array_key_exists(1, $args); + $predicateType = $hasPredicate ? $inferrer->infer($args[1]) : Type::getNull(); + $valuesType = PropertiesValuesType::infer($objectType, partial: $hasPredicate); + + if (!$objectType || !$valuesType) { + return null; + } + + $storage = new DynamicFunctionStorage(); + $storage->params = [ + new FunctionLikeParameter('object', false, new Union([$objectType])), + new FunctionLikeParameter('predicate', false, $predicateType), + ]; + $storage->return_type = $valuesType; + + return $storage; + } +} diff --git a/src/Psalm/Reflect/Provider/PropertiesSetProvider.php b/src/Psalm/Reflect/Provider/PropertiesSetProvider.php new file mode 100644 index 0000000..8e22922 --- /dev/null +++ b/src/Psalm/Reflect/Provider/PropertiesSetProvider.php @@ -0,0 +1,49 @@ + + */ + public static function getFunctionIds(): array + { + return ['veewee\reflecta\reflect\properties_set']; + } + + public static function getFunctionStorage(DynamicFunctionStorageProviderEvent $event): ?DynamicFunctionStorage + { + $args = $event->getArgs(); + $inferrer = $event->getArgTypeInferer(); + + $objectType = ObjectType::infer($inferrer, $args[0]); + $valuesType = PropertiesValuesType::infer($objectType, partial: true); + $predicateType = array_key_exists(2, $args) ? $inferrer->infer($args[2]) : Type::getNull(); + + if (!$objectType || !$valuesType) { + return null; + } + + $storage = new DynamicFunctionStorage(); + $storage->params = [ + new FunctionLikeParameter('object', false, new Union([$objectType])), + new FunctionLikeParameter('values', false, $valuesType), + new FunctionLikeParameter('predicate', false, $predicateType), + ]; + $storage->return_type = new Union([$objectType]); + + return $storage; + } +} diff --git a/tests/fixtures/MultipleProperties.php b/tests/fixtures/MultipleProperties.php new file mode 100644 index 0000000..b5d86f0 --- /dev/null +++ b/tests/fixtures/MultipleProperties.php @@ -0,0 +1,10 @@ +z = 123; + + return properties_get($x); +} + +/** + * @return array{z ?: int|null} + */ +function test_get_optional_prop_return_type(): array +{ + $x = new X(); + $x->z = 123; + + return properties_get($x, property_visibility(Visibility::Private)); +} + +/** + * @return array{a: string, b: string, c: string} + */ +function test_get_multi_props_return_type(): array +{ + $x = new MultipleProperties(); + + return properties_get($x); +} + +/** + * @return array{a ?: string, b ?: string, c ?: string} + */ +function test_get_optional_multi_props_return_type(): array +{ + $x = new MultipleProperties(); + + return properties_get($x, property_visibility(Visibility::Private)); +} + +/** + * @return array{x: string, ...} + */ +function test_get_dynamic_props_return_type(): array +{ + $x = new Dynamic(); + + return properties_get($x); +} + +/** + * @return array{x ?: string, ...} + */ +function test_get_optional_dynamic_props_return_type(): array +{ + $x = new Dynamic(); + + return properties_get($x, property_visibility(Visibility::Private)); +} + +function test_get_mixed_return_type_on_templated_object(): array +{ + $curried = static fn (): Closure => static fn (object $object): array => properties_get($object); + $x = new X(); + + return $curried()($x); +} diff --git a/tests/static-analyzer/Reflect/properties_set.php b/tests/static-analyzer/Reflect/properties_set.php new file mode 100644 index 0000000..fdfb0df --- /dev/null +++ b/tests/static-analyzer/Reflect/properties_set.php @@ -0,0 +1,64 @@ +z = 123; + + return properties_set($x, ['z' => 456]); +} + +function test_set_valid_prop_value_type_with_predicate(): X +{ + $x = new X(); + $x->z = 123; + + return properties_set($x, ['z' => 456], property_visibility(Visibility::Private)); +} + +function test_set_partial_props(): MultipleProperties +{ + $x = new MultipleProperties(); + $x->a = ''; + $x->b = ''; + + return properties_set($x, ['c' => 'foo']); +} + +/** + * @psalm-suppress InvalidScalarArgument + */ +function test_set_invalid_prop_value_type(): X +{ + $x = new X(); + $x->z = 123; + + return properties_set($x, ['z' => 'nope']); +} + +/** + * @psalm-suppress InvalidArgument + */ +function test_assigning_unknown_property(): X +{ + $x = new X(); + + return properties_set($x, ['unknown' => 'nope']); +} + +function test_set_new_prop_on_dynamic_class(): Dynamic +{ + $x = new Dynamic(); + $x->x = 'string'; + + return properties_set($x, ['foo' => 'bar']); +}