Skip to content

Constant-fold explode() into a ConstantArrayType when separator, string and limit are all constant - #6123

Open
phpstan-bot wants to merge 6 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-w8ysocy
Open

Constant-fold explode() into a ConstantArrayType when separator, string and limit are all constant#6123
phpstan-bot wants to merge 6 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-w8ysocy

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Destructuring the result of explode() produced over-optimistic types:

$string = 'App/Service::foo';
[$first, $second] = explode(':::', $string);
// $second was inferred as string

':::' does not occur in 'App/Service::foo', so at runtime $second is never assigned and the code is broken. PHPStan silently inferred string for it, which then made a follow-up if (null === $second) check look like an always-false comparison.

The root cause was not array destructuring but explode()'s return type: it returned non-empty-list<string> even though both the separator and the string were compile-time constants. Now explode() is constant-folded, so the example infers array{'App/Service::foo'} and PHPStan reports the real problem — Offset 1 does not exist on array{'App/Service::foo'}.

Changes

  • src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php
    • New createConstantSplitType() evaluates the split with the real explode() when the separator, subject string and limit are all constant, and builds the exact ConstantArrayType.
    • Unions of constants are cross-multiplied into a union of constant arrays (','|';' × 'a,b'|'x;y;z' → four constant arrays), bounded by the new CONSTANT_COMBINATION_LIMIT class constant.
    • Splits longer than ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT bail out to the previous generic return type instead of building an oversized constant array.
    • Folding bails out when any separator may be '', leaving the existing never / false handling for that case intact.
    • The limit is read through getFiniteTypes(), so ranges such as int<1, 3> fold into a union too.
  • tests/PHPStan/Analyser/nsrt/array-destructuring.php — three assertions asserted the previously imprecise lowercase-string&uppercase-string for values destructured out of explode('-', '2018-12-19') / explode('*', ''); updated to the now-exact '2018', '12' and ''.

Analogous cases probed

  • Sibling "constant string in, constant array out" extensions: str_split(), mb_str_split(), preg_split() and array_chunk() already constant-fold — explode() was the only member of that family that did not. str_word_count() is locale-dependent and sscanf() / str_getcsv() would need full format/CSV emulation across PHP versions, so those are deliberately left as-is.
  • Every array-destructuring form: plain [$a, $b] = …, list($a, $b) = …, nested [[$a, $b]] = …, string-keyed ['k' => $a] = …, skipped elements [, $b] = …, and all the foreach ($x as [$a, $b]) / foreach ($x as ['k' => [$a]]) variants. All of them already route through GetOffsetValueTypeExpr and ArrayDestructuringRule consistently 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

ExplodeFunctionDynamicReturnTypeExtension only 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 — StrSplitFunctionReturnTypeExtension and PregSplitDynamicReturnTypeExtension — do compute the exact result for constant input; explode() was the outlier.

Because the returned non-empty-list<string> claims every offset is a string, both $arr[1] and [$first, $second] = $arr inferred string for offsets that provably do not exist, and NonexistentOffsetInArrayDimFetchCheck had nothing to report (a maybe offset on a general array is only reported under the opt-in reportPossiblyNonexistentGeneralArrayOffset). With the exact array{'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, asserting array{'App/Service::foo'} for the explode() call, 'App/Service::foo' for $first and *ERROR* for $second. Fails on master with non-empty-list<string> / string / string.
  • tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest::testBug15013 with tests/PHPStan/Rules/Arrays/data/bug-15013.php — asserts that the reproducer now reports Offset 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 — new constantSplit() covering the folding matrix: plain splits, empty subject, positive limits (0, 1, 2), negative limits (-1, and -5 producing array{}), unions of separators/subjects/limits, an int<1, 3> limit range, and the non-folding fallbacks (possibly-empty separator, non-constant separator/subject/limit).

Fixes phpstan/phpstan#15013

ondrejmirtes and others added 6 commits February 28, 2026 21:25
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

List destruction type issue

2 participants