diff --git a/src/Target/Php/Transpiler/TypeReference/TypeReferenceTranspiler.php b/src/Target/Php/Transpiler/TypeReference/TypeReferenceTranspiler.php index af5a7326..d3bed4f8 100644 --- a/src/Target/Php/Transpiler/TypeReference/TypeReferenceTranspiler.php +++ b/src/Target/Php/Transpiler/TypeReference/TypeReferenceTranspiler.php @@ -31,6 +31,8 @@ use PackageFactory\ComponentEngine\TypeSystem\Type\SlotType\SlotType; use PackageFactory\ComponentEngine\TypeSystem\Type\StringType\StringType; use PackageFactory\ComponentEngine\TypeSystem\Type\StructType\StructType; +use PackageFactory\ComponentEngine\TypeSystem\Type\UnionType\UnionType; +use PackageFactory\ComponentEngine\TypeSystem\TypeInterface; final class TypeReferenceTranspiler { @@ -43,7 +45,26 @@ public function __construct( public function transpile(TypeReferenceNode $typeReferenceNode): string { $type = $this->scope->resolveTypeReference($typeReferenceNode); - $phpTypeReference = match ($type::class) { + + return match ($type::class) { + UnionType::class => $this->transpileUnionType($type, $typeReferenceNode), + default => $this->transpileNonUnionType($type, $typeReferenceNode) + }; + } + + private function transpileUnionType(UnionType $unionType, TypeReferenceNode $typeReferenceNode): string + { + if (count($unionType) === 2 && $unionType->containsNull()) { + $typeWithoutNull = $unionType->withoutNull(); + return $this->transpileNullableType($typeWithoutNull, $typeReferenceNode); + } + + throw new \Exception('@TODO Transpilation of complex union types is not implemented'); + } + + private function transpileNonUnionType(TypeInterface $type, TypeReferenceNode $typeReferenceNode): string + { + return match ($type::class) { NumberType::class => 'int|float', StringType::class => 'string', BooleanType::class => 'bool', @@ -51,14 +72,14 @@ public function transpile(TypeReferenceNode $typeReferenceNode): string ComponentType::class => $this->strategy->getPhpTypeReferenceForComponentType($type, $typeReferenceNode), EnumType::class => $this->strategy->getPhpTypeReferenceForEnumType($type, $typeReferenceNode), StructType::class => $this->strategy->getPhpTypeReferenceForStructType($type, $typeReferenceNode), + UnionType::class => throw new \Exception("There is no such thing as nested unions, think again."), default => $this->strategy->getPhpTypeReferenceForCustomType($type, $typeReferenceNode) }; + } - return $typeReferenceNode->isOptional - ? match ($phpTypeReference) { - 'int|float' => 'null|int|float', - default => '?' . $phpTypeReference - } - : $phpTypeReference; + private function transpileNullableType(TypeInterface $typeWithoutNull, TypeReferenceNode $typeReferenceNode): string + { + $phpTypeWithoutNull = $this->transpileNonUnionType($typeWithoutNull, $typeReferenceNode); + return (str_contains($phpTypeWithoutNull, '|') ? 'null|' : '?') . $phpTypeWithoutNull; } } diff --git a/src/TypeSystem/Narrower/Expression/ExpressionTypeNarrower.php b/src/TypeSystem/Narrower/Expression/ExpressionTypeNarrower.php new file mode 100644 index 00000000..5cac539f --- /dev/null +++ b/src/TypeSystem/Narrower/Expression/ExpressionTypeNarrower.php @@ -0,0 +1,151 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\ComponentEngine\TypeSystem\Narrower\Expression; + +use PackageFactory\ComponentEngine\Definition\BinaryOperator; +use PackageFactory\ComponentEngine\Parser\Ast\BinaryOperationNode; +use PackageFactory\ComponentEngine\Parser\Ast\BooleanLiteralNode; +use PackageFactory\ComponentEngine\Parser\Ast\ExpressionNode; +use PackageFactory\ComponentEngine\Parser\Ast\IdentifierNode; +use PackageFactory\ComponentEngine\Parser\Ast\UnaryOperationNode; +use PackageFactory\ComponentEngine\TypeSystem\Narrower\NarrowedTypes; +use PackageFactory\ComponentEngine\TypeSystem\Narrower\Truthiness; +use PackageFactory\ComponentEngine\TypeSystem\Resolver\Expression\ExpressionTypeResolver; +use PackageFactory\ComponentEngine\TypeSystem\ScopeInterface; +use PackageFactory\ComponentEngine\TypeSystem\Type\NullType\NullType; + +/** + * This class handles the analysis of identifier types that are used in a condition + * and based on the requested branch: truthy or falsy, will predict the types a variable will have in the respective branch + * so it matches the expected runtime behaviour + * + * For example given this expression: `nullableString ? nullableString : "fallback"` based on the condition `nullableString` + * it will infer that in the truthy context nullableString is a string while in the falsy context it will infer that it is null. + * In the above case the ternary expression will resolve to a string. + * + * The structure is partially inspired by phpstan + * https://github.com/phpstan/phpstan-src/blob/07bb4aa2d5e39dafa78f56c5df132c763c2d1b67/src/Analyser/TypeSpecifier.php#L111 + */ +class ExpressionTypeNarrower +{ + private function __construct( + private readonly ScopeInterface $scope, + private readonly Truthiness $assumedTruthiness + ) { + } + + public static function forTruthy(ScopeInterface $scope): self + { + return new self($scope, Truthiness::TRUTHY); + } + + public static function forFalsy(ScopeInterface $scope): self + { + return new self($scope, Truthiness::FALSY); + } + + public function narrowTypesOfSymbolsIn(ExpressionNode $expressionNode): NarrowedTypes + { + if ($expressionNode->root instanceof IdentifierNode) { + $type = $this->scope->lookupTypeFor($expressionNode->root->value); + if (!$type) { + return NarrowedTypes::empty(); + } + // case `nullableString ? "nullableString is not null" : "nullableString is null"` + return NarrowedTypes::fromEntry($expressionNode->root->value, $this->assumedTruthiness->narrowType($type)); + } + + if (($binaryOperationNode = $expressionNode->root) instanceof BinaryOperationNode) { + $right = $binaryOperationNode->right; + $left = $binaryOperationNode->left; + + if ( + (($boolean = $right->root) instanceof BooleanLiteralNode + && $other = $left // @phpstan-ignore-line + ) || (($boolean = $left->root) instanceof BooleanLiteralNode + && $other = $right // @phpstan-ignore-line + ) + ) { + switch ($binaryOperationNode->operator) { + case BinaryOperator::AND: + if ($boolean->value && $this->assumedTruthiness === Truthiness::TRUTHY) { + return $this->narrowTypesOfSymbolsIn($other); + } + break; + case BinaryOperator::EQUAL: + case BinaryOperator::NOT_EQUAL: + $contextBasedOnOperator = $this->assumedTruthiness->basedOnBinaryOperator($binaryOperationNode->operator); + assert($contextBasedOnOperator !== null); + + if ($other->root instanceof IdentifierNode) { + return NarrowedTypes::empty(); + } + + $subNarrower = new self( + $this->scope, + $boolean->value ? $contextBasedOnOperator : $contextBasedOnOperator->negate() + ); + return $subNarrower->narrowTypesOfSymbolsIn($other); + } + + return NarrowedTypes::empty(); + } + + $expressionTypeResolver = (new ExpressionTypeResolver($this->scope)); + if ( + ($expressionTypeResolver->resolveTypeOf($right)->is(NullType::get()) + && $other = $left // @phpstan-ignore-line + ) || ($expressionTypeResolver->resolveTypeOf($left)->is(NullType::get()) + && $other = $right // @phpstan-ignore-line + ) + ) { + if (!$other->root instanceof IdentifierNode) { + return NarrowedTypes::empty(); + } + $type = $this->scope->lookupTypeFor($other->root->value); + if (!$type) { + return NarrowedTypes::empty(); + } + + if (!$contextBasedOnOperator = $this->assumedTruthiness->basedOnBinaryOperator($binaryOperationNode->operator)) { + return NarrowedTypes::empty(); + } + + return NarrowedTypes::fromEntry( + $other->root->value, + $contextBasedOnOperator->negate()->narrowType($type) + ); + } + } + + if (($unaryOperationNode = $expressionNode->root) instanceof UnaryOperationNode) { + $subNarrower = new self( + $this->scope, + $this->assumedTruthiness->negate() + ); + return $subNarrower->narrowTypesOfSymbolsIn($unaryOperationNode->argument); + } + + return NarrowedTypes::empty(); + } +} diff --git a/src/TypeSystem/Narrower/NarrowedTypes.php b/src/TypeSystem/Narrower/NarrowedTypes.php new file mode 100644 index 00000000..8e2ff64c --- /dev/null +++ b/src/TypeSystem/Narrower/NarrowedTypes.php @@ -0,0 +1,62 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\ComponentEngine\TypeSystem\Narrower; + +use PackageFactory\ComponentEngine\TypeSystem\TypeInterface; + +class NarrowedTypes +{ + /** + * Map of identifierName to the corresponding inferred type + * @var array + */ + private readonly array $types; + + private function __construct( + TypeInterface ...$types + ) { + /** @var array $types */ + $this->types = $types; + } + + public static function empty(): self + { + return new self(); + } + + public static function fromEntry(string $identifierName, TypeInterface $type): self + { + return new self(...[$identifierName => $type]); + } + + public function getType(string $identifierName): ?TypeInterface + { + return $this->types[$identifierName] ?? null; + } + + /** @return array */ + public function toArray(): array + { + return $this->types; + } +} diff --git a/src/TypeSystem/Narrower/Truthiness.php b/src/TypeSystem/Narrower/Truthiness.php new file mode 100644 index 00000000..b839fea3 --- /dev/null +++ b/src/TypeSystem/Narrower/Truthiness.php @@ -0,0 +1,63 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\ComponentEngine\TypeSystem\Narrower; + +use PackageFactory\ComponentEngine\Definition\BinaryOperator; +use PackageFactory\ComponentEngine\TypeSystem\Type\NullType\NullType; +use PackageFactory\ComponentEngine\TypeSystem\Type\UnionType\UnionType; +use PackageFactory\ComponentEngine\TypeSystem\TypeInterface; + +enum Truthiness +{ + case TRUTHY; + + case FALSY; + + public function negate(): self + { + return match ($this) { + self::TRUTHY => self::FALSY, + self::FALSY => self::TRUTHY + }; + } + + public function basedOnBinaryOperator(BinaryOperator $operator): ?self + { + return match ($operator) { + BinaryOperator::EQUAL => $this, + BinaryOperator::NOT_EQUAL => $this->negate(), + default => null, + }; + } + + public function narrowType(TypeInterface $type): TypeInterface + { + if (!$type instanceof UnionType || !$type->containsNull()) { + return $type; + } + return match ($this) { + self::TRUTHY => $type->withoutNull(), + self::FALSY => NullType::get() + }; + } +} diff --git a/src/TypeSystem/Resolver/TernaryOperation/TernaryOperationTypeResolver.php b/src/TypeSystem/Resolver/TernaryOperation/TernaryOperationTypeResolver.php index c3e6d8ff..f2a662a0 100644 --- a/src/TypeSystem/Resolver/TernaryOperation/TernaryOperationTypeResolver.php +++ b/src/TypeSystem/Resolver/TernaryOperation/TernaryOperationTypeResolver.php @@ -25,6 +25,7 @@ use PackageFactory\ComponentEngine\Parser\Ast\BooleanLiteralNode; use PackageFactory\ComponentEngine\Parser\Ast\TernaryOperationNode; use PackageFactory\ComponentEngine\TypeSystem\Resolver\Expression\ExpressionTypeResolver; +use PackageFactory\ComponentEngine\TypeSystem\Scope\TernaryBranchScope\TernaryBranchScope; use PackageFactory\ComponentEngine\TypeSystem\ScopeInterface; use PackageFactory\ComponentEngine\TypeSystem\Type\UnionType\UnionType; use PackageFactory\ComponentEngine\TypeSystem\TypeInterface; @@ -38,20 +39,29 @@ public function __construct( public function resolveTypeOf(TernaryOperationNode $ternaryOperationNode): TypeInterface { - $expressionTypeResolver = new ExpressionTypeResolver( - scope: $this->scope + $trueExpressionTypeResolver = new ExpressionTypeResolver( + scope: TernaryBranchScope::forTruthyBranch( + $ternaryOperationNode->condition, + $this->scope + ) ); - $conditionNode = $ternaryOperationNode->condition->root; - if ($conditionNode instanceof BooleanLiteralNode) { - return $conditionNode->value - ? $expressionTypeResolver->resolveTypeOf($ternaryOperationNode->true) - : $expressionTypeResolver->resolveTypeOf($ternaryOperationNode->false); + $falseExpressionTypeResolver = new ExpressionTypeResolver( + scope: TernaryBranchScope::forFalsyBranch( + $ternaryOperationNode->condition, + $this->scope + ) + ); + + if ($ternaryOperationNode->condition->root instanceof BooleanLiteralNode) { + return $ternaryOperationNode->condition->root->value + ? $trueExpressionTypeResolver->resolveTypeOf($ternaryOperationNode->true) + : $falseExpressionTypeResolver->resolveTypeOf($ternaryOperationNode->false); } return UnionType::of( - $expressionTypeResolver->resolveTypeOf($ternaryOperationNode->true), - $expressionTypeResolver->resolveTypeOf($ternaryOperationNode->false) + $trueExpressionTypeResolver->resolveTypeOf($ternaryOperationNode->true), + $falseExpressionTypeResolver->resolveTypeOf($ternaryOperationNode->false) ); } } diff --git a/src/TypeSystem/Scope/GlobalScope/GlobalScope.php b/src/TypeSystem/Scope/GlobalScope/GlobalScope.php index a6ab1b87..ada0f5cb 100644 --- a/src/TypeSystem/Scope/GlobalScope/GlobalScope.php +++ b/src/TypeSystem/Scope/GlobalScope/GlobalScope.php @@ -22,13 +22,14 @@ namespace PackageFactory\ComponentEngine\TypeSystem\Scope\GlobalScope; -use PackageFactory\ComponentEngine\Parser\Ast\ComponentDeclarationNode; use PackageFactory\ComponentEngine\Parser\Ast\TypeReferenceNode; use PackageFactory\ComponentEngine\TypeSystem\ScopeInterface; use PackageFactory\ComponentEngine\TypeSystem\Type\BooleanType\BooleanType; +use PackageFactory\ComponentEngine\TypeSystem\Type\NullType\NullType; use PackageFactory\ComponentEngine\TypeSystem\Type\NumberType\NumberType; use PackageFactory\ComponentEngine\TypeSystem\Type\SlotType\SlotType; use PackageFactory\ComponentEngine\TypeSystem\Type\StringType\StringType; +use PackageFactory\ComponentEngine\TypeSystem\Type\UnionType\UnionType; use PackageFactory\ComponentEngine\TypeSystem\TypeInterface; final class GlobalScope implements ScopeInterface @@ -51,12 +52,16 @@ public function lookupTypeFor(string $name): ?TypeInterface public function resolveTypeReference(TypeReferenceNode $typeReferenceNode): TypeInterface { - return match ($typeReferenceNode->name) { + $type = match ($typeReferenceNode->name) { 'string' => StringType::get(), 'number' => NumberType::get(), 'boolean' => BooleanType::get(), 'slot' => SlotType::get(), default => throw new \Exception('@TODO: Unknown Type ' . $typeReferenceNode->name) }; + if ($typeReferenceNode->isOptional) { + $type = UnionType::of($type, NullType::get()); + } + return $type; } } diff --git a/src/TypeSystem/Scope/ModuleScope/ModuleScope.php b/src/TypeSystem/Scope/ModuleScope/ModuleScope.php index 034da99e..80748ea2 100644 --- a/src/TypeSystem/Scope/ModuleScope/ModuleScope.php +++ b/src/TypeSystem/Scope/ModuleScope/ModuleScope.php @@ -26,6 +26,8 @@ use PackageFactory\ComponentEngine\Parser\Ast\ModuleNode; use PackageFactory\ComponentEngine\Parser\Ast\TypeReferenceNode; use PackageFactory\ComponentEngine\TypeSystem\ScopeInterface; +use PackageFactory\ComponentEngine\TypeSystem\Type\NullType\NullType; +use PackageFactory\ComponentEngine\TypeSystem\Type\UnionType\UnionType; use PackageFactory\ComponentEngine\TypeSystem\TypeInterface; final class ModuleScope implements ScopeInterface @@ -45,7 +47,11 @@ public function lookupTypeFor(string $name): ?TypeInterface public function resolveTypeReference(TypeReferenceNode $typeReferenceNode): TypeInterface { if ($importNode = $this->moduleNode->imports->get($typeReferenceNode->name)) { - return $this->loader->resolveTypeOfImport($importNode); + $type = $this->loader->resolveTypeOfImport($importNode); + if ($typeReferenceNode->isOptional) { + $type = UnionType::of($type, NullType::get()); + } + return $type; } if ($this->parentScope) { diff --git a/src/TypeSystem/Scope/TernaryBranchScope/TernaryBranchScope.php b/src/TypeSystem/Scope/TernaryBranchScope/TernaryBranchScope.php new file mode 100644 index 00000000..c2092839 --- /dev/null +++ b/src/TypeSystem/Scope/TernaryBranchScope/TernaryBranchScope.php @@ -0,0 +1,65 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\ComponentEngine\TypeSystem\Scope\TernaryBranchScope; + +use PackageFactory\ComponentEngine\Parser\Ast\ExpressionNode; +use PackageFactory\ComponentEngine\Parser\Ast\TypeReferenceNode; +use PackageFactory\ComponentEngine\TypeSystem\Narrower\NarrowedTypes; +use PackageFactory\ComponentEngine\TypeSystem\Narrower\Expression\ExpressionTypeNarrower; +use PackageFactory\ComponentEngine\TypeSystem\ScopeInterface; +use PackageFactory\ComponentEngine\TypeSystem\TypeInterface; + +final class TernaryBranchScope implements ScopeInterface +{ + public function __construct( + private readonly NarrowedTypes $narrowedTypes, + private readonly ScopeInterface $parentScope + ) { + } + + public static function forTruthyBranch(ExpressionNode $conditionNode, ScopeInterface $parentScope): self + { + return new self( + ExpressionTypeNarrower::forTruthy($parentScope)->narrowTypesOfSymbolsIn($conditionNode), + $parentScope + ); + } + + public static function forFalsyBranch(ExpressionNode $conditionNode, ScopeInterface $parentScope): self + { + return new self( + ExpressionTypeNarrower::forFalsy($parentScope)->narrowTypesOfSymbolsIn($conditionNode), + $parentScope + ); + } + + public function lookupTypeFor(string $name): ?TypeInterface + { + return $this->narrowedTypes->getType($name) ?? $this->parentScope->lookupTypeFor($name); + } + + public function resolveTypeReference(TypeReferenceNode $typeReferenceNode): TypeInterface + { + return $this->parentScope->resolveTypeReference($typeReferenceNode); + } +} diff --git a/src/TypeSystem/Type/UnionType/UnionType.php b/src/TypeSystem/Type/UnionType/UnionType.php index c48ed99d..78371379 100644 --- a/src/TypeSystem/Type/UnionType/UnionType.php +++ b/src/TypeSystem/Type/UnionType/UnionType.php @@ -22,24 +22,29 @@ namespace PackageFactory\ComponentEngine\TypeSystem\Type\UnionType; +use PackageFactory\ComponentEngine\TypeSystem\Type\NullType\NullType; use PackageFactory\ComponentEngine\TypeSystem\TypeInterface; -final class UnionType implements TypeInterface +/** + * @implements \IteratorAggregate + */ +final class UnionType implements TypeInterface, \IteratorAggregate, \Countable { /** * @var TypeInterface[] */ - private array $members; + private readonly array $members; private function __construct(TypeInterface ...$members) { + assert(count($members) > 1, 'UnionType must hold at least two different members'); $this->members = $members; } - public static function of(TypeInterface ...$members): TypeInterface + public static function of(TypeInterface $firstMember, TypeInterface ...$members): TypeInterface { $uniqueMembers = []; - foreach ($members as $member) { + foreach ([$firstMember, ...$members] as $member) { foreach ($uniqueMembers as $uniqueMember) { if ($member->is($uniqueMember)) { continue 2; @@ -53,7 +58,29 @@ public static function of(TypeInterface ...$members): TypeInterface return $uniqueMembers[0]; } - return new self(...$members); + return new self(...$uniqueMembers); + } + + public function containsNull(): bool + { + foreach ($this->members as $member) { + if ($member->is(NullType::get())) { + return true; + } + } + return false; + } + + public function withoutNull(): TypeInterface + { + $nonNullMembers = []; + foreach ($this->members as $member) { + if ($member->is(NullType::get())) { + continue; + } + $nonNullMembers[] = $member; + } + return self::of(...$nonNullMembers); } public function is(TypeInterface $other): bool @@ -78,4 +105,21 @@ public function is(TypeInterface $other): bool return false; } } + + /** @return \Iterator */ + public function getIterator(): \Iterator + { + yield from $this->members; + } + + /** @return array */ + public function toArray(): array + { + return $this->members; + } + + public function count(): int + { + return count($this->members); + } } diff --git a/test/Unit/TypeSystem/Narrower/Expression/ExpressionTypeNarrowerTest.php b/test/Unit/TypeSystem/Narrower/Expression/ExpressionTypeNarrowerTest.php new file mode 100644 index 00000000..060eefa7 --- /dev/null +++ b/test/Unit/TypeSystem/Narrower/Expression/ExpressionTypeNarrowerTest.php @@ -0,0 +1,132 @@ +. + */ + +declare(strict_types=1); + +namespace PackageFactory\ComponentEngine\Test\Unit\TypeSystem\Narrower\Expression; + + +use PackageFactory\ComponentEngine\Parser\Ast\ExpressionNode; +use PackageFactory\ComponentEngine\Test\Unit\TypeSystem\Scope\Fixtures\DummyScope; +use PackageFactory\ComponentEngine\TypeSystem\Narrower\Expression\ExpressionTypeNarrower; +use PackageFactory\ComponentEngine\TypeSystem\Narrower\NarrowedTypes; +use PackageFactory\ComponentEngine\TypeSystem\Type\NullType\NullType; +use PackageFactory\ComponentEngine\TypeSystem\Type\StringType\StringType; +use PackageFactory\ComponentEngine\TypeSystem\Type\UnionType\UnionType; +use PHPUnit\Framework\TestCase; + +final class ExpressionTypeNarrowerTest extends TestCase +{ + public function narrowedExpressionsExamples(): mixed + { + return [ + 'nullableString' => [ + 'nullableString', + $variableIsString = NarrowedTypes::fromEntry('nullableString', StringType::get()) + ], + + 'nullableString === null' => [ + 'nullableString === null', + $variableIsNull = NarrowedTypes::fromEntry('nullableString', NullType::get()) + ], + // Patience you must have my young Padawan. + 'null === nullableString' => [ + 'null === nullableString', $variableIsNull + ], + + 'nullableString !== null' => [ + 'nullableString !== null', $variableIsString + ], + 'null !== nullableString' => [ + 'null !== nullableString', $variableIsString + ], + + 'true === (nullableString === null)' => [ + 'true === (nullableString === null)', $variableIsNull + ], + 'false !== (nullableString === null)' => [ + 'false !== (nullableString === null)', $variableIsNull + ], + + 'false === (nullableString === null)' => [ + 'false === (nullableString === null)', $variableIsString + ], + 'true !== (nullableString === null)' => [ + 'true !== (nullableString === null)', $variableIsString + ], + + 'nullableString === variableOfTypeNull' => [ + 'nullableString === variableOfTypeNull', $variableIsNull + ], + + 'nullableString && true' => [ + 'nullableString && true', + $variableIsString + ], + + 'FALSY:: nullableString && true' => [ + 'nullableString && true', + NarrowedTypes::empty(), + false + ], + + 'nullableString === true' => [ + 'nullableString === true', + NarrowedTypes::empty() + ], + + '!nullableString' => [ + '!nullableString', $variableIsNull + ], + + '!!nullableString' => [ + '!!nullableString', $variableIsString + ], + ]; + } + + /** + * @dataProvider narrowedExpressionsExamples + * @test + */ + public function narrowedExpressions( + string $expressionAsString, + NarrowedTypes $expectedTypes, + bool $truthiness = true + ): void { + $scope = new DummyScope([ + 'nullableString' => UnionType::of(StringType::get(), NullType::get()), + 'variableOfTypeNull' => NullType::get() + ]); + + $expressionTypeNarrower = $truthiness + ? ExpressionTypeNarrower::forTruthy($scope) + : ExpressionTypeNarrower::forFalsy($scope); + + $expressionNode = ExpressionNode::fromString($expressionAsString); + + $actualTypes = $expressionTypeNarrower->narrowTypesOfSymbolsIn($expressionNode); + + $this->assertEqualsCanonicalizing( + $expectedTypes->toArray(), + $actualTypes->toArray() + ); + } +} diff --git a/test/Unit/TypeSystem/Resolver/TernaryOperation/TernaryOperationTypeResolverTest.php b/test/Unit/TypeSystem/Resolver/TernaryOperation/TernaryOperationTypeResolverTest.php index 60845e64..430cdb26 100644 --- a/test/Unit/TypeSystem/Resolver/TernaryOperation/TernaryOperationTypeResolverTest.php +++ b/test/Unit/TypeSystem/Resolver/TernaryOperation/TernaryOperationTypeResolverTest.php @@ -26,6 +26,7 @@ use PackageFactory\ComponentEngine\Parser\Ast\TernaryOperationNode; use PackageFactory\ComponentEngine\Test\Unit\TypeSystem\Scope\Fixtures\DummyScope; use PackageFactory\ComponentEngine\TypeSystem\Resolver\TernaryOperation\TernaryOperationTypeResolver; +use PackageFactory\ComponentEngine\TypeSystem\Type\NullType\NullType; use PackageFactory\ComponentEngine\TypeSystem\Type\NumberType\NumberType; use PackageFactory\ComponentEngine\TypeSystem\Type\StringType\StringType; use PackageFactory\ComponentEngine\TypeSystem\Type\UnionType\UnionType; @@ -44,9 +45,24 @@ public function ternaryOperationExamples(): array 'false ? 42 : "foo"' => ['false ? 42 : "foo"', StringType::get()], '1 < 2 ? 42 : "foo"' => ['1 < 2 ? 42 : "foo"', UnionType::of(NumberType::get(), StringType::get())], '1 < 2 ? variableOfTypeString : variableOfTypeNumber' => [ - '1 < 2 ? variableOfTypeString : variableOfTypeNumber', + '1 < 2 ? variableOfTypeString : variableOfTypeNumber', UnionType::of(NumberType::get(), StringType::get()) - ] + ], + + 'nullableString ? nullableString : "fallback"' => [ + 'nullableString ? nullableString : "fallback"', StringType::get() + ], + 'nullableString ? null : nullableString' => [ + 'nullableString ? null : nullableString', NullType::get() + ], + + 'nullableString === null ? "" : nullableString' => [ + 'nullableString === null ? "" : nullableString', StringType::get() + ], + // Patience you must have my young Padawan. + 'null === nullableString ? "" : nullableString' => [ + 'null === nullableString ? "" : nullableString', StringType::get() + ], ]; } @@ -62,6 +78,7 @@ public function resolvesTernaryOperationToResultingType(string $ternaryExpressio $scope = new DummyScope([ 'variableOfTypeString' => StringType::get(), 'variableOfTypeNumber' => NumberType::get(), + 'nullableString' => UnionType::of(StringType::get(), NullType::get()) ]); $ternaryOperationTypeResolver = new TernaryOperationTypeResolver( scope: $scope diff --git a/test/Unit/TypeSystem/Scope/Fixtures/DummyScope.php b/test/Unit/TypeSystem/Scope/Fixtures/DummyScope.php index 954a1f3c..cb3440b9 100644 --- a/test/Unit/TypeSystem/Scope/Fixtures/DummyScope.php +++ b/test/Unit/TypeSystem/Scope/Fixtures/DummyScope.php @@ -24,6 +24,8 @@ use PackageFactory\ComponentEngine\Parser\Ast\TypeReferenceNode; use PackageFactory\ComponentEngine\TypeSystem\ScopeInterface; +use PackageFactory\ComponentEngine\TypeSystem\Type\NullType\NullType; +use PackageFactory\ComponentEngine\TypeSystem\Type\UnionType\UnionType; use PackageFactory\ComponentEngine\TypeSystem\TypeInterface; final class DummyScope implements ScopeInterface @@ -46,6 +48,9 @@ public function lookupTypeFor(string $name): ?TypeInterface public function resolveTypeReference(TypeReferenceNode $typeReferenceNode): TypeInterface { if ($type = $this->typeNameToTypeMap[$typeReferenceNode->name] ?? null) { + if ($typeReferenceNode->isOptional) { + $type = UnionType::of($type, NullType::get()); + } return $type; } diff --git a/test/Unit/TypeSystem/Type/UnionType/UnionTypeTest.php b/test/Unit/TypeSystem/Type/UnionType/UnionTypeTest.php index 105edf6a..eb54784b 100644 --- a/test/Unit/TypeSystem/Type/UnionType/UnionTypeTest.php +++ b/test/Unit/TypeSystem/Type/UnionType/UnionTypeTest.php @@ -22,6 +22,7 @@ namespace PackageFactory\ComponentEngine\Test\Unit\TypeSystem\Type\UnionType; +use PackageFactory\ComponentEngine\TypeSystem\Type\NullType\NullType; use PackageFactory\ComponentEngine\TypeSystem\Type\NumberType\NumberType; use PackageFactory\ComponentEngine\TypeSystem\Type\StringType\StringType; use PackageFactory\ComponentEngine\TypeSystem\Type\UnionType\UnionType; @@ -29,6 +30,16 @@ final class UnionTypeTest extends TestCase { + /** + * @test + */ + public function unionRequiresAtLeastOneMember(): void + { + $this->expectException(\TypeError::class); + /** @phpstan-ignore-next-line */ + UnionType::of(); + } + /** * @test */ @@ -87,6 +98,27 @@ public function isReturnsTrueIfGivenTypeIsCongruentUnionTypeWithRedundantMembers $this->assertTrue($unionType->is($otherUnionType)); } + /** + * @test + */ + public function unionOnlyHoldsDeduplicatedMembers(): void + { + $unionType = UnionType::of(NumberType::get(), StringType::get()); + $otherUnionType = UnionType::of(NumberType::get(), StringType::get(), NumberType::get(), StringType::get()); + + $this->assertTrue($unionType->is($otherUnionType)); + + $this->assertInstanceOf(UnionType::class, $unionType); + $this->assertInstanceOf(UnionType::class, $otherUnionType); + + $this->assertCount(count($unionType), $otherUnionType); + + $this->assertEqualsCanonicalizing( + $unionType->toArray(), + $otherUnionType->toArray() + ); + } + /** * @test */ @@ -97,4 +129,52 @@ public function isReturnsFalseIfGivenTypeIsNotCongruent(): void $this->assertFalse($unionType->is(NumberType::get())); $this->assertFalse($unionType->is(StringType::get())); } -} \ No newline at end of file + + /** + * @test + */ + public function containsNullOnNullableString(): void + { + $unionType = UnionType::of(StringType::get(), NullType::get()); + + $this->assertInstanceOf(UnionType::class, $unionType); + + $this->assertTrue($unionType->containsNull()); + + $withoutNull = $unionType->withoutNull(); + + $this->assertTrue($withoutNull->is(StringType::get())); + } + + /** + * @test + */ + public function containsNullWithMultipleMembers(): void + { + $unionType = UnionType::of(StringType::get(), NumberType::get(), NullType::get()); + + $this->assertInstanceOf(UnionType::class, $unionType); + + $this->assertTrue($unionType->containsNull()); + + $withoutNull = $unionType->withoutNull(); + + $this->assertTrue($withoutNull->is(UnionType::of(StringType::get(), NumberType::get()))); + } + + /** + * @test + */ + public function withoutNullOnUnionWithoutNull(): void + { + $unionType = UnionType::of(StringType::get(), NumberType::get()); + + $this->assertInstanceOf(UnionType::class, $unionType); + + $this->assertFalse($unionType->containsNull()); + + $withoutNull = $unionType->withoutNull(); + + $this->assertTrue($withoutNull->is($unionType)); + } +}