feat: implement core Lexer, Parser, and AST nodes (Phases 1-3)
This commit is contained in:
parent
7c4d5518dd
commit
10aac20afb
72
src/Config.php
Normal file
72
src/Config.php
Normal 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
298
src/Engine.php
Normal 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
209
src/Parser/Lexer.php
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/Parser/Node/BlockNode.php
Normal file
30
src/Parser/Node/BlockNode.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Parser/Node/ExtendsNode.php
Normal file
22
src/Parser/Node/ExtendsNode.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/Parser/Node/FilterLoadNode.php
Normal file
28
src/Parser/Node/FilterLoadNode.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/Parser/Node/ForeachNode.php
Normal file
38
src/Parser/Node/ForeachNode.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/Parser/Node/IncludeNode.php
Normal file
28
src/Parser/Node/IncludeNode.php
Normal 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
25
src/Parser/Node/Node.php
Normal 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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Parser/Node/ParentNode.php
Normal file
20
src/Parser/Node/ParentNode.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Parser/Node/TextNode.php
Normal file
22
src/Parser/Node/TextNode.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Parser/Node/VariableNode.php
Normal file
32
src/Parser/Node/VariableNode.php
Normal 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
383
src/Parser/Parser.php
Normal 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
29
src/Parser/Token.php
Normal 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
26
src/Parser/TokenType.php
Normal 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
60
tests/ConfigTest.php
Normal 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
105
tests/LexerTest.php
Normal 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
186
tests/ParserTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue