Parser::parseChunk()   F
last analyzed

Complexity

Conditions 49
Paths 349

Size

Total Lines 186

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 76
CRAP Score 49.3001

Importance

Changes 0
Metric Value
dl 0
loc 186
ccs 76
cts 80
cp 0.95
rs 1.1766
c 0
b 0
f 0
cc 49
nc 349
nop 0
crap 49.3001

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 Block|null $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 string              $sourceName
103 49
     */
104
    public function __construct(Compiler $lessc, $sourceName = null)
105 49
    {
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 49
        // 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 49
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 49
112 1
        if (!self::$operatorString) {
113 1
            self::$operatorString =
114
                '(' . implode('|', array_map([Compiler::class, 'pregQuote'], array_keys(self::$precedence))) . ')';
115 1
116 1
            $commentSingle = Compiler::pregQuote(self::$commentSingle);
117 1
            $commentMultiLeft = Compiler::pregQuote(self::$commentMultiLeft);
118
            $commentMultiRight = Compiler::pregQuote(self::$commentMultiRight);
119 1
120 1
            self::$commentMulti = $commentMultiLeft . '.*?' . $commentMultiRight;
121
            self::$whitePattern = '/' . $commentSingle . '[^\n]*\s*|(' . self::$commentMulti . ')\s*|\s+/Ais';
122 49
        }
123
    }
124
125
    /**
126
     * @param string $buffer
127
     *
128
     * @return Block
129
     * @throws \LesserPhp\Exception\GeneralException
130 49
     */
131
    public function parse($buffer)
