Skip to content

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

Merged
VincentLanglet merged 7 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-w8ysocy
Sep 22, 2026
Merged

VincentLanglet merged 7 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

Comment thread src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php
@VincentLanglet VincentLanglet self-assigned this Sep 20, 2026
Comment thread src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php Outdated
Comment thread src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php Outdated
Comment thread tests/PHPStan/Analyser/nsrt/bug-15013.php
@staabm
staabm force-pushed the create-pull-request/patch-w8ysocy branch from 6a499ff to f9d615d Compare September 21, 2026 06:46
phpstan-bot and others added 5 commits September 22, 2026 07:05
…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>
@staabm
staabm force-pushed the create-pull-request/patch-w8ysocy branch from f9d615d to 46d3c7d Compare September 22, 2026 05:05
}
}

if ($hasEmptyDelimiter && !$this->phpVersion->throwsValueErrorForInternalFunctions()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should use $scope->getPhpVersion() instead of DI injected PhpVersion - similar to 715eccd

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/PHPStan/Analyser/ExplodePhp7Test.php Outdated
phpstan-bot and others added 2 commits September 22, 2026 05:20
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>
@VincentLanglet
VincentLanglet merged commit 98898c2 into phpstan:2.2.x Sep 22, 2026
858 of 892 checks passed
@VincentLanglet
VincentLanglet deleted the create-pull-request/patch-w8ysocy branch September 22, 2026 08:07
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

3 participants