From 9fc57555447252c366beebc71d6638144002d49a Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Mon, 24 Aug 2026 17:14:46 +0800 Subject: [PATCH] feat(parser): add support for PHP 8.5 void cast syntax - Implement parsing and validation for (void) cast expressions - Add VoidCastValidationVisitor to enforce statement-only usage rules - Allow (void) cast in discarded for-expression-list positions - Generate appropriate diagnostic errors for invalid (void) cast contexts - Update documentation to reflect void cast support and limitations - Add comprehensive tests for void cast functionality and error cases --- docs/INCOMPATIBLE_PHP_FEATURES.md | 3 +- .../src/ConstantExpressionValidatorTest.php | 5 + phpunit/src/NegativeCompatibilityTest.php | 73 ++++++++++++ src/CompilerBase.php | 7 ++ src/Parser/LoopControlTrait.php | 21 +++- src/Parser/UnaryExpressionTrait.php | 12 +- src/Preprocessor.php | 4 + src/Transform/ConstantExpressionValidator.php | 3 +- src/Transform/VoidCastValidationVisitor.php | 106 ++++++++++++++++++ src/Translator.php | 4 + tests/compiler/basic/void-cast.phpt | 42 +++++++ 11 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 src/Transform/VoidCastValidationVisitor.php create mode 100644 tests/compiler/basic/void-cast.phpt diff --git a/docs/INCOMPATIBLE_PHP_FEATURES.md b/docs/INCOMPATIBLE_PHP_FEATURES.md index c96d28aa..f457d127 100644 --- a/docs/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/INCOMPATIBLE_PHP_FEATURES.md @@ -14,7 +14,8 @@ ## 声明与类型 - 不支持可变变量 `$$var`。 -- 不支持 PHP 8.5 `#[NoDiscard]` 及用于显式丢弃返回值的 `(void)` 语法。 +- 暂不支持 PHP 8.5 `#[NoDiscard]`。 +- 支持 PHP 8.5 `(void)` 显式丢弃语句;操作数仍会求值并保留副作用,不能在赋值、返回、参数或条件等值上下文中使用。 - PHP 8.4 property hooks 会编译为 AOT getter/setter,并注册对应的 Zend hook 元数据;直接属性读写、Reflection 和对象遍历均受支持。当前不支持对 hook 属性取引用。 - 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)` 非对称属性可见性,并通过 PHP 8.4+ 的类级对象 handler 执行同等作用域检查。 diff --git a/phpunit/src/ConstantExpressionValidatorTest.php b/phpunit/src/ConstantExpressionValidatorTest.php index 4e4a3380..abd0815f 100644 --- a/phpunit/src/ConstantExpressionValidatorTest.php +++ b/phpunit/src/ConstantExpressionValidatorTest.php @@ -68,6 +68,11 @@ final class ConstantExpressionValidatorTest extends PHPUnit\Framework\TestCase '8.5', 'Constant expression contains invalid operations', ]; + yield 'PHP 8.5 void cast is a statement' => [ + '(void) 1', + '8.5', + 'Constant expression contains invalid operations', + ]; yield 'first-class callable before PHP 8.5' => [ 'strlen(...)', '8.4', diff --git a/phpunit/src/NegativeCompatibilityTest.php b/phpunit/src/NegativeCompatibilityTest.php index eec5512e..0539d920 100644 --- a/phpunit/src/NegativeCompatibilityTest.php +++ b/phpunit/src/NegativeCompatibilityTest.php @@ -304,6 +304,79 @@ function main(): void foreach ($rows as [&$left, &$right]) { // @diagnostic } } +PHP, + ]; + + yield 'void cast result assignment' => [ + 'prepare', + 'The (void) cast can only be used as a statement', + <<<'PHP' + [ + 'prepare', + 'The (void) cast can only be used as a statement', + <<<'PHP' + [ + 'prepare', + 'The (void) cast can only be used as a statement', + <<<'PHP' + [ + 'prepare', + 'The (void) cast can only be used as a statement', + <<<'PHP' + [ + 'prepare', + 'The (void) cast can only be used as a statement', + <<<'PHP' +parseCastArray($expr); case 'Expr_Cast_Object': return $this->parseCastObject($expr); + case 'Expr_Cast_Void': + return $this->parseCastVoid($expr); case 'Expr_ConstFetch': return $this->parseConstFetch($expr); case 'Expr_UnaryMinus': @@ -1181,6 +1183,9 @@ class CompilerBase implements PropertyAccessContext protected function parseExprAsValue(NodeAbstract $expr): string { + if ($expr instanceof Expr\Cast\Void_) { + return $this->parseExpr($expr); + } $value = $this->wrapVoidExprAsNull($expr, $this->parseExpr($expr)); return $this->normalizeNativeObjectValueExpr($expr, $value); } @@ -2970,6 +2975,8 @@ class CompilerBase implements PropertyAccessContext case 'Expr_Cast_Bool': case 'Scalar_Bool': return Type::BOOL; + case 'Expr_Cast_Void': + return Type::VOID; case 'Expr_Array': case 'Expr_Cast_Array': return Type::ARRAY; diff --git a/src/Parser/LoopControlTrait.php b/src/Parser/LoopControlTrait.php index 1e90883c..bc42888d 100644 --- a/src/Parser/LoopControlTrait.php +++ b/src/Parser/LoopControlTrait.php @@ -7,9 +7,8 @@ namespace TypePhp\Parser; -use TypePhp\Type; - use PhpParser\Node; +use TypePhp\Type; trait LoopControlTrait { @@ -38,12 +37,17 @@ trait LoopControlTrait $list_cond_expr = []; $hasCondStmts = false; foreach ($cond as $expr) { - $this->assertExprCanBeUsedAsCondition($expr, 'for condition'); + $voidCast = $expr instanceof Node\Expr\Cast\Void_; + if (!$voidCast) { + $this->assertExprCanBeUsedAsCondition($expr, 'for condition'); + } [$condExpr, $beforeStmts, $afterStmts] = $this->parseExprWithCapturedStmts($expr); $condExpr = $this->stringifyParsedExpr($condExpr); $hasCondStmts = $hasCondStmts || $beforeStmts || $afterStmts; - $list_cond[] = [$expr, $condExpr, $beforeStmts, $afterStmts]; - $list_cond_expr[] = $this->convertConditionExpr($expr, $condExpr); + $list_cond[] = [$expr, $condExpr, $beforeStmts, $afterStmts, $voidCast]; + $list_cond_expr[] = $voidCast + ? $condExpr + : $this->convertConditionExpr($expr, $condExpr); } $code .= $this->parseBeforeStmtLines() . PHP_EOL; @@ -55,8 +59,13 @@ trait LoopControlTrait } else { $condResult = $this->genTmpVarName(); $condCode .= $this->getIndent() . 'bool ' . $condResult . ' = true;' . PHP_EOL; - foreach ($list_cond as [$condNode, $condExpr, $beforeStmts, $afterStmts]) { + foreach ($list_cond as [$condNode, $condExpr, $beforeStmts, $afterStmts, $voidCast]) { $condCode .= $this->formatCapturedStmtLines($beforeStmts); + if ($voidCast) { + $condCode .= $this->getIndent() . $condExpr . ';' . PHP_EOL; + $condCode .= $this->formatCapturedStmtLines($afterStmts); + continue; + } if ($afterStmts) { $tmpVar = $this->addTmpVar(Type::VAR); $condCode .= $this->getIndent() . $tmpVar . ' = ' . $condExpr . ';' . PHP_EOL; diff --git a/src/Parser/UnaryExpressionTrait.php b/src/Parser/UnaryExpressionTrait.php index e50f108b..e273551d 100644 --- a/src/Parser/UnaryExpressionTrait.php +++ b/src/Parser/UnaryExpressionTrait.php @@ -8,12 +8,20 @@ namespace TypePhp\Parser; -use TypePhp\Type; - use PhpParser\Node\Expr; +use TypePhp\Transform\VoidCastValidationVisitor; +use TypePhp\Type; trait UnaryExpressionTrait { + protected function parseCastVoid(Expr\Cast\Void_ $node): string + { + if (!$node->getAttribute(VoidCastValidationVisitor::ALLOWED_ATTRIBUTE, false)) { + $this->fatalError($node, 'The (void) cast can only be used as a statement'); + } + return 'static_cast(' . $this->parseExpr($node->expr) . ')'; + } + protected function parseBitwiseNot(Expr\BitwiseNot $expr): string { $pythonOperator = $this->parsePythonUnaryOperator($expr); diff --git a/src/Preprocessor.php b/src/Preprocessor.php index a03c86f3..7a77132a 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -30,6 +30,7 @@ use TypePhp\Transform\FunctionAttributeLowering; use TypePhp\Transform\ConstantExpressionValidationVisitor; use TypePhp\Transform\RuntimeAttributeFactoryLowering; use TypePhp\Transform\Visitor; +use TypePhp\Transform\VoidCastValidationVisitor; use TypePhp\NativeClass\NativeGlobalDiscovery; use TypePhp\NativeClass\NativeGlobalTypeResolver; use PhpParser\Modifiers; @@ -281,6 +282,9 @@ class Preprocessor extends CompilerBase $traverser = new NodeTraverser(); $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); + $traverser->addVisitor(new VoidCastValidationVisitor( + fn (Node $node, string $message) => $this->fatalError($node, $message), + )); $traverser->addVisitor(new Visitor( fn (Node $node, string $message) => $this->warning($node, $message), $this->file, diff --git a/src/Transform/ConstantExpressionValidator.php b/src/Transform/ConstantExpressionValidator.php index 16bcec3d..357a9466 100644 --- a/src/Transform/ConstantExpressionValidator.php +++ b/src/Transform/ConstantExpressionValidator.php @@ -92,7 +92,8 @@ final class ConstantExpressionValidator || $expression instanceof Expr\PropertyFetch || $expression instanceof Expr\NullsafePropertyFetch || $expression instanceof Expr\New_ - || ($this->php85 && ($expression instanceof Expr\Cast + || ($this->php85 && (($expression instanceof Expr\Cast + && !$expression instanceof Expr\Cast\Void_) || $expression instanceof Expr\Closure || $expression instanceof Expr\FuncCall || $expression instanceof Expr\StaticCall)); diff --git a/src/Transform/VoidCastValidationVisitor.php b/src/Transform/VoidCastValidationVisitor.php new file mode 100644 index 00000000..74852532 --- /dev/null +++ b/src/Transform/VoidCastValidationVisitor.php @@ -0,0 +1,106 @@ + */ + private array $stack = []; + + /** @param \Closure(Node, string): never|null $fatalError */ + public function __construct(private readonly ?\Closure $fatalError = null) + { + } + + public function beforeTraverse(array $nodes): null + { + $this->stack = []; + return null; + } + + public function enterNode(Node $node): null + { + if ($node instanceof Expr\Cast\Void_) { + $parent = $this->stack === [] ? null : $this->stack[array_key_last($this->stack)]; + if (!$this->isDiscardedPosition($node, $parent)) { + $this->fail($node); + } + $node->setAttribute(self::ALLOWED_ATTRIBUTE, true); + } + + $this->stack[] = $node; + return null; + } + + public function leaveNode(Node $node): null + { + array_pop($this->stack); + return null; + } + + private function isDiscardedPosition(Expr\Cast\Void_ $node, ?Node $parent): bool + { + if ($parent instanceof Stmt\Expression) { + return $parent->expr === $node; + } + if (!$parent instanceof Stmt\For_) { + return false; + } + if ($this->containsIdenticalNode($parent->init, $node) + || $this->containsIdenticalNode($parent->loop, $node)) { + return true; + } + + $lastCondition = array_key_last($parent->cond); + foreach ($parent->cond as $index => $condition) { + if ($condition === $node) { + // Only the final condition controls the loop. Earlier items + // are evaluated solely for side effects and may be void casts. + return $index !== $lastCondition; + } + } + return false; + } + + /** @param list $nodes */ + private function containsIdenticalNode(array $nodes, Node $needle): bool + { + foreach ($nodes as $node) { + if ($node === $needle) { + return true; + } + } + return false; + } + + private function fail(Node $node): never + { + if ($this->fatalError !== null) { + ($this->fatalError)($node, self::ERROR_MESSAGE); + } + throw new SyntaxError(self::ERROR_MESSAGE); + } +} diff --git a/src/Translator.php b/src/Translator.php index e496511a..d435588a 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -47,6 +47,7 @@ use TypePhp\Transform\Visitor; use TypePhp\Transform\ConstructorLowering; use TypePhp\Transform\ConstantExpressionValidationVisitor; use TypePhp\Transform\RuntimeAttributeFactoryLowering; +use TypePhp\Transform\VoidCastValidationVisitor; use PhpParser\Modifiers; use PhpParser\Node; use PhpParser\NodeAbstract; @@ -2685,6 +2686,9 @@ CODE; $ast = $this->parser->parse($phpCode); $traverser = new NodeTraverser(); $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); + $traverser->addVisitor(new VoidCastValidationVisitor( + fn (Node $node, string $message) => $this->fatalError($node, $message), + )); $traverser->addVisitor(new Visitor(sourceFile: $this->file)); $traverser->addVisitor(new ConstantExpressionValidationVisitor( $this->phpVersion, diff --git a/tests/compiler/basic/void-cast.phpt b/tests/compiler/basic/void-cast.phpt new file mode 100644 index 00000000..8bf855fc --- /dev/null +++ b/tests/compiler/basic/void-cast.phpt @@ -0,0 +1,42 @@ +--TEST-- +PHP 8.5 void cast preserves operand side effects in every discarded position +--FILE-- + +--EXPECT-- +statement +assignment operand +for init +for condition +body:0 +for loop +for condition +body:1 +for loop +for condition +int(7) +int(2)