132 49
    {
133 49
        $this->count = 0;
134
        $this->line = 1;
135 49
136 49
        $this->clearBlockStack();
137 49
        $this->buffer = $this->writeComments ? $buffer : $this->removeComments($buffer);
138 49
        $this->pushSpecialBlock('root');
139 49
        $this->eatWhiteDefault = true;
140
        $this->seenComments = [];
141 49
142
        $this->whitespace();
143
144 49
        // parse the entire file
145
        while (false !== $this->parseChunk()) {
146
            ;
147
        }
148 49
149
        if ($this->count !== strlen($this->buffer)) {
150
            //            var_dump($this->count);
151
//            var_dump($this->buffer);
152
            $this->throwError();
153
        }
154
155 49
        // 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 49
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 49
     */
200
    protected function parseChunk()
201 49
    {
202
        if (empty($this->buffer)) {
203
            return false;
204 49
        }
205
        $s = $this->seek();
206 49
207 46
        if ($this->whitespace()) {
208
            return true;
209
        }
210
211 49
        // setting a property
212 47
        if ($this->keyword($key) && $this->assign() && $this->propertyValue($value, $key) && $this->end()) {
213
            $this->append(['assign', $key, $value], $s);
214 47
215
            return true;
216 49
        } else {
217
            $this->seek($s);
218
        }
219
220 49
        // look for special css blocks
221 26
        if ($this->literal('@', false)) {
222
            $this->count--;
223
224 26
            // media
225 3
            if ($this->literal('@media')) {
226
                return $this->handleLiteralMedia($s);
227
            }
228 26
229 26
            if ($this->literal('@', false) && $this->keyword($directiveName)) {
230 4
                if ($this->isDirective($directiveName, $this->blockDirectives)) {
231 4
                    if ($this->handleDirectiveBlock($directiveName) === true) {
232
                        return true;
233 25
                    }
234 1
                } elseif ($this->isDirective($directiveName, $this->lineDirectives)) {
235 1
                    if ($this->handleDirectiveLine($directiveName) === true) {
236
                        return true;
237 25
                    }
238 24
                } elseif ($this->literal(':', true)) {
239 1
                    if ($this->handleRulesetDefinition($directiveName) === true) {
240
                        return true;
241
                    }
242
                }
243
            }
244 25
245
            $this->seek($s);
246
        }
247 49
248 4
        if ($this->literal('&', false)) {
249 4
            $this->count--;
250
            if ($this->literal('&:extend')) {
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 49
        // setting a variable
282 49
        if ($this->variable($var) && $this->assign() &&
283
            $this->propertyValue($value) && $this->end()
284 23
        ) {
285
            $this->append(['assign', $var, $value], $s);
286 23
287
            return true;
288 49
        } else {
289
            $this->seek($s);
290
        }
291 49
292 3
        if ($this->import($importValue)) {
293
            $this->append($importValue, $s);
294 3
295
            return true;
296
        }
297
298 49
        // opening parametric mixin
299 49
        if ($this->tag($tag, true) && $this->argumentDef($args, $isVararg) &&
300 49
            ($this->guards($guards) || true) &&
301
            $this->literal('{')
302 18
        ) {
303 18
            $block = $this->pushBlock($this->fixTags([$tag]));
304 18
            $block->args = $args;
0 ignored issues
show
Bug introduced by
The property args does not seem to exist in LesserPhp\Block.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
305 18
            $block->isVararg = $isVararg;
306 5
            if (!empty($guards)) {
307
                $block->guards = $guards;
0 ignored issues
show
Bug introduced by
The property guards does not seem to exist in LesserPhp\Block.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
308
            }
309 18
310
            return true;
311 49
        } else {
312
            $this->seek($s);
313
        }
314
315 49
        // opening a simple block
316 46
        if ($this->tags($tags) && $this->literal('{', false)) {
317 46
            $tags = $this->fixTags($tags);
318
            $this->pushBlock($tags);
319 46
320
            return true;
321 49
        } else {
322
            $this->seek($s);
323
        }
324
325 49
        // closing a block
326
        if ($this->literal('}', false)) {
327 46
            try {
328
                $block = $this->pop();
329
            } catch (\Exception $e) {
330
                $this->seek($s);
331
                $this->throwError($e->getMessage());
332
333 46
                return false; // will never be reached, but silences the ide for now
334 46
            }
335 46
336 46
            $hidden = false;
337 46
            if ($block->type === null) {
338 46
                $hidden = true;
339 46
                if (!isset($block->args)) {
340 46
                    foreach ($block->tags as $tag) {
0 ignored issues
show
Bug introduced by
The expression $block->tags of type array|null is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
341
                        if (!is_string($tag) || $tag[0] !== $this->lessc->getMPrefix()) {
342
                            $hidden = false;
343
                            break;
344
                        }
345 46
                    }
346 46
                }
347 46
348
                foreach ($block->tags as $tag) {
0 ignored issues
show
Bug introduced by
The expression $block->tags of type array|null is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
349
                    if (is_string($tag)) {
350
                        $this->env->children[$tag][] = $block;
351
                    }
352 46
                }
353 46
            }
354
355
            if (!$hidden) {
356
                $this->append(['block', $block], $s);
357
            }
358 46
359
            // this is done here so comments aren't bundled into he block that
360 46
            // was just closed
361
            $this->whitespace();
362
363
            return true;
364 49
        }
365 49
366 49
        // mixin
367
        if ($this->mixinTags($tags) &&
368 22
            ($this->argumentDef($argv, $isVararg) || true) &&
369 22
            ($this->keyword($suffix) || true) && $this->end()
370
        ) {
371 22
            $tags = $this->fixTags($tags);
372
            $this->append(['mixin', $tags, $argv, $suffix], $s);
373 49
374
            return true;
375
        } else {
376
            $this->seek($s);
377 49
        }
378 4
379
        // spare ;
380
        if ($this->literal(';')) {
381 49
            return true;
382
        }
383
384
        return false; // got nothing, throw error
385
    }
386
387
    /**
388
     * @param string $directiveName
389
     * @param array  $directives
390 26
     *
391
     * @return bool
392
     */
393 26
    protected function isDirective($directiveName, array $directives)
394 26
    {
395
        // TODO: cache pattern in parser
396 26
        $pattern = implode('|', array_map([Compiler::class, 'pregQuote'], $directives));
397
        $pattern = '/^(-[a-z-]+-)?(' . $pattern . ')$/i';
398
399
        return (preg_match($pattern, $directiveName) === 1);
400
    }
401
402
    /**
403
     * @param array $tags
404 46
     *
405
     * @return array
406
     */
407 46
    protected function fixTags(array $tags)
408 46
    {
409 46
        // move @ tags out of variable namespace
410
        foreach ($tags as &$tag) {
411
            if ($tag[0] === $this->lessc->getVPrefix()) {
412
                $tag[0] = $this->lessc->getMPrefix();
413 46
            }
414
        }
415
416
        return $tags;
417
    }
418
419
    /**
420
     * a list of expressions
421
     *
422
     * @param $exps
423 48
     *
424
     * @return bool
425 48
     */
426
    protected function expressionList(&$exps)
427 48
    {
428 48
        $values = [];
429
430
        while ($this->expression($exp)) {
431 48
            $values[] = $exp;
432 26
        }
433
434
        if (count($values) === 0) {
435 48
            return false;
436
        }
437 48
438
        $exps = Compiler::compressList($values, ' ');
439
440
        return true;
441
    }
442
443
    /**
444
     * Attempt to consume an expression.
445
     * @link http://en.wikipedia.org/wiki/Operator-precedence_parser#Pseudo-code
446
     *
447
     * @param $out
448 48
     *
449
     * @return bool
450 48
     */
451 48
    protected function expression(&$out)
452
    {
453
        if ($this->value($lhs)) {
454 48
            $out = $this->expHelper($lhs, 0);
455 2
456 2
            // look for / shorthand
457 2
            if (!empty($this->env->supressedDivision)) {
0 ignored issues
show
Bug introduced by
The property supressedDivision does not seem to exist in LesserPhp\Block.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
458
                unset($this->env->supressedDivision);
459 2
                $s = $this->seek();
460 2
                if ($this->literal('/') && $this->value($rhs)) {
461 2
                    $out = [
462
                        'list',
463
                        '',
464
                        [$out, ['keyword', '/'], $rhs],
465
                    ];
466
                } else {
467
                    $this->seek($s);
468 48
                }
469
            }
470
471 48
            return true;
472
        }
473
474
        return false;
475
    }
476
477
    /**
478
     * recursively parse infix equation with $lhs at precedence $minP
479
     *
480
     * @param     $lhs
481
     * @param int $minP
482 48
     *
483
     * @return array
484 48
     */
485 48
    protected function expHelper($lhs, $minP)
486
    {
487 48
        $this->inExp = true;
488 48
        $ss = $this->seek();
489
490
        while (true) {
491
            $whiteBefore = isset($this->buffer[$this->count - 1]) && ctype_space($this->buffer[$this->count - 1]);
492 48
493
            // If there is whitespace before the operator, then we require
494 48
            // whitespace after the operator for it to be an expression
495 48
            $needWhite = $whiteBefore && !$this->inParens;
496
497 20
            if ($this->match(self::$operatorString . ($needWhite ? '\s' : ''), $m) &&
498 20
                self::$precedence[$m[1]] >= $minP
499 20
            ) {
500 20
                if (!$this->inParens &&
501
                    isset($this->env->currentProperty) &&
502 5
                    $m[1] === '/' &&
503 5
                    empty($this->env->supressedDivision)
504 2
                ) {
505 5
                    foreach (self::$supressDivisionProps as $pattern) {
506
                        if (preg_match($pattern, $this->env->currentProperty)) {
0 ignored issues
show
Bug introduced by
The property currentProperty does not seem to exist in LesserPhp\Block.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
507
                            $this->env->supressedDivision = true;
0 ignored issues
show
Bug introduced by
The property supressedDivision does not seem to exist in LesserPhp\Block.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
508
                            break 2;
509
                        }
510 20
                    }
511
                }
512 20
513
                $whiteAfter = isset($this->buffer[$this->count - 1]) && ctype_space($this->buffer[$this->count - 1]);
514
515
                if (!$this->value($rhs)) {
516
                    break;
517 20
                }
518 20
519
                // peek for next operator to see what to do with rhs
520 1
                if ($this->peek(self::$operatorString, $next) &&
521
                    self::$precedence[$next[1]] > self::$precedence[$m[1]]
522
                ) {
523 20
                    $rhs = $this->expHelper($rhs, self::$precedence[$next[1]]);
524 20
                }
525
526 20
                $lhs = ['expression', $m[1], $lhs, $rhs, $whiteBefore, $whiteAfter];
527
                $ss = $this->seek();
528
529 48
                continue;
530
            }
531
532 48
            break;
533
        }
534 48
535
        $this->seek($ss);
536
537
        return $lhs;
538
    }
539
540
    /**
541
     * consume a list of values for a property
542
     *
543
     * @param        $value
544
     * @param string $keyName
545 48
     *
546
     * @return bool
547 48
     */
548
    public function propertyValue(&$value, $keyName = null)
549 48
    {
550 47
        $values = [];
551
552
        if ($keyName !== null) {
553 48
            $this->env->currentProperty = $keyName;
0 ignored issues
show
Bug introduced by
The property currentProperty does not seem to exist in LesserPhp\Block.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
554 48
        }
555 48
556 48
        $s = null;
557 48
        while ($this->expressionList($v)) {
558 48
            $values[] = $v;
559
            $s = $this->seek();
560
            if (!$this->literal(',')) {
561
                break;
562 48
            }
563 48
        }
564
565
        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...
566 48
            $this->seek($s);
567 47
        }
568
569
        if ($keyName !== null) {
570 48
            unset($this->env->currentProperty);
571 3
        }
572
573
        if (count($values) === 0) {
574 48
            return false;
575
        }
576 48
577
        $value = Compiler::compressList($values, ', ');
578
579
        return true;
580
    }
581
582
    /**
583
     * @param $out
584 48
     *
585
     * @return bool
586 48
     */
587
    protected function parenValue(&$out)
588
    {
589 48
        $s = $this->seek();
590 48
591
        // speed shortcut
592
        if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] !== '(') {
593 5
            return false;
594 5
        }
595 5
596 5
        $inParens = $this->inParens;
597
        if ($this->literal('(') &&
598 3
            ($this->inParens = true) && $this->expression($exp) &&
599 3
            $this->literal(')')
600
        ) {
601 3
            $out = $exp;
602
            $this->inParens = $inParens;
603 2
604 2
            return true;
605
        } else {
606
            $this->inParens = $inParens;
607 2
            $this->seek($s);
608
        }
609
610
        return false;
611
    }
