Merge branch 'MDL-76525-MOODLE_400_STABLE' of https://github.com/PhMemmel/moodle...
[moodle.git] / lib / scssphp / Parser.php
blob36e7ac036151cfa6343f73eb9eb88f8a45459667
1 <?php
3 /**
4 * SCSSPHP
6 * @copyright 2012-2020 Leaf Corcoran
8 * @license http://opensource.org/licenses/MIT MIT
10 * @link http://scssphp.github.io/scssphp
13 namespace ScssPhp\ScssPhp;
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;
31 /**
32 * Parser
34 * @author Leaf Corcoran <leafot@gmail.com>
36 * @internal
38 class Parser
40 const SOURCE_INDEX = -1;
41 const SOURCE_LINE = -2;
42 const SOURCE_COLUMN = -3;
44 /**
45 * @var array<string, int>
47 protected static $precedence = [
48 '=' => 0,
49 'or' => 1,
50 'and' => 2,
51 '==' => 3,
52 '!=' => 3,
53 '<=' => 4,
54 '>=' => 4,
55 '<' => 4,
56 '>' => 4,
57 '+' => 5,
58 '-' => 5,
59 '*' => 6,
60 '/' => 6,
61 '%' => 6,
64 /**
65 * @var string
67 protected static $commentPattern;
68 /**
69 * @var string
71 protected static $operatorPattern;
72 /**
73 * @var string
75 protected static $whitePattern;
77 /**
78 * @var Cache|null
80 protected $cache;
82 private $sourceName;
83 private $sourceIndex;
84 /**
85 * @var array<int, int>
87 private $sourcePositions;
88 /**
89 * @var array|null
91 private $charset;
92 /**
93 * The current offset in the buffer
95 * @var int
97 private $count;
98 /**
99 * @var Block|null
101 private $env;
103 * @var bool
105 private $inParens;
107 * @var bool
109 private $eatWhiteDefault;
111 * @var bool
113 private $discardComments;
114 private $allowVars;
116 * @var string
118 private $buffer;
119 private $utf8;
121 * @var string|null
123 private $encoding;
124 private $patternModifiers;
125 private $commentsSeen;
127 private $cssOnly;
130 * @var LoggerInterface
132 private $logger;
135 * Constructor
137 * @api
139 * @param string|null $sourceName
140 * @param int $sourceIndex
141 * @param string|null $encoding
142 * @param Cache|null $cache
143 * @param bool $cssOnly
144 * @param LoggerInterface|null $logger
146 public function __construct($sourceName, $sourceIndex = 0, $encoding = 'utf-8', Cache $cache = null, $cssOnly = false, LoggerInterface $logger = null)
148 $this->sourceName = $sourceName ?: '(stdin)';
149 $this->sourceIndex = $sourceIndex;
150 $this->charset = null;
151 $this->utf8 = ! $encoding || strtolower($encoding) === 'utf-8';
152 $this->patternModifiers = $this->utf8 ? 'Aisu' : 'Ais';
153 $this->commentsSeen = [];
154 $this->commentsSeen = [];
155 $this->allowVars = true;
156 $this->cssOnly = $cssOnly;
157 $this->logger = $logger ?: new QuietLogger();
159 if (empty(static::$operatorPattern)) {
160 static::$operatorPattern = '([*\/%+-]|[!=]\=|\>\=?|\<\=?|and|or)';
162 $commentSingle = '\/\/';
163 $commentMultiLeft = '\/\*';
164 $commentMultiRight = '\*\/';
166 static::$commentPattern = $commentMultiLeft . '.*?' . $commentMultiRight;
167 static::$whitePattern = $this->utf8
168 ? '/' . $commentSingle . '[^\n]*\s*|(' . static::$commentPattern . ')\s*|\s+/AisuS'
169 : '/' . $commentSingle . '[^\n]*\s*|(' . static::$commentPattern . ')\s*|\s+/AisS';
172 $this->cache = $cache;
176 * Get source file name
178 * @api
180 * @return string
182 public function getSourceName()
184 return $this->sourceName;
188 * Throw parser error
190 * @api
192 * @param string $msg
194 * @phpstan-return never-return
196 * @throws ParserException
198 * @deprecated use "parseError" and throw the exception in the caller instead.
200 public function throwParseError($msg = 'parse error')
202 @trigger_error(
203 'The method "throwParseError" is deprecated. Use "parseError" and throw the exception in the caller instead',
204 E_USER_DEPRECATED
207 throw $this->parseError($msg);
211 * Creates a parser error
213 * @api
215 * @param string $msg
217 * @return ParserException
219 public function parseError($msg = 'parse error')
221 list($line, $column) = $this->getSourcePosition($this->count);
223 $loc = empty($this->sourceName)
224 ? "line: $line, column: $column"
225 : "$this->sourceName on line $line, at column $column";
227 if ($this->peek('(.*?)(\n|$)', $m, $this->count)) {
228 $this->restoreEncoding();
230 $e = new ParserException("$msg: failed at `$m[1]` $loc");
231 $e->setSourcePosition([$this->sourceName, $line, $column]);
233 return $e;
236 $this->restoreEncoding();
238 $e = new ParserException("$msg: $loc");
239 $e->setSourcePosition([$this->sourceName, $line, $column]);
241 return $e;
245 * Parser buffer
247 * @api
249 * @param string $buffer
251 * @return Block
253 public function parse($buffer)
255 if ($this->cache) {
256 $cacheKey = $this->sourceName . ':' . md5($buffer);
257 $parseOptions = [
258 'charset' => $this->charset,
259 'utf8' => $this->utf8,
261 $v = $this->cache->getCache('parse', $cacheKey, $parseOptions);
263 if (! \is_null($v)) {
264 return $v;
268 // strip BOM (byte order marker)
269 if (substr($buffer, 0, 3) === "\xef\xbb\xbf") {
270 $buffer = substr($buffer, 3);
273 $this->buffer = rtrim($buffer, "\x00..\x1f");
274 $this->count = 0;
275 $this->env = null;
276 $this->inParens = false;
277 $this->eatWhiteDefault = true;
279 $this->saveEncoding();
280 $this->extractLineNumbers($buffer);
282 $this->pushBlock(null); // root block
283 $this->whitespace();
284 $this->pushBlock(null);
285 $this->popBlock();
287 while ($this->parseChunk()) {
291 if ($this->count !== \strlen($this->buffer)) {
292 throw $this->parseError();
295 if (! empty($this->env->parent)) {
296 throw $this->parseError('unclosed block');
299 if ($this->charset) {
300 array_unshift($this->env->children, $this->charset);
303 $this->restoreEncoding();
305 if ($this->cache) {
306 $this->cache->setCache('parse', $cacheKey, $this->env, $parseOptions);
309 return $this->env;
313 * Parse a value or value list
315 * @api
317 * @param string $buffer
318 * @param string|array $out
320 * @return bool
322 public function parseValue($buffer, &$out)
324 $this->count = 0;
325 $this->env = null;
326 $this->inParens = false;
327 $this->eatWhiteDefault = true;
328 $this->buffer = (string) $buffer;
330 $this->saveEncoding();
331 $this->extractLineNumbers($this->buffer);
333 $list = $this->valueList($out);
335 $this->restoreEncoding();
337 return $list;
341 * Parse a selector or selector list
343 * @api
345 * @param string $buffer
346 * @param string|array $out
347 * @param bool $shouldValidate
349 * @return bool
351 public function parseSelector($buffer, &$out, $shouldValidate = true)
353 $this->count = 0;
354 $this->env = null;
355 $this->inParens = false;
356 $this->eatWhiteDefault = true;
357 $this->buffer = (string) $buffer;
359 $this->saveEncoding();
360 $this->extractLineNumbers($this->buffer);
362 // discard space/comments at the start
363 $this->discardComments = true;
364 $this->whitespace();
365 $this->discardComments = false;
367 $selector = $this->selectors($out);
369 $this->restoreEncoding();
371 if ($shouldValidate && $this->count !== strlen($buffer)) {
372 throw $this->parseError("`" . substr($buffer, $this->count) . "` is not a valid Selector in `$buffer`");
375 return $selector;
379 * Parse a media Query
381 * @api
383 * @param string $buffer
384 * @param string|array $out
386 * @return bool
388 public function parseMediaQueryList($buffer, &$out)
390 $this->count = 0;
391 $this->env = null;
392 $this->inParens = false;
393 $this->eatWhiteDefault = true;
394 $this->buffer = (string) $buffer;
396 $this->saveEncoding();
397 $this->extractLineNumbers($this->buffer);
399 $isMediaQuery = $this->mediaQueryList($out);
401 $this->restoreEncoding();
403 return $isMediaQuery;
407 * Parse a single chunk off the head of the buffer and append it to the
408 * current parse environment.
410 * Returns false when the buffer is empty, or when there is an error.
412 * This function is called repeatedly until the entire document is
413 * parsed.
415 * This parser is most similar to a recursive descent parser. Single
416 * functions represent discrete grammatical rules for the language, and
417 * they are able to capture the text that represents those rules.
419 * Consider the function Compiler::keyword(). (All parse functions are
420 * structured the same.)
422 * The function takes a single reference argument. When calling the
423 * function it will attempt to match a keyword on the head of the buffer.
424 * If it is successful, it will place the keyword in the referenced
425 * argument, advance the position in the buffer, and return true. If it
426 * fails then it won't advance the buffer and it will return false.
428 * All of these parse functions are powered by Compiler::match(), which behaves
429 * the same way, but takes a literal regular expression. Sometimes it is
430 * more convenient to use match instead of creating a new function.
432 * Because of the format of the functions, to parse an entire string of
433 * grammatical rules, you can chain them together using &&.
435 * But, if some of the rules in the chain succeed before one fails, then
436 * the buffer position will be left at an invalid state. In order to
437 * avoid this, Compiler::seek() is used to remember and set buffer positions.
439 * Before parsing a chain, use $s = $this->count to remember the current
440 * position into $s. Then if a chain fails, use $this->seek($s) to
441 * go back where we started.
443 * @return bool
445 protected function parseChunk()
447 $s = $this->count;
449 // the directives
450 if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] === '@') {
451 if (
452 $this->literal('@at-root', 8) &&
453 ($this->selectors($selector) || true) &&
454 ($this->map($with) || true) &&
455 (($this->matchChar('(') &&
456 $this->interpolation($with) &&
457 $this->matchChar(')')) || true) &&
458 $this->matchChar('{', false)
460 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
462 $atRoot = new AtRootBlock();
463 $this->registerPushedBlock($atRoot, $s);
464 $atRoot->selector = $selector;
465 $atRoot->with = $with;
467 return true;
470 $this->seek($s);
472 if (
473 $this->literal('@media', 6) &&
474 $this->mediaQueryList($mediaQueryList) &&
475 $this->matchChar('{', false)
477 $media = new MediaBlock();
478 $this->registerPushedBlock($media, $s);
479 $media->queryList = $mediaQueryList[2];
481 return true;
484 $this->seek($s);
486 if (
487 $this->literal('@mixin', 6) &&
488 $this->keyword($mixinName) &&
489 ($this->argumentDef($args) || true) &&
490 $this->matchChar('{', false)
492 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
494 $mixin = new CallableBlock(Type::T_MIXIN);
495 $this->registerPushedBlock($mixin, $s);
496 $mixin->name = $mixinName;
497 $mixin->args = $args;
499 return true;
502 $this->seek($s);
504 if (
505 ($this->literal('@include', 8) &&
506 $this->keyword($mixinName) &&
507 ($this->matchChar('(') &&
508 ($this->argValues($argValues) || true) &&
509 $this->matchChar(')') || true) &&
510 ($this->end()) ||
511 ($this->literal('using', 5) &&
512 $this->argumentDef($argUsing) &&
513 ($this->end() || $this->matchChar('{') && $hasBlock = true)) ||
514 $this->matchChar('{') && $hasBlock = true)
516 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
518 $child = [
519 Type::T_INCLUDE,
520 $mixinName,
521 isset($argValues) ? $argValues : null,
522 null,
523 isset($argUsing) ? $argUsing : null
526 if (! empty($hasBlock)) {
527 $include = new ContentBlock();
528 $this->registerPushedBlock($include, $s);
529 $include->child = $child;
530 } else {
531 $this->append($child, $s);
534 return true;
537 $this->seek($s);
539 if (
540 $this->literal('@scssphp-import-once', 20) &&
541 $this->valueList($importPath) &&
542 $this->end()
544 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
546 list($line, $column) = $this->getSourcePosition($s);
547 $file = $this->sourceName;
548 $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);
550 $this->append([Type::T_SCSSPHP_IMPORT_ONCE, $importPath], $s);
552 return true;
555 $this->seek($s);
557 if (
558 $this->literal('@import', 7) &&
559 $this->valueList($importPath) &&
560 $importPath[0] !== Type::T_FUNCTION_CALL &&
561 $this->end()
563 if ($this->cssOnly) {
564 $this->assertPlainCssValid([Type::T_IMPORT, $importPath], $s);
565 $this->append([Type::T_COMMENT, rtrim(substr($this->buffer, $s, $this->count - $s))]);
566 return true;
569 $this->append([Type::T_IMPORT, $importPath], $s);
571 return true;
574 $this->seek($s);
576 if (
577 $this->literal('@import', 7) &&
578 $this->url($importPath) &&
579 $this->end()
581 if ($this->cssOnly) {
582 $this->assertPlainCssValid([Type::T_IMPORT, $importPath], $s);
583 $this->append([Type::T_COMMENT, rtrim(substr($this->buffer, $s, $this->count - $s))]);
584 return true;
587 $this->append([Type::T_IMPORT, $importPath], $s);
589 return true;
592 $this->seek($s);
594 if (
595 $this->literal('@extend', 7) &&
596 $this->selectors($selectors) &&
597 $this->end()
599 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
601 // check for '!flag'
602 $optional = $this->stripOptionalFlag($selectors);
603 $this->append([Type::T_EXTEND, $selectors, $optional], $s);
605 return true;
608 $this->seek($s);
610 if (
611 $this->literal('@function', 9) &&
612 $this->keyword($fnName) &&
613 $this->argumentDef($args) &&
614 $this->matchChar('{', false)
616 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
618 $func = new CallableBlock(Type::T_FUNCTION);
619 $this->registerPushedBlock($func, $s);
620 $func->name = $fnName;
621 $func->args = $args;
623 return true;
626 $this->seek($s);
628 if (
629 $this->literal('@return', 7) &&
630 ($this->valueList($retVal) || true) &&
631 $this->end()
633 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
635 $this->append([Type::T_RETURN, isset($retVal) ? $retVal : [Type::T_NULL]], $s);
637 return true;
640 $this->seek($s);
642 if (
643 $this->literal('@each', 5) &&
644 $this->genericList($varNames, 'variable', ',', false) &&
645 $this->literal('in', 2) &&
646 $this->valueList($list) &&
647 $this->matchChar('{', false)
649 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
651 $each = new EachBlock();
652 $this->registerPushedBlock($each, $s);
654 foreach ($varNames[2] as $varName) {
655 $each->vars[] = $varName[1];
658 $each->list = $list;
660 return true;
663 $this->seek($s);
665 if (
666 $this->literal('@while', 6) &&
667 $this->expression($cond) &&
668 $this->matchChar('{', false)
670 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
672 while (
673 $cond[0] === Type::T_LIST &&
674 ! empty($cond['enclosing']) &&
675 $cond['enclosing'] === 'parent' &&
676 \count($cond[2]) == 1
678 $cond = reset($cond[2]);
681 $while = new WhileBlock();
682 $this->registerPushedBlock($while, $s);
683 $while->cond = $cond;
685 return true;
688 $this->seek($s);
690 if (
691 $this->literal('@for', 4) &&
692 $this->variable($varName) &&
693 $this->literal('from', 4) &&
694 $this->expression($start) &&
695 ($this->literal('through', 7) ||
696 ($forUntil = true && $this->literal('to', 2))) &&
697 $this->expression($end) &&
698 $this->matchChar('{', false)
700 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
702 $for = new ForBlock();
703 $this->registerPushedBlock($for, $s);
704 $for->var = $varName[1];
705 $for->start = $start;
706 $for->end = $end;
707 $for->until = isset($forUntil);
709 return true;
712 $this->seek($s);
714 if (
715 $this->literal('@if', 3) &&
716 $this->functionCallArgumentsList($cond, false, '{', false)
718 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
720 $if = new IfBlock();
721 $this->registerPushedBlock($if, $s);
723 while (
724 $cond[0] === Type::T_LIST &&
725 ! empty($cond['enclosing']) &&
726 $cond['enclosing'] === 'parent' &&
727 \count($cond[2]) == 1
729 $cond = reset($cond[2]);
732 $if->cond = $cond;
733 $if->cases = [];
735 return true;
738 $this->seek($s);
740 if (
741 $this->literal('@debug', 6) &&
742 $this->functionCallArgumentsList($value, false)
744 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
746 $this->append([Type::T_DEBUG, $value], $s);
748 return true;
751 $this->seek($s);
753 if (
754 $this->literal('@warn', 5) &&
755 $this->functionCallArgumentsList($value, false)
757 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
759 $this->append([Type::T_WARN, $value], $s);
761 return true;
764 $this->seek($s);
766 if (
767 $this->literal('@error', 6) &&
768 $this->functionCallArgumentsList($value, false)
770 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
772 $this->append([Type::T_ERROR, $value], $s);
774 return true;
777 $this->seek($s);
779 if (
780 $this->literal('@content', 8) &&
781 ($this->end() ||
782 $this->matchChar('(') &&
783 $this->argValues($argContent) &&
784 $this->matchChar(')') &&
785 $this->end())
787 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
789 $this->append([Type::T_MIXIN_CONTENT, isset($argContent) ? $argContent : null], $s);
791 return true;
794 $this->seek($s);
796 $last = $this->last();
798 if (isset($last) && $last[0] === Type::T_IF) {
799 list(, $if) = $last;
800 assert($if instanceof IfBlock);
802 if ($this->literal('@else', 5)) {
803 if ($this->matchChar('{', false)) {
804 $else = new ElseBlock();
805 } elseif (
806 $this->literal('if', 2) &&
807 $this->functionCallArgumentsList($cond, false, '{', false)
809 $else = new ElseifBlock();
810 $else->cond = $cond;
813 if (isset($else)) {
814 $this->registerPushedBlock($else, $s);
815 $if->cases[] = $else;
817 return true;
821 $this->seek($s);
824 // only retain the first @charset directive encountered
825 if (
826 $this->literal('@charset', 8) &&
827 $this->valueList($charset) &&
828 $this->end()
830 if (! isset($this->charset)) {
831 $statement = [Type::T_CHARSET, $charset];
833 list($line, $column) = $this->getSourcePosition($s);
835 $statement[static::SOURCE_LINE] = $line;
836 $statement[static::SOURCE_COLUMN] = $column;
837 $statement[static::SOURCE_INDEX] = $this->sourceIndex;
839 $this->charset = $statement;
842 return true;
845 $this->seek($s);
847 if (
848 $this->literal('@supports', 9) &&
849 ($t1 = $this->supportsQuery($supportQuery)) &&
850 ($t2 = $this->matchChar('{', false))
852 $directive = new DirectiveBlock();
853 $this->registerPushedBlock($directive, $s);
854 $directive->name = 'supports';
855 $directive->value = $supportQuery;
857 return true;
860 $this->seek($s);
862 // doesn't match built in directive, do generic one
863 if (
864 $this->matchChar('@', false) &&
865 $this->mixedKeyword($dirName) &&
866 $this->directiveValue($dirValue, '{')
868 if (count($dirName) === 1 && is_string(reset($dirName))) {
869 $dirName = reset($dirName);
870 } else {
871 $dirName = [Type::T_STRING, '', $dirName];
873 if ($dirName === 'media') {
874 $directive = new MediaBlock();
875 } else {
876 $directive = new DirectiveBlock();
877 $directive->name = $dirName;
879 $this->registerPushedBlock($directive, $s);
881 if (isset($dirValue)) {
882 ! $this->cssOnly || ($dirValue = $this->assertPlainCssValid($dirValue));
883 $directive->value = $dirValue;
886 return true;
889 $this->seek($s);
891 // maybe it's a generic blockless directive
892 if (
893 $this->matchChar('@', false) &&
894 $this->mixedKeyword($dirName) &&
895 ! $this->isKnownGenericDirective($dirName) &&
896 ($this->end(false) || ($this->directiveValue($dirValue, '') && $this->end(false)))
898 if (\count($dirName) === 1 && \is_string(\reset($dirName))) {
899 $dirName = \reset($dirName);
900 } else {
901 $dirName = [Type::T_STRING, '', $dirName];
903 if (
904 ! empty($this->env->parent) &&
905 $this->env->type &&
906 ! \in_array($this->env->type, [Type::T_DIRECTIVE, Type::T_MEDIA])
908 $plain = \trim(\substr($this->buffer, $s, $this->count - $s));
909 throw $this->parseError(
910 "Unknown directive `{$plain}` not allowed in `" . $this->env->type . "` block"
913 // blockless directives with a blank line after keeps their blank lines after
914 // sass-spec compliance purpose
915 $s = $this->count;
916 $hasBlankLine = false;
917 if ($this->match('\s*?\n\s*\n', $out, false)) {
918 $hasBlankLine = true;
919 $this->seek($s);
921 $isNotRoot = ! empty($this->env->parent);
922 $this->append([Type::T_DIRECTIVE, [$dirName, $dirValue, $hasBlankLine, $isNotRoot]], $s);
923 $this->whitespace();
925 return true;
928 $this->seek($s);
930 return false;
933 $inCssSelector = null;
934 if ($this->cssOnly) {
935 $inCssSelector = (! empty($this->env->parent) &&
936 ! in_array($this->env->type, [Type::T_DIRECTIVE, Type::T_MEDIA]));
938 // custom properties : right part is static
939 if (($this->customProperty($name) ) && $this->matchChar(':', false)) {
940 $start = $this->count;
942 // but can be complex and finish with ; or }
943 foreach ([';','}'] as $ending) {
944 if (
945 $this->openString($ending, $stringValue, '(', ')', false) &&
946 $this->end()
948 $end = $this->count;
949 $value = $stringValue;
951 // check if we have only a partial value due to nested [] or { } to take in account
952 $nestingPairs = [['[', ']'], ['{', '}']];
954 foreach ($nestingPairs as $nestingPair) {
955 $p = strpos($this->buffer, $nestingPair[0], $start);
957 if ($p && $p < $end) {
958 $this->seek($start);
960 if (
961 $this->openString($ending, $stringValue, $nestingPair[0], $nestingPair[1], false) &&
962 $this->end() &&
963 $this->count > $end
965 $end = $this->count;
966 $value = $stringValue;
971 $this->seek($end);
972 $this->append([Type::T_CUSTOM_PROPERTY, $name, $value], $s);
974 return true;
978 // TODO: output an error here if nothing found according to sass spec
981 $this->seek($s);
983 // property shortcut
984 // captures most properties before having to parse a selector
985 if (
986 $this->keyword($name, false) &&
987 $this->literal(': ', 2) &&
988 $this->valueList($value) &&
989 $this->end()
991 $name = [Type::T_STRING, '', [$name]];
992 $this->append([Type::T_ASSIGN, $name, $value], $s);
994 return true;
997 $this->seek($s);
999 // variable assigns
1000 if (
1001 $this->variable($name) &&
1002 $this->matchChar(':') &&
1003 $this->valueList($value) &&
1004 $this->end()
1006 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
1008 // check for '!flag'
1009 $assignmentFlags = $this->stripAssignmentFlags($value);
1010 $this->append([Type::T_ASSIGN, $name, $value, $assignmentFlags], $s);
1012 return true;
1015 $this->seek($s);
1017 // opening css block
1018 if (
1019 $this->selectors($selectors) &&
1020 $this->matchChar('{', false)
1022 ! $this->cssOnly || ! $inCssSelector || $this->assertPlainCssValid(false);
1024 $this->pushBlock($selectors, $s);
1026 if ($this->eatWhiteDefault) {
1027 $this->whitespace();
1028 $this->append(null); // collect comments at the beginning if needed
1031 return true;
1034 $this->seek($s);
1036 // property assign, or nested assign
1037 if (
1038 $this->propertyName($name) &&
1039 $this->matchChar(':')
1041 $foundSomething = false;
1043 if ($this->valueList($value)) {
1044 if (empty($this->env->parent)) {
1045 throw $this->parseError('expected "{"');
1048 $this->append([Type::T_ASSIGN, $name, $value], $s);
1049 $foundSomething = true;
1052 if ($this->matchChar('{', false)) {
1053 ! $this->cssOnly || $this->assertPlainCssValid(false);
1055 $propBlock = new NestedPropertyBlock();
1056 $this->registerPushedBlock($propBlock, $s);
1057 $propBlock->prefix = $name;
1058 $propBlock->hasValue = $foundSomething;
1060 $foundSomething = true;
1061 } elseif ($foundSomething) {
1062 $foundSomething = $this->end();
1065 if ($foundSomething) {
1066 return true;
1070 $this->seek($s);
1072 // closing a block
1073 if ($this->matchChar('}', false)) {
1074 $block = $this->popBlock();
1076 if (! isset($block->type) || $block->type !== Type::T_IF) {
1077 if ($this->env->parent) {
1078 $this->append(null); // collect comments before next statement if needed
1082 if ($block instanceof ContentBlock) {
1083 $include = $block->child;
1084 assert(\is_array($include));
1085 unset($block->child);
1086 $include[3] = $block;
1087 $this->append($include, $s);
1088 } elseif (!$block instanceof ElseBlock && !$block instanceof ElseifBlock) {
1089 $type = isset($block->type) ? $block->type : Type::T_BLOCK;
1090 $this->append([$type, $block], $s);
1093 // collect comments just after the block closing if needed
1094 if ($this->eatWhiteDefault) {
1095 $this->whitespace();
1097 if ($this->env->comments) {
1098 $this->append(null);
1102 return true;
1105 // extra stuff
1106 if ($this->matchChar(';')) {
1107 return true;
1110 return false;
1114 * Push block onto parse tree
1116 * @param array|null $selectors
1117 * @param int $pos
1119 * @return Block
1121 protected function pushBlock($selectors, $pos = 0)
1123 $b = new Block();
1124 $b->selectors = $selectors;
1126 $this->registerPushedBlock($b, $pos);
1128 return $b;
1132 * @param Block $b
1133 * @param int $pos
1135 * @return void
1137 private function registerPushedBlock(Block $b, $pos)
1139 list($line, $column) = $this->getSourcePosition($pos);
1141 $b->sourceName = $this->sourceName;
1142 $b->sourceLine = $line;
1143 $b->sourceColumn = $column;
1144 $b->sourceIndex = $this->sourceIndex;
1145 $b->comments = [];
1146 $b->parent = $this->env;
1148 if (! $this->env) {
1149 $b->children = [];
1150 } elseif (empty($this->env->children)) {
1151 $this->env->children = $this->env->comments;
1152 $b->children = [];
1153 $this->env->comments = [];
1154 } else {
1155 $b->children = $this->env->comments;
1156 $this->env->comments = [];
1159 $this->env = $b;
1161 // collect comments at the beginning of a block if needed
1162 if ($this->eatWhiteDefault) {
1163 $this->whitespace();
1165 if ($this->env->comments) {
1166 $this->append(null);
1172 * Push special (named) block onto parse tree
1174 * @deprecated
1176 * @param string $type
1177 * @param int $pos
1179 * @return Block
1181 protected function pushSpecialBlock($type, $pos)
1183 $block = $this->pushBlock(null, $pos);
1184 $block->type = $type;
1186 return $block;
1190 * Pop scope and return last block
1192 * @return Block
1194 * @throws \Exception
1196 protected function popBlock()
1199 // collect comments ending just before of a block closing
1200 if ($this->env->comments) {
1201 $this->append(null);
1204 // pop the block
1205 $block = $this->env;
1207 if (empty($block->parent)) {
1208 throw $this->parseError('unexpected }');
1211 if ($block->type == Type::T_AT_ROOT) {
1212 // keeps the parent in case of self selector &
1213 $block->selfParent = $block->parent;
1216 $this->env = $block->parent;
1218 unset($block->parent);
1220 return $block;
1224 * Peek input stream
1226 * @param string $regex
1227 * @param array $out
1228 * @param int $from
1230 * @return int
1232 protected function peek($regex, &$out, $from = null)
1234 if (! isset($from)) {
1235 $from = $this->count;
1238 $r = '/' . $regex . '/' . $this->patternModifiers;
1239 $result = preg_match($r, $this->buffer, $out, 0, $from);
1241 return $result;
1245 * Seek to position in input stream (or return current position in input stream)
1247 * @param int $where
1249 protected function seek($where)
1251 $this->count = $where;
1255 * Assert a parsed part is plain CSS Valid
1257 * @param array|false $parsed
1258 * @param int $startPos
1260 * @throws ParserException
1262 protected function assertPlainCssValid($parsed, $startPos = null)
1264 $type = '';
1265 if ($parsed) {
1266 $type = $parsed[0];
1267 $parsed = $this->isPlainCssValidElement($parsed);
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';
1276 if ($type) {
1277 $message .= " ($type)";
1279 throw $this->parseError($message);
1282 return $parsed;
1286 * Check a parsed element is plain CSS Valid
1288 * @param array $parsed
1289 * @param bool $allowExpression
1291 * @return bool|array
1293 protected function isPlainCssValidElement($parsed, $allowExpression = false)
1295 // keep string as is
1296 if (is_string($parsed)) {
1297 return $parsed;
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])
1327 return false;
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;
1338 case Type::T_COMMENT:
1339 if (isset($parsed[2])) {
1340 return false;
1342 return $parsed;
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;
1352 return $parsed;
1354 case Type::T_IMPORT:
1355 if ($parsed[1][0] === Type::T_LIST) {
1356 return false;
1358 $parsed[1] = $this->isPlainCssValidElement($parsed[1]);
1359 if ($parsed[1] === false) {
1360 return false;
1362 return $parsed;
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;
1373 return $parsed;
1375 case Type::T_LIST:
1376 if (!empty($parsed['enclosing'])) {
1377 return false;
1379 foreach ($parsed[2] as $k => $listElement) {
1380 $parsed[2][$k] = $this->isPlainCssValidElement($listElement);
1381 if (! $parsed[2][$k]) {
1382 return false;
1385 return $parsed;
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;
1396 return $parsed;
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;
1403 $lhs = $this->isPlainCssValidElement($lhs, true);
1404 if (! $lhs) {
1405 return false;
1407 $rhs = $this->isPlainCssValidElement($rhs, true);
1408 if (! $rhs) {
1409 return false;
1412 return [
1413 Type::T_STRING,
1414 '', [
1415 $this->inParens ? '(' : '',
1416 $lhs,
1417 ($whiteBefore ? ' ' : '') . $op . ($whiteAfter ? ' ' : ''),
1418 $rhs,
1419 $this->inParens ? ')' : ''
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;
1429 return $parsed;
1431 case Type::T_FUNCTION:
1432 $argsList = $parsed[2];
1433 foreach ($argsList[2] as $argElement) {
1434 if (! $this->isPlainCssValidElement($argElement)) {
1435 return false;
1438 return $parsed;
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;
1449 $arg = $this->isPlainCssValidElement($arg[1], $parsed[1] === 'calc');
1450 if (! $arg) {
1451 return false;
1453 $argsList[2][] = $arg;
1455 $parsed[2] = $argsList;
1456 return $parsed;
1459 return false;
1463 * Match string looking for either ending delim, escape, or string interpolation
1465 * {@internal This is a workaround for preg_match's 250K string match limit. }}
1467 * @param array $m Matches (passed by reference)
1468 * @param string $delim Delimiter
1470 * @return bool True if match; false otherwise
1472 protected function matchString(&$m, $delim)
1474 $token = null;
1476 $end = \strlen($this->buffer);
1478 // look for either ending delim, escape, or string interpolation
1479 foreach (['#{', '\\', "\r", $delim] as $lookahead) {
1480 $pos = strpos($this->buffer, $lookahead, $this->count);
1482 if ($pos !== false && $pos < $end) {
1483 $end = $pos;
1484 $token = $lookahead;
1488 if (! isset($token)) {
1489 return false;
1492 $match = substr($this->buffer, $this->count, $end - $this->count);
1493 $m = [
1494 $match . $token,
1495 $match,
1496 $token
1498 $this->count = $end + \strlen($token);
1500 return true;
1504 * Try to match something on head of buffer
1506 * @param string $regex
1507 * @param array $out
1508 * @param bool $eatWhitespace
1510 * @return bool
1512 protected function match($regex, &$out, $eatWhitespace = null)
1514 $r = '/' . $regex . '/' . $this->patternModifiers;
1516 if (! preg_match($r, $this->buffer, $out, 0, $this->count)) {
1517 return false;
1520 $this->count += \strlen($out[0]);
1522 if (! isset($eatWhitespace)) {
1523 $eatWhitespace = $this->eatWhiteDefault;
1526 if ($eatWhitespace) {
1527 $this->whitespace();
1530 return true;
1534 * Match a single string
1536 * @param string $char
1537 * @param bool $eatWhitespace
1539 * @return bool
1541 protected function matchChar($char, $eatWhitespace = null)
1543 if (! isset($this->buffer[$this->count]) || $this->buffer[$this->count] !== $char) {
1544 return false;
1547 $this->count++;
1549 if (! isset($eatWhitespace)) {
1550 $eatWhitespace = $this->eatWhiteDefault;
1553 if ($eatWhitespace) {
1554 $this->whitespace();
1557 return true;
1561 * Match literal string
1563 * @param string $what
1564 * @param int $len
1565 * @param bool $eatWhitespace
1567 * @return bool
1569 protected function literal($what, $len, $eatWhitespace = null)
1571 if (strcasecmp(substr($this->buffer, $this->count, $len), $what) !== 0) {
1572 return false;
1575 $this->count += $len;
1577 if (! isset($eatWhitespace)) {
1578 $eatWhitespace = $this->eatWhiteDefault;
1581 if ($eatWhitespace) {
1582 $this->whitespace();
1585 return true;
1589 * Match some whitespace
1591 * @return bool
1593 protected function whitespace()
1595 $gotWhite = false;
1597 while (preg_match(static::$whitePattern, $this->buffer, $m, 0, $this->count)) {
1598 if (isset($m[1]) && empty($this->commentsSeen[$this->count])) {
1599 // comment that are kept in the output CSS
1600 $comment = [];
1601 $startCommentCount = $this->count;
1602 $endCommentCount = $this->count + \strlen($m[1]);
1604 // find interpolations in comment
1605 $p = strpos($this->buffer, '#{', $this->count);
1607 while ($p !== false && $p < $endCommentCount) {
1608 $c = substr($this->buffer, $this->count, $p - $this->count);
1609 $comment[] = $c;
1610 $this->count = $p;
1611 $out = null;
1613 if ($this->interpolation($out)) {
1614 // keep right spaces in the following string part
1615 if ($out[3]) {
1616 while ($this->buffer[$this->count - 1] !== '}') {
1617 $this->count--;
1620 $out[3] = '';
1623 $comment[] = [Type::T_COMMENT, substr($this->buffer, $p, $this->count - $p), $out];
1624 } else {
1625 list($line, $column) = $this->getSourcePosition($this->count);
1626 $file = $this->sourceName;
1627 if (!$this->discardComments) {
1628 $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);
1630 $comment[] = substr($this->buffer, $this->count, 2);
1632 $this->count += 2;
1635 $p = strpos($this->buffer, '#{', $this->count);
1638 // remaining part
1639 $c = substr($this->buffer, $this->count, $endCommentCount - $this->count);
1641 if (! $comment) {
1642 // single part static comment
1643 $this->appendComment([Type::T_COMMENT, $c]);
1644 } else {
1645 $comment[] = $c;
1646 $staticComment = substr($this->buffer, $startCommentCount, $endCommentCount - $startCommentCount);
1647 $commentStatement = [Type::T_COMMENT, $staticComment, [Type::T_STRING, '', $comment]];
1649 list($line, $column) = $this->getSourcePosition($startCommentCount);
1650 $commentStatement[self::SOURCE_LINE] = $line;
1651 $commentStatement[self::SOURCE_COLUMN] = $column;
1652 $commentStatement[self::SOURCE_INDEX] = $this->sourceIndex;
1654 $this->appendComment($commentStatement);
1657 $this->commentsSeen[$startCommentCount] = true;
1658 $this->count = $endCommentCount;
1659 } else {
1660 // comment that are ignored and not kept in the output css
1661 $this->count += \strlen($m[0]);
1662 // silent comments are not allowed in plain CSS files
1663 ! $this->cssOnly
1664 || ! \strlen(trim($m[0]))
1665 || $this->assertPlainCssValid(false, $this->count - \strlen($m[0]));
1668 $gotWhite = true;
1671 return $gotWhite;
1675 * Append comment to current block
1677 * @param array $comment
1679 protected function appendComment($comment)
1681 if (! $this->discardComments) {
1682 $this->env->comments[] = $comment;
1687 * Append statement to current block
1689 * @param array|null $statement
1690 * @param int $pos
1692 protected function append($statement, $pos = null)
1694 if (! \is_null($statement)) {
1695 ! $this->cssOnly || ($statement = $this->assertPlainCssValid($statement, $pos));
1697 if (! \is_null($pos)) {
1698 list($line, $column) = $this->getSourcePosition($pos);
1700 $statement[static::SOURCE_LINE] = $line;
1701 $statement[static::SOURCE_COLUMN] = $column;
1702 $statement[static::SOURCE_INDEX] = $this->sourceIndex;
1705 $this->env->children[] = $statement;
1708 $comments = $this->env->comments;
1710 if ($comments) {
1711 $this->env->children = array_merge($this->env->children, $comments);
1712 $this->env->comments = [];
1717 * Returns last child was appended
1719 * @return array|null
1721 protected function last()
1723 $i = \count($this->env->children) - 1;
1725 if (isset($this->env->children[$i])) {
1726 return $this->env->children[$i];
1731 * Parse media query list
1733 * @param array $out
1735 * @return bool
1737 protected function mediaQueryList(&$out)
1739 return $this->genericList($out, 'mediaQuery', ',', false);
1743 * Parse media query
1745 * @param array $out
1747 * @return bool
1749 protected function mediaQuery(&$out)
1751 $expressions = null;
1752 $parts = [];
1754 if (
1755 ($this->literal('only', 4) && ($only = true) ||
1756 $this->literal('not', 3) && ($not = true) || true) &&
1757 $this->mixedKeyword($mediaType)
1759 $prop = [Type::T_MEDIA_TYPE];
1761 if (isset($only)) {
1762 $prop[] = [Type::T_KEYWORD, 'only'];
1765 if (isset($not)) {
1766 $prop[] = [Type::T_KEYWORD, 'not'];
1769 $media = [Type::T_LIST, '', []];
1771 foreach ((array) $mediaType as $type) {
1772 if (\is_array($type)) {
1773 $media[2][] = $type;
1774 } else {
1775 $media[2][] = [Type::T_KEYWORD, $type];
1779 $prop[] = $media;
1780 $parts[] = $prop;
1783 if (empty($parts) || $this->literal('and', 3)) {
1784 $this->genericList($expressions, 'mediaExpression', 'and', false);
1786 if (\is_array($expressions)) {
1787 $parts = array_merge($parts, $expressions[2]);
1791 $out = $parts;
1793 return true;
1797 * Parse supports query
1799 * @param array $out
1801 * @return bool
1803 protected function supportsQuery(&$out)
1805 $expressions = null;
1806 $parts = [];
1808 $s = $this->count;
1810 $not = false;
1812 if (
1813 ($this->literal('not', 3) && ($not = true) || true) &&
1814 $this->matchChar('(') &&
1815 ($this->expression($property)) &&
1816 $this->literal(': ', 2) &&
1817 $this->valueList($value) &&
1818 $this->matchChar(')')
1820 $support = [Type::T_STRING, '', [[Type::T_KEYWORD, ($not ? 'not ' : '') . '(']]];
1821 $support[2][] = $property;
1822 $support[2][] = [Type::T_KEYWORD, ': '];
1823 $support[2][] = $value;
1824 $support[2][] = [Type::T_KEYWORD, ')'];
1826 $parts[] = $support;
1827 $s = $this->count;
1828 } else {
1829 $this->seek($s);
1832 if (
1833 $this->matchChar('(') &&
1834 $this->supportsQuery($subQuery) &&
1835 $this->matchChar(')')
1837 $parts[] = [Type::T_STRING, '', [[Type::T_KEYWORD, '('], $subQuery, [Type::T_KEYWORD, ')']]];
1838 $s = $this->count;
1839 } else {
1840 $this->seek($s);
1843 if (
1844 $this->literal('not', 3) &&
1845 $this->supportsQuery($subQuery)
1847 $parts[] = [Type::T_STRING, '', [[Type::T_KEYWORD, 'not '], $subQuery]];
1848 $s = $this->count;
1849 } else {
1850 $this->seek($s);
1853 if (
1854 $this->literal('selector(', 9) &&
1855 $this->selector($selector) &&
1856 $this->matchChar(')')
1858 $support = [Type::T_STRING, '', [[Type::T_KEYWORD, 'selector(']]];
1860 $selectorList = [Type::T_LIST, '', []];
1862 foreach ($selector as $sc) {
1863 $compound = [Type::T_STRING, '', []];
1865 foreach ($sc as $scp) {
1866 if (\is_array($scp)) {
1867 $compound[2][] = $scp;
1868 } else {
1869 $compound[2][] = [Type::T_KEYWORD, $scp];
1873 $selectorList[2][] = $compound;
1876 $support[2][] = $selectorList;
1877 $support[2][] = [Type::T_KEYWORD, ')'];
1878 $parts[] = $support;
1879 $s = $this->count;
1880 } else {
1881 $this->seek($s);
1884 if ($this->variable($var) or $this->interpolation($var)) {
1885 $parts[] = $var;
1886 $s = $this->count;
1887 } else {
1888 $this->seek($s);
1891 if (
1892 $this->literal('and', 3) &&
1893 $this->genericList($expressions, 'supportsQuery', ' and', false)
1895 array_unshift($expressions[2], [Type::T_STRING, '', $parts]);
1897 $parts = [$expressions];
1898 $s = $this->count;
1899 } else {
1900 $this->seek($s);
1903 if (
1904 $this->literal('or', 2) &&
1905 $this->genericList($expressions, 'supportsQuery', ' or', false)
1907 array_unshift($expressions[2], [Type::T_STRING, '', $parts]);
1909 $parts = [$expressions];
1910 $s = $this->count;
1911 } else {
1912 $this->seek($s);
1915 if (\count($parts)) {
1916 if ($this->eatWhiteDefault) {
1917 $this->whitespace();
1920 $out = [Type::T_STRING, '', $parts];
1922 return true;
1925 return false;
1930 * Parse media expression
1932 * @param array $out
1934 * @return bool
1936 protected function mediaExpression(&$out)
1938 $s = $this->count;
1939 $value = null;
1941 if (
1942 $this->matchChar('(') &&
1943 $this->expression($feature) &&
1944 ($this->matchChar(':') &&
1945 $this->expression($value) || true) &&
1946 $this->matchChar(')')
1948 $out = [Type::T_MEDIA_EXPRESSION, $feature];
1950 if ($value) {
1951 $out[] = $value;
1954 return true;
1957 $this->seek($s);
1959 return false;
1963 * Parse argument values
1965 * @param array $out
1967 * @return bool
1969 protected function argValues(&$out)
1971 $discardComments = $this->discardComments;
1972 $this->discardComments = true;
1974 if ($this->genericList($list, 'argValue', ',', false)) {
1975 $out = $list[2];
1977 $this->discardComments = $discardComments;
1979 return true;
1982 $this->discardComments = $discardComments;
1984 return false;
1988 * Parse argument value
1990 * @param array $out
1992 * @return bool
1994 protected function argValue(&$out)
1996 $s = $this->count;
1998 $keyword = null;
2000 if (! $this->variable($keyword) || ! $this->matchChar(':')) {
2001 $this->seek($s);
2003 $keyword = null;
2006 if ($this->genericList($value, 'expression', '', true)) {
2007 $out = [$keyword, $value, false];
2008 $s = $this->count;
2010 if ($this->literal('...', 3)) {
2011 $out[2] = true;
2012 } else {
2013 $this->seek($s);
2016 return true;
2019 return false;
2023 * Check if a generic directive is known to be able to allow almost any syntax or not
2024 * @param mixed $directiveName
2025 * @return bool
2027 protected function isKnownGenericDirective($directiveName)
2029 if (\is_array($directiveName) && \is_string(reset($directiveName))) {
2030 $directiveName = reset($directiveName);
2032 if (! \is_string($directiveName)) {
2033 return false;
2035 if (
2036 \in_array($directiveName, [
2037 'at-root',
2038 'media',
2039 'mixin',
2040 'include',
2041 'scssphp-import-once',
2042 'import',
2043 'extend',
2044 'function',
2045 'break',
2046 'continue',
2047 'return',
2048 'each',
2049 'while',
2050 'for',
2051 'if',
2052 'debug',
2053 'warn',
2054 'error',
2055 'content',
2056 'else',
2057 'charset',
2058 'supports',
2059 // Todo
2060 'use',
2061 'forward',
2064 return true;
2066 return false;
2070 * Parse directive value list that considers $vars as keyword
2072 * @param array $out
2073 * @param bool|string $endChar
2075 * @return bool
2077 protected function directiveValue(&$out, $endChar = false)
2079 $s = $this->count;
2081 if ($this->variable($out)) {
2082 if ($endChar && $this->matchChar($endChar, false)) {
2083 return true;
2086 if (! $endChar && $this->end()) {
2087 return true;
2091 $this->seek($s);
2093 if (\is_string($endChar) && $this->openString($endChar ? $endChar : ';', $out, null, null, true, ";}{")) {
2094 if ($endChar && $this->matchChar($endChar, false)) {
2095 return true;
2097 $ss = $this->count;
2098 if (!$endChar && $this->end()) {
2099 $this->seek($ss);
2100 return true;
2104 $this->seek($s);
2106 $allowVars = $this->allowVars;
2107 $this->allowVars = false;
2109 $res = $this->genericList($out, 'spaceList', ',');
2110 $this->allowVars = $allowVars;
2112 if ($res) {
2113 if ($endChar && $this->matchChar($endChar, false)) {
2114 return true;
2117 if (! $endChar && $this->end()) {
2118 return true;
2122 $this->seek($s);
2124 if ($endChar && $this->matchChar($endChar, false)) {
2125 return true;
2128 return false;
2132 * Parse comma separated value list
2134 * @param array $out
2136 * @return bool
2138 protected function valueList(&$out)
2140 $discardComments = $this->discardComments;
2141 $this->discardComments = true;
2142 $res = $this->genericList($out, 'spaceList', ',');
2143 $this->discardComments = $discardComments;
2145 return $res;
2149 * Parse a function call, where externals () are part of the call
2150 * and not of the value list
2152 * @param array $out
2153 * @param bool $mandatoryEnclos
2154 * @param null|string $charAfter
2155 * @param null|bool $eatWhiteSp
2157 * @return bool
2159 protected function functionCallArgumentsList(&$out, $mandatoryEnclos = true, $charAfter = null, $eatWhiteSp = null)
2161 $s = $this->count;
2163 if (
2164 $this->matchChar('(') &&
2165 $this->valueList($out) &&
2166 $this->matchChar(')') &&
2167 ($charAfter ? $this->matchChar($charAfter, $eatWhiteSp) : $this->end())
2169 return true;
2172 if (! $mandatoryEnclos) {
2173 $this->seek($s);
2175 if (
2176 $this->valueList($out) &&
2177 ($charAfter ? $this->matchChar($charAfter, $eatWhiteSp) : $this->end())
2179 return true;
2183 $this->seek($s);
2185 return false;
2189 * Parse space separated value list
2191 * @param array $out
2193 * @return bool
2195 protected function spaceList(&$out)
2197 return $this->genericList($out, 'expression');
2201 * Parse generic list
2203 * @param array $out
2204 * @param string $parseItem The name of the method used to parse items
2205 * @param string $delim
2206 * @param bool $flatten
2208 * @return bool
2210 protected function genericList(&$out, $parseItem, $delim = '', $flatten = true)
2212 $s = $this->count;
2213 $items = [];
2214 $value = null;
2216 while ($this->$parseItem($value)) {
2217 $trailing_delim = false;
2218 $items[] = $value;
2220 if ($delim) {
2221 if (! $this->literal($delim, \strlen($delim))) {
2222 break;
2225 $trailing_delim = true;
2226 } else {
2227 // if no delim watch that a keyword didn't eat the single/double quote
2228 // from the following starting string
2229 if ($value[0] === Type::T_KEYWORD) {
2230 $word = $value[1];
2232 $last_char = substr($word, -1);
2234 if (
2235 strlen($word) > 1 &&
2236 in_array($last_char, [ "'", '"']) &&
2237 substr($word, -2, 1) !== '\\'
2239 // if there is a non escaped opening quote in the keyword, this seems unlikely a mistake
2240 $word = str_replace('\\' . $last_char, '\\\\', $word);
2241 if (strpos($word, $last_char) < strlen($word) - 1) {
2242 continue;
2245 $currentCount = $this->count;
2247 // let's try to rewind to previous char and try a parse
2248 $this->count--;
2249 // in case the keyword also eat spaces
2250 while (substr($this->buffer, $this->count, 1) !== $last_char) {
2251 $this->count--;
2254 $nextValue = null;
2255 if ($this->$parseItem($nextValue)) {
2256 if ($nextValue[0] === Type::T_KEYWORD && $nextValue[1] === $last_char) {
2257 // bad try, forget it
2258 $this->seek($currentCount);
2259 continue;
2261 if ($nextValue[0] !== Type::T_STRING) {
2262 // bad try, forget it
2263 $this->seek($currentCount);
2264 continue;
2267 // OK it was a good idea
2268 $value[1] = substr($value[1], 0, -1);
2269 array_pop($items);
2270 $items[] = $value;
2271 $items[] = $nextValue;
2272 } else {
2273 // bad try, forget it
2274 $this->seek($currentCount);
2275 continue;
2282 if (! $items) {
2283 $this->seek($s);
2285 return false;
2288 if ($trailing_delim) {
2289 $items[] = [Type::T_NULL];
2292 if ($flatten && \count($items) === 1) {
2293 $out = $items[0];
2294 } else {
2295 $out = [Type::T_LIST, $delim, $items];
2298 return true;
2302 * Parse expression
2304 * @param array $out
2305 * @param bool $listOnly
2306 * @param bool $lookForExp
2308 * @return bool
2310 protected function expression(&$out, $listOnly = false, $lookForExp = true)
2312 $s = $this->count;
2313 $discard = $this->discardComments;
2314 $this->discardComments = true;
2315 $allowedTypes = ($listOnly ? [Type::T_LIST] : [Type::T_LIST, Type::T_MAP]);
2317 if ($this->matchChar('(')) {
2318 if ($this->enclosedExpression($lhs, $s, ')', $allowedTypes)) {
2319 if ($lookForExp) {
2320 $out = $this->expHelper($lhs, 0);
2321 } else {
2322 $out = $lhs;
2325 $this->discardComments = $discard;
2327 return true;
2330 $this->seek($s);
2333 if (\in_array(Type::T_LIST, $allowedTypes) && $this->matchChar('[')) {
2334 if ($this->enclosedExpression($lhs, $s, ']', [Type::T_LIST])) {
2335 if ($lookForExp) {
2336 $out = $this->expHelper($lhs, 0);
2337 } else {
2338 $out = $lhs;
2341 $this->discardComments = $discard;
2343 return true;
2346 $this->seek($s);
2349 if (! $listOnly && $this->value($lhs)) {
2350 if ($lookForExp) {
2351 $out = $this->expHelper($lhs, 0);
2352 } else {
2353 $out = $lhs;
2356 $this->discardComments = $discard;
2358 return true;
2361 $this->discardComments = $discard;
2363 return false;
2367 * Parse expression specifically checking for lists in parenthesis or brackets
2369 * @param array $out
2370 * @param int $s
2371 * @param string $closingParen
2372 * @param array $allowedTypes
2374 * @return bool
2376 protected function enclosedExpression(&$out, $s, $closingParen = ')', $allowedTypes = [Type::T_LIST, Type::T_MAP])
2378 if ($this->matchChar($closingParen) && \in_array(Type::T_LIST, $allowedTypes)) {
2379 $out = [Type::T_LIST, '', []];
2381 switch ($closingParen) {
2382 case ')':
2383 $out['enclosing'] = 'parent'; // parenthesis list
2384 break;
2386 case ']':
2387 $out['enclosing'] = 'bracket'; // bracketed list
2388 break;
2391 return true;
2394 if (
2395 $this->valueList($out) &&
2396 $this->matchChar($closingParen) && ! ($closingParen === ')' &&
2397 \in_array($out[0], [Type::T_EXPRESSION, Type::T_UNARY])) &&
2398 \in_array(Type::T_LIST, $allowedTypes)
2400 if ($out[0] !== Type::T_LIST || ! empty($out['enclosing'])) {
2401 $out = [Type::T_LIST, '', [$out]];
2404 switch ($closingParen) {
2405 case ')':
2406 $out['enclosing'] = 'parent'; // parenthesis list
2407 break;
2409 case ']':
2410 $out['enclosing'] = 'bracket'; // bracketed list
2411 break;
2414 return true;
2417 $this->seek($s);
2419 if (\in_array(Type::T_MAP, $allowedTypes) && $this->map($out)) {
2420 return true;
2423 return false;
2427 * Parse left-hand side of subexpression
2429 * @param array $lhs
2430 * @param int $minP
2432 * @return array
2434 protected function expHelper($lhs, $minP)
2436 $operators = static::$operatorPattern;
2438 $ss = $this->count;
2439 $whiteBefore = isset($this->buffer[$this->count - 1]) &&
2440 ctype_space($this->buffer[$this->count - 1]);
2442 while ($this->match($operators, $m, false) && static::$precedence[$m[1]] >= $minP) {
2443 $whiteAfter = isset($this->buffer[$this->count]) &&
2444 ctype_space($this->buffer[$this->count]);
2445 $varAfter = isset($this->buffer[$this->count]) &&
2446 $this->buffer[$this->count] === '$';
2448 $this->whitespace();
2450 $op = $m[1];
2452 // don't turn negative numbers into expressions
2453 if ($op === '-' && $whiteBefore && ! $whiteAfter && ! $varAfter) {
2454 break;
2457 if (! $this->value($rhs) && ! $this->expression($rhs, true, false)) {
2458 break;
2461 if ($op === '-' && ! $whiteAfter && $rhs[0] === Type::T_KEYWORD) {
2462 break;
2465 // consume higher-precedence operators on the right-hand side
2466 $rhs = $this->expHelper($rhs, static::$precedence[$op] + 1);
2468 $lhs = [Type::T_EXPRESSION, $op, $lhs, $rhs, $this->inParens, $whiteBefore, $whiteAfter];
2470 $ss = $this->count;
2471 $whiteBefore = isset($this->buffer[$this->count - 1]) &&
2472 ctype_space($this->buffer[$this->count - 1]);
2475 $this->seek($ss);
2477 return $lhs;
2481 * Parse value
2483 * @param array $out
2485 * @return bool
2487 protected function value(&$out)
2489 if (! isset($this->buffer[$this->count])) {
2490 return false;
2493 $s = $this->count;
2494 $char = $this->buffer[$this->count];
2496 if (
2497 $this->literal('url(', 4) &&
2498 $this->match('data:([a-z]+)\/([a-z0-9.+-]+);base64,', $m, false)
2500 $len = strspn(
2501 $this->buffer,
2502 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwyxz0123456789+/=',
2503 $this->count
2506 $this->count += $len;
2508 if ($this->matchChar(')')) {
2509 $content = substr($this->buffer, $s, $this->count - $s);
2510 $out = [Type::T_KEYWORD, $content];
2512 return true;
2516 $this->seek($s);
2518 if (
2519 $this->literal('url(', 4, false) &&
2520 $this->match('\s*(\/\/[^\s\)]+)\s*', $m)
2522 $content = 'url(' . $m[1];
2524 if ($this->matchChar(')')) {
2525 $content .= ')';
2526 $out = [Type::T_KEYWORD, $content];
2528 return true;
2532 $this->seek($s);
2534 // not
2535 if ($char === 'n' && $this->literal('not', 3, false)) {
2536 if (
2537 $this->whitespace() &&
2538 $this->value($inner)
2540 $out = [Type::T_UNARY, 'not', $inner, $this->inParens];
2542 return true;
2545 $this->seek($s);
2547 if ($this->parenValue($inner)) {
2548 $out = [Type::T_UNARY, 'not', $inner, $this->inParens];
2550 return true;
2553 $this->seek($s);
2556 // addition
2557 if ($char === '+') {
2558 $this->count++;
2560 $follow_white = $this->whitespace();
2562 if ($this->value($inner)) {
2563 $out = [Type::T_UNARY, '+', $inner, $this->inParens];
2565 return true;
2568 if ($follow_white) {
2569 $out = [Type::T_KEYWORD, $char];
2570 return true;
2573 $this->seek($s);
2575 return false;
2578 // negation
2579 if ($char === '-') {
2580 if ($this->customProperty($out)) {
2581 return true;
2584 $this->count++;
2586 $follow_white = $this->whitespace();
2588 if ($this->variable($inner) || $this->unit($inner) || $this->parenValue($inner)) {
2589 $out = [Type::T_UNARY, '-', $inner, $this->inParens];
2591 return true;
2594 if (
2595 $this->keyword($inner) &&
2596 ! $this->func($inner, $out)
2598 $out = [Type::T_UNARY, '-', $inner, $this->inParens];
2600 return true;
2603 if ($follow_white) {
2604 $out = [Type::T_KEYWORD, $char];
2606 return true;
2609 $this->seek($s);
2612 // paren
2613 if ($char === '(' && $this->parenValue($out)) {
2614 return true;
2617 if ($char === '#') {
2618 if ($this->interpolation($out) || $this->color($out)) {
2619 return true;
2622 $this->count++;
2624 if ($this->keyword($keyword)) {
2625 $out = [Type::T_KEYWORD, '#' . $keyword];
2627 return true;
2630 $this->count--;
2633 if ($this->matchChar('&', true)) {
2634 $out = [Type::T_SELF];
2636 return true;
2639 if ($char === '$' && $this->variable($out)) {
2640 return true;
2643 if ($char === 'p' && $this->progid($out)) {
2644 return true;
2647 if (($char === '"' || $char === "'") && $this->string($out)) {
2648 return true;
2651 if ($this->unit($out)) {
2652 return true;
2655 // unicode range with wildcards
2656 if (
2657 $this->literal('U+', 2) &&
2658 $this->match('\?+|([0-9A-F]+(\?+|(-[0-9A-F]+))?)', $m, false)
2660 $unicode = explode('-', $m[0]);
2661 if (strlen(reset($unicode)) <= 6 && strlen(end($unicode)) <= 6) {
2662 $out = [Type::T_KEYWORD, 'U+' . $m[0]];
2664 return true;
2666 $this->count -= strlen($m[0]) + 2;
2669 if ($this->keyword($keyword, false)) {
2670 if ($this->func($keyword, $out)) {
2671 return true;
2674 $this->whitespace();
2676 if ($keyword === 'null') {
2677 $out = [Type::T_NULL];
2678 } else {
2679 $out = [Type::T_KEYWORD, $keyword];
2682 return true;
2685 return false;
2689 * Parse parenthesized value
2691 * @param array $out
2693 * @return bool
2695 protected function parenValue(&$out)
2697 $s = $this->count;
2699 $inParens = $this->inParens;
2701 if ($this->matchChar('(')) {
2702 if ($this->matchChar(')')) {
2703 $out = [Type::T_LIST, '', []];
2705 return true;
2708 $this->inParens = true;
2710 if (
2711 $this->expression($exp) &&
2712 $this->matchChar(')')
2714 $out = $exp;
2715 $this->inParens = $inParens;
2717 return true;
2721 $this->inParens = $inParens;
2722 $this->seek($s);
2724 return false;
2728 * Parse "progid:"
2730 * @param array $out
2732 * @return bool
2734 protected function progid(&$out)
2736 $s = $this->count;
2738 if (
2739 $this->literal('progid:', 7, false) &&
2740 $this->openString('(', $fn) &&
2741 $this->matchChar('(')
2743 $this->openString(')', $args, '(');
2745 if ($this->matchChar(')')) {
2746 $out = [Type::T_STRING, '', [
2747 'progid:', $fn, '(', $args, ')'
2750 return true;
2754 $this->seek($s);
2756 return false;
2760 * Parse function call
2762 * @param string $name
2763 * @param array $func
2765 * @return bool
2767 protected function func($name, &$func)
2769 $s = $this->count;
2771 if ($this->matchChar('(')) {
2772 if ($name === 'alpha' && $this->argumentList($args)) {
2773 $func = [Type::T_FUNCTION, $name, [Type::T_STRING, '', $args]];
2775 return true;
2778 if ($name !== 'expression' && ! preg_match('/^(-[a-z]+-)?calc$/', $name)) {
2779 $ss = $this->count;
2781 if (
2782 $this->argValues($args) &&
2783 $this->matchChar(')')
2785 $func = [Type::T_FUNCTION_CALL, $name, $args];
2787 return true;
2790 $this->seek($ss);
2793 if (
2794 ($this->openString(')', $str, '(') || true) &&
2795 $this->matchChar(')')
2797 $args = [];
2799 if (! empty($str)) {
2800 $args[] = [null, [Type::T_STRING, '', [$str]]];
2803 $func = [Type::T_FUNCTION_CALL, $name, $args];
2805 return true;
2809 $this->seek($s);
2811 return false;
2815 * Parse function call argument list
2817 * @param array $out
2819 * @return bool
2821 protected function argumentList(&$out)
2823 $s = $this->count;
2824 $this->matchChar('(');
2826 $args = [];
2828 while ($this->keyword($var)) {
2829 if (
2830 $this->matchChar('=') &&
2831 $this->expression($exp)
2833 $args[] = [Type::T_STRING, '', [$var . '=']];
2834 $arg = $exp;
2835 } else {
2836 break;
2839 $args[] = $arg;
2841 if (! $this->matchChar(',')) {
2842 break;
2845 $args[] = [Type::T_STRING, '', [', ']];
2848 if (! $this->matchChar(')') || ! $args) {
2849 $this->seek($s);
2851 return false;
2854 $out = $args;
2856 return true;
2860 * Parse mixin/function definition argument list
2862 * @param array $out
2864 * @return bool
2866 protected function argumentDef(&$out)
2868 $s = $this->count;
2869 $this->matchChar('(');
2871 $args = [];
2873 while ($this->variable($var)) {
2874 $arg = [$var[1], null, false];
2876 $ss = $this->count;
2878 if (
2879 $this->matchChar(':') &&
2880 $this->genericList($defaultVal, 'expression', '', true)
2882 $arg[1] = $defaultVal;
2883 } else {
2884 $this->seek($ss);
2887 $ss = $this->count;
2889 if ($this->literal('...', 3)) {
2890 $sss = $this->count;
2892 if (! $this->matchChar(')')) {
2893 throw $this->parseError('... has to be after the final argument');
2896 $arg[2] = true;
2898 $this->seek($sss);
2899 } else {
2900 $this->seek($ss);
2903 $args[] = $arg;
2905 if (! $this->matchChar(',')) {
2906 break;
2910 if (! $this->matchChar(')')) {
2911 $this->seek($s);
2913 return false;
2916 $out = $args;
2918 return true;
2922 * Parse map
2924 * @param array $out
2926 * @return bool
2928 protected function map(&$out)
2930 $s = $this->count;
2932 if (! $this->matchChar('(')) {
2933 return false;
2936 $keys = [];
2937 $values = [];
2939 while (
2940 $this->genericList($key, 'expression', '', true) &&
2941 $this->matchChar(':') &&
2942 $this->genericList($value, 'expression', '', true)
2944 $keys[] = $key;
2945 $values[] = $value;
2947 if (! $this->matchChar(',')) {
2948 break;
2952 if (! $keys || ! $this->matchChar(')')) {
2953 $this->seek($s);
2955 return false;
2958 $out = [Type::T_MAP, $keys, $values];
2960 return true;
2964 * Parse color
2966 * @param array $out
2968 * @return bool
2970 protected function color(&$out)
2972 $s = $this->count;
2974 if ($this->match('(#([0-9a-f]+)\b)', $m)) {
2975 if (\in_array(\strlen($m[2]), [3,4,6,8])) {
2976 $out = [Type::T_KEYWORD, $m[0]];
2978 return true;
2981 $this->seek($s);
2983 return false;
2986 return false;
2990 * Parse number with unit
2992 * @param array $unit
2994 * @return bool
2996 protected function unit(&$unit)
2998 $s = $this->count;
3000 if ($this->match('([0-9]*(\.)?[0-9]+)([%a-zA-Z]+)?', $m, false)) {
3001 if (\strlen($this->buffer) === $this->count || ! ctype_digit($this->buffer[$this->count])) {
3002 $this->whitespace();
3004 $unit = new Node\Number($m[1], empty($m[3]) ? '' : $m[3]);
3006 return true;
3009 $this->seek($s);
3012 return false;
3016 * Parse string
3018 * @param array $out
3019 * @param bool $keepDelimWithInterpolation
3021 * @return bool
3023 protected function string(&$out, $keepDelimWithInterpolation = false)
3025 $s = $this->count;
3027 if ($this->matchChar('"', false)) {
3028 $delim = '"';
3029 } elseif ($this->matchChar("'", false)) {
3030 $delim = "'";
3031 } else {
3032 return false;
3035 $content = [];
3036 $oldWhite = $this->eatWhiteDefault;
3037 $this->eatWhiteDefault = false;
3038 $hasInterpolation = false;
3040 while ($this->matchString($m, $delim)) {
3041 if ($m[1] !== '') {
3042 $content[] = $m[1];
3045 if ($m[2] === '#{') {
3046 $this->count -= \strlen($m[2]);
3048 if ($this->interpolation($inter, false)) {
3049 $content[] = $inter;
3050 $hasInterpolation = true;
3051 } else {
3052 $this->count += \strlen($m[2]);
3053 $content[] = '#{'; // ignore it
3055 } elseif ($m[2] === "\r") {
3056 $content[] = chr(10);
3057 // TODO : warning
3058 # DEPRECATION WARNING on line x, column y of zzz:
3059 # Unescaped multiline strings are deprecated and will be removed in a future version of Sass.
3060 # To include a newline in a string, use "\a" or "\a " as in CSS.
3061 if ($this->matchChar("\n", false)) {
3062 $content[] = ' ';
3064 } elseif ($m[2] === '\\') {
3065 if (
3066 $this->literal("\r\n", 2, false) ||
3067 $this->matchChar("\r", false) ||
3068 $this->matchChar("\n", false) ||
3069 $this->matchChar("\f", false)
3071 // this is a continuation escaping, to be ignored
3072 } elseif ($this->matchEscapeCharacter($c)) {
3073 $content[] = $c;
3074 } else {
3075 throw $this->parseError('Unterminated escape sequence');
3077 } else {
3078 $this->count -= \strlen($delim);
3079 break; // delim
3083 $this->eatWhiteDefault = $oldWhite;
3085 if ($this->literal($delim, \strlen($delim))) {
3086 if ($hasInterpolation && ! $keepDelimWithInterpolation) {
3087 $delim = '"';
3090 $out = [Type::T_STRING, $delim, $content];
3092 return true;
3095 $this->seek($s);
3097 return false;
3101 * @param string $out
3102 * @param bool $inKeywords
3104 * @return bool
3106 protected function matchEscapeCharacter(&$out, $inKeywords = false)
3108 $s = $this->count;
3109 if ($this->match('[a-f0-9]', $m, false)) {
3110 $hex = $m[0];
3112 for ($i = 5; $i--;) {
3113 if ($this->match('[a-f0-9]', $m, false)) {
3114 $hex .= $m[0];
3115 } else {
3116 break;
3120 // CSS allows Unicode escape sequences to be followed by a delimiter space
3121 // (necessary in some cases for shorter sequences to disambiguate their end)
3122 $this->matchChar(' ', false);
3124 $value = hexdec($hex);
3126 if (!$inKeywords && ($value == 0 || ($value >= 0xD800 && $value <= 0xDFFF) || $value >= 0x10FFFF)) {
3127 $out = "\xEF\xBF\xBD"; // "\u{FFFD}" but with a syntax supported on PHP 5
3128 } elseif ($value < 0x20) {
3129 $out = Util::mbChr($value);
3130 } else {
3131 $out = Util::mbChr($value);
3134 return true;
3137 if ($this->match('.', $m, false)) {
3138 if ($inKeywords && in_array($m[0], ["'",'"','@','&',' ','\\',':','/','%'])) {
3139 $this->seek($s);
3140 return false;
3142 $out = $m[0];
3144 return true;
3147 return false;
3151 * Parse keyword or interpolation
3153 * @param array $out
3154 * @param bool $restricted
3156 * @return bool
3158 protected function mixedKeyword(&$out, $restricted = false)
3160 $parts = [];
3162 $oldWhite = $this->eatWhiteDefault;
3163 $this->eatWhiteDefault = false;
3165 for (;;) {
3166 if ($restricted ? $this->restrictedKeyword($key) : $this->keyword($key)) {
3167 $parts[] = $key;
3168 continue;
3171 if ($this->interpolation($inter)) {
3172 $parts[] = $inter;
3173 continue;
3176 break;
3179 $this->eatWhiteDefault = $oldWhite;
3181 if (! $parts) {
3182 return false;
3185 if ($this->eatWhiteDefault) {
3186 $this->whitespace();
3189 $out = $parts;
3191 return true;
3195 * Parse an unbounded string stopped by $end
3197 * @param string $end
3198 * @param array $out
3199 * @param string $nestOpen
3200 * @param string $nestClose
3201 * @param bool $rtrim
3202 * @param string $disallow
3204 * @return bool
3206 protected function openString($end, &$out, $nestOpen = null, $nestClose = null, $rtrim = true, $disallow = null)
3208 $oldWhite = $this->eatWhiteDefault;
3209 $this->eatWhiteDefault = false;
3211 if ($nestOpen && ! $nestClose) {
3212 $nestClose = $end;
3215 $patt = ($disallow ? '[^' . $this->pregQuote($disallow) . ']' : '.');
3216 $patt = '(' . $patt . '*?)([\'"]|#\{|'
3217 . $this->pregQuote($end) . '|'
3218 . (($nestClose && $nestClose !== $end) ? $this->pregQuote($nestClose) . '|' : '')
3219 . static::$commentPattern . ')';
3221 $nestingLevel = 0;
3223 $content = [];
3225 while ($this->match($patt, $m, false)) {
3226 if (isset($m[1]) && $m[1] !== '') {
3227 $content[] = $m[1];
3229 if ($nestOpen) {
3230 $nestingLevel += substr_count($m[1], $nestOpen);
3234 $tok = $m[2];
3236 $this->count -= \strlen($tok);
3238 if ($tok === $end && ! $nestingLevel) {
3239 break;
3242 if ($tok === $nestClose) {
3243 $nestingLevel--;
3246 if (($tok === "'" || $tok === '"') && $this->string($str, true)) {
3247 $content[] = $str;
3248 continue;
3251 if ($tok === '#{' && $this->interpolation($inter)) {
3252 $content[] = $inter;
3253 continue;
3256 $content[] = $tok;
3257 $this->count += \strlen($tok);
3260 $this->eatWhiteDefault = $oldWhite;
3262 if (! $content || $tok !== $end) {
3263 return false;
3266 // trim the end
3267 if ($rtrim && \is_string(end($content))) {
3268 $content[\count($content) - 1] = rtrim(end($content));
3271 $out = [Type::T_STRING, '', $content];
3273 return true;
3277 * Parser interpolation
3279 * @param string|array $out
3280 * @param bool $lookWhite save information about whitespace before and after
3282 * @return bool
3284 protected function interpolation(&$out, $lookWhite = true)
3286 $oldWhite = $this->eatWhiteDefault;
3287 $allowVars = $this->allowVars;
3288 $this->allowVars = true;
3289 $this->eatWhiteDefault = true;
3291 $s = $this->count;
3293 if (
3294 $this->literal('#{', 2) &&
3295 $this->valueList($value) &&
3296 $this->matchChar('}', false)
3298 if ($value === [Type::T_SELF]) {
3299 $out = $value;
3300 } else {
3301 if ($lookWhite) {
3302 $left = ($s > 0 && preg_match('/\s/', $this->buffer[$s - 1])) ? ' ' : '';
3303 $right = (
3304 ! empty($this->buffer[$this->count]) &&
3305 preg_match('/\s/', $this->buffer[$this->count])
3306 ) ? ' ' : '';
3307 } else {
3308 $left = $right = false;
3311 $out = [Type::T_INTERPOLATE, $value, $left, $right];
3314 $this->eatWhiteDefault = $oldWhite;
3315 $this->allowVars = $allowVars;
3317 if ($this->eatWhiteDefault) {
3318 $this->whitespace();
3321 return true;
3324 $this->seek($s);
3326 $this->eatWhiteDefault = $oldWhite;
3327 $this->allowVars = $allowVars;
3329 return false;
3333 * Parse property name (as an array of parts or a string)
3335 * @param array $out
3337 * @return bool
3339 protected function propertyName(&$out)
3341 $parts = [];
3343 $oldWhite = $this->eatWhiteDefault;
3344 $this->eatWhiteDefault = false;
3346 for (;;) {
3347 if ($this->interpolation($inter)) {
3348 $parts[] = $inter;
3349 continue;
3352 if ($this->keyword($text)) {
3353 $parts[] = $text;
3354 continue;
3357 if (! $parts && $this->match('[:.#]', $m, false)) {
3358 // css hacks
3359 $parts[] = $m[0];
3360 continue;
3363 break;
3366 $this->eatWhiteDefault = $oldWhite;
3368 if (! $parts) {
3369 return false;
3372 // match comment hack
3373 if (preg_match(static::$whitePattern, $this->buffer, $m, 0, $this->count)) {
3374 if (! empty($m[0])) {
3375 $parts[] = $m[0];
3376 $this->count += \strlen($m[0]);
3380 $this->whitespace(); // get any extra whitespace
3382 $out = [Type::T_STRING, '', $parts];
3384 return true;
3388 * Parse custom property name (as an array of parts or a string)
3390 * @param array $out
3392 * @return bool
3394 protected function customProperty(&$out)
3396 $s = $this->count;
3398 if (! $this->literal('--', 2, false)) {
3399 return false;
3402 $parts = ['--'];
3404 $oldWhite = $this->eatWhiteDefault;
3405 $this->eatWhiteDefault = false;
3407 for (;;) {
3408 if ($this->interpolation($inter)) {
3409 $parts[] = $inter;
3410 continue;
3413 if ($this->matchChar('&', false)) {
3414 $parts[] = [Type::T_SELF];
3415 continue;
3418 if ($this->variable($var)) {
3419 $parts[] = $var;
3420 continue;
3423 if ($this->keyword($text)) {
3424 $parts[] = $text;
3425 continue;
3428 break;
3431 $this->eatWhiteDefault = $oldWhite;
3433 if (\count($parts) == 1) {
3434 $this->seek($s);
3436 return false;
3439 $this->whitespace(); // get any extra whitespace
3441 $out = [Type::T_STRING, '', $parts];
3443 return true;
3447 * Parse comma separated selector list
3449 * @param array $out
3450 * @param string|bool $subSelector
3452 * @return bool
3454 protected function selectors(&$out, $subSelector = false)
3456 $s = $this->count;
3457 $selectors = [];
3459 while ($this->selector($sel, $subSelector)) {
3460 $selectors[] = $sel;
3462 if (! $this->matchChar(',', true)) {
3463 break;
3466 while ($this->matchChar(',', true)) {
3467 ; // ignore extra
3471 if (! $selectors) {
3472 $this->seek($s);
3474 return false;
3477 $out = $selectors;
3479 return true;
3483 * Parse whitespace separated selector list
3485 * @param array $out
3486 * @param string|bool $subSelector
3488 * @return bool
3490 protected function selector(&$out, $subSelector = false)
3492 $selector = [];
3494 $discardComments = $this->discardComments;
3495 $this->discardComments = true;
3497 for (;;) {
3498 $s = $this->count;
3500 if ($this->match('[>+~]+', $m, true)) {
3501 if (
3502 $subSelector && \is_string($subSelector) && strpos($subSelector, 'nth-') === 0 &&
3503 $m[0] === '+' && $this->match("(\d+|n\b)", $counter)
3505 $this->seek($s);
3506 } else {
3507 $selector[] = [$m[0]];
3508 continue;
3512 if ($this->selectorSingle($part, $subSelector)) {
3513 $selector[] = $part;
3514 $this->whitespace();
3515 continue;
3518 break;
3521 $this->discardComments = $discardComments;
3523 if (! $selector) {
3524 return false;
3527 $out = $selector;
3529 return true;
3533 * parsing escaped chars in selectors:
3534 * - escaped single chars are kept escaped in the selector but in a normalized form
3535 * (if not in 0-9a-f range as this would be ambigous)
3536 * - other escaped sequences (multibyte chars or 0-9a-f) are kept in their initial escaped form,
3537 * normalized to lowercase
3539 * TODO: this is a fallback solution. Ideally escaped chars in selectors should be encoded as the genuine chars,
3540 * and escaping added when printing in the Compiler, where/if it's mandatory
3541 * - but this require a better formal selector representation instead of the array we have now
3543 * @param string $out
3544 * @param bool $keepEscapedNumber
3546 * @return bool
3548 protected function matchEscapeCharacterInSelector(&$out, $keepEscapedNumber = false)
3550 $s_escape = $this->count;
3551 if ($this->match('\\\\', $m)) {
3552 $out = '\\' . $m[0];
3553 return true;
3556 if ($this->matchEscapeCharacter($escapedout, true)) {
3557 if (strlen($escapedout) === 1) {
3558 if (!preg_match(",\w,", $escapedout)) {
3559 $out = '\\' . $escapedout;
3560 return true;
3561 } elseif (! $keepEscapedNumber || ! \is_numeric($escapedout)) {
3562 $out = $escapedout;
3563 return true;
3566 $escape_sequence = rtrim(substr($this->buffer, $s_escape, $this->count - $s_escape));
3567 if (strlen($escape_sequence) < 6) {
3568 $escape_sequence .= ' ';
3570 $out = '\\' . strtolower($escape_sequence);
3571 return true;
3573 if ($this->match('\\S', $m)) {
3574 $out = '\\' . $m[0];
3575 return true;
3579 return false;
3583 * Parse the parts that make up a selector
3585 * {@internal
3586 * div[yes=no]#something.hello.world:nth-child(-2n+1)%placeholder
3587 * }}
3589 * @param array $out
3590 * @param string|bool $subSelector
3592 * @return bool
3594 protected function selectorSingle(&$out, $subSelector = false)
3596 $oldWhite = $this->eatWhiteDefault;
3597 $this->eatWhiteDefault = false;
3599 $parts = [];
3601 if ($this->matchChar('*', false)) {
3602 $parts[] = '*';
3605 for (;;) {
3606 if (! isset($this->buffer[$this->count])) {
3607 break;
3610 $s = $this->count;
3611 $char = $this->buffer[$this->count];
3613 // see if we can stop early
3614 if ($char === '{' || $char === ',' || $char === ';' || $char === '}' || $char === '@') {
3615 break;
3618 // parsing a sub selector in () stop with the closing )
3619 if ($subSelector && $char === ')') {
3620 break;
3623 //self
3624 switch ($char) {
3625 case '&':
3626 $parts[] = Compiler::$selfSelector;
3627 $this->count++;
3628 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
3629 continue 2;
3631 case '.':
3632 $parts[] = '.';
3633 $this->count++;
3634 continue 2;
3636 case '|':
3637 $parts[] = '|';
3638 $this->count++;
3639 continue 2;
3642 // handling of escaping in selectors : get the escaped char
3643 if ($char === '\\') {
3644 $this->count++;
3645 if ($this->matchEscapeCharacterInSelector($escaped, true)) {
3646 $parts[] = $escaped;
3647 continue;
3649 $this->count--;
3652 if ($char === '%') {
3653 $this->count++;
3655 if ($this->placeholder($placeholder)) {
3656 $parts[] = '%';
3657 $parts[] = $placeholder;
3658 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
3659 continue;
3662 break;
3665 if ($char === '#') {
3666 if ($this->interpolation($inter)) {
3667 $parts[] = $inter;
3668 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
3669 continue;
3672 $parts[] = '#';
3673 $this->count++;
3674 continue;
3677 // a pseudo selector
3678 if ($char === ':') {
3679 if ($this->buffer[$this->count + 1] === ':') {
3680 $this->count += 2;
3681 $part = '::';
3682 } else {
3683 $this->count++;
3684 $part = ':';
3687 if ($this->mixedKeyword($nameParts, true)) {
3688 $parts[] = $part;
3690 foreach ($nameParts as $sub) {
3691 $parts[] = $sub;
3694 $ss = $this->count;
3696 if (
3697 $nameParts === ['not'] ||
3698 $nameParts === ['is'] ||
3699 $nameParts === ['has'] ||
3700 $nameParts === ['where'] ||
3701 $nameParts === ['slotted'] ||
3702 $nameParts === ['nth-child'] ||
3703 $nameParts === ['nth-last-child'] ||
3704 $nameParts === ['nth-of-type'] ||
3705 $nameParts === ['nth-last-of-type']
3707 if (
3708 $this->matchChar('(', true) &&
3709 ($this->selectors($subs, reset($nameParts)) || true) &&
3710 $this->matchChar(')')
3712 $parts[] = '(';
3714 while ($sub = array_shift($subs)) {
3715 while ($ps = array_shift($sub)) {
3716 foreach ($ps as &$p) {
3717 $parts[] = $p;
3720 if (\count($sub) && reset($sub)) {
3721 $parts[] = ' ';
3725 if (\count($subs) && reset($subs)) {
3726 $parts[] = ', ';
3730 $parts[] = ')';
3731 } else {
3732 $this->seek($ss);
3734 } elseif (
3735 $this->matchChar('(', true) &&
3736 ($this->openString(')', $str, '(') || true) &&
3737 $this->matchChar(')')
3739 $parts[] = '(';
3741 if (! empty($str)) {
3742 $parts[] = $str;
3745 $parts[] = ')';
3746 } else {
3747 $this->seek($ss);
3750 continue;
3754 $this->seek($s);
3756 // 2n+1
3757 if ($subSelector && \is_string($subSelector) && strpos($subSelector, 'nth-') === 0) {
3758 if ($this->match("(\s*(\+\s*|\-\s*)?(\d+|n|\d+n))+", $counter)) {
3759 $parts[] = $counter[0];
3760 //$parts[] = str_replace(' ', '', $counter[0]);
3761 continue;
3765 $this->seek($s);
3767 // attribute selector
3768 if (
3769 $char === '[' &&
3770 $this->matchChar('[') &&
3771 ($this->openString(']', $str, '[') || true) &&
3772 $this->matchChar(']')
3774 $parts[] = '[';
3776 if (! empty($str)) {
3777 $parts[] = $str;
3780 $parts[] = ']';
3781 continue;
3784 $this->seek($s);
3786 // for keyframes
3787 if ($this->unit($unit)) {
3788 $parts[] = $unit;
3789 continue;
3792 if ($this->restrictedKeyword($name, false, true)) {
3793 $parts[] = $name;
3794 continue;
3797 break;
3800 $this->eatWhiteDefault = $oldWhite;
3802 if (! $parts) {
3803 return false;
3806 $out = $parts;
3808 return true;
3812 * Parse a variable
3814 * @param array $out
3816 * @return bool
3818 protected function variable(&$out)
3820 $s = $this->count;
3822 if (
3823 $this->matchChar('$', false) &&
3824 $this->keyword($name)
3826 if ($this->allowVars) {
3827 $out = [Type::T_VARIABLE, $name];
3828 } else {
3829 $out = [Type::T_KEYWORD, '$' . $name];
3832 return true;
3835 $this->seek($s);
3837 return false;
3841 * Parse a keyword
3843 * @param string $word
3844 * @param bool $eatWhitespace
3845 * @param bool $inSelector
3847 * @return bool
3849 protected function keyword(&$word, $eatWhitespace = null, $inSelector = false)
3851 $s = $this->count;
3852 $match = $this->match(
3853 $this->utf8
3854 ? '(([\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]) ?|[\\\\].)*)'
3855 : '(([\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]) ?|[\\\\].)*)',
3857 false
3860 if ($match) {
3861 $word = $m[1];
3863 // handling of escaping in keyword : get the escaped char
3864 if (strpos($word, '\\') !== false) {
3865 $send = $this->count;
3866 $escapedWord = [];
3867 $this->seek($s);
3868 $previousEscape = false;
3869 while ($this->count < $send) {
3870 $char = $this->buffer[$this->count];
3871 $this->count++;
3872 if (
3873 $this->count < $send
3874 && $char === '\\'
3875 && !$previousEscape
3876 && (
3877 $inSelector ?
3878 $this->matchEscapeCharacterInSelector($out)
3880 $this->matchEscapeCharacter($out, true)
3883 $escapedWord[] = $out;
3884 } else {
3885 if ($previousEscape) {
3886 $previousEscape = false;
3887 } elseif ($char === '\\') {
3888 $previousEscape = true;
3890 $escapedWord[] = $char;
3894 $word = implode('', $escapedWord);
3897 if (is_null($eatWhitespace) ? $this->eatWhiteDefault : $eatWhitespace) {
3898 $this->whitespace();
3901 return true;
3904 return false;
3908 * Parse a keyword that should not start with a number
3910 * @param string $word
3911 * @param bool $eatWhitespace
3912 * @param bool $inSelector
3914 * @return bool
3916 protected function restrictedKeyword(&$word, $eatWhitespace = null, $inSelector = false)
3918 $s = $this->count;
3920 if ($this->keyword($word, $eatWhitespace, $inSelector) && (\ord($word[0]) > 57 || \ord($word[0]) < 48)) {
3921 return true;
3924 $this->seek($s);
3926 return false;
3930 * Parse a placeholder
3932 * @param string|array $placeholder
3934 * @return bool
3936 protected function placeholder(&$placeholder)
3938 $match = $this->match(
3939 $this->utf8
3940 ? '([\pL\w\-_]+)'
3941 : '([\w\-_]+)',
3945 if ($match) {
3946 $placeholder = $m[1];
3948 return true;
3951 if ($this->interpolation($placeholder)) {
3952 return true;
3955 return false;
3959 * Parse a url
3961 * @param array $out
3963 * @return bool
3965 protected function url(&$out)
3967 if ($this->literal('url(', 4)) {
3968 $s = $this->count;
3970 if (
3971 ($this->string($out) || $this->spaceList($out)) &&
3972 $this->matchChar(')')
3974 $out = [Type::T_STRING, '', ['url(', $out, ')']];
3976 return true;
3979 $this->seek($s);
3981 if (
3982 $this->openString(')', $out) &&
3983 $this->matchChar(')')
3985 $out = [Type::T_STRING, '', ['url(', $out, ')']];
3987 return true;
3991 return false;
3995 * Consume an end of statement delimiter
3996 * @param bool $eatWhitespace
3998 * @return bool
4000 protected function end($eatWhitespace = null)
4002 if ($this->matchChar(';', $eatWhitespace)) {
4003 return true;
4006 if ($this->count === \strlen($this->buffer) || $this->buffer[$this->count] === '}') {
4007 // if there is end of file or a closing block next then we don't need a ;
4008 return true;
4011 return false;
4015 * Strip assignment flag from the list
4017 * @param array $value
4019 * @return array
4021 protected function stripAssignmentFlags(&$value)
4023 $flags = [];
4025 for ($token = &$value; $token[0] === Type::T_LIST && ($s = \count($token[2])); $token = &$lastNode) {
4026 $lastNode = &$token[2][$s - 1];
4028 while ($lastNode[0] === Type::T_KEYWORD && \in_array($lastNode[1], ['!default', '!global'])) {
4029 array_pop($token[2]);
4031 $node = end($token[2]);
4032 $token = $this->flattenList($token);
4033 $flags[] = $lastNode[1];
4034 $lastNode = $node;
4038 return $flags;
4042 * Strip optional flag from selector list
4044 * @param array $selectors
4046 * @return string
4048 protected function stripOptionalFlag(&$selectors)
4050 $optional = false;
4051 $selector = end($selectors);
4052 $part = end($selector);
4054 if ($part === ['!optional']) {
4055 array_pop($selectors[\count($selectors) - 1]);
4057 $optional = true;
4060 return $optional;
4064 * Turn list of length 1 into value type
4066 * @param array $value
4068 * @return array
4070 protected function flattenList($value)
4072 if ($value[0] === Type::T_LIST && \count($value[2]) === 1) {
4073 return $this->flattenList($value[2][0]);
4076 return $value;
4080 * Quote regular expression
4082 * @param string $what
4084 * @return string
4086 private function pregQuote($what)
4088 return preg_quote($what, '/');
4092 * Extract line numbers from buffer
4094 * @param string $buffer
4096 private function extractLineNumbers($buffer)
4098 $this->sourcePositions = [0 => 0];
4099 $prev = 0;
4101 while (($pos = strpos($buffer, "\n", $prev)) !== false) {
4102 $this->sourcePositions[] = $pos;
4103 $prev = $pos + 1;
4106 $this->sourcePositions[] = \strlen($buffer);
4108 if (substr($buffer, -1) !== "\n") {
4109 $this->sourcePositions[] = \strlen($buffer) + 1;
4114 * Get source line number and column (given character position in the buffer)
4116 * @param int $pos
4118 * @return array
4120 private function getSourcePosition($pos)
4122 $low = 0;
4123 $high = \count($this->sourcePositions);
4125 while ($low < $high) {
4126 $mid = (int) (($high + $low) / 2);
4128 if ($pos < $this->sourcePositions[$mid]) {
4129 $high = $mid - 1;
4130 continue;
4133 if ($pos >= $this->sourcePositions[$mid + 1]) {
4134 $low = $mid + 1;
4135 continue;
4138 return [$mid + 1, $pos - $this->sourcePositions[$mid]];
4141 return [$low + 1, $pos - $this->sourcePositions[$low]];
4145 * Save internal encoding of mbstring
4147 * When mbstring.func_overload is used to replace the standard PHP string functions,
4148 * this method configures the internal encoding to a single-byte one so that the
4149 * behavior matches the normal behavior of PHP string functions while using the parser.
4150 * The existing internal encoding is saved and will be restored when calling {@see restoreEncoding}.
4152 * If mbstring.func_overload is not used (or does not override string functions), this method is a no-op.
4154 * @return void
4156 private function saveEncoding()
4158 if (\PHP_VERSION_ID < 80000 && \extension_loaded('mbstring') && (2 & (int) ini_get('mbstring.func_overload')) > 0) {
4159 $this->encoding = mb_internal_encoding();
4161 mb_internal_encoding('iso-8859-1');
4166 * Restore internal encoding
4168 * @return void
4170 private function restoreEncoding()
4172 if (\extension_loaded('mbstring') && $this->encoding) {
4173 mb_internal_encoding($this->encoding);