diff --git a/docs/INCOMPATIBLE_PHP_FEATURES.md b/docs/INCOMPATIBLE_PHP_FEATURES.md index f2f3d415..e776513a 100644 --- a/docs/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/INCOMPATIBLE_PHP_FEATURES.md @@ -16,7 +16,7 @@ - 不支持 `yield` / `yield from`。 - 不支持可变变量 `$$var`。 - 不支持 PHP 8.4 property hooks。 -- 不支持函数或方法按引用返回。 +- 不支持函数、方法、闭包或箭头函数按引用返回。 - `__construct()` 不允许返回值。 - 参数默认值不允许出现在必填参数之前(`PHP`允许,但会直接丢弃此默认参数)。 - 不支持引用可变参数 `&...$args`。 @@ -34,6 +34,7 @@ ## 调用与引用 - 闭包和箭头函数不支持引用参数。 +- 引用赋值的右侧必须是编译器可直接定位的变量、数组元素或对象属性;不支持从调用结果或复杂静态属性表达式建立引用。 - 动态调用、闭包调用等编译期无法确定参数签名的调用,不能自动转换引用参数;需要显式使用 `refval()`。 - `refval()` 只接受变量、数组元素或对象属性。 - 带 unpack 且尾部追加 named arguments 的调用会退化为动态调用,不能使用 native call。 diff --git a/tests/aot/ref/unset.phpt b/tests/aot/ref/unset.phpt new file mode 100644 index 00000000..6e27517f --- /dev/null +++ b/tests/aot/ref/unset.phpt @@ -0,0 +1,26 @@ +--TEST-- +object link operator +--FILE-- + +--EXPECT-- +int(2) +int(2) +int(2) +bool(false) +int(1) +int(2) \ No newline at end of file diff --git a/tests/aot/symfony/closure-bind-reference-property.phpt b/tests/aot/symfony/closure-bind-reference-property.phpt new file mode 100644 index 00000000..e2dd1ec9 --- /dev/null +++ b/tests/aot/symfony/closure-bind-reference-property.phpt @@ -0,0 +1,43 @@ +--TEST-- +Symfony pattern: Closure::bind returns reference to private property +--SKIPIF-- + +--FILE-- + 'service', + ]; + + public function getReferenceAccessor(): Closure + { + return Closure::bind(fn &() => $this->instanceof, $this, self::class); + } + + public function dump(): void + { + foreach ($this->instanceof as $key => $value) { + var_dump($key.':'.$value); + } + } +} + +function main(): void +{ + $configurator = new ReferenceConfigurator(); + $accessor = $configurator->getReferenceAccessor(); + $instanceof = &$accessor(); + + $instanceof['extra'] = 'listener'; + $instanceof['base'] = strtoupper($instanceof['base']); + + $configurator->dump(); +} +?> +--EXPECT-- +string(12) "base:SERVICE" +string(14) "extra:listener" diff --git a/tests/aot/symfony/configurator-magic-call-dynamic-setter.phpt b/tests/aot/symfony/configurator-magic-call-dynamic-setter.phpt new file mode 100644 index 00000000..d65c6232 --- /dev/null +++ b/tests/aot/symfony/configurator-magic-call-dynamic-setter.phpt @@ -0,0 +1,57 @@ +--TEST-- +Symfony pattern: __call forwards to concatenated dynamic setter with unpack +--FILE-- +{'set'.$method}(...$args); + } + + throw new BadMethodCallException(sprintf('Call to undefined method "%s::%s()".', static::class, $method)); + } + + private function setOption(string $name, mixed $value, bool $append = false): static + { + if ($append) { + $this->values[$name][] = $value; + } else { + $this->values[$name] = $value; + } + + return $this; + } + + public function all(): array + { + return $this->values; + } +} + +function main(): void +{ + $configurator = new Configurator(); + $configurator->Option('debug', true); + $configurator->Option('tags', 'console', true); + $configurator->Option(...['tags', 'worker', true]); + + var_dump($configurator->all()); +} +?> +--EXPECT-- +array(2) { + ["debug"]=> + bool(true) + ["tags"]=> + array(2) { + [0]=> + string(7) "console" + [1]=> + string(6) "worker" + } +} diff --git a/tests/aot/symfony/container-dynamic-registry-coalesce.phpt b/tests/aot/symfony/container-dynamic-registry-coalesce.phpt new file mode 100644 index 00000000..3299e0ab --- /dev/null +++ b/tests/aot/symfony/container-dynamic-registry-coalesce.phpt @@ -0,0 +1,66 @@ +--TEST-- +Symfony pattern: dynamic registry property with array coalesce assignment +--FILE-- +{$registry}[$id] ?? null : null; + } + if (false !== $registry) { + return $this->{$registry}[$id] ??= $load ? $this->load($method) : $this->{$method}($this); + } + if (!$load) { + return $this->{$method}($this); + } + + return ($factory = $this->factories[$id] ?? $this->factories['service_container'][$id] ?? null) ? $factory($this) : $this->load($method); + } + + private function load(string $method): object + { + return $this->{$method}($this); + } + + private function createLogger(self $container): object + { + return (object) ['id' => 'logger', 'count' => count($container->services) + count($container->privates)]; + } +} + +function main(): void +{ + $container = new RegistryContainer(); + $container->factories['service_container']['mailer'] = static fn (RegistryContainer $container): object => (object) ['id' => 'mailer']; + + $logger = $container->getService('services', 'logger', 'createLogger', false); + $sameLogger = $container->getService('services', 'logger', 'createLogger', false); + $privateLogger = $container->getService('privates', 'logger', 'createLogger', true); + $mailer = $container->getService(false, 'mailer', 'createLogger', true); + + var_dump($logger === $sameLogger); + var_dump($logger->id, $logger->count); + var_dump($privateLogger->id, $privateLogger->count); + var_dump($mailer->id); +} +?> +--EXPECT-- +bool(true) +string(6) "logger" +int(0) +string(6) "logger" +int(1) +string(6) "mailer" diff --git a/tests/aot/symfony/container-static-first-class-fallback.phpt b/tests/aot/symfony/container-static-first-class-fallback.phpt new file mode 100644 index 00000000..33683ee3 --- /dev/null +++ b/tests/aot/symfony/container-static-first-class-fallback.phpt @@ -0,0 +1,49 @@ +--TEST-- +Symfony pattern: container fallback caches static first-class callable +--FILE-- +factories['custom'] = static fn (self $container): object => (object) ['id' => 'custom']; + } + + public function get(string $id): object + { + return $this->services[$id] + ?? $this->services[$id = $this->aliases[$id] ?? $id] + ?? ('service_container' === $id ? $this : ($this->factories[$id] ?? self::$make ??= self::make(...))($this, $id)); + } + + public static function make(self $container, string $id): object + { + $service = (object) ['id' => $id]; + $container->services[$id] = $service; + + return $service; + } +} + +function main(): void +{ + $container = new MiniContainer(); + $container->aliases['logger'] = 'monolog'; + + var_dump($container->get('logger')->id); + var_dump($container->get('monolog')->id); + var_dump($container->get('custom')->id); + var_dump(MiniContainer::$make instanceof Closure); +} +?> +--EXPECT-- +string(7) "monolog" +string(7) "monolog" +string(6) "custom" +bool(true) diff --git a/tests/aot/symfony/dynamic-first-class-callable-branch.phpt b/tests/aot/symfony/dynamic-first-class-callable-branch.phpt new file mode 100644 index 00000000..0684cb03 --- /dev/null +++ b/tests/aot/symfony/dynamic-first-class-callable-branch.phpt @@ -0,0 +1,35 @@ +--TEST-- +Symfony pattern: dynamic first-class callable branch +--FILE-- +{$callable[1]}(...) : $callable[1](...); +} + +function main(): void +{ + $objectCallable = makeCallable([new CallableTarget(), 'format']); + $functionCallable = makeCallable([null, 'format_global']); + + var_dump($objectCallable('symfony')); + var_dump($functionCallable('AOT')); +} +?> +--EXPECT-- +string(14) "object:SYMFONY" +string(12) "function:aot" diff --git a/tests/aot/symfony/lazy-string-array-callable.phpt b/tests/aot/symfony/lazy-string-array-callable.phpt new file mode 100644 index 00000000..2a39b790 --- /dev/null +++ b/tests/aot/symfony/lazy-string-array-callable.phpt @@ -0,0 +1,56 @@ +--TEST-- +Symfony pattern: lazy string resolves array callable with ??= method name +--FILE-- +value = static function () use (&$callback, &$arguments): string { + static $value; + + if (null !== $arguments) { + if (!is_callable($callback)) { + $callback[0] = $callback[0](); + $callback[1] ??= '__invoke'; + } + $value = $callback(...$arguments); + $callback = 'callable'; + $arguments = null; + } + + return $value; + }; + + return $lazyString; + } + + public function __toString(): string + { + return ($this->value)(); + } +} + +function main(): void +{ + $lazy = LazyString::fromCallable([static fn () => new Formatter()], ['symfony', 'app:']); + + var_dump((string) $lazy); + var_dump((string) $lazy); +} +?> +--EXPECT-- +string(11) "app:SYMFONY" +string(11) "app:SYMFONY" diff --git a/tests/aot/symfony/routing-object-vars-json-tail.phpt b/tests/aot/symfony/routing-object-vars-json-tail.phpt new file mode 100644 index 00000000..1a736830 --- /dev/null +++ b/tests/aot/symfony/routing-object-vars-json-tail.phpt @@ -0,0 +1,54 @@ +--TEST-- +Symfony pattern: get_object_vars parameters and array_key_last tail output +--FILE-- + $value) { + if (is_object($value) && $vars = get_object_vars($value)) { + unset($parameters[$key]); + $parameters += $vars; + } + } + + return $parameters + $defaults; +} + +function streamJsonParts(array $jsonParts): void +{ + echo $jsonParts[array_key_last($jsonParts)]; +} + +function main(): void +{ + var_dump(mergeRouteParams(['_route' => 'blog_show', 'page' => 1], [ + 'post' => new RouteParam('symfony-aot', 3), + 'format' => 'json', + ])); + + streamJsonParts(['{"items":', '[1,2,3]', '}']); +} +?> +--EXPECT-- +array(4) { + ["format"]=> + string(4) "json" + ["slug"]=> + string(11) "symfony-aot" + ["page"]=> + int(3) + ["_route"]=> + string(9) "blog_show" +} +} diff --git a/tests/aot/symfony/serialize-unserialize-private-state.phpt b/tests/aot/symfony/serialize-unserialize-private-state.phpt new file mode 100644 index 00000000..7e2a7e43 --- /dev/null +++ b/tests/aot/symfony/serialize-unserialize-private-state.phpt @@ -0,0 +1,58 @@ +--TEST-- +Symfony pattern: __serialize and __unserialize restore private state +--FILE-- +resource = $resource; + } + + public function setState(string $store, mixed $value): void + { + $this->state[$store] = $value; + } + + public function __serialize(): array + { + return [ + 'resource' => $this->resource, + 'expiresAt' => $this->expiresAt, + 'state' => $this->state, + ]; + } + + public function __unserialize(array $data): void + { + $this->resource = $data['resource']; + $this->expiresAt = $data['expiresAt'] ?? null; + $this->state = $data['state'] ?? []; + } + + public function describe(): string + { + return $this->resource.':'.implode(',', array_keys($this->state)); + } +} + +function main(): void +{ + $state = new SerializableState('lock-key'); + $state->setState('redis', ['token' => 'abc']); + $state->setState('pdo', ['token' => 'def']); + + $copy = unserialize(serialize($state)); + + var_dump($copy instanceof SerializableState); + var_dump($copy->describe()); +} +?> +--EXPECT-- +bool(true) +string(18) "lock-key:redis,pdo" diff --git a/tests/aot/symfony/uid-binary-string-carry.phpt b/tests/aot/symfony/uid-binary-string-carry.phpt new file mode 100644 index 00000000..c12fc78b --- /dev/null +++ b/tests/aot/symfony/uid-binary-string-carry.phpt @@ -0,0 +1,29 @@ +--TEST-- +Symfony pattern: binary string offset mutation with ord/chr carry +--FILE-- +>= 8; + } + + return $a; + } +} + +function main(): void +{ + var_dump(bin2hex(BinaryUtil::add(hex2bin('00000000000000ff'), hex2bin('0000000000000001')))); + var_dump(bin2hex(BinaryUtil::add(hex2bin('ffffffffffffffff'), hex2bin('0000000000000001')))); +} +?> +--EXPECT-- +string(16) "0000000000000100" +string(16) "0000000000000000" diff --git a/tests/aot/symfony/validator-serializer-array-callbacks.phpt b/tests/aot/symfony/validator-serializer-array-callbacks.phpt new file mode 100644 index 00000000..37cae0ab --- /dev/null +++ b/tests/aot/symfony/validator-serializer-array-callbacks.phpt @@ -0,0 +1,61 @@ +--TEST-- +Symfony pattern: first-class builtin array_map and variadic context merge +--FILE-- +normalizationContexts['*'] = $context; + } + + foreach ($groups as $group) { + $this->normalizationContexts[$group] = $context; + } + } + + public function getNormalizationContextForGroups(array $groups): array + { + $contexts = []; + foreach ($groups as $group) { + $contexts[] = $this->normalizationContexts[$group] ?? []; + } + + return array_merge($this->normalizationContexts['*'] ?? [], ...$contexts); + } +} + +function countWords(array $words): int +{ + return count(array_filter(array_map(trim(...), $words), static fn ($word) => '' !== $word)); +} + +function main(): void +{ + var_dump(countWords([' one ', '', " \t ", 'two', ' three '])); + + $metadata = new AttributeMetadata(); + $metadata->setNormalizationContextForGroups(['skip_null' => true]); + $metadata->setNormalizationContextForGroups(['groups' => ['public']], ['read']); + $metadata->setNormalizationContextForGroups(['max_depth' => 2], ['detail']); + + var_dump($metadata->getNormalizationContextForGroups(['read', 'missing', 'detail'])); +} +?> +--EXPECT-- +int(3) +array(3) { + ["skip_null"]=> + bool(true) + ["groups"]=> + array(1) { + [0]=> + string(6) "public" + } + ["max_depth"]=> + int(2) +} diff --git a/tests/aot/symfony/var-exporter-clone-cache.phpt b/tests/aot/symfony/var-exporter-clone-cache.phpt new file mode 100644 index 00000000..848e5661 --- /dev/null +++ b/tests/aot/symfony/var-exporter-clone-cache.phpt @@ -0,0 +1,49 @@ +--TEST-- +Symfony pattern: clone object from static cache with null coalescing +--FILE-- +name = 'mutated'; + $second->name = 'changed'; + + var_dump($first->name); + var_dump($second->name); + var_dump(CloneRegistry::$prototypes[$class]->name); +} +?> +--EXPECT-- +create:CloneableService +string(7) "mutated" +string(7) "changed" +string(6) "cached" diff --git a/tests/aot/symfony/var-exporter-reference-clone-cache.phpt b/tests/aot/symfony/var-exporter-reference-clone-cache.phpt new file mode 100644 index 00000000..95f10c77 --- /dev/null +++ b/tests/aot/symfony/var-exporter-reference-clone-cache.phpt @@ -0,0 +1,53 @@ +--TEST-- +Symfony pattern: reference cache with clone and null coalescing +--SKIPIF-- + +--FILE-- +name = 'mutated'; + $second->name = 'changed'; + + var_dump($first->name); + var_dump($second->name); + var_dump(PrototypeRegistry::$prototypes[$class]->name); +} +?> +--EXPECT-- +create:ExportedService +string(7) "mutated" +string(7) "changed" +string(6) "cached" diff --git a/tests/aot/symfony/vardumper-static-property-default-cache.phpt b/tests/aot/symfony/vardumper-static-property-default-cache.phpt new file mode 100644 index 00000000..1ba3a68d --- /dev/null +++ b/tests/aot/symfony/vardumper-static-property-default-cache.phpt @@ -0,0 +1,63 @@ +--TEST-- +Symfony pattern: static property default cache keyed by runtime class +--XFAIL-- +Known AOT bug: coalesce assignment with a ternary right-hand expression can cache the condition result instead of the full ternary value. +--FILE-- +name = $name; + $this->count = $count; + $this->extra = $extra; + } + + public function __serialize(): array + { + static $noDefault = new stdClass(); + + $data = []; + foreach ($this as $k => $v) { + $default = self::$propertyDefaults[$this::class][$k] ??= ($p = new ReflectionProperty($this, $k))->hasDefaultValue() + ? $p->getDefaultValue() + : ($p->hasType() ? $noDefault : null); + + if ($noDefault === $default || $default !== $v) { + $data[$k] = $v; + } + } + + return $data; + } +} + +function main(): void +{ + var_dump((new StubLike('default', null))->__serialize()); + var_dump((new StubLike('changed', 3, ['tag']))->__serialize()); +} +?> +--EXPECT-- +array(1) { + ["count"]=> + NULL +} +array(3) { + ["name"]=> + string(7) "changed" + ["count"]=> + int(3) + ["extra"]=> + array(1) { + [0]=> + string(3) "tag" + } +}