From 4ce436f81d273d8b6057a1109076907fd79f98cf Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Sat, 3 Oct 2026 22:40:39 +0000 Subject: [PATCH 1/2] Add %empty branch to %loop and harden loop directives A bare %empty between %loop and %endloop renders its content when the iterable is empty, like Blade's @forelse / @empty. %empty($x) with parentheses remains the conditional helper. A pre-scan in Compiler::compile records which loops own a branch so each directive compiles correctly line by line; nested loops get their own flag and a %loop without %empty still compiles to a plain foreach. Also fix the greedy condition in %stop(...) and %jump(...), which swallowed any later parentheses on the same line, and add unit tests covering every loop directive, the pre-scan, and the stack runner. --- CHANGELOG.md | 5 + docs/en.md | 17 ++ docs/fr.md | 17 ++ src/Compiler.php | 1 + src/Lexique/CompileLoop.php | 132 ++++++++- tests/CompileLoopTest.php | 524 ++++++++++++++++++++++++++++++++++++ 6 files changed, 693 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5df86c4..17c12b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* Add the `%empty` loop branch: a bare `%empty` (no parentheses) between `%loop` and `%endloop` renders its content when the iterable is empty, like Blade's `@forelse` / `@empty`. `%empty($x)` with parentheses remains the conditional helper. Nested loops each get their own empty flag, and a `%loop` without `%empty` still compiles to a plain `foreach`. + ### Fixed +* Fix greedy condition in `%stop(...)` and `%jump(...)`: when another `(...)` followed on the same line, the condition extended to the last `)` and produced broken PHP. The breaker directives now use the same balanced-paren matcher as the loop heads. * Fix greedy `condition_pattern` that produced broken PHP when an `%if`, `%unless`, `%isset`, `%loop`, `%while`, or `%for` directive sat on a single line with a `{{ }}` echo in its body (the head would extend past the real `)` and swallow the echo's closing paren). Replaced `(.*)` with a recursive balanced-paren matcher in `Compiler::$condition_pattern`. ## 3.1.5 - 2025-12-21 diff --git a/docs/en.md b/docs/en.md index cd02203..5e9e98e 100644 --- a/docs/en.md +++ b/docs/en.md @@ -10,6 +10,7 @@ - [The `%if` directives](#the-if-directives) - [The `%loop` / `%for` / `%while` directives](#the-loop--for--while-directives) - [Using `%loop`](#using-loop) + - [The `%empty` branch of a loop](#the-empty-branch-of-a-loop) - [Syntactic sugars `%jump` and `%stop`](#syntactic-sugars-jump-and-stop) - [The use of `%for` and `%while`](#the-use-of-for-and-while) - [Conditional Classes and Styles](#conditional-classes-and-styles) @@ -260,6 +261,22 @@ With syntactic sugars, we can reduce the code like this: %endloop ``` +### The `%empty` branch of a loop + +When the list you iterate over may be empty, you often end up wrapping the `%loop` in an `%if`. A bare `%empty` inside the loop lets you declare the content to render when the loop body never ran: + +```t +%loop($users as $user) + Hello {{ $user->name }} +%empty + No user yet. +%endloop +``` + +The `%empty` branch is rendered only if `$users` is empty. It works with nested loops, each `%loop` keeps its own `%empty`. This branch is only available with `%loop`. + +> Note: `%empty` without parentheses is the loop branch. `%empty($value)` with parentheses stays the conditional helper described above and can still be used inside a loop. + ### The use of `%for` and `%while` This clause does exactly the action of `for`. diff --git a/docs/fr.md b/docs/fr.md index e55b688..7948c8d 100644 --- a/docs/fr.md +++ b/docs/fr.md @@ -10,6 +10,7 @@ - [les directives `%if`](#les-directives-if) - [Les directives `%loop` / `%for` / `%while`](#les-directives-loop--for--while) - [L'utilisation de `%loop`](#lutilisation-de-loop) + - [La branche `%empty` d'une boucle](#la-branche-empty-dune-boucle) - [Les sucres syntaxiques `%jump` et `%stop`](#les-sucres-syntaxiques-jump-et-stop) - [L'utilisation de `%for` et `%while`](#lutilisation-de-for-et-while) - [Classes et styles conditionnels](#classes-et-styles-conditionnels) @@ -260,6 +261,22 @@ Avec les sucres syntaxique, on peut réduire le code comme ceci: %endloop ``` +### La branche `%empty` d'une boucle + +Lorsque la liste parcourue peut être vide, on finit souvent par entourer le `%loop` d'un `%if`. Un `%empty` seul dans la boucle permet de déclarer le contenu à afficher lorsque le corps de la boucle n'a jamais été exécuté: + +```t +%loop($users as $user) + Bonjour {{ $user->name }} +%empty + Aucun utilisateur pour le moment. +%endloop +``` + +La branche `%empty` n'est affichée que si `$users` est vide. Elle fonctionne avec les boucles imbriquées, chaque `%loop` garde son propre `%empty`. Cette branche n'est disponible qu'avec `%loop`. + +> Note: `%empty` sans parenthèses est la branche de boucle. `%empty($valeur)` avec parenthèses reste l'aide conditionnelle décrite plus haut et peut toujours être utilisée dans une boucle. + ### L'utilisation de `%for` et `%while` Cette clause faire exactement l'action de `for`. diff --git a/src/Compiler.php b/src/Compiler.php index e7b86e3..63191e2 100644 --- a/src/Compiler.php +++ b/src/Compiler.php @@ -198,6 +198,7 @@ public function compile(string $data): string $data = $this->compileVerbatim($data); $data = $this->compileComments($data); $data = $this->collapseMultilineDirectives($data); + $this->scanLoopEmpty($data); $data = preg_split('/\n|\r\n/', $data); diff --git a/src/Lexique/CompileLoop.php b/src/Lexique/CompileLoop.php index 32673d5..5bf8f76 100644 --- a/src/Lexique/CompileLoop.php +++ b/src/Lexique/CompileLoop.php @@ -4,6 +4,35 @@ trait CompileLoop { + /** + * The name of the runtime flag that records whether a %loop body ran. + * + * @var string + */ + private string $loop_empty_flag = '$__tintin_loop_{depth}'; + + /** + * For each %loop in the template (document order): its nesting depth and + * whether it owns a bare %empty branch, as [depth, has_empty]. + * + * @var array + */ + private array $loop_empty_heads = []; + + /** + * For each bare %empty in the template (document order): the nesting depth of its loop. + * + * @var int[] + */ + private array $loop_empty_branches = []; + + /** + * For each %endloop in the template (document order): does its loop own a bare %empty? + * + * @var bool[] + */ + private array $loop_empty_ends = []; + /** * Definition of all available stack * @return array @@ -12,6 +41,7 @@ private function getLoopStack(): array { return [ 'Foreach', + 'LoopEmpty', 'EndForeach', 'Continue', 'Break', @@ -41,6 +71,59 @@ protected function compileLoopStack(string $expression): string return $expression; } + /** + * Pre-scan the template for %loop / %empty / %endloop so that each + * directive knows, when it is compiled line by line, whether its loop owns + * an %empty branch and at which nesting depth it sits. + * + * Only a bare `%empty` (no parentheses) is a loop branch; `%empty($x)` is + * the conditional helper and is left to the helpers stack. + * + * @param string $data + * @return void + */ + protected function scanLoopEmpty(string $data): void + { + $this->loop_empty_heads = []; + $this->loop_empty_branches = []; + $this->loop_empty_ends = []; + + preg_match_all('/%(empty\b(?!\s*\()|endloop|loop\b)/', $data, $matches); + + $stack = []; + + foreach ($matches[1] as $token) { + if ($token === 'loop') { + $stack[] = count($this->loop_empty_heads); + $this->loop_empty_heads[] = [count($stack), false]; + continue; + } + + if (count($stack) === 0) { + continue; + } + + if ($token === 'empty') { + $this->loop_empty_heads[end($stack)][1] = true; + $this->loop_empty_branches[] = count($stack); + continue; + } + + $this->loop_empty_ends[] = $this->loop_empty_heads[array_pop($stack)][1]; + } + } + + /** + * Build the empty flag name for the given nesting depth + * + * @param int $depth + * @return string + */ + private function loopEmptyFlag(int $depth): string + { + return str_replace('{depth}', (string) $depth, $this->loop_empty_flag); + } + /** * Compile the %loop directive * @@ -82,6 +165,9 @@ private function compileEndLoop($expression, $lexic, $o_lexic): string /** * Compile the loop breaker directive * + * The optional condition uses a recursive balanced-paren matcher so that + * `%stop($x) ... ($y)` on one line stops at the condition's own `)`. + * * @param string $expression * @param string $lexic * @param string $o_lexic @@ -90,7 +176,7 @@ private function compileEndLoop($expression, $lexic, $o_lexic): string private function compileBreaker($expression, $lexic, $o_lexic): string { $output = preg_replace_callback( - "/($lexic\s*(\(.+\)\s*)|$lexic)/s", + "/($lexic\s*(\((?:[^()]|(?2))*\))\s*|$lexic)/s", function ($match) use ($lexic, $o_lexic) { array_shift($match); @@ -114,7 +200,41 @@ function ($match) use ($lexic, $o_lexic) { */ protected function compileForeach(string $expression): string { - return $this->compileLoop($expression, '%loop', 'foreach'); + $regex = sprintf($this->condition_pattern, '%loop'); + + $output = preg_replace_callback($regex, function ($match) { + [$depth, $has_empty] = array_shift($this->loop_empty_heads) ?? [1, false]; + + if (!$has_empty) { + return ""; + } + + $flag = $this->loopEmptyFlag($depth); + + return ""; + }, $expression); + + return $output == $expression ? '' : $output; + } + + /** + * Compile the bare %empty directive inside a %loop + * + * Closes the foreach and opens the branch rendered when the loop body never ran. + * `%empty(...)` with parentheses is not matched here, it is the conditional helper. + * + * @param string $expression + * @return string + */ + protected function compileLoopEmpty(string $expression): string + { + $output = preg_replace_callback('/\n*%empty\b(?!\s*\()\n*/', function () { + $depth = array_shift($this->loop_empty_branches) ?? 1; + + return "loopEmptyFlag($depth)}): ?>"; + }, $expression); + + return $output == $expression ? '' : $output; } /** @@ -147,7 +267,13 @@ protected function compileFor(string $expression): string */ protected function compileEndForeach(string $expression): string { - return $this->compileEndLoop($expression, '%endloop', 'endforeach'); + $output = preg_replace_callback('/\n*%endloop\n*/', function () { + $has_empty = array_shift($this->loop_empty_ends) ?? false; + + return $has_empty ? "" : ""; + }, $expression); + + return $output == $expression ? '' : $output; } /** diff --git a/tests/CompileLoopTest.php b/tests/CompileLoopTest.php index 49ab3d6..d954e60 100644 --- a/tests/CompileLoopTest.php +++ b/tests/CompileLoopTest.php @@ -147,4 +147,528 @@ public function testCompileMultilineLoop() $this->assertStringContainsString('$arrayes as $arr', $output); $this->assertStringContainsString('makeReflectionFor('compileLoopEmpty'); + + $render = $compile_loop_empty->invoke($this->compiler, '%empty'); + + $this->assertEquals('', $render); + } + + /** + * A %loop with a bare %empty branch renders the fallback when the iterable is empty. + */ + public function testCompileLoopWithLoopEmpty() + { + $source = "%loop(\$users as \$user)\nHello {{ \$user }}\n%empty\nNobody\n%endloop"; + + $render = $this->compiler->compile($source); + + $this->assertStringContainsString( + '', + $render + ); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString("Nobody\n", $render); + $this->assertStringNotContainsString('', $render); + + $this->assertSame('Hello aHello b', $this->evaluate($render, ['users' => ['a', 'b']])); + $this->assertSame('Nobody', $this->evaluate($render, ['users' => []])); + } + + /** + * A %loop without a bare %empty keeps compiling to a plain foreach. + */ + public function testCompileLoopWithoutLoopEmptyIsUnchanged() + { + $source = "%loop(\$users as \$user)\nHello {{ \$user }}\n%endloop"; + + $render = $this->compiler->compile($source); + + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringNotContainsString('__tintin_loop', $render); + } + + /** + * Nested loops each get their own empty flag, and a sibling loop after a + * %empty block is not affected by it. + */ + public function testCompileNestedLoopEmpty() + { + $source = implode("\n", [ + '%loop($groups as $group)', + '[{{ $group["name"] }}', + '%loop($group["members"] as $member)', + '{{ $member }}', + '%empty', + 'empty-group', + '%endloop', + ']', + '%empty', + 'no-groups', + '%endloop', + '%loop($others as $other)', + '{{ $other }}', + '%endloop', + ]); + + $render = $this->compiler->compile($source); + + $this->assertStringContainsString( + '$__tintin_loop_1 = true; foreach ($groups as $group): $__tintin_loop_1 = false;', + $render + ); + $this->assertStringContainsString( + '$__tintin_loop_2 = true; foreach ($group["members"] as $member): $__tintin_loop_2 = false;', + $render + ); + $this->assertStringContainsString('', $render); + $this->assertSame(2, substr_count($render, '')); + $this->assertSame(1, substr_count($render, '')); + + $output = $this->evaluate($render, [ + 'groups' => [ + ['name' => 'a', 'members' => ['x', 'y']], + ['name' => 'b', 'members' => []], + ], + 'others' => ['z'], + ]); + + $this->assertSame('[axy] [bempty-group ] z', $output); + + $output = $this->evaluate($render, ['groups' => [], 'others' => []]); + + $this->assertSame('no-groups', $output); + } + + /** + * A bare %empty on a single line with its loop compiles like the multi-line form. + */ + public function testCompileInlineLoopEmpty() + { + $source = '%loop ($items as $item) {{ $item }} %empty none %endloop'; + + $render = $this->compiler->compile($source); + + $this->assertSame('a b', $this->evaluate($render, ['items' => ['a', 'b']])); + $this->assertSame('none', $this->evaluate($render, ['items' => []])); + } + + /** + * %empty($x) with parentheses is still the conditional helper, even inside a %loop. + */ + public function testEmptyHelperInsideLoopIsNotTreatedAsLoopBranch() + { + $source = "%loop(\$users as \$user)\n%empty(\$user)\n-\n%endempty\n{{ \$user }}\n%endloop"; + + $render = $this->compiler->compile($source); + + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringNotContainsString('__tintin_loop', $render); + + $this->assertSame('- a', $this->evaluate($render, ['users' => ['', 'a']])); + } + + /** + * Compiling twice with the same compiler instance must not leak loop state. + */ + public function testCompileLoopEmptyStateIsResetBetweenCompiles() + { + $this->compiler->compile("%loop(\$a as \$b)\n%empty\n%endloop"); + + $render = $this->compiler->compile("%loop(\$a as \$b)\n%endloop"); + + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringNotContainsString('__tintin_loop', $render); + } + + /** + * %loop head variants: no space, key/value pairs, surrounding whitespace. + */ + public function testCompileForeachHeadVariants() + { + $compile_foreach = $this->makeReflectionFor('compileForeach'); + + $render = $compile_foreach->invoke($this->compiler, '%loop($names as $name)'); + $this->assertEquals('', $render); + + $render = $compile_foreach->invoke($this->compiler, '%loop($users as $id => $user)'); + $this->assertEquals(' $user): ?>', $render); + + $render = $compile_foreach->invoke($this->compiler, '%loop($user->posts() as [$title, $body])'); + $this->assertEquals('posts() as [$title, $body]): ?>', $render); + + $render = $compile_foreach->invoke($this->compiler, ' %loop ($a as $b) '); + $this->assertEquals(' ', $render); + } + + /** + * Each loop compiler returns an empty string when its directive is absent, + * so the stack runner knows to keep the previous expression. + */ + public function testLoopCompilersReturnEmptyStringWhenNothingMatches() + { + $methods = [ + 'compileForeach', 'compileEndForeach', 'compileWhile', 'compileEndWhile', + 'compileFor', 'compileEndFor', 'compileContinue', 'compileBreak', 'compileLoopEmpty', + ]; + + foreach ($methods as $method) { + $render = $this->makeReflectionFor($method)->invoke($this->compiler, 'Hello {{ $name }}'); + + $this->assertSame('', $render, "$method should ignore an expression without its directive"); + } + } + + /** + * %endloop, %endwhile and %endfor swallow the newlines around them. + */ + public function testCompileLoopEndsStripSurroundingNewlines() + { + $cases = [ + 'compileEndForeach' => ["\n\n%endloop\n", ''], + 'compileEndWhile' => ["\n%endwhile\n\n", ''], + 'compileEndFor' => ["\n%endfor\n", ''], + ]; + + foreach ($cases as $method => [$source, $expected]) { + $render = $this->makeReflectionFor($method)->invoke($this->compiler, $source); + + $this->assertEquals($expected, $render, $method); + } + } + + /** + * %while head variants: no space, nested parentheses in the condition. + */ + public function testCompileWhileHeadVariants() + { + $compile_while = $this->makeReflectionFor('compileWhile'); + + $render = $compile_while->invoke($this->compiler, '%while($i < 10)'); + $this->assertEquals('', $render); + + $render = $compile_while->invoke($this->compiler, '%while ($i < count($items) && !empty($items[$i]))'); + $this->assertEquals('', $render); + + $render = $compile_while->invoke($this->compiler, '%while (($row = $cursor->next()) !== null)'); + $this->assertEquals('next()) !== null): ?>', $render); + } + + /** + * %for head variants: no space, function calls and nested parentheses. + */ + public function testCompileForHeadVariants() + { + $compile_for = $this->makeReflectionFor('compileFor'); + + $render = $compile_for->invoke($this->compiler, '%for($i = 0; $i < 10; $i++)'); + $this->assertEquals('', $render); + + $render = $compile_for->invoke($this->compiler, '%for ($i = 0, $n = count($items); $i < $n; $i += 2)'); + $this->assertEquals('', $render); + + $render = $compile_for->invoke($this->compiler, '%for (;;)'); + $this->assertEquals('', $render); + } + + /** + * %stop compiles to break, bare or guarded by a condition. + */ + public function testCompileStop() + { + $compile_break = $this->makeReflectionFor('compileBreak'); + + $render = $compile_break->invoke($this->compiler, '%stop'); + $this->assertEquals('', $render); + + $render = $compile_break->invoke($this->compiler, '%stop ($name == "Tintin")'); + $this->assertEquals('', $render); + + $render = $compile_break->invoke($this->compiler, '%stop($i > 10)'); + $this->assertEquals(' 10): break; endif; ?>', $render); + } + + /** + * %jump compiles to continue, with or without a space before its condition. + */ + public function testCompileJumpHeadVariants() + { + $compile_continue = $this->makeReflectionFor('compileContinue'); + + $render = $compile_continue->invoke($this->compiler, '%jump($name == "Tintin")'); + $this->assertEquals('', $render); + + $render = $compile_continue->invoke($this->compiler, '%jump (in_array($name, $skipped))'); + $this->assertEquals('', $render); + } + + /** + * The breaker directives must not be confused with words that merely start + * with them, such as a %stopwatch custom directive or plain text. + */ + public function testBreakerDoesNotMatchInsideOtherWords() + { + $compile_break = $this->makeReflectionFor('compileBreak'); + $compile_continue = $this->makeReflectionFor('compileContinue'); + + $this->assertSame('', $compile_break->invoke($this->compiler, 'nonstop')); + $this->assertSame('', $compile_continue->invoke($this->compiler, 'highjump')); + } + + /** + * Regression: a guarded %stop / %jump followed by other parentheses on the + * same line must stop at its own `)` instead of swallowing the rest. + */ + public function testBreakerConditionStopsAtItsOwnParenthesis() + { + $compile_break = $this->makeReflectionFor('compileBreak'); + $compile_continue = $this->makeReflectionFor('compileContinue'); + + $render = $compile_break->invoke($this->compiler, '%stop($b) '); + $this->assertEquals('', $render); + + $render = $compile_continue->invoke($this->compiler, '%jump(in_array($b, ["x"])) {{ $b }} %jump($c)'); + $this->assertEquals( + '{{ $b }} ', + $render + ); + } + + /** + * The bare %empty compiler ignores the parenthesised helper form. + */ + public function testCompileLoopEmptyIgnoresHelperForm() + { + $compile_loop_empty = $this->makeReflectionFor('compileLoopEmpty'); + + $this->assertSame('', $compile_loop_empty->invoke($this->compiler, '%empty($users)')); + $this->assertSame('', $compile_loop_empty->invoke($this->compiler, '%empty ($users)')); + $this->assertSame('', $compile_loop_empty->invoke($this->compiler, '%endempty')); + $this->assertSame('', $compile_loop_empty->invoke($this->compiler, '%notempty($users)')); + } + + /** + * The bare %empty swallows the newlines around it like the other branch directives. + */ + public function testCompileLoopEmptyStripsSurroundingNewlines() + { + $compile_loop_empty = $this->makeReflectionFor('compileLoopEmpty'); + + $render = $compile_loop_empty->invoke($this->compiler, "\n%empty\n"); + + $this->assertEquals('', $render); + } + + /** + * The pre-scan records, in document order, each loop's depth and whether it + * owns an %empty branch, each branch's depth, and each end's branch flag. + */ + public function testScanLoopEmptyRecordsNestedStructure() + { + $scan = $this->makeReflectionFor('scanLoopEmpty'); + + $scan->invoke($this->compiler, implode("\n", [ + '%loop($groups as $group)', + '%loop($group as $member)', + '%empty', + '%endloop', + '%empty', + '%endloop', + '%loop($others as $other)', + '%endloop', + ])); + + $this->assertSame([[1, true], [2, true], [1, false]], $this->readProperty('loop_empty_heads')); + $this->assertSame([2, 1], $this->readProperty('loop_empty_branches')); + $this->assertSame([true, true, false], $this->readProperty('loop_empty_ends')); + } + + /** + * The pre-scan ignores the %empty(...) helper, a stray %empty or %endloop + * outside any loop, and %while / %for which have no empty branch. + */ + public function testScanLoopEmptyIgnoresHelperAndStrayTokens() + { + $scan = $this->makeReflectionFor('scanLoopEmpty'); + + $scan->invoke($this->compiler, implode("\n", [ + '%empty', + '%endloop', + '%loop($users as $user)', + '%empty($user)', + '%endempty', + '%notempty($user)', + '%endnotempty', + '%endloop', + '%while($i < 3)', + '%empty', + '%endwhile', + ])); + + $this->assertSame([[1, false]], $this->readProperty('loop_empty_heads')); + $this->assertSame([], $this->readProperty('loop_empty_branches')); + $this->assertSame([false], $this->readProperty('loop_empty_ends')); + } + + /** + * The pre-scan starts from a clean slate on every call. + */ + public function testScanLoopEmptyResetsPreviousState() + { + $scan = $this->makeReflectionFor('scanLoopEmpty'); + + $scan->invoke($this->compiler, "%loop(\$a as \$b)\n%empty\n%endloop"); + $scan->invoke($this->compiler, "%loop(\$a as \$b)\n%endloop"); + + $this->assertSame([[1, false]], $this->readProperty('loop_empty_heads')); + $this->assertSame([], $this->readProperty('loop_empty_branches')); + $this->assertSame([false], $this->readProperty('loop_empty_ends')); + } + + /** + * The loop stack runner applies every loop directive found on one line. + */ + public function testCompileLoopStackAppliesAllDirectives() + { + $compile_stack = $this->makeReflectionFor('compileLoopStack'); + + $render = $compile_stack->invoke( + $this->compiler, + '%loop($a as $b) %stop($b) %endloop %while($x) %jump %endwhile %for($i = 0; $i < 1; $i++) %endfor' + ); + + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringNotContainsString('%', $render); + } + + /** + * The loop stack runner leaves a line without loop directives untouched. + */ + public function testCompileLoopStackLeavesOtherLinesUntouched() + { + $compile_stack = $this->makeReflectionFor('compileLoopStack'); + + $line = '

