1<?php 2 3/* 4 * This file is part of Twig. 5 * 6 * (c) Fabien Potencier 7 * (c) Armin Ronacher 8 * 9 * For the full copyright and license information, please view the LICENSE 10 * file that was distributed with this source code. 11 */ 12 13namespace Twig; 14 15use Twig\Error\SyntaxError; 16 17/** 18 * Lexes a template string. 19 * 20 * @author Fabien Potencier <fabien@symfony.com> 21 */ 22class Lexer implements \Twig_LexerInterface 23{ 24 protected $tokens; 25 protected $code; 26 protected $cursor; 27 protected $lineno; 28 protected $end; 29 protected $state; 30 protected $states; 31 protected $brackets; 32 protected $env; 33 // to be renamed to $name in 2.0 (where it is private) 34 protected $filename; 35 protected $options; 36 protected $regexes; 37 protected $position; 38 protected $positions; 39 protected $currentVarBlockLine; 40 41 private $source; 42 43 const STATE_DATA = 0; 44 const STATE_BLOCK = 1; 45 const STATE_VAR = 2; 46 const STATE_STRING = 3; 47 const STATE_INTERPOLATION = 4; 48 49 const REGEX_NAME = '/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/A'; 50 const REGEX_NUMBER = '/[0-9]+(?:\.[0-9]+)?/A'; 51 const REGEX_STRING = '/"([^#"\\\\]*(?:\\\\.[^#"\\\\]*)*)"|\'([^\'\\\\]*(?:\\\\.[^\'\\\\]*)*)\'/As'; 52 const REGEX_DQ_STRING_DELIM = '/"/A'; 53 const REGEX_DQ_STRING_PART = '/[^#"\\\\]*(?:(?:\\\\.|#(?!\{))[^#"\\\\]*)*/As'; 54 const PUNCTUATION = '()[]{}?:.,|'; 55 56 public function __construct(Environment $env, array $options = []) 57 { 58 $this->env = $env; 59 60 $this->options = array_merge([ 61 'tag_comment' => ['{#', '#}'], 62 'tag_block' => ['{%', '%}'], 63 'tag_variable' => ['{{', '}}'], 64 'whitespace_trim' => '-', 65 'interpolation' => ['#{', '}'], 66 ], $options); 67 68 $this->regexes = [ 69 'lex_var' => '/\s*'.preg_quote($this->options['whitespace_trim'].$this->options['tag_variable'][1], '/').'\s*|\s*'.preg_quote($this->options['tag_variable'][1], '/').'/A', 70 'lex_block' => '/\s*(?:'.preg_quote($this->options['whitespace_trim'].$this->options['tag_block'][1], '/').'\s*|\s*'.preg_quote($this->options['tag_block'][1], '/').')\n?/A', 71 'lex_raw_data' => '/('.preg_quote($this->options['tag_block'][0].$this->options['whitespace_trim'], '/').'|'.preg_quote($this->options['tag_block'][0], '/').')\s*(?:end%s)\s*(?:'.preg_quote($this->options['whitespace_trim'].$this->options['tag_block'][1], '/').'\s*|\s*'.preg_quote($this->options['tag_block'][1], '/').')/s', 72 'operator' => $this->getOperatorRegex(), 73 'lex_comment' => '/(?:'.preg_quote($this->options['whitespace_trim'], '/').preg_quote($this->options['tag_comment'][1], '/').'\s*|'.preg_quote($this->options['tag_comment'][1], '/').')\n?/s', 74 'lex_block_raw' => '/\s*(raw|verbatim)\s*(?:'.preg_quote($this->options['whitespace_trim'].$this->options['tag_block'][1], '/').'\s*|\s*'.preg_quote($this->options['tag_block'][1], '/').')/As', 75 'lex_block_line' => '/\s*line\s+(\d+)\s*'.preg_quote($this->options['tag_block'][1], '/').'/As', 76 'lex_tokens_start' => '/('.preg_quote($this->options['tag_variable'][0], '/').'|'.preg_quote($this->options['tag_block'][0], '/').'|'.preg_quote($this->options['tag_comment'][0], '/').')('.preg_quote($this->options['whitespace_trim'], '/').')?/s', 77 'interpolation_start' => '/'.preg_quote($this->options['interpolation'][0], '/').'\s*/A', 78 'interpolation_end' => '/\s*'.preg_quote($this->options['interpolation'][1], '/').'/A', 79 ]; 80 } 81 82 public function tokenize($code, $name = null) 83 { 84 if (!$code instanceof Source) { 85 @trigger_error(sprintf('Passing a string as the $code argument of %s() is deprecated since version 1.27 and will be removed in 2.0. Pass a \Twig\Source instance instead.', __METHOD__), E_USER_DEPRECATED); 86 $this->source = new Source($code, $name); 87 } else { 88 $this->source = $code; 89 } 90 91 if (((int) ini_get('mbstring.func_overload')) & 2) { 92 @trigger_error('Support for having "mbstring.func_overload" different from 0 is deprecated version 1.29 and will be removed in 2.0.', E_USER_DEPRECATED); 93 } 94 95 if (\function_exists('mb_internal_encoding') && ((int) ini_get('mbstring.func_overload')) & 2) { 96 $mbEncoding = mb_internal_encoding(); 97 mb_internal_encoding('ASCII'); 98 } else { 99 $mbEncoding = null; 100 } 101 102 $this->code = str_replace(["\r\n", "\r"], "\n", $this->source->getCode()); 103 $this->filename = $this->source->getName(); 104 $this->cursor = 0; 105 $this->lineno = 1; 106 $this->end = \strlen($this->code); 107 $this->tokens = []; 108 $this->state = self::STATE_DATA; 109 $this->states = []; 110 $this->brackets = []; 111 $this->position = -1; 112 113 // find all token starts in one go 114 preg_match_all($this->regexes['lex_tokens_start'], $this->code, $matches, PREG_OFFSET_CAPTURE); 115 $this->positions = $matches; 116 117 while ($this->cursor < $this->end) { 118 // dispatch to the lexing functions depending 119 // on the current state 120 switch ($this->state) { 121 case self::STATE_DATA: 122 $this->lexData(); 123 break; 124 125 case self::STATE_BLOCK: 126 $this->lexBlock(); 127 break; 128 129 case self::STATE_VAR: 130 $this->lexVar(); 131 break; 132 133 case self::STATE_STRING: 134 $this->lexString(); 135 break; 136 137 case self::STATE_INTERPOLATION: 138 $this->lexInterpolation(); 139 break; 140 } 141 } 142 143 $this->pushToken(Token::EOF_TYPE); 144 145 if (!empty($this->brackets)) { 146 list($expect, $lineno) = array_pop($this->brackets); 147 throw new SyntaxError(sprintf('Unclosed "%s".', $expect), $lineno, $this->source); 148 } 149 150 if ($mbEncoding) { 151 mb_internal_encoding($mbEncoding); 152 } 153 154 return new TokenStream($this->tokens, $this->source); 155 } 156 157 protected function lexData() 158 { 159 // if no matches are left we return the rest of the template as simple text token 160 if ($this->position == \count($this->positions[0]) - 1) { 161 $this->pushToken(Token::TEXT_TYPE, substr($this->code, $this->cursor)); 162 $this->cursor = $this->end; 163 164 return; 165 } 166 167 // Find the first token after the current cursor 168 $position = $this->positions[0][++$this->position]; 169 while ($position[1] < $this->cursor) { 170 if ($this->position == \count($this->positions[0]) - 1) { 171 return; 172 } 173 $position = $this->positions[0][++$this->position]; 174 } 175 176 // push the template text first 177 $text = $textContent = substr($this->code, $this->cursor, $position[1] - $this->cursor); 178 if (isset($this->positions[2][$this->position][0])) { 179 $text = rtrim($text); 180 } 181 $this->pushToken(Token::TEXT_TYPE, $text); 182 $this->moveCursor($textContent.$position[0]); 183 184 switch ($this->positions[1][$this->position][0]) { 185 case $this->options['tag_comment'][0]: 186 $this->lexComment(); 187 break; 188 189 case $this->options['tag_block'][0]: 190 // raw data? 191 if (preg_match($this->regexes['lex_block_raw'], $this->code, $match, null, $this->cursor)) { 192 $this->moveCursor($match[0]); 193 $this->lexRawData($match[1]); 194 // {% line \d+ %} 195 } elseif (preg_match($this->regexes['lex_block_line'], $this->code, $match, null, $this->cursor)) { 196 $this->moveCursor($match[0]); 197 $this->lineno = (int) $match[1]; 198 } else { 199 $this->pushToken(Token::BLOCK_START_TYPE); 200 $this->pushState(self::STATE_BLOCK); 201 $this->currentVarBlockLine = $this->lineno; 202 } 203 break; 204 205 case $this->options['tag_variable'][0]: 206 $this->pushToken(Token::VAR_START_TYPE); 207 $this->pushState(self::STATE_VAR); 208 $this->currentVarBlockLine = $this->lineno; 209 break; 210 } 211 } 212 213 protected function lexBlock() 214 { 215 if (empty($this->brackets) && preg_match($this->regexes['lex_block'], $this->code, $match, null, $this->cursor)) { 216 $this->pushToken(Token::BLOCK_END_TYPE); 217 $this->moveCursor($match[0]); 218 $this->popState(); 219 } else { 220 $this->lexExpression(); 221 } 222 } 223 224 protected function lexVar() 225 { 226 if (empty($this->brackets) && preg_match($this->regexes['lex_var'], $this->code, $match, null, $this->cursor)) { 227 $this->pushToken(Token::VAR_END_TYPE); 228 $this->moveCursor($match[0]); 229 $this->popState(); 230 } else { 231 $this->lexExpression(); 232 } 233 } 234 235 protected function lexExpression() 236 { 237 // whitespace 238 if (preg_match('/\s+/A', $this->code, $match, null, $this->cursor)) { 239 $this->moveCursor($match[0]); 240 241 if ($this->cursor >= $this->end) { 242 throw new SyntaxError(sprintf('Unclosed "%s".', self::STATE_BLOCK === $this->state ? 'block' : 'variable'), $this->currentVarBlockLine, $this->source); 243 } 244 } 245 246 // operators 247 if (preg_match($this->regexes['operator'], $this->code, $match, null, $this->cursor)) { 248 $this->pushToken(Token::OPERATOR_TYPE, preg_replace('/\s+/', ' ', $match[0])); 249 $this->moveCursor($match[0]); 250 } 251 // names 252 elseif (preg_match(self::REGEX_NAME, $this->code, $match, null, $this->cursor)) { 253 $this->pushToken(Token::NAME_TYPE, $match[0]); 254 $this->moveCursor($match[0]); 255 } 256 // numbers 257 elseif (preg_match(self::REGEX_NUMBER, $this->code, $match, null, $this->cursor)) { 258 $number = (float) $match[0]; // floats 259 if (ctype_digit($match[0]) && $number <= PHP_INT_MAX) { 260 $number = (int) $match[0]; // integers lower than the maximum 261 } 262 $this->pushToken(Token::NUMBER_TYPE, $number); 263 $this->moveCursor($match[0]); 264 } 265 // punctuation 266 elseif (false !== strpos(self::PUNCTUATION, $this->code[$this->cursor])) { 267 // opening bracket 268 if (false !== strpos('([{', $this->code[$this->cursor])) { 269 $this->brackets[] = [$this->code[$this->cursor], $this->lineno]; 270 } 271 // closing bracket 272 elseif (false !== strpos(')]}', $this->code[$this->cursor])) { 273 if (empty($this->brackets)) { 274 throw new SyntaxError(sprintf('Unexpected "%s".', $this->code[$this->cursor]), $this->lineno, $this->source); 275 } 276 277 list($expect, $lineno) = array_pop($this->brackets); 278 if ($this->code[$this->cursor] != strtr($expect, '([{', ')]}')) { 279 throw new SyntaxError(sprintf('Unclosed "%s".', $expect), $lineno, $this->source); 280 } 281 } 282 283 $this->pushToken(Token::PUNCTUATION_TYPE, $this->code[$this->cursor]); 284 ++$this->cursor; 285 } 286 // strings 287 elseif (preg_match(self::REGEX_STRING, $this->code, $match, null, $this->cursor)) { 288 $this->pushToken(Token::STRING_TYPE, stripcslashes(substr($match[0], 1, -1))); 289 $this->moveCursor($match[0]); 290 } 291 // opening double quoted string 292 elseif (preg_match(self::REGEX_DQ_STRING_DELIM, $this->code, $match, null, $this->cursor)) { 293 $this->brackets[] = ['"', $this->lineno]; 294 $this->pushState(self::STATE_STRING); 295 $this->moveCursor($match[0]); 296 } 297 // unlexable 298 else { 299 throw new SyntaxError(sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source); 300 } 301 } 302 303 protected function lexRawData($tag) 304 { 305 if ('raw' === $tag) { 306 @trigger_error(sprintf('Twig Tag "raw" is deprecated since version 1.21. Use "verbatim" instead in %s at line %d.', $this->filename, $this->lineno), E_USER_DEPRECATED); 307 } 308 309 if (!preg_match(str_replace('%s', $tag, $this->regexes['lex_raw_data']), $this->code, $match, PREG_OFFSET_CAPTURE, $this->cursor)) { 310 throw new SyntaxError(sprintf('Unexpected end of file: Unclosed "%s" block.', $tag), $this->lineno, $this->source); 311 } 312 313 $text = substr($this->code, $this->cursor, $match[0][1] - $this->cursor); 314 $this->moveCursor($text.$match[0][0]); 315 316 if (false !== strpos($match[1][0], $this->options['whitespace_trim'])) { 317 $text = rtrim($text); 318 } 319 320 $this->pushToken(Token::TEXT_TYPE, $text); 321 } 322 323 protected function lexComment() 324 { 325 if (!preg_match($this->regexes['lex_comment'], $this->code, $match, PREG_OFFSET_CAPTURE, $this->cursor)) { 326 throw new SyntaxError('Unclosed comment.', $this->lineno, $this->source); 327 } 328 329 $this->moveCursor(substr($this->code, $this->cursor, $match[0][1] - $this->cursor).$match[0][0]); 330 } 331 332 protected function lexString() 333 { 334 if (preg_match($this->regexes['interpolation_start'], $this->code, $match, null, $this->cursor)) { 335 $this->brackets[] = [$this->options['interpolation'][0], $this->lineno]; 336 $this->pushToken(Token::INTERPOLATION_START_TYPE); 337 $this->moveCursor($match[0]); 338 $this->pushState(self::STATE_INTERPOLATION); 339 } elseif (preg_match(self::REGEX_DQ_STRING_PART, $this->code, $match, null, $this->cursor) && \strlen($match[0]) > 0) { 340 $this->pushToken(Token::STRING_TYPE, stripcslashes($match[0])); 341 $this->moveCursor($match[0]); 342 } elseif (preg_match(self::REGEX_DQ_STRING_DELIM, $this->code, $match, null, $this->cursor)) { 343 list($expect, $lineno) = array_pop($this->brackets); 344 if ('"' != $this->code[$this->cursor]) { 345 throw new SyntaxError(sprintf('Unclosed "%s".', $expect), $lineno, $this->source); 346 } 347 348 $this->popState(); 349 ++$this->cursor; 350 } else { 351 // unlexable 352 throw new SyntaxError(sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source); 353 } 354 } 355 356 protected function lexInterpolation() 357 { 358 $bracket = end($this->brackets); 359 if ($this->options['interpolation'][0] === $bracket[0] && preg_match($this->regexes['interpolation_end'], $this->code, $match, null, $this->cursor)) { 360 array_pop($this->brackets); 361 $this->pushToken(Token::INTERPOLATION_END_TYPE); 362 $this->moveCursor($match[0]); 363 $this->popState(); 364 } else { 365 $this->lexExpression(); 366 } 367 } 368 369 protected function pushToken($type, $value = '') 370 { 371 // do not push empty text tokens 372 if (Token::TEXT_TYPE === $type && '' === $value) { 373 return; 374 } 375 376 $this->tokens[] = new Token($type, $value, $this->lineno); 377 } 378 379 protected function moveCursor($text) 380 { 381 $this->cursor += \strlen($text); 382 $this->lineno += substr_count($text, "\n"); 383 } 384 385 protected function getOperatorRegex() 386 { 387 $operators = array_merge( 388 ['='], 389 array_keys($this->env->getUnaryOperators()), 390 array_keys($this->env->getBinaryOperators()) 391 ); 392 393 $operators = array_combine($operators, array_map('strlen', $operators)); 394 arsort($operators); 395 396 $regex = []; 397 foreach ($operators as $operator => $length) { 398 // an operator that ends with a character must be followed by 399 // a whitespace or a parenthesis 400 if (ctype_alpha($operator[$length - 1])) { 401 $r = preg_quote($operator, '/').'(?=[\s()])'; 402 } else { 403 $r = preg_quote($operator, '/'); 404 } 405 406 // an operator with a space can be any amount of whitespaces 407 $r = preg_replace('/\s+/', '\s+', $r); 408 409 $regex[] = $r; 410 } 411 412 return '/'.implode('|', $regex).'/A'; 413 } 414 415 protected function pushState($state) 416 { 417 $this->states[] = $this->state; 418 $this->state = $state; 419 } 420 421 protected function popState() 422 { 423 if (0 === \count($this->states)) { 424 throw new \LogicException('Cannot pop state without a previous state.'); 425 } 426 427 $this->state = array_pop($this->states); 428 } 429} 430 431class_alias('Twig\Lexer', 'Twig_Lexer'); 432