[ Index ]

PHP Cross Reference of DokuWiki

title

Body

[close]

/vendor/splitbrain/lesserphp/src/ -> Lessc.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\Functions\AbstractFunctionCollection;
  16  use LesserPHP\Utils\Color;
  17  use LesserPHP\Utils\Util;
  18  use stdClass;
  19  
  20  /**
  21   * The LESS compiler and parser.
  22   *
  23   * Converting LESS to CSS is a three stage process. The incoming file is parsed
  24   * by `Parser` into a syntax tree, then it is compiled into another tree
  25   * representing the CSS structure by `Lessc`. The CSS tree is fed into a
  26   * formatter, which then outputs CSS as a string.
  27   *
  28   * During the first compile, all values are *reduced*, which means that their
  29   * types are brought to the lowest form before being dumped as strings. This
  30   * handles math equations, variable dereferences, and the like.
  31   *
  32   * The `compile` function of `Lessc` is the entry point.
  33   *
  34   * In summary:
  35   *
  36   * The `Lessc` class creates an instance of the parser, feeds it LESS code,
  37   * then transforms the resulting tree to a CSS tree. This class also holds the
  38   * evaluation context, such as all available mixins and variables at any given
  39   * time.
  40   *
  41   * The `Parser` class is only concerned with parsing its input.
  42   *
  43   * The `Formatter` takes a CSS tree, and dumps it to a formatted string,
  44   * handling things like indentation.
  45   */
  46  class Lessc
  47  {
  48      /** @var string A list of directories used to search for imported files */
  49      protected $importDir = [];
  50  
  51      /** @var bool Should imports be disabled? */
  52      protected bool $importDisabled = false;
  53  
  54      /** @var null|int Round numbers to this precision FIXME currently not settable */
  55      protected ?int $numberPrecision = null;
  56  
  57      /** @var bool Should comments be preserved in the output? */
  58      protected bool $preserveComments = false;
  59  
  60      /** @var array List of all functions registered to be available in LESS */
  61      protected array $libFunctions = [];
  62  
  63      /** @var array List of all registered variables */
  64      protected array $registeredVars = [];
  65  
  66      /** @var FormatterClassic|null The formatter for the output */
  67      protected ?FormatterClassic $formatter = null;
  68  
  69      /** @var stdClass|null Environment FIXME should probably be its own proper class */
  70      protected ?stdClass $env = null;
  71  
  72      /** @var stdClass|null The currently parsed block FIXME should probably be its own proper class */
  73      protected ?stdClass $scope = null;
  74  
  75      /** @var array [file => mtime] list of all files that have been parsed, to avoid circular imports */
  76      protected array $allParsedFiles = [];
  77  
  78      /** @var Parser|null The currently used Parser instance. Used when creating error messages */
  79      protected ?Parser $sourceParser = null;
  80  
  81      /** @var int The position in the current parsing step (in $sourceParser)  */
  82      protected int $sourceLoc = -1;
  83  
  84      /** @var int counter to uniquely identify imports */
  85      protected static int $nextImportId = 0;
  86  
  87      // region public API
  88  
  89      /**
  90       * Initialize the LESS Parser
  91       */
  92      public function __construct()
  93      {
  94          $this->registerLibraryFunctions();
  95      }
  96  
  97      /**
  98       * Compile the given LESS string into CSS
  99       *
 100       * @param string $string LESS code
 101       * @param string|null $name optional filename to show in errors
 102       * @throws Exception
 103       * @throws ParserException
 104       */
 105      public function compile(string $string, ?string $name = null): string
 106      {
 107          $locale = setlocale(LC_NUMERIC, 0);
 108          setlocale(LC_NUMERIC, 'C');
 109  
 110          $parser = $this->makeParser($name);
 111          $root = $parser->parse($string);
 112  
 113          $this->env = null;
 114          $this->scope = null;
 115          $this->allParsedFiles = [];
 116  
 117          if ($this->formatter === null) $this->setFormatter();
 118  
 119          if (!empty($this->registeredVars)) {
 120              $this->injectVariables($this->registeredVars);
 121          }
 122  
 123          $this->sourceParser = $parser; // used for error messages
 124  
 125          try {
 126              $this->compileBlock($root);
 127          } catch (Exception $e) {
 128              setlocale(LC_NUMERIC, $locale);
 129              $position = $this->sourceLoc !== -1 ? $this->sourceLoc : $root->count;
 130              $this->sourceParser->throwError($e->getMessage(), $position, $e);
 131          }
 132  
 133          ob_start();
 134          $this->formatter->block($this->scope);
 135          $out = ob_get_clean();
 136          setlocale(LC_NUMERIC, $locale);
 137          return $out;
 138      }
 139  
 140      /**
 141       * Parse the given File and return the compiled CSS.
 142       *
 143       * If an output file is specified, the compiled CSS will be written to that file.
 144       *
 145       * @param string $fname LESS file
 146       * @param string|null $outFname optional output file
 147       * @return int|string number of bytes written to file, or CSS if no output file
 148       * @throws Exception
 149       * @throws ParserException
 150       */
 151      public function compileFile(string $fname, ?string $outFname = null)
 152      {
 153          if (!is_readable($fname)) {
 154              throw new Exception('load error: failed to find ' . $fname);
 155          }
 156  
 157          $pi = pathinfo($fname);
 158  
 159          $oldImport = $this->importDir;
 160  
 161          $this->importDir = (array)$this->importDir;
 162          $this->importDir[] = $pi['dirname'] . '/';
 163  
 164          $this->addParsedFile($fname);
 165  
 166          $out = $this->compile(file_get_contents($fname), $fname);
 167  
 168          $this->importDir = $oldImport;
 169  
 170          if ($outFname !== null) {
 171              return file_put_contents($outFname, $out);
 172          }
 173  
 174          return $out;
 175      }
 176  
 177      // endregion
 178  
 179      // region configuration API
 180  
 181      /**
 182       * Should comments be preserved in the output?
 183       *
 184       * Default is false
 185       *
 186       * @param bool $preserve
 187       */
 188      public function setPreserveComments(bool $preserve): void
 189      {
 190          $this->preserveComments = $preserve;
 191      }
 192  
 193      /**
 194       * Register a custom function to be available in LESS
 195       *
 196       * @param string $name name of function
 197       * @param callable $func callback
 198       */
 199      public function registerFunction(string $name, callable $func): void
 200      {
 201          $this->libFunctions[$name] = $func;
 202      }
 203  
 204      /**
 205       * Remove a function from being available in LESS
 206       *
 207       * @param string $name The name of the function to unregister
 208       */
 209      public function unregisterFunction(string $name): void
 210      {
 211          if (isset($this->libFunctions[$name])) {
 212              unset($this->libFunctions[$name]);
 213          }
 214      }
 215  
 216      /**
 217       * Add additional variables to the parser
 218       *
 219       * Given variables are merged with any already set variables
 220       *
 221       * @param array $variables [name => value, ...]
 222       */
 223      public function setVariables($variables): void
 224      {
 225          $this->registeredVars = array_merge($this->registeredVars, $variables);
 226      }
 227  
 228      /**
 229       * Get the currently set variables
 230       *
 231       * @return array [name => value, ...]
 232       */
 233      public function getVariables(): array
 234      {
 235          return $this->registeredVars;
 236      }
 237  
 238      /**
 239       * Remove a currently set variable
 240       *
 241       * @param string $name
 242       */
 243      public function unsetVariable(string $name): void
 244      {
 245          if (isset($this->registeredVars[$name])) {
 246              unset($this->registeredVars[$name]);
 247          }
 248      }
 249  
 250      /**
 251       * Set the directories to search for imports
 252       *
 253       * Overwrites any previously set directories
 254       *
 255       * @param string|string[] $dirs
 256       */
 257      public function setImportDir($dirs): void
 258      {
 259          $this->importDir = (array)$dirs;
 260      }
 261  
 262      /**
 263       * Add an additional directory to search for imports
 264       */
 265      public function addImportDir(string $dir): void
 266      {
 267          $this->importDir = (array)$this->importDir;
 268          $this->importDir[] = $dir;
 269      }
 270  
 271      /**
 272       * Enable or disable import statements
 273       *
 274       * There is usually no need to disable imports
 275       *
 276       * @param bool $enable
 277       * @return void
 278       */
 279      public function enableImports(bool $enable): void
 280      {
 281          $this->importDisabled = !$enable;
 282      }
 283  
 284      /**
 285       * Set the formatter to use for output
 286       *
 287       * @param FormatterClassic|null $formatter Null for the default LessJs formatter
 288       * @return void
 289       */
 290      public function setFormatter(?FormatterClassic $formatter = null)
 291      {
 292          if ($formatter === null) {
 293              $formatter = new FormatterLessJs();
 294          }
 295  
 296          $this->formatter = $formatter;
 297      }
 298  
 299      // endregion
 300  
 301  
 302      /**
 303       * Register all the default functions
 304       */
 305      protected function registerLibraryFunctions()
 306      {
 307          $files = glob(__DIR__ . '/Functions/*.php');
 308          foreach ($files as $file) {
 309              $name = basename($file, '.php');
 310              if (substr($name, 0, 8) == 'Abstract') continue;
 311              $class = '\\LesserPHP\\Functions\\' . $name;
 312              $funcObj = new $class($this);
 313              if ($funcObj instanceof AbstractFunctionCollection) {
 314                  foreach ($funcObj->getFunctions() as $name => $callback) {
 315                      $this->registerFunction($name, $callback);
 316                  }
 317              }
 318          }
 319      }
 320  
 321      /**
 322       * attempts to find the path of an import url, returns null for css files
 323       *
 324       * @internal parser internal method
 325       */
 326      public function findImport(string $url): ?string
 327      {
 328          foreach ((array)$this->importDir as $dir) {
 329              $full = $dir . (substr($dir, -1) != '/' ? '/' : '') . $url;
 330              if ($this->fileExists($file = $full . '.less') || $this->fileExists($file = $full)) {
 331                  return $file;
 332              }
 333          }
 334  
 335          return null;
 336      }
 337  
 338      /**
 339       * Check if a given file exists and is actually a file
 340       *
 341       * @param string $name file path
 342       * @return bool
 343       */
 344      protected function fileExists(string $name): bool
 345      {
 346          return is_file($name);
 347      }
 348  
 349      /**
 350       * @internal parser internal method
 351       */
 352      public static function compressList($items, $delim)
 353      {
 354          if (!isset($items[1]) && isset($items[0])) return $items[0];
 355          else return ['list', $delim, $items];
 356      }
 357  
 358  
 359      /**
 360       * @throws Exception
 361       */
 362      protected function tryImport($importPath, $parentBlock, $out)
 363      {
 364          if ($importPath[0] == 'function' && $importPath[1] == 'url') {
 365              $importPath = $this->flattenList($importPath[2]);
 366          }
 367  
 368          $str = $this->coerceString($importPath);
 369          if ($str === null) return false;
 370  
 371          $url = $this->compileValue($this->unwrap($str));
 372  
 373          // don't import if it ends in css
 374          if (substr_compare($url, '.css', -4, 4) === 0) return false;
 375  
 376          $realPath = $this->findImport($url);
 377  
 378          if ($realPath === null) return false;
 379  
 380          if ($this->importDisabled) {
 381              return [false, '/* import disabled */'];
 382          }
 383  
 384          if (isset($this->allParsedFiles[realpath($realPath)])) {
 385              return [false, null];
 386          }
 387  
 388          $this->addParsedFile($realPath);
 389          $parser = $this->makeParser($realPath);
 390          $root = $parser->parse(file_get_contents($realPath));
 391  
 392          // set the parents of all the block props
 393          foreach ($root->props as $prop) {
 394              if ($prop[0] == 'block') {
 395                  $prop[1]->parent = $parentBlock;
 396              }
 397          }
 398  
 399          // copy mixins into scope, set their parents
 400          // bring blocks from import into current block
 401          // TODO: need to mark the source parser these came from this file
 402          foreach ($root->children as $childName => $child) {
 403              if (isset($parentBlock->children[$childName])) {
 404                  $parentBlock->children[$childName] = array_merge(
 405                      $parentBlock->children[$childName],
 406                      $child
 407                  );
 408              } else {
 409                  $parentBlock->children[$childName] = $child;
 410              }
 411          }
 412  
 413          $pi = pathinfo($realPath);
 414          $dir = $pi['dirname'];
 415  
 416          [$top, $bottom] = $this->sortProps($root->props, true);
 417          $this->compileImportedProps($top, $parentBlock, $out, $parser, $dir);
 418  
 419          return [true, $bottom, $parser, $dir];
 420      }
 421  
 422      /**
 423       * @throws Exception
 424       */
 425      protected function compileImportedProps($props, $block, $out, $sourceParser, $importDir)
 426      {
 427          $oldSourceParser = $this->sourceParser;
 428  
 429          $oldImport = $this->importDir;
 430  
 431          // TODO: this is because the importDir api is stupid
 432          $this->importDir = (array)$this->importDir;
 433          array_unshift($this->importDir, $importDir);
 434  
 435          foreach ($props as $prop) {
 436              $this->compileProp($prop, $block, $out);
 437          }
 438  
 439          $this->importDir = $oldImport;
 440          $this->sourceParser = $oldSourceParser;
 441      }
 442  
 443      /**
 444       * Recursively compiles a block.
 445       *
 446       * A block is analogous to a CSS block in most cases. A single LESS document
 447       * is encapsulated in a block when parsed, but it does not have parent tags
 448       * so all of it's children appear on the root level when compiled.
 449       *
 450       * Blocks are made up of props and children.
 451       *
 452       * Props are property instructions, array tuples which describe an action
 453       * to be taken, eg. write a property, set a variable, mixin a block.
 454       *
 455       * The children of a block are just all the blocks that are defined within.
 456       * This is used to look up mixins when performing a mixin.
 457       *
 458       * Compiling the block involves pushing a fresh environment on the stack,
 459       * and iterating through the props, compiling each one.
 460       *
 461       * @throws Exception
 462       * @see compileProp()
 463       */
 464      protected function compileBlock($block)
 465      {
 466          switch ($block->type) {
 467              case 'root':
 468                  $this->compileRoot($block);
 469                  break;
 470              case null:
 471                  $this->compileCSSBlock($block);
 472                  break;
 473              case 'media':
 474                  $this->compileMedia($block);
 475                  break;
 476              case 'directive':
 477                  $name = '@' . $block->name;
 478                  if (!empty($block->value)) {
 479                      $name .= ' ' . $this->compileValue($this->reduce($block->value));
 480                  }
 481  
 482                  $this->compileNestedBlock($block, [$name]);
 483                  break;
 484              default:
 485                  throw new Exception("unknown block type: $block->type\n");
 486          }
 487      }
 488  
 489      /**
 490       * @throws Exception
 491       */
 492      protected function compileCSSBlock($block)
 493      {
 494          $env = $this->pushEnv();
 495  
 496          $selectors = $this->compileSelectors($block->tags);
 497          $env->selectors = $this->multiplySelectors($selectors);
 498          $out = $this->makeOutputBlock(null, $env->selectors);
 499  
 500          $this->scope->children[] = $out;
 501          $this->compileProps($block, $out);
 502  
 503          $block->scope = $env; // mixins carry scope with them!
 504          $this->popEnv();
 505      }
 506  
 507      /**
 508       * @throws Exception
 509       */
 510      protected function compileMedia($media)
 511      {
 512          $env = $this->pushEnv($media);
 513          $parentScope = $this->mediaParent($this->scope);
 514  
 515          $query = $this->compileMediaQuery($this->multiplyMedia($env));
 516  
 517          $this->scope = $this->makeOutputBlock($media->type, [$query]);
 518          $parentScope->children[] = $this->scope;
 519  
 520          $this->compileProps($media, $this->scope);
 521  
 522          if (count($this->scope->lines) > 0) {
 523              $orphanSelelectors = $this->findClosestSelectors();
 524              if (!is_null($orphanSelelectors)) {
 525                  $orphan = $this->makeOutputBlock(null, $orphanSelelectors);
 526                  $orphan->lines = $this->scope->lines;
 527                  array_unshift($this->scope->children, $orphan);
 528                  $this->scope->lines = [];
 529              }
 530          }
 531  
 532          $this->scope = $this->scope->parent;
 533          $this->popEnv();
 534      }
 535  
 536      protected function mediaParent($scope)
 537      {
 538          while (!empty($scope->parent)) {
 539              if (!empty($scope->type) && $scope->type != 'media') {
 540                  break;
 541              }
 542              $scope = $scope->parent;
 543          }
 544  
 545          return $scope;
 546      }
 547  
 548      /**
 549       * @throws Exception
 550       */
 551      protected function compileNestedBlock($block, $selectors)
 552      {
 553          $this->pushEnv($block);
 554          $this->scope = $this->makeOutputBlock($block->type, $selectors);
 555          $this->scope->parent->children[] = $this->scope;
 556  
 557          $this->compileProps($block, $this->scope);
 558  
 559          $this->scope = $this->scope->parent;
 560          $this->popEnv();
 561      }
 562  
 563      /**
 564       * @throws Exception
 565       */
 566      protected function compileRoot($root)
 567      {
 568          $this->pushEnv();
 569          $this->scope = $this->makeOutputBlock($root->type);
 570          $this->compileProps($root, $this->scope);
 571          $this->popEnv();
 572      }
 573  
 574      /**
 575       * @throws Exception
 576       */
 577      protected function compileProps($block, $out)
 578      {
 579          foreach ($this->sortProps($block->props) as $prop) {
 580              $this->compileProp($prop, $block, $out);
 581          }
 582          $out->lines = $this->deduplicate($out->lines);
 583      }
 584  
 585      /**
 586       * Deduplicate lines in a block. Comments are not deduplicated. If a
 587       * duplicate rule is detected, the comments immediately preceding each
 588       * occurrence are consolidated.
 589       */
 590      protected function deduplicate($lines)
 591      {
 592          $unique = [];
 593          $comments = [];
 594  
 595          foreach ($lines as $line) {
 596              if (strpos($line, '/*') === 0) {
 597                  $comments[] = $line;
 598                  continue;
 599              }
 600              if (!in_array($line, $unique)) {
 601                  $unique[] = $line;
 602              }
 603              array_splice($unique, array_search($line, $unique), 0, $comments);
 604              $comments = [];
 605          }
 606          return array_merge($unique, $comments);
 607      }
 608  
 609      protected function sortProps($props, $split = false)
 610      {
 611          $vars = [];
 612          $imports = [];
 613          $other = [];
 614          $stack = [];
 615  
 616          foreach ($props as $prop) {
 617              switch ($prop[0]) {
 618                  case 'comment':
 619                      $stack[] = $prop;
 620                      break;
 621                  case 'assign':
 622                      $stack[] = $prop;
 623                      if (isset($prop[1][0]) && $prop[1][0] == Constants::VPREFIX) {
 624                          $vars = array_merge($vars, $stack);
 625                      } else {
 626                          $other = array_merge($other, $stack);
 627                      }
 628                      $stack = [];
 629                      break;
 630                  case 'import':
 631                      $id = self::$nextImportId++;
 632                      $prop[] = $id;
 633                      $stack[] = $prop;
 634                      $imports = array_merge($imports, $stack);
 635                      $other[] = ['import_mixin', $id];
 636                      $stack = [];
 637                      break;
 638                  default:
 639                      $stack[] = $prop;
 640                      $other = array_merge($other, $stack);
 641                      $stack = [];
 642                      break;
 643              }
 644          }
 645          $other = array_merge($other, $stack);
 646  
 647          if ($split) {
 648              return [array_merge($vars, $imports, $vars), $other];
 649          } else {
 650              return array_merge($vars, $imports, $vars, $other);
 651          }
 652      }
 653  
 654      /**
 655       * @throws Exception
 656       */
 657      protected function compileMediaQuery($queries)
 658      {
 659          $compiledQueries = [];
 660          foreach ($queries as $query) {
 661              $parts = [];
 662              foreach ($query as $q) {
 663                  switch ($q[0]) {
 664                      case 'mediaType':
 665                          $parts[] = implode(' ', array_slice($q, 1));
 666                          break;
 667                      case 'mediaExp':
 668                          if (isset($q[2])) {
 669                              $parts[] = "($q[1]: " .
 670                                  $this->compileValue($this->reduce($q[2])) . ')';
 671                          } else {
 672                              $parts[] = "($q[1])";
 673                          }
 674                          break;
 675                      case 'variable':
 676                          $parts[] = $this->compileValue($this->reduce($q));
 677                          break;
 678                  }
 679              }
 680  
 681              if (count($parts) > 0) {
 682                  $compiledQueries[] = implode(' and ', $parts);
 683              }
 684          }
 685  
 686          $out = '@media';
 687          if (!empty($parts)) {
 688              $out .= ' ' .
 689                  implode($this->formatter->selectorSeparator, $compiledQueries);
 690          }
 691          return $out;
 692      }
 693  
 694      protected function multiplyMedia($env, $childQueries = null)
 695      {
 696          if (is_null($env) ||
 697              !empty($env->block->type) && $env->block->type != 'media') {
 698              return $childQueries;
 699          }
 700  
 701          // plain old block, skip
 702          if (empty($env->block->type)) {
 703              return $this->multiplyMedia($env->parent, $childQueries);
 704          }
 705  
 706          $out = [];
 707          $queries = $env->block->queries;
 708          if (is_null($childQueries)) {
 709              $out = $queries;
 710          } else {
 711              foreach ($queries as $parent) {
 712                  foreach ($childQueries as $child) {
 713                      $out[] = array_merge($parent, $child);
 714                  }
 715              }
 716          }
 717  
 718          return $this->multiplyMedia($env->parent, $out);
 719      }
 720  
 721      protected function expandParentSelectors(&$tag, $replace): int
 722      {
 723          $parts = explode("$&$", $tag);
 724          $count = 0;
 725          foreach ($parts as &$part) {
 726              $part = str_replace(Constants::PARENT_SELECTOR, $replace, $part, $c);
 727              $count += $c;
 728          }
 729          $tag = implode(Constants::PARENT_SELECTOR, $parts);
 730          return $count;
 731      }
 732  
 733      protected function findClosestSelectors()
 734      {
 735          $env = $this->env;
 736          $selectors = null;
 737          while ($env !== null) {
 738              if (isset($env->selectors)) {
 739                  $selectors = $env->selectors;
 740                  break;
 741              }
 742              $env = $env->parent;
 743          }
 744  
 745          return $selectors;
 746      }
 747  
 748  
 749      // multiply $selectors against the nearest selectors in env
 750      protected function multiplySelectors($selectors)
 751      {
 752          // find parent selectors
 753  
 754          $parentSelectors = $this->findClosestSelectors();
 755          if (is_null($parentSelectors)) {
 756              // kill parent reference in top level selector
 757              foreach ($selectors as &$s) {
 758                  $this->expandParentSelectors($s, '');
 759              }
 760  
 761              return $selectors;
 762          }
 763  
 764          $out = [];
 765          foreach ($parentSelectors as $parent) {
 766              foreach ($selectors as $child) {
 767                  $count = $this->expandParentSelectors($child, $parent);
 768  
 769                  // don't prepend the parent tag if & was used
 770                  if ($count > 0) {
 771                      $out[] = trim($child);
 772                  } else {
 773                      $out[] = trim($parent . ' ' . $child);
 774                  }
 775              }
 776          }
 777  
 778          return $out;
 779      }
 780  
 781      /**
 782       * reduces selector expressions
 783       * @throws Exception
 784       */
 785      protected function compileSelectors($selectors)
 786      {
 787          $out = [];
 788  
 789          foreach ($selectors as $s) {
 790              if (is_array($s)) {
 791                  [, $value] = $s;
 792                  $out[] = trim($this->compileValue($this->reduce($value)));
 793              } else {
 794                  $out[] = $s;
 795              }
 796          }
 797  
 798          return $out;
 799      }
 800  
 801      protected function eq($left, $right)
 802      {
 803          return $left == $right;
 804      }
 805  
 806      /**
 807       * @return bool
 808       * @throws Exception
 809       */
 810      protected function patternMatch($block, $orderedArgs, $keywordArgs)
 811      {
 812          // match the guards if it has them
 813          // any one of the groups must have all its guards pass for a match
 814          if (!empty($block->guards)) {
 815              $groupPassed = false;
 816              foreach ($block->guards as $guardGroup) {
 817                  foreach ($guardGroup as $guard) {
 818                      $this->pushEnv();
 819                      $this->zipSetArgs($block->args, $orderedArgs, $keywordArgs);
 820  
 821                      $negate = false;
 822                      if ($guard[0] == 'negate') {
 823                          $guard = $guard[1];
 824                          $negate = true;
 825                      }
 826  
 827                      $passed = $this->reduce($guard) == Constants::TRUE;
 828                      if ($negate) $passed = !$passed;
 829  
 830                      $this->popEnv();
 831  
 832                      if ($passed) {
 833                          $groupPassed = true;
 834                      } else {
 835                          $groupPassed = false;
 836                          break;
 837                      }
 838                  }
 839  
 840                  if ($groupPassed) break;
 841              }
 842  
 843              if (!$groupPassed) {
 844                  return false;
 845              }
 846          }
 847  
 848          if (empty($block->args)) {
 849              return $block->isVararg || empty($orderedArgs) && empty($keywordArgs);
 850          }
 851  
 852          $remainingArgs = $block->args;
 853          if ($keywordArgs) {
 854              $remainingArgs = [];
 855              foreach ($block->args as $arg) {
 856                  if ($arg[0] == 'arg' && isset($keywordArgs[$arg[1]])) {
 857                      continue;
 858                  }
 859  
 860                  $remainingArgs[] = $arg;
 861              }
 862          }
 863  
 864          $i = -1; // no args
 865          // try to match by arity or by argument literal
 866          foreach ($remainingArgs as $i => $arg) {
 867              switch ($arg[0]) {
 868                  case 'lit':
 869                      if (empty($orderedArgs[$i]) || !$this->eq($arg[1], $orderedArgs[$i])) {
 870                          return false;
 871                      }
 872                      break;
 873                  case 'arg':
 874                      // no arg and no default value
 875                      if (!isset($orderedArgs[$i]) && !isset($arg[2])) {
 876                          return false;
 877                      }
 878                      break;
 879                  case 'rest':
 880                      $i--; // rest can be empty
 881                      break 2;
 882              }
 883          }
 884  
 885          if ($block->isVararg) {
 886              return true; // not having enough is handled above
 887          } else {
 888              $numMatched = $i + 1;
 889              // greater than because default values always match
 890              return $numMatched >= count($orderedArgs);
 891          }
 892      }
 893  
 894      /**
 895       * @throws Exception
 896       */
 897      protected function patternMatchAll($blocks, $orderedArgs, $keywordArgs, $skip = [])
 898      {
 899          $matches = null;
 900          foreach ($blocks as $block) {
 901              // skip seen blocks that don't have arguments
 902              if (isset($skip[$block->id]) && !isset($block->args)) {
 903                  continue;
 904              }
 905  
 906              if ($this->patternMatch($block, $orderedArgs, $keywordArgs)) {
 907                  $matches[] = $block;
 908              }
 909          }
 910  
 911          return $matches;
 912      }
 913  
 914      /**
 915       * attempt to find blocks matched by path and args
 916       * @throws Exception
 917       */
 918      protected function findBlocks($searchIn, $path, $orderedArgs, $keywordArgs, $seen = [])
 919      {
 920          if ($searchIn == null) return null;
 921          if (isset($seen[$searchIn->id])) return null;
 922          $seen[$searchIn->id] = true;
 923  
 924          $name = $path[0];
 925  
 926          if (isset($searchIn->children[$name])) {
 927              $blocks = $searchIn->children[$name];
 928              if (count($path) == 1) {
 929                  $matches = $this->patternMatchAll($blocks, $orderedArgs, $keywordArgs, $seen);
 930                  if (!empty($matches)) {
 931                      // This will return all blocks that match in the closest
 932                      // scope that has any matching block, like lessjs
 933                      return $matches;
 934                  }
 935              } else {
 936                  $matches = [];
 937                  foreach ($blocks as $subBlock) {
 938                      $subMatches = $this->findBlocks(
 939                          $subBlock,
 940                          array_slice($path, 1),
 941                          $orderedArgs,
 942                          $keywordArgs,
 943                          $seen
 944                      );
 945  
 946                      if (!is_null($subMatches)) {
 947                          foreach ($subMatches as $sm) {
 948                              $matches[] = $sm;
 949                          }
 950                      }
 951                  }
 952  
 953                  return count($matches) > 0 ? $matches : null;
 954              }
 955          }
 956          if ($searchIn->parent === $searchIn) return null;
 957          return $this->findBlocks($searchIn->parent, $path, $orderedArgs, $keywordArgs, $seen);
 958      }
 959  
 960      /**
 961       * sets all argument names in $args to either the default value
 962       * or the one passed in through $values
 963       *
 964       * @throws Exception
 965       */
 966      protected function zipSetArgs($args, $orderedValues, $keywordValues)
 967      {
 968          $assignedValues = [];
 969  
 970          $i = 0;
 971          foreach ($args as $a) {
 972              if ($a[0] == 'arg') {
 973                  if (isset($keywordValues[$a[1]])) {
 974                      // has keyword arg
 975                      $value = $keywordValues[$a[1]];
 976                  } elseif (isset($orderedValues[$i])) {
 977                      // has ordered arg
 978                      $value = $orderedValues[$i];
 979                      $i++;
 980                  } elseif (isset($a[2])) {
 981                      // has default value
 982                      $value = $a[2];
 983                  } else {
 984                      throw new Exception('Failed to assign arg ' . $a[1]);
 985                  }
 986  
 987                  $value = $this->reduce($value);
 988                  $this->set($a[1], $value);
 989                  $assignedValues[] = $value;
 990              } else {
 991                  // a lit
 992                  $i++;
 993              }
 994          }
 995  
 996          // check for a rest
 997          $last = end($args);
 998          if ($last !== false && $last[0] === 'rest') {
 999              $rest = array_slice($orderedValues, count($args) - 1);
1000              $this->set($last[1], $this->reduce(['list', ' ', $rest]));
1001          }
1002  
1003          // wow is this the only true use of PHP's + operator for arrays?
1004          $this->env->arguments = $assignedValues + $orderedValues;
1005      }
1006  
1007      /**
1008       * compile a prop and update $lines or $blocks appropriately
1009       * @throws Exception
1010       */
1011      protected function compileProp($prop, $block, $out)
1012      {
1013          // set error position context
1014          $this->sourceLoc = $prop[-1] ?? -1;
1015  
1016          switch ($prop[0]) {
1017              case 'assign':
1018                  [, $name, $value] = $prop;
1019                  if ($name[0] == Constants::VPREFIX) {
1020                      $this->set($name, $value);
1021                  } else {
1022                      $out->lines[] = $this->formatter->property(
1023                          $name,
1024                          $this->compileValue($this->reduce($value))
1025                      );
1026                  }
1027                  break;
1028              case 'block':
1029                  [, $child] = $prop;
1030                  $this->compileBlock($child);
1031                  break;
1032              case 'ruleset':
1033              case 'mixin':
1034                  [, $path, $args, $suffix] = $prop;
1035  
1036                  $orderedArgs = [];
1037                  $keywordArgs = [];
1038                  foreach ((array)$args as $arg) {
1039                      switch ($arg[0]) {
1040                          case 'arg':
1041                              if (!isset($arg[2])) {
1042                                  $orderedArgs[] = $this->reduce(['variable', $arg[1]]);
1043                              } else {
1044                                  $keywordArgs[$arg[1]] = $this->reduce($arg[2]);
1045                              }
1046                              break;
1047  
1048                          case 'lit':
1049                              $orderedArgs[] = $this->reduce($arg[1]);
1050                              break;
1051                          default:
1052                              throw new Exception('Unknown arg type: ' . $arg[0]);
1053                      }
1054                  }
1055  
1056                  $mixins = $this->findBlocks($block, $path, $orderedArgs, $keywordArgs);
1057  
1058                  if ($mixins === null) {
1059                      $block->parser->throwError("{$prop[1][0]} is undefined", $block->count);
1060                  }
1061  
1062                  if (strpos($prop[1][0], "$") === 0) {
1063                      //Use Ruleset Logic - Only last element
1064                      $mixins = [array_pop($mixins)];
1065                  }
1066  
1067                  foreach ($mixins as $mixin) {
1068                      if ($mixin === $block && !$orderedArgs) {
1069                          continue;
1070                      }
1071  
1072                      $haveScope = false;
1073                      if (isset($mixin->parent->scope)) {
1074                          $haveScope = true;
1075                          $mixinParentEnv = $this->pushEnv();
1076                          $mixinParentEnv->storeParent = $mixin->parent->scope;
1077                      }
1078  
1079                      $haveArgs = false;
1080                      if (isset($mixin->args)) {
1081                          $haveArgs = true;
1082                          $this->pushEnv();
1083                          $this->zipSetArgs($mixin->args, $orderedArgs, $keywordArgs);
1084                      }
1085  
1086                      $oldParent = $mixin->parent;
1087                      if ($mixin != $block) $mixin->parent = $block;
1088  
1089                      foreach ($this->sortProps($mixin->props) as $subProp) {
1090                          if ($suffix !== null &&
1091                              $subProp[0] == 'assign' &&
1092                              is_string($subProp[1]) &&
1093                              $subProp[1][0] != Constants::VPREFIX) {
1094                              $subProp[2] = ['list', ' ', [$subProp[2], ['keyword', $suffix]]];
1095                          }
1096  
1097                          $this->compileProp($subProp, $mixin, $out);
1098                      }
1099  
1100                      $mixin->parent = $oldParent;
1101  
1102                      if ($haveArgs) $this->popEnv();
1103                      if ($haveScope) $this->popEnv();
1104                  }
1105  
1106                  break;
1107              case 'raw':
1108              case 'comment':
1109                  $out->lines[] = $prop[1];
1110                  break;
1111              case 'directive':
1112                  [, $name, $value] = $prop;
1113                  $out->lines[] = "@$name " . $this->compileValue($this->reduce($value)) . ';';
1114                  break;
1115              case 'import':
1116                  [, $importPath, $importId] = $prop;
1117                  $importPath = $this->reduce($importPath);
1118  
1119                  if (!isset($this->env->imports)) {
1120                      $this->env->imports = [];
1121                  }
1122  
1123                  $result = $this->tryImport($importPath, $block, $out);
1124  
1125                  $this->env->imports[$importId] = $result === false ?
1126                      [false, '@import ' . $this->compileValue($importPath) . ';'] :
1127                      $result;
1128  
1129                  break;
1130              case 'import_mixin':
1131                  [, $importId] = $prop;
1132                  $import = $this->env->imports[$importId];
1133                  if ($import[0] === false) {
1134                      if (isset($import[1])) {
1135                          $out->lines[] = $import[1];
1136                      }
1137                  } else {
1138                      [, $bottom, $parser, $importDir] = $import;
1139                      $this->compileImportedProps($bottom, $block, $out, $parser, $importDir);
1140                  }
1141  
1142                  break;
1143              default:
1144                  $block->parser->throwError("unknown op: $prop[0]\n", $block->count);
1145          }
1146      }
1147  
1148  
1149      /**
1150       * Compiles a primitive value into a CSS property value.
1151       *
1152       * Values in lessphp are typed by being wrapped in arrays, their format is
1153       * typically:
1154       *
1155       *     array(type, contents [, additional_contents]*)
1156       *
1157       * The input is expected to be reduced. This function will not work on
1158       * things like expressions and variables.
1159       * @throws Exception
1160       * @internal parser internal method
1161       */
1162      public function compileValue($value)
1163      {
1164          switch ($value[0]) {
1165              case 'list':
1166                  // [1] - delimiter
1167                  // [2] - array of values
1168                  return implode($value[1], array_map([$this, 'compileValue'], $value[2]));
1169              case 'raw_color':
1170                  if (!empty($this->formatter->compressColors)) {
1171                      return $this->compileValue(Color::coerceColor($value));
1172                  }
1173                  return $value[1];
1174              case 'keyword':
1175                  // [1] - the keyword
1176                  return $value[1];
1177              case 'number':
1178                  [, $num, $unit] = $value;
1179                  // [1] - the number
1180                  // [2] - the unit
1181                  if ($this->numberPrecision !== null) {
1182                      $num = round($num, $this->numberPrecision);
1183                  }
1184                  return $num . $unit;
1185              case 'string':
1186                  // [1] - contents of string (includes quotes)
1187                  [, $delim, $content] = $value;
1188                  foreach ($content as &$part) {
1189                      if (is_array($part)) {
1190                          $part = $this->compileValue($part);
1191                      }
1192                  }
1193                  return $delim . implode($content) . $delim;
1194              case 'color':
1195                  // [1] - red component (either number or a %)
1196                  // [2] - green component
1197                  // [3] - blue component
1198                  // [4] - optional alpha component
1199                  [, $r, $g, $b] = $value;
1200                  $r = round($r);
1201                  $g = round($g);
1202                  $b = round($b);
1203  
1204                  if (count($value) == 5 && $value[4] != 1) { // rgba
1205                      return 'rgba(' . $r . ',' . $g . ',' . $b . ',' . $value[4] . ')';
1206                  }
1207  
1208                  $h = sprintf('#%02x%02x%02x', $r, $g, $b);
1209  
1210                  if (!empty($this->formatter->compressColors)) {
1211                      // Converting hex color to short notation (e.g. #003399 to #039)
1212                      if ($h[1] === $h[2] && $h[3] === $h[4] && $h[5] === $h[6]) {
1213                          $h = '#' . $h[1] . $h[3] . $h[5];
1214                      }
1215                  }
1216  
1217                  return $h;
1218  
1219              case 'function':
1220                  [, $name, $args] = $value;
1221                  return $name . '(' . $this->compileValue($args) . ')';
1222              default: // assumed to be unit
1223                  throw new Exception("unknown value type: $value[0]");
1224          }
1225      }
1226  
1227      /**
1228       * Utility func to unquote a string
1229       *
1230       * @todo this not really a good name for this function
1231       * @internal parser internal method
1232       */
1233      public function unwrap(array $arg): array
1234      {
1235          switch ($arg[0]) {
1236              case 'list':
1237                  $items = $arg[2];
1238                  if (isset($items[0])) {
1239                      return self::unwrap($items[0]);
1240                  }
1241                  throw new Exception('unrecognised input');
1242              case 'string':
1243                  $arg[1] = '';
1244                  return $arg;
1245              case 'keyword':
1246                  return $arg;
1247              default:
1248                  return ['keyword', $this->compileValue($arg)];
1249          }
1250      }
1251  
1252      /**
1253       * Convert the rgb, rgba, hsl color literals of function type
1254       * as returned by the parser into values of color type.
1255       *
1256       * @throws Exception
1257       */
1258      protected function funcToColor($func)
1259      {
1260          $fname = $func[1];
1261          if ($func[2][0] != 'list') return false; // need a list of arguments
1262          $rawComponents = $func[2][2];
1263  
1264          if ($fname == 'hsl' || $fname == 'hsla') {
1265              $hsl = ['hsl'];
1266              $i = 0;
1267              foreach ($rawComponents as $c) {
1268                  $val = $this->reduce($c);
1269                  $val = isset($val[1]) ? floatval($val[1]) : 0;
1270  
1271                  if ($i == 0) $clamp = 360;
1272                  elseif ($i < 3) $clamp = 100;
1273                  else $clamp = 1;
1274  
1275                  $hsl[] = Util::clamp($val, $clamp);
1276                  $i++;
1277              }
1278  
1279              while (count($hsl) < 4) $hsl[] = 0;
1280              return Color::toRGB($hsl);
1281          } elseif ($fname == 'rgb' || $fname == 'rgba') {
1282              $components = [];
1283              $i = 1;
1284              foreach ($rawComponents as $c) {
1285                  $c = $this->reduce($c);
1286                  if ($i < 4) {
1287                      if ($c[0] == 'number' && $c[2] == '%') {
1288                          $components[] = 255 * ($c[1] / 100);
1289                      } else {
1290                          $components[] = floatval($c[1]);
1291                      }
1292                  } elseif ($i == 4) {
1293                      if ($c[0] == 'number' && $c[2] == '%') {
1294                          $components[] = 1.0 * ($c[1] / 100);
1295                      } else {
1296                          $components[] = floatval($c[1]);
1297                      }
1298                  } else break;
1299  
1300                  $i++;
1301              }
1302              while (count($components) < 3) $components[] = 0;
1303              array_unshift($components, 'color');
1304              return Color::fixColor($components);
1305          }
1306  
1307          return false;
1308      }
1309  
1310      /**
1311       * @throws Exception
1312       * @internal parser internal method
1313       */
1314      public function reduce($value, $forExpression = false)
1315      {
1316          switch ($value[0]) {
1317              case 'interpolate':
1318                  $reduced = $this->reduce($value[1]);
1319                  $var = $this->compileValue($reduced);
1320                  $res = $this->reduce(['variable', Constants::VPREFIX . $var]);
1321  
1322                  if ($res[0] == 'raw_color') {
1323                      $res = Color::coerceColor($res);
1324                  }
1325  
1326                  if (empty($value[2])) $res = $this->unwrap($res);
1327  
1328                  return $res;
1329              case 'variable':
1330                  $key = $value[1];
1331                  if (is_array($key)) {
1332                      $key = $this->reduce($key);
1333                      $key = Constants::VPREFIX . $this->compileValue($this->unwrap($key));
1334                  }
1335  
1336                  $seen =& $this->env->seenNames;
1337  
1338                  if (!empty($seen[$key])) {
1339                      throw new Exception("infinite loop detected: $key");
1340                  }
1341  
1342                  $seen[$key] = true;
1343                  $out = $this->reduce($this->get($key));
1344                  $seen[$key] = false;
1345                  return $out;
1346              case 'list':
1347                  foreach ($value[2] as &$item) {
1348                      $item = $this->reduce($item, $forExpression);
1349                  }
1350                  return $value;
1351              case 'expression':
1352                  return $this->evaluate($value);
1353              case 'string':
1354                  foreach ($value[2] as &$part) {
1355                      if (is_array($part)) {
1356                          $strip = $part[0] == 'variable';
1357                          $part = $this->reduce($part);
1358                          if ($strip) $part = $this->unwrap($part);
1359                      }
1360                  }
1361                  return $value;
1362              case 'escape':
1363                  [, $inner] = $value;
1364                  return $this->unwrap($this->reduce($inner));
1365              case 'function':
1366                  $color = $this->funcToColor($value);
1367                  if ($color) return $color;
1368  
1369                  [, $name, $args] = $value;
1370  
1371                  $f = $this->libFunctions[$name] ?? null;
1372  
1373                  if (is_callable($f)) {
1374                      if ($args[0] == 'list')
1375                          $args = self::compressList($args[2], $args[1]);
1376  
1377                      $ret = call_user_func($f, $this->reduce($args, true), $this);
1378  
1379                      if (is_null($ret)) {
1380                          return ['string', '', [$name, '(', $args, ')']];
1381                      }
1382  
1383                      // convert to a typed value if the result is a php primitive
1384                      if (is_numeric($ret)) $ret = ['number', $ret, ''];
1385                      elseif (!is_array($ret)) $ret = ['keyword', $ret];
1386  
1387                      return $ret;
1388                  }
1389  
1390                  // plain function, reduce args
1391                  $value[2] = $this->reduce($value[2]);
1392                  return $value;
1393              case 'unary':
1394                  [, $op, $exp] = $value;
1395                  $exp = $this->reduce($exp);
1396  
1397                  if ($exp[0] == 'number') {
1398                      switch ($op) {
1399                          case '+':
1400                              return $exp;
1401                          case '-':
1402                              $exp[1] *= -1;
1403                              return $exp;
1404                      }
1405                  }
1406                  return ['string', '', [$op, $exp]];
1407          }
1408  
1409          if ($forExpression) {
1410              switch ($value[0]) {
1411                  case 'keyword':
1412                      if ($color = Color::coerceColor($value)) {
1413                          return $color;
1414                      }
1415                      break;
1416                  case 'raw_color':
1417                      return Color::coerceColor($value);
1418              }
1419          }
1420  
1421          return $value;
1422      }
1423  
1424  
1425      // make something string like into a string
1426      protected function coerceString($value)
1427      {
1428          switch ($value[0]) {
1429              case 'string':
1430                  return $value;
1431              case 'keyword':
1432                  return ['string', '', [$value[1]]];
1433          }
1434          return null;
1435      }
1436  
1437      // turn list of length 1 into value type
1438      protected function flattenList($value)
1439      {
1440          if ($value[0] == 'list' && count($value[2]) == 1) {
1441              return $this->flattenList($value[2][0]);
1442          }
1443          return $value;
1444      }
1445  
1446      /**
1447       * evaluate an expression
1448       * @throws Exception
1449       */
1450      protected function evaluate($exp)
1451      {
1452          [, $op, $left, $right, $whiteBefore, $whiteAfter] = $exp;
1453  
1454          $left = $this->reduce($left, true);
1455          $right = $this->reduce($right, true);
1456  
1457          if ($leftColor = Color::coerceColor($left)) {
1458              $left = $leftColor;
1459          }
1460  
1461          if ($rightColor = Color::coerceColor($right)) {
1462              $right = $rightColor;
1463          }
1464  
1465          $ltype = $left[0];
1466          $rtype = $right[0];
1467  
1468          // operators that work on all types
1469          if ($op == 'and') {
1470              return Util::toBool($left == Constants::TRUE && $right == Constants::TRUE);
1471          }
1472  
1473          if ($op == '=') {
1474              return Util::toBool($this->eq($left, $right));
1475          }
1476  
1477          if ($op == '+' && !is_null($str = $this->stringConcatenate($left, $right))) {
1478              return $str;
1479          }
1480  
1481          // type based operators
1482          $fname = sprintf('op_%s_%s', $ltype, $rtype);
1483          if (is_callable([$this, $fname])) {
1484              $out = $this->$fname($op, $left, $right);
1485              if (!is_null($out)) return $out;
1486          }
1487  
1488          // make the expression look it did before being parsed
1489          $paddedOp = $op;
1490          if ($whiteBefore) $paddedOp = ' ' . $paddedOp;
1491          if ($whiteAfter) $paddedOp .= ' ';
1492  
1493          return ['string', '', [$left, $paddedOp, $right]];
1494      }
1495  
1496      protected function stringConcatenate($left, $right)
1497      {
1498          if ($strLeft = $this->coerceString($left)) {
1499              if ($right[0] == 'string') {
1500                  $right[1] = '';
1501              }
1502              $strLeft[2][] = $right;
1503              return $strLeft;
1504          }
1505  
1506          if ($strRight = $this->coerceString($right)) {
1507              array_unshift($strRight[2], $left);
1508              return $strRight;
1509          }
1510      }
1511  
1512  
1513      /**
1514       * @throws Exception
1515       */
1516      protected function op_number_color($op, $lft, $rgt)
1517      {
1518          if ($op == '+' || $op == '*') {
1519              return $this->op_color_number($op, $rgt, $lft);
1520          }
1521      }
1522  
1523      /**
1524       * @throws Exception
1525       */
1526      protected function op_color_number($op, $lft, $rgt)
1527      {
1528          if ($rgt[0] == '%') $rgt[1] /= 100;
1529  
1530          return $this->op_color_color(
1531              $op,
1532              $lft,
1533              array_fill(1, count($lft) - 1, $rgt[1])
1534          );
1535      }
1536  
1537      /**
1538       * @throws Exception
1539       */
1540      protected function op_color_color($op, $left, $right)
1541      {
1542          $out = ['color'];
1543          $max = count($left) > count($right) ? count($left) : count($right);
1544          foreach (range(1, $max - 1) as $i) {
1545              $lval = $left[$i] ?? 0;
1546              $rval = $right[$i] ?? 0;
1547              switch ($op) {
1548                  case '+':
1549                      $out[] = $lval + $rval;
1550                      break;
1551                  case '-':
1552                      $out[] = $lval - $rval;
1553                      break;
1554                  case '*':
1555                      $out[] = $lval * $rval;
1556                      break;
1557                  case '%':
1558                      $out[] = $lval % $rval;
1559                      break;
1560                  case '/':
1561                      if ($rval == 0) throw new Exception("evaluate error: can't divide by zero");
1562                      $out[] = $lval / $rval;
1563                      break;
1564                  default:
1565                      throw new Exception('evaluate error: color op number failed on op ' . $op);
1566              }
1567          }
1568          return Color::fixColor($out);
1569      }
1570  
1571  
1572      /**
1573       * operator on two numbers
1574       * @throws Exception
1575       */
1576      protected function op_number_number($op, $left, $right)
1577      {
1578          $unit = empty($left[2]) ? $right[2] : $left[2];
1579  
1580          $value = 0;
1581          switch ($op) {
1582              case '+':
1583                  $value = $left[1] + $right[1];
1584                  break;
1585              case '*':
1586                  $value = $left[1] * $right[1];
1587                  break;
1588              case '-':
1589                  $value = $left[1] - $right[1];
1590                  break;
1591              case '%':
1592                  $value = $left[1] % $right[1];
1593                  break;
1594              case '/':
1595                  if ($right[1] == 0) throw new Exception('parse error: divide by zero');
1596                  $value = $left[1] / $right[1];
1597                  break;
1598              case '<':
1599                  return Util::toBool($left[1] < $right[1]);
1600              case '>':
1601                  return Util::toBool($left[1] > $right[1]);
1602              case '>=':
1603                  return Util::toBool($left[1] >= $right[1]);
1604              case '=<':
1605                  return Util::toBool($left[1] <= $right[1]);
1606              default:
1607                  throw new Exception('parse error: unknown number operator: ' . $op);
1608          }
1609  
1610          return ['number', $value, $unit];
1611      }
1612  
1613  
1614      /* environment functions */
1615  
1616      protected function makeOutputBlock($type, $selectors = null)
1617      {
1618          $b = new stdclass;
1619          $b->lines = [];
1620          $b->children = [];
1621          $b->selectors = $selectors;
1622          $b->type = $type;
1623          $b->parent = $this->scope;
1624          return $b;
1625      }
1626  
1627      // the state of execution
1628      protected function pushEnv($block = null)
1629      {
1630          $e = new stdclass;
1631          $e->parent = $this->env;
1632          $e->store = [];
1633          $e->block = $block;
1634  
1635          $this->env = $e;
1636          return $e;
1637      }
1638  
1639      // pop something off the stack
1640      protected function popEnv()
1641      {
1642          $old = $this->env;
1643          $this->env = $this->env->parent;
1644          return $old;
1645      }
1646  
1647      // set something in the current env
1648      protected function set($name, $value)
1649      {
1650          $this->env->store[$name] = $value;
1651      }
1652  
1653  
1654      /**
1655       * get the highest occurrence entry for a name
1656       * @throws Exception
1657       */
1658      protected function get($name)
1659      {
1660          $current = $this->env;
1661  
1662          // track scope to evaluate
1663          $scope_secondary = [];
1664  
1665          $isArguments = $name == Constants::VPREFIX . 'arguments';
1666          while ($current) {
1667              if ($isArguments && isset($current->arguments)) {
1668                  return ['list', ' ', $current->arguments];
1669              }
1670  
1671              if (isset($current->store[$name]))
1672                  return $current->store[$name];
1673              // has secondary scope?
1674              if (isset($current->storeParent))
1675                  $scope_secondary[] = $current->storeParent;
1676  
1677              $current = $current->parent ?? null;
1678          }
1679  
1680          while (count($scope_secondary)) {
1681              // pop one off
1682              $current = array_shift($scope_secondary);
1683              while ($current) {
1684                  if ($isArguments && isset($current->arguments)) {
1685                      return ['list', ' ', $current->arguments];
1686                  }
1687  
1688                  if (isset($current->store[$name])) {
1689                      return $current->store[$name];
1690                  }
1691  
1692                  // has secondary scope?
1693                  if (isset($current->storeParent)) {
1694                      $scope_secondary[] = $current->storeParent;
1695                  }
1696  
1697                  $current = $current->parent ?? null;
1698              }
1699          }
1700  
1701          throw new Exception("variable $name is undefined");
1702      }
1703  
1704      /**
1705       * inject array of unparsed strings into environment as variables
1706       * @throws Exception
1707       */
1708      protected function injectVariables($args)
1709      {
1710          $this->pushEnv();
1711          $parser = new Parser(__METHOD__);
1712          foreach ($args as $name => $strValue) {
1713              if ($name[0] != '@') $name = '@' . $name;
1714              $parser->count = 0;
1715              $parser->buffer = (string)$strValue;
1716              if (!$parser->propertyValue($value)) {
1717                  throw new Exception("failed to parse passed in variable $name: $strValue");
1718              }
1719  
1720              $this->set($name, $value);
1721          }
1722      }
1723  
1724      /**
1725       * Create a new parser instance
1726       *
1727       * @param string|null $name A name to identify the parser in error messages
1728       */
1729      protected function makeParser(?string $name): Parser
1730      {
1731          $parser = new Parser($name);
1732          $parser->writeComments = $this->preserveComments;
1733  
1734          return $parser;
1735      }
1736  
1737      /**
1738       * Add the given file to the list of parsed files
1739       *
1740       * @param $file
1741       */
1742      protected function addParsedFile($file): void
1743      {
1744          $this->allParsedFiles[realpath($file)] = filemtime($file);
1745      }
1746  }