Skip to content

Commit

Permalink
Implement list literals (#19)
Browse files Browse the repository at this point in the history
  • Loading branch information
MidnightDesign authored Feb 29, 2024
1 parent 539c843 commit af11e7d
Show file tree
Hide file tree
Showing 10 changed files with 165 additions and 7 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ See [Types](#types)
- `123`: Integer
- `"foo"`: String
- `1.23`: Float
- `[1, myInt:int, 3]`: List of integers
- `["foo", myString:string, "bar"]`: List of strings

### Operators

Expand Down
10 changes: 10 additions & 0 deletions src/Expr.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ public static function literal(mixed $value, Span|null $location = null): Litera
return new Literal($value, $location ?? self::dummySpan());
}

/**
* @template T
* @param list<Expression<T>> $elements
* @return ListLiteral<T>
*/
public static function listLiteral(array $elements, Span $location): ListLiteral
{
return new ListLiteral($elements, $location);
}

/**
* @template T
* @param Expression<mixed> $target
Expand Down
80 changes: 80 additions & 0 deletions src/ListLiteral.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Eventjet\Ausdruck;

use Eventjet\Ausdruck\Parser\Span;

use function array_map;
use function implode;

/**
* @template T
* @extends Expression<list<T>>
*/
final class ListLiteral extends Expression
{
/**
* @param list<Expression<T>> $elements
*/
public function __construct(public readonly array $elements, public readonly Span $location)
{
}

public function __toString()
{
return '[' . implode(', ', $this->elements) . ']';
}

public function location(): Span
{
return $this->location;
}

/**
* @return list<T>
*/
public function evaluate(Scope $scope): array
{
return array_map(
static fn(Expression $element): mixed => $element->evaluate($scope),
$this->elements,
);
}

public function equals(Expression $other): bool
{
if (!$other instanceof self) {
return false;
}
foreach ($this->elements as $i => $element) {
if ($element->equals($other->elements[$i])) {
continue;
}
return false;
}
return true;
}

/**
* @return Type<list<T>>
*/
public function getType(): Type
{
$elementType = null;
foreach ($this->elements as $element) {
$type = $element->getType();
if ($elementType === null) {
$elementType = $type;
continue;
}
/** @psalm-suppress RedundantCondition */
if ($elementType->equals($type)) {
continue;
}
$elementType = Type::any();
}
return Type::listOf($elementType ?? Type::any());
}
}
30 changes: 28 additions & 2 deletions src/Parser/ExpressionParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Eventjet\Ausdruck\Expr;
use Eventjet\Ausdruck\Expression;
use Eventjet\Ausdruck\Get;
use Eventjet\Ausdruck\ListLiteral;
use Eventjet\Ausdruck\Scope;
use Eventjet\Ausdruck\Type;

Expand Down Expand Up @@ -204,6 +205,9 @@ private static function parseLazy(Expression|null $left, Peekable $tokens, Decla
/** @phpstan-ignore-next-line False positive */
return $left->gt($right);
}
if ($token === Token::OpenBracket) {
return self::parseListLiteral($tokens, $declarations);
}
return null;
}

