Differences Between: [Versions 310 and 400] [Versions 311 and 400] [Versions 39 and 400] [Versions 400 and 401] [Versions 400 and 402] [Versions 400 and 403]
1 <?php 2 3 /** 4 * SCSSPHP 5 * 6 * @copyright 2012-2020 Leaf Corcoran 7 * 8 * @license http://opensource.org/licenses/MIT MIT 9 * 10 * @link http://scssphp.github.io/scssphp 11 */ 12 13 namespace ScssPhp\ScssPhp; 14 15 use ScssPhp\ScssPhp\Block\AtRootBlock; 16 use ScssPhp\ScssPhp\Block\CallableBlock; 17 use ScssPhp\ScssPhp\Block\ContentBlock; 18 use ScssPhp\ScssPhp\Block\DirectiveBlock; 19 use ScssPhp\ScssPhp\Block\EachBlock; 20 use ScssPhp\ScssPhp\Block\ElseBlock; 21 use ScssPhp\ScssPhp\Block\ElseifBlock; 22 use ScssPhp\ScssPhp\Block\ForBlock; 23 use ScssPhp\ScssPhp\Block\IfBlock; 24 use ScssPhp\ScssPhp\Block\MediaBlock; 25 use ScssPhp\ScssPhp\Block\NestedPropertyBlock; 26 use ScssPhp\ScssPhp\Block\WhileBlock; 27 use ScssPhp\ScssPhp\Exception\ParserException; 28 use ScssPhp\ScssPhp\Logger\LoggerInterface; 29 use ScssPhp\ScssPhp\Logger\QuietLogger; 30 31 /** 32 * Parser 33 * 34 * @author Leaf Corcoran <leafot@gmail.com> 35 * 36 * @internal 37 */ 38 class Parser 39 { 40 const SOURCE_INDEX = -1; 41 const SOURCE_LINE = -2; 42 const SOURCE_COLUMN = -3; 43 44 /** 45 * @var array<string, int> 46 */ 47 protected static $precedence = [ 48 '=' => 0, 49 'or' => 1, 50 'and' => 2, 51 '==' => 3, 52 '!=' => 3, 53 '<=' => 4, 54 '>=' => 4, 55 '<' => 4, 56 '>' => 4, 57 '+' => 5, 58 '-' => 5, 59 '*' => 6, 60 '/' => 6, 61 '%' => 6, 62 ]; 63 64 /** 65 * @var string 66 */ 67 protected static $commentPattern; 68 /** 69 * @var string 70 */ 71 protected static $operatorPattern; 72 /** 73 * @var string 74 */ 75 protected static $whitePattern; 76 77 /** 78 * @var Cache|null 79 */ 80 protected $cache; 81 82 private $sourceName; 83 private $sourceIndex; 84 /** 85 * @var array<int, int> 86 */ 87 private $sourcePositions; 88 /** 89 * @var array|null 90 */ 91 private $charset; 92 /** 93 * The current offset in the buffer 94 * 95 * @var int 96 */ 97 private $count; 98 /** 99 * @var Block|null 100 */ 101 private $env; 102 /** 103 * @var bool 104 */ 105 private $inParens; 106 /** 107 * @var bool 108 */ 109 private $eatWhiteDefault; 110 /** 111 * @var bool 112 */ 113 private $discardComments; 114 private $allowVars; 115 /** 116 * @var string 117 */ 118 private $buffer; 119 private $utf8; 120 /** 121 * @var string|null 122 */ 123 private $encoding; 124 private $patternModifiers; 125 private $commentsSeen; 126 127 private $cssOnly; 128 129 /** 130 * @var LoggerInterface 131 */ 132 private $logger; 133 134 /** 135 * Constructor 136 * 137 * @api 138 * 139 * @param string|null $sourceName 140 * @param int $sourceIndex 141 * @param string|null $encoding 142 * @param Cache|null $cache 143 * @param bool $cssOnly 144 * @param LoggerInterface|null $logger 145 */ 146 public function __construct($sourceName, $sourceIndex = 0, $encoding = 'utf-8', Cache $cache = null, $cssOnly = false, LoggerInterface $logger = null) 147 { 148 $this->sourceName = $sourceName ?: '(stdin)'; 149 $this->sourceIndex = $sourceIndex; 150 $this->charset = null; 151 $this->utf8 = ! $encoding || strtolower($encoding) === 'utf-8'; 152 $this->patternModifiers = $this->utf8 ? 'Aisu' : 'Ais'; 153 $this->commentsSeen = []; 154 $this->commentsSeen = []; 155 $this->allowVars = true; 156 $this->cssOnly = $cssOnly; 157 $this->logger = $logger ?: new QuietLogger(); 158 159 if (empty(static::$operatorPattern)) { 160 static::$operatorPattern = '([*\/%+-]|[!=]\=|\>\=?|\<\=?|and|or)'; 161 162 $commentSingle = '\/\/'; 163 $commentMultiLeft = '\/\*'; 164 $commentMultiRight = '\*\/'; 165 166 static::$commentPattern = $commentMultiLeft . '.*?' . $commentMultiRight; 167 static::$whitePattern = $this->utf8 168 ? '/' . $commentSingle . '[^\n]*\s*|(' . static::$commentPattern . ')\s*|\s+/AisuS' 169 : '/' . $commentSingle . '[^\n]*\s*|(' . static::$commentPattern . ')\s*|\s+/AisS'; 170 } 171 172 $this->cache = $cache; 173 } 174 175 /** 176 * Get source file name 177 * 178 * @api 179 * 180 * @return string 181 */ 182 public function getSourceName() 183 { 184 return $this->sourceName; 185 } 186 187 /** 188 * Throw parser error 189 * 190 * @api 191 * 192 * @param string $msg 193 * 194 * @phpstan-return never-return 195 * 196 * @throws ParserException 197 * 198 * @deprecated use "parseError" and throw the exception in the caller instead. 199 */ 200 public function throwParseError($msg = 'parse error') 201 { 202 @trigger_error( 203 'The method "throwParseError" is deprecated. Use "parseError" and throw the exception in the caller instead', 204 E_USER_DEPRECATED 205 ); 206 207 throw $this->parseError($msg); 208 } 209 210 /** 211 * Creates a parser error 212 * 213 * @api 214 * 215 * @param string $msg 216 * 217 * @return ParserException 218 */ 219 public function parseError($msg = 'parse error') 220 { 221 list($line, $column) = $this->getSourcePosition($this->count); 222 223 $loc = empty($this->sourceName) 224 ? "line: $line, column: $column" 225 : "$this->sourceName on line $line, at column $column"; 226 227 if ($this->peek('(.*?)(\n|$)', $m, $this->count)) { 228 $this->restoreEncoding(); 229 230 $e = new ParserException("$msg: failed at `$m[1]` $loc"); 231 $e->setSourcePosition([$this->sourceName, $line, $column]); 232 233 return $e; 234 } 235 236 $this->restoreEncoding(); 237 238 $e = new ParserException("$msg: $loc"); 239 $e->setSourcePosition([$this->sourceName, $line, $column]); 240 241 return $e; 242 } 243 244 /** 245 * Parser buffer 246 * 247 * @api 248 * 249 * @param string $buffer 250 * 251 * @return Block 252 */ 253 public function parse($buffer) 254 { 255 if ($this->cache) { 256 $cacheKey = $this->sourceName . ':' . md5($buffer); 257 $parseOptions = [ 258 'charset' => $this->charset, 259 'utf8' => $this->utf8, 260 ]; 261 $v = $this->cache->getCache('parse', $cacheKey, $parseOptions); 262 263 if (! \is_null($v)) { 264 return $v; 265 } 266 } 267 268 // strip BOM (byte order marker) 269 if (substr($buffer, 0, 3) === "\xef\xbb\xbf") { 270 $buffer = substr($buffer, 3); 271 } 272 273 $this->buffer = rtrim($buffer, "\x00..\x1f"); 274 $this->count = 0; 275 $this->env = null; 276 $this->inParens = false; 277 $this->eatWhiteDefault = true; 278 279 $this->saveEncoding(); 280 $this->extractLineNumbers($buffer); 281 282 $this->pushBlock(null); // root block 283 $this->whitespace(); 284 $this->pushBlock(null); 285 $this->popBlock(); 286 287 while ($this->parseChunk()) { 288 ; 289 } 290 291 if ($this->count !== \strlen($this->buffer)) { 292 throw $this->parseError(); 293 } 294 295 if (! empty($this->env->parent)) { 296 throw $this->parseError('unclosed block'); 297 } 298 299 if ($this->charset) { 300 array_unshift($this->env->children, $this->charset); 301 } 302 303 $this->restoreEncoding(); 304 305 if ($this->cache) { 306 $this->cache->setCache('parse', $cacheKey, $this->env, $parseOptions); 307 } 308 309 return $this->env; 310 } 311 312 /** 313 * Parse a value or value list 314 * 315 * @api 316 * 317 * @param string $buffer 318 * @param string|array $out 319 * 320 * @return bool 321 */ 322 public function parseValue($buffer, &$out) 323 { 324 $this->count = 0; 325 $this->env = null; 326 $this->inParens = false; 327 $this->eatWhiteDefault = true; 328 $this->buffer = (string) $buffer; 329 330 $this->saveEncoding(); 331 $this->extractLineNumbers($this->buffer); 332 333 $list = $this->valueList($out); 334 335 $this->restoreEncoding(); 336 337 return $list; 338 } 339 340 /** 341 * Parse a selector or selector list 342 * 343 * @api 344 * 345 * @param string $buffer 346 * @param string|array $out 347 * @param bool $shouldValidate 348 * 349 * @return bool 350 */ 351 public function parseSelector($buffer, &$out, $shouldValidate = true) 352 { 353 $this->count = 0; 354 $this->env = null; 355 $this->inParens = false; 356 $this->eatWhiteDefault = true; 357 $this->buffer = (string) $buffer; 358 359 $this->saveEncoding(); 360 $this->extractLineNumbers($this->buffer); 361 362 // discard space/comments at the start 363 $this->discardComments = true; 364 $this->whitespace(); 365 $this->discardComments = false; 366 367 $selector = $this->selectors($out); 368 369 $this->restoreEncoding(); 370 371 if ($shouldValidate && $this->count !== strlen($buffer)) { 372 throw $this->parseError("`" . substr($buffer, $this->count) . "` is not a valid Selector in `$buffer`"); 373 } 374 375 return $selector; 376 } 377 378 /** 379 * Parse a media Query 380 * 381 * @api 382 * 383 * @param string $buffer 384 * @param string|array $out 385 * 386 * @return bool 387 */ 388 public function parseMediaQueryList($buffer, &$out) 389 { 390 $this->count = 0; 391 $this->env = null; 392 $this->inParens = false; 393 $this->eatWhiteDefault = true; 394 $this->buffer = (string) $buffer; 395 396 $this->saveEncoding(); 397 $this->extractLineNumbers($this->buffer); 398 399 $isMediaQuery = $this->mediaQueryList($out); 400 401 $this->restoreEncoding(); 402 403 return $isMediaQuery; 404 } 405 406 /** 407 * Parse a single chunk off the head of the buffer and append it to the 408 * current parse environment. 409 * 410 * Returns false when the buffer is empty, or when there is an error. 411 * 412 * This function is called repeatedly until the entire document is 413 * parsed. 414 * 415 * This parser is most similar to a recursive descent parser. Single 416 * functions represent discrete grammatical rules for the language, and 417 * they are able to capture the text that represents those rules. 418 * 419 * Consider the function Compiler::keyword(). (All parse functions are 420 * structured the same.) 421 * 422 * The function takes a single reference argument. When calling the 423 * function it will attempt to match a keyword on the head of the buffer. 424 * If it is successful, it will place the keyword in the referenced 425 * argument, advance the position in the buffer, and return true. If it 426 * fails then it won't advance the buffer and it will return false. 427 * 428 * All of these parse functions are powered by Compiler::match(), which behaves 429 * the same way, but takes a literal regular expression. Sometimes it is 430 * more convenient to use match instead of creating a new function. 431 * 432 * Because of the format of the functions, to parse an entire string of 433 * grammatical rules, you can chain them together using &&. 434 * 435 * But, if some of the rules in the chain succeed before one fails, then 436 * the buffer position will be left at an invalid state. In order to 437 * avoid this, Compiler::seek() is used to remember and set buffer positions. 438 * 439 * Before parsing a chain, use $s = $this->count to remember the current 440 * position into $s. Then if a chain fails, use $this->seek($s) to 441 * go back where we started. 442 * 443 * @return bool 444 */ 445 protected function parseChunk() 446 { 447 $s = $this->count; 448 449 // the directives 450 if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] === '@') { 451 if ( 452 $this->literal('@at-root', 8) && 453 ($this->selectors($selector) || true) && 454 ($this->map($with) || true) && 455 (($this->matchChar('(') && 456 $this->interpolation($with) && 457 $this->matchChar(')')) || true) && 458 $this->matchChar('{', false) 459 ) { 460 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 461 462 $atRoot = new AtRootBlock(); 463 $this->registerPushedBlock($atRoot, $s); 464 $atRoot->selector = $selector; 465 $atRoot->with = $with; 466 467 return true; 468 } 469 470 $this->seek($s); 471 472 if ( 473 $this->literal('@media', 6) && 474 $this->mediaQueryList($mediaQueryList) && 475 $this->matchChar('{', false) 476 ) { 477 $media = new MediaBlock(); 478 $this->registerPushedBlock($media, $s); 479 $media->queryList = $mediaQueryList[2]; 480 481 return true; 482 } 483 484 $this->seek($s); 485 486 if ( 487 $this->literal('@mixin', 6) && 488 $this->keyword($mixinName) && 489 ($this->argumentDef($args) || true) && 490 $this->matchChar('{', false) 491 ) { 492 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 493 494 $mixin = new CallableBlock(Type::T_MIXIN); 495 $this->registerPushedBlock($mixin, $s); 496 $mixin->name = $mixinName; 497 $mixin->args = $args; 498 499 return true; 500 } 501 502 $this->seek($s); 503 504 if ( 505 ($this->literal('@include', 8) && 506 $this->keyword($mixinName) && 507 ($this->matchChar('(') && 508 ($this->argValues($argValues) || true) && 509 $this->matchChar(')') || true) && 510 ($this->end()) || 511 ($this->literal('using', 5) && 512 $this->argumentDef($argUsing) && 513 ($this->end() || $this->matchChar('{') && $hasBlock = true)) || 514 $this->matchChar('{') && $hasBlock = true) 515 ) { 516 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 517 518 $child = [ 519 Type::T_INCLUDE, 520 $mixinName, 521 isset($argValues) ? $argValues : null, 522 null, 523 isset($argUsing) ? $argUsing : null 524 ]; 525 526 if (! empty($hasBlock)) { 527 $include = new ContentBlock(); 528 $this->registerPushedBlock($include, $s); 529 $include->child = $child; 530 } else { 531 $this->append($child, $s); 532 } 533 534 return true; 535 } 536 537 $this->seek($s); 538 539 if ( 540 $this->literal('@scssphp-import-once', 20) && 541 $this->valueList($importPath) && 542 $this->end() 543 ) { 544 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 545 546 list($line, $column) = $this->getSourcePosition($s); 547 $file = $this->sourceName; 548 $this->logger->warn("The \"@scssphp-import-once\" directive is deprecated and will be removed in ScssPhp 2.0, in \"$file\", line $line, column $column.", true); 549 550 $this->append([Type::T_SCSSPHP_IMPORT_ONCE, $importPath], $s); 551 552 return true; 553 } 554 555 $this->seek($s); 556 557 if ( 558 $this->literal('@import', 7) && 559 $this->valueList($importPath) && 560 $importPath[0] !== Type::T_FUNCTION_CALL && 561 $this->end() 562 ) { 563 if ($this->cssOnly) { 564 $this->assertPlainCssValid([Type::T_IMPORT, $importPath], $s); 565 $this->append([Type::T_COMMENT, rtrim(substr($this->buffer, $s, $this->count - $s))]); 566 return true; 567 } 568 569 $this->append([Type::T_IMPORT, $importPath], $s); 570 571 return true; 572 } 573 574 $this->seek($s); 575 576 if ( 577 $this->literal('@import', 7) && 578 $this->url($importPath) && 579 $this->end() 580 ) { 581 if ($this->cssOnly) { 582 $this->assertPlainCssValid([Type::T_IMPORT, $importPath], $s); 583 $this->append([Type::T_COMMENT, rtrim(substr($this->buffer, $s, $this->count - $s))]); 584 return true; 585 } 586 587 $this->append([Type::T_IMPORT, $importPath], $s); 588 589 return true; 590 } 591 592 $this->seek($s); 593 594 if ( 595 $this->literal('@extend', 7) && 596 $this->selectors($selectors) && 597 $this->end() 598 ) { 599 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 600 601 // check for '!flag' 602 $optional = $this->stripOptionalFlag($selectors); 603 $this->append([Type::T_EXTEND, $selectors, $optional], $s); 604 605 return true; 606 } 607 608 $this->seek($s); 609 610 if ( 611 $this->literal('@function', 9) && 612 $this->keyword($fnName) && 613 $this->argumentDef($args) && 614 $this->matchChar('{', false) 615 ) { 616 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 617 618 $func = new CallableBlock(Type::T_FUNCTION); 619 $this->registerPushedBlock($func, $s); 620 $func->name = $fnName; 621 $func->args = $args; 622 623 return true; 624 } 625 626 $this->seek($s); 627 628 if ( 629 $this->literal('@return', 7) && 630 ($this->valueList($retVal) || true) && 631 $this->end() 632 ) { 633 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 634 635 $this->append([Type::T_RETURN, isset($retVal) ? $retVal : [Type::T_NULL]], $s); 636 637 return true; 638 } 639 640 $this->seek($s); 641 642 if ( 643 $this->literal('@each', 5) && 644 $this->genericList($varNames, 'variable', ',', false) && 645 $this->literal('in', 2) && 646 $this->valueList($list) && 647 $this->matchChar('{', false) 648 ) { 649 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 650 651 $each = new EachBlock(); 652 $this->registerPushedBlock($each, $s); 653 654 foreach ($varNames[2] as $varName) { 655 $each->vars[] = $varName[1]; 656 } 657 658 $each->list = $list; 659 660 return true; 661 } 662 663 $this->seek($s); 664 665 if ( 666 $this->literal('@while', 6) && 667 $this->expression($cond) && 668 $this->matchChar('{', false) 669 ) { 670 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 671 672 while ( 673 $cond[0] === Type::T_LIST && 674 ! empty($cond['enclosing']) && 675 $cond['enclosing'] === 'parent' && 676 \count($cond[2]) == 1 677 ) { 678 $cond = reset($cond[2]); 679 } 680 681 $while = new WhileBlock(); 682 $this->registerPushedBlock($while, $s); 683 $while->cond = $cond; 684 685 return true; 686 } 687 688 $this->seek($s); 689 690 if ( 691 $this->literal('@for', 4) && 692 $this->variable($varName) && 693 $this->literal('from', 4) && 694 $this->expression($start) && 695 ($this->literal('through', 7) || 696 ($forUntil = true && $this->literal('to', 2))) && 697 $this->expression($end) && 698 $this->matchChar('{', false) 699 ) { 700 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 701 702 $for = new ForBlock(); 703 $this->registerPushedBlock($for, $s); 704 $for->var = $varName[1]; 705 $for->start = $start; 706 $for->end = $end; 707 $for->until = isset($forUntil); 708 709 return true; 710 } 711 712 $this->seek($s); 713 714 if ( 715 $this->literal('@if', 3) && 716 $this->functionCallArgumentsList($cond, false, '{', false) 717 ) { 718 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 719 720 $if = new IfBlock(); 721 $this->registerPushedBlock($if, $s); 722 723 while ( 724 $cond[0] === Type::T_LIST && 725 ! empty($cond['enclosing']) && 726 $cond['enclosing'] === 'parent' && 727 \count($cond[2]) == 1 728 ) { 729 $cond = reset($cond[2]); 730 } 731 732 $if->cond = $cond; 733 $if->cases = []; 734 735 return true; 736 } 737 738 $this->seek($s); 739 740 if ( 741 $this->literal('@debug', 6) && 742 $this->functionCallArgumentsList($value, false) 743 ) { 744 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 745 746 $this->append([Type::T_DEBUG, $value], $s); 747 748 return true; 749 } 750 751 $this->seek($s); 752 753 if ( 754 $this->literal('@warn', 5) && 755 $this->functionCallArgumentsList($value, false) 756 ) { 757 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 758 759 $this->append([Type::T_WARN, $value], $s); 760 761 return true; 762 } 763 764 $this->seek($s); 765 766 if ( 767 $this->literal('@error', 6) && 768 $this->functionCallArgumentsList($value, false) 769 ) { 770 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 771 772 $this->append([Type::T_ERROR, $value], $s); 773 774 return true; 775 } 776 777 $this->seek($s); 778 779 if ( 780 $this->literal('@content', 8) && 781 ($this->end() || 782 $this->matchChar('(') && 783 $this->argValues($argContent) && 784 $this->matchChar(')') && 785 $this->end()) 786 ) { 787 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 788 789 $this->append([Type::T_MIXIN_CONTENT, isset($argContent) ? $argContent : null], $s); 790 791 return true; 792 } 793 794 $this->seek($s); 795 796 $last = $this->last(); 797 798 if (isset($last) && $last[0] === Type::T_IF) { 799 list(, $if) = $last; 800 assert($if instanceof IfBlock); 801 802 if ($this->literal('@else', 5)) { 803 if ($this->matchChar('{', false)) { 804 $else = new ElseBlock(); 805 } elseif ( 806 $this->literal('if', 2) && 807 $this->functionCallArgumentsList($cond, false, '{', false) 808 ) { 809 $else = new ElseifBlock(); 810 $else->cond = $cond; 811 } 812 813 if (isset($else)) { 814 $this->registerPushedBlock($else, $s); 815 $if->cases[] = $else; 816 817 return true; 818 } 819 } 820 821 $this->seek($s); 822 } 823 824 // only retain the first @charset directive encountered 825 if ( 826 $this->literal('@charset', 8) && 827 $this->valueList($charset) && 828 $this->end() 829 ) { 830 if (! isset($this->charset)) { 831 $statement = [Type::T_CHARSET, $charset]; 832 833 list($line, $column) = $this->getSourcePosition($s); 834 835 $statement[static::SOURCE_LINE] = $line; 836 $statement[static::SOURCE_COLUMN] = $column; 837 $statement[static::SOURCE_INDEX] = $this->sourceIndex; 838 839 $this->charset = $statement; 840 } 841 842 return true; 843 } 844 845 $this->seek($s); 846 847 if ( 848 $this->literal('@supports', 9) && 849 ($t1 = $this->supportsQuery($supportQuery)) && 850 ($t2 = $this->matchChar('{', false)) 851 ) { 852 $directive = new DirectiveBlock(); 853 $this->registerPushedBlock($directive, $s); 854 $directive->name = 'supports'; 855 $directive->value = $supportQuery; 856 857 return true; 858 } 859 860 $this->seek($s); 861 862 // doesn't match built in directive, do generic one 863 if ( 864 $this->matchChar('@', false) && 865 $this->mixedKeyword($dirName) && 866 $this->directiveValue($dirValue, '{') 867 ) { 868 if (count($dirName) === 1 && is_string(reset($dirName))) { 869 $dirName = reset($dirName); 870 } else { 871 $dirName = [Type::T_STRING, '', $dirName]; 872 } 873 if ($dirName === 'media') { 874 $directive = new MediaBlock(); 875 } else { 876 $directive = new DirectiveBlock(); 877 $directive->name = $dirName; 878 } 879 $this->registerPushedBlock($directive, $s); 880 881 if (isset($dirValue)) { 882 ! $this->cssOnly || ($dirValue = $this->assertPlainCssValid($dirValue)); 883 $directive->value = $dirValue; 884 } 885 886 return true; 887 } 888 889 $this->seek($s); 890 891 // maybe it's a generic blockless directive 892 if ( 893 $this->matchChar('@', false) && 894 $this->mixedKeyword($dirName) && 895 ! $this->isKnownGenericDirective($dirName) && 896 ($this->end(false) || ($this->directiveValue($dirValue, '') && $this->end(false))) 897 ) { 898 if (\count($dirName) === 1 && \is_string(\reset($dirName))) { 899 $dirName = \reset($dirName); 900 } else { 901 $dirName = [Type::T_STRING, '', $dirName]; 902 } 903 if ( 904 ! empty($this->env->parent) && 905 $this->env->type && 906 ! \in_array($this->env->type, [Type::T_DIRECTIVE, Type::T_MEDIA]) 907 ) { 908 $plain = \trim(\substr($this->buffer, $s, $this->count - $s)); 909 throw $this->parseError( 910 "Unknown directive `{$plain}` not allowed in `" . $this->env->type . "` block" 911 ); 912 } 913 // blockless directives with a blank line after keeps their blank lines after 914 // sass-spec compliance purpose 915 $s = $this->count; 916 $hasBlankLine = false; 917 if ($this->match('\s*?\n\s*\n', $out, false)) { 918 $hasBlankLine = true; 919 $this->seek($s); 920 } 921 $isNotRoot = ! empty($this->env->parent); 922 $this->append([Type::T_DIRECTIVE, [$dirName, $dirValue, $hasBlankLine, $isNotRoot]], $s); 923 $this->whitespace(); 924 925 return true; 926 } 927 928 $this->seek($s); 929 930 return false; 931 } 932 933 $inCssSelector = null; 934 if ($this->cssOnly) { 935 $inCssSelector = (! empty($this->env->parent) && 936 ! in_array($this->env->type, [Type::T_DIRECTIVE, Type::T_MEDIA])); 937 } 938 // custom properties : right part is static 939 if (($this->customProperty($name) ) && $this->matchChar(':', false)) { 940 $start = $this->count; 941 942 // but can be complex and finish with ; or } 943 foreach ([';','}'] as $ending) { 944 if ( 945 $this->openString($ending, $stringValue, '(', ')', false) && 946 $this->end() 947 ) { 948 $end = $this->count; 949 $value = $stringValue; 950 951 // check if we have only a partial value due to nested [] or { } to take in account 952 $nestingPairs = [['[', ']'], ['{', '}']]; 953 954 foreach ($nestingPairs as $nestingPair) { 955 $p = strpos($this->buffer, $nestingPair[0], $start); 956 957 if ($p && $p < $end) { 958 $this->seek($start); 959 960 if ( 961 $this->openString($ending, $stringValue, $nestingPair[0], $nestingPair[1], false) && 962 $this->end() && 963 $this->count > $end 964 ) { 965 $end = $this->count; 966 $value = $stringValue; 967 } 968 } 969 } 970 971 $this->seek($end); 972 $this->append([Type::T_CUSTOM_PROPERTY, $name, $value], $s); 973 974 return true; 975 } 976 } 977 978 // TODO: output an error here if nothing found according to sass spec 979 } 980 981 $this->seek($s); 982 983 // property shortcut 984 // captures most properties before having to parse a selector 985 if ( 986 $this->keyword($name, false) && 987 $this->literal(': ', 2) && 988 $this->valueList($value) && 989 $this->end() 990 ) { 991 $name = [Type::T_STRING, '', [$name]]; 992 $this->append([Type::T_ASSIGN, $name, $value], $s); 993 994 return true; 995 } 996 997 $this->seek($s); 998 999 // variable assigns 1000 if ( 1001 $this->variable($name) && 1002 $this->matchChar(':') && 1003 $this->valueList($value) && 1004 $this->end() 1005 ) { 1006 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 1007 1008 // check for '!flag' 1009 $assignmentFlags = $this->stripAssignmentFlags($value); 1010 $this->append([Type::T_ASSIGN, $name, $value, $assignmentFlags], $s); 1011 1012 return true; 1013 } 1014 1015 $this->seek($s); 1016 1017 // opening css block 1018 if ( 1019 $this->selectors($selectors) && 1020 $this->matchChar('{', false) 1021 ) { 1022 ! $this->cssOnly || ! $inCssSelector || $this->assertPlainCssValid(false); 1023 1024 $this->pushBlock($selectors, $s); 1025 1026 if ($this->eatWhiteDefault) { 1027 $this->whitespace(); 1028 $this->append(null); // collect comments at the beginning if needed 1029 } 1030 1031 return true; 1032 } 1033 1034 $this->seek($s); 1035 1036 // property assign, or nested assign 1037 if ( 1038 $this->propertyName($name) && 1039 $this->matchChar(':') 1040 ) { 1041 $foundSomething = false; 1042 1043 if ($this->valueList($value)) { 1044 if (empty($this->env->parent)) { 1045 throw $this->parseError('expected "{"'); 1046 } 1047 1048 $this->append([Type::T_ASSIGN, $name, $value], $s); 1049 $foundSomething = true; 1050 } 1051 1052 if ($this->matchChar('{', false)) { 1053 ! $this->cssOnly || $this->assertPlainCssValid(false); 1054 1055 $propBlock = new NestedPropertyBlock(); 1056 $this->registerPushedBlock($propBlock, $s); 1057 $propBlock->prefix = $name; 1058 $propBlock->hasValue = $foundSomething; 1059 1060 $foundSomething = true; 1061 } elseif ($foundSomething) { 1062 $foundSomething = $this->end(); 1063 } 1064 1065 if ($foundSomething) { 1066 return true; 1067 } 1068 } 1069 1070 $this->seek($s); 1071 1072 // closing a block 1073 if ($this->matchChar('}', false)) { 1074 $block = $this->popBlock(); 1075 1076 if (! isset($block->type) || $block->type !== Type::T_IF) { 1077 if ($this->env->parent) { 1078 $this->append(null); // collect comments before next statement if needed 1079 } 1080 } 1081 1082 if ($block instanceof ContentBlock) { 1083 $include = $block->child; 1084 assert(\is_array($include)); 1085 unset($block->child); 1086 $include[3] = $block; 1087 $this->append($include, $s); 1088 } elseif (!$block instanceof ElseBlock && !$block instanceof ElseifBlock) { 1089 $type = isset($block->type) ? $block->type : Type::T_BLOCK; 1090 $this->append([$type, $block], $s); 1091 } 1092 1093 // collect comments just after the block closing if needed 1094 if ($this->eatWhiteDefault) { 1095 $this->whitespace(); 1096 1097 if ($this->env->comments) { 1098 $this->append(null); 1099 } 1100 } 1101 1102 return true; 1103 } 1104 1105 // extra stuff 1106 if ($this->matchChar(';')) { 1107 return true; 1108 } 1109 1110 return false; 1111 } 1112 1113 /** 1114 * Push block onto parse tree 1115 * 1116 * @param array|null $selectors 1117 * @param int $pos 1118 * 1119 * @return Block 1120 */ 1121 protected function pushBlock($selectors, $pos = 0) 1122 { 1123 $b = new Block(); 1124 $b->selectors = $selectors; 1125 1126 $this->registerPushedBlock($b, $pos); 1127 1128 return $b; 1129 } 1130 1131 /** 1132 * @param Block $b 1133 * @param int $pos 1134 * 1135 * @return void 1136 */ 1137 private function registerPushedBlock(Block $b, $pos) 1138 { 1139 list($line, $column) = $this->getSourcePosition($pos); 1140 1141 $b->sourceName = $this->sourceName; 1142 $b->sourceLine = $line; 1143 $b->sourceColumn = $column; 1144 $b->sourceIndex = $this->sourceIndex; 1145 $b->comments = []; 1146 $b->parent = $this->env; 1147 1148 if (! $this->env) { 1149 $b->children = []; 1150 } elseif (empty($this->env->children)) { 1151 $this->env->children = $this->env->comments; 1152 $b->children = []; 1153 $this->env->comments = []; 1154 } else { 1155 $b->children = $this->env->comments; 1156 $this->env->comments = []; 1157 } 1158 1159 $this->env = $b; 1160 1161 // collect comments at the beginning of a block if needed 1162 if ($this->eatWhiteDefault) { 1163 $this->whitespace(); 1164 1165 if ($this->env->comments) { 1166 $this->append(null); 1167 } 1168 } 1169 } 1170 1171 /** 1172 * Push special (named) block onto parse tree 1173 * 1174 * @deprecated 1175 * 1176 * @param string $type 1177 * @param int $pos 1178 * 1179 * @return Block 1180 */ 1181 protected function pushSpecialBlock($type, $pos) 1182 { 1183 $block = $this->pushBlock(null, $pos); 1184 $block->type = $type; 1185 1186 return $block; 1187 } 1188 1189 /** 1190 * Pop scope and return last block 1191 * 1192 * @return Block 1193 * 1194 * @throws \Exception 1195 */ 1196 protected function popBlock() 1197 { 1198 1199 // collect comments ending just before of a block closing 1200 if ($this->env->comments) { 1201 $this->append(null); 1202 } 1203 1204 // pop the block 1205 $block = $this->env; 1206 1207 if (empty($block->parent)) { 1208 throw $this->parseError('unexpected }'); 1209 } 1210 1211 if ($block->type == Type::T_AT_ROOT) { 1212 // keeps the parent in case of self selector & 1213 $block->selfParent = $block->parent; 1214 } 1215 1216 $this->env = $block->parent; 1217 1218 unset($block->parent); 1219 1220 return $block; 1221 } 1222 1223 /** 1224 * Peek input stream 1225 * 1226 * @param string $regex 1227 * @param array $out 1228 * @param int $from 1229 * 1230 * @return int 1231 */ 1232 protected function peek($regex, &$out, $from = null) 1233 { 1234 if (! isset($from)) { 1235 $from = $this->count; 1236 } 1237 1238 $r = '/' . $regex . '/' . $this->patternModifiers; 1239 $result = preg_match($r, $this->buffer, $out, 0, $from); 1240 1241 return $result; 1242 } 1243 1244 /** 1245 * Seek to position in input stream (or return current position in input stream) 1246 * 1247 * @param int $where 1248 */ 1249 protected function seek($where) 1250 { 1251 $this->count = $where; 1252 } 1253 1254 /** 1255 * Assert a parsed part is plain CSS Valid 1256 * 1257 * @param array|false $parsed 1258 * @param int $startPos 1259 * 1260 * @throws ParserException 1261 */ 1262 protected function assertPlainCssValid($parsed, $startPos = null) 1263 { 1264 $type = ''; 1265 if ($parsed) { 1266 $type = $parsed[0]; 1267 $parsed = $this->isPlainCssValidElement($parsed); 1268 } 1269 if (! $parsed) { 1270 if (! \is_null($startPos)) { 1271 $plain = rtrim(substr($this->buffer, $startPos, $this->count - $startPos)); 1272 $message = "Error : `{$plain}` isn't allowed in plain CSS"; 1273 } else { 1274 $message = 'Error: SCSS syntax not allowed in CSS file'; 1275 } 1276 if ($type) { 1277 $message .= " ($type)"; 1278 } 1279 throw $this->parseError($message); 1280 } 1281 1282 return $parsed; 1283 } 1284 1285 /** 1286 * Check a parsed element is plain CSS Valid 1287 * 1288 * @param array $parsed 1289 * @param bool $allowExpression 1290 * 1291 * @return bool|array 1292 */ 1293 protected function isPlainCssValidElement($parsed, $allowExpression = false) 1294 { 1295 // keep string as is 1296 if (is_string($parsed)) { 1297 return $parsed; 1298 } 1299 1300 if ( 1301 \in_array($parsed[0], [Type::T_FUNCTION, Type::T_FUNCTION_CALL]) && 1302 !\in_array($parsed[1], [ 1303 'alpha', 1304 'attr', 1305 'calc', 1306 'cubic-bezier', 1307 'env', 1308 'grayscale', 1309 'hsl', 1310 'hsla', 1311 'hwb', 1312 'invert', 1313 'linear-gradient', 1314 'min', 1315 'max', 1316 'radial-gradient', 1317 'repeating-linear-gradient', 1318 'repeating-radial-gradient', 1319 'rgb', 1320 'rgba', 1321 'rotate', 1322 'saturate', 1323 'var', 1324 ]) && 1325 Compiler::isNativeFunction($parsed[1]) 1326 ) { 1327 return false; 1328 } 1329 1330 switch ($parsed[0]) { 1331 case Type::T_BLOCK: 1332 case Type::T_KEYWORD: 1333 case Type::T_NULL: 1334 case Type::T_NUMBER: 1335 case Type::T_MEDIA: 1336 return $parsed; 1337 1338 case Type::T_COMMENT: 1339 if (isset($parsed[2])) { 1340 return false; 1341 } 1342 return $parsed; 1343 1344 case Type::T_DIRECTIVE: 1345 if (\is_array($parsed[1])) { 1346 $parsed[1][1] = $this->isPlainCssValidElement($parsed[1][1]); 1347 if (! $parsed[1][1]) { 1348 return false; 1349 } 1350 } 1351 1352 return $parsed; 1353 1354 case Type::T_IMPORT: 1355 if ($parsed[1][0] === Type::T_LIST) { 1356 return false; 1357 } 1358 $parsed[1] = $this->isPlainCssValidElement($parsed[1]); 1359 if ($parsed[1] === false) { 1360 return false; 1361 } 1362 return $parsed; 1363 1364 case Type::T_STRING: 1365 foreach ($parsed[2] as $k => $substr) { 1366 if (\is_array($substr)) { 1367 $parsed[2][$k] = $this->isPlainCssValidElement($substr); 1368 if (! $parsed[2][$k]) { 1369 return false; 1370 } 1371 } 1372 } 1373 return $parsed; 1374 1375 case Type::T_LIST: 1376 if (!empty($parsed['enclosing'])) { 1377 return false; 1378 } 1379 foreach ($parsed[2] as $k => $listElement) { 1380 $parsed[2][$k] = $this->isPlainCssValidElement($listElement); 1381 if (! $parsed[2][$k]) { 1382 return false; 1383 } 1384 } 1385 return $parsed; 1386 1387 case Type::T_ASSIGN: 1388 foreach ([1, 2, 3] as $k) { 1389 if (! empty($parsed[$k])) { 1390 $parsed[$k] = $this->isPlainCssValidElement($parsed[$k]); 1391 if (! $parsed[$k]) { 1392 return false; 1393 } 1394 } 1395 } 1396 return $parsed; 1397 1398 case Type::T_EXPRESSION: 1399 list( ,$op, $lhs, $rhs, $inParens, $whiteBefore, $whiteAfter) = $parsed; 1400 if (! $allowExpression && ! \in_array($op, ['and', 'or', '/'])) { 1401 return false; 1402 } 1403 $lhs = $this->isPlainCssValidElement($lhs, true); 1404 if (! $lhs) { 1405 return false; 1406 } 1407 $rhs = $this->isPlainCssValidElement($rhs, true); 1408 if (! $rhs) { 1409 return false; 1410 } 1411 1412 return [ 1413 Type::T_STRING, 1414 '', [ 1415 $this->inParens ? '(' : '', 1416 $lhs, 1417 ($whiteBefore ? ' ' : '') . $op . ($whiteAfter ? ' ' : ''), 1418 $rhs, 1419 $this->inParens ? ')' : '' 1420 ] 1421 ]; 1422 1423 case Type::T_CUSTOM_PROPERTY: 1424 case Type::T_UNARY: 1425 $parsed[2] = $this->isPlainCssValidElement($parsed[2]); 1426 if (! $parsed[2]) { 1427 return false; 1428 } 1429 return $parsed; 1430 1431 case Type::T_FUNCTION: 1432 $argsList = $parsed[2]; 1433 foreach ($argsList[2] as $argElement) { 1434 if (! $this->isPlainCssValidElement($argElement)) { 1435 return false; 1436 } 1437 } 1438 return $parsed; 1439 1440 case Type::T_FUNCTION_CALL: 1441 $parsed[0] = Type::T_FUNCTION; 1442 $argsList = [Type::T_LIST, ',', []]; 1443 foreach ($parsed[2] as $arg) { 1444 if ($arg[0] || ! empty($arg[2])) { 1445 // no named arguments possible in a css function call 1446 // nor ... argument 1447 return false; 1448 } 1449 $arg = $this->isPlainCssValidElement($arg[1], $parsed[1] === 'calc'); 1450 if (! $arg) { 1451 return false; 1452 } 1453 $argsList[2][] = $arg; 1454 } 1455 $parsed[2] = $argsList; 1456 return $parsed; 1457 } 1458 1459 return false; 1460 } 1461 1462 /** 1463 * Match string looking for either ending delim, escape, or string interpolation 1464 * 1465 * {@internal This is a workaround for preg_match's 250K string match limit. }} 1466 * 1467 * @param array $m Matches (passed by reference) 1468 * @param string $delim Delimiter 1469 * 1470 * @return bool True if match; false otherwise 1471 */ 1472 protected function matchString(&$m, $delim) 1473 { 1474 $token = null; 1475 1476 $end = \strlen($this->buffer); 1477 1478 // look for either ending delim, escape, or string interpolation 1479 foreach (['#{', '\\', "\r", $delim] as $lookahead) { 1480 $pos = strpos($this->buffer, $lookahead, $this->count); 1481 1482 if ($pos !== false && $pos < $end) { 1483 $end = $pos; 1484 $token = $lookahead; 1485 } 1486 } 1487 1488 if (! isset($token)) { 1489 return false; 1490 } 1491 1492 $match = substr($this->buffer, $this->count, $end - $this->count); 1493 $m = [ 1494 $match . $token, 1495 $match, 1496 $token 1497 ]; 1498 $this->count = $end + \strlen($token); 1499 1500 return true; 1501 } 1502 1503 /** 1504 * Try to match something on head of buffer 1505 * 1506 * @param string $regex 1507 * @param array $out 1508 * @param bool $eatWhitespace 1509 * 1510 * @return bool 1511 */ 1512 protected function match($regex, &$out, $eatWhitespace = null) 1513 { 1514 $r = '/' . $regex . '/' . $this->patternModifiers; 1515 1516 if (! preg_match($r, $this->buffer, $out, 0, $this->count)) { 1517 return false; 1518 } 1519 1520 $this->count += \strlen($out[0]); 1521 1522 if (! isset($eatWhitespace)) { 1523 $eatWhitespace = $this->eatWhiteDefault; 1524 } 1525 1526 if ($eatWhitespace) { 1527 $this->whitespace(); 1528 } 1529 1530 return true; 1531 } 1532 1533 /** 1534 * Match a single string 1535 * 1536 * @param string $char 1537 * @param bool $eatWhitespace 1538 * 1539 * @return bool 1540 */ 1541 protected function matchChar($char, $eatWhitespace = null) 1542 { 1543 if (! isset($this->buffer[$this->count]) || $this->buffer[$this->count] !== $char) { 1544 return false; 1545 } 1546 1547 $this->count++; 1548 1549 if (! isset($eatWhitespace)) { 1550 $eatWhitespace = $this->eatWhiteDefault; 1551 } 1552 1553 if ($eatWhitespace) { 1554 $this->whitespace(); 1555 } 1556 1557 return true; 1558 } 1559 1560 /** 1561 * Match literal string 1562 * 1563 * @param string $what 1564 * @param int $len 1565 * @param bool $eatWhitespace 1566 * 1567 * @return bool 1568 */ 1569 protected function literal($what, $len, $eatWhitespace = null) 1570 { 1571 if (strcasecmp(substr($this->buffer, $this->count, $len), $what) !== 0) { 1572 return false; 1573 } 1574 1575 $this->count += $len; 1576 1577 if (! isset($eatWhitespace)) { 1578 $eatWhitespace = $this->eatWhiteDefault; 1579 } 1580 1581 if ($eatWhitespace) { 1582 $this->whitespace(); 1583 } 1584 1585 return true; 1586 } 1587 1588 /** 1589 * Match some whitespace 1590 * 1591 * @return bool 1592 */ 1593 protected function whitespace() 1594 { 1595 $gotWhite = false; 1596 1597 while (preg_match(static::$whitePattern, $this->buffer, $m, 0, $this->count)) { 1598 if (isset($m[1]) && empty($this->commentsSeen[$this->count])) { 1599 // comment that are kept in the output CSS 1600 $comment = []; 1601 $startCommentCount = $this->count; 1602 $endCommentCount = $this->count + \strlen($m[1]); 1603 1604 // find interpolations in comment 1605 $p = strpos($this->buffer, '#{', $this->count); 1606 1607 while ($p !== false && $p < $endCommentCount) { 1608 $c = substr($this->buffer, $this->count, $p - $this->count); 1609 $comment[] = $c; 1610 $this->count = $p; 1611 $out = null; 1612 1613 if ($this->interpolation($out)) { 1614 // keep right spaces in the following string part 1615 if ($out[3]) { 1616 while ($this->buffer[$this->count - 1] !== '}') { 1617 $this->count--; 1618 } 1619 1620 $out[3] = ''; 1621 } 1622 1623 $comment[] = [Type::T_COMMENT, substr($this->buffer, $p, $this->count - $p), $out]; 1624 } else { 1625 list($line, $column) = $this->getSourcePosition($this->count); 1626 $file = $this->sourceName; 1627 if (!$this->discardComments) { 1628 $this->logger->warn("Unterminated interpolations in multiline comments are deprecated and will be removed in ScssPhp 2.0, in \"$file\", line $line, column $column.", true); 1629 } 1630 $comment[] = substr($this->buffer, $this->count, 2); 1631 1632 $this->count += 2; 1633 } 1634 1635 $p = strpos($this->buffer, '#{', $this->count); 1636 } 1637 1638 // remaining part 1639 $c = substr($this->buffer, $this->count, $endCommentCount - $this->count); 1640 1641 if (! $comment) { 1642 // single part static comment 1643 $this->appendComment([Type::T_COMMENT, $c]); 1644 } else { 1645 $comment[] = $c; 1646 $staticComment = substr($this->buffer, $startCommentCount, $endCommentCount - $startCommentCount); 1647 $commentStatement = [Type::T_COMMENT, $staticComment, [Type::T_STRING, '', $comment]]; 1648 1649 list($line, $column) = $this->getSourcePosition($startCommentCount); 1650 $commentStatement[self::SOURCE_LINE] = $line; 1651 $commentStatement[self::SOURCE_COLUMN] = $column; 1652 $commentStatement[self::SOURCE_INDEX] = $this->sourceIndex; 1653 1654 $this->appendComment($commentStatement); 1655 } 1656 1657 $this->commentsSeen[$startCommentCount] = true; 1658 $this->count = $endCommentCount; 1659 } else { 1660 // comment that are ignored and not kept in the output css 1661 $this->count += \strlen($m[0]); 1662 // silent comments are not allowed in plain CSS files 1663 ! $this->cssOnly 1664 || ! \strlen(trim($m[0])) 1665 || $this->assertPlainCssValid(false, $this->count - \strlen($m[0])); 1666 } 1667 1668 $gotWhite = true; 1669 } 1670 1671 return $gotWhite; 1672 } 1673 1674 /** 1675 * Append comment to current block 1676 * 1677 * @param array $comment 1678 */ 1679 protected function appendComment($comment) 1680 { 1681 if (! $this->discardComments) { 1682 $this->env->comments[] = $comment; 1683 } 1684 } 1685 1686 /** 1687 * Append statement to current block 1688 * 1689 * @param array|null $statement 1690 * @param int $pos 1691 */ 1692 protected function append($statement, $pos = null) 1693 { 1694 if (! \is_null($statement)) { 1695 ! $this->cssOnly || ($statement = $this->assertPlainCssValid($statement, $pos)); 1696 1697 if (! \is_null($pos)) { 1698 list($line, $column) = $this->getSourcePosition($pos); 1699 1700 $statement[static::SOURCE_LINE] = $line; 1701 $statement[static::SOURCE_COLUMN] = $column; 1702 $statement[static::SOURCE_INDEX] = $this->sourceIndex; 1703 } 1704 1705 $this->env->children[] = $statement; 1706 } 1707 1708 $comments = $this->env->comments; 1709 1710 if ($comments) { 1711 $this->env->children = array_merge($this->env->children, $comments); 1712 $this->env->comments = []; 1713 } 1714 } 1715 1716 /** 1717 * Returns last child was appended 1718 * 1719 * @return array|null 1720 */ 1721 protected function last() 1722 { 1723 $i = \count($this->env->children) - 1; 1724 1725 if (isset($this->env->children[$i])) { 1726 return $this->env->children[$i]; 1727 } 1728 } 1729 1730 /** 1731 * Parse media query list 1732 * 1733 * @param array $out 1734 * 1735 * @return bool 1736 */ 1737 protected function mediaQueryList(&$out) 1738 { 1739 return $this->genericList($out, 'mediaQuery', ',', false); 1740 } 1741 1742 /** 1743 * Parse media query 1744 * 1745 * @param array $out 1746 * 1747 * @return bool 1748 */ 1749 protected function mediaQuery(&$out) 1750 { 1751 $expressions = null; 1752 $parts = []; 1753 1754 if ( 1755 ($this->literal('only', 4) && ($only = true) || 1756 $this->literal('not', 3) && ($not = true) || true) && 1757 $this->mixedKeyword($mediaType) 1758 ) { 1759 $prop = [Type::T_MEDIA_TYPE]; 1760 1761 if (isset($only)) { 1762 $prop[] = [Type::T_KEYWORD, 'only']; 1763 } 1764 1765 if (isset($not)) { 1766 $prop[] = [Type::T_KEYWORD, 'not']; 1767 } 1768 1769 $media = [Type::T_LIST, '', []]; 1770 1771 foreach ((array) $mediaType as $type) { 1772 if (\is_array($type)) { 1773 $media[2][] = $type; 1774 } else { 1775 $media[2][] = [Type::T_KEYWORD, $type]; 1776 } 1777 } 1778 1779 $prop[] = $media; 1780 $parts[] = $prop; 1781 } 1782 1783 if (empty($parts) || $this->literal('and', 3)) { 1784 $this->genericList($expressions, 'mediaExpression', 'and', false); 1785 1786 if (\is_array($expressions)) { 1787 $parts = array_merge($parts, $expressions[2]); 1788 } 1789 } 1790 1791 $out = $parts; 1792 1793 return true; 1794 } 1795 1796 /** 1797 * Parse supports query 1798 * 1799 * @param array $out 1800 * 1801 * @return bool 1802 */ 1803 protected function supportsQuery(&$out) 1804 { 1805 $expressions = null; 1806 $parts = []; 1807 1808 $s = $this->count; 1809 1810 $not = false; 1811 1812 if ( 1813 ($this->literal('not', 3) && ($not = true) || true) && 1814 $this->matchChar('(') && 1815 ($this->expression($property)) && 1816 $this->literal(': ', 2) && 1817 $this->valueList($value) && 1818 $this->matchChar(')') 1819 ) { 1820 $support = [Type::T_STRING, '', [[Type::T_KEYWORD, ($not ? 'not ' : '') . '(']]]; 1821 $support[2][] = $property; 1822 $support[2][] = [Type::T_KEYWORD, ': ']; 1823 $support[2][] = $value; 1824 $support[2][] = [Type::T_KEYWORD, ')']; 1825 1826 $parts[] = $support; 1827 $s = $this->count; 1828 } else { 1829 $this->seek($s); 1830 } 1831 1832 if ( 1833 $this->matchChar('(') && 1834 $this->supportsQuery($subQuery) && 1835 $this->matchChar(')') 1836 ) { 1837 $parts[] = [Type::T_STRING, '', [[Type::T_KEYWORD, '('], $subQuery, [Type::T_KEYWORD, ')']]]; 1838 $s = $this->count; 1839 } else { 1840 $this->seek($s); 1841 } 1842 1843 if ( 1844 $this->literal('not', 3) && 1845 $this->supportsQuery($subQuery) 1846 ) { 1847 $parts[] = [Type::T_STRING, '', [[Type::T_KEYWORD, 'not '], $subQuery]]; 1848 $s = $this->count; 1849 } else { 1850 $this->seek($s); 1851 } 1852 1853 if ( 1854 $this->literal('selector(', 9) && 1855 $this->selector($selector) && 1856 $this->matchChar(')') 1857 ) { 1858 $support = [Type::T_STRING, '', [[Type::T_KEYWORD, 'selector(']]]; 1859 1860 $selectorList = [Type::T_LIST, '', []]; 1861 1862 foreach ($selector as $sc) { 1863 $compound = [Type::T_STRING, '', []]; 1864 1865 foreach ($sc as $scp) { 1866 if (\is_array($scp)) { 1867 $compound[2][] = $scp; 1868 } else { 1869 $compound[2][] = [Type::T_KEYWORD, $scp]; 1870 } 1871 } 1872 1873 $selectorList[2][] = $compound; 1874 } 1875 1876 $support[2][] = $selectorList; 1877 $support[2][] = [Type::T_KEYWORD, ')']; 1878 $parts[] = $support; 1879 $s = $this->count; 1880 } else { 1881 $this->seek($s); 1882 } 1883 1884 if ($this->variable($var) or $this->interpolation($var)) { 1885 $parts[] = $var; 1886 $s = $this->count; 1887 } else { 1888 $this->seek($s); 1889 } 1890 1891 if ( 1892 $this->literal('and', 3) && 1893 $this->genericList($expressions, 'supportsQuery', ' and', false) 1894 ) { 1895 array_unshift($expressions[2], [Type::T_STRING, '', $parts]); 1896 1897 $parts = [$expressions]; 1898 $s = $this->count; 1899 } else { 1900 $this->seek($s); 1901 } 1902 1903 if ( 1904 $this->literal('or', 2) && 1905 $this->genericList($expressions, 'supportsQuery', ' or', false) 1906 ) { 1907 array_unshift($expressions[2], [Type::T_STRING, '', $parts]); 1908 1909 $parts = [$expressions]; 1910 $s = $this->count; 1911 } else { 1912 $this->seek($s); 1913 } 1914 1915 if (\count($parts)) { 1916 if ($this->eatWhiteDefault) { 1917 $this->whitespace(); 1918 } 1919 1920 $out = [Type::T_STRING, '', $parts]; 1921 1922 return true; 1923 } 1924 1925 return false; 1926 } 1927 1928 1929 /** 1930 * Parse media expression 1931 * 1932 * @param array $out 1933 * 1934 * @return bool 1935 */ 1936 protected function mediaExpression(&$out) 1937 { 1938 $s = $this->count; 1939 $value = null; 1940 1941 if ( 1942 $this->matchChar('(') && 1943 $this->expression($feature) && 1944 ($this->matchChar(':') && 1945 $this->expression($value) || true) && 1946 $this->matchChar(')') 1947 ) { 1948 $out = [Type::T_MEDIA_EXPRESSION, $feature]; 1949 1950 if ($value) { 1951 $out[] = $value; 1952 } 1953 1954 return true; 1955 } 1956 1957 $this->seek($s); 1958 1959 return false; 1960 } 1961 1962 /** 1963 * Parse argument values 1964 * 1965 * @param array $out 1966 * 1967 * @return bool 1968 */ 1969 protected function argValues(&$out) 1970 { 1971 $discardComments = $this->discardComments; 1972 $this->discardComments = true; 1973 1974 if ($this->genericList($list, 'argValue', ',', false)) { 1975 $out = $list[2]; 1976 1977 $this->discardComments = $discardComments; 1978 1979 return true; 1980 } 1981 1982 $this->discardComments = $discardComments; 1983 1984 return false; 1985 } 1986 1987 /** 1988 * Parse argument value 1989 * 1990 * @param array $out 1991 * 1992 * @return bool 1993 */ 1994 protected function argValue(&$out) 1995 { 1996 $s = $this->count; 1997 1998 $keyword = null; 1999 2000 if (! $this->variable($keyword) || ! $this->matchChar(':')) { 2001 $this->seek($s); 2002 2003 $keyword = null; 2004 } 2005 2006 if ($this->genericList($value, 'expression', '', true)) { 2007 $out = [$keyword, $value, false]; 2008 $s = $this->count; 2009 2010 if ($this->literal('...', 3)) { 2011 $out[2] = true; 2012 } else { 2013 $this->seek($s); 2014 } 2015 2016 return true; 2017 } 2018 2019 return false; 2020 } 2021 2022 /** 2023 * Check if a generic directive is known to be able to allow almost any syntax or not 2024 * @param mixed $directiveName 2025 * @return bool 2026 */ 2027 protected function isKnownGenericDirective($directiveName) 2028 { 2029 if (\is_array($directiveName) && \is_string(reset($directiveName))) { 2030 $directiveName = reset($directiveName); 2031 } 2032 if (! \is_string($directiveName)) { 2033 return false; 2034 } 2035 if ( 2036 \in_array($directiveName, [ 2037 'at-root', 2038 'media', 2039 'mixin', 2040 'include', 2041 'scssphp-import-once', 2042 'import', 2043 'extend', 2044 'function', 2045 'break', 2046 'continue', 2047 'return', 2048 'each', 2049 'while', 2050 'for', 2051 'if', 2052 'debug', 2053 'warn', 2054 'error', 2055 'content', 2056 'else', 2057 'charset', 2058 'supports', 2059 // Todo 2060 'use', 2061 'forward', 2062 ]) 2063 ) { 2064 return true; 2065 } 2066 return false; 2067 } 2068 2069 /** 2070 * Parse directive value list that considers $vars as keyword 2071 * 2072 * @param array $out 2073 * @param bool|string $endChar 2074 * 2075 * @return bool 2076 */ 2077 protected function directiveValue(&$out, $endChar = false) 2078 { 2079 $s = $this->count; 2080 2081 if ($this->variable($out)) { 2082 if ($endChar && $this->matchChar($endChar, false)) { 2083 return true; 2084 } 2085 2086 if (! $endChar && $this->end()) { 2087 return true; 2088 } 2089 } 2090 2091 $this->seek($s); 2092 2093 if (\is_string($endChar) && $this->openString($endChar ? $endChar : ';', $out, null, null, true, ";}{")) { 2094 if ($endChar && $this->matchChar($endChar, false)) { 2095 return true; 2096 } 2097 $ss = $this->count; 2098 if (!$endChar && $this->end()) { 2099 $this->seek($ss); 2100 return true; 2101 } 2102 } 2103 2104 $this->seek($s); 2105 2106 $allowVars = $this->allowVars; 2107 $this->allowVars = false; 2108 2109 $res = $this->genericList($out, 'spaceList', ','); 2110 $this->allowVars = $allowVars; 2111 2112 if ($res) { 2113 if ($endChar && $this->matchChar($endChar, false)) { 2114 return true; 2115 } 2116 2117 if (! $endChar && $this->end()) { 2118 return true; 2119 } 2120 } 2121 2122 $this->seek($s); 2123 2124 if ($endChar && $this->matchChar($endChar, false)) { 2125 return true; 2126 } 2127 2128 return false; 2129 } 2130 2131 /** 2132 * Parse comma separated value list 2133 * 2134 * @param array $out 2135 * 2136 * @return bool 2137 */ 2138 protected function valueList(&$out) 2139 { 2140 $discardComments = $this->discardComments; 2141 $this->discardComments = true; 2142 $res = $this->genericList($out, 'spaceList', ','); 2143 $this->discardComments = $discardComments; 2144 2145 return $res; 2146 } 2147 2148 /** 2149 * Parse a function call, where externals () are part of the call 2150 * and not of the value list 2151 * 2152 * @param array $out 2153 * @param bool $mandatoryEnclos 2154 * @param null|string $charAfter 2155 * @param null|bool $eatWhiteSp 2156 * 2157 * @return bool 2158 */ 2159 protected function functionCallArgumentsList(&$out, $mandatoryEnclos = true, $charAfter = null, $eatWhiteSp = null) 2160 { 2161 $s = $this->count; 2162 2163 if ( 2164 $this->matchChar('(') && 2165 $this->valueList($out) && 2166 $this->matchChar(')') && 2167 ($charAfter ? $this->matchChar($charAfter, $eatWhiteSp) : $this->end()) 2168 ) { 2169 return true; 2170 } 2171 2172 if (! $mandatoryEnclos) { 2173 $this->seek($s); 2174 2175 if ( 2176 $this->valueList($out) && 2177 ($charAfter ? $this->matchChar($charAfter, $eatWhiteSp) : $this->end()) 2178 ) { 2179 return true; 2180 } 2181 } 2182 2183 $this->seek($s); 2184 2185 return false; 2186 } 2187 2188 /** 2189 * Parse space separated value list 2190 * 2191 * @param array $out 2192 * 2193 * @return bool 2194 */ 2195 protected function spaceList(&$out) 2196 { 2197 return $this->genericList($out, 'expression'); 2198 } 2199 2200 /** 2201 * Parse generic list 2202 * 2203 * @param array $out 2204 * @param string $parseItem The name of the method used to parse items 2205 * @param string $delim 2206 * @param bool $flatten 2207 * 2208 * @return bool 2209 */ 2210 protected function genericList(&$out, $parseItem, $delim = '', $flatten = true) 2211 { 2212 $s = $this->count; 2213 $items = []; 2214 $value = null; 2215 2216 while ($this->$parseItem($value)) { 2217 $trailing_delim = false; 2218 $items[] = $value; 2219 2220 if ($delim) { 2221 if (! $this->literal($delim, \strlen($delim))) { 2222 break; 2223 } 2224 2225 $trailing_delim = true; 2226 } else { 2227 // if no delim watch that a keyword didn't eat the single/double quote 2228 // from the following starting string 2229 if ($value[0] === Type::T_KEYWORD) { 2230 $word = $value[1]; 2231 2232 $last_char = substr($word, -1); 2233 2234 if ( 2235 strlen($word) > 1 && 2236 in_array($last_char, [ "'", '"']) && 2237 substr($word, -2, 1) !== '\\' 2238 ) { 2239 // if there is a non escaped opening quote in the keyword, this seems unlikely a mistake 2240 $word = str_replace('\\' . $last_char, '\\\\', $word); 2241 if (strpos($word, $last_char) < strlen($word) - 1) { 2242 continue; 2243 } 2244 2245 $currentCount = $this->count; 2246 2247 // let's try to rewind to previous char and try a parse 2248 $this->count--; 2249 // in case the keyword also eat spaces 2250 while (substr($this->buffer, $this->count, 1) !== $last_char) { 2251 $this->count--; 2252 } 2253 2254 $nextValue = null; 2255 if ($this->$parseItem($nextValue)) { 2256 if ($nextValue[0] === Type::T_KEYWORD && $nextValue[1] === $last_char) { 2257 // bad try, forget it 2258 $this->seek($currentCount); 2259 continue; 2260 } 2261 if ($nextValue[0] !== Type::T_STRING) { 2262 // bad try, forget it 2263 $this->seek($currentCount); 2264 continue; 2265 } 2266 2267 // OK it was a good idea 2268 $value[1] = substr($value[1], 0, -1); 2269 array_pop($items); 2270 $items[] = $value; 2271 $items[] = $nextValue; 2272 } else { 2273 // bad try, forget it 2274 $this->seek($currentCount); 2275 continue; 2276 } 2277 } 2278 } 2279 } 2280 } 2281 2282 if (! $items) { 2283 $this->seek($s); 2284 2285 return false; 2286 } 2287 2288 if ($trailing_delim) { 2289 $items[] = [Type::T_NULL]; 2290 } 2291 2292 if ($flatten && \count($items) === 1) { 2293 $out = $items[0]; 2294 } else { 2295 $out = [Type::T_LIST, $delim, $items]; 2296 } 2297 2298 return true; 2299 } 2300 2301 /** 2302 * Parse expression 2303 * 2304 * @param array $out 2305 * @param bool $listOnly 2306 * @param bool $lookForExp 2307 * 2308 * @return bool 2309 */ 2310 protected function expression(&$out, $listOnly = false, $lookForExp = true) 2311 { 2312 $s = $this->count; 2313 $discard = $this->discardComments; 2314 $this->discardComments = true; 2315 $allowedTypes = ($listOnly ? [Type::T_LIST] : [Type::T_LIST, Type::T_MAP]); 2316 2317 if ($this->matchChar('(')) { 2318 if ($this->enclosedExpression($lhs, $s, ')', $allowedTypes)) { 2319 if ($lookForExp) { 2320 $out = $this->expHelper($lhs, 0); 2321 } else { 2322 $out = $lhs; 2323 } 2324 2325 $this->discardComments = $discard; 2326 2327 return true; 2328 } 2329 2330 $this->seek($s); 2331 } 2332 2333 if (\in_array(Type::T_LIST, $allowedTypes) && $this->matchChar('[')) { 2334 if ($this->enclosedExpression($lhs, $s, ']', [Type::T_LIST])) { 2335 if ($lookForExp) { 2336 $out = $this->expHelper($lhs, 0); 2337 } else { 2338 $out = $lhs; 2339 } 2340 2341 $this->discardComments = $discard; 2342 2343 return true; 2344 } 2345 2346 $this->seek($s); 2347 } 2348 2349 if (! $listOnly && $this->value($lhs)) { 2350 if ($lookForExp) { 2351 $out = $this->expHelper($lhs, 0); 2352 } else { 2353 $out = $lhs; 2354 } 2355 2356 $this->discardComments = $discard; 2357 2358 return true; 2359 } 2360 2361 $this->discardComments = $discard; 2362 2363 return false; 2364 } 2365 2366 /** 2367 * Parse expression specifically checking for lists in parenthesis or brackets 2368 * 2369 * @param array $out 2370 * @param int $s 2371 * @param string $closingParen 2372 * @param array $allowedTypes 2373 * 2374 * @return bool 2375 */ 2376 protected function enclosedExpression(&$out, $s, $closingParen = ')', $allowedTypes = [Type::T_LIST, Type::T_MAP]) 2377 { 2378 if ($this->matchChar($closingParen) && \in_array(Type::T_LIST, $allowedTypes)) { 2379 $out = [Type::T_LIST, '', []]; 2380 2381 switch ($closingParen) { 2382 case ')': 2383 $out['enclosing'] = 'parent'; // parenthesis list 2384 break; 2385 2386 case ']': 2387 $out['enclosing'] = 'bracket'; // bracketed list 2388 break; 2389 } 2390 2391 return true; 2392 } 2393 2394 if ( 2395 $this->valueList($out) && 2396 $this->matchChar($closingParen) && ! ($closingParen === ')' && 2397 \in_array($out[0], [Type::T_EXPRESSION, Type::T_UNARY])) && 2398 \in_array(Type::T_LIST, $allowedTypes) 2399 ) { 2400 if ($out[0] !== Type::T_LIST || ! empty($out['enclosing'])) { 2401 $out = [Type::T_LIST, '', [$out]]; 2402 } 2403 2404 switch ($closingParen) { 2405 case ')': 2406 $out['enclosing'] = 'parent'; // parenthesis list 2407 break; 2408 2409 case ']': 2410 $out['enclosing'] = 'bracket'; // bracketed list 2411 break; 2412 } 2413 2414 return true; 2415 } 2416 2417 $this->seek($s); 2418 2419 if (\in_array(Type::T_MAP, $allowedTypes) && $this->map($out)) { 2420 return true; 2421 } 2422 2423 return false; 2424 } 2425 2426 /** 2427 * Parse left-hand side of subexpression 2428 * 2429 * @param array $lhs 2430 * @param int $minP 2431 * 2432 * @return array 2433 */ 2434 protected function expHelper($lhs, $minP) 2435 { 2436 $operators = static::$operatorPattern; 2437 2438 $ss = $this->count; 2439 $whiteBefore = isset($this->buffer[$this->count - 1]) && 2440 ctype_space($this->buffer[$this->count - 1]); 2441 2442 while ($this->match($operators, $m, false) && static::$precedence[$m[1]] >= $minP) { 2443 $whiteAfter = isset($this->buffer[$this->count]) && 2444 ctype_space($this->buffer[$this->count]); 2445 $varAfter = isset($this->buffer[$this->count]) && 2446 $this->buffer[$this->count] === '$'; 2447 2448 $this->whitespace(); 2449 2450 $op = $m[1]; 2451 2452 // don't turn negative numbers into expressions 2453 if ($op === '-' && $whiteBefore && ! $whiteAfter && ! $varAfter) { 2454 break; 2455 } 2456 2457 if (! $this->value($rhs) && ! $this->expression($rhs, true, false)) { 2458 break; 2459 } 2460 2461 if ($op === '-' && ! $whiteAfter && $rhs[0] === Type::T_KEYWORD) { 2462 break; 2463 } 2464 2465 // consume higher-precedence operators on the right-hand side 2466 $rhs = $this->expHelper($rhs, static::$precedence[$op] + 1); 2467 2468 $lhs = [Type::T_EXPRESSION, $op, $lhs, $rhs, $this->inParens, $whiteBefore, $whiteAfter]; 2469 2470 $ss = $this->count; 2471 $whiteBefore = isset($this->buffer[$this->count - 1]) && 2472 ctype_space($this->buffer[$this->count - 1]); 2473 } 2474 2475 $this->seek($ss); 2476 2477 return $lhs; 2478 } 2479 2480 /** 2481 * Parse value 2482 * 2483 * @param array $out 2484 * 2485 * @return bool 2486 */ 2487 protected function value(&$out) 2488 { 2489 if (! isset($this->buffer[$this->count])) { 2490 return false; 2491 } 2492 2493 $s = $this->count; 2494 $char = $this->buffer[$this->count]; 2495 2496 if ( 2497 $this->literal('url(', 4) && 2498 $this->match('data:([a-z]+)\/([a-z0-9.+-]+);base64,', $m, false) 2499 ) { 2500 $len = strspn( 2501 $this->buffer, 2502 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwyxz0123456789+/=', 2503 $this->count 2504 ); 2505 2506 $this->count += $len; 2507 2508 if ($this->matchChar(')')) { 2509 $content = substr($this->buffer, $s, $this->count - $s); 2510 $out = [Type::T_KEYWORD, $content]; 2511 2512 return true; 2513 } 2514 } 2515 2516 $this->seek($s); 2517 2518 if ( 2519 $this->literal('url(', 4, false) && 2520 $this->match('\s*(\/\/[^\s\)]+)\s*', $m) 2521 ) { 2522 $content = 'url(' . $m[1]; 2523 2524 if ($this->matchChar(')')) { 2525 $content .= ')'; 2526 $out = [Type::T_KEYWORD, $content]; 2527 2528 return true; 2529 } 2530 } 2531 2532 $this->seek($s); 2533 2534 // not 2535 if ($char === 'n' && $this->literal('not', 3, false)) { 2536 if ( 2537 $this->whitespace() && 2538 $this->value($inner) 2539 ) { 2540 $out = [Type::T_UNARY, 'not', $inner, $this->inParens]; 2541 2542 return true; 2543 } 2544 2545 $this->seek($s); 2546 2547 if ($this->parenValue($inner)) { 2548 $out = [Type::T_UNARY, 'not', $inner, $this->inParens]; 2549 2550 return true; 2551 } 2552 2553 $this->seek($s); 2554 } 2555 2556 // addition 2557 if ($char === '+') { 2558 $this->count++; 2559 2560 $follow_white = $this->whitespace(); 2561 2562 if ($this->value($inner)) { 2563 $out = [Type::T_UNARY, '+', $inner, $this->inParens]; 2564 2565 return true; 2566 } 2567 2568 if ($follow_white) { 2569 $out = [Type::T_KEYWORD, $char]; 2570 return true; 2571 } 2572 2573 $this->seek($s); 2574 2575 return false; 2576 } 2577 2578 // negation 2579 if ($char === '-') { 2580 if ($this->customProperty($out)) { 2581 return true; 2582 } 2583 2584 $this->count++; 2585 2586 $follow_white = $this->whitespace(); 2587 2588 if ($this->variable($inner) || $this->unit($inner) || $this->parenValue($inner)) { 2589 $out = [Type::T_UNARY, '-', $inner, $this->inParens]; 2590 2591 return true; 2592 } 2593 2594 if ( 2595 $this->keyword($inner) && 2596 ! $this->func($inner, $out) 2597 ) { 2598 $out = [Type::T_UNARY, '-', $inner, $this->inParens]; 2599 2600 return true; 2601 } 2602 2603 if ($follow_white) { 2604 $out = [Type::T_KEYWORD, $char]; 2605 2606 return true; 2607 } 2608 2609 $this->seek($s); 2610 } 2611 2612 // paren 2613 if ($char === '(' && $this->parenValue($out)) { 2614 return true; 2615 } 2616 2617 if ($char === '#') { 2618 if ($this->interpolation($out) || $this->color($out)) { 2619 return true; 2620 } 2621 2622 $this->count++; 2623 2624 if ($this->keyword($keyword)) { 2625 $out = [Type::T_KEYWORD, '#' . $keyword]; 2626 2627 return true; 2628 } 2629 2630 $this->count--; 2631 } 2632 2633 if ($this->matchChar('&', true)) { 2634 $out = [Type::T_SELF]; 2635 2636 return true; 2637 } 2638 2639 if ($char === '$' && $this->variable($out)) { 2640 return true; 2641 } 2642 2643 if ($char === 'p' && $this->progid($out)) { 2644 return true; 2645 } 2646 2647 if (($char === '"' || $char === "'") && $this->string($out)) { 2648 return true; 2649 } 2650 2651 if ($this->unit($out)) { 2652 return true; 2653 } 2654 2655 // unicode range with wildcards 2656 if ( 2657 $this->literal('U+', 2) && 2658 $this->match('\?+|([0-9A-F]+(\?+|(-[0-9A-F]+))?)', $m, false) 2659 ) { 2660 $unicode = explode('-', $m[0]); 2661 if (strlen(reset($unicode)) <= 6 && strlen(end($unicode)) <= 6) { 2662 $out = [Type::T_KEYWORD, 'U+' . $m[0]]; 2663 2664 return true; 2665 } 2666 $this->count -= strlen($m[0]) + 2; 2667 } 2668 2669 if ($this->keyword($keyword, false)) { 2670 if ($this->func($keyword, $out)) { 2671 return true; 2672 } 2673 2674 $this->whitespace(); 2675 2676 if ($keyword === 'null') { 2677 $out = [Type::T_NULL]; 2678 } else { 2679 $out = [Type::T_KEYWORD, $keyword]; 2680 } 2681 2682 return true; 2683 } 2684 2685 return false; 2686 } 2687 2688 /** 2689 * Parse parenthesized value 2690 * 2691 * @param array $out 2692 * 2693 * @return bool 2694 */ 2695 protected function parenValue(&$out) 2696 { 2697 $s = $this->count; 2698 2699 $inParens = $this->inParens; 2700 2701 if ($this->matchChar('(')) { 2702 if ($this->matchChar(')')) { 2703 $out = [Type::T_LIST, '', []]; 2704 2705 return true; 2706 } 2707 2708 $this->inParens = true; 2709 2710 if ( 2711 $this->expression($exp) && 2712 $this->matchChar(')') 2713 ) { 2714 $out = $exp; 2715 $this->inParens = $inParens; 2716 2717 return true; 2718 } 2719 } 2720 2721 $this->inParens = $inParens; 2722 $this->seek($s); 2723 2724 return false; 2725 } 2726 2727 /** 2728 * Parse "progid:" 2729 * 2730 * @param array $out 2731 * 2732 * @return bool 2733 */ 2734 protected function progid(&$out) 2735 { 2736 $s = $this->count; 2737 2738 if ( 2739 $this->literal('progid:', 7, false) && 2740 $this->openString('(', $fn) && 2741 $this->matchChar('(') 2742 ) { 2743 $this->openString(')', $args, '('); 2744 2745 if ($this->matchChar(')')) { 2746 $out = [Type::T_STRING, '', [ 2747 'progid:', $fn, '(', $args, ')' 2748 ]]; 2749 2750 return true; 2751 } 2752 } 2753 2754 $this->seek($s); 2755 2756 return false; 2757 } 2758 2759 /** 2760 * Parse function call 2761 * 2762 * @param string $name 2763 * @param array $func 2764 * 2765 * @return bool 2766 */ 2767 protected function func($name, &$func) 2768 { 2769 $s = $this->count; 2770 2771 if ($this->matchChar('(')) { 2772 if ($name === 'alpha' && $this->argumentList($args)) { 2773 $func = [Type::T_FUNCTION, $name, [Type::T_STRING, '', $args]]; 2774 2775 return true; 2776 } 2777 2778 if ($name !== 'expression' && ! preg_match('/^(-[a-z]+-)?calc$/', $name)) { 2779 $ss = $this->count; 2780 2781 if ( 2782 $this->argValues($args) && 2783 $this->matchChar(')') 2784 ) { 2785 $func = [Type::T_FUNCTION_CALL, $name, $args]; 2786 2787 return true; 2788 } 2789 2790 $this->seek($ss); 2791 } 2792 2793 if ( 2794 ($this->openString(')', $str, '(') || true) && 2795 $this->matchChar(')') 2796 ) { 2797 $args = []; 2798 2799 if (! empty($str)) { 2800 $args[] = [null, [Type::T_STRING, '', [$str]]]; 2801 } 2802 2803 $func = [Type::T_FUNCTION_CALL, $name, $args]; 2804 2805 return true; 2806 } 2807 } 2808 2809 $this->seek($s); 2810 2811 return false; 2812 } 2813 2814 /** 2815 * Parse function call argument list 2816 * 2817 * @param array $out 2818 * 2819 * @return bool 2820 */ 2821 protected function argumentList(&$out) 2822 { 2823 $s = $this->count; 2824 $this->matchChar('('); 2825 2826 $args = []; 2827 2828 while ($this->keyword($var)) { 2829 if ( 2830 $this->matchChar('=') && 2831 $this->expression($exp) 2832 ) { 2833 $args[] = [Type::T_STRING, '', [$var . '=']]; 2834 $arg = $exp; 2835 } else { 2836 break; 2837 } 2838 2839 $args[] = $arg; 2840 2841 if (! $this->matchChar(',')) { 2842 break; 2843 } 2844 2845 $args[] = [Type::T_STRING, '', [', ']]; 2846 } 2847 2848 if (! $this->matchChar(')') || ! $args) { 2849 $this->seek($s); 2850 2851 return false; 2852 } 2853 2854 $out = $args; 2855 2856 return true; 2857 } 2858 2859 /** 2860 * Parse mixin/function definition argument list 2861 * 2862 * @param array $out 2863 * 2864 * @return bool 2865 */ 2866 protected function argumentDef(&$out) 2867 { 2868 $s = $this->count; 2869 $this->matchChar('('); 2870 2871 $args = []; 2872 2873 while ($this->variable($var)) { 2874 $arg = [$var[1], null, false]; 2875 2876 $ss = $this->count; 2877 2878 if ( 2879 $this->matchChar(':') && 2880 $this->genericList($defaultVal, 'expression', '', true) 2881 ) { 2882 $arg[1] = $defaultVal; 2883 } else { 2884 $this->seek($ss); 2885 } 2886 2887 $ss = $this->count; 2888 2889 if ($this->literal('...', 3)) { 2890 $sss = $this->count; 2891 2892 if (! $this->matchChar(')')) { 2893 throw $this->parseError('... has to be after the final argument'); 2894 } 2895 2896 $arg[2] = true; 2897 2898 $this->seek($sss); 2899 } else { 2900 $this->seek($ss); 2901 } 2902 2903 $args[] = $arg; 2904 2905 if (! $this->matchChar(',')) { 2906 break; 2907 } 2908 } 2909 2910 if (! $this->matchChar(')')) { 2911 $this->seek($s); 2912 2913 return false; 2914 } 2915 2916 $out = $args; 2917 2918 return true; 2919 } 2920 2921 /** 2922 * Parse map 2923 * 2924 * @param array $out 2925 * 2926 * @return bool 2927 */ 2928 protected function map(&$out) 2929 { 2930 $s = $this->count; 2931 2932 if (! $this->matchChar('(')) { 2933 return false; 2934 } 2935 2936 $keys = []; 2937 $values = []; 2938 2939 while ( 2940 $this->genericList($key, 'expression', '', true) && 2941 $this->matchChar(':') && 2942 $this->genericList($value, 'expression', '', true) 2943 ) { 2944 $keys[] = $key; 2945 $values[] = $value; 2946 2947 if (! $this->matchChar(',')) { 2948 break; 2949 } 2950 } 2951 2952 if (! $keys || ! $this->matchChar(')')) { 2953 $this->seek($s); 2954 2955 return false; 2956 } 2957 2958 $out = [Type::T_MAP, $keys, $values]; 2959 2960 return true; 2961 } 2962 2963 /** 2964 * Parse color 2965 * 2966 * @param array $out 2967 * 2968 * @return bool 2969 */ 2970 protected function color(&$out) 2971 { 2972 $s = $this->count; 2973 2974 if ($this->match('(#([0-9a-f]+)\b)', $m)) { 2975 if (\in_array(\strlen($m[2]), [3,4,6,8])) { 2976 $out = [Type::T_KEYWORD, $m[0]]; 2977 2978 return true; 2979 } 2980 2981 $this->seek($s); 2982 2983 return false; 2984 } 2985 2986 return false; 2987 } 2988 2989 /** 2990 * Parse number with unit 2991 * 2992 * @param array $unit 2993 * 2994 * @return bool 2995 */ 2996 protected function unit(&$unit) 2997 { 2998 $s = $this->count; 2999 3000 if ($this->match('([0-9]*(\.)?[0-9]+)([%a-zA-Z]+)?', $m, false)) { 3001 if (\strlen($this->buffer) === $this->count || ! ctype_digit($this->buffer[$this->count])) { 3002 $this->whitespace(); 3003 3004 $unit = new Node\Number($m[1], empty($m[3]) ? '' : $m[3]); 3005 3006 return true; 3007 } 3008 3009 $this->seek($s); 3010 } 3011 3012 return false; 3013 } 3014 3015 /** 3016 * Parse string 3017 * 3018 * @param array $out 3019 * @param bool $keepDelimWithInterpolation 3020 * 3021 * @return bool 3022 */ 3023 protected function string(&$out, $keepDelimWithInterpolation = false) 3024 { 3025 $s = $this->count; 3026 3027 if ($this->matchChar('"', false)) { 3028 $delim = '"'; 3029 } elseif ($this->matchChar("'", false)) { 3030 $delim = "'"; 3031 } else { 3032 return false; 3033 } 3034 3035 $content = []; 3036 $oldWhite = $this->eatWhiteDefault; 3037 $this->eatWhiteDefault = false; 3038 $hasInterpolation = false; 3039 3040 while ($this->matchString($m, $delim)) { 3041 if ($m[1] !== '') { 3042 $content[] = $m[1]; 3043 } 3044 3045 if ($m[2] === '#{') { 3046 $this->count -= \strlen($m[2]); 3047 3048 if ($this->interpolation($inter, false)) { 3049 $content[] = $inter; 3050 $hasInterpolation = true; 3051 } else { 3052 $this->count += \strlen($m[2]); 3053 $content[] = '#{'; // ignore it 3054 } 3055 } elseif ($m[2] === "\r") { 3056 $content[] = chr(10); 3057 // TODO : warning 3058 # DEPRECATION WARNING on line x, column y of zzz: 3059 # Unescaped multiline strings are deprecated and will be removed in a future version of Sass. 3060 # To include a newline in a string, use "\a" or "\a " as in CSS. 3061 if ($this->matchChar("\n", false)) { 3062 $content[] = ' '; 3063 } 3064 } elseif ($m[2] === '\\') { 3065 if ( 3066 $this->literal("\r\n", 2, false) || 3067 $this->matchChar("\r", false) || 3068 $this->matchChar("\n", false) || 3069 $this->matchChar("\f", false) 3070 ) { 3071 // this is a continuation escaping, to be ignored 3072 } elseif ($this->matchEscapeCharacter($c)) { 3073 $content[] = $c; 3074 } else { 3075 throw $this->parseError('Unterminated escape sequence'); 3076 } 3077 } else { 3078 $this->count -= \strlen($delim); 3079 break; // delim 3080 } 3081 } 3082 3083 $this->eatWhiteDefault = $oldWhite; 3084 3085 if ($this->literal($delim, \strlen($delim))) { 3086 if ($hasInterpolation && ! $keepDelimWithInterpolation) { 3087 $delim = '"'; 3088 } 3089 3090 $out = [Type::T_STRING, $delim, $content]; 3091 3092 return true; 3093 } 3094 3095 $this->seek($s); 3096 3097 return false; 3098 } 3099 3100 /** 3101 * @param string $out 3102 * @param bool $inKeywords 3103 * 3104 * @return bool 3105 */ 3106 protected function matchEscapeCharacter(&$out, $inKeywords = false) 3107 { 3108 $s = $this->count; 3109 if ($this->match('[a-f0-9]', $m, false)) { 3110 $hex = $m[0]; 3111 3112 for ($i = 5; $i--;) { 3113 if ($this->match('[a-f0-9]', $m, false)) { 3114 $hex .= $m[0]; 3115 } else { 3116 break; 3117 } 3118 } 3119 3120 // CSS allows Unicode escape sequences to be followed by a delimiter space 3121 // (necessary in some cases for shorter sequences to disambiguate their end) 3122 $this->matchChar(' ', false); 3123 3124 $value = hexdec($hex); 3125 3126 if (!$inKeywords && ($value == 0 || ($value >= 0xD800 && $value <= 0xDFFF) || $value >= 0x10FFFF)) { 3127 $out = "\xEF\xBF\xBD"; // "\u{FFFD}" but with a syntax supported on PHP 5 3128 } elseif ($value < 0x20) { 3129 $out = Util::mbChr($value); 3130 } else { 3131 $out = Util::mbChr($value); 3132 } 3133 3134 return true; 3135 } 3136 3137 if ($this->match('.', $m, false)) { 3138 if ($inKeywords && in_array($m[0], ["'",'"','@','&',' ','\\',':','/','%'])) { 3139 $this->seek($s); 3140 return false; 3141 } 3142 $out = $m[0]; 3143 3144 return true; 3145 } 3146 3147 return false; 3148 } 3149 3150 /** 3151 * Parse keyword or interpolation 3152 * 3153 * @param array $out 3154 * @param bool $restricted 3155 * 3156 * @return bool 3157 */ 3158 protected function mixedKeyword(&$out, $restricted = false) 3159 { 3160 $parts = []; 3161 3162 $oldWhite = $this->eatWhiteDefault; 3163 $this->eatWhiteDefault = false; 3164 3165 for (;;) { 3166 if ($restricted ? $this->restrictedKeyword($key) : $this->keyword($key)) { 3167 $parts[] = $key; 3168 continue; 3169 } 3170 3171 if ($this->interpolation($inter)) { 3172 $parts[] = $inter; 3173 continue; 3174 } 3175 3176 break; 3177 } 3178 3179 $this->eatWhiteDefault = $oldWhite; 3180 3181 if (! $parts) { 3182 return false; 3183 } 3184 3185 if ($this->eatWhiteDefault) { 3186 $this->whitespace(); 3187 } 3188 3189 $out = $parts; 3190 3191 return true; 3192 } 3193 3194 /** 3195 * Parse an unbounded string stopped by $end 3196 * 3197 * @param string $end 3198 * @param array $out 3199 * @param string $nestOpen 3200 * @param string $nestClose 3201 * @param bool $rtrim 3202 * @param string $disallow 3203 * 3204 * @return bool 3205 */ 3206 protected function openString($end, &$out, $nestOpen = null, $nestClose = null, $rtrim = true, $disallow = null) 3207 { 3208 $oldWhite = $this->eatWhiteDefault; 3209 $this->eatWhiteDefault = false; 3210 3211 if ($nestOpen && ! $nestClose) { 3212 $nestClose = $end; 3213 } 3214 3215 $patt = ($disallow ? '[^' . $this->pregQuote($disallow) . ']' : '.'); 3216 $patt = '(' . $patt . '*?)([\'"]|#\{|' 3217 . $this->pregQuote($end) . '|' 3218 . (($nestClose && $nestClose !== $end) ? $this->pregQuote($nestClose) . '|' : '') 3219 . static::$commentPattern . ')'; 3220 3221 $nestingLevel = 0; 3222 3223 $content = []; 3224 3225 while ($this->match($patt, $m, false)) { 3226 if (isset($m[1]) && $m[1] !== '') { 3227 $content[] = $m[1]; 3228 3229 if ($nestOpen) { 3230 $nestingLevel += substr_count($m[1], $nestOpen); 3231 } 3232 } 3233 3234 $tok = $m[2]; 3235 3236 $this->count -= \strlen($tok); 3237 3238 if ($tok === $end && ! $nestingLevel) { 3239 break; 3240 } 3241 3242 if ($tok === $nestClose) { 3243 $nestingLevel--; 3244 } 3245 3246 if (($tok === "'" || $tok === '"') && $this->string($str, true)) { 3247 $content[] = $str; 3248 continue; 3249 } 3250 3251 if ($tok === '#{' && $this->interpolation($inter)) { 3252 $content[] = $inter; 3253 continue; 3254 } 3255 3256 $content[] = $tok; 3257 $this->count += \strlen($tok); 3258 } 3259 3260 $this->eatWhiteDefault = $oldWhite; 3261 3262 if (! $content || $tok !== $end) { 3263 return false; 3264 } 3265 3266 // trim the end 3267 if ($rtrim && \is_string(end($content))) { 3268 $content[\count($content) - 1] = rtrim(end($content)); 3269 } 3270 3271 $out = [Type::T_STRING, '', $content]; 3272 3273 return true; 3274 } 3275 3276 /** 3277 * Parser interpolation 3278 * 3279 * @param string|array $out 3280 * @param bool $lookWhite save information about whitespace before and after 3281 * 3282 * @return bool 3283 */ 3284 protected function interpolation(&$out, $lookWhite = true) 3285 { 3286 $oldWhite = $this->eatWhiteDefault; 3287 $allowVars = $this->allowVars; 3288 $this->allowVars = true; 3289 $this->eatWhiteDefault = true; 3290 3291 $s = $this->count; 3292 3293 if ( 3294 $this->literal('#{', 2) && 3295 $this->valueList($value) && 3296 $this->matchChar('}', false) 3297 ) { 3298 if ($value === [Type::T_SELF]) { 3299 $out = $value; 3300 } else { 3301 if ($lookWhite) { 3302 $left = ($s > 0 && preg_match('/\s/', $this->buffer[$s - 1])) ? ' ' : ''; 3303 $right = ( 3304 ! empty($this->buffer[$this->count]) && 3305 preg_match('/\s/', $this->buffer[$this->count]) 3306 ) ? ' ' : ''; 3307 } else { 3308 $left = $right = false; 3309 } 3310 3311 $out = [Type::T_INTERPOLATE, $value, $left, $right]; 3312 } 3313 3314 $this->eatWhiteDefault = $oldWhite; 3315 $this->allowVars = $allowVars; 3316 3317 if ($this->eatWhiteDefault) { 3318 $this->whitespace(); 3319 } 3320 3321 return true; 3322 } 3323 3324 $this->seek($s); 3325 3326 $this->eatWhiteDefault = $oldWhite; 3327 $this->allowVars = $allowVars; 3328 3329 return false; 3330 } 3331 3332 /** 3333 * Parse property name (as an array of parts or a string) 3334 * 3335 * @param array $out 3336 * 3337 * @return bool 3338 */ 3339 protected function propertyName(&$out) 3340 { 3341 $parts = []; 3342 3343 $oldWhite = $this->eatWhiteDefault; 3344 $this->eatWhiteDefault = false; 3345 3346 for (;;) { 3347 if ($this->interpolation($inter)) { 3348 $parts[] = $inter; 3349 continue; 3350 } 3351 3352 if ($this->keyword($text)) { 3353 $parts[] = $text; 3354 continue; 3355 } 3356 3357 if (! $parts && $this->match('[:.#]', $m, false)) { 3358 // css hacks 3359 $parts[] = $m[0]; 3360 continue; 3361 } 3362 3363 break; 3364 } 3365 3366 $this->eatWhiteDefault = $oldWhite; 3367 3368 if (! $parts) { 3369 return false; 3370 } 3371 3372 // match comment hack 3373 if (preg_match(static::$whitePattern, $this->buffer, $m, 0, $this->count)) { 3374 if (! empty($m[0])) { 3375 $parts[] = $m[0]; 3376 $this->count += \strlen($m[0]); 3377 } 3378 } 3379 3380 $this->whitespace(); // get any extra whitespace 3381 3382 $out = [Type::T_STRING, '', $parts]; 3383 3384 return true; 3385 } 3386 3387 /** 3388 * Parse custom property name (as an array of parts or a string) 3389 * 3390 * @param array $out 3391 * 3392 * @return bool 3393 */ 3394 protected function customProperty(&$out) 3395 { 3396 $s = $this->count; 3397 3398 if (! $this->literal('--', 2, false)) { 3399 return false; 3400 } 3401 3402 $parts = ['--']; 3403 3404 $oldWhite = $this->eatWhiteDefault; 3405 $this->eatWhiteDefault = false; 3406 3407 for (;;) { 3408 if ($this->interpolation($inter)) { 3409 $parts[] = $inter; 3410 continue; 3411 } 3412 3413 if ($this->matchChar('&', false)) { 3414 $parts[] = [Type::T_SELF]; 3415 continue; 3416 } 3417 3418 if ($this->variable($var)) { 3419 $parts[] = $var; 3420 continue; 3421 } 3422 3423 if ($this->keyword($text)) { 3424 $parts[] = $text; 3425 continue; 3426 } 3427 3428 break; 3429 } 3430 3431 $this->eatWhiteDefault = $oldWhite; 3432 3433 if (\count($parts) == 1) { 3434 $this->seek($s); 3435 3436 return false; 3437 } 3438 3439 $this->whitespace(); // get any extra whitespace 3440 3441 $out = [Type::T_STRING, '', $parts]; 3442 3443 return true; 3444 } 3445 3446 /** 3447 * Parse comma separated selector list 3448 * 3449 * @param array $out 3450 * @param string|bool $subSelector 3451 * 3452 * @return bool 3453 */ 3454 protected function selectors(&$out, $subSelector = false) 3455 { 3456 $s = $this->count; 3457 $selectors = []; 3458 3459 while ($this->selector($sel, $subSelector)) { 3460 $selectors[] = $sel; 3461 3462 if (! $this->matchChar(',', true)) { 3463 break; 3464 } 3465 3466 while ($this->matchChar(',', true)) { 3467 ; // ignore extra 3468 } 3469 } 3470 3471 if (! $selectors) { 3472 $this->seek($s); 3473 3474 return false; 3475 } 3476 3477 $out = $selectors; 3478 3479 return true; 3480 } 3481 3482 /** 3483 * Parse whitespace separated selector list 3484 * 3485 * @param array $out 3486 * @param string|bool $subSelector 3487 * 3488 * @return bool 3489 */ 3490 protected function selector(&$out, $subSelector = false) 3491 { 3492 $selector = []; 3493 3494 $discardComments = $this->discardComments; 3495 $this->discardComments = true; 3496 3497 for (;;) { 3498 $s = $this->count; 3499 3500 if ($this->match('[>+~]+', $m, true)) { 3501 if ( 3502 $subSelector && \is_string($subSelector) && strpos($subSelector, 'nth-') === 0 && 3503 $m[0] === '+' && $this->match("(\d+|n\b)", $counter) 3504 ) { 3505 $this->seek($s); 3506 } else { 3507 $selector[] = [$m[0]]; 3508 continue; 3509 } 3510 } 3511 3512 if ($this->selectorSingle($part, $subSelector)) { 3513 $selector[] = $part; 3514 $this->whitespace(); 3515 continue; 3516 } 3517 3518 break; 3519 } 3520 3521 $this->discardComments = $discardComments; 3522 3523 if (! $selector) { 3524 return false; 3525 } 3526 3527 $out = $selector; 3528 3529 return true; 3530 } 3531 3532 /** 3533 * parsing escaped chars in selectors: 3534 * - escaped single chars are kept escaped in the selector but in a normalized form 3535 * (if not in 0-9a-f range as this would be ambigous) 3536 * - other escaped sequences (multibyte chars or 0-9a-f) are kept in their initial escaped form, 3537 * normalized to lowercase 3538 * 3539 * TODO: this is a fallback solution. Ideally escaped chars in selectors should be encoded as the genuine chars, 3540 * and escaping added when printing in the Compiler, where/if it's mandatory 3541 * - but this require a better formal selector representation instead of the array we have now 3542 * 3543 * @param string $out 3544 * @param bool $keepEscapedNumber 3545 * 3546 * @return bool 3547 */ 3548 protected function matchEscapeCharacterInSelector(&$out, $keepEscapedNumber = false) 3549 { 3550 $s_escape = $this->count; 3551 if ($this->match('\\\\', $m)) { 3552 $out = '\\' . $m[0]; 3553 return true; 3554 } 3555 3556 if ($this->matchEscapeCharacter($escapedout, true)) { 3557 if (strlen($escapedout) === 1) { 3558 if (!preg_match(",\w,", $escapedout)) { 3559 $out = '\\' . $escapedout; 3560 return true; 3561 } elseif (! $keepEscapedNumber || ! \is_numeric($escapedout)) { 3562 $out = $escapedout; 3563 return true; 3564 } 3565 } 3566 $escape_sequence = rtrim(substr($this->buffer, $s_escape, $this->count - $s_escape)); 3567 if (strlen($escape_sequence) < 6) { 3568 $escape_sequence .= ' '; 3569 } 3570 $out = '\\' . strtolower($escape_sequence); 3571 return true; 3572 } 3573 if ($this->match('\\S', $m)) { 3574 $out = '\\' . $m[0]; 3575 return true; 3576 } 3577 3578 3579 return false; 3580 } 3581 3582 /** 3583 * Parse the parts that make up a selector 3584 * 3585 * {@internal 3586 * div[yes=no]#something.hello.world:nth-child(-2n+1)%placeholder 3587 * }} 3588 * 3589 * @param array $out 3590 * @param string|bool $subSelector 3591 * 3592 * @return bool 3593 */ 3594 protected function selectorSingle(&$out, $subSelector = false) 3595 { 3596 $oldWhite = $this->eatWhiteDefault; 3597 $this->eatWhiteDefault = false; 3598 3599 $parts = []; 3600 3601 if ($this->matchChar('*', false)) { 3602 $parts[] = '*'; 3603 } 3604 3605 for (;;) { 3606 if (! isset($this->buffer[$this->count])) { 3607 break; 3608 } 3609 3610 $s = $this->count; 3611 $char = $this->buffer[$this->count]; 3612 3613 // see if we can stop early 3614 if ($char === '{' || $char === ',' || $char === ';' || $char === '}' || $char === '@') { 3615 break; 3616 } 3617 3618 // parsing a sub selector in () stop with the closing ) 3619 if ($subSelector && $char === ')') { 3620 break; 3621 } 3622 3623 //self 3624 switch ($char) { 3625 case '&': 3626 $parts[] = Compiler::$selfSelector; 3627 $this->count++; 3628 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 3629 continue 2; 3630 3631 case '.': 3632 $parts[] = '.'; 3633 $this->count++; 3634 continue 2; 3635 3636 case '|': 3637 $parts[] = '|'; 3638 $this->count++; 3639 continue 2; 3640 } 3641 3642 // handling of escaping in selectors : get the escaped char 3643 if ($char === '\\') { 3644 $this->count++; 3645 if ($this->matchEscapeCharacterInSelector($escaped, true)) { 3646 $parts[] = $escaped; 3647 continue; 3648 } 3649 $this->count--; 3650 } 3651 3652 if ($char === '%') { 3653 $this->count++; 3654 3655 if ($this->placeholder($placeholder)) { 3656 $parts[] = '%'; 3657 $parts[] = $placeholder; 3658 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 3659 continue; 3660 } 3661 3662 break; 3663 } 3664 3665 if ($char === '#') { 3666 if ($this->interpolation($inter)) { 3667 $parts[] = $inter; 3668 ! $this->cssOnly || $this->assertPlainCssValid(false, $s); 3669 continue; 3670 } 3671 3672 $parts[] = '#'; 3673 $this->count++; 3674 continue; 3675 } 3676 3677 // a pseudo selector 3678 if ($char === ':') { 3679 if ($this->buffer[$this->count + 1] === ':') { 3680 $this->count += 2; 3681 $part = '::'; 3682 } else { 3683 $this->count++; 3684 $part = ':'; 3685 } 3686 3687 if ($this->mixedKeyword($nameParts, true)) { 3688 $parts[] = $part; 3689 3690 foreach ($nameParts as $sub) { 3691 $parts[] = $sub; 3692 } 3693 3694 $ss = $this->count; 3695 3696 if ( 3697 $nameParts === ['not'] || 3698 $nameParts === ['is'] || 3699 $nameParts === ['has'] || 3700 $nameParts === ['where'] || 3701 $nameParts === ['slotted'] || 3702 $nameParts === ['nth-child'] || 3703 $nameParts === ['nth-last-child'] || 3704 $nameParts === ['nth-of-type'] || 3705 $nameParts === ['nth-last-of-type'] 3706 ) { 3707 if ( 3708 $this->matchChar('(', true) && 3709 ($this->selectors($subs, reset($nameParts)) || true) && 3710 $this->matchChar(')') 3711 ) { 3712 $parts[] = '('; 3713 3714 while ($sub = array_shift($subs)) { 3715 while ($ps = array_shift($sub)) { 3716 foreach ($ps as &$p) { 3717 $parts[] = $p; 3718 } 3719 3720 if (\count($sub) && reset($sub)) { 3721 $parts[] = ' '; 3722 } 3723 } 3724 3725 if (\count($subs) && reset($subs)) { 3726 $parts[] = ', '; 3727 } 3728 } 3729 3730 $parts[] = ')'; 3731 } else { 3732 $this->seek($ss); 3733 } 3734 } elseif ( 3735 $this->matchChar('(', true) && 3736 ($this->openString(')', $str, '(') || true) && 3737 $this->matchChar(')') 3738 ) { 3739 $parts[] = '('; 3740 3741 if (! empty($str)) { 3742 $parts[] = $str; 3743 } 3744 3745 $parts[] = ')'; 3746 } else { 3747 $this->seek($ss); 3748 } 3749 3750 continue; 3751 } 3752 } 3753 3754 $this->seek($s); 3755 3756 // 2n+1 3757 if ($subSelector && \is_string($subSelector) && strpos($subSelector, 'nth-') === 0) { 3758 if ($this->match("(\s*(\+\s*|\-\s*)?(\d+|n|\d+n))+", $counter)) { 3759 $parts[] = $counter[0]; 3760 //$parts[] = str_replace(' ', '', $counter[0]); 3761 continue; 3762 } 3763 } 3764 3765 $this->seek($s); 3766 3767 // attribute selector 3768 if ( 3769 $char === '[' && 3770 $this->matchChar('[') && 3771 ($this->openString(']', $str, '[') || true) && 3772 $this->matchChar(']') 3773 ) { 3774 $parts[] = '['; 3775 3776 if (! empty($str)) { 3777 $parts[] = $str; 3778 } 3779 3780 $parts[] = ']'; 3781 continue; 3782 } 3783 3784 $this->seek($s); 3785 3786 // for keyframes 3787 if ($this->unit($unit)) { 3788 $parts[] = $unit; 3789 continue; 3790 } 3791 3792 if ($this->restrictedKeyword($name, false, true)) { 3793 $parts[] = $name; 3794 continue; 3795 } 3796 3797 break; 3798 } 3799 3800 $this->eatWhiteDefault = $oldWhite; 3801 3802 if (! $parts) { 3803 return false; 3804 } 3805 3806 $out = $parts; 3807 3808 return true; 3809 } 3810 3811 /** 3812 * Parse a variable 3813 * 3814 * @param array $out 3815 * 3816 * @return bool 3817 */ 3818 protected function variable(&$out) 3819 { 3820 $s = $this->count; 3821 3822 if ( 3823 $this->matchChar('$', false) && 3824 $this->keyword($name) 3825 ) { 3826 if ($this->allowVars) { 3827 $out = [Type::T_VARIABLE, $name]; 3828 } else { 3829 $out = [Type::T_KEYWORD, '$' . $name]; 3830 } 3831 3832 return true; 3833 } 3834 3835 $this->seek($s); 3836 3837 return false; 3838 } 3839 3840 /** 3841 * Parse a keyword 3842 * 3843 * @param string $word 3844 * @param bool $eatWhitespace 3845 * @param bool $inSelector 3846 * 3847 * @return bool 3848 */ 3849 protected function keyword(&$word, $eatWhitespace = null, $inSelector = false) 3850 { 3851 $s = $this->count; 3852 $match = $this->match( 3853 $this->utf8 3854 ? '(([\pL\w\x{00A0}-\x{10FFFF}_\-\*!"\']|\\\\[a-f0-9]{6} ?|\\\\[a-f0-9]{1,5}(?![a-f0-9]) ?|[\\\\].)([\pL\w\x{00A0}-\x{10FFFF}\-_"\']|\\\\[a-f0-9]{6} ?|\\\\[a-f0-9]{1,5}(?![a-f0-9]) ?|[\\\\].)*)' 3855 : '(([\w_\-\*!"\']|\\\\[a-f0-9]{6} ?|\\\\[a-f0-9]{1,5}(?![a-f0-9]) ?|[\\\\].)([\w\-_"\']|\\\\[a-f0-9]{6} ?|\\\\[a-f0-9]{1,5}(?![a-f0-9]) ?|[\\\\].)*)', 3856 $m, 3857 false 3858 ); 3859 3860 if ($match) { 3861 $word = $m[1]; 3862 3863 // handling of escaping in keyword : get the escaped char 3864 if (strpos($word, '\\') !== false) { 3865 $send = $this->count; 3866 $escapedWord = []; 3867 $this->seek($s); 3868 $previousEscape = false; 3869 while ($this->count < $send) { 3870 $char = $this->buffer[$this->count]; 3871 $this->count++; 3872 if ( 3873 $this->count < $send 3874 && $char === '\\' 3875 && !$previousEscape 3876 && ( 3877 $inSelector ? 3878 $this->matchEscapeCharacterInSelector($out) 3879 : 3880 $this->matchEscapeCharacter($out, true) 3881 ) 3882 ) { 3883 $escapedWord[] = $out; 3884 } else { 3885 if ($previousEscape) { 3886 $previousEscape = false; 3887 } elseif ($char === '\\') { 3888 $previousEscape = true; 3889 } 3890 $escapedWord[] = $char; 3891 } 3892 } 3893 3894 $word = implode('', $escapedWord); 3895 } 3896 3897 if (is_null($eatWhitespace) ? $this->eatWhiteDefault : $eatWhitespace) { 3898 $this->whitespace(); 3899 } 3900 3901 return true; 3902 } 3903 3904 return false; 3905 } 3906 3907 /** 3908 * Parse a keyword that should not start with a number 3909 * 3910 * @param string $word 3911 * @param bool $eatWhitespace 3912 * @param bool $inSelector 3913 * 3914 * @return bool 3915 */ 3916 protected function restrictedKeyword(&$word, $eatWhitespace = null, $inSelector = false) 3917 { 3918 $s = $this->count; 3919 3920 if ($this->keyword($word, $eatWhitespace, $inSelector) && (\ord($word[0]) > 57 || \ord($word[0]) < 48)) { 3921 return true; 3922 } 3923 3924 $this->seek($s); 3925 3926 return false; 3927 } 3928 3929 /** 3930 * Parse a placeholder 3931 * 3932 * @param string|array $placeholder 3933 * 3934 * @return bool 3935 */ 3936 protected function placeholder(&$placeholder) 3937 { 3938 $match = $this->match( 3939 $this->utf8 3940 ? '([\pL\w\-_]+)' 3941 : '([\w\-_]+)', 3942 $m 3943 ); 3944 3945 if ($match) { 3946 $placeholder = $m[1]; 3947 3948 return true; 3949 } 3950 3951 if ($this->interpolation($placeholder)) { 3952 return true; 3953 } 3954 3955 return false; 3956 } 3957 3958 /** 3959 * Parse a url 3960 * 3961 * @param array $out 3962 * 3963 * @return bool 3964 */ 3965 protected function url(&$out) 3966 { 3967 if ($this->literal('url(', 4)) { 3968 $s = $this->count; 3969 3970 if ( 3971 ($this->string($out) || $this->spaceList($out)) && 3972 $this->matchChar(')') 3973 ) { 3974 $out = [Type::T_STRING, '', ['url(', $out, ')']]; 3975 3976 return true; 3977 } 3978 3979 $this->seek($s); 3980 3981 if ( 3982 $this->openString(')', $out) && 3983 $this->matchChar(')') 3984 ) { 3985 $out = [Type::T_STRING, '', ['url(', $out, ')']]; 3986 3987 return true; 3988 } 3989 } 3990 3991 return false; 3992 } 3993 3994 /** 3995 * Consume an end of statement delimiter 3996 * @param bool $eatWhitespace 3997 * 3998 * @return bool 3999 */ 4000 protected function end($eatWhitespace = null) 4001 { 4002 if ($this->matchChar(';', $eatWhitespace)) { 4003 return true; 4004 } 4005 4006 if ($this->count === \strlen($this->buffer) || $this->buffer[$this->count] === '}') { 4007 // if there is end of file or a closing block next then we don't need a ; 4008 return true; 4009 } 4010 4011 return false; 4012 } 4013 4014 /** 4015 * Strip assignment flag from the list 4016 * 4017 * @param array $value 4018 * 4019 * @return array 4020 */ 4021 protected function stripAssignmentFlags(&$value) 4022 { 4023 $flags = []; 4024 4025 for ($token = &$value; $token[0] === Type::T_LIST && ($s = \count($token[2])); $token = &$lastNode) { 4026 $lastNode = &$token[2][$s - 1]; 4027 4028 while ($lastNode[0] === Type::T_KEYWORD && \in_array($lastNode[1], ['!default', '!global'])) { 4029 array_pop($token[2]); 4030 4031 $node = end($token[2]); 4032 $token = $this->flattenList($token); 4033 $flags[] = $lastNode[1]; 4034 $lastNode = $node; 4035 } 4036 } 4037 4038 return $flags; 4039 } 4040 4041 /** 4042 * Strip optional flag from selector list 4043 * 4044 * @param array $selectors 4045 * 4046 * @return string 4047 */ 4048 protected function stripOptionalFlag(&$selectors) 4049 { 4050 $optional = false; 4051 $selector = end($selectors); 4052 $part = end($selector); 4053 4054 if ($part === ['!optional']) { 4055 array_pop($selectors[\count($selectors) - 1]); 4056 4057 $optional = true; 4058 } 4059 4060 return $optional; 4061 } 4062 4063 /** 4064 * Turn list of length 1 into value type 4065 * 4066 * @param array $value 4067 * 4068 * @return array 4069 */ 4070 protected function flattenList($value) 4071 { 4072 if ($value[0] === Type::T_LIST && \count($value[2]) === 1) { 4073 return $this->flattenList($value[2][0]); 4074 } 4075 4076 return $value; 4077 } 4078 4079 /** 4080 * Quote regular expression 4081 * 4082 * @param string $what 4083 * 4084 * @return string 4085 */ 4086 private function pregQuote($what) 4087 { 4088 return preg_quote($what, '/'); 4089 } 4090 4091 /** 4092 * Extract line numbers from buffer 4093 * 4094 * @param string $buffer 4095 */ 4096 private function extractLineNumbers($buffer) 4097 { 4098 $this->sourcePositions = [0 => 0]; 4099 $prev = 0; 4100 4101 while (($pos = strpos($buffer, "\n", $prev)) !== false) { 4102 $this->sourcePositions[] = $pos; 4103 $prev = $pos + 1; 4104 } 4105 4106 $this->sourcePositions[] = \strlen($buffer); 4107 4108 if (substr($buffer, -1) !== "\n") { 4109 $this->sourcePositions[] = \strlen($buffer) + 1; 4110 } 4111 } 4112 4113 /** 4114 * Get source line number and column (given character position in the buffer) 4115 * 4116 * @param int $pos 4117 * 4118 * @return array 4119 */ 4120 private function getSourcePosition($pos) 4121 { 4122 $low = 0; 4123 $high = \count($this->sourcePositions); 4124 4125 while ($low < $high) { 4126 $mid = (int) (($high + $low) / 2); 4127 4128 if ($pos < $this->sourcePositions[$mid]) { 4129 $high = $mid - 1; 4130 continue; 4131 } 4132 4133 if ($pos >= $this->sourcePositions[$mid + 1]) { 4134 $low = $mid + 1; 4135 continue; 4136 } 4137 4138 return [$mid + 1, $pos - $this->sourcePositions[$mid]]; 4139 } 4140 4141 return [$low + 1, $pos - $this->sourcePositions[$low]]; 4142 } 4143 4144 /** 4145 * Save internal encoding of mbstring 4146 * 4147 * When mbstring.func_overload is used to replace the standard PHP string functions, 4148 * this method configures the internal encoding to a single-byte one so that the 4149 * behavior matches the normal behavior of PHP string functions while using the parser. 4150 * The existing internal encoding is saved and will be restored when calling {@see restoreEncoding}. 4151 * 4152 * If mbstring.func_overload is not used (or does not override string functions), this method is a no-op. 4153 * 4154 * @return void 4155 */ 4156 private function saveEncoding() 4157 { 4158 if (\PHP_VERSION_ID < 80000 && \extension_loaded('mbstring') && (2 & (int) ini_get('mbstring.func_overload')) > 0) { 4159 $this->encoding = mb_internal_encoding(); 4160 4161 mb_internal_encoding('iso-8859-1'); 4162 } 4163 } 4164 4165 /** 4166 * Restore internal encoding 4167 * 4168 * @return void 4169 */ 4170 private function restoreEncoding() 4171 { 4172 if (\extension_loaded('mbstring') && $this->encoding) { 4173 mb_internal_encoding($this->encoding); 4174 } 4175 } 4176 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body