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

nette / latte / 20837225455

09 Jan 2026 12:47AM UTC coverage: 95.017%. Remained the same
20837225455

push

github

dg
Helpers::sortBeforeAfter() reimplemented using Kahn's algorithm for topological sorting

38 of 38 new or added lines in 1 file covered. (100.0%)

23 existing lines in 7 files now uncovered.

5549 of 5840 relevant lines covered (95.02%)

0.95 hits per line

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

92.96
/src/Latte/Compiler/TagParser.php
1
<?php
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
declare(strict_types=1);
9

10
namespace Latte\Compiler;
11

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

21

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

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

36
        private const SymbolNone = -1;
37

38
        public readonly TokenStream $stream;
39
        public string $text;
40

41
        /** @var \SplObjectStorage<Expression\ArrayNode> */
42
        protected \SplObjectStorage $shortArrays;
43
        private readonly int $offsetDelta;
44

45

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

56

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

65

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

74

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

85

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

95
                if (!$tokens) {
1✔
96
                        return $this->parseExpression();
1✔
97
                }
98

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

105
                return $parser->parseExpression();
1✔
106
        }
107

108

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

124
                return $res ? new Node\SuperiorTypeNode($res) : null;
1✔
125
        }
126

127

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

138

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

151

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

159

160
        public function isEnd(): bool
161
        {
162
                return $this->stream->peek()->isEnd();
1✔
163
        }
164

165

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

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

187

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

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

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

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

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

234
                                } elseif ($rule !== self::UnexpectedTokenRule) { // reduce
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
                                        if ($ruleLength === 0) {
1✔
252
                                                $this->startTokenStack[$stackPos] = $token;
1✔
253
                                        }
254

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

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

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

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

274

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

294
                return false;
1✔
295
        }
296

297

298
        public function throwReservedKeywordException(Token $token): never
1✔
299
        {
300
                throw new Latte\CompileException("Keyword '$token->text' cannot be used in Latte.", $token->position);
1✔
UNCOV
301
        }
×
302

303

304
        private function checkFunctionName(Expression\FunctionCallNode $func): Expression\FunctionCallNode
1✔
305
        {
306
                if ($func->name instanceof NameNode && $func->name->isKeyword()) {
1✔
307
                        $this->throwReservedKeywordException(new Token(0, (string) $func->name, $func->name->position));
1✔
308
                }
309
                return $func;
1✔
310
        }
311

312

313
        private static function handleBuiltinTypes(NameNode $name): NameNode|Node\IdentifierNode
1✔
314
        {
315
                $builtinTypes = [
1✔
316
                        'bool' => true, 'int' => true, 'float' => true, 'string' => true, 'iterable' => true, 'void' => true,
317
                        'object' => true, 'null' => true, 'false' => true, 'mixed' => true, 'never' => true,
318
                ];
319

320
                $lowerName = strtolower($name->toCodeString());
1✔
321
                return isset($builtinTypes[$lowerName])
1✔
322
                        ? new Node\IdentifierNode($lowerName, $name->position)
1✔
323
                        : $name;
1✔
324
        }
325

326

327
        private static function parseOffset(string $str, Position $position): Scalar\StringNode|Scalar\IntegerNode
1✔
328
        {
329
                if (!preg_match('/^(?:0|-?[1-9][0-9]*)$/', $str)) {
1✔
330
                        return new Scalar\StringNode($str, $position);
1✔
331
                }
332

333
                $num = +$str;
1✔
334
                if (!is_int($num)) {
1✔
335
                        return new Scalar\StringNode($str, $position);
1✔
336
                }
337

338
                return new Scalar\IntegerNode($num, Scalar\IntegerNode::KindDecimal, $position);
1✔
339
        }
340

341

342
        /** @param ExpressionNode[] $parts */
343
        private function parseDocString(
1✔
344
                string $startToken,
345
                array $parts,
346
                string $endToken,
347
                Position $startPos,
348
                Position $endPos,
349
        ): Scalar\StringNode|Scalar\InterpolatedStringNode
