From 30ebc309da62bde1ecb04d9112e577f6078031e0 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Sat, 11 Jul 2026 19:38:28 +0800 Subject: [PATCH] fix(parser): enforce exact method name matching for extension functions - Removed camelCase to snake_case conversion in extension function lookups - Changed object extension methods to require exact name matches without case conversion - Updated universal method extensions to use exact spelling instead of case variants - Modified keyword extension methods to use exact __{method} naming - Simplified function candidate search to single exact match instead of multiple variants - Updated test cases to reflect exact naming requirements for all extension methods - Maintained case-insensitive lookup behavior according to PHP function semantics --- src/Parser/UniversalMethodCall.php | 179 +++++++++--------- tests/aot/keyword_extension/001.phpt | 8 +- .../universal_method/object_extension.phpt | 2 +- .../object_extension_exact_name.phpt | 65 +++++++ .../universal_method_extension.phpt | 4 +- .../universal_method_extension_chain.phpt | 8 +- 6 files changed, 161 insertions(+), 105 deletions(-) create mode 100644 tests/aot/universal_method/object_extension_exact_name.phpt diff --git a/src/Parser/UniversalMethodCall.php b/src/Parser/UniversalMethodCall.php index cac59dda..4be57f5f 100644 --- a/src/Parser/UniversalMethodCall.php +++ b/src/Parser/UniversalMethodCall.php @@ -350,33 +350,22 @@ trait UniversalMethodCall Type::BOX => 'box', ]; - protected function camelToSnake(string $name): string + protected function extensionFunctionName(string $prefix, string $method): string { - return strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $name)); + return $prefix . '_' . $method; } - /** @return list */ - protected function extensionFunctionCandidates(string $prefix, string $method): array + protected function findUserExtensionFunction(string $prefix, string $method, string $namespace = ''): ?array { - return array_values(array_unique([ - $prefix . '_' . $this->camelToSnake($method), - $prefix . '_' . $method, - ])); - } - - /** Resolve extension candidates in exactly one namespace. */ - protected function extensionFunctionDefinitions(string $prefix, string $method, string $namespace = ''): iterable - { - foreach ($this->extensionFunctionCandidates($prefix, $method) as $localName) { - $function = $this->getNativeName($localName, $namespace); - if (!$this->hasFunction($function)) { - continue; - } - $definition = $this->getFunction($function); - if ($definition->namespace === $namespace) { - yield $function => $definition; - } + $function = $this->getNativeName($this->extensionFunctionName($prefix, $method), $namespace); + if (!$this->hasFunction($function)) { + return null; + } + $definition = $this->getFunction($function); + if ($definition->namespace !== $namespace) { + return null; } + return ['name' => $function, 'definition' => $definition]; } protected const array TO_CONVERT_FN = [ @@ -419,8 +408,10 @@ trait UniversalMethodCall /** * Look up a statically compiled object extension in the object's own - * namespace. This lookup is only used by the named MethodCall AST path; - * dynamic method names and StaticCall nodes deliberately do not use it. + * namespace. The class prefix and method suffix must match the declared + * names without converting between camelCase and snake_case. This lookup + * is only used by the named MethodCall AST path; dynamic method names and + * StaticCall nodes deliberately do not use it. * Real methods are resolved before this fallback, while __call() is used * only if no valid extension exists. */ @@ -434,34 +425,36 @@ trait UniversalMethodCall $namespace = $separator === false ? '' : substr($class, 0, $separator); $shortClass = $separator === false ? $class : substr($class, $separator + 1); - foreach ($this->extensionFunctionDefinitions($shortClass, $method, $namespace) as $function => $funcDef) { - if (empty($funcDef->argInfoList)) { - continue; - } - $receiver = $funcDef->argInfoList[0]; - if ($receiver->byRef - || $receiver->type !== Type::OBJECT - || !$this->isSameClassName($receiver->declaredClass, $class)) { - continue; - } - - $totalParams = count($funcDef->argInfoList); - return [ - 'handler' => 'object_extension_fn', - 'fn' => $function, - 'return_type' => $funcDef->returnType, - 'min_args' => max(0, $funcDef->argCountRequired - 1), - 'max_args' => $funcDef->hasVariadicArg() ? -1 : $totalParams - 1, - ]; + $extension = $this->findUserExtensionFunction($shortClass, $method, $namespace); + if ($extension === null) { + return null; } - return null; + $function = $extension['name']; + $funcDef = $extension['definition']; + if (empty($funcDef->argInfoList)) { + return null; + } + $receiver = $funcDef->argInfoList[0]; + if ($receiver->byRef + || $receiver->type !== Type::OBJECT + || !$this->isSameClassName($receiver->declaredClass, $class)) { + return null; + } + + $totalParams = count($funcDef->argInfoList); + return [ + 'handler' => 'object_extension_fn', + 'fn' => $function, + 'return_type' => $funcDef->returnType, + 'min_args' => max(0, $funcDef->argCountRequired - 1), + 'max_args' => $funcDef->hasVariadicArg() ? -1 : $totalParams - 1, + ]; } /** * Look up an extension function for the given type+method. - * Extension functions may use either {typePrefix}_{snake_case_method} - * or {typePrefix}_{lowerCamelCaseMethod}. Snake case takes precedence when - * both functions exist. + * Extension functions use the exact {typePrefix}_{method} spelling. + * Lookup remains case-insensitive according to PHP function semantics. * * Checks compiled user-defined functions first, then falls back to PHP internal * functions using reflection to resolve parameter counts and return types. @@ -473,31 +466,27 @@ trait UniversalMethodCall return null; } - foreach ($this->extensionFunctionCandidates($prefix, $method) as $funcName) { - $resolvedName = $this->resolveExtensionFunctionName($funcName); - if ($resolvedName !== null) { - $funcDef = $this->getFunction($resolvedName); - if ($funcDef->namespace !== '') { - continue; - } - if (!$this->validateExtensionFirstParam($type, $funcDef)) { - continue; - } - return [ - 'handler' => 'php_fn', - 'fn' => $resolvedName, - 'receiver_pos' => 1, - 'return_type' => $funcDef->returnType, - 'min_args' => 0, - 'max_args' => -1, - ]; + $funcName = $this->extensionFunctionName($prefix, $method); + $resolvedName = $this->resolveExtensionFunctionName($funcName); + if ($resolvedName !== null) { + $funcDef = $this->getFunction($resolvedName); + if ($funcDef->namespace !== '' || !$this->validateExtensionFirstParam($type, $funcDef)) { + return null; } + return [ + 'handler' => 'php_fn', + 'fn' => $resolvedName, + 'receiver_pos' => 1, + 'return_type' => $funcDef->returnType, + 'min_args' => 0, + 'max_args' => -1, + ]; + } - if ($this->isInternalFunction($funcName)) { - $internal = $this->buildInternalExtensionMethod($type, $funcName); - if ($internal !== null) { - return $internal; - } + if ($this->isInternalFunction($funcName)) { + $internal = $this->buildInternalExtensionMethod($type, $funcName); + if ($internal !== null) { + return $internal; } } @@ -505,35 +494,37 @@ trait UniversalMethodCall } /** - * Look up a keyword extension method using __snake_case or - * __lowerCamelCase in the root namespace. Snake case takes precedence. + * Look up a keyword extension method using the exact __{method} spelling + * in the root namespace. Lookup remains case-insensitive. */ protected function findKeywordExtensionMethod(string $method): ?array { - foreach ($this->extensionFunctionDefinitions('_', $method) as $funcName => $funcDef) { - if (empty($funcDef->argInfoList)) { - continue; - } - $firstParam = $funcDef->argInfoList[0]; - if ($firstParam->type !== Type::VAR) { - continue; - } - - $totalParams = count($funcDef->argInfoList); - $minArgs = max(0, $funcDef->argCountRequired - 1); - $maxArgs = $funcDef->hasVariadicArg() ? -1 : $totalParams - 1; - - return [ - 'handler' => 'php_fn', - 'fn' => $funcName, - 'receiver_pos' => 1, - 'return_type' => $funcDef->returnType, - 'min_args' => $minArgs, - 'max_args' => $maxArgs, - ]; + $extension = $this->findUserExtensionFunction('_', $method); + if ($extension === null) { + return null; + } + $funcName = $extension['name']; + $funcDef = $extension['definition']; + if (empty($funcDef->argInfoList)) { + return null; + } + $firstParam = $funcDef->argInfoList[0]; + if ($firstParam->type !== Type::VAR) { + return null; } - return null; + $totalParams = count($funcDef->argInfoList); + $minArgs = max(0, $funcDef->argCountRequired - 1); + $maxArgs = $funcDef->hasVariadicArg() ? -1 : $totalParams - 1; + + return [ + 'handler' => 'php_fn', + 'fn' => $funcName, + 'receiver_pos' => 1, + 'return_type' => $funcDef->returnType, + 'min_args' => $minArgs, + 'max_args' => $maxArgs, + ]; } /** diff --git a/tests/aot/keyword_extension/001.phpt b/tests/aot/keyword_extension/001.phpt index a33f39a4..926aaeba 100644 --- a/tests/aot/keyword_extension/001.phpt +++ b/tests/aot/keyword_extension/001.phpt @@ -1,5 +1,5 @@ --TEST-- -keyword extension method: varDump() +keyword extension method: exact snake_case name --FILE-- varDump(); + $str->var_dump(); $int_val = 42; - $int_val->varDump(); + $int_val->var_dump(); $float_val = 3.14; - $float_val->varDump(); + $float_val->var_dump(); } ?> --EXPECT-- diff --git a/tests/aot/universal_method/object_extension.phpt b/tests/aot/universal_method/object_extension.phpt index 5cbc5cf0..4216f292 100644 --- a/tests/aot/universal_method/object_extension.phpt +++ b/tests/aot/universal_method/object_extension.phpt @@ -23,7 +23,7 @@ namespace App { } } - function User_test_method(User $user, string $suffix): string + function User_testMethod(User $user, string $suffix): string { return $user->name . $suffix . ':snake'; } diff --git a/tests/aot/universal_method/object_extension_exact_name.phpt b/tests/aot/universal_method/object_extension_exact_name.phpt new file mode 100644 index 00000000..2036e5de --- /dev/null +++ b/tests/aot/universal_method/object_extension_exact_name.phpt @@ -0,0 +1,65 @@ +--TEST-- +Object extension methods require consistent names and ignore letter case +--FILE-- +name; + } + + function UserService_profile_label(UserService $service): string + { + return 'snake:' . $service->name; + } + + function user_service_wrongName(UserService $service): string + { + return 'wrong class prefix'; + } + + function UserService_other_name(UserService $service): string + { + return 'wrong method suffix'; + } + + function UserService_CASECheck(UserService $service): string + { + return 'case-insensitive:' . $service->name; + } +} + +namespace { + function main(): void + { + $service = new \App\UserService('alice'); + + var_dump($service->displayName()); + var_dump($service->profile_label()); + var_dump($service->wrongName()); + var_dump($service->otherName()); + var_dump($service->casecheck()); + } +} +?> +--EXPECT-- +string(11) "camel:alice" +string(11) "snake:alice" +string(15) "magic:wrongName" +string(15) "magic:otherName" +string(22) "case-insensitive:alice" diff --git a/tests/aot/universal_method/universal_method_extension.phpt b/tests/aot/universal_method/universal_method_extension.phpt index 3b26edde..b50a8ed3 100644 --- a/tests/aot/universal_method/universal_method_extension.phpt +++ b/tests/aot/universal_method/universal_method_extension.phpt @@ -23,10 +23,10 @@ function str_shout(string $str): string function main() { $num = 1024 * 512; - var_dump($num->toBytes()); + var_dump($num->to_bytes()); $array = [22, 33, 44]; - var_dump($array->getFirstElement()); + var_dump($array->get_first_element()); $str = "hello"; var_dump($str->shout()); diff --git a/tests/aot/universal_method/universal_method_extension_chain.phpt b/tests/aot/universal_method/universal_method_extension_chain.phpt index cc956478..c0bf3cfe 100644 --- a/tests/aot/universal_method/universal_method_extension_chain.phpt +++ b/tests/aot/universal_method/universal_method_extension_chain.phpt @@ -36,11 +36,11 @@ function array_last(array $arr): mixed { function main() { $num = 2; - var_dump($num->toWords()->upper()); - var_dump($num->toWords()->double()->upper()); + var_dump($num->to_words()->upper()); + var_dump($num->to_words()->double()->upper()); $str = "hello"; - var_dump($str->getLength()->add(100)); + var_dump($str->get_length()->add(100)); var_dump($str->double()->length()); $str2 = "hello world"; @@ -52,4 +52,4 @@ string(3) "TWO" string(6) "TWOTWO" int(105) int(10) -string(5) "world" \ No newline at end of file +string(5) "world"