feat(callable): add support for bare function names as callable arguments

- Add acceptsCallable property to ArgInfo entity to track callable type acceptance
- Implement logic to detect when function parameters accept callable types
- Add normalization of bare function callable arguments to string representation
- Create helper methods to check if types contain callable declarations
- Add resolution logic for bare identifier function names in callable context
- Update native class support to set acceptsCallable flag based on type checking
- Modify preprocessor to populate acceptsCallable field during argument parsing
- Remove deprecated typeDeclContainsCallable method and consolidate logic
- Add comprehensive test coverage for bare
master
韩天峰 3 weeks ago
parent 3bb73de645
commit 9d68d2803c
  1. 15
      examples/cases/fn-name.php
  2. 2
      src/Entity/ArgInfo.php
  3. 106
      src/Generator/CallArgumentGenerator.php
  4. 1
      src/NativeClass/NativeClassSupportTrait.php
  5. 57
      src/Parser/FunctionCallTrait.php
  6. 35
      src/Preprocessor.php
  7. 24
      src/TypeSystem/CompoundTypeDeclarationValidationTrait.php
  8. 4
      src/TypeSystem/NativeTypeCompatibilityTrait.php
  9. 77
      tests/compiler/basic/constant-name-case.phpt
  10. 86
      tests/compiler/callable/bare-function-argument.phpt

@ -0,0 +1,15 @@
<?php
function cube($n)
{
return ($n * $n * $n);
}
function main()
{
$a = [1, 2, 3, 4, 5];
$b = array_map(cube, $a);
print_r($b);
}

@ -47,6 +47,8 @@ class ArgInfo
public bool $nullable = false;
public bool $undeclared = false;
public bool $explicitMixed = false;
/** The declared parameter type accepts callable values. */
public bool $acceptsCallable = false;
public bool $property = false;
/** This parameter binding and any referenced object are read-only in the callee. */
public bool $immutable = false;

