diff --git a/src/Compiler.php b/src/Compiler.php index 5f0a22a..e7b86e3 100644 --- a/src/Compiler.php +++ b/src/Compiler.php @@ -46,6 +46,7 @@ class Compiler 'LoopStack', 'ExtendsStack', 'HelpersStack', + 'CustomDirective', 'Json', 'Class', 'Import' @@ -115,11 +116,19 @@ class Compiler protected $result = ''; /** - * The expression pattern + * The expression pattern. + * + * Group 2 uses a recursive subpattern so the inner capture stops at the + * matching `)` instead of greedily eating up to the last `)` on the line. + * Without this, a single-line directive whose body also contains parens + * (e.g. `%if ($x) {{ $x }} %endif`, where the echo pass has + * already rewritten `{{ $x }}` into ``) would have its + * head extended past the real close-paren, producing broken PHP like + * `...e($x): ?>; ?>`. * * @var string */ - protected $condition_pattern = '/(%s\s*\((.*)\))\s*/sm'; + protected $condition_pattern = '/(%s\s*\(((?:[^()]|\((?2)\))*)\))\s*/s'; /** * The option expression pattern diff --git a/src/Lexique/CompileCustomDirective.php b/src/Lexique/CompileCustomDirective.php index 358a0dd..6a6cfc5 100644 --- a/src/Lexique/CompileCustomDirective.php +++ b/src/Lexique/CompileCustomDirective.php @@ -5,7 +5,7 @@ trait CompileCustomDirective { /** - * Compile the custom directive + * Compile the custom statement * * @param string $expression * @return string diff --git a/tests/CompileIfTest.php b/tests/CompileIfTest.php index 0e8a7d6..962f2fa 100644 --- a/tests/CompileIfTest.php +++ b/tests/CompileIfTest.php @@ -98,6 +98,77 @@ public function testcompileEndifStatement() $this->assertEquals($render, ''); } + public function testInlineIfWithEchoBody() + { + $source = '%if ($service->runtime_version) {{ $service->runtime_version }} %endif'; + + $render = $this->compiler->compile($source); + + $this->assertStringContainsString('runtime_version): ?>', $render); + $this->assertStringContainsString('runtime_version); ?>', $render); + $this->assertStringContainsString('', $render); + $this->assertStringNotContainsString('): ?>; ?>', $render); + } + + public function testInlineIfWithNestedParens() + { + $source = '%if (count($items) > 0) yes %endif'; + + $render = $this->compiler->compile($source); + + $this->assertStringContainsString(' 0): ?>', $render); + } + + /** + * Regression: %unless shares condition_pattern with %if. + */ + public function testInlineUnlessWithEchoBody() + { + $source = '%unless ($name) {{ $name }} %endunless'; + + $render = $this->compiler->compile($source); + + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringNotContainsString('): ?>; ?>', $render); + } + + /** + * Regression: %isset shares condition_pattern with %if. + */ + public function testInlineIssetWithEchoBody() + { + $source = '%isset ($name) {{ $name }} %endisset'; + + $render = $this->compiler->compile($source); + + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringNotContainsString('): ?>; ?>', $render); + } + + /** + * Regression: %elseif (and the %elif alias) share condition_pattern. + * Exercise an if/elseif/else chain with echoes in every branch. + */ + public function testInlineIfElseIfElseWithEchoBodies() + { + $source = '%if ($a) {{ $a }} %elseif ($b) {{ $b }} %else {{ $c }} %endif'; + + $render = $this->compiler->compile($source); + + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringNotContainsString('): ?>; ?>', $render); + } + public function testBlockStatement() { $html = file_get_contents(__DIR__ . '/view/sample.tintin.php'); diff --git a/tests/CompileLoopTest.php b/tests/CompileLoopTest.php index c26321a..49ab3d6 100644 --- a/tests/CompileLoopTest.php +++ b/tests/CompileLoopTest.php @@ -86,6 +86,54 @@ public function testCompileBreaker() $this->assertEquals($render, ''); } + /** + * Regression: a single-line %loop whose body contains an echo must not + * have its head extended past the real `)` by the greedy condition + * pattern (shared with %if). + */ + public function testInlineLoopWithEchoBody() + { + $source = '%loop ($items as $item) {{ $item }} %endloop'; + + $render = $this->compiler->compile($source); + + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringNotContainsString('): ?>; ?>', $render); + } + + /** + * Regression: same greedy-match bug, %while variant. + */ + public function testInlineWhileWithEchoBody() + { + $source = '%while ($i < count($items)) {{ $i }} %endwhile'; + + $render = $this->compiler->compile($source); + + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringNotContainsString('): ?>; ?>', $render); + } + + /** + * Regression: %for has the extra wrinkle of semicolons inside the head. + * The balanced-paren matcher must still stop at the head's real `)`. + */ + public function testInlineForWithEchoBody() + { + $source = '%for ($i = 0; $i < count($items); $i++) {{ $i }} %endfor'; + + $render = $this->compiler->compile($source); + + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringContainsString('', $render); + $this->assertStringNotContainsString('): ?>; ?>', $render); + } + /** * A multi-line %loop expression must compile the same as the single-line form. */