Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

Differences Between: [Versions 310 and 400] [Versions 311 and 400] [Versions 39 and 400] [Versions 400 and 401] [Versions 400 and 402] [Versions 400 and 403]

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