350
        {
351
                $hereDoc = !str_contains($startToken, "'");
1✔
352
                preg_match('/\A[ \t]*/', $endToken, $matches);
1✔
353
                $indentation = $matches[0];
1✔
354
                if (str_contains($indentation, ' ') && str_contains($indentation, "\t")) {
1✔
UNCOV
355
                        throw new CompileException('Invalid indentation - tabs and spaces cannot be mixed', $endPos);
×
356

357
                } elseif (!$parts) {
1✔
358
                        return new Scalar\StringNode('', $startPos);
1✔
359

360
                } elseif (!$parts[0] instanceof Node\InterpolatedStringPartNode) {
1✔
361
                        // If there is no leading encapsed string part, pretend there is an empty one
UNCOV
362
                        $this->stripIndentation('', $indentation, true, false, $parts[0]->position);
×
363
                }
364

365
                $newParts = [];
1✔
366
                foreach ($parts as $i => $part) {
1✔
367
                        if ($part instanceof Node\InterpolatedStringPartNode) {
1✔
368
                                $isLast = $i === count($parts) - 1;
1✔
369
                                $part->value = $this->stripIndentation(
1✔
370
                                        $part->value,
1✔
371
                                        $indentation,
372
                                        $i === 0,
1✔
373
                                        $isLast,
374
                                        $part->position,
1✔
375
                                );
376
                                if ($isLast) {
1✔
377
                                        $part->value = preg_replace('~(\r\n|\n|\r)\z~', '', $part->value);
1✔
378
                                }
379
                                if ($hereDoc) {
1✔
380
                                        $part->value = PhpHelpers::decodeEscapeSequences($part->value, null);
1✔
381
                                }
382
                                if ($i === 0 && $isLast) {
1✔
383
                                        return new Scalar\StringNode($part->value, $startPos);
1✔
384
                                }
385
                                if ($part->value === '') {
1✔
386
                                        continue;
1✔
387
                                }
388
                        }
389
                        $newParts[] = $part;
1✔
390
                }
391

392
                return new Scalar\InterpolatedStringNode($newParts, $startPos);
1✔
393
        }
394

395

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

430

431
        public function convertArrayToList(Expression\ArrayNode $array): Node\ListNode
1✔
432
        {
433
                unset($this->shortArrays[$array]);
1✔
434
                $items = [];
1✔
435
                foreach ($array->items as $item) {
1✔
436
                        $value = $item->value;
1✔
437
                        if ($item->unpack) {
1✔
438
                                throw new CompileException('Spread operator is not supported in assignments.', $value->position);
1✔
439
                        }
440
                        $value = match (true) {
1✔
441
                                $value instanceof Expression\TemporaryNode => $value->value,
1✔
442
                                $value instanceof Expression\ArrayNode && isset($this->shortArrays[$value]) => $this->convertArrayToList($value),
1✔
443
                                default => $value,
1✔
444
                        };
445
                        $items[] = $value
1✔
446
                                ? new Node\ListItemNode($value, $item->key, $item->byRef, $item->position)
1✔
447
                                : null;
1✔
448
                }
449
                return new Node\ListNode($items, $array->position);
1✔
450
        }
451

452

453
        private function finalizeShortArrays(): void
454
        {
455
                foreach ($this->shortArrays as $node) {
1✔
456
                        foreach ($node->items as $item) {
1✔
457
                                if ($item->value instanceof Expression\TemporaryNode) {
1✔
458
                                        throw new CompileException('Cannot use empty array elements or list() in arrays.', $item->position);
1✔
459
                                }
460
                        }
461
                }
462
        }
1✔
463

464

465
        /** @param  Token[]  $tokens */
466
        private function filterTokens(array $tokens): array
1✔
467
        {
468
                $this->text = '';
1✔
469
                $res = [];
1✔
470
                foreach ($tokens as $token) {
1✔
471
                        $this->text .= $token->text;
1✔
472
                        if (!$token->is(Token::Php_Whitespace, Token::Php_Comment)) {
1✔
473
                                $res[] = $token;
1✔
474
                        }
475
                }
476

477
                return $res;
1✔
478
        }
479
}
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