feat: implement Interpreter with inheritance and partials support (Phases 4-5)

This commit is contained in:
Funky Waddle 2026-02-11 23:42:15 -06:00
parent 10aac20afb
commit 439e4b99fb
11 changed files with 706 additions and 0 deletions

View 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
View 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('&copy; 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
View 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("&lt;script&gt;", $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
View 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
View 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' ]}&copy; 2026{[ endblock ]}</footer>
</body>
</html>

View file

@ -0,0 +1 @@
Hello {{ name }}!

View file

@ -0,0 +1 @@
{[ include 'recursive' ]}

View file

@ -0,0 +1,2 @@
Name: {{ name }}
ID: {{ id }}

10
tests/fixtures/tests/child.scape.php vendored Normal file
View 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 ]}

View 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
View file

@ -0,0 +1,4 @@
Hello {{ name }}!
{( foreach item in items )}
- {{ item }}
{( endforeach )}