@ -22,6 +22,101 @@ trait CallArgumentGenerator
/** Guard against a broken lowering path producing an unbounded call. */
private const CALL_ARGUMENT_LIMIT = 65_536;
private function argInfoAcceptsCallable(?ArgInfo $argInfo): bool
{
return $argInfo?->acceptsCallable ?? false;
}
private function reflectionTypeAcceptsCallable(?\ReflectionType $type): bool
{
if ($type instanceof \ReflectionNamedType) {
return strcasecmp($type->getName(), 'callable') === 0;
}
if ($type instanceof \ReflectionUnionType) {
foreach ($type->getTypes() as $member) {
if ($this->reflectionTypeAcceptsCallable($member)) {
return true;
}
}
}
return false;
}
private function callArgumentAcceptsCallable(
string $funcName,
string $className,
int $index,
?string $argName,
): bool {
if ($funcName === '') {
return false;
}
$argInfo = $argName === null
? $this->getAotCallArgInfo($funcName, $className, $index)
: $this->getAotCallArgInfoByName($funcName, $className, $argName);
if ($argInfo !== null) {
return $this->argInfoAcceptsCallable($argInfo);
}
if ($className !== '') {
if ($className === self::DYNAMIC_CALLED_CLASS) {
return false;
}
$class = Reflection::getClass($className);
if ($class === null || !$class->hasMethod($funcName)) {
return false;
}
$parameters = $class->getMethod($funcName)->getParameters();
} else {
$function = Reflection::getFunction($funcName);
if ($function === null) {
return false;
}
$parameters = $function->getParameters();
}
$variadic = null;
foreach ($parameters as $parameterIndex => $parameter) {
if ($parameter->isVariadic()) {
$variadic = $parameter;
}
if (($argName !== null && $parameter->getName() === $argName)
|| ($argName === null && $parameterIndex === $index)
) {
return $this->reflectionTypeAcceptsCallable($parameter->getType());
}
}
return $argName === null
&& $variadic !== null
&& $this->reflectionTypeAcceptsCallable($variadic->getType());
}
private function normalizeBareFunctionCallableArgument(
Node\Arg $arg,
bool $acceptsCallable,
): Node\Arg {
if (!$acceptsCallable
|| $arg->unpack
|| !$arg->value instanceof Expr\ConstFetch
) {
return $arg;
}
$function = $this->resolveBareCallableFunctionName($arg->value);
if ($function === null) {
return $arg;
}
$normalized = clone $arg;
$normalized->value = new Node\Scalar\String_(
$function,
$arg->value->getAttributes(),
);
return $normalized;
}
protected function parseNativeCallArgs(
array $callArgs,
string $nativeFunc,
@ -474,6 +569,17 @@ trait CallArgumentGenerator
if ($this->isPlaceholderExpr($arg)) {
throw new PlaceHolder();
}
if (!$arg->unpack && $arg->value instanceof Expr\ConstFetch) {
$arg = $this->normalizeBareFunctionCallableArgument(
$arg,
$this->callArgumentAcceptsCallable(
$funcName,
$className,
$i,
$arg->name?->name,
),
);
}
$this->validateTypedArrayDynamicArgument($arg, $funcName, $className, $i);
if ($arg->unpack) {
if ($hasNamedArg) {

@ -335,6 +335,7 @@ trait NativeClassSupportTrait
}
$node = $this->reflectionTypeToNode($type, $declaringClass);
$argument->typeStr = $this->typeCheckNodeToString($node);
$argument->acceptsCallable = $this->typeNodeContainsCallable($node);
if ($node instanceof Node\NullableType
|| $node instanceof Node\UnionType
|| $node instanceof Node\IntersectionType

@ -20,6 +20,63 @@ use TypePhp\Exception\PlaceHolder;
trait FunctionCallTrait
{
/**
* Resolve a bare identifier used specifically in a callable parameter.
* Constants retain PHP precedence; only an otherwise unresolved constant
* fetch may be treated as a TypePHP function symbol.
*/
private function resolveBareCallableFunctionName(Expr\ConstFetch $expr): ?string
{
if (!$expr->name instanceof Node\Name) {
return null;
}
$source = ltrim($this->parseIdentifier($expr->name), '\\');
if ($source === ''
|| str_contains($source, '::')
|| in_array(strtolower($source), ['null', 'true', 'false'], true)
|| isset($this->useConstants[$source])
) {
return null;
}
[$constantName, $runtimeNamespaceFallback] = $this->resolveConstantFetchName(
$expr,
$source,
);
if ($this->hasConstant($constantName)
|| $constantName === 'PHP_EOL'
|| $this->isInternalConstant($constantName)
) {
return null;
}
if ($runtimeNamespaceFallback
&& ($this->hasConstant($source)
|| $source === 'PHP_EOL'
|| $this->isInternalConstant($source))
) {
return null;
}
$target = $this->resolveStaticFunctionCallTarget($expr->name);
$nativeFunction = $this->findNativeFunction($target['nativeLookup']);
if ($nativeFunction !== false) {
$function = $this->getFunction($nativeFunction);
if ($this->functionRequiresNativeAbi($function)) {
$this->fatalError($expr, 'Native ABI functions cannot be used as callable arguments');
}
return $function->getNamespacedName();
}
if (($target['definitelyGlobal'] || $target['namespacedFallback'])
&& $this->isInternalFunction($target['lower'])
) {
return $target['lower'];
}
return null;
}
/**
* Resolve the one static function name used by every call path. Function
* imports and function names are case-insensitive, unlike constant names.

@ -1247,6 +1247,8 @@ class Preprocessor extends CompilerBase
$argInfo->nullable = true;
}
$argInfo->undeclared = $param->type === null;
$argInfo->acceptsCallable = $param->type !== null
&& $this->typeNodeContainsCallable($param->type);
if (
$param->type !== null
&& !$param->type instanceof NullableType
@ -2175,7 +2177,7 @@ class Preprocessor extends CompilerBase
[$declaredType, $class] = $v->type
? $this->resolveTypeDecl($v->type, self::DECL_TYPE_OF_CONST)
: [null, ''];
if ($v->type !== null && $this->typeDeclContainsCallable($v->type)) {
if ($v->type !== null && $this->typeNodeContainsCallable($v->type)) {
$constName = $v->consts !== [] ? $this->parseIdentifier($v->consts[0]->name) : '';
$this->fatalError(
$v,
@ -2343,7 +2345,7 @@ class Preprocessor extends CompilerBase
// `callable` is a runtime-context type (a string or array may or may
// not be callable depending on scope), so Zend forbids it in property
// types entirely - bare, nullable, or as a union member.
if ($typeNode !== null && $this->typeDeclContainsCallable($typeNode)) {
if ($typeNode !== null && $this->typeNodeContainsCallable($typeNode)) {
$this->fatalError(
$errorNode,
"Property `{$this->classDef->getNamespacedName(false)}::\${$name}` cannot have type `{$this->typeCheckNodeToString($typeNode)}`",
@ -2419,31 +2421,6 @@ class Preprocessor extends CompilerBase
return $propDef;
}
/**
* Whether a declared type mentions `callable` outside an intersection.
* Zend forbids callable in property and class-constant types; callable
* inside an intersection is rejected first, with its own diagnostic, by
* the common declaration validation in parseTypeDecl().
*/
private function typeDeclContainsCallable(NodeAbstract $typeNode): bool
{
if ($typeNode instanceof NullableType) {
return $this->typeDeclContainsCallable($typeNode->type);
}
if ($typeNode instanceof UnionType) {
foreach ($typeNode->types as $member) {
if ($this->typeDeclContainsCallable($member)) {
return true;
}
}
return false;
}
if ($typeNode instanceof IntersectionType) {
return false;
}
return strtolower($this->parseIdentifier($typeNode)) === 'callable';
}
private function validateAsymmetricPropertyDeclaration(
string $name,
int $flags,
@ -3242,7 +3219,7 @@ class Preprocessor extends CompilerBase
if ($stmt->type) {
$this->validateClassScopeTypeKeywords($stmt->type, true, false);
[$type, $class] = $this->resolveTypeDecl($stmt->type, self::DECL_TYPE_OF_CONST);
if ($this->typeDeclContainsCallable($stmt->type)) {
if ($this->typeNodeContainsCallable($stmt->type)) {
$this->fatalError(
$stmt,
"Class constant `{$interfaceName}::{$constName}` cannot have type `{$this->typeCheckNodeToString($stmt->type)}`",
@ -3382,7 +3359,7 @@ class Preprocessor extends CompilerBase
$nullable = $property->type instanceof NullableType;
foreach ($property->props as $prop) {
$name = $this->parseIdentifier($prop->name);
if ($property->type !== null && $this->typeDeclContainsCallable($property->type)) {
if ($property->type !== null && $this->typeNodeContainsCallable($property->type)) {
$this->fatalError(
$property,
"Property `{$this->interfaceDef->getNamespacedName(false)}::\${$name}` cannot have type `{$this->typeCheckNodeToString($property->type)}`",

@ -25,6 +25,30 @@ use PhpParser\NodeAbstract;
*/
trait CompoundTypeDeclarationValidationTrait
{
/** Return whether a declared type AST accepts callable values. */
protected function typeNodeContainsCallable(?NodeAbstract $type): bool
{
if ($type === null) {
return false;
}
if ($type instanceof NullableType) {
return $this->typeNodeContainsCallable($type->type);
}
if ($type instanceof UnionType) {
foreach ($type->types as $member) {
if ($this->typeNodeContainsCallable($member)) {
return true;
}
}
return false;
}
if ($type instanceof IntersectionType) {
return false;
}
return ($type instanceof Node\Name || $type instanceof Node\Identifier)
&& strcasecmp($type->toString(), 'callable') === 0;
}
/** @var array<string, true> */
private const array PHP_INTERSECTION_FORBIDDEN_TYPES = [
'array' => true,

@ -178,6 +178,10 @@ trait NativeTypeCompatibilityTrait
int $argIndex = 0
): string
{
$arg = $this->normalizeBareFunctionCallableArgument(
$arg,
$this->argInfoAcceptsCallable($argInfo),
);
$type = $this->detectTypeOfExpr($arg->value);
$this->assertExprCanBeUsedAsValue($arg->value, 'function argument');
if ($this->isVarExpr($arg->value)) {

@ -0,0 +1,77 @@
--TEST--
Ordinary constants are case-sensitive and magic constants are case-insensitive
--FILE--
<?php
namespace ConstantCase {
const ExactName = 'exact';
trait MagicTrait
{
public function traitNameMatches(): bool
{
return __TrAiT__ === __TRAIT__;
}
}
class MagicScope
{
use MagicTrait;
public function namesMatch(): array
{
return [
__ClAsS__ === __CLASS__,
__FuNcTiOn__ === __FUNCTION__,
__MeThOd__ === __METHOD__,
__NaMeSpAcE__ === __NAMESPACE__,
];
}
}
function functionNameMatches(): bool
{
return __FuNcTiOn__ === __FUNCTION__;
}
}
namespace {
function main(): void
{
var_dump(\ConstantCase\ExactName);
var_dump(\constantcase\ExactName);
try {
var_dump(\ConstantCase\exactname);
} catch (\Error $error) {
echo "ordinary constant is case-sensitive\n";
}
var_dump(__FiLe__ === __FILE__);
var_dump(__DiR__ === __DIR__);
var_dump(is_int(__LiNe__));
var_dump(__NaMeSpAcE__ === __NAMESPACE__);
var_dump(\ConstantCase\functionNameMatches());
var_dump((new \ConstantCase\MagicScope())->namesMatch());
var_dump((new \ConstantCase\MagicScope())->traitNameMatches());
}
}
?>
--EXPECT--
string(5) "exact"
string(5) "exact"
ordinary constant is case-sensitive
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
array(4) {
[0]=>
bool(true)
[1]=>
bool(true)
[2]=>
bool(true)
[3]=>
bool(true)
}
bool(true)

@ -0,0 +1,86 @@
--TEST--
Bare function names are resolved only for callable parameters
--FILE--
<?php
namespace BareCallable\Library {
function triple(int $value): int
{
return $value * 3;
}
}
namespace BareCallable\Application {
use function BareCallable\Library\triple as imported_triple;
function apply(callable $callback, int $value): int
{
return $callback($value);
}
function local_double(int $value): int
{
return $value * 2;
}
function run(): void
{
var_dump(array_map(local_double, [1, 2, 3]));
var_dump(array_map(callback: local_double, array: [4]));
var_dump(apply(imported_triple, 4));
var_dump(array_map(\strlen, ['a', 'abcd']));
try {
$notACallableArgument = local_double;
} catch (\Error $error) {
echo "constant rules preserved\n";
}
}
}
namespace {
const cube = 'square';
function cube(int $value): int
{
return $value * $value * $value;
}
function square(int $value): int
{
return $value * $value;
}
function main(): void
{
BareCallable\Application\run();
var_dump(array_map(cube, [2, 3]));
}
}
?>
--EXPECT--
array(3) {
[0]=>
int(2)
[1]=>
int(4)
[2]=>
int(6)
}
array(1) {
[0]=>
int(8)
}
int(12)
array(2) {
[0]=>
int(1)
[1]=>
int(4)
}
constant rules preserved
array(2) {
[0]=>
int(4)
[1]=>
int(9)
}
Loading…
Cancel
Save