diff --git a/src/Core/Compiler/PhpSyntaxValidator.php b/src/Core/Compiler/PhpSyntaxValidator.php
index a2aed2b..39e0b1d 100644
--- a/src/Core/Compiler/PhpSyntaxValidator.php
+++ b/src/Core/Compiler/PhpSyntaxValidator.php
@@ -6,6 +6,7 @@
use PhpParser\Error;
use PhpParser\Parser as PhpAstParser;
use PhpParser\ParserFactory;
+use PhpToken;
use Sugar\Core\Ast\ComponentNode;
use Sugar\Core\Ast\DirectiveNode;
use Sugar\Core\Ast\DocumentNode;
@@ -278,6 +279,12 @@ private function prepareRawPhpValidationCode(RawPhpNode $node): array
return ['', 0];
}
+ // Alternative syntax control structures (e.g. `if():` ... `endif;`) span multiple
+ // PHP blocks and cannot be validated as an isolated snippet.
+ if ($this->hasOpenAlternativeSyntax($normalizedCode)) {
+ return ['', 0];
+ }
+
$lineOffset = 0;
if (str_contains($normalizedCode, 'use')) {
$normalizedNode = new RawPhpNode($normalizedCode, $node->line, $node->column);
@@ -301,6 +308,84 @@ private function prepareRawPhpValidationCode(RawPhpNode $node): array
return [$normalizedCode, $lineOffset];
}
+ /**
+ * Detect whether a raw PHP snippet contains unmatched alternative control structure syntax.
+ *
+ * PHP alternative syntax (e.g. `if (): ... endif;`) can span multiple separate PHP
+ * blocks inside a template. Validating such a fragment in isolation would always fail
+ * because the opener block lacks its matching `endXxx` counterpart (or vice-versa).
+ *
+ * The method tokenises the snippet with `PhpToken::tokenize()` and tracks two categories:
+ * - Openers (`T_IF`, `T_FOR`, `T_FOREACH`, `T_WHILE`, `T_SWITCH`, `T_DECLARE`) followed
+ * by `:` always increment the nesting depth.
+ * - Continuations (`T_ELSE`, `T_ELSEIF`) and inner tokens (`T_CASE`, `T_DEFAULT`)
+ * followed by `:` only increment depth when currently at depth 0 (orphaned snippets);
+ * when depth > 0 they are continuations of a block already opened in this snippet and
+ * do not affect the balance.
+ * - Closers (`T_ENDIF`, `T_ENDFOR`, `T_ENDFOREACH`, `T_ENDWHILE`, `T_ENDSWITCH`,
+ * `T_ENDDECLARE`) decrement the depth.
+ * A non-zero balance means the snippet is part of a multi-block construct and should not
+ * be validated in isolation.
+ *
+ * @param string $code Stripped PHP code (without open/close tags)
+ * @return bool True when the snippet has unbalanced alternative syntax
+ */
+ private function hasOpenAlternativeSyntax(string $code): bool
+ {
+ $tokens = PhpToken::tokenize('isIgnorable()) {
+ continue;
+ }
+
+ if (in_array($token->id, $endTokens, true)) {
+ $depth--;
+ $expectColon = false;
+ $orphanColon = false;
+ } elseif (in_array($token->id, $openerTokens, true)) {
+ $expectColon = true;
+ $orphanColon = false;
+ $parenDepth = 0;
+ } elseif (in_array($token->id, $continuationTokens, true)) {
+ // When depth > 0 this is a continuation inside a block opened in this snippet;
+ // do not change depth so that the matching closer brings it back to zero.
+ // When depth === 0 it is an orphaned clause in a multi-block template.
+ if ($depth === 0) {
+ $expectColon = true;
+ $orphanColon = true;
+ }
+
+ $parenDepth = 0;
+ } elseif ($token->text === '(') {
+ $parenDepth++;
+ } elseif ($token->text === ')') {
+ $parenDepth--;
+ } elseif ($token->text === ':' && $parenDepth === 0 && $expectColon) {
+ if ($orphanColon) {
+ return true;
+ }
+
+ $depth++;
+ $expectColon = false;
+ $orphanColon = false;
+ } elseif ($token->text === '{') {
+ $expectColon = false;
+ $orphanColon = false;
+ }
+ }
+
+ return $depth !== 0;
+ }
+
/**
* Strip optional PHP open/close tags from raw snippet content.
*/
diff --git a/tests/Helper/Trait/CompilerTestTrait.php b/tests/Helper/Trait/CompilerTestTrait.php
index f46a07b..4f32098 100644
--- a/tests/Helper/Trait/CompilerTestTrait.php
+++ b/tests/Helper/Trait/CompilerTestTrait.php
@@ -107,6 +107,7 @@ protected function setUpCompilerWithStringLoader(
bool $withDefaultDirectives = true,
bool $absolutePathsOnly = false,
array $customPasses = [],
+ bool $phpSyntaxValidationEnabled = false,
): void {
$loaderConfig = $config ?? new SugarConfig();
@@ -141,6 +142,7 @@ protected function setUpCompilerWithStringLoader(
templateLoader: $this->templateLoader,
config: $config,
customPasses: $customPasses,
+ phpSyntaxValidationEnabled: $phpSyntaxValidationEnabled,
);
// Registry property is initialized before pass wiring.
diff --git a/tests/Integration/PhpNormalizationIntegrationTest.php b/tests/Integration/PhpNormalizationIntegrationTest.php
index 701547e..68a6758 100644
--- a/tests/Integration/PhpNormalizationIntegrationTest.php
+++ b/tests/Integration/PhpNormalizationIntegrationTest.php
@@ -320,4 +320,73 @@ public function testHeredocBodyWithCloseTagCompilesCorrectly(): void
$this->assertStringContainsString('some ?> content', $output);
}
+
+ public function testAlternativeSyntaxIfBlockCompilesAndRendersInDebugMode(): void
+ {
+ $this->setUpCompilerWithStringLoader(
+ templates: [],
+ config: new SugarConfig(),
+ phpSyntaxValidationEnabled: true,
+ );
+
+ $template = <<<'SUGAR'
+
+
hello
+
+SUGAR;
+
+ // debug: true was previously triggering a false "Invalid PHP block" error
+ $compiled = $this->compiler->compile($template, 'alt-syntax.sugar.php', debug: true);
+ $output = $this->executeTemplate($compiled);
+
+ $this->assertStringContainsString('hello
', $output);
+ }
+
+ public function testAlternativeSyntaxForeachBlockCompilesAndRendersInDebugMode(): void
+ {
+ $this->setUpCompilerWithStringLoader(
+ templates: [],
+ config: new SugarConfig(),
+ phpSyntaxValidationEnabled: true,
+ );
+
+ $template = <<<'SUGAR'
+
+SUGAR;
+
+ $compiled = $this->compiler->compile($template, 'alt-foreach.sugar.php', debug: true);
+ $output = $this->executeTemplate($compiled, ['items' => ['Apple', 'Banana']]);
+
+ $this->assertStringContainsString('Apple', $output);
+ $this->assertStringContainsString('Banana', $output);
+ }
+
+ public function testAlternativeSyntaxElseBlockCompilesAndRendersInDebugMode(): void
+ {
+ $this->setUpCompilerWithStringLoader(
+ templates: [],
+ config: new SugarConfig(),
+ phpSyntaxValidationEnabled: true,
+ );
+
+ $template = <<<'SUGAR'
+
+ visible
+
+ hidden
+
+SUGAR;
+
+ $compiled = $this->compiler->compile($template, 'alt-else.sugar.php', debug: true);
+
+ $outputVisible = $this->executeTemplate($compiled, ['show' => true]);
+ $this->assertStringContainsString('visible', $outputVisible);
+
+ $outputHidden = $this->executeTemplate($compiled, ['show' => false]);
+ $this->assertStringContainsString('hidden', $outputHidden);
+ }
}
diff --git a/tests/Unit/Core/Compiler/PhpSyntaxValidatorTest.php b/tests/Unit/Core/Compiler/PhpSyntaxValidatorTest.php
index cf6e770..79ea149 100644
--- a/tests/Unit/Core/Compiler/PhpSyntaxValidatorTest.php
+++ b/tests/Unit/Core/Compiler/PhpSyntaxValidatorTest.php
@@ -561,6 +561,260 @@ public function testTemplateSegmentsValidateMalformedPhpImportNode(): void
}
}
+ public function testTemplateSegmentsAllowAlternativeSyntaxIfOpener(): void
+ {
+ if (!$this->hasPhpParserSupport()) {
+ $this->expectNotToPerformAssertions();
+
+ return;
+ }
+
+ $context = new CompilationContext(
+ templatePath: '@app/pages/home.sugar.php',
+ source: '',
+ debug: true,
+ );
+
+ // Opener block: `if(true):` – has no matching endif in this snippet
+ $document = new DocumentNode([
+ new RawPhpNode('if(true):', 1, 1),
+ ]);
+
+ $validator = new PhpSyntaxValidator();
+ $validator->templateSegments($document, $context);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testTemplateSegmentsAllowAlternativeSyntaxIfCloser(): void
+ {
+ if (!$this->hasPhpParserSupport()) {
+ $this->expectNotToPerformAssertions();
+
+ return;
+ }
+
+ $context = new CompilationContext(
+ templatePath: '@app/pages/home.sugar.php',
+ source: '',
+ debug: true,
+ );
+
+ // Closer block: `endif;` – has no matching opener in this snippet
+ $document = new DocumentNode([
+ new RawPhpNode('endif;', 5, 1),
+ ]);
+
+ $validator = new PhpSyntaxValidator();
+ $validator->templateSegments($document, $context);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testTemplateSegmentsAllowAlternativeSyntaxForeachBlocks(): void
+ {
+ if (!$this->hasPhpParserSupport()) {
+ $this->expectNotToPerformAssertions();
+
+ return;
+ }
+
+ $context = new CompilationContext(
+ templatePath: '@app/pages/home.sugar.php',
+ source: '',
+ debug: true,
+ );
+
+ $document = new DocumentNode([
+ new RawPhpNode('foreach ($items as $item):', 1, 1),
+ new RawPhpNode('endforeach;', 5, 1),
+ ]);
+
+ $validator = new PhpSyntaxValidator();
+ $validator->templateSegments($document, $context);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testTemplateSegmentsAllowAlternativeSyntaxWithBodyInOpener(): void
+ {
+ if (!$this->hasPhpParserSupport()) {
+ $this->expectNotToPerformAssertions();
+
+ return;
+ }
+
+ $context = new CompilationContext(
+ templatePath: '@app/pages/home.sugar.php',
+ source: '',
+ debug: true,
+ );
+
+ // Opener block that also contains body code before the close tag
+ $document = new DocumentNode([
+ new RawPhpNode("if (\$show):\n \$label = 'hello';", 1, 1),
+ new RawPhpNode('endif;', 5, 1),
+ ]);
+
+ $validator = new PhpSyntaxValidator();
+ $validator->templateSegments($document, $context);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testTemplateSegmentsAllowAlternativeSyntaxWhileBlock(): void
+ {
+ if (!$this->hasPhpParserSupport()) {
+ $this->expectNotToPerformAssertions();
+
+ return;
+ }
+
+ $context = new CompilationContext(
+ templatePath: '@app/pages/home.sugar.php',
+ source: '',
+ debug: true,
+ );
+
+ $document = new DocumentNode([
+ new RawPhpNode('while ($i < 10):', 1, 1),
+ new RawPhpNode('endwhile;', 3, 1),
+ ]);
+
+ $validator = new PhpSyntaxValidator();
+ $validator->templateSegments($document, $context);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testTemplateSegmentsStillValidateRegularPhpBlockErrors(): void
+ {
+ if (!$this->hasPhpParserSupport()) {
+ $this->expectNotToPerformAssertions();
+
+ return;
+ }
+
+ $context = new CompilationContext(
+ templatePath: '@app/pages/home.sugar.php',
+ source: '',
+ debug: true,
+ );
+
+ // Regular (brace-based) but syntactically broken code must still be caught
+ $document = new DocumentNode([
+ new RawPhpNode('$x = ;', 1, 1),
+ ]);
+
+ $validator = new PhpSyntaxValidator();
+
+ $this->expectException(SyntaxException::class);
+ $this->expectExceptionMessage('Invalid PHP block');
+
+ $validator->templateSegments($document, $context);
+ }
+
+ public function testTemplateSegmentsValidateBalancedIfElseEndifInSingleBlock(): void
+ {
+ if (!$this->hasPhpParserSupport()) {
+ $this->expectNotToPerformAssertions();
+
+ return;
+ }
+
+ $context = new CompilationContext(
+ templatePath: '@app/pages/home.sugar.php',
+ source: '',
+ debug: true,
+ );
+
+ // A complete if/else/endif in one block is balanced — validation must run, not be skipped.
+ // This verifies that else: inside an already-open block does not inflate the depth.
+ $document = new DocumentNode([
+ new RawPhpNode("if (\$x):\n \$a = 1;\nelse:\n \$a = 2;\nendif;", 1, 1),
+ ]);
+
+ $validator = new PhpSyntaxValidator();
+ $validator->templateSegments($document, $context);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testTemplateSegmentsAllowOrphanedElseColon(): void
+ {
+ if (!$this->hasPhpParserSupport()) {
+ $this->expectNotToPerformAssertions();
+
+ return;
+ }
+
+ $context = new CompilationContext(
+ templatePath: '@app/pages/home.sugar.php',
+ source: '',
+ debug: true,
+ );
+
+ // Standalone else: is an orphaned continuation block in a multi-block template
+ $document = new DocumentNode([
+ new RawPhpNode('else:', 3, 1),
+ ]);
+
+ $validator = new PhpSyntaxValidator();
+ $validator->templateSegments($document, $context);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testTemplateSegmentsAllowOrphanedCaseBlock(): void
+ {
+ if (!$this->hasPhpParserSupport()) {
+ $this->expectNotToPerformAssertions();
+
+ return;
+ }
+
+ $context = new CompilationContext(
+ templatePath: '@app/pages/home.sugar.php',
+ source: '',
+ debug: true,
+ );
+
+ // Standalone case label is an orphaned inner block in a multi-block switch
+ $document = new DocumentNode([
+ new RawPhpNode('case 1:', 2, 1),
+ ]);
+
+ $validator = new PhpSyntaxValidator();
+ $validator->templateSegments($document, $context);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testTemplateSegmentsValidateBalancedSwitchInSingleBlock(): void
+ {
+ if (!$this->hasPhpParserSupport()) {
+ $this->expectNotToPerformAssertions();
+
+ return;
+ }
+
+ $context = new CompilationContext(
+ templatePath: '@app/pages/home.sugar.php',
+ source: '',
+ debug: true,
+ );
+
+ // A complete switch/endswitch with case labels in one block is balanced
+ $document = new DocumentNode([
+ new RawPhpNode("switch (\$x):\ncase 1:\n \$r = 'a';\n break;\ndefault:\n \$r = 'b';\nendswitch;", 1, 1),
+ ]);
+
+ $validator = new PhpSyntaxValidator();
+ $validator->templateSegments($document, $context);
+
+ $this->addToAssertionCount(1);
+ }
+
/**
* @param \PhpParser\Parser|null $parser Parser instance to inject
*/