Skip to content

Resolve self/parent/static in Closure::bind() scope - #4081

Open
zonuexe wants to merge 3 commits into
phpstan:2.2.xfrom
zonuexe:fix/bind-class-scope
Open

Resolve self/parent/static in Closure::bind() scope#4081
zonuexe wants to merge 3 commits into
phpstan:2.2.xfrom
zonuexe:fix/bind-class-scope

Conversation

@zonuexe

@zonuexe zonuexe commented Jul 4, 2025

Copy link
Copy Markdown
Contributor

Resolves self / parent / static against the Closure::bind() scope (the 3rd argument) inside the bound closure body, for both type inference and the related rules.

Rebased onto 2.2.x and reworked: the original inline patch in MutatingScope::getType() no longer exists after expression handling was split into ExprHandler services, so the logic now lives where each expression/rule resolves its class scope.

What works now

Inside Closure::bind(fn () => …, $newThis, Scope::class):

  • Class constantsself::CONST, parent::CONST resolve against the bound scope.
  • ::classself::class, parent::class.
  • Static method callsself::method(), parent::method() return types.
  • Static property accessself::$prop, parent::$prop types.
  • Instantiationnew self, new parent, new static.
  • Rules no longer emit Using self outside of class scope. etc. and check accessibility against the bound class: ClassConstantRule, StaticMethodCallCheck, AccessStaticPropertiesCheck, InstantiationRule.

This works even when Closure::bind() is called outside a class context (the closure body type is inferred in the enclosing scope, where the closure-bind scope classes are otherwise unavailable — hence the class-name nodes are annotated by ClosureBindArgVisitor).