612
613
    /**
614
     * a single value
615
     *
616
     * @param array $value
617 48
     *
618
     * @return bool
619 48
     */
620
    protected function value(&$value)
621
    {
622 48
        $s = $this->seek();
623
624 7
        // speed shortcut
625 7
        if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] === '-') {
626 6
            // negation
627 7
            if ($this->literal('-', false) &&
628
                (($this->variable($inner) && $inner = ['variable', $inner]) ||
629 6
                    $this->unit($inner) ||
630
                    $this->parenValue($inner))
631 6
            ) {
632
                $value = ['unary', '-', $inner];
633 2
634
                return true;
635
            } else {
636
                $this->seek($s);
637 48
            }
638 3
        }
639
640 48
        if ($this->parenValue($value)) {
641 38
            return true;
642
        }
643 48
        if ($this->unit($value)) {
644 13
            return true;
645
        }
646 48
        if ($this->color($value)) {
647 25
            return true;
648
        }
649 48
        if ($this->func($value)) {
650 21
            return true;
651
        }
652
        if ($this->stringValue($value)) {
653 48
            return true;
654 35
        }
655
656 35
        if ($this->keyword($word)) {
657
            $value = ['keyword', $word];
658
659
            return true;
660 48
        }
661 29
662
        // try a variable
663 29
        if ($this->variable($var)) {
664
            $value = ['variable', $var];
665
666
            return true;
667 48
        }
668 4
669
        // unquote string (should this work on any type?
670 4
        if ($this->literal('~') && $this->stringValue($str)) {
671
            $value = ['escape', $str];
672 48
673
            return true;
674
        } else {
675
            $this->seek($s);
676 48
        }
677 1
678
        // css hack: \0
679 1
        if ($this->literal('\\') && $this->match('([0-9]+)', $m)) {
680
            $value = ['keyword', '\\' . $m[1]];
681 48
682
            return true;
683
        } else {
684 48
            $this->seek($s);
685
        }
686
687
        return false;
688
    }
689
690
    /**
691
     * an import statement
692
     *
693
     * @param array $out
694 49
     *
695
     * @return bool|null
696 49
     */
697 49
    protected function import(&$out)
698
    {
699
        if (!$this->literal('@import')) {
700
            return false;
701
        }
702
703
        // @import "something.css" media;
704 3
        // @import url("something.css") media;
705 3
        // @import url(something.css) media;
706
707 3
        if ($this->propertyValue($value)) {
708
            $out = ['import', $value];
709
710
            return true;
711
        }
712
713
        return false;
714
    }
715
716
    /**
717
     * @param $out
718 3
     *
719
     * @return bool
720 3
     */
721 3
    protected function mediaQueryList(&$out)
722
    {
723 3
        if ($this->genericList($list, 'mediaQuery', ',', false)) {
724
            $out = $list[2];
725
726
            return true;
727
        }
728
729
        return false;
730
    }
731
732
    /**
733
     * @param $out
734 3
     *
735
     * @return bool
736 3
     */
737
    protected function mediaQuery(&$out)
738 3
    {
739 3
        $s = $this->seek();
740
741 3
        $expressions = null;
742 3
        $parts = [];
743
744 3
        if (($this->literal('only') && ($only = true) || $this->literal('not') && ($not = true) || true) &&
745 3
            $this->keyword($mediaType)
746
        ) {
747
            $prop = ['mediaType'];
748 3
            if (isset($only)) {
749
                $prop[] = 'only';
750
            }
751 3
            if (isset($not)) {
752 3
                $prop[] = 'not';
753
            }
754 1
            $prop[] = $mediaType;
755
            $parts[] = $prop;
756
        } else {
757
            $this->seek($s);
758 3
        }
759
760
761 1
        if (!empty($mediaType) && !$this->literal('and')) {
762 1
            // ~
763 1
        } else {
764
            $this->genericList($expressions, 'mediaExpression', 'and', false);
765
            if (is_array($expressions)) {
766
                $parts = array_merge($parts, $expressions[2]);
767 3
            }
768
        }
769
770
        if (count($parts) === 0) {
771
            $this->seek($s);
772
773 3
            return false;
774
        }
775 3
776
        $out = $parts;
777
778
        return true;
779
    }
780
781
    /**
782
     * @param $out
783 1
     *
784
     * @return bool
785 1
     */
786 1
    protected function mediaExpression(&$out)
787 1
    {
788 1
        $s = $this->seek();
789 1
        $value = null;
790 1
        if ($this->literal('(') &&
791
            $this->keyword($feature) &&
792 1
            ($this->literal(':') && $this->expression($value) || true) &&
793 1
            $this->literal(')')
794 1
        ) {
795
            $out = ['mediaExp', $feature];
796
            if ($value) {
797 1
                $out[] = $value;
798 1
            }
799 1
800
            return true;
801 1
        } elseif ($this->variable($variable)) {
802
            $out = ['variable', $variable];
803
804
            return true;
805
        }
806
807
        $this->seek($s);
808
809
        return false;
810
    }
811
812
    /**
813
     * an unbounded string stopped by $end
814
     *
815
     * @param string   $end
816
     * @param          $out
817
     * @param null     $nestingOpen
818
     * @param string[] $rejectStrs
819 26
     *
820
     * @return bool
821 26
     */
822 26
    protected function openString($end, &$out, $nestingOpen = null, $rejectStrs = null)
