diff --git a/src/Psalm/Internal/Type/Comparator/IntegerRangeComparator.php b/src/Psalm/Internal/Type/Comparator/IntegerRangeComparator.php new file mode 100644 index 00000000000..583afcad9fd --- /dev/null +++ b/src/Psalm/Internal/Type/Comparator/IntegerRangeComparator.php @@ -0,0 +1,31 @@ +min_bound === TIntRange::BOUND_MIN; + $is_input_max = $input_type_part->max_bound === TIntRange::BOUND_MAX; + $is_container_min = $container_type_part->min_bound === TIntRange::BOUND_MIN; + $is_container_max = $container_type_part->max_bound === TIntRange::BOUND_MAX; + + $is_input_min_in_container = ( + $is_container_min || + (!$is_input_min && $container_type_part->min_bound <= $input_type_part->min_bound) + ); + $is_input_max_in_container = ( + $is_container_max || + (!$is_input_max && $container_type_part->max_bound >= $input_type_part->max_bound) + ); + return $is_input_min_in_container && $is_input_max_in_container; + } +} diff --git a/src/Psalm/Internal/Type/Comparator/ScalarTypeComparator.php b/src/Psalm/Internal/Type/Comparator/ScalarTypeComparator.php index 79012adad0f..39fcc8b1c2d 100644 --- a/src/Psalm/Internal/Type/Comparator/ScalarTypeComparator.php +++ b/src/Psalm/Internal/Type/Comparator/ScalarTypeComparator.php @@ -19,6 +19,7 @@ use Psalm\Type\Atomic\TFloat; use Psalm\Type\Atomic\THtmlEscapedString; use Psalm\Type\Atomic\TInt; +use Psalm\Type\Atomic\TIntRange; use Psalm\Type\Atomic\TLiteralClassString; use Psalm\Type\Atomic\TLiteralFloat; use Psalm\Type\Atomic\TLiteralInt; @@ -383,14 +384,23 @@ public static function isContainedBy( return false; } + if ($input_type_part instanceof TIntRange && $container_type_part instanceof TIntRange) { + return IntegerRangeComparator::isContainedBy( + $input_type_part, + $container_type_part + ); + } + if ($input_type_part instanceof TInt && $container_type_part instanceof TPositiveInt) { if ($input_type_part instanceof TPositiveInt) { return true; } - if ($input_type_part instanceof TLiteralInt) { return $input_type_part->value > 0; } + if ($input_type_part instanceof TIntRange) { + return $input_type_part->isPositive(); + } if ($atomic_comparison_result) { $atomic_comparison_result->type_coerced = true; diff --git a/src/Psalm/Internal/Type/TypeParser.php b/src/Psalm/Internal/Type/TypeParser.php index a5d4bcace47..d505223ed3f 100644 --- a/src/Psalm/Internal/Type/TypeParser.php +++ b/src/Psalm/Internal/Type/TypeParser.php @@ -13,6 +13,7 @@ use Psalm\Type\Atomic\TClassStringMap; use Psalm\Type\Atomic\TClosure; use Psalm\Type\Atomic\TGenericObject; +use Psalm\Type\Atomic\TIntRange; use Psalm\Type\Atomic\TIterable; use Psalm\Type\Atomic\TKeyedArray; use Psalm\Type\Atomic\TList; @@ -466,6 +467,7 @@ function ($int) { * @param array $type_aliases * @return Atomic|Union * @throws TypeParseTreeException + * @psalm-suppress ComplexMethod to be refactored */ private static function getTypeFromGenericTree( ParseTree\GenericTree $parse_tree, @@ -777,6 +779,51 @@ private static function getTypeFromGenericTree( return new Atomic\TIntMaskOf($param_type); } + if ($generic_type_value === 'int') { + if (count($generic_params) !== 2) { + throw new TypeParseTreeException('int range must have 2 params'); + } + + $min_bound = Atomic\TIntRange::BOUND_MIN; + $max_bound = Atomic\TIntRange::BOUND_MAX; + + $param0_union_types = array_values($generic_params[0]->getAtomicTypes()); + $param1_union_types = array_values($generic_params[1]->getAtomicTypes()); + + if (count($param0_union_types) > 1 || count($param1_union_types) > 1) { + throw new TypeParseTreeException('Union types are not allowed in int range type'); + } + + if ($param0_union_types[0] instanceof TNamedObject && + $param0_union_types[0]->value === TIntRange::BOUND_MAX + ) { + throw new TypeParseTreeException("min bound for int range param can't be 'max'"); + } + if ($param1_union_types[0] instanceof TNamedObject && + $param1_union_types[0]->value === TIntRange::BOUND_MIN + ) { + throw new TypeParseTreeException("max bound for int range param can't be 'min'"); + } + + + if ($param0_union_types[0] instanceof TLiteralInt) { + $min_bound = $param0_union_types[0]->value; + } + if ($param1_union_types[0] instanceof TLiteralInt) { + $max_bound = $param1_union_types[0]->value; + } + + if ($min_bound === TIntRange::BOUND_MIN && $max_bound === TIntRange::BOUND_MAX) { + return new Atomic\TInt(); + } + + if ($min_bound === 0 && $max_bound === TIntRange::BOUND_MAX) { + return new Atomic\TPositiveInt(); + } + + return new Atomic\TIntRange($min_bound, $max_bound); + } + if (isset(TypeTokenizer::PSALM_RESERVED_WORDS[$generic_type_value]) && $generic_type_value !== 'self' && $generic_type_value !== 'static' diff --git a/src/Psalm/Type/Atomic/TIntRange.php b/src/Psalm/Type/Atomic/TIntRange.php new file mode 100644 index 00000000000..09206467dc4 --- /dev/null +++ b/src/Psalm/Type/Atomic/TIntRange.php @@ -0,0 +1,77 @@ +min_bound = $min_bound; + $this->max_bound = $max_bound; + } + + public function __toString(): string + { + return $this->getKey(); + } + + public function getKey(bool $include_extra = true): string + { + return 'int<' . $this->min_bound . ', ' . $this->max_bound . '>'; + } + + public function canBeFullyExpressedInPhp(int $php_major_version, int $php_minor_version): bool + { + return false; + } + + /** + * @param array $aliased_classes + */ + public function toPhpString( + ?string $namespace, + array $aliased_classes, + ?string $this_class, + int $php_major_version, + int $php_minor_version + ): ?string { + return $php_major_version >= 7 ? 'int' : null; + } + + /** + * @param array $aliased_classes + */ + public function toNamespacedString( + ?string $namespace, + array $aliased_classes, + ?string $this_class, + bool $use_phpdoc_format + ): string { + return $use_phpdoc_format ? 'int' : 'int<' . $this->min_bound . ', ' . $this->max_bound . '>'; + } + + public function isPositive(): bool + { + return $this->min_bound !== self::BOUND_MIN && $this->min_bound > 0; + } +} diff --git a/tests/IntRangeTest.php b/tests/IntRangeTest.php new file mode 100644 index 00000000000..dd96bac1769 --- /dev/null +++ b/tests/IntRangeTest.php @@ -0,0 +1,68 @@ +,error_levels?:string[]}> + */ + public function providerValidCodeParse(): iterable + { + return [ + 'intRangeContained' => [ + ' $a + * @return int<-1, max> + */ + function scope(int $a){ + return $a; + }', + ], + 'positiveIntRange' => [ + ' $a + * @return positive-int + */ + function scope(int $a){ + return $a; + }', + ], + 'intRangeToInt' => [ + ' $a + * @return int + */ + function scope(int $a){ + return $a; + }', + ], + ]; + } + + /** + * @return iterable + */ + public function providerInvalidCodeParse(): iterable + { + return [ + 'intRangeNotContained' => [ + ' $a + * @return int<-1, 11> + * @psalm-suppress InvalidReturnStatement + */ + function scope(int $a){ + return $a; + }', + 'error_message' => 'InvalidReturnType', + ], + ]; + } +}