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

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. }