Skip to content

Narrow string type through variable assigned from strlen()/mb_strlen() via conditional expression holders#5706

Closed
phpstan-bot wants to merge 1 commit into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-kzg4avq
Closed

Narrow string type through variable assigned from strlen()/mb_strlen() via conditional expression holders#5706
phpstan-bot wants to merge 1 commit into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-kzg4avq

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When strlen() or mb_strlen() result is assigned to a variable and that variable is later compared (e.g. $len > 0), PHPStan now propagates the narrowing back to the original string argument. Previously, only direct comparisons like strlen($str) > 0 narrowed the string type; an intermediate variable broke the narrowing chain, causing false positives like "Offset 0 might not exist on ''|':'".

Changes

  • src/Analyser/TypeSpecifier.php: Added conditional expression holders for $var = strlen($str) and $var = mb_strlen($str) assignments in the null (assignment) context. Two holders are created per assignment:

    • If $var >= 1 (int<1, max>), narrow $str to non-empty-string
    • If $var >= 2 (int<2, max>), narrow $str to non-falsy-string

    Added new private method createStrlenConditionalExpressionHolders() following the same pattern as createArrayDimFetchConditionalExpressionHolder().

Root cause

When code like this was analysed:

$inputLen = strlen($input);
$hasTrailingColon = $inputLen > 0 && $input[$inputLen-1] === ':';

The TypeSpecifier already had handlers for narrowing strings when strlen() appears directly in a comparison (e.g. strlen($str) > 0). However, when the result was assigned to an intermediate variable first, the connection between the variable and the strlen() call was lost. The comparison $inputLen > 0 only narrowed $inputLen (to int<1, max>), not $input.

The fix uses PHPStan's existing ConditionalExpressionHolder mechanism to create a deferred relationship at assignment time: "if $inputLen is later narrowed to int<1, max>, then also narrow $input to non-empty-string". This mechanism is already used successfully for array_key_first(), array_search(), and similar functions.

Analogous cases probed

  • mb_strlen(): Included in the fix with the same narrowing rules
  • count()/sizeof(): Already works through variables (verified — has existing narrowing mechanism)
  • Re-assignment: Verified that re-assigning the variable ($len = 5) correctly invalidates the conditional holders
  • Various comparison operators: > 0, >= 1, !== 0, >= 2, > 1 all work correctly through the holder mechanism
  • Non-variable string args: Property access ($obj->name) as the strlen argument also narrows correctly

Test

  • NSRT test (tests/PHPStan/Analyser/nsrt/bug-13688.php): Tests type narrowing through variable for constant string unions, non-empty-string, non-falsy-string, mb_strlen, !== 0 comparison, === 0 non-narrowing, and re-assignment invalidation.
  • Rule test (tests/PHPStan/Rules/Arrays/data/bug-13688.php + NonexistentOffsetInArrayDimFetchRuleTest::testBug13688): Verifies the original false positive "Offset 0 might not exist" is no longer reported.

Fixes phpstan/phpstan#13688

…en()` via conditional expression holders

- When `$len = strlen($str)` is assigned and later `$len > 0` is checked,
  PHPStan now narrows `$str` to `non-empty-string`
- When `$len >= 2` is checked, `$str` is narrowed to `non-falsy-string`
- Implemented via ConditionalExpressionHolder in TypeSpecifier for the
  Assign null context, matching the existing pattern for array_key_first
  and array_search
- Also handles `mb_strlen()` with the same narrowing rules
- Conditional holders are correctly invalidated when the variable is
  re-assigned to a non-strlen value

Closes phpstan/phpstan#13688
@staabm staabm closed this May 19, 2026
@staabm staabm deleted the create-pull-request/patch-kzg4avq branch May 19, 2026 06:06
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.

2 participants