Approach

  • ClosureBindArgVisitor annotates self/parent/static class-name nodes inside the closure body only (1st argument) with the bind scope argument. The $newThis/$newScope arguments are intentionally not rescoped — this keeps Closure::bind(self::cond() ? … : …, …) correct (see #6319).
  • ClosureBindScopeResolver (new) resolves that annotation to a ClassReflection, shared by ClassConstFetchHandler and the rules.
  • MutatingScope::resolveName() / resolveTypeByName() honour the annotation so static calls / properties / new resolve without touching each handler.

Issues

Not included (follow-ups)

zonuexe added a commit to zonuexe/phpstan-src that referenced this pull request Jul 8, 2026
Annotate special class-name nodes (self/parent/static) that appear inside a
Closure::bind() call with the bind scope argument via ClosureBindArgVisitor, and
resolve the class constant type against that bound class in ClassConstFetchHandler.

Reconstruction of PR phpstan#4081 (closure-bind-scope) rebased onto 2.2.x: the original
MutatingScope::getType() ClassConstFetch branch no longer exists after expression
handling was split into ExprHandler services.
zonuexe added a commit to zonuexe/phpstan-src that referenced this pull request Jul 8, 2026
… properties and instantiation

Extend the Closure::bind() scope binding beyond class constants to the natural
remaining surface:

- MutatingScope::resolveName()/resolveTypeByName() now honour the bind scope
  annotated on self/parent/static class-name nodes, so static method-call return
  types, static property types and `new self/parent/static` resolve against the
  bound class even outside a class context (the closure body's type is inferred in
  the enclosing scope where the closure-bind scope classes are otherwise absent).
- StaticMethodCallCheck, AccessStaticPropertiesCheck and InstantiationRule no
  longer report "outside of class scope" for self/parent/static inside a bound
  closure, resolving accessibility against the bound class.
- ClosureBindArgVisitor now only rescopes the closure body (1st argument), not the
  $newThis/$newScope arguments, avoiding a self-referential scope annotation on a
  `self::class` scope argument.

Continues the reconstruction of PR phpstan#4081 on 2.2.x.
@zonuexe
zonuexe force-pushed the fix/bind-class-scope branch from b3c42c8 to 1fb897e Compare July 8, 2026 12:46
@zonuexe
zonuexe changed the base branch from 2.1.x to 2.2.x July 8, 2026 12:46
@zonuexe zonuexe changed the title Closure::bind() scope binding improvements Resolve self/parent/static in Closure::bind() scope Jul 8, 2026
@zonuexe
zonuexe marked this pull request as ready for review July 8, 2026 12:49
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

@zonuexe
zonuexe marked this pull request as draft July 14, 2026 03:23
zonuexe added a commit to zonuexe/phpstan-src that referenced this pull request Jul 14, 2026
Annotate special class-name nodes (self/parent/static) that appear inside a
Closure::bind() call with the bind scope argument via ClosureBindArgVisitor, and
resolve the class constant type against that bound class in ClassConstFetchHandler.

Reconstruction of PR phpstan#4081 (closure-bind-scope) rebased onto 2.2.x: the original
MutatingScope::getType() ClassConstFetch branch no longer exists after expression
handling was split into ExprHandler services.
zonuexe added a commit to zonuexe/phpstan-src that referenced this pull request Jul 14, 2026
… properties and instantiation

Extend the Closure::bind() scope binding beyond class constants to the natural
remaining surface:

- MutatingScope::resolveName()/resolveTypeByName() now honour the bind scope
  annotated on self/parent/static class-name nodes, so static method-call return
  types, static property types and `new self/parent/static` resolve against the
  bound class even outside a class context (the closure body's type is inferred in
  the enclosing scope where the closure-bind scope classes are otherwise absent).
- StaticMethodCallCheck, AccessStaticPropertiesCheck and InstantiationRule no
  longer report "outside of class scope" for self/parent/static inside a bound
  closure, resolving accessibility against the bound class.
- ClosureBindArgVisitor now only rescopes the closure body (1st argument), not the
  $newThis/$newScope arguments, avoiding a self-referential scope annotation on a
  `self::class` scope argument.

Continues the reconstruction of PR phpstan#4081 on 2.2.x.
@zonuexe
zonuexe force-pushed the fix/bind-class-scope branch from 1fb897e to 8d9f662 Compare July 14, 2026 03:29
zonuexe added a commit to zonuexe/phpstan-src that referenced this pull request Jul 14, 2026
Annotate special class-name nodes (self/parent/static) that appear inside a
Closure::bind() call with the bind scope argument via ClosureBindArgVisitor, and
resolve the class constant type against that bound class in ClassConstFetchHandler.

Reconstruction of PR phpstan#4081 (closure-bind-scope) rebased onto 2.2.x: the original
MutatingScope::getType() ClassConstFetch branch no longer exists after expression
handling was split into ExprHandler services.
zonuexe added a commit to zonuexe/phpstan-src that referenced this pull request Jul 14, 2026
… properties and instantiation

Extend the Closure::bind() scope binding beyond class constants to the natural
remaining surface:

- MutatingScope::resolveName()/resolveTypeByName() now honour the bind scope
  annotated on self/parent/static class-name nodes, so static method-call return
  types, static property types and `new self/parent/static` resolve against the
  bound class even outside a class context (the closure body's type is inferred in
  the enclosing scope where the closure-bind scope classes are otherwise absent).
- StaticMethodCallCheck, AccessStaticPropertiesCheck and InstantiationRule no
  longer report "outside of class scope" for self/parent/static inside a bound
  closure, resolving accessibility against the bound class.
- ClosureBindArgVisitor now only rescopes the closure body (1st argument), not the
  $newThis/$newScope arguments, avoiding a self-referential scope annotation on a
  `self::class` scope argument.

Continues the reconstruction of PR phpstan#4081 on 2.2.x.
@zonuexe
zonuexe force-pushed the fix/bind-class-scope branch from 8d9f662 to 0b15116 Compare July 14, 2026 13:23
zonuexe added a commit to zonuexe/phpstan-src that referenced this pull request Jul 20, 2026
Annotate special class-name nodes (self/parent/static) that appear inside a
Closure::bind() call with the bind scope argument via ClosureBindArgVisitor, and
resolve the class constant type against that bound class in ClassConstFetchHandler.

Reconstruction of PR phpstan#4081 (closure-bind-scope) rebased onto 2.2.x: the original
MutatingScope::getType() ClassConstFetch branch no longer exists after expression
handling was split into ExprHandler services.
zonuexe added a commit to zonuexe/phpstan-src that referenced this pull request Jul 20, 2026
… properties and instantiation

Extend the Closure::bind() scope binding beyond class constants to the natural
remaining surface:

- MutatingScope::resolveName()/resolveTypeByName() now honour the bind scope
  annotated on self/parent/static class-name nodes, so static method-call return
  types, static property types and `new self/parent/static` resolve against the
  bound class even outside a class context (the closure body's type is inferred in
  the enclosing scope where the closure-bind scope classes are otherwise absent).
- StaticMethodCallCheck, AccessStaticPropertiesCheck and InstantiationRule no
  longer report "outside of class scope" for self/parent/static inside a bound
  closure, resolving accessibility against the bound class.
- ClosureBindArgVisitor now only rescopes the closure body (1st argument), not the
  $newThis/$newScope arguments, avoiding a self-referential scope annotation on a
  `self::class` scope argument.

Continues the reconstruction of PR phpstan#4081 on 2.2.x.
@zonuexe
zonuexe force-pushed the fix/bind-class-scope branch from 0b15116 to 58a5dde Compare July 20, 2026 05:15
zonuexe added a commit to zonuexe/phpstan-src that referenced this pull request Jul 20, 2026
… properties and instantiation

Extend the Closure::bind() scope binding beyond class constants to the natural
remaining surface:

- MutatingScope::resolveName()/resolveTypeByName() now honour the bind scope
  annotated on self/parent/static class-name nodes, so static method-call return
  types, static property types and `new self/parent/static` resolve against the
  bound class even outside a class context (the closure body's type is inferred in
  the enclosing scope where the closure-bind scope classes are otherwise absent).
- StaticMethodCallCheck, AccessStaticPropertiesCheck and InstantiationRule no
  longer report "outside of class scope" for self/parent/static inside a bound
  closure, resolving accessibility against the bound class.
- ClosureBindArgVisitor now only rescopes the closure body (1st argument), not the
  $newThis/$newScope arguments, avoiding a self-referential scope annotation on a
  `self::class` scope argument.

Continues the reconstruction of PR phpstan#4081 on 2.2.x.
@zonuexe
zonuexe force-pushed the fix/bind-class-scope branch from 58a5dde to 514ad90 Compare July 20, 2026 05:33
@zonuexe
zonuexe marked this pull request as ready for review July 20, 2026 05:41
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

zonuexe added a commit to zonuexe/phpstan-src that referenced this pull request Jul 23, 2026
Annotate special class-name nodes (self/parent/static) that appear inside a
Closure::bind() call with the bind scope argument via ClosureBindArgVisitor, and
resolve the class constant type against that bound class in ClassConstFetchHandler.

Reconstruction of PR phpstan#4081 (closure-bind-scope) rebased onto 2.2.x: the original
MutatingScope::getType() ClassConstFetch branch no longer exists after expression
handling was split into ExprHandler services.
zonuexe added a commit to zonuexe/phpstan-src that referenced this pull request Jul 23, 2026
… properties and instantiation

Extend the Closure::bind() scope binding beyond class constants to the natural
remaining surface:

- MutatingScope::resolveName()/resolveTypeByName() now honour the bind scope
  annotated on self/parent/static class-name nodes, so static method-call return
  types, static property types and `new self/parent/static` resolve against the
  bound class even outside a class context (the closure body's type is inferred in
  the enclosing scope where the closure-bind scope classes are otherwise absent).
- StaticMethodCallCheck, AccessStaticPropertiesCheck and InstantiationRule no
  longer report "outside of class scope" for self/parent/static inside a bound
  closure, resolving accessibility against the bound class.
- ClosureBindArgVisitor now only rescopes the closure body (1st argument), not the
  $newThis/$newScope arguments, avoiding a self-referential scope annotation on a
  `self::class` scope argument.

Continues the reconstruction of PR phpstan#4081 on 2.2.x.
@zonuexe
zonuexe force-pushed the fix/bind-class-scope branch from 514ad90 to 2fc7cd8 Compare July 23, 2026 10:22
zonuexe added 3 commits August 9, 2026 16:23
Annotate special class-name nodes (self/parent/static) that appear inside a
Closure::bind() call with the bind scope argument via ClosureBindArgVisitor, and
resolve the class constant type against that bound class in ClassConstFetchHandler.

Reconstruction of PR phpstan#4081 (closure-bind-scope) rebased onto 2.2.x: the original
MutatingScope::getType() ClassConstFetch branch no longer exists after expression
handling was split into ExprHandler services.
Extract the bind-scope resolution into a shared ClosureBindScopeResolver service
and use it both in ClassConstFetchHandler (type) and ClassConstantRule (rules), so
self::/parent::/static:: class constants inside a bound closure no longer report
"Using self outside of class scope" and resolve accessibility against the bound class.
… properties and instantiation

Extend the Closure::bind() scope binding beyond class constants to the natural
remaining surface:

- MutatingScope::resolveName()/resolveTypeByName() now honour the bind scope
  annotated on self/parent/static class-name nodes, so static method-call return
  types, static property types and `new self/parent/static` resolve against the
  bound class even outside a class context (the closure body's type is inferred in
  the enclosing scope where the closure-bind scope classes are otherwise absent).
- StaticMethodCallCheck, AccessStaticPropertiesCheck and InstantiationRule no
  longer report "outside of class scope" for self/parent/static inside a bound
  closure, resolving accessibility against the bound class.
- ClosureBindArgVisitor now only rescopes the closure body (1st argument), not the
  $newThis/$newScope arguments, avoiding a self-referential scope annotation on a
  `self::class` scope argument.

Continues the reconstruction of PR phpstan#4081 on 2.2.x.
@zonuexe
zonuexe force-pushed the fix/bind-class-scope branch from 2fc7cd8 to 5c4cac0 Compare August 9, 2026 07:26
@staabm

staabm commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

@SanderMuller please review :)

