1<?php 2/** 3 * http://leafo.net/lessphp 4 * 5 * LESS CSS compiler, adapted from http://lesscss.org 6 * 7 * Copyright 2013, Leaf Corcoran <leafot@gmail.com> 8 * Copyright 2016, Marcus Schwarz <github@maswaba.de> 9 * Licensed under MIT or GPLv3, see LICENSE 10 */ 11 12namespace LesserPHP; 13 14use Exception; 15use LesserPHP\Utils\Util; 16use stdClass; 17 18/** 19 * responsible for taking a string of LESS code and converting it into a syntax tree 20 */ 21class Parser 22{ 23 24 public $eatWhiteDefault; 25 public $sourceName; 26 public $writeComments; 27 public $count; 28 public $line; 29 public $env; 30 public $buffer; 31 public $seenComments; 32 public $inExp; 33 34 protected static $nextBlockId = 0; // used to uniquely identify blocks 35 36 protected static $precedence = [ 37 '=<' => 0, 38 '>=' => 0, 39 '=' => 0, 40 '<' => 0, 41 '>' => 0, 42 43 '+' => 1, 44 '-' => 1, 45 '*' => 2, 46 '/' => 2, 47 '%' => 2, 48 ]; 49 50 protected static $whitePattern; 51 protected static $commentMulti; 52 53 protected static $commentSingle = '//'; 54 protected static $commentMultiLeft = '/*'; 55 protected static $commentMultiRight = '*/'; 56 57 // regex string to match any of the operators 58 protected static $operatorString; 59 60 // these properties will supress division unless it's inside parenthases 61 protected static $supressDivisionProps = 62 ['/border-radius$/i', '/^font$/i']; 63 64 protected $blockDirectives = [ 65 'font-face', 66 'keyframes', 67 'page', 68 '-moz-document', 69 'viewport', 70 '-moz-viewport', 71 '-o-viewport', 72 '-ms-viewport' 73 ]; 74 protected $lineDirectives = ['charset']; 75 76 /** 77 * if we are in parens we can be more liberal with whitespace around 78 * operators because it must evaluate to a single value and thus is less 79 * ambiguous. 80 * 81 * Consider: 82 * property1: 10 -5; // is two numbers, 10 and -5 83 * property2: (10 -5); // should evaluate to 5 84 */ 85 protected $inParens = false; 86 87 // caches preg escaped literals 88 protected static $literalCache = []; 89 90 protected $currentProperty; 91 92 /** 93 * @param string|null $sourceName name used for error messages 94 */ 95 public function __construct(?string $sourceName = null) 96 { 97 $this->eatWhiteDefault = true; 98 $this->sourceName = $sourceName; // name used for error messages 99 100 $this->writeComments = false; 101 102 if (!self::$operatorString) { 103 self::$operatorString = 104 '(' . implode('|', array_map( 105 [Util::class, 'pregQuote'], 106 array_keys(self::$precedence) 107 )) . ')'; 108 109 $commentSingle = Util::pregQuote(self::$commentSingle); 110 $commentMultiLeft = Util::pregQuote(self::$commentMultiLeft); 111 $commentMultiRight = Util::pregQuote(self::$commentMultiRight); 112 113 self::$commentMulti = $commentMultiLeft . '.*?' . $commentMultiRight; 114 self::$whitePattern = '/' . $commentSingle . '[^\n]*\s*|(' . self::$commentMulti . ')\s*|\s+/Ais'; 115 } 116 } 117 118 /** 119 * @throws Exception 120 */ 121 public function parse($buffer) 122 { 123 $this->count = 0; 124 $this->line = 1; 125 126 $this->env = null; // block stack 127 $this->buffer = $this->writeComments ? $buffer : $this->removeComments($buffer); 128 $this->pushSpecialBlock('root'); 129 $this->eatWhiteDefault = true; 130 $this->seenComments = []; 131 132 // trim whitespace on head 133 // if (preg_match('/^\s+/', $this->buffer, $m)) { 134 // $this->line += substr_count($m[0], "\n"); 135 // $this->buffer = ltrim($this->buffer); 136 // } 137 $this->whitespace(); 138 139 // parse the entire file 140 while (false !== $this->parseChunk()) { 141 // no-op 142 } 143 144 if ($this->count != strlen($this->buffer)) { 145 $this->throwError(); 146 } 147 148 // TODO report where the block was opened 149 if (!property_exists($this->env, 'parent') || !is_null($this->env->parent)) { 150 $this->throwError('parse error: unclosed block'); 151 } 152 153 return $this->env; 154 } 155 156 /** 157 * Parse a single chunk off the head of the buffer and append it to the 158 * current parse environment. 159 * Returns false when the buffer is empty, or when there is an error. 160 * 161 * This function is called repeatedly until the entire document is 162 * parsed. 163 * 164 * This parser is most similar to a recursive descent parser. Single 165 * functions represent discrete grammatical rules for the language, and 166 * they are able to capture the text that represents those rules. 167 * 168 * Consider the function Lessc::keyword(). (all parse functions are 169 * structured the same) 170 * 171 * The function takes a single reference argument. When calling the 172 * function it will attempt to match a keyword on the head of the buffer. 173 * If it is successful, it will place the keyword in the referenced 174 * argument, advance the position in the buffer, and return true. If it 175 * fails then it won't advance the buffer and it will return false. 176 * 177 * All of these parse functions are powered by Lessc::match(), which behaves 178 * the same way, but takes a literal regular expression. Sometimes it is 179 * more convenient to use match instead of creating a new function. 180 * 181 * Because of the format of the functions, to parse an entire string of 182 * grammatical rules, you can chain them together using &&. 183 * 184 * But, if some of the rules in the chain succeed before one fails, then 185 * the buffer position will be left at an invalid state. In order to 186 * avoid this, Lessc::seek() is used to remember and set buffer positions. 187 * 188 * Before parsing a chain, use $s = $this->seek() to remember the current 189 * position into $s. Then if a chain fails, use $this->seek($s) to 190 * go back where we started. 191 * 192 * @throws Exception 193 */ 194 protected function parseChunk() 195 { 196 if (empty($this->buffer)) return false; 197 $s = $this->seek(); 198 199 if ($this->whitespace()) { 200 return true; 201 } 202 203 // setting a property 204 if ($this->keyword($key) && $this->assign() && 205 $this->propertyValue($value, $key) && $this->end()) { 206 $this->append(['assign', $key, $value], $s); 207 return true; 208 } else { 209 $this->seek($s); 210 } 211 212 213 // look for special css blocks 214 if ($this->literal('@', false)) { 215 $this->count--; 216 217 // media 218 if ($this->literal('@media')) { 219 if (($this->mediaQueryList($mediaQueries) || true) 220 && $this->literal('{')) { 221 $media = $this->pushSpecialBlock('media'); 222 $media->queries = is_null($mediaQueries) ? [] : $mediaQueries; 223 return true; 224 } else { 225 $this->seek($s); 226 return false; 227 } 228 } 229 230 if ($this->literal('@', false) && $this->keyword($dirName)) { 231 if ($this->isDirective($dirName, $this->blockDirectives)) { 232 if (($this->openString('{', $dirValue, null, [';']) || true) && 233 $this->literal('{')) { 234 $dir = $this->pushSpecialBlock('directive'); 235 $dir->name = $dirName; 236 if (isset($dirValue)) $dir->value = $dirValue; 237 return true; 238 } 239 } elseif ($this->isDirective($dirName, $this->lineDirectives)) { 240 if ($this->propertyValue($dirValue) && $this->end()) { 241 $this->append(['directive', $dirName, $dirValue]); 242 return true; 243 } 244 } elseif ($this->literal(':', true)) { 245 //Ruleset Definition 246 if (($this->openString('{', $dirValue, null, [';']) || true) && 247 $this->literal('{')) { 248 $dir = $this->pushBlock($this->fixTags(['@' . $dirName])); 249 $dir->name = $dirName; 250 if (isset($dirValue)) $dir->value = $dirValue; 251 return true; 252 } 253 } 254 } 255 256 $this->seek($s); 257 } 258 259 // setting a variable 260 if ($this->variable($var) && $this->assign() && 261 $this->propertyValue($value) && $this->end()) { 262 $this->append(['assign', $var, $value], $s); 263 return true; 264 } else { 265 $this->seek($s); 266 } 267 268 if ($this->import($importValue)) { 269 $this->append($importValue, $s); 270 return true; 271 } 272 273 // opening parametric mixin 274 if ($this->tag($tag, true) && $this->argumentDef($args, $isVararg) && 275 ($this->guards($guards) || true) && 276 $this->literal('{')) { 277 $block = $this->pushBlock($this->fixTags([$tag])); 278 $block->args = $args; 279 $block->isVararg = $isVararg; 280 if (!empty($guards)) $block->guards = $guards; 281 return true; 282 } else { 283 $this->seek($s); 284 } 285 286 // opening a simple block 287 if ($this->tags($tags) && $this->literal('{', false)) { 288 $tags = $this->fixTags($tags); 289 $this->pushBlock($tags); 290 return true; 291 } else { 292 $this->seek($s); 293 } 294 295 // closing a block 296 if ($this->literal('}', false)) { 297 try { 298 $block = $this->pop(); 299 } catch (Exception $e) { 300 $this->seek($s); 301 $this->throwError($e->getMessage()); 302 } 303 304 $hidden = false; 305 if (is_null($block->type)) { 306 $hidden = true; 307 if (!isset($block->args)) { 308 foreach ($block->tags as $tag) { 309 if (!is_string($tag) || $tag[0] != Constants::MPREFIX) { 310 $hidden = false; 311 break; 312 } 313 } 314 } 315 316 foreach ($block->tags as $tag) { 317 if (is_string($tag)) { 318 $this->env->children[$tag][] = $block; 319 } 320 } 321 } 322 323 if (!$hidden) { 324 $this->append(['block', $block], $s); 325 } 326 327 // this is done here so comments aren't bundled into he block that 328 // was just closed 329 $this->whitespace(); 330 return true; 331 } 332 333 // mixin 334 if ($this->mixinTags($tags) && 335 ($this->argumentDef($argv, $isVararg) || true) && 336 ($this->keyword($suffix) || true) && $this->end()) { 337 $tags = $this->fixTags($tags); 338 $this->append(['mixin', $tags, $argv, $suffix], $s); 339 return true; 340 } else { 341 $this->seek($s); 342 } 343 344 // spare ; 345 if ($this->literal(';')) return true; 346 347 return false; // got nothing, throw error 348 } 349 350 protected function isDirective($dirname, $directives) 351 { 352 // TODO: cache pattern in parser 353 $pattern = implode( 354 '|', 355 array_map([Util::class, 'pregQuote'], $directives) 356 ); 357 $pattern = '/^(-[a-z-]+-)?(' . $pattern . ')$/i'; 358 359 return preg_match($pattern, $dirname); 360 } 361 362 protected function fixTags($tags) 363 { 364 // move @ tags out of variable namespace 365 foreach ($tags as &$tag) { 366 if ($tag[0] == Constants::VPREFIX) 367 $tag[0] = Constants::MPREFIX; 368 } 369 return $tags; 370 } 371 372 // a list of expressions 373 protected function expressionList(&$exps) 374 { 375 $values = []; 376 377 while ($this->expression($exp)) { 378 $values[] = $exp; 379 } 380 381 if (count($values) == 0) return false; 382 383 $exps = Lessc::compressList($values, ' '); 384 return true; 385 } 386 387 /** 388 * Attempt to consume an expression. 389 * @link http://en.wikipedia.org/wiki/Operator-precedence_parser#Pseudo-code 390 */ 391 protected function expression(&$out) 392 { 393 if ($this->value($lhs)) { 394 $out = $this->expHelper($lhs, 0); 395 396 // look for / shorthand 397 if (!empty($this->env->supressedDivision)) { 398 unset($this->env->supressedDivision); 399 $s = $this->seek(); 400 if ($this->literal('/') && $this->value($rhs)) { 401 $out = ['list', '', [$out, ['keyword', '/'], $rhs]]; 402 } else { 403 $this->seek($s); 404 } 405 } 406 407 return true; 408 } 409 return false; 410 } 411 412 /** 413 * recursively parse infix equation with $lhs at precedence $minP 414 */ 415 protected function expHelper($lhs, $minP) 416 { 417 $this->inExp = true; 418 $ss = $this->seek(); 419 420 while (true) { 421 $whiteBefore = isset($this->buffer[$this->count - 1]) && 422 ctype_space($this->buffer[$this->count - 1]); 423 424 // If there is whitespace before the operator, then we require 425 // whitespace after the operator for it to be an expression 426 $needWhite = $whiteBefore && !$this->inParens; 427 428 if ( 429 $this->match(self::$operatorString . ($needWhite ? '\s' : ''), $m) && 430 self::$precedence[$m[1]] >= $minP 431 ) { 432 if ( 433 !$this->inParens && isset($this->env->currentProperty) && $m[1] == '/' && 434 empty($this->env->supressedDivision) 435 ) { 436 foreach (self::$supressDivisionProps as $pattern) { 437 if (preg_match($pattern, $this->env->currentProperty)) { 438 $this->env->supressedDivision = true; 439 break 2; 440 } 441 } 442 } 443 444 445 $whiteAfter = isset($this->buffer[$this->count - 1]) && 446 ctype_space($this->buffer[$this->count - 1]); 447 448 if (!$this->value($rhs)) break; 449 450 // peek for next operator to see what to do with rhs 451 if ( 452 $this->peek(self::$operatorString, $next) && 453 self::$precedence[$next[1]] > self::$precedence[$m[1]] 454 ) { 455 $rhs = $this->expHelper($rhs, self::$precedence[$next[1]]); 456 } 457 458 $lhs = ['expression', $m[1], $lhs, $rhs, $whiteBefore, $whiteAfter]; 459 $ss = $this->seek(); 460 461 continue; 462 } 463 464 break; 465 } 466 467 $this->seek($ss); 468 469 return $lhs; 470 } 471 472 // consume a list of values for a property 473 public function propertyValue(&$value, $keyName = null) 474 { 475 $values = []; 476 477 if ($keyName !== null) $this->env->currentProperty = $keyName; 478 479 $s = null; 480 while ($this->expressionList($v)) { 481 $values[] = $v; 482 $s = $this->seek(); 483 if (!$this->literal(',')) break; 484 } 485 486 if ($s) $this->seek($s); 487 488 if ($keyName !== null) unset($this->env->currentProperty); 489 490 if (count($values) == 0) return false; 491 492 $value = Lessc::compressList($values, ', '); 493 return true; 494 } 495 496 protected function parenValue(&$out) 497 { 498 $s = $this->seek(); 499 500 // speed shortcut 501 if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] != '(') { 502 return false; 503 } 504 505 $inParens = $this->inParens; 506 if ($this->literal('(') && 507 ($this->inParens = true) && $this->expression($exp) && 508 $this->literal(')')) { 509 $out = $exp; 510 $this->inParens = $inParens; 511 return true; 512 } else { 513 $this->inParens = $inParens; 514 $this->seek($s); 515 } 516 517 return false; 518 } 519 520 // a single value 521 protected function value(&$value) 522 { 523 $s = $this->seek(); 524 525 // speed shortcut 526 if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] == '-') { 527 // negation 528 if ($this->literal('-', false) && 529 (($this->variable($inner) && $inner = ['variable', $inner]) || 530 $this->unit($inner) || 531 $this->parenValue($inner))) { 532 $value = ['unary', '-', $inner]; 533 return true; 534 } else { 535 $this->seek($s); 536 } 537 } 538 539 if ($this->parenValue($value)) return true; 540 if ($this->unit($value)) return true; 541 if ($this->color($value)) return true; 542 if ($this->func($value)) return true; 543 if ($this->stringValue($value)) return true; 544 545 if ($this->keyword($word)) { 546 $value = ['keyword', $word]; 547 return true; 548 } 549 550 // try a variable 551 if ($this->variable($var)) { 552 $value = ['variable', $var]; 553 return true; 554 } 555 556 // unquote string (should this work on any type? 557 if ($this->literal('~') && $this->stringValue($str)) { 558 $value = ['escape', $str]; 559 return true; 560 } else { 561 $this->seek($s); 562 } 563 564 // css hack: \0 565 if ($this->literal('\\') && $this->match('([0-9]+)', $m)) { 566 $value = ['keyword', '\\' . $m[1]]; 567 return true; 568 } else { 569 $this->seek($s); 570 } 571 572 return false; 573 } 574 575 // an import statement 576 protected function import(&$out) 577 { 578 if (!$this->literal('@import')) return false; 579 580 // @import "something.css" media; 581 // @import url("something.css") media; 582 // @import url(something.css) media; 583 584 if ($this->propertyValue($value)) { 585 $out = ['import', $value]; 586 return true; 587 } 588 return false; 589 } 590 591 protected function mediaQueryList(&$out) 592 { 593 if ($this->genericList($list, 'mediaQuery', ',', false)) { 594 $out = $list[2]; 595 return true; 596 } 597 return false; 598 } 599 600 protected function mediaQuery(&$out) 601 { 602 $s = $this->seek(); 603 604 $expressions = null; 605 $parts = []; 606 607 if ( 608 ( 609 $this->literal('only') && ($only = true) || 610 $this->literal('not') && ($not = true) || 611 true 612 ) && 613 $this->keyword($mediaType) 614 ) { 615 $prop = ['mediaType']; 616 if (isset($only)) $prop[] = 'only'; 617 if (isset($not)) $prop[] = 'not'; 618 $prop[] = $mediaType; 619 $parts[] = $prop; 620 } else { 621 $this->seek($s); 622 } 623 624 625 if (!empty($mediaType) && !$this->literal('and')) { 626 // ~ 627 } else { 628 $this->genericList($expressions, 'mediaExpression', 'and', false); 629 if (is_array($expressions)) $parts = array_merge($parts, $expressions[2]); 630 } 631 632 if (count($parts) == 0) { 633 $this->seek($s); 634 return false; 635 } 636 637 $out = $parts; 638 return true; 639 } 640 641 protected function mediaExpression(&$out) 642 { 643 $s = $this->seek(); 644 $value = null; 645 if ($this->literal('(') && 646 $this->keyword($feature) && 647 ($this->literal(':') && $this->expression($value) || true) && 648 $this->literal(')')) { 649 $out = ['mediaExp', $feature]; 650 if ($value) $out[] = $value; 651 return true; 652 } elseif ($this->variable($variable)) { 653 $out = ['variable', $variable]; 654 return true; 655 } 656 657 $this->seek($s); 658 return false; 659 } 660 661 // an unbounded string stopped by $end 662 protected function openString($end, &$out, $nestingOpen = null, $rejectStrs = null) 663 { 664 $oldWhite = $this->eatWhiteDefault; 665 $this->eatWhiteDefault = false; 666 667 $stop = ["'", '"', '@{', $end]; 668 $stop = array_map([Util::class, 'pregQuote'], $stop); 669 // $stop[] = self::$commentMulti; 670 671 if (!is_null($rejectStrs)) { 672 $stop = array_merge($stop, $rejectStrs); 673 } 674 675 $patt = '(.*?)(' . implode('|', $stop) . ')'; 676 677 $nestingLevel = 0; 678 679 $content = []; 680 while ($this->match($patt, $m, false)) { 681 if (!empty($m[1])) { 682 $content[] = $m[1]; 683 if ($nestingOpen) { 684 $nestingLevel += substr_count($m[1], $nestingOpen); 685 } 686 } 687 688 $tok = $m[2]; 689 690 $this->count -= strlen($tok); 691 if ($tok == $end) { 692 if ($nestingLevel == 0) { 693 break; 694 } else { 695 $nestingLevel--; 696 } 697 } 698 699 if (($tok == "'" || $tok == '"') && $this->stringValue($str)) { 700 $content[] = $str; 701 continue; 702 } 703 704 if ($tok == '@{' && $this->interpolation($inter)) { 705 $content[] = $inter; 706 continue; 707 } 708 709 if (!empty($rejectStrs) && in_array($tok, $rejectStrs)) { 710 break; 711 } 712 713 $content[] = $tok; 714 $this->count += strlen($tok); 715 } 716 717 $this->eatWhiteDefault = $oldWhite; 718 719 if (count($content) == 0) return false; 720 721 // trim the end 722 if (is_string(end($content))) { 723 $content[count($content) - 1] = rtrim(end($content)); 724 } 725 726 $out = ['string', '', $content]; 727 return true; 728 } 729 730 protected function stringValue(&$out) 731 { 732 $s = $this->seek(); 733 if ($this->literal('"', false)) { 734 $delim = '"'; 735 } elseif ($this->literal("'", false)) { 736 $delim = "'"; 737 } else { 738 return false; 739 } 740 741 $content = []; 742 743 // look for either ending delim , escape, or string interpolation 744 $patt = '([^\n]*?)(@\{|\\\\|' . Util::pregQuote($delim) . ')'; 745 746 $oldWhite = $this->eatWhiteDefault; 747 $this->eatWhiteDefault = false; 748 749 while ($this->match($patt, $m, false)) { 750 $content[] = $m[1]; 751 if ($m[2] == '@{') { 752 $this->count -= strlen($m[2]); 753 if ($this->interpolation($inter)) { 754 $content[] = $inter; 755 } else { 756 $this->count += strlen($m[2]); 757 $content[] = '@{'; // ignore it 758 } 759 } elseif ($m[2] == '\\') { 760 $content[] = $m[2]; 761 if ($this->literal($delim, false)) { 762 $content[] = $delim; 763 } 764 } else { 765 $this->count -= strlen($delim); 766 break; // delim 767 } 768 } 769 770 $this->eatWhiteDefault = $oldWhite; 771 772 if ($this->literal($delim)) { 773 $out = ['string', $delim, $content]; 774 return true; 775 } 776 777 $this->seek($s); 778 return false; 779 } 780 781 protected function interpolation(&$out) 782 { 783 $oldWhite = $this->eatWhiteDefault; 784 $this->eatWhiteDefault = true; 785 786 $s = $this->seek(); 787 if ($this->literal('@{') && 788 $this->openString('}', $interp, null, ["'", '"', ';']) && 789 $this->literal('}', false)) { 790 $out = ['interpolate', $interp]; 791 $this->eatWhiteDefault = $oldWhite; 792 if ($this->eatWhiteDefault) $this->whitespace(); 793 return true; 794 } 795 796 $this->eatWhiteDefault = $oldWhite; 797 $this->seek($s); 798 return false; 799 } 800 801 protected function unit(&$unit) 802 { 803 // speed shortcut 804 if (isset($this->buffer[$this->count])) { 805 $char = $this->buffer[$this->count]; 806 if (!ctype_digit($char) && $char != '.') return false; 807 } 808 809 if ($this->match('([0-9]+(?:\.[0-9]*)?|\.[0-9]+)([%a-zA-Z]+)?', $m)) { 810 $unit = ['number', $m[1], empty($m[2]) ? '' : $m[2]]; 811 return true; 812 } 813 return false; 814 } 815 816 // a # color 817 protected function color(&$out) 818 { 819 if ($this->match('(#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3}))', $m)) { 820 if (strlen($m[1]) > 7) { 821 $out = ['string', '', [$m[1]]]; 822 } else { 823 $out = ['raw_color', $m[1]]; 824 } 825 return true; 826 } 827 828 return false; 829 } 830 831 /** 832 * consume an argument definition list surrounded by () 833 * each argument is a variable name with optional value 834 * or at the end a ... or a variable named followed by ... 835 * arguments are separated by , unless a ; is in the list, then ; is the 836 * delimiter. 837 * 838 * @throws Exception 839 */ 840 protected function argumentDef(&$args, &$isVararg) 841 { 842 $s = $this->seek(); 843 if (!$this->literal('(')) return false; 844 845 $values = []; 846 $delim = ','; 847 $method = 'expressionList'; 848 849 $value = null; 850 $rhs = null; 851 852 $isVararg = false; 853 while (true) { 854 if ($this->literal('...')) { 855 $isVararg = true; 856 break; 857 } 858 859 if ($this->$method($value)) { 860 if ($value[0] == 'variable') { 861 $arg = ['arg', $value[1]]; 862 $ss = $this->seek(); 863 864 if ($this->assign() && $this->$method($rhs)) { 865 $arg[] = $rhs; 866 } else { 867 $this->seek($ss); 868 if ($this->literal('...')) { 869 $arg[0] = 'rest'; 870 $isVararg = true; 871 } 872 } 873 874 $values[] = $arg; 875 if ($isVararg) break; 876 continue; 877 } else { 878 $values[] = ['lit', $value]; 879 } 880 } 881 882 883 if (!$this->literal($delim)) { 884 if ($delim == ',' && $this->literal(';')) { 885 // found new delim, convert existing args 886 $delim = ';'; 887 $method = 'propertyValue'; 888 889 // transform arg list 890 if (isset($values[1])) { // 2 items 891 $newList = []; 892 foreach ($values as $i => $arg) { 893 switch ($arg[0]) { 894 case 'arg': 895 if ($i) { 896 $this->throwError('Cannot mix ; and , as delimiter types'); 897 } 898 $newList[] = $arg[2]; 899 break; 900 case 'lit': 901 $newList[] = $arg[1]; 902 break; 903 case 'rest': 904 $this->throwError('Unexpected rest before semicolon'); 905 } 906 } 907 908 $newList = ['list', ', ', $newList]; 909 910 switch ($values[0][0]) { 911 case 'arg': 912 $newArg = ['arg', $values[0][1], $newList]; 913 break; 914 case 'lit': 915 $newArg = ['lit', $newList]; 916 break; 917 } 918 } elseif ($values) { // 1 item 919 $newArg = $values[0]; 920 } 921 922 if ($newArg) { 923 $values = [$newArg]; 924 } 925 } else { 926 break; 927 } 928 } 929 } 930 931 if (!$this->literal(')')) { 932 $this->seek($s); 933 return false; 934 } 935 936 $args = $values; 937 938 return true; 939 } 940 941 // consume a list of tags 942 // this accepts a hanging delimiter 943 protected function tags(&$tags, $simple = false, $delim = ',') 944 { 945 $tags = []; 946 while ($this->tag($tt, $simple)) { 947 $tags[] = $tt; 948 if (!$this->literal($delim)) break; 949 } 950 if (count($tags) == 0) return false; 951 952 return true; 953 } 954 955 // list of tags of specifying mixin path 956 // optionally separated by > (lazy, accepts extra >) 957 protected function mixinTags(&$tags) 958 { 959 $tags = []; 960 while ($this->tag($tt, true)) { 961 $tags[] = $tt; 962 $this->literal('>'); 963 } 964 965 if (count($tags) == 0) return false; 966 967 return true; 968 } 969 970 // a bracketed value (contained within in a tag definition) 971 protected function tagBracket(&$parts, &$hasExpression) 972 { 973 // speed shortcut 974 if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] != '[') { 975 return false; 976 } 977 978 $s = $this->seek(); 979 980 $hasInterpolation = false; 981 982 if ($this->literal('[', false)) { 983 $attrParts = ['[']; 984 // keyword, string, operator 985 while (true) { 986 if ($this->literal(']', false)) { 987 $this->count--; 988 break; // get out early 989 } 990 991 if ($this->match('\s+', $m)) { 992 $attrParts[] = ' '; 993 continue; 994 } 995 if ($this->stringValue($str)) { 996 // escape parent selector, (yuck) 997 foreach ($str[2] as &$chunk) { 998 if (is_string($chunk)) { 999 $chunk = str_replace(Constants::PARENT_SELECTOR, "$&$", $chunk); 1000 } 1001 } 1002 1003 $attrParts[] = $str; 1004 $hasInterpolation = true; 1005 continue; 1006 } 1007 1008 if ($this->keyword($word)) { 1009 $attrParts[] = $word; 1010 continue; 1011 } 1012 1013 if ($this->interpolation($inter)) { 1014 $attrParts[] = $inter; 1015 $hasInterpolation = true; 1016 continue; 1017 } 1018 1019 // operator, handles attr namespace too 1020 if ($this->match('[|-~\$\*\^=]+', $m)) { 1021 $attrParts[] = $m[0]; 1022 continue; 1023 } 1024 1025 break; 1026 } 1027 1028 if ($this->literal(']', false)) { 1029 $attrParts[] = ']'; 1030 foreach ($attrParts as $part) { 1031 $parts[] = $part; 1032 } 1033 $hasExpression = $hasExpression || $hasInterpolation; 1034 return true; 1035 } 1036 $this->seek($s); 1037 } 1038 1039 $this->seek($s); 1040 return false; 1041 } 1042 1043 // a space separated list of selectors 1044 protected function tag(&$tag, $simple = false) 1045 { 1046 if ($simple) 1047 $chars = '^@,:;{}\][>\(\) "\''; 1048 else $chars = '^@,;{}["\''; 1049 1050 $s = $this->seek(); 1051 1052 $hasExpression = false; 1053 $parts = []; 1054 while ($this->tagBracket($parts, $hasExpression)) { 1055 // no-op 1056 } 1057 1058 $oldWhite = $this->eatWhiteDefault; 1059 $this->eatWhiteDefault = false; 1060 1061 while (true) { 1062 if ($this->match('([' . $chars . '0-9][' . $chars . ']*)', $m)) { 1063 $parts[] = $m[1]; 1064 if ($simple) break; 1065 1066 while ($this->tagBracket($parts, $hasExpression)) { 1067 // no-op 1068 } 1069 continue; 1070 } 1071 1072 if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] == '@') { 1073 if ($this->interpolation($interp)) { 1074 $hasExpression = true; 1075 $interp[2] = true; // don't unescape 1076 $parts[] = $interp; 1077 continue; 1078 } 1079 1080 if ($this->literal('@')) { 1081 $parts[] = '@'; 1082 continue; 1083 } 1084 } 1085 1086 if ($this->unit($unit)) { // for keyframes 1087 $parts[] = $unit[1]; 1088 $parts[] = $unit[2]; 1089 continue; 1090 } 1091 1092 break; 1093 } 1094 1095 $this->eatWhiteDefault = $oldWhite; 1096 if (!$parts) { 1097 $this->seek($s); 1098 return false; 1099 } 1100 1101 if ($hasExpression) { 1102 $tag = ['exp', ['string', '', $parts]]; 1103 } else { 1104 $tag = trim(implode($parts)); 1105 } 1106 1107 $this->whitespace(); 1108 return true; 1109 } 1110 1111 // a css function 1112 protected function func(&$func) 1113 { 1114 $s = $this->seek(); 1115 1116 if ($this->match('(%|[\w\-_][\w\-_:\.]+|[\w_])', $m) && $this->literal('(')) { 1117 $fname = $m[1]; 1118 1119 $sPreArgs = $this->seek(); 1120 1121 $args = []; 1122 while (true) { 1123 $ss = $this->seek(); 1124 // this ugly nonsense is for ie filter properties 1125 if ($this->keyword($name) && $this->literal('=') && $this->expressionList($value)) { 1126 $args[] = ['string', '', [$name, '=', $value]]; 1127 } else { 1128 $this->seek($ss); 1129 if ($this->expressionList($value)) { 1130 $args[] = $value; 1131 } 1132 } 1133 1134 if (!$this->literal(',')) break; 1135 } 1136 $args = ['list', ',', $args]; 1137 1138 if ($this->literal(')')) { 1139 $func = ['function', $fname, $args]; 1140 return true; 1141 } elseif ($fname == 'url') { 1142 // couldn't parse and in url? treat as string 1143 $this->seek($sPreArgs); 1144 if ($this->openString(')', $string) && $this->literal(')')) { 1145 $func = ['function', $fname, $string]; 1146 return true; 1147 } 1148 } 1149 } 1150 1151 $this->seek($s); 1152 return false; 1153 } 1154 1155 // consume a less variable 1156 protected function variable(&$name) 1157 { 1158 $s = $this->seek(); 1159 if ($this->literal(Constants::VPREFIX, false) && 1160 ($this->variable($sub) || $this->keyword($name))) { 1161 if (!empty($sub)) { 1162 $name = ['variable', $sub]; 1163 } else { 1164 $name = Constants::VPREFIX . $name; 1165 } 1166 return true; 1167 } 1168 1169 $name = null; 1170 $this->seek($s); 1171 return false; 1172 } 1173 1174 /** 1175 * Consume an assignment operator 1176 * Can optionally take a name that will be set to the current property name 1177 */ 1178 protected function assign($name = null) 1179 { 1180 if ($name) $this->currentProperty = $name; 1181 return $this->literal(':') || $this->literal('='); 1182 } 1183 1184 // consume a keyword 1185 protected function keyword(&$word) 1186 { 1187 if ($this->match('([\w_\-\*!"][\w\-_"]*)', $m)) { 1188 $word = $m[1]; 1189 return true; 1190 } 1191 return false; 1192 } 1193 1194 // consume an end of statement delimiter 1195 protected function end() 1196 { 1197 if ($this->literal(';', false)) { 1198 return true; 1199 } elseif ($this->count == strlen($this->buffer) || $this->buffer[$this->count] == '}') { 1200 // if there is end of file or a closing block next then we don't need a ; 1201 return true; 1202 } 1203 return false; 1204 } 1205 1206 protected function guards(&$guards) 1207 { 1208 $s = $this->seek(); 1209 1210 if (!$this->literal('when')) { 1211 $this->seek($s); 1212 return false; 1213 } 1214 1215 $guards = []; 1216 1217 while ($this->guardGroup($g)) { 1218 $guards[] = $g; 1219 if (!$this->literal(',')) break; 1220 } 1221 1222 if (count($guards) == 0) { 1223 $guards = null; 1224 $this->seek($s); 1225 return false; 1226 } 1227 1228 return true; 1229 } 1230 1231 // a bunch of guards that are and'd together 1232 // TODO rename to guardGroup 1233 protected function guardGroup(&$guardGroup) 1234 { 1235 $s = $this->seek(); 1236 $guardGroup = []; 1237 while ($this->guard($guard)) { 1238 $guardGroup[] = $guard; 1239 if (!$this->literal('and')) break; 1240 } 1241 1242 if (count($guardGroup) == 0) { 1243 $guardGroup = null; 1244 $this->seek($s); 1245 return false; 1246 } 1247 1248 return true; 1249 } 1250 1251 protected function guard(&$guard) 1252 { 1253 $s = $this->seek(); 1254 $negate = $this->literal('not'); 1255 1256 if ($this->literal('(') && $this->expression($exp) && $this->literal(')')) { 1257 $guard = $exp; 1258 if ($negate) $guard = ['negate', $guard]; 1259 return true; 1260 } 1261 1262 $this->seek($s); 1263 return false; 1264 } 1265 1266 /* raw parsing functions */ 1267 1268 protected function literal($what, $eatWhitespace = null) 1269 { 1270 if ($eatWhitespace === null) $eatWhitespace = $this->eatWhiteDefault; 1271 1272 // shortcut on single letter 1273 if (!isset($what[1]) && isset($this->buffer[$this->count])) { 1274 if ($this->buffer[$this->count] == $what) { 1275 if (!$eatWhitespace) { 1276 $this->count++; 1277 return true; 1278 } 1279 // goes below... 1280 } else { 1281 return false; 1282 } 1283 } 1284 1285 if (!isset(self::$literalCache[$what])) { 1286 self::$literalCache[$what] = Util::pregQuote($what); 1287 } 1288 1289 return $this->match(self::$literalCache[$what], $m, $eatWhitespace); 1290 } 1291 1292 protected function genericList(&$out, $parseItem, $delim = '', $flatten = true) 1293 { 1294 $s = $this->seek(); 1295 $items = []; 1296 $value = null; 1297 while ($this->$parseItem($value)) { 1298 $items[] = $value; 1299 if ($delim) { 1300 if (!$this->literal($delim)) break; 1301 } 1302 } 1303 1304 if (count($items) == 0) { 1305 $this->seek($s); 1306 return false; 1307 } 1308 1309 if ($flatten && count($items) == 1) { 1310 $out = $items[0]; 1311 } else { 1312 $out = ['list', $delim, $items]; 1313 } 1314 1315 return true; 1316 } 1317 1318 1319 // advance counter to next occurrence of $what 1320 // $until - don't include $what in advance 1321 // $allowNewline, if string, will be used as valid char set 1322 protected function to($what, &$out, $until = false, $allowNewline = false) 1323 { 1324 if (is_string($allowNewline)) { 1325 $validChars = $allowNewline; 1326 } else { 1327 $validChars = $allowNewline ? '.' : "[^\n]"; 1328 } 1329 if (!$this->match('(' . $validChars . '*?)' . Lessc::preg_quote($what), $m, !$until)) return false; 1330 if ($until) $this->count -= strlen($what); // give back $what 1331 $out = $m[1]; 1332 return true; 1333 } 1334 1335 // try to match something on head of buffer 1336 protected function match($regex, &$out, $eatWhitespace = null) 1337 { 1338 if ($eatWhitespace === null) $eatWhitespace = $this->eatWhiteDefault; 1339 1340 $r = '/' . $regex . ($eatWhitespace && !$this->writeComments ? '\s*' : '') . '/Ais'; 1341 if (preg_match($r, $this->buffer, $out, 0, $this->count)) { 1342 $this->count += strlen($out[0]); 1343 if ($eatWhitespace && $this->writeComments) $this->whitespace(); 1344 return true; 1345 } 1346 return false; 1347 } 1348 1349 // match some whitespace 1350 protected function whitespace() 1351 { 1352 if ($this->writeComments) { 1353 $gotWhite = false; 1354 while (preg_match(self::$whitePattern, $this->buffer, $m, 0, $this->count)) { 1355 if (isset($m[1]) && empty($this->seenComments[$this->count])) { 1356 $this->append(['comment', $m[1]]); 1357 $this->seenComments[$this->count] = true; 1358 } 1359 $this->count += strlen($m[0]); 1360 $gotWhite = true; 1361 } 1362 return $gotWhite; 1363 } else { 1364 $this->match('', $m); 1365 return strlen($m[0]) > 0; 1366 } 1367 } 1368 1369 // match something without consuming it 1370 protected function peek($regex, &$out = null, $from = null) 1371 { 1372 if (is_null($from)) $from = $this->count; 1373 $r = '/' . $regex . '/Ais'; 1374 return preg_match($r, $this->buffer, $out, 0, $from); 1375 } 1376 1377 // seek to a spot in the buffer or return where we are on no argument 1378 protected function seek($where = null) 1379 { 1380 if ($where === null) return $this->count; 1381 else $this->count = $where; 1382 return true; 1383 } 1384 1385 /* misc functions */ 1386 1387 /** 1388 * Throw a parser exception 1389 * 1390 * This function tries to use the current parsing context to provide 1391 * additional info on where/why the error occurred. 1392 * 1393 * @param string $msg The error message to throw 1394 * @param int|null $count A line number counter to use instead of the current count 1395 * @param \Throwable|null $previous A previous exception to chain 1396 * @throws ParserException 1397 */ 1398 public function throwError(string $msg = 'parse error', ?int $count = null, \Throwable $previous = null) 1399 { 1400 $count = is_null($count) ? $this->count : $count; 1401 1402 $line = $this->line + substr_count(substr($this->buffer, 0, $count), "\n"); 1403 1404 if ($this->peek("(.*?)(\n|$)", $m, $count)) { 1405 $culprit = $m[1]; 1406 } 1407 1408 throw new ParserException( 1409 $msg, 1410 $culprit, 1411 $this->sourceName, 1412 $line, 1413 $previous 1414 ); 1415 } 1416 1417 protected function pushBlock($selectors = null, $type = null) 1418 { 1419 $b = new stdClass(); 1420 $b->parent = $this->env; 1421 1422 $b->type = $type; 1423 $b->id = self::$nextBlockId++; 1424 1425 $b->isVararg = false; // TODO: kill me from here 1426 $b->tags = $selectors; 1427 1428 $b->props = []; 1429 $b->children = []; 1430 1431 // add a reference to the parser so 1432 // we can access the parser to throw errors 1433 // or retrieve the sourceName of this block. 1434 $b->parser = $this; 1435 1436 // so we know the position of this block 1437 $b->count = $this->count; 1438 1439 $this->env = $b; 1440 return $b; 1441 } 1442 1443 // push a block that doesn't multiply tags 1444 protected function pushSpecialBlock($type) 1445 { 1446 return $this->pushBlock(null, $type); 1447 } 1448 1449 // append a property to the current block 1450 protected function append($prop, $pos = null) 1451 { 1452 if ($pos !== null) $prop[-1] = $pos; 1453 $this->env->props[] = $prop; 1454 } 1455 1456 // pop something off the stack 1457 protected function pop() 1458 { 1459 $old = $this->env; 1460 $this->env = $this->env->parent; 1461 return $old; 1462 } 1463 1464 // remove comments from $text 1465 // todo: make it work for all functions, not just url 1466 protected function removeComments($text) 1467 { 1468 $look = ['url(', '//', '/*', '"', "'"]; 1469 1470 $out = ''; 1471 $min = null; 1472 while (true) { 1473 // find the next item 1474 foreach ($look as $token) { 1475 $pos = strpos($text, $token); 1476 if ($pos !== false) { 1477 if (!isset($min) || $pos < $min[1]) $min = [$token, $pos]; 1478 } 1479 } 1480 1481 if (is_null($min)) break; 1482 1483 $count = $min[1]; 1484 $skip = 0; 1485 $newlines = 0; 1486 switch ($min[0]) { 1487 case 'url(': 1488 if (preg_match('/url\(.*?\)/', $text, $m, 0, $count)) 1489 $count += strlen($m[0]) - strlen($min[0]); 1490 break; 1491 case '"': 1492 case "'": 1493 if (preg_match('/' . $min[0] . '.*?(?<!\\\\)' . $min[0] . '/', $text, $m, 0, $count)) 1494 $count += strlen($m[0]) - 1; 1495 break; 1496 case '//': 1497 $skip = strpos($text, "\n", $count); 1498 if ($skip === false) $skip = strlen($text) - $count; 1499 else $skip -= $count; 1500 break; 1501 case '/*': 1502 if (preg_match('/\/\*.*?\*\//s', $text, $m, 0, $count)) { 1503 $skip = strlen($m[0]); 1504 $newlines = substr_count($m[0], "\n"); 1505 } 1506 break; 1507 } 1508 1509 if ($skip == 0) $count += strlen($min[0]); 1510 1511 $out .= substr($text, 0, $count) . str_repeat("\n", $newlines); 1512 $text = substr($text, $count + $skip); 1513 1514 $min = null; 1515 } 1516 1517 return $out . $text; 1518 } 1519} 1520