1<?php
2/**
3 * SCSSPHP
4 *
5 * @copyright 2012-2019 Leaf Corcoran
6 *
7 * @license http://opensource.org/licenses/MIT MIT
8 *
9 * @link http://scssphp.github.io/scssphp
10 */
11
12namespace ScssPhp\ScssPhp;
13
14use ScssPhp\ScssPhp\Block;
15use ScssPhp\ScssPhp\Cache;
16use ScssPhp\ScssPhp\Compiler;
17use ScssPhp\ScssPhp\Exception\ParserException;
18use ScssPhp\ScssPhp\Node;
19use ScssPhp\ScssPhp\Type;
20
21/**
22 * Parser
23 *
24 * @author Leaf Corcoran <leafot@gmail.com>
25 */
26class Parser
27{
28    const SOURCE_INDEX  = -1;
29    const SOURCE_LINE   = -2;
30    const SOURCE_COLUMN = -3;
31
32    /**
33     * @var array
34     */
35    protected static $precedence = [
36        '='   => 0,
37        'or'  => 1,
38        'and' => 2,
39        '=='  => 3,
40        '!='  => 3,
41        '<=>' => 3,
42        '<='  => 4,
43        '>='  => 4,
44        '<'   => 4,
45        '>'   => 4,
46        '+'   => 5,
47        '-'   => 5,
48        '*'   => 6,
49        '/'   => 6,
50        '%'   => 6,
51    ];
52
53    protected static $commentPattern;
54    protected static $operatorPattern;
55    protected static $whitePattern;
56
57    protected $cache;
58
59    private $sourceName;
60    private $sourceIndex;
61    private $sourcePositions;
62    private $charset;
63    private $count;
64    private $env;
65    private $inParens;
66    private $eatWhiteDefault;
67    private $discardComments;
68    private $buffer;
69    private $utf8;
70    private $encoding;
71    private $patternModifiers;
72    private $commentsSeen;
73
74    /**
75     * Constructor
76     *
77     * @api
78     *
79     * @param string                 $sourceName
80     * @param integer                $sourceIndex
81     * @param string                 $encoding
82     * @param \ScssPhp\ScssPhp\Cache $cache
83     */
84    public function __construct($sourceName, $sourceIndex = 0, $encoding = 'utf-8', $cache = null)
85    {
86        $this->sourceName       = $sourceName ?: '(stdin)';
87        $this->sourceIndex      = $sourceIndex;
88        $this->charset          = null;
89        $this->utf8             = ! $encoding || strtolower($encoding) === 'utf-8';
90        $this->patternModifiers = $this->utf8 ? 'Aisu' : 'Ais';
91        $this->commentsSeen     = [];
92        $this->discardComments  = false;
93
94        if (empty(static::$operatorPattern)) {
95            static::$operatorPattern = '([*\/%+-]|[!=]\=|\>\=?|\<\=\>|\<\=?|and|or)';
96
97            $commentSingle      = '\/\/';
98            $commentMultiLeft   = '\/\*';
99            $commentMultiRight  = '\*\/';
100
101            static::$commentPattern = $commentMultiLeft . '.*?' . $commentMultiRight;
102            static::$whitePattern = $this->utf8
103                ? '/' . $commentSingle . '[^\n]*\s*|(' . static::$commentPattern . ')\s*|\s+/AisuS'
104                : '/' . $commentSingle . '[^\n]*\s*|(' . static::$commentPattern . ')\s*|\s+/AisS';
105        }
106
107        if ($cache) {
108            $this->cache = $cache;
109        }
110    }
111
112    /**
113     * Get source file name
114     *
115     * @api
116     *
117     * @return string
118     */
119    public function getSourceName()
120    {
121        return $this->sourceName;
122    }
123
124    /**
125     * Throw parser error
126     *
127     * @api
128     *
129     * @param string $msg
130     *
131     * @throws \ScssPhp\ScssPhp\Exception\ParserException
132     */
133    public function throwParseError($msg = 'parse error')
134    {
135        list($line, $column) = $this->getSourcePosition($this->count);
136
137        $loc = empty($this->sourceName)
138             ? "line: $line, column: $column"
139             : "$this->sourceName on line $line, at column $column";
140
141        if ($this->peek("(.*?)(\n|$)", $m, $this->count)) {
142            throw new ParserException("$msg: failed at `$m[1]` $loc");
143        }
144
145        throw new ParserException("$msg: $loc");
146    }
147
148    /**
149     * Parser buffer
150     *
151     * @api
152     *
153     * @param string $buffer
154     *
155     * @return \ScssPhp\ScssPhp\Block
156     */
157    public function parse($buffer)
158    {
159        if ($this->cache) {
160            $cacheKey = $this->sourceName . ":" . md5($buffer);
161            $parseOptions = [
162                'charset' => $this->charset,
163                'utf8' => $this->utf8,
164            ];
165            $v = $this->cache->getCache("parse", $cacheKey, $parseOptions);
166
167            if (! is_null($v)) {
168                return $v;
169            }
170        }
171
172        // strip BOM (byte order marker)
173        if (substr($buffer, 0, 3) === "\xef\xbb\xbf") {
174            $buffer = substr($buffer, 3);
175        }
176
177        $this->buffer          = rtrim($buffer, "\x00..\x1f");
178        $this->count           = 0;
179        $this->env             = null;
180        $this->inParens        = false;
181        $this->eatWhiteDefault = true;
182
183        $this->saveEncoding();
184        $this->extractLineNumbers($buffer);
185
186        $this->pushBlock(null); // root block
187        $this->whitespace();
188        $this->pushBlock(null);
189        $this->popBlock();
190
191        while ($this->parseChunk()) {
192            ;
193        }
194
195        if ($this->count !== strlen($this->buffer)) {
196            $this->throwParseError();
197        }
198
199        if (! empty($this->env->parent)) {
200            $this->throwParseError('unclosed block');
201        }
202
203        if ($this->charset) {
204            array_unshift($this->env->children, $this->charset);
205        }
206
207        $this->restoreEncoding();
208
209        if ($this->cache) {
210            $this->cache->setCache("parse", $cacheKey, $this->env, $parseOptions);
211        }
212
213        return $this->env;
214    }
215
216    /**
217     * Parse a value or value list
218     *
219     * @api
220     *
221     * @param string       $buffer
222     * @param string|array $out
223     *
224     * @return boolean
225     */
226    public function parseValue($buffer, &$out)
227    {
228        $this->count           = 0;
229        $this->env             = null;
230        $this->inParens        = false;
231        $this->eatWhiteDefault = true;
232        $this->buffer          = (string) $buffer;
233
234        $this->saveEncoding();
235
236        $list = $this->valueList($out);
237
238        $this->restoreEncoding();
239
240        return $list;
241    }
242
243    /**
244     * Parse a selector or selector list
245     *
246     * @api
247     *
248     * @param string       $buffer
249     * @param string|array $out
250     *
251     * @return boolean
252     */
253    public function parseSelector($buffer, &$out)
254    {
255        $this->count           = 0;
256        $this->env             = null;
257        $this->inParens        = false;
258        $this->eatWhiteDefault = true;
259        $this->buffer          = (string) $buffer;
260
261        $this->saveEncoding();
262
263        $selector = $this->selectors($out);
264
265        $this->restoreEncoding();
266
267        return $selector;
268    }
269
270    /**
271     * Parse a media Query
272     *
273     * @api
274     *
275     * @param string       $buffer
276     * @param string|array $out
277     *
278     * @return boolean
279     */
280    public function parseMediaQueryList($buffer, &$out)
281    {
282        $this->count           = 0;
283        $this->env             = null;
284        $this->inParens        = false;
285        $this->eatWhiteDefault = true;
286        $this->buffer          = (string) $buffer;
287
288        $this->saveEncoding();
289
290        $isMediaQuery = $this->mediaQueryList($out);
291
292        $this->restoreEncoding();
293
294        return $isMediaQuery;
295    }
296
297    /**
298     * Parse a single chunk off the head of the buffer and append it to the
299     * current parse environment.
300     *
301     * Returns false when the buffer is empty, or when there is an error.
302     *
303     * This function is called repeatedly until the entire document is
304     * parsed.
305     *
306     * This parser is most similar to a recursive descent parser. Single
307     * functions represent discrete grammatical rules for the language, and
308     * they are able to capture the text that represents those rules.
309     *
310     * Consider the function Compiler::keyword(). (All parse functions are
311     * structured the same.)
312     *
313     * The function takes a single reference argument. When calling the
314     * function it will attempt to match a keyword on the head of the buffer.
315     * If it is successful, it will place the keyword in the referenced
316     * argument, advance the position in the buffer, and return true. If it
317     * fails then it won't advance the buffer and it will return false.
318     *
319     * All of these parse functions are powered by Compiler::match(), which behaves
320     * the same way, but takes a literal regular expression. Sometimes it is
321     * more convenient to use match instead of creating a new function.
322     *
323     * Because of the format of the functions, to parse an entire string of
324     * grammatical rules, you can chain them together using &&.
325     *
326     * But, if some of the rules in the chain succeed before one fails, then
327     * the buffer position will be left at an invalid state. In order to
328     * avoid this, Compiler::seek() is used to remember and set buffer positions.
329     *
330     * Before parsing a chain, use $s = $this->count to remember the current
331     * position into $s. Then if a chain fails, use $this->seek($s) to
332     * go back where we started.
333     *
334     * @return boolean
335     */
336    protected function parseChunk()
337    {
338        $s = $this->count;
339
340        // the directives
341        if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] === '@') {
342            if ($this->literal('@at-root', 8) &&
343                ($this->selectors($selector) || true) &&
344                ($this->map($with) || true) &&
345                (($this->matchChar('(')
346                    && $this->interpolation($with)
347                    && $this->matchChar(')')) || true) &&
348                $this->matchChar('{', false)
349            ) {
350                $atRoot = $this->pushSpecialBlock(Type::T_AT_ROOT, $s);
351                $atRoot->selector = $selector;
352                $atRoot->with     = $with;
353
354                return true;
355            }
356
357            $this->seek($s);
358
359            if ($this->literal('@media', 6) && $this->mediaQueryList($mediaQueryList) && $this->matchChar('{', false)) {
360                $media = $this->pushSpecialBlock(Type::T_MEDIA, $s);
361                $media->queryList = $mediaQueryList[2];
362
363                return true;
364            }
365
366            $this->seek($s);
367
368            if ($this->literal('@mixin', 6) &&
369                $this->keyword($mixinName) &&
370                ($this->argumentDef($args) || true) &&
371                $this->matchChar('{', false)
372            ) {
373                $mixin = $this->pushSpecialBlock(Type::T_MIXIN, $s);
374                $mixin->name = $mixinName;
375                $mixin->args = $args;
376
377                return true;
378            }
379
380            $this->seek($s);
381
382            if ($this->literal('@include', 8) &&
383                $this->keyword($mixinName) &&
384                ($this->matchChar('(') &&
385                    ($this->argValues($argValues) || true) &&
386                    $this->matchChar(')') || true) &&
387                ($this->end() ||
388                    ($this->literal('using', 5) &&
389                        $this->argumentDef($argUsing) &&
390                        ($this->end() || $this->matchChar('{') && $hasBlock = true)) ||
391                    $this->matchChar('{') && $hasBlock = true)
392            ) {
393                $child = [
394                    Type::T_INCLUDE,
395                    $mixinName,
396                    isset($argValues) ? $argValues : null,
397                    null,
398                    isset($argUsing) ? $argUsing : null
399                ];
400
401                if (! empty($hasBlock)) {
402                    $include = $this->pushSpecialBlock(Type::T_INCLUDE, $s);
403                    $include->child = $child;
404                } else {
405                    $this->append($child, $s);
406                }
407
408                return true;
409            }
410
411            $this->seek($s);
412
413            if ($this->literal('@scssphp-import-once', 20) &&
414                $this->valueList($importPath) &&
415                $this->end()
416            ) {
417                $this->append([Type::T_SCSSPHP_IMPORT_ONCE, $importPath], $s);
418
419                return true;
420            }
421
422            $this->seek($s);
423
424            if ($this->literal('@import', 7) &&
425                $this->valueList($importPath) &&
426                $this->end()
427            ) {
428                $this->append([Type::T_IMPORT, $importPath], $s);
429
430                return true;
431            }
432
433            $this->seek($s);
434
435            if ($this->literal('@import', 7) &&
436                $this->url($importPath) &&
437                $this->end()
438            ) {
439                $this->append([Type::T_IMPORT, $importPath], $s);
440
441                return true;
442            }
443
444            $this->seek($s);
445
446            if ($this->literal('@extend', 7) &&
447                $this->selectors($selectors) &&
448                $this->end()
449            ) {
450                // check for '!flag'
451                $optional = $this->stripOptionalFlag($selectors);
452                $this->append([Type::T_EXTEND, $selectors, $optional], $s);
453
454                return true;
455            }
456
457            $this->seek($s);
458
459            if ($this->literal('@function', 9) &&
460                $this->keyword($fnName) &&
461                $this->argumentDef($args) &&
462                $this->matchChar('{', false)
463            ) {
464                $func = $this->pushSpecialBlock(Type::T_FUNCTION, $s);
465                $func->name = $fnName;
466                $func->args = $args;
467
468                return true;
469            }
470
471            $this->seek($s);
472
473            if ($this->literal('@break', 6) && $this->end()) {
474                $this->append([Type::T_BREAK], $s);
475
476                return true;
477            }
478
479            $this->seek($s);
480
481            if ($this->literal('@continue', 9) && $this->end()) {
482                $this->append([Type::T_CONTINUE], $s);
483
484                return true;
485            }
486
487            $this->seek($s);
488
489            if ($this->literal('@return', 7) && ($this->valueList($retVal) || true) && $this->end()) {
490                $this->append([Type::T_RETURN, isset($retVal) ? $retVal : [Type::T_NULL]], $s);
491
492                return true;
493            }
494
495            $this->seek($s);
496
497            if ($this->literal('@each', 5) &&
498                $this->genericList($varNames, 'variable', ',', false) &&
499                $this->literal('in', 2) &&
500                $this->valueList($list) &&
501                $this->matchChar('{', false)
502            ) {
503                $each = $this->pushSpecialBlock(Type::T_EACH, $s);
504
505                foreach ($varNames[2] as $varName) {
506                    $each->vars[] = $varName[1];
507                }
508
509                $each->list = $list;
510
511                return true;
512            }
513
514            $this->seek($s);
515
516            if ($this->literal('@while', 6) &&
517                $this->expression($cond) &&
518                $this->matchChar('{', false)
519            ) {
520                $while = $this->pushSpecialBlock(Type::T_WHILE, $s);
521                $while->cond = $cond;
522
523                return true;
524            }
525
526            $this->seek($s);
527
528            if ($this->literal('@for', 4) &&
529                $this->variable($varName) &&
530                $this->literal('from', 4) &&
531                $this->expression($start) &&
532                ($this->literal('through', 7) ||
533                    ($forUntil = true && $this->literal('to', 2))) &&
534                $this->expression($end) &&
535                $this->matchChar('{', false)
536            ) {
537                $for = $this->pushSpecialBlock(Type::T_FOR, $s);
538                $for->var   = $varName[1];
539                $for->start = $start;
540                $for->end   = $end;
541                $for->until = isset($forUntil);
542
543                return true;
544            }
545
546            $this->seek($s);
547
548            if ($this->literal('@if', 3) && $this->valueList($cond) && $this->matchChar('{', false)) {
549                $if = $this->pushSpecialBlock(Type::T_IF, $s);
550                while ($cond[0] === Type::T_LIST
551                    && !empty($cond['enclosing'])
552                    && $cond['enclosing'] === 'parent'
553                    && count($cond[2]) == 1) {
554                    $cond = reset($cond[2]);
555                }
556                $if->cond  = $cond;
557                $if->cases = [];
558
559                return true;
560            }
561
562            $this->seek($s);
563
564            if ($this->literal('@debug', 6) &&
565                $this->valueList($value) &&
566                $this->end()
567            ) {
568                $this->append([Type::T_DEBUG, $value], $s);
569
570                return true;
571            }
572
573            $this->seek($s);
574
575            if ($this->literal('@warn', 5) &&
576                $this->valueList($value) &&
577                $this->end()
578            ) {
579                $this->append([Type::T_WARN, $value], $s);
580
581                return true;
582            }
583
584            $this->seek($s);
585
586            if ($this->literal('@error', 6) &&
587                $this->valueList($value) &&
588                $this->end()
589            ) {
590                $this->append([Type::T_ERROR, $value], $s);
591
592                return true;
593            }
594
595            $this->seek($s);
596
597            #if ($this->literal('@content', 8))
598
599            if ($this->literal('@content', 8) &&
600                ($this->end() ||
601                    $this->matchChar('(') &&
602                        $this->argValues($argContent) &&
603                        $this->matchChar(')') &&
604                    $this->end())) {
605                $this->append([Type::T_MIXIN_CONTENT, isset($argContent) ? $argContent : null], $s);
606
607                return true;
608            }
609
610            $this->seek($s);
611
612            $last = $this->last();
613
614            if (isset($last) && $last[0] === Type::T_IF) {
615                list(, $if) = $last;
616
617                if ($this->literal('@else', 5)) {
618                    if ($this->matchChar('{', false)) {
619                        $else = $this->pushSpecialBlock(Type::T_ELSE, $s);
620                    } elseif ($this->literal('if', 2) && $this->valueList($cond) && $this->matchChar('{', false)) {
621                        $else = $this->pushSpecialBlock(Type::T_ELSEIF, $s);
622                        $else->cond = $cond;
623                    }
624
625                    if (isset($else)) {
626                        $else->dontAppend = true;
627                        $if->cases[] = $else;
628
629                        return true;
630                    }
631                }
632
633                $this->seek($s);
634            }
635
636            // only retain the first @charset directive encountered
637            if ($this->literal('@charset', 8) &&
638                $this->valueList($charset) &&
639                $this->end()
640            ) {
641                if (! isset($this->charset)) {
642                    $statement = [Type::T_CHARSET, $charset];
643
644                    list($line, $column) = $this->getSourcePosition($s);
645
646                    $statement[static::SOURCE_LINE]   = $line;
647                    $statement[static::SOURCE_COLUMN] = $column;
648                    $statement[static::SOURCE_INDEX]  = $this->sourceIndex;
649
650                    $this->charset = $statement;
651                }
652
653                return true;
654            }
655
656            $this->seek($s);
657
658            if ($this->literal('@supports', 9) &&
659                ($t1=$this->supportsQuery($supportQuery)) &&
660                ($t2=$this->matchChar('{', false))
661            ) {
662                $directive = $this->pushSpecialBlock(Type::T_DIRECTIVE, $s);
663                $directive->name  = 'supports';
664                $directive->value = $supportQuery;
665
666                return true;
667            }
668
669            $this->seek($s);
670
671            // doesn't match built in directive, do generic one
672            if ($this->matchChar('@', false) &&
673                $this->keyword($dirName) &&
674                ($this->variable($dirValue) || $this->openString('{', $dirValue) || true) &&
675                $this->matchChar('{', false)
676            ) {
677                if ($dirName === 'media') {
678                    $directive = $this->pushSpecialBlock(Type::T_MEDIA, $s);
679                } else {
680                    $directive = $this->pushSpecialBlock(Type::T_DIRECTIVE, $s);
681                    $directive->name = $dirName;
682                }
683
684                if (isset($dirValue)) {
685                    $directive->value = $dirValue;
686                }
687
688                return true;
689            }
690
691            $this->seek($s);
692
693            // maybe it's a generic blockless directive
694            if ($this->matchChar('@', false) &&
695                $this->keyword($dirName) &&
696                $this->valueList($dirValue) &&
697                $this->end()
698            ) {
699                $this->append([Type::T_DIRECTIVE, [$dirName, $dirValue]], $s);
700
701                return true;
702            }
703
704            $this->seek($s);
705
706            return false;
707        }
708
709        // property shortcut
710        // captures most properties before having to parse a selector
711        if ($this->keyword($name, false) &&
712            $this->literal(': ', 2) &&
713            $this->valueList($value) &&
714            $this->end()
715        ) {
716            $name = [Type::T_STRING, '', [$name]];
717            $this->append([Type::T_ASSIGN, $name, $value], $s);
718
719            return true;
720        }
721
722        $this->seek($s);
723
724        // variable assigns
725        if ($this->variable($name) &&
726            $this->matchChar(':') &&
727            $this->valueList($value) &&
728            $this->end()
729        ) {
730            // check for '!flag'
731            $assignmentFlags = $this->stripAssignmentFlags($value);
732            $this->append([Type::T_ASSIGN, $name, $value, $assignmentFlags], $s);
733
734            return true;
735        }
736
737        $this->seek($s);
738
739        // misc
740        if ($this->literal('-->', 3)) {
741            return true;
742        }
743
744        // opening css block
745        if ($this->selectors($selectors) && $this->matchChar('{', false)) {
746            $this->pushBlock($selectors, $s);
747
748            if ($this->eatWhiteDefault) {
749                $this->whitespace();
750                $this->append(null); // collect comments at the beginning if needed
751            }
752
753            return true;
754        }
755
756        $this->seek($s);
757
758        // property assign, or nested assign
759        if ($this->propertyName($name) && $this->matchChar(':')) {
760            $foundSomething = false;
761
762            if ($this->valueList($value)) {
763                if (empty($this->env->parent)) {
764                    $this->throwParseError('expected "{"');
765                }
766
767                $this->append([Type::T_ASSIGN, $name, $value], $s);
768                $foundSomething = true;
769            }
770
771            if ($this->matchChar('{', false)) {
772                $propBlock = $this->pushSpecialBlock(Type::T_NESTED_PROPERTY, $s);
773                $propBlock->prefix = $name;
774                $propBlock->hasValue = $foundSomething;
775
776                $foundSomething = true;
777            } elseif ($foundSomething) {
778                $foundSomething = $this->end();
779            }
780
781            if ($foundSomething) {
782                return true;
783            }
784        }
785
786        $this->seek($s);
787
788        // closing a block
789        if ($this->matchChar('}', false)) {
790            $block = $this->popBlock();
791
792            if (! isset($block->type) || $block->type !== Type::T_IF) {
793                if ($this->env->parent) {
794                    $this->append(null); // collect comments before next statement if needed
795                }
796            }
797
798            if (isset($block->type) && $block->type === Type::T_INCLUDE) {
799                $include = $block->child;
800                unset($block->child);
801                $include[3] = $block;
802                $this->append($include, $s);
803            } elseif (empty($block->dontAppend)) {
804                $type = isset($block->type) ? $block->type : Type::T_BLOCK;
805                $this->append([$type, $block], $s);
806            }
807
808            // collect comments just after the block closing if needed
809            if ($this->eatWhiteDefault) {
810                $this->whitespace();
811
812                if ($this->env->comments) {
813                    $this->append(null);
814                }
815            }
816
817            return true;
818        }
819
820        // extra stuff
821        if ($this->matchChar(';') ||
822            $this->literal('<!--', 4)
823        ) {
824            return true;
825        }
826
827        return false;
828    }
829
830    /**
831     * Push block onto parse tree
832     *
833     * @param array   $selectors
834     * @param integer $pos
835     *
836     * @return \ScssPhp\ScssPhp\Block
837     */
838    protected function pushBlock($selectors, $pos = 0)
839    {
840        list($line, $column) = $this->getSourcePosition($pos);
841
842        $b = new Block;
843        $b->sourceName   = $this->sourceName;
844        $b->sourceLine   = $line;
845        $b->sourceColumn = $column;
846        $b->sourceIndex  = $this->sourceIndex;
847        $b->selectors    = $selectors;
848        $b->comments     = [];
849        $b->parent       = $this->env;
850
851        if (! $this->env) {
852            $b->children = [];
853        } elseif (empty($this->env->children)) {
854            $this->env->children = $this->env->comments;
855            $b->children = [];
856            $this->env->comments = [];
857        } else {
858            $b->children = $this->env->comments;
859            $this->env->comments = [];
860        }
861
862        $this->env = $b;
863
864        // collect comments at the beginning of a block if needed
865        if ($this->eatWhiteDefault) {
866            $this->whitespace();
867
868            if ($this->env->comments) {
869                $this->append(null);
870            }
871        }
872
873        return $b;
874    }
875
876    /**
877     * Push special (named) block onto parse tree
878     *
879     * @param string  $type
880     * @param integer $pos
881     *
882     * @return \ScssPhp\ScssPhp\Block
883     */
884    protected function pushSpecialBlock($type, $pos)
885    {
886        $block = $this->pushBlock(null, $pos);
887        $block->type = $type;
888
889        return $block;
890    }
891
892    /**
893     * Pop scope and return last block
894     *
895     * @return \ScssPhp\ScssPhp\Block
896     *
897     * @throws \Exception
898     */
899    protected function popBlock()
900    {
901
902        // collect comments ending just before of a block closing
903        if ($this->env->comments) {
904            $this->append(null);
905        }
906
907        // pop the block
908        $block = $this->env;
909
910        if (empty($block->parent)) {
911            $this->throwParseError('unexpected }');
912        }
913
914        if ($block->type == Type::T_AT_ROOT) {
915            // keeps the parent in case of self selector &
916            $block->selfParent = $block->parent;
917        }
918
919        $this->env = $block->parent;
920
921        unset($block->parent);
922
923        return $block;
924    }
925
926    /**
927     * Peek input stream
928     *
929     * @param string  $regex
930     * @param array   $out
931     * @param integer $from
932     *
933     * @return integer
934     */
935    protected function peek($regex, &$out, $from = null)
936    {
937        if (! isset($from)) {
938            $from = $this->count;
939        }
940
941        $r = '/' . $regex . '/' . $this->patternModifiers;
942        $result = preg_match($r, $this->buffer, $out, null, $from);
943
944        return $result;
945    }
946
947    /**
948     * Seek to position in input stream (or return current position in input stream)
949     *
950     * @param integer $where
951     */
952    protected function seek($where)
953    {
954        $this->count = $where;
955    }
956
957    /**
958     * Match string looking for either ending delim, escape, or string interpolation
959     *
960     * {@internal This is a workaround for preg_match's 250K string match limit. }}
961     *
962     * @param array  $m     Matches (passed by reference)
963     * @param string $delim Delimeter
964     *
965     * @return boolean True if match; false otherwise
966     */
967    protected function matchString(&$m, $delim)
968    {
969        $token = null;
970
971        $end = strlen($this->buffer);
972
973        // look for either ending delim, escape, or string interpolation
974        foreach (['#{', '\\', $delim] as $lookahead) {
975            $pos = strpos($this->buffer, $lookahead, $this->count);
976
977            if ($pos !== false && $pos < $end) {
978                $end = $pos;
979                $token = $lookahead;
980            }
981        }
982
983        if (! isset($token)) {
984            return false;
985        }
986
987        $match = substr($this->buffer, $this->count, $end - $this->count);
988        $m = [
989            $match . $token,
990            $match,
991            $token
992        ];
993        $this->count = $end + strlen($token);
994
995        return true;
996    }
997
998    /**
999     * Try to match something on head of buffer
1000     *
1001     * @param string  $regex
1002     * @param array   $out
1003     * @param boolean $eatWhitespace
1004     *
1005     * @return boolean
1006     */
1007    protected function match($regex, &$out, $eatWhitespace = null)
1008    {
1009        $r = '/' . $regex . '/' . $this->patternModifiers;
1010
1011        if (! preg_match($r, $this->buffer, $out, null, $this->count)) {
1012            return false;
1013        }
1014
1015        $this->count += strlen($out[0]);
1016
1017        if (! isset($eatWhitespace)) {
1018            $eatWhitespace = $this->eatWhiteDefault;
1019        }
1020
1021        if ($eatWhitespace) {
1022            $this->whitespace();
1023        }
1024
1025        return true;
1026    }
1027
1028    /**
1029     * Match a single string
1030     *
1031     * @param string  $char
1032     * @param boolean $eatWhitespace
1033     *
1034     * @return boolean
1035     */
1036    protected function matchChar($char, $eatWhitespace = null)
1037    {
1038        if (! isset($this->buffer[$this->count]) || $this->buffer[$this->count] !== $char) {
1039            return false;
1040        }
1041
1042        $this->count++;
1043
1044        if (! isset($eatWhitespace)) {
1045            $eatWhitespace = $this->eatWhiteDefault;
1046        }
1047
1048        if ($eatWhitespace) {
1049            $this->whitespace();
1050        }
1051
1052        return true;
1053    }
1054
1055    /**
1056     * Match literal string
1057     *
1058     * @param string  $what
1059     * @param integer $len
1060     * @param boolean $eatWhitespace
1061     *
1062     * @return boolean
1063     */
1064    protected function literal($what, $len, $eatWhitespace = null)
1065    {
1066        if (strcasecmp(substr($this->buffer, $this->count, $len), $what) !== 0) {
1067            return false;
1068        }
1069
1070        $this->count += $len;
1071
1072        if (! isset($eatWhitespace)) {
1073            $eatWhitespace = $this->eatWhiteDefault;
1074        }
1075
1076        if ($eatWhitespace) {
1077            $this->whitespace();
1078        }
1079
1080        return true;
1081    }
1082
1083    /**
1084     * Match some whitespace
1085     *
1086     * @return boolean
1087     */
1088    protected function whitespace()
1089    {
1090        $gotWhite = false;
1091
1092        while (preg_match(static::$whitePattern, $this->buffer, $m, null, $this->count)) {
1093            if (isset($m[1]) && empty($this->commentsSeen[$this->count])) {
1094                // comment that are kept in the output CSS
1095                $comment = [];
1096                $startCommentCount = $this->count;
1097                $endCommentCount = $this->count + strlen($m[1]);
1098
1099                // find interpolations in comment
1100                $p = strpos($this->buffer, '#{', $this->count);
1101
1102                while ($p !== false && $p < $endCommentCount) {
1103                    $c           = substr($this->buffer, $this->count, $p - $this->count);
1104                    $comment[]   = $c;
1105                    $this->count = $p;
1106                    $out         = null;
1107
1108                    if ($this->interpolation($out)) {
1109                        // keep right spaces in the following string part
1110                        if ($out[3]) {
1111                            while ($this->buffer[$this->count-1] !== '}') {
1112                                $this->count--;
1113                            }
1114
1115                            $out[3] = '';
1116                        }
1117
1118                        $comment[] = [Type::T_COMMENT, substr($this->buffer, $p, $this->count - $p), $out];
1119                    } else {
1120                        $comment[] = substr($this->buffer, $this->count, 2);
1121
1122                        $this->count += 2;
1123                    }
1124
1125                    $p = strpos($this->buffer, '#{', $this->count);
1126                }
1127
1128                // remaining part
1129                $c = substr($this->buffer, $this->count, $endCommentCount - $this->count);
1130
1131                if (! $comment) {
1132                    // single part static comment
1133                    $this->appendComment([Type::T_COMMENT, $c]);
1134                } else {
1135                    $comment[] = $c;
1136                    $staticComment = substr($this->buffer, $startCommentCount, $endCommentCount - $startCommentCount);
1137                    $this->appendComment([Type::T_COMMENT, $staticComment, [Type::T_STRING, '', $comment]]);
1138                }
1139
1140                $this->commentsSeen[$startCommentCount] = true;
1141                $this->count = $endCommentCount;
1142            } else {
1143                // comment that are ignored and not kept in the output css
1144                $this->count += strlen($m[0]);
1145            }
1146
1147            $gotWhite = true;
1148        }
1149
1150        return $gotWhite;
1151    }
1152
1153    /**
1154     * Append comment to current block
1155     *
1156     * @param array $comment
1157     */
1158    protected function appendComment($comment)
1159    {
1160        if (! $this->discardComments) {
1161            if ($comment[0] === Type::T_COMMENT) {
1162                if (is_string($comment[1])) {
1163                    $comment[1] = substr(preg_replace(['/^\s+/m', '/^(.)/m'], ['', ' \1'], $comment[1]), 1);
1164                }
1165                if (isset($comment[2]) and is_array($comment[2]) and $comment[2][0] === Type::T_STRING) {
1166                    foreach ($comment[2][2] as $k => $v) {
1167                        if (is_string($v)) {
1168                            $p = strpos($v, "\n");
1169                            if ($p !== false) {
1170                                $comment[2][2][$k] = substr($v, 0, $p + 1)
1171                                    . preg_replace(['/^\s+/m', '/^(.)/m'], ['', ' \1'], substr($v, $p+1));
1172                            }
1173                        }
1174                    }
1175                }
1176            }
1177
1178            $this->env->comments[] = $comment;
1179        }
1180    }
1181
1182    /**
1183     * Append statement to current block
1184     *
1185     * @param array   $statement
1186     * @param integer $pos
1187     */
1188    protected function append($statement, $pos = null)
1189    {
1190        if (! is_null($statement)) {
1191            if (! is_null($pos)) {
1192                list($line, $column) = $this->getSourcePosition($pos);
1193
1194                $statement[static::SOURCE_LINE]   = $line;
1195                $statement[static::SOURCE_COLUMN] = $column;
1196                $statement[static::SOURCE_INDEX]  = $this->sourceIndex;
1197            }
1198
1199            $this->env->children[] = $statement;
1200        }
1201
1202        $comments = $this->env->comments;
1203
1204        if ($comments) {
1205            $this->env->children = array_merge($this->env->children, $comments);
1206            $this->env->comments = [];
1207        }
1208    }
1209
1210    /**
1211     * Returns last child was appended
1212     *
1213     * @return array|null
1214     */
1215    protected function last()
1216    {
1217        $i = count($this->env->children) - 1;
1218
1219        if (isset($this->env->children[$i])) {
1220            return $this->env->children[$i];
1221        }
1222    }
1223
1224    /**
1225     * Parse media query list
1226     *
1227     * @param array $out
1228     *
1229     * @return boolean
1230     */
1231    protected function mediaQueryList(&$out)
1232    {
1233        return $this->genericList($out, 'mediaQuery', ',', false);
1234    }
1235
1236    /**
1237     * Parse media query
1238     *
1239     * @param array $out
1240     *
1241     * @return boolean
1242     */
1243    protected function mediaQuery(&$out)
1244    {
1245        $expressions = null;
1246        $parts = [];
1247
1248        if (($this->literal('only', 4) && ($only = true) || $this->literal('not', 3) && ($not = true) || true) &&
1249            $this->mixedKeyword($mediaType)
1250        ) {
1251            $prop = [Type::T_MEDIA_TYPE];
1252
1253            if (isset($only)) {
1254                $prop[] = [Type::T_KEYWORD, 'only'];
1255            }
1256
1257            if (isset($not)) {
1258                $prop[] = [Type::T_KEYWORD, 'not'];
1259            }
1260
1261            $media = [Type::T_LIST, '', []];
1262
1263            foreach ((array) $mediaType as $type) {
1264                if (is_array($type)) {
1265                    $media[2][] = $type;
1266                } else {
1267                    $media[2][] = [Type::T_KEYWORD, $type];
1268                }
1269            }
1270
1271            $prop[]  = $media;
1272            $parts[] = $prop;
1273        }
1274
1275        if (empty($parts) || $this->literal('and', 3)) {
1276            $this->genericList($expressions, 'mediaExpression', 'and', false);
1277
1278            if (is_array($expressions)) {
1279                $parts = array_merge($parts, $expressions[2]);
1280            }
1281        }
1282
1283        $out = $parts;
1284
1285        return true;
1286    }
1287
1288    /**
1289     * Parse supports query
1290     *
1291     * @param array $out
1292     *
1293     * @return boolean
1294     */
1295    protected function supportsQuery(&$out)
1296    {
1297        $expressions = null;
1298        $parts = [];
1299
1300        $s = $this->count;
1301
1302        $not = false;
1303
1304        if (($this->literal('not', 3) && ($not = true) || true) &&
1305            $this->matchChar('(') &&
1306            ($this->expression($property)) &&
1307            $this->literal(': ', 2) &&
1308            $this->valueList($value) &&
1309            $this->matchChar(')')
1310         ) {
1311            $support = [Type::T_STRING, '', [[Type::T_KEYWORD, ($not ? 'not ' : '') . '(']]];
1312            $support[2][] = $property;
1313            $support[2][] = [Type::T_KEYWORD, ': '];
1314            $support[2][] = $value;
1315            $support[2][] = [Type::T_KEYWORD, ')'];
1316
1317            $parts[] = $support;
1318            $s = $this->count;
1319        } else {
1320            $this->seek($s);
1321        }
1322
1323        if ($this->matchChar('(') &&
1324            $this->supportsQuery($subQuery) &&
1325            $this->matchChar(')')
1326        ) {
1327            $parts[] = [Type::T_STRING, '', [[Type::T_KEYWORD, '('], $subQuery, [Type::T_KEYWORD, ')']]];
1328            $s = $this->count;
1329        } else {
1330            $this->seek($s);
1331        }
1332
1333        if ($this->literal('not', 3) &&
1334            $this->supportsQuery($subQuery)
1335        ) {
1336            $parts[] = [Type::T_STRING, '', [[Type::T_KEYWORD, 'not '], $subQuery]];
1337            $s = $this->count;
1338        } else {
1339            $this->seek($s);
1340        }
1341
1342        if ($this->literal('selector(', 9) &&
1343            $this->selector($selector) &&
1344            $this->matchChar(')')
1345        ) {
1346            $support = [Type::T_STRING, '', [[Type::T_KEYWORD, 'selector(']]];
1347
1348            $selectorList = [Type::T_LIST, '', []];
1349
1350            foreach ($selector as $sc) {
1351                $compound = [Type::T_STRING, '', []];
1352
1353                foreach ($sc as $scp) {
1354                    if (is_array($scp)) {
1355                        $compound[2][] = $scp;
1356                    } else {
1357                        $compound[2][] = [Type::T_KEYWORD, $scp];
1358                    }
1359                }
1360
1361                $selectorList[2][] = $compound;
1362            }
1363            $support[2][] = $selectorList;
1364            $support[2][] = [Type::T_KEYWORD, ')'];
1365            $parts[] = $support;
1366            $s = $this->count;
1367        } else {
1368            $this->seek($s);
1369        }
1370
1371        if ($this->variable($var) or $this->interpolation($var)) {
1372            $parts[] = $var;
1373            $s = $this->count;
1374        } else {
1375            $this->seek($s);
1376        }
1377
1378        if ($this->literal('and', 3) &&
1379            $this->genericList($expressions, 'supportsQuery', ' and', false)) {
1380            array_unshift($expressions[2], [Type::T_STRING, '', $parts]);
1381
1382            $parts = [$expressions];
1383            $s = $this->count;
1384        } else {
1385            $this->seek($s);
1386        }
1387
1388        if ($this->literal('or', 2) &&
1389            $this->genericList($expressions, 'supportsQuery', ' or', false)) {
1390            array_unshift($expressions[2], [Type::T_STRING, '', $parts]);
1391
1392            $parts = [$expressions];
1393            $s = $this->count;
1394        } else {
1395            $this->seek($s);
1396        }
1397
1398        if (count($parts)) {
1399            if ($this->eatWhiteDefault) {
1400                $this->whitespace();
1401            }
1402
1403            $out = [Type::T_STRING, '', $parts];
1404
1405            return true;
1406        }
1407
1408        return false;
1409    }
1410
1411
1412    /**
1413     * Parse media expression
1414     *
1415     * @param array $out
1416     *
1417     * @return boolean
1418     */
1419    protected function mediaExpression(&$out)
1420    {
1421        $s = $this->count;
1422        $value = null;
1423
1424        if ($this->matchChar('(') &&
1425            $this->expression($feature) &&
1426            ($this->matchChar(':') && $this->expression($value) || true) &&
1427            $this->matchChar(')')
1428        ) {
1429            $out = [Type::T_MEDIA_EXPRESSION, $feature];
1430
1431            if ($value) {
1432                $out[] = $value;
1433            }
1434
1435            return true;
1436        }
1437
1438        $this->seek($s);
1439
1440        return false;
1441    }
1442
1443    /**
1444     * Parse argument values
1445     *
1446     * @param array $out
1447     *
1448     * @return boolean
1449     */
1450    protected function argValues(&$out)
1451    {
1452        if ($this->genericList($list, 'argValue', ',', false)) {
1453            $out = $list[2];
1454
1455            return true;
1456        }
1457
1458        return false;
1459    }
1460
1461    /**
1462     * Parse argument value
1463     *
1464     * @param array $out
1465     *
1466     * @return boolean
1467     */
1468    protected function argValue(&$out)
1469    {
1470        $s = $this->count;
1471
1472        $keyword = null;
1473
1474        if (! $this->variable($keyword) || ! $this->matchChar(':')) {
1475            $this->seek($s);
1476
1477            $keyword = null;
1478        }
1479
1480        if ($this->genericList($value, 'expression')) {
1481            $out = [$keyword, $value, false];
1482            $s = $this->count;
1483
1484            if ($this->literal('...', 3)) {
1485                $out[2] = true;
1486            } else {
1487                $this->seek($s);
1488            }
1489
1490            return true;
1491        }
1492
1493        return false;
1494    }
1495
1496    /**
1497     * Parse comma separated value list
1498     *
1499     * @param array $out
1500     *
1501     * @return boolean
1502     */
1503    protected function valueList(&$out)
1504    {
1505        $discardComments = $this->discardComments;
1506        $this->discardComments = true;
1507        $res = $this->genericList($out, 'spaceList', ',');
1508        $this->discardComments = $discardComments;
1509
1510        return $res;
1511    }
1512
1513    /**
1514     * Parse space separated value list
1515     *
1516     * @param array $out
1517     *
1518     * @return boolean
1519     */
1520    protected function spaceList(&$out)
1521    {
1522        return $this->genericList($out, 'expression');
1523    }
1524
1525    /**
1526     * Parse generic list
1527     *
1528     * @param array    $out
1529     * @param callable $parseItem
1530     * @param string   $delim
1531     * @param boolean  $flatten
1532     *
1533     * @return boolean
1534     */
1535    protected function genericList(&$out, $parseItem, $delim = '', $flatten = true)
1536    {
1537        $s     = $this->count;
1538        $items = [];
1539        $value = null;
1540
1541        while ($this->$parseItem($value)) {
1542            $trailing_delim = false;
1543            $items[] = $value;
1544
1545            if ($delim) {
1546                if (! $this->literal($delim, strlen($delim))) {
1547                    break;
1548                }
1549                $trailing_delim = true;
1550            }
1551        }
1552
1553        if (! $items) {
1554            $this->seek($s);
1555
1556            return false;
1557        }
1558
1559        if ($trailing_delim) {
1560            $items[] = [Type::T_NULL];
1561        }
1562        if ($flatten && count($items) === 1) {
1563            $out = $items[0];
1564        } else {
1565            $out = [Type::T_LIST, $delim, $items];
1566        }
1567
1568        return true;
1569    }
1570
1571    /**
1572     * Parse expression
1573     *
1574     * @param array $out
1575     * @param bool $listOnly
1576     * @param bool $lookForExp
1577     *
1578     * @return boolean
1579     */
1580    protected function expression(&$out, $listOnly = false, $lookForExp = true)
1581    {
1582        $s = $this->count;
1583        $discard = $this->discardComments;
1584        $this->discardComments = true;
1585        $allowedTypes = ($listOnly ? [Type::T_LIST] : [Type::T_LIST, Type::T_MAP]);
1586
1587        if ($this->matchChar('(')) {
1588            if ($this->enclosedExpression($lhs, $s, ")", $allowedTypes)) {
1589                if ($lookForExp) {
1590                    $out = $this->expHelper($lhs, 0);
1591                } else {
1592                    $out = $lhs;
1593                }
1594
1595                $this->discardComments = $discard;
1596
1597                return true;
1598            }
1599
1600            $this->seek($s);
1601        }
1602
1603        if (in_array(Type::T_LIST, $allowedTypes) && $this->matchChar('[')) {
1604            if ($this->enclosedExpression($lhs, $s, "]", [Type::T_LIST])) {
1605                if ($lookForExp) {
1606                    $out = $this->expHelper($lhs, 0);
1607                } else {
1608                    $out = $lhs;
1609                }
1610                $this->discardComments = $discard;
1611
1612                return true;
1613            }
1614
1615            $this->seek($s);
1616        }
1617
1618        if (!$listOnly && $this->value($lhs)) {
1619            if ($lookForExp) {
1620                $out = $this->expHelper($lhs, 0);
1621            } else {
1622                $out = $lhs;
1623            }
1624
1625            $this->discardComments = $discard;
1626
1627            return true;
1628        }
1629
1630        $this->discardComments = $discard;
1631        return false;
1632    }
1633
1634    /**
1635     * Parse expression specifically checking for lists in parenthesis or brackets
1636     *
1637     * @param array   $out
1638     * @param integer $s
1639     * @param string  $closingParen
1640     * @param array   $allowedTypes
1641     *
1642     * @return boolean
1643     */
1644    protected function enclosedExpression(&$out, $s, $closingParen = ")", $allowedTypes = [Type::T_LIST, Type::T_MAP])
1645    {
1646        if ($this->matchChar($closingParen) && in_array(Type::T_LIST, $allowedTypes)) {
1647            $out = [Type::T_LIST, '', []];
1648            switch ($closingParen) {
1649                case ")":
1650                    $out['enclosing'] = 'parent'; // parenthesis list
1651                    break;
1652                case "]":
1653                    $out['enclosing'] = 'bracket'; // bracketed list
1654                    break;
1655            }
1656            return true;
1657        }
1658
1659        if ($this->valueList($out) && $this->matchChar($closingParen)
1660            && in_array($out[0], [Type::T_LIST, Type::T_KEYWORD])
1661            && in_array(Type::T_LIST, $allowedTypes)) {
1662            if ($out[0] !== Type::T_LIST || ! empty($out['enclosing'])) {
1663                $out = [Type::T_LIST, '', [$out]];
1664            }
1665            switch ($closingParen) {
1666                case ")":
1667                    $out['enclosing'] = 'parent'; // parenthesis list
1668                    break;
1669                case "]":
1670                    $out['enclosing'] = 'bracket'; // bracketed list
1671                    break;
1672            }
1673            return true;
1674        }
1675
1676        $this->seek($s);
1677
1678        if (in_array(Type::T_MAP, $allowedTypes) && $this->map($out)) {
1679            return true;
1680        }
1681
1682        return false;
1683    }
1684
1685    /**
1686     * Parse left-hand side of subexpression
1687     *
1688     * @param array   $lhs
1689     * @param integer $minP
1690     *
1691     * @return array
1692     */
1693    protected function expHelper($lhs, $minP)
1694    {
1695        $operators = static::$operatorPattern;
1696
1697        $ss = $this->count;
1698        $whiteBefore = isset($this->buffer[$this->count - 1]) &&
1699            ctype_space($this->buffer[$this->count - 1]);
1700
1701        while ($this->match($operators, $m, false) && static::$precedence[$m[1]] >= $minP) {
1702            $whiteAfter = isset($this->buffer[$this->count]) &&
1703                ctype_space($this->buffer[$this->count]);
1704            $varAfter = isset($this->buffer[$this->count]) &&
1705                $this->buffer[$this->count] === '$';
1706
1707            $this->whitespace();
1708
1709            $op = $m[1];
1710
1711            // don't turn negative numbers into expressions
1712            if ($op === '-' && $whiteBefore && ! $whiteAfter && ! $varAfter) {
1713                break;
1714            }
1715
1716            if (! $this->value($rhs) && ! $this->expression($rhs, true, false)) {
1717                break;
1718            }
1719
1720            // peek and see if rhs belongs to next operator
1721            if ($this->peek($operators, $next) && static::$precedence[$next[1]] > static::$precedence[$op]) {
1722                $rhs = $this->expHelper($rhs, static::$precedence[$next[1]]);
1723            }
1724
1725            $lhs = [Type::T_EXPRESSION, $op, $lhs, $rhs, $this->inParens, $whiteBefore, $whiteAfter];
1726            $ss = $this->count;
1727            $whiteBefore = isset($this->buffer[$this->count - 1]) &&
1728                ctype_space($this->buffer[$this->count - 1]);
1729        }
1730
1731        $this->seek($ss);
1732
1733        return $lhs;
1734    }
1735
1736    /**
1737     * Parse value
1738     *
1739     * @param array $out
1740     *
1741     * @return boolean
1742     */
1743    protected function value(&$out)
1744    {
1745        if (! isset($this->buffer[$this->count])) {
1746            return false;
1747        }
1748
1749        $s = $this->count;
1750        $char = $this->buffer[$this->count];
1751
1752        if ($this->literal('url(', 4) && $this->match('data:([a-z]+)\/([a-z0-9.+-]+);base64,', $m, false)) {
1753            $len = strspn(
1754                $this->buffer,
1755                'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwyxz0123456789+/=',
1756                $this->count
1757            );
1758
1759            $this->count += $len;
1760
1761            if ($this->matchChar(')')) {
1762                $content = substr($this->buffer, $s, $this->count - $s);
1763                $out = [Type::T_KEYWORD, $content];
1764
1765                return true;
1766            }
1767        }
1768
1769        $this->seek($s);
1770
1771        if ($this->literal('url(', 4, false) && $this->match('\s*(\/\/[^\s\)]+)\s*', $m)) {
1772            $content = 'url(' . $m[1];
1773
1774            if ($this->matchChar(')')) {
1775                $content .= ')';
1776                $out = [Type::T_KEYWORD, $content];
1777
1778                return true;
1779            }
1780        }
1781
1782        $this->seek($s);
1783
1784        // not
1785        if ($char === 'n' && $this->literal('not', 3, false)) {
1786            if ($this->whitespace() && $this->value($inner)) {
1787                $out = [Type::T_UNARY, 'not', $inner, $this->inParens];
1788
1789                return true;
1790            }
1791
1792            $this->seek($s);
1793
1794            if ($this->parenValue($inner)) {
1795                $out = [Type::T_UNARY, 'not', $inner, $this->inParens];
1796
1797                return true;
1798            }
1799
1800            $this->seek($s);
1801        }
1802
1803        // addition
1804        if ($char === '+') {
1805            $this->count++;
1806
1807            if ($this->value($inner)) {
1808                $out = [Type::T_UNARY, '+', $inner, $this->inParens];
1809
1810                return true;
1811            }
1812
1813            $this->count--;
1814
1815            return false;
1816        }
1817
1818        // negation
1819        if ($char === '-') {
1820            $this->count++;
1821
1822            if ($this->variable($inner) || $this->unit($inner) || $this->parenValue($inner)) {
1823                $out = [Type::T_UNARY, '-', $inner, $this->inParens];
1824
1825                return true;
1826            }
1827
1828            $this->count--;
1829        }
1830
1831        // paren
1832        if ($char === '(' && $this->parenValue($out)) {
1833            return true;
1834        }
1835
1836        if ($char === '#') {
1837            if ($this->interpolation($out) || $this->color($out)) {
1838                return true;
1839            }
1840        }
1841
1842        if ($this->matchChar('&', true)) {
1843            $out = [Type::T_SELF];
1844
1845            return true;
1846        }
1847
1848        if ($char === '$' && $this->variable($out)) {
1849            return true;
1850        }
1851
1852        if ($char === 'p' && $this->progid($out)) {
1853            return true;
1854        }
1855
1856        if (($char === '"' || $char === "'") && $this->string($out)) {
1857            return true;
1858        }
1859
1860        if ($this->unit($out)) {
1861            return true;
1862        }
1863
1864        // unicode range with wildcards
1865        if ($this->literal('U+', 2) && $this->match('([0-9A-F]+\?*)(-([0-9A-F]+))?', $m, false)) {
1866            $out = [Type::T_KEYWORD, 'U+' . $m[0]];
1867
1868            return true;
1869        }
1870
1871        if ($this->keyword($keyword, false)) {
1872            if ($this->func($keyword, $out)) {
1873                return true;
1874            }
1875
1876            $this->whitespace();
1877
1878            if ($keyword === 'null') {
1879                $out = [Type::T_NULL];
1880            } else {
1881                $out = [Type::T_KEYWORD, $keyword];
1882            }
1883
1884            return true;
1885        }
1886
1887        return false;
1888    }
1889
1890    /**
1891     * Parse parenthesized value
1892     *
1893     * @param array $out
1894     *
1895     * @return boolean
1896     */
1897    protected function parenValue(&$out)
1898    {
1899        $s = $this->count;
1900
1901        $inParens = $this->inParens;
1902
1903        if ($this->matchChar('(')) {
1904            if ($this->matchChar(')')) {
1905                $out = [Type::T_LIST, '', []];
1906
1907                return true;
1908            }
1909
1910            $this->inParens = true;
1911
1912            if ($this->expression($exp) && $this->matchChar(')')) {
1913                $out = $exp;
1914                $this->inParens = $inParens;
1915
1916                return true;
1917            }
1918        }
1919
1920        $this->inParens = $inParens;
1921        $this->seek($s);
1922
1923        return false;
1924    }
1925
1926    /**
1927     * Parse "progid:"
1928     *
1929     * @param array $out
1930     *
1931     * @return boolean
1932     */
1933    protected function progid(&$out)
1934    {
1935        $s = $this->count;
1936
1937        if ($this->literal('progid:', 7, false) &&
1938            $this->openString('(', $fn) &&
1939            $this->matchChar('(')
1940        ) {
1941            $this->openString(')', $args, '(');
1942
1943            if ($this->matchChar(')')) {
1944                $out = [Type::T_STRING, '', [
1945                    'progid:', $fn, '(', $args, ')'
1946                ]];
1947
1948                return true;
1949            }
1950        }
1951
1952        $this->seek($s);
1953
1954        return false;
1955    }
1956
1957    /**
1958     * Parse function call
1959     *
1960     * @param string $name
1961     * @param array  $func
1962     *
1963     * @return boolean
1964     */
1965    protected function func($name, &$func)
1966    {
1967        $s = $this->count;
1968
1969        if ($this->matchChar('(')) {
1970            if ($name === 'alpha' && $this->argumentList($args)) {
1971                $func = [Type::T_FUNCTION, $name, [Type::T_STRING, '', $args]];
1972
1973                return true;
1974            }
1975
1976            if ($name !== 'expression' && ! preg_match('/^(-[a-z]+-)?calc$/', $name)) {
1977                $ss = $this->count;
1978
1979                if ($this->argValues($args) && $this->matchChar(')')) {
1980                    $func = [Type::T_FUNCTION_CALL, $name, $args];
1981
1982                    return true;
1983                }
1984
1985                $this->seek($ss);
1986            }
1987
1988            if (($this->openString(')', $str, '(') || true) &&
1989                $this->matchChar(')')
1990            ) {
1991                $args = [];
1992
1993                if (! empty($str)) {
1994                    $args[] = [null, [Type::T_STRING, '', [$str]]];
1995                }
1996
1997                $func = [Type::T_FUNCTION_CALL, $name, $args];
1998
1999                return true;
2000            }
2001        }
2002
2003        $this->seek($s);
2004
2005        return false;
2006    }
2007
2008    /**
2009     * Parse function call argument list
2010     *
2011     * @param array $out
2012     *
2013     * @return boolean
2014     */
2015    protected function argumentList(&$out)
2016    {
2017        $s = $this->count;
2018        $this->matchChar('(');
2019
2020        $args = [];
2021
2022        while ($this->keyword($var)) {
2023            if ($this->matchChar('=') && $this->expression($exp)) {
2024                $args[] = [Type::T_STRING, '', [$var . '=']];
2025                $arg = $exp;
2026            } else {
2027                break;
2028            }
2029
2030            $args[] = $arg;
2031
2032            if (! $this->matchChar(',')) {
2033                break;
2034            }
2035
2036            $args[] = [Type::T_STRING, '', [', ']];
2037        }
2038
2039        if (! $this->matchChar(')') || ! $args) {
2040            $this->seek($s);
2041
2042            return false;
2043        }
2044
2045        $out = $args;
2046
2047        return true;
2048    }
2049
2050    /**
2051     * Parse mixin/function definition  argument list
2052     *
2053     * @param array $out
2054     *
2055     * @return boolean
2056     */
2057    protected function argumentDef(&$out)
2058    {
2059        $s = $this->count;
2060        $this->matchChar('(');
2061
2062        $args = [];
2063
2064        while ($this->variable($var)) {
2065            $arg = [$var[1], null, false];
2066
2067            $ss = $this->count;
2068
2069            if ($this->matchChar(':') && $this->genericList($defaultVal, 'expression')) {
2070                $arg[1] = $defaultVal;
2071            } else {
2072                $this->seek($ss);
2073            }
2074
2075            $ss = $this->count;
2076
2077            if ($this->literal('...', 3)) {
2078                $sss = $this->count;
2079
2080                if (! $this->matchChar(')')) {
2081                    $this->throwParseError('... has to be after the final argument');
2082                }
2083
2084                $arg[2] = true;
2085                $this->seek($sss);
2086            } else {
2087                $this->seek($ss);
2088            }
2089
2090            $args[] = $arg;
2091
2092            if (! $this->matchChar(',')) {
2093                break;
2094            }
2095        }
2096
2097        if (! $this->matchChar(')')) {
2098            $this->seek($s);
2099
2100            return false;
2101        }
2102
2103        $out = $args;
2104
2105        return true;
2106    }
2107
2108    /**
2109     * Parse map
2110     *
2111     * @param array $out
2112     *
2113     * @return boolean
2114     */
2115    protected function map(&$out)
2116    {
2117        $s = $this->count;
2118
2119        if (! $this->matchChar('(')) {
2120            return false;
2121        }
2122
2123        $keys = [];
2124        $values = [];
2125
2126        while ($this->genericList($key, 'expression') && $this->matchChar(':') &&
2127            $this->genericList($value, 'expression')
2128        ) {
2129            $keys[] = $key;
2130            $values[] = $value;
2131
2132            if (! $this->matchChar(',')) {
2133                break;
2134            }
2135        }
2136
2137        if (! $keys || ! $this->matchChar(')')) {
2138            $this->seek($s);
2139
2140            return false;
2141        }
2142
2143        $out = [Type::T_MAP, $keys, $values];
2144
2145        return true;
2146    }
2147
2148    /**
2149     * Parse color
2150     *
2151     * @param array $out
2152     *
2153     * @return boolean
2154     */
2155    protected function color(&$out)
2156    {
2157        $s = $this->count;
2158
2159        if ($this->match('(#([0-9a-f]+))', $m)) {
2160            if (in_array(strlen($m[2]), [3,4,6,8])) {
2161                $out = [Type::T_KEYWORD, $m[0]];
2162                return true;
2163            }
2164
2165            $this->seek($s);
2166            return false;
2167        }
2168
2169        return false;
2170    }
2171
2172    /**
2173     * Parse number with unit
2174     *
2175     * @param array $unit
2176     *
2177     * @return boolean
2178     */
2179    protected function unit(&$unit)
2180    {
2181        $s = $this->count;
2182
2183        if ($this->match('([0-9]*(\.)?[0-9]+)([%a-zA-Z]+)?', $m, false)) {
2184            if (strlen($this->buffer) === $this->count || ! ctype_digit($this->buffer[$this->count])) {
2185                $this->whitespace();
2186
2187                $unit = new Node\Number($m[1], empty($m[3]) ? '' : $m[3]);
2188
2189                return true;
2190            }
2191
2192            $this->seek($s);
2193        }
2194
2195        return false;
2196    }
2197
2198    /**
2199     * Parse string
2200     *
2201     * @param array $out
2202     *
2203     * @return boolean
2204     */
2205    protected function string(&$out)
2206    {
2207        $s = $this->count;
2208
2209        if ($this->matchChar('"', false)) {
2210            $delim = '"';
2211        } elseif ($this->matchChar("'", false)) {
2212            $delim = "'";
2213        } else {
2214            return false;
2215        }
2216
2217        $content = [];
2218        $oldWhite = $this->eatWhiteDefault;
2219        $this->eatWhiteDefault = false;
2220        $hasInterpolation = false;
2221
2222        while ($this->matchString($m, $delim)) {
2223            if ($m[1] !== '') {
2224                $content[] = $m[1];
2225            }
2226
2227            if ($m[2] === '#{') {
2228                $this->count -= strlen($m[2]);
2229
2230                if ($this->interpolation($inter, false)) {
2231                    $content[] = $inter;
2232                    $hasInterpolation = true;
2233                } else {
2234                    $this->count += strlen($m[2]);
2235                    $content[] = '#{'; // ignore it
2236                }
2237            } elseif ($m[2] === '\\') {
2238                if ($this->matchChar('"', false)) {
2239                    $content[] = $m[2] . '"';
2240                } elseif ($this->matchChar("'", false)) {
2241                    $content[] = $m[2] . "'";
2242                } elseif ($this->literal("\\", 1, false)) {
2243                    $content[] = $m[2] . "\\";
2244                } elseif ($this->literal("\r\n", 2, false) ||
2245                  $this->matchChar("\r", false) ||
2246                  $this->matchChar("\n", false) ||
2247                  $this->matchChar("\f", false)
2248                ) {
2249                    // this is a continuation escaping, to be ignored
2250                } else {
2251                    $content[] = $m[2];
2252                }
2253            } else {
2254                $this->count -= strlen($delim);
2255                break; // delim
2256            }
2257        }
2258
2259        $this->eatWhiteDefault = $oldWhite;
2260
2261        if ($this->literal($delim, strlen($delim))) {
2262            if ($hasInterpolation) {
2263                $delim = '"';
2264
2265                foreach ($content as &$string) {
2266                    if ($string === "\\\\") {
2267                        $string = "\\";
2268                    } elseif ($string === "\\'") {
2269                        $string = "'";
2270                    } elseif ($string === '\\"') {
2271                        $string = '"';
2272                    }
2273                }
2274            }
2275
2276            $out = [Type::T_STRING, $delim, $content];
2277
2278            return true;
2279        }
2280
2281        $this->seek($s);
2282
2283        return false;
2284    }
2285
2286    /**
2287     * Parse keyword or interpolation
2288     *
2289     * @param array   $out
2290     * @param boolean $restricted
2291     *
2292     * @return boolean
2293     */
2294    protected function mixedKeyword(&$out, $restricted = false)
2295    {
2296        $parts = [];
2297
2298        $oldWhite = $this->eatWhiteDefault;
2299        $this->eatWhiteDefault = false;
2300
2301        for (;;) {
2302            if ($restricted ? $this->restrictedKeyword($key) : $this->keyword($key)) {
2303                $parts[] = $key;
2304                continue;
2305            }
2306
2307            if ($this->interpolation($inter)) {
2308                $parts[] = $inter;
2309                continue;
2310            }
2311
2312            break;
2313        }
2314
2315        $this->eatWhiteDefault = $oldWhite;
2316
2317        if (! $parts) {
2318            return false;
2319        }
2320
2321        if ($this->eatWhiteDefault) {
2322            $this->whitespace();
2323        }
2324
2325        $out = $parts;
2326
2327        return true;
2328    }
2329
2330    /**
2331     * Parse an unbounded string stopped by $end
2332     *
2333     * @param string $end
2334     * @param array  $out
2335     * @param string $nestingOpen
2336     *
2337     * @return boolean
2338     */
2339    protected function openString($end, &$out, $nestingOpen = null)
2340    {
2341        $oldWhite = $this->eatWhiteDefault;
2342        $this->eatWhiteDefault = false;
2343
2344        $patt = '(.*?)([\'"]|#\{|' . $this->pregQuote($end) . '|' . static::$commentPattern . ')';
2345
2346        $nestingLevel = 0;
2347
2348        $content = [];
2349
2350        while ($this->match($patt, $m, false)) {
2351            if (isset($m[1]) && $m[1] !== '') {
2352                $content[] = $m[1];
2353
2354                if ($nestingOpen) {
2355                    $nestingLevel += substr_count($m[1], $nestingOpen);
2356                }
2357            }
2358
2359            $tok = $m[2];
2360
2361            $this->count-= strlen($tok);
2362
2363            if ($tok === $end && ! $nestingLevel--) {
2364                break;
2365            }
2366
2367            if (($tok === "'" || $tok === '"') && $this->string($str)) {
2368                $content[] = $str;
2369                continue;
2370            }
2371
2372            if ($tok === '#{' && $this->interpolation($inter)) {
2373                $content[] = $inter;
2374                continue;
2375            }
2376
2377            $content[] = $tok;
2378            $this->count+= strlen($tok);
2379        }
2380
2381        $this->eatWhiteDefault = $oldWhite;
2382
2383        if (! $content) {
2384            return false;
2385        }
2386
2387        // trim the end
2388        if (is_string(end($content))) {
2389            $content[count($content) - 1] = rtrim(end($content));
2390        }
2391
2392        $out = [Type::T_STRING, '', $content];
2393
2394        return true;
2395    }
2396
2397    /**
2398     * Parser interpolation
2399     *
2400     * @param string|array $out
2401     * @param boolean      $lookWhite save information about whitespace before and after
2402     *
2403     * @return boolean
2404     */
2405    protected function interpolation(&$out, $lookWhite = true)
2406    {
2407        $oldWhite = $this->eatWhiteDefault;
2408        $this->eatWhiteDefault = true;
2409
2410        $s = $this->count;
2411
2412        if ($this->literal('#{', 2) && $this->valueList($value) && $this->matchChar('}', false)) {
2413            if ($value === [Type::T_SELF]) {
2414                $out = $value;
2415            } else {
2416                if ($lookWhite) {
2417                    $left = ($s > 0 && preg_match('/\s/', $this->buffer[$s - 1])) ? ' ' : '';
2418                    $right = preg_match('/\s/', $this->buffer[$this->count]) ? ' ': '';
2419                } else {
2420                    $left = $right = false;
2421                }
2422
2423                $out = [Type::T_INTERPOLATE, $value, $left, $right];
2424            }
2425
2426            $this->eatWhiteDefault = $oldWhite;
2427
2428            if ($this->eatWhiteDefault) {
2429                $this->whitespace();
2430            }
2431
2432            return true;
2433        }
2434
2435        $this->seek($s);
2436
2437        $this->eatWhiteDefault = $oldWhite;
2438
2439        return false;
2440    }
2441
2442    /**
2443     * Parse property name (as an array of parts or a string)
2444     *
2445     * @param array $out
2446     *
2447     * @return boolean
2448     */
2449    protected function propertyName(&$out)
2450    {
2451        $parts = [];
2452
2453        $oldWhite = $this->eatWhiteDefault;
2454        $this->eatWhiteDefault = false;
2455
2456        for (;;) {
2457            if ($this->interpolation($inter)) {
2458                $parts[] = $inter;
2459                continue;
2460            }
2461
2462            if ($this->keyword($text)) {
2463                $parts[] = $text;
2464                continue;
2465            }
2466
2467            if (! $parts && $this->match('[:.#]', $m, false)) {
2468                // css hacks
2469                $parts[] = $m[0];
2470                continue;
2471            }
2472
2473            break;
2474        }
2475
2476        $this->eatWhiteDefault = $oldWhite;
2477
2478        if (! $parts) {
2479            return false;
2480        }
2481
2482        // match comment hack
2483        if (preg_match(
2484            static::$whitePattern,
2485            $this->buffer,
2486            $m,
2487            null,
2488            $this->count
2489        )) {
2490            if (! empty($m[0])) {
2491                $parts[] = $m[0];
2492                $this->count += strlen($m[0]);
2493            }
2494        }
2495
2496        $this->whitespace(); // get any extra whitespace
2497
2498        $out = [Type::T_STRING, '', $parts];
2499
2500        return true;
2501    }
2502
2503    /**
2504     * Parse comma separated selector list
2505     *
2506     * @param array   $out
2507     * @param boolean $subSelector
2508     *
2509     * @return boolean
2510     */
2511    protected function selectors(&$out, $subSelector = false)
2512    {
2513        $s = $this->count;
2514        $selectors = [];
2515
2516        while ($this->selector($sel, $subSelector)) {
2517            $selectors[] = $sel;
2518
2519            if (! $this->matchChar(',', true)) {
2520                break;
2521            }
2522
2523            while ($this->matchChar(',', true)) {
2524                ; // ignore extra
2525            }
2526        }
2527
2528        if (! $selectors) {
2529            $this->seek($s);
2530
2531            return false;
2532        }
2533
2534        $out = $selectors;
2535
2536        return true;
2537    }
2538
2539    /**
2540     * Parse whitespace separated selector list
2541     *
2542     * @param array   $out
2543     * @param boolean $subSelector
2544     *
2545     * @return boolean
2546     */
2547    protected function selector(&$out, $subSelector = false)
2548    {
2549        $selector = [];
2550
2551        for (;;) {
2552            $s = $this->count;
2553
2554            if ($this->match('[>+~]+', $m, true)) {
2555                if ($subSelector && is_string($subSelector) && strpos($subSelector, 'nth-') === 0 &&
2556                    $m[0] === '+' && $this->match("(\d+|n\b)", $counter)
2557                ) {
2558                    $this->seek($s);
2559                } else {
2560                    $selector[] = [$m[0]];
2561                    continue;
2562                }
2563            }
2564
2565            if ($this->selectorSingle($part, $subSelector)) {
2566                $selector[] = $part;
2567                $this->match('\s+', $m);
2568                continue;
2569            }
2570
2571            if ($this->match('\/[^\/]+\/', $m, true)) {
2572                $selector[] = [$m[0]];
2573                continue;
2574            }
2575
2576            break;
2577        }
2578
2579        if (! $selector) {
2580            return false;
2581        }
2582
2583        $out = $selector;
2584
2585        return true;
2586    }
2587
2588    /**
2589     * Parse the parts that make up a selector
2590     *
2591     * {@internal
2592     *     div[yes=no]#something.hello.world:nth-child(-2n+1)%placeholder
2593     * }}
2594     *
2595     * @param array   $out
2596     * @param boolean $subSelector
2597     *
2598     * @return boolean
2599     */
2600    protected function selectorSingle(&$out, $subSelector = false)
2601    {
2602        $oldWhite = $this->eatWhiteDefault;
2603        $this->eatWhiteDefault = false;
2604
2605        $parts = [];
2606
2607        if ($this->matchChar('*', false)) {
2608            $parts[] = '*';
2609        }
2610
2611        for (;;) {
2612            if (! isset($this->buffer[$this->count])) {
2613                break;
2614            }
2615
2616            $s = $this->count;
2617            $char = $this->buffer[$this->count];
2618
2619            // see if we can stop early
2620            if ($char === '{' || $char === ',' || $char === ';' || $char === '}' || $char === '@') {
2621                break;
2622            }
2623
2624            // parsing a sub selector in () stop with the closing )
2625            if ($subSelector && $char === ')') {
2626                break;
2627            }
2628
2629            //self
2630            switch ($char) {
2631                case '&':
2632                    $parts[] = Compiler::$selfSelector;
2633                    $this->count++;
2634                    continue 2;
2635
2636                case '.':
2637                    $parts[] = '.';
2638                    $this->count++;
2639                    continue 2;
2640
2641                case '|':
2642                    $parts[] = '|';
2643                    $this->count++;
2644                    continue 2;
2645            }
2646
2647            if ($char === '\\' && $this->match('\\\\\S', $m)) {
2648                $parts[] = $m[0];
2649                continue;
2650            }
2651
2652            if ($char === '%') {
2653                $this->count++;
2654
2655                if ($this->placeholder($placeholder)) {
2656                    $parts[] = '%';
2657                    $parts[] = $placeholder;
2658                    continue;
2659                }
2660
2661                break;
2662            }
2663
2664            if ($char === '#') {
2665                if ($this->interpolation($inter)) {
2666                    $parts[] = $inter;
2667                    continue;
2668                }
2669
2670                $parts[] = '#';
2671                $this->count++;
2672                continue;
2673            }
2674
2675            // a pseudo selector
2676            if ($char === ':') {
2677                if ($this->buffer[$this->count + 1] === ':') {
2678                    $this->count += 2;
2679                    $part = '::';
2680                } else {
2681                    $this->count++;
2682                    $part = ':';
2683                }
2684
2685                if ($this->mixedKeyword($nameParts, true)) {
2686                    $parts[] = $part;
2687
2688                    foreach ($nameParts as $sub) {
2689                        $parts[] = $sub;
2690                    }
2691
2692                    $ss = $this->count;
2693
2694                    if ($nameParts === ['not'] || $nameParts === ['is'] ||
2695                        $nameParts === ['has'] || $nameParts === ['where'] ||
2696                        $nameParts === ['slotted'] ||
2697                        $nameParts === ['nth-child'] || $nameParts == ['nth-last-child'] ||
2698                        $nameParts === ['nth-of-type'] || $nameParts == ['nth-last-of-type']
2699                    ) {
2700                        if ($this->matchChar('(', true) &&
2701                          ($this->selectors($subs, reset($nameParts)) || true) &&
2702                          $this->matchChar(')')
2703                        ) {
2704                            $parts[] = '(';
2705
2706                            while ($sub = array_shift($subs)) {
2707                                while ($ps = array_shift($sub)) {
2708                                    foreach ($ps as &$p) {
2709                                        $parts[] = $p;
2710                                    }
2711
2712                                    if (count($sub) && reset($sub)) {
2713                                        $parts[] = ' ';
2714                                    }
2715                                }
2716
2717                                if (count($subs) && reset($subs)) {
2718                                    $parts[] = ', ';
2719                                }
2720                            }
2721
2722                            $parts[] = ')';
2723                        } else {
2724                            $this->seek($ss);
2725                        }
2726                    } else {
2727                        if ($this->matchChar('(') &&
2728                          ($this->openString(')', $str, '(') || true) &&
2729                          $this->matchChar(')')
2730                        ) {
2731                            $parts[] = '(';
2732
2733                            if (! empty($str)) {
2734                                $parts[] = $str;
2735                            }
2736
2737                            $parts[] = ')';
2738                        } else {
2739                            $this->seek($ss);
2740                        }
2741                    }
2742
2743                    continue;
2744                }
2745            }
2746
2747            $this->seek($s);
2748
2749            // 2n+1
2750            if ($subSelector && is_string($subSelector) && strpos($subSelector, 'nth-') === 0) {
2751                if ($this->match("(\s*(\+\s*|\-\s*)?(\d+|n|\d+n))+", $counter)) {
2752                    $parts[] = $counter[0];
2753                    //$parts[] = str_replace(' ', '', $counter[0]);
2754                    continue;
2755                }
2756            }
2757
2758            $this->seek($s);
2759
2760            // attribute selector
2761            if ($char === '[' &&
2762                $this->matchChar('[') &&
2763                ($this->openString(']', $str, '[') || true) &&
2764                $this->matchChar(']')
2765            ) {
2766                $parts[] = '[';
2767
2768                if (! empty($str)) {
2769                    $parts[] = $str;
2770                }
2771
2772                $parts[] = ']';
2773                continue;
2774            }
2775
2776            $this->seek($s);
2777
2778            // for keyframes
2779            if ($this->unit($unit)) {
2780                $parts[] = $unit;
2781                continue;
2782            }
2783
2784            if ($this->restrictedKeyword($name)) {
2785                $parts[] = $name;
2786                continue;
2787            }
2788
2789            break;
2790        }
2791
2792        $this->eatWhiteDefault = $oldWhite;
2793
2794        if (! $parts) {
2795            return false;
2796        }
2797
2798        $out = $parts;
2799
2800        return true;
2801    }
2802
2803    /**
2804     * Parse a variable
2805     *
2806     * @param array $out
2807     *
2808     * @return boolean
2809     */
2810    protected function variable(&$out)
2811    {
2812        $s = $this->count;
2813
2814        if ($this->matchChar('$', false) && $this->keyword($name)) {
2815            $out = [Type::T_VARIABLE, $name];
2816
2817            return true;
2818        }
2819
2820        $this->seek($s);
2821
2822        return false;
2823    }
2824
2825    /**
2826     * Parse a keyword
2827     *
2828     * @param string  $word
2829     * @param boolean $eatWhitespace
2830     *
2831     * @return boolean
2832     */
2833    protected function keyword(&$word, $eatWhitespace = null)
2834    {
2835        if ($this->match(
2836            $this->utf8
2837                ? '(([\pL\w\x{00A0}-\x{10FFFF}_\-\*!"\']|[\\\\].)([\pL\w\x{00A0}-\x{10FFFF}\-_"\']|[\\\\].)*)'
2838                : '(([\w_\-\*!"\']|[\\\\].)([\w\-_"\']|[\\\\].)*)',
2839            $m,
2840            $eatWhitespace
2841        )) {
2842            $word = $m[1];
2843
2844            return true;
2845        }
2846
2847        return false;
2848    }
2849
2850    /**
2851     * Parse a keyword that should not start with a number
2852     *
2853     * @param string  $word
2854     * @param boolean $eatWhitespace
2855     *
2856     * @return boolean
2857     */
2858    protected function restrictedKeyword(&$word, $eatWhitespace = null)
2859    {
2860        $s = $this->count;
2861
2862        if ($this->keyword($word, $eatWhitespace) && (ord($word[0]) > 57 || ord($word[0]) < 48)) {
2863            return true;
2864        }
2865
2866        $this->seek($s);
2867
2868        return false;
2869    }
2870
2871    /**
2872     * Parse a placeholder
2873     *
2874     * @param string|array $placeholder
2875     *
2876     * @return boolean
2877     */
2878    protected function placeholder(&$placeholder)
2879    {
2880        if ($this->match(
2881            $this->utf8
2882                ? '([\pL\w\-_]+)'
2883                : '([\w\-_]+)',
2884            $m
2885        )) {
2886            $placeholder = $m[1];
2887
2888            return true;
2889        }
2890
2891        if ($this->interpolation($placeholder)) {
2892            return true;
2893        }
2894
2895        return false;
2896    }
2897
2898    /**
2899     * Parse a url
2900     *
2901     * @param array $out
2902     *
2903     * @return boolean
2904     */
2905    protected function url(&$out)
2906    {
2907        if ($this->match('(url\(\s*(["\']?)([^)]+)\2\s*\))', $m)) {
2908            $out = [Type::T_STRING, '', ['url(' . $m[2] . $m[3] . $m[2] . ')']];
2909
2910            return true;
2911        }
2912
2913        return false;
2914    }
2915
2916    /**
2917     * Consume an end of statement delimiter
2918     *
2919     * @return boolean
2920     */
2921    protected function end()
2922    {
2923        if ($this->matchChar(';')) {
2924            return true;
2925        }
2926
2927        if ($this->count === strlen($this->buffer) || $this->buffer[$this->count] === '}') {
2928            // if there is end of file or a closing block next then we don't need a ;
2929            return true;
2930        }
2931
2932        return false;
2933    }
2934
2935    /**
2936     * Strip assignment flag from the list
2937     *
2938     * @param array $value
2939     *
2940     * @return array
2941     */
2942    protected function stripAssignmentFlags(&$value)
2943    {
2944        $flags = [];
2945
2946        for ($token = &$value; $token[0] === Type::T_LIST && ($s = count($token[2])); $token = &$lastNode) {
2947            $lastNode = &$token[2][$s - 1];
2948
2949            while ($lastNode[0] === Type::T_KEYWORD && in_array($lastNode[1], ['!default', '!global'])) {
2950                array_pop($token[2]);
2951
2952                $node     = end($token[2]);
2953                $token    = $this->flattenList($token);
2954                $flags[]  = $lastNode[1];
2955                $lastNode = $node;
2956            }
2957        }
2958
2959        return $flags;
2960    }
2961
2962    /**
2963     * Strip optional flag from selector list
2964     *
2965     * @param array $selectors
2966     *
2967     * @return string
2968     */
2969    protected function stripOptionalFlag(&$selectors)
2970    {
2971        $optional = false;
2972        $selector = end($selectors);
2973        $part     = end($selector);
2974
2975        if ($part === ['!optional']) {
2976            array_pop($selectors[count($selectors) - 1]);
2977
2978            $optional = true;
2979        }
2980
2981        return $optional;
2982    }
2983
2984    /**
2985     * Turn list of length 1 into value type
2986     *
2987     * @param array $value
2988     *
2989     * @return array
2990     */
2991    protected function flattenList($value)
2992    {
2993        if ($value[0] === Type::T_LIST && count($value[2]) === 1) {
2994            return $this->flattenList($value[2][0]);
2995        }
2996
2997        return $value;
2998    }
2999
3000    /**
3001     * @deprecated
3002     *
3003     * {@internal
3004     *     advance counter to next occurrence of $what
3005     *     $until - don't include $what in advance
3006     *     $allowNewline, if string, will be used as valid char set
3007     * }}
3008     */
3009    protected function to($what, &$out, $until = false, $allowNewline = false)
3010    {
3011        if (is_string($allowNewline)) {
3012            $validChars = $allowNewline;
3013        } else {
3014            $validChars = $allowNewline ? '.' : "[^\n]";
3015        }
3016
3017        $m = null;
3018
3019        if (! $this->match('(' . $validChars . '*?)' . $this->pregQuote($what), $m, ! $until)) {
3020            return false;
3021        }
3022
3023        if ($until) {
3024            $this->count -= strlen($what); // give back $what
3025        }
3026
3027        $out = $m[1];
3028
3029        return true;
3030    }
3031
3032    /**
3033     * @deprecated
3034     */
3035    protected function show()
3036    {
3037        if ($this->peek("(.*?)(\n|$)", $m, $this->count)) {
3038            return $m[1];
3039        }
3040
3041        return '';
3042    }
3043
3044    /**
3045     * Quote regular expression
3046     *
3047     * @param string $what
3048     *
3049     * @return string
3050     */
3051    private function pregQuote($what)
3052    {
3053        return preg_quote($what, '/');
3054    }
3055
3056    /**
3057     * Extract line numbers from buffer
3058     *
3059     * @param string $buffer
3060     */
3061    private function extractLineNumbers($buffer)
3062    {
3063        $this->sourcePositions = [0 => 0];
3064        $prev = 0;
3065
3066        while (($pos = strpos($buffer, "\n", $prev)) !== false) {
3067            $this->sourcePositions[] = $pos;
3068            $prev = $pos + 1;
3069        }
3070
3071        $this->sourcePositions[] = strlen($buffer);
3072
3073        if (substr($buffer, -1) !== "\n") {
3074            $this->sourcePositions[] = strlen($buffer) + 1;
3075        }
3076    }
3077
3078    /**
3079     * Get source line number and column (given character position in the buffer)
3080     *
3081     * @param integer $pos
3082     *
3083     * @return array
3084     */
3085    private function getSourcePosition($pos)
3086    {
3087        $low = 0;
3088        $high = count($this->sourcePositions);
3089
3090        while ($low < $high) {
3091            $mid = (int) (($high + $low) / 2);
3092
3093            if ($pos < $this->sourcePositions[$mid]) {
3094                $high = $mid - 1;
3095                continue;
3096            }
3097
3098            if ($pos >= $this->sourcePositions[$mid + 1]) {
3099                $low = $mid + 1;
3100                continue;
3101            }
3102
3103            return [$mid + 1, $pos - $this->sourcePositions[$mid]];
3104        }
3105
3106        return [$low + 1, $pos - $this->sourcePositions[$low]];
3107    }
3108
3109    /**
3110     * Save internal encoding
3111     */
3112    private function saveEncoding()
3113    {
3114        if (version_compare(PHP_VERSION, '7.2.0') >= 0) {
3115            return;
3116        }
3117
3118        // deprecated in PHP 7.2
3119        $iniDirective = 'mbstring.func_overload';
3120
3121        if (extension_loaded('mbstring') && ini_get($iniDirective) & 2) {
3122            $this->encoding = mb_internal_encoding();
3123
3124            mb_internal_encoding('iso-8859-1');
3125        }
3126    }
3127
3128    /**
3129     * Restore internal encoding
3130     */
3131    private function restoreEncoding()
3132    {
3133        if (extension_loaded('mbstring') && $this->encoding) {
3134            mb_internal_encoding($this->encoding);
3135        }
3136    }
3137}
3138