Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

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

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