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