[ Index ]

PHP Cross Reference of DokuWiki

title

Body

[close]

/vendor/splitbrain/lesserphp/src/ -> Parser.php (source)

   1  <?php
   2  /**
   3   * http://leafo.net/lessphp
   4   *
   5   * LESS CSS compiler, adapted from http://lesscss.org
   6   *
   7   * Copyright 2013, Leaf Corcoran <leafot@gmail.com>
   8   * Copyright 2016, Marcus Schwarz <github@maswaba.de>
   9   * Licensed under MIT or GPLv3, see LICENSE
  10   */
  11  
  12  namespace LesserPHP;
  13  
  14  use Exception;
  15  use LesserPHP\Utils\Util;
  16  use stdClass;
  17  
  18  /**
  19   * responsible for taking a string of LESS code and converting it into a syntax tree
  20   */
  21  class Parser
  22  {
  23  
  24      public $eatWhiteDefault;
  25      public $sourceName;
  26      public $writeComments;
  27      public $count;
  28      public $line;
  29      public $env;
  30      public $buffer;
  31      public $seenComments;
  32      public $inExp;
  33  
  34      protected static $nextBlockId = 0; // used to uniquely identify blocks
  35  
  36      protected static $precedence = [
  37          '=<' => 0,
  38          '>=' => 0,
  39          '=' => 0,
  40          '<' => 0,
  41          '>' => 0,
  42  
  43          '+' => 1,
  44          '-' => 1,
  45          '*' => 2,
  46          '/' => 2,
  47          '%' => 2,
  48      ];
  49  
  50      protected static $whitePattern;
  51      protected static $commentMulti;
  52  
  53      protected static $commentSingle = '//';
  54      protected static $commentMultiLeft = '/*';
  55      protected static $commentMultiRight = '*/';
  56  
  57      // regex string to match any of the operators
  58      protected static $operatorString;
  59  
  60      // these properties will supress division unless it's inside parenthases
  61      protected static $supressDivisionProps =
  62          ['/border-radius$/i', '/^font$/i'];
  63  
  64      protected $blockDirectives = [
  65          'font-face',
  66          'keyframes',
  67          'page',
  68          '-moz-document',
  69          'viewport',
  70          '-moz-viewport',
  71          '-o-viewport',
  72          '-ms-viewport'
  73      ];
  74      protected $lineDirectives = ['charset'];
  75  
  76      /**
  77       * if we are in parens we can be more liberal with whitespace around
  78       * operators because it must evaluate to a single value and thus is less
  79       * ambiguous.
  80       *
  81       * Consider:
  82       *     property1: 10 -5; // is two numbers, 10 and -5
  83       *     property2: (10 -5); // should evaluate to 5
  84       */
  85      protected $inParens = false;
  86  
  87      // caches preg escaped literals
  88      protected static $literalCache = [];
  89  
  90      protected $currentProperty;
  91  
  92      /**
  93       * @param string|null $sourceName name used for error messages
  94       */
  95      public function __construct(?string $sourceName = null)
  96      {
  97          $this->eatWhiteDefault = true;
  98          $this->sourceName = $sourceName; // name used for error messages
  99  
 100          $this->writeComments = false;
 101  
 102          if (!self::$operatorString) {
 103              self::$operatorString =
 104                  '(' . implode('|', array_map(
 105                      [Util::class, 'pregQuote'],
 106                      array_keys(self::$precedence)
 107                  )) . ')';
 108  
 109              $commentSingle = Util::pregQuote(self::$commentSingle);
 110              $commentMultiLeft = Util::pregQuote(self::$commentMultiLeft);
 111              $commentMultiRight = Util::pregQuote(self::$commentMultiRight);
 112  
 113              self::$commentMulti = $commentMultiLeft . '.*?' . $commentMultiRight;
 114              self::$whitePattern = '/' . $commentSingle . '[^\n]*\s*|(' . self::$commentMulti . ')\s*|\s+/Ais';
 115          }
 116      }
 117  
 118      /**
 119       * @throws Exception
 120       */
 121      public function parse($buffer)
 122      {
 123          $this->count = 0;
 124          $this->line = 1;
 125  
 126          $this->env = null; // block stack
 127          $this->buffer = $this->writeComments ? $buffer : $this->removeComments($buffer);
 128          $this->pushSpecialBlock('root');
 129          $this->eatWhiteDefault = true;
 130          $this->seenComments = [];
 131  
 132          // trim whitespace on head
 133          // if (preg_match('/^\s+/', $this->buffer, $m)) {
 134          //  $this->line += substr_count($m[0], "\n");
 135          //  $this->buffer = ltrim($this->buffer);
 136          // }
 137          $this->whitespace();
 138  
 139          // parse the entire file
 140          while (false !== $this->parseChunk()) {
 141              // no-op
 142          }
 143  
 144          if ($this->count != strlen($this->buffer)) {
 145              $this->throwError(sprintf(
 146                  "parse error: count mismatches buffer length %d != %d",
 147                  $this->count,
 148                  strlen($this->buffer)
 149              ));
 150          }
 151  
 152          // TODO report where the block was opened
 153          if (!property_exists($this->env, 'parent') || !is_null($this->env->parent)) {
 154              $this->throwError('parse error: unclosed block');
 155          }
 156  
 157          return $this->env;
 158      }
 159  
 160      /**
 161       * Parse a single chunk off the head of the buffer and append it to the
 162       * current parse environment.
 163       * Returns false when the buffer is empty, or when there is an error.
 164       *
 165       * This function is called repeatedly until the entire document is
 166       * parsed.
 167       *
 168       * This parser is most similar to a recursive descent parser. Single
 169       * functions represent discrete grammatical rules for the language, and
 170       * they are able to capture the text that represents those rules.
 171       *
 172       * Consider the function Lessc::keyword(). (all parse functions are
 173       * structured the same)
 174       *
 175       * The function takes a single reference argument. When calling the
 176       * function it will attempt to match a keyword on the head of the buffer.
 177       * If it is successful, it will place the keyword in the referenced
 178       * argument, advance the position in the buffer, and return true. If it
 179       * fails then it won't advance the buffer and it will return false.
 180       *
 181       * All of these parse functions are powered by Lessc::match(), which behaves
 182       * the same way, but takes a literal regular expression. Sometimes it is
 183       * more convenient to use match instead of creating a new function.
 184       *
 185       * Because of the format of the functions, to parse an entire string of
 186       * grammatical rules, you can chain them together using &&.
 187       *
 188       * But, if some of the rules in the chain succeed before one fails, then
 189       * the buffer position will be left at an invalid state. In order to
 190       * avoid this, Lessc::seek() is used to remember and set buffer positions.
 191       *
 192       * Before parsing a chain, use $s = $this->seek() to remember the current
 193       * position into $s. Then if a chain fails, use $this->seek($s) to
 194       * go back where we started.
 195       *
 196       * @throws Exception
 197       */
 198      protected function parseChunk()
 199      {
 200          if (empty($this->buffer)) return false;
 201          $s = $this->seek();
 202  
 203          if ($this->whitespace()) {
 204              return true;
 205          }
 206  
 207          // setting a property
 208          if ($this->keyword($key) && $this->assign() &&
 209              $this->propertyValue($value, $key) && $this->end()) {
 210              $this->append(['assign', $key, $value], $s);
 211              return true;
 212          } else {
 213              $this->seek($s);
 214          }
 215  
 216  
 217          // look for special css blocks
 218          if ($this->literal('@', false)) {
 219              $this->count--;
 220  
 221              // media
 222              if ($this->literal('@media')) {
 223                  if (($this->mediaQueryList($mediaQueries) || true)
 224                      && $this->literal('{')) {
 225                      $media = $this->pushSpecialBlock('media');
 226                      $media->queries = is_null($mediaQueries) ? [] : $mediaQueries;
 227                      return true;
 228                  } else {
 229                      $this->seek($s);
 230                      return false;
 231                  }
 232              }
 233  
 234              if ($this->literal('@', false) && $this->keyword($dirName)) {
 235                  if ($this->isDirective($dirName, $this->blockDirectives)) {
 236                      if (($this->openString('{', $dirValue, null, [';']) || true) &&
 237                          $this->literal('{')) {
 238                          $dir = $this->pushSpecialBlock('directive');
 239                          $dir->name = $dirName;
 240                          if (isset($dirValue)) $dir->value = $dirValue;
 241                          return true;
 242                      }
 243                  } elseif ($this->isDirective($dirName, $this->lineDirectives)) {
 244                      if ($this->propertyValue($dirValue) && $this->end()) {
 245                          $this->append(['directive', $dirName, $dirValue]);
 246                          return true;
 247                      }
 248                  } elseif ($this->literal(':', true)) {
 249                      //Ruleset Definition
 250                      if (($this->openString('{', $dirValue, null, [';']) || true) &&
 251                          $this->literal('{')) {
 252                          $dir = $this->pushBlock($this->fixTags(['@' . $dirName]));
 253                          $dir->name = $dirName;
 254                          if (isset($dirValue)) $dir->value = $dirValue;
 255                          return true;
 256                      }
 257                  }
 258              }
 259  
 260              $this->seek($s);
 261          }
 262  
 263          // setting a variable
 264          if ($this->variable($var) && $this->assign() &&
 265              $this->propertyValue($value) && $this->end()) {
 266              $this->append(['assign', $var, $value], $s);
 267              return true;
 268          } else {
 269              $this->seek($s);
 270          }
 271  
 272          if ($this->import($importValue)) {
 273              $this->append($importValue, $s);
 274              return true;
 275          }
 276  
 277          // opening parametric mixin
 278          if ($this->tag($tag, true) && $this->argumentDef($args, $isVararg) &&
 279              ($this->guards($guards) || true) &&
 280              $this->literal('{')) {
 281              $block = $this->pushBlock($this->fixTags([$tag]));
 282              $block->args = $args;
 283              $block->isVararg = $isVararg;
 284              if (!empty($guards)) $block->guards = $guards;
 285              return true;
 286          } else {
 287              $this->seek($s);
 288          }
 289  
 290          // opening a simple block
 291          if ($this->tags($tags) && $this->literal('{', false)) {
 292              $tags = $this->fixTags($tags);
 293              $this->pushBlock($tags);
 294              return true;
 295          } else {
 296              $this->seek($s);
 297          }
 298  
 299          // closing a block
 300          if ($this->literal('}', false)) {
 301              try {
 302                  $block = $this->pop();
 303              } catch (Exception $e) {
 304                  $this->seek($s);
 305                  $this->throwError($e->getMessage());
 306              }
 307  
 308              $hidden = false;
 309              if (is_null($block->type)) {
 310                  $hidden = true;
 311                  if (!isset($block->args)) {
 312                      foreach ($block->tags as $tag) {
 313                          if (!is_string($tag) || $tag[0] != Constants::MPREFIX) {
 314                              $hidden = false;
 315                              break;
 316                          }
 317                      }
 318                  }
 319  
 320                  foreach ($block->tags as $tag) {
 321                      if (is_string($tag)) {
 322                          $this->env->children[$tag][] = $block;
 323                      }
 324                  }
 325              }
 326  
 327              if (!$hidden) {
 328                  $this->append(['block', $block], $s);
 329              }
 330  
 331              // this is done here so comments aren't bundled into he block that
 332              // was just closed
 333              $this->whitespace();
 334              return true;
 335          }
 336  
 337          // mixin
 338          if ($this->mixinTags($tags) &&
 339              ($this->argumentDef($argv, $isVararg) || true) &&
 340              ($this->keyword($suffix) || true) && $this->end()) {
 341              $tags = $this->fixTags($tags);
 342              $this->append(['mixin', $tags, $argv, $suffix], $s);
 343              return true;
 344          } else {
 345              $this->seek($s);
 346          }
 347  
 348          // spare ;
 349          if ($this->literal(';')) return true;
 350  
 351          return false; // got nothing, throw error
 352      }
 353  
 354      protected function isDirective($dirname, $directives)
 355      {
 356          // TODO: cache pattern in parser
 357          $pattern = implode(
 358              '|',
 359              array_map([Util::class, 'pregQuote'], $directives)
 360          );
 361          $pattern = '/^(-[a-z-]+-)?(' . $pattern . ')$/i';
 362  
 363          return preg_match($pattern, $dirname);
 364      }
 365  
 366      protected function fixTags($tags)
 367      {
 368          // move @ tags out of variable namespace
 369          foreach ($tags as &$tag) {
 370              if ($tag[0] == Constants::VPREFIX)
 371                  $tag[0] = Constants::MPREFIX;
 372          }
 373          return $tags;
 374      }
 375  
 376      // a list of expressions
 377      protected function expressionList(&$exps)
 378      {
 379          $values = [];
 380  
 381          while ($this->expression($exp)) {
 382              $values[] = $exp;
 383          }
 384  
 385          if (count($values) == 0) return false;
 386  
 387          $exps = Lessc::compressList($values, ' ');
 388          return true;
 389      }
 390  
 391      /**
 392       * Attempt to consume an expression.
 393       * @link http://en.wikipedia.org/wiki/Operator-precedence_parser#Pseudo-code
 394       */
 395      protected function expression(&$out)
 396      {
 397          if ($this->value($lhs)) {
 398              $out = $this->expHelper($lhs, 0);
 399  
 400              // look for / shorthand
 401              if (!empty($this->env->supressedDivision)) {
 402                  unset($this->env->supressedDivision);
 403                  $s = $this->seek();
 404                  if ($this->literal('/') && $this->value($rhs)) {
 405                      $out = ['list', '', [$out, ['keyword', '/'], $rhs]];
 406                  } else {
 407                      $this->seek($s);
 408                  }
 409              }
 410  
 411              return true;
 412          }
 413          return false;
 414      }
 415  
 416      /**
 417       * recursively parse infix equation with $lhs at precedence $minP
 418       */
 419      protected function expHelper($lhs, $minP)
 420      {
 421          $this->inExp = true;
 422          $ss = $this->seek();
 423  
 424          while (true) {
 425              $whiteBefore = isset($this->buffer[$this->count - 1]) &&
 426                  ctype_space($this->buffer[$this->count - 1]);
 427  
 428              // If there is whitespace before the operator, then we require
 429              // whitespace after the operator for it to be an expression
 430              $needWhite = $whiteBefore && !$this->inParens;
 431  
 432              if (
 433                  $this->match(self::$operatorString . ($needWhite ? '\s' : ''), $m) &&
 434                  self::$precedence[$m[1]] >= $minP
 435              ) {
 436                  if (
 437                      !$this->inParens && isset($this->env->currentProperty) && $m[1] == '/' &&
 438                      empty($this->env->supressedDivision)
 439                  ) {
 440                      foreach (self::$supressDivisionProps as $pattern) {
 441                          if (preg_match($pattern, $this->env->currentProperty)) {
 442                              $this->env->supressedDivision = true;
 443                              break 2;
 444                          }
 445                      }
 446                  }
 447  
 448  
 449                  $whiteAfter = isset($this->buffer[$this->count - 1]) &&
 450                      ctype_space($this->buffer[$this->count - 1]);
 451  
 452                  if (!$this->value($rhs)) break;
 453  
 454                  // peek for next operator to see what to do with rhs
 455                  if (
 456                      $this->peek(self::$operatorString, $next) &&
 457                      self::$precedence[$next[1]] > self::$precedence[$m[1]]
 458                  ) {
 459                      $rhs = $this->expHelper($rhs, self::$precedence[$next[1]]);
 460                  }
 461  
 462                  $lhs = ['expression', $m[1], $lhs, $rhs, $whiteBefore, $whiteAfter];
 463                  $ss = $this->seek();
 464  
 465                  continue;
 466              }
 467  
 468              break;
 469          }
 470  
 471          $this->seek($ss);
 472  
 473          return $lhs;
 474      }
 475  
 476      // consume a list of values for a property
 477      public function propertyValue(&$value, $keyName = null)
 478      {
 479          $values = [];
 480  
 481          if ($keyName !== null) $this->env->currentProperty = $keyName;
 482  
 483          $s = null;
 484          while ($this->expressionList($v)) {
 485              $values[] = $v;
 486              $s = $this->seek();
 487              if (!$this->literal(',')) break;
 488          }
 489  
 490          if ($s) $this->seek($s);
 491  
 492          if ($keyName !== null) unset($this->env->currentProperty);
 493  
 494          if (count($values) == 0) return false;
 495  
 496          $value = Lessc::compressList($values, ', ');
 497          return true;
 498      }
 499  
 500      protected function parenValue(&$out)
 501      {
 502          $s = $this->seek();
 503  
 504          // speed shortcut
 505          if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] != '(') {
 506              return false;
 507          }
 508  
 509          $inParens = $this->inParens;
 510          if ($this->literal('(') &&
 511              ($this->inParens = true) && $this->expression($exp) &&
 512              $this->literal(')')) {
 513              $out = $exp;
 514              $this->inParens = $inParens;
 515              return true;
 516          } else {
 517              $this->inParens = $inParens;
 518              $this->seek($s);
 519          }
 520  
 521          return false;
 522      }
 523  
 524      // a single value
 525      protected function value(&$value)
 526      {
 527          $s = $this->seek();
 528  
 529          // speed shortcut
 530          if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] == '-') {
 531              // negation
 532              if ($this->literal('-', false) &&
 533                  (($this->variable($inner) && $inner = ['variable', $inner]) ||
 534                      $this->unit($inner) ||
 535                      $this->parenValue($inner))) {
 536                  $value = ['unary', '-', $inner];
 537                  return true;
 538              } else {
 539                  $this->seek($s);
 540              }
 541          }
 542  
 543          if ($this->parenValue($value)) return true;
 544          if ($this->unit($value)) return true;
 545          if ($this->color($value)) return true;
 546          if ($this->func($value)) return true;
 547          if ($this->stringValue($value)) return true;
 548  
 549          if ($this->keyword($word)) {
 550              $value = ['keyword', $word];
 551              return true;
 552          }
 553  
 554          // try a variable
 555          if ($this->variable($var)) {
 556              $value = ['variable', $var];
 557              return true;
 558          }
 559  
 560          // unquote string (should this work on any type?
 561          if ($this->literal('~') && $this->stringValue($str)) {
 562              $value = ['escape', $str];
 563              return true;
 564          } else {
 565              $this->seek($s);
 566          }
 567  
 568          // css hack: \0
 569          if ($this->literal('\\') && $this->match('([0-9]+)', $m)) {
 570              $value = ['keyword', '\\' . $m[1]];
 571              return true;
 572          } else {
 573              $this->seek($s);
 574          }
 575  
 576          return false;
 577      }
 578  
 579      // an import statement
 580      protected function import(&$out)
 581      {
 582          if (!$this->literal('@import')) return false;
 583  
 584          // @import "something.css" media;
 585          // @import url("something.css") media;
 586          // @import url(something.css) media;
 587  
 588          if ($this->propertyValue($value)) {
 589              $out = ['import', $value];
 590              return true;
 591          }
 592          return false;
 593      }
 594  
 595      protected function mediaQueryList(&$out)
 596      {
 597          if ($this->genericList($list, 'mediaQuery', ',', false)) {
 598              $out = $list[2];
 599              return true;
 600          }
 601          return false;
 602      }
 603  
 604      protected function mediaQuery(&$out)
 605      {
 606          $s = $this->seek();
 607  
 608          $expressions = null;
 609          $parts = [];
 610  
 611          if (
 612              (
 613                  $this->literal('only') && ($only = true) ||
 614                  $this->literal('not') && ($not = true) ||
 615                  true
 616              ) &&
 617              $this->keyword($mediaType)
 618          ) {
 619              $prop = ['mediaType'];
 620              if (isset($only)) $prop[] = 'only';
 621              if (isset($not)) $prop[] = 'not';
 622              $prop[] = $mediaType;
 623              $parts[] = $prop;
 624          } else {
 625              $this->seek($s);
 626          }
 627  
 628  
 629          if (!empty($mediaType) && !$this->literal('and')) {
 630              // ~
 631          } else {
 632              $this->genericList($expressions, 'mediaExpression', 'and', false);
 633              if (is_array($expressions)) $parts = array_merge($parts, $expressions[2]);
 634          }
 635  
 636          if (count($parts) == 0) {
 637              $this->seek($s);
 638              return false;
 639          }
 640  
 641          $out = $parts;
 642          return true;
 643      }
 644  
 645      protected function mediaExpression(&$out)
 646      {
 647          $s = $this->seek();
 648          $value = null;
 649          if ($this->literal('(') &&
 650              $this->keyword($feature) &&
 651              ($this->literal(':') && $this->expression($value) || true) &&
 652              $this->literal(')')) {
 653              $out = ['mediaExp', $feature];
 654              if ($value) $out[] = $value;
 655              return true;
 656          } elseif ($this->variable($variable)) {
 657              $out = ['variable', $variable];
 658              return true;
 659          }
 660  
 661          $this->seek($s);
 662          return false;
 663      }
 664  
 665      // an unbounded string stopped by $end
 666      protected function openString($end, &$out, $nestingOpen = null, $rejectStrs = null)
 667      {
 668          $oldWhite = $this->eatWhiteDefault;
 669          $this->eatWhiteDefault = false;
 670  
 671          $stop = ["'", '"', '@{', $end];
 672          $stop = array_map([Util::class, 'pregQuote'], $stop);
 673          // $stop[] = self::$commentMulti;
 674  
 675          if (!is_null($rejectStrs)) {
 676              $stop = array_merge($stop, $rejectStrs);
 677          }
 678  
 679          $patt = '(.*?)(' . implode('|', $stop) . ')';
 680  
 681          $nestingLevel = 0;
 682  
 683          $content = [];
 684          while ($this->match($patt, $m, false)) {
 685              if (!empty($m[1])) {
 686                  $content[] = $m[1];
 687                  if ($nestingOpen) {
 688                      $nestingLevel += substr_count($m[1], $nestingOpen);
 689                  }
 690              }
 691  
 692              $tok = $m[2];
 693  
 694              $this->count -= strlen($tok);
 695              if ($tok == $end) {
 696                  if ($nestingLevel == 0) {
 697                      break;
 698                  } else {
 699                      $nestingLevel--;
 700                  }
 701              }
 702  
 703              if (($tok == "'" || $tok == '"') && $this->stringValue($str)) {
 704                  $content[] = $str;
 705                  continue;
 706              }
 707  
 708              if ($tok == '@{' && $this->interpolation($inter)) {
 709                  $content[] = $inter;
 710                  continue;
 711              }
 712  
 713              if (!empty($rejectStrs) && in_array($tok, $rejectStrs)) {
 714                  break;
 715              }
 716  
 717              $content[] = $tok;
 718              $this->count += strlen($tok);
 719          }
 720  
 721          $this->eatWhiteDefault = $oldWhite;
 722  
 723          if (count($content) == 0) return false;
 724  
 725          // trim the end
 726          if (is_string(end($content))) {
 727              $content[count($content) - 1] = rtrim(end($content));
 728          }
 729  
 730          $out = ['string', '', $content];
 731          return true;
 732      }
 733  
 734      protected function stringValue(&$out)
 735      {
 736          $s = $this->seek();
 737          if ($this->literal('"', false)) {
 738              $delim = '"';
 739          } elseif ($this->literal("'", false)) {
 740              $delim = "'";
 741          } else {
 742              return false;
 743          }
 744  
 745          $content = [];
 746  
 747          // look for either ending delim , escape, or string interpolation
 748          $patt = '([^\n]*?)(@\{|\\\\|' . Util::pregQuote($delim) . ')';
 749  
 750          $oldWhite = $this->eatWhiteDefault;
 751          $this->eatWhiteDefault = false;
 752  
 753          while ($this->match($patt, $m, false)) {
 754              $content[] = $m[1];
 755              if ($m[2] == '@{') {
 756                  $this->count -= strlen($m[2]);
 757                  if ($this->interpolation($inter)) {
 758                      $content[] = $inter;
 759                  } else {
 760                      $this->count += strlen($m[2]);
 761                      $content[] = '@{'; // ignore it
 762                  }
 763              } elseif ($m[2] == '\\') {
 764                  $content[] = $m[2];
 765                  if ($this->literal($delim, false)) {
 766                      $content[] = $delim;
 767                  }
 768              } else {
 769                  $this->count -= strlen($delim);
 770                  break; // delim
 771              }
 772          }
 773  
 774          $this->eatWhiteDefault = $oldWhite;
 775  
 776          if ($this->literal($delim)) {
 777              $out = ['string', $delim, $content];
 778              return true;
 779          }
 780  
 781          $this->seek($s);
 782          return false;
 783      }
 784  
 785      protected function interpolation(&$out)
 786      {
 787          $oldWhite = $this->eatWhiteDefault;
 788          $this->eatWhiteDefault = true;
 789  
 790          $s = $this->seek();
 791          if ($this->literal('@{') &&
 792              $this->openString('}', $interp, null, ["'", '"', ';']) &&
 793              $this->literal('}', false)) {
 794              $out = ['interpolate', $interp];
 795              $this->eatWhiteDefault = $oldWhite;
 796              if ($this->eatWhiteDefault) $this->whitespace();
 797              return true;
 798          }
 799  
 800          $this->eatWhiteDefault = $oldWhite;
 801          $this->seek($s);
 802          return false;
 803      }
 804  
 805      protected function unit(&$unit)
 806      {
 807          // speed shortcut
 808          if (isset($this->buffer[$this->count])) {
 809              $char = $this->buffer[$this->count];
 810              if (!ctype_digit($char) && $char != '.') return false;
 811          }
 812  
 813          if ($this->match('([0-9]+(?:\.[0-9]*)?|\.[0-9]+)([%a-zA-Z]+)?', $m)) {
 814              $unit = ['number', $m[1], empty($m[2]) ? '' : $m[2]];
 815              return true;
 816          }
 817          return false;
 818      }
 819  
 820      // a # color
 821      protected function color(&$out)
 822      {
 823          if ($this->match('(#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3}))', $m)) {
 824              if (strlen($m[1]) > 7) {
 825                  $out = ['string', '', [$m[1]]];
 826              } else {
 827                  $out = ['raw_color', $m[1]];
 828              }
 829              return true;
 830          }
 831  
 832          return false;
 833      }
 834  
 835      /**
 836       * consume an argument definition list surrounded by ()
 837       * each argument is a variable name with optional value
 838       * or at the end a ... or a variable named followed by ...
 839       * arguments are separated by , unless a ; is in the list, then ; is the
 840       * delimiter.
 841       *
 842       * @throws Exception
 843       */
 844      protected function argumentDef(&$args, &$isVararg)
 845      {
 846          $s = $this->seek();
 847          if (!$this->literal('(')) return false;
 848  
 849          $values = [];
 850          $delim = ',';
 851          $method = 'expressionList';
 852  
 853          $value = null;
 854          $rhs = null;
 855  
 856          $isVararg = false;
 857          while (true) {
 858              if ($this->literal('...')) {
 859                  $isVararg = true;
 860                  break;
 861              }
 862  
 863              if ($this->$method($value)) {
 864                  if ($value[0] == 'variable') {
 865                      $arg = ['arg', $value[1]];
 866                      $ss = $this->seek();
 867  
 868                      if ($this->assign() && $this->$method($rhs)) {
 869                          $arg[] = $rhs;
 870                      } else {
 871                          $this->seek($ss);
 872                          if ($this->literal('...')) {
 873                              $arg[0] = 'rest';
 874                              $isVararg = true;
 875                          }
 876                      }
 877  
 878                      $values[] = $arg;
 879                      if ($isVararg) break;
 880                      continue;
 881                  } else {
 882                      $values[] = ['lit', $value];
 883                  }
 884              }
 885  
 886  
 887              if (!$this->literal($delim)) {
 888                  if ($delim == ',' && $this->literal(';')) {
 889                      // found new delim, convert existing args
 890                      $delim = ';';
 891                      $method = 'propertyValue';
 892  
 893                      // transform arg list
 894                      if (isset($values[1])) { // 2 items
 895                          $newList = [];
 896                          foreach ($values as $i => $arg) {
 897                              switch ($arg[0]) {
 898                                  case 'arg':
 899                                      if ($i) {
 900                                          $this->throwError('Cannot mix ; and , as delimiter types');
 901                                      }
 902                                      $newList[] = $arg[2];
 903                                      break;
 904                                  case 'lit':
 905                                      $newList[] = $arg[1];
 906                                      break;
 907                                  case 'rest':
 908                                      $this->throwError('Unexpected rest before semicolon');
 909                              }
 910                          }
 911  
 912                          $newList = ['list', ', ', $newList];
 913  
 914                          switch ($values[0][0]) {
 915                              case 'arg':
 916                                  $newArg = ['arg', $values[0][1], $newList];
 917                                  break;
 918                              case 'lit':
 919                                  $newArg = ['lit', $newList];
 920                                  break;
 921                          }
 922                      } elseif ($values) { // 1 item
 923                          $newArg = $values[0];
 924                      }
 925  
 926                      if ($newArg) {
 927                          $values = [$newArg];
 928                      }
 929                  } else {
 930                      break;
 931                  }
 932              }
 933          }
 934  
 935          if (!$this->literal(')')) {
 936              $this->seek($s);
 937              return false;
 938          }
 939  
 940          $args = $values;
 941  
 942          return true;
 943      }
 944  
 945      // consume a list of tags
 946      // this accepts a hanging delimiter
 947      protected function tags(&$tags, $simple = false, $delim = ',')
 948      {
 949          $tags = [];
 950          while ($this->tag($tt, $simple)) {
 951              $tags[] = $tt;
 952              if (!$this->literal($delim)) break;
 953          }
 954          if (count($tags) == 0) return false;
 955  
 956          return true;
 957      }
 958  
 959      // list of tags of specifying mixin path
 960      // optionally separated by > (lazy, accepts extra >)
 961      protected function mixinTags(&$tags)
 962      {
 963          $tags = [];
 964          while ($this->tag($tt, true)) {
 965              $tags[] = $tt;
 966              $this->literal('>');
 967          }
 968  
 969          if (count($tags) == 0) return false;
 970  
 971          return true;
 972      }
 973  
 974      // a bracketed value (contained within in a tag definition)
 975      protected function tagBracket(&$parts, &$hasExpression)
 976      {
 977          // speed shortcut
 978          if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] != '[') {
 979              return false;
 980          }
 981  
 982          $s = $this->seek();
 983  
 984          $hasInterpolation = false;
 985  
 986          if ($this->literal('[', false)) {
 987              $attrParts = ['['];
 988              // keyword, string, operator
 989              while (true) {
 990                  if ($this->literal(']', false)) {
 991                      $this->count--;
 992                      break; // get out early
 993                  }
 994  
 995                  if ($this->match('\s+', $m)) {
 996                      $attrParts[] = ' ';
 997                      continue;
 998                  }
 999                  if ($this->stringValue($str)) {
1000                      // escape parent selector, (yuck)
1001                      foreach ($str[2] as &$chunk) {
1002                          if (is_string($chunk)) {
1003                              $chunk = str_replace(Constants::PARENT_SELECTOR, "$&$", $chunk);
1004                          }
1005                      }
1006  
1007                      $attrParts[] = $str;
1008                      $hasInterpolation = true;
1009                      continue;
1010                  }
1011  
1012                  if ($this->keyword($word)) {
1013                      $attrParts[] = $word;
1014                      continue;
1015                  }
1016  
1017                  if ($this->interpolation($inter)) {
1018                      $attrParts[] = $inter;
1019                      $hasInterpolation = true;
1020                      continue;
1021                  }
1022  
1023                  // operator, handles attr namespace too
1024                  if ($this->match('[|-~\$\*\^=]+', $m)) {
1025                      $attrParts[] = $m[0];
1026                      continue;
1027                  }
1028  
1029                  break;
1030              }
1031  
1032              if ($this->literal(']', false)) {
1033                  $attrParts[] = ']';
1034                  foreach ($attrParts as $part) {
1035                      $parts[] = $part;
1036                  }
1037                  $hasExpression = $hasExpression || $hasInterpolation;
1038                  return true;
1039              }
1040              $this->seek($s);
1041          }
1042  
1043          $this->seek($s);
1044          return false;
1045      }
1046  
1047      // a space separated list of selectors
1048      protected function tag(&$tag, $simple = false)
1049      {
1050          if ($simple)
1051              $chars = '^@,:;{}\][>\(\) "\'';
1052          else $chars = '^@,;{}["\'';
1053  
1054          $s = $this->seek();
1055  
1056          $hasExpression = false;
1057          $parts = [];
1058          while ($this->tagBracket($parts, $hasExpression)) {
1059              // no-op
1060          }
1061  
1062          $oldWhite = $this->eatWhiteDefault;
1063          $this->eatWhiteDefault = false;
1064  
1065          while (true) {
1066              if ($this->match('([' . $chars . '0-9][' . $chars . ']*)', $m)) {
1067                  $parts[] = $m[1];
1068                  if ($simple) break;
1069  
1070                  while ($this->tagBracket($parts, $hasExpression)) {
1071                      // no-op
1072                  }
1073                  continue;
1074              }
1075  
1076              if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] == '@') {
1077                  if ($this->interpolation($interp)) {
1078                      $hasExpression = true;
1079                      $interp[2] = true; // don't unescape
1080                      $parts[] = $interp;
1081                      continue;
1082                  }
1083  
1084                  if ($this->literal('@')) {
1085                      $parts[] = '@';
1086                      continue;
1087                  }
1088              }
1089  
1090              if ($this->unit($unit)) { // for keyframes
1091                  $parts[] = $unit[1];
1092                  $parts[] = $unit[2];
1093                  continue;
1094              }
1095  
1096              break;
1097          }
1098  
1099          $this->eatWhiteDefault = $oldWhite;
1100          if (!$parts) {
1101              $this->seek($s);
1102              return false;
1103          }
1104  
1105          if ($hasExpression) {
1106              $tag = ['exp', ['string', '', $parts]];
1107          } else {
1108              $tag = trim(implode($parts));
1109          }
1110  
1111          $this->whitespace();
1112          return true;
1113      }
1114  
1115      // a css function
1116      protected function func(&$func)
1117      {
1118          $s = $this->seek();
1119  
1120          if ($this->match('(%|[\w\-_][\w\-_:\.]+|[\w_])', $m) && $this->literal('(')) {
1121              $fname = $m[1];
1122  
1123              $sPreArgs = $this->seek();
1124  
1125              $args = [];
1126              while (true) {
1127                  $ss = $this->seek();
1128                  // this ugly nonsense is for ie filter properties
1129                  if ($this->keyword($name) && $this->literal('=') && $this->expressionList($value)) {
1130                      $args[] = ['string', '', [$name, '=', $value]];
1131                  } else {
1132                      $this->seek($ss);
1133                      if ($this->expressionList($value)) {
1134                          $args[] = $value;
1135                      }
1136                  }
1137  
1138                  if (!$this->literal(',')) break;
1139              }
1140              $args = ['list', ',', $args];
1141  
1142              if ($this->literal(')')) {
1143                  $func = ['function', $fname, $args];
1144                  return true;
1145              } elseif ($fname == 'url') {
1146                  // couldn't parse and in url? treat as string
1147                  $this->seek($sPreArgs);
1148                  if ($this->openString(')', $string) && $this->literal(')')) {
1149                      $func = ['function', $fname, $string];
1150                      return true;
1151                  }
1152              }
1153          }
1154  
1155          $this->seek($s);
1156          return false;
1157      }
1158  
1159      // consume a less variable
1160      protected function variable(&$name)
1161      {
1162          $s = $this->seek();
1163          if ($this->literal(Constants::VPREFIX, false) &&
1164              ($this->variable($sub) || $this->keyword($name))) {
1165              if (!empty($sub)) {
1166                  $name = ['variable', $sub];
1167              } else {
1168                  $name = Constants::VPREFIX . $name;
1169              }
1170              return true;
1171          }
1172  
1173          $name = null;
1174          $this->seek($s);
1175          return false;
1176      }
1177  
1178      /**
1179       * Consume an assignment operator
1180       * Can optionally take a name that will be set to the current property name
1181       */
1182      protected function assign($name = null)
1183      {
1184          if ($name) $this->currentProperty = $name;
1185          return $this->literal(':') || $this->literal('=');
1186      }
1187  
1188      // consume a keyword
1189      protected function keyword(&$word)
1190      {
1191          if ($this->match('([\w_\-\*!"][\w\-_"]*)', $m)) {
1192              $word = $m[1];
1193              return true;
1194          }
1195          return false;
1196      }
1197  
1198      // consume an end of statement delimiter
1199      protected function end()
1200      {
1201          if ($this->literal(';', false)) {
1202              return true;
1203          } elseif ($this->count == strlen($this->buffer) || $this->buffer[$this->count] == '}') {
1204              // if there is end of file or a closing block next then we don't need a ;
1205              return true;
1206          }
1207          return false;
1208      }
1209  
1210      protected function guards(&$guards)
1211      {
1212          $s = $this->seek();
1213  
1214          if (!$this->literal('when')) {
1215              $this->seek($s);
1216              return false;
1217          }
1218  
1219          $guards = [];
1220  
1221          while ($this->guardGroup($g)) {
1222              $guards[] = $g;
1223              if (!$this->literal(',')) break;
1224          }
1225  
1226          if (count($guards) == 0) {
1227              $guards = null;
1228              $this->seek($s);
1229              return false;
1230          }
1231  
1232          return true;
1233      }
1234  
1235      // a bunch of guards that are and'd together
1236      // TODO rename to guardGroup
1237      protected function guardGroup(&$guardGroup)
1238      {
1239          $s = $this->seek();
1240          $guardGroup = [];
1241          while ($this->guard($guard)) {
1242              $guardGroup[] = $guard;
1243              if (!$this->literal('and')) break;
1244          }
1245  
1246          if (count($guardGroup) == 0) {
1247              $guardGroup = null;
1248              $this->seek($s);
1249              return false;
1250          }
1251  
1252          return true;
1253      }
1254  
1255      protected function guard(&$guard)
1256      {
1257          $s = $this->seek();
1258          $negate = $this->literal('not');
1259  
1260          if ($this->literal('(') && $this->expression($exp) && $this->literal(')')) {
1261              $guard = $exp;
1262              if ($negate) $guard = ['negate', $guard];
1263              return true;
1264          }
1265  
1266          $this->seek($s);
1267          return false;
1268      }
1269  
1270      /* raw parsing functions */
1271  
1272      protected function literal($what, $eatWhitespace = null)
1273      {
1274          if ($eatWhitespace === null) $eatWhitespace = $this->eatWhiteDefault;
1275  
1276          // shortcut on single letter
1277          if (!isset($what[1]) && isset($this->buffer[$this->count])) {
1278              if ($this->buffer[$this->count] == $what) {
1279                  if (!$eatWhitespace) {
1280                      $this->count++;
1281                      return true;
1282                  }
1283                  // goes below...
1284              } else {
1285                  return false;
1286              }
1287          }
1288  
1289          if (!isset(self::$literalCache[$what])) {
1290              self::$literalCache[$what] = Util::pregQuote($what);
1291          }
1292  
1293          return $this->match(self::$literalCache[$what], $m, $eatWhitespace);
1294      }
1295  
1296      protected function genericList(&$out, $parseItem, $delim = '', $flatten = true)
1297      {
1298          $s = $this->seek();
1299          $items = [];
1300          $value = null;
1301          while ($this->$parseItem($value)) {
1302              $items[] = $value;
1303              if ($delim) {
1304                  if (!$this->literal($delim)) break;
1305              }
1306          }
1307  
1308          if (count($items) == 0) {
1309              $this->seek($s);
1310              return false;
1311          }
1312  
1313          if ($flatten && count($items) == 1) {
1314              $out = $items[0];
1315          } else {
1316              $out = ['list', $delim, $items];
1317          }
1318  
1319          return true;
1320      }
1321  
1322  
1323      // advance counter to next occurrence of $what
1324      // $until - don't include $what in advance
1325      // $allowNewline, if string, will be used as valid char set
1326      protected function to($what, &$out, $until = false, $allowNewline = false)
1327      {
1328          if (is_string($allowNewline)) {
1329              $validChars = $allowNewline;
1330          } else {
1331              $validChars = $allowNewline ? '.' : "[^\n]";
1332          }
1333          if (!$this->match('(' . $validChars . '*?)' . Lessc::preg_quote($what), $m, !$until)) return false;
1334          if ($until) $this->count -= strlen($what); // give back $what
1335          $out = $m[1];
1336          return true;
1337      }
1338  
1339      // try to match something on head of buffer
1340      protected function match($regex, &$out, $eatWhitespace = null)
1341      {
1342          if ($eatWhitespace === null) $eatWhitespace = $this->eatWhiteDefault;
1343  
1344          $r = '/' . $regex . ($eatWhitespace && !$this->writeComments ? '\s*' : '') . '/Ais';
1345          if (preg_match($r, $this->buffer, $out, 0, $this->count)) {
1346              $this->count += strlen($out[0]);
1347              if ($eatWhitespace && $this->writeComments) $this->whitespace();
1348              return true;
1349          }
1350          return false;
1351      }
1352  
1353      // match some whitespace
1354      protected function whitespace()
1355      {
1356          if ($this->writeComments) {
1357              $gotWhite = false;
1358              while (preg_match(self::$whitePattern, $this->buffer, $m, 0, $this->count)) {
1359                  if (isset($m[1]) && empty($this->seenComments[$this->count])) {
1360                      $this->append(['comment', $m[1]]);
1361                      $this->seenComments[$this->count] = true;
1362                  }
1363                  $this->count += strlen($m[0]);
1364                  $gotWhite = true;
1365              }
1366              return $gotWhite;
1367          } else {
1368              $this->match('', $m);
1369              return strlen($m[0]) > 0;
1370          }
1371      }
1372  
1373      // match something without consuming it
1374      protected function peek($regex, &$out = null, $from = null)
1375      {
1376          if (is_null($from)) $from = $this->count;
1377          $r = '/' . $regex . '/Ais';
1378          return preg_match($r, $this->buffer, $out, 0, $from);
1379      }
1380  
1381      // seek to a spot in the buffer or return where we are on no argument
1382      protected function seek($where = null)
1383      {
1384          if ($where === null) return $this->count;
1385          else $this->count = $where;
1386          return true;
1387      }
1388  
1389      /* misc functions */
1390  
1391      /**
1392       * Throw a parser exception
1393       *
1394       * This function tries to use the current parsing context to provide
1395       * additional info on where/why the error occurred.
1396       *
1397       * @param string $msg The error message to throw
1398       * @param int|null $count A line number counter to use instead of the current count
1399       * @param \Throwable|null $previous A previous exception to chain
1400       * @throws ParserException
1401       */
1402      public function throwError(string $msg = 'parse error', ?int $count = null, \Throwable $previous = null)
1403      {
1404          $count = is_null($count) ? $this->count : $count;
1405  
1406          $line = $this->line + substr_count(substr($this->buffer, 0, $count), "\n");
1407  
1408          if ($this->peek("(.*?)(\n|$)", $m, $count)) {
1409              $culprit = $m[1];
1410          }
1411  
1412          throw new ParserException(
1413              $msg,
1414              $culprit,
1415              $this->sourceName,
1416              $line,
1417              $previous
1418          );
1419      }
1420  
1421      protected function pushBlock($selectors = null, $type = null)
1422      {
1423          $b = new stdClass();
1424          $b->parent = $this->env;
1425  
1426          $b->type = $type;
1427          $b->id = self::$nextBlockId++;
1428  
1429          $b->isVararg = false; // TODO: kill me from here
1430          $b->tags = $selectors;
1431  
1432          $b->props = [];
1433          $b->children = [];
1434  
1435          // add a reference to the parser so
1436          // we can access the parser to throw errors
1437          // or retrieve the sourceName of this block.
1438          $b->parser = $this;
1439  
1440          // so we know the position of this block
1441          $b->count = $this->count;
1442  
1443          $this->env = $b;
1444          return $b;
1445      }
1446  
1447      // push a block that doesn't multiply tags
1448      protected function pushSpecialBlock($type)
1449      {
1450          return $this->pushBlock(null, $type);
1451      }
1452  
1453      // append a property to the current block
1454      protected function append($prop, $pos = null)
1455      {
1456          if ($pos !== null) $prop[-1] = $pos;
1457          $this->env->props[] = $prop;
1458      }
1459  
1460      // pop something off the stack
1461      protected function pop()
1462      {
1463          $old = $this->env;
1464          $this->env = $this->env->parent;
1465          return $old;
1466      }
1467  
1468      // remove comments from $text
1469      // todo: make it work for all functions, not just url
1470      protected function removeComments($text)
1471      {
1472          $look = ['url(', '//', '/*', '"', "'"];
1473  
1474          $out = '';
1475          $min = null;
1476          while (true) {
1477              // find the next item
1478              foreach ($look as $token) {
1479                  $pos = strpos($text, $token);
1480                  if ($pos !== false) {
1481                      if (!isset($min) || $pos < $min[1]) $min = [$token, $pos];
1482                  }
1483              }
1484  
1485              if (is_null($min)) break;
1486  
1487              $count = $min[1];
1488              $skip = 0;
1489              $newlines = 0;
1490              switch ($min[0]) {
1491                  case 'url(':
1492                      if (preg_match('/url\(.*?\)/', $text, $m, 0, $count))
1493                          $count += strlen($m[0]) - strlen($min[0]);
1494                      break;
1495                  case '"':
1496                  case "'":
1497                      if (preg_match('/' . $min[0] . '.*?(?<!\\\\)' . $min[0] . '/', $text, $m, 0, $count))
1498                          $count += strlen($m[0]) - 1;
1499                      break;
1500                  case '//':
1501                      $skip = strpos($text, "\n", $count);
1502                      if ($skip === false) $skip = strlen($text) - $count;
1503                      else $skip -= $count;
1504                      break;
1505                  case '/*':
1506                      if (preg_match('/\/\*.*?\*\//s', $text, $m, 0, $count)) {
1507                          $skip = strlen($m[0]);
1508                          $newlines = substr_count($m[0], "\n");
1509                      }
1510                      break;
1511              }
1512  
1513              if ($skip == 0) $count += strlen($min[0]);
1514  
1515              $out .= substr($text, 0, $count) . str_repeat("\n", $newlines);
1516              $text = substr($text, $count + $skip);
1517  
1518              $min = null;
1519          }
1520  
1521          return $out . $text;
1522      }
1523  }