feat: implement Interpreter with inheritance and partials support (Phases 4-5)
This commit is contained in:
parent
10aac20afb
commit
439e4b99fb
395
src/Interpreter/Interpreter.php
Normal file
395
src/Interpreter/Interpreter.php
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Scape\Interpreter;
|
||||||
|
|
||||||
|
use Scape\Engine;
|
||||||
|
use Scape\Config;
|
||||||
|
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;
|
||||||
|
use Scape\Exceptions\RecursionLimitException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Interpreter
|
||||||
|
*
|
||||||
|
* Walks the AST and renders the final output.
|
||||||
|
*
|
||||||
|
* @package Scape\Interpreter
|
||||||
|
*/
|
||||||
|
class Interpreter
|
||||||
|
{
|
||||||
|
private ValueResolver $resolver;
|
||||||
|
|
||||||
|
/** @var BlockNode[] */
|
||||||
|
private array $blocks = [];
|
||||||
|
|
||||||
|
/** @var Node[] */
|
||||||
|
private array $activeBlockStack = [];
|
||||||
|
|
||||||
|
private int $recursionDepth = 0;
|
||||||
|
private const MAX_RECURSION_DEPTH = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Config $config
|
||||||
|
* @param Engine|null $engine The engine instance to load partials.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly Config $config,
|
||||||
|
private readonly ?Engine $engine = null
|
||||||
|
) {
|
||||||
|
$this->resolver = new ValueResolver($config, $engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interprets a layout with child blocks.
|
||||||
|
*
|
||||||
|
* @param Node[] $layoutAst
|
||||||
|
* @param Node[] $childAst
|
||||||
|
* @param array $context
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function interpretWithLayout(array $layoutAst, array $childAst, array $context): string
|
||||||
|
{
|
||||||
|
// First, collect all blocks from child
|
||||||
|
$this->blocks = [];
|
||||||
|
$this->collectBlocks($childAst);
|
||||||
|
|
||||||
|
return $this->interpret($layoutAst, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects all BlockNodes from the given AST.
|
||||||
|
*
|
||||||
|
* @param Node[] $nodes
|
||||||
|
*/
|
||||||
|
private function collectBlocks(array $nodes): void
|
||||||
|
{
|
||||||
|
foreach ($nodes as $node) {
|
||||||
|
if ($node instanceof BlockNode) {
|
||||||
|
$this->blocks[$node->name] = $node;
|
||||||
|
// Also collect nested blocks
|
||||||
|
$this->collectBlocks($node->body);
|
||||||
|
} elseif ($node instanceof ForeachNode) {
|
||||||
|
$this->collectBlocks($node->body);
|
||||||
|
$this->collectBlocks($node->first);
|
||||||
|
$this->collectBlocks($node->inner);
|
||||||
|
$this->collectBlocks($node->last);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interprets a list of nodes.
|
||||||
|
*
|
||||||
|
* @param Node[] $nodes The AST nodes.
|
||||||
|
* @param array $context The data context.
|
||||||
|
*
|
||||||
|
* @return string The rendered output.
|
||||||
|
*/
|
||||||
|
public function interpret(array $nodes, array $context): string
|
||||||
|
{
|
||||||
|
$output = '';
|
||||||
|
foreach ($nodes as $node) {
|
||||||
|
$output .= $this->interpretNode($node, $context);
|
||||||
|
}
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function interpretNode(Node $node, array $context): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$node instanceof TextNode => $node->content,
|
||||||
|
$node instanceof VariableNode => $this->interpretVariable($node, $context),
|
||||||
|
$node instanceof ForeachNode => $this->interpretForeach($node, $context),
|
||||||
|
$node instanceof BlockNode => $this->interpretBlock($node, $context),
|
||||||
|
$node instanceof ParentNode => $this->interpretParent($node, $context),
|
||||||
|
$node instanceof IncludeNode => $this->interpretInclude($node, $context),
|
||||||
|
$node instanceof FilterLoadNode => $this->interpretFilterLoad($node, $context),
|
||||||
|
$node instanceof ExtendsNode => '',
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function interpretInclude(IncludeNode $node, array $context): string
|
||||||
|
{
|
||||||
|
if ($this->engine === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->recursionDepth >= self::MAX_RECURSION_DEPTH) {
|
||||||
|
throw new RecursionLimitException("Maximum recursion depth of " . self::MAX_RECURSION_DEPTH . " exceeded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$includeContext = [];
|
||||||
|
if ($node->context === 'context') {
|
||||||
|
$includeContext = $context;
|
||||||
|
} elseif ($node->context !== null) {
|
||||||
|
// Check if it's an inline array e.g. ['a' => 1]
|
||||||
|
if (str_starts_with($node->context, '[') && str_ends_with($node->context, ']')) {
|
||||||
|
$includeContext = $this->parseInlineArray($node->context, $context);
|
||||||
|
} else {
|
||||||
|
// If it contains a pipe, use the full expression
|
||||||
|
$expression = $node->context;
|
||||||
|
$val = $this->resolver->resolve($expression, $context);
|
||||||
|
if (is_array($val)) {
|
||||||
|
$includeContext = $val;
|
||||||
|
} elseif (is_object($val)) {
|
||||||
|
$includeContext = (array)$val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$partialAst = $this->engine->loadPartialAst($node->path);
|
||||||
|
} catch (\Scape\Exceptions\TemplateNotFoundException $e) {
|
||||||
|
if ($this->config->isDebug()) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
return "<!-- Scape: Partial '{$node->path}' not found -->";
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAVE the current blocks state, as partials are siloed
|
||||||
|
$oldBlocks = $this->blocks;
|
||||||
|
$this->blocks = [];
|
||||||
|
|
||||||
|
// Save current interpreter state that might be affected by recursion
|
||||||
|
// (activeBlockStack is already managed by push/pop in interpretBlock)
|
||||||
|
|
||||||
|
$this->recursionDepth++;
|
||||||
|
$output = $this->interpret($partialAst, $includeContext);
|
||||||
|
$this->recursionDepth--;
|
||||||
|
|
||||||
|
// RESTORE blocks state
|
||||||
|
$this->blocks = $oldBlocks;
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseInlineArray(string $expression, array $context): array
|
||||||
|
{
|
||||||
|
// expression is like ['user' => user, 'id' => 1]
|
||||||
|
$inner = trim(substr($expression, 1, -1));
|
||||||
|
if ($inner === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Improved splitting to handle spaces and potential commas in values (though KISS says simple)
|
||||||
|
$pairs = explode(',', $inner);
|
||||||
|
$result = [];
|
||||||
|
foreach ($pairs as $pair) {
|
||||||
|
if (str_contains($pair, '=>')) {
|
||||||
|
[$key, $value] = explode('=>', $pair, 2);
|
||||||
|
$key = trim($key, " \t\n\r\0\x0B'\"");
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
// Handle string literals in values
|
||||||
|
if ((str_starts_with($value, "'") && str_ends_with($value, "'")) ||
|
||||||
|
(str_starts_with($value, '"') && str_ends_with($value, '"'))) {
|
||||||
|
$resolvedValue = substr($value, 1, -1);
|
||||||
|
} elseif (is_numeric($value)) {
|
||||||
|
$resolvedValue = $value + 0; // cast to int or float
|
||||||
|
} else {
|
||||||
|
$resolvedValue = $this->resolver->resolve($value, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[$key] = $resolvedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function interpretBlock(BlockNode $node, array $context): string
|
||||||
|
{
|
||||||
|
// If we have an override for this block
|
||||||
|
if (isset($this->blocks[$node->name])) {
|
||||||
|
$override = $this->blocks[$node->name];
|
||||||
|
|
||||||
|
// Push current layout block to stack for {[ parent ]}
|
||||||
|
$this->activeBlockStack[] = $node;
|
||||||
|
$output = $this->interpret($override->body, $context);
|
||||||
|
array_pop($this->activeBlockStack);
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No override, render layout default content
|
||||||
|
return $this->interpret($node->body, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function interpretParent(ParentNode $node, array $context): string
|
||||||
|
{
|
||||||
|
if (empty($this->activeBlockStack)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the body of the layout block we are currently overriding
|
||||||
|
$layoutBlock = end($this->activeBlockStack);
|
||||||
|
return $this->interpret($layoutBlock->body, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function interpretVariable(VariableNode $node, array $context): string
|
||||||
|
{
|
||||||
|
// If it's the special 'context' variable (used in partials)
|
||||||
|
if ($node->path === 'context') {
|
||||||
|
$value = $context;
|
||||||
|
} else {
|
||||||
|
$value = $this->resolver->resolve($node->path, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($node->filters) && $this->engine !== null) {
|
||||||
|
foreach ($node->filters as $filterInfo) {
|
||||||
|
$filterName = $filterInfo['name'];
|
||||||
|
$args = $filterInfo['args'];
|
||||||
|
|
||||||
|
// Resolve arguments (if they are variables)
|
||||||
|
$resolvedArgs = [];
|
||||||
|
foreach ($args as $arg) {
|
||||||
|
if ((str_starts_with($arg, "'") && str_ends_with($arg, "'")) ||
|
||||||
|
(str_starts_with($arg, '"') && str_ends_with($arg, '"'))) {
|
||||||
|
$resolvedArgs[] = substr($arg, 1, -1);
|
||||||
|
} elseif (is_numeric($arg)) {
|
||||||
|
$resolvedArgs[] = $arg + 0;
|
||||||
|
} else {
|
||||||
|
$resolvedArgs[] = $this->resolver->resolve($arg, $context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$filter = $this->engine->getFilter($filterName);
|
||||||
|
$value = $filter->transform($value, $resolvedArgs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$stringValue = (string)$value;
|
||||||
|
|
||||||
|
if ($node->isRaw) {
|
||||||
|
return $stringValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlspecialchars($stringValue, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function interpretFilterLoad(FilterLoadNode $node, array $context): string
|
||||||
|
{
|
||||||
|
if ($this->engine === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($node->isInternal) {
|
||||||
|
$this->loadInternalFilter($node->path);
|
||||||
|
} else {
|
||||||
|
$this->loadCustomFilter($node->path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadInternalFilter(string $path): void
|
||||||
|
{
|
||||||
|
// Example: filters:string
|
||||||
|
[$namespace, $library] = explode(':', $path);
|
||||||
|
|
||||||
|
if ($namespace === 'filters' && $library === 'string') {
|
||||||
|
$this->engine->registerFilter('lower', new \Scape\Filters\LowerFilter());
|
||||||
|
$this->engine->registerFilter('upper', new \Scape\Filters\UpperFilter());
|
||||||
|
$this->engine->registerFilter('ucfirst', new \Scape\Filters\UcfirstFilter());
|
||||||
|
$this->engine->registerFilter('currency', new \Scape\Filters\CurrencyFilter());
|
||||||
|
$this->engine->registerFilter('float', new \Scape\Filters\FloatFilter());
|
||||||
|
$this->engine->registerFilter('date', new \Scape\Filters\DateFilter());
|
||||||
|
$this->engine->registerFilter('truncate', new \Scape\Filters\TruncateFilter());
|
||||||
|
$this->engine->registerFilter('default', new \Scape\Filters\DefaultFilter());
|
||||||
|
$this->engine->registerFilter('json', new \Scape\Filters\JsonFilter());
|
||||||
|
$this->engine->registerFilter('url_encode', new \Scape\Filters\UrlEncodeFilter());
|
||||||
|
$this->engine->registerFilter('join', new \Scape\Filters\JoinFilter());
|
||||||
|
$this->engine->registerFilter('first', new \Scape\Filters\FirstFilter());
|
||||||
|
$this->engine->registerFilter('last', new \Scape\Filters\LastFilter());
|
||||||
|
$this->engine->registerFilter('word_count', new \Scape\Filters\WordCountFilter());
|
||||||
|
$this->engine->registerFilter('keys', new \Scape\Filters\KeysFilter());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadCustomFilter(string $path): void
|
||||||
|
{
|
||||||
|
$filePath = rtrim($this->config->getFiltersDir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('.', DIRECTORY_SEPARATOR, $path) . '.php';
|
||||||
|
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
throw new \Scape\Exceptions\FilterNotFoundException("Custom filter file not found at '{$filePath}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$filter = require $filePath;
|
||||||
|
if (!$filter instanceof \Scape\Interfaces\FilterInterface) {
|
||||||
|
throw new \Scape\Exceptions\FilterNotFoundException("Custom filter must return an instance of FilterInterface.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the last part of dot notation as alias
|
||||||
|
$parts = explode('.', $path);
|
||||||
|
$alias = end($parts);
|
||||||
|
$this->engine->registerFilter($alias, $filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function interpretForeach(ForeachNode $node, array $context): string
|
||||||
|
{
|
||||||
|
$collection = $this->resolver->resolve($node->collection, $context);
|
||||||
|
|
||||||
|
if (!is_iterable($collection)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = '';
|
||||||
|
$items = is_array($collection) ? $collection : iterator_to_array($collection);
|
||||||
|
$total = count($items);
|
||||||
|
$currentIndex = 0;
|
||||||
|
|
||||||
|
foreach ($items as $key => $item) {
|
||||||
|
$loopContext = $context;
|
||||||
|
$loopContext[$node->valueVar] = $item;
|
||||||
|
if ($node->keyVar !== null) {
|
||||||
|
$loopContext[$node->keyVar] = $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop variables
|
||||||
|
$loopContext['index'] = $currentIndex;
|
||||||
|
$loopContext['pos'] = $currentIndex + 1;
|
||||||
|
|
||||||
|
// Positional rendering
|
||||||
|
if ($currentIndex === 0 && !empty($node->first)) {
|
||||||
|
$output .= $this->interpret($node->first, $loopContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($currentIndex > 0 && $currentIndex < $total - 1 && !empty($node->inner)) {
|
||||||
|
$output .= $this->interpret($node->inner, $loopContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($currentIndex === $total - 1 && $currentIndex > 0 && !empty($node->last)) {
|
||||||
|
// Last is only for iterations > 1 if we follow "inner" logic,
|
||||||
|
// but usually "last" should render even if there's only one item?
|
||||||
|
// Specs say: "Renders only on the last iteration."
|
||||||
|
// If there's 1 item, it's first AND last.
|
||||||
|
// Let's re-read: "{( first )} ... {( endfirst )}: Renders only on the first iteration."
|
||||||
|
// "{( last )} ... {( endlast )}: Renders only on the last iteration."
|
||||||
|
// So if 1 item, both should render.
|
||||||
|
$output .= $this->interpret($node->last, $loopContext);
|
||||||
|
} elseif ($currentIndex === $total - 1 && $total === 1) {
|
||||||
|
// Single item case
|
||||||
|
$output .= $this->interpret($node->last, $loopContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
$output .= $this->interpret($node->body, $loopContext);
|
||||||
|
|
||||||
|
$currentIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
tests/EngineTest.php
Normal file
123
tests/EngineTest.php
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Scape\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Scape\Engine;
|
||||||
|
use Scape\Exceptions\TemplateNotFoundException;
|
||||||
|
|
||||||
|
use Scape\Exceptions\RecursionLimitException;
|
||||||
|
|
||||||
|
class EngineTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $templatesDir;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->templatesDir = __DIR__ . '/fixtures';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderSignature(): void
|
||||||
|
{
|
||||||
|
$engine = new Engine();
|
||||||
|
$this->assertTrue(method_exists($engine, 'render'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThrowsTemplateNotFoundExceptionInDebug(): void
|
||||||
|
{
|
||||||
|
$this->expectException(TemplateNotFoundException::class);
|
||||||
|
|
||||||
|
$engine = new Engine(['mode' => 'debug']);
|
||||||
|
$engine->render('missing.template');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRendersPlaceholderInProduction(): void
|
||||||
|
{
|
||||||
|
$engine = new Engine(['mode' => 'production']);
|
||||||
|
$output = $engine->render('missing.template');
|
||||||
|
$this->assertEquals("<!-- Scape: Template 'missing.template' not found -->", $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenders404Fallback(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->templatesDir . '/404.scape.php', 'Custom 404: {{ missing_template }}');
|
||||||
|
|
||||||
|
$engine = new Engine(['templates_dir' => $this->templatesDir, 'mode' => 'production']);
|
||||||
|
$output = $engine->render('missing.template');
|
||||||
|
|
||||||
|
$this->assertEquals('Custom 404: missing.template', $output);
|
||||||
|
|
||||||
|
unlink($this->templatesDir . '/404.scape.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRendersSimpleTemplate(): void
|
||||||
|
{
|
||||||
|
$engine = new Engine([
|
||||||
|
'templates_dir' => $this->templatesDir,
|
||||||
|
'mode' => 'production'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$output = $engine->render('tests.simple', [
|
||||||
|
'name' => 'Funky',
|
||||||
|
'items' => ['Apple', 'Banana']
|
||||||
|
]);
|
||||||
|
|
||||||
|
$expected = "Hello Funky!\n - Apple\n - Banana\n";
|
||||||
|
$this->assertEquals($expected, $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRendersTemplateWithInheritance(): void
|
||||||
|
{
|
||||||
|
$engine = new Engine([
|
||||||
|
'templates_dir' => $this->templatesDir,
|
||||||
|
'layouts_dir' => $this->templatesDir . '/layouts',
|
||||||
|
'mode' => 'production'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$output = $engine->render('tests.child', [
|
||||||
|
'name' => 'Funky'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('<title>Child Page - Funky</title>', $output);
|
||||||
|
$this->assertStringContainsString('<h1>Site Header</h1>', $output);
|
||||||
|
$this->assertStringContainsString('<h2>Welcome</h2>', $output);
|
||||||
|
$this->assertStringContainsString('Nested default', $output);
|
||||||
|
$this->assertStringContainsString('© 2026 - Custom Footer', $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRendersIncludeWithContext(): void
|
||||||
|
{
|
||||||
|
$engine = new Engine([
|
||||||
|
'templates_dir' => $this->templatesDir,
|
||||||
|
'partials_dir' => $this->templatesDir . '/partials',
|
||||||
|
'mode' => 'production'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$output = $engine->render('tests.include', [
|
||||||
|
'name' => 'Funky',
|
||||||
|
'id' => 123,
|
||||||
|
'person' => ['name' => 'John', 'id' => 456]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('Hello Funky!', $output);
|
||||||
|
$this->assertStringContainsString('Name: Funky', $output);
|
||||||
|
$this->assertStringContainsString('ID: 123', $output);
|
||||||
|
$this->assertStringContainsString('Name: John', $output);
|
||||||
|
$this->assertStringContainsString('ID: 456', $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRecursionLimit(): void
|
||||||
|
{
|
||||||
|
$this->expectException(RecursionLimitException::class);
|
||||||
|
|
||||||
|
$engine = new Engine([
|
||||||
|
'templates_dir' => $this->templatesDir,
|
||||||
|
'partials_dir' => $this->templatesDir . '/partials',
|
||||||
|
'mode' => 'production'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$engine->render('partials.recursive');
|
||||||
|
}
|
||||||
|
}
|
||||||
144
tests/InterpreterTest.php
Normal file
144
tests/InterpreterTest.php
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Scape\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Scape\Config;
|
||||||
|
use Scape\Interpreter\Interpreter;
|
||||||
|
use Scape\Parser\Node\ForeachNode;
|
||||||
|
use Scape\Parser\Node\TextNode;
|
||||||
|
use Scape\Parser\Node\VariableNode;
|
||||||
|
use Scape\Exceptions\PropertyNotFoundException;
|
||||||
|
|
||||||
|
class InterpreterTest extends TestCase
|
||||||
|
{
|
||||||
|
private Interpreter $interpreter;
|
||||||
|
private Config $config;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->config = new Config(['mode' => 'production']);
|
||||||
|
$this->interpreter = new Interpreter($this->config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRendersText(): void
|
||||||
|
{
|
||||||
|
$nodes = [new TextNode("Hello World", 1)];
|
||||||
|
$this->assertEquals("Hello World", $this->interpreter->interpret($nodes, []));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRendersVariable(): void
|
||||||
|
{
|
||||||
|
$nodes = [new VariableNode("{{ name }}", "name", [], false, 1)];
|
||||||
|
$this->assertEquals("Phred", $this->interpreter->interpret($nodes, ['name' => 'Phred']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEscapesVariable(): void
|
||||||
|
{
|
||||||
|
$nodes = [new VariableNode("{{ name }}", "name", [], false, 1)];
|
||||||
|
$this->assertEquals("<script>", $this->interpreter->interpret($nodes, ['name' => '<script>']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRendersRawVariable(): void
|
||||||
|
{
|
||||||
|
$nodes = [new VariableNode("{{{ name }}}", "name", [], true, 1)];
|
||||||
|
$this->assertEquals("<script>", $this->interpreter->interpret($nodes, ['name' => '<script>']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRendersNestedVariable(): void
|
||||||
|
{
|
||||||
|
$nodes = [new VariableNode("{{ user.name }}", "user.name", [], false, 1)];
|
||||||
|
$this->assertEquals("Phred", $this->interpreter->interpret($nodes, ['user' => ['name' => 'Phred']]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRendersBracketVariable(): void
|
||||||
|
{
|
||||||
|
$nodes = [new VariableNode("{{ items[0] }}", "items[0]", [], false, 1)];
|
||||||
|
$this->assertEquals("First", $this->interpreter->interpret($nodes, ['items' => ['First', 'Second']]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMissingVariableInProductionReturnsEmpty(): void
|
||||||
|
{
|
||||||
|
$nodes = [new VariableNode("{{ missing }}", "missing", [], false, 1)];
|
||||||
|
$this->assertEquals("", $this->interpreter->interpret($nodes, []));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMissingVariableInDebugThrowsException(): void
|
||||||
|
{
|
||||||
|
$config = new Config(['mode' => 'debug']);
|
||||||
|
$interpreter = new Interpreter($config);
|
||||||
|
$nodes = [new VariableNode("{{ missing }}", "missing", [], false, 1)];
|
||||||
|
|
||||||
|
$this->expectException(PropertyNotFoundException::class);
|
||||||
|
$interpreter->interpret($nodes, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRendersForeach(): void
|
||||||
|
{
|
||||||
|
$nodes = [
|
||||||
|
new ForeachNode(
|
||||||
|
"items",
|
||||||
|
"item",
|
||||||
|
null,
|
||||||
|
[
|
||||||
|
new VariableNode("{{ item }}", "item", [], false, 1),
|
||||||
|
new TextNode(" ", 1)
|
||||||
|
],
|
||||||
|
1
|
||||||
|
)
|
||||||
|
];
|
||||||
|
$this->assertEquals("A B C ", $this->interpreter->interpret($nodes, ['items' => ['A', 'B', 'C']]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRendersForeachWithKey(): void
|
||||||
|
{
|
||||||
|
$nodes = [
|
||||||
|
new ForeachNode(
|
||||||
|
"items",
|
||||||
|
"item",
|
||||||
|
"key",
|
||||||
|
[
|
||||||
|
new VariableNode("{{ key }}", "key", [], false, 1),
|
||||||
|
new TextNode(":", 1),
|
||||||
|
new VariableNode("{{ item }}", "item", [], false, 1),
|
||||||
|
new TextNode(" ", 1)
|
||||||
|
],
|
||||||
|
1
|
||||||
|
)
|
||||||
|
];
|
||||||
|
$this->assertEquals("a:1 b:2 ", $this->interpreter->interpret($nodes, ['items' => ['a' => 1, 'b' => 2]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testForeachPositionalRendering(): void
|
||||||
|
{
|
||||||
|
$nodes = [
|
||||||
|
new ForeachNode(
|
||||||
|
"items",
|
||||||
|
"item",
|
||||||
|
null,
|
||||||
|
[new VariableNode("{{ item }}", "item", [], false, 1)],
|
||||||
|
1,
|
||||||
|
[new TextNode("[FIRST]", 1)],
|
||||||
|
[new TextNode("[INNER]", 1)],
|
||||||
|
[new TextNode("[LAST]", 1)]
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
// 3 items:
|
||||||
|
// 1: [FIRST]A
|
||||||
|
// 2: [INNER]B
|
||||||
|
// 3: [LAST]C
|
||||||
|
$this->assertEquals("[FIRST]A[INNER]B[LAST]C", $this->interpreter->interpret($nodes, ['items' => ['A', 'B', 'C']]));
|
||||||
|
|
||||||
|
// 2 items:
|
||||||
|
// 1: [FIRST]A
|
||||||
|
// 2: [LAST]B
|
||||||
|
$this->assertEquals("[FIRST]A[LAST]B", $this->interpreter->interpret($nodes, ['items' => ['A', 'B']]));
|
||||||
|
|
||||||
|
// 1 item:
|
||||||
|
// 1: [FIRST][LAST]A
|
||||||
|
$this->assertEquals("[FIRST][LAST]A", $this->interpreter->interpret($nodes, ['items' => ['A']]));
|
||||||
|
}
|
||||||
|
}
|
||||||
10
tests/fixtures/filters/currency.php
vendored
Normal file
10
tests/fixtures/filters/currency.php
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Scape\Interfaces\FilterInterface;
|
||||||
|
|
||||||
|
return new class implements FilterInterface {
|
||||||
|
public function transform(mixed $value, array $args = []): mixed {
|
||||||
|
$symbol = $args[0] ?? '$';
|
||||||
|
return $symbol . number_format((float)$value, 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
10
tests/fixtures/layouts/base.scape.php
vendored
Normal file
10
tests/fixtures/layouts/base.scape.php
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<html>
|
||||||
|
<head><title>{[ block 'title' ]}Default Title{[ endblock ]}</title></head>
|
||||||
|
<body>
|
||||||
|
<header><h1>Site Header</h1></header>
|
||||||
|
<main>
|
||||||
|
{[ block 'content' ]}Default Content{[ endblock ]}
|
||||||
|
</main>
|
||||||
|
<footer>{[ block 'footer' ]}© 2026{[ endblock ]}</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
tests/fixtures/partials/hello.scape.php
vendored
Normal file
1
tests/fixtures/partials/hello.scape.php
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Hello {{ name }}!
|
||||||
1
tests/fixtures/partials/recursive.scape.php
vendored
Normal file
1
tests/fixtures/partials/recursive.scape.php
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{[ include 'recursive' ]}
|
||||||
2
tests/fixtures/partials/user_info.scape.php
vendored
Normal file
2
tests/fixtures/partials/user_info.scape.php
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
Name: {{ name }}
|
||||||
|
ID: {{ id }}
|
||||||
10
tests/fixtures/tests/child.scape.php
vendored
Normal file
10
tests/fixtures/tests/child.scape.php
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{[ extends 'base' ]}
|
||||||
|
{[ block 'title' ]}Child Page - {{ name }}{[ endblock ]}
|
||||||
|
{[ block 'content' ]}
|
||||||
|
<h2>Welcome</h2>
|
||||||
|
<p>This is the content for {{ name }}.</p>
|
||||||
|
{[ block 'nested' ]}Nested default{[ endblock ]}
|
||||||
|
{[ endblock ]}
|
||||||
|
{[ block 'footer' ]}
|
||||||
|
{[ parent ]} - Custom Footer
|
||||||
|
{[ endblock ]}
|
||||||
6
tests/fixtures/tests/include.scape.php
vendored
Normal file
6
tests/fixtures/tests/include.scape.php
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
Include test:
|
||||||
|
{[ include 'hello' with ['name' => 'Funky'] ]}
|
||||||
|
Context test:
|
||||||
|
{[ include 'user_info' with context ]}
|
||||||
|
Data source test:
|
||||||
|
{[ include 'user_info' with person ]}
|
||||||
4
tests/fixtures/tests/simple.scape.php
vendored
Normal file
4
tests/fixtures/tests/simple.scape.php
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
Hello {{ name }}!
|
||||||
|
{( foreach item in items )}
|
||||||
|
- {{ item }}
|
||||||
|
{( endforeach )}
|
||||||
Loading…
Reference in a new issue