diff --git a/docs/INCOMPATIBLE_PHP_FEATURES.md b/docs/INCOMPATIBLE_PHP_FEATURES.md index e776513a..08ed7a1c 100644 --- a/docs/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/INCOMPATIBLE_PHP_FEATURES.md @@ -52,7 +52,6 @@ - `match` 的 arm condition 不能是 `match` 表达式。 - `foreach` by reference 的 value 只能是变量。 - `foreach` by reference 不支持 list destructuring。 -- `foreach` 遍历 `IteratorAggregate` 返回的 `ArrayObject` 时,当前行为与标准 PHP 不完全一致。 - 固定 native typed object property 不允许按 PHP 未初始化语义自由 `unset()`。 - native 类型变量执行 `unset()` 不会产生标准 PHP 的变量删除语义。 diff --git a/src/Php/CompilerBase.php b/src/Php/CompilerBase.php index adac6bb0..4b02ea8e 100644 --- a/src/Php/CompilerBase.php +++ b/src/Php/CompilerBase.php @@ -5384,13 +5384,37 @@ class CompilerBase extends \PhpAot\Core\Translator implements PropertyAccessCont } } - $iteratorVar = $this->genTmpVarName(); - $code = ''; $expr = $this->parseIdentifier($node->expr); $code .= $this->parseBeforeStmtLines() . PHP_EOL; - $code .= self::TYPE_ARRAY . " {$iteratorVar} = " . $expr . ';' . PHP_EOL; - $code .= $this->parseForeachArray($node, $iteratorVar); + + $iterableVar = $this->genTmpVarName(); + $arrayVar = $this->genTmpVarName(); + $objectVar = $this->genTmpVarName(); + $this->addLocalVar($iterableVar, self::TYPE_VAR); + $this->addLocalVar($arrayVar, self::TYPE_ARRAY); + $this->addLocalVar($objectVar, self::TYPE_OBJECT); + + $code .= $iterableVar . ' = ' . $expr . ';' . PHP_EOL; + $code .= 'if (' . $iterableVar . '.isArray()) {' . PHP_EOL; + $this->indentLevel++; + $code .= $this->getIndent() . $arrayVar . ' = ' . $iterableVar . ';' . PHP_EOL; + $code .= $this->parseForeachArray($node, $arrayVar) . PHP_EOL; + $this->indentLevel--; + $code .= $this->getIndent() . '} else if (' . $iterableVar . '.isObject()) {' . PHP_EOL; + $this->indentLevel++; + $code .= $this->getIndent() . $objectVar . ' = ' . $iterableVar . ';' . PHP_EOL; + if ($node->byRef) { + $code .= $this->getIndent() . 'php::throwException(zend_ce_error, "Cannot use & with foreach");' . PHP_EOL; + } else { + $code .= $this->parseForeachObject($node, $objectVar); + } + $this->indentLevel--; + $code .= $this->getIndent() . '} else {' . PHP_EOL; + $this->indentLevel++; + $code .= $this->getIndent() . 'php::throwException(zend_ce_type_error, "foreach() argument must be of type array|object");' . PHP_EOL; + $this->indentLevel--; + $code .= $this->getIndent() . '}'; return $code; } diff --git a/src/Php/Translator.php b/src/Php/Translator.php index 99e87ed9..02fea3b4 100644 --- a/src/Php/Translator.php +++ b/src/Php/Translator.php @@ -3796,11 +3796,17 @@ CODE; $existing->default === $incoming->default; } - protected function parseForeachObject(Foreach_ $node): string + protected function parseForeachObject(Foreach_ $node, ?string $objectExpr = null): string { - $obj = $this->parseIdentifier($node->expr); - $tmpVar = $this->genTmpVarName(); - $this->addLocalVar($tmpVar, self::TYPE_OBJECT); + $obj = $objectExpr ?? $this->parseIdentifier($node->expr); + $iterableVar = $this->genTmpVarName(); + $this->addLocalVar($iterableVar, self::TYPE_VAR); + + $iteratorObj = $this->genTmpVarName(); + $this->addLocalVar($iteratorObj, self::TYPE_OBJECT); + + $aggregateObj = $this->genTmpVarName(); + $this->addLocalVar($aggregateObj, self::TYPE_OBJECT); $tmpArrayVar = $this->genTmpVarName(); $this->addLocalVar($tmpArrayVar, self::TYPE_ARRAY); @@ -3813,25 +3819,52 @@ CODE; $keyStr = $this->getLiteralString('key'); $nextStr = $this->getLiteralString('next'); $rewindStr = $this->getLiteralString('rewind'); + $invalidAggregateReturn = static function (string $aggregateObj): string { + return 'php::throwException(zend_ce_exception, (php::concat({' + . 'php::Str("Objects returned by "), ' + . $aggregateObj . '.getClassName(), ' + . 'php::Str("::getIterator() must be traversable or implement interface Iterator")' + . '})).toCString());'; + }; - $code = 'if (' . $obj . '.instanceOf(' . $IteratorAggregateCe . ')) {' . PHP_EOL; - $code .= $this->getIndent() . $tmpVar . ' = ' . $obj . '.call(' . $getIteratorStr . ');' . PHP_EOL . '}' . PHP_EOL; - $code .= 'else if (' . $obj . '.instanceOf(' . $IteratorCe . ')) {' . PHP_EOL; - $code .= $this->getIndent() . $tmpVar . ' = ' . $obj . ';' . PHP_EOL . '}' . PHP_EOL; + $code = $iterableVar . ' = ' . $obj . ';' . PHP_EOL; + $code .= $iteratorObj . ' = ' . $iterableVar . ';' . PHP_EOL; + $code .= 'if (' . $iteratorObj . '.instanceOf(' . $IteratorAggregateCe . ')) {' . PHP_EOL; + $this->indentLevel++; + $code .= $this->getIndent() . 'do {' . PHP_EOL; + $this->indentLevel++; + $code .= $this->getIndent() . $aggregateObj . ' = ' . $iteratorObj . ';' . PHP_EOL; + $code .= $this->getIndent() . $iterableVar . ' = ' . $aggregateObj . '.call(' . $getIteratorStr . ');' . PHP_EOL; + $code .= $this->getIndent() . 'if (UNEXPECTED(!' . $iterableVar . '.isObject())) {' . PHP_EOL; + $this->indentLevel++; + $code .= $this->getIndent() . $invalidAggregateReturn($aggregateObj) . PHP_EOL; + $this->indentLevel--; + $code .= $this->getIndent() . '}' . PHP_EOL; + $code .= $this->getIndent() . $iteratorObj . ' = ' . $iterableVar . ';' . PHP_EOL; + $code .= $this->getIndent() . 'if (UNEXPECTED(!' . $iteratorObj . '.instanceOf(' . $IteratorCe . ') && !' . $iteratorObj . '.instanceOf(' . $IteratorAggregateCe . '))) {' . PHP_EOL; + $this->indentLevel++; + $code .= $this->getIndent() . $invalidAggregateReturn($aggregateObj) . PHP_EOL; + $this->indentLevel--; + $code .= $this->getIndent() . '}' . PHP_EOL; + $this->indentLevel--; + $code .= $this->getIndent() . '} while (' . $iteratorObj . '.instanceOf(' . $IteratorAggregateCe . '));' . PHP_EOL; + $this->indentLevel--; + $code .= $this->getIndent() . '}' . PHP_EOL; - $code .= 'if (' . $tmpVar . ') {' . PHP_EOL; + $code .= 'if (' . $iteratorObj . '.instanceOf(' . $IteratorCe . ')) {' . PHP_EOL; $this->indentLevel++; - $code .= $this->getIndent() . $tmpVar . '.call(' . $rewindStr . ');' . PHP_EOL; - $code .= $this->getIndent() . 'for (;' . $tmpVar . '.call(' . $validStr . '); ' . $tmpVar . '.call(' . $nextStr . ')) {' . PHP_EOL; + $code .= $this->getIndent() . $iteratorObj . '.call(' . $rewindStr . ');' . PHP_EOL; + $code .= $this->getIndent() . 'for (;' . $iteratorObj . '.call(' . $validStr . '); ' . $iteratorObj . '.call(' . $nextStr . ')) {' . PHP_EOL; $this->indentLevel++; - $code .= $this->parseForeachKeyAssignment($node, $tmpVar . '.call(' . $keyStr . ')'); - $code .= $this->parseForeachValueAssignment($node, $tmpVar . '.call(' . $currentStr . ')'); + $code .= $this->parseForeachKeyAssignment($node, $iteratorObj . '.call(' . $keyStr . ')'); + $code .= $this->parseForeachValueAssignment($node, $iteratorObj . '.call(' . $currentStr . ')'); $code .= $this->parseForeachBody($node); $code .= '}' . PHP_EOL; $this->indentLevel--; $code .= $this->getIndent() . '} else {' . PHP_EOL; + $this->indentLevel++; $code .= $this->getIndent() . $tmpArrayVar . ' = php::call(' . $this->getFuncPtr('get_object_vars') . ', {' . $obj . '});' . PHP_EOL; $code .= $this->parseForeachArray($node, $tmpArrayVar); $this->indentLevel--; diff --git a/tests/aot/loop/foreach-byref-dynamic-array.phpt b/tests/aot/loop/foreach-byref-dynamic-array.phpt new file mode 100644 index 00000000..bab694b4 --- /dev/null +++ b/tests/aot/loop/foreach-byref-dynamic-array.phpt @@ -0,0 +1,22 @@ +--TEST-- +foreach by reference supports dynamically evaluated arrays +--FILE-- + +--EXPECT-- +int(2) +int(4) +int(6) diff --git a/tests/aot/loop/foreach-invalid-iteratoraggregate.phpt b/tests/aot/loop/foreach-invalid-iteratoraggregate.phpt new file mode 100644 index 00000000..87c1d9ea --- /dev/null +++ b/tests/aot/loop/foreach-invalid-iteratoraggregate.phpt @@ -0,0 +1,55 @@ +--TEST-- +foreach rejects invalid IteratorAggregate getIterator return values +--FILE-- + true]; + } +} + +final class NestedInvalidAggregate implements IteratorAggregate +{ + public function getIterator(): Traversable + { + return new ObjectReturningAggregate(); + } +} + +function check(object $iterable): void +{ + try { + foreach ($iterable as $value) { + var_dump($value); + } + } catch (Throwable $e) { + var_dump($e instanceof Exception); + var_dump(str_contains($e->getMessage(), 'must be traversable or implement interface Iterator')); + } +} + +function main(): void +{ + check(new ArrayReturningAggregate()); + check(new ObjectReturningAggregate()); + check(new NestedInvalidAggregate()); +} +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/tests/aot/loop/foreach-iterable-objects.phpt b/tests/aot/loop/foreach-iterable-objects.phpt new file mode 100644 index 00000000..6d952bbe --- /dev/null +++ b/tests/aot/loop/foreach-iterable-objects.phpt @@ -0,0 +1,102 @@ +--TEST-- +foreach supports PHP iterable object interfaces +--FILE-- +pos = 0; + } + + public function current(): mixed + { + return array_values($this->items)[$this->pos]; + } + + public function key(): mixed + { + return array_keys($this->items)[$this->pos]; + } + + public function next(): void + { + ++$this->pos; + } + + public function valid(): bool + { + return $this->pos < count($this->items); + } +} + +final class ArrayAggregate implements IteratorAggregate +{ + public function __construct(private array $items) + { + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } +} + +final class ArrayObjectAggregate implements IteratorAggregate +{ + public function __construct(private array $items) + { + } + + public function getIterator(): Traversable + { + return new ArrayObject($this->items); + } +} + +final class TraversableProvider +{ + public function getIterator(): Traversable + { + return new ArrayIterator(['m' => 'method']); + } +} + +function dump_iterable(iterable $items): void +{ + foreach ($items as $key => $value) { + var_dump($key.':'.$value); + } +} + +function main(): void +{ + dump_iterable(['a' => 'array']); + dump_iterable(new NumberIterator(['i' => 'iterator'])); + dump_iterable(new ArrayAggregate(['g' => 'aggregate'])); + dump_iterable(new ArrayObjectAggregate(['o' => 'arrayobject'])); + + $provider = new TraversableProvider(); + foreach ($provider->getIterator() as $key => $value) { + var_dump($key.':'.$value); + } + + foreach ((object) ['p' => 'property'] as $key => $value) { + var_dump($key.':'.$value); + } +} +?> +--EXPECT-- +string(7) "a:array" +string(10) "i:iterator" +string(11) "g:aggregate" +string(13) "o:arrayobject" +string(8) "m:method" +string(10) "p:property" diff --git a/tests/aot/loop/foreach-method-return-array.phpt b/tests/aot/loop/foreach-method-return-array.phpt new file mode 100644 index 00000000..2edacee3 --- /dev/null +++ b/tests/aot/loop/foreach-method-return-array.phpt @@ -0,0 +1,25 @@ +--TEST-- +foreach supports dynamic method calls returning arrays +--FILE-- + 1, 'b' => 2]; + } +} + +function main(): void +{ + $provider = new ArrayProvider(); + + foreach ($provider->values() as $key => $value) { + var_dump($key.':'.$value); + } +} +?> +--EXPECT-- +string(3) "a:1" +string(3) "b:2" diff --git a/tests/aot/stdlib/count_countable.phpt b/tests/aot/stdlib/count_countable.phpt new file mode 100644 index 00000000..7ee4f7ac --- /dev/null +++ b/tests/aot/stdlib/count_countable.phpt @@ -0,0 +1,64 @@ +--TEST-- +count: Countable objects use Countable::count() +--FILE-- +routes); + } +} + +function count_mixed(mixed $value): int +{ + return count($value); +} + +function main(): void +{ + $bag = new RouteBag(['home', 'about', 'contact']); + var_dump(count($bag)); + var_dump(count($bag, COUNT_RECURSIVE)); + var_dump(count_mixed($bag)); + + $anonymous = new class([1, 2, 3, 4]) implements Countable { + public function __construct(private array $items) + { + } + + public function count(): int + { + echo "anonymous::count\n"; + return count($this->items); + } + }; + var_dump(count($anonymous)); + + $arrayObject = new ArrayObject(['x', 'y']); + var_dump(count($arrayObject)); + + try { + count(new stdClass()); + } catch (TypeError $e) { + echo $e->getMessage(), "\n"; + } +} +?> +--EXPECT-- +RouteBag::count +int(3) +RouteBag::count +int(3) +RouteBag::count +int(3) +anonymous::count +int(4) +int(2) +count(): Argument #1 ($value) must be of type Countable|array diff --git a/tests/aot/symfony/anonymous-iteratoraggregate-cache.phpt b/tests/aot/symfony/anonymous-iteratoraggregate-cache.phpt index 167540ef..83123fb6 100644 --- a/tests/aot/symfony/anonymous-iteratoraggregate-cache.phpt +++ b/tests/aot/symfony/anonymous-iteratoraggregate-cache.phpt @@ -1,7 +1,5 @@ --TEST-- Symfony pattern: anonymous IteratorAggregate with cached ??= ArrayObject ---XFAIL-- -Known AOT bug: foreach over ArrayObject returned from IteratorAggregate can call ArrayObject::rewind() directly. --FILE-- routes); + } +} + +function main(): void +{ + $routes = new CountableRoutes(['home', 'about', 'contact']); + var_dump(count($routes)); +} +?> +--EXPECT-- +int(3) diff --git a/tests/aot/symfony/error-handler-finally-first-class.phpt b/tests/aot/symfony/error-handler-finally-first-class.phpt new file mode 100644 index 00000000..7f7a57b2 --- /dev/null +++ b/tests/aot/symfony/error-handler-finally-first-class.phpt @@ -0,0 +1,46 @@ +--TEST-- +Symfony pattern: set_error_handler with first-class static method and finally restore +--FILE-- + +--EXPECT-- +string(4) "done" +string(17) "512:boxed-warning" diff --git a/tests/aot/symfony/foreach-traversable-method-result.phpt b/tests/aot/symfony/foreach-traversable-method-result.phpt new file mode 100644 index 00000000..7738eb41 --- /dev/null +++ b/tests/aot/symfony/foreach-traversable-method-result.phpt @@ -0,0 +1,25 @@ +--TEST-- +Symfony pattern: foreach over Traversable returned from method +--FILE-- + 1, 'second' => 2]); + } +} + +function main(): void +{ + $provider = new TraversableProvider(); + + foreach ($provider->getIterator() as $key => $value) { + var_dump($key.':'.$value); + } +} +?> +--EXPECT-- +string(7) "first:1" +string(8) "second:2" diff --git a/tests/aot/symfony/parameter-bag-filter-callback.phpt b/tests/aot/symfony/parameter-bag-filter-callback.phpt new file mode 100644 index 00000000..99981430 --- /dev/null +++ b/tests/aot/symfony/parameter-bag-filter-callback.phpt @@ -0,0 +1,58 @@ +--TEST-- +Symfony pattern: parameter bag filter callback with coalesce throw +--FILE-- +parameters[$key] ?? $default; + + if (is_int($options)) { + $options = ['flags' => $options]; + } + + if ((FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof Closure)) { + throw new InvalidArgumentException('callback filter requires a Closure'); + } + + $options['flags'] ??= 0; + + return filter_var($value, $filter, $options); + } + + public function getInt(string $key): int + { + return $this->filter($key, null, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE) + ?? throw new UnexpectedValueException('invalid int: '.$key); + } +} + +function main(): void +{ + $bag = new MiniParameterBag([ + 'port' => '9501', + 'name' => 'Symfony', + ]); + + var_dump($bag->getInt('port')); + var_dump($bag->filter('name', '', FILTER_CALLBACK, [ + 'options' => static fn (string $value): string => strtolower($value), + ])); + + try { + $bag->getInt('missing'); + } catch (UnexpectedValueException $e) { + var_dump($e->getMessage()); + } +} +?> +--EXPECT-- +int(9501) +string(7) "symfony" +string(20) "invalid int: missing" diff --git a/tests/aot/symfony/recursive-array-iterator-leaves.phpt b/tests/aot/symfony/recursive-array-iterator-leaves.phpt new file mode 100644 index 00000000..b2af1773 --- /dev/null +++ b/tests/aot/symfony/recursive-array-iterator-leaves.phpt @@ -0,0 +1,47 @@ +--TEST-- +Symfony pattern: recursive iterator over nested context attributes +--FILE-- + $value) { + $path = []; + for ($depth = 0; $depth <= $it->getDepth(); ++$depth) { + $path[] = $it->getSubIterator($depth)->key(); + } + $result[implode('.', $path)] = $value; + } + + ksort($result); + return $result; +} + +function main(): void +{ + $flat = flattenLeaves([ + 'groups' => ['Default', 'Extra'], + 'options' => [ + 'normalizer' => [ + 'trim' => true, + 'lower' => false, + ], + ], + ]); + + foreach ($flat as $key => $value) { + var_dump($key.'='.json_encode($value)); + } +} +?> +--EXPECT-- +string(18) "groups.0="Default"" +string(16) "groups.1="Extra"" +string(30) "options.normalizer.lower=false" +string(28) "options.normalizer.trim=true" diff --git a/tests/aot/symfony/route-collection-clone-iterator.phpt b/tests/aot/symfony/route-collection-clone-iterator.phpt new file mode 100644 index 00000000..6bf2777e --- /dev/null +++ b/tests/aot/symfony/route-collection-clone-iterator.phpt @@ -0,0 +1,84 @@ +--TEST-- +Symfony pattern: route collection deep clone with ArrayIterator +--FILE-- +routes[$name] = $route; + } + + public function addAlias(string $name, MiniAlias $alias): void + { + $this->aliases[$name] = $alias; + } + + public function __clone() + { + foreach ($this->routes as $name => $route) { + $this->routes[$name] = clone $route; + } + + foreach ($this->aliases as $name => $alias) { + $this->aliases[$name] = clone $alias; + } + } + + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->routes); + } + + public function routeCount(): int + { + return count($this->routes); + } + + public function aliasTarget(string $name): string + { + return $this->aliases[$name]->target; + } +} + +function main(): void +{ + $collection = new MiniRouteCollection(); + $collection->add('home', new MiniRoute('/')); + $collection->add('about', new MiniRoute('/about')); + $collection->addAlias('root', new MiniAlias('home')); + + $copy = clone $collection; + + foreach (iterator_to_array($copy->getIterator()) as $name => $route) { + $route->path = strtoupper($route->path); + var_dump($name.':'.$route->path); + } + + var_dump($collection->routeCount()); + var_dump($collection->aliasTarget('root')); +} +?> +--EXPECT-- +string(6) "home:/" +string(12) "about:/ABOUT" +int(2) +string(4) "home"