823
    {
824 26
        $oldWhite = $this->eatWhiteDefault;
825 26
        $this->eatWhiteDefault = false;
826
827
        $stop = ["'", '"', '@{', $end];
828 26
        $stop = array_map([Compiler::class, 'pregQuote'], $stop);
829 25
        // $stop[] = self::$commentMulti;
830
831
        if ($rejectStrs !== null) {
832 26
            $stop = array_merge($stop, $rejectStrs);
833
        }
834 26
835
        $patt = '(.*?)(' . implode('|', $stop) . ')';
836 26
837 26
        $nestingLevel = 0;
838 26
839 25
        $content = [];
840 25
        while ($this->match($patt, $m, false)) {
841
            if (!empty($m[1])) {
842
                $content[] = $m[1];
843
                if ($nestingOpen) {
844
                    $nestingLevel += substr_count($m[1], $nestingOpen);
845 26
                }
846
            }
847 26
848 26
            $tok = $m[2];
849 14
850 14
            $this->count -= strlen($tok);
851
            if ($tok == $end) {
852
                if ($nestingLevel === 0) {
853
                    break;
854
                } else {
855
                    $nestingLevel--;
856 24
                }
857 13
            }
858 13
859
            if (($tok === "'" || $tok === '"') && $this->stringValue($str)) {
860
                $content[] = $str;
861 23
                continue;
862 3
            }
863 3
864
            if ($tok === '@{' && $this->interpolation($inter)) {
865
                $content[] = $inter;
866 23
                continue;
867 23
            }
868
869
            if (!empty($rejectStrs) && in_array($tok, $rejectStrs)) {
870
                break;
871
            }
872
873
            $content[] = $tok;
874 26
            $this->count += strlen($tok);
875
        }
876 26
877 4
        $this->eatWhiteDefault = $oldWhite;
878
879
        if (count($content) === 0) {
880
            return false;
881 25
        }
882 25
883
        // trim the end
884
        if (is_string(end($content))) {
885 25
            $content[count($content) - 1] = rtrim(end($content));
886
        }
887 25
888
        $out = ['string', '', $content];
889
890
        return true;
891
    }
892
893
    /**
894
     * @param $out
895 48
     *
896
     * @return bool
897 48
     */
898 48
    protected function stringValue(&$out)
899 22
    {
900 48
        $s = $this->seek();
901 12
        if ($this->literal('"', false)) {
902
            $delim = '"';
903 48
        } elseif ($this->literal("'", false)) {
904
            $delim = "'";
905
        } else {
906 24
            return false;
907
        }
908
909
        $content = [];
910 24
911
        // look for either ending delim , escape, or string interpolation
912 24
        $patt = '([^\n]*?)(@\{|\\\\|' .
913 24
            Compiler::pregQuote($delim) . ')';
914
915 24
        $oldWhite = $this->eatWhiteDefault;
916 24
        $this->eatWhiteDefault = false;
917 24
918 6
        while ($this->match($patt, $m, false)) {
919 6
            $content[] = $m[1];
920 6
            if ($m[2] === '@{') {
921
                $this->count -= strlen($m[2]);
922 1
                if ($this->interpolation($inter)) {
923 6
                    $content[] = $inter;
924
                } else {
925 24
                    $this->count += strlen($m[2]);
926 2
                    $content[] = '@{'; // ignore it
927 2
                }
928 2
            } elseif ($m[2] === '\\') {
929
                $content[] = $m[2];
930
                if ($this->literal($delim, false)) {
931 24
                    $content[] = $delim;
932 24
                }
933
            } else {
934
                $this->count -= strlen($delim);
935
                break; // delim
936 24
            }
937
        }
938 24
939 24
        $this->eatWhiteDefault = $oldWhite;
940
941 24
        if ($this->literal($delim)) {
942
            $out = ['string', $delim, $content];
943
944 1
            return true;
945
        }
946 1
947
        $this->seek($s);
948
949
        return false;
950
    }
951
952
    /**
953
     * @param $out
954 19
     *
955
     * @return bool
956 19
     */
957 19
    protected function interpolation(&$out)
958
    {
959 19
        $oldWhite = $this->eatWhiteDefault;
960 19
        $this->eatWhiteDefault = true;
961 19
962 19
        $s = $this->seek();
963
        if ($this->literal('@{') &&
964 7
            $this->openString('}', $interp, null, ["'", '"', ';']) &&
965 7
            $this->literal('}', false)
966 7
        ) {
967 1
            $out = ['interpolate', $interp];
968
            $this->eatWhiteDefault = $oldWhite;
969
            if ($this->eatWhiteDefault) {
970 7
                $this->whitespace();
971
            }
972
973 16
            return true;
974 16
        }
975
976 16
        $this->eatWhiteDefault = $oldWhite;
977
        $this->seek($s);
978
979
        return false;
980
    }
981
982
    /**
983
     * @param $unit
984 49
     *
985
     * @return bool
986
     */
987 49
    protected function unit(&$unit)
988 49
    {
989 49
        // speed shortcut
990 49
        if (isset($this->buffer[$this->count])) {
991
            $char = $this->buffer[$this->count];
992
            if (!ctype_digit($char) && $char !== '.') {
993
                return false;
994 49
            }
995 38
        }
996
997 38
        if ($this->match('([0-9]+(?:\.[0-9]*)?|\.[0-9]+)([%a-zA-Z]+)?', $m)) {
998
            $unit = ['number', $m[1], empty($m[2]) ? '' : $m[2]];
999
1000 49
            return true;
1001
        }
1002
1003
        return false;
1004
    }
1005
1006
1007
    /**
1008
     * a # color
1009
     *
1010
     * @param $out
1011 48
     *
1012
     * @return bool
1013 48
     */
1014 13
    protected function color(&$out)
1015 1
    {
1016
        if ($this->match('(#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3}))', $m)) {
1017 13
            if (strlen($m[1]) > 7) {
1018
                $out = ['string', '', [$m[1]]];
1019
            } else {
1020 13
                $out = ['raw_color', $m[1]];
1021
            }
1022
1023 48
            return true;
1024
        }
1025
1026
        return false;
1027
    }
1028
1029
    /**
1030
     * consume an argument definition list surrounded by ()
1031
     * each argument is a variable name with optional value
1032
     * or at the end a ... or a variable named followed by ...
1033
     * arguments are separated by , unless a ; is in the list, then ; is the
1034
     * delimiter.
1035
     *
1036
     * @param $args
1037
     * @param $isVararg
1038
     *
1039 45
     * @return bool
1040
     * @throws \LesserPhp\Exception\GeneralException
1041 45
     */
1042 45
    protected function argumentDef(&$args, &$isVararg)
