1<?php
2
3/**
4 * SCSSPHP
5 *
6 * @copyright 2012-2020 Leaf Corcoran
7 *
8 * @license http://opensource.org/licenses/MIT MIT
9 *
10 * @link http://scssphp.github.io/scssphp
11 */
12
13namespace ScssPhp\ScssPhp;
14
15use ScssPhp\ScssPhp\Exception\ParserException;
16
17/**
18 * Parser
19 *
20 * @author Leaf Corcoran <leafot@gmail.com>
21 */
22class Parser
23{
24    const SOURCE_INDEX  = -1;
25    const SOURCE_LINE   = -2;
26    const SOURCE_COLUMN = -3;
27
28    /**
29     * @var array<string, int>
30     */
31    protected static $precedence = [
32        '='   => 0,
33        'or'  => 1,
34        'and' => 2,
35        '=='  => 3,
36        '!='  => 3,
37        '<='  => 4,
38        '>='  => 4,
39        '<'   => 4,
40        '>'   => 4,
41        '+'   => 5,
42        '-'   => 5,
43        '*'   => 6,
44        '/'   => 6,
45        '%'   => 6,
46    ];
47
48    /**
49     * @var string
50     */
51    protected static $commentPattern;
52    /**
53     * @var string
54     */
55    protected static $operatorPattern;
56    /**
57     * @var string
58     */
59    protected static $whitePattern;
60
61    /**
62     * @var Cache|null
63     */
64    protected $cache;
65
66    private $sourceName;
67    private $sourceIndex;
68    /**
69     * @var array<int, int>
70     */
71    private $sourcePositions;
72    /**
73     * @var array|null
74     */
75    private $charset;
76    /**
77     * The current offset in the buffer
78     *
79     * @var int
80     */
81    private $count;
82    /**
83     * @var Block
84     */
85    private $env;
86    /**
87     * @var bool
88     */
89    private $inParens;
90    /**
91     * @var bool
92     */
93    private $eatWhiteDefault;
94    /**
95     * @var bool
96     */
97    private $discardComments;
98    private $allowVars;
99    /**
100     * @var string
101     */
102    private $buffer;
103    private $utf8;
104    /**
105     * @var string|null
106     */
107    private $encoding;
108    private $patternModifiers;
109    private $commentsSeen;
110
111    private $cssOnly;
112
113    /**
114     * Constructor
115     *
116     * @api
117     *
118     * @param string      $sourceName
119     * @param integer     $sourceIndex
120     * @param string|null $encoding
121     * @param Cache|null  $cache
122     * @param bool        $cssOnly
123     */
124    public function __construct($sourceName, $sourceIndex = 0, $encoding = 'utf-8', Cache $cache = null, $cssOnly = false)
125    {
126        $this->sourceName       = $sourceName ?: '(stdin)';
127        $this->sourceIndex      = $sourceIndex;
128        $this->charset          = null;
129        $this->utf8             = ! $encoding || strtolower($encoding) === 'utf-8';
130        $this->patternModifiers = $this->utf8 ? 'Aisu' : 'Ais';
131        $this->commentsSeen     = [];
132        $this->commentsSeen     = [];
133        $this->allowVars        = true;
134        $this->cssOnly          = $cssOnly;
135
136        if (empty(static::$operatorPattern)) {
137            static::$operatorPattern = '([*\/%+-]|[!=]\=|\>\=?|\<\=?|and|or)';
138
139            $commentSingle      = '\/\/';
140            $commentMultiLeft   = '\/\*';
141            $commentMultiRight  = '\*\/';
142
143            static::$commentPattern = $commentMultiLeft . '.*?' . $commentMultiRight;
144            static::$whitePattern = $this->utf8
145                ? '/' . $commentSingle . '[^\n]*\s*|(' . static::$commentPattern . ')\s*|\s+/AisuS'
146                : '/' . $commentSingle . '[^\n]*\s*|(' . static::$commentPattern . ')\s*|\s+/AisS';
147        }
148
149        $this->cache = $cache;
150    }
151
152    /**
153     * Get source file name
154     *
155     * @api
156     *
157     * @return string
158     */
159    public function getSourceName()
160    {
161        return $this->sourceName;
162    }
163
164    /**
165     * Throw parser error
166     *
167     * @api
168     *
169     * @param string $msg
170     *
171     * @throws ParserException
172     *
173     * @deprecated use "parseError" and throw the exception in the caller instead.
174     */
175    public function throwParseError($msg = 'parse error')
176    {
177        @trigger_error(
178            'The method "throwParseError" is deprecated. Use "parseError" and throw the exception in the caller instead',
179            E_USER_DEPRECATED
180        );
181
182        throw $this->parseError($msg);
183    }
184
185    /**
186     * Creates a parser error
187     *
188     * @api
189     *
190     * @param string $msg
191     *
192     * @return ParserException
193     */
194    public function parseError($msg = 'parse error')
195    {
196        list($line, $column) = $this->getSourcePosition($this->count);
197
198        $loc = empty($this->sourceName)
199             ? "line: $line, column: $column"
200             : "$this->sourceName on line $line, at column $column";
201
202        if ($this->peek('(.*?)(\n|$)', $m, $this->count)) {
203            $this->restoreEncoding();
204
205            $e = new ParserException("$msg: failed at `$m[1]` $loc");
206            $e->setSourcePosition([$this->sourceName, $line, $column]);
207
208            return $e;
209        }
210
211        $this->restoreEncoding();
212
213        $e = new ParserException("$msg: $loc");
214        $e->setSourcePosition([$this->sourceName, $line, $column]);
215
216        return $e;
217    }
218
219    /**
220     * Parser buffer
221     *
222     * @api
223     *
224     * @param string $buffer
225     *
226     * @return Block
227     */
228    public function parse($buffer)
229    {
230        if ($this->cache) {
231            $cacheKey = $this->sourceName . ':' . md5($buffer);
232            $parseOptions = [
233                'charset' => $this->charset,
234                'utf8' => $this->utf8,
235            ];
236            $v = $this->cache->getCache('parse', $cacheKey, $parseOptions);
237
238            if (! \is_null($v)) {
239                return $v;
240            }
241        }
242
243        // strip BOM (byte order marker)
244        if (substr($buffer, 0, 3) === "\xef\xbb\xbf") {
245            $buffer = substr($buffer, 3);
246        }
247
248        $this->buffer          = rtrim($buffer, "\x00..\x1f");
249        $this->count           = 0;
250        $this->env             = null;
251        $this->inParens        = false;
252        $this->eatWhiteDefault = true;
253
254        $this->saveEncoding();
255        $this->extractLineNumbers($buffer);
256
257        $this->pushBlock(null); // root block
258        $this->whitespace();
259        $this->pushBlock(null);
260        $this->popBlock();
261
262        while ($this->parseChunk()) {
263            ;
264        }
265
266        if ($this->count !== \strlen($this->buffer)) {
267            throw $this->parseError();
268        }
269
270        if (! empty($this->env->parent)) {
271            throw $this->parseError('unclosed block');
272        }
273
274        if ($this->charset) {
275            array_unshift($this->env->children, $this->charset);
276        }
277
278        $this->restoreEncoding();
279
280        if ($this->cache) {
281            $this->cache->setCache('parse', $cacheKey, $this->env, $parseOptions);
282        }
283
284        return $this->env;
285    }
286
287    /**
288     * Parse a value or value list
289     *
290     * @api
291     *
292     * @param string       $buffer
293     * @param string|array $out
294     *
295     * @return boolean
296     */
297    public function parseValue($buffer, &$out)
298    {
299        $this->count           = 0;
300        $this->env             = null;
301        $this->inParens        = false;
302        $this->eatWhiteDefault = true;
303        $this->buffer          = (string) $buffer;
304
305        $this->saveEncoding();
306        $this->extractLineNumbers($this->buffer);
307
308        $list = $this->valueList($out);
309
310        $this->restoreEncoding();
311
312        return $list;
313    }
314
315    /**
316     * Parse a selector or selector list
317     *
318     * @api
319     *
320     * @param string       $buffer
321     * @param string|array $out
322     * @param bool         $shouldValidate
323     *
324     * @return boolean
325     */
326    public function parseSelector($buffer, &$out, $shouldValidate = true)
327    {
328        $this->count           = 0;
329        $this->env             = null;
330        $this->inParens        = false;
331        $this->eatWhiteDefault = true;
332        $this->buffer          = (string) $buffer;
333
334        $this->saveEncoding();
335        $this->extractLineNumbers($this->buffer);
336
337        // discard space/comments at the start
338        $this->discardComments = true;
339        $this->whitespace();
340        $this->discardComments = false;
341
342        $selector = $this->selectors($out);
343
344        $this->restoreEncoding();
345
346        if ($shouldValidate && $this->count !== strlen($buffer)) {
347            throw $this->parseError("`" . substr($buffer, $this->count) . "` is not a valid Selector in `$buffer`");
348        }
349
350        return $selector;
351    }
352
353    /**
354     * Parse a media Query
355     *
356     * @api
357     *
358     * @param string       $buffer
359     * @param string|array $out
360     *
361     * @return boolean
362     */
363    public function parseMediaQueryList($buffer, &$out)
364    {
365        $this->count           = 0;
366        $this->env             = null;
367        $this->inParens        = false;
368        $this->eatWhiteDefault = true;
369        $this->buffer          = (string) $buffer;
370
371        $this->saveEncoding();
372        $this->extractLineNumbers($this->buffer);
373
374        $isMediaQuery = $this->mediaQueryList($out);
375
376        $this->restoreEncoding();
377
378        return $isMediaQuery;
379    }
380
381    /**
382     * Parse a single chunk off the head of the buffer and append it to the
383     * current parse environment.
384     *
385     * Returns false when the buffer is empty, or when there is an error.
386     *
387     * This function is called repeatedly until the entire document is
388     * parsed.
389     *
390     * This parser is most similar to a recursive descent parser. Single
391     * functions represent discrete grammatical rules for the language, and
392     * they are able to capture the text that represents those rules.
393     *
394     * Consider the function Compiler::keyword(). (All parse functions are
395     * structured the same.)
396     *
397     * The function takes a single reference argument. When calling the
398     * function it will attempt to match a keyword on the head of the buffer.
399     * If it is successful, it will place the keyword in the referenced
400     * argument, advance the position in the buffer, and return true. If it
401     * fails then it won't advance the buffer and it will return false.
402     *
403     * All of these parse functions are powered by Compiler::match(), which behaves
404     * the same way, but takes a literal regular expression. Sometimes it is
405     * more convenient to use match instead of creating a new function.
406     *
407     * Because of the format of the functions, to parse an entire string of
408     * grammatical rules, you can chain them together using &&.
409     *
410     * But, if some of the rules in the chain succeed before one fails, then
411     * the buffer position will be left at an invalid state. In order to
412     * avoid this, Compiler::seek() is used to remember and set buffer positions.
413     *
414     * Before parsing a chain, use $s = $this->count to remember the current
415     * position into $s. Then if a chain fails, use $this->seek($s) to
416     * go back where we started.
417     *
418     * @return boolean
419     */
420    protected function parseChunk()
421    {
422        $s = $this->count;
423
424        // the directives
425        if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] === '@') {
426            if (
427                $this->literal('@at-root', 8) &&
428                ($this->selectors($selector) || true) &&
429                ($this->map($with) || true) &&
430                (($this->matchChar('(') &&
431                    $this->interpolation($with) &&
432                    $this->matchChar(')')) || true) &&
433                $this->matchChar('{', false)
434            ) {
435                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
436
437                $atRoot = $this->pushSpecialBlock(Type::T_AT_ROOT, $s);
438                $atRoot->selector = $selector;
439                $atRoot->with     = $with;
440
441                return true;
442            }
443
444            $this->seek($s);
445
446            if (
447                $this->literal('@media', 6) &&
448                $this->mediaQueryList($mediaQueryList) &&
449                $this->matchChar('{', false)
450            ) {
451                $media = $this->pushSpecialBlock(Type::T_MEDIA, $s);
452                $media->queryList = $mediaQueryList[2];
453
454                return true;
455            }
456
457            $this->seek($s);
458
459            if (
460                $this->literal('@mixin', 6) &&
461                $this->keyword($mixinName) &&
462                ($this->argumentDef($args) || true) &&
463                $this->matchChar('{', false)
464            ) {
465                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
466
467                $mixin = $this->pushSpecialBlock(Type::T_MIXIN, $s);
468                $mixin->name = $mixinName;
469                $mixin->args = $args;
470
471                return true;
472            }
473
474            $this->seek($s);
475
476            if (
477                ($this->literal('@include', 8) &&
478                    $this->keyword($mixinName) &&
479                    ($this->matchChar('(') &&
480                    ($this->argValues($argValues) || true) &&
481                    $this->matchChar(')') || true) &&
482                    ($this->end()) ||
483                ($this->literal('using', 5) &&
484                    $this->argumentDef($argUsing) &&
485                    ($this->end() || $this->matchChar('{') && $hasBlock = true)) ||
486                $this->matchChar('{') && $hasBlock = true)
487            ) {
488                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
489
490                $child = [
491                    Type::T_INCLUDE,
492                    $mixinName,
493                    isset($argValues) ? $argValues : null,
494                    null,
495                    isset($argUsing) ? $argUsing : null
496                ];
497
498                if (! empty($hasBlock)) {
499                    $include = $this->pushSpecialBlock(Type::T_INCLUDE, $s);
500                    $include->child = $child;
501                } else {
502                    $this->append($child, $s);
503                }
504
505                return true;
506            }
507
508            $this->seek($s);
509
510            if (
511                $this->literal('@scssphp-import-once', 20) &&
512                $this->valueList($importPath) &&
513                $this->end()
514            ) {
515                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
516
517                $this->append([Type::T_SCSSPHP_IMPORT_ONCE, $importPath], $s);
518
519                return true;
520            }
521
522            $this->seek($s);
523
524            if (
525                $this->literal('@import', 7) &&
526                $this->valueList($importPath) &&
527                $importPath[0] !== Type::T_FUNCTION_CALL &&
528                $this->end()
529            ) {
530                if ($this->cssOnly) {
531                    $this->assertPlainCssValid([Type::T_IMPORT, $importPath], $s);
532                    $this->append([Type::T_COMMENT, rtrim(substr($this->buffer, $s, $this->count - $s))]);
533                    return true;
534                }
535
536                $this->append([Type::T_IMPORT, $importPath], $s);
537
538                return true;
539            }
540
541            $this->seek($s);
542
543            if (
544                $this->literal('@import', 7) &&
545                $this->url($importPath) &&
546                $this->end()
547            ) {
548                if ($this->cssOnly) {
549                    $this->assertPlainCssValid([Type::T_IMPORT, $importPath], $s);
550                    $this->append([Type::T_COMMENT, rtrim(substr($this->buffer, $s, $this->count - $s))]);
551                    return true;
552                }
553
554                $this->append([Type::T_IMPORT, $importPath], $s);
555
556                return true;
557            }
558
559            $this->seek($s);
560
561            if (
562                $this->literal('@extend', 7) &&
563                $this->selectors($selectors) &&
564                $this->end()
565            ) {
566                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
567
568                // check for '!flag'
569                $optional = $this->stripOptionalFlag($selectors);
570                $this->append([Type::T_EXTEND, $selectors, $optional], $s);
571
572                return true;
573            }
574
575            $this->seek($s);
576
577            if (
578                $this->literal('@function', 9) &&
579                $this->keyword($fnName) &&
580                $this->argumentDef($args) &&
581                $this->matchChar('{', false)
582            ) {
583                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
584
585                $func = $this->pushSpecialBlock(Type::T_FUNCTION, $s);
586                $func->name = $fnName;
587                $func->args = $args;
588
589                return true;
590            }
591
592            $this->seek($s);
593
594            if (
595                $this->literal('@return', 7) &&
596                ($this->valueList($retVal) || true) &&
597                $this->end()
598            ) {
599                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
600
601                $this->append([Type::T_RETURN, isset($retVal) ? $retVal : [Type::T_NULL]], $s);
602
603                return true;
604            }
605
606            $this->seek($s);
607
608            if (
609                $this->literal('@each', 5) &&
610                $this->genericList($varNames, 'variable', ',', false) &&
611                $this->literal('in', 2) &&
612                $this->valueList($list) &&
613                $this->matchChar('{', false)
614            ) {
615                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
616
617                $each = $this->pushSpecialBlock(Type::T_EACH, $s);
618
619                foreach ($varNames[2] as $varName) {
620                    $each->vars[] = $varName[1];
621                }
622
623                $each->list = $list;
624
625                return true;
626            }
627
628            $this->seek($s);
629
630            if (
631                $this->literal('@while', 6) &&
632                $this->expression($cond) &&
633                $this->matchChar('{', false)
634            ) {
635                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
636
637                while (
638                    $cond[0] === Type::T_LIST &&
639                    ! empty($cond['enclosing']) &&
640                    $cond['enclosing'] === 'parent' &&
641                    \count($cond[2]) == 1
642                ) {
643                    $cond = reset($cond[2]);
644                }
645
646                $while = $this->pushSpecialBlock(Type::T_WHILE, $s);
647                $while->cond = $cond;
648
649                return true;
650            }
651
652            $this->seek($s);
653
654            if (
655                $this->literal('@for', 4) &&
656                $this->variable($varName) &&
657                $this->literal('from', 4) &&
658                $this->expression($start) &&
659                ($this->literal('through', 7) ||
660                    ($forUntil = true && $this->literal('to', 2))) &&
661                $this->expression($end) &&
662                $this->matchChar('{', false)
663            ) {
664                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
665
666                $for = $this->pushSpecialBlock(Type::T_FOR, $s);
667                $for->var   = $varName[1];
668                $for->start = $start;
669                $for->end   = $end;
670                $for->until = isset($forUntil);
671
672                return true;
673            }
674
675            $this->seek($s);
676
677            if (
678                $this->literal('@if', 3) &&
679                $this->functionCallArgumentsList($cond, false, '{', false)
680            ) {
681                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
682
683                $if = $this->pushSpecialBlock(Type::T_IF, $s);
684
685                while (
686                    $cond[0] === Type::T_LIST &&
687                    ! empty($cond['enclosing']) &&
688                    $cond['enclosing'] === 'parent' &&
689                    \count($cond[2]) == 1
690                ) {
691                    $cond = reset($cond[2]);
692                }
693
694                $if->cond  = $cond;
695                $if->cases = [];
696
697                return true;
698            }
699
700            $this->seek($s);
701
702            if (
703                $this->literal('@debug', 6) &&
704                $this->functionCallArgumentsList($value, false)
705            ) {
706                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
707
708                $this->append([Type::T_DEBUG, $value], $s);
709
710                return true;
711            }
712
713            $this->seek($s);
714
715            if (
716                $this->literal('@warn', 5) &&
717                $this->functionCallArgumentsList($value, false)
718            ) {
719                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
720
721                $this->append([Type::T_WARN, $value], $s);
722
723                return true;
724            }
725
726            $this->seek($s);
727
728            if (
729                $this->literal('@error', 6) &&
730                $this->functionCallArgumentsList($value, false)
731            ) {
732                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
733
734                $this->append([Type::T_ERROR, $value], $s);
735
736                return true;
737            }
738
739            $this->seek($s);
740
741            if (
742                $this->literal('@content', 8) &&
743                ($this->end() ||
744                    $this->matchChar('(') &&
745                    $this->argValues($argContent) &&
746                    $this->matchChar(')') &&
747                    $this->end())
748            ) {
749                ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
750
751                $this->append([Type::T_MIXIN_CONTENT, isset($argContent) ? $argContent : null], $s);
752
753                return true;
754            }
755
756            $this->seek($s);
757
758            $last = $this->last();
759
760            if (isset($last) && $last[0] === Type::T_IF) {
761                list(, $if) = $last;
762
763                if ($this->literal('@else', 5)) {
764                    if ($this->matchChar('{', false)) {
765                        $else = $this->pushSpecialBlock(Type::T_ELSE, $s);
766                    } elseif (
767                        $this->literal('if', 2) &&
768                        $this->functionCallArgumentsList($cond, false, '{', false)
769                    ) {
770                        $else = $this->pushSpecialBlock(Type::T_ELSEIF, $s);
771                        $else->cond = $cond;
772                    }
773
774                    if (isset($else)) {
775                        $else->dontAppend = true;
776                        $if->cases[] = $else;
777
778                        return true;
779                    }
780                }
781
782                $this->seek($s);
783            }
784
785            // only retain the first @charset directive encountered
786            if (
787                $this->literal('@charset', 8) &&
788                $this->valueList($charset) &&
789                $this->end()
790            ) {
791                if (! isset($this->charset)) {
792                    $statement = [Type::T_CHARSET, $charset];
793
794                    list($line, $column) = $this->getSourcePosition($s);
795
796                    $statement[static::SOURCE_LINE]   = $line;
797                    $statement[static::SOURCE_COLUMN] = $column;
798                    $statement[static::SOURCE_INDEX]  = $this->sourceIndex;
799
800                    $this->charset = $statement;
801                }
802
803                return true;
804            }
805
806            $this->seek($s);
807
808            if (
809                $this->literal('@supports', 9) &&
810                ($t1 = $this->supportsQuery($supportQuery)) &&
811                ($t2 = $this->matchChar('{', false))
812            ) {
813                $directive = $this->pushSpecialBlock(Type::T_DIRECTIVE, $s);
814                $directive->name  = 'supports';
815                $directive->value = $supportQuery;
816
817                return true;
818            }
819
820            $this->seek($s);
821
822            // doesn't match built in directive, do generic one
823            if (
824                $this->matchChar('@', false) &&
825                $this->mixedKeyword($dirName) &&
826                $this->directiveValue($dirValue, '{')
827            ) {
828                if (count($dirName) === 1 && is_string(reset($dirName))) {
829                    $dirName = reset($dirName);
830                } else {
831                    $dirName = [Type::T_STRING, '', $dirName];
832                }
833                if ($dirName === 'media') {
834                    $directive = $this->pushSpecialBlock(Type::T_MEDIA, $s);
835                } else {
836                    $directive = $this->pushSpecialBlock(Type::T_DIRECTIVE, $s);
837                    $directive->name = $dirName;
838                }
839
840                if (isset($dirValue)) {
841                    ! $this->cssOnly || ($dirValue = $this->assertPlainCssValid($dirValue));
842                    $directive->value = $dirValue;
843                }
844
845                return true;
846            }
847
848            $this->seek($s);
849
850            // maybe it's a generic blockless directive
851            if (
852                $this->matchChar('@', false) &&
853                $this->mixedKeyword($dirName) &&
854                ! $this->isKnownGenericDirective($dirName) &&
855                ($this->end(false) || ($this->directiveValue($dirValue, '') && $this->end(false)))
856            ) {
857                if (\count($dirName) === 1 && \is_string(\reset($dirName))) {
858                    $dirName = \reset($dirName);
859                } else {
860                    $dirName = [Type::T_STRING, '', $dirName];
861                }
862                if (
863                    ! empty($this->env->parent) &&
864                    $this->env->type &&
865                    ! \in_array($this->env->type, [Type::T_DIRECTIVE, Type::T_MEDIA])
866                ) {
867                    $plain = \trim(\substr($this->buffer, $s, $this->count - $s));
868                    throw $this->parseError(
869                        "Unknown directive `{$plain}` not allowed in `" . $this->env->type . "` block"
870                    );
871                }
872                // blockless directives with a blank line after keeps their blank lines after
873                // sass-spec compliance purpose
874                $s = $this->count;
875                $hasBlankLine = false;
876                if ($this->match('\s*?\n\s*\n', $out, false)) {
877                    $hasBlankLine = true;
878                    $this->seek($s);
879                }
880                $isNotRoot = ! empty($this->env->parent);
881                $this->append([Type::T_DIRECTIVE, [$dirName, $dirValue, $hasBlankLine, $isNotRoot]], $s);
882                $this->whitespace();
883
884                return true;
885            }
886
887            $this->seek($s);
888
889            return false;
890        }
891
892        $inCssSelector = null;
893        if ($this->cssOnly) {
894            $inCssSelector = (! empty($this->env->parent) &&
895                ! in_array($this->env->type, [Type::T_DIRECTIVE, Type::T_MEDIA]));
896        }
897        // custom properties : right part is static
898        if (($this->customProperty($name) ) && $this->matchChar(':', false)) {
899            $start = $this->count;
900
901            // but can be complex and finish with ; or }
902            foreach ([';','}'] as $ending) {
903                if (
904                    $this->openString($ending, $stringValue, '(', ')', false) &&
905                    $this->end()
906                ) {
907                    $end = $this->count;
908                    $value = $stringValue;
909
910                    // check if we have only a partial value due to nested [] or { } to take in account
911                    $nestingPairs = [['[', ']'], ['{', '}']];
912
913                    foreach ($nestingPairs as $nestingPair) {
914                        $p = strpos($this->buffer, $nestingPair[0], $start);
915
916                        if ($p && $p < $end) {
917                            $this->seek($start);
918
919                            if (
920                                $this->openString($ending, $stringValue, $nestingPair[0], $nestingPair[1], false) &&
921                                $this->end() &&
922                                $this->count > $end
923                            ) {
924                                $end = $this->count;
925                                $value = $stringValue;
926                            }
927                        }
928                    }
929
930                    $this->seek($end);
931                    $this->append([Type::T_CUSTOM_PROPERTY, $name, $value], $s);
932
933                    return true;
934                }
935            }
936
937            // TODO: output an error here if nothing found according to sass spec
938        }
939
940        $this->seek($s);
941
942        // property shortcut
943        // captures most properties before having to parse a selector
944        if (
945            $this->keyword($name, false) &&
946            $this->literal(': ', 2) &&
947            $this->valueList($value) &&
948            $this->end()
949        ) {
950            $name = [Type::T_STRING, '', [$name]];
951            $this->append([Type::T_ASSIGN, $name, $value], $s);
952
953            return true;
954        }
955
956        $this->seek($s);
957
958        // variable assigns
959        if (
960            $this->variable($name) &&
961            $this->matchChar(':') &&
962            $this->valueList($value) &&
963            $this->end()
964        ) {
965            ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
966
967            // check for '!flag'
968            $assignmentFlags = $this->stripAssignmentFlags($value);
969            $this->append([Type::T_ASSIGN, $name, $value, $assignmentFlags], $s);
970
971            return true;
972        }
973
974        $this->seek($s);
975
976        // misc
977        if ($this->literal('-->', 3)) {
978            return true;
979        }
980
981        // opening css block
982        if (
983            $this->selectors($selectors) &&
984            $this->matchChar('{', false)
985        ) {
986            ! $this->cssOnly || ! $inCssSelector || $this->assertPlainCssValid(false);
987
988            $this->pushBlock($selectors, $s);
989
990            if ($this->eatWhiteDefault) {
991                $this->whitespace();
992                $this->append(null); // collect comments at the beginning if needed
993            }
994
995            return true;
996        }
997
998        $this->seek($s);
999
1000        // property assign, or nested assign
1001        if (
1002            $this->propertyName($name) &&
1003            $this->matchChar(':')
1004        ) {
1005            $foundSomething = false;
1006
1007            if ($this->valueList($value)) {
1008                if (empty($this->env->parent)) {
1009                    throw $this->parseError('expected "{"');
1010                }
1011
1012                $this->append([Type::T_ASSIGN, $name, $value], $s);
1013                $foundSomething = true;
1014            }
1015
1016            if ($this->matchChar('{', false)) {
1017                ! $this->cssOnly || $this->assertPlainCssValid(false);
1018
1019                $propBlock = $this->pushSpecialBlock(Type::T_NESTED_PROPERTY, $s);
1020                $propBlock->prefix = $name;
1021                $propBlock->hasValue = $foundSomething;
1022
1023                $foundSomething = true;
1024            } elseif ($foundSomething) {
1025                $foundSomething = $this->end();
1026            }
1027
1028            if ($foundSomething) {
1029                return true;
1030            }
1031        }
1032
1033        $this->seek($s);
1034
1035        // closing a block
1036        if ($this->matchChar('}', false)) {
1037            $block = $this->popBlock();
1038
1039            if (! isset($block->type) || $block->type !== Type::T_IF) {
1040                if ($this->env->parent) {
1041                    $this->append(null); // collect comments before next statement if needed
1042                }
1043            }
1044
1045            if (isset($block->type) && $block->type === Type::T_INCLUDE) {
1046                $include = $block->child;
1047                unset($block->child);
1048                $include[3] = $block;
1049                $this->append($include, $s);
1050            } elseif (empty($block->dontAppend)) {
1051                $type = isset($block->type) ? $block->type : Type::T_BLOCK;
1052                $this->append([$type, $block], $s);
1053            }
1054
1055            // collect comments just after the block closing if needed
1056            if ($this->eatWhiteDefault) {
1057                $this->whitespace();
1058
1059                if ($this->env->comments) {
1060                    $this->append(null);
1061                }
1062            }
1063
1064            return true;
1065        }
1066
1067        // extra stuff
1068        if (
1069            $this->matchChar(';') ||
1070            $this->literal('<!--', 4)
1071        ) {
1072            return true;
1073        }
1074
1075        return false;
1076    }
1077
1078    /**
1079     * Push block onto parse tree
1080     *
1081     * @param array|null $selectors
1082     * @param integer $pos
1083     *
1084     * @return Block
1085     */
1086    protected function pushBlock($selectors, $pos = 0)
1087    {
1088        list($line, $column) = $this->getSourcePosition($pos);
1089
1090        $b = new Block();
1091        $b->sourceName   = $this->sourceName;
1092        $b->sourceLine   = $line;
1093        $b->sourceColumn = $column;
1094        $b->sourceIndex  = $this->sourceIndex;
1095        $b->selectors    = $selectors;
1096        $b->comments     = [];
1097        $b->parent       = $this->env;
1098
1099        if (! $this->env) {
1100            $b->children = [];
1101        } elseif (empty($this->env->children)) {
1102            $this->env->children = $this->env->comments;
1103            $b->children = [];
1104            $this->env->comments = [];
1105        } else {
1106            $b->children = $this->env->comments;
1107            $this->env->comments = [];
1108        }
1109
1110        $this->env = $b;
1111
1112        // collect comments at the beginning of a block if needed
1113        if ($this->eatWhiteDefault) {
1114            $this->whitespace();
1115
1116            if ($this->env->comments) {
1117                $this->append(null);
1118            }
1119        }
1120
1121        return $b;
1122    }
1123
1124    /**
1125     * Push special (named) block onto parse tree
1126     *
1127     * @param string  $type
1128     * @param integer $pos
1129     *
1130     * @return Block
1131     */
1132    protected function pushSpecialBlock($type, $pos)
1133    {
1134        $block = $this->pushBlock(null, $pos);
1135        $block->type = $type;
1136
1137        return $block;
1138    }
1139
1140    /**
1141     * Pop scope and return last block
1142     *
1143     * @return Block
1144     *
1145     * @throws \Exception
1146     */
1147    protected function popBlock()
1148    {
1149
1150        // collect comments ending just before of a block closing
1151        if ($this->env->comments) {
1152            $this->append(null);
1153        }
1154
1155        // pop the block
1156        $block = $this->env;
1157
1158        if (empty($block->parent)) {
1159            throw $this->parseError('unexpected }');
1160        }
1161
1162        if ($block->type == Type::T_AT_ROOT) {
1163            // keeps the parent in case of self selector &
1164            $block->selfParent = $block->parent;
1165        }
1166
1167        $this->env = $block->parent;
1168
1169        unset($block->parent);
1170
1171        return $block;
1172    }
1173
1174    /**
1175     * Peek input stream
1176     *
1177     * @param string  $regex
1178     * @param array   $out
1179     * @param integer $from
1180     *
1181     * @return integer
1182     */
1183    protected function peek($regex, &$out, $from = null)
1184    {
1185        if (! isset($from)) {
1186            $from = $this->count;
1187        }
1188
1189        $r = '/' . $regex . '/' . $this->patternModifiers;
1190        $result = preg_match($r, $this->buffer, $out, null, $from);
1191
1192        return $result;
1193    }
1194
1195    /**
1196     * Seek to position in input stream (or return current position in input stream)
1197     *
1198     * @param integer $where
1199     */
1200    protected function seek($where)
1201    {
1202        $this->count = $where;
1203    }
1204
1205    /**
1206     * Assert a parsed part is plain CSS Valid
1207     *
1208     * @param array|false $parsed
1209     * @param int $startPos
1210     * @throws ParserException
1211     */
1212    protected function assertPlainCssValid($parsed, $startPos = null)
1213    {
1214        $type = '';
1215        if ($parsed) {
1216            $type = $parsed[0];
1217            $parsed = $this->isPlainCssValidElement($parsed);
1218        }
1219        if (! $parsed) {
1220            if (! \is_null($startPos)) {
1221                $plain = rtrim(substr($this->buffer, $startPos, $this->count - $startPos));
1222                $message = "Error : `{$plain}` isn't allowed in plain CSS";
1223            } else {
1224                $message = 'Error: SCSS syntax not allowed in CSS file';
1225            }
1226            if ($type) {
1227                $message .= " ($type)";
1228            }
1229            throw $this->parseError($message);
1230        }
1231
1232        return $parsed;
1233    }
1234
1235    /**
1236     * Check a parsed element is plain CSS Valid
1237     * @param array $parsed
1238     * @return bool|array
1239     */
1240    protected function isPlainCssValidElement($parsed, $allowExpression = false)
1241    {
1242        // keep string as is
1243        if (is_string($parsed)) {
1244            return $parsed;
1245        }
1246
1247        if (
1248            \in_array($parsed[0], [Type::T_FUNCTION, Type::T_FUNCTION_CALL]) &&
1249            !\in_array($parsed[1], [
1250                'alpha',
1251                'attr',
1252                'calc',
1253                'cubic-bezier',
1254                'env',
1255                'grayscale',
1256                'hsl',
1257                'hsla',
1258                'invert',
1259                'linear-gradient',
1260                'min',
1261                'max',
1262                'radial-gradient',
1263                'repeating-linear-gradient',
1264                'repeating-radial-gradient',
1265                'rgb',
1266                'rgba',
1267                'rotate',
1268                'saturate',
1269                'var',
1270            ]) &&
1271            Compiler::isNativeFunction($parsed[1])
1272        ) {
1273            return false;
1274        }
1275
1276        switch ($parsed[0]) {
1277            case Type::T_BLOCK:
1278            case Type::T_KEYWORD:
1279            case Type::T_NULL:
1280            case Type::T_NUMBER:
1281            case Type::T_MEDIA:
1282                return $parsed;
1283
1284            case Type::T_COMMENT:
1285                if (isset($parsed[2])) {
1286                    return false;
1287                }
1288                return $parsed;
1289
1290            case Type::T_DIRECTIVE:
1291                if (\is_array($parsed[1])) {
1292                    $parsed[1][1] = $this->isPlainCssValidElement($parsed[1][1]);
1293                    if (! $parsed[1][1]) {
1294                        return false;
1295                    }
1296                }
1297
1298                return $parsed;
1299
1300            case Type::T_IMPORT:
1301                if ($parsed[1][0] === Type::T_LIST) {
1302                    return false;
1303                }
1304                $parsed[1] = $this->isPlainCssValidElement($parsed[1]);
1305                if ($parsed[1] === false) {
1306                    return false;
1307                }
1308                return $parsed;
1309
1310            case Type::T_STRING:
1311                foreach ($parsed[2] as $k => $substr) {
1312                    if (\is_array($substr)) {
1313                        $parsed[2][$k] = $this->isPlainCssValidElement($substr);
1314                        if (! $parsed[2][$k]) {
1315                            return false;
1316                        }
1317                    }
1318                }
1319                return $parsed;
1320
1321            case Type::T_LIST:
1322                if (!empty($parsed['enclosing'])) {
1323                    return false;
1324                }
1325                foreach ($parsed[2] as $k => $listElement) {
1326                    $parsed[2][$k] = $this->isPlainCssValidElement($listElement);
1327                    if (! $parsed[2][$k]) {
1328                        return false;
1329                    }
1330                }
1331                return $parsed;
1332
1333            case Type::T_ASSIGN:
1334                foreach ([1, 2, 3] as $k) {
1335                    if (! empty($parsed[$k])) {
1336                        $parsed[$k] = $this->isPlainCssValidElement($parsed[$k]);
1337                        if (! $parsed[$k]) {
1338                            return false;
1339                        }
1340                    }
1341                }
1342                return $parsed;
1343
1344            case Type::T_EXPRESSION:
1345                list( ,$op, $lhs, $rhs, $inParens, $whiteBefore, $whiteAfter) = $parsed;
1346                if (! $allowExpression &&  ! \in_array($op, ['and', 'or', '/'])) {
1347                    return false;
1348                }
1349                $lhs = $this->isPlainCssValidElement($lhs, true);
1350                if (! $lhs) {
1351                    return false;
1352                }
1353                $rhs = $this->isPlainCssValidElement($rhs, true);
1354                if (! $rhs) {
1355                    return false;
1356                }
1357
1358                return [
1359                    Type::T_STRING,
1360                    '', [
1361                        $this->inParens ? '(' : '',
1362                        $lhs,
1363                        ($whiteBefore ? ' ' : '') . $op . ($whiteAfter ? ' ' : ''),
1364                        $rhs,
1365                        $this->inParens ? ')' : ''
1366                    ]
1367                ];
1368
1369            case Type::T_CUSTOM_PROPERTY:
1370            case Type::T_UNARY:
1371                $parsed[2] = $this->isPlainCssValidElement($parsed[2]);
1372                if (! $parsed[2]) {
1373                    return false;
1374                }
1375                return $parsed;
1376
1377            case Type::T_FUNCTION:
1378                $argsList = $parsed[2];
1379                foreach ($argsList[2] as $argElement) {
1380                    if (! $this->isPlainCssValidElement($argElement)) {
1381                        return false;
1382                    }
1383                }
1384                return $parsed;
1385
1386            case Type::T_FUNCTION_CALL:
1387                $parsed[0] = Type::T_FUNCTION;
1388                $argsList = [Type::T_LIST, ',', []];
1389                foreach ($parsed[2] as $arg) {
1390                    if ($arg[0] || ! empty($arg[2])) {
1391                        // no named arguments possible in a css function call
1392                        // nor ... argument
1393                        return false;
1394                    }
1395                    $arg = $this->isPlainCssValidElement($arg[1], $parsed[1] === 'calc');
1396                    if (! $arg) {
1397                        return false;
1398                    }
1399                    $argsList[2][] = $arg;
1400                }
1401                $parsed[2] = $argsList;
1402                return $parsed;
1403        }
1404
1405        return false;
1406    }
1407
1408    /**
1409     * Match string looking for either ending delim, escape, or string interpolation
1410     *
1411     * {@internal This is a workaround for preg_match's 250K string match limit. }}
1412     *
1413     * @param array  $m     Matches (passed by reference)
1414     * @param string $delim Delimiter
1415     *
1416     * @return boolean True if match; false otherwise
1417     */
1418    protected function matchString(&$m, $delim)
1419    {
1420        $token = null;
1421
1422        $end = \strlen($this->buffer);
1423
1424        // look for either ending delim, escape, or string interpolation
1425        foreach (['#{', '\\', "\r", $delim] as $lookahead) {
1426            $pos = strpos($this->buffer, $lookahead, $this->count);
1427
1428            if ($pos !== false && $pos < $end) {
1429                $end = $pos;
1430                $token = $lookahead;
1431            }
1432        }
1433
1434        if (! isset($token)) {
1435            return false;
1436        }
1437
1438        $match = substr($this->buffer, $this->count, $end - $this->count);
1439        $m = [
1440            $match . $token,
1441            $match,
1442            $token
1443        ];
1444        $this->count = $end + \strlen($token);
1445
1446        return true;
1447    }
1448
1449    /**
1450     * Try to match something on head of buffer
1451     *
1452     * @param string  $regex
1453     * @param array   $out
1454     * @param boolean $eatWhitespace
1455     *
1456     * @return boolean
1457     */
1458    protected function match($regex, &$out, $eatWhitespace = null)
1459    {
1460        $r = '/' . $regex . '/' . $this->patternModifiers;
1461
1462        if (! preg_match($r, $this->buffer, $out, null, $this->count)) {
1463            return false;
1464        }
1465
1466        $this->count += \strlen($out[0]);
1467
1468        if (! isset($eatWhitespace)) {
1469            $eatWhitespace = $this->eatWhiteDefault;
1470        }
1471
1472        if ($eatWhitespace) {
1473            $this->whitespace();
1474        }
1475
1476        return true;
1477    }
1478
1479    /**
1480     * Match a single string
1481     *
1482     * @param string  $char
1483     * @param boolean $eatWhitespace
1484     *
1485     * @return boolean
1486     */
1487    protected function matchChar($char, $eatWhitespace = null)
1488    {
1489        if (! isset($this->buffer[$this->count]) || $this->buffer[$this->count] !== $char) {
1490            return false;
1491        }
1492
1493        $this->count++;
1494
1495        if (! isset($eatWhitespace)) {
1496            $eatWhitespace = $this->eatWhiteDefault;
1497        }
1498
1499        if ($eatWhitespace) {
1500            $this->whitespace();
1501        }
1502
1503        return true;
1504    }
1505
1506    /**
1507     * Match literal string
1508     *
1509     * @param string  $what
1510     * @param integer $len
1511     * @param boolean $eatWhitespace
1512     *
1513     * @return boolean
1514     */
1515    protected function literal($what, $len, $eatWhitespace = null)
1516    {
1517        if (strcasecmp(substr($this->buffer, $this->count, $len), $what) !== 0) {
1518            return false;
1519        }
1520
1521        $this->count += $len;
1522
1523        if (! isset($eatWhitespace)) {
1524            $eatWhitespace = $this->eatWhiteDefault;
1525        }
1526
1527        if ($eatWhitespace) {
1528            $this->whitespace();
1529        }
1530
1531        return true;
1532    }
1533
1534    /**
1535     * Match some whitespace
1536     *
1537     * @return boolean
1538     */
1539    protected function whitespace()
1540    {
1541        $gotWhite = false;
1542
1543        while (preg_match(static::$whitePattern, $this->buffer, $m, null, $this->count)) {
1544            if (isset($m[1]) && empty($this->commentsSeen[$this->count])) {
1545                // comment that are kept in the output CSS
1546                $comment = [];
1547                $startCommentCount = $this->count;
1548                $endCommentCount = $this->count + \strlen($m[1]);
1549
1550                // find interpolations in comment
1551                $p = strpos($this->buffer, '#{', $this->count);
1552
1553                while ($p !== false && $p < $endCommentCount) {
1554                    $c           = substr($this->buffer, $this->count, $p - $this->count);
1555                    $comment[]   = $c;
1556                    $this->count = $p;
1557                    $out         = null;
1558
1559                    if ($this->interpolation($out)) {
1560                        // keep right spaces in the following string part
1561                        if ($out[3]) {
1562                            while ($this->buffer[$this->count - 1] !== '}') {
1563                                $this->count--;
1564                            }
1565
1566                            $out[3] = '';
1567                        }
1568
1569                        $comment[] = [Type::T_COMMENT, substr($this->buffer, $p, $this->count - $p), $out];
1570                    } else {
1571                        $comment[] = substr($this->buffer, $this->count, 2);
1572
1573                        $this->count += 2;
1574                    }
1575
1576                    $p = strpos($this->buffer, '#{', $this->count);
1577                }
1578
1579                // remaining part
1580                $c = substr($this->buffer, $this->count, $endCommentCount - $this->count);
1581
1582                if (! $comment) {
1583                    // single part static comment
1584                    $this->appendComment([Type::T_COMMENT, $c]);
1585                } else {
1586                    $comment[] = $c;
1587                    $staticComment = substr($this->buffer, $startCommentCount, $endCommentCount - $startCommentCount);
1588                    $this->appendComment([Type::T_COMMENT, $staticComment, [Type::T_STRING, '', $comment]]);
1589                }
1590
1591                $this->commentsSeen[$startCommentCount] = true;
1592                $this->count = $endCommentCount;
1593            } else {
1594                // comment that are ignored and not kept in the output css
1595                $this->count += \strlen($m[0]);
1596                // silent comments are not allowed in plain CSS files
1597                ! $this->cssOnly
1598                  || ! \strlen(trim($m[0]))
1599                  || $this->assertPlainCssValid(false, $this->count - \strlen($m[0]));
1600            }
1601
1602            $gotWhite = true;
1603        }
1604
1605        return $gotWhite;
1606    }
1607
1608    /**
1609     * Append comment to current block
1610     *
1611     * @param array $comment
1612     */
1613    protected function appendComment($comment)
1614    {
1615        if (! $this->discardComments) {
1616            $this->env->comments[] = $comment;
1617        }
1618    }
1619
1620    /**
1621     * Append statement to current block
1622     *
1623     * @param array|null $statement
1624     * @param integer $pos
1625     */
1626    protected function append($statement, $pos = null)
1627    {
1628        if (! \is_null($statement)) {
1629            ! $this->cssOnly || ($statement = $this->assertPlainCssValid($statement, $pos));
1630
1631            if (! \is_null($pos)) {
1632                list($line, $column) = $this->getSourcePosition($pos);
1633
1634                $statement[static::SOURCE_LINE]   = $line;
1635                $statement[static::SOURCE_COLUMN] = $column;
1636                $statement[static::SOURCE_INDEX]  = $this->sourceIndex;
1637            }
1638
1639            $this->env->children[] = $statement;
1640        }
1641
1642        $comments = $this->env->comments;
1643
1644        if ($comments) {
1645            $this->env->children = array_merge($this->env->children, $comments);
1646            $this->env->comments = [];
1647        }
1648    }
1649
1650    /**
1651     * Returns last child was appended
1652     *
1653     * @return array|null
1654     */
1655    protected function last()
1656    {
1657        $i = \count($this->env->children) - 1;
1658
1659        if (isset($this->env->children[$i])) {
1660            return $this->env->children[$i];
1661        }
1662    }
1663
1664    /**
1665     * Parse media query list
1666     *
1667     * @param array $out
1668     *
1669     * @return boolean
1670     */
1671    protected function mediaQueryList(&$out)
1672    {
1673        return $this->genericList($out, 'mediaQuery', ',', false);
1674    }
1675
1676    /**
1677     * Parse media query
1678     *
1679     * @param array $out
1680     *
1681     * @return boolean
1682     */
1683    protected function mediaQuery(&$out)
1684    {
1685        $expressions = null;
1686        $parts = [];
1687
1688        if (
1689            ($this->literal('only', 4) && ($only = true) ||
1690            $this->literal('not', 3) && ($not = true) || true) &&
1691            $this->mixedKeyword($mediaType)
1692        ) {
1693            $prop = [Type::T_MEDIA_TYPE];
1694
1695            if (isset($only)) {
1696                $prop[] = [Type::T_KEYWORD, 'only'];
1697            }
1698
1699            if (isset($not)) {
1700                $prop[] = [Type::T_KEYWORD, 'not'];
1701            }
1702
1703            $media = [Type::T_LIST, '', []];
1704
1705            foreach ((array) $mediaType as $type) {
1706                if (\is_array($type)) {
1707                    $media[2][] = $type;
1708                } else {
1709                    $media[2][] = [Type::T_KEYWORD, $type];
1710                }
1711            }
1712
1713            $prop[]  = $media;
1714            $parts[] = $prop;
1715        }
1716
1717        if (empty($parts) || $this->literal('and', 3)) {
1718            $this->genericList($expressions, 'mediaExpression', 'and', false);
1719
1720            if (\is_array($expressions)) {
1721                $parts = array_merge($parts, $expressions[2]);
1722            }
1723        }
1724
1725        $out = $parts;
1726
1727        return true;
1728    }
1729
1730    /**
1731     * Parse supports query
1732     *
1733     * @param array $out
1734     *
1735     * @return boolean
1736     */
1737    protected function supportsQuery(&$out)
1738    {
1739        $expressions = null;
1740        $parts = [];
1741
1742        $s = $this->count;
1743
1744        $not = false;
1745
1746        if (
1747            ($this->literal('not', 3) && ($not = true) || true) &&
1748            $this->matchChar('(') &&
1749            ($this->expression($property)) &&
1750            $this->literal(': ', 2) &&
1751            $this->valueList($value) &&
1752            $this->matchChar(')')
1753        ) {
1754            $support = [Type::T_STRING, '', [[Type::T_KEYWORD, ($not ? 'not ' : '') . '(']]];
1755            $support[2][] = $property;
1756            $support[2][] = [Type::T_KEYWORD, ': '];
1757            $support[2][] = $value;
1758            $support[2][] = [Type::T_KEYWORD, ')'];
1759
1760            $parts[] = $support;
1761            $s = $this->count;
1762        } else {
1763            $this->seek($s);
1764        }
1765
1766        if (
1767            $this->matchChar('(') &&
1768            $this->supportsQuery($subQuery) &&
1769            $this->matchChar(')')
1770        ) {
1771            $parts[] = [Type::T_STRING, '', [[Type::T_KEYWORD, '('], $subQuery, [Type::T_KEYWORD, ')']]];
1772            $s = $this->count;
1773        } else {
1774            $this->seek($s);
1775        }
1776
1777        if (
1778            $this->literal('not', 3) &&
1779            $this->supportsQuery($subQuery)
1780        ) {
1781            $parts[] = [Type::T_STRING, '', [[Type::T_KEYWORD, 'not '], $subQuery]];
1782            $s = $this->count;
1783        } else {
1784            $this->seek($s);
1785        }
1786
1787        if (
1788            $this->literal('selector(', 9) &&
1789            $this->selector($selector) &&
1790            $this->matchChar(')')
1791        ) {
1792            $support = [Type::T_STRING, '', [[Type::T_KEYWORD, 'selector(']]];
1793
1794            $selectorList = [Type::T_LIST, '', []];
1795
1796            foreach ($selector as $sc) {
1797                $compound = [Type::T_STRING, '', []];
1798
1799                foreach ($sc as $scp) {
1800                    if (\is_array($scp)) {
1801                        $compound[2][] = $scp;
1802                    } else {
1803                        $compound[2][] = [Type::T_KEYWORD, $scp];
1804                    }
1805                }
1806
1807                $selectorList[2][] = $compound;
1808            }
1809
1810            $support[2][] = $selectorList;
1811            $support[2][] = [Type::T_KEYWORD, ')'];
1812            $parts[] = $support;
1813            $s = $this->count;
1814        } else {
1815            $this->seek($s);
1816        }
1817
1818        if ($this->variable($var) or $this->interpolation($var)) {
1819            $parts[] = $var;
1820            $s = $this->count;
1821        } else {
1822            $this->seek($s);
1823        }
1824
1825        if (
1826            $this->literal('and', 3) &&
1827            $this->genericList($expressions, 'supportsQuery', ' and', false)
1828        ) {
1829            array_unshift($expressions[2], [Type::T_STRING, '', $parts]);
1830
1831            $parts = [$expressions];
1832            $s = $this->count;
1833        } else {
1834            $this->seek($s);
1835        }
1836
1837        if (
1838            $this->literal('or', 2) &&
1839            $this->genericList($expressions, 'supportsQuery', ' or', false)
1840        ) {
1841            array_unshift($expressions[2], [Type::T_STRING, '', $parts]);
1842
1843            $parts = [$expressions];
1844            $s = $this->count;
1845        } else {
1846            $this->seek($s);
1847        }
1848
1849        if (\count($parts)) {
1850            if ($this->eatWhiteDefault) {
1851                $this->whitespace();
1852            }
1853
1854            $out = [Type::T_STRING, '', $parts];
1855
1856            return true;
1857        }
1858
1859        return false;
1860    }
1861
1862
1863    /**
1864     * Parse media expression
1865     *
1866     * @param array $out
1867     *
1868     * @return boolean
1869     */
1870    protected function mediaExpression(&$out)
1871    {
1872        $s = $this->count;
1873        $value = null;
1874
1875        if (
1876            $this->matchChar('(') &&
1877            $this->expression($feature) &&
1878            ($this->matchChar(':') &&
1879                $this->expression($value) || true) &&
1880            $this->matchChar(')')
1881        ) {
1882            $out = [Type::T_MEDIA_EXPRESSION, $feature];
1883
1884            if ($value) {
1885                $out[] = $value;
1886            }
1887
1888            return true;
1889        }
1890
1891        $this->seek($s);
1892
1893        return false;
1894    }
1895
1896    /**
1897     * Parse argument values
1898     *
1899     * @param array $out
1900     *
1901     * @return boolean
1902     */
1903    protected function argValues(&$out)
1904    {
1905        $discardComments = $this->discardComments;
1906        $this->discardComments = true;
1907
1908        if ($this->genericList($list, 'argValue', ',', false)) {
1909            $out = $list[2];
1910
1911            $this->discardComments = $discardComments;
1912
1913            return true;
1914        }
1915
1916        $this->discardComments = $discardComments;
1917
1918        return false;
1919    }
1920
1921    /**
1922     * Parse argument value
1923     *
1924     * @param array $out
1925     *
1926     * @return boolean
1927     */
1928    protected function argValue(&$out)
1929    {
1930        $s = $this->count;
1931
1932        $keyword = null;
1933
1934        if (! $this->variable($keyword) || ! $this->matchChar(':')) {
1935            $this->seek($s);
1936
1937            $keyword = null;
1938        }
1939
1940        if ($this->genericList($value, 'expression', '', true)) {
1941            $out = [$keyword, $value, false];
1942            $s = $this->count;
1943
1944            if ($this->literal('...', 3)) {
1945                $out[2] = true;
1946            } else {
1947                $this->seek($s);
1948            }
1949
1950            return true;
1951        }
1952
1953        return false;
1954    }
1955
1956    /**
1957     * Check if a generic directive is known to be able to allow almost any syntax or not
1958     * @param mixed $directiveName
1959     * @return bool
1960     */
1961    protected function isKnownGenericDirective($directiveName)
1962    {
1963        if (\is_array($directiveName) && \is_string(reset($directiveName))) {
1964            $directiveName = reset($directiveName);
1965        }
1966        if (! \is_string($directiveName)) {
1967            return false;
1968        }
1969        if (
1970            \in_array($directiveName, [
1971            'at-root',
1972            'media',
1973            'mixin',
1974            'include',
1975            'scssphp-import-once',
1976            'import',
1977            'extend',
1978            'function',
1979            'break',
1980            'continue',
1981            'return',
1982            'each',
1983            'while',
1984            'for',
1985            'if',
1986            'debug',
1987            'warn',
1988            'error',
1989            'content',
1990            'else',
1991            'charset',
1992            'supports',
1993            // Todo
1994            'use',
1995            'forward',
1996            ])
1997        ) {
1998            return true;
1999        }
2000        return false;
2001    }
2002
2003    /**
2004     * Parse directive value list that considers $vars as keyword
2005     *
2006     * @param array          $out
2007     * @param boolean|string $endChar
2008     *
2009     * @return boolean
2010     */
2011    protected function directiveValue(&$out, $endChar = false)
2012    {
2013        $s = $this->count;
2014
2015        if ($this->variable($out)) {
2016            if ($endChar && $this->matchChar($endChar, false)) {
2017                return true;
2018            }
2019
2020            if (! $endChar && $this->end()) {
2021                return true;
2022            }
2023        }
2024
2025        $this->seek($s);
2026
2027        if (\is_string($endChar) && $this->openString($endChar ? $endChar : ';', $out, null, null, true, ";}{")) {
2028            if ($endChar && $this->matchChar($endChar, false)) {
2029                return true;
2030            }
2031            $ss = $this->count;
2032            if (!$endChar && $this->end()) {
2033                $this->seek($ss);
2034                return true;
2035            }
2036        }
2037
2038        $this->seek($s);
2039
2040        $allowVars = $this->allowVars;
2041        $this->allowVars = false;
2042
2043        $res = $this->genericList($out, 'spaceList', ',');
2044        $this->allowVars = $allowVars;
2045
2046        if ($res) {
2047            if ($endChar && $this->matchChar($endChar, false)) {
2048                return true;
2049            }
2050
2051            if (! $endChar && $this->end()) {
2052                return true;
2053            }
2054        }
2055
2056        $this->seek($s);
2057
2058        if ($endChar && $this->matchChar($endChar, false)) {
2059            return true;
2060        }
2061
2062        return false;
2063    }
2064
2065    /**
2066     * Parse comma separated value list
2067     *
2068     * @param array $out
2069     *
2070     * @return boolean
2071     */
2072    protected function valueList(&$out)
2073    {
2074        $discardComments = $this->discardComments;
2075        $this->discardComments = true;
2076        $res = $this->genericList($out, 'spaceList', ',');
2077        $this->discardComments = $discardComments;
2078
2079        return $res;
2080    }
2081
2082    /**
2083     * Parse a function call, where externals () are part of the call
2084     * and not of the value list
2085     *
2086     * @param $out
2087     * @param bool $mandatoryEnclos
2088     * @param null|string $charAfter
2089     * @param null|bool $eatWhiteSp
2090     * @return bool
2091     */
2092    protected function functionCallArgumentsList(&$out, $mandatoryEnclos = true, $charAfter = null, $eatWhiteSp = null)
2093    {
2094        $s = $this->count;
2095
2096        if (
2097            $this->matchChar('(') &&
2098            $this->valueList($out) &&
2099            $this->matchChar(')') &&
2100            ($charAfter ? $this->matchChar($charAfter, $eatWhiteSp) : $this->end())
2101        ) {
2102            return true;
2103        }
2104
2105        if (! $mandatoryEnclos) {
2106            $this->seek($s);
2107
2108            if (
2109                $this->valueList($out) &&
2110                ($charAfter ? $this->matchChar($charAfter, $eatWhiteSp) : $this->end())
2111            ) {
2112                return true;
2113            }
2114        }
2115
2116        $this->seek($s);
2117
2118        return false;
2119    }
2120
2121    /**
2122     * Parse space separated value list
2123     *
2124     * @param array $out
2125     *
2126     * @return boolean
2127     */
2128    protected function spaceList(&$out)
2129    {
2130        return $this->genericList($out, 'expression');
2131    }
2132
2133    /**
2134     * Parse generic list
2135     *
2136     * @param array   $out
2137     * @param string  $parseItem The name of the method used to parse items
2138     * @param string  $delim
2139     * @param boolean $flatten
2140     *
2141     * @return boolean
2142     */
2143    protected function genericList(&$out, $parseItem, $delim = '', $flatten = true)
2144    {
2145        $s     = $this->count;
2146        $items = [];
2147        $value = null;
2148
2149        while ($this->$parseItem($value)) {
2150            $trailing_delim = false;
2151            $items[] = $value;
2152
2153            if ($delim) {
2154                if (! $this->literal($delim, \strlen($delim))) {
2155                    break;
2156                }
2157
2158                $trailing_delim = true;
2159            } else {
2160                // if no delim watch that a keyword didn't eat the single/double quote
2161                // from the following starting string
2162                if ($value[0] === Type::T_KEYWORD) {
2163                    $word = $value[1];
2164
2165                    $last_char = substr($word, -1);
2166
2167                    if (
2168                        strlen($word) > 1 &&
2169                        in_array($last_char, [ "'", '"']) &&
2170                        substr($word, -2, 1) !== '\\'
2171                    ) {
2172                        // if there is a non escaped opening quote in the keyword, this seems unlikely a mistake
2173                        $word = str_replace('\\' . $last_char, '\\\\', $word);
2174                        if (strpos($word, $last_char) < strlen($word) - 1) {
2175                            continue;
2176                        }
2177
2178                        $currentCount = $this->count;
2179
2180                        // let's try to rewind to previous char and try a parse
2181                        $this->count--;
2182                        // in case the keyword also eat spaces
2183                        while (substr($this->buffer, $this->count, 1) !== $last_char) {
2184                            $this->count--;
2185                        }
2186
2187                        $nextValue = null;
2188                        if ($this->$parseItem($nextValue)) {
2189                            if ($nextValue[0] === Type::T_KEYWORD && $nextValue[1] === $last_char) {
2190                                // bad try, forget it
2191                                $this->seek($currentCount);
2192                                continue;
2193                            }
2194                            if ($nextValue[0] !== Type::T_STRING) {
2195                                // bad try, forget it
2196                                $this->seek($currentCount);
2197                                continue;
2198                            }
2199
2200                            // OK it was a good idea
2201                            $value[1] = substr($value[1], 0, -1);
2202                            array_pop($items);
2203                            $items[] = $value;
2204                            $items[] = $nextValue;
2205                        } else {
2206                            // bad try, forget it
2207                            $this->seek($currentCount);
2208                            continue;
2209                        }
2210                    }
2211                }
2212            }
2213        }
2214
2215        if (! $items) {
2216            $this->seek($s);
2217
2218            return false;
2219        }
2220
2221        if ($trailing_delim) {
2222            $items[] = [Type::T_NULL];
2223        }
2224
2225        if ($flatten && \count($items) === 1) {
2226            $out = $items[0];
2227        } else {
2228            $out = [Type::T_LIST, $delim, $items];
2229        }
2230
2231        return true;
2232    }
2233
2234    /**
2235     * Parse expression
2236     *
2237     * @param array   $out
2238     * @param boolean $listOnly
2239     * @param boolean $lookForExp
2240     *
2241     * @return boolean
2242     */
2243    protected function expression(&$out, $listOnly = false, $lookForExp = true)
2244    {
2245        $s = $this->count;
2246        $discard = $this->discardComments;
2247        $this->discardComments = true;
2248        $allowedTypes = ($listOnly ? [Type::T_LIST] : [Type::T_LIST, Type::T_MAP]);
2249
2250        if ($this->matchChar('(')) {
2251            if ($this->enclosedExpression($lhs, $s, ')', $allowedTypes)) {
2252                if ($lookForExp) {
2253                    $out = $this->expHelper($lhs, 0);
2254                } else {
2255                    $out = $lhs;
2256                }
2257
2258                $this->discardComments = $discard;
2259
2260                return true;
2261            }
2262
2263            $this->seek($s);
2264        }
2265
2266        if (\in_array(Type::T_LIST, $allowedTypes) && $this->matchChar('[')) {
2267            if ($this->enclosedExpression($lhs, $s, ']', [Type::T_LIST])) {
2268                if ($lookForExp) {
2269                    $out = $this->expHelper($lhs, 0);
2270                } else {
2271                    $out = $lhs;
2272                }
2273
2274                $this->discardComments = $discard;
2275
2276                return true;
2277            }
2278
2279            $this->seek($s);
2280        }
2281
2282        if (! $listOnly && $this->value($lhs)) {
2283            if ($lookForExp) {
2284                $out = $this->expHelper($lhs, 0);
2285            } else {
2286                $out = $lhs;
2287            }
2288
2289            $this->discardComments = $discard;
2290
2291            return true;
2292        }
2293
2294        $this->discardComments = $discard;
2295
2296        return false;
2297    }
2298
2299    /**
2300     * Parse expression specifically checking for lists in parenthesis or brackets
2301     *
2302     * @param array   $out
2303     * @param integer $s
2304     * @param string  $closingParen
2305     * @param array   $allowedTypes
2306     *
2307     * @return boolean
2308     */
2309    protected function enclosedExpression(&$out, $s, $closingParen = ')', $allowedTypes = [Type::T_LIST, Type::T_MAP])
2310    {
2311        if ($this->matchChar($closingParen) && \in_array(Type::T_LIST, $allowedTypes)) {
2312            $out = [Type::T_LIST, '', []];
2313
2314            switch ($closingParen) {
2315                case ')':
2316                    $out['enclosing'] = 'parent'; // parenthesis list
2317                    break;
2318
2319                case ']':
2320                    $out['enclosing'] = 'bracket'; // bracketed list
2321                    break;
2322            }
2323
2324            return true;
2325        }
2326
2327        if (
2328            $this->valueList($out) &&
2329            $this->matchChar($closingParen) && ! ($closingParen === ')' &&
2330            \in_array($out[0], [Type::T_EXPRESSION, Type::T_UNARY])) &&
2331            \in_array(Type::T_LIST, $allowedTypes)
2332        ) {
2333            if ($out[0] !== Type::T_LIST || ! empty($out['enclosing'])) {
2334                $out = [Type::T_LIST, '', [$out]];
2335            }
2336
2337            switch ($closingParen) {
2338                case ')':
2339                    $out['enclosing'] = 'parent'; // parenthesis list
2340                    break;
2341
2342                case ']':
2343                    $out['enclosing'] = 'bracket'; // bracketed list
2344                    break;
2345            }
2346
2347            return true;
2348        }
2349
2350        $this->seek($s);
2351
2352        if (\in_array(Type::T_MAP, $allowedTypes) && $this->map($out)) {
2353            return true;
2354        }
2355
2356        return false;
2357    }
2358
2359    /**
2360     * Parse left-hand side of subexpression
2361     *
2362     * @param array   $lhs
2363     * @param integer $minP
2364     *
2365     * @return array
2366     */
2367    protected function expHelper($lhs, $minP)
2368    {
2369        $operators = static::$operatorPattern;
2370
2371        $ss = $this->count;
2372        $whiteBefore = isset($this->buffer[$this->count - 1]) &&
2373            ctype_space($this->buffer[$this->count - 1]);
2374
2375        while ($this->match($operators, $m, false) && static::$precedence[$m[1]] >= $minP) {
2376            $whiteAfter = isset($this->buffer[$this->count]) &&
2377                ctype_space($this->buffer[$this->count]);
2378            $varAfter = isset($this->buffer[$this->count]) &&
2379                $this->buffer[$this->count] === '$';
2380
2381            $this->whitespace();
2382
2383            $op = $m[1];
2384
2385            // don't turn negative numbers into expressions
2386            if ($op === '-' && $whiteBefore && ! $whiteAfter && ! $varAfter) {
2387                break;
2388            }
2389
2390            if (! $this->value($rhs) && ! $this->expression($rhs, true, false)) {
2391                break;
2392            }
2393
2394            if ($op === '-' && ! $whiteAfter && $rhs[0] === Type::T_KEYWORD) {
2395                break;
2396            }
2397
2398            // peek and see if rhs belongs to next operator
2399            if ($this->peek($operators, $next) && static::$precedence[$next[1]] > static::$precedence[$op]) {
2400                $rhs = $this->expHelper($rhs, static::$precedence[$next[1]]);
2401            }
2402
2403            $lhs = [Type::T_EXPRESSION, $op, $lhs, $rhs, $this->inParens, $whiteBefore, $whiteAfter];
2404
2405            $ss = $this->count;
2406            $whiteBefore = isset($this->buffer[$this->count - 1]) &&
2407                ctype_space($this->buffer[$this->count - 1]);
2408        }
2409
2410        $this->seek($ss);
2411
2412        return $lhs;
2413    }
2414
2415    /**
2416     * Parse value
2417     *
2418     * @param array $out
2419     *
2420     * @return boolean
2421     */
2422    protected function value(&$out)
2423    {
2424        if (! isset($this->buffer[$this->count])) {
2425            return false;
2426        }
2427
2428        $s = $this->count;
2429        $char = $this->buffer[$this->count];
2430
2431        if (
2432            $this->literal('url(', 4) &&
2433            $this->match('data:([a-z]+)\/([a-z0-9.+-]+);base64,', $m, false)
2434        ) {
2435            $len = strspn(
2436                $this->buffer,
2437                'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwyxz0123456789+/=',
2438                $this->count
2439            );
2440
2441            $this->count += $len;
2442
2443            if ($this->matchChar(')')) {
2444                $content = substr($this->buffer, $s, $this->count - $s);
2445                $out = [Type::T_KEYWORD, $content];
2446
2447                return true;
2448            }
2449        }
2450
2451        $this->seek($s);
2452
2453        if (
2454            $this->literal('url(', 4, false) &&
2455            $this->match('\s*(\/\/[^\s\)]+)\s*', $m)
2456        ) {
2457            $content = 'url(' . $m[1];
2458
2459            if ($this->matchChar(')')) {
2460                $content .= ')';
2461                $out = [Type::T_KEYWORD, $content];
2462
2463                return true;
2464            }
2465        }
2466
2467        $this->seek($s);
2468
2469        // not
2470        if ($char === 'n' && $this->literal('not', 3, false)) {
2471            if (
2472                $this->whitespace() &&
2473                $this->value($inner)
2474            ) {
2475                $out = [Type::T_UNARY, 'not', $inner, $this->inParens];
2476
2477                return true;
2478            }
2479
2480            $this->seek($s);
2481
2482            if ($this->parenValue($inner)) {
2483                $out = [Type::T_UNARY, 'not', $inner, $this->inParens];
2484
2485                return true;
2486            }
2487
2488            $this->seek($s);
2489        }
2490
2491        // addition
2492        if ($char === '+') {
2493            $this->count++;
2494
2495            $follow_white = $this->whitespace();
2496
2497            if ($this->value($inner)) {
2498                $out = [Type::T_UNARY, '+', $inner, $this->inParens];
2499
2500                return true;
2501            }
2502
2503            if ($follow_white) {
2504                $out = [Type::T_KEYWORD, $char];
2505                return  true;
2506            }
2507
2508            $this->seek($s);
2509
2510            return false;
2511        }
2512
2513        // negation
2514        if ($char === '-') {
2515            if ($this->customProperty($out)) {
2516                return true;
2517            }
2518
2519            $this->count++;
2520
2521            $follow_white = $this->whitespace();
2522
2523            if ($this->variable($inner) || $this->unit($inner) || $this->parenValue($inner)) {
2524                $out = [Type::T_UNARY, '-', $inner, $this->inParens];
2525
2526                return true;
2527            }
2528
2529            if (
2530                $this->keyword($inner) &&
2531                ! $this->func($inner, $out)
2532            ) {
2533                $out = [Type::T_UNARY, '-', $inner, $this->inParens];
2534
2535                return true;
2536            }
2537
2538            if ($follow_white) {
2539                $out = [Type::T_KEYWORD, $char];
2540
2541                return  true;
2542            }
2543
2544            $this->seek($s);
2545        }
2546
2547        // paren
2548        if ($char === '(' && $this->parenValue($out)) {
2549            return true;
2550        }
2551
2552        if ($char === '#') {
2553            if ($this->interpolation($out) || $this->color($out)) {
2554                return true;
2555            }
2556
2557            $this->count++;
2558
2559            if ($this->keyword($keyword)) {
2560                $out = [Type::T_KEYWORD, '#' . $keyword];
2561
2562                return true;
2563            }
2564
2565            $this->count--;
2566        }
2567
2568        if ($this->matchChar('&', true)) {
2569            $out = [Type::T_SELF];
2570
2571            return true;
2572        }
2573
2574        if ($char === '$' && $this->variable($out)) {
2575            return true;
2576        }
2577
2578        if ($char === 'p' && $this->progid($out)) {
2579            return true;
2580        }
2581
2582        if (($char === '"' || $char === "'") && $this->string($out)) {
2583            return true;
2584        }
2585
2586        if ($this->unit($out)) {
2587            return true;
2588        }
2589
2590        // unicode range with wildcards
2591        if (
2592            $this->literal('U+', 2) &&
2593            $this->match('\?+|([0-9A-F]+(\?+|(-[0-9A-F]+))?)', $m, false)
2594        ) {
2595            $unicode = explode('-', $m[0]);
2596            if (strlen(reset($unicode)) <= 6 && strlen(end($unicode)) <= 6) {
2597                $out = [Type::T_KEYWORD, 'U+' . $m[0]];
2598
2599                return true;
2600            }
2601            $this->count -= strlen($m[0]) + 2;
2602        }
2603
2604        if ($this->keyword($keyword, false)) {
2605            if ($this->func($keyword, $out)) {
2606                return true;
2607            }
2608
2609            $this->whitespace();
2610
2611            if ($keyword === 'null') {
2612                $out = [Type::T_NULL];
2613            } else {
2614                $out = [Type::T_KEYWORD, $keyword];
2615            }
2616
2617            return true;
2618        }
2619
2620        return false;
2621    }
2622
2623    /**
2624     * Parse parenthesized value
2625     *
2626     * @param array $out
2627     *
2628     * @return boolean
2629     */
2630    protected function parenValue(&$out)
2631    {
2632        $s = $this->count;
2633
2634        $inParens = $this->inParens;
2635
2636        if ($this->matchChar('(')) {
2637            if ($this->matchChar(')')) {
2638                $out = [Type::T_LIST, '', []];
2639
2640                return true;
2641            }
2642
2643            $this->inParens = true;
2644
2645            if (
2646                $this->expression($exp) &&
2647                $this->matchChar(')')
2648            ) {
2649                $out = $exp;
2650                $this->inParens = $inParens;
2651
2652                return true;
2653            }
2654        }
2655
2656        $this->inParens = $inParens;
2657        $this->seek($s);
2658
2659        return false;
2660    }
2661
2662    /**
2663     * Parse "progid:"
2664     *
2665     * @param array $out
2666     *
2667     * @return boolean
2668     */
2669    protected function progid(&$out)
2670    {
2671        $s = $this->count;
2672
2673        if (
2674            $this->literal('progid:', 7, false) &&
2675            $this->openString('(', $fn) &&
2676            $this->matchChar('(')
2677        ) {
2678            $this->openString(')', $args, '(');
2679
2680            if ($this->matchChar(')')) {
2681                $out = [Type::T_STRING, '', [
2682                    'progid:', $fn, '(', $args, ')'
2683                ]];
2684
2685                return true;
2686            }
2687        }
2688
2689        $this->seek($s);
2690
2691        return false;
2692    }
2693
2694    /**
2695     * Parse function call
2696     *
2697     * @param string $name
2698     * @param array  $func
2699     *
2700     * @return boolean
2701     */
2702    protected function func($name, &$func)
2703    {
2704        $s = $this->count;
2705
2706        if ($this->matchChar('(')) {
2707            if ($name === 'alpha' && $this->argumentList($args)) {
2708                $func = [Type::T_FUNCTION, $name, [Type::T_STRING, '', $args]];
2709
2710                return true;
2711            }
2712
2713            if ($name !== 'expression' && ! preg_match('/^(-[a-z]+-)?calc$/', $name)) {
2714                $ss = $this->count;
2715
2716                if (
2717                    $this->argValues($args) &&
2718                    $this->matchChar(')')
2719                ) {
2720                    $func = [Type::T_FUNCTION_CALL, $name, $args];
2721
2722                    return true;
2723                }
2724
2725                $this->seek($ss);
2726            }
2727
2728            if (
2729                ($this->openString(')', $str, '(') || true) &&
2730                $this->matchChar(')')
2731            ) {
2732                $args = [];
2733
2734                if (! empty($str)) {
2735                    $args[] = [null, [Type::T_STRING, '', [$str]]];
2736                }
2737
2738                $func = [Type::T_FUNCTION_CALL, $name, $args];
2739
2740                return true;
2741            }
2742        }
2743
2744        $this->seek($s);
2745
2746        return false;
2747    }
2748
2749    /**
2750     * Parse function call argument list
2751     *
2752     * @param array $out
2753     *
2754     * @return boolean
2755     */
2756    protected function argumentList(&$out)
2757    {
2758        $s = $this->count;
2759        $this->matchChar('(');
2760
2761        $args = [];
2762
2763        while ($this->keyword($var)) {
2764            if (
2765                $this->matchChar('=') &&
2766                $this->expression($exp)
2767            ) {
2768                $args[] = [Type::T_STRING, '', [$var . '=']];
2769                $arg = $exp;
2770            } else {
2771                break;
2772            }
2773
2774            $args[] = $arg;
2775
2776            if (! $this->matchChar(',')) {
2777                break;
2778            }
2779
2780            $args[] = [Type::T_STRING, '', [', ']];
2781        }
2782
2783        if (! $this->matchChar(')') || ! $args) {
2784            $this->seek($s);
2785
2786            return false;
2787        }
2788
2789        $out = $args;
2790
2791        return true;
2792    }
2793
2794    /**
2795     * Parse mixin/function definition  argument list
2796     *
2797     * @param array $out
2798     *
2799     * @return boolean
2800     */
2801    protected function argumentDef(&$out)
2802    {
2803        $s = $this->count;
2804        $this->matchChar('(');
2805
2806        $args = [];
2807
2808        while ($this->variable($var)) {
2809            $arg = [$var[1], null, false];
2810
2811            $ss = $this->count;
2812
2813            if (
2814                $this->matchChar(':') &&
2815                $this->genericList($defaultVal, 'expression', '', true)
2816            ) {
2817                $arg[1] = $defaultVal;
2818            } else {
2819                $this->seek($ss);
2820            }
2821
2822            $ss = $this->count;
2823
2824            if ($this->literal('...', 3)) {
2825                $sss = $this->count;
2826
2827                if (! $this->matchChar(')')) {
2828                    throw $this->parseError('... has to be after the final argument');
2829                }
2830
2831                $arg[2] = true;
2832
2833                $this->seek($sss);
2834            } else {
2835                $this->seek($ss);
2836            }
2837
2838            $args[] = $arg;
2839
2840            if (! $this->matchChar(',')) {
2841                break;
2842            }
2843        }
2844
2845        if (! $this->matchChar(')')) {
2846            $this->seek($s);
2847
2848            return false;
2849        }
2850
2851        $out = $args;
2852
2853        return true;
2854    }
2855
2856    /**
2857     * Parse map
2858     *
2859     * @param array $out
2860     *
2861     * @return boolean
2862     */
2863    protected function map(&$out)
2864    {
2865        $s = $this->count;
2866
2867        if (! $this->matchChar('(')) {
2868            return false;
2869        }
2870
2871        $keys = [];
2872        $values = [];
2873
2874        while (
2875            $this->genericList($key, 'expression', '', true) &&
2876            $this->matchChar(':') &&
2877            $this->genericList($value, 'expression', '', true)
2878        ) {
2879            $keys[] = $key;
2880            $values[] = $value;
2881
2882            if (! $this->matchChar(',')) {
2883                break;
2884            }
2885        }
2886
2887        if (! $keys || ! $this->matchChar(')')) {
2888            $this->seek($s);
2889
2890            return false;
2891        }
2892
2893        $out = [Type::T_MAP, $keys, $values];
2894
2895        return true;
2896    }
2897
2898    /**
2899     * Parse color
2900     *
2901     * @param array $out
2902     *
2903     * @return boolean
2904     */
2905    protected function color(&$out)
2906    {
2907        $s = $this->count;
2908
2909        if ($this->match('(#([0-9a-f]+)\b)', $m)) {
2910            if (\in_array(\strlen($m[2]), [3,4,6,8])) {
2911                $out = [Type::T_KEYWORD, $m[0]];
2912
2913                return true;
2914            }
2915
2916            $this->seek($s);
2917
2918            return false;
2919        }
2920
2921        return false;
2922    }
2923
2924    /**
2925     * Parse number with unit
2926     *
2927     * @param array $unit
2928     *
2929     * @return boolean
2930     */
2931    protected function unit(&$unit)
2932    {
2933        $s = $this->count;
2934
2935        if ($this->match('([0-9]*(\.)?[0-9]+)([%a-zA-Z]+)?', $m, false)) {
2936            if (\strlen($this->buffer) === $this->count || ! ctype_digit($this->buffer[$this->count])) {
2937                $this->whitespace();
2938
2939                $unit = new Node\Number($m[1], empty($m[3]) ? '' : $m[3]);
2940
2941                return true;
2942            }
2943
2944            $this->seek($s);
2945        }
2946
2947        return false;
2948    }
2949
2950    /**
2951     * Parse string
2952     *
2953     * @param array $out
2954     *
2955     * @return boolean
2956     */
2957    protected function string(&$out, $keepDelimWithInterpolation = false)
2958    {
2959        $s = $this->count;
2960
2961        if ($this->matchChar('"', false)) {
2962            $delim = '"';
2963        } elseif ($this->matchChar("'", false)) {
2964            $delim = "'";
2965        } else {
2966            return false;
2967        }
2968
2969        $content = [];
2970        $oldWhite = $this->eatWhiteDefault;
2971        $this->eatWhiteDefault = false;
2972        $hasInterpolation = false;
2973
2974        while ($this->matchString($m, $delim)) {
2975            if ($m[1] !== '') {
2976                $content[] = $m[1];
2977            }
2978
2979            if ($m[2] === '#{') {
2980                $this->count -= \strlen($m[2]);
2981
2982                if ($this->interpolation($inter, false)) {
2983                    $content[] = $inter;
2984                    $hasInterpolation = true;
2985                } else {
2986                    $this->count += \strlen($m[2]);
2987                    $content[] = '#{'; // ignore it
2988                }
2989            } elseif ($m[2] === "\r") {
2990                $content[] = chr(10);
2991                // TODO : warning
2992                # DEPRECATION WARNING on line x, column y of zzz:
2993                # Unescaped multiline strings are deprecated and will be removed in a future version of Sass.
2994                # To include a newline in a string, use "\a" or "\a " as in CSS.
2995                if ($this->matchChar("\n", false)) {
2996                    $content[] = ' ';
2997                }
2998            } elseif ($m[2] === '\\') {
2999                if (
3000                    $this->literal("\r\n", 2, false) ||
3001                    $this->matchChar("\r", false) ||
3002                    $this->matchChar("\n", false) ||
3003                    $this->matchChar("\f", false)
3004                ) {
3005                    // this is a continuation escaping, to be ignored
3006                } elseif ($this->matchEscapeCharacter($c)) {
3007                    $content[] = $c;
3008                } else {
3009                    throw $this->parseError('Unterminated escape sequence');
3010                }
3011            } else {
3012                $this->count -= \strlen($delim);
3013                break; // delim
3014            }
3015        }
3016
3017        $this->eatWhiteDefault = $oldWhite;
3018
3019        if ($this->literal($delim, \strlen($delim))) {
3020            if ($hasInterpolation && ! $keepDelimWithInterpolation) {
3021                $delim = '"';
3022            }
3023
3024            $out = [Type::T_STRING, $delim, $content];
3025
3026            return true;
3027        }
3028
3029        $this->seek($s);
3030
3031        return false;
3032    }
3033
3034    /**
3035     * @param string $out
3036     * @param bool $inKeywords
3037     * @return bool
3038     */
3039    protected function matchEscapeCharacter(&$out, $inKeywords = false)
3040    {
3041        $s = $this->count;
3042        if ($this->match('[a-f0-9]', $m, false)) {
3043            $hex = $m[0];
3044
3045            for ($i = 5; $i--;) {
3046                if ($this->match('[a-f0-9]', $m, false)) {
3047                    $hex .= $m[0];
3048                } else {
3049                    break;
3050                }
3051            }
3052
3053            // CSS allows Unicode escape sequences to be followed by a delimiter space
3054            // (necessary in some cases for shorter sequences to disambiguate their end)
3055            $this->matchChar(' ', false);
3056
3057            $value = hexdec($hex);
3058
3059            if (!$inKeywords && ($value == 0 || ($value >= 0xD800 && $value <= 0xDFFF) || $value >= 0x10FFFF)) {
3060                $out = "\xEF\xBF\xBD"; // "\u{FFFD}" but with a syntax supported on PHP 5
3061            } elseif ($value < 0x20) {
3062                $out = Util::mbChr($value);
3063            } else {
3064                $out = Util::mbChr($value);
3065            }
3066
3067            return true;
3068        }
3069
3070        if ($this->match('.', $m, false)) {
3071            if ($inKeywords && in_array($m[0], ["'",'"','@','&',' ','\\',':','/','%'])) {
3072                $this->seek($s);
3073                return false;
3074            }
3075            $out = $m[0];
3076
3077            return true;
3078        }
3079
3080        return false;
3081    }
3082
3083    /**
3084     * Parse keyword or interpolation
3085     *
3086     * @param array   $out
3087     * @param boolean $restricted
3088     *
3089     * @return boolean
3090     */
3091    protected function mixedKeyword(&$out, $restricted = false)
3092    {
3093        $parts = [];
3094
3095        $oldWhite = $this->eatWhiteDefault;
3096        $this->eatWhiteDefault = false;
3097
3098        for (;;) {
3099            if ($restricted ? $this->restrictedKeyword($key) : $this->keyword($key)) {
3100                $parts[] = $key;
3101                continue;
3102            }
3103
3104            if ($this->interpolation($inter)) {
3105                $parts[] = $inter;
3106                continue;
3107            }
3108
3109            break;
3110        }
3111
3112        $this->eatWhiteDefault = $oldWhite;
3113
3114        if (! $parts) {
3115            return false;
3116        }
3117
3118        if ($this->eatWhiteDefault) {
3119            $this->whitespace();
3120        }
3121
3122        $out = $parts;
3123
3124        return true;
3125    }
3126
3127    /**
3128     * Parse an unbounded string stopped by $end
3129     *
3130     * @param string  $end
3131     * @param array   $out
3132     * @param string  $nestOpen
3133     * @param string  $nestClose
3134     * @param boolean $rtrim
3135     * @param string $disallow
3136     *
3137     * @return boolean
3138     */
3139    protected function openString($end, &$out, $nestOpen = null, $nestClose = null, $rtrim = true, $disallow = null)
3140    {
3141        $oldWhite = $this->eatWhiteDefault;
3142        $this->eatWhiteDefault = false;
3143
3144        if ($nestOpen && ! $nestClose) {
3145            $nestClose = $end;
3146        }
3147
3148        $patt = ($disallow ? '[^' . $this->pregQuote($disallow) . ']' : '.');
3149        $patt = '(' . $patt . '*?)([\'"]|#\{|'
3150            . $this->pregQuote($end) . '|'
3151            . (($nestClose && $nestClose !== $end) ? $this->pregQuote($nestClose) . '|' : '')
3152            . static::$commentPattern . ')';
3153
3154        $nestingLevel = 0;
3155
3156        $content = [];
3157
3158        while ($this->match($patt, $m, false)) {
3159            if (isset($m[1]) && $m[1] !== '') {
3160                $content[] = $m[1];
3161
3162                if ($nestOpen) {
3163                    $nestingLevel += substr_count($m[1], $nestOpen);
3164                }
3165            }
3166
3167            $tok = $m[2];
3168
3169            $this->count -= \strlen($tok);
3170
3171            if ($tok === $end && ! $nestingLevel) {
3172                break;
3173            }
3174
3175            if ($tok === $nestClose) {
3176                $nestingLevel--;
3177            }
3178
3179            if (($tok === "'" || $tok === '"') && $this->string($str, true)) {
3180                $content[] = $str;
3181                continue;
3182            }
3183
3184            if ($tok === '#{' && $this->interpolation($inter)) {
3185                $content[] = $inter;
3186                continue;
3187            }
3188
3189            $content[] = $tok;
3190            $this->count += \strlen($tok);
3191        }
3192
3193        $this->eatWhiteDefault = $oldWhite;
3194
3195        if (! $content || $tok !== $end) {
3196            return false;
3197        }
3198
3199        // trim the end
3200        if ($rtrim && \is_string(end($content))) {
3201            $content[\count($content) - 1] = rtrim(end($content));
3202        }
3203
3204        $out = [Type::T_STRING, '', $content];
3205
3206        return true;
3207    }
3208
3209    /**
3210     * Parser interpolation
3211     *
3212     * @param string|array $out
3213     * @param boolean      $lookWhite save information about whitespace before and after
3214     *
3215     * @return boolean
3216     */
3217    protected function interpolation(&$out, $lookWhite = true)
3218    {
3219        $oldWhite = $this->eatWhiteDefault;
3220        $allowVars = $this->allowVars;
3221        $this->allowVars = true;
3222        $this->eatWhiteDefault = true;
3223
3224        $s = $this->count;
3225
3226        if (
3227            $this->literal('#{', 2) &&
3228            $this->valueList($value) &&
3229            $this->matchChar('}', false)
3230        ) {
3231            if ($value === [Type::T_SELF]) {
3232                $out = $value;
3233            } else {
3234                if ($lookWhite) {
3235                    $left = ($s > 0 && preg_match('/\s/', $this->buffer[$s - 1])) ? ' ' : '';
3236                    $right = (
3237                        ! empty($this->buffer[$this->count]) &&
3238                        preg_match('/\s/', $this->buffer[$this->count])
3239                    ) ? ' ' : '';
3240                } else {
3241                    $left = $right = false;
3242                }
3243
3244                $out = [Type::T_INTERPOLATE, $value, $left, $right];
3245            }
3246
3247            $this->eatWhiteDefault = $oldWhite;
3248            $this->allowVars = $allowVars;
3249
3250            if ($this->eatWhiteDefault) {
3251                $this->whitespace();
3252            }
3253
3254            return true;
3255        }
3256
3257        $this->seek($s);
3258
3259        $this->eatWhiteDefault = $oldWhite;
3260        $this->allowVars = $allowVars;
3261
3262        return false;
3263    }
3264
3265    /**
3266     * Parse property name (as an array of parts or a string)
3267     *
3268     * @param array $out
3269     *
3270     * @return boolean
3271     */
3272    protected function propertyName(&$out)
3273    {
3274        $parts = [];
3275
3276        $oldWhite = $this->eatWhiteDefault;
3277        $this->eatWhiteDefault = false;
3278
3279        for (;;) {
3280            if ($this->interpolation($inter)) {
3281                $parts[] = $inter;
3282                continue;
3283            }
3284
3285            if ($this->keyword($text)) {
3286                $parts[] = $text;
3287                continue;
3288            }
3289
3290            if (! $parts && $this->match('[:.#]', $m, false)) {
3291                // css hacks
3292                $parts[] = $m[0];
3293                continue;
3294            }
3295
3296            break;
3297        }
3298
3299        $this->eatWhiteDefault = $oldWhite;
3300
3301        if (! $parts) {
3302            return false;
3303        }
3304
3305        // match comment hack
3306        if (preg_match(static::$whitePattern, $this->buffer, $m, null, $this->count)) {
3307            if (! empty($m[0])) {
3308                $parts[] = $m[0];
3309                $this->count += \strlen($m[0]);
3310            }
3311        }
3312
3313        $this->whitespace(); // get any extra whitespace
3314
3315        $out = [Type::T_STRING, '', $parts];
3316
3317        return true;
3318    }
3319
3320    /**
3321     * Parse custom property name (as an array of parts or a string)
3322     *
3323     * @param array $out
3324     *
3325     * @return boolean
3326     */
3327    protected function customProperty(&$out)
3328    {
3329        $s = $this->count;
3330
3331        if (! $this->literal('--', 2, false)) {
3332            return false;
3333        }
3334
3335        $parts = ['--'];
3336
3337        $oldWhite = $this->eatWhiteDefault;
3338        $this->eatWhiteDefault = false;
3339
3340        for (;;) {
3341            if ($this->interpolation($inter)) {
3342                $parts[] = $inter;
3343                continue;
3344            }
3345
3346            if ($this->matchChar('&', false)) {
3347                $parts[] = [Type::T_SELF];
3348                continue;
3349            }
3350
3351            if ($this->variable($var)) {
3352                $parts[] = $var;
3353                continue;
3354            }
3355
3356            if ($this->keyword($text)) {
3357                $parts[] = $text;
3358                continue;
3359            }
3360
3361            break;
3362        }
3363
3364        $this->eatWhiteDefault = $oldWhite;
3365
3366        if (\count($parts) == 1) {
3367            $this->seek($s);
3368
3369            return false;
3370        }
3371
3372        $this->whitespace(); // get any extra whitespace
3373
3374        $out = [Type::T_STRING, '', $parts];
3375
3376        return true;
3377    }
3378
3379    /**
3380     * Parse comma separated selector list
3381     *
3382     * @param array $out
3383     * @param string|boolean $subSelector
3384     *
3385     * @return boolean
3386     */
3387    protected function selectors(&$out, $subSelector = false)
3388    {
3389        $s = $this->count;
3390        $selectors = [];
3391
3392        while ($this->selector($sel, $subSelector)) {
3393            $selectors[] = $sel;
3394
3395            if (! $this->matchChar(',', true)) {
3396                break;
3397            }
3398
3399            while ($this->matchChar(',', true)) {
3400                ; // ignore extra
3401            }
3402        }
3403
3404        if (! $selectors) {
3405            $this->seek($s);
3406
3407            return false;
3408        }
3409
3410        $out = $selectors;
3411
3412        return true;
3413    }
3414
3415    /**
3416     * Parse whitespace separated selector list
3417     *
3418     * @param array          $out
3419     * @param string|boolean $subSelector
3420     *
3421     * @return boolean
3422     */
3423    protected function selector(&$out, $subSelector = false)
3424    {
3425        $selector = [];
3426
3427        $discardComments = $this->discardComments;
3428        $this->discardComments = true;
3429
3430        for (;;) {
3431            $s = $this->count;
3432
3433            if ($this->match('[>+~]+', $m, true)) {
3434                if (
3435                    $subSelector && \is_string($subSelector) && strpos($subSelector, 'nth-') === 0 &&
3436                    $m[0] === '+' && $this->match("(\d+|n\b)", $counter)
3437                ) {
3438                    $this->seek($s);
3439                } else {
3440                    $selector[] = [$m[0]];
3441                    continue;
3442                }
3443            }
3444
3445            if ($this->selectorSingle($part, $subSelector)) {
3446                $selector[] = $part;
3447                $this->whitespace();
3448                continue;
3449            }
3450
3451            break;
3452        }
3453
3454        $this->discardComments = $discardComments;
3455
3456        if (! $selector) {
3457            return false;
3458        }
3459
3460        $out = $selector;
3461
3462        return true;
3463    }
3464
3465    /**
3466     * parsing escaped chars in selectors:
3467     * - escaped single chars are kept escaped in the selector but in a normalized form
3468     *   (if not in 0-9a-f range as this would be ambigous)
3469     * - other escaped sequences (multibyte chars or 0-9a-f) are kept in their initial escaped form,
3470     *   normalized to lowercase
3471     *
3472     * TODO: this is a fallback solution. Ideally escaped chars in selectors should be encoded as the genuine chars,
3473     * and escaping added when printing in the Compiler, where/if it's mandatory
3474     * - but this require a better formal selector representation instead of the array we have now
3475     *
3476     * @param string $out
3477     * @param bool $keepEscapedNumber
3478     * @return bool
3479     */
3480    protected function matchEscapeCharacterInSelector(&$out, $keepEscapedNumber = false)
3481    {
3482        $s_escape = $this->count;
3483        if ($this->match('\\\\', $m)) {
3484            $out = '\\' . $m[0];
3485            return true;
3486        }
3487
3488        if ($this->matchEscapeCharacter($escapedout, true)) {
3489            if (strlen($escapedout) === 1) {
3490                if (!preg_match(",\w,", $escapedout)) {
3491                    $out = '\\' . $escapedout;
3492                    return true;
3493                } elseif (! $keepEscapedNumber || ! \is_numeric($escapedout)) {
3494                    $out = $escapedout;
3495                    return true;
3496                }
3497            }
3498            $escape_sequence = rtrim(substr($this->buffer, $s_escape, $this->count - $s_escape));
3499            if (strlen($escape_sequence) < 6) {
3500                $escape_sequence .= ' ';
3501            }
3502            $out = '\\' . strtolower($escape_sequence);
3503            return true;
3504        }
3505        if ($this->match('\\S', $m)) {
3506            $out = '\\' . $m[0];
3507            return true;
3508        }
3509
3510
3511        return false;
3512    }
3513
3514    /**
3515     * Parse the parts that make up a selector
3516     *
3517     * {@internal
3518     *     div[yes=no]#something.hello.world:nth-child(-2n+1)%placeholder
3519     * }}
3520     *
3521     * @param array          $out
3522     * @param string|boolean $subSelector
3523     *
3524     * @return boolean
3525     */
3526    protected function selectorSingle(&$out, $subSelector = false)
3527    {
3528        $oldWhite = $this->eatWhiteDefault;
3529        $this->eatWhiteDefault = false;
3530
3531        $parts = [];
3532
3533        if ($this->matchChar('*', false)) {
3534            $parts[] = '*';
3535        }
3536
3537        for (;;) {
3538            if (! isset($this->buffer[$this->count])) {
3539                break;
3540            }
3541
3542            $s = $this->count;
3543            $char = $this->buffer[$this->count];
3544
3545            // see if we can stop early
3546            if ($char === '{' || $char === ',' || $char === ';' || $char === '}' || $char === '@') {
3547                break;
3548            }
3549
3550            // parsing a sub selector in () stop with the closing )
3551            if ($subSelector && $char === ')') {
3552                break;
3553            }
3554
3555            //self
3556            switch ($char) {
3557                case '&':
3558                    $parts[] = Compiler::$selfSelector;
3559                    $this->count++;
3560                    ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
3561                    continue 2;
3562
3563                case '.':
3564                    $parts[] = '.';
3565                    $this->count++;
3566                    continue 2;
3567
3568                case '|':
3569                    $parts[] = '|';
3570                    $this->count++;
3571                    continue 2;
3572            }
3573
3574            // handling of escaping in selectors : get the escaped char
3575            if ($char === '\\') {
3576                $this->count++;
3577                if ($this->matchEscapeCharacterInSelector($escaped, true)) {
3578                    $parts[] = $escaped;
3579                    continue;
3580                }
3581                $this->count--;
3582            }
3583
3584            if ($char === '%') {
3585                $this->count++;
3586
3587                if ($this->placeholder($placeholder)) {
3588                    $parts[] = '%';
3589                    $parts[] = $placeholder;
3590                    ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
3591                    continue;
3592                }
3593
3594                break;
3595            }
3596
3597            if ($char === '#') {
3598                if ($this->interpolation($inter)) {
3599                    $parts[] = $inter;
3600                    ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
3601                    continue;
3602                }
3603
3604                $parts[] = '#';
3605                $this->count++;
3606                continue;
3607            }
3608
3609            // a pseudo selector
3610            if ($char === ':') {
3611                if ($this->buffer[$this->count + 1] === ':') {
3612                    $this->count += 2;
3613                    $part = '::';
3614                } else {
3615                    $this->count++;
3616                    $part = ':';
3617                }
3618
3619                if ($this->mixedKeyword($nameParts, true)) {
3620                    $parts[] = $part;
3621
3622                    foreach ($nameParts as $sub) {
3623                        $parts[] = $sub;
3624                    }
3625
3626                    $ss = $this->count;
3627
3628                    if (
3629                        $nameParts === ['not'] ||
3630                        $nameParts === ['is'] ||
3631                        $nameParts === ['has'] ||
3632                        $nameParts === ['where'] ||
3633                        $nameParts === ['slotted'] ||
3634                        $nameParts === ['nth-child'] ||
3635                        $nameParts === ['nth-last-child'] ||
3636                        $nameParts === ['nth-of-type'] ||
3637                        $nameParts === ['nth-last-of-type']
3638                    ) {
3639                        if (
3640                            $this->matchChar('(', true) &&
3641                            ($this->selectors($subs, reset($nameParts)) || true) &&
3642                            $this->matchChar(')')
3643                        ) {
3644                            $parts[] = '(';
3645
3646                            while ($sub = array_shift($subs)) {
3647                                while ($ps = array_shift($sub)) {
3648                                    foreach ($ps as &$p) {
3649                                        $parts[] = $p;
3650                                    }
3651
3652                                    if (\count($sub) && reset($sub)) {
3653                                        $parts[] = ' ';
3654                                    }
3655                                }
3656
3657                                if (\count($subs) && reset($subs)) {
3658                                    $parts[] = ', ';
3659                                }
3660                            }
3661
3662                            $parts[] = ')';
3663                        } else {
3664                            $this->seek($ss);
3665                        }
3666                    } elseif (
3667                        $this->matchChar('(', true) &&
3668                        ($this->openString(')', $str, '(') || true) &&
3669                        $this->matchChar(')')
3670                    ) {
3671                        $parts[] = '(';
3672
3673                        if (! empty($str)) {
3674                            $parts[] = $str;
3675                        }
3676
3677                        $parts[] = ')';
3678                    } else {
3679                        $this->seek($ss);
3680                    }
3681
3682                    continue;
3683                }
3684            }
3685
3686            $this->seek($s);
3687
3688            // 2n+1
3689            if ($subSelector && \is_string($subSelector) && strpos($subSelector, 'nth-') === 0) {
3690                if ($this->match("(\s*(\+\s*|\-\s*)?(\d+|n|\d+n))+", $counter)) {
3691                    $parts[] = $counter[0];
3692                    //$parts[] = str_replace(' ', '', $counter[0]);
3693                    continue;
3694                }
3695            }
3696
3697            $this->seek($s);
3698
3699            // attribute selector
3700            if (
3701                $char === '[' &&
3702                $this->matchChar('[') &&
3703                ($this->openString(']', $str, '[') || true) &&
3704                $this->matchChar(']')
3705            ) {
3706                $parts[] = '[';
3707
3708                if (! empty($str)) {
3709                    $parts[] = $str;
3710                }
3711
3712                $parts[] = ']';
3713                continue;
3714            }
3715
3716            $this->seek($s);
3717
3718            // for keyframes
3719            if ($this->unit($unit)) {
3720                $parts[] = $unit;
3721                continue;
3722            }
3723
3724            if ($this->restrictedKeyword($name, false, true)) {
3725                $parts[] = $name;
3726                continue;
3727            }
3728
3729            break;
3730        }
3731
3732        $this->eatWhiteDefault = $oldWhite;
3733
3734        if (! $parts) {
3735            return false;
3736        }
3737
3738        $out = $parts;
3739
3740        return true;
3741    }
3742
3743    /**
3744     * Parse a variable
3745     *
3746     * @param array $out
3747     *
3748     * @return boolean
3749     */
3750    protected function variable(&$out)
3751    {
3752        $s = $this->count;
3753
3754        if (
3755            $this->matchChar('$', false) &&
3756            $this->keyword($name)
3757        ) {
3758            if ($this->allowVars) {
3759                $out = [Type::T_VARIABLE, $name];
3760            } else {
3761                $out = [Type::T_KEYWORD, '$' . $name];
3762            }
3763
3764            return true;
3765        }
3766
3767        $this->seek($s);
3768
3769        return false;
3770    }
3771
3772    /**
3773     * Parse a keyword
3774     *
3775     * @param string  $word
3776     * @param boolean $eatWhitespace
3777     * @param boolean $inSelector
3778     *
3779     * @return boolean
3780     */
3781    protected function keyword(&$word, $eatWhitespace = null, $inSelector = false)
3782    {
3783        $s = $this->count;
3784        $match = $this->match(
3785            $this->utf8
3786                ? '(([\pL\w\x{00A0}-\x{10FFFF}_\-\*!"\']|\\\\[a-f0-9]{6} ?|\\\\[a-f0-9]{1,5}(?![a-f0-9]) ?|[\\\\].)([\pL\w\x{00A0}-\x{10FFFF}\-_"\']|\\\\[a-f0-9]{6} ?|\\\\[a-f0-9]{1,5}(?![a-f0-9]) ?|[\\\\].)*)'
3787                : '(([\w_\-\*!"\']|\\\\[a-f0-9]{6} ?|\\\\[a-f0-9]{1,5}(?![a-f0-9]) ?|[\\\\].)([\w\-_"\']|\\\\[a-f0-9]{6} ?|\\\\[a-f0-9]{1,5}(?![a-f0-9]) ?|[\\\\].)*)',
3788            $m,
3789            false
3790        );
3791
3792        if ($match) {
3793            $word = $m[1];
3794
3795            // handling of escaping in keyword : get the escaped char
3796            if (strpos($word, '\\') !== false) {
3797                $send = $this->count;
3798                $escapedWord = [];
3799                $this->seek($s);
3800                $previousEscape = false;
3801                while ($this->count < $send) {
3802                    $char = $this->buffer[$this->count];
3803                    $this->count++;
3804                    if (
3805                        $this->count < $send
3806                        && $char === '\\'
3807                        && !$previousEscape
3808                        && (
3809                            $inSelector ?
3810                                $this->matchEscapeCharacterInSelector($out)
3811                                :
3812                                $this->matchEscapeCharacter($out, true)
3813                        )
3814                    ) {
3815                        $escapedWord[] = $out;
3816                    } else {
3817                        if ($previousEscape) {
3818                            $previousEscape = false;
3819                        } elseif ($char === '\\') {
3820                            $previousEscape = true;
3821                        }
3822                        $escapedWord[] = $char;
3823                    }
3824                }
3825
3826                $word = implode('', $escapedWord);
3827            }
3828
3829            if (is_null($eatWhitespace) ? $this->eatWhiteDefault : $eatWhitespace) {
3830                $this->whitespace();
3831            }
3832
3833            return true;
3834        }
3835
3836        return false;
3837    }
3838
3839    /**
3840     * Parse a keyword that should not start with a number
3841     *
3842     * @param string  $word
3843     * @param boolean $eatWhitespace
3844     * @param boolean $inSelector
3845     *
3846     * @return boolean
3847     */
3848    protected function restrictedKeyword(&$word, $eatWhitespace = null, $inSelector = false)
3849    {
3850        $s = $this->count;
3851
3852        if ($this->keyword($word, $eatWhitespace, $inSelector) && (\ord($word[0]) > 57 || \ord($word[0]) < 48)) {
3853            return true;
3854        }
3855
3856        $this->seek($s);
3857
3858        return false;
3859    }
3860
3861    /**
3862     * Parse a placeholder
3863     *
3864     * @param string|array $placeholder
3865     *
3866     * @return boolean
3867     */
3868    protected function placeholder(&$placeholder)
3869    {
3870        $match = $this->match(
3871            $this->utf8
3872                ? '([\pL\w\-_]+)'
3873                : '([\w\-_]+)',
3874            $m
3875        );
3876
3877        if ($match) {
3878            $placeholder = $m[1];
3879
3880            return true;
3881        }
3882
3883        if ($this->interpolation($placeholder)) {
3884            return true;
3885        }
3886
3887        return false;
3888    }
3889
3890    /**
3891     * Parse a url
3892     *
3893     * @param array $out
3894     *
3895     * @return boolean
3896     */
3897    protected function url(&$out)
3898    {
3899        if ($this->literal('url(', 4)) {
3900            $s = $this->count;
3901
3902            if (
3903                ($this->string($out) || $this->spaceList($out)) &&
3904                $this->matchChar(')')
3905            ) {
3906                $out = [Type::T_STRING, '', ['url(', $out, ')']];
3907
3908                return true;
3909            }
3910
3911            $this->seek($s);
3912
3913            if (
3914                $this->openString(')', $out) &&
3915                $this->matchChar(')')
3916            ) {
3917                $out = [Type::T_STRING, '', ['url(', $out, ')']];
3918
3919                return true;
3920            }
3921        }
3922
3923        return false;
3924    }
3925
3926    /**
3927     * Consume an end of statement delimiter
3928     * @param bool $eatWhitespace
3929     *
3930     * @return boolean
3931     */
3932    protected function end($eatWhitespace = null)
3933    {
3934        if ($this->matchChar(';', $eatWhitespace)) {
3935            return true;
3936        }
3937
3938        if ($this->count === \strlen($this->buffer) || $this->buffer[$this->count] === '}') {
3939            // if there is end of file or a closing block next then we don't need a ;
3940            return true;
3941        }
3942
3943        return false;
3944    }
3945
3946    /**
3947     * Strip assignment flag from the list
3948     *
3949     * @param array $value
3950     *
3951     * @return array
3952     */
3953    protected function stripAssignmentFlags(&$value)
3954    {
3955        $flags = [];
3956
3957        for ($token = &$value; $token[0] === Type::T_LIST && ($s = \count($token[2])); $token = &$lastNode) {
3958            $lastNode = &$token[2][$s - 1];
3959
3960            while ($lastNode[0] === Type::T_KEYWORD && \in_array($lastNode[1], ['!default', '!global'])) {
3961                array_pop($token[2]);
3962
3963                $node     = end($token[2]);
3964                $token    = $this->flattenList($token);
3965                $flags[]  = $lastNode[1];
3966                $lastNode = $node;
3967            }
3968        }
3969
3970        return $flags;
3971    }
3972
3973    /**
3974     * Strip optional flag from selector list
3975     *
3976     * @param array $selectors
3977     *
3978     * @return string
3979     */
3980    protected function stripOptionalFlag(&$selectors)
3981    {
3982        $optional = false;
3983        $selector = end($selectors);
3984        $part     = end($selector);
3985
3986        if ($part === ['!optional']) {
3987            array_pop($selectors[\count($selectors) - 1]);
3988
3989            $optional = true;
3990        }
3991
3992        return $optional;
3993    }
3994
3995    /**
3996     * Turn list of length 1 into value type
3997     *
3998     * @param array $value
3999     *
4000     * @return array
4001     */
4002    protected function flattenList($value)
4003    {
4004        if ($value[0] === Type::T_LIST && \count($value[2]) === 1) {
4005            return $this->flattenList($value[2][0]);
4006        }
4007
4008        return $value;
4009    }
4010
4011    /**
4012     * Quote regular expression
4013     *
4014     * @param string $what
4015     *
4016     * @return string
4017     */
4018    private function pregQuote($what)
4019    {
4020        return preg_quote($what, '/');
4021    }
4022
4023    /**
4024     * Extract line numbers from buffer
4025     *
4026     * @param string $buffer
4027     */
4028    private function extractLineNumbers($buffer)
4029    {
4030        $this->sourcePositions = [0 => 0];
4031        $prev = 0;
4032
4033        while (($pos = strpos($buffer, "\n", $prev)) !== false) {
4034            $this->sourcePositions[] = $pos;
4035            $prev = $pos + 1;
4036        }
4037
4038        $this->sourcePositions[] = \strlen($buffer);
4039
4040        if (substr($buffer, -1) !== "\n") {
4041            $this->sourcePositions[] = \strlen($buffer) + 1;
4042        }
4043    }
4044
4045    /**
4046     * Get source line number and column (given character position in the buffer)
4047     *
4048     * @param integer $pos
4049     *
4050     * @return array
4051     */
4052    private function getSourcePosition($pos)
4053    {
4054        $low = 0;
4055        $high = \count($this->sourcePositions);
4056
4057        while ($low < $high) {
4058            $mid = (int) (($high + $low) / 2);
4059
4060            if ($pos < $this->sourcePositions[$mid]) {
4061                $high = $mid - 1;
4062                continue;
4063            }
4064
4065            if ($pos >= $this->sourcePositions[$mid + 1]) {
4066                $low = $mid + 1;
4067                continue;
4068            }
4069
4070            return [$mid + 1, $pos - $this->sourcePositions[$mid]];
4071        }
4072
4073        return [$low + 1, $pos - $this->sourcePositions[$low]];
4074    }
4075
4076    /**
4077     * Save internal encoding
4078     */
4079    private function saveEncoding()
4080    {
4081        if (\extension_loaded('mbstring')) {
4082            $this->encoding = mb_internal_encoding();
4083
4084            mb_internal_encoding('iso-8859-1');
4085        }
4086    }
4087
4088    /**
4089     * Restore internal encoding
4090     */
4091    private function restoreEncoding()
4092    {
4093        if (\extension_loaded('mbstring') && $this->encoding) {
4094            mb_internal_encoding($this->encoding);
4095        }
4096    }
4097}
4098