Skip to content

Support named arguments in Closure::bind() - #6679

Open
zonuexe wants to merge 4 commits into
phpstan:2.3.xfrom
zonuexe:fix/closure-bind-named-arguments
Open

zonuexe wants to merge 4 commits into
phpstan:2.3.xfrom
zonuexe:fix/closure-bind-named-arguments

Conversation

@zonuexe

@zonuexe zonuexe commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

This makes Closure::bind() analysed the same however its arguments are written: positional, named in any order, or mixed. The bound $this and class scope were only right when the closure, $newThis and $newScope sat at positions 0, 1 and 2. The @param-closure-this check only applied when the closure was at position 0 and a second argument was present. This PR is split out of #4081 so it can be reviewed and merged on its own; it contains nothing about self/parent/static resolution.

Changes

  • StaticCallHandler: the bind-scope factory normalizes the call before reading $newThis and $newScope.
  • ClosureBindArgVisitor: finds the closure and $newThis arguments by position or by name, and marks the closure argument only when both are passed. A call without $newThis, such as Closure::bind($c, newScope: X::class), is no longer marked. A duplicated argument now resolves like in ArgumentsNormalizer::reorderArgs(): the first named one wins.
  • ParametersAcceptorSelector: when the first argument is named, looks up the argument the visitor marked instead of reading $args[0]. Only this rule-side path, which sees the call's original arguments, needed the lookup.
  • Each change is mirrored in its turbo-ext C++ twin, followed by the expected-version bump.

Cause

The factory captured the call as written and read getArgs()[1] and [2]. processArgs() already walks the normalized call, so the closure got the factory in any order. With named arguments, though, the factory read the wrong arguments: $this became mixed, object or a class-name string, and the scope was lost. Members of the scope class were then reported as inaccessible, while a closure bound to another scope could slip through unreported. Separately, the visitor marked the first argument of any Closure::bind() call with more than one argument as the bound closure. In Closure::bind(newThis: $c, closure: ...), where $c has a @param-closure-this type, $c itself was then reported as the wrong type for $newThis. And ParametersAcceptorSelector only checked $args[0], so the override never applied when the closure was passed by name after another argument.

When normalization fails (a missing $newThis, or an unknown name before a skipped parameter), the factory still reads the call as written, as before; those calls are invalid, and the rules report argument.missing / argument.unknown for them.

Tests

  • NSRT closure-bind-named-arguments.php: $this (PHPDoc and native) and a private property inside the closure, for every argument order.
  • ClassConstantRuleTest, AccessPropertiesRuleTest, CallMethodsRuleTest: protected and private members of the scope class are accessible in every order. A closure bound to another scope, with the closure written before $newThis, still gets the access reported; the base missed it.
  • CallStaticMethodsRuleTest: the @param-closure-this override with named arguments, and no false positive when the closure-this variable is passed as $newThis.
  • turbo-ext: a walk-trace fixture; parser-visitors snippets covering every named order, duplicates, unpacked and nested binds; and a scope-family differential of ParametersAcceptorSelector::selectFromArgs() over named Closure::bind() calls.

zonuexe and others added 4 commits October 6, 2026 01:13
The bind-scope factory captured the call as written and read $newThis and
$newScope from getArgs()[1] and [2]. processArgs() already walks the
normalized call, so the closure gets the factory in any argument order, but
with named arguments the factory picked the wrong arguments: $this became
mixed, object or a class-name string, and the class scope was lost, so
accessing the scope class's private and protected members was reported.

Normalize the call inside the factory as well. The reordered Args keep the
same value expressions, so their stored results are still found.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ClosureBindArgVisitor marked the first argument of any Closure::bind() call
with more than one argument as the bound closure. With named arguments the
first argument can be $newThis - Closure::bind(newThis: $c, closure: ...) -
and when it was a variable with a @param-closure-this type,
ParametersAcceptorSelector narrowed the $newThis parameter to that type and
reported the variable passed for it.

Find the closure and $newThis arguments by position or by name, and mark the
closure argument only when both are passed. A duplicated named argument does
not replace the one already found, like in ArgumentsNormalizer::reorderArgs().

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…amed

ParametersAcceptorSelector narrowed the $newThis parameter of Closure::bind()
to a closure variable's @param-closure-this type only when the closure was
the first argument. With named arguments the closure can be anywhere -
Closure::bind(newThis: $o, closure: $c) - and ClosureBindArgVisitor marks it
wherever it is, so look the marked argument up when the first one is named.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant