Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Int range #6207

Merged
merged 3 commits into from
Jul 30, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/Psalm/Internal/Type/Comparator/IntegerRangeComparator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace Psalm\Internal\Type\Comparator;

use Psalm\Type\Atomic\TIntRange;

/**
* @internal
*/
class IntegerRangeComparator
{
public static function isContainedBy(
TIntRange $input_type_part,
TIntRange $container_type_part
) : bool {
$is_input_min = $input_type_part->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;
}
}
12 changes: 11 additions & 1 deletion src/Psalm/Internal/Type/Comparator/ScalarTypeComparator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions src/Psalm/Internal/Type/TypeParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -466,6 +467,7 @@ function ($int) {
* @param array<string, TypeAlias> $type_aliases
* @return Atomic|Union
* @throws TypeParseTreeException
* @psalm-suppress ComplexMethod to be refactored
*/
private static function getTypeFromGenericTree(
ParseTree\GenericTree $parse_tree,
Expand Down Expand Up @@ -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'
Expand Down
77 changes: 77 additions & 0 deletions src/Psalm/Type/Atomic/TIntRange.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php
namespace Psalm\Type\Atomic;

/**
* Denotes an interval of integers between two bounds
*/
class TIntRange extends TInt
{
const BOUND_MIN = 'min';
const BOUND_MAX = 'max';

/**
* @var int|string
* @psalm-var int|'min'
*/
public $min_bound;
/**
* @var int|string
* @var int|'max'
*/
public $max_bound;

/**
* @param int|self::BOUND_MIN $min_bound
* @param int|self::BOUND_MAX $max_bound
*/
public function __construct($min_bound, $max_bound)
{
$this->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<lowercase-string, string> $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<lowercase-string, string> $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;
}
}
68 changes: 68 additions & 0 deletions tests/IntRangeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php
namespace Psalm\Tests;

class IntRangeTest extends TestCase
{
use Traits\InvalidCodeAnalysisTestTrait;
use Traits\ValidCodeAnalysisTestTrait;

/**
* @return iterable<string,array{string,assertions?:array<string,string>,error_levels?:string[]}>
*/
public function providerValidCodeParse(): iterable
{
return [
'intRangeContained' => [
'<?php
/**
* @param int<1,12> $a
* @return int<-1, max>
*/
function scope(int $a){
return $a;
}',
],
'positiveIntRange' => [
'<?php
/**
* @param int<1,12> $a
* @return positive-int
*/
function scope(int $a){
return $a;
}',
],
'intRangeToInt' => [
'<?php
/**
* @param int<1,12> $a
* @return int
*/
function scope(int $a){
return $a;
}',
],
];
}

/**
* @return iterable<string,array{string,error_message:string,1?:string[],2?:bool,3?:string}>
*/
public function providerInvalidCodeParse(): iterable
{
return [
'intRangeNotContained' => [
'<?php
/**
* @param int<1,12> $a
* @return int<-1, 11>
* @psalm-suppress InvalidReturnStatement
*/
function scope(int $a){
return $a;
}',
'error_message' => 'InvalidReturnType',
],
];
}
}