Completed
Push — more-codesniff ( f563eb )
by Marcus
01:57
created

Parser::to()   B

Complexity

Conditions 5
Paths 1

Size

Total Lines 18
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
dl 0
loc 18
ccs 0
cts 2
cp 0
rs 8.8571
c 0
b 0
f 0
cc 5
eloc 12
nc 1
nop 4
crap 30
1
<?php
2
3
namespace LesserPhp;
4
5
use LesserPhp\Exception\GeneralException;
6
7
/**
8
 * lesserphp
9
 * https://www.maswaba.de/lesserphp
10
 *
11
 * LESS CSS compiler, adapted from http://lesscss.org
12
 *
13
 * Copyright 2013, Leaf Corcoran <[email protected]>
14
 * Copyright 2016, Marcus Schwarz <[email protected]>
15
 * Licensed under MIT or GPLv3, see LICENSE
16
 * @package LesserPhp
17
 * // responsible for taking a string of LESS code and converting it into a
18
 * // syntax tree
19
 */
20
class Parser
21
{
22
    protected static $nextBlockId = 0; // used to uniquely identify blocks
23
24
    protected static $precedence = [
25
        '=<' => 0,
26
        '>=' => 0,
27
        '=' => 0,
28
        '<' => 0,
29
        '>' => 0,
30
31
        '+' => 1,
32
        '-' => 1,
33
        '*' => 2,
34
        '/' => 2,
35
        '%' => 2,
36
    ];
37
38
    protected static $whitePattern;
39
    protected static $commentMulti;
40
41
    protected static $commentSingle = '//';
42
    protected static $commentMultiLeft = '/*';
43
    protected static $commentMultiRight = '*/';
44
45
    // regex string to match any of the operators
46
    protected static $operatorString;
47
48
    // these properties will supress division unless it's inside parenthases
49
    protected static $supressDivisionProps =
50
        ['/border-radius$/i', '/^font$/i'];
51
52
    private $blockDirectives = [
53
        'font-face',
54
        'keyframes',
55
        'page',
56
        '-moz-document',
57
        'viewport',
58
        '-moz-viewport',
59
        '-o-viewport',
60
        '-ms-viewport',
61
    ];
62
    private $lineDirectives = ['charset'];
63
64
    /**
65
     * if we are in parens we can be more liberal with whitespace around
66
     * operators because it must evaluate to a single value and thus is less
67
     * ambiguous.
68
     *
69
     * Consider:
70
     *     property1: 10 -5; // is two numbers, 10 and -5
71
     *     property2: (10 -5); // should evaluate to 5
72
     */
73
    protected $inParens = false;
74
75
    // caches preg escaped literals
76
    protected static $literalCache = [];
77
    /** @var int */
78
    public $count;
79
    /** @var int */
80
    private $line;
81
    /** @var array */
82
    private $seenComments;
83
    /** @var string */
84
    public $buffer;
85
86
    /** @var mixed $env Block Stack */
87
    private $env;
88
    /** @var bool */
89
    private $inExp;
90
    /** @var string */
91
    private $currentProperty;
92
93
    /**
94
     * @var bool
95
     */
96
    private $writeComments = false;
97
98
    /**
99
     * Parser constructor.
100
     *
101
     * @param \LesserPhp\Compiler $lessc
102
     * @param null                $sourceName
103
     */
104
    public function __construct(Compiler $lessc, $sourceName = null)
105
    {
106
        $this->eatWhiteDefault = true;
0 ignored issues
show
Bug introduced by
The property eatWhiteDefault does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
107
        // reference to less needed for vPrefix, mPrefix, and parentSelector
108
        $this->lessc = $lessc;
0 ignored issues
show
Bug introduced by
The property lessc does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
109
110
        $this->sourceName = $sourceName; // name used for error messages
0 ignored issues
show
Bug introduced by
The property sourceName does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
111
112
        if (!self::$operatorString) {
113
            self::$operatorString =
114
                '(' . implode('|', array_map([Compiler::class, 'pregQuote'], array_keys(self::$precedence))) . ')';
115
116
            $commentSingle = Compiler::pregQuote(self::$commentSingle);
117
            $commentMultiLeft = Compiler::pregQuote(self::$commentMultiLeft);
118
            $commentMultiRight = Compiler::pregQuote(self::$commentMultiRight);
119
120
            self::$commentMulti = $commentMultiLeft . '.*?' . $commentMultiRight;
121
            self::$whitePattern = '/' . $commentSingle . '[^\n]*\s*|(' . self::$commentMulti . ')\s*|\s+/Ais';
122
        }
123
    }
124
125
    /**
126
     * @param $buffer
127
     *
128
     * @return mixed
129
     * @throws \LesserPhp\Exception\GeneralException
130
     */
131
    public function parse($buffer)
132
    {
133
        $this->count = 0;
134
        $this->line = 1;
135
136
        $this->clearBlockStack();
137
        $this->buffer = $this->writeComments ? $buffer : $this->removeComments($buffer);
138
        $this->pushSpecialBlock('root');
139
        $this->eatWhiteDefault = true;
140
        $this->seenComments = [];
141
142
        $this->whitespace();
143
144
        // parse the entire file
145
        while (false !== $this->parseChunk()) {
146
            ;
147
        }
148
149
        if ($this->count !== strlen($this->buffer)) {
150
            //            var_dump($this->count);
151
//            var_dump($this->buffer);
152
            $this->throwError();
153
        }
154
155
        // TODO report where the block was opened
156
        if (!property_exists($this->env, 'parent') || $this->env->parent !== null) {
157
            throw new GeneralException('parse error: unclosed block');
158
        }
159
160
        return $this->env;
161
    }
162
163
    /**
164
     * Parse a single chunk off the head of the buffer and append it to the
165
     * current parse environment.
166
     * Returns false when the buffer is empty, or when there is an error.
167
     *
168
     * This function is called repeatedly until the entire document is
169
     * parsed.
170
     *
171
     * This parser is most similar to a recursive descent parser. Single
172
     * functions represent discrete grammatical rules for the language, and
173
     * they are able to capture the text that represents those rules.
174
     *
175
     * Consider the function \LesserPhp\Compiler::keyword(). (all parse functions are
176
     * structured the same)
177
     *
178
     * The function takes a single reference argument. When calling the
179
     * function it will attempt to match a keyword on the head of the buffer.
180
     * If it is successful, it will place the keyword in the referenced
181
     * argument, advance the position in the buffer, and return true. If it
182
     * fails then it won't advance the buffer and it will return false.
183
     *
184
     * All of these parse functions are powered by \LesserPhp\Compiler::match(), which behaves
185
     * the same way, but takes a literal regular expression. Sometimes it is
186
     * more convenient to use match instead of creating a new function.
187
     *
188
     * Because of the format of the functions, to parse an entire string of
189
     * grammatical rules, you can chain them together using &&.
190
     *
191
     * But, if some of the rules in the chain succeed before one fails, then
192
     * the buffer position will be left at an invalid state. In order to
193
     * avoid this, \LesserPhp\Compiler::seek() is used to remember and set buffer positions.
194
     *
195
     * Before parsing a chain, use $s = $this->seek() to remember the current
196
     * position into $s. Then if a chain fails, use $this->seek($s) to
197
     * go back where we started.
198
     * @throws \LesserPhp\Exception\GeneralException
199
     */
200
    protected function parseChunk()
201
    {
202
        if (empty($this->buffer)) {
203
            return false;
204
        }
205
        $s = $this->seek();
206
207
        if ($this->whitespace()) {
208
            return true;
209
        }
210
211
        // setting a property
212
        if ($this->keyword($key) && $this->assign() && $this->propertyValue($value, $key) && $this->end()) {
213
            $this->append(['assign', $key, $value], $s);
214
215
            return true;
216
        } else {
217
            $this->seek($s);
218
        }
219
220
        // look for special css blocks
221
        if ($this->literal('@', false)) {
222
            $this->count--;
223
224
            // media
225
            if ($this->literal('@media')) {
226
                return $this->handleLiteralMedia($s);
227
            }
228
229
            if ($this->literal('@', false) && $this->keyword($directiveName)) {
230
                if ($this->isDirective($directiveName, $this->blockDirectives)) {
231
                    if ($this->handleDirectiveBlock($directiveName) === true) {
232
                        return true;
233
                    }
234
                } elseif ($this->isDirective($directiveName, $this->lineDirectives)) {
235
                    if ($this->handleDirectiveLine($directiveName) === true) {
236
                        return true;
237
                    }
238
                } elseif ($this->literal(':', true)) {
239
                    if ($this->handleRulesetDefinition($directiveName) === true) {
240
                        return true;
241
                    }
242
                }
243
            }
244
245
            $this->seek($s);
246
        }
247
248
        if ($this->literal('&', false)) {
249
            $this->count--;
250
            if ($this->literal('&:extend')) {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
251
                // hierauf folgt was in runden klammern, und zwar das element, das erweitert werden soll
252
                // heißt also, das was in klammern steht wird um die aktuellen klassen erweitert
253
                /*
254
Aus
255
256
nav ul {
257
  &:extend(.inline);
258
  background: blue;
259
}
260
.inline {
261
  color: red;
262
}
263
264
265
Wird:
266
267
nav ul {
268
  background: blue;
269
}
270
.inline,
271
nav ul {
272
  color: red;
273
}
274
275
                 */
276
//                echo "Here we go";
277
            }
278
        }
279
280
281
        // setting a variable
282
        if ($this->variable($var) && $this->assign() &&
283
            $this->propertyValue($value) && $this->end()
284
        ) {
285
            $this->append(['assign', $var, $value], $s);
286
287
            return true;
288
        } else {
289
            $this->seek($s);
290
        }
291
292
        if ($this->import($importValue)) {
293
            $this->append($importValue, $s);
294
295
            return true;
296
        }
297
298
        // opening parametric mixin
299
        if ($this->tag($tag, true) && $this->argumentDef($args, $isVararg) &&
300
            ($this->guards($guards) || true) &&
301
            $this->literal('{')
302
        ) {
303
            $block = $this->pushBlock($this->fixTags([$tag]));
304
            $block->args = $args;
305
            $block->isVararg = $isVararg;
306
            if (!empty($guards)) {
307
                $block->guards = $guards;
308
            }
309
310
            return true;
311
        } else {
312
            $this->seek($s);
313
        }
314
315
        // opening a simple block
316
        if ($this->tags($tags) && $this->literal('{', false)) {
317
            $tags = $this->fixTags($tags);
318
            $this->pushBlock($tags);
319
320
            return true;
321
        } else {
322
            $this->seek($s);
323
        }
324
325
        // closing a block
326
        if ($this->literal('}', false)) {
327
            try {
328
                $block = $this->pop();
329
            } catch (\Exception $e) {
330
                $this->seek($s);
331
                $this->throwError($e->getMessage());
332
                return false; // will never be reached, but silences the ide for now
333
            }
334
335
            $hidden = false;
336
            if ($block->type === null) {
337
                $hidden = true;
338
                if (!isset($block->args)) {
339
                    foreach ($block->tags as $tag) {
340
                        if (!is_string($tag) || $tag[0] !== $this->lessc->getMPrefix()) {
341
                            $hidden = false;
342
                            break;
343
                        }
344
                    }
345
                }
346
347
                foreach ($block->tags as $tag) {
348
                    if (is_string($tag)) {
349
                        $this->env->children[$tag][] = $block;
350
                    }
351
                }
352
            }
353
354
            if (!$hidden) {
355
                $this->append(['block', $block], $s);
356
            }
357
358
            // this is done here so comments aren't bundled into he block that
359
            // was just closed
360
            $this->whitespace();
361
362
            return true;
363
        }
364
365
        // mixin
366
        if ($this->mixinTags($tags) &&
367
            ($this->argumentDef($argv, $isVararg) || true) &&
368
            ($this->keyword($suffix) || true) && $this->end()
369
        ) {
370
            $tags = $this->fixTags($tags);
371
            $this->append(['mixin', $tags, $argv, $suffix], $s);
372
373
            return true;
374
        } else {
375
            $this->seek($s);
376
        }
377
378
        // spare ;
379
        if ($this->literal(';')) {
380
            return true;
381
        }
382
383
        return false; // got nothing, throw error
384
    }
385
386
    /**
387
     * @param string $directiveName
388
     * @param array $directives
389
     *
390
     * @return bool
391
     */
392
    protected function isDirective($directiveName, array $directives)
393
    {
394
        // TODO: cache pattern in parser
395
        $pattern = implode('|', array_map([Compiler::class, 'pregQuote'], $directives));
396
        $pattern = '/^(-[a-z-]+-)?(' . $pattern . ')$/i';
397
398
        return (preg_match($pattern, $directiveName) === 1);
399
    }
400
401
    /**
402
     * @param array $tags
403
     *
404
     * @return mixed
405
     */
406
    protected function fixTags(array $tags)
407
    {
408
        // move @ tags out of variable namespace
409
        foreach ($tags as &$tag) {
410
            if ($tag[0] === $this->lessc->getVPrefix()) {
411
                $tag[0] = $this->lessc->getMPrefix();
412
            }
413
        }
414
415
        return $tags;
416
    }
417
418
    /**
419
     * a list of expressions
420
     *
421
     * @param $exps
422
     *
423
     * @return bool
424
     */
425
    protected function expressionList(&$exps)
426
    {
427
        $values = [];
428
429
        while ($this->expression($exp)) {
430
            $values[] = $exp;
431
        }
432
433
        if (count($values) === 0) {
434
            return false;
435
        }
436
437
        $exps = Compiler::compressList($values, ' ');
438
439
        return true;
440
    }
441
442
    /**
443
     * Attempt to consume an expression.
444
     * @link http://en.wikipedia.org/wiki/Operator-precedence_parser#Pseudo-code
445
     *
446
     * @param $out
447
     *
448
     * @return bool
449
     */
450
    protected function expression(&$out)
451
    {
452
        if ($this->value($lhs)) {
453
            $out = $this->expHelper($lhs, 0);
454
455
            // look for / shorthand
456
            if (!empty($this->env->supressedDivision)) {
457
                unset($this->env->supressedDivision);
458
                $s = $this->seek();
459
                if ($this->literal('/') && $this->value($rhs)) {
460
                    $out = [
461
                        'list',
462
                        '',
463
                        [$out, ['keyword', '/'], $rhs],
464
                    ];
465
                } else {
466
                    $this->seek($s);
467
                }
468
            }
469
470
            return true;
471
        }
472
473
        return false;
474
    }
475
476
    /**
477
     * recursively parse infix equation with $lhs at precedence $minP
478
     *
479
     * @param $lhs
480
     * @param $minP
481
     *
482
     * @return array
483
     */
484
    protected function expHelper($lhs, $minP)
485
    {
486
        $this->inExp = true;
487
        $ss = $this->seek();
488
489
        while (true) {
490
            $whiteBefore = isset($this->buffer[$this->count - 1]) && ctype_space($this->buffer[$this->count - 1]);
491
492
            // If there is whitespace before the operator, then we require
493
            // whitespace after the operator for it to be an expression
494
            $needWhite = $whiteBefore && !$this->inParens;
495
496
            if ($this->match(self::$operatorString . ($needWhite ? '\s' : ''), $m) &&
497
                self::$precedence[$m[1]] >= $minP
498
            ) {
499
                if (!$this->inParens &&
500
                    isset($this->env->currentProperty) &&
501
                    $m[1] === '/' &&
502
                    empty($this->env->supressedDivision)
503
                ) {
504
                    foreach (self::$supressDivisionProps as $pattern) {
505
                        if (preg_match($pattern, $this->env->currentProperty)) {
506
                            $this->env->supressedDivision = true;
507
                            break 2;
508
                        }
509
                    }
510
                }
511
512
                $whiteAfter = isset($this->buffer[$this->count - 1]) && ctype_space($this->buffer[$this->count - 1]);
513
514
                if (!$this->value($rhs)) {
515
                    break;
516
                }
517
518
                // peek for next operator to see what to do with rhs
519
                if ($this->peek(self::$operatorString, $next) &&
520
                    self::$precedence[$next[1]] > self::$precedence[$m[1]]
521
                ) {
522
                    $rhs = $this->expHelper($rhs, self::$precedence[$next[1]]);
523
                }
524
525
                $lhs = ['expression', $m[1], $lhs, $rhs, $whiteBefore, $whiteAfter];
526
                $ss = $this->seek();
527
528
                continue;
529
            }
530
531
            break;
532
        }
533
534
        $this->seek($ss);
535
536
        return $lhs;
537
    }
538
539
    /**
540
     * consume a list of values for a property
541
     *
542
     * @param      $value
543
     * @param string $keyName
544
     *
545
     * @return bool
546
     */
547
    public function propertyValue(&$value, $keyName = null)
548
    {
549
        $values = [];
550
551
        if ($keyName !== null) {
552
            $this->env->currentProperty = $keyName;
553
        }
554
555
        $s = null;
556
        while ($this->expressionList($v)) {
557
            $values[] = $v;
558
            $s = $this->seek();
559
            if (!$this->literal(',')) {
560
                break;
561
            }
562
        }
563
564
        if ($s) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $s of type null|integer is loosely compared to true; this is ambiguous if the integer can be zero. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
565
            $this->seek($s);
566
        }
567
568
        if ($keyName !== null) {
569
            unset($this->env->currentProperty);
570
        }
571
572
        if (count($values) === 0) {
573
            return false;
574
        }
575
576
        $value = Compiler::compressList($values, ', ');
577
578
        return true;
579
    }
580
581
    /**
582
     * @param $out
583
     *
584
     * @return bool
585
     */
586
    protected function parenValue(&$out)
587
    {
588
        $s = $this->seek();
589
590
        // speed shortcut
591
        if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] !== '(') {
592
            return false;
593
        }
594
595
        $inParens = $this->inParens;
596
        if ($this->literal('(') &&
597
            ($this->inParens = true) && $this->expression($exp) &&
598
            $this->literal(')')
599
        ) {
600
            $out = $exp;
601
            $this->inParens = $inParens;
602
603
            return true;
604
        } else {
605
            $this->inParens = $inParens;
606
            $this->seek($s);
607
        }
608
609
        return false;
610
    }
611
612
    /**
613
     * a single value
614
     *
615
     * @param array $value
616
     *
617
     * @return bool
618
     */
619
    protected function value(&$value)
620
    {
621
        $s = $this->seek();
622
623
        // speed shortcut
624
        if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] === '-') {
625
            // negation
626
            if ($this->literal('-', false) &&
627
                (($this->variable($inner) && $inner = ['variable', $inner]) ||
628
                    $this->unit($inner) ||
629
                    $this->parenValue($inner))
630
            ) {
631
                $value = ['unary', '-', $inner];
632
633
                return true;
634
            } else {
635
                $this->seek($s);
636
            }
637
        }
