diff --git a/.github/workflows/infection.yml b/.github/workflows/infection.yml
index 5eb7dce..c01fb1f 100644
--- a/.github/workflows/infection.yml
+++ b/.github/workflows/infection.yml
@@ -1,6 +1,6 @@
name: Infection
-on: [ pull_request, push ]
+on: [ pull_request ]
jobs:
build:
@@ -52,3 +52,10 @@ jobs:
--min-msi=100 \
--min-covered-msi=100 \
--ignore-msi-with-no-mutations
+
+ - name: Archive Infection log
+ uses: actions/upload-artifact@v3
+ if: always()
+ with:
+ name: infection-log
+ path: infection.log
diff --git a/.github/workflows/phpcs.yml b/.github/workflows/phpcs.yml
index 2ff0e1b..bedf0c7 100644
--- a/.github/workflows/phpcs.yml
+++ b/.github/workflows/phpcs.yml
@@ -1,6 +1,6 @@
name: PHPCS
-on: [ pull_request, push ]
+on: [ pull_request ]
jobs:
build:
diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml
index c273ebc..c95af5d 100644
--- a/.github/workflows/phpstan.yml
+++ b/.github/workflows/phpstan.yml
@@ -1,6 +1,6 @@
name: PHPStan
-on: [ pull_request, push ]
+on: [ pull_request ]
jobs:
build:
diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml
index d141c3a..20f81aa 100644
--- a/.github/workflows/phpunit.yml
+++ b/.github/workflows/phpunit.yml
@@ -1,6 +1,6 @@
name: PHPUnit
-on: [ pull_request, push ]
+on: [ pull_request ]
jobs:
build:
@@ -11,6 +11,7 @@ jobs:
matrix:
php: [ '8.1' ]
prefer-lowest: [ '--prefer-lowest', '' ]
+ fail-fast: false
name: PHPUnit on PHP ${{ matrix.php }}
diff --git a/composer.json b/composer.json
index 3133f95..be22bbe 100644
--- a/composer.json
+++ b/composer.json
@@ -7,7 +7,7 @@
"require-dev": {
"infection/infection": "^0.26.13",
"phpstan/extension-installer": "^1.1",
- "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan": "^1.9",
"phpstan/phpstan-strict-rules": "^1.3",
"phpunit/phpunit": "^9.5",
"slevomat/coding-standard": "^8.1",
@@ -21,7 +21,8 @@
},
"autoload-dev": {
"psr-4": {
- "PhpTypes\\Types\\Tests\\Functional\\": "tests/functional"
+ "PhpTypes\\Types\\Tests\\Functional\\": "tests/functional",
+ "PhpTypes\\Types\\Tests\\Unit\\": "tests/unit"
}
},
"config": {
diff --git a/infection.json b/infection.json
index e2ab411..0466e3b 100644
--- a/infection.json
+++ b/infection.json
@@ -9,6 +9,10 @@
"text": "infection.log"
},
"mutators": {
- "@default": true
- }
-}
\ No newline at end of file
+ "@default": true,
+ "global-ignoreSourceCodeByRegex": [
+ "assert\\(.+\\);"
+ ]
+ },
+ "timeout": 1
+}
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
index b4851a3..1494f51 100644
--- a/phpstan.dist.neon
+++ b/phpstan.dist.neon
@@ -3,10 +3,3 @@ parameters:
paths:
- src
- tests
- scanFiles:
- - generated/PhpTypes/Ast/Generated/PhpTypesBaseListener.php
- - generated/PhpTypes/Ast/Generated/PhpTypesBaseVisitor.php
- - generated/PhpTypes/Ast/Generated/PhpTypesLexer.php
- - generated/PhpTypes/Ast/Generated/PhpTypesListener.php
- - generated/PhpTypes/Ast/Generated/PhpTypesParser.php
- - generated/PhpTypes/Ast/Generated/PhpTypesVisitor.php
diff --git a/phpunit.xml b/phpunit.xml
index 00e8e9d..9f975db 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -7,6 +7,9 @@
./tests/functional
+
+ ./tests/unit
+
diff --git a/src/BoolType.php b/src/BoolType.php
index 4256dca..7cc9e54 100644
--- a/src/BoolType.php
+++ b/src/BoolType.php
@@ -13,17 +13,14 @@ public function __construct(public readonly ?bool $value = null)
{
}
- public function __toString(): string
- {
- return match ($this->value) {
- null => 'bool',
- true => 'true',
- false => 'false',
- };
- }
-
public function toNode(): NodeInterface
{
- return new IdentifierNode((string)$this);
+ return new IdentifierNode(
+ match ($this->value) {
+ null => 'bool',
+ true => 'true',
+ false => 'false',
+ }
+ );
}
}
diff --git a/src/ClassLikeType.php b/src/ClassLikeType.php
index 8a066ac..4385edb 100644
--- a/src/ClassLikeType.php
+++ b/src/ClassLikeType.php
@@ -12,9 +12,13 @@ final class ClassLikeType extends AbstractType
/**
* @param non-empty-string $name
* @param list $typeParameters
+ * @param list $parents
*/
- public function __construct(public readonly string $name, public readonly array $typeParameters = [])
- {
+ public function __construct(
+ public readonly string $name,
+ public readonly array $typeParameters = [],
+ public readonly array $parents = [],
+ ) {
}
public function toNode(): NodeInterface
diff --git a/src/Compatibility.php b/src/Compatibility.php
new file mode 100644
index 0000000..41075d1
--- /dev/null
+++ b/src/Compatibility.php
@@ -0,0 +1,315 @@
+ self::checkBool($super, $sub),
+ $super instanceof CallableType => self::checkCallable($super, $sub),
+ $super instanceof ClassLikeType => self::checkClassLike($super, $sub),
+ $super instanceof ClassStringType => self::checkClassString($super, $sub),
+ $super instanceof FloatType => self::checkFloat($sub),
+ $super instanceof IntLiteralType => self::checkIntLiteral($super, $sub),
+ $super instanceof IntersectionType => self::checkIntersection($super, $sub),
+ $super instanceof IntType => self::checkInt($super, $sub),
+ $super instanceof IterableType => self::checkIterable($super, $sub),
+ $super instanceof ListType => self::checkList($super, $sub),
+ $super instanceof MapType => self::checkMap($super, $sub),
+ $super instanceof MixedType => true,
+ $super instanceof NeverType => false,
+ $super instanceof NullType => $sub instanceof NullType,
+ $super instanceof ScalarType => self::checkScalar($sub),
+ $super instanceof StringLiteralType => self::checkStringLiteral($super, $sub),
+ $super instanceof StringType => self::checkString($super, $sub),
+ $super instanceof StructType => self::checkStruct($super, $sub),
+ $super instanceof TupleType => self::checkTuple($super, $sub),
+ $super instanceof UnionType => self::checkUnion($super, $sub),
+ default => throw new LogicException(sprintf('Unsupported type "%s"', get_class($super))),
+ };
+ }
+
+ private static function checkClassString(ClassStringType $super, AbstractType $sub): bool
+ {
+ if (!$sub instanceof ClassStringType) {
+ return false;
+ }
+ if ($super->class === null) {
+ return true;
+ }
+ if ($sub->class === null) {
+ return false;
+ }
+ return self::check($super->class, $sub->class);
+ }
+
+ private static function checkClassLike(ClassLikeType $super, AbstractType $sub): bool
+ {
+ if (!$sub instanceof ClassLikeType) {
+ return false;
+ }
+ if ($super->name === $sub->name) {
+ return true;
+ }
+ foreach ($sub->parents as $parent) {
+ if (self::check($super, $parent)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static function checkString(StringType $super, AbstractType $sub): bool
+ {
+ if ($sub instanceof StringLiteralType) {
+ if ($super->numeric) {
+ return is_numeric($sub->value);
+ }
+ if ($super->nonEmpty) {
+ return $sub->value !== '';
+ }
+ return true;
+ }
+ if ($sub instanceof ClassStringType) {
+ return !$super->numeric;
+ }
+ if (!$sub instanceof StringType) {
+ return false;
+ }
+ if ($super->numeric) {
+ return $sub->numeric;
+ }
+ if ($super->nonEmpty) {
+ return $sub->nonEmpty;
+ }
+ return true;
+ }
+
+ private static function checkBool(BoolType $super, AbstractType $sub): bool
+ {
+ if (!$sub instanceof BoolType) {
+ return false;
+ }
+ if ($super->value === null) {
+ return true;
+ }
+ return $super->value === $sub->value;
+ }
+
+ private static function checkInt(IntType $super, AbstractType $sub): bool
+ {
+ if ($sub instanceof IntLiteralType) {
+ return ($super->min ?? PHP_INT_MIN) <= $sub->value && $sub->value <= ($super->max ?? PHP_INT_MAX);
+ }
+ if (!$sub instanceof IntType) {
+ return false;
+ }
+ [$superMin, $superMax, $subMin, $subMax] = [
+ $super->min ?? PHP_INT_MIN,
+ $super->max ?? PHP_INT_MAX,
+ $sub->min ?? PHP_INT_MIN,
+ $sub->max ?? PHP_INT_MAX,
+ ];
+ return $superMin <= $subMin && $superMax >= $subMax;
+ }
+
+ private static function checkIntLiteral(IntLiteralType $super, AbstractType $sub): bool
+ {
+ if ($sub instanceof IntLiteralType) {
+ return $super->value === $sub->value;
+ }
+ if ($sub instanceof IntType) {
+ return $sub->min === $super->value && $sub->max === $super->value;
+ }
+ return false;
+ }
+
+ private static function checkFloat(AbstractType $sub): bool
+ {
+ return $sub instanceof FloatType
+ || $sub instanceof IntLiteralType
+ || $sub instanceof IntType;
+ }
+
+ private static function checkUnion(UnionType $super, AbstractType $sub): bool
+ {
+ return self::check($super->left, $sub) || self::check($super->right, $sub);
+ }
+
+ private static function checkScalar(AbstractType $sub): bool
+ {
+ return $sub instanceof ScalarType
+ || $sub instanceof IntLiteralType
+ || $sub instanceof IntType
+ || $sub instanceof FloatType
+ || $sub instanceof BoolType
+ || $sub instanceof StringType
+ || $sub instanceof StringLiteralType
+ || $sub instanceof ClassStringType;
+ }
+
+ private static function checkList(ListType $super, AbstractType $sub): bool
+ {
+ if ($sub instanceof TupleType) {
+ if ($super->nonEmpty && $sub->elements === []) {
+ return false;
+ }
+ foreach ($sub->elements as $element) {
+ if (self::check($super->type, $element)) {
+ continue;
+ }
+ return false;
+ }
+ return true;
+ }
+ if (!$sub instanceof ListType) {
+ return false;
+ }
+ if ($super->nonEmpty && !$sub->nonEmpty) {
+ return false;
+ }
+ return self::check($super->type, $sub->type);
+ }
+
+ private static function checkMap(MapType $super, AbstractType $sub): bool
+ {
+ if ($sub instanceof ToMapInterface) {
+ $sub = $sub->toMap();
+ }
+ if (!$sub instanceof MapType) {
+ return false;
+ }
+ if ($super->nonEmpty && !$sub->nonEmpty) {
+ return false;
+ }
+ return self::check($super->keyType, $sub->keyType) && self::check($super->valueType, $sub->valueType);
+ }
+
+ private static function checkTuple(TupleType $super, AbstractType $sub): bool
+ {
+ if ($super->elements === []) {
+ return $sub instanceof ListType
+ || $sub instanceof TupleType
+ || $sub instanceof MapType
+ || $sub instanceof StructType;
+ }
+ if (!$sub instanceof TupleType) {
+ return false;
+ }
+ if (count($super->elements) > count($sub->elements)) {
+ return false;
+ }
+ foreach ($super->elements as $i => $element) {
+ if (self::check($element, $sub->elements[$i])) {
+ continue;
+ }
+ return false;
+ }
+ return true;
+ }
+
+ private static function checkStruct(StructType $super, AbstractType $sub): bool
+ {
+ if (!$sub instanceof StructType) {
+ return false;
+ }
+ foreach ($super->members as $name => $member) {
+ $subMember = array_key_exists($name, $sub->members) ? $sub->members[$name] : null;
+ if ($subMember === null) {
+ if ($member->optional) {
+ continue;
+ }
+ return false;
+ }
+ if (!self::check($member->type, $subMember->type)) {
+ return false;
+ }
+ if ($subMember->optional && !$member->optional) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static function checkStringLiteral(StringLiteralType $super, AbstractType $sub): bool
+ {
+ return $sub instanceof StringLiteralType && $super->value === $sub->value;
+ }
+
+ private static function checkCallable(CallableType $super, AbstractType $sub): bool
+ {
+ if (!$sub instanceof CallableType) {
+ return false;
+ }
+ if (!$super->returnType instanceof VoidType && !self::check($super->returnType, $sub->returnType)) {
+ return false;
+ }
+ if (count($sub->parameters) > count($super->parameters)) {
+ return false;
+ }
+ foreach ($sub->parameters as $i => $parameter) {
+ $superParameter = $super->parameters[$i];
+ if (!$parameter->optional && $superParameter->optional) {
+ return false;
+ }
+ if (self::check($parameter->type, $superParameter->type)) {
+ continue;
+ }
+ return false;
+ }
+ return true;
+ }
+
+ private static function checkIterable(IterableType $super, AbstractType $sub): bool
+ {
+ if ($sub instanceof ToIterableInterface) {
+ $sub = $sub->toIterable();
+ }
+ if (!$sub instanceof IterableType) {
+ return false;
+ }
+ return self::check($super->keyType, $sub->keyType)
+ && self::check($super->valueType, $sub->valueType);
+ }
+
+ private static function checkSubUnion(AbstractType $super, UnionType $sub): bool
+ {
+ return self::check($super, $sub->left) && self::check($super, $sub->right);
+ }
+
+ private static function checkIntersection(IntersectionType $super, AbstractType $sub): bool
+ {
+ return self::check($super->left, $sub) && self::check($super->right, $sub);
+ }
+
+ private static function checkSubIntersection(AbstractType $super, IntersectionType $sub): bool
+ {
+ return self::check($super, $sub->left) && self::check($super, $sub->right);
+ }
+}
diff --git a/src/Conversion/ToIterableInterface.php b/src/Conversion/ToIterableInterface.php
new file mode 100644
index 0000000..039139e
--- /dev/null
+++ b/src/Conversion/ToIterableInterface.php
@@ -0,0 +1,13 @@
+type->toNode())
: StructMemberNode::required($this->type->toNode());
}
+
+ public function intersect(self $other): self
+ {
+ return new self(IntersectionType::create($this->type, $other->type), $this->optional && $other->optional);
+ }
}
diff --git a/src/IntType.php b/src/IntType.php
index 07995f2..5d801d2 100644
--- a/src/IntType.php
+++ b/src/IntType.php
@@ -14,11 +14,6 @@ public function __construct(public readonly int|null $min = null, public readonl
{
}
- public static function minMax(int $min, int $max): self
- {
- return new self($min, $max);
- }
-
public static function min(int $min): self
{
return new self($min, null);
diff --git a/src/IntersectionType.php b/src/IntersectionType.php
index 48cb49a..af3ca1a 100644
--- a/src/IntersectionType.php
+++ b/src/IntersectionType.php
@@ -7,14 +7,62 @@
use PhpTypes\Ast\Node\IntersectionNode;
use PhpTypes\Ast\Node\NodeInterface;
+use function array_shift;
+use function assert;
+use function count;
+
final class IntersectionType extends AbstractType
{
- public function __construct(public readonly AbstractType $left, public readonly AbstractType $right)
+ private function __construct(public readonly AbstractType $left, public readonly AbstractType $right)
+ {
+ }
+
+ public static function create(AbstractType $left, AbstractType $right): AbstractType
+ {
+ $parts = array_merge(
+ $left instanceof self ? $left->flatten() : [$left],
+ $right instanceof self ? $right->flatten() : [$right],
+ );
+ $structs = [];
+ foreach ($parts as $index => $part) {
+ if (!$part instanceof StructType) {
+ continue;
+ }
+ $structs[] = $part;
+ unset($parts[$index]);
+ }
+ if ($structs !== []) {
+ $parts[] = StructType::merge($structs);
+ }
+ assert($parts !== []);
+ return self::unflatten($parts);
+ }
+
+ /**
+ * @param non-empty-array $types
+ */
+ private static function unflatten(array $types): AbstractType
{
+ $first = array_shift($types);
+ return match (count($types)) {
+ 0 => $first,
+ 1 => new self($first, array_shift($types)),
+ default => new self($first, self::unflatten($types)),
+ };
}
public function toNode(): NodeInterface
{
return new IntersectionNode($this->left->toNode(), $this->right->toNode());
}
+
+ /**
+ * @return non-empty-list
+ */
+ private function flatten(): array
+ {
+ $leftParts = $this->left instanceof self ? $this->left->flatten() : [$this->left];
+ $rightParts = $this->right instanceof self ? $this->right->flatten() : [$this->right];
+ return array_merge($leftParts, $rightParts);
+ }
}
diff --git a/src/ListType.php b/src/ListType.php
index e590048..83745f9 100644
--- a/src/ListType.php
+++ b/src/ListType.php
@@ -6,20 +6,27 @@
use PhpTypes\Ast\Node\IdentifierNode;
use PhpTypes\Ast\Node\NodeInterface;
+use PhpTypes\Types\Conversion\ToIterableInterface;
+use PhpTypes\Types\Conversion\ToMapInterface;
-final class ListType extends AbstractType
+final class ListType extends AbstractType implements ToIterableInterface, ToMapInterface
{
public function __construct(public readonly AbstractType $type, public readonly bool $nonEmpty = false)
{
}
- public function nonEmpty(AbstractType $type): AbstractType
+ public function toNode(): NodeInterface
{
- return new ListType($type, true);
+ return new IdentifierNode($this->nonEmpty ? 'non-empty-list' : 'list', [$this->type->toNode()]);
}
- public function toNode(): NodeInterface
+ public function toMap(): MapType
{
- return new IdentifierNode($this->nonEmpty ? 'non-empty-list' : 'list', [$this->type->toNode()]);
+ return new MapType(new IntType(), $this->type, $this->nonEmpty);
+ }
+
+ public function toIterable(): IterableType
+ {
+ return new IterableType(new IntType(), $this->type);
}
}
diff --git a/src/MapType.php b/src/MapType.php
index 81ac46b..0396a65 100644
--- a/src/MapType.php
+++ b/src/MapType.php
@@ -6,19 +6,28 @@
use PhpTypes\Ast\Node\IdentifierNode;
use PhpTypes\Ast\Node\NodeInterface;
+use PhpTypes\Types\Conversion\ToIterableInterface;
+use RuntimeException;
use function in_array;
+use function sprintf;
-final class MapType extends AbstractType
+final class MapType extends AbstractType implements ToIterableInterface
{
public function __construct(
public readonly AbstractType $keyType,
public readonly AbstractType $valueType,
public readonly bool $nonEmpty = false,
) {
+ if (Compatibility::check(new UnionType(new StringType(), new IntType()), $keyType)) {
+ return;
+ }
+ throw new RuntimeException(
+ sprintf('Can\'t use %s as array key. Only strings and integers are allowed.', $keyType),
+ );
}
- public function nonEmpty(AbstractType $keyType, AbstractType $valueType): AbstractType
+ public static function nonEmpty(AbstractType $keyType, AbstractType $valueType): self
{
return new self($keyType, $valueType, true);
}
@@ -42,4 +51,9 @@ public function toNode(): NodeInterface
[$keyNode, $this->valueType->toNode()]
);
}
+
+ public function toIterable(): IterableType
+ {
+ return new IterableType($this->keyType, $this->valueType);
+ }
}
diff --git a/src/ScalarType.php b/src/ScalarType.php
index c5b1573..2c138a2 100644
--- a/src/ScalarType.php
+++ b/src/ScalarType.php
@@ -1,5 +1,7 @@
register('bool', new BoolType());
$scope->register('false', new BoolType(false));
$scope->register('float', new FloatType());
- $scope->register('int', self::int(...));
$scope->register('iterable', $scope->iterable(...));
$scope->register('list', self::list(...));
$scope->register('mixed', new MixedType());
@@ -45,58 +43,6 @@ public static function global(): self
return $scope;
}
- /**
- * @param list $typeParameters
- */
- private static function int(array $typeParameters): IntType
- {
- switch (count($typeParameters)) {
- case 0:
- return new IntType();
- case 2:
- if (
- $typeParameters[0] instanceof IntLiteralType
- && $typeParameters[1] instanceof IntLiteralType
- ) {
- return IntType::minMax(
- $typeParameters[0]->value,
- $typeParameters[1]->value,
- );
- }
- if (
- $typeParameters[0] instanceof IntLiteralType
- && $typeParameters[1] instanceof IdentifierNode
- && $typeParameters[1]->name === 'max'
- ) {
- return IntType::min($typeParameters[0]->value);
- }
- if (
- $typeParameters[0] instanceof IdentifierNode
- && $typeParameters[0]->name === 'min'
- && $typeParameters[1] instanceof IntLiteralType
- ) {
- return IntType::max($typeParameters[1]->value);
- }
- throw new RuntimeException(
- sprintf(
- 'Integer types must take one of the following forms: ' .
- 'int, `int<23, 42>`, `int`, `int<23, max>`. ' .
- 'Got: int<%s>',
- implode(', ', $typeParameters),
- )
- );
- default:
- throw new RuntimeException(
- sprintf(
- 'Integer types must take one of the following forms: ' .
- 'int, `int<23, 42>`, `int`, `int<23, max>`. ' .
- 'Got: int<%s>',
- implode(', ', $typeParameters),
- )
- );
- }
- }
-
/**
* @param list $types
*/
@@ -130,7 +76,8 @@ public function getType(string $name, array $typeParameters = []): AbstractType
{
$type = $this->types[$name] ?? null;
if ($type === null) {
- throw new RuntimeException(sprintf('Unknown type %s', $name));
+ $typeString = $name . ($typeParameters !== [] ? '<' . implode(', ', $typeParameters) . '>' : '');
+ throw new RuntimeException(sprintf('Unknown type %s', $typeString));
}
return $type instanceof AbstractType ? $type : $type($typeParameters);
}
@@ -155,7 +102,8 @@ private function map(array $typeParameters, bool $nonEmpty = false): MapType
2 => [$typeParameters[0], $typeParameters[1]],
default => throw new RuntimeException(
'Array types must take one of the following forms: ' .
- 'array, array, array',
+ 'array, array, array. ' .
+ 'Got array<' . implode(', ', $typeParameters) . '>',
),
};
return new MapType($keyType, $valueType, $nonEmpty);
diff --git a/src/StringType.php b/src/StringType.php
index a530a5c..cc09eba 100644
--- a/src/StringType.php
+++ b/src/StringType.php
@@ -23,14 +23,6 @@ public static function numeric(): AbstractType
return new StringType(true, true);
}
- public function __toString(): string
- {
- if ($this->numeric) {
- return 'numeric-string';
- }
- return $this->nonEmpty ? 'non-empty-string' : 'string';
- }
-
public function toNode(): NodeInterface
{
if ($this->numeric) {
diff --git a/src/StructType.php b/src/StructType.php
index e2f9471..aa84d30 100644
--- a/src/StructType.php
+++ b/src/StructType.php
@@ -6,17 +6,37 @@
use PhpTypes\Ast\Node\NodeInterface;
use PhpTypes\Ast\Node\StructNode;
+use PhpTypes\Types\Conversion\ToIterableInterface;
+use PhpTypes\Types\Conversion\ToMapInterface;
use PhpTypes\Types\Dto\StructMember;
-final class StructType extends AbstractType
+final class StructType extends AbstractType implements ToIterableInterface, ToMapInterface
{
/**
- * @param array $members
+ * @param non-empty-array $members
*/
public function __construct(public readonly array $members)
{
}
+ /**
+ * @param non-empty-array $structs
+ */
+ public static function merge(iterable $structs): self
+ {
+ $members = [];
+ foreach ($structs as $struct) {
+ foreach ($struct->members as $name => $member) {
+ if (!isset($members[$name])) {
+ $members[$name] = $member;
+ continue;
+ }
+ $members[$name] = $members[$name]->intersect($member);
+ }
+ }
+ return new self($members);
+ }
+
public function toNode(): NodeInterface
{
$members = [];
@@ -25,4 +45,34 @@ public function toNode(): NodeInterface
}
return new StructNode($members);
}
+
+ public function toMap(): MapType
+ {
+ $types = $this->keyAndValueType();
+ return MapType::nonEmpty($types[0], $types[1]);
+ }
+
+ public function toIterable(): IterableType
+ {
+ $types = $this->keyAndValueType();
+ return new IterableType($types[0], $types[1]);
+ }
+
+ /**
+ * @return array{AbstractType, AbstractType}
+ */
+ private function keyAndValueType(): array
+ {
+ /** @var array{AbstractType, AbstractType}|null $types */
+ $types = null;
+ foreach ($this->members as $name => $member) {
+ if ($types === null) {
+ $types = [new StringLiteralType($name), $member->type];
+ continue;
+ }
+ $types[0] = new UnionType($types[0], new StringLiteralType($name));
+ $types[1] = new UnionType($types[1], $member->type);
+ }
+ return $types;
+ }
}
diff --git a/src/TupleType.php b/src/TupleType.php
index cecca99..118e9c0 100644
--- a/src/TupleType.php
+++ b/src/TupleType.php
@@ -6,8 +6,10 @@
use PhpTypes\Ast\Node\NodeInterface;
use PhpTypes\Ast\Node\TupleNode;
+use PhpTypes\Types\Conversion\ToIterableInterface;
+use PhpTypes\Types\Conversion\ToMapInterface;
-final class TupleType extends AbstractType
+final class TupleType extends AbstractType implements ToIterableInterface, ToMapInterface
{
/**
* @param list $elements
@@ -24,4 +26,31 @@ public function toNode(): NodeInterface
}
return new TupleNode($elementNodes);
}
+
+ public function toMap(): MapType
+ {
+ return new MapType(new IntType(), $this->valueType());
+ }
+
+ public function toIterable(): IterableType
+ {
+ return new IterableType(new IntType(), $this->valueType());
+ }
+
+ private function valueType(): AbstractType
+ {
+ $valueType = null;
+ foreach ($this->elements as $element) {
+ if ($valueType === null) {
+ $valueType = $element;
+ continue;
+ }
+ // @infection-ignore-all This isn't really required, it's just an optimization.
+ if (Compatibility::check($valueType, $element)) {
+ continue;
+ }
+ $valueType = new UnionType($valueType, $element);
+ }
+ return $valueType ?? new NeverType();
+ }
}
diff --git a/src/Type.php b/src/Type.php
index 5d03eef..727e228 100644
--- a/src/Type.php
+++ b/src/Type.php
@@ -20,6 +20,8 @@
use RuntimeException;
use function count;
+use function implode;
+use function sprintf;
final class Type
{
@@ -44,6 +46,9 @@ private static function fromNode(NodeInterface $node, Scope $scope): AbstractTyp
$node instanceof StructNode => self::fromStruct($node->members, $scope),
$node instanceof TupleNode => self::fromTuple($node, $scope),
$node instanceof UnionNode => self::fromUnion($node, $scope),
+ default => throw new RuntimeException(
+ sprintf('Unsupported node type: %s (%s)', get_class($node), $node)
+ ),
};
}
@@ -61,10 +66,18 @@ private static function fromIdentifier(IdentifierNode $node, Scope $scope): Abst
private static function fromUnion(UnionNode $node, Scope $scope): AbstractType
{
- return new UnionType(
- self::fromNode($node->left, $scope),
- self::fromNode($node->right, $scope),
- );
+ $left = self::fromNode($node->left, $scope);
+ $right = self::fromNode($node->right, $scope);
+ if (Compatibility::check($left, $right)) {
+ return $left;
+ }
+ if (Compatibility::check($right, $left)) {
+ return $right;
+ }
+ if ($left instanceof BoolType && $right instanceof BoolType) {
+ return new BoolType($left->value === $right->value ? $left->value : null);
+ }
+ return new UnionType($left, $right);
}
private static function fromTuple(TupleNode $node, Scope $scope): TupleType
@@ -77,10 +90,13 @@ private static function fromTuple(TupleNode $node, Scope $scope): TupleType
}
/**
- * @param list $members
+ * @param array $members
*/
- private static function fromStruct(array $members, Scope $scope): StructType
+ private static function fromStruct(array $members, Scope $scope): StructType|TupleType
{
+ if (count($members) === 0) {
+ return new TupleType([]);
+ }
$typeMembers = [];
foreach ($members as $name => $member) {
$typeMembers[$name] = $member->optional
@@ -106,7 +122,7 @@ private static function fromCallable(CallableNode $node, Scope $scope): Callable
private static function fromIntersection(IntersectionNode $node, Scope $scope): AbstractType
{
- return new IntersectionType(
+ return IntersectionType::create(
self::fromNode($node->left, $scope),
self::fromNode($node->right, $scope),
);
@@ -119,7 +135,13 @@ private static function fromInt(IdentifierNode $node): IntType
return new IntType();
}
if ($numberOfParams !== 2) {
- throw new RuntimeException('Invalid number of type parameters');
+ throw new RuntimeException(
+ sprintf(
+ 'The int type takes exactly zero or two type parameters, %d (%s) given',
+ $numberOfParams,
+ implode(', ', $node->typeParameters),
+ ),
+ );
}
$min = (static function () use ($node) {
if ($node->typeParameters[0] instanceof IdentifierNode && $node->typeParameters[0]->name === 'min') {
@@ -128,7 +150,12 @@ private static function fromInt(IdentifierNode $node): IntType
if ($node->typeParameters[0] instanceof IntLiteralNode) {
return $node->typeParameters[0]->value;
}
- throw new RuntimeException('Invalid int type');
+ throw new RuntimeException(
+ sprintf(
+ "Invalid minimum value for int type: %s. Must be an integer or \"min\".",
+ $node->typeParameters[0],
+ )
+ );
})();
$max = (static function () use ($node) {
if ($node->typeParameters[1] instanceof IdentifierNode && $node->typeParameters[1]->name === 'max') {
@@ -137,7 +164,12 @@ private static function fromInt(IdentifierNode $node): IntType
if ($node->typeParameters[1] instanceof IntLiteralNode) {
return $node->typeParameters[1]->value;
}
- throw new RuntimeException('Invalid int type');
+ throw new RuntimeException(
+ sprintf(
+ "Invalid maximum value for int type: %s. Must be an integer or \"max\".",
+ $node->typeParameters[1],
+ )
+ );
})();
return new IntType($min, $max);
}
diff --git a/tests/functional/CompatibilityTest.php b/tests/functional/CompatibilityTest.php
new file mode 100644
index 0000000..75fe69c
--- /dev/null
+++ b/tests/functional/CompatibilityTest.php
@@ -0,0 +1,246 @@
+ */
+ private static array $cache = [];
+
+ private static Scope $scope;
+
+ public static function setUpBeforeClass(): void
+ {
+ parent::setUpBeforeClass();
+
+ self::$scope = Scope::global();
+ $fooInterface = new ClassLikeType('FooInterface');
+ self::$scope->register('FooInterface', $fooInterface);
+ self::$scope->register('Foo', new ClassLikeType('Foo', parents: [$fooInterface]));
+ $runnable = new ClassLikeType('Runnable');
+ self::$scope->register('Runnable', $runnable);
+ $loggable = new ClassLikeType('Loggable');
+ self::$scope->register('Loggable', $loggable);
+ $runnableAndLoggable = new ClassLikeType('RunnableAndLoggable', parents: [$runnable, $loggable]);
+ self::$scope->register('RunnableAndLoggable', $runnableAndLoggable);
+ }
+
+ /**
+ * @return list
+ */
+ private static function compatibleTypes(): array
+ {
+ $types = [];
+ foreach (self::filesInDirectory(__DIR__ . '/compatible-types/') as $file) {
+ foreach (explode("\n", \Safe\file_get_contents($file)) as $line) {
+ $isMatch = \Safe\preg_match('/- `(?.+)` is a subtype of `(?.+)`/', $line, $matches);
+ if ($isMatch === 0) {
+ continue;
+ }
+ $types[] = [$matches['super'], $matches['sub']];
+ }
+ }
+ return $types;
+ }
+
+ /**
+ * @return iterable
+ */
+ private static function filesInDirectory(string $directory): iterable
+ {
+ foreach (new DirectoryIterator($directory) as $file) {
+ if ($file->isDot()) {
+ continue;
+ }
+
+ yield $file->getPathname();
+ }
+ }
+
+ /**
+ * @return iterable
+ */
+ private static function types(): iterable
+ {
+ foreach (self::typeFiles() as $file) {
+ foreach (explode("\n", \Safe\file_get_contents($file)) as $line) {
+ if ($line === '') {
+ continue;
+ }
+ yield $line;
+ }
+ }
+ }
+
+ /**
+ * @return iterable
+ */
+ private static function typeFiles(): iterable
+ {
+ return self::filesInDirectory(__DIR__ . '/types/');
+ }
+
+ /**
+ * @return list
+ */
+ private static function aliases(): array
+ {
+ $aliases = [];
+ foreach (explode("\n", \Safe\file_get_contents(__DIR__ . '/aliases.md')) as $line) {
+ $isMatch = \Safe\preg_match('/^- `(?.+)` is an alias of `(?.+)`/', $line, $matches);
+ if ($isMatch === 0) {
+ continue;
+ }
+ $tuple = [$matches['a'], $matches['b']];
+ sort($tuple);
+ $aliases[] = $tuple;
+ }
+ return $aliases;
+ }
+
+ private static function fromString(string $typeString, Scope $scope): AbstractType
+ {
+ $type = self::$cache[$typeString] ?? null;
+ if ($type === null) {
+ $type = Type::fromString($typeString, $scope);
+ self::$cache[$typeString] = $type;
+ }
+ return $type;
+ }
+
+ /**
+ * @dataProvider compatibilityCases
+ */
+ public function testCompatibility(string $super, string $sub, bool $expected): void
+ {
+ $superType = self::fromString($super, self::$scope);
+ $subType = self::fromString($sub, self::$scope);
+
+ $message = $expected
+ ? sprintf('Expected "%s" to be a subtype of "%s", but it is not', $sub, $super)
+ : sprintf('Expected "%s" not to be a subtype of "%s", but it is', $sub, $super);
+ self::assertSame($expected, Compatibility::check($superType, $subType), $message);
+ }
+
+ /**
+ * @return iterable
+ */
+ public function compatibilityCases(): iterable
+ {
+ $compatibleTypes = self::compatibleTypes();
+ foreach (self::types() as $super) {
+ foreach (self::types() as $sub) {
+ $compatibleTypesKey = array_search([$super, $sub], $compatibleTypes, true);
+ $expected = $compatibleTypesKey !== false;
+ $name = $expected
+ ? sprintf('%s is a subtype of %s', $sub, $super)
+ : sprintf('%s is not a subtype of %s', $sub, $super);
+ yield $name => [$super, $sub, $expected];
+ if ($compatibleTypesKey === false) {
+ continue;
+ }
+ unset($compatibleTypes[$compatibleTypesKey]);
+ }
+ }
+ if ($compatibleTypes === []) {
+ return;
+ }
+ throw new LogicException(
+ sprintf(
+ "There are %s unchecked compatibility declarations:\n%s",
+ count($compatibleTypes),
+ implode(
+ "\n",
+ array_map(static function (array $compatibleType): string {
+ return sprintf('- `%s` is a subtype of `%s`', $compatibleType[1], $compatibleType[0]);
+ }, $compatibleTypes),
+ ),
+ )
+ );
+ }
+
+ /**
+ * @dataProvider allTypes
+ */
+ public function testEveryTypeIsCompatibleWithMixed(string $type): void
+ {
+ self::assertTrue(
+ Compatibility::check(new MixedType(), self::fromString($type, self::$scope)),
+ sprintf('Expected "%s" to be a subtype of "mixed", but it is not', $type),
+ );
+ }
+
+ /**
+ * @return iterable
+ */
+ public function allTypes(): iterable
+ {
+ foreach (self::types() as $type) {
+ yield $type => [$type];
+ }
+ }
+
+ /**
+ * @dataProvider aliasCases
+ */
+ public function testAliases(string $a, string $b, bool $expected): void
+ {
+ $aType = self::fromString($a, self::$scope);
+ $bType = self::fromString($b, self::$scope);
+
+ $aContainsB = Compatibility::check($aType, $bType);
+ $bContainsA = Compatibility::check($bType, $aType);
+ $isAlias = $aContainsB && $bContainsA;
+
+ $message = $expected
+ ? sprintf('Expected "%s" to be an alias of "%s", but it is not', $b, $a)
+ : sprintf('Expected "%s" not to be an alias of "%s", but it is', $b, $a);
+ self::assertSame($expected, $isAlias, $message);
+ }
+
+ /**
+ * @return iterable
+ */
+ public function aliasCases(): iterable
+ {
+ $aliases = self::aliases();
+ $seen = [];
+ foreach (self::types() as $a) {
+ foreach (self::types() as $b) {
+ if ($a === $b) {
+ continue;
+ }
+ $tuple = [$a, $b];
+ sort($tuple);
+ if (in_array($tuple, $seen, true)) {
+ continue;
+ }
+ $expected = array_search($tuple, $aliases, true) !== false;
+ $name = $expected
+ ? sprintf('%s is an alias of %s', $b, $a)
+ : sprintf('%s is not an alias of %s', $b, $a);
+ yield $name => [$a, $b, $expected];
+ $seen[] = $tuple;
+ }
+ }
+ }
+}
diff --git a/tests/functional/InvalidTypesTest.php b/tests/functional/InvalidTypesTest.php
new file mode 100644
index 0000000..89f65d6
--- /dev/null
+++ b/tests/functional/InvalidTypesTest.php
@@ -0,0 +1,83 @@
+register('Foo', new ClassLikeType('Foo'));
+ $scope->register('Bar', new ClassLikeType('Bar'));
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage($expectedMessage);
+
+ Type::fromString($typeString, $scope);
+ }
+
+ /**
+ * @return iterable
+ */
+ public function invalidTypes(): iterable
+ {
+ yield 'Boolean array key' => [
+ 'array',
+ 'Can\'t use bool as array key. Only strings and integers are allowed.',
+ ];
+ yield 'Iterable with three type parameters' => [
+ 'iterable',
+ 'Iterable types must take one of the following forms: ' .
+ 'iterable, iterable, iterable',
+ ];
+ yield 'Class string with two type parameters' => [
+ 'class-string',
+ 'class-string takes zero or one type parameters',
+ ];
+ yield 'List with two type parameters' => [
+ 'list',
+ 'The list type takes exactly one type parameter, 2 (string, int) given',
+ ];
+ yield 'Unknown identifier' => [
+ 'my-imaginary-type',
+ 'Unknown type my-imaginary-type',
+ ];
+ yield 'Unknown identifier with type parameters' => [
+ 'my-imaginary-type',
+ 'Unknown type my-imaginary-type',
+ ];
+ yield 'Map with three type parameters' => [
+ 'array',
+ 'Array types must take one of the following forms: '
+ . 'array, array, array. '
+ . 'Got array',
+ ];
+ yield 'Int with invalid minimum' => [
+ 'int',
+ 'Invalid minimum value for int type: Foo. Must be an integer or "min".',
+ ];
+ yield 'Int with invalid maximum' => [
+ 'int<10, Foo>',
+ 'Invalid maximum value for int type: Foo. Must be an integer or "max".',
+ ];
+ yield 'Int with a single type parameter' => [
+ 'int<10>',
+ 'The int type takes exactly zero or two type parameters, 1 (10) given',
+ ];
+ yield 'Int with three type parameters' => [
+ 'int<10, 20, 30>',
+ 'The int type takes exactly zero or two type parameters, 3 (10, 20, 30) given',
+ ];
+ }
+}
diff --git a/tests/functional/aliases.md b/tests/functional/aliases.md
new file mode 100644
index 0000000..3130e40
--- /dev/null
+++ b/tests/functional/aliases.md
@@ -0,0 +1,37 @@
+- `array` is an alias of `array`
+- `array` is an alias of `array`
+- `array` is an alias of `array`
+- `array` is an alias of `array`
+- `array{name: string}` is an alias of `array{name: string} & array{name?: string}`
+- `array{name: string, age: int}` is an alias of `array{name: string} & array{age: int}`
+- `array{name: string, age?: int}` is an alias of `array{age?: int, name: string}`
+- `int<23, 23>` is an alias of `23`
+- `int<1, max>` is an alias of `positive-int`
+- `int` is an alias of `negative-int`
+- `iterable` is an alias of `iterable`
+- `iterable` is an alias of `iterable`
+- `iterable` is an alias of `iterable`
+- `iterable` is an alias of `iterable`
+- `string | int` is an alias of `int | string`
+- `true | false` is an alias of `bool`
+- `false | true` is an alias of `bool`
+- `false | true` is an alias of `true | false`
+- `bool | true` is an alias of `bool`
+- `bool | true` is an alias of `true | false`
+- `bool | true` is an alias of `false | true`
+- `string | 'foo'` is an alias of `string`
+- `string | list` is an alias of `list | string`
+
+- `array{age?: int, name: string}` is an alias of `array{name: string} & array{name?: string}`
+- `array{name: string, age?: int}` is an alias of `array{name: string} & array{name?: string}`
+- `array{name: string}` is an alias of `array{age?: int, name: string}`
+- `array{name: string, age?: int}` is an alias of `array{name: string}`
+
+- `array{}` is an alias of `array`
+- `array{}` is an alias of `array`
+- `array{}` is an alias of `array`
+- `array{}` is an alias of `list`
+- `array{}` is an alias of `list`
+- `array{}` is an alias of `list`
+- `array{}` is an alias of `list`
+- `array{}` is an alias of `list | list`
diff --git a/tests/functional/compatible-types/bool.md b/tests/functional/compatible-types/bool.md
new file mode 100644
index 0000000..4ad68cd
--- /dev/null
+++ b/tests/functional/compatible-types/bool.md
@@ -0,0 +1,23 @@
+- `bool` is a subtype of `bool`
+- `bool` is a subtype of `bool | true`
+- `bool` is a subtype of `false | true`
+- `bool` is a subtype of `scalar`
+- `bool` is a subtype of `string | int | bool`
+- `bool` is a subtype of `true | false`
+
+- `false` is a subtype of `bool`
+- `false` is a subtype of `bool | true`
+- `false` is a subtype of `false`
+- `false` is a subtype of `false | list`
+- `false` is a subtype of `false | true`
+- `false` is a subtype of `scalar`
+- `false` is a subtype of `string | int | bool`
+- `false` is a subtype of `true | false`
+
+- `true` is a subtype of `bool`
+- `true` is a subtype of `bool | true`
+- `true` is a subtype of `false | true`
+- `true` is a subtype of `true`
+- `true` is a subtype of `scalar`
+- `true` is a subtype of `string | int | bool`
+- `true` is a subtype of `true | false`
diff --git a/tests/functional/compatible-types/callable.md b/tests/functional/compatible-types/callable.md
new file mode 100644
index 0000000..8e96806
--- /dev/null
+++ b/tests/functional/compatible-types/callable.md
@@ -0,0 +1,27 @@
+- `callable(): string` is a subtype of `callable(): string`
+- `callable(): string` is a subtype of `callable(): void`
+
+- `callable(): void` is a subtype of `callable(): void`
+
+- `callable(string): float` is a subtype of `callable(string): float`
+
+- `callable(string): int` is a subtype of `callable(string): float`
+- `callable(string): int` is a subtype of `callable(string): int`
+- `callable(string): int` is a subtype of `callable(string, bool): int`
+- `callable(string): int` is a subtype of `callable(string, int): int`
+
+- `callable(string=): int` is a subtype of `callable(string): float`
+- `callable(string=): int` is a subtype of `callable(string): int`
+- `callable(string=): int` is a subtype of `callable(string=): int`
+- `callable(string=): int` is a subtype of `callable(string, bool): int`
+- `callable(string=): int` is a subtype of `callable(string, int): int`
+
+- `callable(string, bool): int` is a subtype of `callable(string, bool): int`
+
+- `callable(string, int): int` is a subtype of `callable(string, int): int`
+
+- `callable(string | int): int` is a subtype of `callable(string): float`
+- `callable(string | int): int` is a subtype of `callable(string): int`
+- `callable(string | int): int` is a subtype of `callable(string, bool): int`
+- `callable(string | int): int` is a subtype of `callable(string, int): int`
+- `callable(string | int): int` is a subtype of `callable(string | int): int`
diff --git a/tests/functional/compatible-types/class-like.md b/tests/functional/compatible-types/class-like.md
new file mode 100644
index 0000000..ebdb95c
--- /dev/null
+++ b/tests/functional/compatible-types/class-like.md
@@ -0,0 +1,2 @@
+- `RunnableAndLoggable` is a subtype of `RunnableAndLoggable`
+- `RunnableAndLoggable` is a subtype of `Runnable & Loggable`
diff --git a/tests/functional/compatible-types/int-literal.md b/tests/functional/compatible-types/int-literal.md
new file mode 100644
index 0000000..ae6ed4f
--- /dev/null
+++ b/tests/functional/compatible-types/int-literal.md
@@ -0,0 +1,60 @@
+- `-42` is a subtype of `-42`
+- `-42` is a subtype of `float`
+- `-42` is a subtype of `int`
+- `-42` is a subtype of `int | string`
+- `-42` is a subtype of `int<-123, 321>`
+- `-42` is a subtype of `int`
+- `-42` is a subtype of `int`
+- `-42` is a subtype of `negative-int`
+- `-42` is a subtype of `scalar`
+- `-42` is a subtype of `string | int`
+- `-42` is a subtype of `string | int | bool`
+
+- `-1` is a subtype of `-1`
+- `-1` is a subtype of `float`
+- `-1` is a subtype of `int`
+- `-1` is a subtype of `int | string`
+- `-1` is a subtype of `int<-123, 321>`
+- `-1` is a subtype of `int`
+- `-1` is a subtype of `int`
+- `-1` is a subtype of `negative-int`
+- `-1` is a subtype of `scalar`
+- `-1` is a subtype of `string | int`
+- `-1` is a subtype of `string | int | bool`
+
+- `0` is a subtype of `0`
+- `0` is a subtype of `float`
+- `0` is a subtype of `int`
+- `0` is a subtype of `int | string`
+- `0` is a subtype of `int<-123, 321>`
+- `0` is a subtype of `int`
+- `0` is a subtype of `scalar`
+- `0` is a subtype of `string | int`
+- `0` is a subtype of `string | int | bool`
+
+- `1` is a subtype of `1`
+- `1` is a subtype of `float`
+- `1` is a subtype of `int`
+- `1` is a subtype of `int | string`
+- `1` is a subtype of `int<-123, 321>`
+- `1` is a subtype of `int<1, max>`
+- `1` is a subtype of `int`
+- `1` is a subtype of `positive-int`
+- `1` is a subtype of `scalar`
+- `1` is a subtype of `string | int`
+- `1` is a subtype of `string | int | bool`
+
+- `23` is a subtype of `23`
+- `23` is a subtype of `float`
+- `23` is a subtype of `int`
+- `23` is a subtype of `int | string`
+- `23` is a subtype of `int<-123, 321>`
+- `23` is a subtype of `int<1, max>`
+- `23` is a subtype of `int<23, 23>`
+- `23` is a subtype of `int<23, max>`
+- `23` is a subtype of `int<23, 42>`
+- `23` is a subtype of `int`
+- `23` is a subtype of `positive-int`
+- `23` is a subtype of `scalar`
+- `23` is a subtype of `string | int`
+- `23` is a subtype of `string | int | bool`
diff --git a/tests/functional/compatible-types/int.md b/tests/functional/compatible-types/int.md
new file mode 100644
index 0000000..1e0613a
--- /dev/null
+++ b/tests/functional/compatible-types/int.md
@@ -0,0 +1,98 @@
+- `int` is a subtype of `float`
+- `int` is a subtype of `int`
+- `int` is a subtype of `int | string`
+- `int` is a subtype of `scalar`
+- `int` is a subtype of `string | int`
+- `int` is a subtype of `string | int | bool`
+
+- `int<-123, 321>` is a subtype of `float`
+- `int<-123, 321>` is a subtype of `int`
+- `int<-123, 321>` is a subtype of `int | string`
+- `int<-123, 321>` is a subtype of `int<-123, 321>`
+- `int<-123, 321>` is a subtype of `scalar`
+- `int<-123, 321>` is a subtype of `string | int`
+- `int<-123, 321>` is a subtype of `string | int | bool`
+
+- `int<1, max>` is a subtype of `float`
+- `int<1, max>` is a subtype of `int`
+- `int<1, max>` is a subtype of `int | string`
+- `int<1, max>` is a subtype of `int<1, max>`
+- `int<1, max>` is a subtype of `positive-int`
+- `int<1, max>` is a subtype of `scalar`
+- `int<1, max>` is a subtype of `string | int`
+- `int<1, max>` is a subtype of `string | int | bool`
+
+- `int<23, 23>` is a subtype of `23`
+- `int<23, 23>` is a subtype of `float`
+- `int<23, 23>` is a subtype of `int`
+- `int<23, 23>` is a subtype of `int | string`
+- `int<23, 23>` is a subtype of `int<-123, 321>`
+- `int<23, 23>` is a subtype of `int<1, max>`
+- `int<23, 23>` is a subtype of `int<23, 23>`
+- `int<23, 23>` is a subtype of `int<23, 42>`
+- `int<23, 23>` is a subtype of `int<23, max>`
+- `int<23, 23>` is a subtype of `int`
+- `int<23, 23>` is a subtype of `positive-int`
+- `int<23, 23>` is a subtype of `scalar`
+- `int<23, 23>` is a subtype of `string | int`
+- `int<23, 23>` is a subtype of `string | int | bool`
+
+- `int<23, 42>` is a subtype of `float`
+- `int<23, 42>` is a subtype of `int`
+- `int<23, 42>` is a subtype of `int | string`
+- `int<23, 42>` is a subtype of `int<-123, 321>`
+- `int<23, 42>` is a subtype of `int<1, max>`
+- `int<23, 42>` is a subtype of `int<23, 42>`
+- `int<23, 42>` is a subtype of `int<23, max>`
+- `int<23, 42>` is a subtype of `int`
+- `int<23, 42>` is a subtype of `positive-int`
+- `int<23, 42>` is a subtype of `scalar`
+- `int<23, 42>` is a subtype of `string | int`
+- `int<23, 42>` is a subtype of `string | int | bool`
+
+- `int<23, max>` is a subtype of `float`
+- `int<23, max>` is a subtype of `int`
+- `int<23, max>` is a subtype of `int | string`
+- `int<23, max>` is a subtype of `int<1, max>`
+- `int<23, max>` is a subtype of `int<23, max>`
+- `int<23, max>` is a subtype of `positive-int`
+- `int<23, max>` is a subtype of `scalar`
+- `int<23, max>` is a subtype of `string | int`
+- `int<23, max>` is a subtype of `string | int | bool`
+
+- `int` is a subtype of `float`
+- `int` is a subtype of `int`
+- `int` is a subtype of `int | string`
+- `int` is a subtype of `int`
+- `int` is a subtype of `int`
+- `int` is a subtype of `negative-int`
+- `int` is a subtype of `scalar`
+- `int` is a subtype of `string | int`
+- `int` is a subtype of `string | int | bool`
+
+- `int` is a subtype of `float`
+- `int` is a subtype of `int`
+- `int` is a subtype of `int | string`
+- `int` is a subtype of `int`
+- `int` is a subtype of `scalar`
+- `int` is a subtype of `string | int`
+- `int` is a subtype of `string | int | bool`
+
+- `negative-int` is a subtype of `float`
+- `negative-int` is a subtype of `int`
+- `negative-int` is a subtype of `int | string`
+- `negative-int` is a subtype of `int`
+- `negative-int` is a subtype of `int`
+- `negative-int` is a subtype of `negative-int`
+- `negative-int` is a subtype of `scalar`
+- `negative-int` is a subtype of `string | int`
+- `negative-int` is a subtype of `string | int | bool`
+
+- `positive-int` is a subtype of `float`
+- `positive-int` is a subtype of `int`
+- `positive-int` is a subtype of `int | string`
+- `positive-int` is a subtype of `int<1, max>`
+- `positive-int` is a subtype of `positive-int`
+- `positive-int` is a subtype of `scalar`
+- `positive-int` is a subtype of `string | int`
+- `positive-int` is a subtype of `string | int | bool`
diff --git a/tests/functional/compatible-types/intersection.md b/tests/functional/compatible-types/intersection.md
new file mode 100644
index 0000000..decf5a8
--- /dev/null
+++ b/tests/functional/compatible-types/intersection.md
@@ -0,0 +1,30 @@
+- `array{name: string} & array{age: int}` is a subtype of `array`
+- `array{name: string} & array{age: int}` is a subtype of `array`
+- `array{name: string} & array{age: int}` is a subtype of `array`
+- `array{name: string} & array{age: int}` is a subtype of `array<'name' | 'age', string | int>`
+- `array{name: string} & array{age: int}` is a subtype of `array{}`
+- `array{name: string} & array{age: int}` is a subtype of `array{age?: int, name: string}`
+- `array{name: string} & array{age: int}` is a subtype of `array{name: string, age: int}`
+- `array{name: string} & array{age: int}` is a subtype of `array{name: string, age?: int}`
+- `array{name: string} & array{age: int}` is a subtype of `array{name: string}`
+- `array{name: string} & array{age: int}` is a subtype of `array{name: string} & array{age: int}`
+- `array{name: string} & array{age: int}` is a subtype of `array{name: string} & array{name?: string}`
+- `array{name: string} & array{age: int}` is a subtype of `array`
+- `array{name: string} & array{age: int}` is a subtype of `iterable`
+- `array{name: string} & array{age: int}` is a subtype of `iterable`
+- `array{name: string} & array{age: int}` is a subtype of `iterable`
+
+- `array{name: string} & array{name?: string}` is a subtype of `array`
+- `array{name: string} & array{name?: string}` is a subtype of `array`
+- `array{name: string} & array{name?: string}` is a subtype of `array`
+- `array{name: string} & array{name?: string}` is a subtype of `array<'name' | 'age', string | int>`
+- `array{name: string} & array{name?: string}` is a subtype of `array{}`
+- `array{name: string} & array{name?: string}` is a subtype of `array{age?: int, name: string}`
+- `array{name: string} & array{name?: string}` is a subtype of `array{name: string, age?: int}`
+- `array{name: string} & array{name?: string}` is a subtype of `array{name: string}`
+- `array{name: string} & array{name?: string}` is a subtype of `array{name: string} & array{name?: string}`
+- `array{name: string} & array{name?: string}` is a subtype of `array`
+- `array{name: string} & array{name?: string}` is a subtype of `iterable`
+- `array{name: string} & array{name?: string}` is a subtype of `iterable`
+- `array{name: string} & array{name?: string}` is a subtype of `iterable`
+- `array{name: string} & array{name?: string}` is a subtype of `iterable`
diff --git a/tests/functional/compatible-types/iterable.md b/tests/functional/compatible-types/iterable.md
new file mode 100644
index 0000000..f0943f3
--- /dev/null
+++ b/tests/functional/compatible-types/iterable.md
@@ -0,0 +1,52 @@
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
+- `iterable` is a subtype of `iterable`
diff --git a/tests/functional/compatible-types/list.md b/tests/functional/compatible-types/list.md
new file mode 100644
index 0000000..68f53b7
--- /dev/null
+++ b/tests/functional/compatible-types/list.md
@@ -0,0 +1,69 @@
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array{}`
+- `list` is a subtype of `iterable`
+- `list` is a subtype of `iterable`
+- `list` is a subtype of `iterable`
+- `list` is a subtype of `list`
+- `list` is a subtype of `list`
+- `list` is a subtype of `list`
+- `list` is a subtype of `list | list`
+
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array{}`
+- `list` is a subtype of `iterable`
+- `list` is a subtype of `iterable`
+- `list` is a subtype of `iterable`
+- `list` is a subtype of `list`
+
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array{}`
+- `list` is a subtype of `iterable`
+- `list` is a subtype of `iterable`
+- `list` is a subtype of `iterable`
+- `list` is a subtype of `list`
+
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array`
+- `list` is a subtype of `array{}`
+- `list` is a subtype of `false | list`
+- `list` is a subtype of `iterable`
+- `list` is a subtype of `iterable`
+- `list` is a subtype of `iterable