diff --git a/NEW_WORLD.md b/NEW_WORLD.md new file mode 100644 index 00000000000..e2dca35b278 --- /dev/null +++ b/NEW_WORLD.md @@ -0,0 +1,275 @@ +# 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: a guard commit makes `MutatingScope::getType()/getNativeType()/getKeepVoidType()` +and `TypeSpecifier::specifyTypesInCondition()` **throw** unless `PHPSTAN_FNSR=0`. The old +world stays fully functional under `PHPSTAN_FNSR=0` (PHP < 8.1 path); the default (guard on) +is the new world and fails loudly wherever migration is incomplete. + +## 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. + +## 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**: every new-world change starts with a failing probe in + `NewWorldTypeInferenceTest`'s data file, then is made green. Each condition and + branch of new-world code gets a probing assertType test, verified in *both* + worlds (the data file is analysed under the guard and under `PHPSTAN_FNSR=0`, + and must agree). +- **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. + +## 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**: a temporary `TypeInferenceTestCase` subclass + data files asserting + types for migrated handlers, running with the guard active. The pre-existing suite stays red + under the guard until the rewrite completes (and green under `PHPSTAN_FNSR=0` throughout). + **When the whole suite is green under the guard, 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. 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/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 540119391e7..b8f77003ff1 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; @@ -95,6 +98,8 @@ public function __construct( private PhpVersion $phpVersion, private ExprPrinter $exprPrinter, private MatchHandler $matchHandler, + private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -289,6 +294,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 +304,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 +334,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 +345,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 +373,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( @@ -388,9 +409,69 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex 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, $assignedExprResult->getTypeForScope($scope), $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, $assignedExprResult->getTypeForScope($scope), $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,10 +509,18 @@ 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 — old world only until TernaryHandler/MatchHandler migrate $conditionalExpressions = []; - if ($assignedExpr instanceof Ternary) { + if (!NewWorld::isEnabled() && $assignedExpr instanceof Ternary) { $if = $assignedExpr->if; if ($if === null) { $if = $assignedExpr->cond; @@ -455,26 +544,64 @@ public function processAssignVar( } } - if ($assignedExpr instanceof Match_) { + if (!NewWorld::isEnabled() && $assignedExpr instanceof Match_) { $conditionalExpressions = $this->mergeConditionalExpressions( $conditionalExpressions, $this->processMatchForConditionalExpressionsAfterAssign($scopeBeforeAssignEval, $var->name, $assignedExpr), ); } + $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,19 +625,32 @@ public function processAssignVar( $astNode = new Node\Expr\Array_($falseyScalar); } - $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); + if (NewWorld::isEnabled()) { + // `$assignedExpr !== ` / `=== ` narrowing, + // constructed directly (equality on a constant scalar removes/pins its type) + $notIdenticalSpecifiedTypes = new SpecifiedTypes(sureNotTypes: [$assignedExprString => [$assignedExpr, $falseyType]]); + } else { + $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, $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); + if (NewWorld::isEnabled()) { + $identicalSpecifiedTypes = new SpecifiedTypes([$assignedExprString => [$assignedExpr, $falseyType]], []); + } else { + $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, $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); } @@ -1068,7 +1208,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 +1229,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 +1237,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 +1258,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 +1287,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(), ); } 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..658482be9e7 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -94,8 +94,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $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()); } diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 4fdfc9adfc6..23c24a6ca22 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -48,7 +48,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()); diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index d28a3225dee..05e23ac0afb 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; @@ -31,11 +32,13 @@ use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Type\DynamicReturnTypeExtensionRegistryProvider; +use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; use PHPStan\DependencyInjection\Type\DynamicThrowTypeExtensionProvider; 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; @@ -43,17 +46,22 @@ use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Reflection\ResolvedFunctionVariant; use PHPStan\Reflection\ReflectionProvider; +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\ConditionalTypeForParameter; use PHPStan\Type\ClosureType; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; 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; @@ -66,9 +74,12 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; +use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use Throwable; +use function array_key_exists; +use function array_last; use function array_filter; use function array_map; use function array_merge; @@ -78,6 +89,8 @@ use function in_array; use function is_string; use function sprintf; +use function strtolower; +use function substr; use function str_starts_with; /** @@ -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(), @@ -318,7 +336,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), $this->createAdapterScope($expr, $scope, $nameResult, $nodeScopeResolver, $stmt, $storage)); if ($functionThrowPoint !== null) { $throwPoints[] = $functionThrowPoint; } @@ -571,6 +590,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->afterOpenSslCall($functionReflection->getName()); } + $typeCallback = function (Expr $e, MutatingScope $s) use ($nodeScopeResolver, $stmt, $storage, $nameResult): Type { + if (!$e instanceof FuncCall) { + throw new ShouldNotHappenException(); + } + + return $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage); + }; + return new ExpressionResult( $scope, hasYield: $hasYield, @@ -579,15 +606,505 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex 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, $storage, $nameResult, $typeCallback): SpecifiedTypes { + if (!$e instanceof FuncCall) { + throw new ShouldNotHappenException(); + } + + return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, static fn (): Type => $typeCallback($e, $s), $nodeScopeResolver, $stmt, $storage); + }, + 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): Type { + if (!$e instanceof FuncCall) { + throw new ShouldNotHappenException(); + } + + return $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage); + }, + specifyTypesCallback: function (Expr $e, MutatingScope $s, TypeSpecifierContext $ctx) use ($nameResult, $nodeScopeResolver, $stmt, $storage): SpecifiedTypes { + if (!$e instanceof FuncCall) { + throw new ShouldNotHappenException(); + } + + return $this->specifyTypesViaResults($e, $s, $ctx, $nameResult, fn (): Type => $this->resolveTypeViaResults($e, $s, $nameResult, $nodeScopeResolver, $stmt, $storage), $nodeScopeResolver, $stmt, $storage); + }, + ); + + $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, + ): Type + { + $adapterScope = $this->createAdapterScope($expr, $scope, $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. + * + * @param callable(): Type $ownTypeCallback + */ + private function specifyTypesViaResults( + FuncCall $expr, + MutatingScope $scope, + TypeSpecifierContext $context, + ?ExpressionResult $nameResult, + callable $ownTypeCallback, + NodeScopeResolver $nodeScopeResolver, + Stmt $stmt, + ExpressionResultStorage $storage, + ): SpecifiedTypes + { + if (!$expr->name instanceof Name) { + // dynamic-name calls: guarded legacy bridge for now (PHPSTAN_FNSR=0) + return $this->typeSpecifier->specifyTypesInCondition($scope, $expr, $context); + } + + $adapterScope = $this->createAdapterScope($expr, $scope, $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, $ownTypeCallback(), $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 +1112,7 @@ private function getFunctionThrowPoint( continue; } - $throwType = $extension->getThrowTypeFromFunctionCall($functionReflection, $normalizedFuncCall, $scope); + $throwType = $extension->getThrowTypeFromFunctionCall($functionReflection, $normalizedFuncCall, $extensionScope); if ($throwType === null) { return null; } @@ -605,7 +1122,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 +1150,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..61ff8bc3f9c --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -0,0 +1,61 @@ +null()) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + if (!$context->truthy()) { + $removedType = StaticTypeFactory::truthy(); + } elseif (!$context->falsey()) { + $removedType = StaticTypeFactory::falsey(); + } else { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + // mirrors TypeSpecifier::createForExpr() in createFalse() context + $containsNull = !TypeCombinator::containsNull($removedType) && !$exprType->isNull()->no(); + + $originalExpr = $expr; + if (!$containsNull) { + $expr = NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($expr); + } + + $sureNotTypes = [ + $this->exprPrinter->printExpr($expr) => [$expr, $removedType], + ]; + if ($expr !== $originalExpr) { + $sureNotTypes[$this->exprPrinter->printExpr($originalExpr)] = [$originalExpr, $removedType]; + } + + return (new SpecifiedTypes(sureNotTypes: $sureNotTypes))->setRootExpr($originalExpr); + } + +} 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/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index 51de44f579f..8d6a983d291 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -57,7 +57,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $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()); diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index 9d8b220ccfe..cf2cfd748b6 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -51,7 +51,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()); diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index abd0dc63a4f..44fdf3a0ca4 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, $typeCallback($e, $s), $ctx), + expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); } diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index e104b9fed42..9a656c4a1c7 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,6 +99,25 @@ 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, @@ -97,6 +126,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $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, $typeCallback($e, $s), $ctx), + expressionTypeResolverExtensionRegistryProvider: $this->expressionTypeResolverExtensionRegistryProvider, ); } diff --git a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php index c952fcc1297..169ca2851cf 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, $typeCallback($e, $s), $ctx), ); } diff --git a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php index 81c6c9f08f8..07e98a5330e 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, $typeCallback($e, $s), $ctx), ); } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 746c518953d..c064816a82d 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,9 +29,15 @@ 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 */ @@ -29,10 +49,27 @@ 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, ) { $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,16 +98,101 @@ 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->truthyScopeCallback === null) { - return $this->scope; + 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 ($scope->nativeTypesPromoted) { + return $this->getNativeType(); + } + + return $this->getType(); + } + + 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; } + if ($this->specifyTypesCallback !== null && $this->expr !== null) { + return $this->truthyScope = $this->scope->applySpecifiedTypes( + $this->getSpecifiedTypes($this->scope, TypeSpecifierContext::createTruthy()), + [$this->scope->getNodeKey($this->expr) => $this], + ); + } + + if ($this->truthyScopeCallback === null) { + return $this->scope; + } + $callback = $this->truthyScopeCallback; $this->truthyScope = $callback(); return $this->truthyScope; @@ -78,14 +200,21 @@ public function getTruthyScope(): MutatingScope public function getFalseyScope(): MutatingScope { - if ($this->falseyScopeCallback === null) { - return $this->scope; - } - if ($this->falseyScope !== null) { return $this->falseyScope; } + if ($this->specifyTypesCallback !== null && $this->expr !== null) { + return $this->falseyScope = $this->scope->applySpecifiedTypes( + $this->getSpecifiedTypes($this->scope, TypeSpecifierContext::createFalsey()), + [$this->scope->getNodeKey($this->expr) => $this], + ); + } + + if ($this->falseyScopeCallback === null) { + return $this->scope; + } + $callback = $this->falseyScopeCallback; $this->falseyScope = $callback(); return $this->falseyScope; @@ -96,4 +225,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..355edf8113b 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -4,9 +4,11 @@ use Fiber; use PhpParser\Node\Expr; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\Reflection\FunctionReflection; +use PHPStan\ShouldNotHappenException; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParameterReflection; use PHPStan\Type\Type; @@ -54,16 +56,37 @@ 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(); + } + + return $scope->toFiberScope(); + } + + /** @api */ + public function getType(Expr $node): Type + { + return $this->getExpressionResult($node)->getTypeForScope($this); } public function getScopeType(Expr $expr): Type @@ -79,25 +102,13 @@ public function getScopeNativeType(Expr $expr): Type /** @api */ public function getNativeType(Expr $expr): Type { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($expr, $this), - ); - - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); - return $scope->getNativeType($expr); + return $this->getExpressionResult($expr)->getNativeType(); } public function getKeepVoidType(Expr $node): Type { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($node, $this), - ); - - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); - - return $scope->getKeepVoidType($node); + // keepVoid is a one-off we will solve separately; fall back to the regular type for now. + return $this->getExpressionResult($node)->getTypeForScope($this); } public function filterByTruthyValue(Expr $expr): self @@ -120,21 +131,6 @@ public function filterByFalseyValue(Expr $expr): self 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 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..dfdf5f369a4 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -180,7 +180,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 +250,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 +928,11 @@ public function getAnonymousFunctionReturnType(): ?Type /** @api */ public function getType(Expr $node): Type { + $enableFnsr = getenv('PHPSTAN_FNSR'); + if (PHP_VERSION_ID >= 80100 && $enableFnsr !== '0') { + 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 +1202,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') { + 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') { + 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_ && ( @@ -3397,6 +3444,142 @@ 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]; + } + } + + if (!$expr instanceof Expr\Closure && !$expr instanceof Expr\ArrowFunction) { + $exprString = $this->getNodeKey($expr); + if (array_key_exists($exprString, $this->expressionTypes)) { + $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()]; + } + } + + // guarded legacy bridge (works under PHPSTAN_FNSR=0) + return [$this->getType($expr), $this->getNativeType($expr)]; + } + + /** + * @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..66d734e22b5 --- /dev/null +++ b/src/Analyser/NewWorld.php @@ -0,0 +1,23 @@ += 80100 && getenv('PHPSTAN_FNSR') !== '0'; + } + +} diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 41fdf890721..374316dfab6 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -180,6 +180,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 +359,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 { } @@ -1059,7 +1065,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 +1124,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 +1136,7 @@ public function processStmtNode( } $nodeCallback($node, $scope); }, ExpressionContext::createTopLevel()); + $earlyTerminationExpr = $this->findEarlyTerminatingExpr($stmt->expr, $scope, $result->getType()); $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit()); if ( count($result->getImpurePoints()) === 0 @@ -1143,11 +1149,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()), + [$scope->getNodeKey($stmt->expr) => $result], + ); + } else { + $scope = $scope->filterBySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition( + $scope, + $stmt->expr, + TypeSpecifierContext::createNull(), + )); + } $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); $impurePoints = $result->getImpurePoints(); @@ -1301,9 +1314,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 +1352,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(); @@ -2672,7 +2690,7 @@ private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Clo return $scope; } - private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr + private function findEarlyTerminatingExpr(Expr $expr, Scope $scope, 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)) { @@ -2715,7 +2733,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 +2766,9 @@ public function processExprNode( throw new ShouldNotHappenException(); } - return $this->processExprNode($stmt, $newExpr, $scope, $storage, $nodeCallback, $context); + $result = $this->processExprNode($stmt, $newExpr, $scope, $storage, $nodeCallback, $context); + $this->storeResult($storage, $expr, $result); + return $result; } $this->callNodeCallbackWithExpression($nodeCallback, $expr, $scope, $storage, $context); @@ -2760,15 +2779,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 +2800,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; } /** @@ -3519,6 +3546,7 @@ public function processArgs( $processingOrder = array_keys($args); $hasReorderedArgs = false; + $argExprTypes = []; foreach ($args as $arg) { if ($arg->hasAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE)) { $hasReorderedArgs = true; @@ -3702,12 +3730,13 @@ public function processArgs( } $this->storeBeforeScope($storage, $arg->value, $scopeToPass); } 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 +3839,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) { diff --git a/src/Analyser/ResultAwareScope.php b/src/Analyser/ResultAwareScope.php new file mode 100644 index 00000000000..7021ea6ff61 --- /dev/null +++ b/src/Analyser/ResultAwareScope.php @@ -0,0 +1,258 @@ + */ + 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(); + } + + $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/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 27156a8b3f0..8587c42f3e1 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; @@ -89,6 +90,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') { + 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/tests/PHPStan/Analyser/NewWorldTypeInferenceTest.php b/tests/PHPStan/Analyser/NewWorldTypeInferenceTest.php new file mode 100644 index 00000000000..fb88b222a6b --- /dev/null +++ b/tests/PHPStan/Analyser/NewWorldTypeInferenceTest.php @@ -0,0 +1,43 @@ +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..dd019839f50 --- /dev/null +++ b/tests/PHPStan/Analyser/data/new-world.php @@ -0,0 +1,142 @@ +', $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); + } else { + assertType('\'\'', $s); + assertType('0', $len); + } + } + + public function assignByReference(): void + { + $q = 1; + $r = &$q; + assertType('1', $r); + } + +} + +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; +}