638
639
        if ($this->parenValue($value)) {
640
            return true;
641
        }
642
        if ($this->unit($value)) {
643
            return true;
644
        }
645
        if ($this->color($value)) {
646
            return true;
647
        }
648
        if ($this->func($value)) {
649
            return true;
650
        }
651
        if ($this->stringValue($value)) {
652
            return true;
653
        }
654
655
        if ($this->keyword($word)) {
656
            $value = ['keyword', $word];
657
658
            return true;
659
        }
660
661
        // try a variable
662
        if ($this->variable($var)) {
663
            $value = ['variable', $var];
664
665
            return true;
666
        }
667
668
        // unquote string (should this work on any type?
669
        if ($this->literal('~') && $this->stringValue($str)) {
670
            $value = ['escape', $str];
671
672
            return true;
673
        } else {
674
            $this->seek($s);
675
        }
676
677
        // css hack: \0
678
        if ($this->literal('\\') && $this->match('([0-9]+)', $m)) {
679
            $value = ['keyword', '\\' . $m[1]];
680
681
            return true;
682
        } else {
683
            $this->seek($s);
684
        }
685
686
        return false;
687
    }
688
689
    /**
690
     * an import statement
691
     *
692
     * @param array $out
693
     *
694
     * @return bool|null
695
     */
696
    protected function import(&$out)
697
    {
698
        if (!$this->literal('@import')) {
699
            return false;
700
        }
701
702
        // @import "something.css" media;
703
        // @import url("something.css") media;
704
        // @import url(something.css) media;
705
706
        if ($this->propertyValue($value)) {
707
            $out = ['import', $value];
708
709
            return true;
710
        }
711
712
        return false;
713
    }
714
715
    /**
716
     * @param $out
717
     *
718
     * @return bool
719
     */
720
    protected function mediaQueryList(&$out)
721
    {
722
        if ($this->genericList($list, 'mediaQuery', ',', false)) {
723
            $out = $list[2];
724
725
            return true;
726
        }
727
728
        return false;
729
    }
730
731
    /**
732
     * @param $out
733
     *
734
     * @return bool
735
     */
736
    protected function mediaQuery(&$out)