@SanderMuller

SanderMuller commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Reviewed. I ran it rather than only reading it, and the approach holds up: the rescoping is confined to the closure body, everything the description advertises resolves, and the cases that fall outside it fail safe.

Verified against 81e06a583 at level 9, with Closure::bind(fn () => …, null, Target::class) and Target extends Base:

expression before after
self::C Using self outside of class scope. 'target'
parent::C error 'base'
self::SECRET (private const) error 42
self::$prop (private static) error string
self::priv() (private static) error int
new self() error P\Target
self::class error 'P\Target'

A closure nested inside a bound closure inherits the bind scope and resolves to 'target', which matches PHP: the inner closure is created while the outer one already carries the rebound scope.

The confinement claim is real, which was the part I most wanted to check. Inside a method of Ctx, self::cond() in the first argument stays 'ctx', self::obj() in the $newThis argument stays P\Ctx, self::C inside the body is 'target', and self::C after the call is 'ctx' again. So #6319 stays fixed.

Gates on the head: full suite green (21288 tests, 96849 assertions), self-analysis clean. Nothing under turbo-ext, and MutatingScope is not shadowed by it, so there is no native mirror to keep in step.

Three cases sit outside the visitor. All of them decline to rescope rather than picking the wrong class, which is the right direction, but two are worth knowing about:

  1. Named arguments in non-positional order. Closure::bind(closure: fn () => self::C, newScope: Target::class, newThis: null) still reports Using self outside of class scope., because getArgs() is read positionally, so $args[2] is newThis there. A non-class expression lands in the resolver and it returns null, so nothing is misresolved. ArgumentsNormalizer would cover it if you want it, otherwise a comment saying positional-only would help.
  2. An object as the scope, Closure::bind(fn () => self::C, $o, $o), which PHP accepts. getClassStringObjectType() yields no class names for an object type, so it is left alone.
  3. A closure not written inline at the call site. Inherent to the approach, no complaint.

Three small code notes:

  • MutatingScope::resolveClosureBindScopeClassName() repeats what ClosureBindScopeResolver::resolveScopeClass() already does. Is that to avoid a dependency cycle in the scope, or could the scope take the resolver?
  • That method carries two doc blocks, the prose one followed by /** @return non-empty-string|null */. Only the second attaches, so the explanation is orphaned. Worth merging into one.
  • The annotation path now sits beside the existing enterClosureBind() / inClosureBindScopeClasses one. The description explains why the scope-based path cannot reach the out-of-class case, so both earn their place, but a line saying which one owns which case would save the next reader the trip.

The single red job is Compile Turbo Extension (8.4, macos-x86_64) at 35m0s, which is the runner timeout on a compile step, and this PR has no C++ in it.

On performance, since the visitor calls spl_object_id() and an array lookup on every node entered and left, I measured instead of assuming. Three interleaved rounds on a doctrine/symfony codebase: 139.0s median on the base against 138.6s with the PR, inside a within-build spread of about 4s. No measurable cost.

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.

4 participants