Resolve conditional @throws types like ($x is 0 ? Exception : void) at call sites and throw points - #5874
Resolve conditional @throws types like ($x is 0 ? Exception : void) at call sites and throw points#5874phpstan-bot wants to merge 4 commits into
@throws types like ($x is 0 ? Exception : void) at call sites and throw points#5874Conversation
|
Is it correctly remapped when a parameter name changes during PHPDoc inheritance? |
VincentLanglet
left a comment
There was a problem hiding this comment.
There is ConditionalTypeForParameter and ConditionalType with appropriate PhpDocParser Node
Wouldn't it better to introduce a
PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForThrowsNode
|
I've now fully traced the flow and can address the review accurately. The reviewer's suggestion concerns the PhpDocParser AST layer, which lives in a separate package — let me lay out the analysis rather than fabricate a cross-repo change. Response to the reviewThanks @VincentLanglet — I looked into whether a dedicated The phpdoc type parser is context-free. private function parseThrowsTagValue(TokenIterator $tokens): Ast\PhpDoc\ThrowsTagValueNode
{
$type = $this->typeParser->parse($tokens);
...
}So What we'd gain vs. duplicate. The only genuinely throws-specific wrinkle today is that My recommendation: keep the Separately, on @ondrejmirtes's earlier question about parameter-name remapping during PHPDoc inheritance — that's a real concern with the current I did not make code changes or commits for this review point, because the requested change ( Two things I can act on next if you confirm:
Want me to proceed with (1), (2), or both? |
VincentLanglet
left a comment
There was a problem hiding this comment.
I think this should support ConditionalType too since the method could have a phpdoc like
@template TKey of int|string
@throws (TKey is int ? void : Exception)
|
I've addressed the review. Here's a summary of what I did. Review pointVincentLanglet asked that the resolver also support @template TKey of int|string
@throws (TKey is int ? void : Exception)This was a real gap: the resolver only handled Changes
Tests
One note I'll flag honestly: ondrejmirtes's earlier question about parameter-name remapping during PHPDoc inheritance is still orthogonal and not addressed here — happy to take that as a separate change if you'd like. |
VincentLanglet
left a comment
There was a problem hiding this comment.
Add test with nested conditional types
|
I've addressed VincentLanglet's review request to add tests with nested conditional types. Here's a summary. Review request
A "nested" conditional throws type is one where a branch is itself a conditional, e.g. What I added1. Validation (
2. Call-site resolution (
Verification of meaningfulnessPer the project's testing discipline, I confirmed the nested validation test isn't a no-op: I temporarily stopped Results
Committed as |
be0980f to
aba8904
Compare
staabm
left a comment
There was a problem hiding this comment.
add regression tests for snippets mentioned in phpstan/phpstan#9497 (comment)
|
Done. Here's a summary of how I addressed staabm's review. Review request
That comment links two playground reproductions. I fetched both — they're generic (template-based)
In both, the caller What I added
Verification it's meaningfulI temporarily disabled i.e. without the PR's resolution the unbound Results
Committed as |
9799032 to
c20f9b7
Compare
c20f9b7 to
f935e53
Compare
|
My only problem with the code is that there's nothing specific about |
…)` at call sites and throw points - Add `ConditionalThrowTypeResolver` that resolves `ConditionalTypeForParameter` inside a `@throws` type, either against the arguments passed at a call site (`resolveForCall`) or against the parameter variables narrowed in a throw point's scope (`resolveForScope`). - Resolve conditional throws types when computing throw points for function calls (`FuncCallHandler`), method/static calls (`MethodThrowPointHelper`) and constructor calls (`NewHandler`), so callers only see the exception when the arguments actually trigger the throwing branch. - Resolve conditional throws types per throw point in `MissingCheckedExceptionInThrowsCheck` (used by the function, method and property-hook rules) so a function body that throws only in a branch is matched against its own conditional `@throws` declaration. - Accept conditional throws types in `InvalidThrowsPhpDocValueRule` as long as every branch is a valid throws type (a `Throwable` subtype or `void`); plain `void` is still only allowed standalone or inside a conditional branch, so `Throwable|void` remains invalid.
f935e53 to
8d0b940
Compare
VincentLanglet
left a comment
There was a problem hiding this comment.
ConditionalThrowTypeResolver have nothing explicitly related to @throws tag.
Is there some duplicated logic worth to be reused for either
- other conditional types
@phpstan-assert,@phpstan-self-out, and/or@param-out
Nothing in the class is specific to @throws - it resolves any conditional type declared in a PHPDoc tag that lives on the function/method reflection instead of on the ParametersAcceptor, either against a call site's arguments or against the parameter variables in a scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ResolvedFunctionVariantWithOriginal (@return, @PARAM, @param-out, @param-closure-this), TypeSpecifier (@phpstan-assert) and ConditionalTypeResolver (@throws) each walked the type themselves to turn every ConditionalTypeForParameter into a ConditionalType on the subject its parameter resolves to - three copies of the same traversal, including the traverse-children-first dance that avoids an infinite loop on a colliding parameter name. ConditionalTypeForParameter::resolveInType() now does it once, taking a callback that resolves a parameter name to its subject type, so each caller only describes where its subjects come from: bound arguments, argument expressions, or variables in a scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@phpstan-self-out lives on the method reflection, so like @throws it is read raw and never saw the call-site resolution ParametersAcceptorSelector does for the tags the acceptor carries: MethodCallHandler only substituted the template types, leaving a conditional self-out type collapsed to the union of its branches. `@phpstan-self-out ($size is 0 ? EmptyCollection : NonEmpty)` now assigns EmptyCollection to the called-on expression for ->setSize(0) and NonEmpty for ->setSize(7). ConditionalTypeResolver::resolveForCall() takes the acceptor as it is when it is already bound to the call's arguments, instead of always re-resolving the variant from the argument types: a closure argument's return type is inferred while the arguments are processed and the argument type alone no longer tells it, which the self-out type of Bug13253\Pfline::map() depends on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed. Here's how I addressed the review. Review point
Yes on both counts. Three commits: 1.
|
Summary
PHPStan already supports conditional return types such as
($x is 0 ? never : float), but conditional@throwstypes were not supported: a@throws ($x is 0 ? Exception : void)declaration was rejected byInvalidThrowsPhpDocValueRuleas "not subtype of Throwable", the function body was wrongly reported as missing the exception in its@throws, and callers never benefited from the per-argument precision.This change makes conditional
@throwstypes first-class:inverse(0)) is reported as missing the exception in its own@throws, while a caller that does not (e.g.inverse(7), orinverse($x)with$xof typeint<3, 5>) is not.@throws, using the parameter narrowing at each throw point.Changes
src/Analyser/ConditionalThrowTypeResolver.php(new): resolvesConditionalTypeForParameterinside a throws type.resolveForCall()builds the parameter→argument-type map from a call's arguments (named, positional, variadic and default values) and converts the conditional viatoConditional(), then resolves late-resolvable types.resolveForScope()resolves the conditional against the parameter variables narrowed in a given scope (used inside the function body).src/Analyser/ExprHandler/FuncCallHandler.php,src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php,src/Analyser/ExprHandler/NewHandler.php: resolve the reflection's throws type withresolveForCall()before turning it into a throw point, so function calls, method calls, static-method calls andnewall honour conditional throws.src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php: resolve the declared throws type per throw point withresolveForScope()(this check backs the function, method and property-hook missing-throws rules).src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php: recurse intoConditionalType/ConditionalTypeForParameterbranches;voidis accepted only standalone or inside a conditional branch (soThrowable|voidstays invalid).All resolution paths short-circuit on
!$throwType->hasTemplateOrLateResolvableType(), so ordinary throws types are unaffected.Root cause
A conditional
@throwstype is resolved into the sameConditionalTypeForParameterrepresentation as a conditional return type, but nothing ever resolved it: throws types live on the function/method reflection and are read raw (getThrowType()), bypassing the call-siteResolvedFunctionVariantmachinery that resolves conditional return types. As a result the conditional always collapsed to theMaybe-certain union of its branches (Exception|void), which is neither a cleanThrowable(validation failure) nor a precise per-call result (no caller precision, false "missing" on the body).The fix mirrors the conditional-return-type resolution for throws: at every place the raw throws type is consumed, the
ConditionalTypeForParameteris resolved against the relevant subject — the call arguments at call sites, the narrowed parameter variables at throw points.Test
tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest: added valid conditional throws (($x is 0 ? Exception : void),($x is 0 ? Exception : RuntimeException)) which produce no error, and an invalid-branch case (($x is 0 ? stdClass : void)) which is still reported.tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest::testConditionalThrows: a conditional-throwinginverse()function and callers —inverse(0)andinverse($x:int)are reported as missing the exception, whileinverse(7)andinverse($x:int<3,5>)are not, and the body ofinverse()itself is clean.tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest::testConditionalThrows: the same behaviour for instance method calls, static method calls and constructor (new) calls (the analogous parallel constructs).Fixes phpstan/phpstan#7906
Fixes phpstan/phpstan#9497