diff --git a/phpunit/code/ctype-direct-calls.php b/phpunit/code/ctype-direct-calls.php new file mode 100644 index 00000000..77e8c5a6 --- /dev/null +++ b/phpunit/code/ctype-direct-calls.php @@ -0,0 +1,16 @@ +addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + foreach ([ + 'ctype_alnum', + 'ctype_alpha', + 'ctype_cntrl', + 'ctype_digit', + 'ctype_lower', + 'ctype_graph', + 'ctype_print', + 'ctype_punct', + 'ctype_space', + 'ctype_upper', + 'ctype_xdigit', + ] as $function) { + self::assertSame(1, substr_count($code, 'php::fn::' . $function . '('), $function); + } + self::assertStringNotContainsString('get_persistent_func', $code); + self::assertStringNotContainsString('php::call(', $code); + self::assertStringContainsString('result = php::fn::ctype_alnum(_php__var__char);', $code); + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 2c4251b3..ee47090e 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -4704,6 +4704,10 @@ class CompilerBase implements PropertyAccessContext protected function detectFuncCallReturnType(string $name): string { $name = ltrim($name, '\\'); + $config = $this->getFuncCallConfig()[$name] ?? null; + if (is_array($config) && isset($config['returnType'])) { + return $config['returnType']; + } $returnType = Reflection::getFunctionReturnType($name); if ($returnType !== null) { return $this->getTypeFromZendType($returnType); diff --git a/src/Optimizer/FuncCallOptimizer.php b/src/Optimizer/FuncCallOptimizer.php index df81b167..7dc70374 100644 --- a/src/Optimizer/FuncCallOptimizer.php +++ b/src/Optimizer/FuncCallOptimizer.php @@ -38,6 +38,20 @@ trait FuncCallOptimizer protected const int FOLD_KNOWN_CONSTANT = 7; protected const int FOLD_SSA_TYPE = 8; + protected const array CTYPE_FUNCTIONS = [ + 'ctype_alnum', + 'ctype_alpha', + 'ctype_cntrl', + 'ctype_digit', + 'ctype_lower', + 'ctype_graph', + 'ctype_print', + 'ctype_punct', + 'ctype_space', + 'ctype_upper', + 'ctype_xdigit', + ]; + /** @var array|null */ protected ?array $_funcCallConfig = null; @@ -196,6 +210,19 @@ trait FuncCallOptimizer 'is_callable' => ['handler' => 'genIsCallable'], ]; + // PHPX implements these directly with . They remain + // available even when the target libphp has no ext/ctype. + foreach (self::CTYPE_FUNCTIONS as $name) { + $extra[$name] = [ + 'args' => 'v', + 'minArgs' => 1, + 'maxArgs' => 1, + 'namedArgs' => ['text'], + 'returnType' => Type::BOOL, + 'intrinsic' => true, + ]; + } + $config = $extra; foreach ($simple as $name) { if (!isset($config[$name])) { @@ -211,6 +238,11 @@ trait FuncCallOptimizer protected function parseFuncCallWithOptimizer(string $name, Node\Expr\FuncCall $expr): string|false { + $config = $this->getFuncCallConfig()[$name] ?? null; + if ($config === null) { + return false; + } + foreach ($expr->args as $arg) { if ($this->isPlaceholderExpr($arg)) { return false; @@ -219,16 +251,23 @@ trait FuncCallOptimizer // with the syntactic argument list. Named arguments and unpacking // require Zend's runtime binding/expansion semantics, so reject // them before any optimizer-specific handler can consume them. - if ($arg instanceof Node\Arg && ($arg->name !== null || $arg->unpack)) { - return false; + if ($arg instanceof Node\Arg) { + if ($arg->unpack) { + return false; + } + if ($arg->name !== null) { + $namedArgs = $config['namedArgs'] ?? null; + if ($namedArgs === null) { + return false; + } + $argName = $arg->name->toString(); + if (!in_array($argName, $namedArgs, true)) { + $this->fatalError($arg, "Unknown named parameter \${$argName}"); + } + } } } - $config = $this->getFuncCallConfig()[$name] ?? null; - if ($config === null) { - return false; - } - // Optimized php::fn::* calls must obey the same ZendVM escape boundary // as the generic call generator. The four scalar conversions are // language-level Native keyword aliases and are lowered to an exact @@ -256,7 +295,10 @@ trait FuncCallOptimizer if (!$arg instanceof Node\Arg) { continue; } - if ($this->isVarExpr($arg->value) && is_string($arg->value->name) && !$this->hasVar($arg->value->name)) { + if ($this->isVarExpr($arg->value) + && is_string($arg->value->name) + && !$this->hasVar($this->parseIdentifier($arg->value)) + ) { return false; } } @@ -290,9 +332,11 @@ trait FuncCallOptimizer protected function dispatchFuncCall(string $name, Node\Expr\FuncCall $expr, array $config): string|false { - // Named arguments and unpack (...) expansion require runtime handling; fall back to the dynamic call path. + // Unpack expansion and ordinary named arguments require runtime + // handling. Single-argument intrinsics may opt in after validating + // their stable PHP parameter name in parseFuncCallWithOptimizer(). foreach ($expr->args as $arg) { - if ($arg->name !== null || $arg->unpack) { + if ($arg->unpack || ($arg->name !== null && !isset($config['namedArgs']))) { return false; } } @@ -310,6 +354,18 @@ trait FuncCallOptimizer $variadicType = $config['variadicType'] ?? ($refInfo['variadicType'] ?? ''); $nullables = $refInfo['nullables'] ?? []; + if (!$this->hasUnpackCallArg($expr->args)) { + $argCount = count($expr->args); + $minArgs = $config['minArgs'] ?? ($refInfo['minArgs'] ?? 0); + $maxArgs = $config['maxArgs'] ?? ($refInfo['maxArgs'] ?? 0); + if ($argCount < $minArgs) { + $this->fatalError($expr, "{$name}() expects at least {$minArgs} argument(s), {$argCount} given"); + } + if ($maxArgs > 0 && $argCount > $maxArgs) { + $this->fatalError($expr, "{$name}() expects at most {$maxArgs} argument(s), {$argCount} given"); + } + } + if (!$this->hasOptimizerSafeTypedArguments( $expr, $argTypeStr, diff --git a/src/Translator.php b/src/Translator.php index 26e4f3c6..433faeb8 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -143,6 +143,11 @@ class Translator extends Preprocessor } $this->internalFunctions[$functionName] = true; } + foreach ($this->getFuncCallConfig() as $functionName => $config) { + if (is_array($config) && ($config['intrinsic'] ?? false)) { + $this->internalFunctions[$functionName] = true; + } + } unset($this->internalFunctions[self::ENTRY_FUNCTION]); $this->internalConstants = $this->loadInternalConstants(); if ($this->climate->arguments->defined('help')) { diff --git a/tests/compiler/stdlib/ctype-direct.phpt b/tests/compiler/stdlib/ctype-direct.phpt new file mode 100644 index 00000000..5c18c43b --- /dev/null +++ b/tests/compiler/stdlib/ctype-direct.phpt @@ -0,0 +1,67 @@ +--TEST-- +ctype functions use direct C classification without ext-ctype +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(false) +bool(false) +bool(false) +bool(false) +bool(false) +bool(true) +bool(true) +bool(true) +bool(false) +bool(true) +bool(false) +bool(false) +bool(false)