├── LICENSE ├── README.md ├── composer.json └── src ├── ExpressionParser └── BinaryOperatorExpressionParser.php ├── Operator ├── NotSameAsBinary.php └── SameAsBinary.php ├── PhpSyntaxExtension.php ├── Test ├── ArrayTest.php ├── BooleanTest.php ├── CallableTest.php ├── FalseTest.php ├── FloatTest.php ├── IntegerTest.php ├── ObjectTest.php ├── ScalarTest.php ├── StringTest.php └── TrueTest.php └── TokenParser ├── BreakNode.php ├── BreakOrContinueTokenParser.php ├── BreakTokenParser.php ├── ContinueNode.php ├── ContinueTokenParser.php └── ForeachTokenParser.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Andreas Leathley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PHP Syntax for Twig 2 | =================== 3 | 4 | ![Test Coverage](https://img.shields.io/badge/style-100%25-success.svg?style=flat-round&label=test%20coverage) ![PHPStan](https://img.shields.io/badge/style-level%2max-success.svg?style=flat-round&label=phpstan) [![Packagist Version](https://img.shields.io/packagist/v/squirrelphp/twig-php-syntax.svg?style=flat-round)](https://packagist.org/packages/squirrelphp/twig-php-syntax) [![PHP Version](https://img.shields.io/packagist/php-v/squirrelphp/twig-php-syntax.svg)](https://packagist.org/packages/squirrelphp/twig-php-syntax) [![Software License](https://img.shields.io/badge/license-MIT-success.svg?style=flat-round)](LICENSE) 5 | 6 | Enables syntax known from PHP in Twig, so PHP developers can more easily create and edit Twig templates. This is especially useful for small projects, where the PHP developers end up writing Twig templates and it is not worth it to have a slightly different syntax in your templates. 7 | 8 | Installation 9 | ------------ 10 | 11 | composer require squirrelphp/twig-php-syntax 12 | 13 | Configuration 14 | ------------- 15 | 16 | Add PhpSyntaxExtension to Twig: 17 | 18 | ```php 19 | $twig = new \Twig\Environment($loader); 20 | $twig->addExtension(new \Squirrel\TwigPhpSyntax\PhpSyntaxExtension()); 21 | ``` 22 | 23 | You can also have a look at the extension definition and create your own extension class to only include some of the features, if you do not like all of them. 24 | 25 | ### Symfony integration 26 | 27 | If you use `autoconfigure` (which is the default) you just need to load the PhpSyntaxExtension class in `services.yaml` in the `config` directory of your project (the first four lines should already be there, just add the line with the PhpSyntaxExtension class at the end of the file): 28 | 29 | ```yaml 30 | services: 31 | _defaults: 32 | autowire: true 33 | autoconfigure: true 34 | 35 | # Just add the following line, Symfony will register 36 | # the extension in Twig for you if Twig is installed 37 | Squirrel\TwigPhpSyntax\PhpSyntaxExtension: ~ 38 | ``` 39 | 40 | If you do not use `autoconfigure`, you can add the twig extension tag to the service definition: 41 | 42 | ```yaml 43 | services: 44 | Squirrel\TwigPhpSyntax\PhpSyntaxExtension: 45 | tags: 46 | - { name: twig.extension } 47 | ``` 48 | 49 | Features 50 | -------- 51 | 52 | ### === / !== strict comparison operators 53 | 54 | Twig has the `same as` test, which mimicks `===` in PHP, but has a syntax that can be hard to get used to. Using the strict comparison operators from PHP (`===` and `!==`) reduces friction, is familiar and less verbose. 55 | 56 | ```twig 57 | {% if 1 === 1 %} 58 | This will be shown 59 | {% endif %} 60 | 61 | {% if 1 is same as(1) %} 62 | Same as above but with standard Twig syntax 63 | {% endif %} 64 | 65 | 66 | {% if 1 === '1' %} 67 | This will not be shown, as 1 and '1' have different types (string vs. integer) 68 | {% endif %} 69 | 70 | 71 | {% if somevariable === "test" %} 72 | somevariable is of type string and equals "test" 73 | {% endif %} 74 | 75 | {% if somevariable !== "test" %} 76 | somevariable either is not a string or does not equal "test" 77 | {% endif %} 78 | ``` 79 | 80 | ### strtotime filter 81 | 82 | Comparing timestamps in templates when the data only has (date) strings is a bit cumbersome in Twig, as there is no `strtotime` filter - this library adds it exactly as it is in PHP: 83 | 84 | ```twig 85 | {% if "2018-05-05"|strtotime > "2017-05-05"|strtotime %} 86 | This is always true, as 2018 results in a larger timestamp integer than 2017 87 | {% endif %} 88 | 89 | {% if post.date|strtotime > otherpost.date|strtotime %} 90 | Compares the dates of post and otherpost. strtotime returns an integer 91 | or throws an InvalidArgumentException if strtotime returns false 92 | {% endif %} 93 | 94 | {# Sets next thursday as a timestamp variable, but also sets "now" 95 | like in strtotime in PHP to define from where the timestamp is 96 | calculated if it is a relative date and not an absolute date #} 97 | {% set nextThusday = "next Thursday"|strtotime(now=sometimestamp) %} 98 | ``` 99 | 100 | ### foreach loops 101 | 102 | Twig uses `for` to create loops, with a slightly different syntax compared to `foreach` in PHP. With this library `foreach` becomes available in Twig with the same syntax as in PHP: 103 | 104 | ```twig 105 | {% foreach list as sublist %} 106 | {% foreach sublist as key => value %} 107 | {% endforeach %} 108 | {% endforeach %} 109 | ``` 110 | 111 | Internally it behaves the exact same way as `for`: it actually creates ForNode elements, so you have the same functionality like in `for` loops, including [the `loop` variable](https://twig.symfony.com/doc/3.x/tags/for.html#the-loop-variable) and `else`. `else` works the same as with `for`: 112 | 113 | ```twig 114 | {% foreach list as sublist %} 115 | {% foreach sublist as key => value %} 116 | {% else %} 117 | Array "sublist" is empty / no iteration took place 118 | {% endforeach %} 119 | {% else %} 120 | Array "list" is empty / no iteration took place 121 | {% endforeach %} 122 | ``` 123 | 124 | ### break and continue 125 | 126 | Sometimes it can be convenient to break loops in Twig, yet there is no native support for it. This library adds `break` and `continue` and they work exactly as in PHP: 127 | 128 | ```twig 129 | {% foreach list as entry %} 130 | {% if loop.index > 10 %} 131 | {% break %} 132 | {% endif %} 133 | {% endforeach %} 134 | ``` 135 | 136 | You can use `break` with a number to break out of multiple loops, just like in PHP: (`continue` does not support this) 137 | 138 | ```twig 139 | {% foreach list as sublist %} 140 | {% foreach sublist as entry %} 141 | {% if loop.index > 10 %} 142 | {% break 2 %} {# breaks out of both foreach loops #} 143 | {% endif %} 144 | {% endforeach %} 145 | {% endforeach %} 146 | ``` 147 | 148 | While you can often circumvent the usage of `break` and `continue` in Twig, it sometimes leads to additional nesting and more complicated code. Just one `break` or `continue` can clarify behavior and intent in these instances. Yet I would advise to use `break` and `continue` sparingly. 149 | 150 | ### Variable type tests (string, array, true, callable, etc.) 151 | 152 | Adds tests known from PHP, so you can test a value for being: 153 | 154 | - an array (like `is_array`) 155 | - a boolean (like `is_bool`) 156 | - a callable (like `is_callable`) 157 | - a float (like `is_float`) 158 | - an integer (like `is_int`) 159 | - an object (like `is_object`) 160 | - a scalar (integer, float, string or boolean, like `is_scalar`) 161 | - a string (like `is_string`) 162 | - true (like `=== true`) 163 | - false (like `=== false`) 164 | 165 | It uses the mentioned PHP functions / comparisons internally, so you have the same behavior as in PHP. 166 | 167 | ```twig 168 | {% if someflag is true %} {# instead of {% if someflag is same as(true) %} #} 169 | {% endif %} 170 | 171 | {% if someflag is false %} {# instead of {% if someflag is same as(false) %} #} 172 | {% endif %} 173 | 174 | {% if somevar is string %} {# no equivalent in Twig %} #} 175 | {% endif %} 176 | 177 | {% if somevar is scalar %} {# no equivalent in Twig %} #} 178 | {% endif %} 179 | 180 | {% if somevar is object %} {# no equivalent in Twig %} #} 181 | {% endif %} 182 | 183 | {% if somevar is integer %} {# no equivalent in Twig %} #} 184 | {% endif %} 185 | {% if somevar is int %} {# same as integer test above, alternate way to write it %} #} 186 | {% endif %} 187 | 188 | {% if somevar is float %} {# no equivalent in Twig %} #} 189 | {% endif %} 190 | 191 | {% if somevar is callable %} {# no equivalent in Twig %} #} 192 | {% endif %} 193 | 194 | {% if somevar is boolean %} {# no equivalent in Twig %} #} 195 | {% endif %} 196 | {% if somevar is bool %} {# same as boolean test above, alternate way to write it %} #} 197 | {% endif %} 198 | 199 | {% if somevar is array %} {# no equivalent in Twig %} #} 200 | {% endif %} 201 | ``` 202 | 203 | ### Convert to type: intval, strval, floatval and boolval filters 204 | 205 | Converting a variable to a specific type is not something Twig encourages and it probably should be avoided, if possible. Yet there are situations where you just want to convert something to an integer or string so you can be sure a comparison is type safe or that there is no unexpected behavior because one value has the wrong type. 206 | 207 | ```twig 208 | {% if '5'|intval === 5 %} 209 | Convert '5' to an integer - this if block is being executed 210 | {% endif %} 211 | 212 | {% if 5.7|strval === '5.7' %} 213 | Convert 5.7 to a string - this if block is being executed 214 | {% endif %} 215 | 216 | {% if 1|boolval === true %} 217 | Convert 1 to a boolean - this if block is being executed 218 | {% endif %} 219 | 220 | {% if '5.7'|floatval === 5.7 %} 221 | Convert '5.7' to a float - this if block is being executed 222 | {% endif %} 223 | ``` 224 | 225 | These filters mainly behave like the ones in PHP (and use the corresponding PHP functions internally), but there is some additional behavior to detect or avoid likely errors: 226 | 227 | - only scalar values, null and objects with a __toString method are allowed, so if you use any of these filters with an array or an object that cannot be cast to a string it will throw an exception 228 | - null will return 0 for intval, '' for strval, false for boolval and 0.0 for floatval (just like in PHP) 229 | - objects with a __toString method will be converted to a string first (using the __toString method), and only after that intval, boolval and floatval will be used 230 | - boolval should be used with caution, as if you give it any non-numeric string it will return true, yet empty strings and "0" will return false. boolval is here more for completeness, as it is probably the least useful conversion function in PHP. The recommendation is to use the other three functions instead of using boolval if possible. 231 | 232 | ### && and || 233 | 234 | If you want to make expressions even more like PHP, you can use `&&` instead of `and` and `||` instead of `or`. This might be the least useful part of this library, as `and` and `or` are already short and clear, yet it is another easily remedied difference between Twig and PHP, and `&&` and `||` can be easier to spot in comparison to `and` and `or`. 235 | 236 | ```twig 237 | {% if someflag === true && otherflag === false %} 238 | instead of if someflag === true and otherflag === false 239 | {% endif %} 240 | 241 | {% if someflag === true || otherflag === true %} 242 | instead of if someflag === true or otherflag === false 243 | {% endif %} 244 | ``` -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "squirrelphp/twig-php-syntax", 3 | "type": "library", 4 | "description": "Adds common PHP syntax to twig templates, like ===, foreach and continue/break.", 5 | "keywords": [ 6 | "php", 7 | "twig", 8 | "syntax", 9 | "foreach" 10 | ], 11 | "homepage": "https://github.com/squirrelphp/twig-php-syntax", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Andreas Leathley", 16 | "email": "andreas.leathley@panaxis.ch" 17 | } 18 | ], 19 | "require": { 20 | "php": ">=8.1", 21 | "twig/twig": "^3.21" 22 | }, 23 | "require-dev": { 24 | "captainhook/captainhook-phar": "^5.0", 25 | "captainhook/hook-installer": "^1.0", 26 | "phpunit/phpunit": "^9.0", 27 | "symfony/finder": "^6.0|^7.0", 28 | "symfony/process": "^6.0|^7.0" 29 | }, 30 | "config": { 31 | "sort-packages": false, 32 | "allow-plugins": { 33 | "captainhook/captainhook-phar": true, 34 | "captainhook/hook-installer": true 35 | } 36 | }, 37 | "extra": { 38 | "captainhook": { 39 | "config": "tools/captainhook.json" 40 | } 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "Squirrel\\TwigPhpSyntax\\": "src/" 45 | } 46 | }, 47 | "autoload-dev": { 48 | "psr-4": { 49 | "Squirrel\\TwigPhpSyntax\\Tests\\": "tests/" 50 | } 51 | }, 52 | "scripts": { 53 | "phpstan": "vendor-bin/phpstan/vendor/bin/phpstan analyse --configuration=tools/phpstan.neon", 54 | "phpstan_full": "rm -Rf tools/cache/phpstan && vendor-bin/phpstan/vendor/bin/phpstan analyse --configuration=tools/phpstan.neon", 55 | "phpstan_base": "vendor-bin/phpstan/vendor/bin/phpstan analyse --configuration=tools/phpstan.neon --generate-baseline=tools/phpstan-baseline.php", 56 | "psalm": "vendor-bin/psalm/vendor/bin/psalm --config=tools/psalm.xml --show-info=false", 57 | "psalm_full": "vendor-bin/psalm/vendor/bin/psalm --config=tools/psalm.xml --clear-cache && vendor-bin/psalm/vendor/bin/psalm --config=tools/psalm.xml --show-info=false", 58 | "psalm_base": "vendor-bin/psalm/vendor/bin/psalm --config=tools/psalm.xml --set-baseline=tools/psalm-baseline.xml", 59 | "phpunit": "vendor/bin/phpunit --configuration=tools/phpunit.xml.dist --colors=always --verbose", 60 | "coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --configuration=tools/phpunit.xml.dist --coverage-html=tests/_reports", 61 | "phpcs": "vendor-bin/phpcs/vendor/bin/phpcs --standard=tools/ruleset.xml --extensions=php --cache=tools/cache/.phpcs-cache --colors src tests", 62 | "phpcs_diff": "vendor-bin/phpcs/vendor/bin/phpcs -s --standard=tools/ruleset.xml --extensions=php --cache=tools/cache/.phpcs-cache --colors src tests", 63 | "phpcs_fix": "vendor-bin/phpcs/vendor/bin/phpcbf --standard=tools/ruleset.xml --extensions=php --cache=tools/cache/.phpcs-cache --colors src tests", 64 | "binupdate": "bin/vendorbin update", 65 | "binoutdated": "bin/vendorbin outdated" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/ExpressionParser/BinaryOperatorExpressionParser.php: -------------------------------------------------------------------------------- 1 | $nodeClass */ 17 | private string $nodeClass, 18 | private string $name, 19 | private int $precedence, 20 | private InfixAssociativity $associativity = InfixAssociativity::Left, 21 | ) { 22 | } 23 | 24 | /** 25 | * @return AbstractBinary 26 | */ 27 | public function parse(Parser $parser, AbstractExpression $left, Token $token): AbstractExpression 28 | { 29 | $right = $parser->parseExpression($this->getAssociativity() === InfixAssociativity::Left ? $this->getPrecedence() + 1 : $this->getPrecedence()); 30 | 31 | return new ($this->nodeClass)($left, $right, $token->getLine()); 32 | } 33 | 34 | public function getAssociativity(): InfixAssociativity 35 | { 36 | return $this->associativity; 37 | } 38 | 39 | public function getName(): string 40 | { 41 | return $this->name; 42 | } 43 | 44 | public function getPrecedence(): int 45 | { 46 | return $this->precedence; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Operator/NotSameAsBinary.php: -------------------------------------------------------------------------------- 1 | raw('!=='); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Operator/SameAsBinary.php: -------------------------------------------------------------------------------- 1 | raw('==='); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/PhpSyntaxExtension.php: -------------------------------------------------------------------------------- 1 | validateType($var, 'intval'); 58 | 59 | return \intval($var); 60 | }), 61 | new TwigFilter('floatval', function (mixed $var): float { 62 | if (\is_float($var)) { 63 | return $var; 64 | } 65 | 66 | $var = $this->validateType($var, 'floatval'); 67 | 68 | return \floatval($var); 69 | }), 70 | new TwigFilter('strval', function (mixed $var): string { 71 | if (\is_string($var)) { 72 | return $var; 73 | } 74 | 75 | $var = $this->validateType($var, 'strval'); 76 | 77 | return \strval($var); 78 | }), 79 | new TwigFilter('boolval', function (mixed $var): bool { 80 | if (\is_bool($var)) { 81 | return $var; 82 | } 83 | 84 | $var = $this->validateType($var, 'boolval'); 85 | 86 | return \boolval($var); 87 | }), 88 | ]; 89 | } 90 | 91 | private function validateType(mixed $var, string $functionName): string|int|float|bool|null 92 | { 93 | if (\is_object($var) && \method_exists($var, '__toString')) { 94 | return $var->__toString(); 95 | } 96 | 97 | if (!\is_scalar($var) && $var !== null) { 98 | throw new \InvalidArgumentException( 99 | 'Non-scalar value given to ' . $functionName . ' filter', 100 | ); 101 | } 102 | 103 | return $var; 104 | } 105 | 106 | public function getTests(): array 107 | { 108 | return [ 109 | // adds test: "var is true" 110 | new TwigTest('true', null, ['node_class' => TrueTest::class]), 111 | // adds test: "var is false" 112 | new TwigTest('false', null, ['node_class' => FalseTest::class]), 113 | // adds test: "var is array" 114 | new TwigTest('array', null, ['node_class' => ArrayTest::class]), 115 | // adds test: "var is bool" / "var is boolean" 116 | new TwigTest('bool', null, ['node_class' => BooleanTest::class]), 117 | new TwigTest('boolean', null, ['node_class' => BooleanTest::class]), 118 | // adds test: "var is callable" 119 | new TwigTest('callable', null, ['node_class' => CallableTest::class]), 120 | // adds test: "var is float" 121 | new TwigTest('float', null, ['node_class' => FloatTest::class]), 122 | // adds test: "var is int" / "var is integer" 123 | new TwigTest('int', null, ['node_class' => IntegerTest::class]), 124 | new TwigTest('integer', null, ['node_class' => IntegerTest::class]), 125 | // adds test: "var is object" 126 | new TwigTest('object', null, ['node_class' => ObjectTest::class]), 127 | // adds test: "var is scalar" 128 | new TwigTest('scalar', null, ['node_class' => ScalarTest::class]), 129 | // adds test: "var is string" 130 | new TwigTest('string', null, ['node_class' => StringTest::class]), 131 | ]; 132 | } 133 | 134 | public function getExpressionParsers(): array 135 | { 136 | return [ 137 | new BinaryOperatorExpressionParser(OrBinary::class, '||', 10), 138 | new BinaryOperatorExpressionParser(AndBinary::class, '&&', 15), 139 | new BinaryOperatorExpressionParser(SameAsBinary::class, '===', 20), 140 | new BinaryOperatorExpressionParser(NotSameAsBinary::class, '!==', 20), 141 | ]; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Test/ArrayTest.php: -------------------------------------------------------------------------------- 1 | raw('(true === \\is_array(') 19 | ->subcompile($this->getNode('node')) 20 | ->raw('))') 21 | ; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Test/BooleanTest.php: -------------------------------------------------------------------------------- 1 | raw('(true === \\is_bool(') 20 | ->subcompile($this->getNode('node')) 21 | ->raw('))') 22 | ; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Test/CallableTest.php: -------------------------------------------------------------------------------- 1 | raw('(true === \\is_callable(') 19 | ->subcompile($this->getNode('node')) 20 | ->raw('))') 21 | ; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Test/FalseTest.php: -------------------------------------------------------------------------------- 1 | raw('(false === ') 19 | ->subcompile($this->getNode('node')) 20 | ->raw(')') 21 | ; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Test/FloatTest.php: -------------------------------------------------------------------------------- 1 | raw('(true === \\is_float(') 19 | ->subcompile($this->getNode('node')) 20 | ->raw('))') 21 | ; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Test/IntegerTest.php: -------------------------------------------------------------------------------- 1 | raw('(true === \\is_int(') 20 | ->subcompile($this->getNode('node')) 21 | ->raw('))') 22 | ; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Test/ObjectTest.php: -------------------------------------------------------------------------------- 1 | raw('(true === \\is_object(') 19 | ->subcompile($this->getNode('node')) 20 | ->raw('))') 21 | ; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Test/ScalarTest.php: -------------------------------------------------------------------------------- 1 | raw('(true === \\is_scalar(') 19 | ->subcompile($this->getNode('node')) 20 | ->raw('))') 21 | ; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Test/StringTest.php: -------------------------------------------------------------------------------- 1 | raw('(true === \\is_string(') 19 | ->subcompile($this->getNode('node')) 20 | ->raw('))') 21 | ; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Test/TrueTest.php: -------------------------------------------------------------------------------- 1 | raw('(true === ') 19 | ->subcompile($this->getNode('node')) 20 | ->raw(')') 21 | ; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/TokenParser/BreakNode.php: -------------------------------------------------------------------------------- 1 | addDebugInfo($this) 23 | ->write("break " . $this->loopNumber . ";\n") 24 | ; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/TokenParser/BreakOrContinueTokenParser.php: -------------------------------------------------------------------------------- 1 | getLine(); 17 | $stream = $this->parser->getStream(); 18 | 19 | // How many loops to break out of 20 | $loopNumber = 1; 21 | 22 | $numberToken = $stream->nextIf(Token::NUMBER_TYPE); 23 | 24 | if ($numberToken !== null) { 25 | $loopNumber = (int)$numberToken->getValue(); 26 | } 27 | 28 | if ($loopNumber > 1 && $this->getTag() === 'continue') { 29 | throw new SyntaxError( 30 | \ucfirst($this->getTag()) . ' tag cannot be used with a number higher than 1.', 31 | $stream->getCurrent()->getLine(), 32 | $stream->getSourceContext(), 33 | ); 34 | } 35 | 36 | $stream->expect(Token::BLOCK_END_TYPE); 37 | 38 | // Count how many loops are starting minus the loops ending 39 | $loopCount = 0; 40 | 41 | for ($i = 1; true; $i++) { 42 | try { 43 | // Look ahead to find for and endfor tokens to make sure 44 | // there are more loops ending than starting 45 | $token = $stream->look($i); 46 | } catch (SyntaxError $e) { 47 | // End of template, leading to SyntaxError 48 | break; 49 | } 50 | 51 | // Count both "for" loops and "foreach" loops 52 | if ($token->test(Token::NAME_TYPE, 'for') || $token->test(Token::NAME_TYPE, 'foreach')) { 53 | $loopCount++; 54 | } elseif ($token->test(Token::NAME_TYPE, 'endfor') || $token->test(Token::NAME_TYPE, 'endforeach')) { 55 | $loopCount--; 56 | } 57 | } 58 | 59 | // There should be more loops ending than starting, making loopCount negative 60 | if ($loopCount >= 0) { 61 | throw new SyntaxError( 62 | \ucfirst($this->getTag()) . ' tag is only allowed in \'for\' or \'foreach\' loops.', 63 | $stream->getCurrent()->getLine(), 64 | $stream->getSourceContext(), 65 | ); 66 | } elseif (\abs($loopCount) < $loopNumber) { 67 | throw new SyntaxError( 68 | \ucfirst($this->getTag()) . ' tag uses a loop number higher than the actual loops in this context - you are using the number ' . $loopNumber . ' but in the given context the maximum number is ' . \abs($loopCount) . '.', 69 | $stream->getCurrent()->getLine(), 70 | $stream->getSourceContext(), 71 | ); 72 | } 73 | 74 | return $this->getNodeObject($loopNumber, $lineno); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/TokenParser/BreakTokenParser.php: -------------------------------------------------------------------------------- 1 | addDebugInfo($this) 23 | ->write("if (isset(\$context['loop'])) {\n") 24 | ->indent() 25 | // Taken from ForLoopNode - need to do this otherwise the continue skips it 26 | ->write("++\$context['loop']['index0'];\n") 27 | ->write("++\$context['loop']['index'];\n") 28 | ->write("\$context['loop']['first'] = false;\n") 29 | ->write("if (isset(\$context['loop']['length'])) {\n") 30 | ->indent() 31 | ->write("--\$context['loop']['revindex0'];\n") 32 | ->write("--\$context['loop']['revindex'];\n") 33 | ->write("\$context['loop']['last'] = 0 === \$context['loop']['revindex0'];\n") 34 | ->outdent() 35 | ->write("}\n") 36 | // End taken from FoorLoopNode 37 | ->outdent() 38 | ->write("}\n") 39 | // Do the actual continue operation 40 | ->write("continue " . $this->loopNumber . ";\n") 41 | ; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/TokenParser/ContinueTokenParser.php: -------------------------------------------------------------------------------- 1 | getLine(); 22 | $stream = $this->parser->getStream(); 23 | $seq = $this->parser->parseExpression(); 24 | $stream->expect(Token::NAME_TYPE, 'as'); 25 | $targets = $this->parseAssignmentExpression(); 26 | 27 | $stream->expect(Token::BLOCK_END_TYPE); 28 | $body = $this->parser->subparse([$this, 'decideForeachFork']); 29 | if ($stream->next()->getValue() === 'else') { 30 | $stream->expect(Token::BLOCK_END_TYPE); 31 | $else = new ForElseNode($this->parser->subparse([$this, 'decideForeachEnd'], true), $stream->getCurrent()->getLine()); 32 | } else { 33 | $else = null; 34 | } 35 | $stream->expect(Token::BLOCK_END_TYPE); 36 | 37 | if (\count($targets) > 1) { 38 | $keyTarget = $targets->getNode('0'); 39 | $keyTarget = new AssignContextVariable($keyTarget->getAttribute('name'), $keyTarget->getTemplateLine()); 40 | $valueTarget = $targets->getNode('1'); 41 | $valueTarget = new AssignContextVariable($valueTarget->getAttribute('name'), $valueTarget->getTemplateLine()); 42 | } else { 43 | $keyTarget = new AssignContextVariable('_key', $lineno); 44 | $valueTarget = $targets->getNode('0'); 45 | $valueTarget = new AssignContextVariable($valueTarget->getAttribute('name'), $valueTarget->getTemplateLine()); 46 | } 47 | 48 | return new ForNode($keyTarget, $valueTarget, $seq, null, $body, $else, $lineno); 49 | } 50 | 51 | public function decideForeachFork(Token $token): bool 52 | { 53 | return $token->test(['else', 'endforeach']); 54 | } 55 | 56 | public function decideForeachEnd(Token $token): bool 57 | { 58 | return $token->test('endforeach'); 59 | } 60 | 61 | public function getTag(): string 62 | { 63 | return 'foreach'; 64 | } 65 | 66 | /* 67 | * Taken from ExpressionParser::parseAssignmentExpression, we just exchanged the operator usage from , to => 68 | */ 69 | protected function parseAssignmentExpression(): Nodes 70 | { 71 | $stream = $this->parser->getStream(); 72 | $targets = []; 73 | while (true) { 74 | $token = $this->parser->getCurrentToken(); 75 | if ($stream->test(Token::OPERATOR_TYPE) && preg_match(Lexer::REGEX_NAME, $token->getValue())) { 76 | // in this context, string operators are variable names 77 | $this->parser->getStream()->next(); 78 | } else { 79 | $stream->expect(Token::NAME_TYPE, null, 'Only variables can be assigned to'); 80 | } 81 | $targets[] = new AssignContextVariable($token->getValue(), $token->getLine()); 82 | 83 | // The following line is the only change in the whole method: use => instead of , 84 | if (!$stream->nextIf(Token::OPERATOR_TYPE, '=>')) { 85 | break; 86 | } 87 | } 88 | 89 | return new Nodes($targets); 90 | } 91 | } 92 | --------------------------------------------------------------------------------