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