diff --git a/NEW_WORLD.md b/NEW_WORLD.md new file mode 100644 index 00000000000..f239eaded96 --- /dev/null +++ b/NEW_WORLD.md @@ -0,0 +1,660 @@ +# The New World: single-pass expression processing + +Working design document for the `resolve-type-rewrite` branch. This describes where the +refactoring is going, why, what we gain, and the full inventory of code to build. It is a +branch-lifetime document — delete before merging to the default branch. + +## 1. Motivation + +PHPStan currently traverses the AST of the same expression **multiple times**: + +1. **`NodeScopeResolver::processExprNode`** walks the expression to update the `Scope` + (assignments, narrowing side-effects, throw/impure points). +2. **`MutatingScope::resolveType`** (via `ExprHandler::resolveType`) walks the expression + *again* to compute its `Type` — on whatever scope the caller happens to hold. +3. **`TypeSpecifier::specifyTypesInCondition`** (via `ExprHandler::specifyTypes`) walks it + a *third* time to compute narrowing (`SpecifiedTypes`). + +Because pass 2 and 3 don't have the intermediate scopes that pass 1 computed, they have to +**re-create them**, which means re-invoking the engine from inside type resolution. Concrete +pathologies in today's code: + +- `BooleanAndHandler::resolveType` re-runs `processExprNode($expr->left)` on a **throwaway + `ExpressionResultStorage`** with a `NoopNodeCallback`, just to rebuild the truthy scope of + the left side so it can type the right side — even though `processExpr` four lines earlier + already processed the right side on exactly that scope. The cost is exponential on deep + boolean chains, which is the only reason `BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH` and the + flattened-chain code paths exist. +- `AssignHandler::unwrapAssign` manually walks through nested `$a = $b = 5` because resolving + the type via the scope can't follow the chain naturally. +- `TypeSpecifier` is a condition-*rewriting* engine: handlers build **synthetic** expressions + (`new Identical(...)`, isset and-chains, cast comparisons, swapped `Smaller` nodes) and + re-enter the dispatcher, because narrowing logic can only talk to other narrowing logic + through "an expression + a scope". +- `MutatingScope` keeps `truthyScopes`/`falseyScopes` caches and `FiberScope` keeps a + truthy/falsey expr replay list (`preprocessScope`) purely to paper over the fact that + narrowing is recomputed after the fact instead of being produced once, in place. + +**The fix: process each expression once.** After `processExprNode` finishes we have not just +the updated `Scope` but also the expression's `Type` and its `SpecifiedTypes` — computed by +the handler *at the moment it had all its children's results and the correct intermediate +scopes in hand*. + +## 2. Old world vs. new world + +The old world keeps working on PHP < 8.1 until PHPStan 3.0, where it is mass-deleted. +The new world requires PHP 8.1+ (Fibers). + +| Old world (deleted in 3.0) | New world | +|---|---| +| `MutatingScope::getType/getNativeType/resolveType` | `ExpressionResult::getType()/getNativeType()/getTypeForScope()` | +| `ExprHandler::resolveType` | `typeCallback` wired in `processExpr` | +| `TypeSpecifier::specifyTypesInCondition` dispatcher + `ExprHandler::specifyTypes` | `specifyTypesCallback` wired in `processExpr` | +| `MutatingScope::filterBySpecifiedTypes` | `MutatingScope::applySpecifiedTypes` | +| `filterByTruthyValue` / `filterByFalseyValue` (+ `truthyScopes` caches) | `applySpecifiedTypes($result->getSpecifiedTypes(...))` | +| `ExpressionResult` legacy `truthyScopeCallback`/`falseyScopeCallback` | `getTruthyScope()`/`getFalseyScope()` reimplemented on the line above (accessors stay; ~31 engine call sites untouched) | +| `FiberScope::preprocessScope` truthy/falsey replay | not needed — narrowing applied to real scopes | + +Enforcement: `MutatingScope::getType()/getNativeType()/getKeepVoidType()` and +`TypeSpecifier::specifyTypesInCondition()` **throw** when `NewWorld::disableOldWorld()` +returns `true` (and `PHPSTAN_FNSR` ≠ `0`, PHP 8.1+ — those conditions stay at the call +sites). The old world stays fully functional under `PHPSTAN_FNSR=0` (PHP < 8.1 path). +**The committed state is `return false;`** — mixed mode, everything green. Flipping the +single literal to `return true;` is how a handler-migration leg starts: the guard then +fails loudly wherever migration is incomplete, instead of commenting throws in four places. + +**The goal of this continuous refactoring: the whole test suite is green when the guard +exceptions are set to not fire** (mixed mode — migrated handlers run their new-world +callbacks, everything else takes the guarded legacy bridges). That bar means the rewrite +pays off *before* it is finished: every migrated handler immediately delivers improved and +more precise analysis across the whole test suite, not just in the new-world corpus. The +guard-on mode is the forcing function and the progress meter; the mixed mode is the +deliverable at every point along the way. + +## 3. Design decisions (settled) + +1. **`typeCallback: callable(Expr, MutatingScope): Type`** — one callback, mirroring PR #5224 + (`b2ce1a0558`). `getType()` resolves on the result's own scope; `getNativeType()` on + `doNotTreatPhpDocTypesAsCertain()`; `getTypeForScope($scope)` picks the variant by + `$scope->nativeTypesPromoted`. No separate native/keepVoid callbacks; `getKeepVoidType` + is a one-off solved later. +2. **Inside callbacks, `$scope->getType($child)` becomes `$childResult->getTypeForScope($s)`.** + `MutatingScope::getType()` must never be called from inside an `ExprHandler`. +3. **Never reach into `ExpressionResultStorage` from handler logic.** Child results are + threaded through closures. Storage exists only as the fiber rendezvous (deliver results to + suspended rule callbacks) and the synthetic-node fallback. Every constructed + `ExpressionResult` carries its `expr` so `getType()` always works. +4. **Hard-fail + guarded legacy bridge.** A result without a callback falls back to the + guarded `$scope->getType($expr)`: transparent under `PHPSTAN_FNSR=0` (validated parity vs + baseline on stress files), loud failure under the guard. This is what makes + handler-by-handler migration safe — the suite stays green on the legacy path while the + guard tells us exactly what to migrate next. +5. **New code paths instead of nullable/optional params** on existing methods (no + `?Type $exprType` threading through `TypeSpecifier::create`; `SpecifiedTypes` stays + untouched — it is `@api` and extensions produce it forever). +6. **Copy-and-adjust is sanctioned**: `resolveType` bodies are copied into `typeCallback` + (and `specifyTypes` into `specifyTypesCallback`) with the §3.2 substitution. Dual + maintenance until 3.0 is the accepted cost; mitigate by extracting pure `Type`-taking + helpers shared by both worlds. +7. **`specifyTypesCallback` returns a new envelope object** (working name `NarrowingResult`): + `SpecifiedTypes` + `array` (exprString → result). The map is the + "type oracle": it answers original (pre-narrowing) types in `applySpecifiedTypes` and + `normalize()`, and supplies dim/var types for the `ArrayDimFetch` parent-update — all via + `ExpressionResult::getType()`, honoring §3.3. Extension-produced `SpecifiedTypes` flow + through with an empty map. +8. **The new world is cut away from the old world.** Callbacks contain *copied + and adjusted* code — they never delegate to `resolveType`/`specifyTypes` + (those must be deletable in 3.0). Duplication between the worlds is accepted. + `ResultAwareScope` is used **only at two sanctioned boundaries**: invoking + extensions, and `ParametersAcceptorSelector` (+ the TypeSpecifier + conditional-return/assert helpers until they are ported). It is *not* a + general bridge for running old-world handler bodies. +9. **Two adapters, by execution context**: + - **`FiberScope`** (exists): for *rule* node-callbacks, which run before the expression is + processed. `getType()` suspends the fiber; the engine resumes it with the + `ExpressionResult` at the end of `processExprNode`. Synthetic exprs are processed on + demand at end of traversal. + - **`ResultAwareScope`** (to build): for *extensions and old-world helper code invoked from + inside handler callbacks* — dynamic return type extensions, type-specifying extensions, + `ParametersAcceptorSelector::selectFromArgs`, `TypeSpecifier::create`, assert resolution. + These run mid-analysis where suspension is impossible *and unnecessary*: all children are + already processed. `getType()` resolves in tiers: extension registry → scope-tracked + holder → known-results map → inline re-process (`processExprNode` on a duplicated + storage with `NoopNodeCallback` — handles the synthetic exprs extensions love to build) + → guarded bridge. +10. **Single-pass analysis kills nullsafe short-circuiting.** The old world walks every + eligible expression recursively (`NullsafeShortCircuitingHelper`, + `NullsafeOperatorHelper::getNullsafeShortcircuitedExpr`) to find a `?->` somewhere in + the chain that influences the result. In the new world expressions process inside-out, + so only `NullsafePropertyFetchHandler` and `NullsafeMethodCallHandler` ever see the + `?->` — they emit the plain-chain variant alongside their own key **once**, and every + parent composes their results. `DefaultNarrowingHelper::specifyDefaultTypes()` therefore + needs no expression type at all, and `specifyTypesCallback`s never invoke the + `typeCallback` — narrowing callbacks are cheap, type-free closures. +11. **Result callbacks must not capture the `ExpressionResultStorage`.** Stored results + capturing the storage they live in are reference cycles only the cyclic GC can free; + one call anywhere in an expression makes the whole ancestor result graph cyclic. + Measured: the cycles were the *entire* 4.3× `NodeScopeResolverTest` slowdown (92s → 25s + when broken; the engine work itself was at old-world parity all along, the time went to + GC scans over live cyclic webs). Late asks build their adapters on a **fresh storage** + instead — the synthetics-in-flight cycle guard threads through it, only known-result + seeding is lost on those rare paths. +12. **`InitializerExprTypeResolver` keeps its `callable(Expr): Type` shape; the new world + feeds it a results-first callback** — the form `ArrayHandler` established: the closure + closes over the already-processed child `ExpressionResult`s (keyed by + `spl_object_id($expr)`), answers from `$childResult->getTypeForScope($s)` when the asked + expr is one of them, and only falls back to the guarded `$s->getType($inner)` bridge for + exprs it has no result for. Handlers migrating constructs that resolve through + `InitializerExprTypeResolver` (BinaryOp, ClassConstFetch, ConstFetch, UnaryMinus/Plus, + …) reuse this pattern instead of inventing per-handler plumbing. +13. **Branch scopes prefer the handler's scope callbacks; adapters are seeded per base + scope.** Two lessons from the BooleanAnd/Or leg, both found as mixed-mode nsrt diffs: + - `getTruthyScope()`/`getFalseyScope()` consult `truthyScopeCallback`/`falseyScopeCallback` + *first*, the specify-callback reconstruction second. A handler that can compose a + branch scope incrementally must say so: for `A && B` the truthy scope *is* the right + operand's truthy scope (the left narrowing is already part of it) — re-deriving the + whole conjunction from per-arm `SpecifiedTypes` re-unions types that were never + unioned in the old world and drifts representations (`array` vs + `array`). Consequence: migrated handlers pass *new-world* scope + callbacks or none at all — the old `filterByTruthyValue($expr)` bridges were stripped + when the preference flipped. + - A `ResultAwareScope` must be seeded only with results **evaluated on its base scope**. + A result's memoized type is its evaluation-point type, so seeding the right operand's + result (evaluated on the left-truthy scope) into the pre-condition adapter answered + narrowing-original asks with already-narrowed types (`is_bool($x) && $x` falsey lost + the non-bool half). Everything not seeded re-processes on the base scope (tier 4). + +## 4. What we gain + +- **Performance**: one traversal instead of up to three. The `BooleanAnd::resolveType` + re-processing (and its depth cap), the `filterByTruthyValue` recomputation cascades, and the + `truthyScopes` cache layer all disappear. #5224 measured ~17% on a comparable consolidation. + Types are computed from already-known child types instead of re-walking subtrees. +- **Correctness by construction**: a type is computed exactly where the right scope exists. + No more "which scope do I resolve this on" bugs; the right side of `&&` is typed on the + left-truthy scope because that is literally the scope it was processed on. +- **Simplicity — hacks that delete themselves**: + - `unwrapAssign` (nested assigns flow through result delegation), + - `BooleanAndHandler::resolveType` re-walk + `BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH` + flattened-chain workarounds, + - synthetic re-dispatch nodes inside `specifyTypes` bodies (`new Identical(...)`, isset chains), + - `AssignHandler`'s Ternary lookahead on `$storage->duplicate()`, + - `truthyScopes`/`falseyScopes` caches, `FiberScope::preprocessScope` replay, + - `storeBeforeScope`/`findBeforeScope` (already dead), + - in 3.0: all `resolveType`/`specifyTypes` methods, `MutatingScope::resolveType`, + the `TypeSpecifier` dispatcher, `filterBySpecifiedTypes`, `filterByTruthy/FalseyValue`. +- **Extension compatibility preserved**: third-party extensions keep their signatures. + `Scope::getType` works inside extensions via `ResultAwareScope`/`FiberScope`; + `TypeSpecifier::specifyTypesInCondition` recursion works via an `instanceof` head-check + routing to the new world. + +## 5. Implementation inventory + +Status: ✅ done · 🔶 in progress · 🔧 mechanical · 🎯 design-sensitive + +### A. Core contracts +1. ✅ `ExpressionResult::getType/getNativeType/getTypeForScope` + guarded bridge; results + stored per expr; fiber delivery moved to end of `processExprNode` (`storeResult`). +2. 🎯 `NarrowingResult` envelope (§3.7) + result-based `normalize()`. +3. 🔶 `expr:` on every `ExpressionResult` construction; memoize truthy/falsey applied scopes; + `getSpecifiedTypes` returns the envelope. + +### B. Adapters +4. 🎯 `ResultAwareScope` + factory (§3.8) — unlocks call handlers and all extensions. +5. 🔧 `TypeSpecifier::specifyTypesInCondition` head-check: `ResultAwareScope` → map/inline-process; + `FiberScope` → suspend. Un-guards `AssertFunctionTypeSpecifyingExtension`, + `InArrayFunctionTypeSpecifyingExtension`, `ImpossibleCheckTypeHelper:305`. +6. 🔧 `FiberScope` gaps: `doNotTreatPhpDocTypesAsCertain()` override (today it escapes to a + plain promoted `MutatingScope`), `filterByTruthy/FalseyValue` → suspend + apply, + re-process request for `getScopeType` (maintainer). + +### C. applySpecifiedTypes +7. 🎯 `MutatingScope::applySpecifiedTypes(NarrowingResult): self` — original types via tiers + (extensions → tracked holder → envelope result → bridge); intersect/remove math + + complex-union/`NeverType` early-outs stay centralized (extensions force sure/sureNot + semantics to survive); post-narrowing holders computed locally (kills `getScopeType` at + `MutatingScope:3412`); `IssetExpr` entries → existing certainty ops (already clean). +8. 🔧 New-world path for the `ArrayDimFetch` parent-update in `specifyExpressionType` + (`MutatingScope:2860-2886`): dim/var types from the envelope map. +9. 🔧 `ExpressionResult::getTruthyScope/getFalseyScope` reimplemented on #7 (+ memoization). + +### D. specifyTypesCallback producers +10. 🔧 Leaf default narrowing helper (new path; copy-adjusted + `handleDefaultTruthyOrFalseyContext`/`createForExpr` taking the own type from the result). +11. 🎯 Result-based entry points on `EqualityTypeSpecifyingHelper` (replacing its 7 + `new Identical(...)` re-dispatches), `NonNullabilityHelper`, `NullsafeShortCircuitingHelper`, + `ConditionalExpressionHolderHelper`. +12. 🔧 Compound handlers composing child envelopes at the scopes they were already processed + on: `BooleanNot/And/Or` (incl. flattened variants), `ErrorSuppress`, `Ternary`, `Coalesce`, + `Isset`/`Empty` (compose parts instead of building synthetic chains), `Instanceof`, + `BinaryOp` equality/comparisons, casts. +13. 🔧 Call handlers: type-specifying extensions + conditional-return + asserts via the + adapter; `Assign`/`AssignOp` (createNull from RHS envelope, truthy/falsey via #10). + +### E. typeCallback producers +14. ✅ `Scalar`, `Variable`, `Assign` (Assign re-threaded to avoid storage). +15. 🔧 Trivial: `ConstFetch`, `Print`/`Exit`/`Throw` (fixed types), `Clone`, `ErrorSuppress`, + `Empty`/`Isset`/`Instanceof`/`BooleanNot` (booleans), 15 `Virtual/*` passthroughs. +16. 🔧 `InitializerExprTypeResolver`-backed (it is **already `callable(Expr): Type`- + parameterized** — 82 occurrences): `BinaryOp`, casts, `UnaryMinus/Plus`, `BitwiseNot`, + `InterpolatedString`, `Array_`, `ClassConstFetch`. +17. 🔧 Compound control flow: `BooleanAnd/Or`, `Ternary`, `Coalesce`, `Match` — children are + already processed per-branch; combine child results, delete the re-entry blocks. +18. 🎯 Calls: `FuncCall`/`MethodCall`/`StaticCall`/`New_`/nullsafe — return type extensions + + generics inference (`selectFromArgs`) via the adapter; `PropertyFetch`/`StaticPropertyFetch`; + `Closure`/`ArrowFunction` (existing `ClosureTypeResolver`); `Pre/PostInc/Dec`, `AssignOp`, + `ArrayDimFetch`, `Yield`/`YieldFrom`, `Eval`, `Include`, `Pipe`. + +### F. Engine rewiring +19. 🔶 `NodeScopeResolver` statements: 31 `scope->getType/getNativeType` sites → the result in + hand (`treatPhpDocTypesAsCertain ? getType : getNativeType` maps 1:1 onto + `getType()/getNativeType()`); `:1151` createNull → envelope; 9 `filterBy*` sites — the + synthetic-condition ones (switch `:2023/2049`, foreach `:1462`, while `:1626`) become + direct helper calls with results. (`findEarlyTerminatingExpr` already migrated.) + +## 5a. Working style + +- **TDD with the guard exceptions active**: when migrating a handler to the new + world, always start by flipping `NewWorld::disableOldWorld()` to `return true;` + (the committed state is `return false;` — mixed mode, everything green). Then + drive the work with `NewWorldTypeInferenceTest`: + 1. add probes for the handler's constructs to `data/new-world.php` (or rely on + bridge probes already there) and run the test — **red**, the guard message + names the unmigrated handler; + 2. implement `typeCallback`/`specifyTypesCallback` until the test progresses + past those constructs — the meter walks the data file in order, naming the + next unmigrated handler ("fix, rerun, next"); + 3. flip `disableOldWorld()` back to `return false;` and run the mixed-mode + scoreboard (nsrt `NodeScopeResolverTest` + `make phpstan`) to verify + whole-suite impact — `false` is also what gets committed. + Each condition and branch of new-world code gets a probing assertType test. +- **No TODO markers in new-world code** — deferred functionality is implemented + immediately. Where something genuinely depends on a not-yet-migrated handler, + the code states that dependency as a fact (and bridges or skips), it doesn't + promise future work. + +## 5b. Handler migration checklist + +`[x]` = `processExpr` wires both `typeCallback` and `specifyTypesCallback` into its +`ExpressionResult`. Residual guarded bridges inside migrated handlers are documented +as factual comments at their call sites, not here. + +### Expression handlers + +- [x] ArrayDimFetchHandler — typeCallback composes var/dim results (offsetGet synthetic via unseeded adapter for ArrayAccess; one-level nullsafe short-circuit propagation per §3.10); write-context `$x[]` is NeverType; default narrowing; holder-first helpers gained a scalar tier for the parent-update dims +- [x] ArrayHandler +- [ ] ArrowFunctionHandler +- [x] AssignHandler — Ternary/Match conditional-expression holders stay old-world until those handlers migrate +- [ ] AssignOpHandler +- [x] BinaryOpHandler — typeCallback complete (Identical/NotIdentical via the Type-taking RicherScopeGetTypeHelper variants); specifyTypesCallback invokes the old ~1300-line equality/comparison body directly with an unseeded adapter (single source — the dispatcher round-trip would bounce; the 3.0 cleanup absorbs the body); operand companions for apply originals; the apply path derives synthetic dim-fetch originals from resolvable var+dim; FiberScope::getKeepVoidType via the attributed-clone synthetic +- [x] BitwiseNotHandler — §3.12 results-first InitializerExprTypeResolver callback; default narrowing +- [x] BooleanAndHandler — typeCallback composes child results evaluated on the left-truthy scope (no re-walk, no `BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH`, no flattened path in the new world); truthy scope incremental via `$rightResult->getTruthyScope()` (§3.13); falsey via specifyTypesCallback with per-base-seeded adapters +- [x] BooleanNotHandler — typeCallback folds via the inner result; incremental branch scopes (truthy(!X) = X's falsey scope, §3.13); specifyTypesCallback negates the context onto the inner result (unseeded adapter for unmigrated inner) +- [x] BooleanOrHandler — mirror of BooleanAndHandler (falsey scope incremental, truthy via specifyTypesCallback); `augmentBooleanOrTruthyWithConditionalHolders` priced through the adapters +- [x] CastHandler — §3.12 results-first cast type (Unset_ cast → null); bool/int/double narrowing via the old comparison synthetics through an unseeded adapter +- [x] CastStringHandler — §3.12 results-first cast type; narrowing via the `!= ''` synthetic through an unseeded adapter +- [x] ClassConstFetchHandler — §3.12 results-first class-const type (dynamic class expr via the class result); dynamic const names mixed; default narrowing +- [x] CloneHandler — typeCallback intersects the inner result with object and maps through CloneTypeTraverser; default narrowing +- [ ] ClosureHandler +- [x] CoalesceHandler — isset(left) processed once as a synthetic through the migrated IssetHandler (truthy applied, falsey replaced by the coalesce's own falsey narrowing — isset-falsey would unset the left and poison certainty); typeCallback via issetCheck on an unseeded adapter + the left result on the isset-truthy scope; UNSEEDED everywhere — the left result's memo is null-stripped by ensureNonNullability (§3.13 lesson #2 re-confirmed); TypeExpr tier in apply originals +- [x] ConstFetchHandler — typeCallback: literal true/false/null, holder-tracked runtime constants, ConstantResolver (all unguarded already); default narrowing +- [x] EmptyHandler — typeCallback via issetCheck on an unseeded adapter; specifyTypesCallback invokes the old body directly (the `!isset(X) || !X` synthetic routes through migrated handlers) +- [x] ErrorSuppressHandler — full delegation to the inner result (type, narrowing, branch scopes); unseeded adapter for unmigrated inner +- [x] EvalHandler — constant mixed typeCallback; default narrowing +- [x] ExitHandler — constant NonAcceptingNeverType typeCallback; default narrowing +- [x] FirstClassCallableFuncCallHandler — typed in the NSR fast-path callback (reflection-based; dynamic-name/receiver asks via unseeded adapters; always-truthy Closure narrowing) +- [x] FirstClassCallableMethodCallHandler — see FirstClassCallableFuncCallHandler +- [x] FirstClassCallableNewHandler — see FirstClassCallableFuncCallHandler +- [x] FirstClassCallableStaticCallHandler — see FirstClassCallableFuncCallHandler +- [x] FuncCallHandler — dynamic-name calls bridge +- [x] IncludeHandler — constant mixed typeCallback; default narrowing +- [x] InstanceofHandler — typeCallback folds via the target/class results; specifyTypesCallback is the old create() math with an adapter seeded with the target and class results +- [x] InterpolatedStringHandler — per-part results keyed by spl_object_id (each captured at its own evaluation point); concat folding via resolveConcatType; default narrowing +- [x] IssetHandler — typeCallback via issetCheck on an unseeded adapter; specifyTypesCallback invokes the old body directly (BinaryOp precedent; the multi-isset And-chain synthetic routes through migrated handlers); ensureNonNullability asks priced via the askScopeFactory +- [ ] MatchHandler +- [x] MethodCallHandler — typeCallback via resolveMethodCallTypeViaResults (receiver from its result, one-level nullsafe short-circuit, self-seeded adapter + per-arg companions from processArgs — passed closures keep their context memo); specifyTypesCallback = old body with unseeded adapter; lazy returnTypeCallback threaded into MethodThrowPointHelper +- [x] NewHandler — typeCallback via exactInstantiation on an adapter seeded with per-arg companions (constructor template inference; anonymous classes via reflection; dynamic class exprs); ctor selection pre-args priced through fresh-storage adapters (live-storage duplicate() is O(file) — the +38MB lesson); specifyTypesCallback = old body with unseeded adapter +- [x] NullsafeMethodCallHandler — shares the §3.10 callback; call part reused via MethodCallHandler::processCallWithVarResult; call type bridges until MethodCallHandler migrates; impure calls gate result narrowing +- [x] NullsafePropertyFetchHandler — emits the plain-chain dual key and the subject-not-null entry once, per §3.10; dynamic names bridge +- [x] PipeHandler — full delegation to the rewritten call's result (type + narrowing); the de-guarded FuncCall invokable-name ask rides along +- [x] PostDecHandler +- [x] PostIncHandler +- [x] PreDecHandler +- [x] PreIncHandler +- [x] PrintHandler — constant `1` typeCallback; default narrowing +- [x] PropertyFetchHandler — one-level short-circuit propagation from a nullsafe var; dynamic names bridge +- [x] ScalarHandler +- [x] StaticCallHandler — mirror of MethodCallHandler (late-static-binding name resolution; class expr from its result) +- [x] StaticPropertyFetchHandler — typeCallback mirrors PropertyFetch (native via reflection finder; class-expr via the class result; one-level nullsafe short-circuit; dynamic names bridge); default narrowing +- [x] TernaryHandler — typeCallback composes the branch results (each evaluated on the matching cond-narrowed scope; short ternary asks the cond on its truthy scope via getTypeOnScope); specifyTypesCallback rewrites into the old `(cond && if) || (!cond && else)` synthetic, processed through the migrated boolean handlers (adapter tier 4); branch scopes via the specify path; unlocked AssignHandler's Ternary conditional-holder block (cond result narrowing + getTruthyScope/getFalseyScope + adapter-priced branch types + entry resolver) +- [x] ThrowHandler — typeCallback is the NonAcceptingNeverType constant; throw point takes the inner result's type; default narrowing callback +- [x] UnaryMinusHandler — §3.12 results-first InitializerExprTypeResolver callback; default narrowing +- [x] UnaryPlusHandler — §3.12 results-first InitializerExprTypeResolver callback; default narrowing +- [x] VariableHandler — dynamic variable names bridge +- [x] YieldFromHandler — typeCallback extracts TReturn from the inner result; default narrowing +- [x] YieldHandler — typeCallback reads the enclosing generator's TSend (scope-context only); default narrowing + +### Virtual node handlers + +- [ ] AlwaysRememberedExprHandler +- [ ] ExistingArrayDimFetchHandler +- [ ] FunctionCallableNodeHandler +- [ ] GetIterableKeyTypeExprHandler +- [ ] GetIterableValueTypeExprHandler +- [ ] GetOffsetValueTypeExprHandler +- [ ] InstantiationCallableNodeHandler +- [ ] MethodCallableNodeHandler +- [x] NativeTypeExprHandler +- [ ] OriginalPropertyTypeExprHandler +- [ ] SetExistingOffsetValueTypeExprHandler +- [ ] SetOffsetValueTypeExprHandler +- [ ] StaticMethodCallableNodeHandler +- [x] TypeExprHandler +- [ ] UnsetOffsetExprHandler + +## 6. Migration mechanics + +- **Exercisers**: tiny files analysed with `bin/phpstan analyse -l 8 test.php --debug` under + the guard. `echo '1';` (type slice, green), `$v = 1; if ($v) {} else {}` (narrowing slice). +- **New-world test case** (`NewWorldTypeInferenceTest` + `data/new-world.php`): a temporary + `TypeInferenceTestCase` subclass asserting types for both migrated handlers and the bridges. + Its diagnostic value is **when the old world is cut off by the guard exceptions**: run it + with the guards active and the failures show exactly which handlers still need to implement + the new callbacks (the guard messages name the construct). In the mixed working state it + must stay fully green. **When the whole suite is green in mixed mode, the temporary test + case is deleted** — everything is covered by pre-existing tests. +- **Parity discipline**: after each migration leg, `PHPSTAN_FNSR=0` runs must match baseline + (`git stash` + compare); the new-world result for migrated constructs must match the + old-world result. +- **3.0 mass-deletion list**: everything in the left column of §2, the guard itself, and this + document. + +## 7. Status log + +- 2026-06-09: `ExprHandler` consolidation (resolveType + specifyTypes live in handlers); + guard commit; fiber delivery of `ExpressionResult` (`9cb1d353f0`); `Scalar`/`Variable`/ + `Assign` typeCallbacks; `echo '1';` green under guard; FNSR=0 parity restored (`891bad60ff`). +- 2026-06-10: feasibility research (this document); decision: `NarrowingResult` envelope, + `ResultAwareScope` adapter, tiered original-type resolution in `applySpecifiedTypes`. +- 2026-06-10 (later): first three handlers fully migrated — `ScalarHandler`, + `AssignHandler` (value result threaded through the `processAssignVar` callback; + `hasTypeCallback()` contract; conditional-expression holders gated old-world-only + with a TODO), `FuncCallHandler` (`resolveTypeViaResults`/`specifyTypesViaResults` + copies; return-type + type-specifying extensions and `selectFromArgs` through + `ResultAwareScope`; throw-point never-detection via lazy return-type callback). + Supporting infra: `ResultAwareScope` (tiers: extensions → tracked → known results → + inline re-process → guarded bridge; derivation-safe via `pushInFunctionCall` + overrides), `NewWorld::isEnabled()`, `DefaultNarrowingHelper` (new-world copy of + default truthy/falsey narrowing), `TypeSpecifier::specifyTypesInCondition` + head-check for `ResultAwareScope` (recursion stays new-world) and `FiberScope` + (rules suspend for the result — un-guards `ImpossibleCheckTypeHelper`), + `FiberScope::doNotTreatPhpDocTypesAsCertain` fiber-safety, `processArgs` + callable-arg type from the result. **`NewWorldTypeInferenceTest` added** + (temporary; delete when the whole suite is green under the guard): 13 assertions + over scalars, assigns (incl. nested), params, and function calls (signature, + constant-folding extensions, nested calls) — green in both worlds. +- 2026-06-10 (TDD leg): **`MutatingScope::applySpecifiedTypes`** lands — the new-world + apply side. Original (pre-narrowing) types resolved in tiers (extension registry → + scope-tracked holders → caller-supplied ExpressionResults → guarded bridge); the + conditional-holder matching tail is shared with `filterBySpecifiedTypes` via an + extracted private method. `getTruthyScope`/`getFalseyScope` and the per-statement + createNull narrowing run on it. `VariableHandler` gets its own copied typeCallback + + default-narrowing specify callback; `TypeExpr`/`NativeTypeExpr` virtual handlers + migrate (their type is the wrapped type); synthetic fiber requests are processed on + the plain scope (a FiberScope would suspend from within — found via an infinite + loop in the asserts flow). FuncCall conditional-return + asserts narrowing are + **copied** into the handler (`*ViaResults`), no longer delegating to the + TypeSpecifier internals; the `@api` `create()`/`specifyTypesInCondition()` (with + adapter) remain the sanctioned entry points. Assign conditional-expression holders + (truthy/falsey projection + falsey-scalar equality holders) are ported with a + per-entry type resolver (assigned result → tracked holders → skip unpriceable + entries, e.g. conditional-return narrowing of inner call arguments); Ternary/Match + holders stay old-world until those handlers migrate. If/elseif condition types and + `processArgs` callable/impure-invalidation types come from ExpressionResults. + `NewWorldTypeInferenceTest`: **33 assertions green in both worlds**, including + `if`/`else` narrowing (`$v = 1; if ($v)` — the original exerciser), assign-in-if, + function asserts (`@phpstan-assert`), conditional return types, holder-driven + narrowing (`$len = strlen($s); if ($len)` → `$s` is `non-empty-string`), and + by-reference assignment. +- 2026-06-10 (array leg): **`ArrayHandler`, `BinaryOpHandler`, `Pre/PostInc`, + `Pre/PostDec` migrate.** The headline test: `$a = [$b = 1, $b + 1, $c = $b, + $c + 2, $c++, $c]` infers `array{1, 2, 1, 3, 1, 2}` — each item's type is + captured at its own evaluation point (the old world resolves all items on one + scope and cannot do this). `processVirtualAssign` takes an optional + `$assignedTypeCallback` (auto-priced for `TypeExpr`/`NativeTypeExpr`); + `PreInc/PreDec` extract a pure `resolveTypeFromVarType(Expr, Type)` shared by + both worlds; `PostInc/PostDec` type as the variable's pre-step value and price + the virtual `PreInc/PreDec` assign via the injected pre-handler. BinaryOp's + `resolveTypeFromResults` is a full copy of `resolveType` with identity-matched + child results (Identical/NotIdentical bridge to `RicherScopeGetTypeHelper` + until the equality migration). +- 2026-06-10 (engine fixes found by the leg, via `make phpstan` divergence triage): + 1. **Pending fibers parked too eagerly flushed**: the flush ran at the end of + *every* statement list, so a fiber asking about an expr that the enclosing + statement still had to process (a do-while/while/for condition after its body + list) was answered by synthetic re-processing on the scope captured at + suspension — and the poisoned result was stored under the *real* AST node's + key, early-resuming later legitimate askers (`do { $count++ } while ($count + < 3)` reported `0 < 3 always true`). Fix: statement lists never flush; + parked requests wait for the real `storeResult` resume, and only + analysis-unit boundaries (file statements, function, method, trait) flush + genuine synthetics. Resolved 7 `smaller.alwaysTrue` + ~10 loop-flavored + src divergences. + 2. **First-class callables typed `mixed` when assigned** (both worlds — + a consolidation regression): the FCC early path stored the virtual + `*CallableNode`'s result, whose `expr` was the virtual node; the virtual + handler's `resolveType` is intentionally `mixed`, so the result bridge + asked the wrong node. Fix: rewrap the result with the original expr so the + bridge dispatches to the `FirstClassCallable*Handler`s. + `make phpstan` (4G) divergences: 30 → 13; nsrt mixed failures: 45 → 31 (0 errors). +- 2026-06-10 (corpus + coverage): user-driven xdebug coverage audit of all branch + changes vs 2.2.x under `NewWorldTypeInferenceTest` (raw whole-process coverage — + PHPUnit per-test coverage misses data providers where the analysis runs). + Corpus grown 34 → 132 assertions: all BinaryOp operators, pre/post inc/dec + variants, keyed arrays, `is_callable` pair check, nullable truthy narrowing, + post-inc-in-condition (exprResults tier of `applySpecifiedTypes`), + `assertNativeType` probes, method-call result bridge, tracked-property and + is_* narrowing through `ResultAwareScope` + `TypeSpecifier` head-checks, + dynamic/undefined variables, unmigrated-condition bridges (BooleanNot/And/Or, + instanceof, empty, isset, count), bare-call statements, negated/equality + asserts, first-class callables, list assignment, closures/arrow fns, foreach + virtual assigns, elseif, echo, min/max signature selection, dynamic + return/type-specifying/throw extension probes (`is_int`, `assert`, `intdiv` + try/finally certainty). **Coverage of executable changed lines: 47.5%.** + Remaining uncovered, classified: old-world bodies moved by consolidation + (covered by the pre-existing suite; deleted in 3.0), defensive throws, + rule-driven paths (fiber early-resume/synthetic flush, `FiberScope` + doNotTreat.../keepVoid — TypeInferenceTestCase runs no rules), + `ExpressionTypeResolverExtension` tiers (no such extension in test config), + and future-leg provisions (isset-certainty apply branch, TruthyFalsey + context, nullsafe roots in migrated specify callbacks). +- 2026-06-11 (whole-suite burn-down): **the full test suite finishes again** (the + hang was the premature pending-fiber flush poisoning stored results) and the + scoreboard is now measured suite-wide: 12843 tests, 25 -> ~10 failures. + Fixed, each with its own commit: FiberScope types resolve at the expression's + evaluation point narrowed by rule-applied filters (restores the old + preprocessScope contract; fixes dynamic-call name/param correlation and + chained-call asks; also fixes filterByFalseyValue delegating to + filterByTruthyValue); keepVoid bridges to the old world (regular results + store void as null — "(void) is used" errors were lost); per-scalar + conditional holders bridge through old-world equality (nullsafe subjects pin + non-null); function-call extension reads price at the call point (before the + call's own virtual mutations — array_shift saw the already-shifted arg); + native-type promotion mirrored into the specify-path adapter (PHPDoc tips + were lost); collectors collect before forwarding to rules (suspended rule + fibers deferred execution-end/return collection past the aggregate-node + snapshots) plus a class-boundary fiber flush before the Class*Nodes; + Ternary/Match conditional holders un-gated into mixed mode (isset-ternary + variable certainty); applySpecifiedTypes uses Yes-certainty holders only as + narrowing originals (Maybe holders carry "when defined" types — broke + FNSR=0 parity, found by bisect). Remaining failures are one designed-fix + family (passed-closure typing context through the adapter — see the task + notes; three heuristic attempts each traded fixes for breaks) + the + multi-assign precision improvement awaiting a mode-dependent-expectations + policy. +- 2026-06-11 (property leg): **PropertyFetchHandler + NullsafePropertyFetchHandler + migrate** — the first leg driven end-to-end by the §5a loop with the + disableOldWorld meter. The nullsafe handler is now the only place that knows + about `?->` (§3.10): it evaluates the subject once, narrows it non-null for + the property part via the new type-taking + `ensureShallowNonNullabilityFromTypes()`, fires the rule callback for the + virtual plain fetch itself and stores a result for it, and its + specifyTypesCallback emits the plain-chain dual key (one structural + `getNullsafeShortcircuitedExpr` call) plus a subject-not-null entry — + replacing the old dispatcher-built `BooleanAnd(var !== null, plain)`. + The plain handler propagates a nullsafe var's short-circuit null one level + (no recursion). `ExpressionResult` gains **companionResults** so + applySpecifiedTypes can price the plain variant's original type from the + stored plain result. `FiberScope::getScopeType/getScopeNativeType` rerouted + through the result path (the reserved scope-walk design pending — flagged). + Leg coverage: 89.5%+ of executable changed lines via 18 new corpus probes + (non-nullable/null/array-dim subjects, chains, dynamic names, native asks, + bare-statement context); the rest are defensive throws and rule-only paths. +- 2026-06-10 (boolean leg): **`BooleanAndHandler` + `BooleanOrHandler` migrated** — the + single-pass showcase. The right operand is evaluated on the left-truthy/left-falsey + scope during processing, so the typeCallback composes the two child results directly: + no `processExprNode` re-walk on a throwaway storage, no + `BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH`, no flattened-chain fast path in the new world + (the old ones stay for FNSR=0). Meter demo: a 6-arm `&&`/`||` chain (depth 5 > old cap + 4) narrows every arm and constant-folds whole-chain asks under `disableOldWorld=true`. + specifyTypesCallback is the old math with child narrowing from the child results + (`specifyChildTypes`: result callback, or the old dispatcher *with the adapter scope* + for unmigrated children); `normalize()`/conditional-holder helpers price their + narrowing-original asks through `ResultAwareScope`s seeded per base scope (§3.13). + Found and fixed in the process: + - branch-scope preference flip + legacy-callback strip (§3.13 first bullet; the + representation drift showed up as 6 nsrt diffs: mixed-subtract, pr-5379, bug-9400, + bug-7156, while-loop-variables, bug-14047); + - per-base adapter seeding (§3.13 second bullet; `ReflectionProviderGoldenTest` + remembered-`is_bool()` leak); + - `FuncCallHandler::specifyTypesViaResults` dynamic-name fall-through invokes the + old-world body (`specifyTypesFromCallableCall` + default context) directly — the + dispatcher round-trip bounced an adapter scope back into the seeded self-result + forever (4.5M-frame stack in nsrt); + - virtual nodes built by handlers embed `toFiberScope()` scopes in the new world + (`BooleanAndNode`/`BooleanOrNode` right scope) so the ConstantCondition rules' + `getRightScope()->getType()` asks resolve through the stored right result; + - `ResultAwareScope` answers Yes-tracked plain variables from the holder (tier 2½) — + filter-derived adapters lose their context (`plainScope === null`) and variables + fell through to the guarded bridge; superglobals (Yes-defined, no holder) keep + falling through. + Scoreboard: corpus 194/194 (17 new probes: deep chains, const folds via parent asks, + inside-out narrowing, representation pins for both regressions, statement null-ctx, + Or-in-And falsey, unmigrated-arm fall-through, isset-holder re-derivation, dynamic-name + call in condition); nsrt at the known 6; CallMethods/BooleanAnd/BooleanOr/ImpossibleCheck + rule tests green; `make phpstan` 204 = HEAD parity; FNSR=0 spot checks byte-identical. + Changed-line coverage (corpus + guard-on meter merged): And 84%, Or 90%, + ResultAwareScope 100% — remaining gaps are defensive throws, closure-closing braces, + one fold return measured-missed under fibers (output proven by the meter), the + truthy-and-false holder re-derivation pair, and `setAlwaysOverwriteTypes` propagation. +- 2026-06-10 (ternary leg): **`TernaryHandler` + `BooleanNotHandler` migrated**, and the + AssignHandler Ternary conditional-holder block unlocked. The ternary typeCallback + composes the branch results — each was evaluated on the matching cond-narrowed scope, + so the old resolveType's cond re-processing on a throwaway storage dies; the short + ternary asks the cond on its truthy scope via `getTypeOnScope` (native asks promote the + truthy scope first). Narrowing rewrites into the old `(cond && if) || (!cond && else)` + synthetic and processes it through the *migrated* boolean handlers (unseeded adapter, + tier 4 — §3.13). BooleanNot: fold via the inner result, incremental swapped branch + scopes (truthy(!X) = X's falsey scope), context negation onto the inner result. + AssignHandler's Ternary holder block now takes the cond result's specify callback + + getTruthyScope/getFalseyScope, adapter-priced branch types, and an entry resolver + mirroring the assign one (FNSR=0 keeps the old block verbatim). + Found and fixed: the nullsafe specify callback never ran the plain call's + type-specifying extensions (`@phpstan-assert-if-true`, bug-12866) — the old synthetic + `BooleanAnd(var !== null, plainCall)` dispatch provided that; exposed by BooleanNot's + incremental falsey = nullsafe truthy. `createNullsafeSpecifyCallback` now composes the + plain call's narrowing through the dispatcher (adapter) when the chain executed — + until MethodCallHandler narrowing migrates. + Scoreboard: corpus 219/219 (25 new probes: ternary basics/folds/short-as-condition/ + native, ternary-as-condition synthetic, assign holders incl. untracked entries, + statement null-ctx ×2, BooleanNot folds and branch narrowing, bug-12866 pin); meter + demo (8 dumps) green under disableOldWorld=true and byte-identical under FNSR=0; + nsrt at the known 6; make phpstan 204 = parity; CallMethods/Ternary/BooleanNot/ + BooleanAnd/BooleanOr rule tests green. Changed-line coverage: Ternary 88%, + BooleanNot 85%, Assign 86%, DefaultNarrowingHelper 100% — gaps are defensive throws, + braces, the FNSR=0-only branch and one tier-3 delegation line. +- 2026-06-10 (NodeScopeResolver getType sweep): **statement-level `Scope::getType()` + asks replaced with ExpressionResult asks**, TDD'd under `disableOldWorld=true` with a + 13-construct statement exerciser (if/elseif/else, while incl. always-true, do-while, + for, foreach, switch, const, unset, @var annotations). Per construct: + - If/elseif: next-arm scope = the elseif cond result's falsey scope. + - While: before-cond boolean and last-pass cond boolean from the cond results; the + loop-exit scope via the new `filterByFalseyValueUsingResult()` (apply path when the + cond result carries a specify callback, guarded `filterByFalseyValue` for unmigrated + conds / FNSR=0). + - Do-while: the cond is processed once (hoisted above the always-iterates check and + the DoWhileLoopConditionNode callback — rules now see cond sub-exprs first) and the + single result feeds the boolean, falsey scope, and throw/impure points. + - For: the last-cond result is captured (was discarded) and feeds always-iterates + + post-loop falsey filtering; `inferForLoopExpressions` count-pattern asks priced + through adapters. + - Foreach: `getForeachIterateeTypes()` computes the iteratee PHPDoc+native pair per + `$originalScope` (memoized result asks on the eval scope, `getTypeOnScope` on the + pollute-filtered one) and threads it through the NSR `enterForeach` helper, the + constant-array unroll, and new `MutatingScope::enterForeach`/`enterForeachKey` + signatures (no internal asks; no external callers existed); the post-loop dim-fetch/ + key/value re-asks go through per-scope adapters; the traversable throw point takes + the iteratee type. + - Switch: exhaustiveness asks the cond result on the case-narrowed scope + (`getTypeOnScope`); per-case `Equal` synthetics stay with the equality leg. + - Const_/ClassConst: the value result's types; Unset_ dim-var via adapter; + `findEarlyTerminatingExpr` called-on types via adapter; `processStmtVarAnnotation` + and execution-end never-checks via adapters; AssignHandler by-ref array keys via + adapter; `MethodThrowPointHelper` takes a lazy return-type callback (FuncCall's + shape; MethodCall/StaticCall pass null = guarded bridge until their legs). + - `MutatingScope::specifyExpressionType`'s ArrayDimFetch parent-update reads the dim/ + var/native types holder-first (`getTypeFromTrackedHolder`) — the plan's §C tier-1 + resolution, hit via `enterForeachKey`'s dim-fetch holder assignment. + - `LiteralArrayItem` embeds fiber scopes in the new world (DuplicateKeys rule asks) — + the BooleanAndNode/BooleanOrNode lesson generalized: any handler-built virtual node + carrying a scope embeds `toFiberScope()`. + - ThrowHandler migrated en passant (constant never type). + Documented residue (raw asks that stay): rule-callback closures already receive + FiberScope (NSR:958); FNSR=0 branches (If cond ternaries); recursive by-ref + closure-use self-ask (ClosureHandler leg); by-ref args fallback (documented); + `createCallableParameters` type callbacks (task #18 — callers price them); + `filterBy*` over engine synthetics (`$arrayComparisonExpr`, switch case `Equal`, + maybe-empty foreach merge — the equality/BinaryOp leg). + Scoreboard: corpus 232/232 (+13 statement probes); meter exerciser green under the + guard and byte-identical under FNSR=0; nsrt at the known 6; `make phpstan` 204 = + parity (3 fresh findings fixed: redundant null check, unused truthy helper variant, + Scope-vs-MutatingScope on the end-node adapter); DefinedVariableRuleTest + testDynamicAccess failure confirmed pre-existing at HEAD. +- 2026-06-11 (handler batch): **21 more handlers migrated**, one commit each: + ConstFetch, UnaryMinus/UnaryPlus/BitwiseNot (§3.12), Print/Clone/Exit/Eval/Include + (constants/delegation), ErrorSuppress (full delegation), CastString/Cast (§3.12 + + comparison synthetics via adapters), ClassConstFetch, InterpolatedString (per-part + results), Instanceof, **BinaryOp** (the equality milestone: old body direct with + unseeded adapter; Type-taking RicherScopeGetTypeHelper variants kill the Identical + type bridge; operand companions; apply originals gain dim-fetch + TypeExpr + scalar + tiers; FiberScope::getKeepVoidType via the attributed-clone synthetic), ArrayDimFetch, + Isset/Empty (issetCheck on adapters; NonNullabilityHelper askScopeFactory), + **Coalesce** (isset synthetic through the migrated IssetHandler; the §3.13 + evaluation-base rule re-confirmed the hard way — never seed results evaluated on + ensured scopes), StaticPropertyFetch, the **first-class callable quartet** (typed in + the NSR fast-path), **MethodCall/StaticCall** (resolveViaResults with self-seeded + adapters + per-arg companions; the old specify bodies direct; MethodThrowPointHelper + takes the lazy return-type callback — the implicit-throws/never bridges are gone), + **New** (exactInstantiation on a companion-seeded adapter), Yield/YieldFrom, Pipe + (full delegation to the rewritten call). + Cross-cutting: `processArgs` carries closure/arrow context wrappers as + companionResults — a passed closure's type resolves on its context scope, fixing the + method/static flavor of the passed-closure problem (task #18's FuncCall flavor and its + 6 nsrt knowns remain). **Memory lesson (§3.11 extended): adapters in hot paths must + use fresh storages — synthetic processing duplicates the adapter's storage and + duplicating the live per-file storage is O(file)** (the New leg measured +38 MB peak + on a 30-file directory and OOM'd the 599M workers; with fresh storages the batch is + memory-neutral: 162.5 MB peak on src/Rules/Comparison before and after). + Scoreboard held at every commit: corpus grew 194 → 335 assertions; nsrt at the known + 6; `make phpstan` 204 = parity; FNSR=0 spot-identical. + Remaining handlers: AssignOp (before-scope machinery, AssignOp-Coalesce), Match + (per-arm Identical synthetics, enum fast-path — unlocks the AssignHandler Match + holder block), Closure + ArrowFunction (the task #18 design session). +- **Known engine debt — `ExpressionResultStorage` memory retention**: every + `ExpressionResult` (holding its after-scope, callbacks, memoized types) is + retained for the whole file; `make phpstan` needs ~12.5 GB at 4G-per-worker + limits and OOMs at the default 599M in nested-foreach files + (`SplObjectStorage::addAll` in `duplicate()`). Pre-existing at HEAD before the + array leg (5 OOM errors baseline vs 7 with it). Needs an eviction strategy + (results evictable once no fiber/conditional holder can still ask — e.g. + per-statement or per-function clearing, or weak references); per project + discipline the fix is algorithmic, not a memory-limit bump. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0ac2ab24cec..2f0cc92a9b0 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -69,7 +69,7 @@ parameters: - rawMessage: Casting to string something that's already string. identifier: cast.useless - count: 3 + count: 5 path: src/Analyser/MutatingScope.php - diff --git a/src/Analyser/DirectInternalScopeFactory.php b/src/Analyser/DirectInternalScopeFactory.php index 533d37c5f92..c16b83aff62 100644 --- a/src/Analyser/DirectInternalScopeFactory.php +++ b/src/Analyser/DirectInternalScopeFactory.php @@ -38,6 +38,7 @@ public function __construct( private $nodeCallback, private ConstantResolver $constantResolver, private bool $fiber = false, + private bool $resultAware = false, ) { } @@ -64,6 +65,8 @@ public function create( $className = MutatingScope::class; if ($this->fiber) { $className = FiberScope::class; + } elseif ($this->resultAware) { + $className = ResultAwareScope::class; } return new $className( @@ -120,6 +123,27 @@ public function toFiberFactory(): InternalScopeFactory ); } + public function toResultAwareFactory(): InternalScopeFactory + { + return new self( + $this->container, + $this->reflectionProvider, + $this->initializerExprTypeResolver, + $this->expressionTypeResolverExtensionRegistryProvider, + $this->exprPrinter, + $this->typeSpecifier, + $this->propertyReflectionFinder, + $this->parser, + $this->phpVersion, + $this->attributeReflectionFactory, + $this->configPhpVersion, + $this->nodeCallback, + $this->constantResolver, + false, + true, + ); + } + public function toMutatingFactory(): InternalScopeFactory { return new self( diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index 1c389f9fb9a..ba1fba0fc94 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -13,6 +13,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -23,9 +24,12 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NeverType; +use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function array_merge; /** @@ -35,6 +39,10 @@ final class ArrayDimFetchHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof ArrayDimFetch; @@ -86,8 +94,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: static fn (): Type => new NeverType(), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } @@ -97,7 +106,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($dimResult->getImpurePoints(), $varResult->getImpurePoints()); $scope = $varResult->getScope(); - $varType = $scope->getType($expr->var); + $varType = $varResult->getType(); if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( $stmt, @@ -109,14 +118,62 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex )->getThrowPoints()); } + // a nullsafe var that can be null short-circuits this fetch too; its + // handler already produced the null-union — propagate one level, no + // recursive chain walking (NEW_WORLD.md §3.10) + $isShortcircuited = static function (Expr $e, MutatingScope $s) use ($varResult): bool { + if (!$e instanceof ArrayDimFetch) { + throw new ShouldNotHappenException(); + } + + return ($e->var instanceof Expr\NullsafePropertyFetch || $e->var instanceof Expr\NullsafeMethodCall) + && TypeCombinator::containsNull($varResult->getTypeForScope($s)); + }; + $typeCallback = static function (Expr $e, MutatingScope $s) use ($varResult, $dimResult, $isShortcircuited, $nodeScopeResolver, $stmt): Type { + if (!$e instanceof ArrayDimFetch || $e->dim === null) { + throw new ShouldNotHappenException(); + } + + $varTypeForFetch = $varResult->getTypeForScope($s); + if ($isShortcircuited($e, $s)) { + $varTypeForFetch = TypeCombinator::removeNull($varTypeForFetch); + } + + if ( + !$varTypeForFetch->isArray()->yes() + && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varTypeForFetch)->yes() + ) { + // ArrayAccess: the offsetGet() synthetic, processed on demand + // (ResultAwareScope tier 4) + $fetchedType = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage())->getType( + new MethodCall( + new TypeExpr($varTypeForFetch), + new Identifier('offsetGet'), + [ + new Arg($e->dim), + ], + ), + ); + } else { + $fetchedType = $varTypeForFetch->getOffsetValueType($dimResult->getTypeForScope($s)); + } + + if ($isShortcircuited($e, $s)) { + return TypeCombinator::union($fetchedType, new NullType()); + } + + return $fetchedType; + }; + return new ExpressionResult( $scope, hasYield: $dimResult->hasYield() || $varResult->hasYield(), isAlwaysTerminating: $dimResult->isAlwaysTerminating() || $varResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index f64a168e848..0200d02a460 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -12,7 +12,9 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NewWorld; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; @@ -22,11 +24,14 @@ use PHPStan\Node\LiteralArrayItem; use PHPStan\Node\LiteralArrayNode; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\CallableType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_key_exists; use function array_merge; use function count; +use function spl_object_id; /** * @implements ExprHandler @@ -37,6 +42,7 @@ final class ArrayHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -73,15 +79,19 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $itemNodes = []; + $itemResults = []; $hasYield = false; $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; foreach ($expr->items as $arrayItem) { - $itemNodes[] = new LiteralArrayItem($scope, $arrayItem); + // the embedded item scope answers the rules' getType() asks — in the + // new world those must go through the fiber so stored results answer + $itemNodes[] = new LiteralArrayItem(NewWorld::isEnabled() ? $scope->toFiberScope() : $scope, $arrayItem); $nodeScopeResolver->callNodeCallback($nodeCallback, $arrayItem, $scope, $storage); if ($arrayItem->key !== null) { $keyResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->key, $scope, $storage, $nodeCallback, $context->enterDeep()); + $itemResults[spl_object_id($arrayItem->key)] = $keyResult; $hasYield = $hasYield || $keyResult->hasYield(); $throwPoints = array_merge($throwPoints, $keyResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $keyResult->getImpurePoints()); @@ -90,6 +100,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $valueResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->value, $scope, $storage, $nodeCallback, $context->enterDeep()); + $itemResults[spl_object_id($arrayItem->value)] = $valueResult; $hasYield = $hasYield || $valueResult->hasYield(); $throwPoints = array_merge($throwPoints, $valueResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $valueResult->getImpurePoints()); @@ -98,12 +109,55 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $nodeScopeResolver->callNodeCallback($nodeCallback, new LiteralArrayNode($expr, $itemNodes), $scope, $storage); + // each item type was captured at its own evaluation point in the sequence — + // resolving them on any single scope (the old world) cannot handle items + // with side effects like `[$b = 1, $b + 1, $b++]` + $typeCallback = function (Expr $e, MutatingScope $s) use ($itemResults): Type { + if (!$e instanceof Array_) { + throw new ShouldNotHappenException(); + } + + $type = $this->initializerExprTypeResolver->getArrayType($e, static function (Expr $inner) use ($itemResults, $s): Type { + $id = spl_object_id($inner); + if (array_key_exists($id, $itemResults)) { + return $itemResults[$id]->getTypeForScope($s); + } + + // getArrayType only asks about item keys and values — guarded + // legacy bridge just in case (PHPSTAN_FNSR=0) + return $s->getType($inner); + }); + + if ( + count($e->items) === 2 + && isset($e->items[0], $e->items[1]) + && $type->isCallable()->maybe() + ) { + $isCallableCall = new FuncCall( + new FullyQualified('is_callable'), + [new Arg($e)], + ); + $isCallableCallString = $s->getNodeKey($isCallableCall); + if ( + array_key_exists($isCallableCallString, $s->expressionTypes) + && $s->expressionTypes[$isCallableCallString]->getType()->isTrue()->yes() + ) { + $type = TypeCombinator::intersect($type, new CallableType()); + } + } + + return $type; + }; + return new ExpressionResult( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 540119391e7..8de76347b57 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -28,9 +28,11 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExpressionTypeHolder; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NewWorld; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; @@ -38,6 +40,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; use PHPStan\Node\Expr\ExistingArrayDimFetch; use PHPStan\Node\Expr\GetOffsetValueTypeExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; @@ -73,6 +76,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; use TypeError; +use function array_key_exists; use function array_key_last; use function array_merge; use function array_pop; @@ -95,6 +99,8 @@ public function __construct( private PhpVersion $phpVersion, private ExprPrinter $exprPrinter, private MatchHandler $matchHandler, + private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -289,6 +295,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $assignedExprResult = null; $result = $this->processAssignVar( $nodeScopeResolver, $scope, @@ -298,7 +305,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->expr, $nodeCallback, $context, - static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { + function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver, &$assignedExprResult): ExpressionResult { $impurePoints = []; if ($expr instanceof AssignRef) { $referencedExpr = $expr->expr; @@ -328,6 +335,7 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $nodeScopeResolver->storeBeforeScope($storage, $expr, $scope); $result = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + $assignedExprResult = $result; $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); @@ -338,7 +346,20 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $scope = $scope->exitExpressionAssign($expr->expr); } - return new ExpressionResult($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + // the value of an assignment is its assigned value — delegate to its result + return new ExpressionResult( + $scope, + $hasYield, + $isAlwaysTerminating, + $throwPoints, + $impurePoints, + expr: $expr->expr, + typeCallback: static fn (Expr $e, MutatingScope $s): Type => $result->getTypeForScope($s), + specifyTypesCallback: $result->hasSpecifiedTypesCallback() + ? static fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $result->getSpecifiedTypes($s, $ctx) + : null, + expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, + ); }, true, ); @@ -353,8 +374,9 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex ) { $varName = $expr->var->name; $refName = $expr->expr->name; - $type = $scope->getType($expr->var); - $nativeType = $scope->getNativeType($expr->var); + // the variable was just assigned the referenced value — its type is the value result's + $type = $assignedExprResult !== null ? $assignedExprResult->getType() : $scope->getType($expr->var); + $nativeType = $assignedExprResult !== null ? $assignedExprResult->getNativeType() : $scope->getNativeType($expr->var); // When $varName is assigned, update $refName $scope = $scope->assignExpression( @@ -386,11 +408,69 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: static function (Expr $e, MutatingScope $s) use (&$assignedExprResult): Type { + if ($assignedExprResult === null) { + // assignment shape whose value was not processed through the callback; + // guarded legacy bridge (works under PHPSTAN_FNSR=0) + return $s->getType($e); + } + + return $assignedExprResult->getTypeForScope($s); + }, + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use (&$assignedExprResult): SpecifiedTypes { + /** @var Assign|AssignRef $e */ + return $this->specifyTypesForAssign($e, $s, $ctx, $assignedExprResult); + }, + expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); } + /** + * New-world narrowing for assignments. The RHS-FuncCall special cases + * (array_key_first/array_search/... conditional holders) and non-Variable + * assignment targets still go through the guarded legacy path — they will + * be ported together with the handlers they depend on. + */ + private function specifyTypesForAssign(Assign|AssignRef $expr, MutatingScope $scope, TypeSpecifierContext $context, ?ExpressionResult $assignedExprResult): SpecifiedTypes + { + if ($expr instanceof AssignRef && $assignedExprResult !== null) { + // the old world treats by-reference assignments with default narrowing + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + if ( + $expr instanceof AssignRef + || $assignedExprResult === null + || ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && in_array($expr->expr->name->toLowerString(), ['array_key_first', 'array_key_last', 'array_search', 'array_find_key', 'array_rand'], true) + ) + ) { + // guarded legacy bridge (works under PHPSTAN_FNSR=0) + return $this->typeSpecifier->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr, $context); + } + + if ($context->null()) { + if (!$assignedExprResult->hasSpecifiedTypesCallback()) { + // guarded legacy bridge for not-yet-migrated assigned values + return $this->typeSpecifier->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr, $context); + } + + $specifiedTypes = $assignedExprResult->getSpecifiedTypes($scope->exitFirstLevelStatements(), $context)->setRootExpr($expr); + + return $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var)); + } + + if ($expr->var instanceof Variable && is_string($expr->var->name)) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr->var, $context)->setRootExpr($expr); + } + + // guarded legacy bridge + return $this->typeSpecifier->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr, $context); + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback * @param Closure(MutatingScope $scope): ExpressionResult $processExprCallback @@ -428,30 +508,70 @@ public function processAssignVar( $impurePoints[] = new ImpurePoint($scopeBeforeAssignEval, $var, 'superglobal', 'assign to superglobal variable', true); } $assignedExpr = $this->unwrapAssign($assignedExpr); - $type = $scopeBeforeAssignEval->getType($assignedExpr); - + // The callback result's typeCallback (when present) resolves the type of + // the assigned value — AssignHandler delegates it to the value's own + // ExpressionResult. Callers that don't supply one (AssignOp, virtual + // assigns) fall back to the guarded legacy scope type (PHPSTAN_FNSR=0). + $type = $result->hasTypeCallback() + ? $result->getType() + : $scopeBeforeAssignEval->getType($assignedExpr); + + // Ternary/Match conditional-expression holders need the branch types from + // narrowed scopes — the cond's narrowing comes from its re-processed + // result, the branch types are priced through adapters on the filtered + // scopes (Match: guarded old-world bridge until MatchHandler migrates) $conditionalExpressions = []; if ($assignedExpr instanceof Ternary) { $if = $assignedExpr->if; if ($if === null) { $if = $assignedExpr->cond; } - $condScope = $nodeScopeResolver->processExprNode($stmt, $assignedExpr->cond, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getScope(); - $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); - $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); - $truthyScope = $condScope->filterBySpecifiedTypes($truthySpecifiedTypes); - $falsyScope = $condScope->filterBySpecifiedTypes($falseySpecifiedTypes); - $truthyType = $truthyScope->getType($if); - $falseyType = $falsyScope->getType($assignedExpr->else); + $condResult = $nodeScopeResolver->processExprNode($stmt, $assignedExpr->cond, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep()); + $condScope = $condResult->getScope(); + if (NewWorld::isEnabled() && $condResult->hasSpecifiedTypesCallback()) { + $truthySpecifiedTypes = $condResult->getSpecifiedTypes($condScope, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $condResult->getSpecifiedTypes($condScope, TypeSpecifierContext::createFalsey()); + $truthyScope = $condResult->getTruthyScope(); + $falsyScope = $condResult->getFalseyScope(); + } else { + // not-yet-migrated cond — guarded old-world dispatcher (PHPSTAN_FNSR=0) + $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); + $truthyScope = $condScope->filterBySpecifiedTypes($truthySpecifiedTypes); + $falsyScope = $condScope->filterBySpecifiedTypes($falseySpecifiedTypes); + } + if (NewWorld::isEnabled()) { + $truthyType = $truthyScope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)->getType($if); + $falseyType = $falsyScope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)->getType($assignedExpr->else); + } else { + $truthyType = $truthyScope->getType($if); + $falseyType = $falsyScope->getType($assignedExpr->else); + } if ( $truthyType->isSuperTypeOf($falseyType)->no() && $falseyType->isSuperTypeOf($truthyType)->no() ) { - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $condExprTypeResolver = null; + if (NewWorld::isEnabled()) { + // resolves entry expressions of the projected cond + // SpecifiedTypes — same tiers as $exprTypeResolver below + $condExprString = $condScope->getNodeKey($assignedExpr->cond); + $condExprTypeResolver = static function (Expr $e, string $eString) use ($condExprString, $condResult, $condScope, $nodeScopeResolver, $stmt, $storage): Type { + if ($eString === $condExprString && $condResult->hasTypeCallback()) { + return $condResult->getType(); + } + if (array_key_exists($eString, $condScope->expressionTypes)) { + return TypeUtils::resolveLateResolvableTypes($condScope->expressionTypes[$eString]->getType()); + } + + return $condScope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)->getType($e); + }; + } + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $condExprTypeResolver); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $condExprTypeResolver); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $condExprTypeResolver); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $condExprTypeResolver); } } @@ -462,19 +582,57 @@ public function processAssignVar( ); } + $assignedExprString = $scope->getNodeKey($assignedExpr); + $exprTypeResolver = null; + if (NewWorld::isEnabled()) { + // resolves entry expressions of the projected SpecifiedTypes: + // the assigned expression itself via its ExpressionResult, + // scope-tracked expressions via their holders, anything else + // through the guarded legacy bridge (PHPSTAN_FNSR=0) + $exprTypeResolver = static function (Expr $e, string $eString) use ($assignedExprString, $result, $scope, $nodeScopeResolver, $stmt, $storage): Type { + if ($eString === $assignedExprString && $result->hasTypeCallback()) { + return $result->getType(); + } + if (array_key_exists($eString, $scope->expressionTypes)) { + return TypeUtils::resolveLateResolvableTypes($scope->expressionTypes[$eString]->getType()); + } + + // price sub-expressions of the assigned value (e.g. inner call + // arguments narrowed by conditional return types) through the + // adapter — its tiers and cycle guard fall back to the guarded + // legacy bridge for anything unresolvable (PHPSTAN_FNSR=0) + return $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)->getType($e); + }; + } + + $truthySpecifiedTypes = null; $truthyType = TypeCombinator::removeFalsey($type); if ($truthyType !== $type) { - $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); + if (NewWorld::isEnabled() && $result->hasSpecifiedTypesCallback()) { + $truthySpecifiedTypes = $result->getSpecifiedTypes($scope, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $result->getSpecifiedTypes($scope, TypeSpecifierContext::createFalsey()); + } else { + // old world, or a not-yet-migrated assigned value — guarded bridge (PHPSTAN_FNSR=0) + $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); + } + + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $exprTypeResolver); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $exprTypeResolver); $falseyType = TypeCombinator::intersect($type, StaticTypeFactory::falsey()); - $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $exprTypeResolver); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $exprTypeResolver); } - foreach ([null, false, 0, 0.0, '', '0', []] as $falseyScalar) { + // pure function calls (and any non-call value) may be remembered in holders; + // new-world purity is reflected by the truthy SpecifiedTypes being non-empty + $scalarHoldersAllowed = !NewWorld::isEnabled() + || ($truthySpecifiedTypes !== null && ( + !$assignedExpr instanceof FuncCall + || count($truthySpecifiedTypes->getSureTypes()) + count($truthySpecifiedTypes->getSureNotTypes()) > 0 + )); + foreach ($scalarHoldersAllowed ? [null, false, 0, 0.0, '', '0', []] : [] as $falseyScalar) { $falseyType = ConstantTypeHelper::getTypeFromValue($falseyScalar); $withoutFalseyType = TypeCombinator::remove($type, $falseyType); if ( @@ -498,25 +656,33 @@ public function processAssignVar( $astNode = new Node\Expr\Array_($falseyScalar); } + // `$assignedExpr !== ` / `=== ` narrowing. + // Equality produces entries beyond the assigned expression itself + // (a nullsafe value pins its subject non-null, the shortcircuited + // variant gets its own key) — guarded old-world bridge until the + // equality migration (PHPSTAN_FNSR=0) $notIdenticalConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); $notIdenticalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $notIdenticalConditionExpr, TypeSpecifierContext::createTrue()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr, $exprTypeResolver); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr, $exprTypeResolver); $identicalConditionExpr = new Expr\BinaryOp\Identical($assignedExpr, $astNode); $identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $exprTypeResolver); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $exprTypeResolver); } $nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage); - $scope = $scope->assignVariable($var->name, $type, $scope->getNativeType($assignedExpr), TrinaryLogic::createYes()); + $assignedNativeType = $result->hasTypeCallback() + ? $result->getNativeType() + : $scopeBeforeAssignEval->getNativeType($assignedExpr); + $scope = $scope->assignVariable($var->name, $type, $assignedNativeType, TrinaryLogic::createYes()); foreach ($conditionalExpressions as $exprString => $holders) { $scope = $scope->addConditionalExpressions((string) $exprString, $holders); } if ($assignedExpr instanceof Expr\Array_) { - $scope = $this->processArrayByRefItems($scope, $var->name, $assignedExpr, new Variable($var->name)); + $scope = $this->processArrayByRefItems($nodeScopeResolver, $stmt, $storage, $scope, $var->name, $assignedExpr, new Variable($var->name)); } } else { $nameExprResult = $nodeScopeResolver->processExprNode($stmt, $var->name, $scope, $storage, $nodeCallback, $context); @@ -1068,7 +1234,13 @@ private function unwrapAssign(Expr $expr): Expr * @param ImpurePoint[] $rhsImpurePoints * @return array */ - private function processSureTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr): array + /** + * @param array $conditionalExpressions + * @param ImpurePoint[] $rhsImpurePoints + * @param (callable(Expr, string): Type)|null $exprTypeResolver + * @return array + */ + private function processSureTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr, ?callable $exprTypeResolver = null): array { foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $exprType]) { if (!$this->isExprSafeToProjectThroughVariable($expr, $variableName, $rhsImpurePoints, $assignedExpr)) { @@ -1083,7 +1255,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco $variableType, $innerExpr, $this->exprPrinter->printExpr($innerExpr), - $scope->getType($innerExpr), + $exprTypeResolver !== null ? $exprTypeResolver($innerExpr, $this->exprPrinter->printExpr($innerExpr)) : $scope->getType($innerExpr), TrinaryLogic::createMaybe(), ); continue; @@ -1091,13 +1263,15 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco $exprString = (string) $exprString; + $entryExprType = $exprTypeResolver !== null ? $exprTypeResolver($expr, $exprString) : $scope->getType($expr); + $conditionalExpressions = $this->addConditionalExpressionHolder( $conditionalExpressions, $variableName, $variableType, $expr, $exprString, - TypeCombinator::intersect($scope->getType($expr), $exprType), + TypeCombinator::intersect($entryExprType, $exprType), TrinaryLogic::createYes(), ); } @@ -1110,7 +1284,13 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco * @param ImpurePoint[] $rhsImpurePoints * @return array */ - private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr): array + /** + * @param array $conditionalExpressions + * @param ImpurePoint[] $rhsImpurePoints + * @param (callable(Expr, string): Type)|null $exprTypeResolver + * @return array + */ + private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr, ?callable $exprTypeResolver = null): array { foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $exprType]) { if (!$this->isExprSafeToProjectThroughVariable($expr, $variableName, $rhsImpurePoints, $assignedExpr)) { @@ -1133,13 +1313,15 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $ $exprString = (string) $exprString; + $entryExprType = $exprTypeResolver !== null ? $exprTypeResolver($expr, $exprString) : $scope->getType($expr); + $conditionalExpressions = $this->addConditionalExpressionHolder( $conditionalExpressions, $variableName, $variableType, $expr, $exprString, - TypeCombinator::remove($scope->getType($expr), $exprType), + TypeCombinator::remove($entryExprType, $exprType), TrinaryLogic::createYes(), ); } @@ -1339,12 +1521,14 @@ private function isImplicitArrayCreation(array $dimFetchStack, Scope $scope): Tr return $scope->hasVariableType($varNode->name)->negate(); } - private function processArrayByRefItems(MutatingScope $scope, string $rootVarName, Expr\Array_ $arrayExpr, Expr $parentExpr): MutatingScope + private function processArrayByRefItems(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResultStorage $storage, MutatingScope $scope, string $rootVarName, Expr\Array_ $arrayExpr, Expr $parentExpr): MutatingScope { $implicitIndex = 0; foreach ($arrayExpr->items as $arrayItem) { if ($arrayItem->key !== null) { - $keyType = $scope->getType($arrayItem->key)->toArrayKey(); + // literal keys were just processed as part of the RHS — priced + // through the adapter (ResultAwareScope tier 4) + $keyType = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)->getType($arrayItem->key)->toArrayKey(); if ($implicitIndex !== null) { $keyValues = $keyType->getConstantScalarValues(); @@ -1370,7 +1554,7 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam if ($arrayItem->value instanceof Expr\Array_) { $dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr); - $scope = $this->processArrayByRefItems($scope, $rootVarName, $arrayItem->value, $dimFetchExpr); + $scope = $this->processArrayByRefItems($nodeScopeResolver, $stmt, $storage, $scope, $rootVarName, $arrayItem->value, $dimFetchExpr); } if (!$arrayItem->byRef || !$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) { diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 31af0b9c77f..bf288927511 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -53,6 +53,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $rhsExprResult = null; $assignResult = $this->assignHandler->processAssignVar( $nodeScopeResolver, $scope, @@ -62,7 +63,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nodeCallback, $context, - static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { + static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver, &$rhsExprResult): ExpressionResult { $originalScope = $scope; if ($expr instanceof Expr\AssignOp\Coalesce) { $scope = $scope->filterByFalseyValue( @@ -71,6 +72,7 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex } $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + $rhsExprResult = $exprResult; if ($expr instanceof Expr\AssignOp\Coalesce) { $nodeScopeResolver->storeBeforeScope($storage, $expr, $originalScope); $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $originalScope->getType($expr->var)->isNull()->yes(); @@ -80,10 +82,20 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $isAlwaysTerminating, $exprResult->getThrowPoints(), $exprResult->getImpurePoints(), + expr: $expr, ); } - return $exprResult; + // the assigned value of an AssignOp is the op result, not the right side — + // wrap so processAssignVar falls back to the (guarded) legacy type of $expr + return new ExpressionResult( + $exprResult->getScope(), + $exprResult->hasYield(), + $exprResult->isAlwaysTerminating(), + $exprResult->getThrowPoints(), + $exprResult->getImpurePoints(), + expr: $expr, + ); }, $expr instanceof Expr\AssignOp\Coalesce, ); @@ -94,13 +106,17 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $throwPoints = $assignResult->getThrowPoints(); $impurePoints = $assignResult->getImpurePoints(); if ( - ($expr instanceof Expr\AssignOp\Div || $expr instanceof Expr\AssignOp\Mod) && - !$scope->getType($expr->expr)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + ($expr instanceof Expr\AssignOp\Div || $expr instanceof Expr\AssignOp\Mod) + && $rhsExprResult !== null + && !$rhsExprResult->getType()->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false); } if ($expr instanceof Expr\AssignOp\Concat) { - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + if ($rhsExprResult === null) { + throw new ShouldNotHappenException(); + } + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $rhsExprResult->getType(), $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); } diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 0f604c86441..7a590a5832f 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -66,6 +66,7 @@ public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExprPrinter $exprPrinter, private EqualityTypeSpecifyingHelper $equalityTypeSpecifyingHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -89,29 +90,199 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()); if ( ($expr instanceof BinaryOp\Div || $expr instanceof BinaryOp\Mod) && - !$leftResult->getScope()->getType($expr->right)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + !$rightResult->getType()->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($leftResult->getScope(), new ObjectType(DivisionByZeroError::class), $expr, false); } if ($expr instanceof BinaryOp\Concat) { - $leftToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->left, $scope); - $rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->right, $leftResult->getScope()); + $leftToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->left, $leftResult->getType(), $scope); + $rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->right, $rightResult->getType(), $leftResult->getScope()); $throwPoints = array_merge($throwPoints, $leftToStringResult->getThrowPoints(), $rightToStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $leftToStringResult->getImpurePoints(), $rightToStringResult->getImpurePoints()); } $scope = $rightResult->getScope(); + $typeCallback = function (Expr $e, MutatingScope $s) use ($leftResult, $rightResult): Type { + if (!$e instanceof BinaryOp) { + throw new ShouldNotHappenException(); + } + + return $this->resolveTypeFromResults($e, $s, $leftResult, $rightResult); + }; + return new ExpressionResult( $scope, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + // the old specifyTypes() body stays the single source for the ~1300 + // lines of equality/comparison narrowing — invoked directly (the + // dispatcher round-trip would bounce off the head-check back into + // this callback) with an unseeded adapter: operand and special-case + // asks re-evaluate on the ask scope (tier 4), matching the old + // resolveType-on-ask-scope semantics; inner synthetics + // (BooleanNot(Identical), swapped comparisons) route through the + // migrated handlers. The 3.0 cleanup absorbs the body here. + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof BinaryOp) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $adapterScope, $e, $ctx); + }, + // applySpecifiedTypes resolves narrowing originals for the operands + // (e.g. the count() call in `count($x) > 0`) from here + companionResults: [ + $scope->getNodeKey($expr->left) => $leftResult, + $scope->getNodeKey($expr->right) => $rightResult, + ], ); } + /** + * New-world copy of resolveType(): operand types come from the child + * ExpressionResults (captured at their own evaluation points). Synthetic + * sub-expressions and the identical/not-identical helper still take the + * guarded legacy bridge (PHPSTAN_FNSR=0) until the equality migration. + */ + private function resolveTypeFromResults(BinaryOp $expr, MutatingScope $scope, ExpressionResult $leftResult, ExpressionResult $rightResult): Type + { + $getType = static function (Expr $inner) use ($expr, $scope, $leftResult, $rightResult): Type { + if ($inner === $expr->left) { + return $leftResult->getTypeForScope($scope); + } + if ($inner === $expr->right) { + return $rightResult->getTypeForScope($scope); + } + + return $scope->getType($inner); + }; + + if ($expr instanceof BinaryOp\Smaller) { + return $getType($expr->left)->isSmallerThan($getType($expr->right), $this->phpVersion)->toBooleanType(); + } + + if ($expr instanceof BinaryOp\SmallerOrEqual) { + return $getType($expr->left)->isSmallerThanOrEqual($getType($expr->right), $this->phpVersion)->toBooleanType(); + } + + if ($expr instanceof BinaryOp\Greater) { + return $getType($expr->right)->isSmallerThan($getType($expr->left), $this->phpVersion)->toBooleanType(); + } + + if ($expr instanceof BinaryOp\GreaterOrEqual) { + return $getType($expr->right)->isSmallerThanOrEqual($getType($expr->left), $this->phpVersion)->toBooleanType(); + } + + if ($expr instanceof BinaryOp\Equal || $expr instanceof BinaryOp\NotEqual) { + if ( + $expr->left instanceof Variable + && is_string($expr->left->name) + && $expr->right instanceof Variable + && is_string($expr->right->name) + && $expr->left->name === $expr->right->name + ) { + return new ConstantBooleanType($expr instanceof BinaryOp\Equal); + } + + $equalType = $this->initializerExprTypeResolver->resolveEqualType($getType($expr->left), $getType($expr->right))->type; + if ($expr instanceof BinaryOp\Equal) { + return $equalType; + } + + if ($equalType->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($equalType->isFalse()->yes()) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + } + + if ($expr instanceof BinaryOp\Identical) { + return $this->richerScopeGetTypeHelper->getIdenticalResultFromTypes($scope, $expr, $getType($expr->left), $getType($expr->right))->type; + } + + if ($expr instanceof BinaryOp\NotIdentical) { + return $this->richerScopeGetTypeHelper->getNotIdenticalResultFromTypes($scope, $expr, $getType($expr->left), $getType($expr->right))->type; + } + + if ($expr instanceof BinaryOp\LogicalXor) { + $leftBooleanType = $getType($expr->left)->toBoolean(); + $rightBooleanType = $getType($expr->right)->toBoolean(); + + $leftBooleanValue = $leftBooleanType->isTrue()->yes() ? true : ($leftBooleanType->isFalse()->yes() ? false : null); + $rightBooleanValue = $rightBooleanType->isTrue()->yes() ? true : ($rightBooleanType->isFalse()->yes() ? false : null); + if ($leftBooleanValue !== null && $rightBooleanValue !== null) { + return new ConstantBooleanType( + $leftBooleanValue xor $rightBooleanValue, + ); + } + + return new BooleanType(); + } + + if ($expr instanceof BinaryOp\Spaceship) { + return $this->initializerExprTypeResolver->getSpaceshipType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Concat) { + return $this->initializerExprTypeResolver->getConcatType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\BitwiseAnd) { + return $this->initializerExprTypeResolver->getBitwiseAndType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\BitwiseOr) { + return $this->initializerExprTypeResolver->getBitwiseOrType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\BitwiseXor) { + return $this->initializerExprTypeResolver->getBitwiseXorType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Div) { + return $this->initializerExprTypeResolver->getDivType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Mod) { + return $this->initializerExprTypeResolver->getModType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Plus) { + return $this->initializerExprTypeResolver->getPlusType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Minus) { + return $this->initializerExprTypeResolver->getMinusType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Mul) { + return $this->initializerExprTypeResolver->getMulType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\Pow) { + return $this->initializerExprTypeResolver->getPowType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\ShiftLeft) { + return $this->initializerExprTypeResolver->getShiftLeftType($expr->left, $expr->right, $getType); + } + + if ($expr instanceof BinaryOp\ShiftRight) { + return $this->initializerExprTypeResolver->getShiftRightType($expr->left, $expr->right, $getType); + } + + throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { $getType = static fn (Expr $expr): Type => $scope->getType($expr); diff --git a/src/Analyser/ExprHandler/BitwiseNotHandler.php b/src/Analyser/ExprHandler/BitwiseNotHandler.php index de49fb09887..3f87861b8aa 100644 --- a/src/Analyser/ExprHandler/BitwiseNotHandler.php +++ b/src/Analyser/ExprHandler/BitwiseNotHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -17,6 +18,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -28,6 +30,7 @@ final class BitwiseNotHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -41,12 +44,31 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the InitializerExprTypeResolver callback asks the child result first + // (NEW_WORLD.md paragraph 3.12); anything else takes the guarded bridge + $typeCallback = function (Expr $e, MutatingScope $s) use ($exprResult): Type { + if (!$e instanceof BitwiseNot) { + throw new ShouldNotHappenException(); + } + + return $this->initializerExprTypeResolver->getBitwiseNotType($e->expr, static function (Expr $inner) use ($e, $exprResult, $s): Type { + if ($inner === $e->expr) { + return $exprResult->getTypeForScope($s); + } + + return $s->getType($inner); + }); + }; + return new ExpressionResult( $exprResult->getScope(), hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 6a56274b378..1885790dd3f 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -14,8 +14,10 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NewWorld; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; +use PHPStan\Analyser\ResultAwareScope; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -44,6 +46,7 @@ final class BooleanAndHandler implements ExprHandler public function __construct( private NodeScopeResolver $nodeScopeResolver, private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -252,14 +255,47 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $leftTruthyScope = $leftResult->getTruthyScope(); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftTruthyScope, $storage, $nodeCallback, $context); - $rightExprType = $rightResult->getScope()->getType($expr->right); + $rightExprType = $rightResult->getType(); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { $leftMergedWithRightScope = $leftResult->getFalseyScope(); } else { $leftMergedWithRightScope = $leftResult->getScope()->mergeWith($rightResult->getScope()); } - $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanAndNode($expr, $leftTruthyScope), $scope, $storage, $context); + // the embedded right scope answers the rules' getType()/getNativeType()/ + // narrowing asks about the right operand — in the new world those must go + // through the fiber so the stored right result answers them + $rightScopeForNode = NewWorld::isEnabled() ? $leftTruthyScope->toFiberScope() : $leftTruthyScope; + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanAndNode($expr, $rightScopeForNode), $scope, $storage, $context); + + // the single-pass payoff: the right side was *evaluated* on the left-truthy + // scope, so its result already is what the old resolveType had to rebuild by + // re-processing the left side on a throwaway storage — no re-walk, no + // BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH + $typeCallback = static function (Expr $e, MutatingScope $s) use ($leftResult, $rightResult): Type { + if (!$e instanceof BooleanAnd && !$e instanceof LogicalAnd) { + throw new ShouldNotHappenException(); + } + + $leftBooleanType = $leftResult->getTypeForScope($s)->toBoolean(); + if ($leftBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(false); + } + + $rightBooleanType = $rightResult->getTypeForScope($s)->toBoolean(); + if ($rightBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(false); + } + + if ( + $leftBooleanType->isTrue()->yes() + && $rightBooleanType->isTrue()->yes() + ) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }; return new ExpressionResult( $leftMergedWithRightScope, @@ -267,9 +303,115 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), - truthyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByTruthyValue($expr->right), - falseyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByFalseyValue($expr), + // incremental truthy scope: the right operand was evaluated on the + // left-truthy scope, so its truthy scope IS the whole conjunction's — + // no re-derivation, no cross-arm combination (and no representational + // drift from re-uniting per-arm types). The falsey scope cannot be + // composed this way (¬(A && B) needs both arms) — specify path. + truthyScopeCallback: static fn (): MutatingScope => $rightResult->getTruthyScope(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt, $leftResult, $rightResult, $leftTruthyScope), ); } + /** + * New-world copy of specifyTypes(): child narrowing comes from the child + * ExpressionResults — the recursion is structural, so deep chains compose + * linearly and the flattened fast path is not needed. The normalize/ + * conditional-holder helper code resolves narrowing originals with + * $scope->getType() — those asks are priced through adapters seeded with + * the operand results (fresh storage per ask, NEW_WORLD.md §3.11). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResult $leftResult, ExpressionResult $rightResult, MutatingScope $leftTruthyScope): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $leftResult, $rightResult, $leftTruthyScope): SpecifiedTypes { + if (!$e instanceof BooleanAnd && !$e instanceof LogicalAnd) { + throw new ShouldNotHappenException(); + } + + if ($ctx->null()) { + return (new SpecifiedTypes([], []))->setRootExpr($e); + } + + // each adapter is seeded only with the result evaluated on its base + // scope — a result's memoized type is its evaluation-point type, so + // seeding it under another base would answer asks about narrowing + // originals with already-narrowed types. Other asks re-process on the + // base scope (ResultAwareScope tier 4) + $adapterStorage = new ExpressionResultStorage(); + $scopeAdapter = $s->toResultAwareScope([$s->getNodeKey($e->left) => $leftResult], $nodeScopeResolver, $stmt, $adapterStorage); + $rightScopeAdapter = $leftTruthyScope->toResultAwareScope([$s->getNodeKey($e->right) => $rightResult], $nodeScopeResolver, $stmt, $adapterStorage); + + $leftTypes = $this->specifyChildTypes($leftResult, $e->left, $s, $scopeAdapter, $ctx)->setRootExpr($e); + $rightTypes = $this->specifyChildTypes($rightResult, $e->right, $leftTruthyScope, $rightScopeAdapter, $ctx)->setRootExpr($e); + if ($ctx->true()) { + $types = $leftTypes->unionWith($rightTypes); + } else { + $leftNormalized = $leftTypes->normalize($scopeAdapter); + $rightNormalized = $rightTypes->normalize($rightScopeAdapter); + $types = $leftNormalized->intersectWith($rightNormalized); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($scopeAdapter, $rightScopeAdapter, $leftNormalized, $rightNormalized, $e->left, $e->right, false, $types); + } + if ($ctx->false()) { + $leftTypesForHolders = $leftTypes; + $rightTypesForHolders = $rightTypes; + // In a mixed truthy-and-false context, re-derive empty holders from the falsey narrowing. + if ($ctx->truthy()) { + if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) { + $leftTypesForHolders = $this->specifyChildTypes($leftResult, $e->left, $s, $scopeAdapter, TypeSpecifierContext::createFalsey())->setRootExpr($e); + } + if ($rightTypesForHolders->getSureTypes() === [] && $rightTypesForHolders->getSureNotTypes() === []) { + $rightTypesForHolders = $this->specifyChildTypes($rightResult, $e->right, $leftTruthyScope, $rightScopeAdapter, TypeSpecifierContext::createFalsey())->setRootExpr($e); + } + } + // For arms still empty (e.g. isset() on an array dim fetch), derive conditions + // from the truthy narrowing instead, swapping sure/sureNot types. + if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) { + $truthyLeftTypes = $this->specifyChildTypes($leftResult, $e->left, $s, $scopeAdapter, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyLeftTypes)) { + $leftTypesForHolders = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); + } + } + if ($rightTypesForHolders->getSureTypes() === [] && $rightTypesForHolders->getSureNotTypes() === []) { + $truthyRightTypes = $this->specifyChildTypes($rightResult, $e->right, $leftTruthyScope, $rightScopeAdapter, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyRightTypes)) { + $rightTypesForHolders = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); + } + } + $result = new SpecifiedTypes( + $types->getSureTypes(), + $types->getSureNotTypes(), + ); + if ($types->shouldOverwrite()) { + $result = $result->setAlwaysOverwriteTypes(); + } + return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $leftTypesForHolders, $rightTypesForHolders, false, true, $rightScopeAdapter, $e->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $rightTypesForHolders, $leftTypesForHolders, false, true, $scopeAdapter, $e->left), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $leftTypesForHolders, $rightTypesForHolders, true, true, $rightScopeAdapter, $e->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $rightTypesForHolders, $leftTypesForHolders, true, true, $scopeAdapter, $e->left), + ]))->setRootExpr($e); + } + + return $types; + }; + } + + /** + * A child's narrowing from its ExpressionResult; not-yet-migrated children + * take the old-world dispatcher with the adapter scope, keeping their inner + * type lookups unguarded. + */ + private function specifyChildTypes(ExpressionResult $result, Expr $child, MutatingScope $scope, ResultAwareScope $adapterScope, TypeSpecifierContext $context): SpecifiedTypes + { + if ($result->hasSpecifiedTypesCallback()) { + return $result->getSpecifiedTypes($scope, $context); + } + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $child, $context); + } + } diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index 6bd2831fb27..f60f0d5ac1c 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -16,6 +17,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Type; @@ -27,6 +29,13 @@ final class BooleanNotHandler implements ExprHandler { + public function __construct( + private DefaultNarrowingHelper $defaultNarrowingHelper, + private TypeSpecifier $typeSpecifier, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof BooleanNot; @@ -37,17 +46,67 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); + $typeCallback = static function (Expr $e, MutatingScope $s) use ($exprResult): Type { + if (!$e instanceof BooleanNot) { + throw new ShouldNotHappenException(); + } + + $exprBooleanType = $exprResult->getTypeForScope($s)->toBoolean(); + if ($exprBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($exprBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }; + return new ExpressionResult( $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + // incremental branch scopes (§3.13): `!X` is truthy exactly when X is + // falsey — the inner result's branch scopes, swapped + truthyScopeCallback: static fn (): MutatingScope => $exprResult->getFalseyScope(), + falseyScopeCallback: static fn (): MutatingScope => $exprResult->getTruthyScope(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt, $exprResult), ); } + /** + * New-world copy of specifyTypes(): the inner expression's narrowing with + * the context negated; a not-yet-migrated inner takes the old-world + * dispatcher with an unseeded adapter (the inner must be evaluated on the + * ask scope, §3.13). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResult $exprResult): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $exprResult): SpecifiedTypes { + if (!$e instanceof BooleanNot) { + throw new ShouldNotHappenException(); + } + + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx); + } + + if ($exprResult->hasSpecifiedTypesCallback()) { + return $exprResult->getSpecifiedTypes($s, $ctx->negate())->setRootExpr($e); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $e->expr, $ctx->negate())->setRootExpr($e); + }; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { $exprBooleanType = $scope->getType($expr->expr)->toBoolean(); diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index d439a2c8082..03de8d5573d 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -12,8 +12,10 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NewWorld; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; +use PHPStan\Analyser\ResultAwareScope; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -43,6 +45,7 @@ final class BooleanOrHandler implements ExprHandler public function __construct( private NodeScopeResolver $nodeScopeResolver, private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -294,14 +297,45 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $leftFalseyScope = $leftResult->getFalseyScope(); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftFalseyScope, $storage, $nodeCallback, $context); - $rightExprType = $rightResult->getScope()->getType($expr->right); + $rightExprType = $rightResult->getType(); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { $leftMergedWithRightScope = $leftResult->getTruthyScope(); } else { $leftMergedWithRightScope = $leftResult->getScope()->mergeWith($rightResult->getScope()); } - $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanOrNode($expr, $leftFalseyScope), $scope, $storage, $context); + // the embedded right scope answers the rules' getType()/getNativeType()/ + // narrowing asks about the right operand — in the new world those must go + // through the fiber so the stored right result answers them + $rightScopeForNode = NewWorld::isEnabled() ? $leftFalseyScope->toFiberScope() : $leftFalseyScope; + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanOrNode($expr, $rightScopeForNode), $scope, $storage, $context); + + // the single-pass payoff, mirrored from BooleanAndHandler: the right side + // was evaluated on the left-falsey scope — no re-walk, no depth cap + $typeCallback = static function (Expr $e, MutatingScope $s) use ($leftResult, $rightResult): Type { + if (!$e instanceof BooleanOr && !$e instanceof LogicalOr) { + throw new ShouldNotHappenException(); + } + + $leftBooleanType = $leftResult->getTypeForScope($s)->toBoolean(); + if ($leftBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + + $rightBooleanType = $rightResult->getTypeForScope($s)->toBoolean(); + if ($rightBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + + if ( + $leftBooleanType->isFalse()->yes() + && $rightBooleanType->isFalse()->yes() + ) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + }; return new ExpressionResult( $leftMergedWithRightScope, @@ -309,9 +343,104 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), - truthyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByFalseyValue($expr->right), + // incremental falsey scope: the right operand was evaluated on the + // left-falsey scope, so its falsey scope IS the whole disjunction's — + // no re-derivation, no cross-arm combination (and no representational + // drift from re-uniting per-arm types). The truthy scope cannot be + // composed this way (A || B truthy needs both arms) — specify path. + falseyScopeCallback: static fn (): MutatingScope => $rightResult->getFalseyScope(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt, $leftResult, $rightResult, $leftFalseyScope), ); } + /** + * New-world copy of specifyTypes(): child narrowing comes from the child + * ExpressionResults — the recursion is structural, so deep chains compose + * linearly and the flattened fast path is not needed. The normalize/ + * conditional-holder helper code resolves narrowing originals with + * $scope->getType() — those asks are priced through adapters seeded with + * the operand results (fresh storage per ask, NEW_WORLD.md §3.11). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResult $leftResult, ExpressionResult $rightResult, MutatingScope $leftFalseyScope): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $leftResult, $rightResult, $leftFalseyScope): SpecifiedTypes { + if (!$e instanceof BooleanOr && !$e instanceof LogicalOr) { + throw new ShouldNotHappenException(); + } + + if ($ctx->null()) { + return (new SpecifiedTypes([], []))->setRootExpr($e); + } + + // each adapter is seeded only with the result evaluated on its base + // scope — a result's memoized type is its evaluation-point type, so + // seeding it under another base would answer asks about narrowing + // originals with already-narrowed types. Other asks re-process on the + // base scope (ResultAwareScope tier 4) + $adapterStorage = new ExpressionResultStorage(); + $scopeAdapter = $s->toResultAwareScope([$s->getNodeKey($e->left) => $leftResult], $nodeScopeResolver, $stmt, $adapterStorage); + $rightScopeAdapter = $leftFalseyScope->toResultAwareScope([$s->getNodeKey($e->right) => $rightResult], $nodeScopeResolver, $stmt, $adapterStorage); + + $leftTypes = $this->specifyChildTypes($leftResult, $e->left, $s, $scopeAdapter, $ctx)->setRootExpr($e); + $rightTypes = $this->specifyChildTypes($rightResult, $e->right, $leftFalseyScope, $rightScopeAdapter, $ctx)->setRootExpr($e); + + if ($ctx->true()) { + if ( + $leftResult->getTypeForScope($s)->toBoolean()->isFalse()->yes() + ) { + $types = $rightTypes->normalize($rightScopeAdapter); + } elseif ( + $leftResult->getTypeForScope($s)->toBoolean()->isTrue()->yes() + || $rightResult->getTypeForScope($s)->toBoolean()->isFalse()->yes() + ) { + $types = $leftTypes->normalize($scopeAdapter); + } else { + $leftNormalized = $leftTypes->normalize($scopeAdapter); + $rightNormalized = $rightTypes->normalize($rightScopeAdapter); + $types = $leftNormalized->intersectWith($rightNormalized); + $types = $this->augmentBooleanOrTruthyWithConditionalHolders($this->typeSpecifier, $scopeAdapter, $rightScopeAdapter, $e, $types); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($scopeAdapter, $rightScopeAdapter, $leftNormalized, $rightNormalized, $e->left, $e->right, true, $types); + } + } else { + $types = $leftTypes->unionWith($rightTypes); + } + + if ($ctx->true()) { + $result = new SpecifiedTypes( + $types->getSureTypes(), + $types->getSureNotTypes(), + ); + if ($types->shouldOverwrite()) { + $result = $result->setAlwaysOverwriteTypes(); + } + return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $leftTypes, $rightTypes, false, false, $rightScopeAdapter, $e->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $rightTypes, $leftTypes, false, false, $scopeAdapter, $e->left), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $leftTypes, $rightTypes, true, false, $rightScopeAdapter, $e->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scopeAdapter, $rightTypes, $leftTypes, true, false, $scopeAdapter, $e->left), + ]))->setRootExpr($e); + } + + return $types; + }; + } + + /** + * A child's narrowing from its ExpressionResult; not-yet-migrated children + * take the old-world dispatcher with the adapter scope, keeping their inner + * type lookups unguarded. + */ + private function specifyChildTypes(ExpressionResult $result, Expr $child, MutatingScope $scope, ResultAwareScope $adapterScope, TypeSpecifierContext $context): SpecifiedTypes + { + if ($result->hasSpecifiedTypesCallback()) { + return $result->getSpecifiedTypes($scope, $context); + } + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $child, $context); + } + } diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index cbc3d70fcb0..de38856bad2 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -15,6 +15,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -23,6 +24,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NullType; use PHPStan\Type\Type; @@ -35,6 +37,8 @@ final class CastHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -55,11 +59,69 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $this->createTypeCallback($exprResult), + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt), ); } + /** + * @return callable(Expr, MutatingScope): Type + */ + private function createTypeCallback(ExpressionResult $exprResult): callable + { + return function (Expr $e, MutatingScope $s) use ($exprResult): Type { + if (!$e instanceof Cast) { + throw new ShouldNotHappenException(); + } + + if ($e instanceof Cast\Unset_) { + return new NullType(); + } + + return $this->initializerExprTypeResolver->getCastType($e, static function (Expr $inner) use ($e, $exprResult, $s): Type { + if ($inner === $e->expr) { + return $exprResult->getTypeForScope($s); + } + + return $s->getType($inner); + }); + }; + } + + /** + * New-world copy of specifyTypes(): the old comparison synthetics, processed + * through the migrated handlers on demand (ResultAwareScope tier 4, + * unseeded — §3.13). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof Cast) { + throw new ShouldNotHappenException(); + } + + $conditionExpr = null; + if ($e instanceof Cast\Bool_) { + $conditionExpr = new Equal($e->expr, new ConstFetch(new FullyQualified('true'))); + } elseif ($e instanceof Cast\Int_) { + $conditionExpr = new NotEqual($e->expr, new Int_(0)); + } elseif ($e instanceof Cast\Double) { + $conditionExpr = new NotEqual($e->expr, new Float_(0.0)); + } + + if ($conditionExpr === null) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $conditionExpr, $ctx)->setRootExpr($e); + }; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { if ($expr instanceof Cast\Unset_) { diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 4fdfc9adfc6..e2e47c83b1f 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -19,6 +19,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Type; use function array_merge; @@ -33,6 +34,7 @@ final class CastStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -48,7 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $exprResult->getImpurePoints(); $throwPoints = $exprResult->getThrowPoints(); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $exprResult->getType(), $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); @@ -60,11 +62,46 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $this->createTypeCallback($exprResult), + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof Cast\String_) { + throw new ShouldNotHappenException(); + } + + // the old synthetic, processed through the migrated handlers on + // demand (ResultAwareScope tier 4, unseeded — §3.13) + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->typeSpecifier->specifyTypesInCondition( + $adapterScope, + new NotEqual($e->expr, new String_('')), + $ctx, + )->setRootExpr($e); + }, ); } + /** + * @return callable(Expr, MutatingScope): Type + */ + private function createTypeCallback(ExpressionResult $exprResult): callable + { + return function (Expr $e, MutatingScope $s) use ($exprResult): Type { + if (!$e instanceof Cast) { + throw new ShouldNotHappenException(); + } + + return $this->initializerExprTypeResolver->getCastType($e, static function (Expr $inner) use ($e, $exprResult, $s): Type { + if ($inner === $e->expr) { + return $exprResult->getTypeForScope($s); + } + + return $s->getType($inner); + }); + }; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { return $this->initializerExprTypeResolver->getCastType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index 25199ed14f7..97069084f76 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -18,6 +19,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\MixedType; use PHPStan\Type\Type; use function array_merge; @@ -31,6 +33,7 @@ final class ClassConstFetchHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -61,6 +64,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $isAlwaysTerminating = false; + $classResult = null; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $classResult->getScope(); @@ -89,8 +93,30 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: function (Expr $e, MutatingScope $s) use ($classResult): Type { + if (!$e instanceof ClassConstFetch) { + throw new ShouldNotHappenException(); + } + + if (!$e->name instanceof Identifier) { + return new MixedType(); + } + + return $this->initializerExprTypeResolver->getClassConstFetchTypeByReflection( + $e->class, + $e->name->name, + $s->isInClass() ? $s->getClassReflection() : null, + static function (Expr $inner) use ($e, $classResult, $s): Type { + if ($classResult !== null && $inner === $e->class) { + return $classResult->getTypeForScope($s); + } + + return $s->getType($inner); + }, + ); + }, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/CloneHandler.php b/src/Analyser/ExprHandler/CloneHandler.php index 9d2f1bf6574..9032fa958fd 100644 --- a/src/Analyser/ExprHandler/CloneHandler.php +++ b/src/Analyser/ExprHandler/CloneHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -29,6 +30,10 @@ final class CloneHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Clone_; @@ -44,6 +49,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + expr: $expr, + typeCallback: static function (Expr $e, MutatingScope $s) use ($exprResult): Type { + $cloneType = TypeCombinator::intersect($exprResult->getTypeForScope($s), new ObjectWithoutClassType()); + return TypeTraverser::map($cloneType, new CloneTypeTraverser()); + }, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index eb566e0166d..bbda0942d64 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -9,9 +9,11 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -34,6 +36,8 @@ final class CoalesceHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -112,31 +116,132 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e return (new SpecifiedTypes([], []))->setRootExpr($expr); } + /** + * The coalesce's non-truthy narrowing: when the left is provably set, a + * non-truthy `left ?? right` narrows the left to null (the right side ran). + */ + private function specifyCoalesceFalseyTypes(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, MutatingScope $scope, Coalesce $expr, TypeSpecifierContext $context): SpecifiedTypes + { + $adapterScope = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + $isset = $adapterScope->issetCheck($expr->left, static fn () => true); + + if ($isset !== true) { + return new SpecifiedTypes(); + } + + return $this->typeSpecifier->create( + $expr->left, + new NullType(), + $context->negate(), + $adapterScope, + )->setRootExpr($expr); + } + public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->left); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->left, static fn (MutatingScope $askedScope): MutatingScope => $askedScope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)); $condScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->left); $condResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $condScope, $storage, $nodeCallback, $context->enterDeep()); $scope = $this->nonNullabilityHelper->revertNonNullability($condResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $expr->left); - $rightScope = $scope->filterByFalseyValue($expr); + // the isset(left) synthetic routes through the migrated IssetHandler — + // processed once, its narrowing applied instead of the guarded filters + $issetLeftExpr = new Expr\Isset_([$expr->left]); + $issetResult = $nodeScopeResolver->processExprNode($stmt, $issetLeftExpr, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep()); + // the right side runs when the left is null/unset — the coalesce's own + // falsey narrowing (left narrowed to null when isset-certain), NOT the + // isset falsey (which would unset the left and poison its certainty) + // the coalesce's own falsey narrowing — left narrowed to null when + // isset-certain. No seeds: the left result was evaluated on the + // non-nullability-ensured scope, so its memoized type is already + // null-stripped — originals must resolve from the holders (§3.13) + $rightScope = $scope->applySpecifiedTypes( + $this->specifyCoalesceFalseyTypes($nodeScopeResolver, $stmt, $scope, $expr, TypeSpecifierContext::createFalsey()), + [], + ); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $rightScope, $storage, $nodeCallback, $context->enterDeep()); - $rightExprType = $scope->getType($expr->right); + $rightExprType = $rightResult->getType(); + $issetTruthyScope = $scope->applySpecifiedTypes( + $issetResult->getSpecifiedTypes($scope, TypeSpecifierContext::createTruthy()), + $issetResult->getExprResultsForApply(), + ); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { - $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left])); + $scope = $issetTruthyScope; } else { - $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left]))->mergeWith($rightResult->getScope()); + $scope = $issetTruthyScope->mergeWith($rightResult->getScope()); } + $typeCallback = function (Expr $e, MutatingScope $s) use ($nodeScopeResolver, $stmt, $condResult, $rightResult, $issetResult): Type { + if (!$e instanceof Coalesce) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + $result = $adapterScope->issetCheck($e->left, static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + + if ($result !== null && $result !== false) { + return TypeCombinator::removeNull($condResult->getTypeOnScope($issetResult->getTruthyScope())); + } + + $rightType = $rightResult->getTypeForScope($s); + + if ($result === null) { + return TypeCombinator::union( + TypeCombinator::removeNull($condResult->getTypeOnScope($issetResult->getTruthyScope())), + $rightType, + ); + } + + return $rightType; + }; + + $specifyTypesCallback = function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $rightResult): SpecifiedTypes { + if (!$e instanceof Coalesce) { + throw new ShouldNotHappenException(); + } + + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + if (!$ctx->true()) { + return $this->specifyCoalesceFalseyTypes($nodeScopeResolver, $stmt, $s, $e, $ctx); + } + + if ((new ConstantBooleanType(false))->isSuperTypeOf($rightResult->getTypeForScope($s)->toBoolean())->yes()) { + return $this->typeSpecifier->create( + $e->left, + new NullType(), + TypeSpecifierContext::createFalse(), + $adapterScope, + )->setRootExpr($e); + } + + // The Coalesce condition matched but produced no narrowing; the legacy + // if/elseif chain fell through to its empty-SpecifiedTypes tail here, + // not to the truthy/falsey default. + return (new SpecifiedTypes([], []))->setRootExpr($e); + }; + return new ExpressionResult( $scope, hasYield: $condResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $condResult->isAlwaysTerminating(), throwPoints: array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($condResult->getImpurePoints(), $rightResult->getImpurePoints()), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } diff --git a/src/Analyser/ExprHandler/ConstFetchHandler.php b/src/Analyser/ExprHandler/ConstFetchHandler.php index ba5ae2c71e1..136063fbf0b 100644 --- a/src/Analyser/ExprHandler/ConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ConstFetchHandler.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -18,6 +19,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\ErrorType; use PHPStan\Type\NullType; @@ -33,6 +35,7 @@ final class ConstFetchHandler implements ExprHandler public function __construct( private ConstantResolver $constantResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -52,11 +55,60 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: fn (Expr $e, MutatingScope $s): Type => $this->resolveConstFetchType($s, $e), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } + /** + * New-world copy of resolveType(): true/false/null literals, then + * holder-tracked runtime constants, then the ConstantResolver — all + * unguarded reads. + */ + private function resolveConstFetchType(MutatingScope $scope, Expr $expr): Type + { + if (!$expr instanceof ConstFetch) { + throw new ShouldNotHappenException(); + } + + $constName = (string) $expr->name; + $loweredConstName = strtolower($constName); + if ($loweredConstName === 'true') { + return new ConstantBooleanType(true); + } elseif ($loweredConstName === 'false') { + return new ConstantBooleanType(false); + } elseif ($loweredConstName === 'null') { + return new NullType(); + } + + $namespacedName = null; + if (!$expr->name->isFullyQualified() && $scope->getNamespace() !== null) { + $namespacedName = new FullyQualified([$scope->getNamespace(), $expr->name->toString()]); + } + $globalName = new FullyQualified($expr->name->toString()); + + foreach ([$namespacedName, $globalName] as $name) { + if ($name === null) { + continue; + } + $constFetch = new ConstFetch($name); + if ($scope->hasExpressionType($constFetch)->yes()) { + return $this->constantResolver->resolveConstantType( + $name->toString(), + $scope->expressionTypes[$scope->getNodeKey($constFetch)]->getType(), + ); + } + } + + $constantType = $this->constantResolver->resolveConstant($expr->name, $scope); + if ($constantType !== null) { + return $constantType; + } + + return new ErrorType(); + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { $constName = (string) $expr->name; diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 185850e6e6f..08cd1cc7d0a 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -32,6 +32,7 @@ final class EmptyHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -85,7 +86,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->expr); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->expr, static fn (MutatingScope $askedScope): MutatingScope => $askedScope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)); $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->expr); $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); @@ -98,8 +99,49 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: static function (Expr $e, MutatingScope $s) use ($nodeScopeResolver, $stmt): Type { + if (!$e instanceof Expr\Empty_) { + throw new ShouldNotHappenException(); + } + + // issetCheck() walks the expression asking for types — priced + // through an unseeded adapter (ResultAwareScope tiers) + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + $result = $adapterScope->issetCheck($e->expr, static function (Type $type): ?bool { + $isNull = $type->isNull(); + $isFalsey = $type->toBoolean()->isFalse(); + if ($isNull->maybe()) { + return null; + } + if ($isFalsey->maybe()) { + return null; + } + + if ($isNull->yes()) { + return $isFalsey->no(); + } + + return !$isFalsey->yes(); + }); + if ($result === null) { + return new BooleanType(); + } + + return new ConstantBooleanType(!$result); + }, + // the old specifyTypes() body stays the single source (the BinaryOp + // precedent) — its `!isset(X) || !X` synthetic routes through the + // migrated handlers via the adapter's synthetic processing + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof Expr\Empty_) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $adapterScope, $e, $ctx); + }, ); } diff --git a/src/Analyser/ExprHandler/ErrorSuppressHandler.php b/src/Analyser/ExprHandler/ErrorSuppressHandler.php index 0e2e47fee9c..bc2efde5215 100644 --- a/src/Analyser/ExprHandler/ErrorSuppressHandler.php +++ b/src/Analyser/ExprHandler/ErrorSuppressHandler.php @@ -16,6 +16,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -25,6 +26,10 @@ final class ErrorSuppressHandler implements ExprHandler { + public function __construct(private TypeSpecifier $typeSpecifier) + { + } + public function supports(Expr $expr): bool { return $expr instanceof ErrorSuppress; @@ -42,9 +47,36 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex impurePoints: $exprResult->getImpurePoints(), truthyScopeCallback: static fn (): MutatingScope => $exprResult->getTruthyScope(), falseyScopeCallback: static fn (): MutatingScope => $exprResult->getFalseyScope(), + expr: $expr, + typeCallback: static fn (Expr $e, MutatingScope $s): Type => $exprResult->getTypeForScope($s), + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt, $exprResult), ); } + /** + * The suppressed expression's narrowing as-is; a not-yet-migrated inner + * takes the old-world dispatcher with an unseeded adapter (the inner must + * be evaluated on the ask scope, NEW_WORLD.md paragraph 3.13). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResult $exprResult): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $exprResult): SpecifiedTypes { + if (!$e instanceof ErrorSuppress) { + throw new ShouldNotHappenException(); + } + + if ($exprResult->hasSpecifiedTypesCallback()) { + return $exprResult->getSpecifiedTypes($s, $ctx)->setRootExpr($e); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $e->expr, $ctx)->setRootExpr($e); + }; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { return $scope->getType($expr->expr); diff --git a/src/Analyser/ExprHandler/EvalHandler.php b/src/Analyser/ExprHandler/EvalHandler.php index c2e635c4880..7255d19a2e7 100644 --- a/src/Analyser/ExprHandler/EvalHandler.php +++ b/src/Analyser/ExprHandler/EvalHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -29,6 +30,10 @@ final class EvalHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Eval_; @@ -50,6 +55,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'eval', 'eval', true)]), + expr: $expr, + typeCallback: static fn (): Type => new MixedType(), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/ExitHandler.php b/src/Analyser/ExprHandler/ExitHandler.php index 7734d8dea34..906fad57b08 100644 --- a/src/Analyser/ExprHandler/ExitHandler.php +++ b/src/Analyser/ExprHandler/ExitHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -28,6 +29,10 @@ final class ExitHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Exit_; @@ -57,6 +62,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: true, throwPoints: $throwPoints, impurePoints: $impurePoints, + expr: $expr, + typeCallback: static fn (): Type => new NonAcceptingNeverType(), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index d28a3225dee..29226ffcbb7 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -18,6 +18,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\VoidToNullTypeTransformer; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; @@ -32,10 +33,12 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Type\DynamicReturnTypeExtensionRegistryProvider; use PHPStan\DependencyInjection\Type\DynamicThrowTypeExtensionProvider; +use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; use PHPStan\Node\ClosureReturnStatementsNode; use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\PossiblyImpureCallExpr; use PHPStan\Node\Expr\TypeExpr; +use PHPStan\Reflection\Assertions; use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; @@ -44,16 +47,21 @@ use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\Reflection\ResolvedFunctionVariant; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\HasPropertyType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; use PHPStan\Type\ClosureType; +use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\ErrorType; use PHPStan\Type\GeneralizePrecision; +use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\TemplateTypeVarianceMap; @@ -67,9 +75,12 @@ use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeTraverser; use PHPStan\Type\UnionType; use Throwable; use function array_filter; +use function array_key_exists; +use function array_last; use function array_map; use function array_merge; use function array_slice; @@ -79,6 +90,8 @@ use function is_string; use function sprintf; use function str_starts_with; +use function strtolower; +use function substr; /** * @implements ExprHandler @@ -89,6 +102,9 @@ final class FuncCallHandler implements ExprHandler public function __construct( private ReflectionProvider $reflectionProvider, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, private DynamicThrowTypeExtensionProvider $dynamicThrowTypeExtensionProvider, private DynamicReturnTypeExtensionRegistryProvider $dynamicReturnTypeExtensionRegistryProvider, #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] @@ -111,18 +127,20 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; + $nameResult = null; if ($expr->name instanceof Expr) { - $nameType = $scope->getType($expr->name); + $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); + $nameType = $nameResult->getType(); if (!$nameType->isCallable()->no()) { + $adapterScope = $this->createAdapterScope($expr, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage); $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, + $adapterScope, $expr->getArgs(), - $nameType->getCallableParametersAcceptors($scope), + $nameType->getCallableParametersAcceptors($adapterScope), null, ); } - $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $nameResult->getScope(); $throwPoints = $nameResult->getThrowPoints(); $impurePoints = $nameResult->getImpurePoints(); @@ -146,7 +164,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } elseif ($this->reflectionProvider->hasFunction($expr->name, $scope)) { $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, + $this->createAdapterScope($expr, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage), $expr->getArgs(), $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants(), @@ -275,6 +293,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scopeBeforeArgs = $scope; $argsResult = $nodeScopeResolver->processArgs($stmt, $functionReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallbackForArgs, $context); $scope = $argsResult->getScope(); + // extensions price arguments at the call point — before the call's own + // virtual mutations (array_shift's shifted arg, invalidations) hit the scope + $scopeAfterArgs = $scope; $hasYield = $argsResult->hasYield(); $throwPoints = array_merge($throwPoints, $argsResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); @@ -297,7 +318,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($normalizedExpr->name instanceof Expr) { - $nameType = $scope->getType($normalizedExpr->name); + $nameType = $nameResult !== null && $normalizedExpr->name === $expr->name + ? $nameResult->getType() + : $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage())->getType($normalizedExpr->name); if ( $nameType->isObject()->yes() && $nameType->isCallable()->yes() @@ -318,7 +341,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($functionReflection !== null) { - $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + $normalizedExprForThrowPoint = $normalizedExpr; + $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, fn (): Type => $this->resolveTypeViaResults($normalizedExprForThrowPoint, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage, $scopeAfterArgs), $this->createAdapterScope($expr, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage)); if ($functionThrowPoint !== null) { $throwPoints[] = $functionThrowPoint; } @@ -571,23 +595,549 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->afterOpenSslCall($functionReflection->getName()); } + // the result lives in $storage — capturing it would make every stored + // FuncCall result a reference cycle only the cyclic GC can free, and one + // call anywhere in an expression makes the whole ancestor graph cyclic + // (measured as the entire 4x analysis slowdown). Late asks build their + // adapter on a fresh storage instead: the synthetics-in-flight cycle + // guard threads through it, only known-result seeding is lost + $typeCallback = function (Expr $e, MutatingScope $s) use ($nodeScopeResolver, $stmt, $nameResult, $scopeAfterArgs): Type { + if (!$e instanceof FuncCall) { + throw new ShouldNotHappenException(); + } + + return $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, new ExpressionResultStorage(), $scopeAfterArgs); + }; + return new ExpressionResult( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $nameResult, $scopeAfterArgs): SpecifiedTypes { + if (!$e instanceof FuncCall) { + throw new ShouldNotHappenException(); + } + + return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, $nodeScopeResolver, $stmt, new ExpressionResultStorage(), $scopeAfterArgs); + }, + expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, + ); + } + + /** + * Builds the ResultAwareScope for extension/selector invocations, seeded with + * a self-result so that code asking about the call currently being resolved + * (e.g. is_int()-family return type extensions going through + * ImpossibleCheckTypeHelper) is answered from the call's own narrowing + * instead of re-processing the call — which would recurse forever. + */ + private function createAdapterScope( + FuncCall $expr, + MutatingScope $scope, + ?ExpressionResult $nameResult, + NodeScopeResolver $nodeScopeResolver, + Stmt $stmt, + ExpressionResultStorage $storage, + ): MutatingScope + { + $selfResult = new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $expr, + typeCallback: function (Expr $e, MutatingScope $s) use ($nameResult, $nodeScopeResolver, $stmt, $storage, $scope): Type { + if (!$e instanceof FuncCall) { + throw new ShouldNotHappenException(); + } + + return $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage, $scope); + }, + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nameResult, $nodeScopeResolver, $stmt, $storage, $scope): SpecifiedTypes { + if (!$e instanceof FuncCall) { + throw new ShouldNotHappenException(); + } + + return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, $nodeScopeResolver, $stmt, $storage, $scope); + }, + ); + + $exprResults = [$scope->getNodeKey($expr) => $selfResult]; + if ($nameResult !== null && $expr->name instanceof Expr) { + $exprResults[$scope->getNodeKey($expr->name)] = $nameResult; + } + + return $scope->toResultAwareScope($exprResults, $nodeScopeResolver, $stmt, $storage); + } + + /** + * New-world copy of resolveType(): resolves the call's return type from + * already-known ExpressionResults. ResultAwareScope is used only at the + * sanctioned boundaries — extension invocations and ParametersAcceptorSelector. + */ + private function resolveTypeViaResults( + FuncCall $expr, + MutatingScope $scope, + ?ExpressionResult $nameResult, + NodeScopeResolver $nodeScopeResolver, + Stmt $stmt, + ExpressionResultStorage $storage, + MutatingScope $callSiteScope, + ): Type + { + $adapterBase = $callSiteScope; + if ($scope->nativeTypesPromoted) { + $promotedCallSiteScope = $callSiteScope->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedCallSiteScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + $adapterBase = $promotedCallSiteScope; + } + $adapterScope = $this->createAdapterScope($expr, $adapterBase, $nameResult, $nodeScopeResolver, $stmt, $storage); + + if ($expr->name instanceof Expr) { + if ($nameResult === null) { + throw new ShouldNotHappenException(); + } + + $calledOnType = $nameResult->getTypeForScope($scope); + if ($calledOnType->isCallable()->no()) { + return new ErrorType(); + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $adapterScope, + $expr->getArgs(), + $calledOnType->getCallableParametersAcceptors($adapterScope), + null, + ); + + $functionName = null; + if ($expr->name instanceof String_) { + /** @var non-empty-string $name */ + $name = $expr->name->value; + $functionName = new Name($name); + } elseif ( + $expr->name instanceof FuncCall + && $expr->name->name instanceof Name + && $expr->name->isFirstClassCallable() + ) { + $functionName = $expr->name->name; + } + + $normalizedNode = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr); + if ($normalizedNode !== null && $functionName !== null && $this->reflectionProvider->hasFunction($functionName, $scope)) { + $functionReflection = $this->reflectionProvider->getFunction($functionName, $scope); + $resolvedType = $this->getDynamicFunctionReturnType($adapterScope, $normalizedNode, $functionReflection); + if ($resolvedType !== null) { + return $resolvedType; + } + } + + return $parametersAcceptor->getReturnType(); + } + + if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { + return new ErrorType(); + } + + $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); + if ($scope->nativeTypesPromoted) { + return ParametersAcceptorSelector::combineAcceptors($functionReflection->getVariants())->getNativeReturnType(); + } + + if ($functionReflection->getName() === 'call_user_func') { + $result = ArgumentsNormalizer::reorderCallUserFuncArguments($expr, $adapterScope); + if ($result !== null) { + [, $innerFuncCall] = $result; + + return $nodeScopeResolver->processExprNode($stmt, $innerFuncCall, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getTypeForScope($scope); + } + } + + if ($functionReflection->getName() === 'call_user_func_array') { + $result = ArgumentsNormalizer::reorderCallUserFuncArrayArguments($expr, $adapterScope); + if ($result !== null) { + [, $innerFuncCall] = $result; + + return $nodeScopeResolver->processExprNode($stmt, $innerFuncCall, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getTypeForScope($scope); + } + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $adapterScope, + $expr->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + $normalizedNode = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr); + if ($normalizedNode !== null) { + if ($functionReflection->getName() === 'clone' && count($normalizedNode->getArgs()) > 0) { + $cloneType = $nodeScopeResolver->processExprNode($stmt, new Expr\Clone_($normalizedNode->getArgs()[0]->value), $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getTypeForScope($scope); + if (count($normalizedNode->getArgs()) === 2) { + $propertiesType = $adapterScope->getType($normalizedNode->getArgs()[1]->value); + if ($propertiesType->isConstantArray()->yes()) { + $constantArrays = $propertiesType->getConstantArrays(); + if (count($constantArrays) === 1) { + $accessories = []; + foreach ($constantArrays[0]->getKeyTypes() as $keyType) { + $constantKeyTypes = $keyType->getConstantScalarValues(); + if (count($constantKeyTypes) !== 1) { + return $cloneType; + } + $accessories[] = new HasPropertyType((string) $constantKeyTypes[0]); + } + if (count($accessories) > 0 && count($accessories) <= 16) { + return TypeCombinator::intersect($cloneType, ...$accessories); + } + } + } + } + + return $cloneType; + } + $resolvedType = $this->getDynamicFunctionReturnType($adapterScope, $normalizedNode, $functionReflection); + if ($resolvedType !== null) { + return $resolvedType; + } + } + + return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $expr); + } + + /** + * New-world copy of specifyTypes(). Conditional-return-type and assert + * narrowing still delegate to TypeSpecifier helpers (with the adapter) — + * to be ported before the old world is deleted. + * + */ + private function specifyTypesViaResults( + FuncCall $expr, + MutatingScope $scope, + TypeSpecifierContext $context, + ?ExpressionResult $nameResult, + NodeScopeResolver $nodeScopeResolver, + Stmt $stmt, + ExpressionResultStorage $storage, + MutatingScope $callSiteScope, + ): SpecifiedTypes + { + if (!$expr->name instanceof Name) { + // dynamic-name calls: the old-world body invoked directly for now + // (guarded except via an adapter) — re-dispatching through + // specifyTypesInCondition would bounce an incoming adapter scope + // straight back to this callback (seeded self-result) forever + $specifiedTypes = $this->specifyTypesFromCallableCall($this->typeSpecifier, $context, $expr, $scope); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + + return $this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + } + + $adapterBase = $callSiteScope; + if ($scope->nativeTypesPromoted) { + $promotedCallSiteScope = $callSiteScope->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedCallSiteScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + $adapterBase = $promotedCallSiteScope; + } + $adapterScope = $this->createAdapterScope($expr, $adapterBase, $nameResult, $nodeScopeResolver, $stmt, $storage); + + if ($this->reflectionProvider->hasFunction($expr->name, $scope)) { + // lazy create parametersAcceptor, as creation can be expensive + $parametersAcceptor = null; + + $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); + $normalizedExpr = $expr; + $args = $expr->getArgs(); + if (count($args) > 0) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($adapterScope, $args, $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); + $normalizedExpr = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr) ?? $expr; + } + + foreach ($this->typeSpecifier->getFunctionTypeSpecifyingExtensions() as $extension) { + if (!$extension->isFunctionSupported($functionReflection, $normalizedExpr, $context)) { + continue; + } + + return $extension->specifyTypes($functionReflection, $normalizedExpr, $adapterScope, $context); + } + + if (count($args) > 0) { + $specifiedTypes = $this->specifyTypesFromConditionalReturnTypeViaResults($context, $expr, $parametersAcceptor, $adapterScope); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + } + + $assertions = $functionReflection->getAsserts(); + if ($assertions->getAll() !== []) { + $parametersAcceptor ??= ParametersAcceptorSelector::selectFromArgs($adapterScope, $args, $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); + + $asserts = $assertions->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( + $type, + $parametersAcceptor->getResolvedTemplateTypeMap(), + $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createInvariant(), + )); + $specifiedTypes = $this->specifyTypesFromAssertsViaResults($context, $expr, $asserts, $parametersAcceptor, $adapterScope); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + } + + // default narrowing with the purity gate mirroring TypeSpecifier::createForExpr() + $hasSideEffects = $functionReflection->hasSideEffects(); + if ($hasSideEffects->yes() || (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no())) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + /** + * @param callable(): Type $returnTypeCallback + */ + /** + * New-world copy of TypeSpecifier::specifyTypesFromConditionalReturnType(). + * The passed scope is a ResultAwareScope, which keeps the @api + * TypeSpecifier::create()/specifyTypesInCondition() calls in the new world. + */ + private function specifyTypesFromConditionalReturnTypeViaResults( + TypeSpecifierContext $context, + Expr\CallLike $call, + ?ParametersAcceptor $parametersAcceptor, + MutatingScope $scope, + ): ?SpecifiedTypes + { + if (!$parametersAcceptor instanceof ResolvedFunctionVariant) { + return null; + } + + $returnType = $parametersAcceptor->getOriginalParametersAcceptor()->getReturnType(); + if (!$returnType instanceof ConditionalTypeForParameter) { + return null; + } + + if ($context->true()) { + $leftType = new ConstantBooleanType(true); + $rightType = new ConstantBooleanType(false); + } elseif ($context->false()) { + $leftType = new ConstantBooleanType(false); + $rightType = new ConstantBooleanType(true); + } elseif ($context->null()) { + $leftType = new MixedType(); + $rightType = new NeverType(); + } else { + return null; + } + + $argumentExpr = null; + $parameters = $parametersAcceptor->getParameters(); + foreach ($call->getArgs() as $i => $arg) { + if ($arg->unpack) { + continue; + } + + if ($arg->name !== null) { + $paramName = $arg->name->toString(); + } elseif (isset($parameters[$i])) { + $paramName = $parameters[$i]->getName(); + } else { + continue; + } + + if ($returnType->getParameterName() !== '$' . $paramName) { + continue; + } + + $argumentExpr = $arg->value; + } + + if ($argumentExpr === null) { + return null; + } + + $targetType = $returnType->getTarget(); + $ifType = $returnType->getIf(); + $elseType = $returnType->getElse(); + + if ( + ( + $argumentExpr instanceof Node\Scalar + || ($argumentExpr instanceof Expr\ConstFetch && in_array(strtolower($argumentExpr->name->toString()), ['true', 'false', 'null'], true)) + ) && ($ifType instanceof NeverType || $elseType instanceof NeverType) + ) { + return null; + } + + if ($leftType->isSuperTypeOf($ifType)->yes() && $rightType->isSuperTypeOf($elseType)->yes()) { + $conditionContext = $returnType->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(); + } elseif ($leftType->isSuperTypeOf($elseType)->yes() && $rightType->isSuperTypeOf($ifType)->yes()) { + $conditionContext = $returnType->isNegated() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + } else { + return null; + } + + $specifiedTypes = $this->typeSpecifier->create( + $argumentExpr, + $targetType, + $conditionContext, + $scope, ); + + if ($targetType->isTrue()->yes() || $targetType->isFalse()->yes()) { + if ($targetType->isFalse()->yes()) { + $conditionContext = $conditionContext->negate(); + } + + $specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->specifyTypesInCondition($scope, $argumentExpr, $conditionContext)); + } + + return $specifiedTypes; + } + + /** + * New-world copy of TypeSpecifier::specifyTypesFromAsserts(). + */ + private function specifyTypesFromAssertsViaResults(TypeSpecifierContext $context, Expr\CallLike $call, Assertions $assertions, ParametersAcceptor $parametersAcceptor, MutatingScope $scope): ?SpecifiedTypes + { + if ($context->null()) { + $asserts = $assertions->getAsserts(); + } elseif ($context->true()) { + $asserts = $assertions->getAssertsIfTrue(); + } elseif ($context->false()) { + $asserts = $assertions->getAssertsIfFalse(); + } else { + throw new ShouldNotHappenException(); + } + + if (count($asserts) === 0) { + return null; + } + + $argsMap = []; + $parameters = $parametersAcceptor->getParameters(); + foreach ($call->getArgs() as $i => $arg) { + if ($arg->unpack) { + continue; + } + + if ($arg->name !== null) { + $paramName = $arg->name->toString(); + } elseif (isset($parameters[$i])) { + $paramName = $parameters[$i]->getName(); + } elseif (count($parameters) > 0 && $parametersAcceptor->isVariadic()) { + $lastParameter = array_last($parameters); + $paramName = $lastParameter->getName(); + } else { + continue; + } + + $argsMap[$paramName][] = $arg->value; + } + foreach ($parameters as $parameter) { + $name = $parameter->getName(); + $defaultValue = $parameter->getDefaultValue(); + if (isset($argsMap[$name]) || $defaultValue === null) { + continue; + } + $argsMap[$name][] = new TypeExpr($defaultValue); + } + + if ($call instanceof MethodCall) { + $argsMap['this'] = [$call->var]; + } + + /** @var SpecifiedTypes|null $types */ + $types = null; + + foreach ($asserts as $assert) { + foreach ($argsMap[substr($assert->getParameter()->getParameterName(), 1)] ?? [] as $parameterExpr) { + $assertedType = TypeTraverser::map($assert->getType(), static function (Type $type, callable $traverse) use ($argsMap, $scope): Type { + if ($type instanceof ConditionalTypeForParameter) { + $parameterName = substr($type->getParameterName(), 1); + if (array_key_exists($parameterName, $argsMap)) { + $type = $traverse($type); + if ($type instanceof ConditionalTypeForParameter) { + $argType = TypeCombinator::union(...array_map(static fn (Expr $expr) => $scope->getType($expr), $argsMap[substr($type->getParameterName(), 1)])); + return $type->toConditional($argType); + } + return $type; + } + } + + return $traverse($type); + }); + + $assertExpr = $assert->getParameter()->getExpr($parameterExpr); + + $templateTypeMap = $parametersAcceptor->getResolvedTemplateTypeMap(); + $containsUnresolvedTemplate = false; + TypeTraverser::map( + $assert->getOriginalType(), + static function (Type $type, callable $traverse) use ($templateTypeMap, &$containsUnresolvedTemplate) { + if ($type instanceof TemplateType && $type->getScope()->getClassName() !== null) { + $resolvedType = $templateTypeMap->getType($type->getName()); + if ($resolvedType === null || $type->getBound()->equals($resolvedType)) { + $containsUnresolvedTemplate = true; + return $type; + } + } + + return $traverse($type); + }, + ); + + $newTypes = $this->typeSpecifier->create( + $assertExpr, + $assertedType, + $assert->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(), + $scope, + )->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null); + $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; + + if (!$context->null() || (!$assertedType->isTrue()->yes() && !$assertedType->isFalse()->yes())) { + continue; + } + + $subContext = $assertedType->isTrue()->yes() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + if ($assert->isNegated()) { + $subContext = $subContext->negate(); + } + + $types = $types->unionWith($this->typeSpecifier->specifyTypesInCondition( + $scope, + $assertExpr, + $subContext, + )); + } + } + + return $types; } + /** + * @param callable(): Type $returnTypeCallback + */ private function getFunctionThrowPoint( FunctionReflection $functionReflection, ?ParametersAcceptor $parametersAcceptor, FuncCall $normalizedFuncCall, MutatingScope $scope, ExpressionContext $context, + callable $returnTypeCallback, + MutatingScope $extensionScope, ): ?InternalThrowPoint { foreach ($this->dynamicThrowTypeExtensionProvider->getDynamicFunctionThrowTypeExtensions() as $extension) { @@ -595,7 +1145,7 @@ private function getFunctionThrowPoint( continue; } - $throwType = $extension->getThrowTypeFromFunctionCall($functionReflection, $normalizedFuncCall, $scope); + $throwType = $extension->getThrowTypeFromFunctionCall($functionReflection, $normalizedFuncCall, $extensionScope); if ($throwType === null) { return null; } @@ -605,7 +1155,7 @@ private function getFunctionThrowPoint( $throwType = $functionReflection->getThrowType(); if ($throwType === null) { - $returnType = $scope->getType($normalizedFuncCall); + $returnType = $returnTypeCallback(); if ($returnType instanceof NeverType && $returnType->isExplicit()) { $throwType = new ObjectType(Throwable::class); } @@ -633,7 +1183,7 @@ private function getFunctionThrowPoint( || $requiredParameters > 0 || count($normalizedFuncCall->getArgs()) > 0 ) { - $functionReturnedType = $scope->getType($normalizedFuncCall); + $functionReturnedType = $returnTypeCallback(); if (!$context->isInThrow() || !(new ObjectType(Throwable::class))->isSuperTypeOf($functionReturnedType)->yes()) { return InternalThrowPoint::createImplicit($scope, $normalizedFuncCall); } diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php new file mode 100644 index 00000000000..aae39806bae --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -0,0 +1,137 @@ +` — they emit the plain-chain + * variant alongside their own key once, and every parent simply composes + * their results. No recursive chain-walking, no type ask. + */ +#[AutowiredService] +final class DefaultNarrowingHelper +{ + + public function __construct( + private ExprPrinter $exprPrinter, + private TypeSpecifier $typeSpecifier, + ) + { + } + + public function specifyDefaultTypes(Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + { + if ($context->null()) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + if (!$context->truthy()) { + $removedType = StaticTypeFactory::truthy(); + } elseif (!$context->falsey()) { + $removedType = StaticTypeFactory::falsey(); + } else { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + return (new SpecifiedTypes(sureNotTypes: [ + $this->exprPrinter->printExpr($expr) => [$expr, $removedType], + ]))->setRootExpr($expr); + } + + /** + * The narrowing callback for `?->` expressions, shared by + * NullsafePropertyFetchHandler and NullsafeMethodCallHandler — the only two + * places that know about short-circuiting (NEW_WORLD.md §3.10). Emits the + * plain-chain dual key (one structural getNullsafeShortcircuitedExpr call) + * and, when the chain provably executed, a subject-not-null entry. + * + * When the chain executed and a plain call expr is supplied, the plain + * call's own narrowing (method type-specifying extensions, asserts) is + * composed in through the old-world dispatcher with an adapter — what the + * old synthetic `BooleanAnd(var !== null, plainCall)` provided — until + * MethodCallHandler's narrowing migrates. + * + * @param Expr\NullsafePropertyFetch|Expr\NullsafeMethodCall $expr + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + public function createNullsafeSpecifyCallback(Expr $expr, ExpressionResult $varResult, bool $resultNarrowingAllowed = true, ?Expr $plainCallExpr = null, ?NodeScopeResolver $nodeScopeResolver = null, ?Stmt $stmt = null): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($varResult, $resultNarrowingAllowed, $plainCallExpr, $nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof Expr\NullsafePropertyFetch && !$e instanceof Expr\NullsafeMethodCall) { + throw new ShouldNotHappenException(); + } + + if ($ctx->null()) { + return (new SpecifiedTypes([], []))->setRootExpr($e); + } + + if (!$ctx->truthy()) { + $removedType = StaticTypeFactory::truthy(); + $chainExecuted = false; + } elseif (!$ctx->falsey()) { + $removedType = StaticTypeFactory::falsey(); + // a truthy result cannot have come from the short-circuit null + $chainExecuted = true; + } else { + return (new SpecifiedTypes([], []))->setRootExpr($e); + } + + // impure calls are not remembered, so narrowing their result is unsound — + // mirrors the call gate in TypeSpecifier::create() (the subject entry below + // stays: the chain executing says nothing about the result's purity) + $sureNotTypes = []; + if ($resultNarrowingAllowed) { + $sureNotTypes[$this->exprPrinter->printExpr($e)] = [$e, $removedType]; + } + + $varType = $varResult->getTypeForScope($s); + $varCanBeNull = TypeCombinator::containsNull($varType); + + if ($resultNarrowingAllowed && ($chainExecuted || !$varCanBeNull)) { + // the plain-chain variant holds the same narrowing + $plain = NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($e); + if ($plain !== $e) { + $sureNotTypes[$this->exprPrinter->printExpr($plain)] = [$plain, $removedType]; + } + } + + if ($chainExecuted && $varCanBeNull) { + // the chain executed, so the subject is not null + $sureNotTypes[$this->exprPrinter->printExpr($e->var)] = [$e->var, new NullType()]; + } + + $types = (new SpecifiedTypes([], $sureNotTypes))->setRootExpr($e); + + if ($chainExecuted && $plainCallExpr !== null && $nodeScopeResolver !== null && $stmt !== null) { + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + $types = $types->unionWith( + $this->typeSpecifier->specifyTypesInCondition($adapterScope, $plainCallExpr, $ctx)->setRootExpr($e), + ); + } + + return $types; + }; + } + +} diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index eab62846f88..ad0b5a31edc 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; +use PHPStan\Type\Type; use function sprintf; #[AutowiredService] @@ -23,13 +24,11 @@ public function __construct( { } - public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): ExpressionResult + public function processImplicitToStringCall(Expr $expr, Type $exprType, MutatingScope $scope): ExpressionResult { $throwPoints = []; $impurePoints = []; - $exprType = $scope->getType($expr); - $toStringMethod = null; if (!$exprType->isObject()->no()) { $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); diff --git a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php index 08ff8735587..a1488d78eed 100644 --- a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php @@ -14,6 +14,7 @@ use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; use ReflectionFunction; use ReflectionMethod; use Throwable; @@ -31,14 +32,22 @@ public function __construct( { } + /** + * @param (callable(): Type)|null $returnTypeCallback lazily resolves the + * call's return type for the explicit-never and implicit-throws + * checks — mirrors FuncCallHandler's shape; null keeps the guarded + * legacy scope ask (PHPSTAN_FNSR=0) until the call handlers migrate + */ public function getThrowPoint( MethodReflection $methodReflection, ParametersAcceptor $parametersAcceptor, MethodCall|StaticCall $normalizedMethodCall, MutatingScope $scope, ExpressionContext $context, + ?callable $returnTypeCallback = null, ): ?InternalThrowPoint { + $returnTypeCallback ??= static fn (): Type => $scope->getType($normalizedMethodCall); if ($normalizedMethodCall instanceof MethodCall) { foreach ($this->dynamicThrowTypeExtensionProvider->getDynamicMethodThrowTypeExtensions() as $extension) { if (!$extension->isMethodSupported($methodReflection)) { @@ -77,7 +86,7 @@ public function getThrowPoint( $throwType = $methodReflection->getThrowType(); if ($throwType === null) { - $returnType = $scope->getType($normalizedMethodCall); + $returnType = $returnTypeCallback(); if ($returnType instanceof NeverType && $returnType->isExplicit()) { $throwType = new ObjectType(Throwable::class); } @@ -88,7 +97,7 @@ public function getThrowPoint( return InternalThrowPoint::createExplicit($scope, $throwType, $normalizedMethodCall, true); } } elseif ($this->implicitThrows) { - $methodReturnedType = $scope->getType($normalizedMethodCall); + $methodReturnedType = $returnTypeCallback(); if (!$context->isInThrow() || !(new ObjectType(Throwable::class))->isSuperTypeOf($methodReturnedType)->yes()) { return InternalThrowPoint::createImplicit($scope, $normalizedMethodCall); } diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index 02e197a28eb..8bbf0d9269a 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -13,16 +13,25 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; #[AutowiredService] final class NonNullabilityHelper { - public function ensureShallowNonNullability(MutatingScope $scope, Scope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult + /** + * @param (callable(MutatingScope): MutatingScope)|null $askScopeFactory wraps + * the scope used for type asks (an adapter in the new world); the + * specification itself happens on the unwrapped scopes. Null keeps + * the guarded direct asks (PHPSTAN_FNSR=0). + */ + public function ensureShallowNonNullability(MutatingScope $scope, Scope $originalScope, Expr $exprToSpecify, ?callable $askScopeFactory = null): EnsuredNonNullabilityResult { - $exprType = $scope->getType($exprToSpecify); + $askScope = $askScopeFactory !== null ? $askScopeFactory($scope) : $scope; + $exprType = $askScope->getType($exprToSpecify); $isNull = $exprType->isNull(); if ($isNull->yes()) { return new EnsuredNonNullabilityResult($scope, []); @@ -32,9 +41,13 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $exprTypeWithoutNull = TypeCombinator::removeNull($exprType); if ($exprType->equals($exprTypeWithoutNull)) { - $originalExprType = $originalScope->getType($exprToSpecify); + if (!$originalScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + $originalAskScope = $askScopeFactory !== null ? $askScopeFactory($originalScope) : $originalScope; + $originalExprType = $originalAskScope->getType($exprToSpecify); if (!$originalExprType->equals($exprTypeWithoutNull)) { - $originalNativeType = $originalScope->getNativeType($exprToSpecify); + $originalNativeType = $originalAskScope->getNativeType($exprToSpecify); return new EnsuredNonNullabilityResult($scope, [ new EnsuredNonNullabilityResultExpression($exprToSpecify, $originalExprType, $originalNativeType, $hasExpressionType), @@ -52,8 +65,8 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $parentExpr = $exprToSpecify->var; $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression( $parentExpr, - $scope->getType($parentExpr), - $scope->getNativeType($parentExpr), + $askScope->getType($parentExpr), + $askScope->getNativeType($parentExpr), $originalScope->hasExpressionType($parentExpr), ); } @@ -64,7 +77,7 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $certainty = $hasExpressionType; } - $nativeType = $scope->getNativeType($exprToSpecify); + $nativeType = $askScope->getNativeType($exprToSpecify); $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression($exprToSpecify, $exprType, $nativeType, $certainty); $scope = $scope->specifyExpressionType( $exprToSpecify, @@ -79,12 +92,64 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina ); } - public function ensureNonNullability(MutatingScope $scope, Expr $expr): EnsuredNonNullabilityResult + /** + * New-world variant of ensureShallowNonNullability(): the expression's type + * comes from its already-known ExpressionResult instead of Scope::getType(). + * The ArrayDimFetch parent record still reads the parent's type through the + * guarded legacy bridge (PHPSTAN_FNSR=0) until ArrayDimFetchHandler migrates. + */ + public function ensureShallowNonNullabilityFromTypes(MutatingScope $scope, Expr $exprToSpecify, Type $exprType, Type $nativeType): EnsuredNonNullabilityResult + { + $isNull = $exprType->isNull(); + if ($isNull->yes()) { + return new EnsuredNonNullabilityResult($scope, []); + } + + $exprTypeWithoutNull = TypeCombinator::removeNull($exprType); + if ($exprType->equals($exprTypeWithoutNull)) { + return new EnsuredNonNullabilityResult($scope, []); + } + + $specifiedExpressions = []; + if ($exprToSpecify instanceof Expr\ArrayDimFetch && $exprToSpecify->dim !== null) { + $parentExpr = $exprToSpecify->var; + $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression( + $parentExpr, + $scope->getType($parentExpr), + $scope->getNativeType($parentExpr), + $scope->hasExpressionType($parentExpr), + ); + } + + $hasExpressionType = $scope->hasExpressionType($exprToSpecify); + $certainty = TrinaryLogic::createYes(); + if (!$hasExpressionType->no()) { + $certainty = $hasExpressionType; + } + + $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression($exprToSpecify, $exprType, $nativeType, $certainty); + $scope = $scope->specifyExpressionType( + $exprToSpecify, + $exprTypeWithoutNull, + TypeCombinator::removeNull($nativeType), + TrinaryLogic::createYes(), + ); + + return new EnsuredNonNullabilityResult( + $scope, + $specifiedExpressions, + ); + } + + /** + * @param (callable(MutatingScope): MutatingScope)|null $askScopeFactory + */ + public function ensureNonNullability(MutatingScope $scope, Expr $expr, ?callable $askScopeFactory = null): EnsuredNonNullabilityResult { $specifiedExpressions = []; $originalScope = $scope; - $scope = $this->lookForExpressionCallback($scope, $expr, function ($scope, $expr) use (&$specifiedExpressions, $originalScope) { - $result = $this->ensureShallowNonNullability($scope, $originalScope, $expr); + $scope = $this->lookForExpressionCallback($scope, $expr, function ($scope, $expr) use (&$specifiedExpressions, $originalScope, $askScopeFactory) { + $result = $this->ensureShallowNonNullability($scope, $originalScope, $expr, $askScopeFactory); foreach ($result->getSpecifiedExpressions() as $specifiedExpression) { $specifiedExpressions[] = $specifiedExpression; } diff --git a/src/Analyser/ExprHandler/IncludeHandler.php b/src/Analyser/ExprHandler/IncludeHandler.php index 788a7c16522..d505a588c97 100644 --- a/src/Analyser/ExprHandler/IncludeHandler.php +++ b/src/Analyser/ExprHandler/IncludeHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -30,6 +31,10 @@ final class IncludeHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Include_; @@ -52,6 +57,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, $identifier, $identifier, true)]), + expr: $expr, + typeCallback: static fn (): Type => new MixedType(), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index 36933e466b3..fca5dccda24 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\MixedType; @@ -38,6 +39,10 @@ final class InstanceofHandler implements ExprHandler { + public function __construct(private TypeSpecifier $typeSpecifier) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Instanceof_; @@ -51,6 +56,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $exprResult->getImpurePoints(); $isAlwaysTerminating = $exprResult->isAlwaysTerminating(); $scope = $exprResult->getScope(); + $classResult = null; if (!$expr->class instanceof Name) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $classResult->getScope(); @@ -66,11 +72,143 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $this->createTypeCallback($exprResult, $classResult), + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt, $exprResult, $classResult), ); } + /** + * @return callable(Expr, MutatingScope): Type + */ + private function createTypeCallback(ExpressionResult $exprResult, ?ExpressionResult $classResult): callable + { + return static function (Expr $e, MutatingScope $s) use ($exprResult, $classResult): Type { + if (!$e instanceof Instanceof_) { + throw new ShouldNotHappenException(); + } + + $expressionType = $exprResult->getTypeForScope($s); + if ( + $s->isInTrait() + && TypeUtils::findThisType($expressionType) !== null + ) { + return new BooleanType(); + } + if ($expressionType instanceof NeverType) { + return new ConstantBooleanType(false); + } + + $uncertainty = false; + + if ($e->class instanceof Name) { + $unresolvedClassName = $e->class->toString(); + if ( + strtolower($unresolvedClassName) === 'static' + && $s->isInClass() + ) { + $classType = new StaticType($s->getClassReflection()); + } else { + $className = $s->resolveName($e->class); + $classType = new ObjectType($className); + } + } else { + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $result = $classResult->getTypeForScope($s)->toObjectTypeForInstanceofCheck(); + $classType = $result->type; + $uncertainty = $result->uncertainty; + } + + if ($classType->isSuperTypeOf(new MixedType())->yes()) { + return new BooleanType(); + } + + $isSuperType = $classType->isSuperTypeOf($expressionType); + + if ($isSuperType->no()) { + return new ConstantBooleanType(false); + } elseif ($isSuperType->yes() && !$uncertainty) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }; + } + + /** + * New-world copy of specifyTypes(): TypeSpecifier::create() resolves its + * null/purity gates through an adapter seeded with the target and class + * results (the FuncCall self-seeding precedent). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResult $exprResult, ?ExpressionResult $classResult): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt, $exprResult, $classResult): SpecifiedTypes { + if (!$e instanceof Instanceof_) { + throw new ShouldNotHappenException(); + } + + $exprNode = $e->expr; + $exprResults = [$s->getNodeKey($exprNode) => $exprResult]; + if ($classResult !== null && $e->class instanceof Expr) { + $exprResults[$s->getNodeKey($e->class)] = $classResult; + } + $adapterScope = $s->toResultAwareScope($exprResults, $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + if ($e->class instanceof Name) { + $className = (string) $e->class; + $lowercasedClassName = strtolower($className); + if ($lowercasedClassName === 'self' && $s->isInClass()) { + $type = new ObjectType($s->getClassReflection()->getName()); + } elseif ($lowercasedClassName === 'static' && $s->isInClass()) { + $type = new StaticType($s->getClassReflection()); + } elseif ($lowercasedClassName === 'parent') { + if ( + $s->isInClass() + && $s->getClassReflection()->getParentClass() !== null + ) { + $type = new ObjectType($s->getClassReflection()->getParentClass()->getName()); + } else { + $type = new NonexistentParentClassType(); + } + } else { + $type = new ObjectType($className); + } + return $this->typeSpecifier->create($exprNode, $type, $ctx, $adapterScope)->setRootExpr($e); + } + + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $result = $classResult->getTypeForScope($s)->toObjectTypeForInstanceofCheck(); + $type = $result->type; + $uncertainty = $result->uncertainty; + + if (!$type->isSuperTypeOf(new MixedType())->yes()) { + if ($ctx->true()) { + $type = TypeCombinator::intersect( + $type, + new ObjectWithoutClassType(), + ); + return $this->typeSpecifier->create($exprNode, $type, $ctx, $adapterScope)->setRootExpr($e); + } elseif ($ctx->false() && !$uncertainty) { + $exprType = $exprResult->getTypeForScope($s); + if (!$type->isSuperTypeOf($exprType)->yes()) { + return $this->typeSpecifier->create($exprNode, $type, $ctx, $adapterScope)->setRootExpr($e); + } + } + } + if ($ctx->true()) { + return $this->typeSpecifier->create($exprNode, new ObjectWithoutClassType(), $ctx, $adapterScope)->setRootExpr($exprNode); + } + + return (new SpecifiedTypes([], []))->setRootExpr($e); + }; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { $expressionType = $scope->getType($expr->expr); diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index 51de44f579f..3d3eac3dda6 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -19,9 +20,11 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Type; use function array_merge; +use function spl_object_id; /** * @implements ExprHandler @@ -33,6 +36,7 @@ final class InterpolatedStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -48,16 +52,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; + $partResults = []; foreach ($expr->parts as $part) { if (!$part instanceof Expr) { continue; } $partResult = $nodeScopeResolver->processExprNode($stmt, $part, $scope, $storage, $nodeCallback, $context->enterDeep()); + $partResults[spl_object_id($part)] = $partResult; $hasYield = $hasYield || $partResult->hasYield(); $throwPoints = array_merge($throwPoints, $partResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $partResult->getImpurePoints()); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($part, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($part, $partResult->getType(), $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); @@ -65,12 +71,42 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $partResult->getScope(); } + // each part type was captured at its own evaluation point in the sequence + $typeCallback = function (Expr $e, MutatingScope $s) use ($partResults): Type { + if (!$e instanceof InterpolatedString) { + throw new ShouldNotHappenException(); + } + + $resultType = new ConstantStringType(''); + $first = true; + foreach ($e->parts as $part) { + if ($part instanceof InterpolatedStringPart) { + $partType = new ConstantStringType($part->value); + } else { + $partResult = $partResults[spl_object_id($part)] ?? null; + $partType = ($partResult !== null ? $partResult->getTypeForScope($s) : $s->getType($part))->toString(); + } + if ($first) { + $resultType = $partType; + $first = false; + continue; + } + + $resultType = $this->initializerExprTypeResolver->resolveConcatType($resultType, $partType); + } + + return $resultType; + }; + return new ExpressionResult( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index da8e48431fd..49e834e0ed3 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -59,6 +59,7 @@ final class IssetHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -350,7 +351,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nonNullabilityResults = []; $isAlwaysTerminating = false; foreach ($expr->vars as $var) { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var); + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var, static fn (MutatingScope $askedScope): MutatingScope => $askedScope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)); $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $varResult->getScope(); @@ -364,7 +365,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex continue; } - $varType = $scope->getType($var->var); + $varType = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, $storage)->getType($var->var); if ($varType->isArray()->yes() || (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { continue; } @@ -391,8 +392,54 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: static function (Expr $e, MutatingScope $s) use ($nodeScopeResolver, $stmt): Type { + if (!$e instanceof Isset_) { + throw new ShouldNotHappenException(); + } + + // issetCheck() walks the expression asking for types — priced + // through an unseeded adapter (ResultAwareScope tiers) + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + $issetResult = true; + foreach ($e->vars as $var) { + $result = $adapterScope->issetCheck($var, static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + if ($result !== null) { + if (!$result) { + return new ConstantBooleanType($result); + } + + continue; + } + + $issetResult = $result; + } + + if ($issetResult === null) { + return new BooleanType(); + } + + return new ConstantBooleanType($issetResult); + }, + // the old specifyTypes() body stays the single source (the BinaryOp + // precedent) — invoked directly with an unseeded adapter; the + // multi-isset And-chain synthetic routes through the migrated handlers + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof Isset_) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $adapterScope, $e, $ctx); + }, ); } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 77f5969ae7d..b4f7d734ed4 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\MethodCall; @@ -32,11 +33,13 @@ use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\TemplateTypeVarianceMap; use PHPStan\Type\MixedType; +use PHPStan\Type\NullType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -58,6 +61,7 @@ public function __construct( private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, private MethodThrowPointHelper $methodThrowPointHelper, private ReflectionProvider $reflectionProvider, + private TypeSpecifier $typeSpecifier, #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, ) @@ -85,17 +89,29 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $closureCallScope ?? $scope, $storage, $nodeCallback, $context->enterDeep()); - $hasYield = $varResult->hasYield(); - $throwPoints = $varResult->getThrowPoints(); - $impurePoints = $varResult->getImpurePoints(); - $isAlwaysTerminating = $varResult->isAlwaysTerminating(); $scope = $varResult->getScope(); if (isset($closureCallScope)) { $scope = $scope->restoreOriginalScopeAfterClosureBind($originalScope); } + + return $this->processCallWithVarResult($nodeScopeResolver, $stmt, $expr, $varResult, $varResult->getType(), $scope, $storage, $nodeCallback, $context); + } + + /** + * The call part after the var was evaluated — NullsafeMethodCallHandler + * reuses it with the subject narrowed non-null and the null stripped from + * $calledOnType, avoiding a second evaluation of the var. + * + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + public function processCallWithVarResult(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, MethodCall $expr, ExpressionResult $varResult, Type $calledOnType, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult + { + $hasYield = $varResult->hasYield(); + $throwPoints = $varResult->getThrowPoints(); + $impurePoints = $varResult->getImpurePoints(); + $isAlwaysTerminating = $varResult->isAlwaysTerminating(); $parametersAcceptor = null; $methodReflection = null; - $calledOnType = $scope->getType($expr->var); if ($expr->name instanceof Identifier) { $methodName = $expr->name->name; $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); @@ -150,7 +166,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $argsResult->getScope(); if ($methodReflection !== null) { - $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + $throwPointScope = $scope; + $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint( + $methodReflection, + $parametersAcceptor, + $normalizedExpr, + $scope, + $context, + fn (): Type => $this->resolveMethodCallTypeViaResults($normalizedExpr, $throwPointScope, $varResult, $argsResult->getCompanionResults(), $nodeScopeResolver, $stmt), + ); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; } @@ -191,22 +215,43 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); + $argResults = $argsResult->getCompanionResults(); + $typeCallback = function (Expr $e, MutatingScope $s) use ($varResult, $argResults, $nodeScopeResolver, $stmt): Type { + if (!$e instanceof MethodCall) { + throw new ShouldNotHappenException(); + } + + return $this->resolveMethodCallTypeViaResults($e, $s, $varResult, $argResults, $nodeScopeResolver, $stmt); + }; + // the old specifyTypes() body stays the single source (the BinaryOp + // precedent) — extensions, conditional return types and asserts resolve + // through an unseeded adapter + $specifyTypesCallback = function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof MethodCall) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $adapterScope, $e, $ctx); + }; + $result = new ExpressionResult( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); - $calledOnType = $originalScope->getType($expr->var); if (!$expr->name instanceof Identifier) { return $result; } $methodName = $expr->name->name; - $methodReflection = $originalScope->getMethodReflection($calledOnType, $methodName); + $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); if ($methodReflection === null) { return $result; } @@ -225,8 +270,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } } @@ -234,6 +280,75 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $result; } + /** + * New-world copy of resolveType(): the receiver comes from its + * ExpressionResult (one-level nullsafe short-circuit per NEW_WORLD.md + * paragraph 3.10); args and dynamic-return extensions resolve through a + * self-seeded adapter so extension helpers re-asking about this call + * terminate (the FuncCall precedent); dynamic method names bridge. + */ +/** + * @param array $argResults + */ + private function resolveMethodCallTypeViaResults(MethodCall $e, MutatingScope $s, ExpressionResult $varResult, array $argResults, NodeScopeResolver $nodeScopeResolver, Stmt $stmt): Type + { + if (!$e->name instanceof Identifier) { + // dynamic method names take the guarded legacy bridge (PHPSTAN_FNSR=0) + return $s->getType($e); + } + + $varType = $varResult->getTypeForScope($s); + $isShortcircuited = ($e->var instanceof Expr\NullsafePropertyFetch || $e->var instanceof Expr\NullsafeMethodCall) + && TypeCombinator::containsNull($varType); + if ($isShortcircuited) { + $varType = TypeCombinator::removeNull($varType); + } + + if ($s->nativeTypesPromoted) { + $methodReflection = $s->getMethodReflection($varType, $e->name->name); + if ($methodReflection === null) { + $returnType = new ErrorType(); + } else { + $returnType = ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); + } + + return $isShortcircuited ? TypeCombinator::union($returnType, new NullType()) : $returnType; + } + + $storage = new ExpressionResultStorage(); + $selfResult = new ExpressionResult( + $s, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $e, + typeCallback: fn (Expr $innerExpr, MutatingScope $innerScope): Type => $this->resolveMethodCallTypeViaResults($e, $innerScope, $varResult, $argResults, $nodeScopeResolver, $stmt), + specifyTypesCallback: function (Expr $innerExpr, MutatingScope $innerScope, TypeSpecifierContext $innerContext) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$innerExpr instanceof MethodCall) { + throw new ShouldNotHappenException(); + } + + $innerAdapterScope = $innerScope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $innerAdapterScope, $innerExpr, $innerContext); + }, + ); + $adapterScope = $s->toResultAwareScope($argResults + [$s->getNodeKey($e) => $selfResult], $nodeScopeResolver, $stmt, $storage); + + $returnType = $this->methodCallReturnTypeHelper->methodCallReturnType( + $adapterScope, + $varType, + $e->name->name, + $e, + ); + if ($returnType === null) { + $returnType = new ErrorType(); + } + + return $isShortcircuited ? TypeCombinator::union($returnType, new NullType()) : $returnType; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { if ($expr->name instanceof Identifier) { diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 2360ccd8076..d75c031b458 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -75,6 +75,7 @@ public function __construct( private DynamicThrowTypeExtensionProvider $dynamicThrowTypeExtensionProvider, private DynamicReturnTypeExtensionRegistryProvider $dynamicReturnTypeExtensionRegistryProvider, private PropertyReflectionFinder $propertyReflectionFinder, + private TypeSpecifier $typeSpecifier, #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private bool $implicitThrows, ) @@ -100,7 +101,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($expr->class instanceof Name) { $className = $scope->resolveName($expr->class); - [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope, false); + [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()), false); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); if ($parametersAcceptor !== null) { @@ -162,7 +163,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } else { $isDynamic = true; - $objectClasses = $scope->getType($expr)->getObjectClassNames(); + // the class expression is not processed yet — price the ask through + // an adapter (the self-type of a dynamic New is derived from it) + $objectClasses = $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage())->getType($expr->class)->getObjectTypeOrClassStringObjectType()->getObjectClassNames(); if (count($objectClasses) === 1) { $objectExprResult = $nodeScopeResolver->processExprNode($stmt, new New_(new Name($objectClasses[0])), $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $className = $objectClasses[0]; @@ -181,7 +184,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $additionalThrowPoints); if ($className !== null) { - [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope, true); + [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()), true); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); } else { $impurePoints[] = new ImpurePoint( @@ -215,12 +218,46 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); } + $argResults = $argsResult->getCompanionResults(); + return new ExpressionResult( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + expr: $expr, + typeCallback: function (Expr $e, MutatingScope $s) use ($argResults, $nodeScopeResolver, $stmt): Type { + if (!$e instanceof New_) { + throw new ShouldNotHappenException(); + } + + // exactInstantiation resolves constructor template inference and + // parent-construct chains with scope asks — priced through an + // adapter seeded with the per-arg companions (passed closures + // keep their context memo) + $adapterScope = $s->toResultAwareScope($argResults, $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + if ($e->class instanceof Name) { + return $this->exactInstantiation($adapterScope, $e, $e->class); + } + if ($e->class instanceof Node\Stmt\Class_) { + $anonymousClassReflection = $this->reflectionProvider->getAnonymousClassReflection($e->class, $s); + + return new ObjectType($anonymousClassReflection->getName()); + } + + return $adapterScope->getType($e->class)->getObjectTypeOrClassStringObjectType(); + }, + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof New_) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $adapterScope, $e, $ctx); + }, ); } diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 864a4859275..aeffd5fc3ce 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\BooleanAnd; use PhpParser\Node\Expr\BinaryOp\NotIdentical; @@ -14,6 +15,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -21,8 +23,10 @@ use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; +use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NullType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -37,6 +41,10 @@ final class NullsafeMethodCallHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private MethodCallHandler $methodCallHandler, + private DefaultNarrowingHelper $defaultNarrowingHelper, + #[AutowiredParameter] + private bool $rememberPossiblyImpureFunctionValues, ) { } @@ -85,25 +93,58 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $scopeBeforeNullsafe = $scope; - $varType = $scope->getType($expr->var); + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $scope = $varResult->getScope(); + $varType = $varResult->getType(); + + // the only place that ever needs to know about `?->`: the subject was just + // evaluated, narrow it non-null for the call part and revert after — + // parents simply compose this result (NEW_WORLD.md §3.10) + $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullabilityFromTypes($scope, $expr->var, $varType, $varResult->getNativeType()); + $scope = $nonNullabilityResult->getScope(); - $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); - $exprResult = $nodeScopeResolver->processExprNode( + $plainCall = new MethodCall($expr->var, $expr->name, $expr->args, $attributes); + + // rules see the virtual plain call as the old delegation provided, and their + // asks about the subject must answer the narrowed type while the call part is + // in flight — the old world re-evaluated the subject on the narrowed scope + $narrowedVarResult = new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $expr->var, + typeCallback: static function (Expr $e, MutatingScope $s) use ($varResult): Type { + $varType = $varResult->getTypeForScope($s); + if ($varType->isNull()->yes()) { + // an always-null subject is not narrowed (the call is reported instead) + return $varType; + } + + return TypeCombinator::removeNull($varType); + }, + ); + $nodeScopeResolver->storeResult($storage, $expr->var, $narrowedVarResult); + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, $plainCall, $scope, $storage, $context); + $plainResult = $this->methodCallHandler->processCallWithVarResult( + $nodeScopeResolver, $stmt, - new MethodCall( - $expr->var, - $expr->name, - $expr->args, - $attributes, - ), - $nonNullabilityResult->getScope(), + $plainCall, + $varResult, + TypeCombinator::removeNull($varType), + $scope, $storage, $nodeCallback, $context, ); - $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); + $plainResult->setExpr($plainCall); + $nodeScopeResolver->storeResult($storage, $plainCall, $plainResult); + $nodeScopeResolver->storeResult($storage, $expr->var, $varResult); + + $scope = $this->nonNullabilityHelper->revertNonNullability($plainResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); $varIsNull = $varType->isNull(); if ($varIsNull->yes()) { @@ -115,14 +156,43 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->mergeWith($scopeBeforeNullsafe); } + $methodReflection = $expr->name instanceof Node\Identifier + ? $scope->getMethodReflection(TypeCombinator::removeNull($varType), $expr->name->toString()) + : null; + $resultNarrowingAllowed = $methodReflection !== null + && !$methodReflection->hasSideEffects()->yes() + && ($this->rememberPossiblyImpureFunctionValues || $methodReflection->hasSideEffects()->no()); + + // the call's own type bridges through the stored plain result until + // MethodCallHandler migrates (PHPSTAN_FNSR=0) — then this composes for free + $typeCallback = static function (Expr $e, MutatingScope $s) use ($varResult, $plainResult): Type { + if (!$e instanceof NullsafeMethodCall) { + throw new ShouldNotHappenException(); + } + + $varType = $varResult->getTypeForScope($s); + if ($varType->isNull()->yes()) { + return new NullType(); + } + + $methodReturnType = $plainResult->getTypeForScope($s); + if (TypeCombinator::containsNull($varType)) { + return TypeCombinator::union($methodReturnType, new NullType()); + } + + return $methodReturnType; + }; + return new ExpressionResult( $scope, - hasYield: $exprResult->hasYield(), + hasYield: $plainResult->hasYield(), isAlwaysTerminating: false, - throwPoints: $exprResult->getThrowPoints(), - impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + throwPoints: $plainResult->getThrowPoints(), + impurePoints: $plainResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $this->defaultNarrowingHelper->createNullsafeSpecifyCallback($expr, $varResult, $resultNarrowingAllowed, $plainCall, $nodeScopeResolver, $stmt), + companionResults: [$scope->getNodeKey($plainCall) => $plainResult], ); } diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 0c9e190ff47..4c9905891dd 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -8,13 +8,16 @@ use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\NullsafePropertyFetch; use PhpParser\Node\Expr\PropertyFetch; +use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; +use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -23,6 +26,8 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\Php\PhpVersion; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NullType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -37,6 +42,10 @@ final class NullsafePropertyFetchHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private PropertyFetchHandler $propertyFetchHandler, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private ExprPrinter $exprPrinter, + private PhpVersion $phpVersion, ) { } @@ -84,24 +93,111 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $scope = $varResult->getScope(); + $hasYield = $varResult->hasYield(); + $throwPoints = $varResult->getThrowPoints(); + $impurePoints = $varResult->getImpurePoints(); + + // the only place that ever needs to know about `?->`: the subject was just + // evaluated, narrow it non-null for the property part and revert after — + // parents simply compose this result (NEW_WORLD.md §3.10) + $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullabilityFromTypes($scope, $expr->var, $varResult->getType(), $varResult->getNativeType()); + $scope = $nonNullabilityResult->getScope(); + $attributes = array_merge($expr->getAttributes(), ['virtualNullsafePropertyFetch' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); - $exprResult = $nodeScopeResolver->processExprNode($stmt, new PropertyFetch( - $expr->var, - $expr->name, - $attributes, - ), $nonNullabilityResult->getScope(), $storage, $nodeCallback, $context); - $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); + $plainFetch = new PropertyFetch($expr->var, $expr->name, $attributes); + + $varTypeWithoutNullCallback = static fn (Expr $e, MutatingScope $s): Type => TypeCombinator::removeNull($varResult->getTypeForScope($s)); + + if ($expr->name instanceof Identifier) { + $propertyName = $expr->name->toString(); + $propertyReflection = $scope->getInstancePropertyReflection(TypeCombinator::removeNull($varResult->getType()), $propertyName); + if ($propertyReflection !== null && $this->phpVersion->supportsPropertyHooks()) { + $propertyDeclaringClass = $propertyReflection->getDeclaringClass(); + if ($propertyDeclaringClass->hasNativeProperty($propertyName)) { + $nativeProperty = $propertyDeclaringClass->getNativeProperty($propertyName); + $throwPoints = array_merge($throwPoints, $nodeScopeResolver->getThrowPointsFromPropertyHook($scope, $plainFetch, $nativeProperty, 'get')); + } + } + } else { + $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $nameResult->hasYield(); + $throwPoints = array_merge($throwPoints, $nameResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $nameResult->getImpurePoints()); + $scope = $nameResult->getScope(); + if ($this->phpVersion->supportsPropertyHooks()) { + $throwPoints[] = InternalThrowPoint::createImplicit($scope, $plainFetch); + } + } + + // rules see the virtual plain fetch as the old delegation provided, and their + // asks about the subject must answer the narrowed type while the fetch part is + // in flight — the old world re-evaluated the subject on the narrowed scope + $narrowedVarResult = new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $expr->var, + typeCallback: static function (Expr $e, MutatingScope $s) use ($varResult): Type { + $varType = $varResult->getTypeForScope($s); + if ($varType->isNull()->yes()) { + // an always-null subject is not narrowed (the call is reported instead) + return $varType; + } + + return TypeCombinator::removeNull($varType); + }, + ); + $nodeScopeResolver->storeResult($storage, $expr->var, $narrowedVarResult); + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, $plainFetch, $scope, $storage, $context); + $plainResult = new ExpressionResult( + $scope, + hasYield: $hasYield, + isAlwaysTerminating: false, + throwPoints: $throwPoints, + impurePoints: $impurePoints, + expr: $plainFetch, + typeCallback: $this->propertyFetchHandler->createTypeCallbackForVarType($varTypeWithoutNullCallback), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), + ); + $nodeScopeResolver->storeResult($storage, $plainFetch, $plainResult); + $nodeScopeResolver->storeResult($storage, $expr->var, $varResult); + + $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); + + $propertyTypeCallback = $this->propertyFetchHandler->createTypeCallbackForVarType($varTypeWithoutNullCallback); + $typeCallback = static function (Expr $e, MutatingScope $s) use ($varResult, $propertyTypeCallback): Type { + if (!$e instanceof NullsafePropertyFetch) { + throw new ShouldNotHappenException(); + } + + $varType = $varResult->getTypeForScope($s); + if ($varType->isNull()->yes()) { + return new NullType(); + } + + $propertyType = $propertyTypeCallback($e, $s); + if (TypeCombinator::containsNull($varType)) { + return TypeCombinator::union($propertyType, new NullType()); + } + + return $propertyType; + }; return new ExpressionResult( $scope, - hasYield: $exprResult->hasYield(), + hasYield: $hasYield, isAlwaysTerminating: false, - throwPoints: $exprResult->getThrowPoints(), - impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + throwPoints: $throwPoints, + impurePoints: $impurePoints, + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $this->defaultNarrowingHelper->createNullsafeSpecifyCallback($expr, $varResult), + companionResults: [$scope->getNodeKey($plainFetch) => $plainResult], ); } diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index 93bd3554ef7..37d14be65df 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -32,6 +32,10 @@ final class PipeHandler implements ExprHandler { + public function __construct(private TypeSpecifier $typeSpecifier) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Pipe; @@ -90,6 +94,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $callResult->isAlwaysTerminating(), throwPoints: $callResult->getThrowPoints(), impurePoints: $callResult->getImpurePoints(), + expr: $expr, + // the pipe IS the rewritten call — full delegation to its result + typeCallback: static fn (Expr $e, MutatingScope $s): Type => $callResult->getTypeForScope($s), + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($callResult, $callExpr, $nodeScopeResolver, $stmt): SpecifiedTypes { + if ($callResult->hasSpecifiedTypesCallback()) { + return $callResult->getSpecifiedTypes($s, $ctx)->setRootExpr($e); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $callExpr, $ctx)->setRootExpr($e); + }, ); } diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index 6c38de4a62e..2582a2a26e1 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -17,6 +18,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -26,6 +28,13 @@ final class PostDecHandler implements ExprHandler { + public function __construct( + private PreDecHandler $preDecHandler, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PostDec; @@ -35,6 +44,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the expression's value is the variable's type before the step + $typeCallback = static function (Expr $e, MutatingScope $s) use ($varResult): Type { + if (!$e instanceof PostDec) { + throw new ShouldNotHappenException(); + } + + return $varResult->getTypeForScope($s); + }; + $scope = $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), $storage, @@ -42,6 +60,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->var, new PreDec($expr->var), $nodeCallback, + fn (Expr $e, MutatingScope $s): Type => $this->preDecHandler->resolveTypeFromVarType($e instanceof PreDec ? $e->var : $e, $varResult->getTypeForScope($s)), )->getScope(); return new ExpressionResult( @@ -50,6 +69,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index f26c45c50fe..07cd49b7f37 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -17,6 +18,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -26,6 +28,13 @@ final class PostIncHandler implements ExprHandler { + public function __construct( + private PreIncHandler $preIncHandler, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PostInc; @@ -35,6 +44,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the expression's value is the variable's type before the step + $typeCallback = static function (Expr $e, MutatingScope $s) use ($varResult): Type { + if (!$e instanceof PostInc) { + throw new ShouldNotHappenException(); + } + + return $varResult->getTypeForScope($s); + }; + $scope = $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), $storage, @@ -42,6 +60,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->var, new PreInc($expr->var), $nodeCallback, + fn (Expr $e, MutatingScope $s): Type => $this->preIncHandler->resolveTypeFromVarType($e instanceof PreInc ? $e->var : $e, $varResult->getTypeForScope($s)), )->getScope(); return new ExpressionResult( @@ -50,6 +69,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index 4abde925a80..aba8237b630 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -3,7 +3,6 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\Minus; use PhpParser\Node\Expr\PreDec; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt; @@ -11,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -18,8 +18,11 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\BenevolentUnionType; +use PHPStan\Type\ConstantTypeHelper; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; @@ -40,6 +43,13 @@ final class PreDecHandler implements ExprHandler { + public function __construct( + private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PreDec; @@ -47,7 +57,15 @@ public function supports(Expr $expr): bool public function resolveType(MutatingScope $scope, Expr $expr): Type { - $varType = $scope->getType($expr->var); + return $this->resolveTypeFromVarType($expr->var, $scope->getType($expr->var)); + } + + /** + * The type of the decremented value, from the variable's own type — + * new-world copy of resolveType() usable by both worlds. + */ + public function resolveTypeFromVarType(Expr $varExpr, Type $varType): Type + { $varScalars = $varType->getConstantScalarValues(); if (count($varScalars) > 0) { @@ -66,7 +84,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type --$varValue; } - $newTypes[] = $scope->getTypeFromValue($varValue); + $newTypes[] = ConstantTypeHelper::getTypeFromValue($varValue); } return TypeCombinator::union(...$newTypes); } elseif ($varType->isString()->yes()) { @@ -91,13 +109,25 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type ]); } - return $scope->getType(new Minus($expr->var, new Int_(1))); + return $this->initializerExprTypeResolver->getMinusType( + $varExpr, + new Int_(1), + static fn (Expr $e): Type => $e === $varExpr ? $varType : ConstantTypeHelper::getTypeFromValue(1), + ); } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $typeCallback = function (Expr $e, MutatingScope $s) use ($varResult): Type { + if (!$e instanceof PreDec) { + throw new ShouldNotHappenException(); + } + + return $this->resolveTypeFromVarType($e->var, $varResult->getTypeForScope($s)); + }; + $scope = $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), $storage, @@ -105,6 +135,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->var, $expr, $nodeCallback, + $typeCallback, )->getScope(); return new ExpressionResult( @@ -113,6 +144,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index 1750b876542..2d51c7cc4f9 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -3,7 +3,6 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\Plus; use PhpParser\Node\Expr\PreInc; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt; @@ -11,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -18,8 +18,11 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\BenevolentUnionType; +use PHPStan\Type\ConstantTypeHelper; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; @@ -41,6 +44,13 @@ final class PreIncHandler implements ExprHandler { + public function __construct( + private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PreInc; @@ -48,7 +58,15 @@ public function supports(Expr $expr): bool public function resolveType(MutatingScope $scope, Expr $expr): Type { - $varType = $scope->getType($expr->var); + return $this->resolveTypeFromVarType($expr->var, $scope->getType($expr->var)); + } + + /** + * The type of the incremented value, from the variable's own type — + * new-world copy of resolveType() usable by both worlds. + */ + public function resolveTypeFromVarType(Expr $varExpr, Type $varType): Type + { $varScalars = $varType->getConstantScalarValues(); if (count($varScalars) > 0) { @@ -67,7 +85,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type ++$varValue; } - $newTypes[] = $scope->getTypeFromValue($varValue); + $newTypes[] = ConstantTypeHelper::getTypeFromValue($varValue); } return TypeCombinator::union(...$newTypes); } elseif ($varType->isString()->yes()) { @@ -92,13 +110,25 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type ]); } - return $scope->getType(new Plus($expr->var, new Int_(1))); + return $this->initializerExprTypeResolver->getPlusType( + $varExpr, + new Int_(1), + static fn (Expr $e): Type => $e === $varExpr ? $varType : ConstantTypeHelper::getTypeFromValue(1), + ); } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $typeCallback = function (Expr $e, MutatingScope $s) use ($varResult): Type { + if (!$e instanceof PreInc) { + throw new ShouldNotHappenException(); + } + + return $this->resolveTypeFromVarType($e->var, $varResult->getTypeForScope($s)); + }; + $scope = $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), $storage, @@ -106,6 +136,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->var, $expr, $nodeCallback, + $typeCallback, )->getScope(); return new ExpressionResult( @@ -114,6 +145,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index 9d8b220ccfe..836f2ba3ec4 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; @@ -31,6 +32,7 @@ final class PrintHandler implements ExprHandler public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,7 +53,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = $exprResult->getThrowPoints(); $impurePoints = $exprResult->getImpurePoints(); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $exprResult->getType(), $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); @@ -63,6 +65,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: array_merge($impurePoints, [new ImpurePoint($scope, $expr, 'print', 'print', true)]), + expr: $expr, + typeCallback: static fn (): Type => new ConstantIntegerType(1), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index 9be0c5c28be..07111d309f4 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use Closure; use PhpParser\Node\Expr; use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Identifier; @@ -11,6 +12,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -22,8 +24,10 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; use PHPStan\Rules\Properties\PropertyReflectionFinder; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; +use PHPStan\Type\NullType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_map; @@ -40,6 +44,7 @@ final class PropertyFetchHandler implements ExprHandler public function __construct( private PhpVersion $phpVersion, private PropertyReflectionFinder $propertyReflectionFinder, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,7 +56,6 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $scopeBeforeVar = $scope; $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $varResult->hasYield(); $throwPoints = $varResult->getThrowPoints(); @@ -60,13 +64,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $varResult->getScope(); if ($expr->name instanceof Identifier) { $propertyName = $expr->name->toString(); - $propertyHolderType = $scopeBeforeVar->getType($expr->var); - $propertyReflection = $scopeBeforeVar->getInstancePropertyReflection($propertyHolderType, $propertyName); + $propertyHolderType = $varResult->getType(); + $propertyReflection = $scope->getInstancePropertyReflection($propertyHolderType, $propertyName); if ($propertyReflection !== null && $this->phpVersion->supportsPropertyHooks()) { $propertyDeclaringClass = $propertyReflection->getDeclaringClass(); if ($propertyDeclaringClass->hasNativeProperty($propertyName)) { $nativeProperty = $propertyDeclaringClass->getNativeProperty($propertyName); - $throwPoints = array_merge($throwPoints, $nodeScopeResolver->getThrowPointsFromPropertyHook($scopeBeforeVar, $expr, $nativeProperty, 'get')); + $throwPoints = array_merge($throwPoints, $nodeScopeResolver->getThrowPointsFromPropertyHook($scope, $expr, $nativeProperty, 'get')); } } } else { @@ -87,11 +91,83 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $this->createTypeCallback($varResult), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } + /** + * Shared with NullsafePropertyFetchHandler — it passes the var's type with + * null already removed and unions the null back itself. + * + * @param callable(Expr, MutatingScope): Type $varTypeCallback + */ + public function createTypeCallbackForVarType(callable $varTypeCallback): Closure + { + return function (Expr $e, MutatingScope $s) use ($varTypeCallback): Type { + if (!$e instanceof PropertyFetch && !$e instanceof Expr\NullsafePropertyFetch) { + throw new ShouldNotHappenException(); + } + + if (!$e->name instanceof Identifier) { + // dynamic property names: guarded legacy bridge (PHPSTAN_FNSR=0) + return $s->getType($e); + } + + $varType = $varTypeCallback($e, $s); + + if ($s->nativeTypesPromoted) { + $propertyReflection = $s->getInstancePropertyReflection($varType, $e->name->name); + if ($propertyReflection === null) { + return new ErrorType(); + } + + if (!$propertyReflection->hasNativeType()) { + return new MixedType(); + } + + return $propertyReflection->getNativeType(); + } + + $returnType = $this->propertyFetchType($s, $varType, $e->name->name, $e); + + return $returnType ?? new ErrorType(); + }; + } + + private function createTypeCallback(ExpressionResult $varResult): Closure + { + // a nullsafe var that can be null short-circuits this fetch too; its + // handler already produced the null-union — propagate one level, no + // recursive chain walking (NEW_WORLD.md §3.10) + $isShortcircuited = static function (Expr $e, MutatingScope $s) use ($varResult): bool { + if (!$e instanceof PropertyFetch) { + throw new ShouldNotHappenException(); + } + + return ($e->var instanceof Expr\NullsafePropertyFetch || $e->var instanceof Expr\NullsafeMethodCall) + && TypeCombinator::containsNull($varResult->getTypeForScope($s)); + }; + $inner = $this->createTypeCallbackForVarType(static function (Expr $e, MutatingScope $s) use ($varResult, $isShortcircuited): Type { + $varType = $varResult->getTypeForScope($s); + if ($isShortcircuited($e, $s)) { + return TypeCombinator::removeNull($varType); + } + + return $varType; + }); + + return static function (Expr $e, MutatingScope $s) use ($inner, $isShortcircuited): Type { + $type = $inner($e, $s); + if ($isShortcircuited($e, $s)) { + return TypeCombinator::union($type, new NullType()); + } + + return $type; + }; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { if ($expr->name instanceof Identifier) { @@ -137,7 +213,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return new MixedType(); } - private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, string $propertyName, PropertyFetch $propertyFetch): ?Type + private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, string $propertyName, PropertyFetch|Expr\NullsafePropertyFetch $propertyFetch): ?Type { $propertyReflection = $scope->getInstancePropertyReflection($fetchedOnType, $propertyName); if ($propertyReflection === null) { diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index abd0dc63a4f..871097fb8a3 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -17,6 +18,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Type; @@ -30,6 +32,8 @@ final class ScalarHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -41,12 +45,18 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $typeCallback = fn (Expr $e, MutatingScope $s): Type => $this->initializerExprTypeResolver->getType($e, InitializerExprContext::fromScope($s)); + return new ExpressionResult( $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), + expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); } diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index e24683ac8f1..2b6b068d10d 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -36,11 +36,13 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\TemplateTypeVarianceMap; use PHPStan\Type\MixedType; +use PHPStan\Type\NullType; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; use PHPStan\Type\StaticType; @@ -66,6 +68,7 @@ public function __construct( private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, private MethodThrowPointHelper $methodThrowPointHelper, private ReflectionProvider $reflectionProvider, + private TypeSpecifier $typeSpecifier, #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, ) @@ -83,6 +86,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; + $classResult = null; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $classResult->hasYield(); @@ -217,7 +221,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scopeFunction = $scope->getFunction(); if ($methodReflection !== null) { - $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + $throwPointScope = $scope; + $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint( + $methodReflection, + $parametersAcceptor, + $normalizedExpr, + $scope, + $context, + fn (): Type => $this->resolveStaticCallTypeViaResults($normalizedExpr, $throwPointScope, $classResult, $argsResult->getCompanionResults(), $nodeScopeResolver, $stmt), + ); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; } @@ -279,17 +291,126 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); + $argResults = $argsResult->getCompanionResults(); + return new ExpressionResult( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: function (Expr $e, MutatingScope $s) use ($classResult, $argResults, $nodeScopeResolver, $stmt): Type { + if (!$e instanceof StaticCall) { + throw new ShouldNotHappenException(); + } + + return $this->resolveStaticCallTypeViaResults($e, $s, $classResult, $argResults, $nodeScopeResolver, $stmt); + }, + // the old specifyTypes() body stays the single source (the BinaryOp + // precedent) — extensions, conditional return types and asserts + // resolve through an unseeded adapter + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof StaticCall) { + throw new ShouldNotHappenException(); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $adapterScope, $e, $ctx); + }, ); } + /** + * New-world copy of resolveType(): the class expression comes from its + * ExpressionResult (one-level nullsafe short-circuit per NEW_WORLD.md + * paragraph 3.10); args and dynamic-return extensions resolve through a + * self-seeded adapter (the FuncCall precedent); dynamic names bridge. + */ +/** + * @param array $argResults + */ + private function resolveStaticCallTypeViaResults(StaticCall $e, MutatingScope $s, ?ExpressionResult $classResult, array $argResults, NodeScopeResolver $nodeScopeResolver, Stmt $stmt): Type + { + if (!$e->name instanceof Identifier) { + // dynamic method names take the guarded legacy bridge (PHPSTAN_FNSR=0) + return $s->getType($e); + } + + $isShortcircuited = false; + $classType = null; + if ($e->class instanceof Expr) { + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $classType = $classResult->getTypeForScope($s); + $isShortcircuited = ($e->class instanceof Expr\NullsafePropertyFetch || $e->class instanceof Expr\NullsafeMethodCall) + && TypeCombinator::containsNull($classType); + if ($isShortcircuited) { + $classType = TypeCombinator::removeNull($classType); + } + } + + if ($s->nativeTypesPromoted) { + if ($e->class instanceof Name) { + $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($s, $e->class, $e->name); + } else { + $staticMethodCalledOnType = $classType; + } + $methodReflection = $s->getMethodReflection( + $staticMethodCalledOnType, + $e->name->name, + ); + if ($methodReflection === null) { + $callType = new ErrorType(); + } else { + $callType = ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); + } + + return $isShortcircuited ? TypeCombinator::union($callType, new NullType()) : $callType; + } + + if ($e->class instanceof Name) { + $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($s, $e->class, $e->name); + } else { + $staticMethodCalledOnType = $classType->getObjectTypeOrClassStringObjectType(); + } + + $storage = new ExpressionResultStorage(); + $selfResult = new ExpressionResult( + $s, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $e, + typeCallback: fn (Expr $innerExpr, MutatingScope $innerScope): Type => $this->resolveStaticCallTypeViaResults($e, $innerScope, $classResult, $argResults, $nodeScopeResolver, $stmt), + specifyTypesCallback: function (Expr $innerExpr, MutatingScope $innerScope, TypeSpecifierContext $innerContext) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$innerExpr instanceof StaticCall) { + throw new ShouldNotHappenException(); + } + + $innerAdapterScope = $innerScope->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->specifyTypes($this->typeSpecifier, $innerAdapterScope, $innerExpr, $innerContext); + }, + ); + $adapterScope = $s->toResultAwareScope($argResults + [$s->getNodeKey($e) => $selfResult], $nodeScopeResolver, $stmt, $storage); + + $callType = $this->methodCallReturnTypeHelper->methodCallReturnType( + $adapterScope, + $staticMethodCalledOnType, + $e->name->toString(), + $e, + ); + if ($callType === null) { + $callType = new ErrorType(); + } + + return $isShortcircuited ? TypeCombinator::union($callType, new NullType()) : $callType; + } + public function resolveType(MutatingScope $scope, Expr $expr): Type { if ($expr->name instanceof Identifier) { diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index ec36cf64388..6fa3d6cd82c 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -13,6 +13,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; @@ -22,9 +23,11 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; +use PHPStan\Type\NullType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_map; @@ -40,6 +43,7 @@ final class StaticPropertyFetchHandler implements ExprHandler public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -63,6 +67,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ), ]; $isAlwaysTerminating = false; + $classResult = null; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $classResult->hasYield(); @@ -80,14 +85,80 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $nameResult->getScope(); } + // a nullsafe class expr that can be null short-circuits this fetch too — + // propagate one level (NEW_WORLD.md paragraph 3.10) + $isShortcircuited = static function (Expr $e, MutatingScope $s) use ($classResult): bool { + if (!$e instanceof StaticPropertyFetch) { + throw new ShouldNotHappenException(); + } + + return $classResult !== null + && ($e->class instanceof Expr\NullsafePropertyFetch || $e->class instanceof Expr\NullsafeMethodCall) + && TypeCombinator::containsNull($classResult->getTypeForScope($s)); + }; + $typeCallback = function (Expr $e, MutatingScope $s) use ($classResult, $isShortcircuited): Type { + if (!$e instanceof StaticPropertyFetch) { + throw new ShouldNotHappenException(); + } + + if (!$e->name instanceof VarLikeIdentifier) { + // dynamic property names take the guarded legacy bridge (PHPSTAN_FNSR=0) + return $s->getType($e); + } + + if ($s->nativeTypesPromoted) { + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($e, $s); + if ($propertyReflection === null) { + return new ErrorType(); + } + if (!$propertyReflection->hasNativeType()) { + return new MixedType(); + } + + $nativeType = $propertyReflection->getNativeType(); + + if ($isShortcircuited($e, $s)) { + return TypeCombinator::union($nativeType, new NullType()); + } + + return $nativeType; + } + + if ($e->class instanceof Name) { + $staticPropertyFetchedOnType = $s->resolveTypeByName($e->class); + } else { + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $staticPropertyFetchedOnType = TypeCombinator::removeNull($classResult->getTypeForScope($s))->getObjectTypeOrClassStringObjectType(); + } + + $fetchType = $this->propertyFetchType( + $s, + $staticPropertyFetchedOnType, + $e->name->toString(), + $e, + ); + if ($fetchType === null) { + $fetchType = new ErrorType(); + } + + if ($isShortcircuited($e, $s)) { + return TypeCombinator::union($fetchType, new NullType()); + } + + return $fetchType; + }; + return new ExpressionResult( $scope, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 3dcc769ad36..349678cc1ef 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; @@ -19,6 +20,7 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -33,6 +35,8 @@ final class TernaryHandler implements ExprHandler public function __construct( private NodeScopeResolver $nodeScopeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private TypeSpecifier $typeSpecifier, ) { } @@ -106,6 +110,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifTrueScope = $ternaryCondResult->getTruthyScope(); $ifFalseScope = $ternaryCondResult->getFalseyScope(); $ifTrueType = null; + $ifResult = null; if ($expr->if === null) { $elseResult = $nodeScopeResolver->processExprNode($stmt, $expr->else, $ifFalseScope, $storage, $nodeCallback, $context); @@ -117,7 +122,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $ifResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $ifResult->getImpurePoints()); $ifTrueScope = $ifResult->getScope(); - $ifTrueType = $ifTrueScope->getType($expr->if); + $ifTrueType = $ifResult->getType(); $elseResult = $nodeScopeResolver->processExprNode($stmt, $expr->else, $ifFalseScope, $storage, $nodeCallback, $context); $throwPoints = array_merge($throwPoints, $elseResult->getThrowPoints()); @@ -125,7 +130,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifFalseScope = $elseResult->getScope(); } - $condType = $scope->getType($expr->cond); + $condType = $ternaryCondResult->getType(); if ($condType->isTrue()->yes()) { $finalScope = $ifTrueScope; } elseif ($condType->isFalse()->yes()) { @@ -134,7 +139,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($ifTrueType instanceof NeverType && $ifTrueType->isExplicit()) { $finalScope = $ifFalseScope; } else { - $ifFalseType = $ifFalseScope->getType($expr->else); + $ifFalseType = $elseResult->getType(); if ($ifFalseType instanceof NeverType && $ifFalseType->isExplicit()) { $finalScope = $ifTrueScope; @@ -144,15 +149,112 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } + // the single-pass payoff: each branch was evaluated on the matching + // cond-narrowed scope, so the result type composes from the branch + // results — the old resolveType re-processed the condition on a + // throwaway storage to rebuild those scopes + $typeCallback = static function (Expr $e, MutatingScope $s) use ($ternaryCondResult, $ifResult, $elseResult): Type { + if (!$e instanceof Ternary) { + throw new ShouldNotHappenException(); + } + + $booleanCondType = $ternaryCondResult->getTypeForScope($s)->toBoolean(); + + if ($e->if === null) { + // short ternary: the truthy value is the condition itself, + // narrowed by its own truthiness + $truthyScope = $ternaryCondResult->getTruthyScope(); + if ($s->nativeTypesPromoted) { + $promotedTruthyScope = $truthyScope->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedTruthyScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + $truthyScope = $promotedTruthyScope; + } + + if ($booleanCondType->isTrue()->yes()) { + return $ternaryCondResult->getTypeOnScope($truthyScope); + } + + if ($booleanCondType->isFalse()->yes()) { + return $elseResult->getTypeForScope($s); + } + + return TypeCombinator::union( + TypeCombinator::removeFalsey($ternaryCondResult->getTypeOnScope($truthyScope)), + $elseResult->getTypeForScope($s), + ); + } + + if ($ifResult === null) { + throw new ShouldNotHappenException(); + } + + if ($booleanCondType->isTrue()->yes()) { + return $ifResult->getTypeForScope($s); + } + + if ($booleanCondType->isFalse()->yes()) { + return $elseResult->getTypeForScope($s); + } + + return TypeCombinator::union( + $ifResult->getTypeForScope($s), + $elseResult->getTypeForScope($s), + ); + }; + return new ExpressionResult( $finalScope, hasYield: $ternaryCondResult->hasYield(), isAlwaysTerminating: $ternaryCondResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $finalScope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $finalScope->filterByFalseyValue($expr), + // branch scopes via the specify path (§3.13) — a ternary's narrowing + // cannot be composed incrementally from one child + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: $this->createSpecifyTypesCallback($nodeScopeResolver, $stmt), ); } + /** + * New-world copy of specifyTypes(): the ternary rewrites itself into the + * same synthetic disjunction the old world used — + * `(cond && if) || (!cond && else)` — and the synthetic is processed on + * demand through the migrated BooleanOr/BooleanAnd handlers + * (ResultAwareScope tier 4). No seeds: the synthetic's children must be + * evaluated on the ask scope (§3.13). + * + * @return callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Stmt $stmt): callable + { + return function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nodeScopeResolver, $stmt): SpecifiedTypes { + if (!$e instanceof Ternary) { + throw new ShouldNotHappenException(); + } + + if ($e->cond instanceof Ternary || $ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx); + } + + if ($e->if !== null) { + $conditionExpr = new BooleanOr( + new BooleanAnd($e->cond, $e->if), + new BooleanAnd(new Expr\BooleanNot($e->cond), $e->else), + ); + } else { + $conditionExpr = new BooleanOr( + $e->cond, + new BooleanAnd(new Expr\BooleanNot($e->cond), $e->else), + ); + } + + $adapterScope = $s->toResultAwareScope([], $nodeScopeResolver, $stmt, new ExpressionResultStorage()); + + return $this->typeSpecifier->specifyTypesInCondition($adapterScope, $conditionExpr, $ctx)->setRootExpr($e); + }; + } + } diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index 63c9b4720e8..f25fcf83a45 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -28,6 +29,10 @@ final class ThrowHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Throw_; @@ -41,8 +46,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope, hasYield: false, isAlwaysTerminating: true, - throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $scope->getType($expr->expr), $expr, false)]), + throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $exprResult->getType(), $expr, false)]), impurePoints: $exprResult->getImpurePoints(), + expr: $expr, + typeCallback: static fn (): Type => new NonAcceptingNeverType(), + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/UnaryMinusHandler.php b/src/Analyser/ExprHandler/UnaryMinusHandler.php index 2bc2d872380..612f2a713f8 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -17,6 +18,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -28,6 +30,7 @@ final class UnaryMinusHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -41,12 +44,31 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the InitializerExprTypeResolver callback asks the child result first + // (NEW_WORLD.md paragraph 3.12); anything else takes the guarded bridge + $typeCallback = function (Expr $e, MutatingScope $s) use ($exprResult): Type { + if (!$e instanceof UnaryMinus) { + throw new ShouldNotHappenException(); + } + + return $this->initializerExprTypeResolver->getUnaryMinusType($e->expr, static function (Expr $inner) use ($e, $exprResult, $s): Type { + if ($inner === $e->expr) { + return $exprResult->getTypeForScope($s); + } + + return $s->getType($inner); + }); + }; + return new ExpressionResult( $exprResult->getScope(), hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/UnaryPlusHandler.php b/src/Analyser/ExprHandler/UnaryPlusHandler.php index 676110b904f..2ff7da26fee 100644 --- a/src/Analyser/ExprHandler/UnaryPlusHandler.php +++ b/src/Analyser/ExprHandler/UnaryPlusHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -17,6 +18,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -28,6 +30,7 @@ final class UnaryPlusHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -41,12 +44,31 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the InitializerExprTypeResolver callback asks the child result first + // (NEW_WORLD.md paragraph 3.12); anything else takes the guarded bridge + $typeCallback = function (Expr $e, MutatingScope $s) use ($exprResult): Type { + if (!$e instanceof UnaryPlus) { + throw new ShouldNotHappenException(); + } + + return $this->initializerExprTypeResolver->getUnaryPlusType($e->expr, static function (Expr $inner) use ($e, $exprResult, $s): Type { + if ($inner === $e->expr) { + return $exprResult->getTypeForScope($s); + } + + return $s->getType($inner); + }); + }; + return new ExpressionResult( $exprResult->getScope(), hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index e104b9fed42..ad85c75a34a 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -19,6 +20,8 @@ use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; use PHPStan\Type\Type; @@ -34,6 +37,13 @@ final class VariableHandler implements ExprHandler { + public function __construct( + private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Variable; @@ -89,14 +99,35 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $nameResult->isAlwaysTerminating(); $scope = $nameResult->getScope(); } + $typeCallback = static function (Expr $e, MutatingScope $s): Type { + if (!$e instanceof Variable) { + throw new ShouldNotHappenException(); + } + + if (is_string($e->name)) { + if ($s->hasVariableType($e->name)->no()) { + return new ErrorType(); + } + + return $s->getVariableType($e->name); + } + + // dynamic variable names need per-constant-string equality narrowing, + // which requires the BinaryOp equality migration first — guarded + // legacy bridge until then (works under PHPSTAN_FNSR=0) + return $s->getType($e); + }; + return new ExpressionResult( $scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints, - static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), + expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); } diff --git a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php index c952fcc1297..9aa1d457935 100644 --- a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -16,6 +17,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\NativeTypeExpr; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -25,6 +27,12 @@ final class NativeTypeExprHandler implements ExprHandler { + public function __construct( + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof NativeTypeExpr; @@ -35,12 +43,27 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr + $typeCallback = static function (Expr $e, MutatingScope $s): Type { + if (!$e instanceof NativeTypeExpr) { + throw new ShouldNotHappenException(); + } + + if ($s->nativeTypesPromoted) { + return $e->getNativeType(); + } + + return $e->getPhpDocType(); + }; + return new ExpressionResult( $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php index 81c6c9f08f8..6d293acbae9 100644 --- a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -16,6 +17,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -25,6 +27,12 @@ final class TypeExprHandler implements ExprHandler { + public function __construct( + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof TypeExpr; @@ -35,12 +43,23 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr + $typeCallback = static function (Expr $e, MutatingScope $s): Type { + if (!$e instanceof TypeExpr) { + throw new ShouldNotHappenException(); + } + + return $e->getExprType(); + }; + return new ExpressionResult( $scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + expr: $expr, + typeCallback: $typeCallback, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/YieldFromHandler.php b/src/Analyser/ExprHandler/YieldFromHandler.php index 7b86f00abb2..9ec51fed68b 100644 --- a/src/Analyser/ExprHandler/YieldFromHandler.php +++ b/src/Analyser/ExprHandler/YieldFromHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -31,6 +32,10 @@ final class YieldFromHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof YieldFrom; @@ -58,6 +63,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'yieldFrom', 'yield from', true)]), + expr: $expr, + typeCallback: static function (Expr $e, MutatingScope $s) use ($exprResult): Type { + $yieldFromType = $exprResult->getTypeForScope($s); + $generatorReturnType = $yieldFromType->getTemplateType(Generator::class, 'TReturn'); + if ($generatorReturnType instanceof ErrorType) { + return new MixedType(); + } + + return $generatorReturnType; + }, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExprHandler/YieldHandler.php b/src/Analyser/ExprHandler/YieldHandler.php index 48fb166ee70..53ea1b49148 100644 --- a/src/Analyser/ExprHandler/YieldHandler.php +++ b/src/Analyser/ExprHandler/YieldHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -31,6 +32,10 @@ final class YieldHandler implements ExprHandler { + public function __construct(private DefaultNarrowingHelper $defaultNarrowingHelper) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Yield_; @@ -88,6 +93,22 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + expr: $expr, + typeCallback: static function (Expr $e, MutatingScope $s): Type { + $functionReflection = $s->getFunction(); + if ($functionReflection === null) { + return new MixedType(); + } + + $returnType = $functionReflection->getReturnType(); + $generatorSendType = $returnType->getTemplateType(Generator::class, 'TSend'); + if ($generatorSendType instanceof ErrorType) { + return new MixedType(); + } + + return $generatorSendType; + }, + specifyTypesCallback: fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($e, $ctx), ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 746c518953d..32363139b71 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -2,9 +2,23 @@ namespace PHPStan\Analyser; +use PhpParser\Node\Expr; +use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; +use PHPStan\ShouldNotHappenException; +use PHPStan\Type\Type; +use PHPStan\Type\TypeUtils; +use function get_class; +use function sprintf; + final class ExpressionResult { + /** @var (callable(Expr, MutatingScope): Type)|null */ + private $typeCallback; + + /** @var (callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null */ + private $specifyTypesCallback; + /** @var (callable(): MutatingScope)|null */ private $truthyScopeCallback; @@ -15,11 +29,21 @@ final class ExpressionResult private ?MutatingScope $falseyScope = null; + private ?Type $cachedType = null; + + private ?Type $cachedNativeType = null; + /** * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints + * @param (callable(Expr, MutatingScope): Type)|null $typeCallback + * @param (callable(Expr, MutatingScope, TypeSpecifierContext): SpecifiedTypes)|null $specifyTypesCallback * @param (callable(): MutatingScope)|null $truthyScopeCallback * @param (callable(): MutatingScope)|null $falseyScopeCallback + * @param array $companionResults results for companion + * expressions this result's specifyTypesCallback narrows alongside its own + * (the plain-chain variant of a nullsafe fetch) — applySpecifiedTypes + * resolves their pre-narrowing types from here */ public function __construct( private MutatingScope $scope, @@ -29,10 +53,28 @@ public function __construct( private array $impurePoints, ?callable $truthyScopeCallback = null, ?callable $falseyScopeCallback = null, + private ?Expr $expr = null, + ?callable $typeCallback = null, + ?callable $specifyTypesCallback = null, + private ?ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider = null, + private array $companionResults = [], ) { $this->truthyScopeCallback = $truthyScopeCallback; $this->falseyScopeCallback = $falseyScopeCallback; + $this->typeCallback = $typeCallback; + $this->specifyTypesCallback = $specifyTypesCallback; + } + + /** + * Attaches the processed Expr to results coming from not-yet-migrated handlers, + * enabling the legacy type-resolution bridge. Called by NodeScopeResolver::processExprNode(). + * + * @internal + */ + public function setExpr(Expr $expr): void + { + $this->expr ??= $expr; } public function getScope(): MutatingScope @@ -61,34 +103,165 @@ public function getImpurePoints(): array return $this->impurePoints; } - public function getTruthyScope(): MutatingScope + /** + * `ExpressionResult::getType()` is a replacement for `MutatingScope::getType(Expr)` + * for use inside `ExprHandler::processExpr()` implementations. + */ + public function getType(): Type + { + if ($this->cachedType !== null) { + return $this->cachedType; + } + + return $this->cachedType = TypeUtils::resolveLateResolvableTypes($this->getTypeByScope($this->scope)); + } + + /** + * `ExpressionResult::getNativeType()` is a replacement for `MutatingScope::getNativeType(Expr)` + * for use inside `ExprHandler::processExpr()` implementations. + */ + public function getNativeType(): Type + { + if ($this->cachedNativeType !== null) { + return $this->cachedNativeType; + } + + if ($this->typeCallback === null) { + if ($this->expr === null) { + throw new ShouldNotHappenException('ExpressionResult native type was requested but no Expr is attached.'); + } + + // Legacy bridge for not-yet-migrated handlers. Guarded: + // works under PHPSTAN_FNSR=0, throws the guarding exception otherwise. + return $this->cachedNativeType = $this->scope->getNativeType($this->expr); + } + + $promotedScope = $this->scope->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes($this->getTypeByScope($promotedScope)); + } + + /** + * Used instead of `$scope->getType(Expr)` inside the `typeCallback`. The passed scope + * only selects the variant (native types when `nativeTypesPromoted`); the type itself + * is resolved on this result's own (already-correct) scope. + */ + public function getTypeForScope(MutatingScope $scope): Type { - if ($this->truthyScopeCallback === null) { - return $this->scope; + if ($scope->nativeTypesPromoted) { + return $this->getNativeType(); } + return $this->getType(); + } + + /** + * Resolves the type on the given scope, honoring narrowing applied to it + * *after* this expression was evaluated — rules filter their scope by a + * synthetic condition and then ask for types (e.g. a dynamic method call + * narrowed by each possible method name). Unlike `getTypeForScope()`, + * nothing is memoized, and resolution runs on the plain variant of the + * scope so the legacy bridges cannot suspend on this expression again. + */ + public function getTypeOnScope(MutatingScope $scope): Type + { + return TypeUtils::resolveLateResolvableTypes($this->getTypeByScope($scope->toMutatingScope())); + } + + public function hasTypeCallback(): bool + { + return $this->typeCallback !== null && $this->expr !== null; + } + + public function hasSpecifiedTypesCallback(): bool + { + return $this->specifyTypesCallback !== null && $this->expr !== null; + } + + public function getSpecifiedTypes(MutatingScope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + if ($this->expr === null || $this->specifyTypesCallback === null) { + throw new ShouldNotHappenException(sprintf( + 'ExpressionResult specifyTypes was requested but the handler for %s has not been migrated.', + $this->expr === null ? 'this expression' : get_class($this->expr), + )); + } + + $callback = $this->specifyTypesCallback; + return $callback($this->expr, $scope, $context); + } + + public function getTruthyScope(): MutatingScope + { if ($this->truthyScope !== null) { return $this->truthyScope; } - $callback = $this->truthyScopeCallback; - $this->truthyScope = $callback(); - return $this->truthyScope; + // a handler-provided scope callback is authoritative: handlers pass one + // when they can build the branch scope better than re-deriving the whole + // condition from scratch — e.g. BooleanAnd composes the right operand's + // truthy scope incrementally (the left narrowing is already part of it). + // Migrated handlers must pass new-world callbacks here or none at all. + if ($this->truthyScopeCallback !== null) { + $callback = $this->truthyScopeCallback; + return $this->truthyScope = $callback(); + } + + if ($this->specifyTypesCallback !== null && $this->expr !== null) { + return $this->truthyScope = $this->scope->applySpecifiedTypes( + $this->getSpecifiedTypes($this->scope, TypeSpecifierContext::createTruthy()), + $this->getExprResultsForApply(), + ); + } + + return $this->scope; } public function getFalseyScope(): MutatingScope { - if ($this->falseyScopeCallback === null) { - return $this->scope; - } - if ($this->falseyScope !== null) { return $this->falseyScope; } - $callback = $this->falseyScopeCallback; - $this->falseyScope = $callback(); - return $this->falseyScope; + if ($this->falseyScopeCallback !== null) { + $callback = $this->falseyScopeCallback; + return $this->falseyScope = $callback(); + } + + if ($this->specifyTypesCallback !== null && $this->expr !== null) { + return $this->falseyScope = $this->scope->applySpecifiedTypes( + $this->getSpecifiedTypes($this->scope, TypeSpecifierContext::createFalsey()), + $this->getExprResultsForApply(), + ); + } + + return $this->scope; + } + + /** + * @return array + */ + public function getCompanionResults(): array + { + return $this->companionResults; + } + + /** + * Self + companions, keyed by node key — the pre-narrowing type sources + * for applySpecifiedTypes(). + * + * @return array + */ + public function getExprResultsForApply(): array + { + if ($this->expr === null) { + throw new ShouldNotHappenException(); + } + + return $this->companionResults + [$this->scope->getNodeKey($this->expr) => $this]; } public function isAlwaysTerminating(): bool @@ -96,4 +269,39 @@ public function isAlwaysTerminating(): bool return $this->isAlwaysTerminating; } + private function getTypeByScope(MutatingScope $scope): Type + { + if ($this->expr === null) { + throw new ShouldNotHappenException('ExpressionResult type was requested but no Expr is attached.'); + } + + if ($this->typeCallback === null) { + // Legacy bridge for not-yet-migrated handlers. Guarded: + // works under PHPSTAN_FNSR=0, throws the guarding exception otherwise. + return $scope->getType($this->expr); + } + + if ($this->expressionTypeResolverExtensionRegistryProvider !== null) { + foreach ($this->expressionTypeResolverExtensionRegistryProvider->getRegistry()->getExtensions() as $extension) { + $type = $extension->getType($this->expr, $scope); + if ($type !== null) { + return $type; + } + } + } + + if ( + !$this->expr instanceof Expr\Variable + && !$this->expr instanceof Expr\Closure + && !$this->expr instanceof Expr\ArrowFunction + && $scope->hasExpressionType($this->expr)->yes() + ) { + $exprString = $scope->getNodeKey($this->expr); + return $scope->expressionTypes[$exprString]->getType(); + } + + $callback = $this->typeCallback; + return $callback($this->expr, $scope); + } + } diff --git a/src/Analyser/ExpressionResultStorage.php b/src/Analyser/ExpressionResultStorage.php index d14923866c9..9050b875eb0 100644 --- a/src/Analyser/ExpressionResultStorage.php +++ b/src/Analyser/ExpressionResultStorage.php @@ -5,42 +5,51 @@ use Fiber; use PhpParser\Node; use PhpParser\Node\Expr; -use PHPStan\Analyser\Fiber\BeforeScopeForExprRequest; +use PHPStan\Analyser\Fiber\ExpressionResultForExprRequest; use PHPStan\Analyser\Fiber\ParkFiberRequest; use SplObjectStorage; final class ExpressionResultStorage { - /** @var SplObjectStorage */ - private SplObjectStorage $scopes; + /** @var SplObjectStorage */ + private SplObjectStorage $results; - /** @var array, request: BeforeScopeForExprRequest}> */ + /** @var array, request: ExpressionResultForExprRequest}> */ public array $pendingFibers = []; - /** @var list> */ + /** @var list> */ public array $parkedFibers = []; + /** + * Expressions currently being processed on demand by ResultAwareScope — + * descendants (which work on duplicates) detect ancestor cycles through this. + * + * @var array + */ + public array $syntheticsInFlight = []; + public function __construct() { - $this->scopes = new SplObjectStorage(); + $this->results = new SplObjectStorage(); } public function duplicate(): self { $new = new self(); - $new->scopes->addAll($this->scopes); + $new->results->addAll($this->results); + $new->syntheticsInFlight = $this->syntheticsInFlight; return $new; } - public function storeBeforeScope(Expr $expr, Scope $scope): void + public function storeResult(Expr $expr, ExpressionResult $result): void { - $this->scopes[$expr] = $scope; + $this->results[$expr] = $result; } - public function findBeforeScope(Expr $expr): ?Scope + public function findResult(Expr $expr): ?ExpressionResult { - return $this->scopes[$expr] ?? null; + return $this->results[$expr] ?? null; } } diff --git a/src/Analyser/Fiber/BeforeScopeForExprRequest.php b/src/Analyser/Fiber/ExpressionResultForExprRequest.php similarity index 84% rename from src/Analyser/Fiber/BeforeScopeForExprRequest.php rename to src/Analyser/Fiber/ExpressionResultForExprRequest.php index 0fc6ecd35cd..767f334de2d 100644 --- a/src/Analyser/Fiber/BeforeScopeForExprRequest.php +++ b/src/Analyser/Fiber/ExpressionResultForExprRequest.php @@ -5,7 +5,7 @@ use PhpParser\Node\Expr; use PHPStan\Analyser\MutatingScope; -final class BeforeScopeForExprRequest +final class ExpressionResultForExprRequest { public function __construct(public readonly Expr $expr, public readonly MutatingScope $scope) diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index e8d160f6a1d..7a576e4fb83 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -5,9 +5,12 @@ use Fiber; use PhpParser\Node; use PhpParser\Node\Expr; +use PHPStan\Analyser\ExpressionContext; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\ShouldNotHappenException; @@ -29,14 +32,22 @@ public function callNodeCallback( ExpressionResultStorage $storage, ): void { + if ($nodeCallback instanceof NoopNodeCallback) { + return; + } + if (Fiber::getCurrent() !== null) { $nodeCallback($node, $scope->toFiberScope()); return; } if (count($storage->parkedFibers) > 0) { $fiber = array_pop($storage->parkedFibers); + if ($fiber === null) { + throw new ShouldNotHappenException(); + } $request = $fiber->resume([$nodeCallback, $node, $scope]); } else { + /** @var Fiber $fiber */ $fiber = new Fiber(static function () use ($node, $scope, $nodeCallback) { while (true) { // @phpstan-ignore while.alwaysTrue $nodeCallback($node, $scope->toFiberScope()); @@ -48,26 +59,26 @@ public function callNodeCallback( $this->runFiberForNodeCallback($storage, $fiber, $request); } - public function storeBeforeScope(ExpressionResultStorage $storage, Expr $expr, Scope $beforeScope): void + public function storeResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $result): void { - $storage->storeBeforeScope($expr, $beforeScope); - $this->processPendingFibersForRequestedExpr($storage, $expr, $beforeScope); + parent::storeResult($storage, $expr, $result); + $this->processPendingFibersForRequestedExpr($storage, $expr, $result); } /** - * @param Fiber $fiber + * @param Fiber $fiber */ private function runFiberForNodeCallback( ExpressionResultStorage $storage, Fiber $fiber, - BeforeScopeForExprRequest|ParkFiberRequest|null $request, + ExpressionResultForExprRequest|ParkFiberRequest|null $request, ): void { while (!$fiber->isTerminated()) { - if ($request instanceof BeforeScopeForExprRequest) { - $beforeScope = $storage->findBeforeScope($request->expr); - if ($beforeScope !== null) { - $request = $fiber->resume($beforeScope); + if ($request instanceof ExpressionResultForExprRequest) { + $result = $storage->findResult($request->expr); + if ($result !== null) { + $request = $fiber->resume($result); continue; } @@ -100,24 +111,35 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void foreach ($storage->pendingFibers as $key => $pending) { $request = $pending['request']; - $beforeScope = $storage->findBeforeScope($request->expr); - - if ($beforeScope !== null) { + if ($storage->findResult($request->expr) !== null) { throw new ShouldNotHappenException('Pending fibers at the end should be about synthetic nodes'); } unset($storage->pendingFibers[$key]); + // Synthetic node: never visited by traversal, so produce its ExpressionResult now + // on the scope captured at suspension time. + $result = $this->processExprNode( + new Node\Stmt\Expression($request->expr), + $request->expr, + // process on the plain scope — a FiberScope would suspend from within + $request->scope->toMutatingScope(), + $storage, + static function (): void { + }, + ExpressionContext::createDeep(), + ); + $fiber = $pending['fiber']; - $request = $fiber->resume($request->scope); - $this->runFiberForNodeCallback($storage, $fiber, $request); + $nextRequest = $fiber->resume($result); + $this->runFiberForNodeCallback($storage, $fiber, $nextRequest); // Break and restart the loop since the array may have been modified goto start; } } - private function processPendingFibersForRequestedExpr(ExpressionResultStorage $storage, Expr $expr, Scope $result): void + private function processPendingFibersForRequestedExpr(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $result): void { start: diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index b02f322c358..a37888095c3 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -4,18 +4,28 @@ use Fiber; use PhpParser\Node\Expr; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParameterReflection; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; -use function array_pop; +use PHPStan\Type\TypeCombinator; final class FiberScope extends MutatingScope { - /** @var Expr[] */ + /** + * Conditions this scope was filtered by *after* the node visit (rules call + * `filterByTruthyValue` with synthetic conditions — e.g. one per possible + * dynamic method name). Replayed onto each ExpressionResult's own scope in + * getType(): the answer keeps the expression's evaluation-point semantics + * and honors the rule's narrowing. + * + * @var Expr[] + */ private array $truthyValueExprs = []; /** @var Expr[] */ @@ -54,50 +64,129 @@ public function toMutatingScope(): MutatingScope ); } - /** @api */ - public function getType(Expr $node): Type + /** + * Suspends until the engine can deliver the ExpressionResult for the given + * expression — immediately when already processed, after its processExprNode + * finishes when not, or by processing it on demand when it is synthetic. + * + * @internal + */ + public function getExpressionResult(Expr $expr): ExpressionResult { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($node, $this), + /** @var ExpressionResult $result */ + $result = Fiber::suspend( + new ExpressionResultForExprRequest($expr, $this), ); - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); - return $scope->getType($node); + return $result; + } + + public function doNotTreatPhpDocTypesAsCertain(): Scope + { + $scope = parent::doNotTreatPhpDocTypesAsCertain(); + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + $fiberScope = $scope->toFiberScope(); + $fiberScope->truthyValueExprs = $this->truthyValueExprs; + $fiberScope->falseyValueExprs = $this->falseyValueExprs; + + return $fiberScope; } + /** + * The type at the expression's own evaluation point, narrowed by the + * conditions this scope was filtered by since the node visit. + * + * @api + */ + public function getType(Expr $node): Type + { + $result = $this->getExpressionResult($node); + if ($this->truthyValueExprs === [] && $this->falseyValueExprs === []) { + return $result->getTypeForScope($this); + } + + return $result->getTypeOnScope($this->filterByValueExprs($result->getScope())); + } + + /** + * Scope-walk semantics approximated by the expression result + filter replay + * until the dedicated getScopeType design lands — the old walk is the guarded + * legacy path (PHPSTAN_FNSR=0). + */ public function getScopeType(Expr $expr): Type { - return $this->toMutatingScope()->getType($expr); + return $this->getType($expr); } public function getScopeNativeType(Expr $expr): Type { - return $this->toMutatingScope()->getNativeType($expr); + return $this->getNativeType($expr); } /** @api */ public function getNativeType(Expr $expr): Type { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($expr, $this), - ); + $result = $this->getExpressionResult($expr); + if ($this->truthyValueExprs === [] && $this->falseyValueExprs === []) { + return $result->getNativeType(); + } + + $promotedScope = $this->filterByValueExprs($result->getScope())->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); - return $scope->getNativeType($expr); + return $result->getTypeOnScope($promotedScope); } public function getKeepVoidType(Expr $node): Type { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($node, $this), - ); + if ( + !$node instanceof Expr\Match_ + && ( + ( + !$node instanceof Expr\FuncCall + && !$node instanceof Expr\MethodCall + && !$node instanceof Expr\NullsafeMethodCall + && !$node instanceof Expr\StaticCall + ) || $node->isFirstClassCallable() + ) + ) { + return $this->getType($node); + } + + $originalType = $this->getType($node); + if (!TypeCombinator::containsNull($originalType)) { + return $originalType; + } - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); + // the attributed clone is a synthetic expression — the fiber suspends + // for it and the handlers honor the attribute when resolving the + // return type (VoidToNullTypeTransformer) + $clonedNode = clone $node; + $clonedNode->setAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME, true); - return $scope->getKeepVoidType($node); + return $this->getType($clonedNode); + } + + /** + * Replays the rule-applied filters onto the given (plain) scope — the + * filtering runs through the guarded old-world machinery (PHPSTAN_FNSR=0) + * until narrowing by arbitrary synthetic conditions migrates. + */ + private function filterByValueExprs(MutatingScope $scope): MutatingScope + { + foreach ($this->truthyValueExprs as $expr) { + $scope = $scope->filterByTruthyValue($expr); + } + foreach ($this->falseyValueExprs as $expr) { + $scope = $scope->filterByFalseyValue($expr); + } + + return $scope; } public function filterByTruthyValue(Expr $expr): self @@ -106,6 +195,7 @@ public function filterByTruthyValue(Expr $expr): self $scope = parent::filterByTruthyValue($expr); $scope->truthyValueExprs = $this->truthyValueExprs; $scope->truthyValueExprs[] = $expr; + $scope->falseyValueExprs = $this->falseyValueExprs; return $scope; } @@ -113,29 +203,14 @@ public function filterByTruthyValue(Expr $expr): self public function filterByFalseyValue(Expr $expr): self { /** @var self $scope */ - $scope = parent::filterByTruthyValue($expr); + $scope = parent::filterByFalseyValue($expr); + $scope->truthyValueExprs = $this->truthyValueExprs; $scope->falseyValueExprs = $this->falseyValueExprs; $scope->falseyValueExprs[] = $expr; return $scope; } - private function preprocessScope(MutatingScope $scope): Scope - { - if ($this->nativeTypesPromoted) { - $scope = $scope->doNotTreatPhpDocTypesAsCertain(); - } - - foreach ($this->truthyValueExprs as $expr) { - $scope = $scope->filterByTruthyValue($expr); - } - foreach ($this->falseyValueExprs as $expr) { - $scope = $scope->filterByFalseyValue($expr); - } - - return $scope; - } - /** * @param MethodReflection|FunctionReflection|null $reflection */ @@ -151,9 +226,6 @@ public function pushInFunctionCall($reflection, ?ParameterReflection $parameter, public function popInFunctionCall(): self { - $stack = $this->inFunctionCallsStack; - array_pop($stack); - /** @var self $scope */ $scope = parent::popInFunctionCall(); $scope->truthyValueExprs = $this->truthyValueExprs; diff --git a/src/Analyser/InternalScopeFactory.php b/src/Analyser/InternalScopeFactory.php index 3f32562ca40..3bb389d073e 100644 --- a/src/Analyser/InternalScopeFactory.php +++ b/src/Analyser/InternalScopeFactory.php @@ -43,4 +43,6 @@ public function toFiberFactory(): self; public function toMutatingFactory(): self; + public function toResultAwareFactory(): self; + } diff --git a/src/Analyser/LazyInternalScopeFactory.php b/src/Analyser/LazyInternalScopeFactory.php index 30bad59a9a4..16c8cd69d91 100644 --- a/src/Analyser/LazyInternalScopeFactory.php +++ b/src/Analyser/LazyInternalScopeFactory.php @@ -52,6 +52,7 @@ public function __construct( private Container $container, private $nodeCallback, private bool $fiber = false, + private bool $resultAware = false, ) { $this->phpVersion = $this->container->getParameter('phpVersion'); @@ -80,6 +81,8 @@ public function create( $className = MutatingScope::class; if ($this->fiber) { $className = FiberScope::class; + } elseif ($this->resultAware) { + $className = ResultAwareScope::class; } $this->reflectionProvider ??= $this->container->getByType(ReflectionProvider::class); @@ -138,4 +141,9 @@ public function toMutatingFactory(): InternalScopeFactory return new self($this->container, $this->nodeCallback, false); } + public function toResultAwareFactory(): InternalScopeFactory + { + return new self($this->container, $this->nodeCallback, false, true); + } + } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 0980fb6936f..63983951f24 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -36,6 +36,7 @@ use PHPStan\Node\Expr\PossiblyImpureCallExpr; use PHPStan\Node\Expr\PropertyInitializationExpr; use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr; +use PHPStan\Node\Expr\TypeExpr; use PHPStan\Node\IssetExpr; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Node\VirtualNode; @@ -120,6 +121,7 @@ use function count; use function explode; use function get_class; +use function getenv; use function implode; use function in_array; use function is_array; @@ -180,7 +182,7 @@ public function __construct( protected InternalScopeFactory $scopeFactory, private ReflectionProvider $reflectionProvider, private InitializerExprTypeResolver $initializerExprTypeResolver, - private ExpressionTypeResolverExtensionRegistry $expressionTypeResolverExtensionRegistry, + protected ExpressionTypeResolverExtensionRegistry $expressionTypeResolverExtensionRegistry, private ExprPrinter $exprPrinter, private TypeSpecifier $typeSpecifier, private PropertyReflectionFinder $propertyReflectionFinder, @@ -250,6 +252,38 @@ public function toMutatingScope(): self return $this; } + /** + * @param array $exprResults + */ + public function toResultAwareScope(array $exprResults, NodeScopeResolver $nodeScopeResolver, Node\Stmt $stmt, ExpressionResultStorage $storage): ResultAwareScope + { + $scope = $this->scopeFactory->toResultAwareFactory()->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + if (!$scope instanceof ResultAwareScope) { + throw new ShouldNotHappenException(); + } + + $scope->initializeResultAware($this, $exprResults, $nodeScopeResolver, $stmt, $storage); + + return $scope; + } + /** @api */ public function getFile(): string { @@ -896,6 +930,11 @@ public function getAnonymousFunctionReturnType(): ?Type /** @api */ public function getType(Expr $node): Type { + $enableFnsr = getenv('PHPSTAN_FNSR'); + if (PHP_VERSION_ID >= 80100 && $enableFnsr !== '0' && NewWorld::disableOldWorld()) { + throw new ShouldNotHappenException('Scope::getType() should not be used here. Either FiberScope::getType() will be used (by extensions), or ExpressionResult::getType() (by Analyser engine in NodeScopeResolver-adjacent and TypeSpecifier-adjacent code.'); + } + $key = $this->getNodeKey($node); if (!array_key_exists($key, $this->resolvedTypes)) { @@ -1165,11 +1204,21 @@ private function issetCheckUndefined(Expr $expr): ?bool /** @api */ public function getNativeType(Expr $expr): Type { + $enableFnsr = getenv('PHPSTAN_FNSR'); + if (PHP_VERSION_ID >= 80100 && $enableFnsr !== '0' && NewWorld::disableOldWorld()) { + throw new ShouldNotHappenException('Scope::getNativeType() should not be used here. Either FiberScope::getNativeType() will be used (by extensions), or ExpressionResult::getNativeType() (by Analyser engine in NodeScopeResolver-adjacent and TypeSpecifier-adjacent code.'); + } + return $this->promoteNativeTypes()->getType($expr); } public function getKeepVoidType(Expr $node): Type { + $enableFnsr = getenv('PHPSTAN_FNSR'); + if (PHP_VERSION_ID >= 80100 && $enableFnsr !== '0' && NewWorld::disableOldWorld()) { + throw new ShouldNotHappenException('Scope::getKeepVoidType() should not be used here. Either FiberScope::getKeepVoidType() will be used (by extensions), or ExpressionResult::getKeepVoidType() (by Analyser engine in NodeScopeResolver-adjacent and TypeSpecifier-adjacent code.'); + } + if ( !$node instanceof Match_ && ( @@ -2415,10 +2464,8 @@ public function enterMatch(Expr\Match_ $expr, Type $condType, Type $condNativeTy return $this->assignExpression($condExpr, $type, $nativeType); } - public function enterForeach(self $originalScope, Expr $iteratee, string $valueName, ?string $keyName, bool $valueByRef): self + public function enterForeach(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $valueName, ?string $keyName, bool $valueByRef): self { - $iterateeType = $originalScope->getType($iteratee); - $nativeIterateeType = $originalScope->getNativeType($iteratee); $valueType = $originalScope->getIterableValueType($iterateeType); $nativeValueType = $originalScope->getIterableValueType($nativeIterateeType); $scope = $this->assignVariable( @@ -2444,7 +2491,7 @@ public function enterForeach(self $originalScope, Expr $iteratee, string $valueN ); } if ($keyName !== null) { - $scope = $scope->enterForeachKey($originalScope, $iteratee, $keyName); + $scope = $scope->enterForeachKey($originalScope, $iteratee, $iterateeType, $nativeIterateeType, $keyName); if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) { $scope = $scope->assignExpression( @@ -2458,11 +2505,8 @@ public function enterForeach(self $originalScope, Expr $iteratee, string $valueN return $scope; } - public function enterForeachKey(self $originalScope, Expr $iteratee, string $keyName): self + public function enterForeachKey(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $keyName): self { - $iterateeType = $originalScope->getType($iteratee); - $nativeIterateeType = $originalScope->getNativeType($iteratee); - $keyType = $originalScope->getIterableKeyType($iterateeType); $nativeKeyType = $originalScope->getIterableKeyType($nativeIterateeType); @@ -2809,6 +2853,51 @@ private function unsetExpression(Expr $expr): self return $scope->invalidateExpression($expr); } + /** + * Holder-first type read for internal engine bookkeeping (the ArrayDimFetch + * parent-update below): a Yes-tracked expression answers from its holder + * without the guarded resolveType walk — the dim and var of a tracked dim + * fetch are typically holders in the very scope being updated. Anything + * else takes the guarded bridge (PHPSTAN_FNSR=0). + */ + private function getTypeFromTrackedHolder(Expr $expr): Type + { + if ($expr instanceof Node\Scalar) { + return $this->initializerExprTypeResolver->getType($expr, InitializerExprContext::fromScope($this)); + } + + if (!$expr instanceof Expr\Closure && !$expr instanceof Expr\ArrowFunction) { + $exprString = $this->getNodeKey($expr); + if ( + array_key_exists($exprString, $this->expressionTypes) + && $this->hasExpressionType($expr)->yes() + ) { + return TypeUtils::resolveLateResolvableTypes($this->expressionTypes[$exprString]->getType()); + } + } + + return $this->getType($expr); + } + + private function getNativeTypeFromTrackedHolder(Expr $expr): Type + { + if ($expr instanceof Node\Scalar) { + return $this->initializerExprTypeResolver->getType($expr, InitializerExprContext::fromScope($this)); + } + + if (!$expr instanceof Expr\Closure && !$expr instanceof Expr\ArrowFunction) { + $exprString = $this->getNodeKey($expr); + if ( + array_key_exists($exprString, $this->nativeExpressionTypes) + && $this->hasExpressionType($expr)->yes() + ) { + return TypeUtils::resolveLateResolvableTypes($this->nativeExpressionTypes[$exprString]->getType()); + } + } + + return $this->getNativeType($expr); + } + public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, TrinaryLogic $certainty): self { if ($expr instanceof Scalar) { @@ -2842,9 +2931,9 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, && !$expr->dim instanceof Expr\PostDec && !$expr->dim instanceof Expr\PostInc ) { - $dimType = $scope->getType($expr->dim)->toArrayKey(); + $dimType = $scope->getTypeFromTrackedHolder($expr->dim)->toArrayKey(); if ($dimType->isInteger()->yes() || $dimType->isString()->yes()) { - $exprVarType = $scope->getType($expr->var); + $exprVarType = $scope->getTypeFromTrackedHolder($expr->var); $isArray = $exprVarType->isArray(); if (!$exprVarType instanceof MixedType && !$isArray->no()) { $varType = $exprVarType; @@ -2868,7 +2957,7 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, $scope = $scope->specifyExpressionType( $expr->var, $varType, - $scope->getNativeType($expr->var), + $scope->getNativeTypeFromTrackedHolder($expr->var), $certainty, ); } @@ -3397,6 +3486,192 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr)); } + return $this->applySpecifiedExpressionsToConditionals($scope, $specifiedTypes, $specifiedExpressions); + } + + /** + * New-world replacement for filterBySpecifiedTypes(): applies SpecifiedTypes + * without resolving expression types through the guarded Scope::getType(). + * Original (pre-narrowing) types are resolved in tiers: ExpressionTypeResolver + * extensions, scope-tracked holders, ExpressionResults supplied by the caller, + * guarded legacy bridge (PHPSTAN_FNSR=0). + * + * @param array $exprResults + */ + public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes, array $exprResults = []): self + { + $typeSpecifications = []; + foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $type]) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + continue; + } + $typeSpecifications[] = [ + 'sure' => true, + 'exprString' => (string) $exprString, + 'expr' => $expr, + 'type' => $type, + ]; + } + foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $type]) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + continue; + } + $typeSpecifications[] = [ + 'sure' => false, + 'exprString' => (string) $exprString, + 'expr' => $expr, + 'type' => $type, + ]; + } + + usort($typeSpecifications, static function (array $a, array $b): int { + $length = strlen($a['exprString']) - strlen($b['exprString']); + if ($length !== 0) { + return $length; + } + + return $b['sure'] - $a['sure']; // @phpstan-ignore minus.leftNonNumeric, minus.rightNonNumeric + }); + + $scope = $this; + $specifiedExpressions = []; + foreach ($typeSpecifications as $typeSpecification) { + $expr = $typeSpecification['expr']; + $type = $typeSpecification['type']; + + if ($expr instanceof IssetExpr) { + $issetExpr = $expr; + $expr = $issetExpr->getExpr(); + + if ($typeSpecification['sure']) { + $scope = $scope->setExpressionCertainty( + $expr, + TrinaryLogic::createMaybe(), + ); + } else { + $scope = $scope->unsetExpression($expr); + } + + continue; + } + + [$originalType, $originalNativeType] = $scope->resolveOriginalTypesForApply($expr, $exprResults); + + if ($typeSpecification['sure']) { + if ($specifiedTypes->shouldOverwrite()) { + $scope = $scope->assignExpression($expr, $type, $type); + $newType = $type; + } elseif ($scope->isComplexUnionType($originalType)) { + // mirrors addTypeToExpression() + $newType = $originalType; + } else { + $newType = TypeCombinator::intersect($type, $originalType); + $newNativeType = $originalType->equals($originalNativeType) ? $newType : TypeCombinator::intersect($type, $originalNativeType); + $scope = $scope->specifyExpressionType($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } + } elseif ($type instanceof NeverType || $originalType instanceof NeverType || $scope->isComplexUnionType($originalType)) { + // mirrors removeTypeFromExpression() + $newType = $originalType; + } else { + $newType = TypeCombinator::remove($originalType, $type); + $scope = $scope->specifyExpressionType($expr, $newType, TypeCombinator::remove($originalNativeType, $type), TrinaryLogic::createYes()); + } + + $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, TypeUtils::resolveLateResolvableTypes($newType)); + } + + return $this->applySpecifiedExpressionsToConditionals($scope, $specifiedTypes, $specifiedExpressions); + } + + /** + * @param array $exprResults + * @return array{Type, Type} + */ + private function resolveOriginalTypesForApply(Expr $expr, array $exprResults): array + { + foreach ($this->expressionTypeResolverExtensionRegistry->getExtensions() as $extension) { + $extensionType = $extension->getType($expr, $this); + if ($extensionType !== null) { + return [$extensionType, $extensionType]; + } + } + + $resolved = $this->tryResolveOriginalTypesForApply($expr, $exprResults); + if ($resolved !== null) { + return $resolved; + } + + // guarded legacy bridge (works under PHPSTAN_FNSR=0) + return [$this->getType($expr), $this->getNativeType($expr)]; + } + + /** + * @param array $exprResults + * @return array{Type, Type}|null + */ + private function tryResolveOriginalTypesForApply(Expr $expr, array $exprResults): ?array + { + if ($expr instanceof TypeExpr) { + return [$expr->getExprType(), $expr->getExprType()]; + } + + if ($expr instanceof Expr\Closure || $expr instanceof Expr\ArrowFunction) { + return null; + } + + $exprString = $this->getNodeKey($expr); + if ( + array_key_exists($exprString, $this->expressionTypes) + // a Maybe-certainty holder carries the "when defined" type only; + // the original for narrowing math must match getType() semantics + // (a maybe-defined variable is still mixed) + && $this->expressionTypes[$exprString]->getCertainty()->yes() + ) { + $nativeHolder = $this->nativeExpressionTypes[$exprString] ?? $this->expressionTypes[$exprString]; + + return [ + TypeUtils::resolveLateResolvableTypes($this->expressionTypes[$exprString]->getType()), + TypeUtils::resolveLateResolvableTypes($nativeHolder->getType()), + ]; + } + + if (array_key_exists($exprString, $exprResults)) { + return [$exprResults[$exprString]->getType(), $exprResults[$exprString]->getNativeType()]; + } + + if ($expr instanceof Expr\ArrayDimFetch && $expr->dim !== null) { + // a dim fetch built by narrowing code (e.g. the count() index + // inference): derive the original from the resolvable var and dim — + // plain non-null arrays only, ArrayAccess and nullsafe chains bridge + $varPair = $this->tryResolveOriginalTypesForApply($expr->var, $exprResults); + if ($varPair === null) { + return null; + } + $dimPair = $this->tryResolveOriginalTypesForApply($expr->dim, $exprResults); + if ($dimPair === null) { + return null; + } + [$varType, $varNativeType] = $varPair; + [$dimType, $dimNativeType] = $dimPair; + if ($varType->isArray()->yes() && !TypeCombinator::containsNull($varType)) { + return [ + $varType->getOffsetValueType($dimType), + $varNativeType->isArray()->yes() ? $varNativeType->getOffsetValueType($dimNativeType) : new MixedType(), + ]; + } + + return null; + } + + return null; + } + + /** + * @param array $specifiedExpressions + * @return static + */ + private function applySpecifiedExpressionsToConditionals(self $scope, SpecifiedTypes $specifiedTypes, array $specifiedExpressions): self + { $conditions = []; $originallySpecifiedExprStrings = $specifiedExpressions; $prevSpecifiedCount = -1; diff --git a/src/Analyser/NewWorld.php b/src/Analyser/NewWorld.php new file mode 100644 index 00000000000..c97d92bda5e --- /dev/null +++ b/src/Analyser/NewWorld.php @@ -0,0 +1,40 @@ += 80100 && getenv('PHPSTAN_FNSR') !== '0'; + } + + /** + * The single switch for the guard exceptions in MutatingScope::getType()/ + * getNativeType()/getKeepVoidType() and TypeSpecifier::specifyTypesInCondition(). + * + * The committed state is false = mixed mode — migrated handlers run their + * callbacks, everything else takes the legacy bridges; the whole test suite + * must be green here. Flip to true when starting to migrate a handler: the + * old-world entry points then throw on the new-world path — the migration + * meter for the per-handler TDD loop (see NEW_WORLD.md §5a). + * + * The PHP version and PHPSTAN_FNSR gating stays at the call sites. + */ + public static function disableOldWorld(): bool + { + return false; + } + +} diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 41fdf890721..38b80b9bdc4 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -80,6 +80,7 @@ use PHPStan\Node\Expr\ForeachValueByRefExpr; use PHPStan\Node\Expr\GetIterableKeyTypeExpr; use PHPStan\Node\Expr\GetIterableValueTypeExpr; +use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; use PHPStan\Node\Expr\OriginalForeachValueExpr; use PHPStan\Node\Expr\PropertyInitializationExpr; @@ -180,6 +181,7 @@ use function is_array; use function is_int; use function is_string; +use function spl_object_id; use function sprintf; use function strtolower; use function trim; @@ -358,6 +360,11 @@ public function storeBeforeScope(ExpressionResultStorage $storage, Expr $expr, S { } + public function storeResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $result): void + { + $storage->storeResult($expr, $result); + } + protected function processPendingFibers(ExpressionResultStorage $storage): void { } @@ -506,33 +513,17 @@ public function processStmtNodes( * @param Node\Stmt[] $stmts * @param callable(Node $node, Scope $scope): void $nodeCallback */ - private function processStmtNodesInternal( - Node $parentNode, - array $stmts, - MutatingScope $scope, - ExpressionResultStorage $storage, - callable $nodeCallback, - StatementContext $context, - ): InternalStatementResult - { - $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers( - $parentNode, - $stmts, - $scope, - $storage, - $nodeCallback, - $context, - ); - $this->processPendingFibers($storage); - - return $statementResult; - } - /** + * Does not flush pending fibers: a fiber parked on a not-yet-stored expression must + * keep waiting for the expression's real processing (e.g. a do-while condition is + * processed after its body's statement list ends). Pending fibers whose expressions + * never get traversed (synthetic nodes built by rules) are flushed at analysis-unit + * boundaries: end of file statements, function, method, and trait processing. + * * @param Node\Stmt[] $stmts * @param callable(Node $node, Scope $scope): void $nodeCallback */ - private function processStmtNodesInternalWithoutFlushingPendingFibers( + private function processStmtNodesInternal( Node $parentNode, array $stmts, MutatingScope $scope, @@ -810,35 +801,31 @@ public function processStmtNode( $executionEnds = []; $functionImpurePoints = []; $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $functionScope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $functionScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$functionImpurePoints): void { - $nodeCallback($node, $scope); - if ($scope->getFunction() !== $functionScope->getFunction()) { - return; - } - if ($scope->isInAnonymousFunction()) { - return; - } - if ($node instanceof PropertyAssignNode) { - $functionImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - return; - } - if ($node instanceof ExecutionEndNode) { - $executionEnds[] = $node; - return; - } - if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { - $gatheredYieldStatements[] = $node; - } - if (!$node instanceof Return_) { - return; + // collect before forwarding: the inner callback (rules) may suspend + // the fiber, deferring anything after it past the point where the + // FunctionReturnStatementsNode below snapshots these arrays + if ($scope->getFunction() === $functionScope->getFunction() && !$scope->isInAnonymousFunction()) { + if ($node instanceof PropertyAssignNode) { + $functionImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + } elseif ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + } else { + if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { + $gatheredYieldStatements[] = $node; + } + if ($node instanceof Return_) { + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + } + } } - $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + $nodeCallback($node, $scope); }, StatementContext::createTopLevel())->toPublic(); $this->callNodeCallback($nodeCallback, new FunctionReturnStatementsNode( @@ -959,44 +946,38 @@ public function processStmtNode( $executionEnds = []; $methodImpurePoints = []; $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $methodScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$methodImpurePoints): void { - $nodeCallback($node, $scope); - if ($scope->getFunction() !== $methodScope->getFunction()) { - return; - } - if ($scope->isInAnonymousFunction()) { - return; - } - if ($node instanceof PropertyAssignNode) { - if ( - $node->getPropertyFetch() instanceof Expr\PropertyFetch - && $scope->getFunction() instanceof PhpMethodFromParserNodeReflection - && $scope->getFunction()->getDeclaringClass()->hasConstructor() - && $scope->getFunction()->getDeclaringClass()->getConstructor()->getName() === $scope->getFunction()->getName() - && TypeUtils::findThisType($scope->getType($node->getPropertyFetch()->var)) !== null - ) { - return; + // collect before forwarding: the inner callback (rules) may suspend + // the fiber, deferring anything after it past the point where the + // MethodReturnStatementsNode below snapshots these arrays + if ($scope->getFunction() === $methodScope->getFunction() && !$scope->isInAnonymousFunction()) { + if ($node instanceof PropertyAssignNode) { + $isThisConstructorPropertyAssign = $node->getPropertyFetch() instanceof Expr\PropertyFetch + && $scope->getFunction() instanceof PhpMethodFromParserNodeReflection + && $scope->getFunction()->getDeclaringClass()->hasConstructor() + && $scope->getFunction()->getDeclaringClass()->getConstructor()->getName() === $scope->getFunction()->getName() + && TypeUtils::findThisType($scope->getType($node->getPropertyFetch()->var)) !== null; + if (!$isThisConstructorPropertyAssign) { + $methodImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + } + } elseif ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + } else { + if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { + $gatheredYieldStatements[] = $node; + } + if ($node instanceof Return_) { + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + } } - $methodImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - return; - } - if ($node instanceof ExecutionEndNode) { - $executionEnds[] = $node; - return; - } - if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { - $gatheredYieldStatements[] = $node; - } - if (!$node instanceof Return_) { - return; } - $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + $nodeCallback($node, $scope); }, StatementContext::createTopLevel())->toPublic(); $methodReflection = $methodScope->getFunction(); @@ -1059,7 +1040,7 @@ public function processStmtNode( $result = $this->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($echoExpr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($echoExpr, $result->getType(), $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); $scope = $result->getScope(); @@ -1118,7 +1099,6 @@ public function processStmtNode( if ($stmt->expr instanceof Expr\Throw_) { $scope = $stmtScope; } - $earlyTerminationExpr = $this->findEarlyTerminatingExpr($stmt->expr, $scope); $hasAssign = false; $currentScope = $scope; $result = $this->processExprNode($stmt, $stmt->expr, $scope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $currentScope, &$hasAssign): void { @@ -1131,6 +1111,7 @@ public function processStmtNode( } $nodeCallback($node, $scope); }, ExpressionContext::createTopLevel()); + $earlyTerminationExpr = $this->findEarlyTerminatingExpr($stmt->expr, $scope, $stmt, $storage, $result->getType()); $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit()); if ( count($result->getImpurePoints()) === 0 @@ -1143,11 +1124,18 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, new NoopExpressionNode($stmt->expr, $hasAssign), $scope, $storage); } $scope = $result->getScope(); - $scope = $scope->filterBySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition( - $scope, - $stmt->expr, - TypeSpecifierContext::createNull(), - )); + if ($result->hasSpecifiedTypesCallback()) { + $scope = $scope->applySpecifiedTypes( + $result->getSpecifiedTypes($scope, TypeSpecifierContext::createNull()), + $result->getExprResultsForApply(), + ); + } else { + $scope = $scope->filterBySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition( + $scope, + $stmt->expr, + TypeSpecifierContext::createNull(), + )); + } $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); $impurePoints = $result->getImpurePoints(); @@ -1219,6 +1207,10 @@ public function processStmtNode( }); $this->processStmtNodesInternal($stmt, $classLikeStatements, $classScope, $storage, $classStatementsGatherer, $context); + // the gatherer collects each node after the inner callback (rules) ran — + // suspended rule fibers defer those collections, so they must all + // complete before the aggregate nodes below snapshot the gathered data + $this->processPendingFibers($storage); $this->callNodeCallback($nodeCallback, new ClassPropertiesNode($stmt, $this->readWritePropertiesExtensionProvider, $classStatementsGatherer->getProperties(), $classStatementsGatherer->getPropertyUsages(), $classStatementsGatherer->getMethodCalls(), $classStatementsGatherer->getReturnStatementsNodes(), $classStatementsGatherer->getPropertyAssigns(), $classReflection), $classScope, $storage); $this->callNodeCallback($nodeCallback, new ClassMethodsNode($stmt, $classStatementsGatherer->getMethods(), $classStatementsGatherer->getMethodCalls(), $classReflection), $classScope, $storage); $this->callNodeCallback($nodeCallback, new ClassConstantsNode($stmt, $classStatementsGatherer->getConstants(), $classStatementsGatherer->getConstantFetches(), $classReflection), $classScope, $storage); @@ -1301,9 +1293,11 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, $stmt->type, $scope, $storage); } } elseif ($stmt instanceof If_) { - $conditionType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond))->toBoolean(); - $ifAlwaysTrue = $conditionType->isTrue()->yes(); $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $conditionType = (NewWorld::isEnabled() + ? ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType()) + : ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond)))->toBoolean(); + $ifAlwaysTrue = $conditionType->isTrue()->yes(); $exitPoints = []; $throwPoints = $overridingThrowPoints ?? $condResult->getThrowPoints(); $impurePoints = $condResult->getImpurePoints(); @@ -1337,8 +1331,11 @@ public function processStmtNode( $condScope = $scope; foreach ($stmt->elseifs as $elseif) { $this->callNodeCallback($nodeCallback, $elseif, $scope, $storage); - $elseIfConditionType = ($this->treatPhpDocTypesAsCertain ? $condScope->getType($elseif->cond) : $scope->getNativeType($elseif->cond))->toBoolean(); + $elseIfCondScopeBefore = $condScope; $condResult = $this->processExprNode($stmt, $elseif->cond, $condScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $elseIfConditionType = (NewWorld::isEnabled() + ? ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType()) + : ($this->treatPhpDocTypesAsCertain ? $elseIfCondScopeBefore->getType($elseif->cond) : $scope->getNativeType($elseif->cond)))->toBoolean(); $throwPoints = array_merge($throwPoints, $condResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $condResult->getImpurePoints()); $condScope = $condResult->getScope(); @@ -1371,7 +1368,7 @@ public function processStmtNode( $lastElseIfConditionIsTrue = true; } - $condScope = $condScope->filterByFalseyValue($elseif->cond); + $condScope = $condResult->getFalseyScope(); $scope = $condScope; } @@ -1434,6 +1431,7 @@ public function processStmtNode( ); $this->callNodeCallback($nodeCallback, new InForeachNode($stmt), $scope, $storage); $originalScope = $scope; + [$iterateeType, $nativeIterateeType] = $this->getForeachIterateeTypes($condResult, $originalScope); $bodyScope = $scope; if ($stmt->keyVar instanceof Variable) { @@ -1455,19 +1453,20 @@ public function processStmtNode( $storage = $originalStorage->duplicate(); $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; - $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context); + [$iterateeType, $nativeIterateeType] = $this->getForeachIterateeTypes($condResult, $originalScope); + $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $iterateeType, $nativeIterateeType, $originalScope, $originalStorage, $context); if ($unrolledResult !== null) { $bodyScope = $unrolledResult['bodyScope']; $unrolledEndScope = $unrolledResult['endScope']; $unrolledTotalKeys = $unrolledResult['totalKeys']; } else { - $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $nodeCallback); + $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $iterateeType, $nativeIterateeType, $stmt, $nodeCallback); $count = 0; do { $prevScope = $bodyScope; $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); $storage = $originalStorage->duplicate(); - $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); + $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $iterateeType, $nativeIterateeType, $stmt, $nodeCallback); $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); $bodyScope = $bodyScopeResult->getScope(); foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { @@ -1487,7 +1486,7 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); $storage = $originalStorage; - $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); + $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $iterateeType, $nativeIterateeType, $stmt, $nodeCallback); $finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context; $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints(); $finalScope = $finalScopeResult->getScope(); @@ -1522,7 +1521,7 @@ public function processStmtNode( $finalScope = $unrolledEndScope; } - $exprType = $scope->getType($stmt->expr); + $exprType = $condResult->getType(); $hasExpr = $scope->hasExpressionType($stmt->expr); if ( count($breakExitPoints) === 0 @@ -1541,6 +1540,10 @@ public function processStmtNode( $arrayDimFetchLoopTypes = []; $keyLoopTypes = []; foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) { + // the dim fetch and both loop variables are holder-tracked in these + // scopes (enterForeachKey assigns the dim fetch) — the adapter answers + // them without re-walking (NEW_WORLD.md §3.13) + $scopeWithIterableValueType = $scopeWithIterableValueType->toResultAwareScope([], $this, $stmt, $storage); $dimFetchType = $scopeWithIterableValueType->getType($arrayExprDimFetch); // Condition-based narrowings like `is_string($type)` apply to the value // variable but not automatically to the array dim fetch, even though the @@ -1564,6 +1567,7 @@ public function processStmtNode( $arrayDimFetchLoopNativeTypes = []; $keyLoopNativeTypes = []; foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) { + $scopeWithIterableValueType = $scopeWithIterableValueType->toResultAwareScope([], $this, $stmt, $storage); $dimFetchNativeType = $scopeWithIterableValueType->getNativeType($arrayExprDimFetch); if ($originalValueExpr !== null && $scopeWithIterableValueType->hasExpressionType($originalValueExpr)->yes()) { $valueVarNativeType = $scopeWithIterableValueType->getNativeType($stmt->valueVar); @@ -1590,7 +1594,7 @@ public function processStmtNode( $newExprType = $newExprType->mapKeyType(static fn (Type $type): Type => $keyLoopType); } - $nativeExprType = $scope->getNativeType($stmt->expr); + $nativeExprType = $condResult->getNativeType(); $newExprNativeType = $nativeExprType; if ($valueTypeChanged) { $newExprNativeType = $newExprNativeType->mapValueType(static fn (Type $type): Type => $arrayDimFetchLoopNativeType); @@ -1638,7 +1642,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $finalScopeResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $finalScopeResult->getImpurePoints()); } - $traversableThrowPoint = $this->getTraversableForeachThrowPoint($scope, $stmt->expr); + $traversableThrowPoint = $this->getTraversableForeachThrowPoint($scope, $stmt->expr, $condResult->getType()); if ($traversableThrowPoint !== null) { $throwPoints[] = $traversableThrowPoint; } @@ -1658,7 +1662,7 @@ public function processStmtNode( $originalStorage = $storage; $storage = $originalStorage->duplicate(); $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); - $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond))->toBoolean(); + $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $condScope = $condResult->getFalseyScope(); if (!$context->isTopLevel() && $beforeCondBooleanType->isFalse()->yes()) { if (!$this->polluteScopeWithLoopInitialAssignments) { @@ -1702,14 +1706,15 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($scope); $bodyScopeMaybeRan = $bodyScope; $storage = $originalStorage; - $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); + $lastCondResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $bodyScope = $lastCondResult->getTruthyScope(); $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); - $finalScope = $finalScopeResult->getScope()->filterByFalseyValue($stmt->cond); + $finalScope = $this->filterByFalseyValueUsingResult($finalScopeResult->getScope(), $lastCondResult, $stmt->cond); $alwaysIterates = false; $neverIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScopeMaybeRan->getType($stmt->cond) : $bodyScopeMaybeRan->getNativeType($stmt->cond))->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $lastCondResult->getType() : $lastCondResult->getNativeType())->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); $neverIterates = $condBooleanType->isFalse()->yes(); } @@ -1805,9 +1810,11 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); } + $condResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $alwaysIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScope->getType($stmt->cond) : $bodyScope->getNativeType($stmt->cond))->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); } @@ -1823,13 +1830,10 @@ public function processStmtNode( $finalScope = $scope; } if (!$alwaysTerminating) { - $condResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); $hasYield = $condResult->hasYield(); $throwPoints = $condResult->getThrowPoints(); $impurePoints = $condResult->getImpurePoints(); $finalScope = $condResult->getFalseyScope(); - } else { - $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); } $breakExitPoints = $bodyScopeResult->getExitPointsByType(Break_::class); @@ -1873,12 +1877,11 @@ public function processStmtNode( foreach ($stmt->cond as $condExpr) { $condResult = $this->processExprNode($stmt, $condExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); $initScope = $condResult->getScope(); - $condResultScope = $condResult->getScope(); // only the last condition expression is relevant whether the loop continues // see https://www.php.net/manual/en/control-structures.for.php if ($condExpr === $lastCondExpr) { - $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResultScope->getType($condExpr) : $condResultScope->getNativeType($condExpr))->toBoolean(); + $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $isIterableAtLeastOnce = $isIterableAtLeastOnce->and($condTruthiness->isTrue()); } @@ -1927,10 +1930,12 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($initScope); $alwaysIterates = TrinaryLogic::createFromBoolean($context->isTopLevel()); + $lastCondResult = null; if ($lastCondExpr !== null) { - $alwaysIterates = $alwaysIterates->and($bodyScope->getType($lastCondExpr)->toBoolean()->isTrue()); - $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); - $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope); + $lastCondResult = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $alwaysIterates = $alwaysIterates->and($lastCondResult->getType()->toBoolean()->isTrue()); + $bodyScope = $lastCondResult->getTruthyScope(); + $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope, $storage); } $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); @@ -1946,7 +1951,7 @@ public function processStmtNode( $finalScope = $finalScope->generalizeWith($loopScope); if ($lastCondExpr !== null) { - $finalScope = $finalScope->filterByFalseyValue($lastCondExpr); + $finalScope = $this->filterByFalseyValueUsingResult($finalScope, $lastCondResult, $lastCondExpr); } $breakExitPoints = $finalScopeResult->getExitPointsByType(Break_::class); @@ -2052,7 +2057,7 @@ public function processStmtNode( } } - $exhaustive = $scopeForBranches->getType($stmt->cond) instanceof NeverType; + $exhaustive = $condResult->getTypeOnScope($scopeForBranches) instanceof NeverType; if (!$hasDefaultCase && !$exhaustive) { $alwaysTerminating = false; @@ -2305,7 +2310,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); if ($var instanceof ArrayDimFetch && $var->dim !== null) { - $varType = $scope->getType($var->var); + $varType = $scope->toResultAwareScope([], $this, $stmt, $storage)->getType($var->var); if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $this->processExprNode( $stmt, @@ -2436,7 +2441,7 @@ public function leaveNode(Node $node): ?ExistingArrayDimFetch } else { $constantName = new Name\FullyQualified($const->name->toString()); } - $scope = $scope->assignExpression(new ConstFetch($constantName), $scope->getType($const->value), $scope->getNativeType($const->value)); + $scope = $scope->assignExpression(new ConstFetch($constantName), $constResult->getType(), $constResult->getNativeType()); } } elseif ($stmt instanceof Node\Stmt\ClassConst) { $hasYield = false; @@ -2452,8 +2457,8 @@ public function leaveNode(Node $node): ?ExistingArrayDimFetch } $scope = $scope->assignExpression( new Expr\ClassConstFetch(new Name\FullyQualified($scope->getClassReflection()->getName()), $const->name), - $scope->getType($const->value), - $scope->getNativeType($const->value), + $constResult->getType(), + $constResult->getNativeType(), ); } } elseif ($stmt instanceof Node\Stmt\EnumCase) { @@ -2672,17 +2677,61 @@ private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Clo return $scope; } - private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr + /** + * The foreach iteratee's PHPDoc and native types on the given scope — + * memoized result asks when the scope is the iteratee's own evaluation + * scope, per-scope evaluation when it was filtered (e.g. by the + * always-iterable comparison). + * + * @return array{Type, Type} + */ + private function getForeachIterateeTypes(ExpressionResult $condResult, MutatingScope $originalScope): array + { + if ($originalScope === $condResult->getScope()) { + return [$condResult->getType(), $condResult->getNativeType()]; + } + + $promotedScope = $originalScope->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + return [ + $condResult->getTypeOnScope($originalScope), + $condResult->getTypeOnScope($promotedScope), + ]; + } + + /** + * Filters a scope by a condition's falseyness using the condition's own + * ExpressionResult — for engine sites where the filtered scope is NOT the + * result's scope (e.g. the post-body scope of a loop filtered by the + * pre-body condition). Not-yet-migrated conditions take the guarded + * old-world dispatcher (PHPSTAN_FNSR=0). + */ + private function filterByFalseyValueUsingResult(MutatingScope $scope, ExpressionResult $condResult, Expr $cond): MutatingScope + { + if ($condResult->hasSpecifiedTypesCallback()) { + return $scope->applySpecifiedTypes( + $condResult->getSpecifiedTypes($scope, TypeSpecifierContext::createFalsey()), + $condResult->getExprResultsForApply(), + ); + } + + return $scope->filterByFalseyValue($cond); + } + + private function findEarlyTerminatingExpr(Expr $expr, MutatingScope $scope, Node\Stmt $stmt, ExpressionResultStorage $storage, Type $exprType): ?Expr { if (($expr instanceof MethodCall || $expr instanceof Expr\StaticCall) && $expr->name instanceof Node\Identifier) { if (array_key_exists($expr->name->toLowerString(), $this->earlyTerminatingMethodNames)) { if ($expr instanceof MethodCall) { - $methodCalledOnType = $scope->getType($expr->var); + $methodCalledOnType = $scope->toResultAwareScope([], $this, $stmt, $storage)->getType($expr->var); } else { if ($expr->class instanceof Name) { $methodCalledOnType = $scope->resolveTypeByName($expr->class); } else { - $methodCalledOnType = $scope->getType($expr->class); + $methodCalledOnType = $scope->toResultAwareScope([], $this, $stmt, $storage)->getType($expr->class); } } @@ -2715,7 +2764,6 @@ private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr return $expr; } - $exprType = $scope->getType($expr); if ($exprType instanceof NeverType && $exprType->isExplicit()) { return $expr; } @@ -2749,7 +2797,60 @@ public function processExprNode( throw new ShouldNotHappenException(); } - return $this->processExprNode($stmt, $newExpr, $scope, $storage, $nodeCallback, $context); + $innerResult = $this->processExprNode($stmt, $newExpr, $scope, $storage, $nodeCallback, $context); + // carry the original expr, not the virtual callable node — first-class + // callables resolve from reflection; the two scope asks (dynamic + // function name, method receiver) go through an unseeded adapter + $result = new ExpressionResult( + $innerResult->getScope(), + hasYield: $innerResult->hasYield(), + isAlwaysTerminating: $innerResult->isAlwaysTerminating(), + throwPoints: $innerResult->getThrowPoints(), + impurePoints: $innerResult->getImpurePoints(), + expr: $expr, + typeCallback: function (Expr $e, MutatingScope $s) use ($stmt): Type { + if ($e instanceof FuncCall && $e->name instanceof Expr) { + $callableType = $s->toResultAwareScope([], $this, $stmt, new ExpressionResultStorage())->getType($e->name); + if (!$callableType->isCallable()->yes()) { + return new ObjectType(Closure::class); + } + + return $this->initializerExprTypeResolver->createFirstClassCallable( + null, + $callableType->getCallableParametersAcceptors($s), + $s->nativeTypesPromoted, + ); + } + + if ($e instanceof MethodCall) { + if (!$e->name instanceof Node\Identifier) { + return new ObjectType(Closure::class); + } + + $varType = $s->toResultAwareScope([], $this, $stmt, new ExpressionResultStorage())->getType($e->var); + $method = $s->getMethodReflection($varType, $e->name->toString()); + if ($method === null) { + return new ObjectType(Closure::class); + } + + return $this->initializerExprTypeResolver->createFirstClassCallable( + $method, + $method->getVariants(), + $s->nativeTypesPromoted, + ); + } + + if (!$e instanceof Expr\CallLike) { + throw new ShouldNotHappenException(); + } + + return $this->initializerExprTypeResolver->getFirstClassCallableType($e, InitializerExprContext::fromScope($s), $s->nativeTypesPromoted); + }, + // a first-class callable is always a truthy Closure — no narrowing + specifyTypesCallback: static fn (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx): SpecifiedTypes => (new SpecifiedTypes([], []))->setRootExpr($e), + ); + $this->storeResult($storage, $expr, $result); + return $result; } $this->callNodeCallbackWithExpression($nodeCallback, $expr, $scope, $storage, $context); @@ -2760,15 +2861,20 @@ public function processExprNode( continue; } - return $exprHandler->processExpr($this, $stmt, $expr, $scope, $storage, $nodeCallback, $context); + $result = $exprHandler->processExpr($this, $stmt, $expr, $scope, $storage, $nodeCallback, $context); + $result->setExpr($expr); + $this->storeResult($storage, $expr, $result); + return $result; } if ($expr instanceof List_) { // only in assign and foreach, processed elsewhere - return new ExpressionResult($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []); + $result = new ExpressionResult($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], expr: $expr); + $this->storeResult($storage, $expr, $result); + return $result; } - return new ExpressionResult( + $result = new ExpressionResult( $scope, hasYield: false, isAlwaysTerminating: false, @@ -2776,7 +2882,10 @@ public function processExprNode( impurePoints: [], truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + expr: $expr, ); + $this->storeResult($storage, $expr, $result); + return $result; } /** @@ -2983,41 +3092,38 @@ public function processClosureNode( $closureImpurePoints = []; $invalidateExpressions = []; $closureStmtsCallback = static function (Node $node, Scope $scope) use ($nodeCallback, &$executionEnds, &$gatheredReturnStatements, &$gatheredYieldStatements, &$closureScope, &$closureImpurePoints, &$invalidateExpressions): void { - $nodeCallback($node, $scope); - if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { - return; - } - if ($node instanceof PropertyAssignNode) { - $closureImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); - return; - } - if ($node instanceof ExecutionEndNode) { - $executionEnds[] = $node; - return; - } - if ($node instanceof InvalidateExprNode) { - $invalidateExpressions[] = $node; - return; - } - if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { - $gatheredYieldStatements[] = $node; - } - if (!$node instanceof Return_) { - return; + // collect before forwarding: the inner callback (rules) may suspend + // the fiber, deferring anything after it past the point where the + // ClosureReturnStatementsNode below snapshots these arrays + if ($scope->getAnonymousFunctionReflection() === $closureScope->getAnonymousFunctionReflection()) { + if ($node instanceof PropertyAssignNode) { + $closureImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); + } elseif ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + } elseif ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + } else { + if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { + $gatheredYieldStatements[] = $node; + } + if ($node instanceof Return_) { + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + } + } } - $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + $nodeCallback($node, $scope); }; if (count($byRefUses) === 0) { - $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); + $statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); $publicStatementResult = $statementResult->toPublic(); $this->callNodeCallback($nodeCallback, new ClosureReturnStatementsNode( $expr, @@ -3039,7 +3145,7 @@ public function processClosureNode( $prevScope = $closureScope; $storage = $originalStorage->duplicate(); - $intermediaryClosureScopeResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers($expr, $expr->stmts, $closureScope, $storage, new NoopNodeCallback(), StatementContext::createTopLevel()); + $intermediaryClosureScopeResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, new NoopNodeCallback(), StatementContext::createTopLevel()); $intermediaryClosureScope = $intermediaryClosureScopeResult->getScope(); foreach ($intermediaryClosureScopeResult->getExitPoints() as $exitPoint) { $intermediaryClosureScope = $intermediaryClosureScope->mergeWith($exitPoint->getScope()); @@ -3067,7 +3173,7 @@ public function processClosureNode( } $storage = $originalStorage; - $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); + $statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); $publicStatementResult = $statementResult->toPublic(); $this->callNodeCallback($nodeCallback, new ClosureReturnStatementsNode( $expr, @@ -3519,6 +3625,8 @@ public function processArgs( $processingOrder = array_keys($args); $hasReorderedArgs = false; + $argExprTypes = []; + $argResults = []; foreach ($args as $arg) { if ($arg->hasAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE)) { $hasReorderedArgs = true; @@ -3643,6 +3751,10 @@ public function processArgs( } $this->storeBeforeScope($storage, $arg->value, $scopeToPass); + // callback-less companion: the guarded bridge resolves the closure + // on its context scope (the attribute-driven parameter inference) — + // new-world closure typing lands with the ClosureHandler migration + $argResults[$scope->getNodeKey($arg->value)] = new ExpressionResult($scopeToPass, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], expr: $arg->value); $uses = []; foreach ($arg->value->uses as $use) { @@ -3701,13 +3813,15 @@ public function processArgs( $impurePoints = array_merge($impurePoints, $arrowFunctionResult->getImpurePoints()); } $this->storeBeforeScope($storage, $arg->value, $scopeToPass); + $argResults[$scope->getNodeKey($arg->value)] = new ExpressionResult($scopeToPass, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], expr: $arg->value); } else { - $exprType = $scope->getType($arg->value); $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; if ($enterExpressionAssignForByRef) { $scopeToPass = $scopeToPass->enterExpressionAssign($arg->value); } $exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context->enterDeep()); + $exprType = $exprResult->getType(); + $argExprTypes[spl_object_id($arg->value)] = $exprType; $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $exprResult->isAlwaysTerminating(); @@ -3810,7 +3924,9 @@ public function processArgs( $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $argValue); } } elseif ($calleeReflection !== null && $calleeReflection->hasSideEffects()->yes()) { - $argType = $scope->getType($arg->value); + // by-value args were just processed — reuse the result type; + // by-ref args keep the guarded legacy bridge (PHPSTAN_FNSR=0) + $argType = $argExprTypes[spl_object_id($arg->value)] ?? $scope->getType($arg->value); if (!$argType->isObject()->no()) { $nakedReturnType = null; if ($nakedMethodReflection !== null) { @@ -3838,8 +3954,13 @@ public function processArgs( } } - // not storing this, it's scope after processing all args - return new ExpressionResult($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + // not storing this, it's scope after processing all args; the closure/ + // arrow-function argument wrappers ride along so call handlers can seed + // their adapters — a passed closure's type resolves on its context scope + // (the parameter-type inference), which re-processing outside the call + // would lose. Plain arguments re-resolve through the adapter tiers and + // are NOT retained (memory). + return new ExpressionResult($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints, companionResults: $argResults); } /** @@ -3965,8 +4086,23 @@ private function getParameterOutExtensionsType(CallLike $callLike, $calleeReflec /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ - public function processVirtualAssign(MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $var, Expr $assignedExpr, callable $nodeCallback): ExpressionResult + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + * @param (callable(Expr, MutatingScope): Type)|null $assignedTypeCallback resolves + * the assigned value's type in the new world; when null, the virtual + * type wrappers answer directly and anything else takes the guarded + * legacy bridge (PHPSTAN_FNSR=0) + */ + public function processVirtualAssign(MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $var, Expr $assignedExpr, callable $nodeCallback, ?callable $assignedTypeCallback = null): ExpressionResult { + if ($assignedTypeCallback === null) { + if ($assignedExpr instanceof TypeExpr) { + $assignedTypeCallback = static fn (Expr $e, MutatingScope $s): Type => $assignedExpr->getExprType(); + } elseif ($assignedExpr instanceof NativeTypeExpr) { + $assignedTypeCallback = static fn (Expr $e, MutatingScope $s): Type => $s->nativeTypesPromoted ? $assignedExpr->getNativeType() : $assignedExpr->getPhpDocType(); + } + } + return $this->container->getByType(AssignHandler::class)->processAssignVar( $this, $scope, @@ -3976,7 +4112,15 @@ public function processVirtualAssign(MutatingScope $scope, ExpressionResultStora $assignedExpr, new VirtualAssignNodeCallback($nodeCallback), ExpressionContext::createDeep(), - static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $assignedExpr, + typeCallback: $assignedTypeCallback, + ), false, ); } @@ -4044,14 +4188,14 @@ public function processStmtVarAnnotation(MutatingScope $scope, ExpressionResultS $scope = $scope->assignVariable( $name, $varTag->getType(), - $scope->getNativeType($variableNode), + $scope->toResultAwareScope([], $this, $stmt, $storage)->getNativeType($variableNode), $certainty, ); } } if (count($variableLessTags) === 1 && $defaultExpr !== null) { - $originalType = $scope->getType($defaultExpr); + $originalType = $scope->toResultAwareScope([], $this, $stmt, $storage)->getType($defaultExpr); $varTag = $variableLessTags[0]; if (!$originalType->equals($varTag->getType())) { $this->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $defaultExpr), $scope, $storage); @@ -4114,6 +4258,8 @@ public function processVarAnnotation(MutatingScope $scope, array $variableNames, */ private function tryProcessUnrolledConstantArrayForeach( Foreach_ $stmt, + Type $iterateeType, + Type $nativeIterateeType, MutatingScope $originalScope, ExpressionResultStorage $originalStorage, StatementContext $context, @@ -4129,7 +4275,6 @@ private function tryProcessUnrolledConstantArrayForeach( return null; } - $iterateeType = $originalScope->getType($stmt->expr); if (!$iterateeType->isConstantArray()->yes()) { return null; } @@ -4155,7 +4300,6 @@ private function tryProcessUnrolledConstantArrayForeach( return null; } - $nativeIterateeType = $originalScope->getNativeType($stmt->expr); $nativeConstantArrays = $nativeIterateeType->getConstantArrays(); $matchedNativeArrays = count($nativeConstantArrays) === count($constantArrays) ? $nativeConstantArrays : null; @@ -4291,7 +4435,7 @@ private function tryProcessUnrolledConstantArrayForeach( $prevLoopScope = $loopScope; $iterStorage = $originalStorage->duplicate(); $iterBodyScope = $loopScope->mergeWith($endScope); - $iterBodyScope = $this->enterForeach($iterBodyScope, $iterStorage, $originalScope, $stmt, new NoopNodeCallback()); + $iterBodyScope = $this->enterForeach($iterBodyScope, $iterStorage, $originalScope, $iterateeType, $nativeIterateeType, $stmt, new NoopNodeCallback()); $iterBodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $iterBodyScope, $iterStorage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); $loopScope = $iterBodyScopeResult->getScope(); foreach ($iterBodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { @@ -4316,9 +4460,8 @@ private function tryProcessUnrolledConstantArrayForeach( return ['bodyScope' => $bodyScope, 'endScope' => $endScope, 'totalKeys' => $totalKeys]; } - private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $iteratee): ?InternalThrowPoint + private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $iteratee, Type $exprType): ?InternalThrowPoint { - $exprType = $scope->getType($iteratee); $traversableType = new ObjectType(Traversable::class); if ($traversableType->isSuperTypeOf($exprType)->no()) { @@ -4350,13 +4493,12 @@ private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $ite /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ - private function enterForeach(MutatingScope $scope, ExpressionResultStorage $storage, MutatingScope $originalScope, Foreach_ $stmt, callable $nodeCallback): MutatingScope + private function enterForeach(MutatingScope $scope, ExpressionResultStorage $storage, MutatingScope $originalScope, Type $iterateeType, Type $nativeIterateeType, Foreach_ $stmt, callable $nodeCallback): MutatingScope { if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { $scope = $this->processVarAnnotation($scope, [$stmt->expr->name], $stmt); } - $iterateeType = $originalScope->getType($stmt->expr); if ( ($stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name)) && ($stmt->keyVar === null || ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name))) @@ -4365,6 +4507,8 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $scope = $scope->enterForeach( $originalScope, $stmt->expr, + $iterateeType, + $nativeIterateeType, $stmt->valueVar->name, $keyVarName, $stmt->byRef, @@ -4386,7 +4530,7 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto if ( $stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name) ) { - $scope = $scope->enterForeachKey($originalScope, $stmt->expr, $stmt->keyVar->name); + $scope = $scope->enterForeachKey($originalScope, $stmt->expr, $iterateeType, $nativeIterateeType, $stmt->keyVar->name); $vars[] = $stmt->keyVar->name; } elseif ($stmt->keyVar !== null) { $scope = $this->processVirtualAssign( @@ -4455,10 +4599,11 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $args = $stmt->expr->getArgs(); if (count($args) >= 1) { $arrayArg = $args[0]->value; + $arrayArgAdapterScope = $scope->toResultAwareScope([], $this, $stmt, $storage); $scope = $scope->assignExpression( new ArrayDimFetch($arrayArg, $stmt->valueVar), - $scope->getType($arrayArg)->getIterableValueType(), - $scope->getNativeType($arrayArg)->getIterableValueType(), + $arrayArgAdapterScope->getType($arrayArg)->getIterableValueType(), + $arrayArgAdapterScope->getNativeType($arrayArg)->getIterableValueType(), ); } } @@ -4751,7 +4896,11 @@ public function processCalledMethod(MethodReflection $methodReflection): ?Mutati $statementResult = $executionEnd->getStatementResult(); $endNode = $executionEnd->getNode(); if ($endNode instanceof Node\Stmt\Expression) { - $exprType = $statementResult->getScope()->getType($endNode->expr); + $endNodeScope = $statementResult->getScope(); + if (!$endNodeScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + $exprType = $endNodeScope->toResultAwareScope([], $this, $endNode, new ExpressionResultStorage())->getType($endNode->expr); if ($exprType instanceof NeverType && $exprType->isExplicit()) { continue; } @@ -5105,7 +5254,7 @@ private function getNextUnreachableStatements(array $nodes, bool $earlyBinding): return $stmts; } - private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, MutatingScope $bodyScope): MutatingScope + private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, MutatingScope $bodyScope, ExpressionResultStorage $storage): MutatingScope { // infer $items[$i] type from for ($i = 0; $i < count($items); $i++) {...} @@ -5136,12 +5285,13 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $stmt->init[0]->var->name === $lastCondExpr->left->name ) { $arrayArg = $lastCondExpr->right->getArgs()[0]->value; - $arrayType = $bodyScope->getType($arrayArg); + $arrayArgAdapterScope = $bodyScope->toResultAwareScope([], $this, $stmt, $storage); + $arrayType = $arrayArgAdapterScope->getType($arrayArg); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->right->getArgs()[0]->value, $lastCondExpr->left), $arrayType->getIterableValueType(), - $bodyScope->getNativeType($arrayArg)->getIterableValueType(), + $arrayArgAdapterScope->getNativeType($arrayArg)->getIterableValueType(), ); } } @@ -5161,12 +5311,13 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $stmt->init[0]->var->name === $lastCondExpr->right->name ) { $arrayArg = $lastCondExpr->left->getArgs()[0]->value; - $arrayType = $bodyScope->getType($arrayArg); + $arrayArgAdapterScope = $bodyScope->toResultAwareScope([], $this, $stmt, $storage); + $arrayType = $arrayArgAdapterScope->getType($arrayArg); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->left->getArgs()[0]->value, $lastCondExpr->right), $arrayType->getIterableValueType(), - $bodyScope->getNativeType($arrayArg)->getIterableValueType(), + $arrayArgAdapterScope->getNativeType($arrayArg)->getIterableValueType(), ); } } diff --git a/src/Analyser/ResultAwareScope.php b/src/Analyser/ResultAwareScope.php new file mode 100644 index 00000000000..7d3b4c269c1 --- /dev/null +++ b/src/Analyser/ResultAwareScope.php @@ -0,0 +1,273 @@ + */ + private array $exprResults = []; + + private ?MutatingScope $plainScope = null; + + private ?NodeScopeResolver $nodeScopeResolver = null; + + private ?Stmt $stmt = null; + + private ?ExpressionResultStorage $resultStorage = null; + + private ?self $promotedScope = null; + + /** + * @param array $exprResults + * + * @internal + */ + public function initializeResultAware( + MutatingScope $plainScope, + array $exprResults, + NodeScopeResolver $nodeScopeResolver, + Stmt $stmt, + ExpressionResultStorage $resultStorage, + ): void + { + $this->plainScope = $plainScope; + $this->exprResults = $exprResults; + $this->nodeScopeResolver = $nodeScopeResolver; + $this->stmt = $stmt; + $this->resultStorage = $resultStorage; + } + + public function toResultAwareScope(array $exprResults, NodeScopeResolver $nodeScopeResolver, Stmt $stmt, ExpressionResultStorage $storage): self + { + if ($this->plainScope === null) { + // derived through an uncovered scope-mutation path — start fresh from this state + return parent::toResultAwareScope($exprResults, $nodeScopeResolver, $stmt, $storage); + } + + // don't wrap an adapter in an adapter — merge the known results instead + return $this->plainScope->toResultAwareScope($exprResults + $this->exprResults, $nodeScopeResolver, $stmt, $storage); + } + + /** @api */ + public function getType(Expr $node): Type + { + return TypeUtils::resolveLateResolvableTypes($this->resolveTypeViaResults($node)); + } + + /** @api */ + public function getNativeType(Expr $expr): Type + { + $scope = $this->doNotTreatPhpDocTypesAsCertain(); + if (!$scope instanceof self) { + throw new ShouldNotHappenException(); + } + + return $scope->getType($expr); + } + + public function getKeepVoidType(Expr $node): Type + { + // keepVoid is a one-off solved separately; fall back to the regular type for now + return $this->getType($node); + } + + public function doNotTreatPhpDocTypesAsCertain(): Scope + { + if ($this->nativeTypesPromoted) { + return $this; + } + + if ($this->promotedScope !== null) { + return $this->promotedScope; + } + + if ($this->plainScope === null || $this->nodeScopeResolver === null || $this->stmt === null || $this->resultStorage === null) { + // derived through an uncovered scope-mutation path — degrade to the + // plain promoted scope (guarded legacy bridge, PHPSTAN_FNSR=0) + return parent::doNotTreatPhpDocTypesAsCertain(); + } + + $promotedPlainScope = $this->plainScope->doNotTreatPhpDocTypesAsCertain(); + if (!$promotedPlainScope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + return $this->promotedScope = $promotedPlainScope->toResultAwareScope( + $this->exprResults, + $this->nodeScopeResolver, + $this->stmt, + $this->resultStorage, + ); + } + + /** + * The ExpressionResult for the given expr — a known child result, or the + * expression processed on demand. Used by the head of + * TypeSpecifier::specifyTypesInCondition() so that old-world narrowing code + * recursing with this scope stays in the new world where possible. + * + * @internal + */ + public function getExpressionResultForExpr(Expr $expr): ExpressionResult + { + $key = $this->getNodeKey($expr); + if (array_key_exists($key, $this->exprResults)) { + return $this->exprResults[$key]; + } + + if ($this->plainScope === null || ($this->resultStorage !== null && array_key_exists($key, $this->resultStorage->syntheticsInFlight))) { + // no adapter context (derived through an uncovered scope-mutation path), + // or this expression is already being processed up the stack — return a + // callback-less result so the caller takes its guarded legacy bridge + return new ExpressionResult( + $this, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + expr: $expr, + ); + } + + return $this->processSynthetic($expr); + } + + /** + * Scope-deriving methods create new instances through the scope factory — + * carry the adapter context over, mirroring FiberScope. + * + * @param FunctionReflection|MethodReflection|null $reflection + */ + public function pushInFunctionCall($reflection, ?ParameterReflection $parameter, bool $rememberTypes): self + { + $scope = parent::pushInFunctionCall($reflection, $parameter, $rememberTypes); + if (!$scope instanceof self) { + throw new ShouldNotHappenException(); + } + + $scope->copyResultAwareContextFrom($this); + + return $scope; + } + + public function popInFunctionCall(): self + { + $scope = parent::popInFunctionCall(); + if (!$scope instanceof self) { + throw new ShouldNotHappenException(); + } + + $scope->copyResultAwareContextFrom($this); + + return $scope; + } + + private function copyResultAwareContextFrom(self $other): void + { + $this->plainScope = $other->plainScope; + $this->exprResults = $other->exprResults; + $this->nodeScopeResolver = $other->nodeScopeResolver; + $this->stmt = $other->stmt; + $this->resultStorage = $other->resultStorage; + } + + private function resolveTypeViaResults(Expr $node): Type + { + foreach ($this->expressionTypeResolverExtensionRegistry->getExtensions() as $extension) { + $type = $extension->getType($node, $this); + if ($type !== null) { + return $type; + } + } + + if ( + !$node instanceof Expr\Variable + && !$node instanceof Expr\Closure + && !$node instanceof Expr\ArrowFunction + && $this->hasExpressionType($node)->yes() + ) { + return $this->expressionTypes[$this->getNodeKey($node)]->getType(); + } + + if ( + $node instanceof Expr\Variable + && is_string($node->name) + && array_key_exists($this->getNodeKey($node), $this->expressionTypes) + && $this->hasExpressionType($node)->yes() + ) { + // a Yes-tracked variable's type is the holder itself — VariableHandler + // only adds undefined-variable handling, excluded by the Yes certainty. + // (Superglobals are Yes-defined without a holder — they fall through.) + // Keeps variable asks unguarded on filter-derived adapters that lost + // the adapter context (plainScope === null) + return $this->expressionTypes[$this->getNodeKey($node)]->getType(); + } + + $key = $this->getNodeKey($node); + if (array_key_exists($key, $this->exprResults)) { + return $this->exprResults[$key]->getTypeForScope($this); + } + + if ( + $this->plainScope === null + || ($this->resultStorage !== null && array_key_exists($key, $this->resultStorage->syntheticsInFlight)) + ) { + // no adapter context, or this very expression is already being processed + // somewhere up the stack — degrade to the guarded legacy bridge + // (PHPSTAN_FNSR=0) instead of recursing + return parent::getType($node); + } + + return $this->processSynthetic($node)->getTypeForScope($this); + } + + private function processSynthetic(Expr $expr): ExpressionResult + { + if ($this->plainScope === null || $this->nodeScopeResolver === null || $this->stmt === null || $this->resultStorage === null) { + throw new ShouldNotHappenException('ResultAwareScope is missing its adapter context.'); + } + + $storage = $this->resultStorage->duplicate(); + $storage->syntheticsInFlight[$this->getNodeKey($expr)] = true; + + return $this->nodeScopeResolver->processExprNode( + $this->stmt, + $expr, + $this->plainScope, + $storage, + new NoopNodeCallback(), + ExpressionContext::createDeep(), + ); + } + +} diff --git a/src/Analyser/RicherScopeGetTypeHelper.php b/src/Analyser/RicherScopeGetTypeHelper.php index 132c1875809..060041d2233 100644 --- a/src/Analyser/RicherScopeGetTypeHelper.php +++ b/src/Analyser/RicherScopeGetTypeHelper.php @@ -10,6 +10,7 @@ use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Type\Type; use PHPStan\Type\TypeResult; use function is_string; @@ -28,6 +29,17 @@ public function __construct( * @return TypeResult */ public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult + { + return $this->getIdenticalResultFromTypes($scope, $expr, $scope->getType($expr->left), $scope->getType($expr->right)); + } + + /** + * Type-taking variant for the new world: operand types come from their + * ExpressionResults, the scope only answers property-reflection lookups. + * + * @return TypeResult + */ + public function getIdenticalResultFromTypes(Scope $scope, Identical $expr, Type $leftType, Type $rightType): TypeResult { if ( $expr->left instanceof Variable @@ -39,9 +51,6 @@ public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult return new TypeResult(new ConstantBooleanType(true), []); } - $leftType = $scope->getType($expr->left); - $rightType = $scope->getType($expr->right); - if ( ( $expr->left instanceof Node\Expr\PropertyFetch @@ -80,7 +89,15 @@ public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult */ public function getNotIdenticalResult(Scope $scope, Node\Expr\BinaryOp\NotIdentical $expr): TypeResult { - $identicalResult = $this->getIdenticalResult($scope, new Identical($expr->left, $expr->right)); + return $this->getNotIdenticalResultFromTypes($scope, $expr, $scope->getType($expr->left), $scope->getType($expr->right)); + } + + /** + * @return TypeResult + */ + public function getNotIdenticalResultFromTypes(Scope $scope, Node\Expr\BinaryOp\NotIdentical $expr, Type $leftType, Type $rightType): TypeResult + { + $identicalResult = $this->getIdenticalResultFromTypes($scope, new Identical($expr->left, $expr->right), $leftType, $rightType); $identicalType = $identicalResult->type; if ($identicalType instanceof ConstantBooleanType) { return new TypeResult(new ConstantBooleanType(!$identicalType->getValue()), $identicalResult->reasons); diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 27156a8b3f0..37f21797bd8 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -11,6 +11,7 @@ use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Name; +use PHPStan\Analyser\Fiber\FiberScope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Container; use PHPStan\Node\Expr\AlwaysRememberedExpr; @@ -44,10 +45,12 @@ use function array_map; use function array_merge; use function count; +use function getenv; use function in_array; use function strtolower; use function substr; use const COUNT_NORMAL; +use const PHP_VERSION_ID; #[AutowiredService(name: 'typeSpecifier', factory: '@typeSpecifierFactory::create')] final class TypeSpecifier @@ -89,6 +92,32 @@ public function specifyTypesInCondition( return (new SpecifiedTypes([], []))->setRootExpr($expr); } + if ($scope instanceof ResultAwareScope) { + // new world: old-world narrowing code recursing with the adapter + // stays in the new world — resolved through ExpressionResults + $result = $scope->getExpressionResultForExpr($expr); + if ($result->hasSpecifiedTypesCallback()) { + return $result->getSpecifiedTypes($scope, $context); + } + + // not-yet-migrated handler — fall through to the guarded old-world + // dispatcher, keeping the adapter so inner lookups stay unguarded + } elseif ($scope instanceof FiberScope) { + // new world: rules asking for narrowing suspend for the ExpressionResult + $result = $scope->getExpressionResult($expr); + if ($result->hasSpecifiedTypesCallback()) { + return $result->getSpecifiedTypes($scope->toMutatingScope(), $context); + } + + // not-yet-migrated handler — guarded old-world bridge (PHPSTAN_FNSR=0) + $scope = $scope->toMutatingScope(); + } + + $enableFnsr = getenv('PHPSTAN_FNSR'); + if (PHP_VERSION_ID >= 80100 && $enableFnsr !== '0' && NewWorld::disableOldWorld()) { + throw new ShouldNotHappenException('TypeSpecifier should not be used here. Ask ExpressionResult for SpecifiedTypes instead.'); + } + /** @var ExprHandler $exprHandler */ foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { if (!$exprHandler->supports($expr)) { diff --git a/src/Node/ClassStatementsGatherer.php b/src/Node/ClassStatementsGatherer.php index e2b278fb5b0..12c10fb243c 100644 --- a/src/Node/ClassStatementsGatherer.php +++ b/src/Node/ClassStatementsGatherer.php @@ -140,9 +140,12 @@ public function getPropertyAssigns(): array public function __invoke(Node $node, Scope $scope): void { + // gather before forwarding: the inner callback (rules) may suspend the + // fiber, deferring the collection past the point where the class + // aggregate nodes snapshot the gathered data + $this->gatherNodes($node, $scope); $nodeCallback = $this->nodeCallback; $nodeCallback($node, $scope); - $this->gatherNodes($node, $scope); } private function gatherNodes(Node $node, Scope $scope): void diff --git a/tests/PHPStan/Analyser/NewWorldTypeInferenceTest.php b/tests/PHPStan/Analyser/NewWorldTypeInferenceTest.php new file mode 100644 index 00000000000..dbc1badc4ce --- /dev/null +++ b/tests/PHPStan/Analyser/NewWorldTypeInferenceTest.php @@ -0,0 +1,53 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return []; + } + +} diff --git a/tests/PHPStan/Analyser/data/new-world.php b/tests/PHPStan/Analyser/data/new-world.php new file mode 100644 index 00000000000..4eb5eb824f9 --- /dev/null +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -0,0 +1,1289 @@ +', $len); + + $cnt = strlen('abc'); + assertType('3', $cnt); + + $abs = abs($i); + assertType('int<0, max>', $abs); + + $abs2 = abs(7); + assertType('7', $abs2); + + $nested = strlen(strtoupper($s)); + assertType('int<0, max>', $nested); + + $pi = pi(); + assertType('float', $pi); + } + + public function narrowingInIf(string $s): void + { + $v = 1; + if ($v) { + assertType('1', $v); + } else { + assertType('*NEVER*', $v); + } + + $w = rand(0, 1); + assertType('int<0, 1>', $w); + if ($w) { + assertType('1', $w); + } else { + assertType('0', $w); + } + + $len = strlen($s); + assertType('int<0, max>', $len); + if ($len) { + assertType('int<1, max>', $len); + } else { + assertType('0', $len); + } + } + + public function assignInCondition(string $s): void + { + if ($len = strlen($s)) { + assertType('int<1, max>', $len); + } else { + assertType('0', $len); + } + } + + public function functionAsserts(): void + { + $m = mixedValue(); + assertType('mixed', $m); + assertInt($m); + assertType('int', $m); + } + + public function conditionalReturnType(int $i): void + { + assertType('bool', isPositive($i)); + if (isPositive($i)) { + assertType('int<1, max>', $i); + } else { + assertType('int', $i); + } + } + + public function conditionalExpressionHolders(string $s): void + { + $len = strlen($s); + if ($len) { + assertType('non-empty-string', $s); + assertType('int<1, max>', $len); + assertType('int<1, max>', strlen($s)); + } else { + assertType('\'\'', $s); + assertType('0', $len); + } + } + + public function assignByReference(): void + { + $q = 1; + $r = &$q; + assertType('1', $r); + } + + /** + * Each item type is captured at its own evaluation point in the sequence — + * the old world resolves all items on a single scope and cannot get this right. + */ + public function arrayLiteralWithSequentialSideEffects(): void + { + $a = [ + $b = 1, + $b + 1, + $c = $b, + $c + 2, + $c++, + $c, + ]; + assertType('array{1, 2, 1, 3, 1, 2}', $a); + } + + public function comparisonOperators(int $i, int $j): void + { + assertType('bool', $i < $j); + assertType('bool', $i <= $j); + assertType('bool', $i > $j); + assertType('bool', $i >= $j); + assertType('true', 1 < 2); + assertType('true', 2 > 1); + assertType('false', 1 >= 2); + assertType('true', 1 <= 1); + } + + public function equalityOperators(int $i, int $j): void + { + assertType('true', $i == $i); + assertType('false', $i != $i); + assertType('bool', $i == $j); + assertType('bool', $i != $j); + assertType('true', 1 == 1); + assertType('false', 1 != 1); + assertType('bool', $i === $j); + assertType('bool', $i !== $j); + assertType('true', 1 === 1); + assertType('false', 1 !== 1); + } + + public function logicalAndArithmeticOperators(int $i, int $j, bool $b1, bool $b2): void + { + assertType('bool', $b1 xor $b2); + assertType('true', true xor false); + assertType('false', true xor true); + assertType('int<-1, 1>', $i <=> $j); + assertType('1', 2 <=> 1); + assertType("'ab'", 'a' . 'b'); + assertType('int', $i & $j); + assertType('4', 6 & 5); + assertType('int', $i | $j); + assertType('7', 6 | 5); + assertType('int', $i ^ $j); + assertType('3', 6 ^ 5); + assertType('5', 10 / 2); + assertType('(float|int)', $i / $j); + assertType('1', 10 % 3); + assertType('3', 5 - 2); + assertType('int', $i - $j); + assertType('10', 5 * 2); + assertType('25', 5 ** 2); + assertType('20', 5 << 2); + assertType('1', 5 >> 2); + } + + public function incrementDecrement(int $i): void + { + $a = 1; + $preInc = ++$a; + assertType('2', $preInc); + assertType('2', $a); + + $b = 5; + $preDec = --$b; + assertType('4', $preDec); + assertType('4', $b); + + $c = 7; + $postDec = $c--; + assertType('7', $postDec); + assertType('6', $c); + + $u = rand(0, 1) ? 1 : 5; + $u++; + assertType('2|6', $u); + + $d = rand(0, 1) ? 9 : 3; + $d--; + assertType('2|8', $d); + + $i++; + assertType('int', $i); + --$i; + assertType('int', $i); + } + + public function keyedArrayLiteral(int $i): void + { + $a = ['a' => 1, 'b' => $i]; + assertType('array{a: 1, b: int}', $a); + } + + public function callablePairArray(string $method): void + { + if (is_callable([$this, $method])) { + assertType('list{$this(NewWorldTypeInference\Foo), string}&callable(): mixed', [$this, $method]); + } + } + + public function nullableTruthyNarrowing(): void + { + $n = rand(0, 1) ? 'x' : null; + if ($n) { + assertType('\'x\'', $n); + } else { + assertType('null', $n); + } + } + + public function postIncInCondition(int $i): void + { + if ($i++) { + assertType('int', $i); + } + } + + /** + * Nullsafe short-circuiting: a truthy `$bar?->...` implies the subject is + * non-null and the plain-chain variant is narrowed too. In the new world + * this knowledge lives in the nullsafe handlers alone (they process first, + * parents compose their results) — no parent re-derives it from types. + */ + public function nullsafeShortCircuiting(?Holder $holder): void + { + if ($holder?->count) { + assertType('NewWorldTypeInference\Holder', $holder); + assertType('int|int<1, max>', $holder->count); + } + + if ($holder?->name !== null) { + assertType('NewWorldTypeInference\Holder', $holder); + assertType('string', $holder->name); + } + + // nullsafe embedded under a migrated handler's narrowing: the FuncCall's + // conditional return narrows its argument, the apply side narrows $holder + if (strlen((string) $holder?->name) > 0) { + assertType('non-empty-string', (string) $holder?->name); + } + } + + public function nullsafeVariants(Holder $definite, ?Holder $maybe, string $prop): void + { + // non-nullable subject: ?-> behaves like -> (no null union) + assertType('int', $definite?->count); + if ($definite?->count) { + assertType('int|int<1, max>', $definite->count); + } else { + assertType('0', $definite->count); + } + + // null subject: always short-circuits + $nothing = null; + assertType('null', $nothing?->count); + + // chain: the short-circuit null propagates through the plain fetch + assertType('int|null', $maybe?->inner->count); + + // dynamic property names take the legacy bridge + assertType('mixed', $definite->{$prop}); + assertType('mixed', $maybe?->{$prop}); + + // bare statement (null context narrowing) + $maybe?->count; + assertType('NewWorldTypeInference\\Holder|null', $maybe); + } + + public function nullsafeMethodCalls(Holder $definite, ?Holder $maybe): void + { + assertType('int', $definite?->getCount()); + assertType('int|null', $maybe?->getCount()); + + if ($maybe?->getCount()) { + assertType('NewWorldTypeInference\\Holder', $maybe); + assertType('int|int<1, max>', $maybe->getCount()); + } else { + assertType('NewWorldTypeInference\\Holder|null', $maybe); + } + + assertType('int|null', $maybe?->inner->getCount()); + assertNativeType('int|null', $maybe?->getCount()); + } + + /** + * @param array $holders + */ + public function nullsafeOnArrayDimFetch(array $holders): void + { + assertType('int|null', $holders[0]?->count); + } + + public function propertyNativeTypes(Holder $h): void + { + assertNativeType('int', $h->count); + assertNativeType('mixed', $h->untyped); + assertType('*ERROR*', $h->unknownProp); + assertNativeType('*ERROR*', $h->unknownProp); + } + + /** + * @param positive-int $p + */ + public function nativeTypes(int $i, string $s, $p): void + { + assertNativeType('int', $i); + assertNativeType('int<0, max>', strlen($s)); + assertType('int<1, max>', $p); + assertNativeType('mixed', $p); + } + + public function methodCallResult(): void + { + assertType('string', $this->name()); + assertNativeType('string', $this->name()); + } + + public function trackedPropertyNarrowing(): void + { + if (is_int($this->mixedProp)) { + assertType('int', $this->mixedProp); + } + } + + public function mixedNarrowingViaIsFunctions(): void + { + $m = mixedValue(); + if (is_int($m)) { + assertType('int', $m); + } else { + assertType('mixed~int', $m); + } + + $m2 = mixedValue(); + if (is_string($m2)) { + assertType('string', $m2); + } + } + + public function dynamicVariables(string $name): void + { + assertType('*ERROR*', $undefined); + $holder = 1; + assertType('mixed', $$name); + } + + public function unmigratedConditions(string $s, bool $a, bool $b, mixed $m): void + { + if (!$a) { + assertType('false', $a); + } else { + assertType('true', $a); + } + + if ($a && $b) { + assertType('true', $a); + assertType('true', $b); + } + + if ($a || $b) { + assertType('bool', $a); + } + + if ($m instanceof Foo) { + assertType('NewWorldTypeInference\Foo', $m); + } + + if (!empty($s)) { + assertType('non-falsy-string', $s); + } + + $arr = []; + if (rand(0, 1)) { + $arr[] = 'v'; + } + if (isset($arr[0])) { + assertType("array{'v'}", $arr); + } + if (count($arr) > 0) { + assertType("array{'v'}", $arr); + } + } + + public function bareCallStatement(): void + { + $this->name(); + assertType('string', $this->name()); + } + + public function trackedCallExpression(string $s): void + { + $len = strlen($s); + assertType('int<0, max>', strlen($s)); + } + + /** + * @param array $data + */ + public function assertOnUntrackedExpression(array $data): void + { + assert(is_int($data['k'])); + assertType('int', $data['k']); + } + + public function variadicSignatureSelection(int $i): void + { + assertType('int<5, max>', max($i, 5)); + assertType('int', min(1, $i)); + } + + public function echoStatement(string $s): void + { + echo $s; + assertType('string', $s); + } + + public function elseifConditions(int $i): void + { + if ($i > 10) { + assertType('int<11, max>', $i); + } elseif ($i > 5) { + assertType('int<6, 10>', $i); + } else { + assertType('int', $i); + } + } + + public function firstClassCallable(): void + { + $f = strlen(...); + assertType('Closure(string): int<0, max>', $f); + } + + public function listAssignment(): void + { + [$x, $y] = [1, 'a']; + assertType('1', $x); + assertType('\'a\'', $y); + } + + public function closures(): void + { + $fn = function (): int { + return 1; + }; + assertType('1', $fn()); + + $af = static fn (int $z): int => $z + 1; + assertType('int', $af(5)); + } + + public function foreachValueAssignment(): void + { + foreach ([1, 2, 3] as $val) { + assertType('1|2|3', $val); + } + } + + public function dynamicReturnTypeExtensions(mixed $m): void + { + assertType('true', is_int(5)); + assertType('false', is_int('x')); + assertType('bool', is_int($m)); + } + + /** + * intdiv() throw point comes from its DynamicFunctionThrowTypeExtension: + * a possibly-zero divisor throws DivisionByZeroError, a non-zero literal cannot. + */ + public function dynamicThrowTypeExtensions(int $i, int $j): void + { + try { + intdiv($i, $j); + $maybe = 1; + } finally { + assertVariableCertainty(TrinaryLogic::createMaybe(), $maybe); + } + + try { + intdiv($i, 2); + $certain = 1; + } finally { + assertVariableCertainty(TrinaryLogic::createYes(), $certain); + } + } + + public function negatedAndEqualityAsserts(): void + { + $m = mixedValue(); + assertNotInt($m); + assertType('mixed~int', $m); + + $n = mixedValue(); + assertSame5($n); + assertType('5', $n); + } + + /** + * Single-pass composition through a chain deeper than the old + * BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4: each right operand is evaluated + * on the left-truthy scope, so the chain composes linearly with no re-walk. + */ + public function deepBooleanAndChain(bool $a, bool $b, bool $c, bool $d, bool $e, bool $f): void + { + if ($a && $b && $c && $d && $e && $f) { + assertType('true', $a); + assertType('true', $f); + assertType('true', $a && $b && $c && $d && $e && $f); + } + } + + public function deepBooleanOrChain(?int $a, ?int $b, ?int $c, ?int $d, ?int $e, ?int $f): void + { + if ($a || $b || $c || $d || $e || $f) { + assertType('bool', $a || $b || $c || $d || $e || $f); + } else { + assertType('0|null', $a); + assertType('0|null', $f); + } + } + + public function booleanConstantFolding(bool $b): void + { + assertType('true', 1 && 1); + assertType('bool', 1 && $b); + assertType('bool', 0 || $b); + assertType('true', 1 || $b); + assertType('false', $b && 0); + } + + /** the right operand sees the left-truthy/left-falsey scope */ + public function booleanInsideOutNarrowing(?bool $a, bool $b): void + { + if ($a && $b) { + assertType('true', $a); + assertType('true', $b); + } + if ($a || $b) { + assertType('bool|null', $a); + } else { + assertType('false|null', $a); + assertType('false', $b); + } + } + + /** + * The truthy scope of `A && B` is composed incrementally from the right + * operand's truthy scope — re-deriving the whole conjunction would union + * per-arm types and drift the representation (array vs the + * expected array from is_array()). + */ + public function booleanAndNarrowingRepresentation(mixed $m): void + { + if ($m != 0 && !is_array($m) && $m != null && !is_object($m)) { + assertType("mixed~(0|0.0|''|'0'|array|object|false|null)", $m); + } + } + + /** + * The falsey scope of `A && B` comes from the specify callback: narrowing + * originals must be the pre-condition types (per-base adapter seeding) — + * the remembered is_bool() narrowing of the truthy branch must not leak. + */ + public function booleanAndFalseyOriginals(Holder $h): void + { + if (is_bool($h->untyped) && $h->untyped) { + assertType('true', $h->untyped); + } else { + assertType('mixed~true', $h->untyped); + } + assertType('mixed', $h->untyped); + } + + /** + * A dynamic-name call as a boolean operand: its narrowing ask must not + * bounce between the adapter head-check and the FuncCall specify callback + * (the dynamic-name bridge invokes the old-world body directly). + * + * @param callable(): bool $f + */ + public function dynamicNameCallInCondition(callable $f, ?int $i): void + { + if ($i !== null && $f()) { + assertType('int', $i); + } + } + + public function booleanOrShortcutNarrowing(bool $b, bool $c): void + { + if (0 || $b) { + assertType('true', $b); + } + if ($b || 0) { + assertType('true', $b); + } + if (($b || $c) && 1) { + assertType('bool', $b); + } + } + + public function booleanStatementNullContext(bool $a, bool $b): void + { + $a && $b; + $a || $b; + assertType('bool', $a); + } + + public function booleanOrInsideAndFalsey(?int $a, ?int $b, bool $c): void + { + if (($a || $b) && $c) { + assertType('true', $c); + } else { + assertType('int|null', $a); + } + } + + public function booleanOrUnmigratedArm(?int $a, bool $b): void + { + if (!$a || $b) { + assertType('int|null', $a); + } else { + assertType('int|int<1, max>', $a); + } + } + + /** @param array $arr */ + public function booleanIssetHolderRederivation(array $arr): void + { + $ok = isset($arr['a']) && isset($arr['b']); + if ($ok) { + assertType('int', $arr['a']); + assertType('int', $arr['b']); + } + } + + public function booleanOverwriteArm(string $s, bool $b): void + { + if (in_array($s, ['a', 'b'], true) && $b) { + assertType("'a'|'b'", $s); + } + } + + /** constant folds asked through a parent boolean's specify callback */ + public function booleanFoldsViaParentAsks(bool $b, bool $c): void + { + if (($b && 0) || $c) { + assertType('true', $c); + } + if (($b || 1) || $c) { + assertType('bool', $c); + } + if ((0 || 0) || $c) { + assertType('true', $c); + } + } + + /** a negated exactly-true ask drives the mixed truthy-and-false context */ + public function booleanNegatedExactContext(mixed $m, bool $b): void + { + if (!($m instanceof Holder && $b) === false) { + assertType(Holder::class, $m); + assertType('bool', $b); + } + } + + /** + * Each ternary branch was evaluated on the matching cond-narrowed scope — + * the result type composes from the branch results (no cond re-processing). + */ + public function ternaryBasics(bool $b, ?int $a): void + { + assertType('1|2', $b ? 1 : 2); + assertType("'x'|int|int<1, max>", $a ?: 'x'); + assertType("'a'", 1 ? 'a' : 'b'); + assertType("'b'", 0 ? 'a' : 'b'); + assertType('int|int<1, max>', $a ? $a : 5); + } + + /** ternary narrowing: the synthetic (cond && if) || (!cond && else) */ + public function ternaryAsCondition(?bool $b, ?int $c): void + { + if ($b ? $c : 0) { + assertType('true', $b); + assertType('int|int<1, max>', $c); + } + } + + public function ternaryStatementNullContext(bool $b): void + { + $b ? 1 : 2; + assertType('bool', $b); + } + + /** + * Conditional-expression holders projected from a ternary assignment: + * pinning one boolean value pins the recorded cond narrowing. + */ + public function ternaryAssignConditionalHolders(mixed $m): void + { + $flag = $m instanceof Holder ? 1 : 0; + if ($flag === 1) { + assertType(Holder::class, $m); + } else { + assertType('mixed~'.Holder::class, $m); + } + } + + public function booleanNotFolds(bool $b, ?int $a): void + { + assertType('false', !1); + assertType('true', !0); + assertType('bool', !$b); + if (!$a) { + assertType('0|null', $a); + } else { + assertType('int|int<1, max>', $a); + } + assertType('bool', !!$b); + } + + /** + * `!$i?->isA()` falsey = the nullsafe truthy scope: the plain call's + * type-specifying extensions (assert-if-true) compose into the nullsafe + * narrowing (bug-12866 regression). + */ + public function nullsafeAssertIfTrueNarrowing(?AssertingInterface $i): void + { + if (!$i?->isA()) { + return; + } + + assertType(AssertedClass::class, $i); + } + + public function ternaryShortFoldsAndNative(?int $a): void + { + assertType('1', 1 ?: 'x'); + assertType("'x'", 0 ?: 'x'); + assertNativeType("'x'|int|int<1, max>", $a ?: 'x'); + } + + public function ternaryShortAsCondition(?int $a, ?int $b): void + { + if ($a ?: $b) { + assertType('int|null', $a); + } else { + assertType('0|null', $a); + assertType('int|null', $b); + } + } + + public function booleanNotStatementNullContext(bool $b): void + { + !$b; + assertType('bool', $b); + } + + /** untracked compound entries in projected ternary-assign holders */ + public function ternaryAssignUntrackedEntries(Holder $h): void + { + $flag = is_int($h->untyped) ? 1 : 0; + if ($flag === 1) { + assertType('int', $h->untyped); + } + } + + /** + * Statement-level condition handling goes through the conditions' + * ExpressionResults (NodeScopeResolver getType sweep): elseif chains, + * loop conditions and exits, foreach value/key, switch exhaustiveness. + */ + public function statementIfElseIfChain(bool $a, bool $b): void + { + if ($a) { + assertType('true', $a); + } elseif ($b) { + assertType('false', $a); + assertType('true', $b); + } else { + assertType('false', $b); + } + } + + public function statementWhile(?int $i): void + { + while ($i) { + assertType('int|int<1, max>', $i); + $i = 0; + } + // the test config has polluteScopeWithLoopInitialAssignments=false, + // so the loop-exit merge keeps the tight 0|null + assertType('0|null', $i); + } + + public function statementDoWhile(bool $b): void + { + do { + $x = 1; + } while ($b); + assertType('1', $x); + } + + public function statementFor(bool $b): void + { + for ($j = 0; $b; $j++) { + assertType('true', $b); + } + assertType('int<0, max>', $j); + } + + public function statementAlwaysTrueWhile(): void + { + $k = 0; + while (1) { + $k++; + if ($k) { + break; + } + } + assertType('1', $k); + } + + /** @param non-empty-array $items */ + public function statementForeach(array $items): void + { + foreach ($items as $key => $value) { + assertType('int', $key); + assertType('string', $value); + } + } + + public function statementSwitchDefault(int $i): void + { + switch ($i) { + default: + assertType('int', $i); + } + } + + public function constFetchLiterals(bool $b): void + { + assertType('true', true); + assertType('false', false); + assertType('null', null); + assertType('2147483647|9223372036854775807', PHP_INT_MAX); + assertType('bool', $b && true); + assertType('bool', $b || false); + assertType('1', true ? 1 : 2); + assertType('2', false ?: 2); + if ($b !== false) { + assertType('true', $b); + } + } + + public function unaryMinus(int $i, float $f): void + { + assertType('-5', -5); + assertType('int', -$i); + assertType('float', -$f); + assertType('7', -(-7)); + assertType('bool', (bool) -$i); + } + + public function unaryPlus(int $i): void + { + assertType('5', +5); + assertType('int', +$i); + assertType('3', +'3'); + } + + public function bitwiseNot(int $i): void + { + assertType('-6', ~5); + assertType('int', ~$i); + } + + public function printExpr(): void + { + assertType('1', print 'x'); + } + + public function cloneExpr(Holder $h): void + { + assertType(Holder::class, clone $h); + } + + public function errorSuppress(?int $i): void + { + assertType('int|null', @$i); + if (@$i) { + assertType('int|int<1, max>', $i); + } + } + + public function exitInTernary(bool $b): void + { + $b ? exit(1) : null; + assertType('false', $b); + } + + public function casts(?int $i, string $s): void + { + assertType("''|decimal-int-string", (string) $i); + assertType('int', (int) $s); + assertType('bool', (bool) $i); + assertType('float', (float) $i); + assertType('array{}|array{int}', (array) $i); + assertType('stdClass', (object) $i); + assertType('1', (int) true); + assertType("'5'", (string) 5); + } + + /** cast narrowing via the old comparison synthetics through the adapter */ + public function castNarrowing(?int $i, string $s): void + { + if ((bool) $i) { + assertType('int|int<1, max>', $i); + } + if ((string) $s) { + assertType("non-empty-string", $s); + } + } + + /** + * @param array{a: int, b?: string} $shape + * @param list $list + * @param array $map + */ + public function arrayDimFetches(array $shape, array $list, array $map, int $i, string $k, ?Holder $maybe): void + { + assertType('int', $shape['a']); + assertType('string', $list[$i]); + assertType('int', $map[$k]); + assertType('int|int<1, max>', $map[$k] ?: 5); + if ($list[0] === 'x') { + assertType("'x'", $list[0]); + } + assertType('int|null', $maybe?->inner->count); + assertType('1', [1, 2][0]); + } + + public function arrayAccessFetch(\ArrayObject $ao): void + { + assertType('mixed', $ao['key']); + } + + /** @param array $map */ + public function issetChecks(?int $i, array $map, string $k): void + { + assertType('bool', isset($i)); + if (isset($i)) { + assertType('int', $i); + } else { + assertType('null', $i); + } + if (isset($map[$k])) { + assertType('int', $map[$k]); + } + if (isset($i, $map[$k])) { + assertType('int', $i); + } + } + + /** @return \Generator */ + public function yields(): \Generator + { + $sent = yield 'value'; + assertType('bool', $sent); + $ret = yield from $this->innerGen(); + assertType('int', $ret); + + return 1.5; + } + + /** @return \Generator */ + private function innerGen(): \Generator + { + yield 'x'; + + return 5; + } + + /** @param class-string $cls */ + public function instantiations(string $cls): void + { + assertType(Holder::class, new Holder()); + assertType(Holder::class, new $cls()); + assertType('NewWorldTypeInference\\GenericBox', new GenericBox(5)); + assertType('NewWorldTypeInference\\GenericBox', new GenericBox('s')); + } + + public function firstClassCallables(Holder $h): void + { + assertType('Closure(string): int<0, max>', strlen(...)); + assertType('Closure(): int', $h->getCount(...)); + } + + public function methodAndStaticCalls(Holder $h, ?Holder $maybe): void + { + assertType('int', $h->getCount()); + assertType('int|null', $maybe?->getCount()); + if ($maybe?->getCount()) { + assertType(Holder::class, $maybe); + } + } + + /** + * A passed closure's parameter types come from the call context — the + * per-arg companion results carry the context-aware memo into the call + * handlers' adapters. + */ + public function passedClosureContext(): void + { + $mapped = array_map(static function ($s) { + assertType("'a'|'bb'", $s); + + return strlen($s); + }, ['a', 'bb']); + assertType('array{1, 2}', $mapped); + } + + public function staticPropertyFetches(): void + { + assertType('int', StaticHolder::$count); + assertType('string|null', StaticHolder::$name); + if (StaticHolder::$name !== null) { + assertType('string', StaticHolder::$name); + } + } + + /** @param array $map */ + public function coalesce(?int $i, array $map, string $k, ?string $maybe): void + { + assertType('int', $i ?? 5); + assertType("'fallback'|int", $map[$k] ?? 'fallback'); + assertType('int|string|null', $maybe ?? $i); + $x = $i ?? 0; + assertType('int', $x); + if (($i ?? 0) === 5) { + assertType('5', $i); + } + } + + /** @param array $map */ + public function emptyChecks(?int $i, array $map): void + { + assertType('bool', empty($i)); + if (empty($i)) { + assertType('0|null', $i); + } else { + assertType('int|int<1, max>', $i); + } + if (!empty($map)) { + assertType('non-empty-array', $map); + } + } + + public function equalityNarrowing(mixed $m, ?int $i, ?Holder $h, int $n): void + { + if ($i === null) { + assertType('null', $i); + } else { + assertType('int', $i); + } + if ($h !== null) { + assertType(Holder::class, $h); + } + if ($m === 'str') { + assertType("'str'", $m); + } + if ($i == null) { + assertType('0|null', $i); + } else { + assertType('int|int<1, max>', $i); + } + if ($n === 5 || $n === 6) { + assertType('5|6', $n); + } + assertType('bool', $i === null); + assertType('true', 5 === 5); + assertType('false', 5 !== 5); + } + + public function comparisonNarrowing(int $n, ?int $i): void + { + if ($n > 5) { + assertType('int<6, max>', $n); + } + if ($n <= 0) { + assertType('int', $n); + } + if ($i !== null && $i >= 10) { + assertType('int<10, max>', $i); + } + } + + /** @param list $items */ + public function countAndStrlenPatterns(array $items, int $idx, string $s): void + { + if (count($items) > 0) { + assertType('non-empty-list', $items); + } + if ($idx >= 0 && $idx < count($items)) { + assertType('string', $items[$idx]); + } + if (strlen($s) > 0) { + assertType('non-empty-string', $s); + } + } + + public function instanceofChecks(mixed $m, Holder $h, object $o): void + { + assertType('bool', $m instanceof Holder); + assertType('true', $h instanceof Holder); + assertType('false', $h instanceof AssertedClass); + if ($m instanceof Holder && $o instanceof AssertingInterface) { + assertType(Holder::class, $m); + assertType(AssertingInterface::class, $o); + } + if ($m instanceof Holder || $m instanceof AssertedClass) { + assertType('NewWorldTypeInference\\AssertedClass|NewWorldTypeInference\\Holder', $m); + } + if (!($m instanceof Holder)) { + assertType('mixed~NewWorldTypeInference\\Holder', $m); + } + assertType('NewWorldTypeInference\\Holder|null', $m instanceof Holder ? $m : null); + } + + /** @param class-string $cls */ + public function instanceofDynamic(mixed $m, string $cls, object $obj): void + { + if ($m instanceof $cls) { + assertType(Holder::class, $m); + } + if ($m instanceof $obj) { + assertType('object', $m); + } + } + + public function classConstFetch(string $name, int $i): void + { + assertType("'CONST'", Holder::TEST_CONST); + assertType("'NewWorldTypeInference\\\\Holder'", Holder::class); + assertType('non-falsy-string', "prefix-{$name}"); + assertType('lowercase-string&non-falsy-string', "n={$i}!"); + assertType('non-empty-string', "{$name}{$i}"); + } + + private function name(): string + { + return 'x'; + } + + /** @var mixed */ + private $mixedProp; + +} + +class Holder +{ + + public const TEST_CONST = 'CONST'; + + public int $count = 0; + + public string $name = ''; + + public Holder $inner; + + public function getCount(): int + { + return $this->count; + } + + /** @var mixed */ + public $untyped; + +} + +interface AssertingInterface +{ + + /** + * @phpstan-assert-if-true AssertedClass $this + */ + public function isA(): bool; + +} + +/** @template T */ +class GenericBox +{ + + /** @param T $value */ + public function __construct(public mixed $value) + { + } + +} + +class StaticHolder +{ + + public static int $count = 0; + + public static ?string $name = null; + +} + +class AssertedClass implements AssertingInterface +{ + + public function isA(): bool + { + return true; + } + +} + +function mixedValue(): mixed +{ + return 1; +} + +/** + * @phpstan-assert int $value + */ +function assertInt(mixed $value): void +{ +} + +/** + * @return ($i is int<1, max> ? true : false) + */ +function isPositive(int $i): bool +{ + return $i >= 1; +} + +/** + * @phpstan-assert !int $value + */ +function assertNotInt(mixed $value): void +{ +} + +/** + * @phpstan-assert =5 $value + */ +function assertSame5(mixed $value): void +{ +}