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