Expand Down Expand Up @@ -431,7 +435,7 @@ private static function call(Expression $target, Peekable $tokens, Declarations
if ($returnType instanceof TypeError) {
throw $returnType;
}
if ($fnType !== null && !$fnType->args[0]->isSubtypeOf($returnType)) {
if ($fnType !== null && !$returnType->isSubtypeOf($fnType->args[0])) {
throw TypeError::create(
sprintf(
'Inline return type %s of function %s does not match declared return type %s',
Expand Down Expand Up @@ -484,7 +488,7 @@ private static function call(Expression $target, Peekable $tokens, Declarations
sprintf('%s expects %d arguments, got %d', $name, count($parameterTypes), count($args)),
);
}
if (!$argument->matchesType($parameterType)) {
if (!$argument->isSubtypeOf($parameterType)) {
throw new TypeError(
sprintf(
'Argument %d of %s must be of type %s, got %s',
Expand Down Expand Up @@ -538,4 +542,26 @@ private static function nextSpan(Peekable $tokens): Span
$previous = $tokens->previous();
return $previous === null ? Span::char(1, 1) : Span::char($previous->line, $previous->column + 1);
}

/**
* @param Peekable<ParsedToken> $tokens
* @return ListLiteral<mixed>
*/
private static function parseListLiteral(Peekable $tokens, Declarations $declarations): ListLiteral
{
$start = self::expect($tokens, Token::OpenBracket);
$items = [];
while (true) {
$item = self::parseLazy(null, $tokens, $declarations);
if ($item === null) {
break;
}
$items[] = $item;
if ($tokens->peek()?->token === Token::Comma) {
$tokens->next();
}
}
$close = self::expect($tokens, Token::CloseBracket);
return Expr::listLiteral($items, $start->location()->to($close->location()));
}
}
2 changes: 2 additions & 0 deletions src/Parser/Tokenizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ public static function tokenize(iterable $chars): iterable
'>' => Token::CloseAngle,
':' => Token::Colon,
',' => Token::Comma,
'[' => Token::OpenBracket,
']' => Token::CloseBracket,
default => null,
};
if ($singleCharToken !== null) {
Expand Down
5 changes: 4 additions & 1 deletion src/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ public function equals(self $type): bool
if (($this->aliasFor ?? $this)->name !== ($type->aliasFor ?? $type)->name) {
return false;
}
if ($this->name !== 'Func') {
if (!in_array($this->name, ['Func', 'list'], true)) {
return true;
}
foreach ($this->args as $i => $arg) {
Expand Down Expand Up @@ -288,6 +288,9 @@ public function isSubtypeOf(self $other): bool
if ($self->name !== $other->name) {
return false;
}
if ($this->name === 'list') {
return $self->args[0]->isSubtypeOf($other->args[0]);
}
if ($self->name === 'Func') {
if (!$self->returnType()->isSubtypeOf($other->returnType())) {
return false;
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/ExpressionComparisonTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
use Eventjet\Ausdruck\Get;
use Eventjet\Ausdruck\Gt;
use Eventjet\Ausdruck\Lambda;
use Eventjet\Ausdruck\ListLiteral;
use Eventjet\Ausdruck\Literal;
use Eventjet\Ausdruck\Negative;
use Eventjet\Ausdruck\Or_;
use Eventjet\Ausdruck\Parser\Span;
use Eventjet\Ausdruck\Subtract;
use Eventjet\Ausdruck\Type;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -53,6 +55,10 @@ public static function equalsCases(): iterable
Expr::negative(Expr::literal(1)),
Expr::negative(Expr::literal(1)),
];
yield [
Expr::listLiteral([Expr::literal(1), Expr::literal(2), Expr::literal(3)], Span::char(1, 1)),
Expr::listLiteral([Expr::literal(1), Expr::literal(2), Expr::literal(3)], Span::char(1, 1)),
];
}

/**
Expand Down Expand Up @@ -188,6 +194,14 @@ public static function notEqualsCases(): iterable
Expr::negative(Expr::literal(1)),
Expr::negative(Expr::literal(2)),
];
yield ListLiteral::class . ': different elements' => [
Expr::listLiteral([Expr::literal(1), Expr::literal(2), Expr::literal(3)], Span::char(1, 1)),
Expr::listLiteral([Expr::literal(1), Expr::literal(9), Expr::literal(3)], Span::char(1, 1)),
];
yield ListLiteral::class . ': different type' => [
Expr::listLiteral([Expr::literal(1)], Span::char(1, 1)),
Expr::literal(1),
];
}

/**
Expand Down
17 changes: 14 additions & 3 deletions tests/unit/ExpressionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ functions: [
],
),
],
['["foo", "bar"]', new Scope(), ['foo', 'bar']],
['["foo", myVar:string, "bar"].contains("test")', new Scope(['myVar' => 'test']), true],
['["foo",]', new Scope(), ['foo']],
];
/**
* @psalm-suppress PossiblyUndefinedArrayOffset The runtime behavior is well-defined: `$declarations` is just null
Expand Down Expand Up @@ -216,6 +219,7 @@ public static function toStringCases(): iterable
new Declarations(variables: ['foo' => Type::string()]),
];
yield 'Any type' => ['myval:any', 'myval:any'];
yield 'List literal' => ['["foo", "bar"]', '["foo", "bar"]'];
}

/**
Expand Down Expand Up @@ -288,7 +292,7 @@ public static function evaluationErrorsCases(): iterable
}

/**
* @return iterable<string, array{Expression<mixed>, Type<mixed>}>
* @return iterable<string, array{Expression<mixed> | string, Type<mixed>}>
*/
public static function typeCases(): iterable
{
Expand Down Expand Up @@ -354,6 +358,9 @@ public static function typeCases(): iterable
]),
Type::bool(),
];
yield 'List literal with strings' => ['["foo", myVar:string]', Type::listOf(Type::string())];
yield 'List literal with strings and ints' => ['["foo", "bar", 42, myVar:string]', Type::listOf(Type::any())];
yield 'Empty list literal' => ['[]', Type::listOf(Type::any())];
}

/**
Expand Down Expand Up @@ -403,12 +410,16 @@ public function testEvaluationErrors(Expression|array $expression, Scope $scope,
}

/**
* @param Expression<mixed> $expression
* @param Expression<mixed> | string $expression
* @param Type<mixed> $expected
* @dataProvider typeCases
*/
public function testType(Expression $expression, Type $expected): void
public function testType(Expression|string $expression, Type $expected): void
{
if (is_string($expression)) {
$expression = ExpressionParser::parse($expression);
}

/**
* @psalm-suppress ImplicitToStringCast
* @psalm-suppress RedundantCondition I have no idea how to type this better
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/Parser/ExpressionParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ public static function invalidSyntaxExpressions(): iterable
yield 'standalone dot' => ['.'];
yield 'prop access without an object' => ['.foo:string'];
yield 'triple equals without left hand side' => ['=== foo:string'];
yield 'offset without a target' => ['["foo"]'];
yield 'missing variable type' => ['foo'];
yield 'missing variable type in sub-expression' => [
'foo === bar:true',
Expand All @@ -138,6 +137,8 @@ public static function invalidSyntaxExpressions(): iterable
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' => ['{}'];
}

/**
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/TypeCompatibilityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ public static function isSubtypeCases(): iterable
// Option
['Option<string>', 'Option<any>'],
['Some<string>', 'Option<string>'],

// Lists
['list<any>', 'list<any>'],
['list<string>', 'list<string>'],
['list<string>', 'list<any>'],
];
foreach ($cases as $case) {
yield sprintf('%s is a subtype of %s', ...$case) => $case;
Expand Down Expand Up @@ -92,6 +97,10 @@ public static function isNotSubtypeCases(): iterable
['Option<any>', 'Option<string>'],
['Option<string>', 'Some<string>'],
['Some<string>', 'string'],

// Lists
['list<string>', 'list<int>'],
['list<any>', 'list<string>'],
];
foreach ($cases as $case) {
yield sprintf('%s is not a subtype of %s', ...$case) => $case;
Expand Down

0 comments on commit af11e7d

Please sign in to comment.