diff --git a/docs/INCOMPATIBLE_PHP_FEATURES.md b/docs/INCOMPATIBLE_PHP_FEATURES.md index ba864b88..f2f3d415 100644 --- a/docs/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/INCOMPATIBLE_PHP_FEATURES.md @@ -22,7 +22,7 @@ - 不支持引用可变参数 `&...$args`。 - 联合类型、交叉类型、`nullable` 类型在静态编译阶段按 `mixed/any` 处理,只保留运行时 type check。 - 局部变量类型一旦被静态推断为具体 native 类型,不支持在同一作用域内重新赋值为不兼容类型。 -- attribute 参数不支持数组值。 +- attribute 参数不支持数组值和 `new` 表达式。 ## declare diff --git a/phpunit/code/preprocessor/attribute_array_argument.php b/phpunit/code/preprocessor/attribute_array_argument.php new file mode 100644 index 00000000..992c9692 --- /dev/null +++ b/phpunit/code/preprocessor/attribute_array_argument.php @@ -0,0 +1,14 @@ +compiler->prepareFile($file); } + public function testPrepareFileRejectsAttributeArrayArguments(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Array arguments to attributes are not supported'); + + $file = __DIR__ . '/../code/preprocessor/attribute_array_argument.php'; + $this->compiler->prepareFile($file); + } + + public function testPrepareFileRejectsAttributeNewExpressionArguments(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('New expressions in attribute arguments are not supported'); + + $file = __DIR__ . '/../code/preprocessor/attribute_new_expression_argument.php'; + $this->compiler->prepareFile($file); + } + public function testIntersectionParamDeclFallsBackToVarWithRuntimeCheck(): void { $fn = $this->parseFunctionNode('addVisitor(new Visitor()); $stmts = $traverser->traverse($ast); + $this->validateUnsupportedAttributeArguments($stmts); foreach ($stmts as $v) { $type = $v->getType(); @@ -157,6 +158,26 @@ class Preprocessor extends CompilerBase } } + /** + * @param array $stmts + */ + private function validateUnsupportedAttributeArguments(array $stmts): void + { + $nodeFinder = new NodeFinder(); + $attributes = $nodeFinder->findInstanceOf($stmts, Node\Attribute::class); + + foreach ($attributes as $attribute) { + foreach ($attribute->args as $arg) { + if ($arg->value instanceof Node\Expr\Array_ && count($arg->value->items) > 0) { + $this->fatalError($arg, 'Array arguments to attributes are not supported'); + } + if ($arg->value instanceof Node\Expr\New_) { + $this->fatalError($arg, 'New expressions in attribute arguments are not supported'); + } + } + } + } + protected function findSymbolUsing(NodeAbstract $ast) { $nodeFinder = new NodeFinder(); diff --git a/tests/aot/symfony/anonymous-exception-interface.phpt b/tests/aot/symfony/anonymous-exception-interface.phpt new file mode 100644 index 00000000..d835c96c --- /dev/null +++ b/tests/aot/symfony/anonymous-exception-interface.phpt @@ -0,0 +1,28 @@ +--TEST-- +Symfony pattern: anonymous exception class implements marker interface +--FILE-- +getMessage()); +} +?> +--EXPECT-- +bool(true) +bool(true) +string(27) "Service "mailer" not found." diff --git a/tests/aot/symfony/array-map-first-class-object-method.phpt b/tests/aot/symfony/array-map-first-class-object-method.phpt new file mode 100644 index 00000000..0f11200b --- /dev/null +++ b/tests/aot/symfony/array-map-first-class-object-method.phpt @@ -0,0 +1,56 @@ +--TEST-- +Symfony pattern: array_map with first-class object method callable +--FILE-- +name, $this->position]; + } +} + +class SymfonyLikeParameterNormalizer +{ + public function normalize(SymfonyLikeParameter $parameter): string + { + [$name, $position] = $parameter->toArray(); + + return $position.':'.$name; + } +} + +class SymfonyLikeDescriptor +{ + public function __construct(private SymfonyLikeParameterNormalizer $normalizer) + { + } + + public function describe(array $parameters): array + { + return array_map($this->normalizer->normalize(...), $parameters); + } +} + +function main(): void +{ + $descriptor = new SymfonyLikeDescriptor(new SymfonyLikeParameterNormalizer()); + + var_dump($descriptor->describe([ + new SymfonyLikeParameter('request', 0), + new SymfonyLikeParameter('format', 1), + ])); +} +?> +--EXPECT-- +array(2) { + [0]=> + string(9) "0:request" + [1]=> + string(8) "1:format" +} diff --git a/tests/aot/symfony/array-map-first-class-static-method.phpt b/tests/aot/symfony/array-map-first-class-static-method.phpt new file mode 100644 index 00000000..0aef0b09 --- /dev/null +++ b/tests/aot/symfony/array-map-first-class-static-method.phpt @@ -0,0 +1,44 @@ +--TEST-- +Symfony pattern: array_map with first-class static method callable +--FILE-- +format('Y-m-d'), $all]; + } + + public static function renderAll(array $messages, DateTimeImmutable $date, bool $all): array + { + return array_filter(array_map( + self::render(...), + $messages, + array_fill(0, count($messages), $date), + array_fill(0, count($messages), $all) + )); + } +} + +function main(): void +{ + var_dump(SymfonyLikeScheduleRenderer::renderAll(['first', 'skip-second'], new DateTimeImmutable('2026-07-07'), false)); +} +?> +--EXPECT-- +array(1) { + [0]=> + array(3) { + [0]=> + string(5) "first" + [1]=> + string(10) "2026-07-07" + [2]=> + bool(false) + } +} diff --git a/tests/aot/symfony/attribute-array-arguments.phpt b/tests/aot/symfony/attribute-array-arguments.phpt deleted file mode 100644 index 8149b4ba..00000000 --- a/tests/aot/symfony/attribute-array-arguments.phpt +++ /dev/null @@ -1,35 +0,0 @@ ---TEST-- -Symfony pattern: attribute array arguments ---SKIPIF-- - ---FILE-- -getAttributes(SymfonyLikeRoute::class)[0]->newInstance(); - var_dump($route->methods); -} -?> ---EXPECT-- -array(2) { - [0]=> - string(3) "GET" - [1]=> - string(4) "POST" -} diff --git a/tests/aot/symfony/attribute-class-constant-args.phpt b/tests/aot/symfony/attribute-class-constant-args.phpt new file mode 100644 index 00000000..d6d49bad --- /dev/null +++ b/tests/aot/symfony/attribute-class-constant-args.phpt @@ -0,0 +1,35 @@ +--TEST-- +Symfony pattern: attribute named arguments with class constant values +--FILE-- +getAttributes(SymfonyLikeAlias::class)[0]->newInstance(); + + var_dump($attribute->id); + var_dump($attribute->when); +} +?> +--EXPECT-- +string(19) "SymfonyLikeContract" +string(3) "dev" diff --git a/tests/aot/symfony/filter-coalesce-throw.phpt b/tests/aot/symfony/filter-coalesce-throw.phpt new file mode 100644 index 00000000..c38fcd8d --- /dev/null +++ b/tests/aot/symfony/filter-coalesce-throw.phpt @@ -0,0 +1,40 @@ +--TEST-- +Symfony pattern: filter result with coalesce throw expression +--FILE-- +parameters[$key] ?? $default, $filter, $options); + } + + public function getInt(string $key, int $default = 0): int + { + return $this->filter($key, $default, FILTER_VALIDATE_INT, ['flags' => FILTER_REQUIRE_SCALAR | FILTER_NULL_ON_FAILURE]) + ?? throw new UnexpectedValueException(sprintf('Parameter value "%s" cannot be converted to "int".', $key)); + } +} + +function main(): void +{ + $bag = new SymfonyLikeParameterBag(['limit' => '42', 'bad' => 'nope']); + var_dump($bag->getInt('limit')); + + try { + $bag->getInt('bad'); + } catch (Throwable $e) { + var_dump($e::class); + var_dump($e->getMessage()); + } +} +?> +--EXPECT-- +int(42) +string(24) "UnexpectedValueException" +string(51) "Parameter value "bad" cannot be converted to "int"." diff --git a/tests/aot/symfony/match-array-filter-union.phpt b/tests/aot/symfony/match-array-filter-union.phpt new file mode 100644 index 00000000..4f4d4a4a --- /dev/null +++ b/tests/aot/symfony/match-array-filter-union.phpt @@ -0,0 +1,43 @@ +--TEST-- +Symfony pattern: match array filtered then unioned with base arguments +--FILE-- + $serviceId, + ] + array_filter(match ($tagAttributes['trigger'] ?? throw new InvalidArgumentException(sprintf('missing trigger for "%s"', $serviceId))) { + 'every' => [ + '$frequency' => $tagAttributes['frequency'] ?? throw new InvalidArgumentException(sprintf('missing frequency for "%s"', $serviceId)), + '$from' => $tagAttributes['from'] ?? null, + '$until' => $tagAttributes['until'] ?? null, + ], + 'cron' => [ + '$expression' => $tagAttributes['expression'] ?? throw new InvalidArgumentException(sprintf('missing expression for "%s"', $serviceId)), + '$timezone' => $tagAttributes['timezone'] ?? null, + ], + }, static fn ($value) => null !== $value); +} + +function main(): void +{ + var_dump(schedulerArgs(['trigger' => 'every', 'frequency' => '1 hour', 'from' => null], 'task.one')); + var_dump(schedulerArgs(['trigger' => 'cron', 'expression' => '* * * * *', 'timezone' => 'UTC'], 'task.two')); +} +?> +--EXPECT-- +array(2) { + ["$message"]=> + string(8) "task.one" + ["$frequency"]=> + string(6) "1 hour" +} +array(3) { + ["$message"]=> + string(8) "task.two" + ["$expression"]=> + string(9) "* * * * *" + ["$timezone"]=> + string(3) "UTC" +} diff --git a/tests/aot/symfony/uasort-spaceship-elvis.phpt b/tests/aot/symfony/uasort-spaceship-elvis.phpt new file mode 100644 index 00000000..421d165f --- /dev/null +++ b/tests/aot/symfony/uasort-spaceship-elvis.phpt @@ -0,0 +1,41 @@ +--TEST-- +Symfony pattern: uasort with spaceship and elvis fallback comparison +--FILE-- +quality; + } + + public function getIndex(): int + { + return $this->index; + } +} + +function main(): void +{ + $items = [ + 'json' => new SymfonyLikeAcceptItem(0.9, 2, 'json'), + 'html' => new SymfonyLikeAcceptItem(1.0, 1, 'html'), + 'xml' => new SymfonyLikeAcceptItem(0.9, 0, 'xml'), + ]; + + uasort($items, static fn ($a, $b) => $b->getQuality() <=> $a->getQuality() ?: $a->getIndex() <=> $b->getIndex()); + + foreach ($items as $item) { + var_dump($item->name); + } +} +?> +--EXPECT-- +string(4) "html" +string(3) "xml" +string(4) "json"