Skip to content

Commit

Permalink
Struct type (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
MidnightDesign authored Sep 2, 2024
1 parent b6fba9e commit 5e2405f
Show file tree
Hide file tree
Showing 20 changed files with 944 additions and 34 deletions.
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

0 comments on commit 5e2405f

Please sign in to comment.