Skip to content

Only treat implode() separator as guaranteeing a non-empty result when the array has at least two elements - #5801

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

phpstan-bot wants to merge 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-i85h8y2

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

implode(string, non-empty-array) was incorrectly inferred as non-falsy-string. The glue/separator only appears between elements, so a non-empty array with a single element drops the separator entirely — implode(',', ['']) is ''. The fix only lets the separator contribute non-emptiness/non-falsiness when the array is provably of size >= 2.

Changes

  • src/Type/Php/ImplodeFunctionReturnTypeExtension.php: compute $separatorAppears = IntegerRangeType::createAllGreaterThanOrEqualTo(2)->isSuperTypeOf($arrayType->getArraySize())->yes() and only consider the separator's isNonFalsyString()/isNonEmptyString() when it holds. The value-type contribution is unchanged (it is valid for any array with at least one element).
  • tests/PHPStan/Analyser/nsrt/implode.php: added regression tests for non-empty-array/non-empty-list of string and non-empty-string, and array{string, string}/array{string, string, string} (size >= 2 keeps non-falsy-string).
  • tests/PHPStan/Analyser/nsrt/non-empty-string.php and non-falsy-string.php: corrected assertions that encoded the old, unsound behavior (e.g. implode(' ', non-empty-array<string>) is string, not non-falsy-string; implode($glue, non-empty-array) is string, not non-empty-string).
  • build/stubs/PhpParserName.stub: declared PhpParser\Node\Name::getParts(): non-empty-list<non-empty-string> (dev-only stub used by make phpstan) so that implode('\\', $nameParts) in FunctionDefinitionCheck keeps inferring non-empty-string.
  • Probed analogous string-building extensions str_repeat() and str_pad() — both already correctly gate their accessory types on the multiplier/length, so they needed no change.

Root cause

The extension assumed that a non-empty array plus a non-empty/non-falsy separator yields a non-empty/non-falsy string. That is only true when the separator is actually emitted, which requires at least two elements. For a single-element array the separator never appears, so the result is just the (possibly empty) single value. The fix introduces a size-based guard (getArraySize() >= 2) before letting the separator refine the result type.

Test

  • Reproducer from the issue: implode(',', non-empty-array<string>) now infers string instead of non-falsy-string (added to implode.php).
  • Added non-empty-string / non-empty-list of string and non-empty-string cases, and array{string, string} cases proving the separator still refines the result when the array is known to have >= 2 elements.
  • Updated pre-existing assertions in non-empty-string.php / non-falsy-string.php to the corrected results.
  • Full suite (make tests), self-analysis (make phpstan), and make cs-fix all pass.

Fixes phpstan/phpstan#14763
Fixes phpstan/phpstan#5521

@VincentLanglet VincentLanglet left a comment

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.

Add non regression test for phpstan/phpstan#5521

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. I addressed VincentLanglet's review request to add a regression test for issue #5521.

What I did

The reviewer asked for a non-regression test covering #5521 (implode(',', [null]) / implode(',', ['']) was previously inferred as a non-empty string, triggering a false === always-false error).

I added a bug5521() method to tests/PHPStan/Analyser/nsrt/implode.php with the faithful reproducer from the issue's playground:

public function bug5521() {
    // https://github.com/phpstan/phpstan/issues/5521
    $foo = [null];
    assertType("''", implode(',', $foo));

    $bar = [''];
    assertType("''", implode(',', $bar));
}

Both single-element arrays drop the separator entirely and correctly infer '' (an empty string), which is what makes the original === '' comparison valid rather than a false positive.

Verification

  • Confirmed the exact rule-level reproducer from the issue (if (implode(',', [null]) === '')) no longer produces the bogus "between non-empty-string and ''" error.
  • NodeScopeResolverTest for implode.php passes.
  • make phpstan self-analysis is clean.

Committed as Add regression test for #5521 and pushed to the PR branch.

A note for the record: because [null]/[''] are literal constant arrays, they constant-fold to '' and pass regardless of this PR's specific size-≥2 guard — but they are the verbatim reproducer from the issue, so they're the right test to lock in #5521's resolved behavior. The PR's actual guard logic (single-element non-empty-array<string>string) is already covered by the existing nonEmptyArrayOfStrings() test.

@VincentLanglet

Copy link
Copy Markdown
Contributor

phpstan-symfony failure is expected since it's implode on oversize array.

@VincentLanglet VincentLanglet left a comment

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.

This fix is correct to me, PHPStan shouldn't make false assumption.

It's better to infer string even if it's non falsy, than inferring non falsy and promoting bugs by asking developer to remove safe guards.

@VincentLanglet

Copy link
Copy Markdown
Contributor

I think this is ready for review @ondrejmirtes @staabm

