Skip to content

Improve range() return type for invalid steps and long ranges - #6492

Open
zonuexe wants to merge 4 commits into
phpstan:2.2.xfrom
zonuexe:10022/range-php83
Open

zonuexe wants to merge 4 commits into
phpstan:2.2.xfrom
zonuexe:10022/range-php83

Conversation

@zonuexe

@zonuexe zonuexe commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Ref phpstan/phpstan#10022

Two inaccuracies in the return type of range():

  1. An invalid $step makes range() throw a ValueError, but the extension still returned an array type. It now returns never when every combination of the constant arguments throws.
  2. For a range longer than RANGE_LENGTH_THRESHOLD the extension generalized the argument types, which predates the PHP 8.3 changes. It now generalizes the values the range consists of:
 // range('A', 'z')
-non-empty-list<int|(literal-string&lowercase-string&non-falsy-string)|(literal-string&non-falsy-string&uppercase-string)>
+non-empty-list<literal-string&non-empty-string>

 // range(1, 200, 1.0)
-non-empty-list<float>
+non-empty-list<int<1, 200>>

The extension folds the constant arguments by calling the native range(), so a caught ValueError only says that the runtime rejects the step. I gated both changes on PhpVersion wherever PHP 8.3 changed the behaviour. A $step of 0, or one wider than the range, has been a ValueError since PHP 8.0, while a negative $step on an increasing range and a non-finite $step only became one in 8.3. An integral float $step produces ints since 8.3 and floats before that. Analysing for PHP 8.2 still gives non-empty-list<int> for range(2, 5, -1) and non-empty-list<float> for range(1, 200, 1.0). RangePhp82Test covers that axis: it sets phpVersion to 8.2 and runs on 8.3+.

This does not close phpstan/phpstan#10022. PHPStan already infers the PHP 8.3 result for the reproducer in that issue, range('1', 'a'), because the extension folds the constant arguments through the native range(). For the same reason the folded values follow the PHP version PHPStan runs on rather than the configured phpVersion: with phpVersion: 80200 on a PHPStan running on 8.5, range('1', 'a') still comes out as the 49 element character range instead of array{1, 0}. Making the values follow phpVersion means reimplementing range() down to the rounding of start + i * step, and I did not want to put that in this PR. This PR fixes what you can decide without knowing the values: which steps PHP rejects, and how to generalize a range past the threshold.

An invalid step makes `range()` throw a ValueError, so a call where all
constant argument combinations throw is now `*NEVER*` instead of an array
type. The constant arguments are folded by calling `range()` on the PHP
version PHPStan itself runs on, so a caught ValueError is only turned into
`never` when the analysed PHP version is known to reject the step as well:
a step of 0 or one wider than the range has been a ValueError since PHP 8.0,
while a negative step on an increasing range and a non-finite step only
became one in PHP 8.3.

Ranges longer than `RANGE_LENGTH_THRESHOLD` are generalized from the values
the range consists of instead of from the argument types, so `range('A', 'z')`
is a list of single byte strings and `range(1, 200, 1.0)` is a list of ints -
the latter only since PHP 8.3, where an integral float step stopped producing
floats.

Ref phpstan/phpstan#10022
assertType('*NEVER*', range(5, 6, 3));
assertType('*NEVER*', range(2, 5, -1));
assertType('*NEVER*', range('a', 'z', -1));
assertType('*NEVER*', range(1, 10, INF));

@staabm staabm Sep 21, 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.

we need a test that range(1, 10, INF) will be false not *NEVER* on PHP7

https://3v4l.org/osqpM#veol

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 4f4f737. PHP 7 reported an invalid step with a warning and a false return value, so on PHP 7 the extension now returns false for such a call instead of an array type. RangePhp74Test runs with phpVersion: 70400 and covers range(1, 10, INF), range(2, 5, 0), range(5, 6, 3), and array{6}|false for a union where only one combination fails. PHP 7.4, 8.0–8.2 and 8.3+ all reject these steps, so the test runs on every CI runtime without a version gate.

5b68933 fixes the failing CI jobs: PHPUnit 11 treats an empty data provider as an error, so RangePhp82Test now skips through #[RequiresPhp('>= 8.3.0')].

@zonuexe
zonuexe marked this pull request as draft September 21, 2026 10:55
…ider

PHPUnit 11 reports an empty data set as an error.
Before PHP 8.0 an invalid step was reported by a warning and a false
return value instead of a ValueError.
@zonuexe
zonuexe marked this pull request as ready for review September 21, 2026 11:26
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

// PHP 7 reports an invalid step by returning false instead of throwing a ValueError
if ($rangeValues === false) {
if ($this->failsOnAnalysedVersion($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue())) {
if ($this->phpVersion->throwsValueErrorForInternalFunctions()) {

@staabm staabm Sep 21, 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.

if you use $scope->getPhpVersion() in the extension instead of the DI wide PhpVersion, type inference gets more precise and also will support narrowing via conditions like if (PHP_VERSION_ID < 80000) { .

this means you can put your tests into the nsrt/ folder and describe php-version dependent types like

if (PHP_VERSION_ID < 80000) {
	// PHP 7 reports an invalid step with a warning and returns false instead of throwing a ValueError
	assertType('false', range(1, 10, INF));
	assertType('false', range(2, 5, 0));
...
}

without the need for adding a class RangePhp74Test extends TypeInferenceTestCase and the corresponding NEON will also be obsolete -> less files to fiddle with

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 596668a. The extension reads $scope->getPhpVersion() now. I added throwsValueErrorForInternalFunctions() and hasStricterRangeFunction() to PhpVersions and dropped the PhpVersion method this PR had added. I moved the asserts to nsrt/range-invalid-step.php and nsrt/bug-10022.php, split by PHP_VERSION_ID, so RangePhp74Test, RangePhp82Test and the NEON file are gone.

With a version range, the runtime can reject a step that some analysed version accepts. The extension falls back to the general type in that case. Before, range($flag ? 2 : 5, 5, -1) analysed for 8.2 gave array{5} and missed [2, 3, 4, 5].

@zonuexe
zonuexe marked this pull request as draft September 21, 2026 14:22
`Scope::getPhpVersion()` follows conditions like `PHP_VERSION_ID < 80000`,
so the tests move into `nsrt/` and no longer need separate test classes
with a fixed `phpVersion`.

When a combination fails at runtime but the analysed PHP versions might
accept its step, the other constant combinations are not returned on
their own anymore.
@zonuexe
zonuexe marked this pull request as ready for review September 21, 2026 15:36
@zonuexe

zonuexe commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

The Mutation Testing jobs fail on tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php:588, where #[RequiresPhp('>= 8.0')] lacks the patch version (phpunit.attributeRequiresPhpVersion). That line comes from 2.2.x, and #6491 and #6488 fail the same way, so I left it out of this PR.

@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

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.

3 participants