737
    {
738
        $s = $this->seek();
739
740
        $expressions = null;
741
        $parts = [];
742
743
        if (($this->literal('only') && ($only = true) || $this->literal('not') && ($not = true) || true) &&
744
            $this->keyword($mediaType)
745
        ) {
746
            $prop = ['mediaType'];
747
            if (isset($only)) {
748
                $prop[] = 'only';
749
            }
750
            if (isset($not)) {
751
                $prop[] = 'not';
752
            }
753
            $prop[] = $mediaType;
754
            $parts[] = $prop;
755
        } else {
756
            $this->seek($s);
757
        }
758
759
760
        if (!empty($mediaType) && !$this->literal('and')) {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
761
            // ~
762
        } else {
763
            $this->genericList($expressions, 'mediaExpression', 'and', false);
764
            if (is_array($expressions)) {
765
                $parts = array_merge($parts, $expressions[2]);
766
            }
767
        }
768
769
        if (count($parts) === 0) {
770
            $this->seek($s);
771
772
            return false;
773
        }
774
775
        $out = $parts;
776
777
        return true;
778
    }
779
780
    /**
781
     * @param $out
782
     *
783
     * @return bool
784
     */
785
    protected function mediaExpression(&$out)
786
    {
787
        $s = $this->seek();
788
        $value = null;
789
        if ($this->literal('(') &&
790
            $this->keyword($feature) &&
791
            ($this->literal(':') && $this->expression($value) || true) &&
792
            $this->literal(')')
793
        ) {
794
            $out = ['mediaExp', $feature];
795
            if ($value) {
796
                $out[] = $value;
797
            }
798
799
            return true;
800
        } elseif ($this->variable($variable)) {
801
            $out = ['variable', $variable];
802
803
            return true;
804
        }
805
806
        $this->seek($s);
807
808
        return false;
809
    }
810
811
    /**
812
     * an unbounded string stopped by $end
813
     *
814
     * @param      $end
815
     * @param      $out
816
     * @param null $nestingOpen
817
     * @param null $rejectStrs
818
     *
819
     * @return bool
820
     */
821
    protected function openString($end, &$out, $nestingOpen = null, $rejectStrs = null)
822
    {
823
        $oldWhite = $this->eatWhiteDefault;
824
        $this->eatWhiteDefault = false;
825
826
        $stop = ["'", '"', '@{', $end];
827
        $stop = array_map([Compiler::class, 'pregQuote'], $stop);
828
        // $stop[] = self::$commentMulti;
829
830
        if ($rejectStrs !== null) {
831
            $stop = array_merge($stop, $rejectStrs);
832
        }
833
834
        $patt = '(.*?)(' . implode('|', $stop) . ')';
835
836
        $nestingLevel = 0;
837
838
        $content = [];
839
        while ($this->match($patt, $m, false)) {
840
            if (!empty($m[1])) {
841
                $content[] = $m[1];
842
                if ($nestingOpen) {
843
                    $nestingLevel += substr_count($m[1], $nestingOpen);
844
                }
845
            }
846
847
            $tok = $m[2];
848
849
            $this->count -= strlen($tok);
850
            if ($tok == $end) {
851
                if ($nestingLevel === 0) {
852
                    break;
853
                } else {
854
                    $nestingLevel--;
855
                }
856
            }
857
858
            if (($tok === "'" || $tok === '"') && $this->stringValue($str)) {
859
                $content[] = $str;
860
                continue;
861
            }
862
863
            if ($tok === '@{' && $this->interpolation($inter)) {
864
                $content[] = $inter;
865
                continue;
866
            }
867
868
            if (!empty($rejectStrs) && in_array($tok, $rejectStrs)) {
869
                break;
870
            }
871
872
            $content[] = $tok;
873
            $this->count += strlen($tok);
874
        }
875
876
        $this->eatWhiteDefault = $oldWhite;
877
878
        if (count($content) === 0) {
879
            return false;
880
        }
881
882
        // trim the end
883
        if (is_string(end($content))) {
884
            $content[count($content) - 1] = rtrim(end($content));
885
        }
886
887
        $out = ['string', '', $content];
888
889
        return true;
890
    }
891
892
    /**
893
     * @param $out
894
     *
895
     * @return bool
896
     */
897
    protected function stringValue(&$out)
898
    {
899
        $s = $this->seek();
900
        if ($this->literal('"', false)) {
901
            $delim = '"';
902
        } elseif ($this->literal("'", false)) {
903
            $delim = "'";
904
        } else {
905
            return false;
906
        }
907
908
        $content = [];
909
910
        // look for either ending delim , escape, or string interpolation
911
        $patt = '([^\n]*?)(@\{|\\\\|' .
912
            Compiler::pregQuote($delim) . ')';
913
914
        $oldWhite = $this->eatWhiteDefault;
915
        $this->eatWhiteDefault = false;
916
917
        while ($this->match($patt, $m, false)) {
918
            $content[] = $m[1];
919
            if ($m[2] === '@{') {
920
                $this->count -= strlen($m[2]);
921
                if ($this->interpolation($inter)) {
922
                    $content[] = $inter;
923
                } else {
924
                    $this->count += strlen($m[2]);
925
                    $content[] = '@{'; // ignore it
926
                }
927
            } elseif ($m[2] === '\\') {
928
                $content[] = $m[2];
929
                if ($this->literal($delim, false)) {
930
                    $content[] = $delim;
931
                }
932
            } else {
933
                $this->count -= strlen($delim);
934
                break; // delim
935
            }
936
        }
937
938
        $this->eatWhiteDefault = $oldWhite;
939
940
        if ($this->literal($delim)) {
941
            $out = ['string', $delim, $content];
942
943
            return true;
944
        }
945
946
        $this->seek($s);
947
948
        return false;
949
    }
950
951
    /**
952
     * @param $out
953
     *
954
     * @return bool
955
     */
956
    protected function interpolation(&$out)
957
    {
958
        $oldWhite = $this->eatWhiteDefault;
959
        $this->eatWhiteDefault = true;
960
961
        $s = $this->seek();
962
        if ($this->literal('@{') &&
963
            $this->openString('}', $interp, null, ["'", '"', ';']) &&
964
            $this->literal('}', false)
965
        ) {
966
            $out = ['interpolate', $interp];
967
            $this->eatWhiteDefault = $oldWhite;
968
            if ($this->eatWhiteDefault) {
969
                $this->whitespace();
970
            }
971
972
            return true;
973
        }
974
975
        $this->eatWhiteDefault = $oldWhite;
976
        $this->seek($s);
977
978
        return false;
979
    }
980
981
    /**
982
     * @param $unit
983
     *
984
     * @return bool
985
     */
986
    protected function unit(&$unit)
987
    {
988
        // speed shortcut
989
        if (isset($this->buffer[$this->count])) {
990
            $char = $this->buffer[$this->count];
991
            if (!ctype_digit($char) && $char !== '.') {
992
                return false;
993
            }
994
        }
995
996
        if ($this->match('([0-9]+(?:\.[0-9]*)?|\.[0-9]+)([%a-zA-Z]+)?', $m)) {
997
            $unit = ['number', $m[1], empty($m[2]) ? '' : $m[2]];
998
999
            return true;
1000
        }
1001
1002
        return false;
1003
    }
