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

nette / latte / 15764369906

19 Jun 2025 06:38PM UTC coverage: 93.578% (-0.02%) from 93.597%
15764369906

push

github

dg
optimized global function calls

7 of 12 new or added lines in 8 files covered. (58.33%)

2 existing lines in 2 files now uncovered.

5129 of 5481 relevant lines covered (93.58%)

0.94 hits per line

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

93.91
/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 extends 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 TokenStream /*readonly*/ $stream;
37
        public string $text;
38

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

43

44
        public function __construct(array $tokens)
1✔
45
        {
46
                $this->offsetDelta = $tokens[0]->position->offset ?? 0;
1✔
47
                $tokens = $this->filterTokens($tokens);
1✔
48
                $this->stream = new TokenStream(new \ArrayIterator($tokens));
1✔
49
        }
1✔
50

51

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

60

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

69

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

80

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

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

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

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

103

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

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

122

123
        /**
124
         * Parses variables used in foreach.
125
         * @internal
126
         */
127
        public function parseForeach(): array
128
        {
129
                return $this->parse(self::SchemaForeach);
1✔
130
        }
131

132

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

145

146
        /** @deprecated use tryConsumeTokenBeforeUnquotedString() */
147
        public function tryConsumeModifier(string ...$kind): ?Token
148
        {
149
                return $this->tryConsumeTokenBeforeUnquotedString(...$kind);
×
150
        }
151

152

153
        public function isEnd(): bool
154
        {
155
                return $this->stream->peek()->isEnd();
1✔
156
        }
157

158

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

171
                do {
172
                        if (self::ActionBase[$state] === 0) {
1✔
173
                                $rule = self::ActionDefault[$state];
×
174
                        } else {
175
                                if ($symbol === self::SymbolNone) {
1✔
176
                                        $recovery = $recovery
1✔
177
                                                ? [$this->stream->getIndex(), $state, $stateStack, $stackPos, $this->semValue, $this->semStack, $this->startTokenStack]
1✔
178
                                                : null;
1✔
179

180

181
                                        $token = $token
1✔
182
                                                ? $this->stream->consume()
1✔
183
                                                : new Token(ord($schema), $schema);
1✔
184

185
                                        recovery:
186
                                        $symbol = self::TokenToSymbol[$token->type];
1✔
187
                                }
188

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

213
                                                $rule = $action - self::NumNonLeafStates; // shift-and-reduce
1✔
214
                                        } else {
215
                                                $rule = -$action;
1✔
216
                                        }
217
                                } else {
218
                                        $rule = self::ActionDefault[$state];
1✔
219
                                }
220
                        }
221

222
                        do {
223
                                if ($rule === 0) { // accept
1✔
224
                                        $this->finalizeShortArrays();
1✔
225
                                        return $this->semValue;
1✔
226

227
                                } elseif ($rule !== self::UnexpectedTokenRule) { // reduce
1✔
228
                                        $this->reduce($rule, $stackPos);
1✔
229

230
                                        // Goto - shift nonterminal
231
                                        $ruleLength = self::RuleToLength[$rule];
1✔
232
                                        $stackPos -= $ruleLength;
1✔
233
                                        $nonTerminal = self::RuleToNonTerminal[$rule];
1✔
234
                                        $idx = self::GotoBase[$nonTerminal] + $stateStack[$stackPos];
1✔
235
                                        if ($idx >= 0 && $idx < count(self::Goto) && self::GotoCheck[$idx] === $nonTerminal) {
1✔
236
                                                $state = self::Goto[$idx];
1✔
237
                                        } else {
238
                                                $state = self::GotoDefault[$nonTerminal];
1✔
239
                                        }
240

241
                                        ++$stackPos;
1✔
242
                                        $stateStack[$stackPos] = $state;
1✔
243
                                        $this->semStack[$stackPos] = $this->semValue;
1✔
244
                                        if ($ruleLength === 0) {
1✔
245
                                                $this->startTokenStack[$stackPos] = $token;
1✔
246
                                        }
247

248
                                } elseif ($recovery && $this->isExpectedEof($state)) { // recoverable error
1✔
249
                                        [, $state, $stateStack, $stackPos, $this->semValue, $this->semStack, $this->startTokenStack] = $recovery;
1✔
250
                                        $this->stream->seek($recovery[0]);
1✔
251
                                        $token = new Token(Token::End, '');
1✔
252
                                        goto recovery;
1✔
253

254
                                } else { // error
255
                                        throw new Latte\CompileException('Unexpected ' . ($token->text ? "'$token->text'" : 'end'), $token->position);
1✔
256
                                }
257

258
                                if ($state < self::NumNonLeafStates) {
1✔
259
                                        break;
1✔
260
                                }
261

262
                                $rule = $state - self::NumNonLeafStates; // shift-and-reduce
1✔
263
                        } while (true);
1✔
264
                } while (true);
1✔
265
        }
266

267

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

287
                return false;
1✔
288
        }
289

290

291
        public function throwReservedKeywordException(Token $token)
1✔
292
        {
293
                throw new Latte\CompileException("Keyword '$token->text' cannot be used in Latte.", $token->position);
1✔
294
        }
295

296

297
        protected function checkFunctionName(
1✔
298
                Expression\FunctionCallNode|Expression\FunctionCallableNode $func,
299
        ): ExpressionNode
300
        {
301
                if ($func->name instanceof NameNode && $func->name->isKeyword()) {
1✔
302
                        $this->throwReservedKeywordException(new Token(0, (string) $func->name, $func->name->position));
1✔
303
                }
304
                return $func;
1✔
305
        }
306

307

308
        protected static function handleBuiltinTypes(NameNode $name): NameNode|Node\IdentifierNode
1✔
309
        {
310
                $builtinTypes = [
1✔
311
                        'bool' => true, 'int' => true, 'float' => true, 'string' => true, 'iterable' => true, 'void' => true,
312
                        'object' => true, 'null' => true, 'false' => true, 'mixed' => true, 'never' => true,
313
                ];
314

315
                $lowerName = strtolower($name->toCodeString());
1✔
316
                return isset($builtinTypes[$lowerName])
1✔
317
                        ? new Node\IdentifierNode($lowerName, $name->position)
1✔
318
                        : $name;
1✔
319
        }
320

321

322
        protected static function parseOffset(string $str, Position $position): Scalar\StringNode|Scalar\IntegerNode
1✔
323
        {
324
                if (!preg_match('/^(?:0|-?[1-9][0-9]*)$/', $str)) {
1✔
325
                        return new Scalar\StringNode($str, $position);
1✔
326
                }
327

328
                $num = +$str;
1✔
329
                if (!is_int($num)) {
1✔
330
                        return new Scalar\StringNode($str, $position);
1✔
331
                }
332

333
                return new Scalar\IntegerNode($num, Scalar\IntegerNode::KindDecimal, $position);
1✔
334
        }
335

336

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

352
                } elseif (!$parts) {
1✔
353
                        return new Scalar\StringNode('', $startPos);
1✔
354

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

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

387
                return new Scalar\InterpolatedStringNode($newParts, $startPos);
1✔
388
        }
389

390

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

425

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

447

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

459

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

472
                return $res;
1✔
473
        }
474
}
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