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