Infer non-decimal-int-string from constant parts that cannot occur in a decimal-int-string - #6187
Open
phpstan-bot wants to merge 1 commit into
Open
Conversation
…in a decimal-int-string - Add `PHPStan\Type\DecimalIntegerStringHelper` with `canStart()`/`canEnd()`/`canBeInside()`, which decide whether a known constant part can appear at a given position of a decimal-int-string (digits with an optional leading `-`, no redundant leading zeros). - `InitializerExprTypeResolver::resolveConcatType()` now adds `AccessoryDecimalIntegerStringType(inverse: true)` when every constant string of an operand is disqualified at its side. This covers `.`, `.=` and interpolated strings. Deliberately limited to constant operands: `non-decimal-int-string . int` is not sound because `'-' . 1` is `'-1'`. - `AccessoryDecimalIntegerStringType::toNumber()` returns `ErrorType` when inverted; a non-decimal-int-string can be an arbitrary non-numeric string such as `"foo"`, so `1 + $s` is a TypeError. Without this, the new inference made PHPStan stop reporting arithmetic on non-numeric strings. - Analogous cases fixed the same way: - `implode()`/`join()` - a constant separator between at least two elements, or constant element values of a non-empty array. - `sprintf()`/`vsprintf()` - the literal parts of a constant format, extracted by splitting on conversion specifications (bails out when a leftover `%` shows the format was not fully understood). - `strtolower()`/`strtoupper()`/`ucfirst()`/`ucwords()`/`mb_convert_case()` and friends preserve both `decimal-int-string` and `non-decimal-int-string`, since they never touch digits. `mb_convert_kana()` is excluded because it converts digits between half-width and full-width. - `number_format()` with at least one decimal and a decimal separator that cannot occur between digits. - These extensions now build their result with `TypeCombinator::intersect()` instead of `new IntersectionType()` so redundant accessory types are normalized away. - Probed and left alone: `str_pad()` (padding is not provably applied for a non-constant input), `trim()`/`substr()`/`str_replace()`/`strrev()` (can turn a non-decimal-int-string into a decimal one), `str_repeat()` (`"5"` repeated is `"55"`).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Keys built by concatenation lose their "cannot be an integer-like array key" information even when it is statically provable.
'step_' . $idwas inferred aslowercase-string&non-falsy-string, so it could not satisfy anarray<non-decimal-int-string, T>signature and the whole call chain had to be widened toarray<int|string, T>.A decimal-int-string is the canonical string form of an integer: digits with an optional leading
-and no redundant leading zeros. If a constant part of a built string cannot occur at its position of such a string, the whole result can never be one. This PR teaches string concatenation and the string-building functions to infernon-decimal-int-stringin exactly those cases.The inference is deliberately limited to constant parts. The broader
non-decimal-int-string . int→non-decimal-int-stringrule is not sound:'-'is anon-decimal-int-stringwhile'-' . 1is thedecimal-int-string'-1'.Changes
PHPStan\Type\DecimalIntegerStringHelper(src/Type/DecimalIntegerStringHelper.php) withcanStart(),canEnd()andcanBeInside(). Each answers whether a constant string can appear at that position of a decimal-int-string, taking into account whether the surrounding part can be empty.src/Reflection/InitializerExprTypeResolver.php:resolveConcatType()addsAccessoryDecimalIntegerStringType(inverse: true)when every constant string of the left operand is rejected bycanStart(), or every constant string of the right operand bycanEnd(). This covers.,.=and interpolated strings, which all route throughresolveConcatType().src/Type/Accessory/AccessoryDecimalIntegerStringType.php:toNumber()now returnsErrorTypewhen inverted.src/Type/Php/ImplodeFunctionReturnTypeExtension.php: a constant separator that cannot occur inside a decimal-int-string proves the result when the array has at least two elements; constant element values do the same for a non-empty array.src/Type/Php/SprintfFunctionDynamicReturnTypeExtension.php: the literal parts of a constant format are extracted by splitting on conversion specifications and checked the same way. If a%survives the split, the format was not fully understood and no conclusion is drawn.src/Type/Php/StrCaseFunctionsReturnTypeExtension.php:strtolower(),strtoupper(),mb_strtolower(),mb_strtoupper(),lcfirst(),ucfirst(),mb_lcfirst(),mb_ucfirst(),ucwords()andmb_convert_case()never touch digits, so they preserve bothdecimal-int-stringandnon-decimal-int-string.mb_convert_kana()is excluded because it converts digits between their half-width and full-width forms.src/Type/Php/NumberFormatFunctionDynamicReturnTypeExtension.php: with at least one decimal, the decimal separator always ends up between digits, so a separator that cannot occur there proves the result. Restructured so the numeric-string and non-decimal-int-string checks are independent.TypeCombinator::intersect()instead ofnew IntersectionType(), so redundant accessory types (decimal-int-stringimpliesnumeric-string,lowercase-stringanduppercase-string) are normalized away.Probed and found to be already correct or genuinely not provable, so left alone:
str_pad()- for a non-constant input it is not knowable whether padding is applied at all (str_pad((string) $id, 5, '0', STR_PAD_LEFT)is'00042'or'123456').trim()/ltrim()/rtrim(),substr(),str_replace(),strrev()- all can turn a non-decimal-int-string into a decimal one.str_repeat()-'5'repeated is'55', still a decimal-int-string.dechex()/decoct()/decbin()-dechex(1)is'1'.Root cause
resolveConcatType()builds the result's accessory types from the operands'isNonEmptyString(),isLiteralString(),isLowercaseString(),isUppercaseString()andisNumericString(), but had no branch for the decimal-int-string family, so that information was simply never produced. The same gap existed in every sibling string-building extension, which is the recurring pattern here: accessory string types are not re-derived by functions that build strings out of parts. Affected locations wereInitializerExprTypeResolver::resolveConcatType(),ImplodeFunctionReturnTypeExtension,SprintfFunctionDynamicReturnTypeExtension,StrCaseFunctionsReturnTypeExtensionandNumberFormatFunctionDynamicReturnTypeExtension.Producing
non-decimal-int-stringin many more places exposed a latent bug inAccessoryDecimalIntegerStringType::toNumber(): when inverted it returnedint|float, which is only correct for the numeric members of that type."foo"is anon-decimal-int-stringtoo, and1 + "foo"is aTypeError. BecauseErrorTypeextendsMixedType, the intersectionstring&non-decimal-int-stringpicked theint|floatoverStringType'sErrorTypeand PHPStan silently stopped reporting arithmetic on such strings (visible asassertType('*ERROR*', 1 + $string)innsrt/binary.phpflipping tofloat|int). ReturningErrorTypewhen inverted restores that; an intersection that also carriesnumeric-stringstill resolves tofloat|int, sinceErrorTypeis absorbed when a real type is present.Test
tests/PHPStan/Analyser/nsrt/bug-15055.phpholds the reproducer from the issue (literal prefix, enum-value prefix, literal suffix) plus the counter-example that must keep inferring a plain string, and covers:.=, which share the concat code path;'-' . $idand'1' . $idstay plain,'0' . $id/'00' . $id/'-0' . $idare non-decimal,$s . '-1'and'0' . $sstay plain because$scan be empty, and a''|'foo'union draws no conclusion;implode()with a constant separator, with a single element, and with constant values;sprintf()literal parts, including'-%d','%s-%s'and'%05d'which must draw no conclusion;number_format()with and without a decimal separator;mb_convert_kana()staying plain;non-decimal-int-stringbeing*ERROR*.The file fails on 20+ assertions without the source changes.
Existing expectations updated to the corrected types across
nsrt/(binary.php,bug-11129.php,constant-string-unions.php,implode.php,lowercase-string-sprintf.php,non-falsy-string.php,number_format.php,str-casing.phpand others), plusCallMethodsRuleTest::testBug5372.Fixes phpstan/phpstan#15055