diff --git a/composer.json b/composer.json index a1260fa..2f1b9cb 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "infection/infection": "^0.27.0", "maglnet/composer-require-checker": "^4.6", "phpstan/extension-installer": "^1.3", - "phpstan/phpstan": "^1.10.34", + "phpstan/phpstan": "^1.12", "phpstan/phpstan-phpunit": "^1.3", "phpstan/phpstan-strict-rules": "^1.5", "phpunit/phpunit": "^10.2", diff --git a/src/Eq.php b/src/Eq.php index 2d1eaed..4d7b658 100644 --- a/src/Eq.php +++ b/src/Eq.php @@ -6,6 +6,10 @@ use Eventjet\Ausdruck\Parser\Span; +use function array_key_exists; +use function count; +use function get_object_vars; +use function is_object; use function sprintf; /** @@ -18,6 +22,33 @@ public function __construct(public readonly Expression $left, public readonly Ex { } + private static function compareStructs(object $left, object $right): bool + { + $leftVars = get_object_vars($left); + $rightVars = get_object_vars($right); + if (count($leftVars) !== count($rightVars)) { + return false; + } + /** @var mixed $value */ + foreach ($leftVars as $key => $value) { + if (!array_key_exists($key, $rightVars)) { + return false; + } + if (!self::compareValues($value, $rightVars[$key])) { + return false; + } + } + return true; + } + + private static function compareValues(mixed $left, mixed $right): bool + { + if (is_object($left) && is_object($right)) { + return self::compareStructs($left, $right); + } + return $left === $right; + } + public function __toString(): string { return sprintf('%s === %s', $this->left, $this->right); @@ -25,7 +56,7 @@ public function __toString(): string public function evaluate(Scope $scope): bool { - return $this->left->evaluate($scope) === $this->right->evaluate($scope); + return self::compareValues($this->left->evaluate($scope), $this->right->evaluate($scope)); } public function equals(Expression $other): bool diff --git a/src/Expr.php b/src/Expr.php index e4b5a0a..b95a1ea 100644 --- a/src/Expr.php +++ b/src/Expr.php @@ -46,6 +46,14 @@ public static function listLiteral(array $elements, Span $location): ListLiteral return new ListLiteral($elements, $location); } + /** + * @param array $fields + */ + public static function structLiteral(array $fields, Span $location): StructLiteral + { + return new StructLiteral($fields, $location); + } + /** * @param list $arguments */ @@ -89,6 +97,11 @@ public static function negative(Expression $expression, Span|null $location = nu return new Negative($expression, $location ?? self::dummySpan()); } + public static function fieldAccess(Expression $struct, string $field, Span $location): FieldAccess + { + return new FieldAccess($struct, $field, $location); + } + private static function dummySpan(): Span { /** @infection-ignore-all These dummy spans are just there to fill parameter lists */ diff --git a/src/FieldAccess.php b/src/FieldAccess.php new file mode 100644 index 0000000..e3a0147 --- /dev/null +++ b/src/FieldAccess.php @@ -0,0 +1,61 @@ +struct, $this->field); + } + + public function location(): Span + { + return $this->location; + } + + public function evaluate(Scope $scope): mixed + { + $struct = $this->struct->evaluate($scope); + if (!is_object($struct)) { + throw new EvaluationError(sprintf('Expected object, got %s', get_debug_type($struct))); + } + if (!property_exists($struct, $this->field)) { + throw new EvaluationError(sprintf('Unknown field "%s"', $this->field)); + } + /** @phpstan-ignore-next-line property.dynamicName */ + return $struct->{$this->field}; + } + + public function equals(Expression $other): bool + { + return $other instanceof self + && $this->struct->equals($other->struct) + && $this->field === $other->field; + } + + public function getType(): Type + { + return $this->struct->getType()->fields[$this->field]; + } +} diff --git a/src/Parser/Delimiters.php b/src/Parser/Delimiters.php new file mode 100644 index 0000000..52e12bc --- /dev/null +++ b/src/Parser/Delimiters.php @@ -0,0 +1,30 @@ + '{ ', + self::AngleBrackets => '<', + }; + } + + public function end(): string + { + return match ($this) { + self::CurlyBraces => ' }', + self::AngleBrackets => '>', + }; + } +} diff --git a/src/Parser/ExpressionParser.php b/src/Parser/ExpressionParser.php index fe07d2c..dbff011 100644 --- a/src/Parser/ExpressionParser.php +++ b/src/Parser/ExpressionParser.php @@ -7,10 +7,13 @@ use Eventjet\Ausdruck\Call; use Eventjet\Ausdruck\Expr; use Eventjet\Ausdruck\Expression; +use Eventjet\Ausdruck\FieldAccess; use Eventjet\Ausdruck\Get; use Eventjet\Ausdruck\ListLiteral; +use Eventjet\Ausdruck\StructLiteral; use Eventjet\Ausdruck\Type; +use function array_key_exists; use function array_shift; use function assert; use function count; @@ -93,7 +96,7 @@ private static function parseLazy(Expression|null $left, Peekable $tokens, Decla if ($left === null) { self::unexpectedToken($parsedToken); } - return self::call($left, $tokens, $declarations); + return self::dot($left, $tokens, $declarations); } if (is_string($token)) { if ($left !== null) { @@ -184,6 +187,9 @@ private static function parseLazy(Expression|null $left, Peekable $tokens, Decla if ($token === Token::OpenBracket) { return self::parseListLiteral($tokens, $declarations); } + if ($token === Token::OpenBrace) { + return self::parseStructLiteral($tokens, $declarations); + } return null; } @@ -367,18 +373,32 @@ private static function unexpectedToken(ParsedToken $token): never throw SyntaxError::create(sprintf('Unexpected %s', Token::print($token->token)), $token->location()); } + /** + * @param Peekable $tokens + */ + private static function dot(Expression $target, Peekable $tokens, Declarations $declarations): Call|FieldAccess + { + $dot = self::expect($tokens, Token::Dot); + [$name, $nameLocation] = self::expectIdentifier($tokens, $dot, 'function name'); + $token = $tokens->peek()?->token; + return match ($token) { + Token::Colon, Token::OpenParen => self::call($name, $nameLocation, $target, $tokens, $declarations), + default => self::fieldAccess($target, $name, $target->location()->to($nameLocation)), + }; + } + /** * list.some:bool(|item| item:string === needle:string) * ================================================ * * @param Peekable $tokens */ - private static function call(Expression $target, Peekable $tokens, Declarations $declarations): Call + private static function call(string $name, Span $nameLocation, Expression $target, Peekable $tokens, Declarations $declarations): Call { - $dot = self::expect($tokens, Token::Dot); - [$name, $nameLocation] = self::expectIdentifier($tokens, $dot, 'function name'); $fnType = $declarations->functions[$name] ?? null; - if ($tokens->peek()?->token === Token::Colon) { + $colonOrOpenParen = $tokens->peek(); + assert($colonOrOpenParen !== null); + if ($colonOrOpenParen->token === Token::Colon) { $tokens->next(); $typeNode = TypeParser::parse($tokens); if ($typeNode === null) { @@ -463,6 +483,18 @@ private static function call(Expression $target, Peekable $tokens, Declarations return $target->call($name, $returnType, $args, $target->location()->to($closeParen->location())); } + private static function fieldAccess(Expression $target, string $name, Span $location): FieldAccess + { + $targetType = $target->getType(); + if (!$targetType->isStruct()) { + throw TypeError::create(sprintf('Can\'t access field "%s" on non-struct type %s', $name, $targetType), $location); + } + if (!array_key_exists($name, $targetType->fields)) { + throw TypeError::create(sprintf('Unknown field "%s" on type %s', $name, $targetType), $location); + } + return Expr::fieldAccess($target, $name, $location); + } + /** * @param Peekable $tokens * @return array{string, Span} @@ -522,4 +554,49 @@ private static function parseListLiteral(Peekable $tokens, Declarations $declara $close = self::expect($tokens, Token::CloseBracket); return Expr::listLiteral($items, $start->location()->to($close->location())); } + + /** + * @param Peekable $tokens + */ + private static function parseStructLiteral(Peekable $tokens, Declarations $declarations): StructLiteral + { + $start = self::expect($tokens, Token::OpenBrace); + $fields = []; + while (true) { + $field = self::parseStructField($tokens, $declarations); + if ($field === null) { + break; + } + $fields[$field[0]] = $field[1]; + $comma = $tokens->peek(); + if ($comma?->token !== Token::Comma) { + break; + } + $tokens->next(); + } + $close = self::expect($tokens, Token::CloseBrace); + return Expr::structLiteral($fields, $start->location()->to($close->location())); + } + + /** + * @param Peekable $tokens + * @return array{string, Expression} | null + */ + private static function parseStructField(Peekable $tokens, Declarations $declarations): array|null + { + $name = $tokens->peek(); + if ($name === null) { + return null; + } + if (!is_string($name->token)) { + return null; + } + $tokens->next(); + self::expect($tokens, Token::Colon); + $value = self::parseLazy(null, $tokens, $declarations); + if ($value === null) { + throw SyntaxError::create('Expected value after colon', self::nextSpan($tokens)); + } + return [$name->token, $value]; + } } diff --git a/src/Parser/Token.php b/src/Parser/Token.php index 73fe233..406a645 100644 --- a/src/Parser/Token.php +++ b/src/Parser/Token.php @@ -19,6 +19,8 @@ enum Token: string case CloseBracket = ']'; case OpenAngle = '<'; case CloseAngle = '>'; + case OpenBrace = '{'; + case CloseBrace = '}'; case Or = '||'; case And = '&&'; case Pipe = '|'; diff --git a/src/Parser/Tokenizer.php b/src/Parser/Tokenizer.php index 20f4df9..8eb96bc 100644 --- a/src/Parser/Tokenizer.php +++ b/src/Parser/Tokenizer.php @@ -7,6 +7,7 @@ use function assert; use function ctype_space; use function is_numeric; +use function ord; use function sprintf; use function str_contains; use function substr; @@ -17,7 +18,13 @@ */ final class Tokenizer { - public const NON_IDENTIFIER_CHARS = '.[]()"=|<>{}:, -'; + private const LOWER_A = 97; + private const LOWER_Z = 122; + private const UPPER_A = 65; + private const UPPER_Z = 90; + private const ZERO = 48; + private const NINE = 57; + private const UNDERSCORE = 95; /** * @param iterable $chars @@ -45,6 +52,8 @@ public static function tokenize(iterable $chars): iterable ',' => Token::Comma, '[' => Token::OpenBracket, ']' => Token::CloseBracket, + '{' => Token::OpenBrace, + '}' => Token::CloseBrace, default => null, }; if ($singleCharToken !== null) { @@ -107,7 +116,7 @@ public static function tokenize(iterable $chars): iterable } continue; } - if (!str_contains(self::NON_IDENTIFIER_CHARS, $char)) { + if (self::isIdentifierChar($char, first: true)) { $startCol = $column; yield new ParsedToken(self::identifier($chars, $line, $column), $line, $startCol); continue; @@ -132,7 +141,9 @@ private static function identifier(Peekable $chars, int $line, int &$column): st break; } - if (ctype_space($char) || str_contains(self::NON_IDENTIFIER_CHARS, $char)) { + // No idea why it works if "first" is always false, but it + // does, The error is probably caught somewhere else. + if (ctype_space($char) || !self::isIdentifierChar($char, first: false)) { break; } @@ -244,4 +255,16 @@ private static function string(Peekable $chars, int $line, int &$column): Litera } return new Literal($string); } + + private static function isIdentifierChar(string $char, bool $first): bool + { + $byte = ord($char); + $isChar = ($byte >= self::LOWER_A && $byte <= self::LOWER_Z) || ($byte >= self::UPPER_A && $byte <= self::UPPER_Z); + if ($first) { + return $isChar; + } + return $isChar + || ($byte >= self::ZERO && $byte <= self::NINE) + || $byte === self::UNDERSCORE; + } } diff --git a/src/Parser/TypeNode.php b/src/Parser/TypeNode.php index c9008de..e8f3179 100644 --- a/src/Parser/TypeNode.php +++ b/src/Parser/TypeNode.php @@ -17,18 +17,36 @@ final class TypeNode implements Stringable { /** * @param list $args + * @param Delimiters | 'kv' $delimiters */ public function __construct( public readonly string $name, public readonly array $args, public readonly Span $location, + public readonly Delimiters|string $delimiters = Delimiters::AngleBrackets, ) { } + /** + * @param list $fields + */ + public static function struct(array $fields, Span $location): self + { + return new self('', $fields, $location, Delimiters::CurlyBraces); + } + + public static function keyValue(self $key, self $value): self + { + return new self('', [$key, $value], $key->location->to($value->location), 'kv'); + } + public function __toString(): string { + if ($this->delimiters === 'kv') { + return sprintf('%s: %s', $this->args[0], $this->args[1]); + } return $this->args === [] ? $this->name - : sprintf('%s<%s>', $this->name, implode(', ', $this->args)); + : sprintf('%s%s%s%s', $this->name, $this->delimiters->start(), implode(', ', $this->args), $this->delimiters->end()); } } diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index f28767e..4389f35 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -42,6 +42,9 @@ public static function parse(Peekable $tokens): TypeNode|ParsedToken|null if ($parsedToken === null) { return null; } + if ($parsedToken->token === Token::OpenBrace) { + return self::parseStruct($tokens); + } $name = $parsedToken->token; if (!is_string($name)) { return $parsedToken; @@ -126,4 +129,52 @@ private static function parseFunction(Peekable $tokens, Span $fnLocation): TypeN } return new TypeNode('fn', array_merge($params, [$returnType]), $fnLocation->to($returnType->location)); } + + /** + * @param Peekable $tokens + */ + private static function parseStruct(Peekable $tokens): TypeNode + { + $openBraceToken = $tokens->peek(); + assert($openBraceToken !== null); + $start = $openBraceToken->location(); + $tokens->next(); + $fields = []; + while (true) { + $nameToken = $tokens->peek(); + if ($nameToken === null) { + break; + } + if ($nameToken->token === Token::CloseBrace) { + break; + } + $name = $nameToken->token; + if (!is_string($name)) { + throw SyntaxError::create( + sprintf('Expected field name, got %s', Token::print($name)), + $nameToken->location(), + ); + } + $tokens->next(); + self::expect($tokens, Token::Colon); + $type = self::parse($tokens); + if ($type === null) { + throw SyntaxError::create('Expected type, got end of input', $nameToken->location()); + } + if (!$type instanceof TypeNode) { + throw SyntaxError::create( + sprintf('Expected type, got %s', Token::print($type->token)), + $type->location(), + ); + } + $fields[] = TypeNode::keyValue(new TypeNode($name, [], $nameToken->location()), $type); + $token = $tokens->peek(); + if ($token?->token !== Token::Comma) { + break; + } + $tokens->next(); + } + $end = self::expect($tokens, Token::CloseBrace)->location(); + return TypeNode::struct($fields, $start->to($end)); + } } diff --git a/src/Parser/Types.php b/src/Parser/Types.php index e8b77c1..c041c71 100644 --- a/src/Parser/Types.php +++ b/src/Parser/Types.php @@ -8,6 +8,7 @@ use function array_key_last; use function array_pop; +use function assert; use function count; use function sprintf; @@ -52,6 +53,7 @@ public function resolve(TypeNode $node): Type|TypeError 'Option' => $this->resolveOption($this->exactlyOneTypeArg($node)), 'Some' => $this->resolveSome($this->exactlyOneTypeArg($node)), 'None' => self::noArgs(Type::none(), $node), + '' => $this->resolveStruct($node), default => $this->resolveAlias($node->name) ?? TypeError::create( sprintf('Unknown type %s', $node->name), $node->location, @@ -185,4 +187,19 @@ private function resolveFunction(TypeNode $node): Type|TypeError } return Type::func($returnType, $argTypes); } + + private function resolveStruct(TypeNode $node): Type|TypeError + { + $fields = []; + foreach ($node->args as $field) { + assert(count($field->args) === 2); + [$nameNode, $typeNode] = $field->args; + $type = $this->resolve($typeNode); + if ($type instanceof TypeError) { + return $type; + } + $fields[$nameNode->name] = $type; + } + return Type::struct($fields); + } } diff --git a/src/Scope.php b/src/Scope.php index 5a0b69e..5fe6d6c 100644 --- a/src/Scope.php +++ b/src/Scope.php @@ -15,16 +15,18 @@ use function array_values; use function count; use function get_debug_type; +use function get_object_vars; use function implode; use function in_array; use function is_bool; use function is_int; +use function is_object; use function is_string; use function sprintf; /** * @phpstan-type Shape array{ - * vars?: array, + * vars?: array | null>, * parent?: mixed, * } * @api @@ -215,6 +217,18 @@ private static function identity(mixed $value): mixed return $value; } + /** + * @return string|int|bool|array|null + */ + private static function printValue(mixed $var): string|int|bool|array|null + { + return match (true) { + $var === null || is_string($var) || is_int($var) || is_bool($var) => $var, + is_object($var) => array_map(self::printValue(...), get_object_vars($var)), + default => get_debug_type($var), + }; + } + /** * @internal */ @@ -254,12 +268,7 @@ public function func(string $name): callable|null private function shape(): array { $shape = []; - $vars = array_map(static function (mixed $var): string|int|bool|null { - return match (true) { - $var === null || is_string($var) || is_int($var) || is_bool($var) => $var, - default => get_debug_type($var), - }; - }, $this->vars); + $vars = array_map(self::printValue(...), $this->vars); if ($vars !== []) { $shape['vars'] = $vars; } diff --git a/src/StructLiteral.php b/src/StructLiteral.php new file mode 100644 index 0000000..0d2b83b --- /dev/null +++ b/src/StructLiteral.php @@ -0,0 +1,68 @@ + $fields + */ + public function __construct(public readonly array $fields, public readonly Span $location) + { + } + + public function __toString(): string + { + $fieldStrings = []; + foreach ($this->fields as $name => $value) { + $fieldStrings[] = sprintf('%s: %s', $name, $value); + } + return sprintf('{%s}', implode(', ', $fieldStrings)); + } + + public function location(): Span + { + return $this->location; + } + + public function evaluate(Scope $scope): mixed + { + return (object)array_map(static fn(Expression $value): mixed => $value->evaluate($scope), $this->fields); + } + + public function equals(Expression $other): bool + { + if (!$other instanceof self) { + return false; + } + if (count($this->fields) !== count($other->fields)) { + return false; + } + foreach ($this->fields as $name => $value) { + if (array_key_exists($name, $other->fields) && $value->equals($other->fields[$name])) { + continue; + } + return false; + } + return true; + } + + public function getType(): Type + { + return Type::struct(array_map(static fn(Expression $value) => $value->getType(), $this->fields)); + } +} diff --git a/src/Type.php b/src/Type.php index 2849f6d..f05fa53 100644 --- a/src/Type.php +++ b/src/Type.php @@ -8,9 +8,13 @@ use Stringable; use function array_is_list; +use function array_key_exists; use function array_key_first; +use function array_map; use function array_shift; use function array_slice; +use function count; +use function get_object_vars; use function gettype; use function implode; use function in_array; @@ -24,9 +28,14 @@ final class Type implements Stringable { /** * @param list $args + * @param array $fields */ - private function __construct(public readonly string $name, public readonly array $args = [], public readonly self|null $aliasFor = null) - { + private function __construct( + public readonly string $name, + public readonly array $args = [], + public readonly self|null $aliasFor = null, + public readonly array $fields = [], + ) { } public static function string(): self @@ -59,11 +68,6 @@ public static function mapOf(self $keys, self $values): self return new self('map', [$keys, $values]); } - public static function object(string $class): self - { - return new self($class); - } - public static function alias(string $name, self $type): self { return new self($name, $type->args, $type); @@ -96,6 +100,7 @@ public static function fromValue(mixed $value): self 'integer' => self::int(), 'boolean' => self::bool(), 'double' => self::float(), + 'object' => self::struct(array_map(self::fromValue(...), get_object_vars($value))), default => throw new InvalidArgumentException(sprintf('Unsupported type %s', gettype($value))), }; } @@ -115,6 +120,14 @@ public static function none(): self return new self('None'); } + /** + * @param array $fields + */ + public static function struct(array $fields): self + { + return new self('Struct', fields: $fields); + } + private static function never(): self { return new self('never'); @@ -135,6 +148,14 @@ private static function keyAndValueTypeFromArray(array $value): array public function __toString(): string { + if ($this->name === 'Struct') { + $fields = []; + foreach ($this->fields as $name => $fieldType) { + /** @psalm-suppress ImplicitToStringCast */ + $fields[] = $name . ': ' . $fieldType; + } + return '{ ' . implode(', ', $fields) . ' }'; + } if ($this->name === 'Func') { $args = $this->args; $returnType = array_shift($args); @@ -159,7 +180,7 @@ public function equals(self $type): bool if (($this->aliasFor ?? $this)->name !== ($type->aliasFor ?? $type)->name) { return false; } - if (!in_array($this->name, ['Func', 'list'], true)) { + if (!in_array($this->name, ['Func', 'list', 'Struct'], true)) { return true; } foreach ($this->args as $i => $arg) { @@ -168,6 +189,17 @@ public function equals(self $type): bool } return false; } + if (count($type->fields) !== count($this->fields)) { + return false; + } + foreach ($this->fields as $name => $fieldType) { + if (!array_key_exists($name, $type->fields)) { + return false; + } + if (!$type->fields[$name]->equals($fieldType)) { + return false; + } + } return true; } @@ -223,6 +255,16 @@ public function isSubtypeOf(self $other): bool } } } + if ($this->name === 'Struct') { + foreach ($other->fields as $name => $fieldType) { + if (!array_key_exists($name, $self->fields)) { + return false; + } + if (!$self->fields[$name]->isSubtypeOf($fieldType)) { + return false; + } + } + } return true; } @@ -236,6 +278,11 @@ public function returnType(): self return $this->args[0]; } + public function isStruct(): bool + { + return $this->name === 'Struct'; + } + /** * @return list */ diff --git a/tests/unit/ExpressionComparisonTest.php b/tests/unit/ExpressionComparisonTest.php index 90251d4..2ced8fd 100644 --- a/tests/unit/ExpressionComparisonTest.php +++ b/tests/unit/ExpressionComparisonTest.php @@ -9,6 +9,7 @@ use Eventjet\Ausdruck\Eq; use Eventjet\Ausdruck\Expr; use Eventjet\Ausdruck\Expression; +use Eventjet\Ausdruck\FieldAccess; use Eventjet\Ausdruck\Get; use Eventjet\Ausdruck\Gt; use Eventjet\Ausdruck\Lambda; @@ -17,6 +18,7 @@ use Eventjet\Ausdruck\Negative; use Eventjet\Ausdruck\Or_; use Eventjet\Ausdruck\Parser\Span; +use Eventjet\Ausdruck\StructLiteral; use Eventjet\Ausdruck\Subtract; use Eventjet\Ausdruck\Type; use PHPUnit\Framework\TestCase; @@ -64,6 +66,18 @@ public static function equalsCases(): iterable Expr::and_(Expr::literal(true), Expr::literal(false)), Expr::and_(Expr::literal(true), Expr::literal(false)), ]; + yield [ + Expr::fieldAccess(Expr::get('person', Type::struct(['name' => Type::string()])), 'name', self::location()), + Expr::fieldAccess(Expr::get('person', Type::struct(['name' => Type::string()])), 'name', self::location()), + ]; + yield [ + Expr::structLiteral(['foo' => Expr::literal('bar')], self::location()), + Expr::structLiteral(['foo' => Expr::literal('bar')], self::location()), + ]; + yield [ + Expr::structLiteral(['foo' => Expr::literal('bar'), 'bar' => Expr::literal('baz')], self::location()), + Expr::structLiteral(['bar' => Expr::literal('baz'), 'foo' => Expr::literal('bar')], self::location()), + ]; } /** @@ -231,6 +245,44 @@ public static function notEqualsCases(): iterable Expr::listLiteral([Expr::literal(1)], Span::char(1, 1)), Expr::literal(1), ]; + $personType = Type::struct(['name' => Type::string(), 'age' => Type::int()]); + yield FieldAccess::class . ': different fields' => [ + Expr::fieldAccess(Expr::get('person', $personType), 'name', self::location()), + Expr::fieldAccess(Expr::get('person', $personType), 'age', self::location()), + ]; + yield FieldAccess::class . ': different targets' => [ + Expr::fieldAccess(Expr::get('person', $personType), 'name', self::location()), + Expr::fieldAccess(Expr::get('address', $personType), 'name', self::location()), + ]; + yield FieldAccess::class . ': different type' => [ + Expr::fieldAccess(Expr::get('person', $personType), 'name', self::location()), + Expr::call(Expr::get('person', $personType), 'name', Type::string(), []), + ]; + yield StructLiteral::class . ': different type' => [ + new StructLiteral(['foo' => Expr::literal('bar')], self::location()), + Expr::literal('bar'), + ]; + yield StructLiteral::class . ': different field name' => [ + new StructLiteral(['foo' => Expr::literal('bar')], self::location()), + new StructLiteral(['bar' => Expr::literal('bar')], self::location()), + ]; + yield StructLiteral::class . ': different field value' => [ + new StructLiteral(['foo' => Expr::literal('bar')], self::location()), + new StructLiteral(['foo' => Expr::literal('baz')], self::location()), + ]; + yield StructLiteral::class . ': additional field' => [ + new StructLiteral(['foo' => Expr::literal('bar')], self::location()), + new StructLiteral(['foo' => Expr::literal('bar'), 'bar' => Expr::literal('baz')], self::location()), + ]; + yield StructLiteral::class . ': different field value in second field' => [ + new StructLiteral(['foo' => Expr::literal('bar'), 'bar' => Expr::literal('a')], self::location()), + new StructLiteral(['foo' => Expr::literal('bar'), 'bar' => Expr::literal('b')], self::location()), + ]; + } + + private static function location(): Span + { + return Span::char(1, 1); } /** diff --git a/tests/unit/ExpressionTest.php b/tests/unit/ExpressionTest.php index a2e28f4..6d7207b 100644 --- a/tests/unit/ExpressionTest.php +++ b/tests/unit/ExpressionTest.php @@ -10,6 +10,7 @@ use Eventjet\Ausdruck\Literal; use Eventjet\Ausdruck\Parser\Declarations; use Eventjet\Ausdruck\Parser\ExpressionParser; +use Eventjet\Ausdruck\Parser\Span; use Eventjet\Ausdruck\Parser\Types; use Eventjet\Ausdruck\Scope; use Eventjet\Ausdruck\Type; @@ -34,6 +35,9 @@ final class ExpressionTest extends TestCase public static function evaluateCases(): iterable { $s = Type::string(); + $user = new class { + public string $name = 'John'; + }; $cases = [ [static fn(): Expression => Expr::get('foo', $s), new Scope(['foo' => 'bar']), 'bar'], [ @@ -205,6 +209,72 @@ functions: [ ['ints2:list.tail:list()', new Scope(['ints2' => [42]]), []], ['ints3:list.tail:list()', new Scope(['ints3' => []]), []], ['foo:float - bar:float', new Scope(['foo' => 5.5, 'bar' => 3.4]), 2.1], + ['user:{ name: string }.name', new Scope(['user' => $user]), 'John'], + [ + 'user:{ name: string }.name()', + new Scope(['user' => $user], ['name' => static fn() => 'from function']), + 'from function', + new Declarations(functions: ['name' => Type::func(Type::string(), [Type::any()])]), + ], + ['user:{ name: string }.name.substr(1, 2)', new Scope(['user' => $user]), 'oh'], + ['abcdefghijklmnopqrstuvwxyz:bool', new Scope(['abcdefghijklmnopqrstuvwxyz' => false]), false], + ['ABCDEFGHIJKLMNOPQRSTUVWXYZ:bool', new Scope(['ABCDEFGHIJKLMNOPQRSTUVWXYZ' => false]), false], + ['x0123456789:bool', new Scope(['x0123456789' => false]), false], + [ + 'foo:{a: int} === bar:{a: int}', + new Scope(['foo' => self::struct(a: 1), 'bar' => self::struct(a: 1)]), + true, + ], + [ + 'foo:{a: int, b: int} === bar:{a: int, b: int}', + new Scope(['foo' => self::struct(a: 1, b: 1), 'bar' => self::struct(b: 1, a: 1)]), + true, + ], + [ + 'foo:{a: int} === bar:{a: int}', + new Scope(['foo' => self::struct(a: 1, b: 1), 'bar' => self::struct(a: 1)]), + false, + ], + [ + 'foo:{a: int} === bar:{a: int}', + new Scope(['foo' => self::struct(a: 1), 'bar' => self::struct(a: 1, b: 2)]), + false, + ], + [ + 'foo:{a: {b: int}} === bar:{a: {b: int}}', + new Scope(['foo' => self::struct(a: self::struct(b: 1)), 'bar' => self::struct(a: self::struct(b: 1))]), + true, + ], + ['{name: "John"}.name', new Scope(), 'John'], + ['{name: "John", age: 37}.age', new Scope(), 37], + ['{name: "John", age: 37,}.age', new Scope(), 37], + [ + Expr::eq(Expr::structLiteral(['name' => Expr::literal('John')], self::span()), Expr::literal('John')), + new Scope(), + false, + ], + [ + Expr::eq(Expr::literal('John'), Expr::structLiteral(['name' => Expr::literal('John')], self::span())), + new Scope(), + false, + ], + ['{name: "John"} === {name: "Jane"}', new Scope(), false], + [ + Expr::eq( + Expr::structLiteral(['a' => Expr::literal('A'), 'b' => Expr::literal('B')], self::span()), + Expr::structLiteral(['a' => Expr::literal('A'), 'c' => Expr::literal('C')], self::span()), + ), + new Scope(), + false, + ], + [ + Expr::eq( + Expr::structLiteral(['a' => Expr::literal('A'), 'c' => Expr::literal('C')], self::span()), + Expr::structLiteral(['a' => Expr::literal('A'), 'b' => Expr::literal('B')], self::span()), + ), + new Scope(), + false, + ], ]; foreach ($cases as $tuple) { [$expr, $scope, $expected] = $tuple; @@ -235,6 +305,7 @@ public static function toStringCases(): iterable ]; yield 'Any type' => ['myval:any', 'myval:any']; yield 'List literal' => ['["foo", "bar"]', '["foo", "bar"]']; + yield 'Struct literal' => ['{name: "John", age: 37}', '{name: "John", age: 37}']; } /** @@ -336,6 +407,16 @@ public static function evaluationErrorsCases(): iterable new Scope(['foo' => true]), 'Expected operand to be of type int or float', ]; + yield 'Access non-existent struct field' => [ + Expr::fieldAccess(Expr::get('user', Type::struct(['name' => Type::string()])), 'age', self::span()), + new Scope(['user' => (object)['name' => 'John']]), + 'Unknown field "age"', + ]; + yield 'Access field non non-struct' => [ + Expr::fieldAccess(Expr::get('user', Type::string()), 'name', self::span()), + new Scope(['user' => 'John']), + 'Expected object, got string', + ]; } /** @@ -406,6 +487,16 @@ public static function typeCases(): iterable yield 'Empty list literal' => ['[]', Type::listOf(Type::any())]; } + private static function span(): Span + { + return Span::char(1, 1); + } + + private static function struct(mixed ...$fields): object + { + return (object)$fields; + } + /** * @param Expression | string | callable(): Expression $expression * @dataProvider evaluateCases diff --git a/tests/unit/Parser/ExpressionParserTest.php b/tests/unit/Parser/ExpressionParserTest.php index 732ecfc..db96188 100644 --- a/tests/unit/Parser/ExpressionParserTest.php +++ b/tests/unit/Parser/ExpressionParserTest.php @@ -136,16 +136,25 @@ public static function invalidSyntaxExpressions(): iterable yield 'end of string after function call and colon' => ['foo:string.substr:']; yield 'end of string after function dot' => ['foo:string.']; yield 'missing function name' => ['foo:string.:string()']; - yield 'end of string after function name' => ['foo:string.substr']; yield 'list literal: missing closing bracket' => ['[1, 2']; - yield 'empty pair or curly braces' => ['{}']; + yield 'end of string after curly brace' => ['{']; + yield 'struct literal in field name position' => ['{{name: "John"}: "John"}']; + yield 'missing colon in struct literal' => ['{name "John"}']; + yield 'end of string after struct field name' => ['{name']; + yield 'end of string after struct field colon' => ['{name:']; + yield 'end of string after struct field value' => ['{name: "John"']; + yield 'missing value in struct literal' => ['{name: }']; + yield 'missing comma between struct fields' => ['{name: "John" age: 42}']; yield 'single ampersand' => ['foo:bool & bar:bool']; + yield 'non-token, non-identifier symbol' => ['foo:bool € bar:bool']; + yield 'identifier starting with a number' => ['42foo:bool', 'Unexpected identifier foo']; + yield 'identifier starting with an underscore' => ['_foo:bool', 'Unexpected character _']; } /** * @return iterable */ - public static function invalidExpressions(): iterable + public static function typeErrorExpressions(): iterable { yield 'map type with bool key type' => ['foo:map']; yield 'or with string on the left' => ['foo:string || bar:bool']; @@ -170,6 +179,10 @@ public static function invalidExpressions(): iterable yield 'map with an unknown value type' => ['foo:map']; yield 'list with no type arguments' => ['foo:list', 'The list type requires one argument, none given']; yield 'list with two type arguments' => ['foo:list', 'Invalid type "list"']; + yield 'list with two type arguments, second is struct' => [ + 'foo:list', + 'Invalid type "list"', + ]; yield 'list with an unknown type argument' => ['foo:list']; yield 'function call with unknown type' => ['foo:string.substr:Foo(0, 3)']; yield 'negating a string literal' => ['-"foo"', 'Can\'t negate string']; @@ -227,6 +240,12 @@ public static function invalidExpressions(): iterable 'Function foo is not declared and has no inline type', ]; yield 'some with invalid type argument' => ['foo:Some', 'Unknown type Foo']; + yield 'unknown type in struct field' => ['foo:{ name: Foo }', 'Unknown type Foo']; + yield 'access to unknown struct field' => [ + 'foo:{ name: string }.age', + 'Unknown field "age" on type { name: string }', + ]; + yield 'field access on string' => ['foo:string.age', 'Can\'t access field "age" on non-struct type string']; } /** @@ -460,7 +479,7 @@ public function testSyntaxError(string $expression, string|null $expectedMessage } /** - * @dataProvider invalidExpressions + * @dataProvider typeErrorExpressions */ public function testTypeError(string $expression, string|null $expectedMessage = null, Declarations|null $declarations = null): void { diff --git a/tests/unit/Parser/TypeParserTest.php b/tests/unit/Parser/TypeParserTest.php index b29b5bb..aa19856 100644 --- a/tests/unit/Parser/TypeParserTest.php +++ b/tests/unit/Parser/TypeParserTest.php @@ -7,10 +7,11 @@ use Eventjet\Ausdruck\Parser\Span; use Eventjet\Ausdruck\Parser\SyntaxError; use Eventjet\Ausdruck\Parser\TypeParser; +use Eventjet\Ausdruck\Parser\Types; +use Eventjet\Ausdruck\Type; use LogicException; use PHPUnit\Framework\TestCase; -use function assert; use function explode; use function implode; use function preg_match; @@ -53,6 +54,140 @@ public static function syntaxErrorCases(): iterable AUSDRUCK, 'Expected type, got ->', ]; + yield 'Open curly brace' => [ + '{', + 'Expected }, got end of input', + ]; + yield 'Struct instead of struct field name' => [ + '{{name: string}: string}', + 'Expected field name, got {', + ]; + yield 'Struct: end of input after field name' => [ + '{name', + 'Expected :, got end of input', + ]; + yield 'Struct: missing colon between field name and type' => [ + '{name string}', + 'Expected :, got string', + ]; + yield 'Struct: pipe instead of colon' => [ + '{name | string}', + 'Expected :, got |', + ]; + yield 'End of input after struct colon' => [ + '{name:', + 'Expected type, got end of input', + ]; + yield 'Struct: double colon' => [ + '{name:: string}', + 'Expected type, got :', + ]; + yield 'Struct: end of input after struct type' => [ + '{name: string', + 'Expected }, got end of input', + ]; + yield 'Struct: no comma between fields' => [ + '{name: string age: int}', + 'Expected }, got age', + ]; + } + + /** + * @return iterable + */ + public static function parseStringCases(): iterable + { + yield 'Empty struct' => ['{}', Type::struct([])]; + yield 'Empty struct with newline' => ["{\n}", Type::struct([])]; + yield 'Empty struct with blank line' => ["{\n\n}", Type::struct([])]; + yield 'Struct with a single field' => ['{name: string}', Type::struct(['name' => Type::string()])]; + yield 'Struct with a single field and whitespace around it' => [ + '{ name: string }', + Type::struct(['name' => Type::string()]), + ]; + yield 'Struct: whitespace after colon' => [ + '{name : string}', + Type::struct(['name' => Type::string()]), + ]; + yield 'Struct: no whitespace after colon' => [ + '{name:string}', + Type::struct(['name' => Type::string()]), + ]; + yield 'Struct with a single field on a separate line' => [ + << Type::string()]), + ]; + yield 'Struct with a single field on a separate line with indent' => [ + << Type::string()]), + ]; + yield 'Trailing comma after struct field' => ['{name: string,}', Type::struct(['name' => Type::string()])]; + yield 'Trailing comma and whitespace after struct field' => [ + '{name: string, }', + Type::struct(['name' => Type::string()]), + ]; + yield 'Struct field on separate line with trailing comma' => [ + << Type::string()]), + ]; + yield 'Struct with multiple fields and no trailing comma' => [ + '{name: string, age: int}', + Type::struct(['name' => Type::string(), 'age' => Type::int()]), + ]; + yield 'Struct with multiple fields, each on a separate line, with no trailing comma' => [ + << Type::string(), 'age' => Type::int()]), + ]; + yield 'Struct with multiple fields, each on a separate line, with trailing comma' => [ + << Type::string(), 'age' => Type::int()]), + ]; + yield 'Struct with multiple fields, all on one separate line' => [ + <<<'EOF' + { + name: string, age: int + } + EOF, + Type::struct(['name' => Type::string(), 'age' => Type::int()]), + ]; + yield 'Struct with multiple fields, all on one separate line and a trailing comma' => [ + <<<'EOF' + { + name: string, age: int, + } + EOF, + Type::struct(['name' => Type::string(), 'age' => Type::int()]), + ]; + yield 'Struct nested inside another struct' => [ + '{name: {first: string}}', + Type::struct(['name' => Type::struct(['first' => Type::string()])]), + ]; + yield 'Comma after nested struct' => [ + '{name: {first: string},}', + Type::struct(['name' => Type::struct(['first' => Type::string()])]), + ]; } /** @@ -76,7 +211,7 @@ public function testSyntaxErrors(string $type, string $expectedMessage): void } $startCol = strlen($matches['indent']) + 1; $endCol = strlen($matches['indent']) + strlen($matches['marker']); - assert($endCol > 0, 'End column can\'t be lower than 1 because the marker is at least one character long'); + /** @psalm-suppress InvalidArgument False positive */ $expectedSpan = new Span($lineNumber, $startCol, $lineNumber, $endCol); unset($lines[$lineIndex]); } @@ -94,4 +229,23 @@ public function testSyntaxErrors(string $type, string $expectedMessage): void self::assertSame((string)$expectedSpan, (string)$error->location); } } + + /** + * @dataProvider parseStringCases + */ + public function testParseString(string $typeString, Type $expected): void + { + /** + * @psalm-suppress InternalMethod + * @psalm-suppress InternalClass + */ + $node = TypeParser::parseString($typeString); + if ($node instanceof SyntaxError) { + self::fail($node->getMessage()); + } + $actual = (new Types())->resolve($node); + + self::assertInstanceOf(Type::class, $actual); + self::assertTrue($actual->equals($expected)); + } } diff --git a/tests/unit/ScopeTest.php b/tests/unit/ScopeTest.php index 1f8c67c..c42157e 100644 --- a/tests/unit/ScopeTest.php +++ b/tests/unit/ScopeTest.php @@ -7,7 +7,6 @@ use Eventjet\Ausdruck\Scope; use LogicException; use PHPUnit\Framework\TestCase; -use stdClass; use function fopen; @@ -29,7 +28,11 @@ public static function debugCases(): iterable yield [new Scope(['foo' => false]), '{"vars": {"foo": false}}']; yield [new Scope(['foo' => 1]), '{"vars": {"foo": 1}}']; yield [new Scope(['foo' => null]), '{"vars": {"foo": null}}']; - yield [new Scope(['foo' => new stdClass()]), '{"vars": {"foo": "stdClass"}}']; + yield [new Scope(['foo' => (object)['name' => 'John']]), '{"vars": {"foo": {"name": "John"}}}']; + yield [ + new Scope(['foo' => (object)['nested' => (object)['name' => 'John']]]), + '{"vars": {"foo": {"nested": {"name": "John"}}}}', + ]; yield [new Scope(['foo' => fopen('php://temp', 'r')]), '{"vars": {"foo": "resource (stream)"}}']; } diff --git a/tests/unit/TypeTest.php b/tests/unit/TypeTest.php index 8721809..629db8b 100644 --- a/tests/unit/TypeTest.php +++ b/tests/unit/TypeTest.php @@ -31,6 +31,113 @@ public static function failingAssertCases(): iterable 'not a function', 'Expected func(): string, got string', ]; + yield 'Struct: not an object' => [ + Type::struct(['name' => Type::string()]), + 'not an object', + 'Expected { name: string }, got string', + ]; + yield 'Missing struct field' => [ + Type::struct(['name' => Type::string(), 'age' => Type::int()]), + new class { + public string $name = 'John Doe'; + }, + 'Expected { name: string, age: int }, got { name: string }', + ]; + yield 'Struct field has wrong type' => [ + Type::struct(['name' => Type::string()]), + new class { + public int $name = 42; + }, + 'Expected { name: string }, got { name: int }', + ]; + $name = new class { + public string $first = 'John'; + }; + yield 'Struct field has subtype' => [ + Type::struct(['name' => Type::struct(['first' => Type::string(), 'last' => Type::string()])]), + new class ($name) { + public function __construct(public object $name) + { + } + }, + 'Expected { name: { first: string, last: string } }, got { name: { first: string } }', + ]; + } + + /** + * @return iterable + */ + public static function successfulAssertCases(): iterable + { + yield 'Struct' => [ + Type::struct(['name' => Type::string()]), + new class { + public string $name = 'John Doe'; + }, + ]; + yield 'Struct is allowed to have additional fields' => [ + Type::struct(['name' => Type::string()]), + new class { + public string $name = 'John Doe'; + public int $age = 42; + }, + ]; + $name = new class { + public string $first = 'John'; + public string $last = 'Doe'; + }; + yield 'Struct field has supertype' => [ + Type::struct(['name' => Type::struct(['first' => Type::string()])]), + new class ($name) { + public function __construct(public readonly object $name) + { + } + }, + ]; + } + + /** + * @return iterable + */ + public static function fromValuesCases(): iterable + { + yield 'struct' => [ + new class { + public string $name = 'John Doe'; + public int $age = 42; + }, + Type::struct(['name' => Type::string(), 'age' => Type::int()]), + ]; + } + + /** + * @return iterable + */ + public static function notEqualsCases(): iterable + { + yield 'Struct: one has more fields' => [ + Type::struct(['name' => Type::string()]), + Type::struct(['name' => Type::string(), 'age' => Type::int()]), + ]; + yield 'Struct: one has different field type' => [ + Type::struct(['name' => Type::string()]), + Type::struct(['name' => Type::int()]), + ]; + yield 'Struct: one has different field name' => [ + Type::struct(['name' => Type::string()]), + Type::struct(['firstName' => Type::string()]), + ]; + } + + /** + * @return iterable + */ + public static function toStringCases(): iterable + { + yield 'Struct' => [ + Type::struct(['name' => Type::string(), 'age' => Type::int()]), + '{ name: string, age: int }', + ]; } /** @@ -72,4 +179,41 @@ public function testFailingAssert(Type $type, mixed $value, string $expectedMess $type->assert($value); } + + /** + * @dataProvider successfulAssertCases + */ + public function testSuccessfulAssert(Type $type, mixed $value): void + { + $this->expectNotToPerformAssertions(); + + $type->assert($value); + } + + /** + * @dataProvider fromValuesCases + */ + public function testFromValue(mixed $value, Type $expected): void + { + $actual = Type::fromValue($value); + + self::assertTrue($actual->equals($expected)); + } + + /** + * @dataProvider notEqualsCases + */ + public function testNotEquals(Type $a, Type $b): void + { + self::assertFalse($a->equals($b)); + self::assertFalse($b->equals($a)); + } + + /** + * @dataProvider toStringCases + */ + public function testToString(Type $type, string $expected): void + { + self::assertSame($expected, (string)$type); + } }