1004
1005
1006
    /**
1007
     * a # color
1008
     *
1009
     * @param $out
1010
     *
1011
     * @return bool
1012
     */
1013
    protected function color(&$out)
1014
    {
1015
        if ($this->match('(#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3}))', $m)) {
1016
            if (strlen($m[1]) > 7) {
1017
                $out = ['string', '', [$m[1]]];
1018
            } else {
1019
                $out = ['raw_color', $m[1]];
1020
            }
1021
1022
            return true;
1023
        }
1024
1025
        return false;
1026
    }
1027
1028
    /**
1029
     * consume an argument definition list surrounded by ()
1030
     * each argument is a variable name with optional value
1031
     * or at the end a ... or a variable named followed by ...
1032
     * arguments are separated by , unless a ; is in the list, then ; is the
1033
     * delimiter.
1034
     *
1035
     * @param $args
1036
     * @param $isVararg
1037
     *
1038
     * @return bool
1039
     * @throws \LesserPhp\Exception\GeneralException
1040
     */
1041
    protected function argumentDef(&$args, &$isVararg)
1042
    {
1043
        $s = $this->seek();
1044
        if (!$this->literal('(')) {
1045
            return false;
1046
        }
1047
1048
        $values = [];
1049
        $delim = ',';
1050
        $method = 'expressionList';
1051
        $value = [];
1052
        $rhs = null;
1053
1054
        $isVararg = false;
1055
        while (true) {
1056
            if ($this->literal('...')) {
1057
                $isVararg = true;
1058
                break;
1059
            }
1060
1061
            if ($this->$method($value)) {
1062
                if ($value[0] === 'variable') {
1063
                    $arg = ['arg', $value[1]];
1064
                    $ss = $this->seek();
1065
1066
                    if ($this->assign() && $this->$method($rhs)) {
1067
                        $arg[] = $rhs;
1068
                    } else {
1069
                        $this->seek($ss);
1070
                        if ($this->literal('...')) {
1071
                            $arg[0] = 'rest';
1072
                            $isVararg = true;
1073
                        }
1074
                    }
1075
1076
                    $values[] = $arg;
1077
                    if ($isVararg) {
1078
                        break;
1079
                    }
1080
                    continue;
1081
                } else {
1082
                    $values[] = ['lit', $value];
1083
                }
1084
            }
1085
1086
1087
            if (!$this->literal($delim)) {
1088
                if ($delim === ',' && $this->literal(';')) {
1089
                    // found new delim, convert existing args
1090
                    $delim = ';';
1091
                    $method = 'propertyValue';
1092
                    $newArg = null;
1093
1094
                    // transform arg list
1095
                    if (isset($values[1])) { // 2 items
1096
                        $newList = [];
1097
                        foreach ($values as $i => $arg) {
1098
                            switch ($arg[0]) {
1099
                                case 'arg':
1100
                                    if ($i) {
1101
                                        throw new GeneralException('Cannot mix ; and , as delimiter types');
1102
                                    }
1103
                                    $newList[] = $arg[2];
1104
                                    break;
1105
                                case 'lit':
1106
                                    $newList[] = $arg[1];
1107
                                    break;
1108
                                case 'rest':
1109
                                    throw new GeneralException('Unexpected rest before semicolon');
1110
                            }
1111
                        }
1112
1113
                        $newList = ['list', ', ', $newList];
1114
1115
                        switch ($values[0][0]) {
1116
                            case 'arg':
1117
                                $newArg = ['arg', $values[0][1], $newList];
1118
                                break;
1119
                            case 'lit':
1120
                                $newArg = ['lit', $newList];
1121
                                break;
1122
                        }
1123
                    } elseif ($values) { // 1 item
0 ignored issues
show
Bug Best Practice introduced by
The expression $values of type null[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1124
                        $newArg = $values[0];
1125
                    }
1126
1127
                    if ($newArg !== null) {
1128
                        $values = [$newArg];
1129
                    }
1130
                } else {
1131
                    break;
1132
                }
1133
            }
1134
        }
1135
1136
        if (!$this->literal(')')) {
1137
            $this->seek($s);
1138
1139
            return false;
1140
        }
1141
1142
        $args = $values;
1143
1144
        return true;
1145
    }
1146
1147
    /**
1148
     * consume a list of tags
1149
     * this accepts a hanging delimiter
1150
     *
1151
     * @param array  $tags
1152
     * @param bool   $simple
1153
     * @param string $delim
1154
     *
1155
     * @return bool
1156
     */
1157
    protected function tags(&$tags, $simple = false, $delim = ',')
1158
    {
1159
        $tags = [];
1160
        while ($this->tag($tt, $simple)) {
1161
            $tags[] = $tt;
1162
            if (!$this->literal($delim)) {
1163
                break;
1164
            }
1165
        }
1166
1167
        return count($tags) !== 0;
1168
    }
1169
1170
    /**
1171
     * list of tags of specifying mixin path
1172
     * optionally separated by > (lazy, accepts extra >)
1173
     *
1174
     * @param array $tags
1175
     *
1176
     * @return bool
1177
     */
1178
    protected function mixinTags(&$tags)
1179
    {
1180
        $tags = [];
1181
        while ($this->tag($tt, true)) {
1182
            $tags[] = $tt;
1183
            $this->literal('>');
1184
        }
1185
1186
        return count($tags) !== 0;
1187
    }
1188
1189
    /**
1190
     * a bracketed value (contained within in a tag definition)
1191
     *
1192
     * @param array $parts
1193
     * @param bool $hasExpression
1194
     *
1195
     * @return bool
1196
     */
1197
    protected function tagBracket(&$parts, &$hasExpression)
1198
    {
1199
        // speed shortcut
1200
        if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] !== '[') {
1201
            return false;
1202
        }
1203
1204
        $s = $this->seek();
1205
1206
        $hasInterpolation = false;
1207
1208
        if ($this->literal('[', false)) {
1209
            $attrParts = ['['];
1210
            // keyword, string, operator
1211
            while (true) {
1212
                if ($this->literal(']', false)) {
1213
                    $this->count--;
1214
                    break; // get out early
1215
                }
1216
1217
                if ($this->match('\s+', $m)) {
1218
                    $attrParts[] = ' ';
1219
                    continue;
1220
                }
1221
                if ($this->stringValue($str)) {
1222
                    // escape parent selector, (yuck)
1223
                    foreach ($str[2] as &$chunk) {
1224
                        $chunk = str_replace($this->lessc->getParentSelector(), '$&$', $chunk);
1225
                    }
1226
1227
                    $attrParts[] = $str;
1228
                    $hasInterpolation = true;
1229
                    continue;
1230
                }
1231
1232
                if ($this->keyword($word)) {
1233
                    $attrParts[] = $word;
1234
                    continue;
1235
                }
1236
1237
                if ($this->interpolation($inter)) {
1238
                    $attrParts[] = $inter;
1239
                    $hasInterpolation = true;
1240
                    continue;
1241
                }
1242
1243
                // operator, handles attr namespace too
1244
                if ($this->match('[|-~\$\*\^=]+', $m)) {
1245
                    $attrParts[] = $m[0];
1246
                    continue;
1247
                }
1248
1249
                break;
1250
            }
1251
1252
            if ($this->literal(']', false)) {
1253
                $attrParts[] = ']';
1254
                foreach ($attrParts as $part) {
1255
                    $parts[] = $part;
1256
                }
1257
                $hasExpression = $hasExpression || $hasInterpolation;
1258
1259
                return true;
1260
            }
1261
            $this->seek($s);
1262
        }
