From cac2f0405cf71a46b8f4294f35e9f19bd1f02fdd Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Thu, 30 Jul 2026 17:37:16 +0200 Subject: [PATCH] Support comment blocks inside liquid tags - Skip comment bodies without lexing their contents - Cover valid and unclosed liquid comments --- src/Parse/Lexer.php | 67 +++++++++++++++++- tests/Integration/Tags/LiquidTagTest.php | 89 ++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/src/Parse/Lexer.php b/src/Parse/Lexer.php index 1290a5d..a93d77b 100644 --- a/src/Parse/Lexer.php +++ b/src/Parse/Lexer.php @@ -192,9 +192,21 @@ protected function lexBlock(): void throw SyntaxException::unexpectedEndOfTemplate(); } - // The first identifier names the tag and determines whether its body - // must later be treated as opaque raw data. - if ($tag === null && $lastToken->type === TokenType::Identifier) { + // Inside a liquid tag a line starting with `comment` opens a comment + // block, which must not reach the expression lexer any more than a + // `{% comment %}` block does. + if ($tag !== null + && $tag->data === 'liquid' + && $lastToken->type === TokenType::Identifier + && $lastToken->data === 'comment' + && $this->startsLiquidTagLine($tag, $lastToken) + ) { + array_pop($this->tokens); + $this->skipLiquidComment($lastToken->lineNumber); + $this->skipWhitespace(); + } elseif ($tag === null && $lastToken->type === TokenType::Identifier) { + // The first identifier names the tag and determines whether its + // body must later be treated as opaque raw data. $tag = $lastToken; } } @@ -338,6 +350,55 @@ protected function lexComment(): void } } + /** + * A liquid tag is line based: only the first word of a line names a tag. + */ + protected function startsLiquidTagLine(Token $tag, Token $token): bool + { + $previous = $this->tokens[count($this->tokens) - 2] ?? null; + + return $previous === null + || $previous === $tag + || $previous->lineNumber < $token->lineNumber; + } + + /** + * Skip a `comment`/`endcomment` block inside a liquid tag. + * + * Same contract as lexComment(): the body emits no tokens and is never + * lexed, so it may hold anything (apostrophes, stray delimiters), and the + * first `endcomment` closes the block without tracking nesting. + */ + protected function skipLiquidComment(int $lineNumber): void + { + // The liquid tag ends at its own `%}`, so an `endcomment` past that point + // belongs to something else and the comment is never closed. + $blockEnd = strpos($this->source, self::BLOCK_END, $this->cursor); + $offset = $this->cursor; + + while (true) { + if ($blockEnd === false || $offset > $blockEnd) { + // Report the `comment` line, like an unclosed `{% comment %}` does. + $exception = SyntaxException::tagBlockNeverClosed('comment'); + $exception->lineNumber = $lineNumber; + + throw $exception; + } + + $offset += strspn($this->source, " \t", $offset); + + if ($this->comesNext('endcomment', $offset) + && strspn($this->source, self::WORD, $offset + strlen('endcomment'), 1) === 0 + ) { + $this->skip($offset + strlen('endcomment') - $this->cursor); + + return; + } + + $offset += strcspn($this->source, "\n", $offset) + 1; + } + } + protected function lexInlineComment(): void { $offset = $this->cursor; diff --git a/tests/Integration/Tags/LiquidTagTest.php b/tests/Integration/Tags/LiquidTagTest.php index 53e8858..fdd2285 100644 --- a/tests/Integration/Tags/LiquidTagTest.php +++ b/tests/Integration/Tags/LiquidTagTest.php @@ -126,6 +126,95 @@ ); }); +test('comment tag inside liquid tag', function () { + assertTemplateResult('center', <<<'LIQUID' + {%- liquid + comment + Intended for blocks and sections that provide values for all the referenced settings. + + Accepts: + settings: {block.settings || section.settings} + endcomment + + assign horizontal_alignment = settings.horizontal_alignment + echo horizontal_alignment + -%} + LIQUID, staticData: ['settings' => ['horizontal_alignment' => 'center']]); + + // The body is never lexed, so it may contain anything. + assertTemplateResult('ttt', <<<'LIQUID' + {%- liquid + comment + it's a comment {{ with {% delimiters + endcomment + echo 'ttt' + -%} + LIQUID + ); + + assertTemplateResult('', <<<'LIQUID' + {%- liquid + comment + endcomment + -%} + LIQUID + ); + + assertTemplateResult('12', <<<'LIQUID' + {%- liquid + for value in (1..2) + comment + skipped + endcomment + echo value + endfor + -%} + LIQUID + ); + + // Whitespace control of the liquid tag itself is not affected. + assertTemplateResult('Hello!World!', <<<'LIQUID' + Hello! + {%- liquid + comment + this is inside a liquid tag + endcomment + -%} + World! + LIQUID + ); + + // `comment` only opens a comment when it starts a line. + assertTemplateResult('ok', <<<'LIQUID' + {%- liquid + assign comment = 'ok' + echo comment + -%} + LIQUID + ); +}); + +test('comment tag inside liquid tag errors', function () { + assertMatchSyntaxError("Liquid syntax error (line 2): 'comment' tag was never closed", <<<'LIQUID' + {%- liquid + comment + forgot to close the comment + echo 'a' + -%} + LIQUID + ); + + assertMatchSyntaxError("Liquid syntax error (line 5): Unknown tag 'error'", <<<'LIQUID' + {%- liquid + comment + a comment + endcomment + error no such tag + -%} + LIQUID + ); +}); + test('liquid tag in raw', function () { assertTemplateResult("{% liquid echo 'test' %}", <<<'LIQUID' {% raw %}{% liquid echo 'test' %}{% endraw %}