Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Struct type #25

Merged
merged 26 commits into from
Sep 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0930273
Remove the object type (to later replace it with a struct type)
MidnightDesign Aug 9, 2024
d327d25
Delete fixtures
MidnightDesign Aug 9, 2024
998d284
Fix code style in an example
MidnightDesign Aug 9, 2024
e6b026c
Basic struct type implementation (no parsing yet)
MidnightDesign Aug 13, 2024
6adb3b1
Merge remote-tracking branch 'origin/0.2.x' into struct-type
MidnightDesign Aug 13, 2024
9002a74
Merge remote-tracking branch 'origin/0.2.x' into struct-type
MidnightDesign Aug 15, 2024
c835057
Update a test
MidnightDesign Aug 15, 2024
f9c1bcc
Fix some issues related to the new runtime checks
MidnightDesign Aug 15, 2024
20e001f
Parse structs
MidnightDesign Aug 15, 2024
6ada8ae
Remove unused cases from Delimiters, make it @internal
MidnightDesign Aug 15, 2024
159365b
Improve the type parser
MidnightDesign Aug 15, 2024
c1e540d
Add tests
MidnightDesign Aug 15, 2024
27df68e
Merge two if statements
MidnightDesign Aug 15, 2024
86f6939
Replace ?-> with an assertion
MidnightDesign Aug 15, 2024
50bf3c7
Fix... stuff
MidnightDesign Aug 15, 2024
c38614d
Implement struct field access
MidnightDesign Aug 15, 2024
06b632e
Merge remote-tracking branch 'origin/0.2.x' into struct-type
MidnightDesign Aug 15, 2024
e230704
Remove default parameter value
MidnightDesign Aug 15, 2024
9a544ed
Expect specific error messages
MidnightDesign Aug 15, 2024
ab8b924
First
MidnightDesign Aug 15, 2024
f0f7036
Delete a redundant Psalm suppression
MidnightDesign Aug 15, 2024
034ecd3
Fix === with structs (compare structure instead of identity)
MidnightDesign Aug 30, 2024
3b4433c
Implement struct literals
MidnightDesign Aug 30, 2024
8d8ef83
Small fixes
MidnightDesign Sep 2, 2024
7e3bcb0
Require phpstan/phpstan 1.12
MidnightDesign Sep 2, 2024
808fda8
Add tests
MidnightDesign Sep 2, 2024
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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 32 additions & 1 deletion src/Eq.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -18,14 +22,41 @@ 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);
}

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
Expand Down
13 changes: 13 additions & 0 deletions src/Expr.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ public static function listLiteral(array $elements, Span $location): ListLiteral
return new ListLiteral($elements, $location);
}

/**
* @param array<string, Expression> $fields
*/
public static function structLiteral(array $fields, Span $location): StructLiteral
{
return new StructLiteral($fields, $location);
}

/**
* @param list<Expression> $arguments
*/
Expand Down Expand Up @@ -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 */
Expand Down
61 changes: 61 additions & 0 deletions src/FieldAccess.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace Eventjet\Ausdruck;

use Eventjet\Ausdruck\Parser\Span;

use function get_debug_type;
use function is_object;
use function property_exists;
use function sprintf;

/**
* @internal
* @psalm-internal Eventjet\Ausdruck
*/
final class FieldAccess extends Expression
{
public function __construct(
public readonly Expression $struct,
public readonly string $field,
private readonly Span $location,
) {
}

public function __toString(): string
{
return sprintf('%s.%s', $this->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];
}
}
30 changes: 30 additions & 0 deletions src/Parser/Delimiters.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Eventjet\Ausdruck\Parser;

/**
* @psalm-internal Eventjet\Ausdruck\Parser
*/
enum Delimiters
{
case CurlyBraces;
case AngleBrackets;

public function start(): string
{
return match ($this) {
self::CurlyBraces => '{ ',
self::AngleBrackets => '<',
};
}

public function end(): string
{
return match ($this) {
self::CurlyBraces => ' }',
self::AngleBrackets => '>',
};
}
}
87 changes: 82 additions & 5 deletions src/Parser/ExpressionParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<ParsedToken> $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<string>.some:bool(|item| item:string === needle:string)
* ================================================
*
* @param Peekable<ParsedToken> $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) {
Expand Down Expand Up @@ -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<ParsedToken> $tokens
* @return array{string, Span}
Expand Down Expand Up @@ -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<ParsedToken> $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<ParsedToken> $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];
}
}
2 changes: 2 additions & 0 deletions src/Parser/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ enum Token: string
case CloseBracket = ']';
case OpenAngle = '<';
case CloseAngle = '>';
case OpenBrace = '{';
case CloseBrace = '}';
case Or = '||';
case And = '&&';
case Pipe = '|';
Expand Down
29 changes: 26 additions & 3 deletions src/Parser/Tokenizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<mixed, string> $chars
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}
}
Loading
Loading