Hello {{ $name }}

%if($x) %endif'; + + $this->assertSame($line, $compile_stack->invoke($this->compiler, $line)); + } + + /** + * End-to-end: %loop with key/value pairs renders every pair. + */ + public function testRenderForeachWithKeyValue() + { + $render = $this->compiler->compile("%loop(\$users as \$id => \$user)\n{{ \$id }}={{ \$user }};\n%endloop"); + + $this->assertSame('1=a; 2=b;', $this->evaluate($render, ['users' => [1 => 'a', 2 => 'b']])); + } + + /** + * End-to-end: %while runs until its condition becomes false. + */ + public function testRenderWhile() + { + $render = $this->compiler->compile("%while(\$i < 3)\n{{ \$i++ }}\n%endwhile"); + + $this->assertSame('012', $this->evaluate($render, ['i' => 0])); + } + + /** + * End-to-end: %for with %jump and %stop skips one iteration and exits early. + */ + public function testRenderForWithJumpAndStop() + { + $render = $this->compiler->compile(implode("\n", [ + '%for($i = 0; $i < 10; $i++)', + '%jump($i == 1)', + '%stop($i == 3)', + '{{ $i }}', + '%endfor', + ])); + + $this->assertSame('02', $this->evaluate($render, [])); + } + + /** + * End-to-end: bare %stop and %jump inside %if blocks within a %loop. + */ + public function testRenderLoopWithBareJumpAndStop() + { + $render = $this->compiler->compile(implode("\n", [ + '%loop($names as $name)', + '%if($name == "skip")', + '%jump', + '%endif', + '%if($name == "end")', + '%stop', + '%endif', + '{{ $name }},', + '%endloop', + ])); + + $output = $this->evaluate($render, ['names' => ['a', 'skip', 'b', 'end', 'c']]); + + $this->assertSame('a, b,', $output); + } + + /** + * End-to-end: %stop inside a loop that has an %empty branch does not + * trigger the branch, since the body ran at least once. + */ + public function testRenderLoopEmptyNotTriggeredAfterStop() + { + $render = $this->compiler->compile(implode("\n", [ + '%loop($names as $name)', + '{{ $name }}', + '%stop', + '%empty', + 'none', + '%endloop', + ])); + + $this->assertSame('a', $this->evaluate($render, ['names' => ['a', 'b']])); + $this->assertSame('none', $this->evaluate($render, ['names' => []])); + } + + /** + * Read a private property of the compiler under test. + */ + private function readProperty(string $name) + { + $property = new ReflectionProperty(Compiler::class, $name); + $property->setAccessible(true); + + return $property->getValue($this->compiler); + } + + /** + * Run compiled PHP with the given variables and return its output with + * whitespace collapsed, since PHP drops the newline that follows `?>`. + */ + private function evaluate(string $php, array $data): string + { + extract($data); + ob_start(); + eval('?>' . $php); + + return trim(preg_replace('/\s+/', ' ', ob_get_clean())); + } } From c7763dfc2a3cf8f1328965b6d4b48c2901d7520d Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Sun, 4 Oct 2026 10:00:29 +0000 Subject: [PATCH 2/2] Expand loop documentation Document key/value and single-line %loop forms, note that %jump and %stop compile to continue and break and work in every loop kind, give %for and %while real examples, and fix the %else and %endnotempty typos in the conditional helpers section. Table of contents now matches section order. --- docs/en.md | 42 +++++++++++++++++++++++++++++++++++------- docs/fr.md | 44 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/docs/en.md b/docs/en.md index 5e9e98e..20c9804 100644 --- a/docs/en.md +++ b/docs/en.md @@ -10,8 +10,8 @@ - [The `%if` directives](#the-if-directives) - [The `%loop` / `%for` / `%while` directives](#the-loop--for--while-directives) - [Using `%loop`](#using-loop) - - [The `%empty` branch of a loop](#the-empty-branch-of-a-loop) - [Syntactic sugars `%jump` and `%stop`](#syntactic-sugars-jump-and-stop) + - [The `%empty` branch of a loop](#the-empty-branch-of-a-loop) - [The use of `%for` and `%while`](#the-use-of-for-and-while) - [Conditional Classes and Styles](#conditional-classes-and-styles) - [Include subviews](#include-subviews) @@ -204,10 +204,10 @@ In addition to the conditional directives already discussed, the `%isset`, `%emp %notempty($records) // $records is not "empty"... -%notendempty +%endnotempty ``` -> You can add `%esle` to perform an opposite action +> You can add `%else` to perform an opposite action ## The `%loop` / `%for` / `%while` directives @@ -223,6 +223,20 @@ This clause does exactly the action of `foreach`. %endloop ``` +Anything PHP's `foreach` accepts works in the head, such as key/value pairs or list destructuring: + +```t +%loop($users as $id => $user) + {{ $id }}: {{ $user->name }} +%endloop +``` + +The whole loop can also sit on a single line: + +```t +
    %loop($names as $name)
  • {{ $name }}
  • %endloop
+``` + This clause can also be coupled with any other clause such as `%if`. A quick example. @@ -261,6 +275,8 @@ With syntactic sugars, we can reduce the code like this: %endloop ``` +`%stop` compiles to `break` and `%jump` to `continue`. Both work in `%loop`, `%for` and `%while`, with or without a condition, and the condition may contain any PHP expression, including nested parentheses. + ### The `%empty` branch of a loop When the list you iterate over may be empty, you often end up wrapping the `%loop` in an `%if`. A bare `%empty` inside the loop lets you declare the content to render when the loop body never ran: @@ -279,22 +295,34 @@ The `%empty` branch is rendered only if `$users` is empty. It works with nested ### The use of `%for` and `%while` -This clause does exactly the action of `for`. +`%for` does exactly the action of `for`. ```t %for($i = 0; $i < 10; $i++) - //.. + {{ $i }} %endfor ``` -This clause does exactly the action of `while`. +`%while` does exactly the action of `while`. ```t %while($name != 'tintin') - //.. + {{ $name = next($names) }} %endwhile ``` +`%jump` and `%stop` are available in both, exactly as in `%loop`: + +```t +%for($i = 0; $i < 10; $i++) + %jump($i % 2 == 0) + %stop($i > 7) + {{ $i }} +%endfor +``` + +> Note: the `%empty` branch is only available with `%loop`. `%for` and `%while` have no equivalent. + ## Conditional Classes and Styles The `%class` directive conditionally compiles a CSS class string. The directive accepts an array of classes where the array key contains the class(es) you want to add, while the value is a boolean expression. If the array element has a numeric key, it will always be included in the rendered class list: diff --git a/docs/fr.md b/docs/fr.md index 7948c8d..0db6559 100644 --- a/docs/fr.md +++ b/docs/fr.md @@ -10,8 +10,8 @@ - [les directives `%if`](#les-directives-if) - [Les directives `%loop` / `%for` / `%while`](#les-directives-loop--for--while) - [L'utilisation de `%loop`](#lutilisation-de-loop) - - [La branche `%empty` d'une boucle](#la-branche-empty-dune-boucle) - [Les sucres syntaxiques `%jump` et `%stop`](#les-sucres-syntaxiques-jump-et-stop) + - [La branche `%empty` d'une boucle](#la-branche-empty-dune-boucle) - [L'utilisation de `%for` et `%while`](#lutilisation-de-for-et-while) - [Classes et styles conditionnels](#classes-et-styles-conditionnels) - [Inclure les sous-vues](#inclure-les-sous-vues) @@ -204,10 +204,10 @@ En plus des directives conditionnelles déjà discutées, les directives `%isset %notempty($records) // $records is not "empty"... -%notendempty +%endnotempty ``` -> Vous pouvez ajouter `%esle` pour effectuer une action contraire +> Vous pouvez ajouter `%else` pour effectuer une action contraire ## Les directives `%loop` / `%for` / `%while` @@ -223,7 +223,21 @@ Cette clause faire exactement l'action de `foreach`. %endloop ``` -Cette clause peux être aussi coupler avec tout autre clause telque `%if`. +Tout ce que le `foreach` de PHP accepte fonctionne dans l'en-tête, comme les paires clé/valeur ou la déstructuration de liste: + +```t +%loop($users as $id => $user) + {{ $id }}: {{ $user->name }} +%endloop +``` + +La boucle entière peut aussi tenir sur une seule ligne: + +```t +
    %loop($names as $name)
  • {{ $name }}
  • %endloop
