• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nette / latte / 24068999832

07 Apr 2026 07:07AM UTC coverage: 95.03%. Remained the same
24068999832

push

github

dg
updated github actions

5679 of 5976 relevant lines covered (95.03%)

0.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.24
/src/Latte/Compiler/TagParser.php
1
<?php declare(strict_types=1);
2

3
/**
4
 * This file is part of the Latte (https://latte.nette.org)
5
 * Copyright (c) 2008 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace Latte\Compiler;
9

10
use Latte;
11
use Latte\CompileException;
12
use Latte\Compiler\Nodes\Php as Node;
13
use Latte\Compiler\Nodes\Php\Expression;
14
use Latte\Compiler\Nodes\Php\ExpressionNode;
15
use Latte\Compiler\Nodes\Php\NameNode;
16
use Latte\Compiler\Nodes\Php\Scalar;
17
use function count, is_int, ord, preg_match, preg_replace, preg_replace_callback, str_contains, strlen, strtolower, substr;
18

19

20
/**
21
 * Parser for PHP-like expression language used in tags.
22
 * Based on works by Nikita Popov, Moriyoshi Koizumi and Masato Bito.
23
 */
24
final class TagParser
25
{
26
        use TagParserData;
27

28
        private const
29
                SchemaExpression = 'e',
30
                SchemaArguments = 'a',
31
                SchemaFilters = 'm',
32
                SchemaForeach = 'f';
33

34
        private const SymbolNone = -1;
35

36
        public readonly TokenStream $stream;
37
        public string $text;
38

39
        /** @var \SplObjectStorage<Expression\ArrayNode, null> */
40
        protected \SplObjectStorage $shortArrays;
41
        private readonly int $offsetDelta;
42

43

44
        /** @param array<int, mixed>  $tokens */
45
        public function __construct(array $tokens)
1✔
46
        {
47
                $this->offsetDelta = $tokens[0]->position->offset;
1✔
48
                $tokens = $this->filterTokens($tokens);
1✔
49
                $this->stream = new TokenStream(new \ArrayIterator($tokens));
1✔
50
        }
1✔
51

52

53
        /**
54
         * Parses PHP-like expression.
55
         */
56
        public function parseExpression(): ExpressionNode
57
        {
58
                return $this->parse(self::SchemaExpression, recovery: true);
1✔
59
        }
60

61

62
        /**
63
         * Parses optional list of arguments. Named and variadic arguments are also supported.
64
         */
65
        public function parseArguments(): Expression\ArrayNode
66
        {
67
                return $this->parse(self::SchemaArguments, recovery: true);
1✔
68
        }
69

70

71
        /**
72
         * Parses optional list of filters.
73
         */
74
        public function parseModifier(): Node\ModifierNode
75
        {
76
                return $this->isEnd()
1✔
77
                        ? new Node\ModifierNode([])
1✔
78
                        : $this->parse(self::SchemaFilters);
1✔
79
        }
80

81

82
        /**
83
         * Parses unquoted string or PHP-like expression.
84
         */
85
        public function parseUnquotedStringOrExpression(bool $colon = true): ExpressionNode
1✔
86
        {
87
                $position = $this->stream->peek()->position;
1✔
88
                $lexer = new TagLexer;
1✔
89
                $tokens = $lexer->tokenizeUnquotedString($this->text, $position, $colon, $this->offsetDelta);
1✔
90

91
                if (!$tokens) {
1✔
92
                        return $this->parseExpression();
1✔
93
                }
94

95
                $parser = new self($tokens);
1✔
96
                $end = $position->offset + strlen($parser->text) - 2; // 2 quotes
1✔
97
                do {
98
                        $this->stream->consume();
1✔
99
                } while ($this->stream->peek()->position->offset < $end);
1✔
100

101
                return $parser->parseExpression();
1✔
102
        }
103

104

105
        /**
106
         * Parses optional type declaration.
107
         */
108
        public function parseType(): ?Node\SuperiorTypeNode
109
        {
110
                $kind = [
1✔
111
                        Token::Php_Identifier, Token::Php_Constant, Token::Php_Ellipsis, Token::Php_Array, Token::Php_Integer,
112
                        Token::Php_NameFullyQualified, Token::Php_NameQualified, Token::Php_Null, Token::Php_False, Token::Php_FilterPipe,
113
                        '(', ')', '<', '>', '[', ']', '|', '&', '{', '}', ':', ',', '=', '?',
114
                        Token::Php_Sr, // >> in nested generics like array<int, array<string, mixed>>
115
                ];
116
                $res = null;
1✔
117
                while ($token = $this->stream->tryConsume(...$kind)) {
1✔
118
                        $res .= $token->text;
1✔
119
                }
120

121
                return $res ? new Node\SuperiorTypeNode($res) : null;
1✔
122
        }
123

124

125
        /**
126
         * Parses variables used in foreach.
127
         * @return array{?Nodes\Php\ExpressionNode, Nodes\Php\ExpressionNode|Nodes\Php\ListNode, bool}
128
         * @internal
129
         */
130
        public function parseForeach(): array
131
        {
132
                return $this->parse(self::SchemaForeach);
1✔
133
        }
134

135

136
        /**
137
         * Consumes optional token followed by whitespace. Suitable before parseUnquotedStringOrExpression().
138
         */
139
        public function tryConsumeTokenBeforeUnquotedString(string ...$kind): ?Token
1✔
140
        {
141
                $token = $this->stream->peek();
1✔
142
                return $token->is(...$kind) // is followed by whitespace
1✔
143
                        && $this->stream->peek(1)->position->offset > $token->position->offset + $token->position->length
1✔
144
                        ? $this->stream->consume()
1✔
145
                        : null;
1✔
146
        }
147

148

149
        #[\Deprecated('use tryConsumeTokenBeforeUnquotedString()')]
150
        public function tryConsumeModifier(string ...$kind): ?Token
151
        {
152
                trigger_error(__METHOD__ . '() was renamed to tryConsumeTokenBeforeUnquotedString()', E_USER_DEPRECATED);
×
153
                return $this->tryConsumeTokenBeforeUnquotedString(...$kind);
×
154
        }
155

156

157
        public function isEnd(): bool
158
        {
159
                return $this->stream->peek()->isEnd();
1✔
160
        }
161

162

163
        /** @throws Latte\CompileException */
164
        private function parse(string $schema, bool $recovery = false): mixed
1✔
165
        {
166
                $symbol = self::SymbolNone; // We start off with no lookahead-token
1✔
167
                $this->startTokenStack = []; // Keep stack of start token
1✔
168
                $this->endTokenStack = []; // Keep stack of end token
1✔
169
                $token = null;
1✔
170
                $state = 0; // Start off in the initial state and keep a stack of previous states
1✔
171
                $stateStack = [$state];
1✔
172
                $this->semStack = []; // Semantic value stack (contains values of tokens and semantic action results)
1✔
173
                $stackPos = 0; // Current position in the stack(s)
1✔
174
                $this->shortArrays = new \SplObjectStorage;
1✔
175

176
                do {
177
                        if (self::ActionBase[$state] === 0) {
1✔
178
                                $rule = self::ActionDefault[$state];
×
179
                        } else {
180
                                if ($symbol === self::SymbolNone) {
1✔
181
                                        $recovery = $recovery
1✔
182
                                                ? [$this->stream->getIndex(), $state, $stateStack, $stackPos, $this->semValue, $this->semStack, $this->startTokenStack, $this->endTokenStack]
1✔
183
                                                : null;
1✔
184

185

186
                                        $token = $token
1✔
187
                                                ? $this->stream->consume()
1✔
188
                                                : new Token(ord($schema), $schema);
1✔
189

190
                                        recovery:
191
                                        $symbol = self::TokenToSymbol[$token->type];
1✔
192
                                }
193

194
                                $idx = self::ActionBase[$state] + $symbol;
1✔
195
                                if ((($idx >= 0 && $idx < count(self::Action) && self::ActionCheck[$idx] === $symbol)
1✔
196
                                        || ($state < self::Yy2Tblstate
1✔
197
                                                && ($idx = self::ActionBase[$state + self::NumNonLeafStates] + $symbol) >= 0
1✔
198
                                                && $idx < count(self::Action) && self::ActionCheck[$idx] === $symbol))
1✔
199
                                        && ($action = self::Action[$idx]) !== self::DefaultAction
1✔
200
                                ) {
201
                                        /*
202
                                        >= numNonLeafStates: shift and reduce
203
                                        > 0: shift
204
                                        = 0: accept
205
                                        < 0: reduce
206
                                        = -YYUNEXPECTED: error
207
                                        */
208
                                        if ($action > 0) { // shift
1✔
209
                                                ++$stackPos;
1✔
210
                                                $stateStack[$stackPos] = $state = $action;
1✔
211
                                                $this->semStack[$stackPos] = $token->text;
1✔
212
                                                $this->startTokenStack[$stackPos] = $token;
1✔
213
                                                $this->endTokenStack[$stackPos] = $token;
1✔
214
                                                $symbol = self::SymbolNone;
1✔
215
                                                if ($action < self::NumNonLeafStates) {
1✔
216
                                                        continue;
1✔
217
                                                }
218

219
                                                $rule = $action - self::NumNonLeafStates; // shift-and-reduce
1✔
220
                                        } else {
221
                                                $rule = -$action;
1✔
222
                                        }
223
                                } else {
224
                                        $rule = self::ActionDefault[$state];
1✔
225
                                }
226
                        }
227

228
                        do {
229
                                if ($rule === 0) { // accept
1✔
230
                                        $this->finalizeShortArrays();
1✔
231
                                        return $this->semValue;
1✔
232

233
                                } elseif ($rule !== self::UnexpectedTokenRule) { // reduce
1✔
234
                                        $lastEndToken = $this->endTokenStack[$stackPos] ?? $token;
1✔
235
                                        $this->reduce($rule, $stackPos);
1✔
236

237
                                        // Goto - shift nonterminal
238
                                        $ruleLength = self::RuleToLength[$rule];
1✔
239
                                        $stackPos -= $ruleLength;
1✔
240
                                        $nonTerminal = self::RuleToNonTerminal[$rule];
1✔
241
                                        $idx = self::GotoBase[$nonTerminal] + $stateStack[$stackPos];
1✔
242
                                        if ($idx >= 0 && $idx < count(self::Goto) && self::GotoCheck[$idx] === $nonTerminal) {
1✔
243
                                                $state = self::Goto[$idx];
1✔
244
                                        } else {
245
                                                $state = self::GotoDefault[$nonTerminal];
1✔
246
                                        }
247

248
                                        ++$stackPos;
1✔
249
                                        $stateStack[$stackPos] = $state;
1✔
250
                                        $this->semStack[$stackPos] = $this->semValue;
1✔
251
                                        $this->endTokenStack[$stackPos] = $lastEndToken;
1✔
252
                                        if ($ruleLength === 0) {
1✔
253
                                                $this->startTokenStack[$stackPos] = $token;
1✔
254
                                        }
255

256
                                } elseif ($recovery && $this->isExpectedEof($state)) { // recoverable error
1✔
257
                                        [, $state, $stateStack, $stackPos, $this->semValue, $this->semStack, $this->startTokenStack, $this->endTokenStack] = $recovery;
1✔
258
                                        $this->stream->seek($recovery[0]);
1✔
259
                                        $token = new Token(Token::End, '');
1✔
260
                                        goto recovery;
1✔
261

262
                                } else { // error
263
                                        throw new Latte\CompileException('Unexpected ' . ($token->text ? "'$token->text'" : 'end'), $token->position);
1✔
264
                                }
265

266
                                if ($state < self::NumNonLeafStates) {
1✔
267
                                        break;
1✔
268
                                }
269

270
                                $rule = $state - self::NumNonLeafStates; // shift-and-reduce
1✔
271
                        } while (true);
1✔
272
                } while (true);
1✔
273
        }
274

275

276
        /**
277
         * Can EOF be the next token?
278
         */
279
        private function isExpectedEof(int $state): bool
1✔
280
        {
281
                foreach (self::SymbolToName as $symbol => $name) {
1✔
282
                        $idx = self::ActionBase[$state] + $symbol;
1✔
283
                        if (($idx >= 0 && $idx < count(self::Action) && self::ActionCheck[$idx] === $symbol
1✔
284
                                        || $state < self::Yy2Tblstate
1✔
285
                                        && ($idx = self::ActionBase[$state + self::NumNonLeafStates] + $symbol) >= 0
1✔
286
                                        && $idx < count(self::Action) && self::ActionCheck[$idx] === $symbol)
1✔
287
                                && self::Action[$idx] !== self::UnexpectedTokenRule
1✔
288
                                && self::Action[$idx] !== self::DefaultAction
1✔
289
                                && $symbol === 0
1✔
290
                        ) {
291
                                return true;
1✔
292
                        }
293
                }
294

295
                return false;
1✔
296
        }
297

298

299
        private function createPosition(int $startPos, int $endPos): ?Position
1✔
300
        {
301
                return Position::range(
1✔
302
                        $this->startTokenStack[$startPos]->position,
1✔
303
                        $this->endTokenStack[$endPos]->position,
1✔
304
                );
305
        }
306

307

308
        public function throwReservedKeywordException(Token $token): never
1✔
309
        {
310
                throw new Latte\CompileException("Keyword '$token->text' cannot be used in Latte.", $token->position);
1✔
311
        }
×
312

313

314
        private function checkFunctionName(Expression\FunctionCallNode $func): Expression\FunctionCallNode
1✔
315
        {
316
                if ($func->name instanceof NameNode && $func->name->isKeyword()) {
1✔
317
                        $this->throwReservedKeywordException(new Token(0, (string) $func->name, $func->name->position));
1✔
318
                }
319
                return $func;
1✔
320
        }
321

322

323
        private static function handleBuiltinTypes(NameNode $name): NameNode|Node\IdentifierNode
1✔
324
        {
325
                $builtinTypes = [
1✔
326
                        'bool' => true, 'int' => true, 'float' => true, 'string' => true, 'iterable' => true, 'void' => true,
327
                        'object' => true, 'null' => true, 'false' => true, 'mixed' => true, 'never' => true,
328
                ];
329

330
                $lowerName = strtolower($name->toCodeString());
1✔
331
                return isset($builtinTypes[$lowerName])
1✔
332
                        ? new Node\IdentifierNode($lowerName, $name->position)
1✔
333
                        : $name;
1✔
334
        }
335

336

337
        private static function parseOffset(string $str, Position $position): Scalar\StringNode|Scalar\IntegerNode
1✔
338
        {
339
                if (!preg_match('/^(?:0|-?[1-9][0-9]*)$/', $str)) {
1✔
340
                        return new Scalar\StringNode($str, $position);
1✔
341
                }
342

343
                $num = +$str;
1✔
344
                if (!is_int($num)) {
1✔
345
                        return new Scalar\StringNode($str, $position);
1✔
346
                }
347

348
                return new Scalar\IntegerNode($num, Scalar\IntegerNode::KindDecimal, $position);
1✔
349
        }
350

351

352
        /** @param ExpressionNode[]  $parts */
353
        private function parseDocString(
1✔
354
                string $startToken,
355
                array $parts,
356
                string $endToken,
357
                Position $startPos,
358
                Position $endPos,
359
        ): Scalar\StringNode|Scalar\InterpolatedStringNode
360
        {
361
                $hereDoc = !str_contains($startToken, "'");
1✔
362
                preg_match('/\A[ \t]*/', $endToken, $matches);
1✔
363
                $indentation = $matches[0];
1✔
364
                if (str_contains($indentation, ' ') && str_contains($indentation, "\t")) {
1✔
365
                        throw new CompileException('Invalid indentation - tabs and spaces cannot be mixed', $endPos);
×
366

367
                } elseif (!$parts) {
1✔
368
                        return new Scalar\StringNode('', $startPos);
1✔
369

370
                } elseif (!$parts[0] instanceof Node\InterpolatedStringPartNode) {
1✔
371
                        // If there is no leading encapsed string part, pretend there is an empty one
372
                        $this->stripIndentation('', $indentation, true, false, $parts[0]->position);
×
373
                }
374

375
                $newParts = [];
1✔
376
                foreach ($parts as $i => $part) {
1✔
377
                        if ($part instanceof Node\InterpolatedStringPartNode) {
1✔
378
                                $isLast = $i === count($parts) - 1;
1✔
379
                                $part->value = $this->stripIndentation(
1✔
380
                                        $part->value,
1✔
381
                                        $indentation,
382
                                        $i === 0,
1✔
383
                                        $isLast,
384
                                        $part->position,
1✔
385
                                );
386
                                if ($isLast) {
1✔
387
                                        $part->value = preg_replace('~(\r\n|\n|\r)\z~', '', $part->value);
1✔
388
                                }
389
                                if ($hereDoc) {
1✔
390
                                        $part->value = PhpHelpers::decodeEscapeSequences($part->value, null);
1✔
391
                                }
392
                                if ($i === 0 && $isLast) {
1✔
393
                                        return new Scalar\StringNode($part->value, $startPos);
1✔
394
                                }
395
                                if ($part->value === '') {
1✔
396
                                        continue;
1✔
397
                                }
398
                        }
399
                        $newParts[] = $part;
1✔
400
                }
401

402
                return new Scalar\InterpolatedStringNode($newParts, $startPos);
1✔
403
        }
404

405

406
        private function stripIndentation(
1✔
407
                string $str,
408
                string $indentation,
409
                bool $atStart,
410
                bool $atEnd,
411
                Position $position,
412
        ): string
413
        {
414
                if ($indentation === '') {
1✔
415
                        return $str;
1✔
416
                }
417
                $start = $atStart ? '(?:(?<=\n)|\A)' : '(?<=\n)';
×
418
                $end = $atEnd ? '(?:(?=[\r\n])|\z)' : '(?=[\r\n])';
×
419
                $regex = '/' . $start . '([ \t]*)(' . $end . ')?/D';
×
420
                return preg_replace_callback(
×
421
                        $regex,
×
422
                        function ($matches) use ($indentation, $position) {
×
423
                                $indentLen = strlen($indentation);
424
                                $prefix = substr($matches[1], 0, $indentLen);
425
                                if (str_contains($prefix, $indentation[0] === ' ' ? "\t" : ' ')) {
426
                                        throw new CompileException('Invalid indentation - tabs and spaces cannot be mixed', $position);
427
                                } elseif (strlen($prefix) < $indentLen && !isset($matches[2])) {
428
                                        throw new CompileException(
429
                                                'Invalid body indentation level ' .
430
                                                '(expecting an indentation level of at least ' . $indentLen . ')',
431
                                                $position,
432
                                        );
433
                                }
434
                                return substr($matches[0], strlen($prefix));
435
                        },
×
436
                        $str,
×
437
                );
438
        }
439

440

441
        public function convertArrayToList(Expression\ArrayNode $array): Node\ListNode
1✔
442
        {
443
                unset($this->shortArrays[$array]);
1✔
444
                $items = [];
1✔
445
                foreach ($array->items as $item) {
1✔
446
                        $value = $item->value;
1✔
447
                        if ($item->unpack) {
1✔
448
                                throw new CompileException('Spread operator is not supported in assignments.', $value->position);
1✔
449
                        }
450
                        $value = match (true) {
1✔
451
                                $value instanceof Expression\TemporaryNode => $value->value,
1✔
452
                                $value instanceof Expression\ArrayNode && isset($this->shortArrays[$value]) => $this->convertArrayToList($value),
1✔
453
                                default => $value,
1✔
454
                        };
455
                        $items[] = $value
1✔
456
                                ? new Node\ListItemNode($value, $item->key, $item->byRef, $item->position)
1✔
457
                                : null;
1✔
458
                }
459
                return new Node\ListNode($items, $array->position);
1✔
460
        }
461

462

463
        private function finalizeShortArrays(): void
464
        {
465
                foreach ($this->shortArrays as $node) {
1✔
466
                        foreach ($node->items as $item) {
1✔
467
                                if ($item->value instanceof Expression\TemporaryNode) {
1✔
468
                                        throw new CompileException('Cannot use empty array elements or list() in arrays.', $item->position);
1✔
469
                                }
470
                        }
471
                }
472
        }
1✔
473

474

475
        /**
476
         * @param  Token[]  $tokens
477
         * @return Token[]
478
         */
479
        private function filterTokens(array $tokens): array
1✔
480
        {
481
                $this->text = '';
1✔
482
                $res = [];
1✔
483
                foreach ($tokens as $token) {
1✔
484
                        $this->text .= $token->text;
1✔
485
                        if (!$token->is(Token::Php_Whitespace, Token::Php_Comment)) {
1✔
486
                                $res[] = $token;
1✔
487
                        }
488
                }
489

490
                return $res;
1✔
491
        }
492
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc