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

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