425 lines
14 KiB
PHP
425 lines
14 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace Eyrie\Parser;
|
||
|
|
|
||
|
|
use Eyrie\Parser\Node\Node;
|
||
|
|
use Eyrie\Parser\Node\RootNode;
|
||
|
|
use Eyrie\Parser\Node\TextNode;
|
||
|
|
use Eyrie\Parser\Node\ExpressionNode;
|
||
|
|
use Eyrie\Parser\Node\IfNode;
|
||
|
|
use Eyrie\Parser\Node\ForeachNode;
|
||
|
|
use Eyrie\Parser\Node\LoopNode;
|
||
|
|
use Eyrie\Parser\Node\ExtendsNode;
|
||
|
|
use Eyrie\Parser\Node\BlockNode;
|
||
|
|
use Eyrie\Parser\Node\SuperNode;
|
||
|
|
use Eyrie\Parser\Node\IncludeNode;
|
||
|
|
use Eyrie\Parser\Node\ComponentNode;
|
||
|
|
|
||
|
|
class Parser
|
||
|
|
{
|
||
|
|
private array $tokens;
|
||
|
|
private int $cursor = 0;
|
||
|
|
|
||
|
|
public function __construct(array $tokens)
|
||
|
|
{
|
||
|
|
$this->tokens = $tokens;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function parse(): RootNode
|
||
|
|
{
|
||
|
|
$root = new RootNode();
|
||
|
|
|
||
|
|
while (!$this->isEOF()) {
|
||
|
|
$node = $this->parseNode();
|
||
|
|
if ($node) {
|
||
|
|
$root->children[] = $node;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return $root;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function parseNode(): ?Node
|
||
|
|
{
|
||
|
|
$token = $this->peek();
|
||
|
|
|
||
|
|
if ($token->type === TokenType::TEXT) {
|
||
|
|
$this->consume();
|
||
|
|
return new TextNode($token->value, $token->line);
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($token->type === TokenType::OUTPUT_START) {
|
||
|
|
return $this->parseOutput();
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($token->type === TokenType::CONTROL_START) {
|
||
|
|
return $this->parseControl();
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($token->type === TokenType::BLOCK_START) {
|
||
|
|
return $this->parseBlockTag();
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($token->type === TokenType::COMPONENT_START) {
|
||
|
|
return $this->parseComponent();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Placeholder for other tags
|
||
|
|
$this->consume();
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function parseOutput(): ExpressionNode
|
||
|
|
{
|
||
|
|
$startToken = $this->consume(TokenType::OUTPUT_START);
|
||
|
|
$expression = '';
|
||
|
|
$filters = [];
|
||
|
|
|
||
|
|
while (!$this->isEOF() && $this->peek()->type !== TokenType::OUTPUT_END) {
|
||
|
|
if ($this->peek()->value === '|') {
|
||
|
|
$this->consume(); // |
|
||
|
|
$filterName = $this->consume(TokenType::IDENTIFIER)->value;
|
||
|
|
$args = [];
|
||
|
|
if ($this->peek()->value === '(') {
|
||
|
|
$this->consume(); // (
|
||
|
|
while ($this->peek()->value !== ')') {
|
||
|
|
$args[] = $this->consume()->value;
|
||
|
|
if ($this->peek()->value === ',') $this->consume();
|
||
|
|
}
|
||
|
|
$this->consume(); // )
|
||
|
|
}
|
||
|
|
$filters[] = ['name' => $filterName, 'args' => $args];
|
||
|
|
} else {
|
||
|
|
$expression .= $this->consume()->value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->consume(TokenType::OUTPUT_END);
|
||
|
|
|
||
|
|
return new ExpressionNode(trim($expression), $startToken->line, $filters);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function parseControl(): ?Node
|
||
|
|
{
|
||
|
|
$this->consume(TokenType::CONTROL_START);
|
||
|
|
$token = $this->peek();
|
||
|
|
|
||
|
|
if ($token->value === 'if') {
|
||
|
|
return $this->parseIf();
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($token->value === 'foreach') {
|
||
|
|
return $this->parseForeach();
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($token->value === 'loop') {
|
||
|
|
return $this->parseLoop();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle other control structures
|
||
|
|
while (!$this->isEOF() && $this->peek()->type !== TokenType::CONTROL_END) {
|
||
|
|
$this->consume();
|
||
|
|
}
|
||
|
|
$this->consume(TokenType::CONTROL_END);
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function parseIf(): IfNode
|
||
|
|
{
|
||
|
|
$startToken = $this->consume(); // consume 'if'
|
||
|
|
$condition = '';
|
||
|
|
while ($this->peek()->type !== TokenType::CONTROL_END) {
|
||
|
|
$condition .= $this->consume()->value . ' ';
|
||
|
|
}
|
||
|
|
$this->consume(TokenType::CONTROL_END);
|
||
|
|
|
||
|
|
$body = [];
|
||
|
|
$elseifs = [];
|
||
|
|
$else = null;
|
||
|
|
|
||
|
|
while (!$this->isEOF()) {
|
||
|
|
$token = $this->peek();
|
||
|
|
|
||
|
|
if ($token->type === TokenType::CONTROL_START) {
|
||
|
|
$next = $this->tokens[$this->cursor + 1] ?? null;
|
||
|
|
if ($next && $next->value === 'endif') {
|
||
|
|
$this->consume(TokenType::CONTROL_START);
|
||
|
|
$this->consume(); // endif
|
||
|
|
$this->consume(TokenType::CONTROL_END);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
if ($next && $next->value === 'elseif') {
|
||
|
|
$this->consume(TokenType::CONTROL_START);
|
||
|
|
$this->consume(); // elseif
|
||
|
|
$elseifCond = '';
|
||
|
|
while ($this->peek()->type !== TokenType::CONTROL_END) {
|
||
|
|
$elseifCond .= $this->consume()->value . ' ';
|
||
|
|
}
|
||
|
|
$this->consume(TokenType::CONTROL_END);
|
||
|
|
$elseifBody = [];
|
||
|
|
while (!$this->isEOF()) {
|
||
|
|
$t = $this->peek();
|
||
|
|
if ($t->type === TokenType::CONTROL_START) {
|
||
|
|
$n = $this->tokens[$this->cursor + 1] ?? null;
|
||
|
|
if ($n && in_array($n->value, ['elseif', 'else', 'endif'])) break;
|
||
|
|
}
|
||
|
|
$elseifBody[] = $this->parseNode();
|
||
|
|
}
|
||
|
|
$elseifs[] = ['condition' => trim($elseifCond), 'body' => array_filter($elseifBody)];
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if ($next && $next->value === 'else') {
|
||
|
|
$this->consume(TokenType::CONTROL_START);
|
||
|
|
$this->consume(); // else
|
||
|
|
$this->consume(TokenType::CONTROL_END);
|
||
|
|
$elseBody = [];
|
||
|
|
while (!$this->isEOF()) {
|
||
|
|
$t = $this->peek();
|
||
|
|
if ($t->type === TokenType::CONTROL_START) {
|
||
|
|
$n = $this->tokens[$this->cursor + 1] ?? null;
|
||
|
|
if ($n && $n->value === 'endif') break;
|
||
|
|
}
|
||
|
|
$elseBody[] = $this->parseNode();
|
||
|
|
}
|
||
|
|
$else = array_filter($elseBody);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$body[] = $this->parseNode();
|
||
|
|
}
|
||
|
|
|
||
|
|
return new IfNode(trim($condition), array_filter($body), $elseifs, $else, $startToken->line);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function parseForeach(): ForeachNode
|
||
|
|
{
|
||
|
|
$startToken = $this->consume(); // consume 'foreach'
|
||
|
|
$item = $this->consume(TokenType::IDENTIFIER)->value;
|
||
|
|
$this->consume(TokenType::IDENTIFIER); // consume 'in'
|
||
|
|
|
||
|
|
$items = '';
|
||
|
|
while ($this->peek()->type !== TokenType::CONTROL_END) {
|
||
|
|
$items .= $this->consume()->value . ' ';
|
||
|
|
}
|
||
|
|
$this->consume(TokenType::CONTROL_END);
|
||
|
|
|
||
|
|
$body = [];
|
||
|
|
while (!$this->isEOF()) {
|
||
|
|
$token = $this->peek();
|
||
|
|
if ($token->type === TokenType::CONTROL_START) {
|
||
|
|
$next = $this->tokens[$this->cursor + 1] ?? null;
|
||
|
|
if ($next && $next->value === 'endforeach') {
|
||
|
|
$this->consume(TokenType::CONTROL_START);
|
||
|
|
$this->consume(); // endforeach
|
||
|
|
$this->consume(TokenType::CONTROL_END);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$body[] = $this->parseNode();
|
||
|
|
}
|
||
|
|
|
||
|
|
return new ForeachNode(trim($items), $item, array_filter($body), $startToken->line);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function parseLoop(): LoopNode
|
||
|
|
{
|
||
|
|
$startToken = $this->consume(); // consume 'loop'
|
||
|
|
$this->consume(TokenType::IDENTIFIER); // consume 'from'
|
||
|
|
|
||
|
|
$from = '';
|
||
|
|
while ($this->peek()->value !== 'to') {
|
||
|
|
$from .= $this->consume()->value . ' ';
|
||
|
|
}
|
||
|
|
$this->consume(TokenType::IDENTIFIER); // consume 'to'
|
||
|
|
|
||
|
|
$to = '';
|
||
|
|
while ($this->peek()->type !== TokenType::CONTROL_END) {
|
||
|
|
$to .= $this->consume()->value . ' ';
|
||
|
|
}
|
||
|
|
$this->consume(TokenType::CONTROL_END);
|
||
|
|
|
||
|
|
$body = [];
|
||
|
|
while (!$this->isEOF()) {
|
||
|
|
$token = $this->peek();
|
||
|
|
if ($token->type === TokenType::CONTROL_START) {
|
||
|
|
$next = $this->tokens[$this->cursor + 1] ?? null;
|
||
|
|
if ($next && $next->value === 'endloop') {
|
||
|
|
$this->consume(TokenType::CONTROL_START);
|
||
|
|
$this->consume(); // endloop
|
||
|
|
$this->consume(TokenType::CONTROL_END);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$body[] = $this->parseNode();
|
||
|
|
}
|
||
|
|
|
||
|
|
return new LoopNode(trim($from), trim($to), array_filter($body), $startToken->line);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function parseBlockTag(): ?Node
|
||
|
|
{
|
||
|
|
$this->consume(TokenType::BLOCK_START);
|
||
|
|
$token = $this->peek();
|
||
|
|
|
||
|
|
if ($token->value === 'extends') {
|
||
|
|
return $this->parseExtends();
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($token->value === 'block') {
|
||
|
|
return $this->parseBlock();
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($token->value === 'super') {
|
||
|
|
return $this->parseSuper();
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($token->value === 'include') {
|
||
|
|
return $this->parseInclude();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle other block tags (e.g. include, super)
|
||
|
|
while (!$this->isEOF() && $this->peek()->type !== TokenType::BLOCK_END) {
|
||
|
|
$this->consume();
|
||
|
|
}
|
||
|
|
$this->consume(TokenType::BLOCK_END);
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function parseExtends(): ExtendsNode
|
||
|
|
{
|
||
|
|
$startToken = $this->consume(); // extends
|
||
|
|
$layout = $this->consume(TokenType::STRING)->value;
|
||
|
|
$layout = substr($layout, 1, -1); // Strip quotes
|
||
|
|
$this->consume(TokenType::BLOCK_END);
|
||
|
|
|
||
|
|
return new ExtendsNode($layout, $startToken->line);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function parseBlock(): BlockNode
|
||
|
|
{
|
||
|
|
$startToken = $this->consume(); // block
|
||
|
|
$name = $this->consume(TokenType::IDENTIFIER)->value;
|
||
|
|
$context = [];
|
||
|
|
|
||
|
|
if ($this->peek()->value === 'with') {
|
||
|
|
$this->consume(); // with
|
||
|
|
if ($this->peek()->value === '{') {
|
||
|
|
$this->consume(); // {
|
||
|
|
while (!$this->isEOF() && $this->peek()->value !== '}') {
|
||
|
|
$propName = $this->consume(TokenType::IDENTIFIER)->value;
|
||
|
|
$this->consume(TokenType::OPERATOR); // :
|
||
|
|
|
||
|
|
// Consume the value correctly
|
||
|
|
$valueToken = $this->consume();
|
||
|
|
$expr = $valueToken->value;
|
||
|
|
|
||
|
|
$context[$propName] = ['expr' => $expr];
|
||
|
|
if ($this->peek()->value === ',') {
|
||
|
|
$this->consume();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if ($this->peek()->value === '}') {
|
||
|
|
$this->consume(); // }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->consume(TokenType::BLOCK_END);
|
||
|
|
|
||
|
|
$body = [];
|
||
|
|
while (!$this->isEOF()) {
|
||
|
|
$token = $this->peek();
|
||
|
|
if ($token->type === TokenType::BLOCK_START) {
|
||
|
|
$next = $this->tokens[$this->cursor + 1] ?? null;
|
||
|
|
if ($next && $next->value === 'endblock') {
|
||
|
|
$this->consume(TokenType::BLOCK_START);
|
||
|
|
$this->consume(); // endblock
|
||
|
|
$this->consume(TokenType::BLOCK_END);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$body[] = $this->parseNode();
|
||
|
|
}
|
||
|
|
|
||
|
|
return new BlockNode($name, array_filter($body), $context, $startToken->line);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function parseSuper(): SuperNode
|
||
|
|
{
|
||
|
|
$startToken = $this->consume(); // super
|
||
|
|
$this->consume(TokenType::BLOCK_END);
|
||
|
|
|
||
|
|
return new SuperNode($startToken->line);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function parseInclude(): IncludeNode
|
||
|
|
{
|
||
|
|
$startToken = $this->consume(); // include
|
||
|
|
$template = $this->consume(TokenType::STRING)->value;
|
||
|
|
$template = substr($template, 1, -1); // Strip quotes
|
||
|
|
$this->consume(TokenType::BLOCK_END);
|
||
|
|
|
||
|
|
return new IncludeNode($template, [], $startToken->line);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function parseComponent(): ComponentNode
|
||
|
|
{
|
||
|
|
$startToken = $this->consume(TokenType::COMPONENT_START);
|
||
|
|
$name = $this->consume(TokenType::IDENTIFIER)->value;
|
||
|
|
$props = [];
|
||
|
|
|
||
|
|
while ($this->peek()->type !== TokenType::COMPONENT_END) {
|
||
|
|
$propName = $this->consume(TokenType::IDENTIFIER)->value;
|
||
|
|
$this->consume(TokenType::OPERATOR); // =
|
||
|
|
|
||
|
|
$token = $this->peek();
|
||
|
|
if ($token->type === TokenType::STRING) {
|
||
|
|
$val = $this->consume()->value;
|
||
|
|
$props[$propName] = substr($val, 1, -1); // Strip quotes
|
||
|
|
} elseif ($token->value === '{') {
|
||
|
|
$this->consume(); // {
|
||
|
|
$expr = '';
|
||
|
|
while ($this->peek()->value !== '}') {
|
||
|
|
$exprToken = $this->consume();
|
||
|
|
$expr .= $exprToken->value;
|
||
|
|
}
|
||
|
|
$this->consume(); // }
|
||
|
|
$props[$propName] = ['expr' => trim($expr)];
|
||
|
|
} else {
|
||
|
|
$props[$propName] = $this->consume()->value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->consume(TokenType::COMPONENT_END);
|
||
|
|
|
||
|
|
return new ComponentNode($name, $props, $startToken->line);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function peek(): Token
|
||
|
|
{
|
||
|
|
return $this->tokens[$this->cursor];
|
||
|
|
}
|
||
|
|
|
||
|
|
private function consume(?TokenType $type = null): Token
|
||
|
|
{
|
||
|
|
$token = $this->peek();
|
||
|
|
|
||
|
|
if ($type !== null && $token->type !== $type) {
|
||
|
|
throw new \RuntimeException(sprintf('Expected token %s, got %s at line %d', $type->value, $token->type->value, $token->line));
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->cursor++;
|
||
|
|
return $token;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function isEOF(): bool
|
||
|
|
{
|
||
|
|
return $this->peek()->type === TokenType::EOF;
|
||
|
|
}
|
||
|
|
}
|