Skip to content

Commit 9d68d28

Browse files
committed
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
1 parent 3bb73de commit 9d68d28

10 files changed

Lines changed: 378 additions & 29 deletions

File tree

examples/cases/fn-name.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
function cube($n)
4+
{
5+
return ($n * $n * $n);
6+
}
7+
8+
function main()
9+
{
10+
$a = [1, 2, 3, 4, 5];
11+
$b = array_map(cube, $a);
12+
print_r($b);
13+
}
14+
15+

src/Entity/ArgInfo.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ class ArgInfo
4747
public bool $nullable = false;
4848
public bool $undeclared = false;
4949
public bool $explicitMixed = false;
50+
/** The declared parameter type accepts callable values. */
51+
public bool $acceptsCallable = false;
5052
public bool $property = false;
5153
/** This parameter binding and any referenced object are read-only in the callee. */
5254
public bool $immutable = false;

src/Generator/CallArgumentGenerator.php

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,101 @@ trait CallArgumentGenerator
2222
/** Guard against a broken lowering path producing an unbounded call. */
2323
private const CALL_ARGUMENT_LIMIT = 65_536;
2424

25+
private function argInfoAcceptsCallable(?ArgInfo $argInfo): bool
26+
{
27+
return $argInfo?->acceptsCallable ?? false;
28+
}
29+
30+
private function reflectionTypeAcceptsCallable(?\ReflectionType $type): bool
31+
{
32+
if ($type instanceof \ReflectionNamedType) {
33+
return strcasecmp($type->getName(), 'callable') === 0;
34+
}
35+
if ($type instanceof \ReflectionUnionType) {
36+
foreach ($type->getTypes() as $member) {
37+
if ($this->reflectionTypeAcceptsCallable($member)) {
38+
return true;
39+
}
40+
}
41+
}
42+
return false;
43+
}
44+
45+
private function callArgumentAcceptsCallable(
46+
string $funcName,
47+
string $className,
48+
int $index,
49+
?string $argName,
50+
): bool {
51+
if ($funcName === '') {
52+
return false;
53+
}
54+
55+
$argInfo = $argName === null
56+
? $this->getAotCallArgInfo($funcName, $className, $index)
57+
: $this->getAotCallArgInfoByName($funcName, $className, $argName);
58+
if ($argInfo !== null) {
59+
return $this->argInfoAcceptsCallable($argInfo);
60+
}
61+
62+
if ($className !== '') {
63+
if ($className === self::DYNAMIC_CALLED_CLASS) {
64+
return false;
65+
}
66+
$class = Reflection::getClass($className);
67+
if ($class === null || !$class->hasMethod($funcName)) {
68+
return false;
69+
}
70+
$parameters = $class->getMethod($funcName)->getParameters();
71+
} else {
72+
$function = Reflection::getFunction($funcName);
73+
if ($function === null) {
74+
return false;
75+
}
76+
$parameters = $function->getParameters();
77+
}
78+
79+
$variadic = null;
80+
foreach ($parameters as $parameterIndex => $parameter) {
81+
if ($parameter->isVariadic()) {
82+
$variadic = $parameter;
83+
}
84+
if (($argName !== null && $parameter->getName() === $argName)
85+
|| ($argName === null && $parameterIndex === $index)
86+
) {
87+
return $this->reflectionTypeAcceptsCallable($parameter->getType());
88+
}
89+
}
90+
91+
return $argName === null
92+
&& $variadic !== null
93+
&& $this->reflectionTypeAcceptsCallable($variadic->getType());
94+
}
95+
96+
private function normalizeBareFunctionCallableArgument(
97+
Node\Arg $arg,
98+
bool $acceptsCallable,
99+
): Node\Arg {
100+
if (!$acceptsCallable
101+
|| $arg->unpack
102+
|| !$arg->value instanceof Expr\ConstFetch
103+
) {
104+
return $arg;
105+
}
106+
107+
$function = $this->resolveBareCallableFunctionName($arg->value);
108+
if ($function === null) {
109+
return $arg;
110+
}
111+
112+
$normalized = clone $arg;
113+
$normalized->value = new Node\Scalar\String_(
114+
$function,
115+
$arg->value->getAttributes(),
116+
);
117+
return $normalized;
118+
}
119+
25120
protected function parseNativeCallArgs(
26121
array $callArgs,
27122
string $nativeFunc,
@@ -474,6 +569,17 @@ protected function parseCallArgs(
474569
if ($this->isPlaceholderExpr($arg)) {
475570
throw new PlaceHolder();
476571
}
572+
if (!$arg->unpack && $arg->value instanceof Expr\ConstFetch) {
573+
$arg = $this->normalizeBareFunctionCallableArgument(
574+
$arg,
575+
$this->callArgumentAcceptsCallable(
576+
$funcName,
577+
$className,
578+
$i,
579+
$arg->name?->name,
580+
),
581+
);
582+
}
477583
$this->validateTypedArrayDynamicArgument($arg, $funcName, $className, $i);
478584
if ($arg->unpack) {
479585
if ($hasNamedArg) {

src/NativeClass/NativeClassSupportTrait.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ private function applyReflectedParameterType(
335335
}
336336
$node = $this->reflectionTypeToNode($type, $declaringClass);
337337
$argument->typeStr = $this->typeCheckNodeToString($node);
338+
$argument->acceptsCallable = $this->typeNodeContainsCallable($node);
338339
if ($node instanceof Node\NullableType
339340
|| $node instanceof Node\UnionType
340341
|| $node instanceof Node\IntersectionType

src/Parser/FunctionCallTrait.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,63 @@
2020

2121
trait FunctionCallTrait
2222
{
23+
/**
24+
* Resolve a bare identifier used specifically in a callable parameter.
25+
* Constants retain PHP precedence; only an otherwise unresolved constant
26+
* fetch may be treated as a TypePHP function symbol.
27+
*/
28+
private function resolveBareCallableFunctionName(Expr\ConstFetch $expr): ?string
29+
{
30+
if (!$expr->name instanceof Node\Name) {
31+
return null;
32+
}
33+
34+
$source = ltrim($this->parseIdentifier($expr->name), '\\');
35+
if ($source === ''
36+
|| str_contains($source, '::')
37+
|| in_array(strtolower($source), ['null', 'true', 'false'], true)
38+
|| isset($this->useConstants[$source])
39+
) {
40+
return null;
41+
}
42+
43+
[$constantName, $runtimeNamespaceFallback] = $this->resolveConstantFetchName(
44+
$expr,
45+
$source,
46+
);
47+
if ($this->hasConstant($constantName)
48+
|| $constantName === 'PHP_EOL'
49+
|| $this->isInternalConstant($constantName)
50+
) {
51+
return null;
52+
}
53+
if ($runtimeNamespaceFallback
54+
&& ($this->hasConstant($source)
55+
|| $source === 'PHP_EOL'
56+
|| $this->isInternalConstant($source))
57+
) {
58+
return null;
59+
}
60+
61+
$target = $this->resolveStaticFunctionCallTarget($expr->name);
62+
$nativeFunction = $this->findNativeFunction($target['nativeLookup']);
63+
if ($nativeFunction !== false) {
64+
$function = $this->getFunction($nativeFunction);
65+
if ($this->functionRequiresNativeAbi($function)) {
66+
$this->fatalError($expr, 'Native ABI functions cannot be used as callable arguments');
67+
}
68+
return $function->getNamespacedName();
69+
}
70+
71+
if (($target['definitelyGlobal'] || $target['namespacedFallback'])
72+
&& $this->isInternalFunction($target['lower'])
73+
) {
74+
return $target['lower'];
75+
}
76+
77+
return null;
78+
}
79+
2380
/**
2481
* Resolve the one static function name used by every call path. Function
2582
* imports and function names are case-insensitive, unlike constant names.

src/Preprocessor.php

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,6 +1247,8 @@ protected function parseParameterType(Node\Param $param, ArgInfo $argInfo, strin
12471247
$argInfo->nullable = true;
12481248
}
12491249
$argInfo->undeclared = $param->type === null;
1250+
$argInfo->acceptsCallable = $param->type !== null
1251+
&& $this->typeNodeContainsCallable($param->type);
12501252
if (
12511253
$param->type !== null
12521254
&& !$param->type instanceof NullableType
@@ -2175,7 +2177,7 @@ protected function parseClassConstDef(Node\Stmt\ClassConst $v): void
21752177
[$declaredType, $class] = $v->type
21762178
? $this->resolveTypeDecl($v->type, self::DECL_TYPE_OF_CONST)
21772179
: [null, ''];
2178-
if ($v->type !== null && $this->typeDeclContainsCallable($v->type)) {
2180+
if ($v->type !== null && $this->typeNodeContainsCallable($v->type)) {
21792181
$constName = $v->consts !== [] ? $this->parseIdentifier($v->consts[0]->name) : '';
21802182
$this->fatalError(
21812183
$v,
@@ -2343,7 +2345,7 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ
23432345
// `callable` is a runtime-context type (a string or array may or may
23442346
// not be callable depending on scope), so Zend forbids it in property
23452347
// types entirely - bare, nullable, or as a union member.
2346-
if ($typeNode !== null && $this->typeDeclContainsCallable($typeNode)) {
2348+
if ($typeNode !== null && $this->typeNodeContainsCallable($typeNode)) {
23472349
$this->fatalError(
23482350
$errorNode,
23492351
"Property `{$this->classDef->getNamespacedName(false)}::\${$name}` cannot have type `{$this->typeCheckNodeToString($typeNode)}`",
@@ -2419,31 +2421,6 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ
24192421
return $propDef;
24202422
}
24212423

2422-
/**
2423-
* Whether a declared type mentions `callable` outside an intersection.
2424-
* Zend forbids callable in property and class-constant types; callable
2425-
* inside an intersection is rejected first, with its own diagnostic, by
2426-
* the common declaration validation in parseTypeDecl().
2427-
*/
2428-
private function typeDeclContainsCallable(NodeAbstract $typeNode): bool
2429-
{
2430-
if ($typeNode instanceof NullableType) {
2431-
return $this->typeDeclContainsCallable($typeNode->type);
2432-
}
2433-
if ($typeNode instanceof UnionType) {
2434-
foreach ($typeNode->types as $member) {
2435-
if ($this->typeDeclContainsCallable($member)) {
2436-
return true;
2437-
}
2438-
}
2439-
return false;
2440-
}
2441-
if ($typeNode instanceof IntersectionType) {
2442-
return false;
2443-
}
2444-
return strtolower($this->parseIdentifier($typeNode)) === 'callable';
2445-
}
2446-
24472424
private function validateAsymmetricPropertyDeclaration(
24482425
string $name,
24492426
int $flags,
@@ -3242,7 +3219,7 @@ protected function parseInterface(Node\Stmt\Interface_ $v): void
32423219
if ($stmt->type) {
32433220
$this->validateClassScopeTypeKeywords($stmt->type, true, false);
32443221
[$type, $class] = $this->resolveTypeDecl($stmt->type, self::DECL_TYPE_OF_CONST);
3245-
if ($this->typeDeclContainsCallable($stmt->type)) {
3222+
if ($this->typeNodeContainsCallable($stmt->type)) {
32463223
$this->fatalError(
32473224
$stmt,
32483225
"Class constant `{$interfaceName}::{$constName}` cannot have type `{$this->typeCheckNodeToString($stmt->type)}`",
@@ -3382,7 +3359,7 @@ private function prepareInterfaceProperty(Node\Stmt\Property $property): void
33823359
$nullable = $property->type instanceof NullableType;
33833360
foreach ($property->props as $prop) {
33843361
$name = $this->parseIdentifier($prop->name);
3385-
if ($property->type !== null && $this->typeDeclContainsCallable($property->type)) {
3362+
if ($property->type !== null && $this->typeNodeContainsCallable($property->type)) {
33863363
$this->fatalError(
33873364
$property,
33883365
"Property `{$this->interfaceDef->getNamespacedName(false)}::\${$name}` cannot have type `{$this->typeCheckNodeToString($property->type)}`",

src/TypeSystem/CompoundTypeDeclarationValidationTrait.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,30 @@
2525
*/
2626
trait CompoundTypeDeclarationValidationTrait
2727
{
28+
/** Return whether a declared type AST accepts callable values. */
29+
protected function typeNodeContainsCallable(?NodeAbstract $type): bool
30+
{
31+
if ($type === null) {
32+
return false;
33+
}
34+
if ($type instanceof NullableType) {
35+
return $this->typeNodeContainsCallable($type->type);
36+
}
37+
if ($type instanceof UnionType) {
38+
foreach ($type->types as $member) {
39+
if ($this->typeNodeContainsCallable($member)) {
40+
return true;
41+
}
42+
}
43+
return false;
44+
}
45+
if ($type instanceof IntersectionType) {
46+
return false;
47+
}
48+
return ($type instanceof Node\Name || $type instanceof Node\Identifier)
49+
&& strcasecmp($type->toString(), 'callable') === 0;
50+
}
51+
2852
/** @var array<string, true> */
2953
private const array PHP_INTERSECTION_FORBIDDEN_TYPES = [
3054
'array' => true,

src/TypeSystem/NativeTypeCompatibilityTrait.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,10 @@ protected function getTypeConvertedArg(
178178
int $argIndex = 0
179179
): string
180180
{
181+
$arg = $this->normalizeBareFunctionCallableArgument(
182+
$arg,
183+
$this->argInfoAcceptsCallable($argInfo),
184+
);
181185
$type = $this->detectTypeOfExpr($arg->value);
182186
$this->assertExprCanBeUsedAsValue($arg->value, 'function argument');
183187
if ($this->isVarExpr($arg->value)) {

0 commit comments

Comments
 (0)