MDL-70301 lib: Upgrade scssphp to 1.4.1
[moodle.git] / lib / scssphp / Parser.php
blob1faa82f8a8b085d9d00e14898fa296c4a597cdb0
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\Exception\ParserException;
17 /**
18 * Parser
20 * @author Leaf Corcoran <leafot@gmail.com>
22 class Parser
24 const SOURCE_INDEX = -1;
25 const SOURCE_LINE = -2;
26 const SOURCE_COLUMN = -3;
28 /**
29 * @var array<string, int>
31 protected static $precedence = [
32 '=' => 0,
33 'or' => 1,
34 'and' => 2,
35 '==' => 3,
36 '!=' => 3,
37 '<=' => 4,
38 '>=' => 4,
39 '<' => 4,
40 '>' => 4,
41 '+' => 5,
42 '-' => 5,
43 '*' => 6,
44 '/' => 6,
45 '%' => 6,
48 /**
49 * @var string
51 protected static $commentPattern;
52 /**
53 * @var string
55 protected static $operatorPattern;
56 /**
57 * @var string
59 protected static $whitePattern;
61 /**
62 * @var Cache|null
64 protected $cache;
66 private $sourceName;
67 private $sourceIndex;
68 /**
69 * @var array<int, int>
71 private $sourcePositions;
72 /**
73 * @var array|null
75 private $charset;
76 /**
77 * The current offset in the buffer
79 * @var int
81 private $count;
82 /**
83 * @var Block
85 private $env;
86 /**
87 * @var bool
89 private $inParens;
90 /**
91 * @var bool
93 private $eatWhiteDefault;
94 /**
95 * @var bool
97 private $discardComments;
98 private $allowVars;
99 /**
100 * @var string
102 private $buffer;
103 private $utf8;
105 * @var string|null
107 private $encoding;
108 private $patternModifiers;
109 private $commentsSeen;
111 private $cssOnly;
114 * Constructor
116 * @api
118 * @param string $sourceName
119 * @param integer $sourceIndex
120 * @param string|null $encoding
121 * @param Cache|null $cache
122 * @param bool $cssOnly
124 public function __construct($sourceName, $sourceIndex = 0, $encoding = 'utf-8', Cache $cache = null, $cssOnly = false)
126 $this->sourceName = $sourceName ?: '(stdin)';
127 $this->sourceIndex = $sourceIndex;
128 $this->charset = null;
129 $this->utf8 = ! $encoding || strtolower($encoding) === 'utf-8';
130 $this->patternModifiers = $this->utf8 ? 'Aisu' : 'Ais';
131 $this->commentsSeen = [];
132 $this->commentsSeen = [];
133 $this->allowVars = true;
134 $this->cssOnly = $cssOnly;
136 if (empty(static::$operatorPattern)) {
137 static::$operatorPattern = '([*\/%+-]|[!=]\=|\>\=?|\<\=?|and|or)';
139 $commentSingle = '\/\/';
140 $commentMultiLeft = '\/\*';
141 $commentMultiRight = '\*\/';
143 static::$commentPattern = $commentMultiLeft . '.*?' . $commentMultiRight;
144 static::$whitePattern = $this->utf8
145 ? '/' . $commentSingle . '[^\n]*\s*|(' . static::$commentPattern . ')\s*|\s+/AisuS'
146 : '/' . $commentSingle . '[^\n]*\s*|(' . static::$commentPattern . ')\s*|\s+/AisS';
149 $this->cache = $cache;
153 * Get source file name
155 * @api
157 * @return string
159 public function getSourceName()
161 return $this->sourceName;
165 * Throw parser error
167 * @api
169 * @param string $msg
171 * @throws ParserException
173 * @deprecated use "parseError" and throw the exception in the caller instead.
175 public function throwParseError($msg = 'parse error')
177 @trigger_error(
178 'The method "throwParseError" is deprecated. Use "parseError" and throw the exception in the caller instead',
179 E_USER_DEPRECATED
182 throw $this->parseError($msg);
186 * Creates a parser error
188 * @api
190 * @param string $msg
192 * @return ParserException
194 public function parseError($msg = 'parse error')
196 list($line, $column) = $this->getSourcePosition($this->count);
198 $loc = empty($this->sourceName)
199 ? "line: $line, column: $column"
200 : "$this->sourceName on line $line, at column $column";
202 if ($this->peek('(.*?)(\n|$)', $m, $this->count)) {
203 $this->restoreEncoding();
205 $e = new ParserException("$msg: failed at `$m[1]` $loc");
206 $e->setSourcePosition([$this->sourceName, $line, $column]);
208 return $e;
211 $this->restoreEncoding();
213 $e = new ParserException("$msg: $loc");
214 $e->setSourcePosition([$this->sourceName, $line, $column]);
216 return $e;
220 * Parser buffer
222 * @api
224 * @param string $buffer
226 * @return Block
228 public function parse($buffer)
230 if ($this->cache) {
231 $cacheKey = $this->sourceName . ':' . md5($buffer);
232 $parseOptions = [
233 'charset' => $this->charset,
234 'utf8' => $this->utf8,
236 $v = $this->cache->getCache('parse', $cacheKey, $parseOptions);
238 if (! \is_null($v)) {
239 return $v;
243 // strip BOM (byte order marker)
244 if (substr($buffer, 0, 3) === "\xef\xbb\xbf") {
245 $buffer = substr($buffer, 3);
248 $this->buffer = rtrim($buffer, "\x00..\x1f");
249 $this->count = 0;
250 $this->env = null;
251 $this->inParens = false;
252 $this->eatWhiteDefault = true;
254 $this->saveEncoding();
255 $this->extractLineNumbers($buffer);
257 $this->pushBlock(null); // root block
258 $this->whitespace();
259 $this->pushBlock(null);
260 $this->popBlock();
262 while ($this->parseChunk()) {
266 if ($this->count !== \strlen($this->buffer)) {
267 throw $this->parseError();
270 if (! empty($this->env->parent)) {
271 throw $this->parseError('unclosed block');
274 if ($this->charset) {
275 array_unshift($this->env->children, $this->charset);
278 $this->restoreEncoding();
280 if ($this->cache) {
281 $this->cache->setCache('parse', $cacheKey, $this->env, $parseOptions);
284 return $this->env;
288 * Parse a value or value list
290 * @api
292 * @param string $buffer
293 * @param string|array $out
295 * @return boolean
297 public function parseValue($buffer, &$out)
299 $this->count = 0;
300 $this->env = null;
301 $this->inParens = false;
302 $this->eatWhiteDefault = true;
303 $this->buffer = (string) $buffer;
305 $this->saveEncoding();
306 $this->extractLineNumbers($this->buffer);
308 $list = $this->valueList($out);
310 $this->restoreEncoding();
312 return $list;
316 * Parse a selector or selector list
318 * @api
320 * @param string $buffer
321 * @param string|array $out
322 * @param bool $shouldValidate
324 * @return boolean
326 public function parseSelector($buffer, &$out, $shouldValidate = true)
328 $this->count = 0;
329 $this->env = null;
330 $this->inParens = false;
331 $this->eatWhiteDefault = true;
332 $this->buffer = (string) $buffer;
334 $this->saveEncoding();
335 $this->extractLineNumbers($this->buffer);
337 // discard space/comments at the start
338 $this->discardComments = true;
339 $this->whitespace();
340 $this->discardComments = false;
342 $selector = $this->selectors($out);
344 $this->restoreEncoding();
346 if ($shouldValidate && $this->count !== strlen($buffer)) {
347 throw $this->parseError("`" . substr($buffer, $this->count) . "` is not a valid Selector in `$buffer`");
350 return $selector;
354 * Parse a media Query
356 * @api
358 * @param string $buffer
359 * @param string|array $out
361 * @return boolean
363 public function parseMediaQueryList($buffer, &$out)
365 $this->count = 0;
366 $this->env = null;
367 $this->inParens = false;
368 $this->eatWhiteDefault = true;
369 $this->buffer = (string) $buffer;
371 $this->saveEncoding();
372 $this->extractLineNumbers($this->buffer);
374 $isMediaQuery = $this->mediaQueryList($out);
376 $this->restoreEncoding();
378 return $isMediaQuery;
382 * Parse a single chunk off the head of the buffer and append it to the
383 * current parse environment.
385 * Returns false when the buffer is empty, or when there is an error.
387 * This function is called repeatedly until the entire document is
388 * parsed.
390 * This parser is most similar to a recursive descent parser. Single
391 * functions represent discrete grammatical rules for the language, and
392 * they are able to capture the text that represents those rules.
394 * Consider the function Compiler::keyword(). (All parse functions are
395 * structured the same.)
397 * The function takes a single reference argument. When calling the
398 * function it will attempt to match a keyword on the head of the buffer.
399 * If it is successful, it will place the keyword in the referenced
400 * argument, advance the position in the buffer, and return true. If it
401 * fails then it won't advance the buffer and it will return false.
403 * All of these parse functions are powered by Compiler::match(), which behaves
404 * the same way, but takes a literal regular expression. Sometimes it is
405 * more convenient to use match instead of creating a new function.
407 * Because of the format of the functions, to parse an entire string of
408 * grammatical rules, you can chain them together using &&.
410 * But, if some of the rules in the chain succeed before one fails, then
411 * the buffer position will be left at an invalid state. In order to
412 * avoid this, Compiler::seek() is used to remember and set buffer positions.
414 * Before parsing a chain, use $s = $this->count to remember the current
415 * position into $s. Then if a chain fails, use $this->seek($s) to
416 * go back where we started.
418 * @return boolean
420 protected function parseChunk()
422 $s = $this->count;
424 // the directives
425 if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] === '@') {
426 if (
427 $this->literal('@at-root', 8) &&
428 ($this->selectors($selector) || true) &&
429 ($this->map($with) || true) &&
430 (($this->matchChar('(') &&
431 $this->interpolation($with) &&
432 $this->matchChar(')')) || true) &&
433 $this->matchChar('{', false)
435 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
437 $atRoot = $this->pushSpecialBlock(Type::T_AT_ROOT, $s);
438 $atRoot->selector = $selector;
439 $atRoot->with = $with;
441 return true;
444 $this->seek($s);
446 if (
447 $this->literal('@media', 6) &&
448 $this->mediaQueryList($mediaQueryList) &&
449 $this->matchChar('{', false)
451 $media = $this->pushSpecialBlock(Type::T_MEDIA, $s);
452 $media->queryList = $mediaQueryList[2];
454 return true;
457 $this->seek($s);
459 if (
460 $this->literal('@mixin', 6) &&
461 $this->keyword($mixinName) &&
462 ($this->argumentDef($args) || true) &&
463 $this->matchChar('{', false)
465 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
467 $mixin = $this->pushSpecialBlock(Type::T_MIXIN, $s);
468 $mixin->name = $mixinName;
469 $mixin->args = $args;
471 return true;
474 $this->seek($s);
476 if (
477 ($this->literal('@include', 8) &&
478 $this->keyword($mixinName) &&
479 ($this->matchChar('(') &&
480 ($this->argValues($argValues) || true) &&
481 $this->matchChar(')') || true) &&
482 ($this->end()) ||
483 ($this->literal('using', 5) &&
484 $this->argumentDef($argUsing) &&
485 ($this->end() || $this->matchChar('{') && $hasBlock = true)) ||
486 $this->matchChar('{') && $hasBlock = true)
488 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
490 $child = [
491 Type::T_INCLUDE,
492 $mixinName,
493 isset($argValues) ? $argValues : null,
494 null,
495 isset($argUsing) ? $argUsing : null
498 if (! empty($hasBlock)) {
499 $include = $this->pushSpecialBlock(Type::T_INCLUDE, $s);
500 $include->child = $child;
501 } else {
502 $this->append($child, $s);
505 return true;
508 $this->seek($s);
510 if (
511 $this->literal('@scssphp-import-once', 20) &&
512 $this->valueList($importPath) &&
513 $this->end()
515 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
517 $this->append([Type::T_SCSSPHP_IMPORT_ONCE, $importPath], $s);
519 return true;
522 $this->seek($s);
524 if (
525 $this->literal('@import', 7) &&
526 $this->valueList($importPath) &&
527 $importPath[0] !== Type::T_FUNCTION_CALL &&
528 $this->end()
530 if ($this->cssOnly) {
531 $this->assertPlainCssValid([Type::T_IMPORT, $importPath], $s);
532 $this->append([Type::T_COMMENT, rtrim(substr($this->buffer, $s, $this->count - $s))]);
533 return true;
536 $this->append([Type::T_IMPORT, $importPath], $s);
538 return true;
541 $this->seek($s);
543 if (
544 $this->literal('@import', 7) &&
545 $this->url($importPath) &&
546 $this->end()
548 if ($this->cssOnly) {
549 $this->assertPlainCssValid([Type::T_IMPORT, $importPath], $s);
550 $this->append([Type::T_COMMENT, rtrim(substr($this->buffer, $s, $this->count - $s))]);
551 return true;
554 $this->append([Type::T_IMPORT, $importPath], $s);
556 return true;
559 $this->seek($s);
561 if (
562 $this->literal('@extend', 7) &&
563 $this->selectors($selectors) &&
564 $this->end()
566 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
568 // check for '!flag'
569 $optional = $this->stripOptionalFlag($selectors);
570 $this->append([Type::T_EXTEND, $selectors, $optional], $s);
572 return true;
575 $this->seek($s);
577 if (
578 $this->literal('@function', 9) &&
579 $this->keyword($fnName) &&
580 $this->argumentDef($args) &&
581 $this->matchChar('{', false)
583 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
585 $func = $this->pushSpecialBlock(Type::T_FUNCTION, $s);
586 $func->name = $fnName;
587 $func->args = $args;
589 return true;
592 $this->seek($s);
594 if (
595 $this->literal('@return', 7) &&
596 ($this->valueList($retVal) || true) &&
597 $this->end()
599 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
601 $this->append([Type::T_RETURN, isset($retVal) ? $retVal : [Type::T_NULL]], $s);
603 return true;
606 $this->seek($s);
608 if (
609 $this->literal('@each', 5) &&
610 $this->genericList($varNames, 'variable', ',', false) &&
611 $this->literal('in', 2) &&
612 $this->valueList($list) &&
613 $this->matchChar('{', false)
615 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
617 $each = $this->pushSpecialBlock(Type::T_EACH, $s);
619 foreach ($varNames[2] as $varName) {
620 $each->vars[] = $varName[1];
623 $each->list = $list;
625 return true;
628 $this->seek($s);
630 if (
631 $this->literal('@while', 6) &&
632 $this->expression($cond) &&
633 $this->matchChar('{', false)
635 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
637 while (
638 $cond[0] === Type::T_LIST &&
639 ! empty($cond['enclosing']) &&
640 $cond['enclosing'] === 'parent' &&
641 \count($cond[2]) == 1
643 $cond = reset($cond[2]);
646 $while = $this->pushSpecialBlock(Type::T_WHILE, $s);
647 $while->cond = $cond;
649 return true;
652 $this->seek($s);
654 if (
655 $this->literal('@for', 4) &&
656 $this->variable($varName) &&
657 $this->literal('from', 4) &&
658 $this->expression($start) &&
659 ($this->literal('through', 7) ||
660 ($forUntil = true && $this->literal('to', 2))) &&
661 $this->expression($end) &&
662 $this->matchChar('{', false)
664 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
666 $for = $this->pushSpecialBlock(Type::T_FOR, $s);
667 $for->var = $varName[1];
668 $for->start = $start;
669 $for->end = $end;
670 $for->until = isset($forUntil);
672 return true;
675 $this->seek($s);
677 if (
678 $this->literal('@if', 3) &&
679 $this->functionCallArgumentsList($cond, false, '{', false)
681 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
683 $if = $this->pushSpecialBlock(Type::T_IF, $s);
685 while (
686 $cond[0] === Type::T_LIST &&
687 ! empty($cond['enclosing']) &&
688 $cond['enclosing'] === 'parent' &&
689 \count($cond[2]) == 1
691 $cond = reset($cond[2]);
694 $if->cond = $cond;
695 $if->cases = [];
697 return true;
700 $this->seek($s);
702 if (
703 $this->literal('@debug', 6) &&
704 $this->functionCallArgumentsList($value, false)
706 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
708 $this->append([Type::T_DEBUG, $value], $s);
710 return true;
713 $this->seek($s);
715 if (
716 $this->literal('@warn', 5) &&
717 $this->functionCallArgumentsList($value, false)
719 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
721 $this->append([Type::T_WARN, $value], $s);
723 return true;
726 $this->seek($s);
728 if (
729 $this->literal('@error', 6) &&
730 $this->functionCallArgumentsList($value, false)
732 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
734 $this->append([Type::T_ERROR, $value], $s);
736 return true;
739 $this->seek($s);
741 if (
742 $this->literal('@content', 8) &&
743 ($this->end() ||
744 $this->matchChar('(') &&
745 $this->argValues($argContent) &&
746 $this->matchChar(')') &&
747 $this->end())
749 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
751 $this->append([Type::T_MIXIN_CONTENT, isset($argContent) ? $argContent : null], $s);
753 return true;
756 $this->seek($s);
758 $last = $this->last();
760 if (isset($last) && $last[0] === Type::T_IF) {
761 list(, $if) = $last;
763 if ($this->literal('@else', 5)) {
764 if ($this->matchChar('{', false)) {
765 $else = $this->pushSpecialBlock(Type::T_ELSE, $s);
766 } elseif (
767 $this->literal('if', 2) &&
768 $this->functionCallArgumentsList($cond, false, '{', false)
770 $else = $this->pushSpecialBlock(Type::T_ELSEIF, $s);
771 $else->cond = $cond;
774 if (isset($else)) {
775 $else->dontAppend = true;
776 $if->cases[] = $else;
778 return true;
782 $this->seek($s);
785 // only retain the first @charset directive encountered
786 if (
787 $this->literal('@charset', 8) &&
788 $this->valueList($charset) &&
789 $this->end()
791 if (! isset($this->charset)) {
792 $statement = [Type::T_CHARSET, $charset];
794 list($line, $column) = $this->getSourcePosition($s);
796 $statement[static::SOURCE_LINE] = $line;
797 $statement[static::SOURCE_COLUMN] = $column;
798 $statement[static::SOURCE_INDEX] = $this->sourceIndex;
800 $this->charset = $statement;
803 return true;
806 $this->seek($s);
808 if (
809 $this->literal('@supports', 9) &&
810 ($t1 = $this->supportsQuery($supportQuery)) &&
811 ($t2 = $this->matchChar('{', false))
813 $directive = $this->pushSpecialBlock(Type::T_DIRECTIVE, $s);
814 $directive->name = 'supports';
815 $directive->value = $supportQuery;
817 return true;
820 $this->seek($s);
822 // doesn't match built in directive, do generic one
823 if (
824 $this->matchChar('@', false) &&
825 $this->mixedKeyword($dirName) &&
826 $this->directiveValue($dirValue, '{')
828 if (count($dirName) === 1 && is_string(reset($dirName))) {
829 $dirName = reset($dirName);
830 } else {
831 $dirName = [Type::T_STRING, '', $dirName];
833 if ($dirName === 'media') {
834 $directive = $this->pushSpecialBlock(Type::T_MEDIA, $s);
835 } else {
836 $directive = $this->pushSpecialBlock(Type::T_DIRECTIVE, $s);
837 $directive->name = $dirName;
840 if (isset($dirValue)) {
841 ! $this->cssOnly || ($dirValue = $this->assertPlainCssValid($dirValue));
842 $directive->value = $dirValue;
845 return true;
848 $this->seek($s);
850 // maybe it's a generic blockless directive
851 if (
852 $this->matchChar('@', false) &&
853 $this->mixedKeyword($dirName) &&
854 ! $this->isKnownGenericDirective($dirName) &&
855 ($this->end(false) || ($this->directiveValue($dirValue, '') && $this->end(false)))
857 if (\count($dirName) === 1 && \is_string(\reset($dirName))) {
858 $dirName = \reset($dirName);
859 } else {
860 $dirName = [Type::T_STRING, '', $dirName];
862 if (
863 ! empty($this->env->parent) &&
864 $this->env->type &&
865 ! \in_array($this->env->type, [Type::T_DIRECTIVE, Type::T_MEDIA])
867 $plain = \trim(\substr($this->buffer, $s, $this->count - $s));
868 throw $this->parseError(
869 "Unknown directive `{$plain}` not allowed in `" . $this->env->type . "` block"
872 // blockless directives with a blank line after keeps their blank lines after
873 // sass-spec compliance purpose
874 $s = $this->count;
875 $hasBlankLine = false;
876 if ($this->match('\s*?\n\s*\n', $out, false)) {
877 $hasBlankLine = true;
878 $this->seek($s);
880 $isNotRoot = ! empty($this->env->parent);
881 $this->append([Type::T_DIRECTIVE, [$dirName, $dirValue, $hasBlankLine, $isNotRoot]], $s);
882 $this->whitespace();
884 return true;
887 $this->seek($s);
889 return false;
892 $inCssSelector = null;
893 if ($this->cssOnly) {
894 $inCssSelector = (! empty($this->env->parent) &&
895 ! in_array($this->env->type, [Type::T_DIRECTIVE, Type::T_MEDIA]));
897 // custom properties : right part is static
898 if (($this->customProperty($name) ) && $this->matchChar(':', false)) {
899 $start = $this->count;
901 // but can be complex and finish with ; or }
902 foreach ([';','}'] as $ending) {
903 if (
904 $this->openString($ending, $stringValue, '(', ')', false) &&
905 $this->end()
907 $end = $this->count;
908 $value = $stringValue;
910 // check if we have only a partial value due to nested [] or { } to take in account
911 $nestingPairs = [['[', ']'], ['{', '}']];
913 foreach ($nestingPairs as $nestingPair) {
914 $p = strpos($this->buffer, $nestingPair[0], $start);
916 if ($p && $p < $end) {
917 $this->seek($start);
919 if (
920 $this->openString($ending, $stringValue, $nestingPair[0], $nestingPair[1], false) &&
921 $this->end() &&
922 $this->count > $end
924 $end = $this->count;
925 $value = $stringValue;
930 $this->seek($end);
931 $this->append([Type::T_CUSTOM_PROPERTY, $name, $value], $s);
933 return true;
937 // TODO: output an error here if nothing found according to sass spec
940 $this->seek($s);
942 // property shortcut
943 // captures most properties before having to parse a selector
944 if (
945 $this->keyword($name, false) &&
946 $this->literal(': ', 2) &&
947 $this->valueList($value) &&
948 $this->end()
950 $name = [Type::T_STRING, '', [$name]];
951 $this->append([Type::T_ASSIGN, $name, $value], $s);
953 return true;
956 $this->seek($s);
958 // variable assigns
959 if (
960 $this->variable($name) &&
961 $this->matchChar(':') &&
962 $this->valueList($value) &&
963 $this->end()
965 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
967 // check for '!flag'
968 $assignmentFlags = $this->stripAssignmentFlags($value);
969 $this->append([Type::T_ASSIGN, $name, $value, $assignmentFlags], $s);
971 return true;
974 $this->seek($s);
976 // misc
977 if ($this->literal('-->', 3)) {
978 return true;
981 // opening css block
982 if (
983 $this->selectors($selectors) &&
984 $this->matchChar('{', false)
986 ! $this->cssOnly || ! $inCssSelector || $this->assertPlainCssValid(false);
988 $this->pushBlock($selectors, $s);
990 if ($this->eatWhiteDefault) {
991 $this->whitespace();
992 $this->append(null); // collect comments at the beginning if needed
995 return true;
998 $this->seek($s);
1000 // property assign, or nested assign
1001 if (
1002 $this->propertyName($name) &&
1003 $this->matchChar(':')
1005 $foundSomething = false;
1007 if ($this->valueList($value)) {
1008 if (empty($this->env->parent)) {
1009 throw $this->parseError('expected "{"');
1012 $this->append([Type::T_ASSIGN, $name, $value], $s);
1013 $foundSomething = true;
1016 if ($this->matchChar('{', false)) {
1017 ! $this->cssOnly || $this->assertPlainCssValid(false);
1019 $propBlock = $this->pushSpecialBlock(Type::T_NESTED_PROPERTY, $s);
1020 $propBlock->prefix = $name;
1021 $propBlock->hasValue = $foundSomething;
1023 $foundSomething = true;
1024 } elseif ($foundSomething) {
1025 $foundSomething = $this->end();
1028 if ($foundSomething) {
1029 return true;
1033 $this->seek($s);
1035 // closing a block
1036 if ($this->matchChar('}', false)) {
1037 $block = $this->popBlock();
1039 if (! isset($block->type) || $block->type !== Type::T_IF) {
1040 if ($this->env->parent) {
1041 $this->append(null); // collect comments before next statement if needed
1045 if (isset($block->type) && $block->type === Type::T_INCLUDE) {
1046 $include = $block->child;
1047 unset($block->child);
1048 $include[3] = $block;
1049 $this->append($include, $s);
1050 } elseif (empty($block->dontAppend)) {
1051 $type = isset($block->type) ? $block->type : Type::T_BLOCK;
1052 $this->append([$type, $block], $s);
1055 // collect comments just after the block closing if needed
1056 if ($this->eatWhiteDefault) {
1057 $this->whitespace();
1059 if ($this->env->comments) {
1060 $this->append(null);
1064 return true;
1067 // extra stuff
1068 if (
1069 $this->matchChar(';') ||
1070 $this->literal('<!--', 4)
1072 return true;
1075 return false;
1079 * Push block onto parse tree
1081 * @param array|null $selectors
1082 * @param integer $pos
1084 * @return Block
1086 protected function pushBlock($selectors, $pos = 0)
1088 list($line, $column) = $this->getSourcePosition($pos);
1090 $b = new Block();
1091 $b->sourceName = $this->sourceName;
1092 $b->sourceLine = $line;
1093 $b->sourceColumn = $column;
1094 $b->sourceIndex = $this->sourceIndex;
1095 $b->selectors = $selectors;
1096 $b->comments = [];
1097 $b->parent = $this->env;
1099 if (! $this->env) {
1100 $b->children = [];
1101 } elseif (empty($this->env->children)) {
1102 $this->env->children = $this->env->comments;
1103 $b->children = [];
1104 $this->env->comments = [];
1105 } else {
1106 $b->children = $this->env->comments;
1107 $this->env->comments = [];
1110 $this->env = $b;
1112 // collect comments at the beginning of a block if needed
1113 if ($this->eatWhiteDefault) {
1114 $this->whitespace();
1116 if ($this->env->comments) {
1117 $this->append(null);
1121 return $b;
1125 * Push special (named) block onto parse tree
1127 * @param string $type
1128 * @param integer $pos
1130 * @return Block
1132 protected function pushSpecialBlock($type, $pos)
1134 $block = $this->pushBlock(null, $pos);
1135 $block->type = $type;
1137 return $block;
1141 * Pop scope and return last block
1143 * @return Block
1145 * @throws \Exception
1147 protected function popBlock()
1150 // collect comments ending just before of a block closing
1151 if ($this->env->comments) {
1152 $this->append(null);
1155 // pop the block
1156 $block = $this->env;
1158 if (empty($block->parent)) {
1159 throw $this->parseError('unexpected }');
1162 if ($block->type == Type::T_AT_ROOT) {
1163 // keeps the parent in case of self selector &
1164 $block->selfParent = $block->parent;
1167 $this->env = $block->parent;
1169 unset($block->parent);
1171 return $block;
1175 * Peek input stream
1177 * @param string $regex
1178 * @param array $out
1179 * @param integer $from
1181 * @return integer
1183 protected function peek($regex, &$out, $from = null)
1185 if (! isset($from)) {
1186 $from = $this->count;
1189 $r = '/' . $regex . '/' . $this->patternModifiers;
1190 $result = preg_match($r, $this->buffer, $out, null, $from);
1192 return $result;
1196 * Seek to position in input stream (or return current position in input stream)
1198 * @param integer $where
1200 protected function seek($where)
1202 $this->count = $where;
1206 * Assert a parsed part is plain CSS Valid
1208 * @param array|false $parsed
1209 * @param int $startPos
1210 * @throws ParserException
1212 protected function assertPlainCssValid($parsed, $startPos = null)
1214 $type = '';
1215 if ($parsed) {
1216 $type = $parsed[0];
1217 $parsed = $this->isPlainCssValidElement($parsed);
1219 if (! $parsed) {
1220 if (! \is_null($startPos)) {
1221 $plain = rtrim(substr($this->buffer, $startPos, $this->count - $startPos));
1222 $message = "Error : `{$plain}` isn't allowed in plain CSS";
1223 } else {
1224 $message = 'Error: SCSS syntax not allowed in CSS file';
1226 if ($type) {
1227 $message .= " ($type)";
1229 throw $this->parseError($message);
1232 return $parsed;
1236 * Check a parsed element is plain CSS Valid
1237 * @param array $parsed
1238 * @return bool|array
1240 protected function isPlainCssValidElement($parsed, $allowExpression = false)
1242 // keep string as is
1243 if (is_string($parsed)) {
1244 return $parsed;
1247 if (
1248 \in_array($parsed[0], [Type::T_FUNCTION, Type::T_FUNCTION_CALL]) &&
1249 !\in_array($parsed[1], [
1250 'alpha',
1251 'attr',
1252 'calc',
1253 'cubic-bezier',
1254 'env',
1255 'grayscale',
1256 'hsl',
1257 'hsla',
1258 'invert',
1259 'linear-gradient',
1260 'min',
1261 'max',
1262 'radial-gradient',
1263 'repeating-linear-gradient',
1264 'repeating-radial-gradient',
1265 'rgb',
1266 'rgba',
1267 'rotate',
1268 'saturate',
1269 'var',
1270 ]) &&
1271 Compiler::isNativeFunction($parsed[1])
1273 return false;
1276 switch ($parsed[0]) {
1277 case Type::T_BLOCK:
1278 case Type::T_KEYWORD:
1279 case Type::T_NULL:
1280 case Type::T_NUMBER:
1281 case Type::T_MEDIA:
1282 return $parsed;
1284 case Type::T_COMMENT:
1285 if (isset($parsed[2])) {
1286 return false;
1288 return $parsed;
1290 case Type::T_DIRECTIVE:
1291 if (\is_array($parsed[1])) {
1292 $parsed[1][1] = $this->isPlainCssValidElement($parsed[1][1]);
1293 if (! $parsed[1][1]) {
1294 return false;
1298 return $parsed;
1300 case Type::T_IMPORT:
1301 if ($parsed[1][0] === Type::T_LIST) {
1302 return false;
1304 $parsed[1] = $this->isPlainCssValidElement($parsed[1]);
1305 if ($parsed[1] === false) {
1306 return false;
1308 return $parsed;
1310 case Type::T_STRING:
1311 foreach ($parsed[2] as $k => $substr) {
1312 if (\is_array($substr)) {
1313 $parsed[2][$k] = $this->isPlainCssValidElement($substr);
1314 if (! $parsed[2][$k]) {
1315 return false;
1319 return $parsed;
1321 case Type::T_LIST:
1322 if (!empty($parsed['enclosing'])) {
1323 return false;
1325 foreach ($parsed[2] as $k => $listElement) {
1326 $parsed[2][$k] = $this->isPlainCssValidElement($listElement);
1327 if (! $parsed[2][$k]) {
1328 return false;
1331 return $parsed;
1333 case Type::T_ASSIGN:
1334 foreach ([1, 2, 3] as $k) {
1335 if (! empty($parsed[$k])) {
1336 $parsed[$k] = $this->isPlainCssValidElement($parsed[$k]);
1337 if (! $parsed[$k]) {
1338 return false;
1342 return $parsed;
1344 case Type::T_EXPRESSION:
1345 list( ,$op, $lhs, $rhs, $inParens, $whiteBefore, $whiteAfter) = $parsed;
1346 if (! $allowExpression && ! \in_array($op, ['and', 'or', '/'])) {
1347 return false;
1349 $lhs = $this->isPlainCssValidElement($lhs, true);
1350 if (! $lhs) {
1351 return false;
1353 $rhs = $this->isPlainCssValidElement($rhs, true);
1354 if (! $rhs) {
1355 return false;
1358 return [
1359 Type::T_STRING,
1360 '', [
1361 $this->inParens ? '(' : '',
1362 $lhs,
1363 ($whiteBefore ? ' ' : '') . $op . ($whiteAfter ? ' ' : ''),
1364 $rhs,
1365 $this->inParens ? ')' : ''
1369 case Type::T_CUSTOM_PROPERTY:
1370 case Type::T_UNARY:
1371 $parsed[2] = $this->isPlainCssValidElement($parsed[2]);
1372 if (! $parsed[2]) {
1373 return false;
1375 return $parsed;
1377 case Type::T_FUNCTION:
1378 $argsList = $parsed[2];
1379 foreach ($argsList[2] as $argElement) {
1380 if (! $this->isPlainCssValidElement($argElement)) {
1381 return false;
1384 return $parsed;
1386 case Type::T_FUNCTION_CALL:
1387 $parsed[0] = Type::T_FUNCTION;
1388 $argsList = [Type::T_LIST, ',', []];
1389 foreach ($parsed[2] as $arg) {
1390 if ($arg[0] || ! empty($arg[2])) {
1391 // no named arguments possible in a css function call
1392 // nor ... argument
1393 return false;
1395 $arg = $this->isPlainCssValidElement($arg[1], $parsed[1] === 'calc');
1396 if (! $arg) {
1397 return false;
1399 $argsList[2][] = $arg;
1401 $parsed[2] = $argsList;
1402 return $parsed;
1405 return false;
1409 * Match string looking for either ending delim, escape, or string interpolation
1411 * {@internal This is a workaround for preg_match's 250K string match limit. }}
1413 * @param array $m Matches (passed by reference)
1414 * @param string $delim Delimiter
1416 * @return boolean True if match; false otherwise
1418 protected function matchString(&$m, $delim)
1420 $token = null;
1422 $end = \strlen($this->buffer);
1424 // look for either ending delim, escape, or string interpolation
1425 foreach (['#{', '\\', "\r", $delim] as $lookahead) {
1426 $pos = strpos($this->buffer, $lookahead, $this->count);
1428 if ($pos !== false && $pos < $end) {
1429 $end = $pos;
1430 $token = $lookahead;
1434 if (! isset($token)) {
1435 return false;
1438 $match = substr($this->buffer, $this->count, $end - $this->count);
1439 $m = [
1440 $match . $token,
1441 $match,
1442 $token
1444 $this->count = $end + \strlen($token);
1446 return true;
1450 * Try to match something on head of buffer
1452 * @param string $regex
1453 * @param array $out
1454 * @param boolean $eatWhitespace
1456 * @return boolean
1458 protected function match($regex, &$out, $eatWhitespace = null)
1460 $r = '/' . $regex . '/' . $this->patternModifiers;
1462 if (! preg_match($r, $this->buffer, $out, null, $this->count)) {
1463 return false;
1466 $this->count += \strlen($out[0]);
1468 if (! isset($eatWhitespace)) {
1469 $eatWhitespace = $this->eatWhiteDefault;
1472 if ($eatWhitespace) {
1473 $this->whitespace();
1476 return true;
1480 * Match a single string
1482 * @param string $char
1483 * @param boolean $eatWhitespace
1485 * @return boolean
1487 protected function matchChar($char, $eatWhitespace = null)
1489 if (! isset($this->buffer[$this->count]) || $this->buffer[$this->count] !== $char) {
1490 return false;
1493 $this->count++;
1495 if (! isset($eatWhitespace)) {
1496 $eatWhitespace = $this->eatWhiteDefault;
1499 if ($eatWhitespace) {
1500 $this->whitespace();
1503 return true;
1507 * Match literal string
1509 * @param string $what
1510 * @param integer $len
1511 * @param boolean $eatWhitespace
1513 * @return boolean
1515 protected function literal($what, $len, $eatWhitespace = null)
1517 if (strcasecmp(substr($this->buffer, $this->count, $len), $what) !== 0) {
1518 return false;
1521 $this->count += $len;
1523 if (! isset($eatWhitespace)) {
1524 $eatWhitespace = $this->eatWhiteDefault;
1527 if ($eatWhitespace) {
1528 $this->whitespace();
1531 return true;
1535 * Match some whitespace
1537 * @return boolean
1539 protected function whitespace()
1541 $gotWhite = false;
1543 while (preg_match(static::$whitePattern, $this->buffer, $m, null, $this->count)) {
1544 if (isset($m[1]) && empty($this->commentsSeen[$this->count])) {
1545 // comment that are kept in the output CSS
1546 $comment = [];
1547 $startCommentCount = $this->count;
1548 $endCommentCount = $this->count + \strlen($m[1]);
1550 // find interpolations in comment
1551 $p = strpos($this->buffer, '#{', $this->count);
1553 while ($p !== false && $p < $endCommentCount) {
1554 $c = substr($this->buffer, $this->count, $p - $this->count);
1555 $comment[] = $c;
1556 $this->count = $p;
1557 $out = null;
1559 if ($this->interpolation($out)) {
1560 // keep right spaces in the following string part
1561 if ($out[3]) {
1562 while ($this->buffer[$this->count - 1] !== '}') {
1563 $this->count--;
1566 $out[3] = '';
1569 $comment[] = [Type::T_COMMENT, substr($this->buffer, $p, $this->count - $p), $out];
1570 } else {
1571 $comment[] = substr($this->buffer, $this->count, 2);
1573 $this->count += 2;
1576 $p = strpos($this->buffer, '#{', $this->count);
1579 // remaining part
1580 $c = substr($this->buffer, $this->count, $endCommentCount - $this->count);
1582 if (! $comment) {
1583 // single part static comment
1584 $this->appendComment([Type::T_COMMENT, $c]);
1585 } else {
1586 $comment[] = $c;
1587 $staticComment = substr($this->buffer, $startCommentCount, $endCommentCount - $startCommentCount);
1588 $this->appendComment([Type::T_COMMENT, $staticComment, [Type::T_STRING, '', $comment]]);
1591 $this->commentsSeen[$startCommentCount] = true;
1592 $this->count = $endCommentCount;
1593 } else {
1594 // comment that are ignored and not kept in the output css
1595 $this->count += \strlen($m[0]);
1596 // silent comments are not allowed in plain CSS files
1597 ! $this->cssOnly
1598 || ! \strlen(trim($m[0]))
1599 || $this->assertPlainCssValid(false, $this->count - \strlen($m[0]));
1602 $gotWhite = true;
1605 return $gotWhite;
1609 * Append comment to current block
1611 * @param array $comment
1613 protected function appendComment($comment)
1615 if (! $this->discardComments) {
1616 $this->env->comments[] = $comment;
1621 * Append statement to current block
1623 * @param array|null $statement
1624 * @param integer $pos
1626 protected function append($statement, $pos = null)
1628 if (! \is_null($statement)) {
1629 ! $this->cssOnly || ($statement = $this->assertPlainCssValid($statement, $pos));
1631 if (! \is_null($pos)) {
1632 list($line, $column) = $this->getSourcePosition($pos);
1634 $statement[static::SOURCE_LINE] = $line;
1635 $statement[static::SOURCE_COLUMN] = $column;
1636 $statement[static::SOURCE_INDEX] = $this->sourceIndex;
1639 $this->env->children[] = $statement;
1642 $comments = $this->env->comments;
1644 if ($comments) {
1645 $this->env->children = array_merge($this->env->children, $comments);
1646 $this->env->comments = [];
1651 * Returns last child was appended
1653 * @return array|null
1655 protected function last()
1657 $i = \count($this->env->children) - 1;
1659 if (isset($this->env->children[$i])) {
1660 return $this->env->children[$i];
1665 * Parse media query list
1667 * @param array $out
1669 * @return boolean
1671 protected function mediaQueryList(&$out)
1673 return $this->genericList($out, 'mediaQuery', ',', false);
1677 * Parse media query
1679 * @param array $out
1681 * @return boolean
1683 protected function mediaQuery(&$out)
1685 $expressions = null;
1686 $parts = [];
1688 if (
1689 ($this->literal('only', 4) && ($only = true) ||
1690 $this->literal('not', 3) && ($not = true) || true) &&
1691 $this->mixedKeyword($mediaType)
1693 $prop = [Type::T_MEDIA_TYPE];
1695 if (isset($only)) {
1696 $prop[] = [Type::T_KEYWORD, 'only'];
1699 if (isset($not)) {
1700 $prop[] = [Type::T_KEYWORD, 'not'];
1703 $media = [Type::T_LIST, '', []];
1705 foreach ((array) $mediaType as $type) {
1706 if (\is_array($type)) {
1707 $media[2][] = $type;
1708 } else {
1709 $media[2][] = [Type::T_KEYWORD, $type];
1713 $prop[] = $media;
1714 $parts[] = $prop;
1717 if (empty($parts) || $this->literal('and', 3)) {
1718 $this->genericList($expressions, 'mediaExpression', 'and', false);
1720 if (\is_array($expressions)) {
1721 $parts = array_merge($parts, $expressions[2]);
1725 $out = $parts;
1727 return true;
1731 * Parse supports query
1733 * @param array $out
1735 * @return boolean
1737 protected function supportsQuery(&$out)
1739 $expressions = null;
1740 $parts = [];
1742 $s = $this->count;
1744 $not = false;
1746 if (
1747 ($this->literal('not', 3) && ($not = true) || true) &&
1748 $this->matchChar('(') &&
1749 ($this->expression($property)) &&
1750 $this->literal(': ', 2) &&
1751 $this->valueList($value) &&
1752 $this->matchChar(')')
1754 $support = [Type::T_STRING, '', [[Type::T_KEYWORD, ($not ? 'not ' : '') . '(']]];
1755 $support[2][] = $property;
1756 $support[2][] = [Type::T_KEYWORD, ': '];
1757 $support[2][] = $value;
1758 $support[2][] = [Type::T_KEYWORD, ')'];
1760 $parts[] = $support;
1761 $s = $this->count;
1762 } else {
1763 $this->seek($s);
1766 if (
1767 $this->matchChar('(') &&
1768 $this->supportsQuery($subQuery) &&
1769 $this->matchChar(')')
1771 $parts[] = [Type::T_STRING, '', [[Type::T_KEYWORD, '('], $subQuery, [Type::T_KEYWORD, ')']]];
1772 $s = $this->count;
1773 } else {
1774 $this->seek($s);
1777 if (
1778 $this->literal('not', 3) &&
1779 $this->supportsQuery($subQuery)
1781 $parts[] = [Type::T_STRING, '', [[Type::T_KEYWORD, 'not '], $subQuery]];
1782 $s = $this->count;
1783 } else {
1784 $this->seek($s);
1787 if (
1788 $this->literal('selector(', 9) &&
1789 $this->selector($selector) &&
1790 $this->matchChar(')')
1792 $support = [Type::T_STRING, '', [[Type::T_KEYWORD, 'selector(']]];
1794 $selectorList = [Type::T_LIST, '', []];
1796 foreach ($selector as $sc) {
1797 $compound = [Type::T_STRING, '', []];
1799 foreach ($sc as $scp) {
1800 if (\is_array($scp)) {
1801 $compound[2][] = $scp;
1802 } else {
1803 $compound[2][] = [Type::T_KEYWORD, $scp];
1807 $selectorList[2][] = $compound;
1810 $support[2][] = $selectorList;
1811 $support[2][] = [Type::T_KEYWORD, ')'];
1812 $parts[] = $support;
1813 $s = $this->count;
1814 } else {
1815 $this->seek($s);
1818 if ($this->variable($var) or $this->interpolation($var)) {
1819 $parts[] = $var;
1820 $s = $this->count;
1821 } else {
1822 $this->seek($s);
1825 if (
1826 $this->literal('and', 3) &&
1827 $this->genericList($expressions, 'supportsQuery', ' and', false)
1829 array_unshift($expressions[2], [Type::T_STRING, '', $parts]);
1831 $parts = [$expressions];
1832 $s = $this->count;
1833 } else {
1834 $this->seek($s);
1837 if (
1838 $this->literal('or', 2) &&
1839 $this->genericList($expressions, 'supportsQuery', ' or', false)
1841 array_unshift($expressions[2], [Type::T_STRING, '', $parts]);
1843 $parts = [$expressions];
1844 $s = $this->count;
1845 } else {
1846 $this->seek($s);
1849 if (\count($parts)) {
1850 if ($this->eatWhiteDefault) {
1851 $this->whitespace();
1854 $out = [Type::T_STRING, '', $parts];
1856 return true;
1859 return false;
1864 * Parse media expression
1866 * @param array $out
1868 * @return boolean
1870 protected function mediaExpression(&$out)
1872 $s = $this->count;
1873 $value = null;
1875 if (
1876 $this->matchChar('(') &&
1877 $this->expression($feature) &&
1878 ($this->matchChar(':') &&
1879 $this->expression($value) || true) &&
1880 $this->matchChar(')')
1882 $out = [Type::T_MEDIA_EXPRESSION, $feature];
1884 if ($value) {
1885 $out[] = $value;
1888 return true;
1891 $this->seek($s);
1893 return false;
1897 * Parse argument values
1899 * @param array $out
1901 * @return boolean
1903 protected function argValues(&$out)
1905 $discardComments = $this->discardComments;
1906 $this->discardComments = true;
1908 if ($this->genericList($list, 'argValue', ',', false)) {
1909 $out = $list[2];
1911 $this->discardComments = $discardComments;
1913 return true;
1916 $this->discardComments = $discardComments;
1918 return false;
1922 * Parse argument value
1924 * @param array $out
1926 * @return boolean
1928 protected function argValue(&$out)
1930 $s = $this->count;
1932 $keyword = null;
1934 if (! $this->variable($keyword) || ! $this->matchChar(':')) {
1935 $this->seek($s);
1937 $keyword = null;
1940 if ($this->genericList($value, 'expression', '', true)) {
1941 $out = [$keyword, $value, false];
1942 $s = $this->count;
1944 if ($this->literal('...', 3)) {
1945 $out[2] = true;
1946 } else {
1947 $this->seek($s);
1950 return true;
1953 return false;
1957 * Check if a generic directive is known to be able to allow almost any syntax or not
1958 * @param mixed $directiveName
1959 * @return bool
1961 protected function isKnownGenericDirective($directiveName)
1963 if (\is_array($directiveName) && \is_string(reset($directiveName))) {
1964 $directiveName = reset($directiveName);
1966 if (! \is_string($directiveName)) {
1967 return false;
1969 if (
1970 \in_array($directiveName, [
1971 'at-root',
1972 'media',
1973 'mixin',
1974 'include',
1975 'scssphp-import-once',
1976 'import',
1977 'extend',
1978 'function',
1979 'break',
1980 'continue',
1981 'return',
1982 'each',
1983 'while',
1984 'for',
1985 'if',
1986 'debug',
1987 'warn',
1988 'error',
1989 'content',
1990 'else',
1991 'charset',
1992 'supports',
1993 // Todo
1994 'use',
1995 'forward',
1998 return true;
2000 return false;
2004 * Parse directive value list that considers $vars as keyword
2006 * @param array $out
2007 * @param boolean|string $endChar
2009 * @return boolean
2011 protected function directiveValue(&$out, $endChar = false)
2013 $s = $this->count;
2015 if ($this->variable($out)) {
2016 if ($endChar && $this->matchChar($endChar, false)) {
2017 return true;
2020 if (! $endChar && $this->end()) {
2021 return true;
2025 $this->seek($s);
2027 if (\is_string($endChar) && $this->openString($endChar ? $endChar : ';', $out, null, null, true, ";}{")) {
2028 if ($endChar && $this->matchChar($endChar, false)) {
2029 return true;
2031 $ss = $this->count;
2032 if (!$endChar && $this->end()) {
2033 $this->seek($ss);
2034 return true;
2038 $this->seek($s);
2040 $allowVars = $this->allowVars;
2041 $this->allowVars = false;
2043 $res = $this->genericList($out, 'spaceList', ',');
2044 $this->allowVars = $allowVars;
2046 if ($res) {
2047 if ($endChar && $this->matchChar($endChar, false)) {
2048 return true;
2051 if (! $endChar && $this->end()) {
2052 return true;
2056 $this->seek($s);
2058 if ($endChar && $this->matchChar($endChar, false)) {
2059 return true;
2062 return false;
2066 * Parse comma separated value list
2068 * @param array $out
2070 * @return boolean
2072 protected function valueList(&$out)
2074 $discardComments = $this->discardComments;
2075 $this->discardComments = true;
2076 $res = $this->genericList($out, 'spaceList', ',');
2077 $this->discardComments = $discardComments;
2079 return $res;
2083 * Parse a function call, where externals () are part of the call
2084 * and not of the value list
2086 * @param $out
2087 * @param bool $mandatoryEnclos
2088 * @param null|string $charAfter
2089 * @param null|bool $eatWhiteSp
2090 * @return bool
2092 protected function functionCallArgumentsList(&$out, $mandatoryEnclos = true, $charAfter = null, $eatWhiteSp = null)
2094 $s = $this->count;
2096 if (
2097 $this->matchChar('(') &&
2098 $this->valueList($out) &&
2099 $this->matchChar(')') &&
2100 ($charAfter ? $this->matchChar($charAfter, $eatWhiteSp) : $this->end())
2102 return true;
2105 if (! $mandatoryEnclos) {
2106 $this->seek($s);
2108 if (
2109 $this->valueList($out) &&
2110 ($charAfter ? $this->matchChar($charAfter, $eatWhiteSp) : $this->end())
2112 return true;
2116 $this->seek($s);
2118 return false;
2122 * Parse space separated value list
2124 * @param array $out
2126 * @return boolean
2128 protected function spaceList(&$out)
2130 return $this->genericList($out, 'expression');
2134 * Parse generic list
2136 * @param array $out
2137 * @param string $parseItem The name of the method used to parse items
2138 * @param string $delim
2139 * @param boolean $flatten
2141 * @return boolean
2143 protected function genericList(&$out, $parseItem, $delim = '', $flatten = true)
2145 $s = $this->count;
2146 $items = [];
2147 $value = null;
2149 while ($this->$parseItem($value)) {
2150 $trailing_delim = false;
2151 $items[] = $value;
2153 if ($delim) {
2154 if (! $this->literal($delim, \strlen($delim))) {
2155 break;
2158 $trailing_delim = true;
2159 } else {
2160 // if no delim watch that a keyword didn't eat the single/double quote
2161 // from the following starting string
2162 if ($value[0] === Type::T_KEYWORD) {
2163 $word = $value[1];
2165 $last_char = substr($word, -1);
2167 if (
2168 strlen($word) > 1 &&
2169 in_array($last_char, [ "'", '"']) &&
2170 substr($word, -2, 1) !== '\\'
2172 // if there is a non escaped opening quote in the keyword, this seems unlikely a mistake
2173 $word = str_replace('\\' . $last_char, '\\\\', $word);
2174 if (strpos($word, $last_char) < strlen($word) - 1) {
2175 continue;
2178 $currentCount = $this->count;
2180 // let's try to rewind to previous char and try a parse
2181 $this->count--;
2182 // in case the keyword also eat spaces
2183 while (substr($this->buffer, $this->count, 1) !== $last_char) {
2184 $this->count--;
2187 $nextValue = null;
2188 if ($this->$parseItem($nextValue)) {
2189 if ($nextValue[0] === Type::T_KEYWORD && $nextValue[1] === $last_char) {
2190 // bad try, forget it
2191 $this->seek($currentCount);
2192 continue;
2194 if ($nextValue[0] !== Type::T_STRING) {
2195 // bad try, forget it
2196 $this->seek($currentCount);
2197 continue;
2200 // OK it was a good idea
2201 $value[1] = substr($value[1], 0, -1);
2202 array_pop($items);
2203 $items[] = $value;
2204 $items[] = $nextValue;
2205 } else {
2206 // bad try, forget it
2207 $this->seek($currentCount);
2208 continue;
2215 if (! $items) {
2216 $this->seek($s);
2218 return false;
2221 if ($trailing_delim) {
2222 $items[] = [Type::T_NULL];
2225 if ($flatten && \count($items) === 1) {
2226 $out = $items[0];
2227 } else {
2228 $out = [Type::T_LIST, $delim, $items];
2231 return true;
2235 * Parse expression
2237 * @param array $out
2238 * @param boolean $listOnly
2239 * @param boolean $lookForExp
2241 * @return boolean
2243 protected function expression(&$out, $listOnly = false, $lookForExp = true)
2245 $s = $this->count;
2246 $discard = $this->discardComments;
2247 $this->discardComments = true;
2248 $allowedTypes = ($listOnly ? [Type::T_LIST] : [Type::T_LIST, Type::T_MAP]);
2250 if ($this->matchChar('(')) {
2251 if ($this->enclosedExpression($lhs, $s, ')', $allowedTypes)) {
2252 if ($lookForExp) {
2253 $out = $this->expHelper($lhs, 0);
2254 } else {
2255 $out = $lhs;
2258 $this->discardComments = $discard;
2260 return true;
2263 $this->seek($s);
2266 if (\in_array(Type::T_LIST, $allowedTypes) && $this->matchChar('[')) {
2267 if ($this->enclosedExpression($lhs, $s, ']', [Type::T_LIST])) {
2268 if ($lookForExp) {
2269 $out = $this->expHelper($lhs, 0);
2270 } else {
2271 $out = $lhs;
2274 $this->discardComments = $discard;
2276 return true;
2279 $this->seek($s);
2282 if (! $listOnly && $this->value($lhs)) {
2283 if ($lookForExp) {
2284 $out = $this->expHelper($lhs, 0);
2285 } else {
2286 $out = $lhs;
2289 $this->discardComments = $discard;
2291 return true;
2294 $this->discardComments = $discard;
2296 return false;
2300 * Parse expression specifically checking for lists in parenthesis or brackets
2302 * @param array $out
2303 * @param integer $s
2304 * @param string $closingParen
2305 * @param array $allowedTypes
2307 * @return boolean
2309 protected function enclosedExpression(&$out, $s, $closingParen = ')', $allowedTypes = [Type::T_LIST, Type::T_MAP])
2311 if ($this->matchChar($closingParen) && \in_array(Type::T_LIST, $allowedTypes)) {
2312 $out = [Type::T_LIST, '', []];
2314 switch ($closingParen) {
2315 case ')':
2316 $out['enclosing'] = 'parent'; // parenthesis list
2317 break;
2319 case ']':
2320 $out['enclosing'] = 'bracket'; // bracketed list
2321 break;
2324 return true;
2327 if (
2328 $this->valueList($out) &&
2329 $this->matchChar($closingParen) && ! ($closingParen === ')' &&
2330 \in_array($out[0], [Type::T_EXPRESSION, Type::T_UNARY])) &&
2331 \in_array(Type::T_LIST, $allowedTypes)
2333 if ($out[0] !== Type::T_LIST || ! empty($out['enclosing'])) {
2334 $out = [Type::T_LIST, '', [$out]];
2337 switch ($closingParen) {
2338 case ')':
2339 $out['enclosing'] = 'parent'; // parenthesis list
2340 break;
2342 case ']':
2343 $out['enclosing'] = 'bracket'; // bracketed list
2344 break;
2347 return true;
2350 $this->seek($s);
2352 if (\in_array(Type::T_MAP, $allowedTypes) && $this->map($out)) {
2353 return true;
2356 return false;
2360 * Parse left-hand side of subexpression
2362 * @param array $lhs
2363 * @param integer $minP
2365 * @return array
2367 protected function expHelper($lhs, $minP)
2369 $operators = static::$operatorPattern;
2371 $ss = $this->count;
2372 $whiteBefore = isset($this->buffer[$this->count - 1]) &&
2373 ctype_space($this->buffer[$this->count - 1]);
2375 while ($this->match($operators, $m, false) && static::$precedence[$m[1]] >= $minP) {
2376 $whiteAfter = isset($this->buffer[$this->count]) &&
2377 ctype_space($this->buffer[$this->count]);
2378 $varAfter = isset($this->buffer[$this->count]) &&
2379 $this->buffer[$this->count] === '$';
2381 $this->whitespace();
2383 $op = $m[1];
2385 // don't turn negative numbers into expressions
2386 if ($op === '-' && $whiteBefore && ! $whiteAfter && ! $varAfter) {
2387 break;
2390 if (! $this->value($rhs) && ! $this->expression($rhs, true, false)) {
2391 break;
2394 if ($op === '-' && ! $whiteAfter && $rhs[0] === Type::T_KEYWORD) {
2395 break;
2398 // peek and see if rhs belongs to next operator
2399 if ($this->peek($operators, $next) && static::$precedence[$next[1]] > static::$precedence[$op]) {
2400 $rhs = $this->expHelper($rhs, static::$precedence[$next[1]]);
2403 $lhs = [Type::T_EXPRESSION, $op, $lhs, $rhs, $this->inParens, $whiteBefore, $whiteAfter];
2405 $ss = $this->count;
2406 $whiteBefore = isset($this->buffer[$this->count - 1]) &&
2407 ctype_space($this->buffer[$this->count - 1]);
2410 $this->seek($ss);
2412 return $lhs;
2416 * Parse value
2418 * @param array $out
2420 * @return boolean
2422 protected function value(&$out)
2424 if (! isset($this->buffer[$this->count])) {
2425 return false;
2428 $s = $this->count;
2429 $char = $this->buffer[$this->count];
2431 if (
2432 $this->literal('url(', 4) &&
2433 $this->match('data:([a-z]+)\/([a-z0-9.+-]+);base64,', $m, false)
2435 $len = strspn(
2436 $this->buffer,
2437 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwyxz0123456789+/=',
2438 $this->count
2441 $this->count += $len;
2443 if ($this->matchChar(')')) {
2444 $content = substr($this->buffer, $s, $this->count - $s);
2445 $out = [Type::T_KEYWORD, $content];
2447 return true;
2451 $this->seek($s);
2453 if (
2454 $this->literal('url(', 4, false) &&
2455 $this->match('\s*(\/\/[^\s\)]+)\s*', $m)
2457 $content = 'url(' . $m[1];
2459 if ($this->matchChar(')')) {
2460 $content .= ')';
2461 $out = [Type::T_KEYWORD, $content];
2463 return true;
2467 $this->seek($s);
2469 // not
2470 if ($char === 'n' && $this->literal('not', 3, false)) {
2471 if (
2472 $this->whitespace() &&
2473 $this->value($inner)
2475 $out = [Type::T_UNARY, 'not', $inner, $this->inParens];
2477 return true;
2480 $this->seek($s);
2482 if ($this->parenValue($inner)) {
2483 $out = [Type::T_UNARY, 'not', $inner, $this->inParens];
2485 return true;
2488 $this->seek($s);
2491 // addition
2492 if ($char === '+') {
2493 $this->count++;
2495 $follow_white = $this->whitespace();
2497 if ($this->value($inner)) {
2498 $out = [Type::T_UNARY, '+', $inner, $this->inParens];
2500 return true;
2503 if ($follow_white) {
2504 $out = [Type::T_KEYWORD, $char];
2505 return true;
2508 $this->seek($s);
2510 return false;
2513 // negation
2514 if ($char === '-') {
2515 if ($this->customProperty($out)) {
2516 return true;
2519 $this->count++;
2521 $follow_white = $this->whitespace();
2523 if ($this->variable($inner) || $this->unit($inner) || $this->parenValue($inner)) {
2524 $out = [Type::T_UNARY, '-', $inner, $this->inParens];
2526 return true;
2529 if (
2530 $this->keyword($inner) &&
2531 ! $this->func($inner, $out)
2533 $out = [Type::T_UNARY, '-', $inner, $this->inParens];
2535 return true;
2538 if ($follow_white) {
2539 $out = [Type::T_KEYWORD, $char];
2541 return true;
2544 $this->seek($s);
2547 // paren
2548 if ($char === '(' && $this->parenValue($out)) {
2549 return true;
2552 if ($char === '#') {
2553 if ($this->interpolation($out) || $this->color($out)) {
2554 return true;
2557 $this->count++;
2559 if ($this->keyword($keyword)) {
2560 $out = [Type::T_KEYWORD, '#' . $keyword];
2562 return true;
2565 $this->count--;
2568 if ($this->matchChar('&', true)) {
2569 $out = [Type::T_SELF];
2571 return true;
2574 if ($char === '$' && $this->variable($out)) {
2575 return true;
2578 if ($char === 'p' && $this->progid($out)) {
2579 return true;
2582 if (($char === '"' || $char === "'") && $this->string($out)) {
2583 return true;
2586 if ($this->unit($out)) {
2587 return true;
2590 // unicode range with wildcards
2591 if (
2592 $this->literal('U+', 2) &&
2593 $this->match('\?+|([0-9A-F]+(\?+|(-[0-9A-F]+))?)', $m, false)
2595 $unicode = explode('-', $m[0]);
2596 if (strlen(reset($unicode)) <= 6 && strlen(end($unicode)) <= 6) {
2597 $out = [Type::T_KEYWORD, 'U+' . $m[0]];
2599 return true;
2601 $this->count -= strlen($m[0]) + 2;
2604 if ($this->keyword($keyword, false)) {
2605 if ($this->func($keyword, $out)) {
2606 return true;
2609 $this->whitespace();
2611 if ($keyword === 'null') {
2612 $out = [Type::T_NULL];
2613 } else {
2614 $out = [Type::T_KEYWORD, $keyword];
2617 return true;
2620 return false;
2624 * Parse parenthesized value
2626 * @param array $out
2628 * @return boolean
2630 protected function parenValue(&$out)
2632 $s = $this->count;
2634 $inParens = $this->inParens;
2636 if ($this->matchChar('(')) {
2637 if ($this->matchChar(')')) {
2638 $out = [Type::T_LIST, '', []];
2640 return true;
2643 $this->inParens = true;
2645 if (
2646 $this->expression($exp) &&
2647 $this->matchChar(')')
2649 $out = $exp;
2650 $this->inParens = $inParens;
2652 return true;
2656 $this->inParens = $inParens;
2657 $this->seek($s);
2659 return false;
2663 * Parse "progid:"
2665 * @param array $out
2667 * @return boolean
2669 protected function progid(&$out)
2671 $s = $this->count;
2673 if (
2674 $this->literal('progid:', 7, false) &&
2675 $this->openString('(', $fn) &&
2676 $this->matchChar('(')
2678 $this->openString(')', $args, '(');
2680 if ($this->matchChar(')')) {
2681 $out = [Type::T_STRING, '', [
2682 'progid:', $fn, '(', $args, ')'
2685 return true;
2689 $this->seek($s);
2691 return false;
2695 * Parse function call
2697 * @param string $name
2698 * @param array $func
2700 * @return boolean
2702 protected function func($name, &$func)
2704 $s = $this->count;
2706 if ($this->matchChar('(')) {
2707 if ($name === 'alpha' && $this->argumentList($args)) {
2708 $func = [Type::T_FUNCTION, $name, [Type::T_STRING, '', $args]];
2710 return true;
2713 if ($name !== 'expression' && ! preg_match('/^(-[a-z]+-)?calc$/', $name)) {
2714 $ss = $this->count;
2716 if (
2717 $this->argValues($args) &&
2718 $this->matchChar(')')
2720 $func = [Type::T_FUNCTION_CALL, $name, $args];
2722 return true;
2725 $this->seek($ss);
2728 if (
2729 ($this->openString(')', $str, '(') || true) &&
2730 $this->matchChar(')')
2732 $args = [];
2734 if (! empty($str)) {
2735 $args[] = [null, [Type::T_STRING, '', [$str]]];
2738 $func = [Type::T_FUNCTION_CALL, $name, $args];
2740 return true;
2744 $this->seek($s);
2746 return false;
2750 * Parse function call argument list
2752 * @param array $out
2754 * @return boolean
2756 protected function argumentList(&$out)
2758 $s = $this->count;
2759 $this->matchChar('(');
2761 $args = [];
2763 while ($this->keyword($var)) {
2764 if (
2765 $this->matchChar('=') &&
2766 $this->expression($exp)
2768 $args[] = [Type::T_STRING, '', [$var . '=']];
2769 $arg = $exp;
2770 } else {
2771 break;
2774 $args[] = $arg;
2776 if (! $this->matchChar(',')) {
2777 break;
2780 $args[] = [Type::T_STRING, '', [', ']];
2783 if (! $this->matchChar(')') || ! $args) {
2784 $this->seek($s);
2786 return false;
2789 $out = $args;
2791 return true;
2795 * Parse mixin/function definition argument list
2797 * @param array $out
2799 * @return boolean
2801 protected function argumentDef(&$out)
2803 $s = $this->count;
2804 $this->matchChar('(');
2806 $args = [];
2808 while ($this->variable($var)) {
2809 $arg = [$var[1], null, false];
2811 $ss = $this->count;
2813 if (
2814 $this->matchChar(':') &&
2815 $this->genericList($defaultVal, 'expression', '', true)
2817 $arg[1] = $defaultVal;
2818 } else {
2819 $this->seek($ss);
2822 $ss = $this->count;
2824 if ($this->literal('...', 3)) {
2825 $sss = $this->count;
2827 if (! $this->matchChar(')')) {
2828 throw $this->parseError('... has to be after the final argument');
2831 $arg[2] = true;
2833 $this->seek($sss);
2834 } else {
2835 $this->seek($ss);
2838 $args[] = $arg;
2840 if (! $this->matchChar(',')) {
2841 break;
2845 if (! $this->matchChar(')')) {
2846 $this->seek($s);
2848 return false;
2851 $out = $args;
2853 return true;
2857 * Parse map
2859 * @param array $out
2861 * @return boolean
2863 protected function map(&$out)
2865 $s = $this->count;
2867 if (! $this->matchChar('(')) {
2868 return false;
2871 $keys = [];
2872 $values = [];
2874 while (
2875 $this->genericList($key, 'expression', '', true) &&
2876 $this->matchChar(':') &&
2877 $this->genericList($value, 'expression', '', true)
2879 $keys[] = $key;
2880 $values[] = $value;
2882 if (! $this->matchChar(',')) {
2883 break;
2887 if (! $keys || ! $this->matchChar(')')) {
2888 $this->seek($s);
2890 return false;
2893 $out = [Type::T_MAP, $keys, $values];
2895 return true;
2899 * Parse color
2901 * @param array $out
2903 * @return boolean
2905 protected function color(&$out)
2907 $s = $this->count;
2909 if ($this->match('(#([0-9a-f]+)\b)', $m)) {
2910 if (\in_array(\strlen($m[2]), [3,4,6,8])) {
2911 $out = [Type::T_KEYWORD, $m[0]];
2913 return true;
2916 $this->seek($s);
2918 return false;
2921 return false;
2925 * Parse number with unit
2927 * @param array $unit
2929 * @return boolean
2931 protected function unit(&$unit)
2933 $s = $this->count;
2935 if ($this->match('([0-9]*(\.)?[0-9]+)([%a-zA-Z]+)?', $m, false)) {
2936 if (\strlen($this->buffer) === $this->count || ! ctype_digit($this->buffer[$this->count])) {
2937 $this->whitespace();
2939 $unit = new Node\Number($m[1], empty($m[3]) ? '' : $m[3]);
2941 return true;
2944 $this->seek($s);
2947 return false;
2951 * Parse string
2953 * @param array $out
2955 * @return boolean
2957 protected function string(&$out, $keepDelimWithInterpolation = false)
2959 $s = $this->count;
2961 if ($this->matchChar('"', false)) {
2962 $delim = '"';
2963 } elseif ($this->matchChar("'", false)) {
2964 $delim = "'";
2965 } else {
2966 return false;
2969 $content = [];
2970 $oldWhite = $this->eatWhiteDefault;
2971 $this->eatWhiteDefault = false;
2972 $hasInterpolation = false;
2974 while ($this->matchString($m, $delim)) {
2975 if ($m[1] !== '') {
2976 $content[] = $m[1];
2979 if ($m[2] === '#{') {
2980 $this->count -= \strlen($m[2]);
2982 if ($this->interpolation($inter, false)) {
2983 $content[] = $inter;
2984 $hasInterpolation = true;
2985 } else {
2986 $this->count += \strlen($m[2]);
2987 $content[] = '#{'; // ignore it
2989 } elseif ($m[2] === "\r") {
2990 $content[] = chr(10);
2991 // TODO : warning
2992 # DEPRECATION WARNING on line x, column y of zzz:
2993 # Unescaped multiline strings are deprecated and will be removed in a future version of Sass.
2994 # To include a newline in a string, use "\a" or "\a " as in CSS.
2995 if ($this->matchChar("\n", false)) {
2996 $content[] = ' ';
2998 } elseif ($m[2] === '\\') {
2999 if (
3000 $this->literal("\r\n", 2, false) ||
3001 $this->matchChar("\r", false) ||
3002 $this->matchChar("\n", false) ||
3003 $this->matchChar("\f", false)
3005 // this is a continuation escaping, to be ignored
3006 } elseif ($this->matchEscapeCharacter($c)) {
3007 $content[] = $c;
3008 } else {
3009 throw $this->parseError('Unterminated escape sequence');
3011 } else {
3012 $this->count -= \strlen($delim);
3013 break; // delim
3017 $this->eatWhiteDefault = $oldWhite;
3019 if ($this->literal($delim, \strlen($delim))) {
3020 if ($hasInterpolation && ! $keepDelimWithInterpolation) {
3021 $delim = '"';
3024 $out = [Type::T_STRING, $delim, $content];
3026 return true;
3029 $this->seek($s);
3031 return false;
3035 * @param string $out
3036 * @param bool $inKeywords
3037 * @return bool
3039 protected function matchEscapeCharacter(&$out, $inKeywords = false)
3041 $s = $this->count;
3042 if ($this->match('[a-f0-9]', $m, false)) {
3043 $hex = $m[0];
3045 for ($i = 5; $i--;) {
3046 if ($this->match('[a-f0-9]', $m, false)) {
3047 $hex .= $m[0];
3048 } else {
3049 break;
3053 // CSS allows Unicode escape sequences to be followed by a delimiter space
3054 // (necessary in some cases for shorter sequences to disambiguate their end)
3055 $this->matchChar(' ', false);
3057 $value = hexdec($hex);
3059 if (!$inKeywords && ($value == 0 || ($value >= 0xD800 && $value <= 0xDFFF) || $value >= 0x10FFFF)) {
3060 $out = "\xEF\xBF\xBD"; // "\u{FFFD}" but with a syntax supported on PHP 5
3061 } elseif ($value < 0x20) {
3062 $out = Util::mbChr($value);
3063 } else {
3064 $out = Util::mbChr($value);
3067 return true;
3070 if ($this->match('.', $m, false)) {
3071 if ($inKeywords && in_array($m[0], ["'",'"','@','&',' ','\\',':','/','%'])) {
3072 $this->seek($s);
3073 return false;
3075 $out = $m[0];
3077 return true;
3080 return false;
3084 * Parse keyword or interpolation
3086 * @param array $out
3087 * @param boolean $restricted
3089 * @return boolean
3091 protected function mixedKeyword(&$out, $restricted = false)
3093 $parts = [];
3095 $oldWhite = $this->eatWhiteDefault;
3096 $this->eatWhiteDefault = false;
3098 for (;;) {
3099 if ($restricted ? $this->restrictedKeyword($key) : $this->keyword($key)) {
3100 $parts[] = $key;
3101 continue;
3104 if ($this->interpolation($inter)) {
3105 $parts[] = $inter;
3106 continue;
3109 break;
3112 $this->eatWhiteDefault = $oldWhite;
3114 if (! $parts) {
3115 return false;
3118 if ($this->eatWhiteDefault) {
3119 $this->whitespace();
3122 $out = $parts;
3124 return true;
3128 * Parse an unbounded string stopped by $end
3130 * @param string $end
3131 * @param array $out
3132 * @param string $nestOpen
3133 * @param string $nestClose
3134 * @param boolean $rtrim
3135 * @param string $disallow
3137 * @return boolean
3139 protected function openString($end, &$out, $nestOpen = null, $nestClose = null, $rtrim = true, $disallow = null)
3141 $oldWhite = $this->eatWhiteDefault;
3142 $this->eatWhiteDefault = false;
3144 if ($nestOpen && ! $nestClose) {
3145 $nestClose = $end;
3148 $patt = ($disallow ? '[^' . $this->pregQuote($disallow) . ']' : '.');
3149 $patt = '(' . $patt . '*?)([\'"]|#\{|'
3150 . $this->pregQuote($end) . '|'
3151 . (($nestClose && $nestClose !== $end) ? $this->pregQuote($nestClose) . '|' : '')
3152 . static::$commentPattern . ')';
3154 $nestingLevel = 0;
3156 $content = [];
3158 while ($this->match($patt, $m, false)) {
3159 if (isset($m[1]) && $m[1] !== '') {
3160 $content[] = $m[1];
3162 if ($nestOpen) {
3163 $nestingLevel += substr_count($m[1], $nestOpen);
3167 $tok = $m[2];
3169 $this->count -= \strlen($tok);
3171 if ($tok === $end && ! $nestingLevel) {
3172 break;
3175 if ($tok === $nestClose) {
3176 $nestingLevel--;
3179 if (($tok === "'" || $tok === '"') && $this->string($str, true)) {
3180 $content[] = $str;
3181 continue;
3184 if ($tok === '#{' && $this->interpolation($inter)) {
3185 $content[] = $inter;
3186 continue;
3189 $content[] = $tok;
3190 $this->count += \strlen($tok);
3193 $this->eatWhiteDefault = $oldWhite;
3195 if (! $content || $tok !== $end) {
3196 return false;
3199 // trim the end
3200 if ($rtrim && \is_string(end($content))) {
3201 $content[\count($content) - 1] = rtrim(end($content));
3204 $out = [Type::T_STRING, '', $content];
3206 return true;
3210 * Parser interpolation
3212 * @param string|array $out
3213 * @param boolean $lookWhite save information about whitespace before and after
3215 * @return boolean
3217 protected function interpolation(&$out, $lookWhite = true)
3219 $oldWhite = $this->eatWhiteDefault;
3220 $allowVars = $this->allowVars;
3221 $this->allowVars = true;
3222 $this->eatWhiteDefault = true;
3224 $s = $this->count;
3226 if (
3227 $this->literal('#{', 2) &&
3228 $this->valueList($value) &&
3229 $this->matchChar('}', false)
3231 if ($value === [Type::T_SELF]) {
3232 $out = $value;
3233 } else {
3234 if ($lookWhite) {
3235 $left = ($s > 0 && preg_match('/\s/', $this->buffer[$s - 1])) ? ' ' : '';
3236 $right = (
3237 ! empty($this->buffer[$this->count]) &&
3238 preg_match('/\s/', $this->buffer[$this->count])
3239 ) ? ' ' : '';
3240 } else {
3241 $left = $right = false;
3244 $out = [Type::T_INTERPOLATE, $value, $left, $right];
3247 $this->eatWhiteDefault = $oldWhite;
3248 $this->allowVars = $allowVars;
3250 if ($this->eatWhiteDefault) {
3251 $this->whitespace();
3254 return true;
3257 $this->seek($s);
3259 $this->eatWhiteDefault = $oldWhite;
3260 $this->allowVars = $allowVars;
3262 return false;
3266 * Parse property name (as an array of parts or a string)
3268 * @param array $out
3270 * @return boolean
3272 protected function propertyName(&$out)
3274 $parts = [];
3276 $oldWhite = $this->eatWhiteDefault;
3277 $this->eatWhiteDefault = false;
3279 for (;;) {
3280 if ($this->interpolation($inter)) {
3281 $parts[] = $inter;
3282 continue;
3285 if ($this->keyword($text)) {
3286 $parts[] = $text;
3287 continue;
3290 if (! $parts && $this->match('[:.#]', $m, false)) {
3291 // css hacks
3292 $parts[] = $m[0];
3293 continue;
3296 break;
3299 $this->eatWhiteDefault = $oldWhite;
3301 if (! $parts) {
3302 return false;
3305 // match comment hack
3306 if (preg_match(static::$whitePattern, $this->buffer, $m, null, $this->count)) {
3307 if (! empty($m[0])) {
3308 $parts[] = $m[0];
3309 $this->count += \strlen($m[0]);
3313 $this->whitespace(); // get any extra whitespace
3315 $out = [Type::T_STRING, '', $parts];
3317 return true;
3321 * Parse custom property name (as an array of parts or a string)
3323 * @param array $out
3325 * @return boolean
3327 protected function customProperty(&$out)
3329 $s = $this->count;
3331 if (! $this->literal('--', 2, false)) {
3332 return false;
3335 $parts = ['--'];
3337 $oldWhite = $this->eatWhiteDefault;
3338 $this->eatWhiteDefault = false;
3340 for (;;) {
3341 if ($this->interpolation($inter)) {
3342 $parts[] = $inter;
3343 continue;
3346 if ($this->matchChar('&', false)) {
3347 $parts[] = [Type::T_SELF];
3348 continue;
3351 if ($this->variable($var)) {
3352 $parts[] = $var;
3353 continue;
3356 if ($this->keyword($text)) {
3357 $parts[] = $text;
3358 continue;
3361 break;
3364 $this->eatWhiteDefault = $oldWhite;
3366 if (\count($parts) == 1) {
3367 $this->seek($s);
3369 return false;
3372 $this->whitespace(); // get any extra whitespace
3374 $out = [Type::T_STRING, '', $parts];
3376 return true;
3380 * Parse comma separated selector list
3382 * @param array $out
3383 * @param string|boolean $subSelector
3385 * @return boolean
3387 protected function selectors(&$out, $subSelector = false)
3389 $s = $this->count;
3390 $selectors = [];
3392 while ($this->selector($sel, $subSelector)) {
3393 $selectors[] = $sel;
3395 if (! $this->matchChar(',', true)) {
3396 break;
3399 while ($this->matchChar(',', true)) {
3400 ; // ignore extra
3404 if (! $selectors) {
3405 $this->seek($s);
3407 return false;
3410 $out = $selectors;
3412 return true;
3416 * Parse whitespace separated selector list
3418 * @param array $out
3419 * @param string|boolean $subSelector
3421 * @return boolean
3423 protected function selector(&$out, $subSelector = false)
3425 $selector = [];
3427 $discardComments = $this->discardComments;
3428 $this->discardComments = true;
3430 for (;;) {
3431 $s = $this->count;
3433 if ($this->match('[>+~]+', $m, true)) {
3434 if (
3435 $subSelector && \is_string($subSelector) && strpos($subSelector, 'nth-') === 0 &&
3436 $m[0] === '+' && $this->match("(\d+|n\b)", $counter)
3438 $this->seek($s);
3439 } else {
3440 $selector[] = [$m[0]];
3441 continue;
3445 if ($this->selectorSingle($part, $subSelector)) {
3446 $selector[] = $part;
3447 $this->whitespace();
3448 continue;
3451 break;
3454 $this->discardComments = $discardComments;
3456 if (! $selector) {
3457 return false;
3460 $out = $selector;
3462 return true;
3466 * parsing escaped chars in selectors:
3467 * - escaped single chars are kept escaped in the selector but in a normalized form
3468 * (if not in 0-9a-f range as this would be ambigous)
3469 * - other escaped sequences (multibyte chars or 0-9a-f) are kept in their initial escaped form,
3470 * normalized to lowercase
3472 * TODO: this is a fallback solution. Ideally escaped chars in selectors should be encoded as the genuine chars,
3473 * and escaping added when printing in the Compiler, where/if it's mandatory
3474 * - but this require a better formal selector representation instead of the array we have now
3476 * @param string $out
3477 * @param bool $keepEscapedNumber
3478 * @return bool
3480 protected function matchEscapeCharacterInSelector(&$out, $keepEscapedNumber = false)
3482 $s_escape = $this->count;
3483 if ($this->match('\\\\', $m)) {
3484 $out = '\\' . $m[0];
3485 return true;
3488 if ($this->matchEscapeCharacter($escapedout, true)) {
3489 if (strlen($escapedout) === 1) {
3490 if (!preg_match(",\w,", $escapedout)) {
3491 $out = '\\' . $escapedout;
3492 return true;
3493 } elseif (! $keepEscapedNumber || ! \is_numeric($escapedout)) {
3494 $out = $escapedout;
3495 return true;
3498 $escape_sequence = rtrim(substr($this->buffer, $s_escape, $this->count - $s_escape));
3499 if (strlen($escape_sequence) < 6) {
3500 $escape_sequence .= ' ';
3502 $out = '\\' . strtolower($escape_sequence);
3503 return true;
3505 if ($this->match('\\S', $m)) {
3506 $out = '\\' . $m[0];
3507 return true;
3511 return false;
3515 * Parse the parts that make up a selector
3517 * {@internal
3518 * div[yes=no]#something.hello.world:nth-child(-2n+1)%placeholder
3519 * }}
3521 * @param array $out
3522 * @param string|boolean $subSelector
3524 * @return boolean
3526 protected function selectorSingle(&$out, $subSelector = false)
3528 $oldWhite = $this->eatWhiteDefault;
3529 $this->eatWhiteDefault = false;
3531 $parts = [];
3533 if ($this->matchChar('*', false)) {
3534 $parts[] = '*';
3537 for (;;) {
3538 if (! isset($this->buffer[$this->count])) {
3539 break;
3542 $s = $this->count;
3543 $char = $this->buffer[$this->count];
3545 // see if we can stop early
3546 if ($char === '{' || $char === ',' || $char === ';' || $char === '}' || $char === '@') {
3547 break;
3550 // parsing a sub selector in () stop with the closing )
3551 if ($subSelector && $char === ')') {
3552 break;
3555 //self
3556 switch ($char) {
3557 case '&':
3558 $parts[] = Compiler::$selfSelector;
3559 $this->count++;
3560 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
3561 continue 2;
3563 case '.':
3564 $parts[] = '.';
3565 $this->count++;
3566 continue 2;
3568 case '|':
3569 $parts[] = '|';
3570 $this->count++;
3571 continue 2;
3574 // handling of escaping in selectors : get the escaped char
3575 if ($char === '\\') {
3576 $this->count++;
3577 if ($this->matchEscapeCharacterInSelector($escaped, true)) {
3578 $parts[] = $escaped;
3579 continue;
3581 $this->count--;
3584 if ($char === '%') {
3585 $this->count++;
3587 if ($this->placeholder($placeholder)) {
3588 $parts[] = '%';
3589 $parts[] = $placeholder;
3590 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
3591 continue;
3594 break;
3597 if ($char === '#') {
3598 if ($this->interpolation($inter)) {
3599 $parts[] = $inter;
3600 ! $this->cssOnly || $this->assertPlainCssValid(false, $s);
3601 continue;
3604 $parts[] = '#';
3605 $this->count++;
3606 continue;
3609 // a pseudo selector
3610 if ($char === ':') {
3611 if ($this->buffer[$this->count + 1] === ':') {
3612 $this->count += 2;
3613 $part = '::';
3614 } else {
3615 $this->count++;
3616 $part = ':';
3619 if ($this->mixedKeyword($nameParts, true)) {
3620 $parts[] = $part;
3622 foreach ($nameParts as $sub) {
3623 $parts[] = $sub;
3626 $ss = $this->count;
3628 if (
3629 $nameParts === ['not'] ||
3630 $nameParts === ['is'] ||
3631 $nameParts === ['has'] ||
3632 $nameParts === ['where'] ||
3633 $nameParts === ['slotted'] ||
3634 $nameParts === ['nth-child'] ||
3635 $nameParts === ['nth-last-child'] ||
3636 $nameParts === ['nth-of-type'] ||
3637 $nameParts === ['nth-last-of-type']
3639 if (
3640 $this->matchChar('(', true) &&
3641 ($this->selectors($subs, reset($nameParts)) || true) &&
3642 $this->matchChar(')')
3644 $parts[] = '(';
3646 while ($sub = array_shift($subs)) {
3647 while ($ps = array_shift($sub)) {
3648 foreach ($ps as &$p) {
3649 $parts[] = $p;
3652 if (\count($sub) && reset($sub)) {
3653 $parts[] = ' ';
3657 if (\count($subs) && reset($subs)) {
3658 $parts[] = ', ';
3662 $parts[] = ')';
3663 } else {
3664 $this->seek($ss);
3666 } elseif (
3667 $this->matchChar('(', true) &&
3668 ($this->openString(')', $str, '(') || true) &&
3669 $this->matchChar(')')
3671 $parts[] = '(';
3673 if (! empty($str)) {
3674 $parts[] = $str;
3677 $parts[] = ')';
3678 } else {
3679 $this->seek($ss);
3682 continue;
3686 $this->seek($s);
3688 // 2n+1
3689 if ($subSelector && \is_string($subSelector) && strpos($subSelector, 'nth-') === 0) {
3690 if ($this->match("(\s*(\+\s*|\-\s*)?(\d+|n|\d+n))+", $counter)) {
3691 $parts[] = $counter[0];
3692 //$parts[] = str_replace(' ', '', $counter[0]);
3693 continue;
3697 $this->seek($s);
3699 // attribute selector
3700 if (
3701 $char === '[' &&
3702 $this->matchChar('[') &&
3703 ($this->openString(']', $str, '[') || true) &&
3704 $this->matchChar(']')
3706 $parts[] = '[';
3708 if (! empty($str)) {
3709 $parts[] = $str;
3712 $parts[] = ']';
3713 continue;
3716 $this->seek($s);
3718 // for keyframes
3719 if ($this->unit($unit)) {
3720 $parts[] = $unit;
3721 continue;
3724 if ($this->restrictedKeyword($name, false, true)) {
3725 $parts[] = $name;
3726 continue;
3729 break;
3732 $this->eatWhiteDefault = $oldWhite;
3734 if (! $parts) {
3735 return false;
3738 $out = $parts;
3740 return true;
3744 * Parse a variable
3746 * @param array $out
3748 * @return boolean
3750 protected function variable(&$out)
3752 $s = $this->count;
3754 if (
3755 $this->matchChar('$', false) &&
3756 $this->keyword($name)
3758 if ($this->allowVars) {
3759 $out = [Type::T_VARIABLE, $name];
3760 } else {
3761 $out = [Type::T_KEYWORD, '$' . $name];
3764 return true;
3767 $this->seek($s);
3769 return false;
3773 * Parse a keyword
3775 * @param string $word
3776 * @param boolean $eatWhitespace
3777 * @param boolean $inSelector
3779 * @return boolean
3781 protected function keyword(&$word, $eatWhitespace = null, $inSelector = false)
3783 $s = $this->count;
3784 $match = $this->match(
3785 $this->utf8
3786 ? '(([\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]) ?|[\\\\].)*)'
3787 : '(([\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]) ?|[\\\\].)*)',
3789 false
3792 if ($match) {
3793 $word = $m[1];
3795 // handling of escaping in keyword : get the escaped char
3796 if (strpos($word, '\\') !== false) {
3797 $send = $this->count;
3798 $escapedWord = [];
3799 $this->seek($s);
3800 $previousEscape = false;
3801 while ($this->count < $send) {
3802 $char = $this->buffer[$this->count];
3803 $this->count++;
3804 if (
3805 $this->count < $send
3806 && $char === '\\'
3807 && !$previousEscape
3808 && (
3809 $inSelector ?
3810 $this->matchEscapeCharacterInSelector($out)
3812 $this->matchEscapeCharacter($out, true)
3815 $escapedWord[] = $out;
3816 } else {
3817 if ($previousEscape) {
3818 $previousEscape = false;
3819 } elseif ($char === '\\') {
3820 $previousEscape = true;
3822 $escapedWord[] = $char;
3826 $word = implode('', $escapedWord);
3829 if (is_null($eatWhitespace) ? $this->eatWhiteDefault : $eatWhitespace) {
3830 $this->whitespace();
3833 return true;
3836 return false;
3840 * Parse a keyword that should not start with a number
3842 * @param string $word
3843 * @param boolean $eatWhitespace
3844 * @param boolean $inSelector
3846 * @return boolean
3848 protected function restrictedKeyword(&$word, $eatWhitespace = null, $inSelector = false)
3850 $s = $this->count;
3852 if ($this->keyword($word, $eatWhitespace, $inSelector) && (\ord($word[0]) > 57 || \ord($word[0]) < 48)) {
3853 return true;
3856 $this->seek($s);
3858 return false;
3862 * Parse a placeholder
3864 * @param string|array $placeholder
3866 * @return boolean
3868 protected function placeholder(&$placeholder)
3870 $match = $this->match(
3871 $this->utf8
3872 ? '([\pL\w\-_]+)'
3873 : '([\w\-_]+)',
3877 if ($match) {
3878 $placeholder = $m[1];
3880 return true;
3883 if ($this->interpolation($placeholder)) {
3884 return true;
3887 return false;
3891 * Parse a url
3893 * @param array $out
3895 * @return boolean
3897 protected function url(&$out)
3899 if ($this->literal('url(', 4)) {
3900 $s = $this->count;
3902 if (
3903 ($this->string($out) || $this->spaceList($out)) &&
3904 $this->matchChar(')')
3906 $out = [Type::T_STRING, '', ['url(', $out, ')']];
3908 return true;
3911 $this->seek($s);
3913 if (
3914 $this->openString(')', $out) &&
3915 $this->matchChar(')')
3917 $out = [Type::T_STRING, '', ['url(', $out, ')']];
3919 return true;
3923 return false;
3927 * Consume an end of statement delimiter
3928 * @param bool $eatWhitespace
3930 * @return boolean
3932 protected function end($eatWhitespace = null)
3934 if ($this->matchChar(';', $eatWhitespace)) {
3935 return true;
3938 if ($this->count === \strlen($this->buffer) || $this->buffer[$this->count] === '}') {
3939 // if there is end of file or a closing block next then we don't need a ;
3940 return true;
3943 return false;
3947 * Strip assignment flag from the list
3949 * @param array $value
3951 * @return array
3953 protected function stripAssignmentFlags(&$value)
3955 $flags = [];
3957 for ($token = &$value; $token[0] === Type::T_LIST && ($s = \count($token[2])); $token = &$lastNode) {
3958 $lastNode = &$token[2][$s - 1];
3960 while ($lastNode[0] === Type::T_KEYWORD && \in_array($lastNode[1], ['!default', '!global'])) {
3961 array_pop($token[2]);
3963 $node = end($token[2]);
3964 $token = $this->flattenList($token);
3965 $flags[] = $lastNode[1];
3966 $lastNode = $node;
3970 return $flags;
3974 * Strip optional flag from selector list
3976 * @param array $selectors
3978 * @return string
3980 protected function stripOptionalFlag(&$selectors)
3982 $optional = false;
3983 $selector = end($selectors);
3984 $part = end($selector);
3986 if ($part === ['!optional']) {
3987 array_pop($selectors[\count($selectors) - 1]);
3989 $optional = true;
3992 return $optional;
3996 * Turn list of length 1 into value type
3998 * @param array $value
4000 * @return array
4002 protected function flattenList($value)
4004 if ($value[0] === Type::T_LIST && \count($value[2]) === 1) {
4005 return $this->flattenList($value[2][0]);
4008 return $value;
4012 * Quote regular expression
4014 * @param string $what
4016 * @return string
4018 private function pregQuote($what)
4020 return preg_quote($what, '/');
4024 * Extract line numbers from buffer
4026 * @param string $buffer
4028 private function extractLineNumbers($buffer)
4030 $this->sourcePositions = [0 => 0];
4031 $prev = 0;
4033 while (($pos = strpos($buffer, "\n", $prev)) !== false) {
4034 $this->sourcePositions[] = $pos;
4035 $prev = $pos + 1;
4038 $this->sourcePositions[] = \strlen($buffer);
4040 if (substr($buffer, -1) !== "\n") {
4041 $this->sourcePositions[] = \strlen($buffer) + 1;
4046 * Get source line number and column (given character position in the buffer)
4048 * @param integer $pos
4050 * @return array
4052 private function getSourcePosition($pos)
4054 $low = 0;
4055 $high = \count($this->sourcePositions);
4057 while ($low < $high) {
4058 $mid = (int) (($high + $low) / 2);
4060 if ($pos < $this->sourcePositions[$mid]) {
4061 $high = $mid - 1;
4062 continue;
4065 if ($pos >= $this->sourcePositions[$mid + 1]) {
4066 $low = $mid + 1;
4067 continue;
4070 return [$mid + 1, $pos - $this->sourcePositions[$mid]];
4073 return [$low + 1, $pos - $this->sourcePositions[$low]];
4077 * Save internal encoding
4079 private function saveEncoding()
4081 if (\extension_loaded('mbstring')) {
4082 $this->encoding = mb_internal_encoding();
4084 mb_internal_encoding('iso-8859-1');
4089 * Restore internal encoding
4091 private function restoreEncoding()
4093 if (\extension_loaded('mbstring') && $this->encoding) {
4094 mb_internal_encoding($this->encoding);