diff --git a/src/Parser/Delimiters.php b/src/Parser/Delimiters.php new file mode 100644 index 0000000..f78327d --- /dev/null +++ b/src/Parser/Delimiters.php @@ -0,0 +1,33 @@ + '(', + self::Brackets => '[', + self::CurlyBraces => '{', + self::AngleBrackets => '<', + }; + } + + public function end(): string + { + return match ($this) { + self::Parentheses => ')', + self::Brackets => ']', + self::CurlyBraces => '}', + self::AngleBrackets => '>', + }; + } +} 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..f46b265 100644 --- a/src/Parser/Tokenizer.php +++ b/src/Parser/Tokenizer.php @@ -45,6 +45,8 @@ public static function tokenize(iterable $chars): iterable ',' => Token::Comma, '[' => Token::OpenBracket, ']' => Token::CloseBracket, + '{' => Token::OpenBrace, + '}' => Token::CloseBrace, default => null, }; if ($singleCharToken !== null) { 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..3da4b88 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, $parsedToken->location()); + } $name = $parsedToken->token; if (!is_string($name)) { return $parsedToken; @@ -126,4 +129,48 @@ 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, Span $location): TypeNode + { + $openBrace = $tokens->peek()?->location(); + assert($openBrace !== null); + $tokens->next(); + $fields = []; + while (true) { + $nameToken = $tokens->peek(); + if ($nameToken === null) { + break; + } + $name = $nameToken->token; + if (!is_string($name)) { + break; + } + $tokens->next(); + self::expect($tokens, Token::Colon); + $type = self::parse($tokens); + if ($type === null) { + break; + } + 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 === null) { + break; + } + if ($token->token !== Token::Comma) { + break; + } + $tokens->next(); + } + $closeBrace = self::expect($tokens, Token::CloseBrace)->location(); + return TypeNode::struct($fields, $location->to($closeBrace)); + } } diff --git a/src/Parser/Types.php b/src/Parser/Types.php index 015255e..b16f75c 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; @@ -53,6 +54,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, @@ -190,4 +192,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/Type.php b/src/Type.php index fae0f3a..b5c2792 100644 --- a/src/Type.php +++ b/src/Type.php @@ -164,7 +164,7 @@ public function __toString(): string /** @psalm-suppress ImplicitToStringCast */ $fields[] = $name . ': ' . $fieldType; } - return '{' . implode(', ', $fields) . '}'; + return '{ ' . implode(', ', $fields) . ' }'; } if ($this->name === 'Func') { $args = $this->args; diff --git a/tests/unit/Parser/TypeParserTest.php b/tests/unit/Parser/TypeParserTest.php index b29b5bb..eaf20e8 100644 --- a/tests/unit/Parser/TypeParserTest.php +++ b/tests/unit/Parser/TypeParserTest.php @@ -6,7 +6,10 @@ use Eventjet\Ausdruck\Parser\Span; use Eventjet\Ausdruck\Parser\SyntaxError; +use Eventjet\Ausdruck\Parser\TypeNode; use Eventjet\Ausdruck\Parser\TypeParser; +use Eventjet\Ausdruck\Parser\Types; +use Eventjet\Ausdruck\Type; use LogicException; use PHPUnit\Framework\TestCase; @@ -55,6 +58,104 @@ public static function syntaxErrorCases(): iterable ]; } + /** + * @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()])]), + ]; + } + /** * @dataProvider syntaxErrorCases */ @@ -94,4 +195,21 @@ 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); + assert($node instanceof TypeNode); + $actual = (new Types())->resolve($node); + + self::assertInstanceOf(Type::class, $actual); + self::assertTrue($actual->equals($expected)); + } } diff --git a/tests/unit/TypeTest.php b/tests/unit/TypeTest.php index bc56a6f..d6f7f30 100644 --- a/tests/unit/TypeTest.php +++ b/tests/unit/TypeTest.php @@ -34,21 +34,21 @@ public static function failingAssertCases(): iterable yield 'Struct: not an object' => [ Type::struct(['name' => Type::string()]), 'not an object', - 'Expected {name: string}, got string', + '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}', + '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}', + 'Expected { name: string }, got { name: int }', ]; $name = new class { public string $first = 'John'; @@ -60,7 +60,7 @@ public function __construct(public object $name) { } }, - 'Expected {name: {first: string, last: string}}, got {name: {first: string}}', + 'Expected { name: { first: string, last: string } }, got { name: { first: string } }', ]; } @@ -136,7 +136,7 @@ public static function toStringCases(): iterable { yield 'Struct' => [ Type::struct(['name' => Type::string(), 'age' => Type::int()]), - '{name: string, age: int}', + '{ name: string, age: int }', ]; }