From 044ba910940fffc3b2a5d499c505ff8fe57a5368 Mon Sep 17 00:00:00 2001 From: Giandonn Date: Sat, 29 Aug 2026 19:52:55 -0300 Subject: [PATCH] fix(optimizer): stop folding count() on unfoldable array literals doFoldCountLiteral replaced `count([...])` with the number of AST items and dropped the array literal entirely. That is only correct when the item count equals the runtime element count and no element carries an observable effect. Three common shapes break both assumptions: count([bump(), bump()]); // folded to 2, bump() never ran count(['a' => 1, 'a' => 2]); // folded to 2, PHP counts 1 count([...$rest, 9]); // folded to 2, PHP counts 6 The spread case is the most damaging: it silently yields a wrong number in ordinary code that compiles without any diagnostic. The fold now applies only when every item is unkeyed, is not a spread, and holds an expression whose evaluation cannot be observed - a scalar, a constant fetch, a unary sign over either, a nested literal that is itself foldable, or a variable already known to be defined. An undefined variable still reaches the dynamic path so it reports the same diagnostic as PHP. Everything else keeps the runtime php::fn::count() call, so `count([1, 2, 3])` and friends still fold as before. Covered by tests/compiler/array/count-literal-fold.phpt for the runtime semantics and by CountLiteralFoldTest for the fold/no-fold decision in the generated C++. --- phpunit/code/count-literal-fold-safe.php | 10 +++++ phpunit/code/count-literal-fold-unsafe.php | 17 +++++++ phpunit/src/CountLiteralFoldTest.php | 40 +++++++++++++++++ src/Optimizer/FuncCallOptimizer.php | 45 +++++++++++++++++++ tests/compiler/array/count-literal-fold.phpt | 47 ++++++++++++++++++++ 5 files changed, 159 insertions(+) create mode 100644 phpunit/code/count-literal-fold-safe.php create mode 100644 phpunit/code/count-literal-fold-unsafe.php create mode 100644 phpunit/src/CountLiteralFoldTest.php create mode 100644 tests/compiler/array/count-literal-fold.phpt diff --git a/phpunit/code/count-literal-fold-safe.php b/phpunit/code/count-literal-fold-safe.php new file mode 100644 index 00000000..66c558c0 --- /dev/null +++ b/phpunit/code/count-literal-fold-safe.php @@ -0,0 +1,10 @@ + 1, 'a' => 2]), "\n"; + echo count([...$rest, 9]), "\n"; + echo count([$i++, $i++]), "\n"; +} diff --git a/phpunit/src/CountLiteralFoldTest.php b/phpunit/src/CountLiteralFoldTest.php new file mode 100644 index 00000000..83785291 --- /dev/null +++ b/phpunit/src/CountLiteralFoldTest.php @@ -0,0 +1,40 @@ +compileToCpp('count-literal-fold-unsafe.php'); + + // Element side effects, a repeated key and a spread each make the + // number of AST items differ from the runtime element count. + self::assertSame(4, substr_count($cpp, 'php::fn::count(')); + self::assertStringContainsString('php_bump()', $cpp); + self::assertStringContainsString('i++', $cpp); + } + + public function testPlainArrayLiteralsStillFoldAtCompileTime(): void + { + $cpp = $this->compileToCpp('count-literal-fold-safe.php'); + + self::assertStringNotContainsString('php::fn::count(', $cpp); + } + + private function compileToCpp(string $file): string + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/' . $file; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + + return file_get_contents($compiler->convertFile($source)); + } +} diff --git a/src/Optimizer/FuncCallOptimizer.php b/src/Optimizer/FuncCallOptimizer.php index fe2ae690..3c15ca65 100644 --- a/src/Optimizer/FuncCallOptimizer.php +++ b/src/Optimizer/FuncCallOptimizer.php @@ -656,11 +656,56 @@ trait FuncCallOptimizer } $arg = $expr->args[0]->value; if ($arg instanceof Node\Expr\Array_) { + if (!$this->isCountFoldableArray($arg)) { + return false; + } return count($arg->items) . $this->getPlatform()->getIntegerLiteralSuffix(); } return $this->genStdContainerCount($arg); } + /** + * The number of AST items only equals the runtime element count when no + * item spreads another array, no key can collide with another key, and + * dropping the element expressions cannot lose an observable effect. + * Anything else keeps the runtime php::fn::count() call. + */ + protected function isCountFoldableArray(Node\Expr\Array_ $array): bool + { + foreach ($array->items as $item) { + // [...$other] contributes an element count only known at runtime, + // and a key may collapse onto an earlier one: ['a' => 1, 'a' => 2] + // counts as one element, not two. + if ($item->unpack || $item->key !== null) { + return false; + } + if (!$this->isCountFoldableItem($item->value)) { + return false; + } + } + return true; + } + + protected function isCountFoldableItem(Node\Expr $value): bool + { + // ConstFetch also covers true, false and null. + if ($this->isScalar($value) + || $value instanceof Node\Expr\ConstFetch + || $value instanceof Node\Expr\ClassConstFetch + ) { + return true; + } + if ($value instanceof Node\Expr\UnaryMinus || $value instanceof Node\Expr\UnaryPlus) { + return $this->isCountFoldableItem($value->expr); + } + if ($value instanceof Node\Expr\Array_) { + return $this->isCountFoldableArray($value); + } + // A defined variable is a plain read; an undefined one must reach the + // dynamic path so it still reports the same diagnostic as PHP. + return $this->isVarExpr($value) && is_string($value->name) && $this->hasVar($value->name); + } + protected function doFoldKnownClass(Node\Expr\FuncCall $expr): string|false { $cn = $expr->args[0]->value; diff --git a/tests/compiler/array/count-literal-fold.phpt b/tests/compiler/array/count-literal-fold.phpt new file mode 100644 index 00000000..ade723fc --- /dev/null +++ b/tests/compiler/array/count-literal-fold.phpt @@ -0,0 +1,47 @@ +--TEST-- +count() on an array literal keeps spreads, duplicate keys and element side effects +--FILE-- + 1, 'a' => 2])); + + // A spread contributes a count only known at runtime. + $rest = [1, 2, 3, 4, 5]; + var_dump(count([...$rest, 9])); + + // Side effects of the elements must be observable afterwards. + $i = 0; + var_dump(count([$i++, $i++])); + var_dump($i); + + // Plain literals stay eligible for the compile-time fold. + $a = 1; + var_dump(count([1, 2, 3])); + var_dump(count([[1, 2], [3]])); + var_dump(count([$a, -2, true, null])); + var_dump(count([])); +} +?> +--EXPECT-- +bump +bump +int(2) +int(1) +int(6) +int(2) +int(2) +int(3) +int(2) +int(4) +int(0)