Only treat implode() separator as guaranteeing a non-empty result when the array has at least two elements - #5801
phpstan-bot wants to merge 3 commits into
Conversation
VincentLanglet
left a comment
There was a problem hiding this comment.
Add non regression test for phpstan/phpstan#5521
|
Done. I addressed VincentLanglet's review request to add a regression test for issue #5521. What I didThe reviewer asked for a non-regression test covering #5521 ( I added a 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 Verification
Committed as A note for the record: because |
|
phpstan-symfony failure is expected since it's implode on oversize array. |
VincentLanglet
left a comment
There was a problem hiding this comment.
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.
e21dd33 to
02b8281
Compare
|
I think this is ready for review @ondrejmirtes @staabm |
…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>
02b8281 to
f229a05
Compare
|
the PR was 300+ commits behind 2.2.x... just rebased it to get up2date CI results |
| /** @return non-empty-list<non-empty-string> */ | ||
| public function getParts() : array { |
There was a problem hiding this comment.
why do we need this new stub definition?
There was a problem hiding this comment.
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 anexplode()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>
|
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 |
Summary
implode(string, non-empty-array)was incorrectly inferred asnon-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'sisNonFalsyString()/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 fornon-empty-array/non-empty-listofstringandnon-empty-string, andarray{string, string}/array{string, string, string}(size >= 2 keepsnon-falsy-string).tests/PHPStan/Analyser/nsrt/non-empty-string.phpandnon-falsy-string.php: corrected assertions that encoded the old, unsound behavior (e.g.implode(' ', non-empty-array<string>)isstring, notnon-falsy-string;implode($glue, non-empty-array)isstring, notnon-empty-string).build/stubs/PhpParserName.stub: declaredPhpParser\Node\Name::getParts(): non-empty-list<non-empty-string>(dev-only stub used bymake phpstan) so thatimplode('\\', $nameParts)inFunctionDefinitionCheckkeeps inferringnon-empty-string.str_repeat()andstr_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
implode(',', non-empty-array<string>)now infersstringinstead ofnon-falsy-string(added toimplode.php).non-empty-string/non-empty-listofstringandnon-empty-stringcases, andarray{string, string}cases proving the separator still refines the result when the array is known to have >= 2 elements.non-empty-string.php/non-falsy-string.phpto the corrected results.make tests), self-analysis (make phpstan), andmake cs-fixall pass.Fixes phpstan/phpstan#14763
Fixes phpstan/phpstan#5521