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++.
master
Giandonn 1 day ago
parent b493ac79c5
commit 044ba91094
  1. 10
      phpunit/code/count-literal-fold-safe.php
  2. 17
      phpunit/code/count-literal-fold-unsafe.php
  3. 40
      phpunit/src/CountLiteralFoldTest.php
  4. 45
      src/Optimizer/FuncCallOptimizer.php
  5. 47
      tests/compiler/array/count-literal-fold.phpt

@ -0,0 +1,10 @@
<?php
function main(): void
{
$a = 1;
echo count([1, 2, 3]), "\n";
echo count([[1, 2], [3]]), "\n";
echo count([$a, -2, true, null]), "\n";
echo count([]), "\n";
}

@ -0,0 +1,17 @@
<?php
function bump(): int
{
echo "bump\n";
return 1;
}
function main(): void
{
$rest = [1, 2, 3, 4, 5];
$i = 0;
echo count([bump(), bump()]), "\n";
echo count(['a' => 1, 'a' => 2]), "\n";
echo count([...$rest, 9]), "\n";
echo count([$i++, $i++]), "\n";
}

@ -0,0 +1,40 @@
<?php
namespace TypePhp\Tests;
use PHPUnit\Framework\TestCase;
use TypePhp\CompilerTest;
class CountLiteralFoldTest extends TestCase
{
public function testUnfoldableArrayLiteralsKeepTheRuntimeCall(): void
{
$cpp = $this->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));
}
}

@ -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;

@ -0,0 +1,47 @@
--TEST--
count() on an array literal keeps spreads, duplicate keys and element side effects
--FILE--
<?php
function bump(): int
{
echo "bump\n";
return 1;
}
function main()
{
// Element expressions must still run.
var_dump(count([bump(), bump()]));
// A repeated key collapses onto the first one.
var_dump(count(['a' => 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)
Loading…
Cancel
Save