Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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

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