Kaynağa Gözat

introduce Engine to support new template syntax

michelphp 3 hafta önce
ebeveyn
işleme
6652c247d0
7 değiştirilmiş dosya ile 702 ekleme ve 46 silme
  1. 3 3
      README.md
  2. 3 3
      composer.json
  3. 112 0
      composer.lock
  4. 332 0
      src/Engine.php
  5. 2 3
      src/PhpRenderer.php
  6. 77 0
      tests/PhpRendererTest.php
  7. 173 37
      tests/PurePlateTest.php

+ 3 - 3
README.md

@@ -1,6 +1,6 @@
 # PurePlate
 
-PurePlate is a lightweight and versatile template rendering library for native PHP.
+Pure is a high-performance, lexer-based template engine for PHP 7.4+. It compiles a clean, intuitive syntax into native cached PHP code with zero runtime overhead.
 
 ## Installation
 
@@ -15,7 +15,7 @@ composer require michel/pure-plate
 To use the renderer in your project, first create an instance of the `PhpRenderer` class and pass the directory where your templates are located.
 
 ```php
-use Michel\Renderer\PurePlate;
+use Michel\Renderer\PhpRenderer;
 
 // Specify the template directory
 $templateDir = '/path/to/templates';
@@ -26,7 +26,7 @@ $globals = [
 ];
 
 // Create the renderer instance
-$renderer = new PurePlate($templateDir, $globals);
+$renderer = new PhpRenderer($templateDir, $globals);
 ```
 
 ### Creating a Layout

+ 3 - 3
composer.json

@@ -5,7 +5,7 @@
   "require": {
     "php": ">=7.4"
   },
-  "license": "MIT",
+  "license": "MPL-2.0",
   "authors": [
     {
       "name": "F. Michel"
@@ -13,8 +13,8 @@
   ],
   "autoload": {
     "psr-4": {
-      "Michel\\Renderer\\": "src",
-      "Test\\Michel\\Renderer\\": "tests"
+      "Michel\\PurePlate\\": "src",
+      "Test\\Michel\\PurePlate\\": "tests"
     }
   },
   "require-dev": {

+ 112 - 0
composer.lock

@@ -0,0 +1,112 @@
+{
+    "_readme": [
+        "This file locks the dependencies of your project to a known state",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+        "This file is @generated automatically"
+    ],
+    "content-hash": "4611f663f44992e148187cf32a8a0f91",
+    "packages": [],
+    "packages-dev": [
+        {
+            "name": "michel/unitester",
+            "version": "1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://git.depohub.org/michel/unitester",
+                "reference": "2877e96750aad48983ef65ad7592e1ee9cff5d56"
+            },
+            "require": {
+                "ext-mbstring": "*",
+                "php": ">=7.4",
+                "psr/container": "^2.0"
+            },
+            "bin": [
+                "bin/unitester"
+            ],
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/assert.php"
+                ],
+                "psr-4": {
+                    "Michel\\UniTester\\": "src",
+                    "Test\\Michel\\UniTester\\": "tests"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "F. Michel"
+                }
+            ],
+            "description": "PHP UniTester is a unit testing library for PHP that provides a straightforward interface for writing and executing tests. It focuses on a minimalist approach, allowing developers to create assertions and organize tests at the lowest level of PHP, without reliance on complex external libraries.",
+            "time": "2025-12-14T15:53:05+00:00"
+        },
+        {
+            "name": "psr/container",
+            "version": "2.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/container.git",
+                "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+                "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.4.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Container\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common Container Interface (PHP FIG PSR-11)",
+            "homepage": "https://github.com/php-fig/container",
+            "keywords": [
+                "PSR-11",
+                "container",
+                "container-interface",
+                "container-interop",
+                "psr"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/container/issues",
+                "source": "https://github.com/php-fig/container/tree/2.0.2"
+            },
+            "time": "2021-11-05T16:47:00+00:00"
+        }
+    ],
+    "aliases": [],
+    "minimum-stability": "stable",
+    "stability-flags": {},
+    "prefer-stable": false,
+    "prefer-lowest": false,
+    "platform": {
+        "php": ">=7.4"
+    },
+    "platform-dev": {},
+    "plugin-api-version": "2.6.0"
+}

