Merge pull request #18 from hafung/fix/optimized-builtin-typed-arguments --skip-tests

fix(optimizer): preserve typed builtin argument validation
debug-ext-lib
韩天峰 4 hours ago committed by GitHub
commit 223a10636b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 31
      phpunit/code/func-call-optimizer-typed-arguments.php
  2. 35
      phpunit/src/FuncCallOptimizerTest.php
  3. 110
      src/Optimizer/FuncCallOptimizer.php
  4. 2
      tests/compiler/array/array-merge-unpack.phpt
  5. 88
      tests/compiler/stdlib/null_optional_arg.phpt
  6. 316
      tests/compiler/stdlib/strict-builtin-typed-arguments.phpt

@ -0,0 +1,31 @@
<?php
function optimizerDynamicBool(): mixed
{
return true;
}
function optimizerTypedBool(): bool
{
return true;
}
function optimizerTypedInt(): int
{
return 1;
}
function optimizerTypedFloat(): float
{
return 0.0;
}
function optimizerTypedArgumentCalls(): void
{
in_array('1', [1], optimizerTypedBool());
hypot(optimizerTypedInt(), optimizerTypedFloat());
in_array('1', [1], optimizerTypedInt());
in_array('1', [1], optimizerDynamicBool());
strlen(null);
json_decode('null', null);
}

@ -0,0 +1,35 @@
<?php
use TypePhp\CompilerTest;
final class FuncCallOptimizerTest extends BaseTest
{
public function testStrictTypedArgumentsUseOnlyProvenDirectAbiPaths(): void
{
global $translator;
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH);
$translator = $compiler;
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/func-call-optimizer-typed-arguments.php';
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$generated = $compiler->convertFile($source);
$code = file_get_contents($generated);
self::assertIsString($code);
self::assertSame(1, substr_count($code, 'php::fn::in_array('));
self::assertSame(1, substr_count($code, 'php::fn::hypot('));
self::assertSame(1, substr_count($code, 'php::fn::json_decode('));
self::assertSame(3, substr_count($code, 'php::call('));
self::assertStringContainsString('php_optimizertypedbool()', $code);
self::assertStringContainsString('php_optimizertypedint()', $code);
self::assertStringContainsString('php_optimizertypedfloat()', $code);
self::assertStringContainsString('php_optimizerdynamicbool()', $code);
self::assertMatchesRegularExpression('/php_optimizertypedbool\(\);\s*php::fn::in_array/', $code);
self::assertMatchesRegularExpression('/php_optimizertypedint\(\);\s*php::call/', $code);
self::assertMatchesRegularExpression('/php_optimizerdynamicbool\(\);\s*php::call/', $code);
self::assertStringContainsString('php::fn::hypot(php::toFloat(', $code);
self::assertStringContainsString('php::ArgList{php::null}', $code);
self::assertMatchesRegularExpression('/php::fn::json_decode\([^;]+php::null\);/', $code);
}
}

