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
pull/48/head
韩天峰 2 weeks ago
parent f5ca997e65
commit 8e48b9d7fe
  1. 61
      src/CompilerBase.php
  2. 59
      src/Generator/CallArgumentGenerator.php
  3. 9
      src/Generator/PlaceHolderGenerator.php
  4. 25
      src/Parser/MethodCallTrait.php
  5. 12
      src/Parser/NullsafeAccessTrait.php
  6. 53
      tests/compiler/callable/dynamic-method-lexical-scope.phpt
  7. 73
      tests/compiler/callable/scoped-internal-callbacks.phpt

@ -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;

@ -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

@ -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 . '})';
}
}

@ -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 . ')';
}

@ -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;
}

@ -0,0 +1,53 @@
--TEST--
Dynamic method calls use the declaring class scope
--FILE--
<?php
class DynamicScopeBase
{
private function privateValue(): string
{
return 'base-private';
}
public function callPrivate(): string
{
$method = 'privateValue';
return $this->$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"
}

@ -0,0 +1,73 @@
--TEST--
Internal callback functions preserve the declaring method scope
--FILE--
<?php
class ScopedInternalCallbacks
{
private function triple(int $value): int
{
return $value * 3;
}
private static function replace(array $match): string
{
return strtoupper($match[0]);
}
private static function compareValue(mixed $left, mixed $right): int
{
return $left <=> $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)
Loading…
Cancel
Save