1263
1264
        $this->seek($s);
1265
1266
        return false;
1267
    }
1268
1269
    /**
1270
     * a space separated list of selectors
1271
     *
1272
     * @param      $tag
1273
     * @param bool $simple
1274
     *
1275
     * @return bool
1276
     */
1277
    protected function tag(&$tag, $simple = false)
1278
    {
1279
        if ($simple) {
1280
            $chars = '^@,:;{}\][>\(\) "\'';
1281
        } else {
1282
            $chars = '^@,;{}["\'';
1283
        }
1284
1285
        $s = $this->seek();
1286
1287
        $hasExpression = false;
1288
        $parts = [];
1289
        while ($this->tagBracket($parts, $hasExpression)) {
1290
            ;
1291
        }
1292
1293
        $oldWhite = $this->eatWhiteDefault;
1294
        $this->eatWhiteDefault = false;
1295
1296
        while (true) {
1297
            if ($this->match('([' . $chars . '0-9][' . $chars . ']*)', $m)) {
1298
                $parts[] = $m[1];
1299
                if ($simple) {
1300
                    break;
1301
                }
1302
1303
                while ($this->tagBracket($parts, $hasExpression)) {
1304
                    ;
1305
                }
1306
                continue;
1307
            }
1308
1309
            if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] === '@') {
1310
                if ($this->interpolation($interp)) {
1311
                    $hasExpression = true;
1312
                    $interp[2] = true; // don't unescape
1313
                    $parts[] = $interp;
1314
                    continue;
1315
                }
1316
1317
                if ($this->literal('@')) {
1318
                    $parts[] = '@';
1319
                    continue;
1320
                }
1321
            }
1322
1323
            if ($this->unit($unit)) { // for keyframes
1324
                $parts[] = $unit[1];
1325
                $parts[] = $unit[2];
1326
                continue;
1327
            }
1328
1329
            break;
1330
        }
1331
1332
        $this->eatWhiteDefault = $oldWhite;
1333
        if (!$parts) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $parts of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1334
            $this->seek($s);
1335
1336
            return false;
1337
        }
1338
1339
        if ($hasExpression) {
1340
            $tag = ['exp', ['string', '', $parts]];
1341
        } else {
1342
            $tag = trim(implode($parts));
1343
        }
1344
1345
        $this->whitespace();
1346
1347
        return true;
1348
    }
1349
1350
    /**
1351
     * a css function
1352
     *
1353
     * @param array $func
1354
     *
1355
     * @return bool
1356
     */
1357
    protected function func(&$func)
1358
    {
1359
        $s = $this->seek();
1360
1361
        if ($this->match('(%|[\w\-_][\w\-_:\.]+|[\w_])', $m) && $this->literal('(')) {
1362
            $fname = $m[1];
1363
1364
            $sPreArgs = $this->seek();
1365
1366
            $args = [];
1367
            while (true) {
1368
                $ss = $this->seek();
1369
                // this ugly nonsense is for ie filter properties
1370
                if ($this->keyword($name) && $this->literal('=') && $this->expressionList($value)) {
1371
                    $args[] = ['string', '', [$name, '=', $value]];
1372
                } else {
1373
                    $this->seek($ss);
1374
                    if ($this->expressionList($value)) {
1375
                        $args[] = $value;
1376
                    }
1377
                }
1378
1379
                if (!$this->literal(',')) {
1380
                    break;
1381
                }
1382
            }
1383
            $args = ['list', ',', $args];
1384
1385
            if ($this->literal(')')) {
1386
                $func = ['function', $fname, $args];
1387
1388
                return true;
1389
            } elseif ($fname === 'url') {
1390
                // couldn't parse and in url? treat as string
1391
                $this->seek($sPreArgs);
1392
                if ($this->openString(')', $string) && $this->literal(')')) {
1393
                    $func = ['function', $fname, $string];
1394
1395
                    return true;
1396
                }
1397
            }
1398
        }
1399
1400
        $this->seek($s);
1401
1402
        return false;
1403
    }
1404
1405
    /**
1406
     * consume a less variable
1407
     *
1408
     * @param $name
1409
     *
1410
     * @return bool
1411
     */
1412
    protected function variable(&$name)
1413
    {
1414
        $s = $this->seek();
1415
        if ($this->literal($this->lessc->getVPrefix(), false) &&
1416
            ($this->variable($sub) || $this->keyword($name))
1417
        ) {
1418
            if (!empty($sub)) {
1419
                $name = ['variable', $sub];
1420
            } else {
1421
                $name = $this->lessc->getVPrefix() . $name;
1422
            }
1423
1424
            return true;
1425
        }
1426
1427
        $name = null;
1428
        $this->seek($s);
1429
1430
        return false;
1431
    }
1432
1433
    /**
1434
     * Consume an assignment operator
1435
     * Can optionally take a name that will be set to the current property name
1436
     *
1437
     * @param string $name
1438
     *
1439
     * @return bool
1440
     */
1441
    protected function assign($name = null)
1442
    {
1443
        if ($name !== null) {
1444
            $this->currentProperty = $name;
1445
        }
1446
1447
        return $this->literal(':') || $this->literal('=');
1448
    }
1449
1450
    /**
1451
     * consume a keyword
1452
     *
1453
     * @param $word
1454
     *
1455
     * @return bool
1456
     */
1457
    protected function keyword(&$word)
1458
    {
1459
        if ($this->match('([\w_\-\*!"][\w\-_"]*)', $m)) {
1460
            $word = $m[1];
1461
1462
            return true;
1463
        }
1464
1465
        return false;
1466
    }
1467
1468
    /**
1469
     * consume an end of statement delimiter
1470
     *
1471
     * @return bool
1472
     */
1473
    protected function end()
1474
    {
1475
        if ($this->literal(';', false)) {
1476
            return true;
1477
        } elseif ($this->count === strlen($this->buffer) || $this->buffer[$this->count] === '}') {
1478
            // if there is end of file or a closing block next then we don't need a ;
1479
            return true;
1480
        }
1481
1482
        return false;
1483
    }
1484
1485
    /**
1486
     * @param $guards
1487
     *
1488
     * @return bool
1489
     */
1490
    protected function guards(&$guards)
1491
    {
1492
        $s = $this->seek();
1493
1494
        if (!$this->literal('when')) {
1495
            $this->seek($s);
1496
1497
            return false;
1498
        }
1499
1500
        $guards = [];
1501
1502
        while ($this->guardGroup($g)) {
1503
            $guards[] = $g;
1504
            if (!$this->literal(',')) {
1505
                break;
1506
            }
1507
        }
1508
1509
        if (count($guards) === 0) {
1510
            $guards = null;
1511
            $this->seek($s);
1512
1513
            return false;
1514
        }
1515
1516
        return true;
1517
    }
