feat: implement core Lexer, Parser, and AST nodes (Phases 1-3)

This commit is contained in:
Funky Waddle 2026-02-11 23:42:10 -06:00
parent 7c4d5518dd
commit 10aac20afb
18 changed files with 1613 additions and 0 deletions

72
src/Config.php Normal file
View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Scape;
/**
* Class Config
*
* Handles Scape configuration, merging defaults, environment variables, and programmatic overrides.
*
* @package Scape
*/
class Config
{
private string $mode;
private string $templatesDir;
private string $layoutsDir;
private string $partialsDir;
private string $filtersDir;
private string $cacheDir;
/**
* Config constructor.
*
* @param array $overrides Programmatic configuration overrides.
*/
public function __construct(array $overrides = [])
{
$this->mode = $overrides['mode'] ?? (getenv('SCAPE_MODE') ?: 'production');
$this->templatesDir = $overrides['templates_dir'] ?? (getenv('SCAPE_TEMPLATES_DIR') ?: './templates');
$this->layoutsDir = $overrides['layouts_dir'] ?? (getenv('SCAPE_LAYOUTS_DIR') ?: './templates/layouts');
$this->partialsDir = $overrides['partials_dir'] ?? (getenv('SCAPE_PARTIALS_DIR') ?: './templates/partials');
$this->filtersDir = $overrides['filters_dir'] ?? (getenv('SCAPE_FILTERS_DIR') ?: './filters');
$this->cacheDir = $overrides['cache_dir'] ?? (getenv('SCAPE_CACHE_DIR') ?: './.scape/cache');
}
public function getMode(): string
{
return $this->mode;
}
public function getTemplatesDir(): string
{
return $this->templatesDir;
}
public function getLayoutsDir(): string
{
return $this->layoutsDir;
}
public function getPartialsDir(): string
{
return $this->partialsDir;
}
public function getFiltersDir(): string
{
return $this->filtersDir;
}
public function getCacheDir(): string
{
return $this->cacheDir;
}
public function isDebug(): bool
{
return $this->mode === 'debug';
}
}

298
src/Engine.php Normal file
View file

