diff --git a/docs/INCOMPATIBLE_PHP_FEATURES.md b/docs/INCOMPATIBLE_PHP_FEATURES.md index 936cf9a1..a2a22dbc 100644 --- a/docs/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/INCOMPATIBLE_PHP_FEATURES.md @@ -21,6 +21,7 @@ - PHP 8.4 Reflection Lazy Object 不能用于 TypePHP AOT 类。AOT 类以 persistent internal class 注册,而 Zend 的 `zend_object_make_lazy()` 明确拒绝 internal class;运行时动态加载的 ZendPHP user class 不受此限制。 - 支持 `private(set)` 与 `protected(set)` 非对称属性可见性,包括 constructor property promotion;Zend-backed 对象通过 PHP 8.4+ 类级 object handler 执行作用域检查,并保留 promoted/set visibility/implicit final 反射标志;Native 对象通过编译期访问检查执行同等作用域规则。 - 支持 final constructor property promotion,但 TypePHP 要求同时显式声明 `public`、`protected` 或 `private`;不接受 PHP 8.5 的 `final int $value` 隐式 public promotion 写法。该语法作为 TypePHP 扩展不受所链接 `libphp` 的源码语法版本限制,使用 PHP 8.4 `libphp.so` 时仍然可用。 +- TypePHP 禁止在全局或命名空间常量声明上使用 attributes;PHP 8.5 global constant attributes 不在支持范围内。class constant attributes 不受此限制。 - 不支持闭包或箭头函数按引用返回。 - 暂不支持 PHP 8.5 在全局常量、类常量、参数默认值或属性默认值中使用 `static function`;初始化表达式内嵌套的闭包同样会在编译期被拒绝。 - `__construct()` 不允许返回值。 @@ -50,6 +51,7 @@ ## 对象模型 - `toInt()`、`toString()`、`toArray()` 等保留关键词方法先于普通对象方法解析;需要参数的同名业务方法不按普通对象方法语义调用。 +- `toAny()` 和 `toRef()` 是不可覆盖的 TypePHP 关键词方法,普通 class-like 声明不得定义同名方法(方法名按 PHP 规则大小写不敏感)。Native class 仅可显式定义返回 `mixed/any` 的 `toAny()` 转换方法,不提供隐式转换;Native class 不支持 `toRef()`。 - 固定值类型属性未显式初始化时使用类型零值,不保留 ZendPHP 的完整 uninitialized 状态;因此 `??` 等依赖 uninitialized 状态的表达式可能不同。 - 禁止子类用同名 `private` 属性隐藏父类私有属性;`public` / `protected` 同名声明视为同一个继承 property slot,仍须满足类型、可见性和 `readonly` 兼容性要求。 - 为避免 typed property 写入路径引入额外动态检查,native typed property 在右值类型不确定或与属性类型不一致时会退化为 `setProperty()`;部分标量赋值可能遵循 Zend 弱类型转换,而不是 AOT 默认 strict 语义。 diff --git a/phpunit/code/class-constant-object-cast.php b/phpunit/code/class-constant-object-cast.php new file mode 100644 index 00000000..aeffc8ce --- /dev/null +++ b/phpunit/code/class-constant-object-cast.php @@ -0,0 +1,6 @@ + 1]; +} diff --git a/phpunit/code/global-constant-attribute.php b/phpunit/code/global-constant-attribute.php new file mode 100644 index 00000000..e11bef3e --- /dev/null +++ b/phpunit/code/global-constant-attribute.php @@ -0,0 +1,8 @@ +toAny(); +} diff --git a/phpunit/code/native-class-to-any-untyped-return.php b/phpunit/code/native-class-to-any-untyped-return.php new file mode 100644 index 00000000..ae9fe06f --- /dev/null +++ b/phpunit/code/native-class-to-any-untyped-return.php @@ -0,0 +1,10 @@ + 1]; +} diff --git a/phpunit/code/reserved-keyword-interface-method.php b/phpunit/code/reserved-keyword-interface-method.php new file mode 100644 index 00000000..3b44ecb1 --- /dev/null +++ b/phpunit/code/reserved-keyword-interface-method.php @@ -0,0 +1,6 @@ +expectException(\TypePhp\Exception\TestError::class); + $this->expectExceptionMessage( + 'Method name `toAny()` is reserved for a TypePHP keyword method and cannot be declared here', + ); + $this->compile('reserved-to-any-method.php'); + } + + public function testOrdinaryClassCannotDeclareToRefKeywordMethodCaseInsensitively(): void + { + $this->expectException(\TypePhp\Exception\TestError::class); + $this->expectExceptionMessage( + 'Method name `TOREF()` is reserved for a TypePHP keyword method and cannot be declared here', + ); + $this->compile('reserved-to-ref-method.php'); + } + + public function testInterfaceCannotDeclareToAnyKeywordMethod(): void + { + $this->expectException(\TypePhp\Exception\TestError::class); + $this->expectExceptionMessage('Method name `toAny()` is reserved for a TypePHP keyword method'); + $this->compile('reserved-keyword-interface-method.php'); + } + + public function testTraitCannotDeclareToRefKeywordMethod(): void + { + $this->expectException(\TypePhp\Exception\TestError::class); + $this->expectExceptionMessage('Method name `toRef()` is reserved for a TypePHP keyword method'); + $this->compile('reserved-keyword-trait-method.php'); + } + public function testRuntimeAttributesSupportLiteralAndConstantArrays(): void { $this->compile('preprocessor/attribute_array_argument.php'); @@ -12,6 +44,13 @@ class ClassTest extends \BaseTest $this->compile('preprocessor/attribute_new_expression_argument.php'); } + public function testGlobalConstantAttributesAreForbidden(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Attributes on global constants are not supported by TypePHP'); + $this->compile('global-constant-attribute.php'); + } + public function testGetterGeneratesPublicMethodsForInstanceProperties(): void { $this->compile('getter.php'); @@ -862,6 +901,20 @@ class ClassTest extends \BaseTest $this->compile('property-default-invalid-expression.php'); } + public function testClassConstantRejectsObjectCast(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Object casts are not supported in this context'); + $this->compile('class-constant-object-cast.php'); + } + + public function testPropertyDefaultRejectsObjectCast(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Object casts are not supported in this context'); + $this->compile('property-default-object-cast.php'); + } + public function testPropertyDefaultArrayForIntTypeFailsAtCompileTime() { $this->exec( diff --git a/phpunit/src/NativeClass/NativeClassValidationTest.php b/phpunit/src/NativeClass/NativeClassValidationTest.php index 8d225f6e..fb36837d 100644 --- a/phpunit/src/NativeClass/NativeClassValidationTest.php +++ b/phpunit/src/NativeClass/NativeClassValidationTest.php @@ -473,6 +473,31 @@ final class NativeClassValidationTest extends \BaseTest $this->compile('native-class-to-object-return-type.php'); } + public function testRejectsUndefinedNativeObjectToAnyKeyword(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Native class `NativeWithoutToAny` must define `toAny()` for this conversion'); + $this->compile('native-class-to-any-undefined.php'); + } + + public function testRejectsUntypedNativeObjectToAnyReturn(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage( + 'Native conversion method `NativeUntypedToAny::toAny()` must return exactly `mixed` or `any`', + ); + $this->compile('native-class-to-any-untyped-return.php'); + } + + public function testRejectsWrongNativeObjectToAnyReturnType(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage( + 'Native conversion method `NativeWrongToAnyReturn::toAny()` must return exactly `mixed` or `any`', + ); + $this->compile('native-class-to-any-wrong-return.php'); + } + public function testRejectsNativeObjectReferenceFunction(): void { $this->expectException(TestError::class); diff --git a/src/NativeClass/NativeClassSupportTrait.php b/src/NativeClass/NativeClassSupportTrait.php index 4f31d961..ac45227e 100644 --- a/src/NativeClass/NativeClassSupportTrait.php +++ b/src/NativeClass/NativeClassSupportTrait.php @@ -1383,11 +1383,38 @@ trait NativeClassSupportTrait if ($methodDef === null) { $this->fatalError($node, "Native class `{$class}` must define `{$method}()` for this conversion"); } - $function = $methodDef->functionDef; + $this->assertNativeObjectKeywordMethodSignature( + $node, + $class, + $resolvedMethod, + $methodDef->functionDef, + $expectedType, + ); + return $resolvedMethod; + } + + protected function assertNativeObjectKeywordMethodSignature( + NodeAbstract $node, + string $class, + string $method, + FunctionDef $function, + string $expectedType, + ): void { if ($function->argInfoList !== []) { - $this->fatalError($node, "Native conversion method `{$class}::{$resolvedMethod}()` must not accept arguments"); + $this->fatalError($node, "Native conversion method `{$class}::{$method}()` must not accept arguments"); + } + $hasExactReturnType = $function->returnType === $expectedType; + if ($expectedType === Type::VAR) { + // Type::VAR also represents an omitted return type and several + // other dynamic PHP types internally. A Native toAny() bridge is + // only valid when the author explicitly opts into mixed/any. + $hasExactReturnType = in_array( + strtolower($function->returnTypeStr), + ['mixed', 'any'], + true, + ); } - if ($function->returnsByRef || $function->returnNullable || $function->returnType !== $expectedType) { + if ($function->returnsByRef || $function->returnNullable || !$hasExactReturnType) { $expectedTypeName = match ($expectedType) { Type::INT => 'int', Type::FLOAT => 'float', @@ -1399,15 +1426,14 @@ trait NativeClassSupportTrait Type::BIGFLOAT => 'BigFloat', Type::DECIMAL => 'Decimal', Type::OBJECT => 'object', - Type::VAR => 'mixed', + Type::VAR => 'mixed` or `any', default => $expectedType, }; $this->fatalError( $node, - "Native conversion method `{$class}::{$resolvedMethod}()` must return exactly `{$expectedTypeName}`", + "Native conversion method `{$class}::{$method}()` must return exactly `{$expectedTypeName}`", ); } - return $resolvedMethod; } protected function parseNativeObjectExplicitConversion(NodeAbstract $expr, string $method): ?string diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 59cff735..5cfa2db1 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1800,6 +1800,7 @@ class Preprocessor extends CompilerBase $this->resetMethod(); $name = $this->getMethodName($v); $this->method = $name; + $this->assertKeywordMethodMayBeDeclared($v, $name, $this->classDef->nativeObject); $this->assertNativeMagicMethodSupported($v, $name); $flags = $this->parseModifiers($v->flags); $abstract = $flags & Modifiers::ABSTRACT; @@ -1867,6 +1868,16 @@ class Preprocessor extends CompilerBase $this->classDef->addAbstractMethod($name, $flags, $this->methodDef); } + if ($this->classDef->nativeObject && strtolower($name) === 'toany') { + $this->assertNativeObjectKeywordMethodSignature( + $v, + $this->classDef->getNamespacedName(false), + $name, + $this->methodDef->functionDef, + Type::VAR, + ); + } + $fullClassName = $this->getFullClassName(); $fullMethodName = $fullClassName . '::' . $this->method; @@ -1889,6 +1900,25 @@ class Preprocessor extends CompilerBase $this->resetMethod(); } + private function assertKeywordMethodMayBeDeclared( + Node\Stmt\ClassMethod $method, + string $name, + bool $nativeClass, + ): void { + $normalized = strtolower($name); + if ($normalized !== 'toany' && $normalized !== 'toref') { + return; + } + if ($nativeClass && $normalized === 'toany') { + return; + } + + $this->fatalError( + $method, + "Method name `{$name}()` is reserved for a TypePHP keyword method and cannot be declared here", + ); + } + /** * 递归检查所有子类(及子类的子类)是否已定义了同名方法,用于处理子类先于父类被预处理的情况。 */ @@ -1968,6 +1998,7 @@ class Preprocessor extends CompilerBase if ($stmt instanceof Node\Stmt\ClassMethod) { $methodName = $this->getMethodName($stmt); + $this->assertKeywordMethodMayBeDeclared($stmt, $methodName, false); if ($this->interfaceDef->hasMethod($methodName)) { $this->fatalError($stmt, "Duplicate method `{$methodName}`"); } diff --git a/src/Transform/RuntimeAttributeFactoryLowering.php b/src/Transform/RuntimeAttributeFactoryLowering.php index 5dbc5181..017d25de 100644 --- a/src/Transform/RuntimeAttributeFactoryLowering.php +++ b/src/Transform/RuntimeAttributeFactoryLowering.php @@ -127,6 +127,9 @@ final class RuntimeAttributeFactoryLowering extends NodeVisitorAbstract return (new NodeFinder())->findFirst($value, static function (Node $node): bool { return $node instanceof Expr\New_ || $node instanceof Expr\Closure + // A PHP 8.5 array cast may produce a non-empty array even + // though it is not represented by an Array_ AST node. + || $node instanceof Expr\Cast\Array_ || $node instanceof Expr\Cast\Object_ || (($node instanceof Expr\FuncCall || $node instanceof Expr\StaticCall) && $node->isFirstClassCallable()); diff --git a/src/Transform/Visitor.php b/src/Transform/Visitor.php index 1abe1e40..323411bf 100644 --- a/src/Transform/Visitor.php +++ b/src/Transform/Visitor.php @@ -35,6 +35,9 @@ class Visitor extends NodeVisitorAbstract public function enterNode(Node $node): null|Node { + if ($node instanceof Stmt\Const_ && $node->attrGroups !== []) { + throw new SyntaxError('Attributes on global constants are not supported by TypePHP'); + } if ($node instanceof Stmt\Namespace_) { $this->namespaceMagicName = $node->name?->toString() ?? ''; } diff --git a/src/gen_stub.php b/src/gen_stub.php index e00cb6a3..00b2c04b 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -2598,8 +2598,32 @@ class EvaluatedValue $isUnknownConstValue = false; + $evaluator = null; $evaluator = new ConstExprEvaluator( - static function (Expr $expr) use ($allConstInfos, &$isUnknownConstValue) { + static function (Expr $expr) use ( + $allConstInfos, + &$isUnknownConstValue, + &$evaluator, + ) { + // php-parser's ConstExprEvaluator predates PHP 8.5 constant + // expression casts. Keep the compatibility logic in TypePHP: + // validation has already rejected void and disallowed object + // casts before declaration values reach gen_stub.php. + if ($expr instanceof Expr\Cast) { + $value = $evaluator->evaluateDirectly($expr->expr); + return match (true) { + $expr instanceof Expr\Cast\Int_ => (int) $value, + $expr instanceof Expr\Cast\Double => (float) $value, + $expr instanceof Expr\Cast\Bool_ => (bool) $value, + $expr instanceof Expr\Cast\String_ => (string) $value, + $expr instanceof Expr\Cast\Array_ => (array) $value, + $expr instanceof Expr\Cast\Object_ => (object) $value, + default => throw new Exception( + "Unsupported constant expression cast " . $expr->getType() + ), + }; + } + // $expr is a ConstFetch with a name of a C macro here if (!($expr instanceof Expr\ConstFetch) and !($expr instanceof Expr\ClassConstFetch)) { _error: diff --git a/tests/compiler/attribute/class-constant-attributes.phpt b/tests/compiler/attribute/class-constant-attributes.phpt new file mode 100644 index 00000000..3b8d961b --- /dev/null +++ b/tests/compiler/attribute/class-constant-attributes.phpt @@ -0,0 +1,82 @@ +--TEST-- +Class-like constants preserve attributes and Reflection metadata +--FILE-- + 'class'])] + #[ConstantTag(name: 'secondary')] + public const VALUE = 42; +} + +interface InterfaceConstantOwner +{ + #[ConstantTag('interface')] + public const VALUE = 'interface'; +} + +trait TraitConstantOwner +{ + #[ConstantTag('trait')] + public const VALUE = 'trait'; +} + +class TraitConstantConsumer +{ + use TraitConstantOwner; +} + +enum EnumConstantOwner +{ + case Item; + + #[ConstantTag('enum')] + public const VALUE = 'enum'; +} + +function dumpConstantAttributes(string $class, string $constant): void +{ + $reflection = new ReflectionClassConstant($class, $constant); + echo $class, '::', $constant, '=', $reflection->getValue(), "\n"; + + $attributes = $reflection->getAttributes(ConstantTag::class); + var_dump(count($attributes)); + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + echo $instance->name, ':', $instance->metadata['kind'] ?? 'none', "\n"; + } +} + +function main(): void +{ + dumpConstantAttributes(ClassConstantOwner::class, 'VALUE'); + dumpConstantAttributes(InterfaceConstantOwner::class, 'VALUE'); + dumpConstantAttributes(TraitConstantConsumer::class, 'VALUE'); + dumpConstantAttributes(EnumConstantOwner::class, 'VALUE'); +} +?> +--EXPECT-- +ClassConstantOwner::VALUE=42 +int(2) +primary:class +secondary:none +InterfaceConstantOwner::VALUE=interface +int(1) +interface:none +TraitConstantConsumer::VALUE=trait +int(1) +trait:none +EnumConstantOwner::VALUE=enum +int(1) +enum:none diff --git a/tests/compiler/const/constant-expression-casts.phpt b/tests/compiler/const/constant-expression-casts.phpt new file mode 100644 index 00000000..a02538ec --- /dev/null +++ b/tests/compiler/const/constant-expression-casts.phpt @@ -0,0 +1,158 @@ +--TEST-- +PHP 8.5 casts in constant expressions across declaration contexts +--FILE-- + 'global']; +const CAST_SOURCE = 15.75; +const CAST_FROM_CONSTANT = (int) CAST_SOURCE; + +class ConstantCastDefaults +{ + public const INTEGER = (int) 9.75; + public const BOOLEAN = (bool) 0; + public const FLOAT = (float) 8; + public const STRING = (string) 456; + public const ARRAY = (array) 'class'; + public const SOURCE = 14.75; + public const FROM_CONSTANT = (int) self::SOURCE; + + public int $integer = (int) 6.75; + public bool $boolean = (bool) 1; + public float $float = (float) 5; + public string $string = (string) 789; + public array $array = (array) 'property'; +} + +#[ConstantCastMetadata( + (int) 4.75, + (bool) 0.25, + (float) 3, + (string) 321, + (array) 'attribute', + (object) ['value' => 'attribute'], +)] +class ConstantCastTarget +{ +} + +function constantCastDefaults( + int $integer = (int) 2.75, + bool $boolean = (bool) 0, + float $float = (float) 1, + string $string = (string) 654, + array $array = (array) 'parameter', + object $object = (object) ['value' => 'parameter'], +): void { + var_dump($integer, $boolean, $float, $string, $array, $object->value); +} + +function main(): void +{ + var_dump( + CAST_INT, + CAST_BOOL, + CAST_FLOAT, + CAST_STRING, + CAST_ARRAY, + CAST_OBJECT->value, + CAST_FROM_CONSTANT, + ); + var_dump( + ConstantCastDefaults::INTEGER, + ConstantCastDefaults::BOOLEAN, + ConstantCastDefaults::FLOAT, + ConstantCastDefaults::STRING, + ConstantCastDefaults::ARRAY, + ConstantCastDefaults::FROM_CONSTANT, + ); + + $defaults = new ConstantCastDefaults(); + var_dump( + $defaults->integer, + $defaults->boolean, + $defaults->float, + $defaults->string, + $defaults->array, + ); + + constantCastDefaults(); + + $attribute = (new ReflectionClass(ConstantCastTarget::class)) + ->getAttributes(ConstantCastMetadata::class)[0] + ->newInstance(); + var_dump( + $attribute->integer, + $attribute->boolean, + $attribute->float, + $attribute->string, + $attribute->array, + $attribute->object->value, + ); +} +?> +--EXPECT-- +int(12) +bool(true) +float(7) +string(3) "123" +array(1) { + [0]=> + string(6) "global" +} +string(6) "global" +int(15) +int(9) +bool(false) +float(8) +string(3) "456" +array(1) { + [0]=> + string(5) "class" +} +int(14) +int(6) +bool(true) +float(5) +string(3) "789" +array(1) { + [0]=> + string(8) "property" +} +int(2) +bool(false) +float(1) +string(3) "654" +array(1) { + [0]=> + string(9) "parameter" +} +string(9) "parameter" +int(4) +bool(true) +float(3) +string(3) "321" +array(1) { + [0]=> + string(9) "attribute" +} +string(9) "attribute" diff --git a/tests/compiler/native-class/keyword-conversions.phpt b/tests/compiler/native-class/keyword-conversions.phpt index 45121278..10361cc5 100644 --- a/tests/compiler/native-class/keyword-conversions.phpt +++ b/tests/compiler/native-class/keyword-conversions.phpt @@ -32,6 +32,11 @@ class NativeConversions { return 'value=' . $this->value; } + + public function toAny(): mixed + { + return $this->value; + } } #[Native] @@ -43,6 +48,15 @@ class NativeMagicString } } +#[Native] +class NativeExplicitAnyConversion +{ + public function toAny(): any + { + return 'explicit-any'; + } +} + function main(): void { $value = new NativeConversions(); @@ -51,6 +65,8 @@ function main(): void var_dump($value->toFloat()); var_dump($value->toBool()); var_dump($value->toString()); + var_dump($value->toAny()); + var_dump((new NativeExplicitAnyConversion())->toAny()); var_dump((array) $value); var_dump((int) $value); var_dump((float) $value); @@ -75,6 +91,8 @@ int(7) float(7.5) bool(true) string(7) "value=7" +int(7) +string(12) "explicit-any" array(1) { [0]=> int(7)