+``` + +Cette clause peut aussi être couplée avec toute autre clause telle que `%if`. Un exemple rapide. ```t @@ -261,6 +275,8 @@ Avec les sucres syntaxique, on peut réduire le code comme ceci: %endloop ``` +`%stop` compile vers `break` et `%jump` vers `continue`. Les deux fonctionnent dans `%loop`, `%for` et `%while`, avec ou sans condition, et la condition peut contenir n'importe quelle expression PHP, y compris des parenthèses imbriquées. + ### La branche `%empty` d'une boucle Lorsque la liste parcourue peut être vide, on finit souvent par entourer le `%loop` d'un `%if`. Un `%empty` seul dans la boucle permet de déclarer le contenu à afficher lorsque le corps de la boucle n'a jamais été exécuté: @@ -279,22 +295,34 @@ La branche `%empty` n'est affichée que si `$users` est vide. Elle fonctionne av ### L'utilisation de `%for` et `%while` -Cette clause faire exactement l'action de `for`. +`%for` fait exactement l'action de `for`. ```t %for($i = 0; $i < 10; $i++) - // .. + {{ $i }} %endfor ``` -Cette clause faire exactement l'action de `while`. +`%while` fait exactement l'action de `while`. ```t %while($name != 'tintin') - // .. + {{ $name = next($names) }} %endwhile ``` +`%jump` et `%stop` sont disponibles dans les deux, exactement comme dans `%loop`: + +```t +%for($i = 0; $i < 10; $i++) + %jump($i % 2 == 0) + %stop($i > 7) + {{ $i }} +%endfor +``` + +> Note: la branche `%empty` n'est disponible qu'avec `%loop`. `%for` et `%while` n'ont pas d'équivalent. + ## Classes et styles conditionnels La directive `%class` compile conditionnellement une chaîne de classe CSS. La directive accepte un tableau de classes où la clé du tableau contient la ou les classes que vous souhaitez ajouter, tandis que la valeur est une expression booléenne. Si l'élément de tableau a une clé numérique, il sera toujours inclus dans la liste de classe rendue :