diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c2124a4..3793133 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -3276,16 +3276,10 @@ parameters: count: 1 path: tests/Reflection/ReflectionClassTest.php - - - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertSame\(\) with false and true will always evaluate to false\.$#' - identifier: method.impossibleType - count: 1 - path: tests/Reflection/ReflectionClassTest.php - - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' identifier: method.alreadyNarrowedType - count: 1 + count: 2 path: tests/Reflection/ReflectionClassTest.php - diff --git a/src/ClassExtension/Hook/CastObjectHook.php b/src/ClassExtension/Hook/CastObjectHook.php index a8be40d..c5b8e90 100644 --- a/src/ClassExtension/Hook/CastObjectHook.php +++ b/src/ClassExtension/Hook/CastObjectHook.php @@ -68,6 +68,18 @@ public function getCastType(): int return $this->type; } + /** + * Returns the cast type as a named case, or null for an id unknown to this PHP line + * + * Prefer this over comparing getCastType() against numeric constants: the cast-only type ids + * have shifted between PHP minors before, and the enum is guarded against the generated + * engine ground truth. + */ + public function getCastTypeEnum(): ?CastType + { + return CastType::tryFrom($this->type); + } + /** * Returns an object instance */ diff --git a/src/ClassExtension/Hook/CastType.php b/src/ClassExtension/Hook/CastType.php new file mode 100644 index 0000000..f48e7b2 --- /dev/null +++ b/src/ClassExtension/Hook/CastType.php @@ -0,0 +1,47 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\ClassExtension\Hook; + +/** + * Named view of the type ids the engine passes to a cast_object handler + * + * The backing values are the zval type ids from Zend/zend_types.h for the PHP minor this branch + * targets, and they are guarded against the generated ground truth by EngineConstantsTest. Prefer + * dispatching on these cases over raw ReflectionValue constants: the cast-only ids have moved + * between PHP minors before (PHP 8.1 inserted IS_NEVER = 17, shifting _IS_BOOL and _IS_NUMBER up + * by one), and a namesake constant with a stale value misroutes casts silently. + */ +enum CastType: int +{ + /** IS_LONG: explicit (int) casts and integer coercion */ + case Long = 4; + + /** IS_DOUBLE: explicit (float) casts and float coercion */ + case Double = 5; + + /** IS_STRING: explicit (string) casts, string interpolation and echo */ + case String = 6; + + /** IS_ARRAY: passed by extensions that invoke cast_object directly with an array target */ + case Array = 7; + + /** IS_OBJECT: passed by extensions that invoke cast_object directly with an object target */ + case Object = 8; + + /** _IS_BOOL: explicit (bool) casts and every boolean context (if, &&, !, ...) */ + case Bool = 18; + + /** _IS_NUMBER: numeric coercion where either int or float is acceptable */ + case Number = 19; +} diff --git a/src/ClassExtension/Hook/GetPropertiesForHook.php b/src/ClassExtension/Hook/GetPropertiesForHook.php index d0fe414..b4a21f1 100644 --- a/src/ClassExtension/Hook/GetPropertiesForHook.php +++ b/src/ClassExtension/Hook/GetPropertiesForHook.php @@ -77,6 +77,14 @@ public function getPurpose(): int return $this->purpose; } + /** + * Returns the purpose as a named case, or null for a value unknown to this PHP line + */ + public function getPurposeEnum(): ?PropertyPurpose + { + return PropertyPurpose::tryFrom($this->purpose); + } + /** * Proceeds with default handler */ diff --git a/src/ClassExtension/Hook/PropertyPurpose.php b/src/ClassExtension/Hook/PropertyPurpose.php new file mode 100644 index 0000000..06cfde1 --- /dev/null +++ b/src/ClassExtension/Hook/PropertyPurpose.php @@ -0,0 +1,44 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\ClassExtension\Hook; + +/** + * Named view of the zend_prop_purpose values the engine passes to a get_properties_for handler + * + * The backing values mirror the zend_prop_purpose enumeration in Zend/zend_object_handlers.h for + * the PHP minor this branch targets. They are not yet exported by the header generator manifest, + * so unlike CastType they carry no generated-ground-truth guard — the enumeration has been stable + * since PHP 7.4 introduced it, but verify against zend_object_handlers.h when bumping the branch + * to a new minor. + */ +enum PropertyPurpose: int +{ + /** ZEND_PROP_PURPOSE_DEBUG: var_dump() and friends; supersedes the get_debug_info handler */ + case Debug = 0; + + /** ZEND_PROP_PURPOSE_ARRAY_CAST: explicit (array) casts */ + case ArrayCast = 1; + + /** ZEND_PROP_PURPOSE_SERIALIZE: serialize() using the "O" scheme */ + case Serialize = 2; + + /** ZEND_PROP_PURPOSE_VAR_EXPORT: var_export(); the data is passed to __set_state() */ + case VarExport = 3; + + /** ZEND_PROP_PURPOSE_JSON: json_encode() */ + case Json = 4; + + /** ZEND_PROP_PURPOSE_GET_OBJECT_VARS: get_object_vars() */ + case GetObjectVars = 5; +} diff --git a/src/Reflection/ReflectionValue.php b/src/Reflection/ReflectionValue.php index d21f8aa..89b839b 100644 --- a/src/Reflection/ReflectionValue.php +++ b/src/Reflection/ReflectionValue.php @@ -124,6 +124,7 @@ class ReflectionValue implements ReferenceCountedInterface public const IS_VOID = 14; public const IS_STATIC = 15; public const IS_MIXED = 16; + public const IS_NEVER = 17; /* internal types */ public const IS_INDIRECT = 12; @@ -132,8 +133,8 @@ class ReflectionValue implements ReferenceCountedInterface public const _IS_ERROR = 15; /* used for casts */ - public const _IS_BOOL = 17; - public const _IS_NUMBER = 18; + public const _IS_BOOL = 18; + public const _IS_NUMBER = 19; private const Z_TYPE_FLAGS_MASK = 0xFF00; diff --git a/tests/EngineConstantsTest.php b/tests/EngineConstantsTest.php index f6ab9aa..fcd854e 100644 --- a/tests/EngineConstantsTest.php +++ b/tests/EngineConstantsTest.php @@ -16,6 +16,8 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ZEngine\AbstractSyntaxTree\NodeKind; +use ZEngine\ClassExtension\Hook\CastType; +use ZEngine\Reflection\ReflectionValue; use ZEngine\System\OpCode; /** @@ -31,10 +33,12 @@ final class EngineConstantsTest extends TestCase public static function constantOwnerProvider(): array { return [ - 'ZEND_ACC_* on Core' => [Core::class, 'ZEND_ACC_'], - 'ZEND_PROPERTY_HOOK_* on Core' => [Core::class, 'ZEND_PROPERTY_HOOK_'], - 'opcodes on OpCode' => [OpCode::class, ''], - 'AST kinds on NodeKind' => [NodeKind::class, 'AST_'], + 'ZEND_ACC_* on Core' => [Core::class, 'ZEND_ACC_'], + 'ZEND_PROPERTY_HOOK_* on Core' => [Core::class, 'ZEND_PROPERTY_HOOK_'], + 'opcodes on OpCode' => [OpCode::class, ''], + 'AST kinds on NodeKind' => [NodeKind::class, 'AST_'], + 'zval type ids on ReflectionValue' => [ReflectionValue::class, 'IS_'], + 'cast/internal ids on ReflectionValue' => [ReflectionValue::class, '_IS_'], ]; } @@ -77,6 +81,30 @@ public function testModuleApiVersionMatchesRunningEngine(): void ); } + public function testCastTypeCasesMatchGeneratedGroundTruth(): void + { + $generated = self::loadGeneratedConstants(); + $symbolByCase = [ + 'Long' => 'IS_LONG', + 'Double' => 'IS_DOUBLE', + 'String' => 'IS_STRING', + 'Array' => 'IS_ARRAY', + 'Object' => 'IS_OBJECT', + 'Bool' => '_IS_BOOL', + 'Number' => '_IS_NUMBER', + ]; + + foreach (CastType::cases() as $case) { + $this->assertArrayHasKey($case->name, $symbolByCase, "CastType::{$case->name} has no engine symbol mapping in this test"); + $engineName = $symbolByCase[$case->name]; + $this->assertSame( + $generated[$engineName], + $case->value, + "CastType::{$case->name} ({$case->value}) does not match engine {$engineName} ({$generated[$engineName]})", + ); + } + } + /** * @return array */ diff --git a/tests/Reflection/ReflectionClassTest.php b/tests/Reflection/ReflectionClassTest.php index 0055b5d..71d3970 100644 --- a/tests/Reflection/ReflectionClassTest.php +++ b/tests/Reflection/ReflectionClassTest.php @@ -19,6 +19,7 @@ use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use ZEngine\ClassExtension\Hook\CastObjectHook; +use ZEngine\ClassExtension\Hook\CastType; use ZEngine\ClassExtension\Hook\CloneObjectHook; use ZEngine\ClassExtension\Hook\CompareValuesHook; use ZEngine\ClassExtension\Hook\CreateObjectHook; @@ -293,19 +294,18 @@ public function testInstallCastObjectHandler(): void $handler = Closure::fromCallable([ObjectCreateTrait::class, '__init']); $this->refClass->setCreateObjectHandler($handler); $this->refClass->setCastObjectHandler(function (CastObjectHook $hook) { - $castType = $hook->getCastType(); - switch ($castType) { - case ReflectionValue::IS_LONG: - case ReflectionValue::_IS_NUMBER: - return 1; - case ReflectionValue::IS_DOUBLE: - return 2.0; - case ReflectionValue::IS_STRING: - return 'test'; - case ReflectionValue::_IS_BOOL: - return false; - } - throw new \UnexpectedValueException('Unknown type ' . ReflectionValue::name($castType)); + return match ($hook->getCastTypeEnum()) { + CastType::Long, CastType::Number => 1, + CastType::Double => 2.0, + CastType::String => 'test', + // The engine accepts a boolean cast result only as IS_TRUE/IS_FALSE: a handler + // misrouted into the numeric branch would produce long(1), which the engine + // reports as false - so asserting true below proves the _IS_BOOL id is correct + CastType::Bool => true, + default => throw new \UnexpectedValueException( + 'Unknown type ' . ReflectionValue::name($hook->getCastType()), + ), + }; }); $testClass = new TestClass(); @@ -316,7 +316,7 @@ public function testInstallCastObjectHandler(): void $string = (string) $testClass; $this->assertSame('test', $string); $bool = (bool) $testClass; - $this->assertSame(false, $bool); + $this->assertTrue($bool); $this->markTestIncomplete('Initialization object handler brings segfaults thus run it separately'); }