1043 45
    {
1044
        $s = $this->seek();
1045
        if (!$this->literal('(')) {
1046 19
            return false;
1047 19
        }
1048 19
1049
        $values = [];
1050 19
        $delim = ',';
1051 19
        $method = 'expressionList';
1052 19
        $value = [];
1053 2
        $rhs = null;
1054 2
1055
        $isVararg = false;
1056
        while (true) {
1057 19
            if ($this->literal('...')) {
1058 16
                $isVararg = true;
1059 16
                break;
1060 16
            }
1061
1062 16
            if ($this->$method($value)) {
1063 9
                if ($value[0] === 'variable') {
1064
                    $arg = ['arg', $value[1]];
1065 13
                    $ss = $this->seek();
1066 13
1067 2
                    if ($this->assign() && $this->$method($rhs)) {
1068 2
                        $arg[] = $rhs;
1069
                    } else {
1070
                        $this->seek($ss);
1071
                        if ($this->literal('...')) {
1072 16
                            $arg[0] = 'rest';
1073 16
                            $isVararg = true;
1074 2
                        }
1075
                    }
1076 16
1077
                    $values[] = $arg;
1078 15
                    if ($isVararg) {
1079
                        break;
1080
                    }
1081
                    continue;
1082
                } else {
1083 19
                    $values[] = ['lit', $value];
1084 19
                }
1085
            }
1086 2
1087 2
1088 2
            if (!$this->literal($delim)) {
1089
                if ($delim === ',' && $this->literal(';')) {
1090
                    // found new delim, convert existing args
1091 2
                    $delim = ';';
1092 2
                    $method = 'propertyValue';
1093 2
                    $newArg = null;
1094 2
1095 2
                    // transform arg list
1096 2
                    if (isset($values[1])) { // 2 items
1097
                        $newList = [];
1098
                        foreach ($values as $i => $arg) {
1099 2
                            switch ($arg[0]) {
1100 2
                                case 'arg':
1101 2
                                    if ($i) {
1102 2
                                        throw new GeneralException('Cannot mix ; and , as delimiter types');
1103 2
                                    }
1104
                                    $newList[] = $arg[2];
1105 2
                                    break;
1106
                                case 'lit':
1107
                                    $newList[] = $arg[1];
1108
                                    break;
1109 2
                                case 'rest':
1110
                                    throw new GeneralException('Unexpected rest before semicolon');
1111 2
                            }
1112 2
                        }
1113 2
1114 2
                        $newList = ['list', ', ', $newList];
1115 1
1116 1
                        switch ($values[0][0]) {
1117 2
                            case 'arg':
1118
                                $newArg = ['arg', $values[0][1], $newList];
1119 2
                                break;
1120 2
                            case 'lit':
1121
                                $newArg = ['lit', $newList];
1122
                                break;
1123 2
                        }
1124 2
                    } 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...
1125
                        $newArg = $values[0];
1126
                    }
1127 19
1128
                    if ($newArg !== null) {
1129
                        $values = [$newArg];
1130
                    }
1131
                } else {
1132 19
                    break;
1133
                }
1134
            }
1135
        }
1136
1137
        if (!$this->literal(')')) {
1138 19
            $this->seek($s);
1139
1140 19
            return false;
1141
        }
1142
1143
        $args = $values;
1144
1145
        return true;
1146
    }
1147
1148
    /**
1149
     * consume a list of tags
1150
     * this accepts a hanging delimiter
1151
     *
1152
     * @param array  $tags
1153 49
     * @param bool   $simple
1154
     * @param string $delim
1155 49
     *
1156 49
     * @return bool
1157 46
     */
1158 46
    protected function tags(&$tags, $simple = false, $delim = ',')
1159 46
    {
1160
        $tags = [];
1161
        while ($this->tag($tt, $simple)) {
1162
            $tags[] = $tt;
1163 49
            if (!$this->literal($delim)) {
1164
                break;
1165
            }
1166
        }
1167
1168
        return count($tags) !== 0;
1169
    }
1170
1171
    /**
1172
     * list of tags of specifying mixin path
1173
     * optionally separated by > (lazy, accepts extra >)
1174 49
     *
1175
     * @param array $tags
1176 49
     *
1177 49
     * @return bool
1178 22
     */
1179 22
    protected function mixinTags(&$tags)
1180
    {
1181
        $tags = [];
1182 49
        while ($this->tag($tt, true)) {
1183
            $tags[] = $tt;
1184
            $this->literal('>');
1185
        }
1186
1187
        return count($tags) !== 0;
1188
    }
1189
1190
    /**
1191
     * a bracketed value (contained within in a tag definition)
1192
     *
1193 49
     * @param array $parts
1194
     * @param bool  $hasExpression
1195
     *
1196 49
     * @return bool
1197 47
     */
1198
    protected function tagBracket(&$parts, &$hasExpression)
1199
    {
1200 49
        // speed shortcut
1201
        if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] !== '[') {
1202 49
            return false;
1203
        }
1204 49
1205 3
        $s = $this->seek();
1206
1207 3
        $hasInterpolation = false;
1208 3
1209 3
        if ($this->literal('[', false)) {
1210 3
            $attrParts = ['['];
1211
            // keyword, string, operator
1212
            while (true) {
1213 3
                if ($this->literal(']', false)) {
1214
                    $this->count--;
1215
                    break; // get out early
1216
                }
1217 3
1218
                if ($this->match('\s+', $m)) {
1219 3
                    $attrParts[] = ' ';
1220 3
                    continue;
1221
                }
1222
                if ($this->stringValue($str)) {
1223 3
                    // escape parent selector, (yuck)
1224 3
                    foreach ($str[2] as &$chunk) {
1225 3
                        if (is_string($chunk)) {
1226
                            $chunk = str_replace($this->lessc->getParentSelector(), '$&$', $chunk);
1227
                        }
1228 3
                    }
1229 3
1230 3
                    $attrParts[] = $str;
1231
                    $hasInterpolation = true;
1232
                    continue;
1233 3
                }
1234 1
1235 1
                if ($this->keyword($word)) {
1236 1
                    $attrParts[] = $word;
1237
                    continue;
1238
                }
1239
1240 3
                if ($this->interpolation($inter)) {
1241 3
                    $attrParts[] = $inter;
1242 3
                    $hasInterpolation = true;
1243
                    continue;
1244
                }
1245
1246
                // operator, handles attr namespace too
1247
                if ($this->match('[|-~\$\*\^=]+', $m)) {
1248 3
                    $attrParts[] = $m[0];
1249 3
                    continue;
1250 3
                }
1251 3
1252
                break;
1253 3
            }
1254
1255 3
            if ($this->literal(']', false)) {
1256
                $attrParts[] = ']';
1257
                foreach ($attrParts as $part) {
1258
                    $parts[] = $part;
1259
                }
1260 49
                $hasExpression = $hasExpression || $hasInterpolation;
1261
1262 49
                return true;
1263
            }
1264
            $this->seek($s);
1265
        }
1266
1267
        $this->seek($s);
1268
1269
        return false;
1270
    }
1271
1272
    /**
1273 49
     * a space separated list of selectors
1274
     *
1275 49
     * @param      $tag
1276 49
     * @param bool $simple
1277
     *
1278 49
     * @return bool
1279
     */
1280
    protected function tag(&$tag, $simple = false)
1281 49
    {
1282
        if ($simple) {
1283 49
            $chars = '^@,:;{}\][>\(\) "\'';
1284 49
        } else {
1285 49
            $chars = '^@,;{}["\'';
1286
        }
1287
1288
        $s = $this->seek();
1289 49
1290 49
        $hasExpression = false;
1291
        $parts = [];
1292 49
        while ($this->tagBracket($parts, $hasExpression)) {
1293 49
            ;
1294 46
        }
1295 46
1296 45
        $oldWhite = $this->eatWhiteDefault;
1297
        $this->eatWhiteDefault = false;
1298
1299 46
        while (true) {
1300
            if ($this->match('([' . $chars . '0-9][' . $chars . ']*)', $m)) {
1301
                $parts[] = $m[1];
1302 46
                if ($simple) {
1303
                    break;
1304
                }
1305 49
1306 13
                while ($this->tagBracket($parts, $hasExpression)) {
1307 2
                    ;
1308 2
                }
1309 2
                continue;
1310 2
            }
1311
1312
            if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] === '@') {
1313 12
                if ($this->interpolation($interp)) {
1314 12
                    $hasExpression = true;
1315 12
                    $interp[2] = true; // don't unescape
1316
                    $parts[] = $interp;
1317
                    continue;
1318
                }
1319 49
1320 9
                if ($this->literal('@')) {
1321 9
                    $parts[] = '@';
1322 9
                    continue;
1323
                }
1324
            }