1518
1519
    /**
1520
     * a bunch of guards that are and'd together
1521
     *
1522
     * @param $guardGroup
1523
     *
1524
     * @return bool
1525
     */
1526
    protected function guardGroup(&$guardGroup)
1527
    {
1528
        $s = $this->seek();
1529
        $guardGroup = [];
1530
        while ($this->guard($guard)) {
1531
            $guardGroup[] = $guard;
1532
            if (!$this->literal('and')) {
1533
                break;
1534
            }
1535
        }
1536
1537
        if (count($guardGroup) === 0) {
1538
            $guardGroup = null;
1539
            $this->seek($s);
1540
1541
            return false;
1542
        }
1543
1544
        return true;
1545
    }
1546
1547
    /**
1548
     * @param $guard
1549
     *
1550
     * @return bool
1551
     */
1552
    protected function guard(&$guard)
1553
    {
1554
        $s = $this->seek();
1555
        $negate = $this->literal('not');
1556
1557
        if ($this->literal('(') && $this->expression($exp) && $this->literal(')')) {
1558
            $guard = $exp;
1559
            if ($negate) {
1560
                $guard = ['negate', $guard];
1561
            }
1562
1563
            return true;
1564
        }
1565
1566
        $this->seek($s);
1567
1568
        return false;
1569
    }
1570
1571
    /* raw parsing functions */
1572
1573
    /**
1574
     * @param string $what
1575
     * @param bool $eatWhitespace
1576
     *
1577
     * @return bool
1578
     */
1579
    protected function literal($what, $eatWhitespace = null)
1580
    {
1581
        if ($eatWhitespace === null) {
1582
            $eatWhitespace = $this->eatWhiteDefault;
1583
        }
1584
1585
        // shortcut on single letter
1586
        if (!isset($what[1]) && isset($this->buffer[$this->count])) {
1587
            if ($this->buffer[$this->count] === $what) {
1588
                if (!$eatWhitespace) {
1589
                    $this->count++;
1590
1591
                    return true;
1592
                }
1593
                // goes below...
1594
            } else {
1595
                return false;
1596
            }
1597
        }
1598
1599
        if (!isset(self::$literalCache[$what])) {
1600
            self::$literalCache[$what] = Compiler::pregQuote($what);
1601
        }
1602
1603
        return $this->match(self::$literalCache[$what], $m, $eatWhitespace);
1604
    }
1605
1606
    /**
1607
     * @param        $out
1608
     * @param string $parseItem
1609
     * @param string $delim
1610
     * @param bool   $flatten
1611
     *
1612
     * @return bool
1613
     */
1614
    protected function genericList(&$out, $parseItem, $delim = "", $flatten = true)
1615
    {
1616
        // $parseItem is one of mediaQuery, mediaExpression
1617
        $s = $this->seek();
1618
        $items = [];
1619
        $value = null;
1620
        while ($this->$parseItem($value)) {
1621
            $items[] = $value;
1622
            if ($delim) {
1623
                if (!$this->literal($delim)) {
1624
                    break;
1625
                }
1626
            }
1627
        }
1628
1629
        if (count($items) === 0) {
1630
            $this->seek($s);
1631
1632
            return false;
1633
        }
1634
1635
        if ($flatten && count($items) === 1) {
1636
            $out = $items[0];
1637
        } else {
1638
            $out = ['list', $delim, $items];
1639
        }
1640
1641
        return true;
1642
    }
1643
1644
    /**
1645
     * try to match something on head of buffer
1646
     *
1647
     * @param string $regex
1648
     * @param      $out
1649
     * @param bool $eatWhitespace
1650
     *
1651
     * @return bool
1652
     */
1653
    protected function match($regex, &$out, $eatWhitespace = null)
1654
    {
1655
        if ($eatWhitespace === null) {
1656
            $eatWhitespace = $this->eatWhiteDefault;
1657
        }
1658
1659
        $r = '/' . $regex . ($eatWhitespace && !$this->writeComments ? '\s*' : '') . '/Ais';
1660
        if (preg_match($r, $this->buffer, $out, null, $this->count)) {
1661
            $this->count += strlen($out[0]);
1662
            if ($eatWhitespace && $this->writeComments) {
1663
                $this->whitespace();
1664
            }
1665
1666
            return true;
1667
        }
1668
1669
        return false;
1670
    }
1671
1672
    /**
1673
     * match some whitespace
1674
     *
1675
     * @return bool
1676
     */
1677
    protected function whitespace()
1678
    {
1679
        if ($this->writeComments) {
1680
            $gotWhite = false;
1681
            while (preg_match(self::$whitePattern, $this->buffer, $m, null, $this->count)) {
1682
                if (isset($m[1]) && empty($this->seenComments[$this->count])) {
1683
                    $this->append(['comment', $m[1]]);
1684
                    $this->seenComments[$this->count] = true;
1685
                }
1686
                $this->count += mb_strlen($m[0]);
1687
                $gotWhite = true;
1688
            }
1689
1690
            return $gotWhite;
1691
        }
1692
1693
        $this->match('', $m);
1694
        return mb_strlen($m[0]) > 0;
1695
    }
1696
1697
    /**
1698
     * match something without consuming it
1699
     *
1700
     * @param string $regex
1701
     * @param array $out
1702
     * @param int $from
1703
     *
1704
     * @return int
1705
     */
1706
    protected function peek($regex, &$out = null, $from = null)
1707
    {
1708
        if ($from === null) {
1709
            $from = $this->count;
1710
        }
1711
        $r = '/' . $regex . '/Ais';
1712
1713
        return preg_match($r, $this->buffer, $out, null, $from);
1714
    }
1715
1716
    /**
1717
     * seek to a spot in the buffer or return where we are on no argument
1718
     *
1719
     * @param int $where
1720
     *
1721
     * @return int
1722
     */
1723
    protected function seek($where = null)
1724
    {
1725
        if ($where !== null) {
1726
            $this->count = $where;
1727
        }
1728
1729
        return $this->count;
1730
    }
1731
1732
    /* misc functions */
1733
1734
    /**
1735
     * @param string $msg
1736
     * @param int $count
1737
     *
1738
     * @throws \LesserPhp\Exception\GeneralException
1739
     */
1740
    public function throwError($msg = 'parse error', $count = null)
1741
    {
1742
        $count = $count === null ? $this->count : $count;
1743
1744
        $line = $this->line + substr_count(substr($this->buffer, 0, $count), "\n");
1745
1746
        if (!empty($this->sourceName)) {
1747
            $loc = "$this->sourceName on line $line";
1748
        } else {
1749
            $loc = "line: $line";
1750
        }
1751
1752
        // TODO this depends on $this->count
1753
        if ($this->peek("(.*?)(\n|$)", $m, $count)) {
1754
            throw new GeneralException("$msg: failed at `$m[1]` $loc");
1755
        } else {
1756
            throw new GeneralException("$msg: $loc");
1757
        }
1758
    }
