feat(compiler): add support for PHP 8.5 constant expression casts

- Add support for (int), (bool), (float), (string), (array), (object) casts
  in constant expressions across all declaration contexts
- Implement proper evaluation of cast expressions during stub generation
- Add comprehensive test coverage for constant expression casts in various
 contexts including global constants, class constants, properties, and
  parameters
- Update documentation to reflect PHP 8.5 cast support
- Enhance RuntimeAttributeFactoryLowering to handle array cast nodes
  correctly
- Add validation and error handling for unsupported cast types in
  constant expressions
master
韩天峰 18 hours ago
parent 44095252d2
commit 3def25cc3c
  1. 2
      docs/INCOMPATIBLE_PHP_FEATURES.md
  2. 6
      phpunit/code/class-constant-object-cast.php
  3. 8
      phpunit/code/global-constant-attribute.php
  4. 11
      phpunit/code/native-class-to-any-undefined.php
  5. 10
      phpunit/code/native-class-to-any-untyped-return.php
  6. 10
      phpunit/code/native-class-to-any-wrong-return.php
  7. 6
      phpunit/code/property-default-object-cast.php
  8. 6
      phpunit/code/reserved-keyword-interface-method.php
  9. 9
      phpunit/code/reserved-keyword-trait-method.php
  10. 9
      phpunit/code/reserved-to-any-method.php
  11. 9
      phpunit/code/reserved-to-ref-method.php
  12. 53
      phpunit/src/ClassTest.php
  13. 25
      phpunit/src/NativeClass/NativeClassValidationTest.php
  14. 38
      src/NativeClass/NativeClassSupportTrait.php
  15. 31
      src/Preprocessor.php
  16. 3
      src/Transform/RuntimeAttributeFactoryLowering.php
  17. 3
      src/Transform/Visitor.php
  18. 26
      src/gen_stub.php
  19. 82
      tests/compiler/attribute/class-constant-attributes.phpt
  20. 158
      tests/compiler/const/constant-expression-casts.phpt
  21. 18
      tests/compiler/native-class/keyword-conversions.phpt

@ -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 语义。

@ -0,0 +1,6 @@
<?php
class ClassConstantObjectCast
{
public const VALUE = (object) ['value' => 1];
}

@ -0,0 +1,8 @@
<?php
#[GlobalConstantMetadata]
const GLOBAL_CONSTANT_WITH_ATTRIBUTE = 1;
function main(): void
{
}

@ -0,0 +1,11 @@
<?php
#[Native]
class NativeWithoutToAny
{
}
function main(): void
{
(new NativeWithoutToAny())->toAny();
}

@ -0,0 +1,10 @@
<?php
#[Native]
class NativeUntypedToAny
{
public function toAny()
{
return 1;
}
}

@ -0,0 +1,10 @@
<?php
#[Native]
class NativeWrongToAnyReturn
{
public function toAny(): int
{
return 1;
}
}

@ -0,0 +1,6 @@
<?php
class PropertyDefaultObjectCast
{
public object $value = (object) ['value' => 1];
}

@ -0,0 +1,6 @@
<?php
interface ReservedKeywordInterfaceMethod
{
public function toAny(): mixed;
}

@ -0,0 +1,9 @@
<?php
trait ReservedKeywordTraitMethod
{
public function toRef(): mixed
{
return $this;
}
}

@ -0,0 +1,9 @@
<?php
class ReservedToAnyMethod
{
public function toAny(): mixed
{
return $this;
}
}

@ -0,0 +1,9 @@
<?php
class ReservedToRefMethod
{
public function TOREF(): mixed
{
return $this;
}
}

@ -2,6 +2,38 @@
class ClassTest extends \BaseTest
{
public function testOrdinaryClassCannotDeclareToAnyKeywordMethod(): void
{
$this->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(

@ -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);

@ -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

@ -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}`");
}

@ -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());

@ -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() ?? '';
}

@ -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:

@ -0,0 +1,82 @@
--TEST--
Class-like constants preserve attributes and Reflection metadata
--FILE--
<?php
#[Attribute(Attribute::TARGET_CLASS_CONSTANT | Attribute::IS_REPEATABLE)]
class ConstantTag
{
public function __construct(
public string $name,
public array $metadata = [],
) {
}
}
class ClassConstantOwner
{
#[ConstantTag('primary', ['kind' => '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

@ -0,0 +1,158 @@
--TEST--
PHP 8.5 casts in constant expressions across declaration contexts
--FILE--
<?php
#[Attribute(Attribute::TARGET_CLASS)]
class ConstantCastMetadata
{
public function __construct(
public int $integer,
public bool $boolean,
public float $float,
public string $string,
public array $array,
public object $object,
) {
}
}
const CAST_INT = (int) 12.75;
const CAST_BOOL = (bool) 0.5;
const CAST_FLOAT = (float) 7;
const CAST_STRING = (string) 123;
const CAST_ARRAY = (array) 'global';
const CAST_OBJECT = (object) ['value' => '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"

@ -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)

Loading…
Cancel
Save