Skip to content

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
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-fdzaxvr
Open

Infer non-decimal-int-string from constant parts that cannot occur in a decimal-int-string#6187
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-fdzaxvr

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Keys built by concatenation lose their "cannot be an integer-like array key" information even when it is statically provable. 'step_' . $id was inferred as lowercase-string&non-falsy-string, so it could not satisfy an array<non-decimal-int-string, T> signature and the whole call chain had to be widened to array<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 infer non-decimal-int-string in exactly those cases.

The inference is deliberately limited to constant parts. The broader non-decimal-int-string . intnon-decimal-int-string rule is not sound: '-' is a non-decimal-int-string while '-' . 1 is the decimal-int-string '-1'.

Changes

  • New PHPStan\Type\DecimalIntegerStringHelper (src/Type/DecimalIntegerStringHelper.php) with canStart(), canEnd() and canBeInside(). 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() adds AccessoryDecimalIntegerStringType(inverse: true) when every constant string of the left operand is rejected by canStart(), or every constant string of the right operand by canEnd(). This covers ., .= and interpolated strings, which all route through resolveConcatType().
  • src/Type/Accessory/AccessoryDecimalIntegerStringType.php: toNumber() now returns ErrorType when 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() and mb_convert_case() never touch digits, so they preserve both decimal-int-string and non-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.
  • The four extensions above now assemble their result with TypeCombinator::intersect() instead of new IntersectionType(), so redundant accessory types (decimal-int-string implies numeric-string, lowercase-string and uppercase-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() and isNumericString(), 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 were InitializerExprTypeResolver::resolveConcatType(), ImplodeFunctionReturnTypeExtension, SprintfFunctionDynamicReturnTypeExtension, StrCaseFunctionsReturnTypeExtension and NumberFormatFunctionDynamicReturnTypeExtension.

Producing non-decimal-int-string in many more places exposed a latent bug in AccessoryDecimalIntegerStringType::toNumber(): when inverted it returned int|float, which is only correct for the numeric members of that type. "foo" is a non-decimal-int-string too, and 1 + "foo" is a TypeError. Because ErrorType extends MixedType, the intersection string&non-decimal-int-string picked the int|float over StringType's ErrorType and PHPStan silently stopped reporting arithmetic on such strings (visible as assertType('*ERROR*', 1 + $string) in nsrt/binary.php flipping to float|int). Returning ErrorType when inverted restores that; an intersection that also carries numeric-string still resolves to float|int, since ErrorType is absorbed when a real type is present.

Test

tests/PHPStan/Analyser/nsrt/bug-15055.php holds 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:

  • interpolated strings and .=, which share the concat code path;
  • the soundness boundaries: '-' . $id and '1' . $id stay plain, '0' . $id / '00' . $id / '-0' . $id are non-decimal, $s . '-1' and '0' . $s stay plain because $s can 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;
  • the case-conversion functions in both directions, and mb_convert_kana() staying plain;
  • arithmetic on a non-decimal-int-string being *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.php and others), plus CallMethodsRuleTest::testBug5372.

Fixes phpstan/phpstan#15055

…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"`).
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.

Infer non-decimal-int-string from concatenation with a constant part that cannot appear in a decimal int string

1 participant