Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions src/Parser/Declarations.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
namespace Eventjet\Ausdruck\Parser;

use Eventjet\Ausdruck\Type;
use Eventjet\Ausdruck\ValueDeclaration;
use InvalidArgumentException;

use function array_filter;
use function array_key_exists;
use function sprintf;

use const ARRAY_FILTER_USE_KEY;

final class Declarations
{
/** @var array<string, Type> */
Expand All @@ -24,7 +28,22 @@ public function __construct(
public readonly array $variables = [],
array $functions = [],
) {
$fns = [
$fns = self::builtInFunctions();
foreach ($functions as $name => $type) {
if (array_key_exists($name, $fns)) {
throw new InvalidArgumentException(sprintf('Can\'t override built-in function %s', $name));
}
$fns[$name] = $type;
}
$this->functions = $fns;
}

/**
* @return array<string, Type>
*/
private static function builtInFunctions(): array
{
return [
'contains' => Type::func(Type::bool(), [Type::listOf(Type::any()), Type::any()]),
'count' => Type::func(Type::int(), [Type::listOf(Type::any())]),
// Can't declare filter until we have generics
Expand All @@ -39,12 +58,23 @@ public function __construct(
// Can't declare unwrap until we have generics
// 'unwrap' => Type::func(Type::some(Type::any()), [Type::option(Type::any())]),
];
foreach ($functions as $name => $type) {
if (array_key_exists($name, $fns)) {
throw new InvalidArgumentException(sprintf('Can\'t override built-in function %s', $name));
}
$fns[$name] = $type;
}

/**
* @param list<ValueDeclaration> $declarations
*/
public function withAddedDeclarations(array $declarations): self
{
$newValues = $this->variables;
foreach ($declarations as $declaration) {
$newValues[$declaration->name] = $declaration->type;
}
$this->functions = $fns;
$builtInFunctions = self::builtInFunctions();
$functions = array_filter(
$this->functions,
static fn($name) => !array_key_exists($name, $builtInFunctions),
ARRAY_FILTER_USE_KEY,
);
return new self($this->types, $newValues, $functions);
}
}
52 changes: 51 additions & 1 deletion src/Parser/ExpressionParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Eventjet\Ausdruck\ListLiteral;
use Eventjet\Ausdruck\StructLiteral;
use Eventjet\Ausdruck\Type;
use Eventjet\Ausdruck\ValueDeclaration;

use function array_shift;
use function assert;
Expand Down Expand Up @@ -49,7 +50,7 @@ public static function parse(string $expression, Declarations|Types|null $types
* string literals and identifiers are just put back together. If you encounter a case where it does matter,
* just change it to mb_str_split and add an appropriate test case.
*/
return (new self(new Peekable(Tokenizer::tokenize($chars)), $declarations))->parseExpression();
return (new self(new Peekable(Tokenizer::tokenize($chars)), $declarations))->parseStatements();
}

public static function parseTyped(string $expression, Type $type, Declarations|Types|null $types = null): Expression
Expand Down Expand Up @@ -87,6 +88,52 @@ private static function fieldAccess(Expression $target, string $name, Span $loca
return Expr::fieldAccess($target, $name, $location);
}

private function parseStatements(): Expression
{
$declarations = [];
while (true) {
$declaration = $this->parseDeclaration();
if ($declaration === null) {
break;
}
$declarations[] = $declaration;
}
$this->declarations = $this->declarations->withAddedDeclarations($declarations);
return $this->parseExpression();
}

private function parseDeclaration(): ValueDeclaration|null
{
$token = $this->tokens->peek()?->token;
if ($token === 'val') {
return $this->parseValueDeclaration();
}
return null;
}

private function parseValueDeclaration(): ValueDeclaration
{
$valToken = $this->tokens->next();
assert($valToken !== null);
[$name] = $this->expectIdentifier($valToken, 'value name');
$this->expect(Token::Equals);
$typeNode = TypeParser::parse($this->tokens);
if ($typeNode === null) {
throw SyntaxError::create('Expected type, got end of input', $this->nextSpan());
}
if ($typeNode instanceof ParsedToken) {
throw SyntaxError::create(
sprintf('Expected type, got %s', Token::print($typeNode->token)),
$typeNode->location(),
);
}
$type = $this->declarations->types->resolve($typeNode);
if ($type instanceof TypeError) {
throw $type;
}
return new ValueDeclaration($name, $type);
}

private function parseExpression(): Expression
{
/** @var Expression | null $expr */
Expand Down Expand Up @@ -117,6 +164,9 @@ private function parseLazy(Expression|null $left): Expression|null
return null;
}
$token = $parsedToken->token;
if ($token === Token::Equals) {
self::unexpectedToken($parsedToken);
}
if ($token === Token::Dot) {
if ($left === null) {
self::unexpectedToken($parsedToken);
Expand Down
1 change: 1 addition & 0 deletions src/Parser/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ enum Token: string
case Colon = ':';
case Minus = '-';
case Arrow = '->';
case Equals = '=';

/**
* @param Token | string | Literal<string | int | float> $token
Expand Down
3 changes: 3 additions & 0 deletions src/Parser/Tokenizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ private static function equals(Peekable $chars, int $line, int &$column): Token
{
$chars->next();
$column++;
if ($chars->peek() !== '=') {
return Token::Equals;
}
self::expect($chars, '==', $line, $column);
return Token::TripleEquals;
}
Expand Down
12 changes: 12 additions & 0 deletions src/ValueDeclaration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Eventjet\Ausdruck;

final readonly class ValueDeclaration
{
public function __construct(public string $name, public Type $type)
{
}
}
19 changes: 19 additions & 0 deletions tests/unit/ExpressionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,25 @@ functions: [
new Scope(['maybes' => ['foo', null, 'bar']]),
['foo', 'bar'],
],
[
<<<AUSDRUCK
val foo = int

foo
AUSDRUCK,
new Scope(['foo' => 42]),
42,
],
[
<<<AUSDRUCK
val foo = string
val bar = string

foo === bar
AUSDRUCK,
new Scope(['foo' => 'a', 'bar' => 'b']),
false,
],
];
foreach ($cases as $tuple) {
[$expr, $scope, $expected] = $tuple;
Expand Down
8 changes: 7 additions & 1 deletion tests/unit/Parser/ExpressionParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public static function invalidSyntaxExpressions(): iterable
yield 'double equals' => ['foo:string == bar:string'];
yield 'double length fat arrow' => ['foo:string ==> bar:string'];
yield 'end after single pipe' => ['foo:bool |'];
yield 'end after single equals' => ['foo:bool =', 'Expected ==, got end of input'];
yield 'end after single equals' => ['foo:bool =', 'Unexpected ='];
yield 'end after double equals' => ['foo:bool =='];
yield 'close brace after triple equals' => ['foo:bool === )'];
yield 'lambda: missing closing brace' => ['(foo, bar => foo:string'];
Expand Down Expand Up @@ -149,6 +149,11 @@ public static function invalidSyntaxExpressions(): iterable
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 _'];
yield 'End of input after val keyword' => ['val', 'Expected value name, got end of input'];
yield 'End of input after val name' => ['val foo', 'Expected =, got end of input'];
yield 'End of input after val equals' => ['val foo =', 'Expected type, got end of input'];
yield 'No Expression after val statement' => ['val foo = int', 'Expected expression, got end of input'];
yield 'Not a type after val equals' => ["val foo = :\nfoo", 'Expected type, got :'];
}

/**
Expand Down Expand Up @@ -246,6 +251,7 @@ public static function typeErrorExpressions(): iterable
'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'];
yield 'val statement with unknown type' => ["val foo = huh\nfoo", 'Unknown type huh'];
}

/**
Expand Down