Narrow explode() when the delimiter is a known substring#5959
Open
paulbalandan wants to merge 3 commits into
Open
Narrow explode() when the delimiter is a known substring#5959paulbalandan wants to merge 3 commits into
explode() when the delimiter is a known substring#5959paulbalandan wants to merge 3 commits into
Conversation
staabm
reviewed
Jul 1, 2026
bb53127 to
2a372eb
Compare
staabm
reviewed
Jul 1, 2026
2a372eb to
3ead043
Compare
3ead043 to
774bd6b
Compare
staabm
reviewed
Jul 1, 2026
staabm
reviewed
Jul 1, 2026
Contributor
|
feel free to add more commits to the PR instead of rewriting the same commit over and over and force-push. its easier to review as separate commits. |
explode() when the delimiter is a known substring
staabm
reviewed
Jul 1, 2026
staabm
approved these changes
Jul 1, 2026
| return null; | ||
| } | ||
|
|
||
| $finiteTypes = $limitType->getFiniteTypes(); |
Contributor
There was a problem hiding this comment.
Why are we calling getFiniteTypes and then getConstantScalarValues ? Can't we call directly getConstantScalarValues ?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PHPStan did not narrow
explode($delimiter, $string, 2)to a two-element list even when astr_contains($string, $delimiter)guard proved the delimiter is present, so destructuring[$first, $rest] = explode(...)reportedOffset 1 might not exist on non-empty-list<string>(under
reportPossiblyNonexistentGeneralArrayOffset). The report framed this as awhile-onlyproblem, but it reproduced identically inside a plain
if. This teaches theexplodereturn-type extension to use a known-present delimiter.
Changes
src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php— when the scope proves thedelimiter occurs in the string, return
array{string, string}for a limit of exactly2,array{0: string, 1: string, 2?: string, ...}for a larger constant limit, andarray{string, string, ...<string>}otherwise. Presence is probed by reconstructingstr_contains()/str_starts_with()/str_ends_with()on the same haystack and delimiter(with a fully-qualified name, so the node key matches the resolved call) and checking whether
the scope knows the result to be
true.src/Type/Php/StrContainingTypeSpecifyingExtension.php— forstr_contains(),str_starts_with()andstr_ends_with(), also remember the call value astruein thetruthy branch, so a bare
if/whileguard carries the same information... === truealready did.
tests/PHPStan/Analyser/nsrt/bug-14651.php— type-inference regression covering thereporter's
whilereproducer, theifform,str_starts_with/str_ends_withguards, anon-empty-stringvariable needle, and the untouched cases (no guard, different delimiter,limit === 1, negative limit).Root cause
Two gaps combined:
ExplodeFunctionDynamicReturnTypeExtensionalways returnednon-empty-list<string>; itnever consulted whether the delimiter was known to be present, so it could not prove a
second element exists.
if (str_contains(...))did not remember the call's truthiness. When aFunctionTypeSpecifyingExtensionhandles a call,FuncCallHandler::specifyTypes()returnsthe extension's
SpecifiedTypesdirectly and never unionshandleDefaultTruthyOrFalseyContext(), so inside the branchstr_contains($x, $y)stayedbool. (Contrastis_numeric(), which has no extension and is remembered astrue, andstr_contains($x, $y) === true, where theIdenticalhandler pins the call totrue.)With the call unremembered, the guard scope held only the haystack narrowing
(
non-falsy-string), which carries no "contains delimiter" information forexplodeto use.The first gap is fixed in the explode extension; the second by having the string-containment
extension remember the value of the three literal-substring-proving functions.
explodethenreconstructs the guard call and asks the scope for its type — the reconstructed name must be
fully-qualified because parsed calls are stored under their resolved (
\str_contains(...)) key.Test
tests/PHPStan/Analyser/nsrt/bug-14651.phpasserts the inferredexplode()type acrossguarded and unguarded forms; it fails before the fix (guarded cases inferred
non-empty-list<string>) and passes after.limit === 1and negative limits keep theirprevious types, so no new false positives are introduced.
make phpstan(self-analysis) is clean, andNodeScopeResolverTest,AnalyserIntegrationTest, and theComparison,DeadCode,FunctionsandVariablesrule suites pass — no spurious always-true reports.Fixes phpstan/phpstan#14651