From 8e48b9d7fe33285df79bcc45b9385db5ef061e0d Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Tue, 11 Aug 2026 13:39:20 +0800 Subject: [PATCH] feat(compiler): implement scoped callback support for dynamic method calls - Add ATTR_SCOPED_CALLBACK attribute to track callback scope requirements - Introduce getCallableScopeExpr method to generate proper scope expressions - Modify call argument generation to wrap callbacks with scope information - Update dynamic method call logic to preserve declaring class scope - Replace legacy markRuntimeObjectMethodCall with markUnpackedScopedCallbackCall - Add support for scoped callbacks in array functions like array_map - Implement proper scope handling for nullsafe method calls - Generate scoped callable wrappers in placeholder generator - Add comprehensive tests for dynamic method lexical scope behavior - Add tests for internal callback functions preserving method scope --- src/CompilerBase.php | 61 ++++++++++------ src/Generator/CallArgumentGenerator.php | 59 +++++---------- src/Generator/PlaceHolderGenerator.php | 9 +-- src/Parser/MethodCallTrait.php | 25 ++++--- src/Parser/NullsafeAccessTrait.php | 12 ++- .../dynamic-method-lexical-scope.phpt | 53 ++++++++++++++ .../callable/scoped-internal-callbacks.phpt | 73 +++++++++++++++++++ 7 files changed, 209 insertions(+), 83 deletions(-) create mode 100644 tests/compiler/callable/dynamic-method-lexical-scope.phpt create mode 100644 tests/compiler/callable/scoped-internal-callbacks.phpt diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 52be7072..c976465f 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -150,6 +150,7 @@ class CompilerBase implements PropertyAccessContext protected const string ATTR_PROPERTY_FETCH_UPDATE = 'aotPropertyFetchUpdate'; protected const string ATTR_STATEMENT_EXPRESSION = 'aotStatementExpression'; protected const string ATTR_MULTI_RETURN_IMPL = 'aotMultiReturnImpl'; + protected const string ATTR_SCOPED_CALLBACK = 'aotScopedCallback'; /** * Keyword methods (to* builtins) with mandated return types. @@ -1309,6 +1310,17 @@ class CompilerBase implements PropertyAccessContext return $helper . '(' . $id . ', ' . $this->getLiteralString($className) . ')'; } + /** The declaring class controls visibility; the runtime called class does not. */ + protected function getCallableScopeExpr(): string + { + if (!$this->classDef || !$this->methodDef) { + return 'php::CallableScope(nullptr, nullptr, nullptr)'; + } + return 'php_get_callable_scope(' + . $this->getMethodPtr($this->getFullClassName(), $this->methodDef->name) + . ', this_)'; + } + protected function getCeWrapper(string $className): string { if (isset($this->context->ceWrappers[$className])) { @@ -3166,14 +3178,15 @@ class CompilerBase implements PropertyAccessContext string $className = '', bool $requiresDynamicScope = true, ): string { - if ($requiresDynamicScope) { - $this->markRuntimeObjectMethodCall(); + $callArgs = $this->parseCallArgs($args, $funcName, $className); + if ($requiresDynamicScope && $this->methodDef) { + return 'php::callScoped(' . $object . ', ' . $method . ', ' . $this->getCallableScopeExpr() . ', ' . $callArgs . ')'; } - return $object . '.call(' . $method . ', ' . $this->parseCallArgs($args, $funcName, $className) . ')'; + return $object . '.call(' . $method . ', ' . $callArgs . ')'; } - /** Mark methods that need Zend user-frame scope for dynamic visibility checks. */ - protected function markRuntimeObjectMethodCall(): void + /** Retain the legacy frame scope when an unpacked value may contain a callback. */ + protected function markUnpackedScopedCallbackCall(): void { if ($this->methodDef) { $this->methodDef->hasDynamicCall = true; @@ -3245,26 +3258,41 @@ class CompilerBase implements PropertyAccessContext } $argCount = count($args); + $matchedCallbacks = []; + $hasUnpackedArg = false; foreach ($args as $index => $arg) { if ($arg->unpack) { - // An unpacked argument can occupy any remaining callback slot. - $this->markRuntimeObjectMethodCall(); - return; + $hasUnpackedArg = true; + continue; } - foreach ($descriptors as $descriptor) { + foreach ($descriptors as $descriptorIndex => $descriptor) { [$position, $name] = $descriptor; $container = $descriptor[2] ?? false; $callbackPosition = $position < 0 ? $argCount + $position : $position; $matches = $arg->name === null ? $index === $callbackPosition : $arg->name->toString() === $name; - if ($matches && ($container || !$this->isScopeIndependentCallableExpr($arg->value))) { - $this->markRuntimeObjectMethodCall(); - return; + if ($matches) { + $matchedCallbacks[$descriptorIndex] = true; + if ($container || !$this->isScopeIndependentCallableExpr($arg->value)) { + $arg->setAttribute(self::ATTR_SCOPED_CALLBACK, $container ? 'map' : 'callable'); + } + // Some functions accept more than one callback (for + // example array_udiff_uassoc()). Mark every matching + // argument instead of stopping at the first one. + break; } } } + + if ($hasUnpackedArg && count($matchedCallbacks) !== count($descriptors)) { + // If the unpacked value itself supplies a callback, its runtime + // position is not known here. Keep the legacy frame scope only + // for this remaining case; ordinary callback arguments no longer + // mutate the executing Zend frame. + $this->markUnpackedScopedCallbackCall(); + } } private function isScopeIndependentCallableExpr(Expr $expr): bool @@ -3275,15 +3303,6 @@ class CompilerBase implements PropertyAccessContext || $this->isNull($expr); } - protected function internalMethodMayInvokeCallback(string $class, string $method): bool - { - static $methods = [ - 'reflectionmethod::invoke' => true, - 'reflectionmethod::invokeargs' => true, - ]; - return isset($methods[strtolower(ltrim($class, '\\') . '::' . $method)]); - } - protected function validateInternalNamedCallArgs(\ReflectionFunctionAbstract $ref, array $callArgs): void { $hasNamedArg = false; diff --git a/src/Generator/CallArgumentGenerator.php b/src/Generator/CallArgumentGenerator.php index 0bb15b29..70e9029f 100644 --- a/src/Generator/CallArgumentGenerator.php +++ b/src/Generator/CallArgumentGenerator.php @@ -9,9 +9,7 @@ namespace TypePhp\Generator; use TypePhp\Type; -use PhpParser\Modifiers; use PhpParser\Node; -use PhpParser\Node\ArrayItem; use PhpParser\Node\Expr; use PhpParser\NodeAbstract; use TypePhp\Entity\ArgInfo; @@ -424,6 +422,7 @@ trait CallArgumentGenerator $value = ($byRef || $this->isRefvalCall($arg->value) || $this->isToRefCall($arg->value)) ? $this->parseReferenceCallArgValue($arg) : $this->parseCallArgValue($arg); + $value = $this->wrapScopedCallbackArg($arg, $value); if ($separateNamedArgs) { $namedArgsArray = $this->ensureCallNamedArgs($namedArgsVar); $this->context->beforeStmtLines[] = $namedArgsArray . '.set(' . $this->getLiteralString($arg->name->name) . ', ' . $value . ');'; @@ -444,12 +443,17 @@ trait CallArgumentGenerator if ($byRef) { $this->assertReadonlyPropertyReferenceForbidden($arg->value, $arg, false); } - if (($funcName === 'call_user_func' || $funcName === 'call_user_func_array') && $i === 0) { - $callback = $this->parseScopedCallbackArg($arg); - if ($callback !== null) { - $this->addPositionalCallArg($callback, $arrayArgsVar, $list_args); - continue; + $scopedCallback = $arg->getAttribute(self::ATTR_SCOPED_CALLBACK); + if ($scopedCallback !== null) { + if ($this->isVarExpr($arg->value)) { + $name = $this->parseIdentifier($arg->value); + if (!$this->hasVar($name)) { + $this->fatalError($arg, 'Undefined variable `$' . $name . '`'); + } } + $value = $this->wrapScopedCallbackArg($arg, $this->parseCallArgValue($arg)); + $this->addPositionalCallArg($value, $arrayArgsVar, $list_args); + continue; } if ($this->isVarExpr($arg->value)) { $name = $this->parseIdentifier($arg->value); @@ -542,44 +546,15 @@ trait CallArgumentGenerator return $this->hasVar($name) && $this->getVarType($name) === Type::REF; } - protected function parseScopedCallbackArg(Node\Arg $arg): ?string + protected function wrapScopedCallbackArg(Node\Arg $arg, string $value): string { - $value = $arg->value; - if (!$value instanceof Expr\Array_ || count($value->items) < 2 || !$this->methodDef) { - return null; - } - - $first = $value->items[0]; - if (!$first instanceof ArrayItem || $first->key !== null || $first->unpack) { - return null; - } - if (!$first->value instanceof Node\Scalar\String_) { - return null; - } - - $scope = strtolower($first->value->value); - $classExpr = match ($scope) { - 'static' => ($this->methodDef->flags & Modifiers::STATIC) - ? $this->getLiteralString($this->getFullClassName()) - : Symbol::getCalledClass(), - 'self' => $this->getLiteralString($this->getFullClassName()), - 'parent' => $this->classDef->extends ? $this->getLiteralString($this->classDef->extends) : null, - default => null, - }; - if ($classExpr === null) { - return null; - } - - $items = [$classExpr]; - foreach (array_slice($value->items, 1) as $item) { - if (!$item instanceof ArrayItem || $item->key !== null || $item->unpack) { - return null; - } - $this->assertExprCanBeUsedAsValue($item->value, 'callback array item'); - $items[] = $this->parseIdentifier($item->value); + $mode = $arg->getAttribute(self::ATTR_SCOPED_CALLBACK); + if ($mode === null || !$this->methodDef) { + return $value; } - return $this->genArray($items); + $helper = $mode === 'map' ? 'makeScopedCallableMap' : 'makeScopedCallable'; + return 'php::' . $helper . '(' . $value . ', ' . $this->getCallableScopeExpr() . ')'; } protected function ensureCallArgs(?string &$argsVar, array &$listArgs): string diff --git a/src/Generator/PlaceHolderGenerator.php b/src/Generator/PlaceHolderGenerator.php index cf4cd90b..40e2adab 100644 --- a/src/Generator/PlaceHolderGenerator.php +++ b/src/Generator/PlaceHolderGenerator.php @@ -12,13 +12,12 @@ trait PlaceHolderGenerator { protected function genPlaceHolder(string $callable): string { - $ce = $this->getClassEntryPtr(\Closure::class); - $fn = $ce . ', ' . $this->getFuncPtr('Closure::fromCallable'); - $tmpVar = $this->genTmpVarName(); if ($this->classDef) { - $this->context->beforeStmtLines[] = "auto {$tmpVar} = php_switch_scope(this_);"; - $this->context->afterStmtLines[] = "php_restore_scope({$tmpVar});"; + return 'php::makeScopedCallable(' . $callable . ', ' . $this->getCallableScopeExpr() . ')'; } + + $ce = $this->getClassEntryPtr(\Closure::class); + $fn = $ce . ', ' . $this->getFuncPtr('Closure::fromCallable'); return 'php::call(' . $fn . ', {' . $callable . '})'; } } diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index 8c4c74a8..ba1384b6 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -24,6 +24,7 @@ trait MethodCallTrait string $class, string $method, bool $magicMethod = false, + bool $currentObject = false, ): bool { if ($method === '' || $magicMethod) { return true; @@ -51,9 +52,11 @@ trait MethodCallTrait } } - // A named method with no non-public declaration is resolved as a - // normal public call and must not change an unrelated Zend frame. - return false; + // An unresolved method on `$this` may be declared by a runtime + // subclass. The compiler itself exercises this when a method + // inherited from CompilerBase calls a protected helper supplied by + // Translator. Calls on other receivers retain the public fast path. + return $currentObject && $this->methodDef !== null; } protected function isOverrideMethod(string $fullMethodName): bool @@ -525,15 +528,15 @@ trait MethodCallTrait $funcName = ''; } - $requiresDynamicScope = $this->runtimeMethodRequiresDynamicScope($class, $funcName, $magicMethod); + $requiresDynamicScope = $this->runtimeMethodRequiresDynamicScope( + $class, + $funcName, + $magicMethod, + $this->isVarExpr($expr->var) && $this->parseIdentifier($expr->var) === 'this_', + ); if ($class && $funcName && !$magicMethod) { if ($this->isInternalClass($class)) { $methodPtr = $this->getMethodPtr($class, $funcName); - // A small set of internal invokers synchronously executes a - // callback and therefore still needs the caller's scope. - if ($this->internalMethodMayInvokeCallback($class, $funcName)) { - $requiresDynamicScope = true; - } } else { $methodPtr = $method; } @@ -542,8 +545,8 @@ trait MethodCallTrait } if (empty($expr->args)) { - if ($requiresDynamicScope) { - $this->markRuntimeObjectMethodCall(); + if ($requiresDynamicScope && $this->methodDef) { + return 'php::callScoped(' . $object . ', ' . $methodPtr . ', ' . $this->getCallableScopeExpr() . ')'; } return $object . '.call(' . $methodPtr . ')'; } diff --git a/src/Parser/NullsafeAccessTrait.php b/src/Parser/NullsafeAccessTrait.php index a1587f1d..1920f2bd 100644 --- a/src/Parser/NullsafeAccessTrait.php +++ b/src/Parser/NullsafeAccessTrait.php @@ -91,9 +91,7 @@ trait NullsafeAccessTrait ? $this->parseIdentifier($item[4]->name) : ''; $receiverClass = $this->detectClassOfExpr($item[4]->var); - if ($this->runtimeMethodRequiresDynamicScope($receiverClass, $methodName)) { - $this->markRuntimeObjectMethodCall(); - } + $requiresDynamicScope = $this->runtimeMethodRequiresDynamicScope($receiverClass, $methodName); $beforeStmtCount = count($this->context->beforeStmtLines); $afterStmtCount = count($this->context->afterStmtLines); $args = $this->parseCallArgs($item[2]); @@ -104,7 +102,13 @@ trait NullsafeAccessTrait if ($argBeforeStmts) { $code .= $this->getIndent() . implode(PHP_EOL . $this->getIndent(), $argBeforeStmts) . PHP_EOL; } - $code .= $this->getIndent() . "{$tmpVar} = {$object}.call({$item[1]}, {$args});"; + if ($requiresDynamicScope && $this->methodDef) { + $code .= $this->getIndent() + . "{$tmpVar} = php::callScoped({$object}, {$item[1]}, " + . $this->getCallableScopeExpr() . ", {$args});"; + } else { + $code .= $this->getIndent() . "{$tmpVar} = {$object}.call({$item[1]}, {$args});"; + } if ($argAfterStmts) { $code .= $this->getIndent() . implode(PHP_EOL . $this->getIndent(), $argAfterStmts) . PHP_EOL; } diff --git a/tests/compiler/callable/dynamic-method-lexical-scope.phpt b/tests/compiler/callable/dynamic-method-lexical-scope.phpt new file mode 100644 index 00000000..afe741eb --- /dev/null +++ b/tests/compiler/callable/dynamic-method-lexical-scope.phpt @@ -0,0 +1,53 @@ +--TEST-- +Dynamic method calls use the declaring class scope +--FILE-- +$method(); + } + + public function callSubclassHook(): string + { + return $this->subclassHook(); + } + + public function callPrivateCallback(): array + { + return array_map([$this, 'privateValue'], [null]); + } +} + +class DynamicScopeChild extends DynamicScopeBase +{ + protected function subclassHook(): string + { + return 'child-protected'; + } +} + +function main(): void +{ + $object = new DynamicScopeChild(); + var_dump($object->callPrivate()); + var_dump($object->callSubclassHook()); + var_dump($object->callPrivateCallback()); +} + +?> +--EXPECT-- +string(12) "base-private" +string(15) "child-protected" +array(1) { + [0]=> + string(12) "base-private" +} diff --git a/tests/compiler/callable/scoped-internal-callbacks.phpt b/tests/compiler/callable/scoped-internal-callbacks.phpt new file mode 100644 index 00000000..dfb02144 --- /dev/null +++ b/tests/compiler/callable/scoped-internal-callbacks.phpt @@ -0,0 +1,73 @@ +--TEST-- +Internal callback functions preserve the declaring method scope +--FILE-- + $right; + } + + private static function compareKey(mixed $left, mixed $right): int + { + return $left <=> $right; + } + + public function run(): void + { + var_dump(array_map([$this, 'triple'], [1, 2, 3])); + var_dump(array_map(array: [4, 5], callback: [$this, 'triple'])); + var_dump(preg_replace_callback_array([ + '/a+/' => [self::class, 'replace'], + ], 'caaab')); + var_dump(array_udiff_uassoc( + ['a' => 1, 'b' => 2], + ['a' => 1, 'c' => 2], + [self::class, 'compareValue'], + [self::class, 'compareKey'], + )); + + $unpacked = [[$this, 'triple'], 6]; + var_dump(call_user_func(...$unpacked)); + } +} + +function main(): void +{ + (new ScopedInternalCallbacks())->run(); +} + +?> +--EXPECT-- +array(3) { + [0]=> + int(3) + [1]=> + int(6) + [2]=> + int(9) +} +array(2) { + [0]=> + int(12) + [1]=> + int(15) +} +string(5) "cAAAb" +array(1) { + ["b"]=> + int(2) +} +int(18)