1759
1760
    /**
1761
     * @param null $selectors
1762
     * @param null $type
1763
     *
1764
     * @return \stdClass
1765
     */
1766
    protected function pushBlock($selectors = null, $type = null)
1767
    {
1768
        $b = new \stdClass();
1769
        $b->parent = $this->env;
1770
1771
        $b->type = $type;
1772
        $b->id = self::$nextBlockId++;
1773
1774
        $b->isVararg = false; // TODO: kill me from here
1775
        $b->tags = $selectors;
1776
1777
        $b->props = [];
1778
        $b->children = [];
1779
1780
        // add a reference to the parser so
1781
        // we can access the parser to throw errors
1782
        // or retrieve the sourceName of this block.
1783
        $b->parser = $this;
1784
1785
        // so we know the position of this block
1786
        $b->count = $this->count;
1787
1788
        $this->env = $b;
1789
1790
        return $b;
1791
    }
1792
1793
    /**
1794
     * push a block that doesn't multiply tags
1795
     *
1796
     * @param $type
1797
     *
1798
     * @return \stdClass
1799
     */
1800
    protected function pushSpecialBlock($type)
1801
    {
1802
        return $this->pushBlock(null, $type);
1803
    }
1804
1805
    /**
1806
     * append a property to the current block
1807
     *
1808
     * @param      $prop
1809
     * @param  $pos
1810
     */
1811
    protected function append($prop, $pos = null)
1812
    {
1813
        if ($pos !== null) {
1814
            $prop[-1] = $pos;
1815
        }
1816
        $this->env->props[] = $prop;
1817
    }
1818
1819
    /**
1820
     * pop something off the stack
1821
     *
1822
     * @return mixed
1823
     */
1824
    protected function pop()
1825
    {
1826
        $old = $this->env;
1827
        $this->env = $this->env->parent;
1828
1829
        return $old;
1830
    }
1831
1832
    /**
1833
     * remove comments from $text
1834
     * todo: make it work for all functions, not just url
1835
     *
1836
     * @param string $text
1837
     *
1838
     * @return string
1839
     */
1840
    protected function removeComments($text)
1841
    {
1842
        $look = [
1843
            'url(',
1844
            '//',
1845
            '/*',
1846
            '"',
1847
            "'",
1848
        ];
1849
1850
        $out = '';
1851
        $min = null;
1852
        while (true) {
1853
            // find the next item
1854
            foreach ($look as $token) {
1855
                $pos = mb_strpos($text, $token);
1856
                if ($pos !== false) {
1857
                    if ($min === null || $pos < $min[1]) {
1858
                        $min = [$token, $pos];
1859
                    }
1860
                }
1861
            }
1862
1863
            if ($min === null) {
1864
                break;
1865
            }
1866
1867
            $count = $min[1];
1868
            $skip = 0;
1869
            $newlines = 0;
1870
            switch ($min[0]) {
1871
                case 'url(':
1872
                    if (preg_match('/url\(.*?\)/', $text, $m, 0, $count)) {
1873
                        $count += mb_strlen($m[0]) - mb_strlen($min[0]);
1874
                    }
1875
                    break;
1876
                case '"':
1877
                case "'":
1878
                    if (preg_match('/' . $min[0] . '.*?(?<!\\\\)' . $min[0] . '/', $text, $m, 0, $count)) {
1879
                        $count += mb_strlen($m[0]) - 1;
1880
                    }
1881
                    break;
1882
                case '//':
1883
                    $skip = mb_strpos($text, "\n", $count);
1884
                    if ($skip === false) {
1885
                        $skip = mb_strlen($text) - $count;
1886
                    } else {
1887
                        $skip -= $count;
1888
                    }
1889
                    break;
1890
                case '/*':
1891
                    if (preg_match('/\/\*.*?\*\//s', $text, $m, 0, $count)) {
1892
                        $skip = mb_strlen($m[0]);
1893
                        $newlines = mb_substr_count($m[0], "\n");
1894
                    }
1895
                    break;
1896
            }
1897
1898
            if ($skip === 0) {
1899
                $count += mb_strlen($min[0]);
1900
            }
1901
1902
            $out .= mb_substr($text, 0, $count) . str_repeat("\n", $newlines);
1903
            $text = mb_substr($text, $count + $skip);
1904
1905
            $min = null;
1906
        }
1907
1908
        return $out . $text;
1909
    }
1910
1911
    /**
1912
     * @param bool $writeComments
1913
     */
1914
    public function setWriteComments($writeComments)
1915
    {
1916
        $this->writeComments = $writeComments;
1917
    }
1918
1919
    /**
1920
     * @param $s
1921
     *
1922
     * @return bool
1923
     */
1924
    protected function handleLiteralMedia($s)
1925
    {
1926
        // seriously, this || true is required for this statement to work!?
1927
        if (($this->mediaQueryList($mediaQueries) || true) && $this->literal('{')) {
1928
            $media = $this->pushSpecialBlock('media');
1929
            $media->queries = $mediaQueries === null ? [] : $mediaQueries;
1930
1931
            return true;
1932
        } else {
1933
            $this->seek($s);
1934
        }
1935
1936
        return false;
1937
    }
1938
1939
    /**
1940
     * @param string $directiveName
1941
     *
1942
     * @return bool
1943
     */
1944
    protected function handleDirectiveBlock($directiveName)
1945
    {
1946
        // seriously, this || true is required for this statement to work!?
1947
        if (($this->openString('{', $directiveValue, null, [';']) || true) && $this->literal('{')) {
1948
            $dir = $this->pushSpecialBlock('directive');
1949
            $dir->name = $directiveName;
1950
            if ($directiveValue !== null) {
1951
                $dir->value = $directiveValue;
1952
            }
1953
1954
            return true;
1955
        }
1956
1957
        return false;
1958
    }
1959
1960
    /**
1961
     * @param string $directiveName
1962
     *
1963
     * @return bool
1964
     */
1965
    protected function handleDirectiveLine($directiveName)
1966
    {
1967
        if ($this->propertyValue($directiveValue) && $this->end()) {
1968
            $this->append(['directive', $directiveName, $directiveValue]);
1969
1970
            return true;
1971
        }
1972
1973
        return false;
1974
    }
1975
1976
    /**
1977
     * @param string $directiveName
1978
     *
1979
     * @return bool
1980
     */
1981
    protected function handleRulesetDefinition($directiveName)
1982
    {
1983
        //Ruleset Definition
1984
        // seriously, this || true is required for this statement to work!?
1985
        if (($this->openString('{', $directiveValue, null, [';']) || true) && $this->literal('{')) {
1986
            $dir = $this->pushBlock($this->fixTags(['@' . $directiveName]));
1987
            $dir->name = $directiveName;
1988
            if ($directiveValue !== null) {
1989
                $dir->value = $directiveValue;
1990
            }
1991
1992
            return true;
1993
        }
1994
1995
        return false;
1996
    }
1997
1998
    private function clearBlockStack()
1999
    {
2000
        $this->env = null;
2001
    }
2002
}
2003