Repository navigation
Constant-fold explode() into a ConstantArrayType when separator, string and limit are all constant - #6123
Conversation
c53b3d5 to
113cdf2
Compare
6a499ff to
f9d615d
Compare
…string and limit are all constant
- `ExplodeFunctionDynamicReturnTypeExtension` now evaluates the split itself
when the separator, the subject string and the limit are all known constants,
returning the exact `array{...}` instead of `non-empty-list<string>`.
- Unions of constant separators/strings/limits are cross-multiplied into a union
of constant arrays, bounded by `CONSTANT_COMBINATION_LIMIT`; results longer
than `ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT` fall back to the previous
generic type.
- Folding is skipped when any separator may be the empty string, so the existing
`never`/`false` handling for that case is untouched.
- The limit is resolved through `getFiniteTypes()`, so `int<1, 3>` also folds.
- Adjusted `nsrt/array-destructuring.php`, which asserted the previously
imprecise `lowercase-string&uppercase-string` for `explode()` results.
- Probed the sibling "constant string in, constant array out" extensions:
`str_split`/`mb_str_split`, `preg_split` and `array_chunk` already fold, so
`explode()` was the only gap. `str_word_count()` (locale-dependent),
`sscanf()` and `str_getcsv()` (no extension) are deliberately left alone.
- Probed every array-destructuring form (plain, nested, keyed, `list()`,
skipped elements, `foreach` value patterns) against `ArrayDestructuringRule`
and `NonexistentOffsetInArrayDimFetchCheck` — all of them already agree with
plain offset access, so no change was needed there.
An empty separator does not make the whole call unfoldable: on PHP 8+ it throws a ValueError, so that combination contributes nothing to the return type, and before PHP 8 it contributes `false`. Skip the empty separator and fold the remaining ones instead of bailing out of the exact result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds assertions pinning both guards in createConstantSplitType(): CONSTANT_COMBINATION_LIMIT (from the limit range dimension and from the delimiter/string cross product) and the ARRAY_COUNT_LIMIT check on the number of produced elements. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o wide getFiniteTypes() returns nothing for a range wider than CALCULATE_SCALARS_LIMIT, so the call cannot be folded. Also restores the trailing newline of the test data file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Limits are now clamped into the [-partsCount, partsCount] window before they are enumerated: every limit above the number of parts produces the full split, every limit at or below minus the number of parts produces an empty array. Ranges such as int<5, max>, int<-100, -2> and plain int are therefore foldable too, instead of only the small ranges whose finite types could be listed one by one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f9d615d to
46d3c7d
Compare
| } | ||
| } | ||
|
|
||
| if ($hasEmptyDelimiter && !$this->phpVersion->throwsValueErrorForInternalFunctions()) { |
There was a problem hiding this comment.
should use $scope->getPhpVersion() instead of DI injected PhpVersion - similar to 715eccd
There was a problem hiding this comment.
Addressed the review and pushed as be2705f56.
Change (src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php): all three PHP-version checks now go through $scope->getPhpVersion() instead of the DI-injected PhpVersion, following 715eccde5. The constructor is gone entirely — the extension no longer injects PhpVersion; createConstantSplitType() takes the PhpVersions from the scope as an argument.
Change (src/Php/PhpVersions.php): added the trinary-logic counterparts throwsTypeErrorForInternalFunctions() and throwsValueErrorForInternalFunctions(), both >= 80000, matching the PhpVersion methods they replace.
The call sites test with ->yes(), which keeps the semantics correct when the version is only a range: a definitely-empty separator returns never only when the throw is certain, and false is added to the union whenever a non-throwing version is still possible (no or maybe).
Effect — a narrowed PHP_VERSION_ID in the analysed code now decides the result, in both directions:
// configured PHP 8, inside if (PHP_VERSION_ID < 80000)
explode($emptyOrComma, 'a,b') // array{'a', 'b'}|false (was array{'a', 'b'})
explode('', 'a,b') // false (was *NEVER*)
// configured PHP 7.4, inside if (PHP_VERSION_ID >= 80000)
explode($emptyOrComma, 'a,b') // array{'a', 'b'} (was array{'a', 'b'}|false)
explode('', 'a,b') // *NEVER* (was false)Tests: narrowedPhpVersion() added to tests/PHPStan/Analyser/nsrt/explode.php (PHP 8 config, narrowed to < 80000) and to tests/PHPStan/Analyser/data/explode-constant-php7.php (PHP 7.4 config, narrowed to >= 80000) — so each direction is covered from the side where the scope disagrees with the configured version. Verified all four new assertions fail before the source change with exactly the swapped types.
make tests (21535 tests, 95 skipped, no failures), make phpstan (no errors) and make cs are clean.
The extension asked the DI-injected PhpVersion whether internal functions
throw for an invalid separator. Ask $scope->getPhpVersion() instead, so a
narrowed PHP_VERSION_ID in the analysed code decides whether explode('')
is a never-returning ValueError or a false return value.
PhpVersions gains the trinary-logic counterparts of
throwsTypeErrorForInternalFunctions() and
throwsValueErrorForInternalFunctions().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Now that the extension reads the PHP version from the scope, the PHP 7 behaviour of an empty separator can be asserted inside an `if (PHP_VERSION_ID < 80000)` branch of the existing nsrt file, so the dedicated ExplodePhp7Test and its php7-configured data file are no longer needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
Destructuring the result of
explode()produced over-optimistic types:':::'does not occur in'App/Service::foo', so at runtime$secondis never assigned and the code is broken. PHPStan silently inferredstringfor it, which then made a follow-upif (null === $second)check look like an always-false comparison.The root cause was not array destructuring but
explode()'s return type: it returnednon-empty-list<string>even though both the separator and the string were compile-time constants. Nowexplode()is constant-folded, so the example infersarray{'App/Service::foo'}and PHPStan reports the real problem —Offset 1 does not exist on array{'App/Service::foo'}.Changes
src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.phpcreateConstantSplitType()evaluates the split with the realexplode()when the separator, subject string and limit are all constant, and builds the exactConstantArrayType.','|';'×'a,b'|'x;y;z'→ four constant arrays), bounded by the newCONSTANT_COMBINATION_LIMITclass constant.ConstantArrayTypeBuilder::ARRAY_COUNT_LIMITbail out to the previous generic return type instead of building an oversized constant array.'', leaving the existingnever/falsehandling for that case intact.getFiniteTypes(), so ranges such asint<1, 3>fold into a union too.tests/PHPStan/Analyser/nsrt/array-destructuring.php— three assertions asserted the previously impreciselowercase-string&uppercase-stringfor values destructured out ofexplode('-', '2018-12-19')/explode('*', ''); updated to the now-exact'2018','12'and''.Analogous cases probed
str_split(),mb_str_split(),preg_split()andarray_chunk()already constant-fold —explode()was the only member of that family that did not.str_word_count()is locale-dependent andsscanf()/str_getcsv()would need full format/CSV emulation across PHP versions, so those are deliberately left as-is.[$a, $b] = …,list($a, $b) = …, nested[[$a, $b]] = …, string-keyed['k' => $a] = …, skipped elements[, $b] = …, and all theforeach ($x as [$a, $b])/foreach ($x as ['k' => [$a]])variants. All of them already route throughGetOffsetValueTypeExprandArrayDestructuringRuleconsistently with plain$x[1]offset access, and all of them now report the missing offset once the type is exact. No fix was needed there.count()narrowing before destructuring (if (count($parts) === 2) { [$a, $b] = $parts; }) already behaves correctly and stays clean.Root cause
ExplodeFunctionDynamicReturnTypeExtensiononly refined the shape of the result (list-ness, non-emptiness, lowercase/uppercase accessory types) and never computed the actual split, even when every argument was a constant. Its siblings in the same family —StrSplitFunctionReturnTypeExtensionandPregSplitDynamicReturnTypeExtension— do compute the exact result for constant input;explode()was the outlier.Because the returned
non-empty-list<string>claims every offset is astring, both$arr[1]and[$first, $second] = $arrinferredstringfor offsets that provably do not exist, andNonexistentOffsetInArrayDimFetchCheckhad nothing to report (amaybeoffset on a general array is only reported under the opt-inreportPossiblyNonexistentGeneralArrayOffset). With the exactarray{'App/Service::foo'}, the offset is definitely missing, so the check reports it and the destructured variable becomes*ERROR*.Test
tests/PHPStan/Analyser/nsrt/bug-15013.php— the playground reproducer, assertingarray{'App/Service::foo'}for theexplode()call,'App/Service::foo'for$firstand*ERROR*for$second. Fails onmasterwithnon-empty-list<string>/string/string.tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest::testBug15013withtests/PHPStan/Rules/Arrays/data/bug-15013.php— asserts that the reproducer now reportsOffset 1 does not exist on array{'App/Service::foo'}., and that the same destructuring with a separator that is present ('::') stays clean.tests/PHPStan/Analyser/nsrt/explode.php— newconstantSplit()covering the folding matrix: plain splits, empty subject, positive limits (0,1,2), negative limits (-1, and-5producingarray{}), unions of separators/subjects/limits, anint<1, 3>limit range, and the non-folding fallbacks (possibly-empty separator, non-constant separator/subject/limit).Fixes phpstan/phpstan#15013