From 58efb5b7f1175afa7dbf4f5ef40b1cdac9afa2e4 Mon Sep 17 00:00:00 2001 From: Caleb Porzio Date: Tue, 24 Feb 2026 19:23:34 -0500 Subject: [PATCH 1/6] wip --- tests/IntegrationTest.php | 27 +++++++++++++++++++ .../php-comment-with-directive.blade.php | 10 +++++++ 2 files changed, 37 insertions(+) create mode 100644 tests/fixtures/components/php-comment-with-directive.blade.php diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 61ede7f8..66bcb775 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -20,6 +20,33 @@ expect(Blade::render($input))->toBe(''); }); +test('protects php blocks from precompilers that inject php tags', function () { + // Simulate Livewire's SupportMorphAwareBladeCompilation precompiler which + // wraps morph markers in PHP open/close tags. The injected close-tag + // terminates PHP mode even inside // comments, causing @if to be + // compiled as a bare directive. + app('blade.compiler')->precompiler(function ($template) { + $open = '<' . '?php'; + $close = '?' . '>'; + $prefix = $open . ' if(true): ' . $close . '' . $open . ' endif; ' . $close; + + return preg_replace( + '/(?not->toContain('<' . '?php if: ?' . '>'); +}); + // TODO: Install PHPStan, which probably would have caught this. test('supports php engine', function () { // Make sure our hooks do not break views diff --git a/tests/fixtures/components/php-comment-with-directive.blade.php b/tests/fixtures/components/php-comment-with-directive.blade.php new file mode 100644 index 00000000..0e76c8f9 --- /dev/null +++ b/tests/fixtures/components/php-comment-with-directive.blade.php @@ -0,0 +1,10 @@ +@blaze + +@props([]) + +@php + // This comment mentions @if but should not be compiled as a directive + $value = 'hello'; +@endphp + +
{{ $value }}
From ff7e0ea37cf6f9e546f44e44b7c8ce6c6c9c6edd Mon Sep 17 00:00:00 2001 From: Caleb Porzio Date: Tue, 24 Feb 2026 19:24:06 -0500 Subject: [PATCH 2/6] wip --- tests/IntegrationTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 66bcb775..9697eab5 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -38,7 +38,6 @@ }); $compiled = compile('php-comment-with-directive.blade.php'); - dd($compiled); // If Blaze fails to protect the @php block content from precompilers, // the close-tag inside the injected prefix ends the // comment's PHP From 9589e388e277576fa129013b965e22debf4e9bf2 Mon Sep 17 00:00:00 2001 From: Caleb Porzio Date: Tue, 24 Feb 2026 21:28:40 -0500 Subject: [PATCH 3/6] Fix tokenizer parsing component tags inside blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tokenizer's FSM was PHP-unaware — it matched patterns inside raw blocks (comments, strings, etc.), producing broken compiled output. Blade comments ({{-- --}}) and @php blocks were already stripped before tokenization, but raw blocks passed through untouched. Two fixes: 1. Tokenizer: Build a PHP range map via token_get_all() before the FSM loop. When handleTextState() encounters '<' inside a PHP range, fast-forward past the block instead of attempting component matching. Uses PHP's own tokenizer for 100% correct parsing (handles ?> inside strings, heredocs, unclosed blocks at EOF). 2. Wrapper: Stop restoring rawBlocks early. Keeping @php/@verbatim content as @__raw_block_N__@ placeholders through the compilation pipeline protects it from downstream precompilers (e.g. Livewire's morph precompiler) that would otherwise inject PHP tags into comment content. Laravel's own restoreRawContent() handles final restoration. Co-Authored-By: Claude Opus 4.6 --- src/Compiler/Wrapper.php | 12 +- src/Parser/Tokenizer.php | 53 ++++++ tests/CommentBugEndToEndTest.php | 168 ++++++++++++++++++ .../components/php-comment-child.blade.php | 3 + .../components/php-comment-parent.blade.php | 4 + 5 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 tests/CommentBugEndToEndTest.php create mode 100644 tests/fixtures/components/php-comment-child.blade.php create mode 100644 tests/fixtures/components/php-comment-parent.blade.php diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index 472b2621..b20925d4 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -33,17 +33,19 @@ public function wrap(string $compiled, string $path, ?string $source = null): st $sourceUsesThis = str_contains($source, '$this') || str_contains($compiled, '@entangle') || str_contains($compiled, '@script'); $compiled = BladeService::compileUseStatements($compiled); - $compiled = BladeService::restoreRawBlocks($compiled); - $compiled = BladeService::storeVerbatimBlocks($compiled); + + // Don't restore rawBlocks here. Keeping @php/@verbatim content as + // @__raw_block_N__@ placeholders protects it from downstream + // precompilers (e.g. Livewire's morph precompiler) that would + // otherwise inject PHP tags into comment content. Laravel's own + // restoreRawContent() handles restoration at the end of compileString(). $imports = ''; - + $compiled = $this->useExtractor->extract($compiled, function ($statement) use (&$imports) { $imports .= $statement . "\n"; }); - $compiled = BladeService::preStoreUncompiledBlocks($compiled); - $output = ''; $output .= '<'.'?php' . "\n"; diff --git a/src/Parser/Tokenizer.php b/src/Parser/Tokenizer.php index 458ca871..328eb5cd 100644 --- a/src/Parser/Tokenizer.php +++ b/src/Parser/Tokenizer.php @@ -45,6 +45,9 @@ class Tokenizer protected array $tagStack = []; + /** @var array Byte ranges of blocks to skip */ + protected array $phpRanges = []; + protected string $currentPrefix = ''; protected string $currentSlotPrefix = ''; @@ -56,6 +59,8 @@ public function tokenize(string $content): array { $this->resetTokenizer($content); + $this->buildPhpRangeMap(); + $state = TokenizerState::TEXT; while (!$this->isAtEnd()) { @@ -89,6 +94,43 @@ protected function resetTokenizer(string $content): void $this->tagStack = []; $this->currentPrefix = ''; $this->currentSlotPrefix = ''; + $this->phpRanges = []; + } + + /** + * Build a map of byte ranges inside raw PHP blocks using PHP's own tokenizer. + * This prevents the FSM from matching component tags inside PHP code. + */ + protected function buildPhpRangeMap(): void + { + $this->phpRanges = []; + + if (strpos($this->content, 'content); + $pos = 0; + $start = null; + + foreach ($tokens as $token) { + $text = is_array($token) ? $token[1] : $token; + $type = is_array($token) ? $token[0] : null; + + if ($type === T_OPEN_TAG || $type === T_OPEN_TAG_WITH_ECHO) { + $start = $pos; + } elseif ($type === T_CLOSE_TAG && $start !== null) { + $this->phpRanges[] = [$start, $pos + strlen($text)]; + $start = null; + } + + $pos += strlen($text); + } + + // Unclosed PHP block at EOF + if ($start !== null) { + $this->phpRanges[] = [$start, $this->length]; + } } /** @@ -99,6 +141,17 @@ protected function handleTextState(): TokenizerState $char = $this->current(); if ($char === '<') { + // Skip component matching inside raw PHP blocks. + foreach ($this->phpRanges as [$start, $end]) { + if ($this->position >= $start && $this->position < $end) { + $block = substr($this->content, $this->position, $end - $this->position); + $this->buffer .= $block; + $this->advance(strlen($block)); + + return TokenizerState::TEXT; + } + } + if ($slotInfo = $this->matchSlotOpen()) { $this->flushBuffer(); diff --git a/tests/CommentBugEndToEndTest.php b/tests/CommentBugEndToEndTest.php new file mode 100644 index 00000000..ccf8a8c2 --- /dev/null +++ b/tests/CommentBugEndToEndTest.php @@ -0,0 +1,168 @@ + ?> +
hello
+TPL; + + $tokens = $tokenizer->tokenize($input); + + $types = array_map(fn($t) => class_basename($t), $tokens); + + expect($types)->not->toContain('TagSelfCloseToken'); + expect($types)->not->toContain('TagOpenToken'); + expect($types)->each->toBe('TextToken'); +}); + +test('tokenizer skips component inside PHP block comment', function () { + $tokenizer = new Tokenizer(); + + $input = <<<'TPL' + */ ?> +
hello
+TPL; + + $tokens = $tokenizer->tokenize($input); + + $types = array_map(fn($t) => class_basename($t), $tokens); + + expect($types)->not->toContain('TagSelfCloseToken'); + expect($types)->not->toContain('TagOpenToken'); + expect($types)->each->toBe('TextToken'); +}); + +// ========================================== +// End-to-end: PHP comments no longer crash +// ========================================== + +test('component inside PHP line comment is not compiled by Blaze', function () { + $compiled = compile('php-comment-parent.blade.php'); + + // Blaze should NOT emit ensureCompiled/require_once for the child component. + // Before the fix, Blaze's tokenizer would parse inside + // the PHP comment and inject its own compilation calls. + expect($compiled)->not->toContain('ensureCompiled'); + + // The child component's function hash should not appear (Blaze didn't wrap it). + // Laravel's compileComponentTags may still find it, but Blaze should not. + expect($compiled)->not->toContain('$__blaze->pushData'); + expect($compiled)->toContain('visible'); +}); + +// ========================================== +// Controls: Blade comments and @php still work +// ========================================== + +test('component inside Blade comment is correctly ignored', function () { + $output = blade( + view: <<<'BLADE' +{{-- --}} +
visible
+BLADE, + components: [ + 'mycomp' => <<<'BLADE' +@blaze +SHOULD NOT APPEAR +BLADE, + ], + ); + + expect($output)->toContain('visible'); + expect($output)->not->toContain('SHOULD NOT APPEAR'); +}); + +test('component inside @php block is correctly ignored', function () { + $output = blade( + view: <<<'BLADE' +@php // @endphp +
visible
+BLADE, + components: [ + 'mycomp' => <<<'BLADE' +@blaze +SHOULD NOT APPEAR +BLADE, + ], + ); + + expect($output)->toContain('visible'); + expect($output)->not->toContain('SHOULD NOT APPEAR'); +}); + +// ========================================== +// Edge cases +// ========================================== + +test('tokenizer skips component inside PHP string', function () { + $tokenizer = new Tokenizer(); + + $input = <<<'TPL' +"; ?> + +TPL; + + $tokens = $tokenizer->tokenize($input); + + // Only should be parsed as a component + $selfClose = array_filter($tokens, fn($t) => $t instanceof TagSelfCloseToken); + expect(count($selfClose))->toBe(1); + expect(array_values($selfClose)[0]->name)->toBe('real'); +}); + +test('tokenizer skips component inside unclosed PHP block at EOF', function () { + $tokenizer = new Tokenizer(); + + $input = ''; + + $tokens = $tokenizer->tokenize($input); + + $types = array_map(fn($t) => class_basename($t), $tokens); + + expect($types)->not->toContain('TagSelfCloseToken'); + expect($types)->each->toBe('TextToken'); +}); + +test('tokenizer handles multiple PHP blocks with real component between them', function () { + $tokenizer = new Tokenizer(); + + $input = <<<'TPL' + ?> + + */ ?> +TPL; + + $tokens = $tokenizer->tokenize($input); + + $selfClose = array_filter($tokens, fn($t) => $t instanceof TagSelfCloseToken); + expect(count($selfClose))->toBe(1); + expect(array_values($selfClose)[0]->name)->toBe('visible'); +}); + +test('component inside PHP block comment renders correctly end-to-end', function () { + $output = blade( + view: <<<'BLADE' + */ ?> +
visible
+BLADE, + components: [ + 'mycomp' => <<<'BLADE' +@blaze +SHOULD NOT APPEAR +BLADE, + ], + ); + + expect($output)->toContain('visible'); + expect($output)->not->toContain('SHOULD NOT APPEAR'); +}); diff --git a/tests/fixtures/components/php-comment-child.blade.php b/tests/fixtures/components/php-comment-child.blade.php new file mode 100644 index 00000000..7105f128 --- /dev/null +++ b/tests/fixtures/components/php-comment-child.blade.php @@ -0,0 +1,3 @@ +@blaze + +SHOULD NOT APPEAR diff --git a/tests/fixtures/components/php-comment-parent.blade.php b/tests/fixtures/components/php-comment-parent.blade.php new file mode 100644 index 00000000..e0c007da --- /dev/null +++ b/tests/fixtures/components/php-comment-parent.blade.php @@ -0,0 +1,4 @@ +@blaze + + ?> +
visible
From dedd8ace44e0f116c03501b48643f4713ed2856b Mon Sep 17 00:00:00 2001 From: Caleb Porzio Date: Tue, 24 Feb 2026 22:30:53 -0500 Subject: [PATCH 4/6] Clean up PR: reorganize tests, remove dead code, clarify comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move tokenizer tests from CommentBugEndToEndTest → TokenizerTest - Move integration/e2e tests from CommentBugEndToEndTest → IntegrationTest - Delete CommentBugEndToEndTest.php (bug-named catchall) - Remove unused BladeService::storeVerbatimBlocks() and restoreRawBlocks() - Fix style: new Tokenizer() → app(Tokenizer::class), remove decorative separators - Clarify Wrapper.php comment to be self-explanatory without PR context Co-Authored-By: Claude Opus 4.6 --- src/BladeService.php | 26 ----- src/Compiler/Wrapper.php | 9 +- tests/CommentBugEndToEndTest.php | 168 ------------------------------- tests/IntegrationTest.php | 62 ++++++++++++ tests/Parser/TokenizerTest.php | 69 ++++++++++++- 5 files changed, 132 insertions(+), 202 deletions(-) delete mode 100644 tests/CommentBugEndToEndTest.php diff --git a/src/BladeService.php b/src/BladeService.php index 75a9a4c9..2674e777 100644 --- a/src/BladeService.php +++ b/src/BladeService.php @@ -210,32 +210,6 @@ public static function preStoreUncompiledBlocks(string $input): string return $storeVerbatimBlocks->invoke($compiler, $input); } - /** - * Store only @verbatim blocks as raw block placeholders. - */ - public static function storeVerbatimBlocks(string $input): string - { - $compiler = app('blade.compiler'); - - $reflection = new \ReflectionClass($compiler); - $method = $reflection->getMethod('storeVerbatimBlocks'); - - return $method->invoke($compiler, $input); - } - - /** - * Restore raw block placeholders to their original content. - */ - public static function restoreRawBlocks(string $input): string - { - $compiler = app('blade.compiler'); - - $reflection = new \ReflectionClass($compiler); - $method = $reflection->getMethod('restoreRawContent'); - - return $method->invoke($compiler, $input); - } - /** * Invoke the Blade compiler's compileComments via reflection. */ diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index b20925d4..04f5fc68 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -34,11 +34,10 @@ public function wrap(string $compiled, string $path, ?string $source = null): st $compiled = BladeService::compileUseStatements($compiled); - // Don't restore rawBlocks here. Keeping @php/@verbatim content as - // @__raw_block_N__@ placeholders protects it from downstream - // precompilers (e.g. Livewire's morph precompiler) that would - // otherwise inject PHP tags into comment content. Laravel's own - // restoreRawContent() handles restoration at the end of compileString(). + // Keep @php/@verbatim content as @__raw_block_N__@ placeholders so + // downstream precompilers can't match/modify patterns inside them. + // Laravel's restoreRawContent() handles restoration at the end of + // compileString(). $imports = ''; diff --git a/tests/CommentBugEndToEndTest.php b/tests/CommentBugEndToEndTest.php deleted file mode 100644 index ccf8a8c2..00000000 --- a/tests/CommentBugEndToEndTest.php +++ /dev/null @@ -1,168 +0,0 @@ - ?> -
hello
-TPL; - - $tokens = $tokenizer->tokenize($input); - - $types = array_map(fn($t) => class_basename($t), $tokens); - - expect($types)->not->toContain('TagSelfCloseToken'); - expect($types)->not->toContain('TagOpenToken'); - expect($types)->each->toBe('TextToken'); -}); - -test('tokenizer skips component inside PHP block comment', function () { - $tokenizer = new Tokenizer(); - - $input = <<<'TPL' - */ ?> -
hello
-TPL; - - $tokens = $tokenizer->tokenize($input); - - $types = array_map(fn($t) => class_basename($t), $tokens); - - expect($types)->not->toContain('TagSelfCloseToken'); - expect($types)->not->toContain('TagOpenToken'); - expect($types)->each->toBe('TextToken'); -}); - -// ========================================== -// End-to-end: PHP comments no longer crash -// ========================================== - -test('component inside PHP line comment is not compiled by Blaze', function () { - $compiled = compile('php-comment-parent.blade.php'); - - // Blaze should NOT emit ensureCompiled/require_once for the child component. - // Before the fix, Blaze's tokenizer would parse inside - // the PHP comment and inject its own compilation calls. - expect($compiled)->not->toContain('ensureCompiled'); - - // The child component's function hash should not appear (Blaze didn't wrap it). - // Laravel's compileComponentTags may still find it, but Blaze should not. - expect($compiled)->not->toContain('$__blaze->pushData'); - expect($compiled)->toContain('visible'); -}); - -// ========================================== -// Controls: Blade comments and @php still work -// ========================================== - -test('component inside Blade comment is correctly ignored', function () { - $output = blade( - view: <<<'BLADE' -{{-- --}} -
visible
-BLADE, - components: [ - 'mycomp' => <<<'BLADE' -@blaze -SHOULD NOT APPEAR -BLADE, - ], - ); - - expect($output)->toContain('visible'); - expect($output)->not->toContain('SHOULD NOT APPEAR'); -}); - -test('component inside @php block is correctly ignored', function () { - $output = blade( - view: <<<'BLADE' -@php // @endphp -
visible
-BLADE, - components: [ - 'mycomp' => <<<'BLADE' -@blaze -SHOULD NOT APPEAR -BLADE, - ], - ); - - expect($output)->toContain('visible'); - expect($output)->not->toContain('SHOULD NOT APPEAR'); -}); - -// ========================================== -// Edge cases -// ========================================== - -test('tokenizer skips component inside PHP string', function () { - $tokenizer = new Tokenizer(); - - $input = <<<'TPL' -"; ?> - -TPL; - - $tokens = $tokenizer->tokenize($input); - - // Only should be parsed as a component - $selfClose = array_filter($tokens, fn($t) => $t instanceof TagSelfCloseToken); - expect(count($selfClose))->toBe(1); - expect(array_values($selfClose)[0]->name)->toBe('real'); -}); - -test('tokenizer skips component inside unclosed PHP block at EOF', function () { - $tokenizer = new Tokenizer(); - - $input = ''; - - $tokens = $tokenizer->tokenize($input); - - $types = array_map(fn($t) => class_basename($t), $tokens); - - expect($types)->not->toContain('TagSelfCloseToken'); - expect($types)->each->toBe('TextToken'); -}); - -test('tokenizer handles multiple PHP blocks with real component between them', function () { - $tokenizer = new Tokenizer(); - - $input = <<<'TPL' - ?> - - */ ?> -TPL; - - $tokens = $tokenizer->tokenize($input); - - $selfClose = array_filter($tokens, fn($t) => $t instanceof TagSelfCloseToken); - expect(count($selfClose))->toBe(1); - expect(array_values($selfClose)[0]->name)->toBe('visible'); -}); - -test('component inside PHP block comment renders correctly end-to-end', function () { - $output = blade( - view: <<<'BLADE' - */ ?> -
visible
-BLADE, - components: [ - 'mycomp' => <<<'BLADE' -@blaze -SHOULD NOT APPEAR -BLADE, - ], - ); - - expect($output)->toContain('visible'); - expect($output)->not->toContain('SHOULD NOT APPEAR'); -}); diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 9697eab5..ab7c8a9a 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -20,6 +20,68 @@ expect(Blade::render($input))->toBe(''); }); +test('component inside PHP line comment is not compiled by Blaze', function () { + $compiled = compile('php-comment-parent.blade.php'); + + expect($compiled)->not->toContain('ensureCompiled'); + expect($compiled)->not->toContain('$__blaze->pushData'); + expect($compiled)->toContain('visible'); +}); + +test('component inside Blade comment is correctly ignored', function () { + $output = blade( + view: <<<'BLADE' +{{-- --}} +
visible
+BLADE, + components: [ + 'mycomp' => <<<'BLADE' +@blaze +SHOULD NOT APPEAR +BLADE, + ], + ); + + expect($output)->toContain('visible'); + expect($output)->not->toContain('SHOULD NOT APPEAR'); +}); + +test('component inside @php block is correctly ignored', function () { + $output = blade( + view: <<<'BLADE' +@php // @endphp +
visible
+BLADE, + components: [ + 'mycomp' => <<<'BLADE' +@blaze +SHOULD NOT APPEAR +BLADE, + ], + ); + + expect($output)->toContain('visible'); + expect($output)->not->toContain('SHOULD NOT APPEAR'); +}); + +test('component inside PHP block comment renders correctly end-to-end', function () { + $output = blade( + view: <<<'BLADE' + */ ?> +
visible
+BLADE, + components: [ + 'mycomp' => <<<'BLADE' +@blaze +SHOULD NOT APPEAR +BLADE, + ], + ); + + expect($output)->toContain('visible'); + expect($output)->not->toContain('SHOULD NOT APPEAR'); +}); + test('protects php blocks from precompilers that inject php tags', function () { // Simulate Livewire's SupportMorphAwareBladeCompilation precompiler which // wraps morph markers in PHP open/close tags. The injected close-tag diff --git a/tests/Parser/TokenizerTest.php b/tests/Parser/TokenizerTest.php index ef866903..85bdae9b 100644 --- a/tests/Parser/TokenizerTest.php +++ b/tests/Parser/TokenizerTest.php @@ -101,12 +101,75 @@ expect($result)->toEqual([ new TagOpenToken( - name: 'button', - prefix: 'x-', + name: 'button', + prefix: 'x-', attributes: [ ':data="[\'foo\' => \'bar\']"', ':callback="fn () => 0"', ], ), ]); -}); \ No newline at end of file +}); + +test('skips component inside PHP single-line comment', function () { + $input = <<<'TPL' + ?> +
hello
+TPL; + + $tokens = app(Tokenizer::class)->tokenize($input); + + $types = array_map(fn($t) => class_basename($t), $tokens); + + expect($types)->each->toBe('TextToken'); +}); + +test('skips component inside PHP block comment', function () { + $input = <<<'TPL' + */ ?> +
hello
+TPL; + + $tokens = app(Tokenizer::class)->tokenize($input); + + $types = array_map(fn($t) => class_basename($t), $tokens); + + expect($types)->each->toBe('TextToken'); +}); + +test('skips component inside PHP string', function () { + $input = <<<'TPL' +"; ?> + +TPL; + + $tokens = app(Tokenizer::class)->tokenize($input); + + $selfClose = array_filter($tokens, fn($t) => $t instanceof TagSelfCloseToken); + expect(count($selfClose))->toBe(1); + expect(array_values($selfClose)[0]->name)->toBe('real'); +}); + +test('skips component inside unclosed PHP block at EOF', function () { + $input = ''; + + $tokens = app(Tokenizer::class)->tokenize($input); + + $types = array_map(fn($t) => class_basename($t), $tokens); + + expect($types)->each->toBe('TextToken'); +}); + +test('handles multiple PHP blocks with real component between them', function () { + $input = <<<'TPL' + ?> + + */ ?> +TPL; + + $tokens = app(Tokenizer::class)->tokenize($input); + + $selfClose = array_filter($tokens, fn($t) => $t instanceof TagSelfCloseToken); + expect(count($selfClose))->toBe(1); + expect(array_values($selfClose)[0]->name)->toBe('visible'); +}); From 763df616446d00f997f003da12c5e967ed3b0823 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Wed, 25 Feb 2026 11:45:36 +0100 Subject: [PATCH 5/6] Rewrite tokenizer to handle php blocks properly --- src/Parser/Tokenizer.php | 150 ++++++++++++--------------------- tests/Parser/TokenizerTest.php | 72 +++++----------- 2 files changed, 74 insertions(+), 148 deletions(-) diff --git a/src/Parser/Tokenizer.php b/src/Parser/Tokenizer.php index 328eb5cd..35978d9a 100644 --- a/src/Parser/Tokenizer.php +++ b/src/Parser/Tokenizer.php @@ -45,9 +45,6 @@ class Tokenizer protected array $tagStack = []; - /** @var array Byte ranges of blocks to skip */ - protected array $phpRanges = []; - protected string $currentPrefix = ''; protected string $currentSlotPrefix = ''; @@ -55,82 +52,48 @@ class Tokenizer /** * Tokenize a Blade template into an array of tokens. */ - public function tokenize(string $content): array - { - $this->resetTokenizer($content); - - $this->buildPhpRangeMap(); - - $state = TokenizerState::TEXT; - - while (!$this->isAtEnd()) { - $state = match($state) { - TokenizerState::TEXT => $this->handleTextState(), - TokenizerState::TAG_OPEN => $this->handleTagOpenState(), - TokenizerState::TAG_CLOSE => $this->handleTagCloseState(), - TokenizerState::SLOT_OPEN => $this->handleSlotOpenState(), - TokenizerState::SLOT_CLOSE => $this->handleSlotCloseState(), - TokenizerState::SHORT_SLOT => $this->handleShortSlotState(), - default => throw new \RuntimeException("Unknown state: $state"), - }; - } - - $this->flushBuffer(); - - return $this->tokens; - } - - /** - * Reset all tokenizer state for a new tokenization pass. - */ - protected function resetTokenizer(string $content): void + public function tokenize(string $template): array { - $this->content = $content; - $this->position = 0; - $this->length = strlen($content); $this->tokens = []; $this->buffer = ''; $this->currentToken = null; $this->tagStack = []; $this->currentPrefix = ''; $this->currentSlotPrefix = ''; - $this->phpRanges = []; - } - - /** - * Build a map of byte ranges inside raw PHP blocks using PHP's own tokenizer. - * This prevents the FSM from matching component tags inside PHP code. - */ - protected function buildPhpRangeMap(): void - { - $this->phpRanges = []; - if (strpos($this->content, 'content); - $pos = 0; - $start = null; + foreach (token_get_all($template) as $token) { + if (is_array($token) && $token[0] === T_INLINE_HTML) { + $this->position = 0; + $this->content = $token[1]; + $this->length = strlen($token[1]); + + while (!$this->isAtEnd()) { + $state = match ($state) { + TokenizerState::TEXT => $this->handleTextState(), + TokenizerState::TAG_OPEN => $this->handleTagOpenState(), + TokenizerState::TAG_CLOSE => $this->handleTagCloseState(), + TokenizerState::SLOT_OPEN => $this->handleSlotOpenState(), + TokenizerState::SLOT_CLOSE => $this->handleSlotCloseState(), + TokenizerState::SHORT_SLOT => $this->handleShortSlotState(), + default => throw new \RuntimeException("Unknown state: $state"), + }; + } + } else { + // If we hit a non-HTML code inside a tag token, we should discard that token + // and consider everything buffered so far as plain text. + $this->currentToken = null; - foreach ($tokens as $token) { - $text = is_array($token) ? $token[1] : $token; - $type = is_array($token) ? $token[0] : null; + $state = TokenizerState::TEXT; - if ($type === T_OPEN_TAG || $type === T_OPEN_TAG_WITH_ECHO) { - $start = $pos; - } elseif ($type === T_CLOSE_TAG && $start !== null) { - $this->phpRanges[] = [$start, $pos + strlen($text)]; - $start = null; + $this->buffer .= is_array($token) ? $token[1] : $token; } - - $pos += strlen($text); } - // Unclosed PHP block at EOF - if ($start !== null) { - $this->phpRanges[] = [$start, $this->length]; - } + $this->flushBuffer(); + + return $this->tokens; } /** @@ -141,20 +104,9 @@ protected function handleTextState(): TokenizerState $char = $this->current(); if ($char === '<') { - // Skip component matching inside raw PHP blocks. - foreach ($this->phpRanges as [$start, $end]) { - if ($this->position >= $start && $this->position < $end) { - $block = substr($this->content, $this->position, $end - $this->position); - $this->buffer .= $block; - $this->advance(strlen($block)); - - return TokenizerState::TEXT; - } - } + $this->flushBuffer(); if ($slotInfo = $this->matchSlotOpen()) { - $this->flushBuffer(); - $this->currentSlotPrefix = $slotInfo['prefix']; if ($slotInfo['isShort']) { @@ -169,8 +121,6 @@ protected function handleTextState(): TokenizerState } if ($slotInfo = $this->matchSlotClose()) { - $this->flushBuffer(); - $this->currentToken = new SlotCloseToken(); $this->currentSlotPrefix = $slotInfo['prefix']; @@ -183,8 +133,6 @@ protected function handleTextState(): TokenizerState } if ($prefixInfo = $this->matchComponentOpen()) { - $this->flushBuffer(); - $this->currentPrefix = $prefixInfo['prefix']; $this->currentToken = new TagOpenToken( @@ -197,8 +145,6 @@ protected function handleTextState(): TokenizerState } if ($this->peek(1) === '/' && ($prefixInfo = $this->matchComponentClose())) { - $this->flushBuffer(); - $this->currentPrefix = $prefixInfo['prefix']; $this->currentToken = new TagCloseToken( @@ -211,8 +157,6 @@ protected function handleTextState(): TokenizerState } } - $this->buffer .= $char; - $this->advance(); return TokenizerState::TEXT; @@ -242,18 +186,18 @@ protected function handleTagOpenState(): TokenizerState array_pop($this->tagStack); - $this->tokens[] = $this->currentToken; - $this->advance(2); + $this->emitToken(); + return TokenizerState::TEXT; } if ($this->current() === '>') { - $this->tokens[] = $this->currentToken; - $this->advance(); + $this->emitToken(); + return TokenizerState::TEXT; } } @@ -277,10 +221,10 @@ protected function handleTagCloseState(): TokenizerState } if ($this->current() === '>') { - $this->tokens[] = $this->currentToken; - $this->advance(); + $this->emitToken(); + return TokenizerState::TEXT; } @@ -310,10 +254,10 @@ protected function handleSlotOpenState(): TokenizerState } if ($this->current() === '>') { - $this->tokens[] = $this->currentToken; - $this->advance(); + $this->emitToken(); + return TokenizerState::TEXT; } @@ -334,10 +278,10 @@ protected function handleSlotCloseState(): TokenizerState } if ($this->current() === '>') { - $this->tokens[] = $this->currentToken; - $this->advance(); + $this->emitToken(); + return TokenizerState::TEXT; } @@ -359,10 +303,10 @@ protected function handleShortSlotState(): TokenizerState $this->collectAttributes(); if ($this->current() === '>') { - $this->tokens[] = $this->currentToken; - $this->advance(); + $this->emitToken(); + return TokenizerState::TEXT; } } @@ -578,6 +522,8 @@ protected function remaining(): string */ protected function advance(int $count = 1): void { + $this->buffer .= substr($this->content, $this->position, $count); + $this->position += $count; } @@ -589,6 +535,16 @@ protected function isAtEnd(): bool return $this->position >= $this->length; } + /** + * Emit the current token and discard the raw buffer. + */ + protected function emitToken(): void + { + $this->tokens[] = $this->currentToken; + + $this->buffer = ''; + } + /** * Emit any accumulated text buffer as a TextToken. */ diff --git a/tests/Parser/TokenizerTest.php b/tests/Parser/TokenizerTest.php index 85bdae9b..89d2c347 100644 --- a/tests/Parser/TokenizerTest.php +++ b/tests/Parser/TokenizerTest.php @@ -6,6 +6,7 @@ use Livewire\Blaze\Parser\Tokens\TagCloseToken; use Livewire\Blaze\Parser\Tokens\TagOpenToken; use Livewire\Blaze\Parser\Tokens\TagSelfCloseToken; +use Livewire\Blaze\Parser\Tokens\TextToken; test('tokenizes tags', function () { $input = ''; @@ -111,65 +112,34 @@ ]); }); -test('skips component inside PHP single-line comment', function () { - $input = <<<'TPL' - ?> -
hello
-TPL; +test('handles php blocks', function () { + $input = ' ?>'; - $tokens = app(Tokenizer::class)->tokenize($input); - - $types = array_map(fn($t) => class_basename($t), $tokens); - - expect($types)->each->toBe('TextToken'); -}); - -test('skips component inside PHP block comment', function () { - $input = <<<'TPL' - */ ?> -
hello
-TPL; - - $tokens = app(Tokenizer::class)->tokenize($input); - - $types = array_map(fn($t) => class_basename($t), $tokens); - - expect($types)->each->toBe('TextToken'); -}); - -test('skips component inside PHP string', function () { - $input = <<<'TPL' -"; ?> - -TPL; - - $tokens = app(Tokenizer::class)->tokenize($input); + $result = app(Tokenizer::class)->tokenize($input); - $selfClose = array_filter($tokens, fn($t) => $t instanceof TagSelfCloseToken); - expect(count($selfClose))->toBe(1); - expect(array_values($selfClose)[0]->name)->toBe('real'); + expect($result)->toEqual([ + new TagOpenToken(name: 'button', prefix: 'x-'), + new TextToken(content: ' ?>'), + new TagCloseToken(name: 'button', prefix: 'x-'), + ]); }); -test('skips component inside unclosed PHP block at EOF', function () { +test('handles unclosed php blocks', function () { $input = ''; - $tokens = app(Tokenizer::class)->tokenize($input); - - $types = array_map(fn($t) => class_basename($t), $tokens); + $result = app(Tokenizer::class)->tokenize($input); - expect($types)->each->toBe('TextToken'); + expect($result)->toEqual([ + new TextToken(content: ''), + ]); }); -test('handles multiple PHP blocks with real component between them', function () { - $input = <<<'TPL' - ?> - - */ ?> -TPL; +test('handles php blocks inside tags', function () { + $input = '>'; - $tokens = app(Tokenizer::class)->tokenize($input); + $result = app(Tokenizer::class)->tokenize($input); - $selfClose = array_filter($tokens, fn($t) => $t instanceof TagSelfCloseToken); - expect(count($selfClose))->toBe(1); - expect(array_values($selfClose)[0]->name)->toBe('visible'); -}); + expect($result)->toEqual([ + new TextToken(content: '>'), + ]); +}); \ No newline at end of file From d7d8c4354efdbc87e91027cce3f02f29c47b93c1 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Wed, 25 Feb 2026 12:13:48 +0100 Subject: [PATCH 6/6] Add failing test --- tests/Compiler/WrapperTest.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Compiler/WrapperTest.php b/tests/Compiler/WrapperTest.php index 1ffe79ae..5097c740 100644 --- a/tests/Compiler/WrapperTest.php +++ b/tests/Compiler/WrapperTest.php @@ -93,9 +93,12 @@ }); test('hoists use statements to top of output', function ($statement) { - $source = "{$statement}\n
"; + // Replace raw @php blocks for placeholders. This normally happens in BlazeManager before the template gets to the Wrapper + $source = BladeService::preStoreUncompiledBlocks($statement); - expect(app(Wrapper::class)->wrap($source, '', $source))->toStartWith("wrap($source, '', $source))->toStartWith("with([ + ['@use(\'App\Models\User\')'], ['@php use \App\Models\User; @endphp'], + [''], ]); \ No newline at end of file