@ -287,9 +287,19 @@ trait FuncCallOptimizer
$refInfo = $this->getArgReflectionInfo($name);
$argTypeStr = $config['args'] ?? ($refInfo['args'] ?? '');
$defaults = $config['defaults'] ?? [];
$variadicType = $config['variadicType'] ?? ($refInfo['variadicType'] ?? '');
$nullables = $refInfo['nullables'] ?? [];
if (!$this->hasOptimizerSafeTypedArguments(
$expr,
$argTypeStr,
$variadicType,
$nullables,
)) {
return false;
}
if (!empty($config['variadic']) || ($refInfo['variadic'] ?? false)) {
$variadicType = $config['variadicType'] ?? $refInfo['variadicType'] ?? '';
return $this->genVariadicCall($target, $expr, $variadicType);
}
@ -300,11 +310,80 @@ trait FuncCallOptimizer
}
}
$nullables = $refInfo['nullables'] ?? [];
$args = $this->buildArgList($expr, $argTypeStr, $defaults, $nullables);
return $target . '(' . implode(', ', $args) . ')';
}
protected function hasOptimizerSafeTypedArguments(
Node\Expr\FuncCall $expr,
string $argTypeStr,
string $variadicType,
array $nullables,
): bool
{
// The optimized ABI conversions are safe for exact types and for
// strict PHP's int-to-float widening. Every other conversion would
// erase the runtime zval type before Zend can validate the parameter,
// so keep those calls on php::call().
$types = $argTypeStr === '' ? [] : explode('_', $argTypeStr);
foreach ($expr->args as $index => $arg) {
// Custom handlers call this helper too. They cannot lower an
// unpacked list as a fixed C++ ABI argument sequence.
if ($arg->unpack) {
return false;
}
$type = $types[$index] ?? $variadicType;
$base = ($type[0] ?? '') === self::ARG_OPTIONAL ? substr($type, 1) : $type;
if (!in_array($base, [
self::ARG_TYPE_STR,
self::ARG_TYPE_INT,
self::ARG_TYPE_FLOAT,
self::ARG_TYPE_BOOL,
self::ARG_TYPE_ARRAY,
], true)) {
continue;
}
if ($this->isNull($arg->value)) {
if ($nullables[$index] ?? false) {
continue;
}
return false;
}
$expected = match ($base) {
self::ARG_TYPE_STR => Type::STR,
self::ARG_TYPE_INT => Type::INT,
self::ARG_TYPE_FLOAT => Type::FLOAT,
self::ARG_TYPE_BOOL => Type::BOOL,
self::ARG_TYPE_ARRAY => Type::ARRAY,
};
$actual = $this->detectTypeOfExpr($arg->value);
if ($actual === $expected) {
continue;
}
if ($expected === Type::FLOAT && $actual === Type::INT) {
continue;
}
return false;
}
return true;
}
protected function hasOptimizerSafeReflectedArguments(
string $name,
Node\Expr\FuncCall $expr,
array $config,
): bool
{
$refInfo = $this->getArgReflectionInfo($name);
return $this->hasOptimizerSafeTypedArguments(
$expr,
$config['args'] ?? ($refInfo['args'] ?? ''),
$config['variadicType'] ?? ($refInfo['variadicType'] ?? ''),
$refInfo['nullables'] ?? [],
);
}
// =========================================================================
// Auto-detect argument types from PHP reflection
// =========================================================================
@ -860,6 +939,9 @@ trait FuncCallOptimizer
protected function genArrayKeys(string $n, Node\Expr\FuncCall $e, array $c): string|false
{
if (!$this->hasOptimizerSafeReflectedArguments($n, $e, $c)) {
return false;
}
$cnt = count($e->args);
if ($cnt >= 3) {
if ($this->detectTypeOfExpr($e->args[2]->value) !== Type::BOOL) {
@ -874,8 +956,11 @@ trait FuncCallOptimizer
return 'php::fn::array_keys(' . $this->getArg($e, 0) . ')';
}
protected function genArrayKeyExists(string $n, Node\Expr\FuncCall $e, array $c): string
protected function genArrayKeyExists(string $n, Node\Expr\FuncCall $e, array $c): string|false
{
if (!$this->hasOptimizerSafeReflectedArguments($n, $e, $c)) {
return false;
}
// The C++ receiver is PHP's second argument, but PHP still evaluates
// the key first. Resolve both in source order before rearranging them.
$key = $this->getArg($e, 0);
@ -883,7 +968,7 @@ trait FuncCallOptimizer
return $array . '.offsetExists(' . $key . ')';
}
protected function genRound(string $n, Node\Expr\FuncCall $e, array $c): string
protected function genRound(string $n, Node\Expr\FuncCall $e, array $c): string|false
{
$type = $this->detectTypeOfExpr($e->args[0]->value);
if ($type === Type::DECIMAL) {
@ -893,6 +978,9 @@ trait FuncCallOptimizer
}
return 'php::Decimal::round(' . $a0 . ')';
}
if (!$this->hasOptimizerSafeReflectedArguments($n, $e, $c)) {
return false;
}
$args = count($e->args);
if ($args >= 3) {
return 'php::fn::round(' . $this->getArg($e, 0) . ', ' . $this->convertIntExpr($this->getArg($e, 1)) . ', ' . $this->convertIntExpr($this->getArg($e, 2)) . ')';
@ -903,7 +991,7 @@ trait FuncCallOptimizer
return 'php::fn::round(' . $this->getArg($e, 0) . ')';
}
protected function genCount(string $n, Node\Expr\FuncCall $e, array $c): string
protected function genCount(string $n, Node\Expr\FuncCall $e, array $c): string|false
{
$receiver = $e->args[0] ?? null;
$nativeClass = $receiver instanceof Node\Arg
@ -928,6 +1016,10 @@ trait FuncCallOptimizer
));
}
if (!$this->hasOptimizerSafeReflectedArguments($n, $e, $c)) {
return false;
}
$folded = $this->doFoldCountLiteral($e);
if ($folded !== false) return $folded;
if (count($e->args) >= 2) {
@ -938,6 +1030,9 @@ trait FuncCallOptimizer
protected function genDefine(string $n, Node\Expr\FuncCall $e, array $c): string|false
{
if (!$this->hasOptimizerSafeReflectedArguments($n, $e, $c)) {
return false;
}
$arg = $e->args[0]->value;
if ($this->isScalarString($arg) && str_contains($arg->value, '::')) {
$this->fatalError($e, 'Invalid define name `' . $arg->value . '`');
@ -999,8 +1094,11 @@ trait FuncCallOptimizer
return (string) count($funcDef->argInfoList);
}
protected function genFunctionExists(string $name, Node\Expr\FuncCall $expr, array $config): string
protected function genFunctionExists(string $name, Node\Expr\FuncCall $expr, array $config): string|false
{
if (!$this->hasOptimizerSafeReflectedArguments($name, $expr, $config)) {
return false;
}
$funcName = $expr->args[0]->value;
if ($this->isScalarString($funcName)) {
$nameLower = strtolower(trim($funcName->value, '\\'));

@ -16,7 +16,7 @@ class Dg {
$this->r[] = [
'bid' => time(),
'ct' => 1,
'cid' => random_int(1e8, 2147483647),
'cid' => random_int(100000000, 2147483647),
'cp' => strtoupper(substr(bin2hex(random_bytes(8)), 0, 12)),
'crt' => date('Y-m-d H:i:s'),
'mem' => '',

@ -1,52 +1,70 @@
--TEST--
Passing null to optional parameters should use C++ default values
Nullable and non-nullable builtin parameters preserve strict null semantics
--FILE--
<?php
error_reporting(E_ALL & ~E_DEPRECATED);
declare(strict_types=1);
// substr: null length should take rest of string, not return empty
$s = 'hello world';
var_dump(substr($s, 6, null));
var_dump(substr($s, 6) === substr($s, 6, null));
var_dump(substr($s, 0, null) === $s);
var_dump(substr($s, null) === $s);
function main()
{
$s = 'hello world';
var_dump(substr($s, 6, null));
var_dump(substr($s, 6) === substr($s, 6, null));
var_dump(substr($s, 0, null) === $s);
// strpos: null offset should default to 0
var_dump(strpos($s, 'o', null));
var_dump(strpos($s, 'o', null) === strpos($s, 'o'));
try {
substr($s, null);
echo "substr-offset-null=missing TypeError\n";
} catch (TypeError $error) {
echo "substr-offset-null=TypeError\n";
}
// stripos: null offset should default to 0
var_dump(stripos($s, 'O', null));
var_dump(stripos($s, 'O', null) === stripos($s, 'O'));
try {
strpos($s, 'o', null);
echo "strpos-offset-null=missing TypeError\n";
} catch (TypeError $error) {
echo "strpos-offset-null=TypeError\n";
}
// strrpos: null offset should default to 0 (search from end)
var_dump(strrpos('hello hello', 'o', null));
var_dump(strrpos('hello hello', 'o', null) === strrpos('hello hello', 'o'));
try {
stripos($s, 'O', null);
echo "stripos-offset-null=missing TypeError\n";
} catch (TypeError $error) {
echo "stripos-offset-null=TypeError\n";
}
// strstr: null before_needle should default to false
var_dump(strstr($s, 'o', null));
var_dump(strstr($s, 'o', null) === strstr($s, 'o'));
try {
strrpos('hello hello', 'o', null);
echo "strrpos-offset-null=missing TypeError\n";
} catch (TypeError $error) {
echo "strrpos-offset-null=TypeError\n";
}
// str_repeat: ensure non-null still works
var_dump(str_repeat('ab', 3));
try {
strstr($s, 'o', null);
echo "strstr-before-needle-null=missing TypeError\n";
} catch (TypeError $error) {
echo "strstr-before-needle-null=TypeError\n";
}
// explode with null limit: null coerces to 0 (limit=0: whole string as single element)
$arr = explode(' ', 'a b c d', null);
var_dump(count($arr));
var_dump(str_repeat('ab', 3));
try {
explode(' ', 'a b c d', null);
echo "explode-limit-null=missing TypeError\n";
} catch (TypeError $error) {
echo "explode-limit-null=TypeError\n";
}
}
?>
--EXPECT--
string(5) "world"
bool(true)
bool(true)
bool(true)
int(4)
bool(true)
int(4)
bool(true)
int(10)
bool(true)
string(7) "o world"
bool(true)
substr-offset-null=TypeError
strpos-offset-null=TypeError
stripos-offset-null=TypeError
strrpos-offset-null=TypeError
strstr-before-needle-null=TypeError
string(6) "ababab"
int(1)
explode-limit-null=TypeError

@ -0,0 +1,316 @@
--TEST--
Optimized builtins preserve strict typed parameter validation
--FILE--
<?php
declare(strict_types=1);
function mixedBool(): mixed
{
return true;
}
function typedBool(): bool
{
return true;
}
function typedInt(): int
{
return 1;
}
function typedFloat(): float
{
return 1.0;
}
function mixedInt(): mixed
{
return 1;
}
function mixedArray(): mixed
{
return [];
}
function unionInt(): bool|int
{
return 1;
}
function mixedString(): mixed
{
return '1';
}
function orderedNeedle(array &$events): mixed
{
$events[] = 'needle';
return '1';
}
function orderedHaystack(array &$events): mixed
{
$events[] = 'haystack';
return [1];
}
function orderedStrict(array &$events): mixed
{
$events[] = 'strict';
return true;
}
function main()
{
var_dump(in_array('1', [1], true));
var_dump(in_array('1', [1], typedBool()));
var_dump(in_array('1', [1], mixedBool()));
var_dump(array_search('1', [1], mixedBool()));
var_dump(strlen('abc'));
var_dump(strlen((string) mixedInt()));
var_dump(hypot(mixedInt(), 0.0));
var_dump(hypot(typedInt(), 0.0));
var_dump(hypot(3, 4));
var_dump(json_decode('"ok"', mixedBool()));
var_dump(json_decode('null', null));
var_dump(substr('abc', 1, null));
var_dump(strlen(json_decode('"abc"', mixedBool())));
var_dump(array_merge(mixedArray(), ['value' => 1]));
var_dump(array_keys(mixedArray()));
var_dump(function_exists(mixedString()));
var_dump(round(...[1.25, 1]));
$events = [];
var_dump(in_array(
orderedNeedle($events),
orderedHaystack($events),
orderedStrict($events)
));
var_dump($events);
try {
in_array('1', [1], mixedInt());
echo "in-array-mixed-int=missing TypeError\n";
} catch (TypeError $error) {
echo "in-array-mixed-int=TypeError\n";
}
try {
in_array('1', [1], typedInt());
echo "in-array-typed-int=missing TypeError\n";
} catch (TypeError $error) {
echo "in-array-typed-int=TypeError\n";
}
try {
strlen(mixedInt());
echo "strlen-mixed-int=missing TypeError\n";
} catch (TypeError $error) {
echo "strlen-mixed-int=TypeError\n";
}
try {
strlen(null);
echo "strlen-null=missing TypeError\n";
} catch (TypeError $error) {
echo "strlen-null=TypeError\n";
}
try {
array_fill(mixedString(), 1, 'x');
echo "array-fill-mixed-string=missing TypeError\n";
} catch (TypeError $error) {
echo "array-fill-mixed-string=TypeError\n";
}
try {
array_fill(typedFloat(), 1, 'x');
echo "array-fill-typed-float=missing TypeError\n";
} catch (TypeError $error) {
echo "array-fill-typed-float=TypeError\n";
}
try {
hypot(0.0, mixedString());
echo "hypot-mixed-string=missing TypeError\n";
} catch (TypeError $error) {
echo "hypot-mixed-string=TypeError\n";
}
try {
json_decode('null', typedInt());
echo "json-decode-typed-int=missing TypeError\n";
} catch (TypeError $error) {
echo "json-decode-typed-int=TypeError\n";
}
try {
strpos('abc', 'b', null);
echo "strpos-null=missing TypeError\n";
} catch (TypeError $error) {
echo "strpos-null=TypeError\n";
}
try {
in_array('1', mixedInt(), true);
echo "in-array-mixed-haystack=missing TypeError\n";
} catch (TypeError $error) {
echo "in-array-mixed-haystack=TypeError\n";
}
try {
is_callable('strlen', mixedInt());
echo "is-callable-mixed-int=missing TypeError\n";
} catch (TypeError $error) {
echo "is-callable-mixed-int=TypeError\n";
}
try {
array_merge(mixedInt());
echo "array-merge-mixed-int=missing TypeError\n";
} catch (TypeError $error) {
echo "array-merge-mixed-int=TypeError\n";
}
try {
array_search('1', [1], mixedArray());
echo "array-search-mixed-array=missing TypeError\n";
} catch (TypeError $error) {
echo "array-search-mixed-array=TypeError\n";
}
try {
in_array('1', [1], unionInt());
echo "in-array-union-int=missing TypeError\n";
} catch (TypeError $error) {
echo "in-array-union-int=TypeError\n";
}
try {
array_key_exists('key', mixedInt());
echo "array-key-exists-mixed-int=missing TypeError\n";
} catch (TypeError $error) {
echo "array-key-exists-mixed-int=TypeError\n";
}
try {
array_keys(mixedInt());
echo "array-keys-mixed-int=missing TypeError\n";
} catch (TypeError $error) {
echo "array-keys-mixed-int=TypeError\n";
}
try {
round(1.25, mixedString());
echo "round-mixed-string=missing TypeError\n";
} catch (TypeError $error) {
echo "round-mixed-string=TypeError\n";
}
try {
round(1.25, typedFloat());
echo "round-typed-float=missing TypeError\n";
} catch (TypeError $error) {
echo "round-typed-float=TypeError\n";
}
try {
round(1.25, null);
echo "round-null=missing TypeError\n";
} catch (TypeError $error) {
echo "round-null=TypeError\n";
}
try {
count([], mixedBool());
echo "count-mixed-bool=missing TypeError\n";
} catch (TypeError $error) {
echo "count-mixed-bool=TypeError\n";
}
try {
count([], typedBool());
echo "count-typed-bool=missing TypeError\n";
} catch (TypeError $error) {
echo "count-typed-bool=TypeError\n";
}
try {
count([], null);
echo "count-null=missing TypeError\n";
} catch (TypeError $error) {
echo "count-null=TypeError\n";
}
try {
function_exists(mixedInt());
echo "function-exists-mixed-int=missing TypeError\n";
} catch (TypeError $error) {
echo "function-exists-mixed-int=TypeError\n";
}
try {
define(mixedInt(), 1);
echo "define-mixed-int=missing TypeError\n";
} catch (TypeError $error) {
echo "define-mixed-int=TypeError\n";
}
}
?>
--EXPECT--
bool(false)
bool(false)
bool(false)
bool(false)
int(3)
int(1)
float(1)
float(1)
float(5)
string(2) "ok"
NULL
string(2) "bc"
int(3)
array(1) {
["value"]=>
int(1)
}
array(0) {
}
bool(false)
float(1.3)
bool(false)
array(3) {
[0]=>
string(6) "needle"
[1]=>
string(8) "haystack"
[2]=>
string(6) "strict"
}
in-array-mixed-int=TypeError
in-array-typed-int=TypeError
strlen-mixed-int=TypeError
strlen-null=TypeError
array-fill-mixed-string=TypeError
array-fill-typed-float=TypeError
hypot-mixed-string=TypeError
json-decode-typed-int=TypeError
strpos-null=TypeError
in-array-mixed-haystack=TypeError
is-callable-mixed-int=TypeError
array-merge-mixed-int=TypeError
array-search-mixed-array=TypeError
in-array-union-int=TypeError
array-key-exists-mixed-int=TypeError
array-keys-mixed-int=TypeError
round-mixed-string=TypeError
round-typed-float=TypeError
round-null=TypeError
count-mixed-bool=TypeError
count-typed-bool=TypeError
count-null=TypeError
function-exists-mixed-int=TypeError
define-mixed-int=TypeError
Loading…
Cancel
Save