diff --git a/src/Php/PhpVersions.php b/src/Php/PhpVersions.php index 426572ca849..720d3232bc3 100644 --- a/src/Php/PhpVersions.php +++ b/src/Php/PhpVersions.php @@ -71,4 +71,13 @@ public function supportsMaxMemoryLimit(): TrinaryLogic return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result; } + /** + * PHP 8.3 rejects a negative step on an increasing range and a NAN step, builds a character + * range from two single bytes and no longer turns an integral float step into floats. + */ + public function hasStricterRangeFunction(): TrinaryLogic + { + return IntegerRangeType::fromInterval(80300, null)->isSuperTypeOf($this->phpVersions)->result; + } + } diff --git a/src/Type/Php/RangeFunctionArgumentsHelper.php b/src/Type/Php/RangeFunctionArgumentsHelper.php new file mode 100644 index 00000000000..a5e9c73bb60 --- /dev/null +++ b/src/Type/Php/RangeFunctionArgumentsHelper.php @@ -0,0 +1,194 @@ +|false false when range() rejects the arguments + */ + public static function callRange(int|float|string $start, int|float|string $end, int|float $step): array|false + { + try { + return @range($start, $end, $step); + } catch (ValueError) { + return false; + } + } + + /** + * @param TrinaryLogic $hasStricterRange whether the analysed PHP versions are 8.3 or newer + * @param bool|null $runtimeRejects what calling range() on the runtime told, null when it was not called + */ + public static function rejects( + TrinaryLogic $hasStricterRange, + int|float|string $start, + int|float|string $end, + int|float $step, + ?bool $runtimeRejects, + ): TrinaryLogic + { + $results = []; + if (!$hasStricterRange->no()) { + $results[] = self::rejectsSincePhp83($start, $end, $step, $runtimeRejects); + } + if (!$hasStricterRange->yes()) { + $results[] = self::rejectsBeforePhp83($start, $end, $step); + } + + return TrinaryLogic::extremeIdentity(...$results); + } + + private static function rejectsSincePhp83(int|float|string $start, int|float|string $end, int|float $step, ?bool $runtimeRejects): TrinaryLogic + { + // PHP 8.3 checks the step on its own before looking at the boundaries + if (!is_finite((float) $step) || (float) $step === 0.0 || $step === PHP_INT_MIN) { + return TrinaryLogic::createYes(); + } + + // calling range() only tells about PHP 8.3 when PHPStan itself runs on PHP 8.3 or newer + if (PHP_VERSION_ID < 80300) { + $runtimeRejects = null; + } + + if (self::isNegativeStepOnIncreasingRange($start, $end, $step)) { + return TrinaryLogic::createYes(); + } + + if ($runtimeRejects !== null) { + return TrinaryLogic::createFromBoolean($runtimeRejects); + } + + // the older rules stand in, but they compare floats where PHP 8.3 compares an integral float step exactly + if (self::isImprecise($start) || self::isImprecise($end) || self::isImprecise($step)) { + return TrinaryLogic::createMaybe(); + } + + return self::rejectsBeforePhp83($start, $end, $step); + } + + /** + * For numeric boundaries PHP 7 and 8.0-8.2 ignore the sign of the step and reject + * one that is 0 or wider than the range itself. + */ + private static function rejectsBeforePhp83(int|float|string $start, int|float|string $end, int|float $step): TrinaryLogic + { + if (is_string($start) || is_string($end)) { + // how a string boundary was coerced before PHP 8.3 is not modelled here + return TrinaryLogic::createMaybe(); + } + + if ( + is_int($start) && is_int($end) && is_int($step) + && (abs($start) > self::PRECISE_INTEGER_LIMIT || abs($end) > self::PRECISE_INTEGER_LIMIT || abs($step) > self::PRECISE_INTEGER_LIMIT) + ) { + // range() compares integers exactly, which the floats below cannot do anymore + return TrinaryLogic::createMaybe(); + } + + $start = (float) $start; + $end = (float) $end; + $step = abs((float) $step); + if (!is_finite($start) || !is_finite($end) || is_nan($step)) { + return TrinaryLogic::createMaybe(); + } + + if ($start === $end) { + // a step of 0 was only rejected for integer boundaries in this case + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createFromBoolean($step === 0.0 || abs($end - $start) < $step); + } + + /** + * PHP 8.3 rejects a negative step on an increasing range, while earlier versions ignored its sign. + */ + public static function isNegativeStepOnIncreasingRange(int|float|string $start, int|float|string $end, int|float $step): bool + { + if ($step >= 0) { + return false; + } + + // with a float step PHP 8.3 compares numbers, in which only a digit keeps its value + if (!self::isFloatStep($step) && self::isCharacter($start) && self::isCharacter($end)) { + return ord($start[0]) < ord($end[0]); + } + + // an empty string or a character next to a number counts as 0 + return self::toNumber($start) < self::toNumber($end); + } + + /** + * PHP 8.3 keeps a step with a fractional part or beyond the integer range as a float. + */ + private static function isFloatStep(int|float $step): bool + { + if (!is_float($step)) { + return false; + } + + return floor($step) !== $step || abs($step) >= self::INTEGER_STEP_LIMIT; + } + + /** + * PHP 8.3 builds a character range from two single bytes, which includes a digit, + * and takes the first byte of a longer non-numeric string. + * + * @phpstan-assert-if-true non-empty-string $value + */ + private static function isCharacter(int|float|string $value): bool + { + return is_string($value) && $value !== '' && (strlen($value) === 1 || !self::isNumeric($value)); + } + + /** + * PHP 8 accepts whitespace after a numeric string, which is_numeric() on PHP 7.4 does not. + */ + private static function isNumeric(int|float|string $value): bool + { + return is_numeric($value) || is_numeric(rtrim($value, " \t\n\r\v\f")); + } + + private static function isImprecise(int|float|string $value): bool + { + return !is_string($value) && abs($value) > self::PRECISE_INTEGER_LIMIT; + } + + private static function toNumber(int|float|string $value): float + { + return self::isNumeric($value) ? (float) $value : 0.0; + } + +} diff --git a/src/Type/Php/RangeFunctionReturnTypeExtension.php b/src/Type/Php/RangeFunctionReturnTypeExtension.php index 0df7f55f616..f1c9a75cde2 100644 --- a/src/Type/Php/RangeFunctionReturnTypeExtension.php +++ b/src/Type/Php/RangeFunctionReturnTypeExtension.php @@ -6,11 +6,15 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; +use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryLiteralStringType; +use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantFloatType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; @@ -20,21 +24,20 @@ use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; +use PHPStan\Type\NeverType; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; -use ValueError; use function abs; use function count; use function floor; -use function is_array; use function is_finite; +use function is_float; use function is_numeric; use function is_string; use function max; use function min; -use function range; #[AutowiredService] final class RangeFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension @@ -58,46 +61,77 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $endType = $scope->getType($args[1]->value); $stepType = count($args) >= 3 ? $scope->getType($args[2]->value) : new ConstantIntegerType(1); + $phpVersions = $scope->getPhpVersion(); + $hasStricterRange = $phpVersions->hasStricterRangeFunction(); + $throwsValueError = $phpVersions->throwsValueErrorForInternalFunctions(); + $constantReturnTypes = []; + $constantCombinations = 0; + $throwingCombinations = 0; + $hasSkippedCombination = false; + $hasUnknownCombination = false; $startConstants = $startType->getConstantScalarTypes(); foreach ($startConstants as $startConstant) { if (!$startConstant instanceof ConstantIntegerType && !$startConstant instanceof ConstantFloatType && !$startConstant instanceof ConstantStringType) { + $hasSkippedCombination = true; continue; } $endConstants = $endType->getConstantScalarTypes(); foreach ($endConstants as $endConstant) { if (!$endConstant instanceof ConstantIntegerType && !$endConstant instanceof ConstantFloatType && !$endConstant instanceof ConstantStringType) { + $hasSkippedCombination = true; continue; } $stepConstants = $stepType->getConstantScalarTypes(); foreach ($stepConstants as $stepConstant) { if (!$stepConstant instanceof ConstantIntegerType && !$stepConstant instanceof ConstantFloatType) { + $hasSkippedCombination = true; continue; } + $constantCombinations++; + // range() would allocate every item before the length could be checked $rangeLength = self::getRangeLength($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue()); if ($rangeLength !== null && $rangeLength > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { - return self::getLongRangeType($startConstant, $endConstant, $stepConstant, $stepType); - } - - try { - $rangeValues = @range($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue()); - } catch (ValueError) { + // without calling range() nothing rejects a negative step on an increasing range, which PHP 8.3 does + if (RangeFunctionArgumentsHelper::isNegativeStepOnIncreasingRange($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue())) { + if ($hasStricterRange->yes()) { + $throwingCombinations++; + continue; + } + if ($hasStricterRange->maybe()) { + $hasUnknownCombination = true; + continue; + } + } + + $constantReturnTypes[] = self::getLongRangeType($hasStricterRange, $startConstant, $endConstant, $stepConstant, $stepType, null); continue; } - // @phpstan-ignore function.alreadyNarrowedType - if (!is_array($rangeValues)) { + $rangeValues = RangeFunctionArgumentsHelper::callRange($startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue()); + if ($rangeValues === false) { + $fails = RangeFunctionArgumentsHelper::rejects($hasStricterRange, $startConstant->getValue(), $endConstant->getValue(), $stepConstant->getValue(), true); + if (!$fails->yes()) { + // the analysed PHP version might accept the step, but only the runtime's verdict is known + $hasUnknownCombination = true; + } elseif ($throwsValueError->yes()) { + $throwingCombinations++; + } else { + $constantReturnTypes[] = new ConstantBooleanType(false); + } continue; } if (count($rangeValues) > self::RANGE_LENGTH_THRESHOLD) { - return self::getLongRangeType($startConstant, $endConstant, $stepConstant, $stepType); + $constantReturnTypes[] = self::getLongRangeType($hasStricterRange, $startConstant, $endConstant, $stepConstant, $stepType, $rangeValues); + continue; } + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); foreach ($rangeValues as $value) { $arrayBuilder->setOffsetValueType(null, $scope->getTypeFromValue($value)); @@ -108,10 +142,29 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } } - if (count($constantReturnTypes) > 0) { - return TypeCombinator::union(...$constantReturnTypes); + if (!$hasUnknownCombination && !$hasSkippedCombination) { + if (count($constantReturnTypes) > 0) { + return TypeCombinator::union(...$constantReturnTypes); + } + + // nothing is returned when every combination of the constant arguments throws + if ( + $constantCombinations > 0 + && $throwingCombinations === $constantCombinations + && $startType->isConstantScalarValue()->yes() + && $endType->isConstantScalarValue()->yes() + && $stepType->isConstantScalarValue()->yes() + ) { + return new NeverType(); + } } + // the general type covers the combinations that could not be decided, the rest keep their own types + return TypeCombinator::union(self::getGeneralType($startType, $endType, $stepType), ...$constantReturnTypes); + } + + private static function getGeneralType(Type $startType, Type $endType, Type $stepType): Type + { $argType = TypeCombinator::union($startType, $endType); $isInteger = $argType->isInteger()->yes(); $isStepInteger = $stepType->isInteger()->yes(); @@ -174,37 +227,96 @@ private static function getRangeLength(int|float|string $start, int|float|string return floor($length) + 1; } + /** + * @param non-empty-list|null $rangeValues null when range() was not called + */ private static function getLongRangeType( + TrinaryLogic $hasStricterRange, ConstantIntegerType|ConstantFloatType|ConstantStringType $startConstant, ConstantIntegerType|ConstantFloatType|ConstantStringType $endConstant, ConstantIntegerType|ConstantFloatType $stepConstant, Type $stepType, + ?array $rangeValues, ): Type { + $type = self::getLongRangeItemsType($startConstant, $endConstant, $stepConstant, $stepType, $rangeValues); if ( - $startConstant instanceof ConstantIntegerType - && $endConstant instanceof ConstantIntegerType - && $stepConstant instanceof ConstantIntegerType + $hasStricterRange->yes() + || ( + !$startConstant instanceof ConstantFloatType + && !$endConstant instanceof ConstantFloatType + && !$stepConstant instanceof ConstantFloatType + ) ) { + return $type; + } + + // before PHP 8.3 a float argument produced floats even when none of the arguments had a fractional + // part, and even for two strings, of which only a numeric one kept its value + $floatListType = self::getNonEmptyListOfType(new FloatType()); + if ($hasStricterRange->no()) { + return $floatListType; + } + + return TypeCombinator::union($type, $floatListType); + } + + /** + * The type of the items the runtime returned. Without them it follows PHP 8.3 for numeric + * boundaries and generalizes the arguments for a string one. + * + * @param non-empty-list|null $rangeValues + */ + private static function getLongRangeItemsType( + ConstantIntegerType|ConstantFloatType|ConstantStringType $startConstant, + ConstantIntegerType|ConstantFloatType|ConstantStringType $endConstant, + ConstantIntegerType|ConstantFloatType $stepConstant, + Type $stepType, + ?array $rangeValues, + ): Type + { + $floatListType = self::getNonEmptyListOfType(new FloatType()); + + if ($rangeValues !== null) { + // range() only ever returns values of a single type + $firstValue = $rangeValues[0]; + $lastValue = $rangeValues[count($rangeValues) - 1]; + + if (is_string($firstValue) || is_string($lastValue)) { + // a character range consists of single bytes taken from constant boundaries + return self::getNonEmptyListOfType(TypeCombinator::intersect( + new StringType(), + new AccessoryNonEmptyStringType(), + new AccessoryLiteralStringType(), + )); + } + + if (is_float($firstValue) || is_float($lastValue)) { + return $floatListType; + } + + $bounds = $startConstant instanceof ConstantIntegerType && $endConstant instanceof ConstantIntegerType + ? [$startConstant->getValue(), $endConstant->getValue()] + : [$firstValue, $lastValue]; + } elseif ($startConstant instanceof ConstantFloatType || $endConstant instanceof ConstantFloatType) { + return $floatListType; + } elseif (!$startConstant instanceof ConstantIntegerType || !$endConstant instanceof ConstantIntegerType) { return self::getNonEmptyListOfType( - IntegerRangeType::fromInterval( - min($startConstant->getValue(), $endConstant->getValue()), - max($startConstant->getValue(), $endConstant->getValue()), + TypeCombinator::union( + $startConstant->generalize(GeneralizePrecision::moreSpecific()), + $endConstant->generalize(GeneralizePrecision::moreSpecific()), + $stepType->generalize(GeneralizePrecision::moreSpecific()), ), ); + } elseif (floor($stepConstant->getValue()) !== (float) $stepConstant->getValue()) { + // a step with a fractional part produces floats + return $floatListType; + } else { + $bounds = [$startConstant->getValue(), $endConstant->getValue()]; } - if ($stepType->isFloat()->yes()) { - return self::getNonEmptyListOfType(new FloatType()); - } - - return self::getNonEmptyListOfType( - TypeCombinator::union( - $startConstant->generalize(GeneralizePrecision::moreSpecific()), - $endConstant->generalize(GeneralizePrecision::moreSpecific()), - $stepType->generalize(GeneralizePrecision::moreSpecific()), - ), - ); + // the sequence is monotonic, so the first and the last value are its bounds + return self::getNonEmptyListOfType(IntegerRangeType::fromInterval(min($bounds), max($bounds))); } private static function getNonEmptyListOfType(Type $type): IntersectionType diff --git a/tests/PHPStan/Analyser/nsrt/bug-10022-php82.php b/tests/PHPStan/Analyser/nsrt/bug-10022-php82.php new file mode 100644 index 00000000000..ba3f436e41f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10022-php82.php @@ -0,0 +1,14 @@ +', range(1, 200, 1.0)); diff --git a/tests/PHPStan/Analyser/nsrt/bug-10022.php b/tests/PHPStan/Analyser/nsrt/bug-10022.php new file mode 100644 index 00000000000..1bfb21b35dd --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10022.php @@ -0,0 +1,48 @@ += 8.3 + +namespace Bug10022; + +use function PHPStan\Testing\assertType; + +// https://github.com/phpstan/phpstan/issues/10022 +assertType('array{\'1\', \'2\', \'3\', \'4\', \'5\', \'6\', \'7\', \'8\', \'9\', \':\', \';\', \'<\', \'=\', \'>\', \'?\', \'@\', \'A\', \'B\', \'C\', \'D\', \'E\', \'F\', \'G\', \'H\', \'I\', \'J\', \'K\', \'L\', \'M\', \'N\', \'O\', \'P\', \'Q\', \'R\', \'S\', \'T\', \'U\', \'V\', \'W\', \'X\', \'Y\', \'Z\', \'[\', \'\\\\\', \']\', \'^\', \'_\', \'`\', \'a\'}', range('1', 'a')); + +function doFoo(bool $flag): void +{ + if (PHP_VERSION_ID >= 80300) { + // a negative step on an increasing range and a non-finite step throw a ValueError since PHP 8.3 + assertType('*NEVER*', range(2, 5, -1)); + assertType('*NEVER*', range('a', 'z', -1)); + assertType('*NEVER*', range('a', 'z', 0)); + assertType('*NEVER*', range(1, 10, NAN)); + + // an integral float step produces ints since PHP 8.3 + assertType('non-empty-list>', range(1, 200, 1.0)); + assertType('non-empty-list', range('A', 'z', 1.0)); + } elseif (PHP_VERSION_ID >= 80000) { + // the sign of the step used to be ignored + assertType('non-empty-list', range(2, 5, -1)); + assertType('non-empty-list', range('a', 'z', -1)); + + // a float argument used to produce floats even without a fractional part + assertType('non-empty-list', range(1, 200, 1.0)); + assertType('non-empty-list', range('A', 'z', 1.0)); + } else { + // PHP 7 returns false for an invalid step, next to what the other combinations return + assertType('non-empty-list|false', range(2, 5, $flag ? -1 : 0)); + assertType('non-empty-list|false', range(2, 5, $flag ? 0 : true)); + } + + // every combination of the constant arguments contributes to the type + assertType('non-empty-list<0|1|(literal-string&non-empty-string)>', range($flag ? 'A' : 1, 'z')); + assertType('non-empty-list>', range($flag ? 1.0 : 1, 100)); + assertType('non-empty-list>', range(1, 100, $flag ? 0.5 : 1)); + + // ranges longer than the threshold are generalized from the returned values + assertType('non-empty-list', range(1.0, 100.0)); + assertType('non-empty-list', range('A', 'z')); + + // a step that is neither an int nor a float is not folded at all, so nothing + // can be said about the combination it belongs to + assertType('non-empty-list', range(2, 5, $flag ? 0 : true)); +} diff --git a/tests/PHPStan/Analyser/nsrt/range-array-count-limit.php b/tests/PHPStan/Analyser/nsrt/range-array-count-limit.php index ca7161f4bb3..9e75671d7ab 100644 --- a/tests/PHPStan/Analyser/nsrt/range-array-count-limit.php +++ b/tests/PHPStan/Analyser/nsrt/range-array-count-limit.php @@ -13,3 +13,31 @@ function doFoo(): void assertType('non-empty-list', range(0, 100000000, 0.5)); assertType('non-empty-list>', range(0, 300)); } + +function doBar(): void +{ + if (PHP_VERSION_ID >= 80300) { + // a negative step on an increasing range throws a ValueError since PHP 8.3 + assertType('*NEVER*', range(1, 1000, -1)); + + // an integral float step produces ints since PHP 8.3 + assertType('non-empty-list>', range(1, 1000, 1.0)); + } else { + // the sign of the step used to be ignored + assertType('non-empty-list>', range(1, 1000, -1)); + + // a float argument used to produce floats even without a fractional part + assertType('non-empty-list', range(1, 1000, 1.0)); + } + + assertType('non-empty-list', range(1.0, 1000.0)); + assertType('non-empty-list', range(0, 1000, 0.5)); +} + +function doBaz(bool $flag): void +{ + if (PHP_VERSION_ID >= 80000) { + // PHP 8.2 accepts the first combination and PHP 8.3 rejects it + assertType('non-empty-list', range($flag ? 1 : 2000, 1000, -1)); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/range-invalid-step.php b/tests/PHPStan/Analyser/nsrt/range-invalid-step.php new file mode 100644 index 00000000000..f2a2e1ab3d1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/range-invalid-step.php @@ -0,0 +1,24 @@ += 80000) { + // a step of 0 or one wider than the range throws a ValueError since PHP 8.0 + assertType('*NEVER*', range(2, 5, 0)); + assertType('*NEVER*', range(5, 6, 3)); + assertType('*NEVER*', range(1, 10, INF)); + assertType('array{6}', range($flag ? 5 : 6, 6, 3)); + assertType('non-empty-list>', range(1, $flag ? 2 : 300, 5)); + } else { + // PHP 7 reports an invalid step with a warning and returns false instead + assertType('false', range(2, 5, 0)); + assertType('false', range(5, 6, 3)); + assertType('false', range(1, 10, INF)); + assertType('array{6}|false', range($flag ? 5 : 6, 6, 3)); + assertType('non-empty-list>|false', range(1, $flag ? 2 : 300, 5)); + } +} diff --git a/tests/PHPStan/Type/Php/RangeFunctionArgumentsHelperTest.php b/tests/PHPStan/Type/Php/RangeFunctionArgumentsHelperTest.php new file mode 100644 index 00000000000..e113aac2ceb --- /dev/null +++ b/tests/PHPStan/Type/Php/RangeFunctionArgumentsHelperTest.php @@ -0,0 +1,77 @@ + + */ + public static function dataIsNegativeStepOnIncreasingRange(): iterable + { + yield [1, 5, -1, true]; + yield [5, 1, -1, false]; + yield [5, 5, -1, false]; + yield [1, 5, 1, false]; + + // two single bytes form a character range + yield ['a', 'z', -1, true]; + yield ['z', 'a', -1, false]; + yield ['a', '5', -1, false]; + yield ['5', 'a', -1, true]; + yield ['ab', 'z', -1, true]; + yield ['a', 'z', -1.0, true]; + + // PHP 8.3 keeps a step with a fractional part or beyond the integer range as a float, + // which turns a character into 0 unless it is a digit + yield ['a', 'z', -0.5, false]; + yield ['0', '9', -0.0001, true]; + yield ['a', '9', -0.0001, true]; + yield ['9', 'a', -0.0001, false]; + yield ['a', 'z', -1e20, false]; + yield ['a', '9', -1e20, true]; + + // a numeric string with whitespace around it is a number, also on PHP 7.4 + yield ['5 ', 3, -1, false]; + yield [' 5', 3, -1, false]; + + // an empty string or a character next to a number is 0 + yield ['', 'a', -1, false]; + yield ['a', '12', -1, true]; + yield ['a', 5, -1, true]; + } + + #[DataProvider('dataIsNegativeStepOnIncreasingRange')] + public function testIsNegativeStepOnIncreasingRange(int|float|string $start, int|float|string $end, int|float $step, bool $expected): void + { + $this->assertSame($expected, RangeFunctionArgumentsHelper::isNegativeStepOnIncreasingRange($start, $end, $step)); + } + + /** + * @return iterable + */ + public static function dataRejectsSincePhp83WithoutCallingRange(): iterable + { + yield [1, 10, 2, TrinaryLogic::createNo()]; + yield [1, 10, 20, TrinaryLogic::createYes()]; + + // PHP 8.3 compares an integral float step exactly, which a float cannot do above 2 ** 53 + yield [0, 1152921504606846975, 1152921504606846976.0, TrinaryLogic::createMaybe()]; + yield [1, 9007199254740993, 9007199254740992.0, TrinaryLogic::createMaybe()]; + } + + #[DataProvider('dataRejectsSincePhp83WithoutCallingRange')] + public function testRejectsSincePhp83WithoutCallingRange(int|float|string $start, int|float|string $end, int|float $step, TrinaryLogic $expected): void + { + $this->assertSame( + $expected->describe(), + RangeFunctionArgumentsHelper::rejects(TrinaryLogic::createYes(), $start, $end, $step, null)->describe(), + ); + } + +}