Skip to content

Commit

Permalink
introduce basic integer range
Browse files Browse the repository at this point in the history
  • Loading branch information
orklah committed Jul 30, 2021
1 parent 6c475e0 commit 1e3e6a8
Show file tree
Hide file tree
Showing 5 changed files with 280 additions and 1 deletion.
73 changes: 73 additions & 0 deletions src/Psalm/Internal/Type/Comparator/IntegerRangeComparator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace Psalm\Internal\Type\Comparator;

use Psalm\Codebase;
use Psalm\Internal\Analyzer\ClassLikeAnalyzer;
use Psalm\Type;
use Psalm\Type\Atomic\Scalar;
use Psalm\Type\Atomic\TArray;
use Psalm\Type\Atomic\TArrayKey;
use Psalm\Type\Atomic\TBool;
use Psalm\Type\Atomic\TCallableString;
use Psalm\Type\Atomic\TClassString;
use Psalm\Type\Atomic\TDependentGetClass;
use Psalm\Type\Atomic\TDependentGetDebugType;
use Psalm\Type\Atomic\TDependentGetType;
use Psalm\Type\Atomic\TDependentListKey;
use Psalm\Type\Atomic\TFalse;
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;
use Psalm\Type\Atomic\TLiteralString;
use Psalm\Type\Atomic\TLowercaseString;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Atomic\TNonEmptyNonspecificLiteralString;
use Psalm\Type\Atomic\TNonEmptyString;
use Psalm\Type\Atomic\TNonFalsyString;
use Psalm\Type\Atomic\TNonspecificLiteralInt;
use Psalm\Type\Atomic\TNonspecificLiteralString;
use Psalm\Type\Atomic\TNumeric;
use Psalm\Type\Atomic\TNumericString;
use Psalm\Type\Atomic\TPositiveInt;
use Psalm\Type\Atomic\TScalar;
use Psalm\Type\Atomic\TSingleLetter;
use Psalm\Type\Atomic\TString;
use Psalm\Type\Atomic\TTemplateParam;
use Psalm\Type\Atomic\TTemplateParamClass;
use Psalm\Type\Atomic\TTraitString;
use Psalm\Type\Atomic\TTrue;

use function array_values;
use function get_class;
use function strtolower;

/**
* @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;
}
}
72 changes: 72 additions & 0 deletions tests/IntRangeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php
namespace Psalm\Tests;

use function class_exists;

use const DIRECTORY_SEPARATOR;

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',
],
];
}
}

0 comments on commit 1e3e6a8

Please sign in to comment.