diff --git a/src/Interpreter/Interpreter.php b/src/Interpreter/Interpreter.php new file mode 100644 index 0000000..ef42835 --- /dev/null +++ b/src/Interpreter/Interpreter.php @@ -0,0 +1,395 @@ +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 ""; + } + + // 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; + } +} diff --git a/tests/EngineTest.php b/tests/EngineTest.php new file mode 100644 index 0000000..eb7de29 --- /dev/null +++ b/tests/EngineTest.php @@ -0,0 +1,123 @@ +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("", $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('Child Page - Funky', $output); + $this->assertStringContainsString('

Site Header

', $output); + $this->assertStringContainsString('

Welcome

', $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'); + } +} diff --git a/tests/InterpreterTest.php b/tests/InterpreterTest.php new file mode 100644 index 0000000..82e6a83 --- /dev/null +++ b/tests/InterpreterTest.php @@ -0,0 +1,144 @@ +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' => '