vendor/twig/twig/src/Lexer.php line 474

Open in your IDE?
  1. <?php
  2. /*
  3.  * This file is part of Twig.
  4.  *
  5.  * (c) Fabien Potencier
  6.  * (c) Armin Ronacher
  7.  *
  8.  * For the full copyright and license information, please view the LICENSE
  9.  * file that was distributed with this source code.
  10.  */
  11. namespace Twig;
  12. use Twig\Error\SyntaxError;
  13. use Twig\ExpressionParser\ExpressionParsers;
  14. /**
  15.  * @author Fabien Potencier <fabien@symfony.com>
  16.  */
  17. class Lexer
  18. {
  19.     private $isInitialized = false;
  20.     private $tokens;
  21.     private $code;
  22.     private $cursor;
  23.     private $lineno;
  24.     private $end;
  25.     private $state;
  26.     private $states;
  27.     private $brackets;
  28.     private $env;
  29.     private $source;
  30.     private $options;
  31.     private $regexes;
  32.     private $position;
  33.     private $positions;
  34.     private $currentVarBlockLine;
  35.     /** @var list<string> */
  36.     private array $documentation = [];
  37.     private array $openingBrackets = ['{', '(', '['];
  38.     private array $closingBrackets = ['}', ')', ']'];
  39.     public const STATE_DATA = 0;
  40.     public const STATE_BLOCK = 1;
  41.     public const STATE_VAR = 2;
  42.     public const STATE_STRING = 3;
  43.     public const STATE_INTERPOLATION = 4;
  44.     public const REGEX_NAME = '/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/A';
  45.     public const REGEX_STRING = '/"([^#"\\\\]*(?:\\\\.[^#"\\\\]*)*)"|\'([^\'\\\\]*(?:\\\\.[^\'\\\\]*)*)\'/As';
  46.     public const REGEX_NUMBER = '/(?(DEFINE)
  47.         (?<LNUM>[0-9]+(_[0-9]+)*)               # Integers (with underscores)   123_456
  48.         (?<FRAC>\.(?&LNUM))                     # Fractional part               .456
  49.         (?<EXPONENT>[eE][+-]?(?&LNUM))          # Exponent part                 E+10
  50.         (?<DNUM>(?&LNUM)(?:(?&FRAC))?)          # Decimal number                123_456.456
  51.     )(?:(?&DNUM)(?:(?&EXPONENT))?)              #                               123_456.456E+10
  52.     /Ax';
  53.     public const REGEX_DQ_STRING_DELIM = '/"/A';
  54.     public const REGEX_DQ_STRING_PART = '/[^#"\\\\]*(?:(?:\\\\.|#(?!\{))[^#"\\\\]*)*/As';
  55.     public const REGEX_INLINE_COMMENT = '/#[^\n]*/A';
  56.     public const PUNCTUATION = '()[]{}?:.,|';
  57.     private const REGEX_RAW_INLINE_COMMENT = '/#[^\r\n]*/A';
  58.     private const SPECIAL_CHARS = [
  59.         'f' => "\f",
  60.         'n' => "\n",
  61.         'r' => "\r",
  62.         't' => "\t",
  63.         'v' => "\v",
  64.     ];
  65.     public function __construct(Environment $env, array $options = [])
  66.     {
  67.         $this->env = $env;
  68.         $this->options = array_merge([
  69.             'tag_comment' => ['{#', '#}'],
  70.             'tag_block' => ['{%', '%}'],
  71.             'tag_variable' => ['{{', '}}'],
  72.             'whitespace_trim' => '-',
  73.             'whitespace_line_trim' => '~',
  74.             'whitespace_line_chars' => ' \t\0\x0B',
  75.             'interpolation' => ['#{', '}'],
  76.         ], $options);
  77.     }
  78.     private function initialize(): void
  79.     {
  80.         if ($this->isInitialized) {
  81.             return;
  82.         }
  83.         // when the comment closing tag starts with "#" (as "#}" does), the empty comment ("{##}")
  84.         // starts like a documentation comment ("{##"); make sure it keeps lexing as a comment
  85.         $emptyCommentLookahead = str_starts_with($this->options['tag_comment'][1], '#') ? '(?!'.preg_quote(substr($this->options['tag_comment'][1], 1), '#').')' : '';
  86.         // when PHP 7.3 is the min version, we will be able to remove the '#' part in preg_quote as it's part of the default
  87.         $this->regexes = [
  88.             // }}
  89.             'lex_var' => '{
  90.                 \s*
  91.                 (?:'.
  92.                     preg_quote($this->options['whitespace_trim'].$this->options['tag_variable'][1], '#').'\s*'. // -}}\s*
  93.                     '|'.
  94.                     preg_quote($this->options['whitespace_line_trim'].$this->options['tag_variable'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~}}[ \t\0\x0B]*
  95.                     '|'.
  96.                     preg_quote($this->options['tag_variable'][1], '#'). // }}
  97.                 ')
  98.             }Ax',
  99.             // %}
  100.             'lex_block' => '{
  101.                 \s*
  102.                 (?:'.
  103.                     preg_quote($this->options['whitespace_trim'].$this->options['tag_block'][1], '#').'\s*\n?'. // -%}\s*\n?
  104.                     '|'.
  105.                     preg_quote($this->options['whitespace_line_trim'].$this->options['tag_block'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~%}[ \t\0\x0B]*
  106.                     '|'.
  107.                     preg_quote($this->options['tag_block'][1], '#').'(?:\r\n?|\n)?'. // %}(?:\r\n?|\n)?
  108.                 ')
  109.             }Ax',
  110.             // {% endverbatim %}
  111.             'lex_raw_data' => '{'.
  112.                 preg_quote($this->options['tag_block'][0], '#'). // {%
  113.                 '('.
  114.                     $this->options['whitespace_trim']. // -
  115.                     '|'.
  116.                     $this->options['whitespace_line_trim']. // ~
  117.                 ')?\s*endverbatim\s*'.
  118.                 '(?:'.
  119.                     preg_quote($this->options['whitespace_trim'].$this->options['tag_block'][1], '#').'\s*'. // -%}
  120.                     '|'.
  121.                     preg_quote($this->options['whitespace_line_trim'].$this->options['tag_block'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~%}[ \t\0\x0B]*
  122.                     '|'.
  123.                     preg_quote($this->options['tag_block'][1], '#'). // %}
  124.                 ')
  125.             }sx',
  126.             'operator' => $this->getOperatorRegex(),
  127.             // #}
  128.             'lex_comment' => '{
  129.                 (?:'.
  130.                     preg_quote($this->options['whitespace_trim'].$this->options['tag_comment'][1], '#').'\s*\n?'. // -#}\s*\n?
  131.                     '|'.
  132.                     preg_quote($this->options['whitespace_line_trim'].$this->options['tag_comment'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~#}[ \t\0\x0B]*
  133.                     '|'.
  134.                     preg_quote($this->options['tag_comment'][1], '#').'(?:\r\n?|\n)?'. // #}(?:\r\n?|\n)?
  135.                 ')
  136.             }sx',
  137.             // #} or ##}
  138.             'lex_documentation_comment' => '{
  139.                 (?:'.
  140.                     preg_quote($this->options['whitespace_trim'].'#'.$this->options['tag_comment'][1], '#').'\s*\n?'. // -##}\s*\n?
  141.                     '|'.
  142.                     preg_quote($this->options['whitespace_line_trim'].'#'.$this->options['tag_comment'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~##}[ \t\0\x0B]*
  143.                     '|'.
  144.                     preg_quote($this->options['whitespace_trim'].$this->options['tag_comment'][1], '#').'\s*\n?'. // -#}\s*\n?
  145.                     '|'.
  146.                     preg_quote($this->options['whitespace_line_trim'].$this->options['tag_comment'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~#}[ \t\0\x0B]*
  147.                     '|'.
  148.                     preg_quote($this->options['tag_comment'][1], '#').'(?:\r\n?|\n)?'. // #}(?:\r\n?|\n)?
  149.                 ')
  150.             }sx',
  151.             // verbatim %}
  152.             'lex_block_raw' => '{
  153.                 \s*verbatim\s*
  154.                 (?:'.
  155.                     preg_quote($this->options['whitespace_trim'].$this->options['tag_block'][1], '#').'\s*'. // -%}\s*
  156.                     '|'.
  157.                     preg_quote($this->options['whitespace_line_trim'].$this->options['tag_block'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~%}[ \t\0\x0B]*
  158.                     '|'.
  159.                     preg_quote($this->options['tag_block'][1], '#'). // %}
  160.                 ')
  161.             }Asx',
  162.             'lex_block_line' => '{\s*line\s+(\d+)\s*'.preg_quote($this->options['tag_block'][1], '#').'}As',
  163.             // {{ or {% or {#
  164.             'lex_tokens_start' => '{
  165.                 ('.
  166.                     preg_quote($this->options['tag_variable'][0], '#'). // {{
  167.                     '|'.
  168.                     preg_quote($this->options['tag_block'][0], '#'). // {%
  169.                     '|'.
  170.                     preg_quote($this->options['tag_comment'][0].'#', '#').$emptyCommentLookahead. // {## (but not the empty comment {##})
  171.                     '|'.
  172.                     preg_quote($this->options['tag_comment'][0], '#'). // {#
  173.                 ')('.
  174.                     preg_quote($this->options['whitespace_trim'], '#'). // -
  175.                     '|'.
  176.                     preg_quote($this->options['whitespace_line_trim'], '#'). // ~
  177.                 ')?
  178.             }sx',
  179.             'interpolation_start' => '{'.preg_quote($this->options['interpolation'][0], '#').'\s*}A',
  180.             'interpolation_end' => '{\s*'.preg_quote($this->options['interpolation'][1], '#').'}A',
  181.         ];
  182.         $this->isInitialized = true;
  183.     }
  184.     public function tokenize(Source $source): TokenStream
  185.     {
  186.         $this->initialize();
  187.         $this->source = $source;
  188.         $this->code = $source->getCode();
  189.         $this->cursor = 0;
  190.         $this->lineno = 1;
  191.         $this->end = \strlen($this->code);
  192.         $this->tokens = [];
  193.         $this->state = self::STATE_DATA;
  194.         $this->states = [];
  195.         $this->brackets = [];
  196.         $this->position = -1;
  197.         $this->documentation = [];
  198.         // find all token starts in one go
  199.         preg_match_all($this->regexes['lex_tokens_start'], $this->code, $matches, \PREG_OFFSET_CAPTURE);
  200.         $this->positions = $matches;
  201.         while ($this->cursor < $this->end) {
  202.             // dispatch to the lexing functions depending
  203.             // on the current state
  204.             switch ($this->state) {
  205.                 case self::STATE_DATA:
  206.                     $this->lexData();
  207.                     break;
  208.                 case self::STATE_BLOCK:
  209.                     $this->lexBlock();
  210.                     break;
  211.                 case self::STATE_VAR:
  212.                     $this->lexVar();
  213.                     break;
  214.                 case self::STATE_STRING:
  215.                     $this->lexString();
  216.                     break;
  217.                 case self::STATE_INTERPOLATION:
  218.                     $this->lexInterpolation();
  219.                     break;
  220.             }
  221.         }
  222.         $this->pushToken(Token::EOF_TYPE);
  223.         if ($this->brackets) {
  224.             [$expect, $lineno] = array_pop($this->brackets);
  225.             throw new SyntaxError(\sprintf('Unclosed "%s".', $expect), $lineno, $this->source);
  226.         }
  227.         return new TokenStream($this->tokens, $this->source);
  228.     }
  229.     private function lexData(): void
  230.     {
  231.         // if no matches are left we return the rest of the template as simple text token
  232.         if ($this->position == \count($this->positions[0]) - 1) {
  233.             $text = substr($this->code, $this->cursor);
  234.             $this->pushToken(Token::TEXT_TYPE, $this->normalizeNewlines($text));
  235.             $this->moveCursor($text);
  236.             return;
  237.         }
  238.         // Find the first token after the current cursor
  239.         $position = $this->positions[0][++$this->position];
  240.         while ($position[1] < $this->cursor) {
  241.             if ($this->position == \count($this->positions[0]) - 1) {
  242.                 return;
  243.             }
  244.             $position = $this->positions[0][++$this->position];
  245.         }
  246.         // push the template text first
  247.         $text = $textContent = substr($this->code, $this->cursor, $position[1] - $this->cursor);
  248.         // trim?
  249.         if (isset($this->positions[2][$this->position][0])) {
  250.             if ($this->options['whitespace_trim'] === $this->positions[2][$this->position][0]) {
  251.                 // whitespace_trim detected ({%-, {{- or {#-)
  252.                 $text = rtrim($text);
  253.             } elseif ($this->options['whitespace_line_trim'] === $this->positions[2][$this->position][0]) {
  254.                 // whitespace_line_trim detected ({%~, {{~ or {#~)
  255.                 // don't trim \r and \n
  256.                 $text = rtrim($text, " \t\0\x0B");
  257.             }
  258.         }
  259.         $this->pushToken(Token::TEXT_TYPE, $this->normalizeNewlines($text));
  260.         $this->moveCursor($textContent);
  261.         switch ($this->positions[1][$this->position][0]) {
  262.             case $this->options['tag_comment'][0].'#':
  263.                 $this->moveCursor($position[0]);
  264.                 $this->lexComment(true);
  265.                 break;
  266.             case $this->options['tag_comment'][0]:
  267.                 $this->moveCursor($position[0]);
  268.                 $this->lexComment();
  269.                 break;
  270.             case $this->options['tag_block'][0]:
  271.                 $lineno = $this->lineno;
  272.                 $cursor = $this->cursor;
  273.                 $this->moveCursor($position[0]);
  274.                 // raw data?
  275.                 if (preg_match($this->regexes['lex_block_raw'], $this->code, $match, 0, $this->cursor)) {
  276.                     $this->documentation = [];
  277.                     $this->moveCursor($match[0]);
  278.                     $this->lexRawData();
  279.                 // {% line \d+ %}
  280.                 } elseif (preg_match($this->regexes['lex_block_line'], $this->code, $match, 0, $this->cursor)) {
  281.                     $this->documentation = [];
  282.                     $this->moveCursor($match[0]);
  283.                     $this->lineno = (int) $match[1];
  284.                 } else {
  285.                     $this->pushToken(Token::BLOCK_START_TYPE, '', $cursor, $lineno);
  286.                     $this->pushState(self::STATE_BLOCK);
  287.                     $this->currentVarBlockLine = $this->lineno;
  288.                 }
  289.                 break;
  290.             case $this->options['tag_variable'][0]:
  291.                 $lineno = $this->lineno;
  292.                 $cursor = $this->cursor;
  293.                 $this->moveCursor($position[0]);
  294.                 $this->pushToken(Token::VAR_START_TYPE, '', $cursor, $lineno);
  295.                 $this->pushState(self::STATE_VAR);
  296.                 $this->currentVarBlockLine = $this->lineno;
  297.                 break;
  298.         }
  299.     }
  300.     private function lexBlock(): void
  301.     {
  302.         if (!$this->brackets && preg_match($this->regexes['lex_block'], $this->code, $match, 0, $this->cursor)) {
  303.             $this->pushClosingToken(Token::BLOCK_END_TYPE, $match[0]);
  304.             $this->popState();
  305.         } else {
  306.             $this->lexExpression();
  307.         }
  308.     }
  309.     private function lexVar(): void
  310.     {
  311.         if (!$this->brackets && preg_match($this->regexes['lex_var'], $this->code, $match, 0, $this->cursor)) {
  312.             $this->pushClosingToken(Token::VAR_END_TYPE, $match[0]);
  313.             $this->popState();
  314.         } else {
  315.             $this->lexExpression();
  316.         }
  317.     }
  318.     private function lexExpression(): void
  319.     {
  320.         // whitespace
  321.         if (preg_match('/\s+/A', $this->code, $match, 0, $this->cursor)) {
  322.             $this->moveCursor($match[0]);
  323.             if ($this->cursor >= $this->end) {
  324.                 throw new SyntaxError(\sprintf('Unclosed "%s".', self::STATE_BLOCK === $this->state ? 'block' : 'variable'), $this->currentVarBlockLine, $this->source);
  325.             }
  326.         }
  327.         // operators
  328.         if (preg_match($this->regexes['operator'], $this->code, $match, 0, $this->cursor)) {
  329.             $operator = preg_replace('/\s+/', ' ', $match[0]);
  330.             if (\in_array($operator, $this->openingBrackets, true)) {
  331.                 $this->checkBrackets($operator);
  332.             }
  333.             $this->pushToken(Token::OPERATOR_TYPE, $operator);
  334.             $this->moveCursor($match[0]);
  335.         }
  336.         // names
  337.         elseif (preg_match(self::REGEX_NAME, $this->code, $match, 0, $this->cursor)) {
  338.             $this->pushToken(Token::NAME_TYPE, $match[0]);
  339.             $this->moveCursor($match[0]);
  340.         }
  341.         // numbers
  342.         elseif (preg_match(self::REGEX_NUMBER, $this->code, $match, 0, $this->cursor)) {
  343.             $this->pushToken(Token::NUMBER_TYPE, 0 + str_replace('_', '', $match[0]));
  344.             $this->moveCursor($match[0]);
  345.         }
  346.         // punctuation
  347.         elseif (str_contains(self::PUNCTUATION, $this->code[$this->cursor])) {
  348.             $this->checkBrackets($this->code[$this->cursor]);
  349.             $this->pushToken(Token::PUNCTUATION_TYPE, $this->code[$this->cursor]);
  350.             $this->moveCursor($this->code[$this->cursor]);
  351.         }
  352.         // strings
  353.         elseif (preg_match(self::REGEX_STRING, $this->code, $match, 0, $this->cursor)) {
  354.             $this->pushToken(Token::STRING_TYPE, $this->stripcslashes($this->normalizeNewlines(substr($match[0], 1, -1)), substr($match[0], 0, 1)));
  355.             $this->moveCursor($match[0]);
  356.         }
  357.         // opening double quoted string
  358.         elseif (preg_match(self::REGEX_DQ_STRING_DELIM, $this->code, $match, 0, $this->cursor)) {
  359.             $this->brackets[] = ['"', $this->lineno];
  360.             $this->pushState(self::STATE_STRING);
  361.             $this->moveCursor($match[0]);
  362.         }
  363.         // inline comment
  364.         elseif (preg_match(self::REGEX_RAW_INLINE_COMMENT, $this->code, $match, 0, $this->cursor)) {
  365.             if (str_starts_with($match[0], '##')) {
  366.                 $this->addDocumentation(substr($match[0], 2));
  367.             }
  368.             $this->moveCursor($match[0]);
  369.         }
  370.         // unlexable
  371.         else {
  372.             throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source, columnno: $this->source->getColumn($this->cursor));
  373.         }
  374.     }
  375.     private function stripcslashes(string $str, string $quoteType): string
  376.     {
  377.         $result = '';
  378.         $length = \strlen($str);
  379.         $i = 0;
  380.         while ($i < $length) {
  381.             if (false === $pos = strpos($str, '\\', $i)) {
  382.                 $result .= substr($str, $i);
  383.                 break;
  384.             }
  385.             $result .= substr($str, $i, $pos - $i);
  386.             $i = $pos + 1;
  387.             if ($i >= $length) {
  388.                 $result .= '\\';
  389.                 break;
  390.             }
  391.             $nextChar = $str[$i];
  392.             if (isset(self::SPECIAL_CHARS[$nextChar])) {
  393.                 $result .= self::SPECIAL_CHARS[$nextChar];
  394.             } elseif ('\\' === $nextChar) {
  395.                 $result .= $nextChar;
  396.             } elseif ("'" === $nextChar || '"' === $nextChar) {
  397.                 if ($nextChar !== $quoteType) {
  398.                     trigger_deprecation('twig/twig', '3.12', 'Character "%s" should not be escaped; the "\" character is ignored in Twig 3 but will not be in Twig 4. Please remove the extra "\" character at position %d in "%s" at line %d.', $nextChar, $i + 1, $this->source->getName(), $this->lineno);
  399.                 }
  400.                 $result .= $nextChar;
  401.             } elseif ('#' === $nextChar && $i + 1 < $length && '{' === $str[$i + 1]) {
  402.                 $result .= '#{';
  403.                 ++$i;
  404.             } elseif ('x' === $nextChar && $i + 1 < $length && ctype_xdigit($str[$i + 1])) {
  405.                 $hex = $str[++$i];
  406.                 if ($i + 1 < $length && ctype_xdigit($str[$i + 1])) {
  407.                     $hex .= $str[++$i];
  408.                 }
  409.                 $result .= \chr(hexdec($hex));
  410.             } elseif (ctype_digit($nextChar) && $nextChar < '8') {
  411.                 $octal = $nextChar;
  412.                 while ($i + 1 < $length && ctype_digit($str[$i + 1]) && $str[$i + 1] < '8' && \strlen($octal) < 3) {
  413.                     $octal .= $str[++$i];
  414.                 }
  415.                 $result .= \chr(octdec($octal) % 256);
  416.             } else {
  417.                 trigger_deprecation('twig/twig', '3.12', 'Character "%s" should not be escaped; the "\" character is ignored in Twig 3 but will not be in Twig 4. Please remove the extra "\" character at position %d in "%s" at line %d.', $nextChar, $i + 1, $this->source->getName(), $this->lineno);
  418.                 $result .= $nextChar;
  419.             }
  420.             ++$i;
  421.         }
  422.         return $result;
  423.     }
  424.     private function lexRawData(): void
  425.     {
  426.         if (!preg_match($this->regexes['lex_raw_data'], $this->code, $match, \PREG_OFFSET_CAPTURE, $this->cursor)) {
  427.             throw new SyntaxError('Unexpected end of file: Unclosed "verbatim" block.', $this->lineno, $this->source);
  428.         }
  429.         $offset = $this->cursor;
  430.         $text = substr($this->code, $this->cursor, $match[0][1] - $this->cursor);
  431.         $this->moveCursor($text.$match[0][0]);
  432.         // trim?
  433.         if (isset($match[1][0])) {
  434.             if ($this->options['whitespace_trim'] === $match[1][0]) {
  435.                 // whitespace_trim detected ({%-, {{- or {#-)
  436.                 $text = rtrim($text);
  437.             } else {
  438.                 // whitespace_line_trim detected ({%~, {{~ or {#~)
  439.                 // don't trim \r and \n
  440.                 $text = rtrim($text, " \t\0\x0B");
  441.             }
  442.         }
  443.         $this->pushToken(Token::TEXT_TYPE, $this->normalizeNewlines($text), $offset);
  444.     }
  445.     private function lexComment(bool $isDocumentation = false): void
  446.     {
  447.         $regex = $isDocumentation ? 'lex_documentation_comment' : 'lex_comment';
  448.         if (!preg_match($this->regexes[$regex], $this->code, $match, \PREG_OFFSET_CAPTURE, $this->cursor)) {
  449.             throw new SyntaxError('Unclosed comment.', $this->lineno, $this->source);
  450.         }
  451.         $comment = substr($this->code, $this->cursor, $match[0][1] - $this->cursor);
  452.         if ($isDocumentation) {
  453.             $documentation = $comment;
  454.             // support a symmetric "##}" closing marker
  455.             if (str_ends_with($documentation, '#')) {
  456.                 $documentation = substr($documentation, 0, -1);
  457.             }
  458.             $this->addDocumentation($documentation);
  459.         }
  460.         $this->moveCursor($comment.$match[0][0]);
  461.     }
  462.     private function lexString(): void
  463.     {
  464.         if (preg_match($this->regexes['interpolation_start'], $this->code, $match, 0, $this->cursor)) {
  465.             $this->brackets[] = [$this->options['interpolation'][0], $this->lineno];
  466.             $this->pushToken(Token::INTERPOLATION_START_TYPE);
  467.             $this->moveCursor($match[0]);
  468.             $this->pushState(self::STATE_INTERPOLATION);
  469.         } elseif (preg_match(self::REGEX_DQ_STRING_PART, $this->code, $match, 0, $this->cursor) && '' !== $match[0]) {
  470.             $this->pushToken(Token::STRING_TYPE, $this->stripcslashes($this->normalizeNewlines($match[0]), '"'));
  471.             $this->moveCursor($match[0]);
  472.         } elseif (preg_match(self::REGEX_DQ_STRING_DELIM, $this->code, $match, 0, $this->cursor)) {
  473.             [$expect, $lineno] = array_pop($this->brackets);
  474.             if ('"' != $this->code[$this->cursor]) {
  475.                 throw new SyntaxError(\sprintf('Unclosed "%s".', $expect), $lineno, $this->source);
  476.             }
  477.             $this->popState();
  478.             $this->moveCursor($match[0]);
  479.         } else {
  480.             // unlexable
  481.             throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source, columnno: $this->source->getColumn($this->cursor));
  482.         }
  483.     }
  484.     private function lexInterpolation(): void
  485.     {
  486.         $bracket = end($this->brackets);
  487.         if ($this->options['interpolation'][0] === $bracket[0] && preg_match($this->regexes['interpolation_end'], $this->code, $match, 0, $this->cursor)) {
  488.             array_pop($this->brackets);
  489.             $this->pushClosingToken(Token::INTERPOLATION_END_TYPE, $match[0]);
  490.             $this->popState();
  491.         } else {
  492.             $this->lexExpression();
  493.         }
  494.     }
  495.     private function pushToken($type, $value = '', ?int $offset = null, ?int $lineno = null): void
  496.     {
  497.         // do not push empty text tokens
  498.         if (Token::TEXT_TYPE === $type && '' === $value) {
  499.             return;
  500.         }
  501.         // by default the token starts at the current cursor; callers that
  502.         // emit a token after consuming it must pass an explicit offset
  503.         $documentation = null;
  504.         if ($this->documentation && (Token::TEXT_TYPE !== $type || '' !== trim($value))) {
  505.             $documentation = implode("\n", $this->documentation);
  506.             $this->documentation = [];
  507.         }
  508.         $this->tokens[] = new Token($type, $value, $lineno ?? $this->lineno, $offset ?? $this->cursor, $documentation);
  509.     }
  510.     private function addDocumentation(string $documentation): void
  511.     {
  512.         if ('' !== $documentation = trim($this->normalizeNewlines($documentation))) {
  513.             $this->documentation[] = $documentation;
  514.         }
  515.     }
  516.     private function moveCursor($text): void
  517.     {
  518.         $this->cursor += \strlen($text);
  519.         // count "\r\n" and "\r" as a single newline without allocating a
  520.         // normalized copy when the chunk has no carriage return (common case)
  521.         $this->lineno += str_contains($text, "\r") ? substr_count($this->normalizeNewlines($text), "\n") : substr_count($text, "\n");
  522.     }
  523.     private function normalizeNewlines(string $text): string
  524.     {
  525.         return str_replace(["\r\n", "\r"], "\n", $text);
  526.     }
  527.     private function pushClosingToken(int $type, string $match): void
  528.     {
  529.         $leadingWhitespaceLength = \strlen($match) - \strlen(ltrim($match));
  530.         if ($leadingWhitespaceLength) {
  531.             $this->moveCursor(substr($match, 0, $leadingWhitespaceLength));
  532.         }
  533.         $this->pushToken($type);
  534.         $this->moveCursor(substr($match, $leadingWhitespaceLength));
  535.     }
  536.     private function getOperatorRegex(): string
  537.     {
  538.         $expressionParsers = [];
  539.         foreach ($this->env->getExpressionParsers() as $expressionParser) {
  540.             $expressionParsers = array_merge($expressionParsers, ExpressionParsers::getOperatorTokensFor($expressionParser));
  541.         }
  542.         $expressionParsers = array_combine($expressionParsers, array_map('strlen', $expressionParsers));
  543.         arsort($expressionParsers);
  544.         $regex = [];
  545.         foreach ($expressionParsers as $expressionParser => $length) {
  546.             // an operator that ends with a character must be followed by
  547.             // a whitespace, a parenthesis, an opening map [ or sequence {
  548.             $r = preg_quote($expressionParser, '/');
  549.             if (ctype_alpha($expressionParser[$length - 1])) {
  550.                 $r .= '(?=[\s()\[{])';
  551.             }
  552.             // an operator that begins with a character must not have a dot or pipe before
  553.             if (ctype_alpha($expressionParser[0])) {
  554.                 $r = '(?<![\.\|]\s|.[\.\|])'.$r;
  555.             }
  556.             // an operator with a space can be any amount of whitespaces
  557.             $r = preg_replace('/\s+/', '\s+', $r);
  558.             $regex[] = $r;
  559.         }
  560.         return '/'.implode('|', $regex).'/A';
  561.     }
  562.     private function pushState($state): void
  563.     {
  564.         $this->states[] = $this->state;
  565.         $this->state = $state;
  566.     }
  567.     private function popState(): void
  568.     {
  569.         if (0 === \count($this->states)) {
  570.             throw new \LogicException('Cannot pop state without a previous state.');
  571.         }
  572.         $this->state = array_pop($this->states);
  573.     }
  574.     private function checkBrackets(string $code): void
  575.     {
  576.         // opening bracket
  577.         if (\in_array($code, $this->openingBrackets, true)) {
  578.             $this->brackets[] = [$code, $this->lineno];
  579.         } elseif (\in_array($code, $this->closingBrackets, true)) {
  580.             // closing bracket
  581.             if (!$this->brackets) {
  582.                 throw new SyntaxError(\sprintf('Unexpected "%s".', $code), $this->lineno, $this->source, columnno: $this->source->getColumn($this->cursor));
  583.             }
  584.             [$expect, $lineno] = array_pop($this->brackets);
  585.             if ($code !== str_replace($this->openingBrackets, $this->closingBrackets, $expect)) {
  586.                 throw new SyntaxError(\sprintf('Unclosed "%s".', $expect), $lineno, $this->source);
  587.             }
  588.         }
  589.     }
  590. }