@VincentLanglet VincentLanglet self-assigned this Sep 20, 2026
phpstan-bot and others added 2 commits September 20, 2026 11:06
…hen the array has at least two elements

- The separator passed to `implode()`/`join()` only appears *between* elements, so a single-element array (e.g. `implode(',', [''])`) drops it entirely and can still produce an empty/falsy string.
- `ImplodeFunctionReturnTypeExtension` now gates the separator's `non-empty-string`/`non-falsy-string` contribution on the array being provably of size >= 2 (via `getArraySize()` and `IntegerRangeType::createAllGreaterThanOrEqualTo(2)`). The value-type contribution is unchanged since it holds for any non-empty array.
- Updated existing `non-empty-string`/`non-falsy-string` assertions that encoded the previous, unsound behavior to the now-correct `string`/`non-empty-string` results.
- Added regression tests in `implode.php` covering `non-empty-array`/`non-empty-list` of `string` and `non-empty-string`, plus `array{string, string}` (provably >= 2 elements stays `non-falsy-string`).
- Taught PHPStan's own analysis that `PhpParser\Node\Name::getParts()` returns `non-empty-list<non-empty-string>` (dev-only stub) so building a class name with `implode('\\', ...)` stays `non-empty-string`.
- Probed sibling string builders `str_repeat()` and `str_pad()`: both already gate the relevant accessory on the multiplier/length, so no change needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@staabm
staabm force-pushed the create-pull-request/patch-i85h8y2 branch from 02b8281 to f229a05 Compare September 20, 2026 09:06
@staabm

staabm commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

the PR was 300+ commits behind 2.2.x... just rebased it to get up2date CI results

Comment thread build/stubs/PhpParserName.stub Outdated
Comment on lines +26 to +27
/** @return non-empty-list<non-empty-string> */
public function getParts() : array {

@staabm staabm Sep 20, 2026

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.

why do we need this new stub definition?

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 stub is gone.

Why it was there

Without it, the implode() fix caused exactly two self-analysis errors:

src/Rules/ClassCaseSensitivityCheck.php:116  getNameAsWritten() should return non-empty-string but returns string.
src/Rules/FunctionDefinitionCheck.php:883    Parameter #1 $className of ClassNameNodePair expects non-empty-string, string given.

Both rebuild a class name in its originally written case by imploding Name::getParts(). php-parser types that as non-empty-list<string> (PHPStan reads its @psalm-return), so with the separator no longer contributing non-emptiness, implode('\\', …) is just string — while ClassNameNodePair requires non-empty-string.

What I did instead

The stub claimed non-empty-list<non-empty-string>, which is stronger than php-parser guarantees — Name::$name is non-empty, but explode('\\', …) on e.g. new Name(['Foo', '']) does yield empty parts. Rather than encode that over-claim, I made the non-emptiness provable at the two call sites: both results end with the original name, so they now concatenate the resolved prefix with Name::toString(), which php-parser does guarantee to be a non-empty-string.

  • ClassCaseSensitivityCheck::getNameAsWritten() — replacing the last resolved part with the single-part original name is the same as taking everything up to the last \ and appending $originalName->toString(). Also drops an explode() in a hot path.
  • FunctionDefinitionCheck — the prefix parts are joined as before, then $originalName->toString() (identical to imploding $originalParts) is concatenated onto it; both former branches collapse into one expression.

Behaviour is unchanged in both cases.

make phpstan clean, make cs clean, make tests green (21495 tests, 95 skipped). Pushed as d5e73c7be.

The two places that rebuild a class name in its originally written case
imploded `Name::getParts()`, which php-parser types as
`non-empty-list<string>`. Once `implode()` stopped assuming a non-empty
separator makes the result non-empty, both lost `non-empty-string` and
`make phpstan` reported them, since `ClassNameNodePair` takes a
`non-empty-string`.

Both results end with the original name, so concatenate the resolved
prefix with `Name::toString()` - which php-parser already guarantees to
be a `non-empty-string` - instead of imploding parts. This keeps the
result provably non-empty without claiming in a stub that no part of a
`Name` is ever empty, and it saves an `explode()` in
`getNameAsWritten()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ondrejmirtes

Copy link
Copy Markdown
Member

Well my problem here with this and why it wasn't solved yet is described here (5 years ago): phpstan/phpstan#5521 (comment)

@VincentLanglet

Copy link
Copy Markdown
Contributor

Well my problem here with this and why it wasn't solved yet is described here (5 years ago): phpstan/phpstan#5521 (comment)

Sure but I think It's better to infer string even if it's non falsy in some case (the user can still add extra check/narrow the type with @var), than inferring non falsy and promoting bugs by either asking developer to remove safe guards and having the developer missing the edge case.

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.

Incorrect implode() return type implode non-empty string false error reported

4 participants