+ 332 - 0
src/Engine.php

@@ -0,0 +1,332 @@
+<?php
+
+namespace Michel\PurePlate;
+
+use ErrorException;
+use Exception;
+use InvalidArgumentException;
+use ParseError;
+use RuntimeException;
+use Throwable;
+
+final class Engine
+{
+    private string $templateDir;
+    private bool $devMode;
+    private string $cacheDir;
+    private PhpRenderer $renderer;
+    private array $blocks = [];
+
+    public function __construct(
+        string  $templateDir,
+        bool    $devMode = false,
+        ?string $cacheDir = null,
+        array   $globals = []
+    )
+    {
+        $this->templateDir = $templateDir;
+        $this->devMode = $devMode;
+        $this->cacheDir = $cacheDir ?? sys_get_temp_dir() . '/pure_cache';
+        if (!is_dir($this->cacheDir)) {
+            @mkdir($this->cacheDir, 0755, true);
+        }
+
+        foreach ($globals as $key => $value) {
+            if (!is_string($key)) {
+                throw new InvalidArgumentException('Global key must be a string.');
+            }
+        }
+        $globals['_pure'] = $this;
+
+        $this->renderer = new PhpRenderer($this->cacheDir, $globals);
+    }
+
+    /**
+     * @throws ErrorException
+     */
+    public function render(string $filename, array $context = []): string
+    {
+        $templatePath = $this->templateDir . '/' . ltrim($filename, '/');
+        if (!file_exists($templatePath)) {
+            throw new RuntimeException("Template not found: {$templatePath}");
+        }
+
+        $cacheFile = $this->getCacheFilename($filename);
+        if ($this->devMode === true || !$this->isCacheValid($templatePath, $cacheFile)) {
+            $compiledCode = $this->compile($templatePath);
+            try {
+                token_get_all($compiledCode, TOKEN_PARSE);
+                $this->saveToCache($cacheFile, trim($compiledCode) . PHP_EOL);
+            } catch (ParseError $e) {
+                $this->handleError($e, $compiledCode, $templatePath);
+            }
+        }
+
+        set_error_handler(function ($severity, $message, $file, $line) {
+            if (!(error_reporting() & $severity)) {
+                return;
+            }
+            throw new ErrorException($message, 0, $severity, $file, $line);
+        });
+
+        try {
+            return $this->renderer->render(str_replace($this->cacheDir, '', realpath($cacheFile)), $context);
+        } catch (Throwable $e) {
+            $this->handleError($e, file_get_contents($cacheFile), $templatePath);
+        } finally {
+            restore_error_handler();
+        }
+    }
+
+    /**
+     * @throws ErrorException
+     */
+    public function extend(string $filename): void
+    {
+        $_ = $this->render($filename);
+        $cacheFile = $this->getCacheFilename($filename);
+        $this->renderer->extend(str_replace($this->cacheDir, '', realpath($cacheFile)));
+    }
+
+    private function compile(string $path): string
+    {
+        $html = file_get_contents($path);
+        $this->blocks = [];
+        $lines = explode("\n", $html);
+        $output = "";
+
+        foreach ($lines as $i => $line) {
+            $num = $i + 1;
+            $line = preg_replace_callback('/{{(.*?)}}|{% (.*?) %}/', function ($m) use ($num, $path) {
+                $isBlock = !empty($m[2]);
+                $content = trim($isBlock ? $m[2] : $m[1]);
+
+                if ($isBlock) {
+                    return $this->compileStructure($content, $num);
+                }
+
+                $phpExpr = $this->parseTokens($content);
+                return "<?php /*L:$num;F:$path*/ echo htmlspecialchars((string)($phpExpr), ENT_QUOTES); ?>";
+
+            }, $line);
+
+            $output .= $line . "\n";
+        }
+        return $output;
+    }
+
+    private function compileStructure(string $rawContent, int $line): string
+    {
+        $parts = preg_split('/\s+/', $rawContent, 2);
+        $cmd = strtolower(trim($parts[0]));
+        $rawExpr = $parts[1] ?? '';
+
+        if ($cmd === 'extends') {
+            $phpExpr = $this->parseTokens($rawExpr);
+            return "<?php \$_epure->extend($phpExpr); ?>";
+        }
+
+
+        if ($cmd === 'block') {
+            $blockName = trim($rawExpr, "\"' ");
+            return "<?php \$this->startBlock('$blockName'); ?>";
+        }
+
+        if ($cmd === 'endblock') {
+            return "<?php \$this->endblock(); ?>";
+        }
+
+        if ($cmd === 'include') {
+            $phpExpr = $this->parseTokens($rawExpr);
+            return "<?php \$_epure->render($phpExpr); ?>";
+        }
+
+        if ($cmd === 'set') {
+            $phpExpr = $this->parseTokens($rawExpr);
+            return "<?php $phpExpr; ?>";
+        }
+
+        if (in_array($cmd, ['if', 'foreach', 'while', 'for', 'elseif'])) {
+            if ($cmd !== 'elseif') {
+                $this->blocks[] = ['type' => $cmd, 'line' => $line];
+            }
+            $phpExpr = $this->parseTokens($rawExpr);
+
+            return "<?php $cmd ($phpExpr): ?>";
+        }
+
+        if ($cmd === 'else') {
+            return "<?php else: ?>";
+        }
+
+        if (substr($cmd, 0, 3) === 'end') {
+            if (empty($this->blocks)) {
+                throw new Exception("ÉPURE ERROR: '$cmd' inattendu à la ligne $line");
+            }
+            array_pop($this->blocks);
+            return "<?php $cmd; ?>";
+        }
+
+        $phpExpr = $this->parseTokens($rawContent);
+        return "<?php $phpExpr; ?>";
+    }
+
+    private function parseTokens(string $expr): string
+    {
+        if (trim($expr) === '') return '';
+
+        $tokens = token_get_all("<?php " . trim($expr));
+        $res = "";
+        $len = count($tokens);
+
+        for ($i = 0; $i < $len; $i++) {
+            $t = $tokens[$i];
+
+            if ($t === '|') {
+                $filterName = '';
+                $hasParens = false;
+//                $filterIndex = -1;
+
+                for ($j = $i + 1; $j < $len; $j++) {
+                    $nt = $tokens[$j];
+                    if (is_array($nt) && $nt[0] === T_WHITESPACE) {
+                        continue;
+                    }
+                    if (is_array($nt) && $nt[0] === T_STRING) {
+                        $filterName = $nt[1];
+                        $filterIndex = $j;
+
+                        for ($k = $j + 1; $k < $len; $k++) {
+                            $nnt = $tokens[$k];
+                            if (is_array($nnt) && $nnt[0] === T_WHITESPACE) {
+                                continue;
+                            }
+                            if ($nnt === '(') {
+                                $hasParens = true;
+                                $i = $k;
+                            } else {
+                                $i = $filterIndex;
+                            }
+                            break 2;
+                        }
+                        $i = $filterIndex;
+                        break;
+                    }
+                }
+
+                if ($hasParens) {
+                    $res = "$filterName(" . trim($res) . ", ";
+                } else {
+                    $res = "$filterName(" . trim($res) . ")";
+                }
+                continue;
+            }
+
+            if (is_array($t)) {
+                [$id, $text] = $t;
+                if ($id === T_OPEN_TAG) {
+                    continue;
+                }
+
+                $word = strtolower($text);
+
+                if ($id === T_STRING && $word === 'is') {
+                    $nextWords = [];
+                    $nextIndexes = [];
+                    for ($j = $i + 1; $j < $len && count($nextWords) < 2; $j++) {
+                        if (is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) {
+                            continue;
+                        }
+                        $nextWords[] = strtolower(is_array($tokens[$j]) ? $tokens[$j][1] : $tokens[$j]);
+                        $nextIndexes[] = $j;
+                    }
+
+                    if (isset($nextWords[0]) && $nextWords[0] === 'empty') {
+                        $res = "empty(" . trim($res) . ")";
+                        $i = $nextIndexes[0];
+                        continue;
+                    }
+                    if (isset($nextWords[1]) && $nextWords[0] === 'not' && $nextWords[1] === 'empty') {
+                        $res = "!empty(" . trim($res) . ")";
+                        $i = $nextIndexes[1];
+                        continue;
+                    }
+                }
+
+                if ($id === T_STRING || $id === T_EMPTY) {
+                    $isFunction = false;
+                    for ($j = $i + 1; $j < $len; $j++) {
+                        if (is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) continue;
+                        if ($tokens[$j] === '(') {
+                            $isFunction = true;
+                        }
+                        break;
+                    }
+
+                    if ($word === 'not') {
+                        $res .= '!';
+                        continue;
+                    }
+
+                    $trimmedRes = trim($res);
+                    $isProp = (substr($trimmedRes, -2) === '->');
+                    $isReserved = in_array($word, ['as', 'is', 'true', 'false', 'null', 'empty']);
+                    $res .= ($isProp || $isReserved || $isFunction) ? $text : '$' . $text;
+
+                } else {
+                    $res .= $text;
+                }
+            } else {
+                $res .= ($t === '.') ? '->' : $t;
+            }
+        }
+
+        return $res;
+    }
+
+    private function handleError(Throwable $e, string $compiled, string $path): void
+    {
+        $lines = explode("\n", $compiled);
+        $errorLine = $e->getLine();
+        $faultyCode = $lines[$errorLine - 1] ?? '';
+
+        preg_match('/\/\*L:(\d+);F:(.*?)\*\//', $faultyCode, $m);
+
+        $origLine = isset($m[1]) ? (int)$m[1] : $e->getLine();
+        $origFile = $m[2] ?? $path;
+        throw new \ErrorException(
+            "PurePlate Render Error: " . $e->getMessage(),
+            0,
+            ($e instanceof \ErrorException) ? $e->getSeverity() : E_USER_ERROR,
+            $origFile,
+            $origLine
+        );
+
+    }
+
+    private function isCacheValid(string $templateFile, string $cacheFile): bool
+    {
+        if (!file_exists($cacheFile)) {
+            return false;
+        }
+
+        return filemtime($cacheFile) >= filemtime($templateFile);
+    }
+
+    private function getCacheFilename(string $templateFile): string
+    {
+        $hash = md5(realpath($templateFile));
+        $basename = pathinfo($templateFile, PATHINFO_FILENAME);
+        return $this->cacheDir . DIRECTORY_SEPARATOR . $basename . '_' . $hash . '.cache.php';
+    }
+
+    private function saveToCache(string $cacheFile, string $compiledCode): void
+    {
+        $tempFile = $cacheFile . '.tmp';
+        if (file_put_contents($tempFile, $compiledCode, LOCK_EX) !== false) {
+            @rename($tempFile, $cacheFile);
+        } else {
+            throw new RuntimeException("Unable to write cache file: {$cacheFile}");
+        }
+    }
+}