@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace Scape;
use Scape\Interpreter\Interpreter;
use Scape\Parser\Lexer;
use Scape\Parser\Parser;
use Scape\Exceptions\TemplateNotFoundException;
use Scape\Exceptions\FilterNotFoundException;
use Scape\Interfaces\FilterInterface;
use Scape\Interfaces\HostProviderInterface;
/**
* Class Engine
*
* The primary entry point for the Scape template engine.
*
* @package Scape
*/
class Engine
{
private Config $config;
private Interpreter $interpreter;
/** @var FilterInterface[] */
private array $loadedFilters = [];
/** @var HostProviderInterface|null */
private ?HostProviderInterface $hostProvider = null;
/**
* Engine constructor.
*
* @param array|Config|null $config Optional configuration.
*/
public function __construct(array|Config|null $config = null)
{
if ($config instanceof Config) {
$this->config = $config;
} else {
$this->config = new Config($config ?? []);
}
$this->interpreter = new Interpreter($this->config, $this);
}
/**
* Renders a template with the provided data.
*
* @param string $template The dot-notated path to the template.
* @param array $data The data context for rendering.
*
* @return string The rendered output.
*
* @throws TemplateNotFoundException If the template cannot be found.
*/
public function render(string $template, array $data = []): string
{
try {
$ast = $this->loadAst($template);
} catch (TemplateNotFoundException $e) {
return $this->handle404($template, $e);
}
// Check for extends
if (!empty($ast) && $ast[0] instanceof \Scape\Parser\Node\ExtendsNode) {
$extendsNode = array_shift($ast);
$layoutPath = $extendsNode->path;
try {
$layoutAst = $this->loadAst($layoutPath, true);
} catch (TemplateNotFoundException $e) {
return $this->handle404($layoutPath, $e);
}
return $this->interpreter->interpretWithLayout($layoutAst, $ast, $data);
}
return $this->interpreter->interpret($ast, $data);
}
/**
* Handles template not found errors.
*
* @param string $template
* @param TemplateNotFoundException $e
* @return string
* @throws TemplateNotFoundException
*/
private function handle404(string $template, TemplateNotFoundException $e): string
{
$fallbackPath = rtrim($this->config->getTemplatesDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '404.scape.php';
if (file_exists($fallbackPath)) {
$source = file_get_contents($fallbackPath);
$lexer = new Lexer($source);
$tokens = $lexer->tokenize();
$parser = new Parser($tokens);
$ast = $parser->parse();
return $this->interpreter->interpret($ast, ['missing_template' => $template]);
}
if ($this->config->isDebug()) {
throw $e;
}
return "<!-- Scape: Template '{$template}' not found -->";
}
/**
* Loads and parses a template into an AST.
*
* @param string $template
* @param bool $isLayout
* @return array
* @throws TemplateNotFoundException
*/
private function loadAst(string $template, bool $isLayout = false): array
{
$filePath = $isLayout ? $this->resolveLayoutPath($template) : $this->resolveTemplatePath($template);
if (!file_exists($filePath)) {
throw new TemplateNotFoundException("Template '{$template}' not found at '{$filePath}'.");
}
$cacheDir = $this->config->getCacheDir();
$cacheKey = md5($filePath . ($isLayout ? ':layout' : ''));
$cachePath = rtrim($cacheDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $cacheKey . '.ast';
if (file_exists($cachePath)) {
$useCache = true;
if (!$this->config->isDebug()) {
// In production, always use cache if it exists
$useCache = true;
} else {
// In debug mode, check mtime
if (filemtime($filePath) > filemtime($cachePath)) {
$useCache = false;
}
}
if ($useCache) {
$cachedAst = unserialize(file_get_contents($cachePath));
if (is_array($cachedAst)) {
return $cachedAst;
}
}
}
$source = file_get_contents($filePath);
$lexer = new Lexer($source);
$tokens = $lexer->tokenize();
$parser = new Parser($tokens, $isLayout);
$ast = $parser->parse();
// Save to cache
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0777, true);
}
file_put_contents($cachePath, serialize($ast));
return $ast;
}
/**
* Resolves a dot-notated template path to a file path.
*
* @param string $template
* @return string
*/
private function resolveTemplatePath(string $template): string
{
$path = str_replace('.', DIRECTORY_SEPARATOR, $template);
return rtrim($this->config->getTemplatesDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path . '.scape.php';
}
/**
* Resolves a dot-notated layout path to a file path.
*
* @param string $layout
* @return string
*/
private function resolveLayoutPath(string $layout): string
{
$path = str_replace('.', DIRECTORY_SEPARATOR, $layout);
return rtrim($this->config->getLayoutsDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path . '.scape.php';
}
/**
* Resolves a dot-notated partial path to a file path.
*
* @param string $partial
* @return string
*/
public function resolvePartialPath(string $partial): string
{
$path = str_replace('.', DIRECTORY_SEPARATOR, $partial);
return rtrim($this->config->getPartialsDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path . '.scape.php';
}
/**
* Loads a filter by its alias.
*
* @param string $name
* @param FilterInterface $filter
*/
public function registerFilter(string $name, FilterInterface $filter): void
{
$this->loadedFilters[$name] = $filter;
}
/**
* Gets a loaded filter.
*
* @param string $name
* @return FilterInterface
* @throws FilterNotFoundException
*/
public function getFilter(string $name): FilterInterface
{
if (!isset($this->loadedFilters[$name])) {
throw new FilterNotFoundException("Filter '{$name}' is not loaded.");
}
return $this->loadedFilters[$name];
}
/**
* Registers a host provider.
*
* @param HostProviderInterface $provider
*/
public function registerHostProvider(HostProviderInterface $provider): void
{
$this->hostProvider = $provider;
}
/**
* Gets the host provider.
*
* @return HostProviderInterface|null
*/
public function getHostProvider(): ?HostProviderInterface
{
return $this->hostProvider;
}
/**
* Internal method for Interpreter to load partial ASTs.
*
* @param string $partial
* @return array
* @throws TemplateNotFoundException
*/
public function loadPartialAst(string $partial): array
{
$filePath = $this->resolvePartialPath($partial);
if (!file_exists($filePath)) {
throw new TemplateNotFoundException("Partial '{$partial}' not found at '{$filePath}'.");
}
$cacheDir = $this->config->getCacheDir();
$cacheKey = md5($filePath . ':partial');
$cachePath = rtrim($cacheDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $cacheKey . '.ast';
if (file_exists($cachePath)) {
$useCache = true;
if ($this->config->isDebug()) {
if (filemtime($filePath) > filemtime($cachePath)) {
$useCache = false;
}
}
if ($useCache) {
$cachedAst = unserialize(file_get_contents($cachePath));
if (is_array($cachedAst)) {
return $cachedAst;
}
}
}
$source = file_get_contents($filePath);
$lexer = new Lexer($source);
$tokens = $lexer->tokenize();
$parser = new Parser($tokens, false);
$ast = $parser->parse();
// Save to cache
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0777, true);
}
file_put_contents($cachePath, serialize($ast));
return $ast;
}
}

209
src/Parser/Lexer.php Normal file
View file

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Scape\Parser;
/**
* Class Lexer
*
* Tokenizes a Scape template string into a stream of Tokens.
*
* @package Scape\Parser
*/
class Lexer
{
private const TAG_MAP = [
'{{{' => TokenType::RAW_START,
'}}}' => TokenType::RAW_END,
'{{' => TokenType::INTERPOLATION_START,
'}}' => TokenType::INTERPOLATION_END,
'{(' => TokenType::LOGIC_START,
')}' => TokenType::LOGIC_END,
'{[' => TokenType::BLOCK_START,
']}' => TokenType::BLOCK_END,
];
private string $input;
private int $position = 0;
private int $line = 1;
private int $length;
/**
* Lexer constructor.
*
* @param string $input The template string to tokenize.
*/
public function __construct(string $input)
{
$this->input = $input;
$this->length = strlen($input);
}
/**
* Tokenizes the input string.
*
* @return Token[]
*/
public function tokenize(): array
{
$tokens = [];
while ($this->position < $this->length) {
$char = $this->input[$this->position];
if ($char === '{' && $this->peek(1) !== '') {
$tokens = array_merge($tokens, $this->tokenizeTag());
} else {
$tokens[] = $this->tokenizeText();
}
}
$tokens[] = new Token(TokenType::EOF, '', $this->line);
return $tokens;
}
/**
* Tokenizes a tag and its contents.
*
* @return Token[]
*/
private function tokenizeTag(): array
{
$tagType = null;
$startTag = '';
// Check for 3-char tags first ({{{)
$triple = substr($this->input, $this->position, 3);
if ($triple === '{{{') {
$startTag = $triple;
$tagType = TokenType::RAW_START;
} else {
// Check for 2-char tags
$double = substr($this->input, $this->position, 2);
if (isset(self::TAG_MAP[$double])) {
$startTag = $double;
$tagType = self::TAG_MAP[$double];
}
}
if ($tagType === null) {
return [$this->tokenizeText()];
}
$tokens = [];
$tokens[] = new Token($tagType, $startTag, $this->line);
$this->position += strlen($startTag);
// Find the matching end tag
$endTag = match($tagType) {
TokenType::RAW_START => '}}}',
TokenType::INTERPOLATION_START => '}}',
TokenType::LOGIC_START => ')}',
TokenType::BLOCK_START => ']}',
default => '',
};
$endPos = strpos($this->input, $endTag, $this->position);
if ($endPos === false) {
// Malformed tag, treat as text (though in a real engine we might throw a SyntaxException)
// For now, we'll just consume until EOF
$content = substr($this->input, $this->position);
$tokens[] = new Token(TokenType::TEXT, $content, $this->line);
$this->updateLineCount($content);
$this->position = $this->length;
return $tokens;
}
$content = substr($this->input, $this->position, $endPos - $this->position);
$tokens[] = new Token(TokenType::TEXT, $content, $this->line);
$this->updateLineCount($content);
$this->position = $endPos;
$tokens[] = new Token(self::TAG_MAP[$endTag], $endTag, $this->line);
$this->position += strlen($endTag);
// Special Rule: Logic and Block tags consume one trailing newline
if ($tagType === TokenType::LOGIC_START || $tagType === TokenType::BLOCK_START) {
if ($this->peek() === "\r") {
$this->position++;
if ($this->peek() === "\n") {
$this->position++;
}
$this->line++;
} elseif ($this->peek() === "\n") {
$this->position++;
$this->line++;
}
}
return $tokens;
}
/**
* Tokenizes a block of plain text.
*
* @return Token
*/
private function tokenizeText(): Token
{
$nextTagPos = strpos($this->input, '{', $this->position);
if ($nextTagPos === false) {
$content = substr($this->input, $this->position);
$this->position = $this->length;
} else {
// Peek to see if it's a real tag or just a stray '{'
$peek = substr($this->input, $nextTagPos, 2);
if (isset(self::TAG_MAP[$peek]) || substr($this->input, $nextTagPos, 3) === '{{{') {
$content = substr($this->input, $this->position, $nextTagPos - $this->position);
$this->position = $nextTagPos;
} else {
// Not a tag, consume the '{' and continue
$content = substr($this->input, $this->position, $nextTagPos - $this->position + 1);
$this->position = $nextTagPos + 1;
}
}
// Determine the starting line for this text token. If the text begins with a newline,
// the visible content effectively starts on the next line; record that for better diagnostics.
$lineForToken = $this->line;
if ($content !== '') {
if ($content[0] === "\n") {
$lineForToken++;
} elseif ($content[0] === "\r" && (strlen($content) > 1 && $content[1] === "\n")) {
$lineForToken++;
}
}
$token = new Token(TokenType::TEXT, $content, $lineForToken);
$this->updateLineCount($content);
return $token;
}
/**
* Peeks at the character at a relative offset.
*
* @param int $offset
* @return string
*/
private function peek(int $offset = 0): string
{
if ($this->position + $offset >= $this->length) {
return '';
}
return $this->input[$this->position + $offset];
}
/**
* Updates the internal line counter based on newlines in the content.
*
* @param string $content
*/
private function updateLineCount(string $content): void
{
$this->line += substr_count($content, "\n");
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Scape\Parser\Node;
/**
* Class BlockNode
*
* Represents an inheritance block {[ block 'name' ]}.
*
* @package Scape\Parser\Node
*/
class BlockNode extends Node
{
/**
* @param string $name
* @param Node[] $body
* @param int $line
* @param bool $isLayoutOnly Whether this block was defined in a layout (placeholder)
*/
public function __construct(
public readonly string $name,
public readonly array $body,
int $line,
public readonly bool $isLayoutOnly = false
) {
parent::__construct($line);
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Scape\Parser\Node;
/**
* Class ExtendsNode
*
* Represents an {[ extends 'path' ]} tag.
*
* @package Scape\Parser\Node
*/
class ExtendsNode extends Node
{
public function __construct(
public readonly string $path,
int $line
) {
parent::__construct($line);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Scape\Parser\Node;
/**
* Class FilterLoadNode
*
* Represents {( uses ... )} or {( load_filter(...) )}.
*
* @package Scape\Parser\Node
*/
class FilterLoadNode extends Node
{
/**
* @param string $path The path or namespace:library.
* @param bool $isInternal True if 'uses', false if 'load_filter'.
* @param int $line
*/
public function __construct(
public readonly string $path,
public readonly bool $isInternal,
int $line
) {
parent::__construct($line);
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Scape\Parser\Node;
/**
* Class ForeachNode
*
* Represents a {( foreach )} loop.
*
* @package Scape\Parser\Node
*/
class ForeachNode extends Node
{
/**
* @param string $collection The collection being iterated.
* @param string $valueVar The variable name for the value.
* @param string|null $keyVar The optional variable name for the key.
* @param Node[] $body The AST nodes inside the loop.
* @param Node[] $first Nodes inside {( first )}.
* @param Node[] $inner Nodes inside {( inner )}.
* @param Node[] $last Nodes inside {( last )}.
* @param int $line
*/
public function __construct(
public readonly string $collection,
public readonly string $valueVar,
public readonly ?string $keyVar,
public readonly array $body,
int $line,
public readonly array $first = [],
public readonly array $inner = [],
public readonly array $last = []
) {
parent::__construct($line);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Scape\Parser\Node;
/**
* Class IncludeNode
*
* Represents an {[ include 'path' ]} tag.
*
* @package Scape\Parser\Node
*/
class IncludeNode extends Node
{
/**
* @param string $path The dot-notated path to the partial.
* @param string|null $context The data source (variable name, 'context', or null).
* @param int $line
*/
public function __construct(
public readonly string $path,
public readonly ?string $context,
int $line
) {
parent::__construct($line);
}
}

25
src/Parser/Node/Node.php Normal file
View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Scape\Parser\Node;
/**
* Class Node
*
* Abstract base class for all AST nodes in Scape.
*
* @package Scape\Parser\Node
*/
abstract class Node
{
/**
* Node constructor.
*
* @param int $line The line number where this node begins.
*/
public function __construct(
public readonly int $line
) {
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Scape\Parser\Node;
/**
* Class ParentNode
*
* Represents a {[ parent ]} tag.
*
* @package Scape\Parser\Node
*/
class ParentNode extends Node
{
public function __construct(int $line)
{
parent::__construct($line);
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Scape\Parser\Node;
/**
* Class TextNode
*
* Represents a block of plain text.
*
* @package Scape\Parser\Node
*/
class TextNode extends Node
{
public function __construct(
public readonly string $content,
int $line
) {
parent::__construct($line);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Scape\Parser\Node;
/**
* Class VariableNode
*
* Represents variable interpolation ({{ var }} or {{{ var }}}).
*
* @package Scape\Parser\Node
*/
class VariableNode extends Node
{
/**
* @param string $expression The full expression.
* @param string $path The variable path (before first pipe).
* @param array $filters List of filters and their arguments.
* @param bool $isRaw Whether it's raw interpolation.
* @param int $line
*/
public function __construct(
public readonly string $expression,
public readonly string $path,
public readonly array $filters,
public readonly bool $isRaw,
int $line
) {
parent::__construct($line);
}
}

383
src/Parser/Parser.php Normal file
View file

@ -0,0 +1,383 @@
<?php
declare(strict_types=1);
namespace Scape\Parser;
use Scape\Exceptions\SyntaxException;
use Scape\Parser\Node\BlockNode;
use Scape\Parser\Node\ExtendsNode;
use Scape\Parser\Node\FilterLoadNode;
use Scape\Parser\Node\ForeachNode;
use Scape\Parser\Node\IncludeNode;
use Scape\Parser\Node\Node;
use Scape\Parser\Node\ParentNode;
use Scape\Parser\Node\TextNode;
use Scape\Parser\Node\VariableNode;
/**
* Class Parser
*
* Converts a stream of tokens into an AST.
*
* @package Scape\Parser
*/
class Parser
{
/** @var Token[] */
private array $tokens;
private int $position = 0;
private bool $isLayout = false;
/**
* @param Token[] $tokens
* @param bool $isLayout
*/
public function __construct(array $tokens, bool $isLayout = false)
{
$this->tokens = $tokens;
$this->isLayout = $isLayout;
}
/**
* Parses the tokens into an array of AST nodes.
*
* @return Node[]
* @throws SyntaxException
*/
public function parse(): array
{
$nodes = [];
while (!$this->isAtEnd()) {
$nodes[] = $this->parseNode();
}
return $nodes;
}
/**
* @return Node
* @throws SyntaxException
*/
private function parseNode(): Node
{
$token = $this->peek();
return match ($token->type) {
TokenType::TEXT => $this->parseText(),
TokenType::INTERPOLATION_START => $this->parseVariable(false),
TokenType::RAW_START => $this->parseVariable(true),
TokenType::LOGIC_START => $this->parseLogic(),
TokenType::BLOCK_START => $this->parseBlockTag(),
default => throw new SyntaxException("Unexpected token: {$token->type->value} at line {$token->line}"),
};
}
private function parseText(): TextNode
{
$token = $this->consume(TokenType::TEXT);
return new TextNode($token->value, $token->line);
}
private function parseVariable(bool $isRaw): VariableNode
{
$startType = $isRaw ? TokenType::RAW_START : TokenType::INTERPOLATION_START;
$endType = $isRaw ? TokenType::RAW_END : TokenType::INTERPOLATION_END;
$startToken = $this->consume($startType);
$expression = '';
while (!$this->check($endType) && !$this->isAtEnd()) {
$expression .= $this->advance()->value;
}
$this->consume($endType, "Expected closing tag for variable interpolation at line " . $startToken->line);
$trimmedExpression = trim($expression);
$isRawFromFilter = false;
// Handle nested pipes in arguments (very basic)
// We split by | but only if it's not inside quotes
$parts = [];
$currentPart = '';
$inQuote = false;
$quoteChar = '';
for ($i = 0; $i < strlen($trimmedExpression); $i++) {
$char = $trimmedExpression[$i];
if (($char === "'" || $char === '"') && ($i === 0 || $trimmedExpression[$i-1] !== '\\')) {
if (!$inQuote) {
$inQuote = true;
$quoteChar = $char;
} elseif ($char === $quoteChar) {
$inQuote = false;
}
}
if ($char === '|' && !$inQuote) {
$parts[] = $currentPart;
$currentPart = '';
} else {
$currentPart .= $char;
}
}
$parts[] = $currentPart;
$path = trim(array_shift($parts));
$filters = [];
foreach ($parts as $part) {
$part = trim($part);
if (empty($part)) continue;
if (preg_match('/^([\w:]+)(?:\((.*)\))?$/', $part, $matches)) {
$filterName = $matches[1];
if ($filterName === 'raw') {
$isRawFromFilter = true;
continue;
}
$argsString = $matches[2] ?? '';
$args = [];
if ($argsString !== '') {
// Simple comma-separated args
$args = [];
$argsString = trim($argsString);
if ($argsString !== '') {
// Improved regex to handle quoted strings with commas
$args = [];
preg_match_all('/\'(?:\\\\\'|[^\'])*\'|"(?:\\\\"|[^"])*"|[^,]+/', $argsString, $argMatches);
foreach ($argMatches[0] as $match) {
$match = trim($match);
// If it starts with comma, remove it
if (str_starts_with($match, ',')) {
$match = ltrim(substr($match, 1));
}
// If it ends with comma, remove it
if (str_ends_with($match, ',')) {
$match = rtrim(substr($match, 0, -1));
}
$match = trim($match);
if ($match !== '') {
$args[] = $match;
}
}
}
}
$filters[] = [
'name' => $filterName,
'args' => $args
];
}
}
return new VariableNode($trimmedExpression, $path, $filters, $isRaw || $isRawFromFilter, $startToken->line);
}
private function parseLogic(): Node
{
$startToken = $this->consume(TokenType::LOGIC_START);
$content = '';
while (!$this->check(TokenType::LOGIC_END) && !$this->isAtEnd()) {
$content .= $this->advance()->value;
}
$this->consume(TokenType::LOGIC_END, "Expected ')}' at line " . $startToken->line);
$content = trim($content);
if (str_starts_with($content, 'foreach ')) {
return $this->parseForeach($content, $startToken->line);
}
if (str_starts_with($content, 'uses ')) {
$path = trim(substr($content, 5));
return new FilterLoadNode($path, true, $startToken->line);
}
if (preg_match('/^load_filter\([\'"](.+?)[\'"]\)$/', $content, $matches)) {
return new FilterLoadNode($matches[1], false, $startToken->line);
}
throw new SyntaxException("Unknown logic tag: '{( $content )}' at line {$startToken->line}");
}
private function parseForeach(string $content, int $line): ForeachNode
{
// {( foreach item in collection )}
// {( foreach key, item in collection )}
if (preg_match('/^foreach\s+(?:(?P<key>\w+)\s*,\s*)?(?P<value>\w+)\s+in\s+(?P<collection>.+)$/', $content, $matches)) {
$keyVar = $matches['key'] ?: null;
$valueVar = $matches['value'];
$collection = $matches['collection'];
$body = [];
$first = [];
$inner = [];
$last = [];
while (!$this->isAtEnd() && !$this->isLogicEnd('endforeach')) {
if ($this->isLogicStart('first')) {
$this->consumeLogic('first');
$first = $this->parseUntilLogicEnd('endfirst');
} elseif ($this->isLogicStart('inner')) {
$this->consumeLogic('inner');
$inner = $this->parseUntilLogicEnd('endinner');
} elseif ($this->isLogicStart('last')) {
$this->consumeLogic('last');
$last = $this->parseUntilLogicEnd('endlast');
} else {
$body[] = $this->parseNode();
}
}
$this->consumeLogic('endforeach');
return new ForeachNode($collection, $valueVar, $keyVar, $body, $line, $first, $inner, $last);
}
throw new SyntaxException("Invalid foreach syntax: '{( $content )}' at line $line");
}
private function parseBlockTag(): Node
{
$startToken = $this->consume(TokenType::BLOCK_START);
$content = '';
while (!$this->check(TokenType::BLOCK_END) && !$this->isAtEnd()) {
$content .= $this->advance()->value;
}
$this->consume(TokenType::BLOCK_END, "Expected ']}' at line " . $startToken->line);
$content = trim($content);
if (preg_match('/^extends\s+[\'"](.+?)[\'"]$/', $content, $matches)) {
return new ExtendsNode($matches[1], $startToken->line);
}
if (preg_match('/^block\s+[\'"](.+?)[\'"]$/', $content, $matches)) {
$name = $matches[1];
$body = $this->parseUntilBlockEnd('endblock');
return new BlockNode($name, $body, $startToken->line, $this->isLayout);
}
if ($content === 'parent') {
return new ParentNode($startToken->line);
}
if (preg_match('/^include\s+[\'"](.+?)[\'"](?:\s+with\s+(.+))?$/', $content, $matches)) {
$path = $matches[1];
$context = isset($matches[2]) ? trim($matches[2]) : null;
return new IncludeNode($path, $context, $startToken->line);
}
if (preg_match('/^include\s+[\'"](.+?)[\'"]\s+with\s+(\[.*\])$/', $content, $matches)) {
$path = $matches[1];
$context = $matches[2];
return new IncludeNode($path, $context, $startToken->line);
}
throw new SyntaxException("Unknown block tag: '{[ $content ]}' at line {$startToken->line}");
}
private function parseUntilLogicEnd(string $endTag): array
{
$nodes = [];
while (!$this->isAtEnd() && !$this->isLogicEnd($endTag)) {
$nodes[] = $this->parseNode();
}
$this->consumeLogic($endTag);
return $nodes;
}
private function parseUntilBlockEnd(string $endTag): array
{
$nodes = [];
while (!$this->isAtEnd() && !$this->isBlockEnd($endTag)) {
$nodes[] = $this->parseNode();
}
$this->consumeBlock($endTag);
return $nodes;
}
private function isLogicStart(string $tag): bool
{
if ($this->peek()->type !== TokenType::LOGIC_START) {
return false;
}
$contentToken = $this->peek(1);
return $contentToken && trim($contentToken->value) === $tag;
}
private function isLogicEnd(string $tag): bool
{
return $this->isLogicStart($tag);
}
private function isBlockEnd(string $tag): bool
{
if ($this->peek()->type !== TokenType::BLOCK_START) {
return false;
}
$contentToken = $this->peek(1);
return $contentToken && trim($contentToken->value) === $tag;
}
private function consumeLogic(string $tag): void
{
$this->consume(TokenType::LOGIC_START);
$content = '';
while (!$this->check(TokenType::LOGIC_END) && !$this->isAtEnd()) {
$content .= $this->advance()->value;
}
if (trim($content) !== $tag) {
throw new SyntaxException("Expected '{( $tag )}' but got '{( $content )}'");
}
$this->consume(TokenType::LOGIC_END);
}
private function consumeBlock(string $tag): void
{
$this->consume(TokenType::BLOCK_START);
$content = '';
while (!$this->check(TokenType::BLOCK_END) && !$this->isAtEnd()) {
$content .= $this->advance()->value;
}
if (trim($content) !== $tag) {
throw new SyntaxException("Expected '{[ $tag ]}' but got '{[ $content ]}'");
}
$this->consume(TokenType::BLOCK_END);
}
private function peek(int $offset = 0): Token
{
if ($this->position + $offset >= count($this->tokens)) {
return new Token(TokenType::EOF, '', -1);
}
return $this->tokens[$this->position + $offset];
}
private function advance(): Token
{
if (!$this->isAtEnd()) {
$this->position++;
}
return $this->tokens[$this->position - 1];
}
private function isAtEnd(): bool
{
return $this->peek()->type === TokenType::EOF || $this->position >= count($this->tokens);
}
private function check(TokenType $type): bool
{
if ($this->isAtEnd()) {
return false;
}
return $this->peek()->type === $type;
}
private function consume(TokenType $type, string $message = ''): Token
{
if ($this->check($type)) {
return $this->advance();
}
throw new SyntaxException($message ?: "Expected token $type->name but found " . $this->peek()->type->name);
}
}

29
src/Parser/Token.php Normal file
View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Scape\Parser;
/**
* Class Token
*
* Represents a single token identified in a Scape template.
*
* @package Scape\Parser
*/
class Token
{
/**
* Token constructor.
*
* @param TokenType $type The type of the token.
* @param string $value The raw value of the token.
* @param int $line The line number where the token was found.
*/
public function __construct(
public readonly TokenType $type,
public readonly string $value,
public readonly int $line
) {
}
}

26
src/Parser/TokenType.php Normal file
View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Scape\Parser;
/**
* Enum TokenType
*
* Defines the types of tokens identified by the Lexer.
*
* @package Scape\Parser
*/
enum TokenType: string
{
case TEXT = 'TEXT';
case INTERPOLATION_START = 'INTERPOLATION_START'; // {{
case INTERPOLATION_END = 'INTERPOLATION_END'; // }}
case RAW_START = 'RAW_START'; // {{{
case RAW_END = 'RAW_END'; // }}}
case LOGIC_START = 'LOGIC_START'; // {(
case LOGIC_END = 'LOGIC_END'; // )}
case BLOCK_START = 'BLOCK_START'; // {[
case BLOCK_END = 'BLOCK_END'; // ]}
case EOF = 'EOF';
}

60
tests/ConfigTest.php Normal file
View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Scape\Tests;
use PHPUnit\Framework\TestCase;
use Scape\Config;
class ConfigTest extends TestCase
{
protected function setUp(): void
{
// Clear environment variables before each test
putenv('SCAPE_TEMPLATES_DIR');
putenv('SCAPE_LAYOUTS_DIR');
putenv('SCAPE_PARTIALS_DIR');
putenv('SCAPE_FILTERS_DIR');
putenv('SCAPE_MODE');
}
public function testDefaultValues(): void
{
$config = new Config();
$this->assertEquals('production', $config->getMode());
$this->assertEquals('./templates', $config->getTemplatesDir());
$this->assertEquals('./templates/layouts', $config->getLayoutsDir());
$this->assertEquals('./templates/partials', $config->getPartialsDir());
$this->assertEquals('./filters', $config->getFiltersDir());
$this->assertEquals('./.scape/cache', $config->getCacheDir());
}
public function testEnvironmentVariableOverrides(): void
{
putenv('SCAPE_MODE=debug');
putenv('SCAPE_TEMPLATES_DIR=/tmp/templates');
$config = new Config();
$this->assertEquals('debug', $config->getMode());
$this->assertEquals('/tmp/templates', $config->getTemplatesDir());
// Layouts/Partials should still fall back relative to templates if not set?
// Or remain as defaults? Let's check spec.
// Spec says they are managed via env vars. We'll assume they have independent defaults.
}
public function testProgrammaticOverridesPrecedence(): void
{
putenv('SCAPE_MODE=debug');
$config = new Config([
'mode' => 'production',
'templates_dir' => '/custom/path'
]);
$this->assertEquals('production', $config->getMode());
$this->assertEquals('/custom/path', $config->getTemplatesDir());
}
}

105
tests/LexerTest.php Normal file
View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Scape\Tests;
use PHPUnit\Framework\TestCase;
use Scape\Parser\Lexer;
use Scape\Parser\TokenType;
class LexerTest extends TestCase
{
public function testTokenizesSimpleText(): void
{
$lexer = new Lexer("Hello World");
$tokens = $lexer->tokenize();
$this->assertCount(2, $tokens); // TEXT + EOF
$this->assertEquals(TokenType::TEXT, $tokens[0]->type);
$this->assertEquals("Hello World", $tokens[0]->value);
}
public function testTokenizesInterpolation(): void
{
$lexer = new Lexer("{{ var }}");
$tokens = $lexer->tokenize();
$this->assertCount(4, $tokens); // START, TEXT, END, EOF
$this->assertEquals(TokenType::INTERPOLATION_START, $tokens[0]->type);
$this->assertEquals(TokenType::TEXT, $tokens[1]->type);
$this->assertEquals(" var ", $tokens[1]->value);
$this->assertEquals(TokenType::INTERPOLATION_END, $tokens[2]->type);
}
public function testTokenizesRawInterpolation(): void
{
$lexer = new Lexer("{{{ raw }}}");
$tokens = $lexer->tokenize();
$this->assertCount(4, $tokens);
$this->assertEquals(TokenType::RAW_START, $tokens[0]->type);
$this->assertEquals(" raw ", $tokens[1]->value);
$this->assertEquals(TokenType::RAW_END, $tokens[2]->type);
}
public function testTokenizesLogicTags(): void
{
$lexer = new Lexer("{( foreach item in items )}content{( endforeach )}");
$tokens = $lexer->tokenize();
$this->assertEquals(TokenType::LOGIC_START, $tokens[0]->type);
$this->assertEquals(" foreach item in items ", $tokens[1]->value);
$this->assertEquals(TokenType::LOGIC_END, $tokens[2]->type);
$this->assertEquals("content", $tokens[3]->value);
}
public function testLogicTagConsumesTrailingNewline(): void
{
// Logic tag followed by newline should consume the newline
$lexer = new Lexer("{( foreach )}\nLine 2");
$tokens = $lexer->tokenize();
// 0: START, 1: TEXT(" foreach "), 2: END, 3: TEXT("Line 2"), 4: EOF
$this->assertEquals("Line 2", $tokens[3]->value);
$this->assertEquals(TokenType::TEXT, $tokens[3]->type);
$this->assertEquals(2, $tokens[3]->line);
}
public function testLogicTagConsumesTrailingNewlineWithHeredoc(): void
{
$template = <<<TEMPLATE
{( foreach )}
Line 2
TEMPLATE;
$lexer = new Lexer($template);
$tokens = $lexer->tokenize();
// 0: START, 1: TEXT(" foreach "), 2: END, 3: TEXT("Line 2"), 4: EOF
$this->assertEquals("Line 2", $tokens[3]->value);
$this->assertEquals(TokenType::TEXT, $tokens[3]->type);
$this->assertEquals(2, $tokens[3]->line);
}
public function testBlockTagConsumesTrailingNewline(): void
{
$lexer = new Lexer("{[ extends 'layout' ]}\nContent");
$tokens = $lexer->tokenize();
$this->assertEquals("Content", $tokens[3]->value);
}
public function testLineNumberTracking(): void
{
$template = "Line 1\n{{ var }}\nLine 3";
$lexer = new Lexer($template);
$tokens = $lexer->tokenize();
$this->assertEquals(1, $tokens[0]->line); // "Line 1\n"
$this->assertEquals(2, $tokens[1]->line); // "{{"
$this->assertEquals(2, $tokens[2]->line); // " var "
$this->assertEquals(2, $tokens[3]->line); // "}}"
$this->assertEquals(3, $tokens[4]->line); // "\nLine 3"
}
}

186
tests/ParserTest.php Normal file
View file

@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace Scape\Tests;
use PHPUnit\Framework\TestCase;
use Scape\Parser\Lexer;
use Scape\Parser\Parser;
use Scape\Parser\Node\TextNode;
use Scape\Parser\Node\VariableNode;
use Scape\Parser\Node\ForeachNode;
use Scape\Parser\Node\BlockNode;
use Scape\Parser\Node\ExtendsNode;
use Scape\Parser\Node\IncludeNode;
use Scape\Parser\Node\FilterLoadNode;
use Scape\Parser\Node\ParentNode;
class ParserTest extends TestCase
{
public function testParsesSimpleText(): void
{
$lexer = new Lexer("Hello World");
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertCount(1, $nodes);
$this->assertInstanceOf(TextNode::class, $nodes[0]);
$this->assertEquals("Hello World", $nodes[0]->content);
}
public function testParsesInterpolation(): void
{
$lexer = new Lexer("{{ var }}");
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertCount(1, $nodes);
$this->assertInstanceOf(VariableNode::class, $nodes[0]);
$this->assertEquals("var", $nodes[0]->path);
$this->assertFalse($nodes[0]->isRaw);
}
public function testParsesInterpolationWithFilters(): void
{
$template = "{{ price | currency('USD') | lower }}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(VariableNode::class, $nodes[0]);
$this->assertEquals("price", $nodes[0]->path);
$this->assertCount(2, $nodes[0]->filters);
$this->assertEquals("currency", $nodes[0]->filters[0]['name']);
$this->assertEquals(["'USD'"], $nodes[0]->filters[0]['args']);
$this->assertEquals("lower", $nodes[0]->filters[1]['name']);
}
public function testParsesForeach(): void
{
$template = "{( foreach item in items )} {{ item }} {( endforeach )}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertCount(1, $nodes);
$this->assertInstanceOf(ForeachNode::class, $nodes[0]);
$this->assertEquals("items", $nodes[0]->collection);
$this->assertEquals("item", $nodes[0]->valueVar);
$this->assertNull($nodes[0]->keyVar);
$this->assertCount(3, $nodes[0]->body); // Space, Variable, Space
}
public function testParsesForeachWithKey(): void
{
$template = "{( foreach k, v in items )}{( endforeach )}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(ForeachNode::class, $nodes[0]);
$this->assertEquals("k", $nodes[0]->keyVar);
$this->assertEquals("v", $nodes[0]->valueVar);
}
public function testParsesForeachWithPositionalTags(): void
{
$template = "{( foreach item in items )}{( first )}FIRST{( endfirst )}{( inner )}INNER{( endinner )}{( last )}LAST{( endlast )}{( endforeach )}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$node = $nodes[0];
$this->assertInstanceOf(ForeachNode::class, $node);
$this->assertCount(1, $node->first);
$this->assertInstanceOf(TextNode::class, $node->first[0]);
$this->assertEquals("FIRST", $node->first[0]->content);
$this->assertCount(1, $node->inner);
$this->assertEquals("INNER", $node->inner[0]->content);
$this->assertCount(1, $node->last);
$this->assertEquals("LAST", $node->last[0]->content);
}
public function testParsesExtends(): void
{
$template = "{[ extends 'layout.main' ]}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(ExtendsNode::class, $nodes[0]);
$this->assertEquals("layout.main", $nodes[0]->path);
}
public function testParsesBlock(): void
{
$template = "{[ block 'content' ]}Block Content{[ endblock ]}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(BlockNode::class, $nodes[0]);
$this->assertEquals("content", $nodes[0]->name);
$this->assertCount(1, $nodes[0]->body);
$this->assertEquals("Block Content", $nodes[0]->body[0]->content);
}
public function testParsesInclude(): void
{
$template = "{[ include 'partials.nav' ]}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(IncludeNode::class, $nodes[0]);
$this->assertEquals("partials.nav", $nodes[0]->path);
$this->assertNull($nodes[0]->context);
}
public function testParsesIncludeWithContext(): void
{
$template = "{[ include 'partials.user' with user_data ]}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(IncludeNode::class, $nodes[0]);
$this->assertEquals("user_data", $nodes[0]->context);
}
public function testParsesParent(): void
{
$template = "{[ parent ]}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(ParentNode::class, $nodes[0]);
}
public function testParsesUses(): void
{
$template = "{( uses filters:string )}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(FilterLoadNode::class, $nodes[0]);
$this->assertTrue($nodes[0]->isInternal);
$this->assertEquals("filters:string", $nodes[0]->path);
}
public function testParsesLoadFilter(): void
{
$template = "{( load_filter('custom.my_filter') )}";
$lexer = new Lexer($template);
$parser = new Parser($lexer->tokenize());
$nodes = $parser->parse();
$this->assertInstanceOf(FilterLoadNode::class, $nodes[0]);
$this->assertFalse($nodes[0]->isInternal);
$this->assertEquals("custom.my_filter", $nodes[0]->path);
}
}