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 // pre-round to work around more precice rounding in PHP 8.4+ 1201 // see https://github.com/php/php-src/issues/16930 1202 $r = round(round($r, 13)); 1203 $g = round(round($g, 13)); 1204 $b = round(round($b, 13)); 1205 1206 if (count($value) == 5 && $value[4] != 1) { // rgba 1207 return 'rgba(' . $r . ',' . $g . ',' . $b . ',' . $value[4] . ')'; 1208 } 1209 1210 $h = sprintf('#%02x%02x%02x', $r, $g, $b); 1211 1212 if (!empty($this->formatter->compressColors)) { 1213 // Converting hex color to short notation (e.g. #003399 to #039) 1214 if ($h[1] === $h[2] && $h[3] === $h[4] && $h[5] === $h[6]) { 1215 $h = '#' . $h[1] . $h[3] . $h[5]; 1216 } 1217 } 1218 1219 return $h; 1220 1221 case 'function': 1222 [, $name, $args] = $value; 1223 return $name . '(' . $this->compileValue($args) . ')'; 1224 default: // assumed to be unit 1225 throw new Exception("unknown value type: $value[0]"); 1226 } 1227 } 1228 1229 /** 1230 * Utility func to unquote a string 1231 * 1232 * @todo this not really a good name for this function 1233 * @internal parser internal method 1234 */ 1235 public function unwrap(array $arg): array 1236 { 1237 switch ($arg[0]) { 1238 case 'list': 1239 $items = $arg[2]; 1240 if (isset($items[0])) { 1241 return self::unwrap($items[0]); 1242 } 1243 throw new Exception('unrecognised input'); 1244 case 'string': 1245 $arg[1] = ''; 1246 return $arg; 1247 case 'keyword': 1248 return $arg; 1249 default: 1250 return ['keyword', $this->compileValue($arg)]; 1251 } 1252 } 1253 1254 /** 1255 * Convert the rgb, rgba, hsl color literals of function type 1256 * as returned by the parser into values of color type. 1257 * 1258 * @throws Exception 1259 */ 1260 protected function funcToColor($func) 1261 { 1262 $fname = $func[1]; 1263 if ($func[2][0] != 'list') return false; // need a list of arguments 1264 $rawComponents = $func[2][2]; 1265 1266 if ($fname == 'hsl' || $fname == 'hsla') { 1267 $hsl = ['hsl']; 1268 $i = 0; 1269 foreach ($rawComponents as $c) { 1270 $val = $this->reduce($c); 1271 $val = isset($val[1]) ? floatval($val[1]) : 0; 1272 1273 if ($i == 0) $clamp = 360; 1274 elseif ($i < 3) $clamp = 100; 1275 else $clamp = 1; 1276 1277 $hsl[] = Util::clamp($val, $clamp); 1278 $i++; 1279 } 1280 1281 while (count($hsl) < 4) $hsl[] = 0; 1282 return Color::toRGB($hsl); 1283 } elseif ($fname == 'rgb' || $fname == 'rgba') { 1284 $components = []; 1285 $i = 1; 1286 foreach ($rawComponents as $c) { 1287 $c = $this->reduce($c); 1288 if ($i < 4) { 1289 if ($c[0] == 'number' && $c[2] == '%') { 1290 $components[] = 255 * ($c[1] / 100); 1291 } else { 1292 $components[] = floatval($c[1]); 1293 } 1294 } elseif ($i == 4) { 1295 if ($c[0] == 'number' && $c[2] == '%') { 1296 $components[] = 1.0 * ($c[1] / 100); 1297 } else { 1298 $components[] = floatval($c[1]); 1299 } 1300 } else break; 1301 1302 $i++; 1303 } 1304 while (count($components) < 3) $components[] = 0; 1305 array_unshift($components, 'color'); 1306 return Color::fixColor($components); 1307 } 1308 1309 return false; 1310 } 1311 1312 /** 1313 * @throws Exception 1314 * @internal parser internal method 1315 */ 1316 public function reduce($value, $forExpression = false) 1317 { 1318 switch ($value[0]) { 1319 case 'interpolate': 1320 $reduced = $this->reduce($value[1]); 1321 $var = $this->compileValue($reduced); 1322 $res = $this->reduce(['variable', Constants::VPREFIX . $var]); 1323 1324 if ($res[0] == 'raw_color') { 1325 $res = Color::coerceColor($res); 1326 } 1327 1328 if (empty($value[2])) $res = $this->unwrap($res); 1329 1330 return $res; 1331 case 'variable': 1332 $key = $value[1]; 1333 if (is_array($key)) { 1334 $key = $this->reduce($key); 1335 $key = Constants::VPREFIX . $this->compileValue($this->unwrap($key)); 1336 } 1337 1338 $seen =& $this->env->seenNames; 1339 1340 if (!empty($seen[$key])) { 1341 throw new Exception("infinite loop detected: $key"); 1342 } 1343 1344 $seen[$key] = true; 1345 $out = $this->reduce($this->get($key)); 1346 $seen[$key] = false; 1347 return $out; 1348 case 'list': 1349 foreach ($value[2] as &$item) { 1350 $item = $this->reduce($item, $forExpression); 1351 } 1352 return $value; 1353 case 'expression': 1354 return $this->evaluate($value); 1355 case 'string': 1356 foreach ($value[2] as &$part) { 1357 if (is_array($part)) { 1358 $strip = $part[0] == 'variable'; 1359 $part = $this->reduce($part); 1360 if ($strip) $part = $this->unwrap($part); 1361 } 1362 } 1363 return $value; 1364 case 'escape': 1365 [, $inner] = $value; 1366 return $this->unwrap($this->reduce($inner)); 1367 case 'function': 1368 $color = $this->funcToColor($value); 1369 if ($color) return $color; 1370 1371 [, $name, $args] = $value; 1372 1373 $f = $this->libFunctions[$name] ?? null; 1374 1375 if (is_callable($f)) { 1376 if ($args[0] == 'list') 1377 $args = self::compressList($args[2], $args[1]); 1378 1379 $ret = call_user_func($f, $this->reduce($args, true), $this); 1380 1381 if (is_null($ret)) { 1382 return ['string', '', [$name, '(', $args, ')']]; 1383 } 1384 1385 // convert to a typed value if the result is a php primitive 1386 if (is_numeric($ret)) $ret = ['number', $ret, '']; 1387 elseif (!is_array($ret)) $ret = ['keyword', $ret]; 1388 1389 return $ret; 1390 } 1391 1392 // plain function, reduce args 1393 $value[2] = $this->reduce($value[2]); 1394 return $value; 1395 case 'unary': 1396 [, $op, $exp] = $value; 1397 $exp = $this->reduce($exp); 1398 1399 if ($exp[0] == 'number') { 1400 switch ($op) { 1401 case '+': 1402 return $exp; 1403 case '-': 1404 $exp[1] *= -1; 1405 return $exp; 1406 } 1407 } 1408 return ['string', '', [$op, $exp]]; 1409 } 1410 1411 if ($forExpression) { 1412 switch ($value[0]) { 1413 case 'keyword': 1414 if ($color = Color::coerceColor($value)) { 1415 return $color; 1416 } 1417 break; 1418 case 'raw_color': 1419 return Color::coerceColor($value); 1420 } 1421 } 1422 1423 return $value; 1424 } 1425 1426 1427 // make something string like into a string 1428 protected function coerceString($value) 1429 { 1430 switch ($value[0]) { 1431 case 'string': 1432 return $value; 1433 case 'keyword': 1434 return ['string', '', [$value[1]]]; 1435 } 1436 return null; 1437 } 1438 1439 // turn list of length 1 into value type 1440 protected function flattenList($value) 1441 { 1442 if ($value[0] == 'list' && count($value[2]) == 1) { 1443 return $this->flattenList($value[2][0]); 1444 } 1445 return $value; 1446 } 1447 1448 /** 1449 * evaluate an expression 1450 * @throws Exception 1451 */ 1452 protected function evaluate($exp) 1453 { 1454 [, $op, $left, $right, $whiteBefore, $whiteAfter] = $exp; 1455 1456 $left = $this->reduce($left, true); 1457 $right = $this->reduce($right, true); 1458 1459 if ($leftColor = Color::coerceColor($left)) { 1460 $left = $leftColor; 1461 } 1462 1463 if ($rightColor = Color::coerceColor($right)) { 1464 $right = $rightColor; 1465 } 1466 1467 $ltype = $left[0]; 1468 $rtype = $right[0]; 1469 1470 // operators that work on all types 1471 if ($op == 'and') { 1472 return Util::toBool($left == Constants::TRUE && $right == Constants::TRUE); 1473 } 1474 1475 if ($op == '=') { 1476 return Util::toBool($this->eq($left, $right)); 1477 } 1478 1479 if ($op == '+' && !is_null($str = $this->stringConcatenate($left, $right))) { 1480 return $str; 1481 } 1482 1483 // type based operators 1484 $fname = sprintf('op_%s_%s', $ltype, $rtype); 1485 if (is_callable([$this, $fname])) { 1486 $out = $this->$fname($op, $left, $right); 1487 if (!is_null($out)) return $out; 1488 } 1489 1490 // make the expression look it did before being parsed 1491 $paddedOp = $op; 1492 if ($whiteBefore) $paddedOp = ' ' . $paddedOp; 1493 if ($whiteAfter) $paddedOp .= ' '; 1494 1495 return ['string', '', [$left, $paddedOp, $right]]; 1496 } 1497 1498 protected function stringConcatenate($left, $right) 1499 { 1500 if ($strLeft = $this->coerceString($left)) { 1501 if ($right[0] == 'string') { 1502 $right[1] = ''; 1503 } 1504 $strLeft[2][] = $right; 1505 return $strLeft; 1506 } 1507 1508 if ($strRight = $this->coerceString($right)) { 1509 array_unshift($strRight[2], $left); 1510 return $strRight; 1511 } 1512 } 1513 1514 1515 /** 1516 * @throws Exception 1517 */ 1518 protected function op_number_color($op, $lft, $rgt) 1519 { 1520 if ($op == '+' || $op == '*') { 1521 return $this->op_color_number($op, $rgt, $lft); 1522 } 1523 } 1524 1525 /** 1526 * @throws Exception 1527 */ 1528 protected function op_color_number($op, $lft, $rgt) 1529 { 1530 if ($rgt[0] == '%') $rgt[1] /= 100; 1531 1532 return $this->op_color_color( 1533 $op, 1534 $lft, 1535 array_fill(1, count($lft) - 1, $rgt[1]) 1536 ); 1537 } 1538 1539 /** 1540 * @throws Exception 1541 */ 1542 protected function op_color_color($op, $left, $right) 1543 { 1544 $out = ['color']; 1545 $max = count($left) > count($right) ? count($left) : count($right); 1546 foreach (range(1, $max - 1) as $i) { 1547 $lval = $left[$i] ?? 0; 1548 $rval = $right[$i] ?? 0; 1549 switch ($op) { 1550 case '+': 1551 $out[] = $lval + $rval; 1552 break; 1553 case '-': 1554 $out[] = $lval - $rval; 1555 break; 1556 case '*': 1557 $out[] = $lval * $rval; 1558 break; 1559 case '%': 1560 $out[] = $lval % $rval; 1561 break; 1562 case '/': 1563 if ($rval == 0) throw new Exception("evaluate error: can't divide by zero"); 1564 $out[] = $lval / $rval; 1565 break; 1566 default: 1567 throw new Exception('evaluate error: color op number failed on op ' . $op); 1568 } 1569 } 1570 return Color::fixColor($out); 1571 } 1572 1573 1574 /** 1575 * operator on two numbers 1576 * @throws Exception 1577 */ 1578 protected function op_number_number($op, $left, $right) 1579 { 1580 $unit = empty($left[2]) ? $right[2] : $left[2]; 1581 1582 $value = 0; 1583 switch ($op) { 1584 case '+': 1585 $value = $left[1] + $right[1]; 1586 break; 1587 case '*': 1588 $value = $left[1] * $right[1]; 1589 break; 1590 case '-': 1591 $value = $left[1] - $right[1]; 1592 break; 1593 case '%': 1594 $value = $left[1] % $right[1]; 1595 break; 1596 case '/': 1597 if ($right[1] == 0) throw new Exception('parse error: divide by zero'); 1598 $value = $left[1] / $right[1]; 1599 break; 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 case '=<': 1607 return Util::toBool($left[1] <= $right[1]); 1608 default: 1609 throw new Exception('parse error: unknown number operator: ' . $op); 1610 } 1611 1612 return ['number', $value, $unit]; 1613 } 1614 1615 1616 /* environment functions */ 1617 1618 protected function makeOutputBlock($type, $selectors = null) 1619 { 1620 $b = new stdclass; 1621 $b->lines = []; 1622 $b->children = []; 1623 $b->selectors = $selectors; 1624 $b->type = $type; 1625 $b->parent = $this->scope; 1626 return $b; 1627 } 1628 1629 // the state of execution 1630 protected function pushEnv($block = null) 1631 { 1632 $e = new stdclass; 1633 $e->parent = $this->env; 1634 $e->store = []; 1635 $e->block = $block; 1636 1637 $this->env = $e; 1638 return $e; 1639 } 1640 1641 // pop something off the stack 1642 protected function popEnv() 1643 { 1644 $old = $this->env; 1645 $this->env = $this->env->parent; 1646 return $old; 1647 } 1648 1649 // set something in the current env 1650 protected function set($name, $value) 1651 { 1652 $this->env->store[$name] = $value; 1653 } 1654 1655 1656 /** 1657 * get the highest occurrence entry for a name 1658 * @throws Exception 1659 */ 1660 protected function get($name) 1661 { 1662 $current = $this->env; 1663 1664 // track scope to evaluate 1665 $scope_secondary = []; 1666 1667 $isArguments = $name == Constants::VPREFIX . 'arguments'; 1668 while ($current) { 1669 if ($isArguments && isset($current->arguments)) { 1670 return ['list', ' ', $current->arguments]; 1671 } 1672 1673 if (isset($current->store[$name])) 1674 return $current->store[$name]; 1675 // has secondary scope? 1676 if (isset($current->storeParent)) 1677 $scope_secondary[] = $current->storeParent; 1678 1679 $current = $current->parent ?? null; 1680 } 1681 1682 while (count($scope_secondary)) { 1683 // pop one off 1684 $current = array_shift($scope_secondary); 1685 while ($current) { 1686 if ($isArguments && isset($current->arguments)) { 1687 return ['list', ' ', $current->arguments]; 1688 } 1689 1690 if (isset($current->store[$name])) { 1691 return $current->store[$name]; 1692 } 1693 1694 // has secondary scope? 1695 if (isset($current->storeParent)) { 1696 $scope_secondary[] = $current->storeParent; 1697 } 1698 1699 $current = $current->parent ?? null; 1700 } 1701 } 1702 1703 throw new Exception("variable $name is undefined"); 1704 } 1705 1706 /** 1707 * inject array of unparsed strings into environment as variables 1708 * @throws Exception 1709 */ 1710 protected function injectVariables($args) 1711 { 1712 $this->pushEnv(); 1713 $parser = new Parser(__METHOD__); 1714 foreach ($args as $name => $strValue) { 1715 if ($name[0] != '@') $name = '@' . $name; 1716 $parser->count = 0; 1717 $parser->buffer = (string)$strValue; 1718 if (!$parser->propertyValue($value)) { 1719 throw new Exception("failed to parse passed in variable $name: $strValue"); 1720 } 1721 1722 $this->set($name, $value); 1723 } 1724 } 1725 1726 /** 1727 * Create a new parser instance 1728 * 1729 * @param string|null $name A name to identify the parser in error messages 1730 */ 1731 protected function makeParser(?string $name): Parser 1732 { 1733 $parser = new Parser($name); 1734 $parser->writeComments = $this->preserveComments; 1735 1736 return $parser; 1737 } 1738 1739 /** 1740 * Add the given file to the list of parsed files 1741 * 1742 * @param $file 1743 */ 1744 protected function addParsedFile($file): void 1745 { 1746 $this->allParsedFiles[realpath($file)] = filemtime($file); 1747 } 1748} 1749