1325 49
1326
            if ($this->unit($unit)) { // for keyframes
1327
                $parts[] = $unit[1];
1328 49
                $parts[] = $unit[2];
1329 49
                continue;
1330 49
            }
1331
1332 49
            break;
1333
        }
1334
1335 46
        $this->eatWhiteDefault = $oldWhite;
1336 4
        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...
1337
            $this->seek($s);
1338 46
1339
            return false;
1340
        }
1341 46
1342
        if ($hasExpression) {
1343 46
            $tag = ['exp', ['string', '', $parts]];
1344
        } else {
1345
            $tag = trim(implode($parts));
1346
        }
1347
1348
        $this->whitespace();
1349
1350
        return true;
1351
    }
1352
1353 48
    /**
1354
     * a css function
1355 48
     *
1356
     * @param array $func
1357 48
     *
1358 25
     * @return bool
1359
     */
1360 25
    protected function func(&$func)
1361
    {
1362 25
        $s = $this->seek();
1363 25
1364 25
        if ($this->match('(%|[\w\-_][\w\-_:\.]+|[\w_])', $m) && $this->literal('(')) {
1365
            $fname = $m[1];
1366 25
1367 1
            $sPreArgs = $this->seek();
1368
1369 24
            $args = [];
1370 24
            while (true) {
1371 21
                $ss = $this->seek();
1372
                // this ugly nonsense is for ie filter properties
1373
                if ($this->keyword($name) && $this->literal('=') && $this->expressionList($value)) {
1374
                    $args[] = ['string', '', [$name, '=', $value]];
1375 25
                } else {
1376 25
                    $this->seek($ss);
1377
                    if ($this->expressionList($value)) {
1378
                        $args[] = $value;
1379 25
                    }
1380
                }
1381 25
1382 25
                if (!$this->literal(',')) {
1383
                    break;
1384 25
                }
1385 7
            }
1386
            $args = ['list', ',', $args];
1387 6
1388 6
            if ($this->literal(')')) {
1389 6
                $func = ['function', $fname, $args];
1390
1391 6
                return true;
1392
            } elseif ($fname === 'url') {
1393
                // couldn't parse and in url? treat as string
1394
                $this->seek($sPreArgs);
1395
                if ($this->openString(')', $string) && $this->literal(')')) {
1396 48
                    $func = ['function', $fname, $string];
1397
1398 48
                    return true;
1399
                }
1400
            }
1401
        }
1402
1403
        $this->seek($s);
1404
1405
        return false;
1406
    }
1407
1408 49
    /**
1409
     * consume a less variable
1410 49
     *
1411 49
     * @param $name
1412 49
     *
1413
     * @return bool
1414 32
     */
1415 1
    protected function variable(&$name)
1416
    {
1417 32
        $s = $this->seek();
1418
        if ($this->literal($this->lessc->getVPrefix(), false) &&
1419
            ($this->variable($sub) || $this->keyword($name))
1420 32
        ) {
1421
            if (!empty($sub)) {
1422
                $name = ['variable', $sub];
1423 49
            } else {
1424 49
                $name = $this->lessc->getVPrefix() . $name;
1425
            }
1426 49
1427
            return true;
1428
        }
1429
1430
        $name = null;
1431
        $this->seek($s);
1432
1433
        return false;
1434
    }
1435
1436
    /**
1437 48
     * Consume an assignment operator
1438
     * Can optionally take a name that will be set to the current property name
1439 48
     *
1440
     * @param string $name
1441
     *
1442
     * @return bool
1443 48
     */
1444
    protected function assign($name = null)
1445
    {
1446
        if ($name !== null) {
1447
            $this->currentProperty = $name;
1448
        }
1449
1450
        return $this->literal(':') || $this->literal('=');
1451
    }
1452
1453 49
    /**
1454
     * consume a keyword
1455 49
     *
1456 48
     * @param $word
1457
     *
1458 48
     * @return bool
1459
     */
1460
    protected function keyword(&$word)
1461 49
    {
1462
        if ($this->match('([\w_\-\*!"][\w\-_"]*)', $m)) {
1463
            $word = $m[1];
1464
1465
            return true;
1466
        }
1467
1468
        return false;
1469 48
    }
1470
1471 48
    /**
1472 48
     * consume an end of statement delimiter
1473 12
     *
1474
     * @return bool
1475 10
     */
1476
    protected function end()
1477
    {
1478 2
        if ($this->literal(';', false)) {
1479
            return true;
1480
        } elseif ($this->count === strlen($this->buffer) || $this->buffer[$this->count] === '}') {
1481
            // if there is end of file or a closing block next then we don't need a ;
1482
            return true;
1483
        }
1484
1485
        return false;
1486 19
    }
1487
1488 19
    /**
1489
     * @param $guards
1490 19
     *
1491 19
     * @return bool
1492
     */
1493 19
    protected function guards(&$guards)
1494
    {
1495
        $s = $this->seek();
1496 5
1497
        if (!$this->literal('when')) {
1498 5
            $this->seek($s);
1499 5
1500 5
            return false;
1501 5
        }
1502
1503
        $guards = [];
1504
1505 5
        while ($this->guardGroup($g)) {
1506
            $guards[] = $g;
1507
            if (!$this->literal(',')) {
1508
                break;
1509
            }
1510
        }
1511
1512 5
        if (count($guards) === 0) {
1513
            $guards = null;
1514
            $this->seek($s);
1515
1516
            return false;
1517
        }
1518
1519
        return true;
1520
    }
1521
1522 5
    /**
1523
     * a bunch of guards that are and'd together
1524 5
     *
1525 5
     * @param $guardGroup
1526 5
     *
1527 5
     * @return bool
1528 5
     */
1529 5
    protected function guardGroup(&$guardGroup)
1530
    {
1531
        $s = $this->seek();
1532
        $guardGroup = [];
1533 5
        while ($this->guard($guard)) {
1534
            $guardGroup[] = $guard;
1535
            if (!$this->literal('and')) {
1536
                break;
1537
            }
1538
        }
1539
1540 5
        if (count($guardGroup) === 0) {
1541
            $guardGroup = null;
1542
            $this->seek($s);
1543
1544
            return false;
1545
        }
1546
1547
        return true;
1548 5
    }
1549
1550 5
    /**
1551 5
     * @param $guard
1552
     *
1553 5
     * @return bool
1554 5
     */
1555 5
    protected function guard(&$guard)
