Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

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