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 12namespace LesserPHP; 13 14use Exception; 15use LesserPHP\Functions\AbstractFunctionCollection; 16use LesserPHP\Utils\Color; 17use LesserPHP\Utils\Util; 18use 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 */ 46class 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} 1747