1556 1
    {
1557
        $s = $this->seek();
1558
        $negate = $this->literal('not');
1559 5
1560
        if ($this->literal('(') && $this->expression($exp) && $this->literal(')')) {
1561
            $guard = $exp;
1562
            if ($negate) {
1563
                $guard = ['negate', $guard];
1564
            }
1565
1566
            return true;
1567
        }
1568
1569
        $this->seek($s);
1570
1571
        return false;
1572
    }
1573
1574
    /* raw parsing functions */
1575 49
1576
    /**
1577 49
     * @param string $what
1578 49
     * @param bool   $eatWhitespace
1579
     *
1580
     * @return bool
1581
     */
1582 49
    protected function literal($what, $eatWhitespace = null)
1583 49
    {
1584 49
        if ($eatWhitespace === null) {
1585 49
            $eatWhitespace = $this->eatWhiteDefault;
1586
        }
1587 49
1588
        // shortcut on single letter
1589
        if (!isset($what[1]) && isset($this->buffer[$this->count])) {
1590
            if ($this->buffer[$this->count] === $what) {
1591 49
                if (!$eatWhitespace) {
1592
                    $this->count++;
1593
1594
                    return true;
1595 49
                }
1596 11
                // goes below...
1597
            } else {
1598
                return false;
1599 49
            }
1600
        }
1601
1602
        if (!isset(self::$literalCache[$what])) {
1603
            self::$literalCache[$what] = Compiler::pregQuote($what);
1604
        }
1605
1606
        return $this->match(self::$literalCache[$what], $m, $eatWhitespace);
1607
    }
1608
1609
    /**
1610 3
     * @param        $out
1611
     * @param string $parseItem
1612
     * @param string $delim
1613 3
     * @param bool   $flatten
1614 3
     *
1615 3
     * @return bool
1616 3
     */
1617 3
    protected function genericList(&$out, $parseItem, $delim = "", $flatten = true)
1618 3
    {
1619 3
        // $parseItem is one of mediaQuery, mediaExpression
1620
        $s = $this->seek();
1621
        $items = [];
1622
        $value = null;
1623
        while ($this->$parseItem($value)) {
1624 3
            $items[] = $value;
1625
            if ($delim) {
1626
                if (!$this->literal($delim)) {
1627
                    break;
1628
                }
1629
            }
1630 3
        }
1631
1632
        if (count($items) === 0) {
1633 3
            $this->seek($s);
1634
1635
            return false;
1636 3
        }
1637
1638
        if ($flatten && count($items) === 1) {
1639
            $out = $items[0];
1640
        } else {
1641
            $out = ['list', $delim, $items];
1642
        }
1643
1644
        return true;
1645
    }
1646
1647
    /**
1648
     * try to match something on head of buffer
1649
     *
1650
     * @param string $regex
1651
     * @param        $out
1652
     * @param bool   $eatWhitespace
1653
     *
1654
     * @return bool
1655
     */
1656
    protected function match($regex, &$out, $eatWhitespace = null)
1657
    {
1658
        if ($eatWhitespace === null) {
1659
            $eatWhitespace = $this->eatWhiteDefault;
1660
        }
1661
1662
        $r = '/' . $regex . ($eatWhitespace && !$this->writeComments ? '\s*' : '') . '/Ais';
1663
        if (preg_match($r, $this->buffer, $out, null, $this->count)) {
1664
            $this->count += strlen($out[0]);
1665
            if ($eatWhitespace && $this->writeComments) {
1666
                $this->whitespace();
1667
            }
1668
1669
            return true;
1670
        }
1671
1672
        return false;
1673
    }
1674
1675
    /**
1676
     * match some whitespace
1677
     *
1678
     * @return bool
1679 49
     */
1680
    protected function whitespace()
1681 49
    {
1682 49
        if ($this->writeComments) {
1683
            $gotWhite = false;
1684
            while (preg_match(self::$whitePattern, $this->buffer, $m, null, $this->count)) {
1685 49
                if (isset($m[1]) && empty($this->seenComments[$this->count])) {
1686 49
                    $this->append(['comment', $m[1]]);
1687 49
                    $this->seenComments[$this->count] = true;
1688 49
                }
1689 1
                $this->count += mb_strlen($m[0]);
1690
                $gotWhite = true;
1691
            }
1692 49
1693
            return $gotWhite;
1694
        }
1695 49
1696
        $this->match('', $m);
1697
1698
        return mb_strlen($m[0]) > 0;
1699
    }
1700
1701
    /**
1702
     * match something without consuming it
1703 49
     *
1704
     * @param string $regex
1705 49
     * @param array  $out
1706 1
     * @param int    $from
1707 1
     *
1708 1
     * @return int
1709 1
     */
1710 1
    protected function peek($regex, &$out = null, $from = null)
1711
    {
1712 1
        if ($from === null) {
1713 1
            $from = $this->count;
1714
        }
1715
        $r = '/' . $regex . '/Ais';
1716 1
1717
        return preg_match($r, $this->buffer, $out, null, $from);
1718
    }
1719 49
1720 49
    /**
1721
     * seek to a spot in the buffer or return where we are on no argument
1722
     *
1723
     * @param int $where
1724
     *
1725
     * @return int
1726
     */
1727
    protected function seek($where = null)
1728
    {
1729
        if ($where !== null) {
1730
            $this->count = $where;
1731
        }
1732 24
1733
        return $this->count;
1734 24
    }
1735 20
1736
    /* misc functions */
1737 24
1738
    /**
1739 24
     * @param string $msg
1740
     * @param int    $count
1741
     *
1742
     * @throws \LesserPhp\Exception\GeneralException
1743
     */
1744
    public function throwError($msg = 'parse error', $count = null)
1745
    {
1746
        $count = $count === null ? $this->count : $count;
1747
1748
        $line = $this->line + substr_count(substr($this->buffer, 0, $count), "\n");
1749 49
1750
        if (!empty($this->sourceName)) {
1751 49
            $loc = "$this->sourceName on line $line";
1752 49
        } else {
1753
            $loc = "line: $line";
1754
        }
1755 49
1756
        // TODO this depends on $this->count
1757
        if ($this->peek("(.*?)(\n|$)", $m, $count)) {
1758
            throw new GeneralException("$msg: failed at `$m[1]` $loc");
1759
        } else {
1760
            throw new GeneralException("$msg: $loc");
1761
        }
1762
    }
1763
1764
    /**
1765
     * @param array|null  $selectors
1766 5
     * @param string|null $type
1767
     *
1768 5
     * @return Block
1769
     */
1770 5
    protected function pushBlock(array $selectors = null, $type = null)
1771
    {
1772 5
        $this->env = Block::factory($this, self::$nextBlockId++, $this->count, $type, $selectors, $this->env);
1773
1774
        return $this->env;
1775 5
    }
1776
1777
    /**
1778
     * push a block that doesn't multiply tags
1779 5
     *
1780 5
     * @param string $type
1781
     *
1782
     * @return Block|Block\Directive|Block\Media
1783
     */
1784
    protected function pushSpecialBlock($type)
1785
    {
1786
        return $this->pushBlock(null, $type);
1787
    }
1788
1789
    /**
1790
     * append a property to the current block
1791
     *
1792 49
     * @param      $prop
1793
     * @param int  $pos
1794 49
     */
1795 49
    protected function append($prop, $pos = null)
1796
    {
1797 49
        if ($pos !== null) {
1798 49
            $prop[-1] = $pos;
1799
        }
1800 49
1801 49
        $property = Property::factoryFromOldFormat($prop, $pos);
1802
1803 49
        $this->env->props[] = $property;
1804 49
    }
1805
1806
    /**
1807
     * pop something off the stack
1808
     *
1809 49
     * @return Block
1810
     */
1811
    protected function pop()
1812 49
    {
1813
        $old = $this->env;
1814 49
        $this->env = $this->env->parent;
1815
1816 49
        return $old;
1817
    }
1818
1819
    /**
1820
     * remove comments from $text
1821
     * todo: make it work for all functions, not just url
1822
     *
1823
     * @param string $text
1824
     *
1825
     * @return string
1826 49
     */
1827
    protected function removeComments($text)
1828 49
    {
1829
        $look = [
1830
            'url(',
1831
            '//',
1832
            '/*',
1833
            '"',
1834
            "'",
1835
        ];
1836
1837 49
        $out = '';
1838
        $min = null;
1839 49
        while (true) {
1840 49
            // find the next item
1841
            foreach ($look as $token) {
1842 49
                $pos = mb_strpos($text, $token);
1843 49
                if ($pos !== false) {
1844
                    if ($min === null || $pos < $min[1]) {
1845
                        $min = [$token, $pos];
1846
                    }
1847
                }
1848
            }
1849
1850 46
            if ($min === null) {
1851
                break;
1852 46
            }
1853 46
1854
            $count = $min[1];
1855 46
            $skip = 0;
1856
            $newlines = 0;
1857
            switch ($min[0]) {
1858
                case 'url(':
1859
                    if (preg_match('/url\(.*?\)/', $text, $m, 0, $count)) {
1860
                        $count += mb_strlen($m[0]) - mb_strlen($min[0]);
1861
                    }
1862
                    break;
1863
                case '"':
1864
                case "'":
1865
                    if (preg_match('/' . $min[0] . '.*?(?<!\\\\)' . $min[0] . '/', $text, $m, 0, $count)) {
1866 49
                        $count += mb_strlen($m[0]) - 1;
1867
                    }
1868
                    break;
1869 49
                case '//':
1870
                    $skip = mb_strpos($text, "\n", $count);
1871
                    if ($skip === false) {
1872
                        $skip = mb_strlen($text) - $count;
1873
                    } else {
1874
                        $skip -= $count;
1875
                    }
1876 49
                    break;
1877 49
                case '/*':
1878 49
                    if (preg_match('/\/\*.*?\*\//s', $text, $m, 0, $count)) {
1879
                        $skip = mb_strlen($m[0]);
1880 49
                        $newlines = mb_substr_count($m[0], "\n");
1881 49
                    }
1882 49
                    break;
1883 27
            }
1884 49
1885
            if ($skip === 0) {
1886
                $count += mb_strlen($min[0]);
1887
            }
1888
1889 49
            $out .= mb_substr($text, 0, $count) . str_repeat("\n", $newlines);
1890 49
            $text = mb_substr($text, $count + $skip);
1891
1892
            $min = null;
1893 27
        }
1894 27
1895 27
        return $out . $text;
1896 27
    }
1897 27
1898 6
    /**
1899 6
     * @param bool $writeComments
1900
     */
1901 6
    public function setWriteComments($writeComments)
1902 27
    {
1903 22
        $this->writeComments = $writeComments;
1904 24
    }
1905 24
1906
    /**
1907 24
     * @param int $s
1908 18
     *
1909 18
     * @return bool
1910 18
     */
1911
    protected function handleLiteralMedia($s)
1912
    {
1913 18
        // seriously, this || true is required for this statement to work!?
1914
        if (($this->mediaQueryList($mediaQueries) || true) && $this->literal('{')) {
1915 18
            $media = $this->pushSpecialBlock('media');
1916 4
            $media->queries = $mediaQueries === null ? [] : $mediaQueries;
0 ignored issues
show
Bug introduced by
The property queries does not seem to exist in LesserPhp\Block.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
1917 4
1918 4
            return true;
1919 4
        } else {
1920
            $this->seek($s);
1921 4
        }
1922
1923
        return false;
1924 27
    }
1925 24
1926
    /**
1927
     * @param string $directiveName
1928 27
     *
1929 27
     * @return bool
1930
     */
1931 27
    protected function handleDirectiveBlock($directiveName)
1932
    {
1933
        // seriously, this || true is required for this statement to work!?
1934 49
        if (($this->openString('{', $directiveValue, null, [';']) || true) && $this->literal('{')) {
1935
            $dir = $this->pushSpecialBlock('directive');
1936
            $dir->name = $directiveName;
0 ignored issues
show
Bug introduced by
The property name does not seem to exist in LesserPhp\Block.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
1937
            if ($directiveValue !== null) {
1938
                $dir->value = $directiveValue;
0 ignored issues
show
Bug introduced by
The property value does not seem to exist in LesserPhp\Block.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
1939
            }
1940 49
1941
            return true;
1942 49
        }
1943 49
1944
        return false;
1945
    }
1946
1947
    /**
1948
     * @param string $directiveName
1949
     *
1950 3
     * @return bool
1951
     */
1952
    protected function handleDirectiveLine($directiveName)
1953 3
    {
1954 3
        if ($this->propertyValue($directiveValue) && $this->end()) {
1955 3
            $this->append(['directive', $directiveName, $directiveValue]);
1956
1957 3
            return true;
1958
        }
1959
1960
        return false;
1961
    }
1962
1963
    /**
1964
     * @param string $directiveName
1965
     *
1966
     * @return bool
1967
     */
1968
    protected function handleRulesetDefinition($directiveName)
1969
    {
1970 4
        //Ruleset Definition
1971
        $this->openString('{', $directiveValue, null, [';']);
1972
1973 4
        if ($this->literal('{')) {
1974 4
            $dir = $this->pushBlock($this->fixTags(['@' . $directiveName]), 'ruleset');
1975 4
            if (!$dir instanceof Block\Ruleset) {
1976 4
                throw new \RuntimeException('Block factory did not produce a Ruleset');
1977 2
            }
1978
1979
            $dir->name = $directiveName;
1980 4
            if ($directiveValue !== null) {
1981
                $dir->value = $directiveValue;
1982
            }
1983 1
1984
            return true;
1985
        }
1986
1987
        return false;
1988
    }
1989
1990
    private function clearBlockStack()
1991 1
    {
1992
        $this->env = null;
1993 1
    }
1994
}
1995