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