From 9d68d2803cf0c2d4e4e35c5c712771a736aeb784 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Wed, 16 Sep 2026 12:07:51 +0800 Subject: [PATCH] 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 --- examples/cases/fn-name.php | 15 +++ src/Entity/ArgInfo.php | 2 + src/Generator/CallArgumentGenerator.php | 106 ++++++++++++++++++ src/NativeClass/NativeClassSupportTrait.php | 1 + src/Parser/FunctionCallTrait.php | 57 ++++++++++ src/Preprocessor.php | 35 +----- ...CompoundTypeDeclarationValidationTrait.php | 24 ++++ .../NativeTypeCompatibilityTrait.php | 4 + tests/compiler/basic/constant-name-case.phpt | 77 +++++++++++++ .../callable/bare-function-argument.phpt | 86 ++++++++++++++ 10 files changed, 378 insertions(+), 29 deletions(-) create mode 100644 examples/cases/fn-name.php create mode 100644 tests/compiler/basic/constant-name-case.phpt create mode 100644 tests/compiler/callable/bare-function-argument.phpt diff --git a/examples/cases/fn-name.php b/examples/cases/fn-name.php new file mode 100644 index 00000000..f0cd82e2 --- /dev/null +++ b/examples/cases/fn-name.php @@ -0,0 +1,15 @@ +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) { diff --git a/src/NativeClass/NativeClassSupportTrait.php b/src/NativeClass/NativeClassSupportTrait.php index ac30cbed..69910e6b 100644 --- a/src/NativeClass/NativeClassSupportTrait.php +++ b/src/NativeClass/NativeClassSupportTrait.php @@ -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 diff --git a/src/Parser/FunctionCallTrait.php b/src/Parser/FunctionCallTrait.php index 2ed677cb..990dae6d 100644 --- a/src/Parser/FunctionCallTrait.php +++ b/src/Parser/FunctionCallTrait.php @@ -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. diff --git a/src/Preprocessor.php b/src/Preprocessor.php index af5f4e5c..a1c47585 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -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)}`", diff --git a/src/TypeSystem/CompoundTypeDeclarationValidationTrait.php b/src/TypeSystem/CompoundTypeDeclarationValidationTrait.php index b90f5d69..f475eb1a 100644 --- a/src/TypeSystem/CompoundTypeDeclarationValidationTrait.php +++ b/src/TypeSystem/CompoundTypeDeclarationValidationTrait.php @@ -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 */ private const array PHP_INTERSECTION_FORBIDDEN_TYPES = [ 'array' => true, diff --git a/src/TypeSystem/NativeTypeCompatibilityTrait.php b/src/TypeSystem/NativeTypeCompatibilityTrait.php index e030646d..bf3f37cf 100644 --- a/src/TypeSystem/NativeTypeCompatibilityTrait.php +++ b/src/TypeSystem/NativeTypeCompatibilityTrait.php @@ -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)) { diff --git a/tests/compiler/basic/constant-name-case.phpt b/tests/compiler/basic/constant-name-case.phpt new file mode 100644 index 00000000..e399c13f --- /dev/null +++ b/tests/compiler/basic/constant-name-case.phpt @@ -0,0 +1,77 @@ +--TEST-- +Ordinary constants are case-sensitive and magic constants are case-insensitive +--FILE-- +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) diff --git a/tests/compiler/callable/bare-function-argument.phpt b/tests/compiler/callable/bare-function-argument.phpt new file mode 100644 index 00000000..e929ade6 --- /dev/null +++ b/tests/compiler/callable/bare-function-argument.phpt @@ -0,0 +1,86 @@ +--TEST-- +Bare function names are resolved only for callable parameters +--FILE-- + +--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) +}