Enter right-side-assign context for ??= so closures capturing the assigned variable by reference see its assigned type#5895
Conversation
…ssigned variable by reference see its assigned type
- `AssignOpHandler` now calls `ExpressionContext::enterRightSideAssign()` when
evaluating the right-hand side of a `??=` whose target is a simple variable,
mirroring what `AssignHandler` already does for plain `=`/`=&`.
- This lets the by-reference closure-use handling in `NodeScopeResolver`
(which inspects `inAssignRightSideVariableName`/`inAssignRightSideExpr`)
recognise patterns like `$cb ??= function () use (&$cb) { ... }` and resolve
`$cb` to the assigned closure type inside the closure body instead of `null`.
- Without this, `??=` (unlike `=`) left the right-side-assign context empty, so a
recursive `use (&$var)` closure saw `$var` as `null` and produced false
`callable.nonCallable` errors.
- Probed the sibling axis (other assignment operators): plain `=`/`=&` already
set this context; the arithmetic/string compound ops (`+=`, `.=`, ...) cannot
meaningfully capture-and-reassign the same variable by reference, so only
`??=` needed the change.
staabm
left a comment
There was a problem hiding this comment.
we need a fix which also works for phpstan/phpstan#7751
…ssignments A variable captured by reference shares storage with the enclosing variable, and the closure may be invoked at any later point. Forward flow analysis only sees the variable's type at the point the closure is created, so reassignments performed in the enclosing scope afterwards were invisible inside the closure body, producing false "always true/false" impossible-type errors. ClosureUseByRefAssignmentsVisitor collects, per closure, the right-hand side expressions of assignments to each by-reference use variable that occur after the closure definition in the enclosing function-like scope. processClosureExpr() then unions those types into the variable's type seeding the closure body. Assignments preceding the closure are already reflected in the variable's narrowed type and are left untouched, preserving existing precision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fixture uses the mixed type, which requires a `// lint >= 8.0` comment so it is skipped on older PHP versions in CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The changes are complete, tested, and pushed. Let me summarize what I did to address the review. SummaryThe reviewer (staabm) requested that the fix also work for phpstan/phpstan#7751 — the more general case where a by-reference closure capture is reassigned in the enclosing scope after the closure is defined, which the original Root causeForward-flow analysis only knows a captured variable's type at the point the closure is created. A variable captured by reference shares storage with the enclosing variable, and the closure can be invoked at any later time — so reassignments performed in the enclosing scope after the closure are invisible inside the closure body. PHPStan therefore inferred Fix
The key insight that avoided a precision regression: only assignments after the closure are unioned. Assignments before the closure are already reflected in the variable's narrowed type, so unioning them would re-introduce stale/wider types (this initially broke PHPStan's own Verification
Both commits pushed to One thing worth flagging for the reviewer: |
Summary
When a closure captured a variable by reference and that same variable was the target of a
??=assignment, e.g.PHPStan inferred
$isSupportedasnullinside the closure body and reported a falseTrying to invoke null but it's not a callable.error. The exact same pattern written with a plain assignment (if (!isset($isSupported)) { $isSupported = function () use (&$isSupported) ... }) worked correctly. This change makes??=behave like=for this case.Changes
src/Analyser/ExprHandler/AssignOpHandler.php: when evaluating the right-hand side of anExpr\AssignOp\Coalesce(??=) whose target is a simple variable, the handler now callsExpressionContext::enterRightSideAssign($var->name, $expr->expr), the same callAssignHandleralready makes for=/=&.tests/PHPStan/Analyser/nsrt/bug-13810.php: regression test asserting the closure type is inferred for a??=recursive by-reference closure capture.Root cause
The by-reference closure-use handling in
NodeScopeResolver::processClosureExpr()readsinAssignRightSideVariableName/inAssignRightSideExprfrom the currentExpressionContext. When the captured variable matches the variable currently being assigned, it resolves the variable to the assigned (closure) type inside the closure body, which is what makes$x = function () use (&$x) { ... }resolve$xto the closure.Only
AssignHandler(plain=/=&) populated that context.AssignOpHandler, which handles??=together with the arithmetic/string compound operators, never calledenterRightSideAssign(), so for??=the context stayed empty and the by-reference variable fell back to its pre-assignment type (nullafter the??=falsey filter). The fix populates the context for??=, the only compound operator for which capturing-and-reassigning the same variable by reference is meaningful.I probed the sibling assignment operators on the same axis: plain
=/=&already set the context; the arithmetic and string compound ops (+=,-=,.=, ...) require the left-hand variable to already hold a non-closure value and cannot meaningfully reassign it to a captured closure, so they did not need the change.The second example in the issue (a
&$tmpdircapture whose value is reassigned in the enclosing scope after the closure is created) is a different, deeper limitation: PHPStan's forward flow analysis does not see writes performed through the reference later in the enclosing scope. That is not addressed here.Test
tests/PHPStan/Analyser/nsrt/bug-13810.phpasserts that inside$isSupported ??= function (mixed $arg) use (&$isSupported) { ... }the variable$isSupportedisClosure(mixed): bool(previouslynull), andmixed~nullin the enclosing scope after the??=. Verified to fail before the fix (Actual: null) and pass after.Fixes phpstan/phpstan#13810