From f9bd24a507eabcafccb39e1c03a07f2b0d1bf515 Mon Sep 17 00:00:00 2001 From: Jeckerson Date: Wed, 10 Jun 2026 20:02:52 +0000 Subject: [PATCH 1/3] Sync changs from phalcon/cphalcon#17118 --- src/Parser/Parser.php | 22 ++- tests/unit/Compiler/CompileSwitchTest.php | 165 ++++++++++++++++++++++ tests/unit/Compiler/SwitchTest.php | 164 +++++++++++++++++++++ 3 files changed, 348 insertions(+), 3 deletions(-) create mode 100644 tests/unit/Compiler/CompileSwitchTest.php diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index 188a912..62de238 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -63,6 +63,7 @@ public function parse(string $code, string $templatePath = 'eval code'): array $state = $parserStatus->getState(); $scannerStatus = ScannerStatus::OK; + $prevToken = 0; while (($scannerStatus = $scanner->scanForToken()) === ScannerStatus::OK) { $token = $scanner->getToken(); @@ -131,7 +132,8 @@ public function parse(string $code, string $templatePath = 'eval code'): array $parser, $parserStatus, $token, - $state + $state, + $prevToken ), CompilerOpcode::ENDSWITCH->value => $this->handleEndswitch($parser, $parserStatus, $state), CompilerOpcode::RAW_FRAGMENT->value => $this->handleRawFragment( @@ -181,6 +183,11 @@ public function parse(string $code, string $templatePath = 'eval code'): array break; } + // whitespace inside delimiters arrives as IGNORE; skip it + if ($opcode !== CompilerOpcode::IGNORE->value) { + $prevToken = $opcode; + } + $state->setEnd($state->getStart()); } @@ -285,13 +292,22 @@ private function handleCase(phvolt_Parser $parser, Status $parserStatus): void $parser->phvolt_(Opcode::CASE->value); } + /** + * "default" is the {% default %} clause only when it is inside a switch + * and directly follows the opening delimiter; anywhere else (e.g. the + * |default() filter) it is a plain identifier. + */ private function handleDefault( phvolt_Parser $parser, Status $parserStatus, Token $token, - State $state + State $state, + int $prevToken ): void { - if ($state->getSwitchLevel() !== 0) { + if ( + $state->getSwitchLevel() !== 0 && + $prevToken === CompilerOpcode::OPEN_DELIMITER->value + ) { $parser->phvolt_(Opcode::DEFAULT->value); return; diff --git a/tests/unit/Compiler/CompileSwitchTest.php b/tests/unit/Compiler/CompileSwitchTest.php new file mode 100644 index 0000000..79c7e26 --- /dev/null +++ b/tests/unit/Compiler/CompileSwitchTest.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Unit\Compiler; + +use Phalcon\Volt\Compiler; +use PHPUnit\Framework\TestCase; + +final class CompileSwitchTest extends TestCase +{ + private Compiler $compiler; + + public function setUp(): void + { + $this->compiler = new Compiler(); + } + + /** + * Tests the "default" filter inside a case block of a switch + * + * @return void + * + * @author Phalcon Team + * @since 2026-06-10 + * + * @issue https://github.com/phalcon/cphalcon/issues/16003 + */ + public function testMvcViewEngineVoltCompilerCompileSwitchCaseWithDefaultFilter(): void + { + $actual = $this->compiler->compileString( + "{% set aNumber = 1 %} +{% switch aNumber %} + {% case 0 %} + {{ greatText }} + {% break %} + {% case 1 %} + {{ false|default('simple text') }} + {% break %} +{% endswitch %}" + ); + + $this->assertStringContainsString('switch ($aNumber):', $actual); + $this->assertStringContainsString('case 0:', $actual); + $this->assertStringContainsString('case 1:', $actual); + $this->assertStringContainsString( + "(empty(false) ? ('simple text') : (false))", + $actual + ); + } + + /** + * Tests the {% default %} clause surrounded by extra whitespace + * + * @return void + * + * @author Phalcon Team + * @since 2026-06-10 + * + * @issue https://github.com/phalcon/cphalcon/issues/16003 + */ + public function testMvcViewEngineVoltCompilerCompileSwitchDefaultClauseWhitespace(): void + { + $actual = $this->compiler->compileString( + "{% switch x %}{% case 1 %}one{% default %}other{% endswitch %}" + ); + + $this->assertStringContainsString('default:', $actual); + } + + /** + * Tests the {% default %} clause with whitespace control markers + * + * @return void + * + * @author Phalcon Team + * @since 2026-06-10 + * + * @issue https://github.com/phalcon/cphalcon/issues/16003 + */ + public function testMvcViewEngineVoltCompilerCompileSwitchDefaultClauseWhitespaceControl(): void + { + $actual = $this->compiler->compileString( + "{% switch x %}{% case 1 %}one{%- default -%}other{% endswitch %}" + ); + + $this->assertStringContainsString('default:', $actual); + } + + /** + * Tests the "default" filter inside the {% default %} clause itself + * + * @return void + * + * @author Phalcon Team + * @since 2026-06-10 + * + * @issue https://github.com/phalcon/cphalcon/issues/16003 + */ + public function testMvcViewEngineVoltCompilerCompileSwitchDefaultClauseWithDefaultFilter(): void + { + $actual = $this->compiler->compileString( + "{% switch x %}{% case 1 %}one{% default %}" + . "{{ value|default('unknown') }}{% endswitch %}" + ); + + $this->assertStringContainsString('default:', $actual); + $this->assertStringContainsString( + "(empty(\$value) ? ('unknown') : (\$value))", + $actual + ); + } + + /** + * Tests the "default" filter outside a switch (issue #13242 regression) + * + * @return void + * + * @author Phalcon Team + * @since 2026-06-10 + * + * @issue https://github.com/phalcon/cphalcon/issues/16003 + */ + public function testMvcViewEngineVoltCompilerCompileSwitchDefaultFilterOutsideSwitch(): void + { + $actual = $this->compiler->compileString( + "{{ value|default('unknown') }}" + ); + + $this->assertStringContainsString( + "(empty(\$value) ? ('unknown') : (\$value))", + $actual + ); + } + + /** + * Tests "default" used as a plain identifier inside a switch + * + * @return void + * + * @author Phalcon Team + * @since 2026-06-10 + * + * @issue https://github.com/phalcon/cphalcon/issues/16003 + */ + public function testMvcViewEngineVoltCompilerCompileSwitchDefaultIdentifierInsideSwitch(): void + { + $actual = $this->compiler->compileString( + "{% switch x %}{% case 1 %}" + . "{% set default = 'abc' %}{{ default }}{% endswitch %}" + ); + + $this->assertStringContainsString("\$default = 'abc'", $actual); + $this->assertStringContainsString('', $actual); + } +} diff --git a/tests/unit/Compiler/SwitchTest.php b/tests/unit/Compiler/SwitchTest.php index f283a9b..ba66399 100644 --- a/tests/unit/Compiler/SwitchTest.php +++ b/tests/unit/Compiler/SwitchTest.php @@ -98,6 +98,170 @@ public function testMvcViewEngineVoltParserSwitchCase(): void $this->assertSame($expected, $actual); } + /** + * Tests the "default" filter inside a case block of a switch + * + * @return void + * + * @author Phalcon Team + * @since 2026-06-10 + * + * @issue https://github.com/phalcon/cphalcon/issues/16003 + */ + public function testMvcViewEngineVoltParserSwitchCaseDefaultFilter(): void + { + $source = "{% switch x %}{% case 1 %}" + . "{{ false|default('simple text') }}{% break %}{% endswitch %}"; + $expected = [ + [ + 'type' => 411, + 'expr' => [ + 'type' => 265, + 'value' => 'x', + 'file' => 'eval code', + 'line' => 1, + ], + 'case_clauses' => [ + [ + 'type' => 412, + 'expr' => [ + 'type' => 258, + 'value' => '1', + 'file' => 'eval code', + 'line' => 1, + ], + 'file' => 'eval code', + 'line' => 1, + ], + [ + 'type' => 359, + 'expr' => [ + 'type' => 124, + 'left' => [ + 'type' => 262, + 'file' => 'eval code', + 'line' => 1, + ], + 'right' => [ + 'type' => 350, + 'name' => [ + 'type' => 265, + 'value' => 'default', + 'file' => 'eval code', + 'line' => 1, + ], + 'arguments' => [ + [ + 'expr' => [ + 'type' => 260, + 'value' => 'simple text', + 'file' => 'eval code', + 'line' => 1, + ], + 'file' => 'eval code', + 'line' => 1, + ], + ], + 'file' => 'eval code', + 'line' => 1, + ], + 'file' => 'eval code', + 'line' => 1, + ], + 'file' => 'eval code', + 'line' => 1, + ], + [ + 'type' => 320, + 'file' => 'eval code', + 'line' => 1, + ], + ], + 'file' => 'eval code', + 'line' => 1, + ], + ]; + $actual = $this->compiler->parse($source); + $this->assertSame($expected, $actual); + } + + /** + * Tests the "default" filter inside the {% default %} clause itself + * + * @return void + * + * @author Phalcon Team + * @since 2026-06-10 + * + * @issue https://github.com/phalcon/cphalcon/issues/16003 + */ + public function testMvcViewEngineVoltParserSwitchDefaultClauseDefaultFilter(): void + { + $source = "{% switch x %}{% default %}" + . "{{ value|default('unknown') }}{% endswitch %}"; + $expected = [ + [ + 'type' => 411, + 'expr' => [ + 'type' => 265, + 'value' => 'x', + 'file' => 'eval code', + 'line' => 1, + ], + 'case_clauses' => [ + [ + 'type' => 413, + 'file' => 'eval code', + 'line' => 1, + ], + [ + 'type' => 359, + 'expr' => [ + 'type' => 124, + 'left' => [ + 'type' => 265, + 'value' => 'value', + 'file' => 'eval code', + 'line' => 1, + ], + 'right' => [ + 'type' => 350, + 'name' => [ + 'type' => 265, + 'value' => 'default', + 'file' => 'eval code', + 'line' => 1, + ], + 'arguments' => [ + [ + 'expr' => [ + 'type' => 260, + 'value' => 'unknown', + 'file' => 'eval code', + 'line' => 1, + ], + 'file' => 'eval code', + 'line' => 1, + ], + ], + 'file' => 'eval code', + 'line' => 1, + ], + 'file' => 'eval code', + 'line' => 1, + ], + 'file' => 'eval code', + 'line' => 1, + ], + ], + 'file' => 'eval code', + 'line' => 1, + ], + ]; + $actual = $this->compiler->parse($source); + $this->assertSame($expected, $actual); + } + /** * @return void * From bf2b361dbef49508c5b9a751523b23f32ee0a093 Mon Sep 17 00:00:00 2001 From: Jeckerson Date: Sun, 14 Jun 2026 21:28:33 +0000 Subject: [PATCH 2/3] Fix return type --- phpstan-baseline.neon | 8 +------- src/Scanner/Scanner.php | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index dee77c4..d65bf02 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -631,7 +631,7 @@ parameters: path: src/Compiler.php - - message: '#^Parameter \#3 \$subject of function str_replace expects array\\|string, mixed given\.$#' + message: '#^Parameter \#3 \$subject of function preg_replace expects array\\|string, mixed given\.$#' identifier: argument.type count: 1 path: src/Compiler.php @@ -768,12 +768,6 @@ parameters: count: 1 path: src/Compiler.php - - - message: '#^Unreachable statement \- code above always terminates\.$#' - identifier: deadCode.unreachable - count: 1 - path: src/Scanner/Scanner.php - - message: '#^Property Phalcon\\Volt\\Tokens\:\:\$names type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue diff --git a/src/Scanner/Scanner.php b/src/Scanner/Scanner.php index 163e35e..971702f 100644 --- a/src/Scanner/Scanner.php +++ b/src/Scanner/Scanner.php @@ -885,7 +885,7 @@ public function scanForToken(): ScannerStatus } vv81: - $this->state->setCursor($this->state->getMarker()); + $this->state->setCursor((int) $this->state->getMarker()); switch ($vvaccept) { case 0: goto vv5; From 33078d264ba15ccaa4fb02e455a1055267057687 Mon Sep 17 00:00:00 2001 From: Jeckerson Date: Sun, 14 Jun 2026 21:42:40 +0000 Subject: [PATCH 3/3] Sync changes from cphalcon --- phpstan-baseline.neon | 31 +++++++++------- phpstan.neon | 4 +++ src/Compiler.php | 84 +++++++++++++++++++++++++++++-------------- 3 files changed, 79 insertions(+), 40 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d65bf02..215d82f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -49,13 +49,13 @@ parameters: path: src/Compiler.php - - message: '#^Binary operation "\." between ''\<\?php \$'' and mixed results in an error\.$#' + message: '#^Binary operation "\." between ''\$this\-\>tag\-\>'' and mixed results in an error\.$#' identifier: binaryOp.invalid count: 1 path: src/Compiler.php - - message: '#^Binary operation "\." between ''\<\?php \$this\-…'' and mixed results in an error\.$#' + message: '#^Binary operation "\." between ''\<\?php \$'' and mixed results in an error\.$#' identifier: binaryOp.invalid count: 1 path: src/Compiler.php @@ -141,7 +141,7 @@ parameters: - message: '#^Binary operation "\." between mixed and string results in an error\.$#' identifier: binaryOp.invalid - count: 3 + count: 4 path: src/Compiler.php - @@ -186,6 +186,12 @@ parameters: count: 1 path: src/Compiler.php + - + message: '#^Call to method get\(\) on an unknown class Phalcon\\Di\\DiInterface\.$#' + identifier: class.notFound + count: 1 + path: src/Compiler.php + - message: '#^Call to method getViewsDir\(\) on an unknown class Phalcon\\Mvc\\ViewBaseInterface\.$#' identifier: class.notFound @@ -195,7 +201,7 @@ parameters: - message: '#^Call to method has\(\) on an unknown class Phalcon\\Di\\DiInterface\.$#' identifier: class.notFound - count: 1 + count: 2 path: src/Compiler.php - @@ -237,7 +243,7 @@ parameters: - message: '#^Cannot access offset ''type'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible - count: 7 + count: 9 path: src/Compiler.php - @@ -270,6 +276,12 @@ parameters: count: 1 path: src/Compiler.php + - + message: '#^Cannot call method has\(\) on mixed\.$#' + identifier: method.nonObject + count: 1 + path: src/Compiler.php + - message: '#^Instanceof between string and Closure will always evaluate to false\.$#' identifier: instanceof.alwaysFalse @@ -567,7 +579,7 @@ parameters: - message: '#^Parameter \#1 \$expr of method Phalcon\\Volt\\Compiler\:\:expression\(\) expects array, mixed given\.$#' identifier: argument.type - count: 31 + count: 32 path: src/Compiler.php - @@ -839,10 +851,3 @@ parameters: identifier: notIdentical.alwaysTrue count: 1 path: src/Utils.php - - - - message: '#^Possibly invalid array key type string\|null\.$#' - identifier: offsetAccess.invalidOffset - count: 2 - path: src/Compiler.php - reportUnmatched: false diff --git a/phpstan.neon b/phpstan.neon index 490f410..6ef9a4a 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,10 @@ includes: - phpstan-baseline.neon parameters: + # Pin the analyzed PHP version so the CI matrix (8.1-8.5) produces + # identical results regardless of the runtime PHP; keeps one baseline valid + # on every leg. + phpVersion: 80100 bootstrapFiles: - phpunit.php paths: diff --git a/src/Compiler.php b/src/Compiler.php index 2ab684d..728559c 100644 --- a/src/Compiler.php +++ b/src/Compiler.php @@ -811,13 +811,13 @@ public function compileEcho(array $statement): string $expr = $statement['expr']; $exprCode = $this->expression($expr); - if ($expr == Opcode::FCALL->value) { + if ($expr['type'] == Opcode::FCALL->value) { if ($this->isTagFactory($expr)) { $exprCode = $this->expression($expr, true); } $name = $expr['name']; - if ($name == Opcode::IDENTIFIER->value) { + if ($name['type'] == Opcode::IDENTIFIER->value) { /** * super() is a function however the return of this function * must be output as it is @@ -832,7 +832,7 @@ public function compileEcho(array $statement): string * Echo statement */ if (true === $this->autoescape) { - return 'escaper->escapeHtml(' . $exprCode . ')'; + return 'escaper->html(' . $exprCode . ') ?>'; } return ''; @@ -1014,7 +1014,7 @@ public function compileForeach(array $statement, bool $extendsMode = false): str * Generate the loop context for the "foreach" */ if (isset($loopContext[$level])) { - $compilation .= 'self = &$' . $prefixLevel . 'loop; '; @@ -1203,10 +1203,10 @@ public function compileInclude(array $statement): string * Use partial */ if (!isset($statement['params'])) { - return 'partial(' . $path . ')'; + return 'partial(' . $path . '); ?>'; } - return 'partial(' . $pathExpr . ', ' . $this->expression($statement['params']) . ')'; + return 'partial(' . $path . ', ' . $this->expression($statement['params']) . '); ?>'; } /** @@ -1235,7 +1235,7 @@ public function compileMacro(array $statement, bool $extendsMode): string * Register the macro */ $this->macros[$name] = $name; - $macroName = '$this->macros[\'' . $name . '\]'; + $macroName = '$this->macros[\'' . $name . '\']'; $code = ''; @@ -1665,7 +1665,7 @@ final public function expression(array $expr, bool $doubleQuotes = false): strin break; case Opcode::ARRAY->value: - $exprCode = isset($expr['left']) ? '[' . $leftCode . ']' : []; + $exprCode = isset($expr['left']) ? '[' . $leftCode . ']' : '[]'; break; case 258: @@ -1717,19 +1717,19 @@ final public function expression(array $expr, bool $doubleQuotes = false): strin break; case 272: - $exprCode = $leftCode .= ' == ' . $rightCode; + $exprCode = $leftCode . ' == ' . $rightCode; break; case 273: - $exprCode = $leftCode .= ' != ' . $rightCode; + $exprCode = $leftCode . ' != ' . $rightCode; break; case 274: - $exprCode = $leftCode .= ' === ' . $rightCode; + $exprCode = $leftCode . ' === ' . $rightCode; break; case 275: - $exprCode = $leftCode .= ' !== ' . $rightCode; + $exprCode = $leftCode . ' !== ' . $rightCode; break; case Opcode::RANGE->value: @@ -2018,15 +2018,27 @@ public function functionCall(array $expr, bool $doubleQuotes = false): string return "''"; } + /** + * @todo This needs a lot of refactoring and will break a lot of + * applications if removed + */ + if ($name === 'preload') { + return '$this->preload(' . $arguments . ')'; + } + + /** + * Check if it's a method in Phalcon\Tag + * @todo This needs a lot of refactoring and will break a lot of + * applications if removed + */ $method = lcfirst( - //\Phalcon\Text::camelize($name) - ucwords($name) + str_replace(['_', '-'], '', ucwords($name, '_-')) ); $arrayHelpers = [ 'link_to' => true, 'image' => true, - 'form' => true, + 'form_legacy' => true, 'submit_button' => true, 'radio_field' => true, 'check_field' => true, @@ -2042,15 +2054,32 @@ public function functionCall(array $expr, bool $doubleQuotes = false): string "image_input" => true, ]; - /** - * Check if it's a method in Phalcon\Tag - */ if (method_exists('Phalcon\\Tag', $method)) { if (isset($arrayHelpers[$name])) { - return '$this->tag->' . $method . '([' . $arguments . '])'; + return '\\Phalcon\\Tag::' . $method . '([' . $arguments . '])'; } - return '$this->tag->' . $method . '(' . $arguments . ')'; + return '\\Phalcon\\Tag::' . $method . '(' . $arguments . ')'; + } + + /** + * These are for the TagFactory + */ + if ($this->container !== null && true === $this->container->has('tag')) { + $tagService = $this->container->get('tag'); + if (true === $tagService->has($name)) { + /** + * recalculate the arguments because we need them double + * quoted + */ + if (isset($expr['arguments'])) { + $arguments = $this->expression($expr['arguments'], true); + } else { + $arguments = ''; + } + + return '$this->tag->' . $name . '(' . $arguments . ')'; + } } /** @@ -2080,11 +2109,11 @@ public function functionCall(array $expr, bool $doubleQuotes = false): string } if ($name === 'version') { - return 'Phalcon\\Version::get()'; + return '(new Phalcon\\Support\\Version)->get()'; } if ($name === 'version_id') { - return 'Phalcon\\Version::getId()'; + return '(new Phalcon\\Support\\Version)->getId()'; } /** @@ -2349,9 +2378,8 @@ protected function getFinalPath(string $path): string $viewsDirs = $this->view->getViewsDir(); if (is_array($viewsDirs)) { foreach ($viewsDirs as $viewsDir) { - $path = $viewsDir . $path; - if (true === file_exists($path)) { - return $path; + if (true === file_exists($viewsDir . $path)) { + return $viewsDir . $path; } } @@ -2752,7 +2780,9 @@ final protected function statementList(array $statements, bool $extendsMode = fa */ switch ($type) { case Opcode::RAW_FRAGMENT->value: - $compilation .= $statement["value"]; + if (isset($statement['value'])) { + $compilation .= $statement['value']; + } break; case Opcode::IF->value: