PluginProbe
SendWP / 1.4.4
SendWP v1.4.4
trunk 0.0.1 0.0.2 0.0.3 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1 1.2.10 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 1.3 1.3.1 1.4.4 1.4.5 1.4.6 1.4.8
sendwp / vendor / scssphp / scssphp / src / Parser.php

Parser.php in SendWP 1.4.4, at vendor/scssphp/scssphp/src/Parser.php

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