+ 2 - 3
src/PurePlate.php → src/PhpRenderer.php

@@ -1,6 +1,6 @@
 <?php
 
-namespace Michel\Renderer;
+namespace Michel\PurePlate;
 
 use InvalidArgumentException;
 use RuntimeException;
@@ -14,11 +14,10 @@ use function ob_start;
 use function rtrim;
 use function trim;
 
-final class PurePlate
+final class PhpRenderer
 {
     private string $templateDir;
     private array $globals;
-
     private array $blocks = [];
     private ?string $currentBlock = null;
     private ?string $layout = null;

+ 77 - 0
tests/PhpRendererTest.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace Test\Michel\PurePlate;
+
+use Michel\PurePlate\PhpRenderer;
+use Michel\UniTester\TestCase;
+
+final class PhpRendererTest extends TestCase
+{
+    private PhpRenderer $renderer;
+
+    protected function setUp(): void
+    {
+        $this->renderer = new PhpRenderer(__DIR__ . '/views');
+    }
+
+    protected function tearDown(): void
+    {
+    }
+
+    protected function execute(): void
+    {
+       $this->testRenderBasicView();
+       $this->testRenderViewWithLayout();
+       $this->testRenderViewWithBlock();
+       $this->testRenderViewWithExtendingBlock();
+       $this->testStartAndEndBlocks();
+       $this->testRenderViewNotFound();
+    }
+
+    public function testRenderBasicView(): void
+    {
+        $expectedOutput = 'Hello, World!';
+        $output = $this->renderer->render('test.php', ['message' => 'Hello, World!']);
+        $this->assertEquals($expectedOutput, $output);
+    }
+
+    public function testRenderViewWithLayout(): void
+    {
+        $expectedOutput = '<!DOCTYPE html><html lang="fr"><head><title></title></head><body class="body"></body></html>';
+        $output = $this->renderer->render('test_layout.php');
+        $this->assertEquals($expectedOutput, $output);
+    }
+
+    public function testRenderViewWithBlock(): void
+    {
+        $expectedOutput = '';
+        $output = $this->renderer->render('test_block.php');
+        $this->assertEquals($expectedOutput, $output);
+    }
+
+    public function testRenderViewWithExtendingBlock(): void
+    {
+        $expectedOutput = '<!DOCTYPE html><html lang="fr"><head><title>Page Title</title></head><body class="body">Page Content</body></html>';
+        $output = $this->renderer->render('test_extends.php');
+        $this->assertEquals($expectedOutput, $output);
+    }
+
+    public function testStartAndEndBlocks(): void
+    {
+        $this->renderer->startBlock('content');
+        echo 'Block Content';
+        $this->renderer->endBlock();
+
+        $expectedOutput = 'Block Content';
+        $output = $this->renderer->block('content');
+        $this->assertEquals($expectedOutput, $output);
+    }
+
+    public function testRenderViewNotFound(): void
+    {
+        $this->expectException(\InvalidArgumentException::class, function () {
+            $this->renderer->render('non_existent_template.php');
+        });
+    }
+
+}

+ 173 - 37
tests/PurePlateTest.php

@@ -1,77 +1,213 @@
 <?php
 
-namespace Test\Michel\Renderer;
+namespace Test\Michel\PurePlate;
 
-use Michel\Renderer\PurePlate;
+use ErrorException;
+use Michel\PurePlate\Engine;
 use Michel\UniTester\TestCase;
 
 final class PurePlateTest extends TestCase
 {
-    private PurePlate $renderer;
+    private Engine $engine;
+    private string $cacheDir = __DIR__ . '/cache';
+    private string $tplDir = __DIR__ . '/tpl';
 
     protected function setUp(): void
     {
-        $this->renderer = new PurePlate(__DIR__ . '/views');
+        if (!is_dir($this->cacheDir)) {
+            mkdir($this->cacheDir);
+        }
+        if (!is_dir($this->tplDir)) {
+            mkdir($this->tplDir);
+        }
+
+        $this->engine = new Engine($this->tplDir, true, $this->cacheDir);
     }
 
     protected function tearDown(): void
     {
+        if (is_dir($this->cacheDir)) {
+            array_map('unlink', glob("$this->cacheDir/*.*"));
+            rmdir($this->cacheDir);
+        }
+        if (is_dir($this->tplDir)) {
+            array_map('unlink', glob("$this->tplDir/*.*"));
+            rmdir($this->tplDir);
+        }
     }
 
     protected function execute(): void
     {
-       $this->testRenderBasicView();
-       $this->testRenderViewWithLayout();
-       $this->testRenderViewWithBlock();
-       $this->testRenderViewWithExtendingBlock();
-       $this->testStartAndEndBlocks();
-       $this->testRenderViewNotFound();
+        $this->itRendersSimpleVariables();
+        $this->itHandlesFiltersWithArguments();
+        $this->itExecutesLogicBlocks();
+        $this->itMapsErrorsToOriginalTemplateLine();
+        $this->itHandlesComplexLogicAndNotOperator();
+        $this->itHandlesForeachWithObjectsAndArrays();
+        $this->itHandlesComplexNestedLogic();
+        $this->itHandlesNestedForeachAndIf();
+        $this->itHandlesIsNotEmptySyntax();
+        $this->itHandlesNativeArraySyntax();
+    }
+
+    /** @test */
+    public function itRendersSimpleVariables()
+    {
+        $tpl = "Hello {{ user.name }}!";
+        file_put_contents($this->tplDir . '/test.html', $tpl);
+
+        $output = $this->engine->render('test.html', ['user' => (object)['name' => 'Michel']]);
+        $this->assertEquals("Hello Michel!", trim($output));
     }
 
-    public function testRenderBasicView(): void
+    /** @test */
+    public function itHandlesFiltersWithArguments()
     {
-        $expectedOutput = 'Hello, World!';
-        $output = $this->renderer->render('test.php', ['message' => 'Hello, World!']);
-        $this->assertEquals($expectedOutput, $output);
+        $tpl = "Total: {{ price | round(2) }} €";
+        file_put_contents($this->tplDir . '/filter.html', $tpl);
+
+        $output = $this->engine->render('filter.html', ['price' => 12.556]);
+        $this->assertEquals("Total: 12.56 €", trim($output));
     }
 
-    public function testRenderViewWithLayout(): void
+    /** @test */
+    public function itExecutesLogicBlocks()
     {
-        $expectedOutput = '<!DOCTYPE html><html lang="fr"><head><title></title></head><body class="body"></body></html>';
-        $output = $this->renderer->render('test_layout.php');
-        $this->assertEquals($expectedOutput, $output);
+        $tpl = "{% if show %}YES{% else %}NO{% endif %}";
+        file_put_contents($this->tplDir . '/logic.html', $tpl);
+
+        $this->assertEquals("YES", trim($this->engine->render('logic.html', ['show' => true])));
+        $this->assertEquals("NO", trim($this->engine->render('logic.html', ['show' => false])));
+    }
+
+    /** @test */
+    public function itMapsErrorsToOriginalTemplateLine()
+    {
+        $tpl = "Line 1\nLine 2\n{{ undefined_var.property }}";
+        file_put_contents($this->tplDir . '/error.html', $tpl);
+
+        try {
+            $this->engine->render('error.html', []);
+            $this->fail("L'exception aurait dû être lancée.");
+        } catch (ErrorException $e) {
+            // Vérification du mapping de ligne magique /*L:3;F:...*/
+            $this->assertEquals(3, $e->getLine());
+            $this->assertStringContains($e->getFile(), 'error.html');
+        }
+    }
+
+    /** @test */
+    public function itHandlesComplexLogicAndNotOperator()
+    {
+        $tpl = "{% if not user.is_active %}Inactif{% endif %}";
+        file_put_contents($this->tplDir . '/not.html', $tpl);
+
+        $output = $this->engine->render('not.html', ['user' => (object)['is_active' => false]]);
+        $this->assertEquals("Inactif", trim($output));
     }
 
-    public function testRenderViewWithBlock(): void
+    /** @test */
+    public function itHandlesForeachWithObjectsAndArrays()
     {
-        $expectedOutput = '';
-        $output = $this->renderer->render('test_block.php');
-        $this->assertEquals($expectedOutput, $output);
+        $tpl = "<ul>{% foreach users as user %}<li>{{ user.name }} ({{ user.role }})</li>{% endforeach %}</ul>";
+        file_put_contents($this->tplDir . '/foreach.html', $tpl);
+
+        $data = [
+            'users' => [
+                (object)['name' => 'Alice', 'role' => 'Admin'],
+                (object)['name' => 'Bob', 'role' => 'User'],
+            ]
+        ];
+
+        $output = $this->engine->render('foreach.html', $data);
+        $this->assertStringContains($output, 'Alice (Admin)');
+        $this->assertStringContains($output, 'Bob (User)');
     }
 
-    public function testRenderViewWithExtendingBlock(): void
+    /** @test */
+    public function itHandlesComplexNestedLogic()
     {
-        $expectedOutput = '<!DOCTYPE html><html lang="fr"><head><title>Page Title</title></head><body class="body">Page Content</body></html>';
-        $output = $this->renderer->render('test_extends.php');
-        $this->assertEquals($expectedOutput, $output);
+        $tpl = "{% if (user.age >= 18 and user.has_permit) or user.role == 'admin' %}ACCESS GRANTED{% endif %}";
+        file_put_contents($this->tplDir . '/complex_logic.html', $tpl);
+
+        $res1 = $this->engine->render('complex_logic.html', [
+            'user' => (object)['age' => 17, 'has_permit' => false, 'role' => 'admin']
+        ]);
+        $this->assertEquals("ACCESS GRANTED", trim($res1));
+
+        $res2 = $this->engine->render('complex_logic.html', [
+            'user' => (object)['age' => 20, 'has_permit' => true, 'role' => 'user']
+        ]);
+        $this->assertEquals("ACCESS GRANTED", trim($res2));
+
+        $res3 = $this->engine->render('complex_logic.html', [
+            'user' => (object)['age' => 16, 'has_permit' => true, 'role' => 'user']
+        ]);
+        $this->assertEquals("", trim($res3));
     }
 
-    public function testStartAndEndBlocks(): void
+    /** @test */
+    public function itHandlesNestedForeachAndIf()
     {
-        $this->renderer->startBlock('content');
-        echo 'Block Content';
-        $this->renderer->endBlock();
+        $tpl = "{% foreach categories as cat %}
+                {{ cat.name }}:
+                {% foreach cat.items as item %}
+                    {% if item.price > 10 %}{{ item.name }}{% endif %}
+                {% endforeach %}
+            {% endforeach %}";
+
+        file_put_contents($this->tplDir . '/nested.html', $tpl);
 
-        $expectedOutput = 'Block Content';
-        $output = $this->renderer->block('content');
-        $this->assertEquals($expectedOutput, $output);
+        $data = [
+            'categories' => [
+                (object)[
+                    'name' => 'Tech',
+                    'items' => [
+                        (object)['name' => 'Mouse', 'price' => 15],
+                        (object)['name' => 'Pad', 'price' => 5]
+                    ]
+                ]
+            ]
+        ];
+
+        $output = $this->engine->render('nested.html', $data);
+        $this->assertStringContains($output, 'Tech:');
+        $this->assertStringContains($output, 'Mouse');
     }
 
-    public function testRenderViewNotFound(): void
+    /** @test */
+    public function itHandlesIsNotEmptySyntax()
     {
-        $this->expectException(\InvalidArgumentException::class, function () {
-            $this->renderer->render('non_existent_template.php');
-        });
+        $tpl = "{% if tags is not empty %}TAGS: {{ tags | count }}{% else %}EMPTY{% endif %}";
+        file_put_contents($this->tplDir . '/is_not_empty.html', $tpl);
+
+        $res1 = $this->engine->render('is_not_empty.html', ['tags' => ['php', 'lexer']]);
+        $this->assertStringContains('TAGS: 2', $res1);
+
+        $res2 = $this->engine->render('is_not_empty.html', ['tags' => []]);
+        $this->assertStringContains('EMPTY', $res2);
     }
 
+    /** @test */
+    public function itHandlesNativeArraySyntax()
+    {
+        $tpl = "First: {{ tags[0] }}
+            Key: {{ config['env'] }}
+            Count: {{ tags | count }}
+            Dynamic: {% set idx = 1 %}{{ tags[idx] }}";
+
+        file_put_contents($this->tplDir . '/arrays_native.html', $tpl);
+
+        $data = [
+            'tags' => ['PHP', 'Lexer', 'Fast'],
+            'config' => ['env' => 'production']
+        ];
+
+        $output = $this->engine->render('arrays_native.html', $data);
+
+        $this->assertStringContains($output, 'First: PHP');
+        $this->assertStringContains($output, 'Key: production');
+        $this->assertStringContains($output, 'Count: 3');
+        $this->assertStringContains($output, 'Dynamic: Lexer');
+    }
 }