From cda207d07f3e21ce4ded575c659ac0a8ae4ca01c Mon Sep 17 00:00:00 2001 From: Mathew May Date: Wed, 9 Dec 2020 13:16:13 +0800 Subject: [PATCH] MDL-70301 lib: Upgrade scssphp to 1.4.1 --- lib/scssphp/Base/Range.php | 3 +- lib/scssphp/Block.php | 5 +- lib/scssphp/Cache.php | 21 +- lib/scssphp/Colors.php | 37 +- lib/scssphp/Compiler.php | 3378 ++++++++++++----- lib/scssphp/Compiler/Environment.php | 3 +- lib/scssphp/Exception/CompilerException.php | 5 +- lib/scssphp/Exception/ParserException.php | 31 +- lib/scssphp/Exception/RangeException.php | 5 +- lib/scssphp/Exception/SassException.php | 7 + lib/scssphp/Exception/SassScriptException.php | 32 + lib/scssphp/Exception/ServerException.php | 5 +- lib/scssphp/Formatter.php | 68 +- lib/scssphp/Formatter/Compact.php | 7 +- lib/scssphp/Formatter/Compressed.php | 4 +- lib/scssphp/Formatter/Crunched.php | 8 +- lib/scssphp/Formatter/Debug.php | 8 +- lib/scssphp/Formatter/Expanded.php | 6 +- lib/scssphp/Formatter/Nested.php | 20 +- lib/scssphp/Formatter/OutputBlock.php | 5 +- lib/scssphp/Node.php | 3 +- lib/scssphp/Node/Number.php | 653 +++- lib/scssphp/OutputStyle.php | 9 + lib/scssphp/Parser.php | 1708 +++++++-- lib/scssphp/SourceMap/Base64.php | 3 +- lib/scssphp/SourceMap/Base64VLQ.php | 13 +- lib/scssphp/SourceMap/SourceMapGenerator.php | 41 +- lib/scssphp/Type.php | 8 +- lib/scssphp/Util.php | 113 +- lib/scssphp/Util/Path.php | 77 + lib/scssphp/Version.php | 5 +- 31 files changed, 4685 insertions(+), 1606 deletions(-) create mode 100644 lib/scssphp/Exception/SassException.php create mode 100644 lib/scssphp/Exception/SassScriptException.php create mode 100644 lib/scssphp/OutputStyle.php create mode 100644 lib/scssphp/Util/Path.php diff --git a/lib/scssphp/Base/Range.php b/lib/scssphp/Base/Range.php index 094e9030398..2846746d8e9 100644 --- a/lib/scssphp/Base/Range.php +++ b/lib/scssphp/Base/Range.php @@ -1,8 +1,9 @@ */ class Cache { @@ -97,18 +98,20 @@ class Cache { $fileCache = self::$cacheDir . self::cacheName($operation, $what, $options); - if (((self::$forceRefresh === false) || (self::$forceRefresh === 'once' && + if ( + ((self::$forceRefresh === false) || (self::$forceRefresh === 'once' && isset(self::$refreshed[$fileCache]))) && file_exists($fileCache) ) { $cacheTime = filemtime($fileCache); - if ((is_null($lastModified) || $cacheTime > $lastModified) && + if ( + (\is_null($lastModified) || $cacheTime > $lastModified) && $cacheTime + self::$gcLifetime > time() ) { $c = file_get_contents($fileCache); $c = unserialize($c); - if (is_array($c) && isset($c['value'])) { + if (\is_array($c) && isset($c['value'])) { return $c['value']; } } @@ -132,6 +135,7 @@ class Cache $c = ['value' => $value]; $c = serialize($c); + file_put_contents($fileCache, $c); if (self::$forceRefresh === 'once') { @@ -153,6 +157,7 @@ class Cache { $t = [ 'version' => self::CACHE_VERSION, + 'scssphpVersion' => Version::VERSION, 'operation' => $operation, 'what' => $what, 'options' => $options @@ -177,9 +182,7 @@ class Cache self::$cacheDir = rtrim(self::$cacheDir, '/') . '/'; if (! is_dir(self::$cacheDir)) { - if (! mkdir(self::$cacheDir)) { - throw new Exception('Cache directory couldn\'t be created: ' . self::$cacheDir); - } + throw new Exception('Cache directory doesn\'t exist: ' . self::$cacheDir); } if (! is_writable(self::$cacheDir)) { diff --git a/lib/scssphp/Colors.php b/lib/scssphp/Colors.php index ef6409aca11..4b62c361c52 100644 --- a/lib/scssphp/Colors.php +++ b/lib/scssphp/Colors.php @@ -1,8 +1,9 @@ '240,248,255', 'antiquewhite' => '250,235,215', + 'cyan' => '0,255,255', 'aqua' => '0,255,255', 'aquamarine' => '127,255,212', 'azure' => '240,255,255', @@ -46,13 +48,12 @@ class Colors 'cornflowerblue' => '100,149,237', 'cornsilk' => '255,248,220', 'crimson' => '220,20,60', - 'cyan' => '0,255,255', 'darkblue' => '0,0,139', 'darkcyan' => '0,139,139', 'darkgoldenrod' => '184,134,11', 'darkgray' => '169,169,169', - 'darkgreen' => '0,100,0', 'darkgrey' => '169,169,169', + 'darkgreen' => '0,100,0', 'darkkhaki' => '189,183,107', 'darkmagenta' => '139,0,139', 'darkolivegreen' => '85,107,47', @@ -74,15 +75,16 @@ class Colors 'firebrick' => '178,34,34', 'floralwhite' => '255,250,240', 'forestgreen' => '34,139,34', + 'magenta' => '255,0,255', 'fuchsia' => '255,0,255', 'gainsboro' => '220,220,220', 'ghostwhite' => '248,248,255', 'gold' => '255,215,0', 'goldenrod' => '218,165,32', 'gray' => '128,128,128', + 'grey' => '128,128,128', 'green' => '0,128,0', 'greenyellow' => '173,255,47', - 'grey' => '128,128,128', 'honeydew' => '240,255,240', 'hotpink' => '255,105,180', 'indianred' => '205,92,92', @@ -98,8 +100,8 @@ class Colors 'lightcyan' => '224,255,255', 'lightgoldenrodyellow' => '250,250,210', 'lightgray' => '211,211,211', - 'lightgreen' => '144,238,144', 'lightgrey' => '211,211,211', + 'lightgreen' => '144,238,144', 'lightpink' => '255,182,193', 'lightsalmon' => '255,160,122', 'lightseagreen' => '32,178,170', @@ -111,7 +113,6 @@ class Colors 'lime' => '0,255,0', 'limegreen' => '50,205,50', 'linen' => '250,240,230', - 'magenta' => '255,0,255', 'maroon' => '128,0,0', 'mediumaquamarine' => '102,205,170', 'mediumblue' => '0,0,205', @@ -145,7 +146,6 @@ class Colors 'plum' => '221,160,221', 'powderblue' => '176,224,230', 'purple' => '128,0,128', - 'rebeccapurple' => '102,51,153', 'red' => '255,0,0', 'rosybrown' => '188,143,143', 'royalblue' => '65,105,225', @@ -167,7 +167,6 @@ class Colors 'teal' => '0,128,128', 'thistle' => '216,191,216', 'tomato' => '255,99,71', - 'transparent' => '0,0,0,0', 'turquoise' => '64,224,208', 'violet' => '238,130,238', 'wheat' => '245,222,179', @@ -175,6 +174,8 @@ class Colors 'whitesmoke' => '245,245,245', 'yellow' => '255,255,0', 'yellowgreen' => '154,205,50', + 'rebeccapurple' => '102,51,153', + 'transparent' => '0,0,0,0', ]; /** @@ -186,7 +187,7 @@ class Colors */ public static function colorNameToRGBa($colorName) { - if (is_string($colorName) && isset(static::$cssColors[$colorName])) { + if (\is_string($colorName) && isset(static::$cssColors[$colorName])) { $rgba = explode(',', static::$cssColors[$colorName]); // only case with opacity is transparent, with opacity=0, so we can intval on opacity also @@ -217,28 +218,26 @@ class Colors } if ($a < 1) { - # specific case we dont' revert according to spec - #if (! $a && ! $r && ! $g && ! $b) { - # return 'transparent'; - #} - return null; } - if (is_null($reverseColorTable)) { + if (\is_null($reverseColorTable)) { $reverseColorTable = []; foreach (static::$cssColors as $name => $rgb_str) { $rgb_str = explode(',', $rgb_str); - if (count($rgb_str) == 3) { - $reverseColorTable[intval($rgb_str[0])][intval($rgb_str[1])][intval($rgb_str[2])] = $name; + if ( + \count($rgb_str) == 3 && + ! isset($reverseColorTable[\intval($rgb_str[0])][\intval($rgb_str[1])][\intval($rgb_str[2])]) + ) { + $reverseColorTable[\intval($rgb_str[0])][\intval($rgb_str[1])][\intval($rgb_str[2])] = $name; } } } - if (isset($reverseColorTable[intval($r)][intval($g)][intval($b)])) { - return $reverseColorTable[intval($r)][intval($g)][intval($b)]; + if (isset($reverseColorTable[\intval($r)][\intval($g)][\intval($b)])) { + return $reverseColorTable[\intval($r)][\intval($g)][\intval($b)]; } return null; diff --git a/lib/scssphp/Compiler.php b/lib/scssphp/Compiler.php index 711d3382f1d..0997814eef0 100644 --- a/lib/scssphp/Compiler.php +++ b/lib/scssphp/Compiler.php @@ -1,8 +1,9 @@ */ - static protected $operatorNames = [ + protected static $operatorNames = [ '+' => 'add', '-' => 'sub', '*' => 'mul', @@ -87,81 +105,184 @@ class Compiler '<=' => 'lte', '>=' => 'gte', - '<=>' => 'cmp', ]; /** - * @var array + * @var array */ - static protected $namespaces = [ + protected static $namespaces = [ 'special' => '%', 'mixin' => '@', 'function' => '^', ]; - static public $true = [Type::T_KEYWORD, 'true']; - static public $false = [Type::T_KEYWORD, 'false']; - static public $null = [Type::T_NULL]; - static public $nullString = [Type::T_STRING, '', []]; - static public $defaultValue = [Type::T_KEYWORD, '']; - static public $selfSelector = [Type::T_SELF]; - static public $emptyList = [Type::T_LIST, '', []]; - static public $emptyMap = [Type::T_MAP, [], []]; - static public $emptyString = [Type::T_STRING, '"', []]; - static public $with = [Type::T_KEYWORD, 'with']; - static public $without = [Type::T_KEYWORD, 'without']; + public static $true = [Type::T_KEYWORD, 'true']; + public static $false = [Type::T_KEYWORD, 'false']; + /** @deprecated */ + public static $NaN = [Type::T_KEYWORD, 'NaN']; + /** @deprecated */ + public static $Infinity = [Type::T_KEYWORD, 'Infinity']; + public static $null = [Type::T_NULL]; + public static $nullString = [Type::T_STRING, '', []]; + public static $defaultValue = [Type::T_KEYWORD, '']; + public static $selfSelector = [Type::T_SELF]; + public static $emptyList = [Type::T_LIST, '', []]; + public static $emptyMap = [Type::T_MAP, [], []]; + public static $emptyString = [Type::T_STRING, '"', []]; + public static $with = [Type::T_KEYWORD, 'with']; + public static $without = [Type::T_KEYWORD, 'without']; - protected $importPaths = ['']; + /** + * @var array + */ + protected $importPaths = []; + /** + * @var array + */ protected $importCache = []; + /** + * @var string[] + */ protected $importedFiles = []; protected $userFunctions = []; protected $registeredVars = []; + /** + * @var array + */ protected $registeredFeatures = [ 'extend-selector-pseudoclass' => false, 'at-error' => true, - 'units-level-3' => false, + 'units-level-3' => true, 'global-variable-shadowing' => false, ]; + /** + * @var string|null + */ protected $encoding = null; + /** + * @deprecated + */ protected $lineNumberStyle = null; + /** + * @var int|SourceMapGenerator + * @phpstan-var self::SOURCE_MAP_*|SourceMapGenerator + */ protected $sourceMap = self::SOURCE_MAP_NONE; protected $sourceMapOptions = []; /** * @var string|\ScssPhp\ScssPhp\Formatter */ - protected $formatter = 'ScssPhp\ScssPhp\Formatter\Nested'; + protected $formatter = Expanded::class; + /** + * @var Environment + */ protected $rootEnv; + /** + * @var OutputBlock|null + */ protected $rootBlock; /** * @var \ScssPhp\ScssPhp\Compiler\Environment */ protected $env; + /** + * @var OutputBlock|null + */ protected $scope; + /** + * @var Environment|null + */ protected $storeEnv; + /** + * @var bool|null + */ protected $charsetSeen; + /** + * @var array + */ protected $sourceNames; + /** + * @var Cache|null + */ protected $cache; + /** + * @var int + */ protected $indentLevel; + /** + * @var array[] + */ protected $extends; + /** + * @var array + */ protected $extendsMap; + /** + * @var array + */ protected $parsedFiles; + /** + * @var Parser|null + */ protected $parser; + /** + * @var int|null + */ protected $sourceIndex; + /** + * @var int|null + */ protected $sourceLine; + /** + * @var int|null + */ protected $sourceColumn; + /** + * @var resource + */ protected $stderr; + /** + * @var bool|null + */ protected $shouldEvaluate; + /** + * @var null + * @deprecated + */ protected $ignoreErrors; + /** + * @var bool + */ + protected $ignoreCallStackMessage = false; + /** + * @var array[] + */ protected $callStack = []; + /** + * The directory of the currently processed file + * + * @var string|null + */ + private $currentDirectory; + + /** + * The directory of the input file + * + * @var string + */ + private $rootDirectory; + + private $legacyCwdImportPath = true; + /** * Constructor * @@ -182,7 +303,7 @@ class Compiler /** * Get compiler options * - * @return array + * @return array */ public function getCompileOptions() { @@ -194,6 +315,7 @@ class Compiler 'sourceMap' => serialize($this->sourceMap), 'sourceMapOptions' => $this->sourceMapOptions, 'formatter' => $this->formatter, + 'legacyImportPath' => $this->legacyCwdImportPath, ]; return $options; @@ -203,6 +325,8 @@ class Compiler * Set an alternative error output stream, for testing purpose only * * @param resource $handle + * + * @return void */ public function setErrorOuput($handle) { @@ -222,11 +346,11 @@ class Compiler public function compile($code, $path = null) { if ($this->cache) { - $cacheKey = ($path ? $path : "(stdin)") . ":" . md5($code); + $cacheKey = ($path ? $path : '(stdin)') . ':' . md5($code); $compileOptions = $this->getCompileOptions(); - $cache = $this->cache->getCache("compile", $cacheKey, $compileOptions); + $cache = $this->cache->getCache('compile', $cacheKey, $compileOptions); - if (is_array($cache) && isset($cache['dependencies']) && isset($cache['out'])) { + if (\is_array($cache) && isset($cache['dependencies']) && isset($cache['out'])) { // check if any dependency file changed before accepting the cache foreach ($cache['dependencies'] as $file => $mtime) { if (! is_file($file) || filemtime($file) !== $mtime) { @@ -253,47 +377,70 @@ class Compiler $this->storeEnv = null; $this->charsetSeen = null; $this->shouldEvaluate = null; + $this->ignoreCallStackMessage = false; - $this->parser = $this->parserFactory($path); - $tree = $this->parser->parse($code); - $this->parser = null; - - $this->formatter = new $this->formatter(); - $this->rootBlock = null; - $this->rootEnv = $this->pushEnv($tree); - - $this->injectVariables($this->registeredVars); - $this->compileRoot($tree); - $this->popEnv(); - - $sourceMapGenerator = null; - - if ($this->sourceMap) { - if (is_object($this->sourceMap) && $this->sourceMap instanceof SourceMapGenerator) { - $sourceMapGenerator = $this->sourceMap; - $this->sourceMap = self::SOURCE_MAP_FILE; - } elseif ($this->sourceMap !== self::SOURCE_MAP_NONE) { - $sourceMapGenerator = new SourceMapGenerator($this->sourceMapOptions); - } + if (!\is_null($path) && is_file($path)) { + $path = realpath($path) ?: $path; + $this->currentDirectory = dirname($path); + $this->rootDirectory = $this->currentDirectory; + } else { + $this->currentDirectory = null; + $this->rootDirectory = getcwd(); } - $out = $this->formatter->format($this->scope, $sourceMapGenerator); + try { + $this->parser = $this->parserFactory($path); + $tree = $this->parser->parse($code); + $this->parser = null; - if (! empty($out) && $this->sourceMap && $this->sourceMap !== self::SOURCE_MAP_NONE) { - $sourceMap = $sourceMapGenerator->generateJson(); - $sourceMapUrl = null; + $this->formatter = new $this->formatter(); + $this->rootBlock = null; + $this->rootEnv = $this->pushEnv($tree); - switch ($this->sourceMap) { - case self::SOURCE_MAP_INLINE: - $sourceMapUrl = sprintf('data:application/json,%s', Util::encodeURIComponent($sourceMap)); - break; + $this->injectVariables($this->registeredVars); + $this->compileRoot($tree); + $this->popEnv(); - case self::SOURCE_MAP_FILE: - $sourceMapUrl = $sourceMapGenerator->saveMap($sourceMap); - break; + $sourceMapGenerator = null; + + if ($this->sourceMap) { + if (\is_object($this->sourceMap) && $this->sourceMap instanceof SourceMapGenerator) { + $sourceMapGenerator = $this->sourceMap; + $this->sourceMap = self::SOURCE_MAP_FILE; + } elseif ($this->sourceMap !== self::SOURCE_MAP_NONE) { + $sourceMapGenerator = new SourceMapGenerator($this->sourceMapOptions); + } } - $out .= sprintf('/*# sourceMappingURL=%s */', $sourceMapUrl); + $out = $this->formatter->format($this->scope, $sourceMapGenerator); + + $prefix = ''; + + if (!$this->charsetSeen) { + if (strlen($out) !== Util::mbStrlen($out)) { + $prefix = '@charset "UTF-8";' . "\n"; + $out = $prefix . $out; + } + } + + if (! empty($out) && $this->sourceMap && $this->sourceMap !== self::SOURCE_MAP_NONE) { + $sourceMap = $sourceMapGenerator->generateJson($prefix); + $sourceMapUrl = null; + + switch ($this->sourceMap) { + case self::SOURCE_MAP_INLINE: + $sourceMapUrl = sprintf('data:application/json,%s', Util::encodeURIComponent($sourceMap)); + break; + + case self::SOURCE_MAP_FILE: + $sourceMapUrl = $sourceMapGenerator->saveMap($sourceMap); + break; + } + + $out .= sprintf('/*# sourceMappingURL=%s */', $sourceMapUrl); + } + } catch (SassScriptException $e) { + throw $this->error($e->getMessage()); } if ($this->cache && isset($cacheKey) && isset($compileOptions)) { @@ -302,7 +449,7 @@ class Compiler 'out' => &$out, ]; - $this->cache->setCache("compile", $cacheKey, $v, $compileOptions); + $this->cache->setCache('compile', $cacheKey, $v, $compileOptions); } return $out; @@ -317,7 +464,18 @@ class Compiler */ protected function parserFactory($path) { - $parser = new Parser($path, count($this->sourceNames), $this->encoding, $this->cache); + // https://sass-lang.com/documentation/at-rules/import + // CSS files imported by Sass don’t allow any special Sass features. + // In order to make sure authors don’t accidentally write Sass in their CSS, + // all Sass features that aren’t also valid CSS will produce errors. + // Otherwise, the CSS will be rendered as-is. It can even be extended! + $cssOnly = false; + + if (substr($path, '-4') === '.css') { + $cssOnly = true; + } + + $parser = new Parser($path, \count($this->sourceNames), $this->encoding, $this->cache, $cssOnly); $this->sourceNames[] = $path; $this->addParsedFile($path); @@ -336,7 +494,7 @@ class Compiler protected function isSelfExtend($target, $origin) { foreach ($origin as $sel) { - if (in_array($target, $sel)) { + if (\in_array($target, $sel)) { return true; } } @@ -350,14 +508,12 @@ class Compiler * @param array $target * @param array $origin * @param array|null $block + * + * @return void */ protected function pushExtends($target, $origin, $block) { - if ($this->isSelfExtend($target, $origin)) { - return; - } - - $i = count($this->extends); + $i = \count($this->extends); $this->extends[] = [$target, $origin, $block]; foreach ($target as $part) { @@ -379,7 +535,7 @@ class Compiler */ protected function makeOutputBlock($type, $selectors = null) { - $out = new OutputBlock; + $out = new OutputBlock(); $out->type = $type; $out->lines = []; $out->children = []; @@ -404,6 +560,8 @@ class Compiler * Compile root * * @param \ScssPhp\ScssPhp\Block $rootBlock + * + * @return void */ protected function compileRoot(Block $rootBlock) { @@ -416,6 +574,8 @@ class Compiler /** * Report missing selectors + * + * @return void */ protected function missingSelectors() { @@ -435,7 +595,7 @@ class Compiler $origin = $this->collapseSelectors($origin); $this->sourceLine = $block[Parser::SOURCE_LINE]; - $this->throwError("\"$origin\" failed to @extend \"$target\". The selector \"$target\" was not found."); + throw $this->error("\"$origin\" failed to @extend \"$target\". The selector \"$target\" was not found."); } } @@ -444,6 +604,8 @@ class Compiler * * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $block * @param string $parentKey + * + * @return void */ protected function flattenSelectors(OutputBlock $block, $parentKey = null) { @@ -453,7 +615,7 @@ class Compiler foreach ($block->selectors as $s) { $selectors[] = $s; - if (! is_array($s)) { + if (! \is_array($s)) { continue; } @@ -486,7 +648,7 @@ class Compiler $block->selectors[] = $this->compileSelector($selector); } - if ($placeholderSelector && 0 === count($block->selectors) && null !== $parentKey) { + if ($placeholderSelector && 0 === \count($block->selectors) && null !== $parentKey) { unset($block->parent->children[$parentKey]); return; @@ -510,19 +672,20 @@ class Compiler $new = []; foreach ($parts as $part) { - if (is_array($part)) { + if (\is_array($part)) { $part = $this->glueFunctionSelectors($part); $new[] = $part; } else { // a selector part finishing with a ) is the last part of a :not( or :nth-child( // and need to be joined to this - if (count($new) && is_string($new[count($new) - 1]) && - strlen($part) && substr($part, -1) === ')' && strpos($part, '(') === false + if ( + \count($new) && \is_string($new[\count($new) - 1]) && + \strlen($part) && substr($part, -1) === ')' && strpos($part, '(') === false ) { - while (count($new)>1 && substr($new[count($new) - 1], -1) !== '(') { + while (\count($new) > 1 && substr($new[\count($new) - 1], -1) !== '(') { $part = array_pop($new) . $part; } - $new[count($new) - 1] .= $part; + $new[\count($new) - 1] .= $part; } else { $new[] = $part; } @@ -539,17 +702,20 @@ class Compiler * @param array $out * @param integer $from * @param boolean $initial + * + * @return void */ protected function matchExtends($selector, &$out, $from = 0, $initial = true) { static $partsPile = []; $selector = $this->glueFunctionSelectors($selector); - if (count($selector) == 1 && in_array(reset($selector), $partsPile)) { + if (\count($selector) == 1 && \in_array(reset($selector), $partsPile)) { return; } $outRecurs = []; + foreach ($selector as $i => $part) { if ($i < $from) { continue; @@ -557,41 +723,43 @@ class Compiler // check that we are not building an infinite loop of extensions // if the new part is just including a previous part don't try to extend anymore - if (count($part) > 1) { + if (\count($part) > 1) { foreach ($partsPile as $previousPart) { - if (! count(array_diff($previousPart, $part))) { + if (! \count(array_diff($previousPart, $part))) { continue 2; } } } $partsPile[] = $part; + if ($this->matchExtendsSingle($part, $origin, $initial)) { - $after = array_slice($selector, $i + 1); - $before = array_slice($selector, 0, $i); + $after = \array_slice($selector, $i + 1); + $before = \array_slice($selector, 0, $i); list($before, $nonBreakableBefore) = $this->extractRelationshipFromFragment($before); foreach ($origin as $new) { $k = 0; // remove shared parts - if (count($new) > 1) { + if (\count($new) > 1) { while ($k < $i && isset($new[$k]) && $selector[$k] === $new[$k]) { $k++; } } - if (count($nonBreakableBefore) and $k == count($new)) { + + if (\count($nonBreakableBefore) && $k === \count($new)) { $k--; } $replacement = []; - $tempReplacement = $k > 0 ? array_slice($new, $k) : $new; + $tempReplacement = $k > 0 ? \array_slice($new, $k) : $new; - for ($l = count($tempReplacement) - 1; $l >= 0; $l--) { + for ($l = \count($tempReplacement) - 1; $l >= 0; $l--) { $slice = []; foreach ($tempReplacement[$l] as $chunk) { - if (! in_array($chunk, $slice)) { + if (! \in_array($chunk, $slice)) { $slice[] = $chunk; } } @@ -603,7 +771,7 @@ class Compiler } } - $afterBefore = $l != 0 ? array_slice($tempReplacement, 0, $l) : []; + $afterBefore = $l != 0 ? \array_slice($tempReplacement, 0, $l) : []; // Merge shared direct relationships. $mergedBefore = $this->mergeDirectRelationships($afterBefore, $nonBreakableBefore); @@ -622,17 +790,18 @@ class Compiler $this->pushOrMergeExtentedSelector($out, $result); // recursively check for more matches - $startRecurseFrom = count($before) + min(count($nonBreakableBefore), count($mergedBefore)); - if (count($origin) > 1) { + $startRecurseFrom = \count($before) + min(\count($nonBreakableBefore), \count($mergedBefore)); + + if (\count($origin) > 1) { $this->matchExtends($result, $out, $startRecurseFrom, false); } else { $this->matchExtends($result, $outRecurs, $startRecurseFrom, false); } // selector sequence merging - if (! empty($before) && count($new) > 1) { - $preSharedParts = $k > 0 ? array_slice($before, 0, $k) : []; - $postSharedParts = $k > 0 ? array_slice($before, $k) : $before; + if (! empty($before) && \count($new) > 1) { + $preSharedParts = $k > 0 ? \array_slice($before, 0, $k) : []; + $postSharedParts = $k > 0 ? \array_slice($before, $k) : $before; list($betweenSharedParts, $nonBreakabl2) = $this->extractRelationshipFromFragment($afterBefore); @@ -652,7 +821,8 @@ class Compiler } array_pop($partsPile); } - while (count($outRecurs)) { + + while (\count($outRecurs)) { $result = array_shift($outRecurs); $this->pushOrMergeExtentedSelector($out, $result); } @@ -660,16 +830,21 @@ class Compiler /** * Test a part for being a pseudo selector + * * @param string $part - * @param array $matches - * @return bool + * @param array $matches + * + * @return boolean */ protected function isPseudoSelector($part, &$matches) { - if (strpos($part, ":") === 0 - && preg_match(",^::?([\w-]+)\((.+)\)$,", $part, $matches)) { + if ( + strpos($part, ':') === 0 && + preg_match(",^::?([\w-]+)\((.+)\)$,", $part, $matches) + ) { return true; } + return false; } @@ -679,25 +854,35 @@ class Compiler * - same as previous * - in a white list * in this case we merge the pseudo selector content + * * @param array $out * @param array $extended + * + * @return void */ protected function pushOrMergeExtentedSelector(&$out, $extended) { - if (count($out) && count($extended) === 1 && count(reset($extended)) === 1) { + if (\count($out) && \count($extended) === 1 && \count(reset($extended)) === 1) { $single = reset($extended); $part = reset($single); - if ($this->isPseudoSelector($part, $matchesExtended) - && in_array($matchesExtended[1], [ 'slotted' ])) { + + if ( + $this->isPseudoSelector($part, $matchesExtended) && + \in_array($matchesExtended[1], [ 'slotted' ]) + ) { $prev = end($out); $prev = $this->glueFunctionSelectors($prev); - if (count($prev) === 1 && count(reset($prev)) === 1) { + + if (\count($prev) === 1 && \count(reset($prev)) === 1) { $single = reset($prev); $part = reset($single); - if ($this->isPseudoSelector($part, $matchesPrev) - && $matchesPrev[1] === $matchesExtended[1]) { + + if ( + $this->isPseudoSelector($part, $matchesPrev) && + $matchesPrev[1] === $matchesExtended[1] + ) { $extended = explode($matchesExtended[1] . '(', $matchesExtended[0], 2); - $extended[1] = $matchesPrev[2] . ", " . $extended[1]; + $extended[1] = $matchesPrev[2] . ', ' . $extended[1]; $extended = implode($matchesExtended[1] . '(', $extended); $extended = [ [ $extended ]]; array_pop($out); @@ -711,9 +896,9 @@ class Compiler /** * Match extends single * - * @param array $rawSingle - * @param array $outOrigin - * @param bool $initial + * @param array $rawSingle + * @param array $outOrigin + * @param boolean $initial * * @return boolean */ @@ -723,18 +908,18 @@ class Compiler $single = []; // simple usual cases, no need to do the whole trick - if (in_array($rawSingle, [['>'],['+'],['~']])) { + if (\in_array($rawSingle, [['>'],['+'],['~']])) { return false; } foreach ($rawSingle as $part) { // matches Number - if (! is_string($part)) { + if (! \is_string($part)) { return false; } - if (! preg_match('/^[\[.:#%]/', $part) && count($single)) { - $single[count($single) - 1] .= $part; + if (! preg_match('/^[\[.:#%]/', $part) && \count($single)) { + $single[\count($single) - 1] .= $part; } else { $single[] = $part; } @@ -742,7 +927,7 @@ class Compiler $extendingDecoratedTag = false; - if (count($single) > 1) { + if (\count($single) > 1) { $matches = null; $extendingDecoratedTag = preg_match('/^[a-z0-9]+$/i', $single[0], $matches) ? $matches[0] : false; } @@ -756,24 +941,31 @@ class Compiler $counts[$idx] = isset($counts[$idx]) ? $counts[$idx] + 1 : 1; } } - if ($initial - && $this->isPseudoSelector($part, $matches) - && ! in_array($matches[1], [ 'not' ])) { + + if ( + $initial && + $this->isPseudoSelector($part, $matches) && + ! \in_array($matches[1], [ 'not' ]) + ) { $buffer = $matches[2]; $parser = $this->parserFactory(__METHOD__); - if ($parser->parseSelector($buffer, $subSelectors)) { + + if ($parser->parseSelector($buffer, $subSelectors, false)) { foreach ($subSelectors as $ksub => $subSelector) { $subExtended = []; $this->matchExtends($subSelector, $subExtended, 0, false); + if ($subExtended) { $subSelectorsExtended = $subSelectors; $subSelectorsExtended[$ksub] = $subExtended; + foreach ($subSelectorsExtended as $ksse => $sse) { $subSelectorsExtended[$ksse] = $this->collapseSelectors($sse); } + $subSelectorsExtended = implode(', ', $subSelectorsExtended); $singleExtended = $single; - $singleExtended[$k] = str_replace("(".$buffer.")", "($subSelectorsExtended)", $part); + $singleExtended[$k] = str_replace('(' . $buffer . ')', "($subSelectorsExtended)", $part); $outOrigin[] = [ $singleExtended ]; $found = true; } @@ -788,7 +980,7 @@ class Compiler $origin = $this->glueFunctionSelectors($origin); // check count - if ($count !== count($target)) { + if ($count !== \count($target)) { continue; } @@ -798,14 +990,15 @@ class Compiler foreach ($origin as $j => $new) { // prevent infinite loop when target extends itself - if ($this->isSelfExtend($single, $origin)) { + if ($this->isSelfExtend($single, $origin) && ! $initial) { return false; } $replacement = end($new); // Extending a decorated tag with another tag is not possible. - if ($extendingDecoratedTag && $replacement[0] != $extendingDecoratedTag && + if ( + $extendingDecoratedTag && $replacement[0] != $extendingDecoratedTag && preg_match('/^[a-z0-9]+$/i', $replacement[0]) ) { unset($origin[$j]); @@ -814,8 +1007,8 @@ class Compiler $combined = $this->combineSelectorSingle($replacement, $rem); - if (count(array_diff($combined, $origin[$j][count($origin[$j]) - 1]))) { - $origin[$j][count($origin[$j]) - 1] = $combined; + if (\count(array_diff($combined, $origin[$j][\count($origin[$j]) - 1]))) { + $origin[$j][\count($origin[$j]) - 1] = $combined; } } @@ -844,11 +1037,11 @@ class Compiler $parents = []; $children = []; - $j = $i = count($fragment); + $j = $i = \count($fragment); for (;;) { - $children = $j != $i ? array_slice($fragment, $j, $i - $j) : []; - $parents = array_slice($fragment, 0, $j); + $children = $j != $i ? \array_slice($fragment, $j, $i - $j) : []; + $parents = \array_slice($fragment, 0, $j); $slice = end($parents); if (empty($slice) || ! $this->isImmediateRelationshipCombinator($slice[0])) { @@ -874,8 +1067,15 @@ class Compiler $tag = []; $out = []; $wasTag = false; + $pseudo = []; + + while (\count($other) && strpos(end($other), ':') === 0) { + array_unshift($pseudo, array_pop($other)); + } foreach ([array_reverse($base), array_reverse($other)] as $single) { + $rang = count($single); + foreach ($single as $part) { if (preg_match('/^[\[:]/', $part)) { $out[] = $part; @@ -883,21 +1083,26 @@ class Compiler } elseif (preg_match('/^[\.#]/', $part)) { array_unshift($out, $part); $wasTag = false; - } elseif (preg_match('/^[^_-]/', $part)) { + } elseif (preg_match('/^[^_-]/', $part) && $rang === 1) { $tag[] = $part; $wasTag = true; } elseif ($wasTag) { - $tag[count($tag) - 1] .= $part; + $tag[\count($tag) - 1] .= $part; } else { - $out[] = $part; + array_unshift($out, $part); } + $rang--; } } - if (count($tag)) { + if (\count($tag)) { array_unshift($out, $tag[0]); } + while (\count($pseudo)) { + $out[] = array_shift($pseudo); + } + return $out; } @@ -905,6 +1110,8 @@ class Compiler * Compile media * * @param \ScssPhp\ScssPhp\Block $media + * + * @return void */ protected function compileMedia(Block $media) { @@ -929,7 +1136,8 @@ class Compiler foreach ($media->children as $child) { $type = $child[0]; - if ($type !== Type::T_BLOCK && + if ( + $type !== Type::T_BLOCK && $type !== Type::T_MEDIA && $type !== Type::T_DIRECTIVE && $type !== Type::T_IMPORT @@ -940,7 +1148,7 @@ class Compiler } if ($needsWrap) { - $wrapped = new Block; + $wrapped = new Block(); $wrapped->sourceName = $media->sourceName; $wrapped->sourceIndex = $media->sourceIndex; $wrapped->sourceLine = $media->sourceLine; @@ -951,30 +1159,6 @@ class Compiler $wrapped->children = $media->children; $media->children = [[Type::T_BLOCK, $wrapped]]; - - if (isset($this->lineNumberStyle)) { - $annotation = $this->makeOutputBlock(Type::T_COMMENT); - $annotation->depth = 0; - - $file = $this->sourceNames[$media->sourceIndex]; - $line = $media->sourceLine; - - switch ($this->lineNumberStyle) { - case static::LINE_COMMENTS: - $annotation->lines[] = '/* line ' . $line - . ($file ? ', ' . $file : '') - . ' */'; - break; - - case static::DEBUG_INFO: - $annotation->lines[] = '@media -sass-debug-info{' - . ($file ? 'filename{font-family:"' . $file . '"}' : '') - . 'line{font-family:' . $line . '}}'; - break; - } - - $this->scope->children[] = $annotation; - } } $this->compileChildrenNoReturn($media->children, $this->scope); @@ -1010,16 +1194,31 @@ class Compiler * * @param \ScssPhp\ScssPhp\Block|array $block * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $out + * + * @return void */ protected function compileDirective($directive, OutputBlock $out) { - if (is_array($directive)) { - $s = '@' . $directive[0]; + if (\is_array($directive)) { + $directiveName = $this->compileDirectiveName($directive[0]); + $s = '@' . $directiveName; + if (! empty($directive[1])) { $s .= ' ' . $this->compileValue($directive[1]); } - $this->appendRootDirective($s . ';', $out); + // sass-spec compliance on newline after directives, a bit tricky :/ + $appendNewLine = (! empty($directive[2]) || strpos($s, "\n")) ? "\n" : ""; + if (\is_array($directive[0]) && empty($directive[1])) { + $appendNewLine = "\n"; + } + + if (empty($directive[3])) { + $this->appendRootDirective($s . ';' . $appendNewLine, $out, [Type::T_COMMENT, Type::T_DIRECTIVE]); + } else { + $this->appendOutputLine($out, Type::T_DIRECTIVE, $s . ';'); + } } else { + $directive->name = $this->compileDirectiveName($directive->name); $s = '@' . $directive->name; if (! empty($directive->value)) { @@ -1034,10 +1233,28 @@ class Compiler } } + /** + * directive names can include some interpolation + * + * @param string|array $directiveName + * @return array|string + * @throws CompilerException + */ + protected function compileDirectiveName($directiveName) + { + if (is_string($directiveName)) { + return $directiveName; + } + + return $this->compileValue($directiveName); + } + /** * Compile at-root * * @param \ScssPhp\ScssPhp\Block $block + * + * @return void */ protected function compileAtRoot(Block $block) { @@ -1047,7 +1264,7 @@ class Compiler // wrap inline selector if ($block->selector) { - $wrapped = new Block; + $wrapped = new Block(); $wrapped->sourceName = $block->sourceName; $wrapped->sourceIndex = $block->sourceIndex; $wrapped->sourceLine = $block->sourceLine; @@ -1064,7 +1281,9 @@ class Compiler $selfParent = $block->selfParent; - if (! $block->selfParent->selectors && isset($block->parent) && $block->parent && + if ( + ! $block->selfParent->selectors && + isset($block->parent) && $block->parent && isset($block->parent->selectors) && $block->parent->selectors ) { $selfParent = $block->parent; @@ -1092,7 +1311,7 @@ class Compiler * @param array $with * @param array $without * - * @return mixed + * @return OutputBlock */ protected function filterScopeWithWithout($scope, $with, $without) { @@ -1127,7 +1346,7 @@ class Compiler $filteredScopes[] = $s; } - if (count($childStash)) { + if (\count($childStash)) { $scope = array_shift($childStash); } elseif ($scope->children) { $scope = end($scope->children); @@ -1136,7 +1355,7 @@ class Compiler } } - if (! count($filteredScopes)) { + if (! \count($filteredScopes)) { return $this->rootBlock; } @@ -1147,7 +1366,7 @@ class Compiler $p = &$newScope; - while (count($filteredScopes)) { + while (\count($filteredScopes)) { $s = array_shift($filteredScopes); $s->parent = $p; $p->children[] = $s; @@ -1165,11 +1384,11 @@ class Compiler * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $scope * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $previousScope * - * @return mixed + * @return OutputBlock */ protected function completeScope($scope, $previousScope) { - if (! $scope->type && (! $scope->selectors || ! count($scope->selectors)) && count($scope->lines)) { + if (! $scope->type && (! $scope->selectors || ! \count($scope->selectors)) && \count($scope->lines)) { $scope->selectors = $this->findScopeSelectors($previousScope, $scope->depth); } @@ -1221,6 +1440,17 @@ class Compiler $without = ['rule' => true]; if ($withCondition) { + if ($withCondition[0] === Type::T_INTERPOLATE) { + $w = $this->compileValue($withCondition); + + $buffer = "($w)"; + $parser = $this->parserFactory(__METHOD__); + + if ($parser->parseValue($buffer, $reParsedWith)) { + $withCondition = $reParsedWith; + } + } + if ($this->libMapHasKey([$withCondition, static::$with])) { $without = []; // cancel the default $list = $this->coerceList($this->libMapGet([$withCondition, static::$with])); @@ -1250,11 +1480,13 @@ class Compiler /** * Filter env stack * - * @param array $envs + * @param Environment[] $envs * @param array $with * @param array $without * - * @return \ScssPhp\ScssPhp\Compiler\Environment + * @return Environment + * + * @phpstan-param non-empty-array $envs */ protected function filterWithWithout($envs, $with, $without) { @@ -1293,7 +1525,7 @@ class Compiler if ($block->type === Type::T_DIRECTIVE) { if (isset($block->name)) { - return $this->testWithWithout($block->name, $with, $without); + return $this->testWithWithout($this->compileDirectiveName($block->name), $with, $without); } elseif (isset($block->selectors) && preg_match(',@(\w+),ims', json_encode($block->selectors), $m)) { return $this->testWithWithout($m[1], $with, $without); } else { @@ -1302,14 +1534,14 @@ class Compiler } } elseif (isset($block->selectors)) { // a selector starting with number is a keyframe rule - if (count($block->selectors)) { + if (\count($block->selectors)) { $s = reset($block->selectors); - while (is_array($s)) { + while (\is_array($s)) { $s = reset($s); } - if (is_object($s) && $s instanceof Node\Number) { + if (\is_object($s) && $s instanceof Number) { return $this->testWithWithout('keyframes', $with, $without); } } @@ -1332,9 +1564,8 @@ class Compiler */ protected function testWithWithout($what, $with, $without) { - // if without, reject only if in the list (or 'all' is in the list) - if (count($without)) { + if (\count($without)) { return (isset($without[$what]) || isset($without['all'])) ? false : true; } @@ -1348,6 +1579,8 @@ class Compiler * * @param \ScssPhp\ScssPhp\Block $block * @param array $selectors + * + * @return void */ protected function compileKeyframeBlock(Block $block, $selectors) { @@ -1374,8 +1607,10 @@ class Compiler /** * Compile nested properties lines * - * @param \ScssPhp\ScssPhp\Block $block - * @param OutputBlock $out + * @param \ScssPhp\ScssPhp\Block $block + * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $out + * + * @return void */ protected function compileNestedPropertiesBlock(Block $block, OutputBlock $out) { @@ -1410,6 +1645,8 @@ class Compiler * * @param \ScssPhp\ScssPhp\Block $block * @param array $selectors + * + * @return void */ protected function compileNestedBlock(Block $block, $selectors) { @@ -1420,7 +1657,7 @@ class Compiler // wrap assign children in a block // except for @font-face - if ($block->type !== Type::T_DIRECTIVE || $block->name !== "font-face") { + if ($block->type !== Type::T_DIRECTIVE || $this->compileDirectiveName($block->name) !== 'font-face') { // need wrapping? $needWrapping = false; @@ -1432,7 +1669,7 @@ class Compiler } if ($needWrapping) { - $wrapped = new Block; + $wrapped = new Block(); $wrapped->sourceName = $block->sourceName; $wrapped->sourceIndex = $block->sourceIndex; $wrapped->sourceLine = $block->sourceLine; @@ -1471,6 +1708,8 @@ class Compiler * @see Compiler::compileChild() * * @param \ScssPhp\ScssPhp\Block $block + * + * @return void */ protected function compileBlock(Block $block) { @@ -1479,33 +1718,9 @@ class Compiler $out = $this->makeOutputBlock(null); - if (isset($this->lineNumberStyle) && count($env->selectors) && count($block->children)) { - $annotation = $this->makeOutputBlock(Type::T_COMMENT); - $annotation->depth = 0; - - $file = $this->sourceNames[$block->sourceIndex]; - $line = $block->sourceLine; - - switch ($this->lineNumberStyle) { - case static::LINE_COMMENTS: - $annotation->lines[] = '/* line ' . $line - . ($file ? ', ' . $file : '') - . ' */'; - break; - - case static::DEBUG_INFO: - $annotation->lines[] = '@media -sass-debug-info{' - . ($file ? 'filename{font-family:"' . $file . '"}' : '') - . 'line{font-family:' . $line . '}}'; - break; - } - - $this->scope->children[] = $annotation; - } - $this->scope->children[] = $out; - if (count($block->children)) { + if (\count($block->children)) { $out->selectors = $this->multiplySelectors($env, $block->selfParent); // propagate selfParent to the children where they still can be useful @@ -1534,7 +1749,7 @@ class Compiler * @param array $value * @param boolean $pushEnv * - * @return array|mixed|string + * @return string */ protected function compileCommentValue($value, $pushEnv = false) { @@ -1543,18 +1758,20 @@ class Compiler if (isset($value[2])) { if ($pushEnv) { $this->pushEnv(); - $storeEnv = $this->storeEnv; - $this->storeEnv = $this->env; } + $ignoreCallStackMessage = $this->ignoreCallStackMessage; + $this->ignoreCallStackMessage = true; + try { $c = $this->compileValue($value[2]); } catch (\Exception $e) { // ignore error in comment compilation which are only interpolation } + $this->ignoreCallStackMessage = $ignoreCallStackMessage; + if ($pushEnv) { - $this->storeEnv = $storeEnv; $this->popEnv(); } } @@ -1566,6 +1783,8 @@ class Compiler * Compile root level comment * * @param array $block + * + * @return void */ protected function compileComment($block) { @@ -1590,11 +1809,17 @@ class Compiler // after evaluating interpolates, we might need a second pass if ($this->shouldEvaluate) { - $selectors = $this->revertSelfSelector($selectors); + $selectors = $this->replaceSelfSelector($selectors, '&'); $buffer = $this->collapseSelectors($selectors); $parser = $this->parserFactory(__METHOD__); - if ($parser->parseSelector($buffer, $newSelectors)) { + try { + $isValid = $parser->parseSelector($buffer, $newSelectors, true); + } catch (ParserException $e) { + throw $this->error($e->getMessage()); + } + + if ($isValid) { $selectors = array_map([$this, 'evalSelector'], $newSelectors); } } @@ -1624,14 +1849,15 @@ class Compiler protected function evalSelectorPart($part) { foreach ($part as &$p) { - if (is_array($p) && ($p[0] === Type::T_INTERPOLATE || $p[0] === Type::T_STRING)) { + if (\is_array($p) && ($p[0] === Type::T_INTERPOLATE || $p[0] === Type::T_STRING)) { $p = $this->compileValue($p); - // force re-evaluation - if (strpos($p, '&') !== false || strpos($p, ',') !== false) { + // force re-evaluation if self char or non standard char + if (preg_match(',[^\w-],', $p)) { $this->shouldEvaluate = true; } - } elseif (is_string($p) && strlen($p) >= 2 && + } elseif ( + \is_string($p) && \strlen($p) >= 2 && ($first = $p[0]) && ($first === '"' || $first === "'") && substr($p, -1) === $first ) { @@ -1671,15 +1897,15 @@ class Compiler ); if ($selectorFormat && $this->isImmediateRelationshipCombinator($compound)) { - if (count($output)) { - $output[count($output) - 1] .= ' ' . $compound; + if (\count($output)) { + $output[\count($output) - 1] .= ' ' . $compound; } else { $output[] = $compound; } $glueNext = true; } elseif ($glueNext) { - $output[count($output) - 1] .= ' ' . $compound; + $output[\count($output) - 1] .= ' ' . $compound; $glueNext = false; } else { $output[] = $compound; @@ -1715,14 +1941,18 @@ class Compiler * * @return array */ - protected function revertSelfSelector($selectors) + protected function replaceSelfSelector($selectors, $replace = null) { foreach ($selectors as &$part) { - if (is_array($part)) { + if (\is_array($part)) { if ($part === [Type::T_SELF]) { - $part = '&'; + if (\is_null($replace)) { + $replace = $this->reduce([Type::T_SELF]); + $replace = $this->compileValue($replace); + } + $part = $replace; } else { - $part = $this->revertSelfSelector($part); + $part = $this->replaceSelfSelector($part, $replace); } } } @@ -1742,18 +1972,19 @@ class Compiler $joined = []; foreach ($single as $part) { - if (empty($joined) || - ! is_string($part) || + if ( + empty($joined) || + ! \is_string($part) || preg_match('/[\[.:#%]/', $part) ) { $joined[] = $part; continue; } - if (is_array(end($joined))) { + if (\is_array(end($joined))) { $joined[] = $part; } else { - $joined[count($joined) - 1] .= $part; + $joined[\count($joined) - 1] .= $part; } } @@ -1769,7 +2000,7 @@ class Compiler */ protected function compileSelector($selector) { - if (! is_array($selector)) { + if (! \is_array($selector)) { return $selector; // media and the like } @@ -1792,7 +2023,7 @@ class Compiler protected function compileSelectorPart($piece) { foreach ($piece as &$p) { - if (! is_array($p)) { + if (! \is_array($p)) { continue; } @@ -1819,13 +2050,13 @@ class Compiler */ protected function hasSelectorPlaceholder($selector) { - if (! is_array($selector)) { + if (! \is_array($selector)) { return false; } foreach ($selector as $parts) { foreach ($parts as $part) { - if (strlen($part) && '%' === $part[0]) { + if (\strlen($part) && '%' === $part[0]) { return true; } } @@ -1834,6 +2065,11 @@ class Compiler return false; } + /** + * @param string $name + * + * @return void + */ protected function pushCallStack($name = '') { $this->callStack[] = [ @@ -1844,15 +2080,18 @@ class Compiler ]; // infinite calling loop - if (count($this->callStack) > 25000) { + if (\count($this->callStack) > 25000) { // not displayed but you can var_dump it to deep debug $msg = $this->callStackMessage(true, 100); - $msg = "Infinite calling loop"; + $msg = 'Infinite calling loop'; - $this->throwError($msg); + throw $this->error($msg); } } + /** + * @return void + */ protected function popCallStack() { array_pop($this->callStack); @@ -1875,6 +2114,8 @@ class Compiler $ret = $this->compileChild($stm, $out); if (isset($ret)) { + $this->popCallStack(); + return $ret; } } @@ -1885,13 +2126,15 @@ class Compiler } /** - * Compile children and throw exception if unexpected @return + * Compile children and throw exception if unexpected `@return` * * @param array $stms * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $out * @param \ScssPhp\ScssPhp\Block $selfParent * @param string $traceName * + * @return void + * * @throws \Exception */ protected function compileChildrenNoReturn($stms, OutputBlock $out, $selfParent = null, $traceName = '') @@ -1899,11 +2142,11 @@ class Compiler $this->pushCallStack($traceName); foreach ($stms as $stm) { - if ($selfParent && isset($stm[1]) && is_object($stm[1]) && $stm[1] instanceof Block) { + if ($selfParent && isset($stm[1]) && \is_object($stm[1]) && $stm[1] instanceof Block) { $stm[1]->selfParent = $selfParent; $ret = $this->compileChild($stm, $out); $stm[1]->selfParent = null; - } elseif ($selfParent && in_array($stm[0], [TYPE::T_INCLUDE, TYPE::T_EXTEND])) { + } elseif ($selfParent && \in_array($stm[0], [TYPE::T_INCLUDE, TYPE::T_EXTEND])) { $stm['selfParent'] = $selfParent; $ret = $this->compileChild($stm, $out); unset($stm['selfParent']); @@ -1912,9 +2155,7 @@ class Compiler } if (isset($ret)) { - $this->throwError('@return may only be used within a function'); - - return; + throw $this->error('@return may only be used within a function'); } } @@ -1923,7 +2164,7 @@ class Compiler /** - * evaluate media query : compile internal value keeping the structure inchanged + * evaluate media query : compile internal value keeping the structure unchanged * * @param array $queryList * @@ -1939,12 +2180,13 @@ class Compiler $shouldReparse = false; foreach ($query as $kq => $q) { - for ($i = 1; $i < count($q); $i++) { + for ($i = 1; $i < \count($q); $i++) { $value = $this->compileValue($q[$i]); // the parser had no mean to know if media type or expression if it was an interpolation // so you need to reparse if the T_MEDIA_TYPE looks like anything else a media type - if ($q[0] == Type::T_MEDIA_TYPE && + if ( + $q[0] == Type::T_MEDIA_TYPE && (strpos($value, '(') !== false || strpos($value, ')') !== false || strpos($value, ':') !== false || @@ -1958,7 +2200,7 @@ class Compiler } if ($shouldReparse) { - if (is_null($parser)) { + if (\is_null($parser)) { $parser = $this->parserFactory(__METHOD__); } @@ -1972,7 +2214,7 @@ class Compiler if ($parser->parseMediaQueryList($queryString, $queries)) { $queries = $this->evaluateMediaQuery($queries[2]); - while (count($queries)) { + while (\count($queries)) { $outQueryList[] = array_shift($queries); } @@ -1999,7 +2241,7 @@ class Compiler $start = '@media '; $default = trim($start); $out = []; - $current = ""; + $current = ''; foreach ($queryList as $query) { $type = null; @@ -2017,17 +2259,17 @@ class Compiler foreach ($query as $q) { switch ($q[0]) { case Type::T_MEDIA_TYPE: - $newType = array_map([$this, 'compileValue'], array_slice($q, 1)); + $newType = array_map([$this, 'compileValue'], \array_slice($q, 1)); // combining not and anything else than media type is too risky and should be avoided if (! $mediaTypeOnly) { - if (in_array(Type::T_NOT, $newType) || ($type && in_array(Type::T_NOT, $type) )) { + if (\in_array(Type::T_NOT, $newType) || ($type && \in_array(Type::T_NOT, $type) )) { if ($type) { array_unshift($parts, implode(' ', array_filter($type))); } if (! empty($parts)) { - if (strlen($current)) { + if (\strlen($current)) { $current .= $this->formatter->tagSeparator; } @@ -2038,7 +2280,7 @@ class Compiler $out[] = $start . $current; } - $current = ""; + $current = ''; $type = null; $parts = []; } @@ -2090,7 +2332,7 @@ class Compiler } if (! empty($parts)) { - if (strlen($current)) { + if (\strlen($current)) { $current .= $this->formatter->tagSeparator; } @@ -2172,7 +2414,7 @@ class Compiler return $type1; } - if (count($type1) > 1) { + if (\count($type1) > 1) { $m1 = strtolower($type1[0]); $t1 = strtolower($type1[1]); } else { @@ -2180,7 +2422,7 @@ class Compiler $t1 = strtolower($type1[0]); } - if (count($type2) > 1) { + if (\count($type2) > 1) { $m2 = strtolower($type2[0]); $t2 = strtolower($type2[1]); } else { @@ -2213,7 +2455,7 @@ class Compiler } // t1 == t2, neither m1 nor m2 are "not" - return [empty($m1)? $m2 : $m1, $t1]; + return [empty($m1) ? $m2 : $m1, $t1]; } /** @@ -2230,8 +2472,8 @@ class Compiler if ($rawPath[0] === Type::T_STRING) { $path = $this->compileStringContent($rawPath); - if ($path = $this->findImport($path)) { - if (! $once || ! in_array($path, $this->importedFiles)) { + if (strpos($path, 'url(') !== 0 && $path = $this->findImport($path)) { + if (! $once || ! \in_array($path, $this->importedFiles)) { $this->importFile($path, $out); $this->importedFiles[] = $path; } @@ -2239,20 +2481,20 @@ class Compiler return true; } - $this->appendRootDirective('@import ' . $this->compileValue($rawPath). ';', $out); + $this->appendRootDirective('@import ' . $this->compileImportPath($rawPath) . ';', $out); return false; } if ($rawPath[0] === Type::T_LIST) { // handle a list of strings - if (count($rawPath[2]) === 0) { + if (\count($rawPath[2]) === 0) { return false; } foreach ($rawPath[2] as $path) { if ($path[0] !== Type::T_STRING) { - $this->appendRootDirective('@import ' . $this->compileValue($rawPath) . ';', $out); + $this->appendRootDirective('@import ' . $this->compileImportPath($rawPath) . ';', $out); return false; } @@ -2265,19 +2507,68 @@ class Compiler return true; } - $this->appendRootDirective('@import ' . $this->compileValue($rawPath) . ';', $out); + $this->appendRootDirective('@import ' . $this->compileImportPath($rawPath) . ';', $out); return false; } + /** + * @param array $rawPath + * @return string + * @throws CompilerException + */ + protected function compileImportPath($rawPath) + { + $path = $this->compileValue($rawPath); + + // case url() without quotes : suppress \r \n remaining in the path + // if this is a real string there can not be CR or LF char + if (strpos($path, 'url(') === 0) { + $path = str_replace(array("\r", "\n"), array('', ' '), $path); + } else { + // if this is a file name in a string, spaces should be escaped + $path = $this->reduce($rawPath); + $path = $this->escapeImportPathString($path); + $path = $this->compileValue($path); + } + + return $path; + } + + /** + * @param array $path + * @return array + * @throws CompilerException + */ + protected function escapeImportPathString($path) + { + switch ($path[0]) { + case Type::T_LIST: + foreach ($path[2] as $k => $v) { + $path[2][$k] = $this->escapeImportPathString($v); + } + break; + case Type::T_STRING: + if ($path[1]) { + $path = $this->compileValue($path); + $path = str_replace(' ', '\\ ', $path); + $path = [Type::T_KEYWORD, $path]; + } + break; + } + + return $path; + } /** * Append a root directive like @import or @charset as near as the possible from the source code * (keeping before comments, @import and @charset coming before in the source code) * - * @param string $line - * @param @param \ScssPhp\ScssPhp\Formatter\OutputBlock $out - * @param array $allowed + * @param string $line + * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $out + * @param array $allowed + * + * @return void */ protected function appendRootDirective($line, $out, $allowed = [Type::T_COMMENT]) { @@ -2289,8 +2580,8 @@ class Compiler $i = 0; - while ($i < count($root->children)) { - if (! isset($root->children[$i]->type) || ! in_array($root->children[$i]->type, $allowed)) { + while ($i < \count($root->children)) { + if (! isset($root->children[$i]->type) || ! \in_array($root->children[$i]->type, $allowed)) { break; } @@ -2300,7 +2591,7 @@ class Compiler // remove incompatible children from the bottom of the list $saveChildren = []; - while ($i < count($root->children)) { + while ($i < \count($root->children)) { $saveChildren[] = array_pop($root->children); } @@ -2314,7 +2605,7 @@ class Compiler $root->children[] = $child; // repush children - while (count($saveChildren)) { + while (\count($saveChildren)) { $root->children[] = array_pop($saveChildren); } } @@ -2326,24 +2617,22 @@ class Compiler * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $out * @param string $type * @param string|mixed $line + * + * @return void */ protected function appendOutputLine(OutputBlock $out, $type, $line) { $outWrite = &$out; - if ($type === Type::T_COMMENT) { - $parent = $out->parent; - - if (end($parent->children) !== $out) { - $outWrite = &$parent->children[count($parent->children) - 1]; - } - } - // check if it's a flat output or not - if (count($out->children)) { - $lastChild = &$out->children[count($out->children) - 1]; + if (\count($out->children)) { + $lastChild = &$out->children[\count($out->children) - 1]; - if ($lastChild->depth === $out->depth && is_null($lastChild->selectors) && ! count($lastChild->children)) { + if ( + $lastChild->depth === $out->depth && + \is_null($lastChild->selectors) && + ! \count($lastChild->children) + ) { $outWrite = $lastChild; } else { $nextLines = $this->makeOutputBlock($type); @@ -2364,7 +2653,7 @@ class Compiler * @param array $child * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $out * - * @return array + * @return array|Number|null */ protected function compileChild($child, OutputBlock $out) { @@ -2372,7 +2661,7 @@ class Compiler $this->sourceIndex = isset($child[Parser::SOURCE_INDEX]) ? $child[Parser::SOURCE_INDEX] : null; $this->sourceLine = isset($child[Parser::SOURCE_LINE]) ? $child[Parser::SOURCE_LINE] : -1; $this->sourceColumn = isset($child[Parser::SOURCE_COLUMN]) ? $child[Parser::SOURCE_COLUMN] : -1; - } elseif (is_array($child) && isset($child[1]->sourceLine)) { + } elseif (\is_array($child) && isset($child[1]->sourceLine)) { $this->sourceIndex = $child[1]->sourceIndex; $this->sourceLine = $child[1]->sourceLine; $this->sourceColumn = $child[1]->sourceColumn; @@ -2422,13 +2711,37 @@ class Compiler } break; + case Type::T_CUSTOM_PROPERTY: + list(, $name, $value) = $child; + $compiledName = $this->compileValue($name); + + // if the value reduces to null from something else then + // the property should be discarded + if ($value[0] !== Type::T_NULL) { + $value = $this->reduce($value); + + if ($value[0] === Type::T_NULL || $value === static::$nullString) { + break; + } + } + + $compiledValue = $this->compileValue($value); + + $line = $this->formatter->customProperty( + $compiledName, + $compiledValue + ); + + $this->appendOutputLine($out, Type::T_ASSIGN, $line); + break; + case Type::T_ASSIGN: list(, $name, $value) = $child; if ($name[0] === Type::T_VARIABLE) { $flags = isset($child[3]) ? $child[3] : []; - $isDefault = in_array('!default', $flags); - $isGlobal = in_array('!global', $flags); + $isDefault = \in_array('!default', $flags); + $isGlobal = \in_array('!global', $flags); if ($isGlobal) { $this->set($name[1], $this->reduce($value), false, $this->rootEnv, $value); @@ -2436,7 +2749,7 @@ class Compiler } $shouldSet = $isDefault && - (is_null($result = $this->get($name[1], false)) || + (\is_null($result = $this->get($name[1], false)) || $result === static::$null); if (! $isDefault || $shouldSet) { @@ -2448,7 +2761,7 @@ class Compiler $compiledName = $this->compileValue($name); // handle shorthand syntaxes : size / line-height... - if (in_array($compiledName, ['font', 'grid-row', 'grid-column', 'border-radius'])) { + if (\in_array($compiledName, ['font', 'grid-row', 'grid-column', 'border-radius'])) { if ($value[0] === Type::T_VARIABLE) { // if the font value comes from variable, the content is already reduced // (i.e., formulas were already calculated), so we need the original unreduced value @@ -2468,7 +2781,7 @@ class Compiler break; } - if ($compiledName === 'font' and $value[0] === Type::T_LIST && $value[1]==',') { + if ($compiledName === 'font' && $value[0] === Type::T_LIST && $value[1] === ',') { // this is the case if more than one font is given: example: "font: 400 1em/1.3 arial,helvetica" // we need to handle the first list element $shorthandValue=&$value[2][0]; @@ -2480,11 +2793,11 @@ class Compiler if ($shorthandDividerNeedsUnit) { $divider = $shorthandValue[3]; - if (is_array($divider)) { + if (\is_array($divider)) { $divider = $this->reduce($divider, true); } - if (intval($divider->dimension) and !count($divider->units)) { + if ($divider instanceof Number && \intval($divider->getDimension()) && $divider->unitless()) { $revert = false; } } @@ -2497,17 +2810,18 @@ class Compiler if ($item[0] === Type::T_EXPRESSION && $item[1] === '/') { if ($maxShorthandDividers > 0) { $revert = true; + // if the list of values is too long, this has to be a shorthand, // otherwise it could be a real division - if (is_null($maxListElements) or count($shorthandValue[2]) <= $maxListElements) { + if (\is_null($maxListElements) || \count($shorthandValue[2]) <= $maxListElements) { if ($shorthandDividerNeedsUnit) { $divider = $item[3]; - if (is_array($divider)) { + if (\is_array($divider)) { $divider = $this->reduce($divider, true); } - if (intval($divider->dimension) and !count($divider->units)) { + if ($divider instanceof Number && \intval($divider->getDimension()) && $divider->unitless()) { $revert = false; } } @@ -2535,11 +2849,14 @@ class Compiler $compiledValue = $this->compileValue($value); - $line = $this->formatter->property( - $compiledName, - $compiledValue - ); - $this->appendOutputLine($out, Type::T_ASSIGN, $line); + // ignore empty value + if (\strlen($compiledValue)) { + $line = $this->formatter->property( + $compiledName, + $compiledValue + ); + $this->appendOutputLine($out, Type::T_ASSIGN, $line); + } break; case Type::T_COMMENT: @@ -2562,6 +2879,7 @@ class Compiler case Type::T_EXTEND: foreach ($child[1] as $sel) { + $sel = $this->replaceSelfSelector($sel); $results = $this->evalSelectors([$sel]); foreach ($results as $result) { @@ -2586,7 +2904,8 @@ class Compiler } foreach ($if->cases as $case) { - if ($case->type === Type::T_ELSE || + if ( + $case->type === Type::T_ELSE || $case->type === Type::T_ELSEIF && $this->isTruthy($this->reduce($case->cond)) ) { return $this->compileChildren($case->children, $out); @@ -2597,12 +2916,12 @@ class Compiler case Type::T_EACH: list(, $each) = $child; - $list = $this->coerceList($this->reduce($each->list)); + $list = $this->coerceList($this->reduce($each->list), ',', true); $this->pushEnv(); foreach ($list[2] as $item) { - if (count($each->vars) === 1) { + if (\count($each->vars) === 1) { $this->set($each->vars[0], $item, true); } else { list(,, $values) = $this->coerceList($item); @@ -2615,19 +2934,17 @@ class Compiler $ret = $this->compileChildren($each->children, $out); if ($ret) { - if ($ret[0] !== Type::T_CONTROL) { - $this->popEnv(); + $store = $this->env->store; + $this->popEnv(); + $this->backPropagateEnv($store, $each->vars); - return $ret; - } - - if ($ret[1]) { - break; - } + return $ret; } } - + $store = $this->env->store; $this->popEnv(); + $this->backPropagateEnv($store, $each->vars); + break; case Type::T_WHILE: @@ -2637,13 +2954,7 @@ class Compiler $ret = $this->compileChildren($while->children, $out); if ($ret) { - if ($ret[0] !== Type::T_CONTROL) { - return $ret; - } - - if ($ret[1]) { - break; - } + return $ret; } } break; @@ -2654,48 +2965,54 @@ class Compiler $start = $this->reduce($for->start, true); $end = $this->reduce($for->end, true); - if (! ($start[2] == $end[2] || $end->unitless())) { - $this->throwError('Incompatible units: "%s" and "%s".', $start->unitStr(), $end->unitStr()); - - break; + if (! $start instanceof Number) { + throw $this->error('%s is not a number', $start[0]); } - $unit = $start[2]; - $start = $start[1]; - $end = $end[1]; + if (! $end instanceof Number) { + throw $this->error('%s is not a number', $end[0]); + } + + $start->assertSameUnitOrUnitless($end); + + $numeratorUnits = $start->getNumeratorUnits(); + $denominatorUnits = $start->getDenominatorUnits(); + + $start = $start->getDimension(); + $end = $end->getDimension(); $d = $start < $end ? 1 : -1; + $this->pushEnv(); + for (;;) { - if ((! $for->until && $start - $d == $end) || + if ( + (! $for->until && $start - $d == $end) || ($for->until && $start == $end) ) { break; } - $this->set($for->var, new Node\Number($start, $unit)); + $this->set($for->var, new Number($start, $numeratorUnits, $denominatorUnits)); $start += $d; $ret = $this->compileChildren($for->children, $out); if ($ret) { - if ($ret[0] !== Type::T_CONTROL) { - return $ret; - } + $store = $this->env->store; + $this->popEnv(); + $this->backPropagateEnv($store, [$for->var]); - if ($ret[1]) { - break; - } + return $ret; } } + + $store = $this->env->store; + $this->popEnv(); + $this->backPropagateEnv($store, [$for->var]); + break; - case Type::T_BREAK: - return [Type::T_CONTROL, true]; - - case Type::T_CONTINUE: - return [Type::T_CONTROL, false]; - case Type::T_RETURN: return $this->reduce($child[1], true); @@ -2710,8 +3027,7 @@ class Compiler $mixin = $this->get(static::$namespaces['mixin'] . $name, false); if (! $mixin) { - $this->throwError("Undefined mixin $name"); - break; + throw $this->error("Undefined mixin $name"); } $callingScope = $this->getStoreEnv(); @@ -2720,9 +3036,6 @@ class Compiler $this->pushEnv(); $this->env->depth--; - $storeEnv = $this->storeEnv; - $this->storeEnv = $this->env; - // Find the parent selectors in the env to be able to know what '&' refers to in the mixin // and assign this fake parent to childs $selfParent = null; @@ -2737,7 +3050,7 @@ class Compiler $parent->selectors = $parentSelectors; foreach ($mixin->children as $k => $child) { - if (isset($child[1]) && is_object($child[1]) && $child[1] instanceof Block) { + if (isset($child[1]) && \is_object($child[1]) && $child[1] instanceof Block) { $mixin->children[$k][1]->parent = $parent; } } @@ -2771,12 +3084,10 @@ class Compiler if (! empty($mixin->parentEnv)) { $this->env->declarationScopeParent = $mixin->parentEnv; } else { - $this->throwError("@mixin $name() without parentEnv"); + throw $this->error("@mixin $name() without parentEnv"); } - $this->compileChildrenNoReturn($mixin->children, $out, $selfParent, $this->env->marker . " " . $name); - - $this->storeEnv = $storeEnv; + $this->compileChildrenNoReturn($mixin->children, $out, $selfParent, $this->env->marker . ' ' . $name); $this->popEnv(); break; @@ -2788,9 +3099,6 @@ class Compiler $argContent = $child[1]; if (! $content) { - $content = new \stdClass(); - $content->scope = new \stdClass(); - $content->children = $env->parent->block->children; break; } @@ -2799,7 +3107,7 @@ class Compiler if (isset($argUsing) && isset($argContent)) { // Get the arguments provided for the content with the names provided in the "using" argument list - $this->storeEnv = $this->env; + $this->storeEnv = null; $varsUsing = $this->applyArguments($argUsing, $argContent, false); } @@ -2819,39 +3127,34 @@ class Compiler case Type::T_DEBUG: list(, $value) = $child; - $fname = $this->sourceNames[$this->sourceIndex]; + $fname = $this->getPrettyPath($this->sourceNames[$this->sourceIndex]); $line = $this->sourceLine; - $value = $this->compileValue($this->reduce($value, true)); + $value = $this->compileDebugValue($value); - fwrite($this->stderr, "File $fname on line $line DEBUG: $value\n"); + fwrite($this->stderr, "$fname:$line DEBUG: $value\n"); break; case Type::T_WARN: list(, $value) = $child; - $fname = $this->sourceNames[$this->sourceIndex]; + $fname = $this->getPrettyPath($this->sourceNames[$this->sourceIndex]); $line = $this->sourceLine; - $value = $this->compileValue($this->reduce($value, true)); + $value = $this->compileDebugValue($value); - fwrite($this->stderr, "File $fname on line $line WARN: $value\n"); + fwrite($this->stderr, "WARNING: $value\n on line $line of $fname\n\n"); break; case Type::T_ERROR: list(, $value) = $child; - $fname = $this->sourceNames[$this->sourceIndex]; + $fname = $this->getPrettyPath($this->sourceNames[$this->sourceIndex]); $line = $this->sourceLine; $value = $this->compileValue($this->reduce($value, true)); - $this->throwError("File $fname on line $line ERROR: $value\n"); - break; - - case Type::T_CONTROL: - $this->throwError('@break/@continue not permitted in this scope'); - break; + throw $this->error("File $fname on line $line ERROR: $value\n"); default: - $this->throwError("unknown child type: $child[0]"); + throw $this->error("unknown child type: $child[0]"); } } @@ -2859,14 +3162,21 @@ class Compiler * Reduce expression to string * * @param array $exp + * @param bool $keepParens * * @return array */ - protected function expToString($exp) + protected function expToString($exp, $keepParens = false) { - list(, $op, $left, $right, /* $inParens */, $whiteLeft, $whiteRight) = $exp; + list(, $op, $left, $right, $inParens, $whiteLeft, $whiteRight) = $exp; - $content = [$this->reduce($left)]; + $content = []; + + if ($keepParens && $inParens) { + $content[] = '('; + } + + $content[] = $this->reduce($left); if ($whiteLeft) { $content[] = ' '; @@ -2880,13 +3190,17 @@ class Compiler $content[] = $this->reduce($right); + if ($keepParens && $inParens) { + $content[] = ')'; + } + return [Type::T_STRING, '', $content]; } /** * Is truthy? * - * @param array $value + * @param array|Number $value * * @return boolean */ @@ -2934,14 +3248,14 @@ class Compiler /** * Reduce value * - * @param array $value + * @param array|Number $value * @param boolean $inExp * - * @return null|string|array|\ScssPhp\ScssPhp\Node\Number + * @return null|string|array|Number */ protected function reduce($value, $inExp = false) { - if (is_null($value)) { + if (\is_null($value)) { return null; } @@ -2959,8 +3273,9 @@ class Compiler } // special case: looks like css shorthand - if ($opName == 'div' && ! $inParens && ! $inExp && isset($right[2]) && - (($right[0] !== Type::T_NUMBER && $right[2] != '') || + if ( + $opName == 'div' && ! $inParens && ! $inExp && + (($right[0] !== Type::T_NUMBER && isset($right[2]) && $right[2] != '') || ($right[0] === Type::T_NUMBER && ! $right->unitless())) ) { return $this->expToString($value); @@ -2981,52 +3296,15 @@ class Compiler // 3. op[op name] $fn = "op${ucOpName}${ucLType}${ucRType}"; - if (is_callable([$this, $fn]) || + if ( + \is_callable([$this, $fn]) || (($fn = "op${ucLType}${ucRType}") && - is_callable([$this, $fn]) && + \is_callable([$this, $fn]) && $passOp = true) || (($fn = "op${ucOpName}") && - is_callable([$this, $fn]) && + \is_callable([$this, $fn]) && $genOp = true) ) { - $coerceUnit = false; - - if (! isset($genOp) && - $left[0] === Type::T_NUMBER && $right[0] === Type::T_NUMBER - ) { - $coerceUnit = true; - - switch ($opName) { - case 'mul': - $targetUnit = $left[2]; - - foreach ($right[2] as $unit => $exp) { - $targetUnit[$unit] = (isset($targetUnit[$unit]) ? $targetUnit[$unit] : 0) + $exp; - } - break; - - case 'div': - $targetUnit = $left[2]; - - foreach ($right[2] as $unit => $exp) { - $targetUnit[$unit] = (isset($targetUnit[$unit]) ? $targetUnit[$unit] : 0) - $exp; - } - break; - - case 'mod': - $targetUnit = $left[2]; - break; - - default: - $targetUnit = $left->unitless() ? $right[2] : $left[2]; - } - - if (! $left->unitless() && ! $right->unitless()) { - $left = $left->normalize(); - $right = $right->normalize(); - } - } - $shouldEval = $inParens || $inExp; if (isset($passOp)) { @@ -3036,10 +3314,6 @@ class Compiler } if (isset($out)) { - if ($coerceUnit && $out[0] === Type::T_NUMBER) { - $out = $out->coerce($targetUnit); - } - return $out; } } @@ -3052,13 +3326,13 @@ class Compiler $inExp = $inExp || $this->shouldEval($exp); $exp = $this->reduce($exp); - if ($exp[0] === Type::T_NUMBER) { + if ($exp instanceof Number) { switch ($op) { case '+': - return new Node\Number($exp[1], $exp[2]); + return $exp; case '-': - return new Node\Number(-$exp[1], $exp[2]); + return $exp->unaryMinus(); } } @@ -3099,7 +3373,7 @@ class Compiler case Type::T_STRING: foreach ($value[2] as &$item) { - if (is_array($item) || $item instanceof \ArrayAccess) { + if (\is_array($item) || $item instanceof \ArrayAccess) { $item = $this->reduce($item); } } @@ -3119,7 +3393,8 @@ class Compiler return $this->fncall($value[1], $value[2]); case Type::T_SELF: - $selfSelector = $this->multiplySelectors($this->env); + $selfParent = ! empty($this->env->block->selfParent) ? $this->env->block->selfParent : null; + $selfSelector = $this->multiplySelectors($this->env, $selfParent); $selfSelector = $this->collapseSelectors($selfSelector, true); return $selfSelector; @@ -3135,32 +3410,237 @@ class Compiler * @param string $name * @param array $argValues * - * @return array|null + * @return array|Number */ - protected function fncall($name, $argValues) + protected function fncall($functionReference, $argValues) { - // SCSS @function - if ($this->callScssFunction($name, $argValues, $returnValue)) { - return $returnValue; - } + // a string means this is a static hard reference coming from the parsing + if (is_string($functionReference)) { + $name = $functionReference; - // native PHP functions - if ($this->callNativeFunction($name, $argValues, $returnValue)) { - return $returnValue; - } - - // for CSS functions, simply flatten the arguments into a list - $listArgs = []; - - foreach ((array) $argValues as $arg) { - if (empty($arg[0])) { - $listArgs[] = $this->reduce($arg[1]); + $functionReference = $this->getFunctionReference($name); + if ($functionReference === static::$null || $functionReference[0] !== Type::T_FUNCTION_REFERENCE) { + $functionReference = [Type::T_FUNCTION, $name, [Type::T_LIST, ',', []]]; } } - return [Type::T_FUNCTION, $name, [Type::T_LIST, ',', $listArgs]]; + // a function type means we just want a plain css function call + if ($functionReference[0] === Type::T_FUNCTION) { + // for CSS functions, simply flatten the arguments into a list + $listArgs = []; + + foreach ((array) $argValues as $arg) { + if (empty($arg[0]) || count($argValues) === 1) { + $listArgs[] = $this->reduce($this->stringifyFncallArgs($arg[1])); + } + } + + return [Type::T_FUNCTION, $functionReference[1], [Type::T_LIST, ',', $listArgs]]; + } + + if ($functionReference === static::$null || $functionReference[0] !== Type::T_FUNCTION_REFERENCE) { + return static::$defaultValue; + } + + + switch ($functionReference[1]) { + // SCSS @function + case 'scss': + return $this->callScssFunction($functionReference[3], $argValues); + + // native PHP functions + case 'user': + case 'native': + list(,,$name, $fn, $prototype) = $functionReference; + + // special cases of css valid functions min/max + $name = strtolower($name); + if (\in_array($name, ['min', 'max'])) { + $cssFunction = $this->cssValidArg( + [Type::T_FUNCTION_CALL, $name, $argValues], + ['min', 'max', 'calc', 'env', 'var'] + ); + if ($cssFunction !== false) { + return $cssFunction; + } + } + $returnValue = $this->callNativeFunction($name, $fn, $prototype, $argValues); + + if (! isset($returnValue)) { + return $this->fncall([Type::T_FUNCTION, $name, [Type::T_LIST, ',', []]], $argValues); + } + + return $returnValue; + + default: + return static::$defaultValue; + } } + protected function cssValidArg($arg, $allowed_function = [], $inFunction = false) + { + switch ($arg[0]) { + case Type::T_INTERPOLATE: + return [Type::T_KEYWORD, $this->CompileValue($arg)]; + + case Type::T_FUNCTION: + if (! \in_array($arg[1], $allowed_function)) { + return false; + } + if ($arg[2][0] === Type::T_LIST) { + foreach ($arg[2][2] as $k => $subarg) { + $arg[2][2][$k] = $this->cssValidArg($subarg, $allowed_function, $arg[1]); + if ($arg[2][2][$k] === false) { + return false; + } + } + } + return $arg; + + case Type::T_FUNCTION_CALL: + if (! \in_array($arg[1], $allowed_function)) { + return false; + } + $cssArgs = []; + foreach ($arg[2] as $argValue) { + if ($argValue === static::$null) { + return false; + } + $cssArg = $this->cssValidArg($argValue[1], $allowed_function, $arg[1]); + if (empty($argValue[0]) && $cssArg !== false) { + $cssArgs[] = [$argValue[0], $cssArg]; + } else { + return false; + } + } + + return $this->fncall([Type::T_FUNCTION, $arg[1], [Type::T_LIST, ',', []]], $cssArgs); + + case Type::T_STRING: + case Type::T_KEYWORD: + if (!$inFunction or !\in_array($inFunction, ['calc', 'env', 'var'])) { + return false; + } + return $this->stringifyFncallArgs($arg); + + case Type::T_NUMBER: + return $this->stringifyFncallArgs($arg); + + case Type::T_LIST: + if (!$inFunction) { + return false; + } + if (empty($arg['enclosing']) and $arg[1] === '') { + foreach ($arg[2] as $k => $subarg) { + $arg[2][$k] = $this->cssValidArg($subarg, $allowed_function, $inFunction); + if ($arg[2][$k] === false) { + return false; + } + } + $arg[0] = Type::T_STRING; + return $arg; + } + return false; + + case Type::T_EXPRESSION: + if (! \in_array($arg[1], ['+', '-', '/', '*'])) { + return false; + } + $arg[2] = $this->cssValidArg($arg[2], $allowed_function, $inFunction); + $arg[3] = $this->cssValidArg($arg[3], $allowed_function, $inFunction); + if ($arg[2] === false || $arg[3] === false) { + return false; + } + return $this->expToString($arg, true); + + case Type::T_VARIABLE: + case Type::T_SELF: + default: + return false; + } + } + + + /** + * Reformat fncall arguments to proper css function output + * + * @param $arg + * + * @return array|\ArrayAccess|Number|string|null + */ + protected function stringifyFncallArgs($arg) + { + + switch ($arg[0]) { + case Type::T_LIST: + foreach ($arg[2] as $k => $v) { + $arg[2][$k] = $this->stringifyFncallArgs($v); + } + break; + + case Type::T_EXPRESSION: + if ($arg[1] === '/') { + $arg[2] = $this->stringifyFncallArgs($arg[2]); + $arg[3] = $this->stringifyFncallArgs($arg[3]); + $arg[5] = $arg[6] = false; // no space around / + $arg = $this->expToString($arg); + } + break; + + case Type::T_FUNCTION_CALL: + $name = strtolower($arg[1]); + + if (in_array($name, ['max', 'min', 'calc'])) { + $args = $arg[2]; + $arg = $this->fncall([Type::T_FUNCTION, $name, [Type::T_LIST, ',', []]], $args); + } + break; + } + + return $arg; + } + + /** + * Find a function reference + * @param string $name + * @param bool $safeCopy + * @return array + */ + protected function getFunctionReference($name, $safeCopy = false) + { + // SCSS @function + if ($func = $this->get(static::$namespaces['function'] . $name, false)) { + if ($safeCopy) { + $func = clone $func; + } + + return [Type::T_FUNCTION_REFERENCE, 'scss', $name, $func]; + } + + // native PHP functions + + // try to find a native lib function + $normalizedName = $this->normalizeName($name); + $libName = null; + + if (isset($this->userFunctions[$normalizedName])) { + // see if we can find a user function + list($f, $prototype) = $this->userFunctions[$normalizedName]; + + return [Type::T_FUNCTION_REFERENCE, 'user', $name, $f, $prototype]; + } + + if (($f = $this->getBuiltinFunction($normalizedName)) && \is_callable($f)) { + $libName = $f[1]; + $prototype = isset(static::$$libName) ? static::$$libName : null; + + return [Type::T_FUNCTION_REFERENCE, 'native', $name, $f, $prototype]; + } + + return static::$null; + } + + /** * Normalize name * @@ -3176,9 +3656,9 @@ class Compiler /** * Normalize value * - * @param array $value + * @param array|Number $value * - * @return array + * @return array|Number */ public function normalizeValue($value) { @@ -3205,9 +3685,6 @@ class Compiler case Type::T_STRING: return [$value[0], '"', [$this->compileStringContent($value)]]; - case Type::T_NUMBER: - return $value->normalize(); - case Type::T_INTERPOLATE: return [Type::T_KEYWORD, $this->compileValue($value)]; @@ -3219,70 +3696,66 @@ class Compiler /** * Add numbers * - * @param array $left - * @param array $right + * @param Number $left + * @param Number $right * - * @return \ScssPhp\ScssPhp\Node\Number + * @return Number */ - protected function opAddNumberNumber($left, $right) + protected function opAddNumberNumber(Number $left, Number $right) { - return new Node\Number($left[1] + $right[1], $left[2]); + return $left->plus($right); } /** * Multiply numbers * - * @param array $left - * @param array $right + * @param Number $left + * @param Number $right * - * @return \ScssPhp\ScssPhp\Node\Number + * @return Number */ - protected function opMulNumberNumber($left, $right) + protected function opMulNumberNumber(Number $left, Number $right) { - return new Node\Number($left[1] * $right[1], $left[2]); + return $left->times($right); } /** * Subtract numbers * - * @param array $left - * @param array $right + * @param Number $left + * @param Number $right * - * @return \ScssPhp\ScssPhp\Node\Number + * @return Number */ - protected function opSubNumberNumber($left, $right) + protected function opSubNumberNumber(Number $left, Number $right) { - return new Node\Number($left[1] - $right[1], $left[2]); + return $left->minus($right); } /** * Divide numbers * - * @param array $left - * @param array $right + * @param Number $left + * @param Number $right * - * @return array|\ScssPhp\ScssPhp\Node\Number + * @return Number */ - protected function opDivNumberNumber($left, $right) + protected function opDivNumberNumber(Number $left, Number $right) { - if ($right[1] == 0) { - return [Type::T_STRING, '', [$left[1] . $left[2] . '/' . $right[1] . $right[2]]]; - } - - return new Node\Number($left[1] / $right[1], $left[2]); + return $left->dividedBy($right); } /** * Mod numbers * - * @param array $left - * @param array $right + * @param Number $left + * @param Number $right * - * @return \ScssPhp\ScssPhp\Node\Number + * @return Number */ - protected function opModNumberNumber($left, $right) + protected function opModNumberNumber(Number $left, Number $right) { - return new Node\Number($left[1] % $right[1], $left[2]); + return $left->modulo($right); } /** @@ -3321,11 +3794,11 @@ class Compiler /** * Boolean and * - * @param array $left - * @param array $right + * @param array|Number $left + * @param array|Number $right * @param boolean $shouldEval * - * @return array|null + * @return array|Number|null */ protected function opAnd($left, $right, $shouldEval) { @@ -3349,11 +3822,11 @@ class Compiler /** * Boolean or * - * @param array $left - * @param array $right + * @param array|Number $left + * @param array|Number $right * @param boolean $shouldEval * - * @return array|null + * @return array|Number|null */ protected function opOr($left, $right, $shouldEval) { @@ -3385,6 +3858,15 @@ class Compiler */ protected function opColorColor($op, $left, $right) { + if ($op !== '==' && $op !== '!=') { + $warning = "Color arithmetic is deprecated and will be an error in future versions.\n" + . "Consider using Sass's color functions instead."; + $fname = $this->getPrettyPath($this->sourceNames[$this->sourceIndex]); + $line = $this->sourceLine; + + fwrite($this->stderr, "DEPRECATION WARNING: $warning\n on line $line of $fname\n\n"); + } + $out = [Type::T_COLOR]; foreach ([1, 2, 3] as $i) { @@ -3405,13 +3887,16 @@ class Compiler break; case '%': + if ($rval == 0) { + throw $this->error("color: Can't take modulo by zero"); + } + $out[] = $lval % $rval; break; case '/': if ($rval == 0) { - $this->throwError("color: Can't divide by zero"); - break 2; + throw $this->error("color: Can't divide by zero"); } $out[] = (int) ($lval / $rval); @@ -3424,8 +3909,7 @@ class Compiler return $this->opNeq($left, $right); default: - $this->throwError("color: unknown op $op"); - break 2; + throw $this->error("color: unknown op $op"); } } @@ -3443,13 +3927,21 @@ class Compiler * * @param string $op * @param array $left - * @param array $right + * @param Number $right * * @return array */ - protected function opColorNumber($op, $left, $right) + protected function opColorNumber($op, $left, Number $right) { - $value = $right[1]; + if ($op === '==') { + return static::$false; + } + + if ($op === '!=') { + return static::$true; + } + + $value = $right->getDimension(); return $this->opColorColor( $op, @@ -3462,14 +3954,22 @@ class Compiler * Compare number and color * * @param string $op - * @param array $left + * @param Number $left * @param array $right * * @return array */ - protected function opNumberColor($op, $left, $right) + protected function opNumberColor($op, Number $left, $right) { - $value = $left[1]; + if ($op === '==') { + return static::$false; + } + + if ($op === '!=') { + return static::$true; + } + + $value = $left->getDimension(); return $this->opColorColor( $op, @@ -3481,8 +3981,8 @@ class Compiler /** * Compare number1 == number2 * - * @param array $left - * @param array $right + * @param array|Number $left + * @param array|Number $right * * @return array */ @@ -3502,8 +4002,8 @@ class Compiler /** * Compare number1 != number2 * - * @param array $left - * @param array $right + * @param array|Number $left + * @param array|Number $right * * @return array */ @@ -3521,70 +4021,81 @@ class Compiler } /** - * Compare number1 >= number2 + * Compare number1 == number2 * - * @param array $left - * @param array $right + * @param Number $left + * @param Number $right * * @return array */ - protected function opGteNumberNumber($left, $right) + protected function opEqNumberNumber(Number $left, Number $right) { - return $this->toBool($left[1] >= $right[1]); + return $this->toBool($left->equals($right)); + } + + /** + * Compare number1 != number2 + * + * @param Number $left + * @param Number $right + * + * @return array + */ + protected function opNeqNumberNumber(Number $left, Number $right) + { + return $this->toBool(!$left->equals($right)); + } + + /** + * Compare number1 >= number2 + * + * @param Number $left + * @param Number $right + * + * @return array + */ + protected function opGteNumberNumber(Number $left, Number $right) + { + return $this->toBool($left->greaterThanOrEqual($right)); } /** * Compare number1 > number2 * - * @param array $left - * @param array $right + * @param Number $left + * @param Number $right * * @return array */ - protected function opGtNumberNumber($left, $right) + protected function opGtNumberNumber(Number $left, Number $right) { - return $this->toBool($left[1] > $right[1]); + return $this->toBool($left->greaterThan($right)); } /** * Compare number1 <= number2 * - * @param array $left - * @param array $right + * @param Number $left + * @param Number $right * * @return array */ - protected function opLteNumberNumber($left, $right) + protected function opLteNumberNumber(Number $left, Number $right) { - return $this->toBool($left[1] <= $right[1]); + return $this->toBool($left->lessThanOrEqual($right)); } /** * Compare number1 < number2 * - * @param array $left - * @param array $right + * @param Number $left + * @param Number $right * * @return array */ - protected function opLtNumberNumber($left, $right) + protected function opLtNumberNumber(Number $left, Number $right) { - return $this->toBool($left[1] < $right[1]); - } - - /** - * Three-way comparison, aka spaceship operator - * - * @param array $left - * @param array $right - * - * @return \ScssPhp\ScssPhp\Node\Number - */ - protected function opCmpNumberNumber($left, $right) - { - $n = $left[1] - $right[1]; - - return new Node\Number($n ? $n / abs($n) : 0, ''); + return $this->toBool($left->lessThan($right)); } /** @@ -3601,6 +4112,48 @@ class Compiler return $thing ? static::$true : static::$false; } + /** + * Escape non printable chars in strings output as in dart-sass + * @param string $string + * @return string + */ + public function escapeNonPrintableChars($string, $inKeyword = false) + { + static $replacement = []; + if (empty($replacement[$inKeyword])) { + for ($i = 0; $i < 32; $i++) { + if ($i !== 9 || $inKeyword) { + $replacement[$inKeyword][chr($i)] = '\\' . dechex($i) . ($inKeyword ? ' ' : chr(0)); + } + } + } + $string = str_replace(array_keys($replacement[$inKeyword]), array_values($replacement[$inKeyword]), $string); + // chr(0) is not a possible char from the input, so any chr(0) comes from our escaping replacement + if (strpos($string, chr(0)) !== false) { + if (substr($string, -1) === chr(0)) { + $string = substr($string, 0, -1); + } + $string = str_replace( + [chr(0) . '\\',chr(0) . ' '], + [ '\\', ' '], + $string + ); + if (strpos($string, chr(0)) !== false) { + $parts = explode(chr(0), $string); + $string = array_shift($parts); + while (count($parts)) { + $next = array_shift($parts); + if (strpos("0123456789abcdefABCDEF" . chr(9), $next[0]) !== false) { + $string .= " "; + } + $string .= $next; + } + } + } + + return $string; + } + /** * Compiles a primitive value into a CSS property value. * @@ -3614,9 +4167,9 @@ class Compiler * * @api * - * @param array $value + * @param array|Number|string $value * - * @return string|array + * @return string */ public function compileValue($value) { @@ -3624,6 +4177,9 @@ class Compiler switch ($value[0]) { case Type::T_KEYWORD: + if (is_string($value[1])) { + $value[1] = $this->escapeNonPrintableChars($value[1], true); + } return $value[1]; case Type::T_COLOR: @@ -3637,18 +4193,18 @@ class Compiler $g = $this->compileRGBAValue($g); $b = $this->compileRGBAValue($b); - if (count($value) === 5) { + if (\count($value) === 5) { $alpha = $this->compileRGBAValue($value[4], true); if (! is_numeric($alpha) || $alpha < 1) { $colorName = Colors::RGBaToColorName($r, $g, $b, $alpha); - if (! is_null($colorName)) { + if (! \is_null($colorName)) { return $colorName; } if (is_numeric($alpha)) { - $a = new Node\Number($alpha, ''); + $a = new Number($alpha, ''); } else { $a = $alpha; } @@ -3663,7 +4219,7 @@ class Compiler $colorName = Colors::RGBaToColorName($r, $g, $b); - if (! is_null($colorName)) { + if (! \is_null($colorName)) { return $colorName; } @@ -3680,13 +4236,42 @@ class Compiler return $value->output($this); case Type::T_STRING: - return $value[1] . $this->compileStringContent($value) . $value[1]; + $content = $this->compileStringContent($value); + + if ($value[1]) { + $content = str_replace('\\', '\\\\', $content); + + $content = $this->escapeNonPrintableChars($content); + + // force double quote as string quote for the output in certain cases + if ( + $value[1] === "'" && + (strpos($content, '"') === false or strpos($content, "'") !== false) && + strpbrk($content, '{}\\\'') !== false + ) { + $value[1] = '"'; + } elseif ( + $value[1] === '"' && + (strpos($content, '"') !== false and strpos($content, "'") === false) + ) { + $value[1] = "'"; + } + + $content = str_replace($value[1], '\\' . $value[1], $content); + } + + return $value[1] . $content . $value[1]; case Type::T_FUNCTION: $args = ! empty($value[2]) ? $this->compileValue($value[2]) : ''; return "$value[1]($args)"; + case Type::T_FUNCTION_REFERENCE: + $name = ! empty($value[2]) ? $value[2] : ''; + + return "get-function(\"$name\")"; + case Type::T_LIST: $value = $this->extractInterpolation($value); @@ -3695,52 +4280,72 @@ class Compiler } list(, $delim, $items) = $value; - $pre = $post = ""; + $pre = $post = ''; + if (! empty($value['enclosing'])) { switch ($value['enclosing']) { case 'parent': - //$pre = "("; - //$post = ")"; + //$pre = '('; + //$post = ')'; break; case 'forced_parent': - $pre = "("; - $post = ")"; + $pre = '('; + $post = ')'; break; case 'bracket': case 'forced_bracket': - $pre = "["; - $post = "]"; + $pre = '['; + $post = ']'; break; } } $prefix_value = ''; + if ($delim !== ' ') { $prefix_value = ' '; } $filtered = []; + $same_string_quote = null; foreach ($items as $item) { + if (\is_null($same_string_quote)) { + $same_string_quote = false; + if ($item[0] === Type::T_STRING) { + $same_string_quote = $item[1]; + foreach ($items as $ii) { + if ($ii[0] !== Type::T_STRING) { + $same_string_quote = false; + break; + } + } + } + } if ($item[0] === Type::T_NULL) { continue; } + if ($same_string_quote === '"' && $item[0] === Type::T_STRING && $item[1]) { + $item[1] = $same_string_quote; + } $compiled = $this->compileValue($item); - if ($prefix_value && strlen($compiled)) { + + if ($prefix_value && \strlen($compiled)) { $compiled = $prefix_value . $compiled; } + $filtered[] = $compiled; } - return $pre . substr(implode("$delim", $filtered), strlen($prefix_value)) . $post; + return $pre . substr(implode("$delim", $filtered), \strlen($prefix_value)) . $post; case Type::T_MAP: $keys = $value[1]; $values = $value[2]; $filtered = []; - for ($i = 0, $s = count($keys); $i < $s; $i++) { + for ($i = 0, $s = \count($keys); $i < $s; $i++) { $filtered[$this->compileValue($keys[$i])] = $this->compileValue($values[$i]); } @@ -3761,8 +4366,9 @@ class Compiler $delim .= ' '; } - $left = count($left[2]) > 0 ? - $this->compileValue($left) . $delim . $whiteLeft: ''; + $left = \count($left[2]) > 0 + ? $this->compileValue($left) . $delim . $whiteLeft + : ''; $delim = $right[1]; @@ -3770,7 +4376,7 @@ class Compiler $delim .= ' '; } - $right = count($right[2]) > 0 ? + $right = \count($right[2]) > 0 ? $whiteRight . $delim . $this->compileValue($right) : ''; return $left . $this->compileValue($interpolate) . $right; @@ -3815,7 +4421,7 @@ class Compiler break; case Type::T_STRING: - $reduced = [Type::T_KEYWORD, $this->compileStringContent($reduced)]; + $reduced = [Type::T_STRING, '', [$this->compileStringContent($reduced)]]; break; case Type::T_NULL: @@ -3831,7 +4437,25 @@ class Compiler return $this->compileCommentValue($value); default: - $this->throwError("unknown value type: ".json_encode($value)); + throw $this->error('unknown value type: ' . json_encode($value)); + } + } + + /** + * @param array $value + * + * @return array|string + */ + protected function compileDebugValue($value) + { + $value = $this->reduce($value, true); + + switch ($value[0]) { + case Type::T_STRING: + return $this->compileStringContent($value); + + default: + return $this->compileValue($value); } } @@ -3859,7 +4483,7 @@ class Compiler $parts = []; foreach ($string[2] as $part) { - if (is_array($part) || $part instanceof \ArrayAccess) { + if (\is_array($part) || $part instanceof \ArrayAccess) { $parts[] = $this->compileValue($part); } else { $parts[] = $part; @@ -3882,8 +4506,8 @@ class Compiler foreach ($items as $i => $item) { if ($item[0] === Type::T_INTERPOLATE) { - $before = [Type::T_LIST, $list[1], array_slice($items, 0, $i)]; - $after = [Type::T_LIST, $list[1], array_slice($items, $i + 1)]; + $before = [Type::T_LIST, $list[1], \array_slice($items, 0, $i)]; + $after = [Type::T_LIST, $list[1], \array_slice($items, $i + 1)]; return [Type::T_INTERPOLATED, $item, $before, $after]; } @@ -3908,7 +4532,7 @@ class Compiler $selfParentSelectors = null; - if (! is_null($selfParent) && $selfParent->selectors) { + if (! \is_null($selfParent) && $selfParent->selectors) { $selfParentSelectors = $this->evalSelectors($selfParent->selectors); } @@ -3924,8 +4548,8 @@ class Compiler $prevSelectors = $selectors; $selectors = []; - foreach ($prevSelectors as $selector) { - foreach ($parentSelectors as $parent) { + foreach ($parentSelectors as $parent) { + foreach ($prevSelectors as $selector) { if ($selfParentSelectors) { foreach ($selfParentSelectors as $selfParent) { // if no '&' in the selector, each call will give same result, only add once @@ -3945,6 +4569,11 @@ class Compiler $selectors = array_values($selectors); + // case we are just starting a at-root : nothing to multiply but parentSelectors + if (! $selectors && $selfParentSelectors) { + $selectors = $selfParentSelectors; + } + return $selectors; } @@ -3953,7 +4582,7 @@ class Compiler * * @param array $parent * @param array $child - * @param boolean &$stillHasSelf + * @param boolean $stillHasSelf * @param array $selfParentSelectors * @return array @@ -3975,7 +4604,7 @@ class Compiler if ($p === static::$selfSelector && ! $setSelf) { $setSelf = true; - if (is_null($selfParentSelectors)) { + if (\is_null($selfParentSelectors)) { $selfParentSelectors = $parent; } @@ -3986,7 +4615,7 @@ class Compiler } foreach ($parentPart as $pp) { - if (is_array($pp)) { + if (\is_array($pp)) { $flatten = []; array_walk_recursive($pp, function ($a) use (&$flatten) { @@ -4020,7 +4649,8 @@ class Compiler */ protected function multiplyMedia(Environment $env = null, $childQueries = null) { - if (! isset($env) || + if ( + ! isset($env) || ! empty($env->block->type) && $env->block->type !== Type::T_MEDIA ) { return $childQueries; @@ -4043,7 +4673,7 @@ class Compiler list($this->env, $this->storeEnv) = $store; - if (is_null($childQueries)) { + if (\is_null($childQueries)) { $childQueries = $parentQueries; } else { $originalQueries = $childQueries; @@ -4066,9 +4696,11 @@ class Compiler /** * Convert env linked list to stack * - * @param \ScssPhp\ScssPhp\Compiler\Environment $env + * @param Environment $env * - * @return array + * @return Environment[] + * + * @phpstan-return non-empty-array */ protected function compactEnv(Environment $env) { @@ -4082,9 +4714,11 @@ class Compiler /** * Convert env stack to singly linked list * - * @param array $envs + * @param Environment[] $envs * - * @return \ScssPhp\ScssPhp\Compiler\Environment + * @return Environment + * + * @phpstan-param non-empty-array $envs */ protected function extractEnv($envs) { @@ -4105,25 +4739,47 @@ class Compiler */ protected function pushEnv(Block $block = null) { - $env = new Environment; + $env = new Environment(); $env->parent = $this->env; + $env->parentStore = $this->storeEnv; $env->store = []; $env->block = $block; $env->depth = isset($this->env->depth) ? $this->env->depth + 1 : 0; $this->env = $env; + $this->storeEnv = null; return $env; } /** * Pop environment + * + * @return void */ protected function popEnv() { + $this->storeEnv = $this->env->parentStore; $this->env = $this->env->parent; } + /** + * Propagate vars from a just poped Env (used in @each and @for) + * + * @param array $store + * @param null|string[] $excludedVars + * + * @return void + */ + protected function backPropagateEnv($store, $excludedVars = null) + { + foreach ($store as $key => $value) { + if (empty($excludedVars) || ! \in_array($key, $excludedVars)) { + $this->set($key, $value, true); + } + } + } + /** * Get store environment * @@ -4142,6 +4798,8 @@ class Compiler * @param boolean $shadow * @param \ScssPhp\ScssPhp\Compiler\Environment $env * @param mixed $valueUnreduced + * + * @return void */ protected function set($name, $value, $shadow = false, Environment $env = null, $valueUnreduced = null) { @@ -4165,29 +4823,50 @@ class Compiler * @param mixed $value * @param \ScssPhp\ScssPhp\Compiler\Environment $env * @param mixed $valueUnreduced + * + * @return void */ protected function setExisting($name, $value, Environment $env, $valueUnreduced = null) { $storeEnv = $env; + $specialContentKey = static::$namespaces['special'] . 'content'; $hasNamespace = $name[0] === '^' || $name[0] === '@' || $name[0] === '%'; + $maxDepth = 10000; + for (;;) { - if (array_key_exists($name, $env->store)) { + if ($maxDepth-- <= 0) { + break; + } + + if (\array_key_exists($name, $env->store)) { break; } if (! $hasNamespace && isset($env->marker)) { + if (! empty($env->store[$specialContentKey])) { + $env = $env->store[$specialContentKey]->scope; + continue; + } + + if (! empty($env->declarationScopeParent)) { + $env = $env->declarationScopeParent; + continue; + } else { + $env = $storeEnv; + break; + } + } + + if (isset($env->parentStore)) { + $env = $env->parentStore; + } elseif (isset($env->parent)) { + $env = $env->parent; + } else { $env = $storeEnv; break; } - - if (! isset($env->parent)) { - $env = $storeEnv; - break; - } - - $env = $env->parent; } $env->store[$name] = $value; @@ -4204,6 +4883,8 @@ class Compiler * @param mixed $value * @param \ScssPhp\ScssPhp\Compiler\Environment $env * @param mixed $valueUnreduced + * + * @return void */ protected function setRaw($name, $value, Environment $env, $valueUnreduced = null) { @@ -4244,7 +4925,7 @@ class Compiler break; } - if (array_key_exists($normalizedName, $env->store)) { + if (\array_key_exists($normalizedName, $env->store)) { if ($unreduced && isset($env->storeUnreduced[$normalizedName])) { return $env->storeUnreduced[$normalizedName]; } @@ -4266,15 +4947,17 @@ class Compiler continue; } - if (! isset($env->parent)) { + if (isset($env->parentStore)) { + $env = $env->parentStore; + } elseif (isset($env->parent)) { + $env = $env->parent; + } else { break; } - - $env = $env->parent; } if ($shouldThrow) { - $this->throwError("Undefined variable \$$name" . ($maxDepth <= 0 ? " (infinite recursion)" : "")); + throw $this->error("Undefined variable \$$name" . ($maxDepth <= 0 ? ' (infinite recursion)' : '')); } // found nothing @@ -4291,13 +4974,15 @@ class Compiler */ protected function has($name, Environment $env = null) { - return ! is_null($this->get($name, false, $env)); + return ! \is_null($this->get($name, false, $env)); } /** * Inject variables * * @param array $args + * + * @return void */ protected function injectVariables(array $args) { @@ -4326,6 +5011,8 @@ class Compiler * @api * * @param array $variables + * + * @return void */ public function setVariables(array $variables) { @@ -4338,6 +5025,8 @@ class Compiler * @api * * @param string $name + * + * @return void */ public function unsetVariable($name) { @@ -4362,6 +5051,8 @@ class Compiler * @api * * @param string $path + * + * @return void */ public function addParsedFile($path) { @@ -4388,10 +5079,12 @@ class Compiler * @api * * @param string|callable $path + * + * @return void */ public function addImportPath($path) { - if (! in_array($path, $this->importPaths)) { + if (! \in_array($path, $this->importPaths)) { $this->importPaths[] = $path; } } @@ -4401,11 +5094,24 @@ class Compiler * * @api * - * @param string|array $path + * @param string|array $path + * + * @return void */ public function setImportPaths($path) { - $this->importPaths = (array) $path; + $paths = (array) $path; + $actualImportPaths = array_filter($paths, function ($path) { + return $path !== ''; + }); + + $this->legacyCwdImportPath = \count($actualImportPaths) !== \count($paths); + + if ($this->legacyCwdImportPath) { + @trigger_error('Passing an empty string in the import paths to refer to the current working directory is deprecated. If that\'s the intended behavior, the value of "getcwd()" should be used directly instead. If this was used for resolving relative imports of the input alongside "chdir" with the source directory, the path of the input file should be passed to "compile()" instead.', E_USER_DEPRECATED); + } + + $this->importPaths = $actualImportPaths; } /** @@ -4414,10 +5120,42 @@ class Compiler * @api * * @param integer $numberPrecision + * + * @return void + * + * @deprecated The number precision is not configurable anymore. The default is enough for all browsers. */ public function setNumberPrecision($numberPrecision) { - Node\Number::$precision = $numberPrecision; + @trigger_error('The number precision is not configurable anymore. ' + . 'The default is enough for all browsers.', E_USER_DEPRECATED); + } + + /** + * Sets the output style. + * + * @api + * + * @param string $style One of the OutputStyle constants + * + * @return void + * + * @phpstan-param OutputStyle::* $style + */ + public function setOutputStyle($style) + { + switch ($style) { + case OutputStyle::EXPANDED: + $this->formatter = Expanded::class; + break; + + case OutputStyle::COMPRESSED: + $this->formatter = Compressed::class; + break; + + default: + throw new \InvalidArgumentException(sprintf('Invalid output style "%s".', $style)); + } } /** @@ -4426,9 +5164,18 @@ class Compiler * @api * * @param string $formatterName + * + * @return void + * + * @deprecated Use {@see setOutputStyle} instead. */ public function setFormatter($formatterName) { + if (!\in_array($formatterName, [Expanded::class, Compressed::class], true)) { + @trigger_error('Formatters other than Expanded and Compressed are deprecated.', E_USER_DEPRECATED); + } + @trigger_error('The method "setFormatter" is deprecated. Use "setOutputStyle" instead.', E_USER_DEPRECATED); + $this->formatter = $formatterName; } @@ -4438,10 +5185,15 @@ class Compiler * @api * * @param string $lineNumberStyle + * + * @return void + * + * @deprecated The line number output is not supported anymore. Use source maps instead. */ public function setLineNumberStyle($lineNumberStyle) { - $this->lineNumberStyle = $lineNumberStyle; + @trigger_error('The line number output is not supported anymore. ' + . 'Use source maps instead.', E_USER_DEPRECATED); } /** @@ -4450,6 +5202,10 @@ class Compiler * @api * * @param integer $sourceMap + * + * @return void + * + * @phpstan-param self::SOURCE_MAP_* $sourceMap */ public function setSourceMap($sourceMap) { @@ -4462,6 +5218,8 @@ class Compiler * @api * * @param array $sourceMapOptions + * + * @return void */ public function setSourceMapOptions($sourceMapOptions) { @@ -4475,7 +5233,9 @@ class Compiler * * @param string $name * @param callable $func - * @param array $prototype + * @param array|null $prototype + * + * @return void */ public function registerFunction($name, $func, $prototype = null) { @@ -4488,6 +5248,8 @@ class Compiler * @api * * @param string $name + * + * @return void */ public function unregisterFunction($name) { @@ -4500,9 +5262,15 @@ class Compiler * @api * * @param string $name + * + * @return void + * + * @deprecated Registering additional features is deprecated. */ public function addFeature($name) { + @trigger_error('Registering additional features is deprecated.', E_USER_DEPRECATED); + $this->registeredFeatures[$name] = true; } @@ -4511,9 +5279,12 @@ class Compiler * * @param string $path * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $out + * + * @return void */ protected function importFile($path, OutputBlock $out) { + $this->pushCallStack('import ' . $this->getPrettyPath($path)); // see if tree is cached $realPath = realpath($path); @@ -4529,11 +5300,12 @@ class Compiler $this->importCache[$realPath] = $tree; } - $pi = pathinfo($path); + $currentDirectory = $this->currentDirectory; + $this->currentDirectory = dirname($path); - array_unshift($this->importPaths, $pi['dirname']); $this->compileChildrenNoReturn($tree->children, $out); - array_shift($this->importPaths); + $this->currentDirectory = $currentDirectory; + $this->popCallStack(); } /** @@ -4547,44 +5319,185 @@ class Compiler */ public function findImport($url) { - $urls = []; - // for "normal" scss imports (ignore vanilla css and external requests) - if (! preg_match('~\.css$|^https?://~', $url)) { - // try both normal and the _partial filename - $urls = [$url, preg_replace('~[^/]+$~', '_\0', $url)]; - } + // Callback importers are still called for BC. + if (preg_match('~\.css$|^https?://|^//~', $url)) { + foreach ($this->importPaths as $dir) { + if (\is_string($dir)) { + continue; + } - $hasExtension = preg_match('/[.]s?css$/', $url); + if (\is_callable($dir)) { + // check custom callback for import path + $file = \call_user_func($dir, $url); - foreach ($this->importPaths as $dir) { - if (is_string($dir)) { - // check urls for normal import paths - foreach ($urls as $full) { - $separator = ( - ! empty($dir) && - substr($dir, -1) !== '/' && - substr($full, 0, 1) !== '/' - ) ? '/' : ''; - $full = $dir . $separator . $full; - - if (is_file($file = $full . '.scss') || - ($hasExtension && is_file($file = $full)) - ) { + if (! \is_null($file)) { return $file; } } - } elseif (is_callable($dir)) { - // check custom callback for import path - $file = call_user_func($dir, $url); + } + return null; + } - if (! is_null($file)) { + if (!\is_null($this->currentDirectory)) { + $relativePath = $this->resolveImportPath($url, $this->currentDirectory); + + if (!\is_null($relativePath)) { + return $relativePath; + } + } + + foreach ($this->importPaths as $dir) { + if (\is_string($dir)) { + $path = $this->resolveImportPath($url, $dir); + + if (!\is_null($path)) { + return $path; + } + } elseif (\is_callable($dir)) { + // check custom callback for import path + $file = \call_user_func($dir, $url); + + if (! \is_null($file)) { return $file; } } } - return null; + if ($this->legacyCwdImportPath) { + $path = $this->resolveImportPath($url, getcwd()); + + if (!\is_null($path)) { + @trigger_error('Resolving imports relatively to the current working directory is deprecated. If that\'s the intended behavior, the value of "getcwd()" should be added as an import path explicitly instead. If this was used for resolving relative imports of the input alongside "chdir" with the source directory, the path of the input file should be passed to "compile()" instead.', E_USER_DEPRECATED); + + return $path; + } + } + + throw $this->error("`$url` file not found for @import"); + } + + /** + * @param string $url + * @param string $baseDir + * + * @return string|null + */ + private function resolveImportPath($url, $baseDir) + { + $path = Path::join($baseDir, $url); + + $hasExtension = preg_match('/.scss$/', $url); + + if ($hasExtension) { + return $this->checkImportPathConflicts($this->tryImportPath($path)); + } + + $result = $this->checkImportPathConflicts($this->tryImportPathWithExtensions($path)); + + if (!\is_null($result)) { + return $result; + } + + return $this->tryImportPathAsDirectory($path); + } + + /** + * @param string[] $paths + * + * @return string|null + */ + private function checkImportPathConflicts(array $paths) + { + if (\count($paths) === 0) { + return null; + } + + if (\count($paths) === 1) { + return $paths[0]; + } + + $formattedPrettyPaths = []; + + foreach ($paths as $path) { + $formattedPrettyPaths[] = ' ' . $this->getPrettyPath($path); + } + + throw $this->error("It's not clear which file to import. Found:\n" . implode("\n", $formattedPrettyPaths)); + } + + /** + * @param string $path + * + * @return string[] + */ + private function tryImportPathWithExtensions($path) + { + $result = $this->tryImportPath($path.'.scss'); + + if ($result) { + return $result; + } + + return $this->tryImportPath($path.'.css'); + } + + /** + * @param string $path + * + * @return string[] + */ + private function tryImportPath($path) + { + $partial = dirname($path).'/_'.basename($path); + + $candidates = []; + + if (is_file($partial)) { + $candidates[] = $partial; + } + + if (is_file($path)) { + $candidates[] = $path; + } + + return $candidates; + } + + /** + * @param string $path + * + * @return string|null + */ + private function tryImportPathAsDirectory($path) + { + if (!is_dir($path)) { + return null; + } + + return $this->checkImportPathConflicts($this->tryImportPathWithExtensions($path.'/index')); + } + + /** + * @param string $path + * + * @return string + */ + private function getPrettyPath($path) + { + $normalizedPath = $path; + $normalizedRootDirectory = $this->rootDirectory.'/'; + + if (\DIRECTORY_SEPARATOR === '\\') { + $normalizedRootDirectory = str_replace('\\', '/', $normalizedRootDirectory); + $normalizedPath = str_replace('\\', '/', $path); + } + + if (0 === strpos($normalizedPath, $normalizedRootDirectory)) { + return substr($normalizedPath, \strlen($normalizedRootDirectory)); + } + + return $path; } /** @@ -4593,6 +5506,8 @@ class Compiler * @api * * @param string $encoding + * + * @return void */ public function setEncoding($encoding) { @@ -4607,14 +5522,30 @@ class Compiler * @param boolean $ignoreErrors * * @return \ScssPhp\ScssPhp\Compiler + * + * @deprecated Ignoring Sass errors is not longer supported. */ public function setIgnoreErrors($ignoreErrors) { - $this->ignoreErrors = $ignoreErrors; + @trigger_error('Ignoring Sass errors is not longer supported.', E_USER_DEPRECATED); return $this; } + /** + * Get source position + * + * @api + * + * @return array + */ + public function getSourcePosition() + { + $sourceFile = isset($this->sourceNames[$this->sourceIndex]) ? $this->sourceNames[$this->sourceIndex] : ''; + + return [$sourceFile, $this->sourceLine, $this->sourceColumn]; + } + /** * Throw error (exception) * @@ -4623,33 +5554,85 @@ class Compiler * @param string $msg Message with optional sprintf()-style vararg parameters * * @throws \ScssPhp\ScssPhp\Exception\CompilerException + * + * @deprecated use "error" and throw the exception in the caller instead. */ public function throwError($msg) { - if ($this->ignoreErrors) { - return; + @trigger_error( + 'The method "throwError" is deprecated. Use "error" and throw the exception in the caller instead', + E_USER_DEPRECATED + ); + + throw $this->error(...func_get_args()); + } + + /** + * Build an error (exception) + * + * @api + * + * @param string $msg Message with optional sprintf()-style vararg parameters + * + * @return CompilerException + */ + public function error($msg, ...$args) + { + if ($args) { + $msg = sprintf($msg, ...$args); } - $line = $this->sourceLine; - $column = $this->sourceColumn; + if (! $this->ignoreCallStackMessage) { + $line = $this->sourceLine; + $column = $this->sourceColumn; - $loc = isset($this->sourceNames[$this->sourceIndex]) - ? $this->sourceNames[$this->sourceIndex] . " on line $line, at column $column" - : "line: $line, column: $column"; + $loc = isset($this->sourceNames[$this->sourceIndex]) + ? $this->getPrettyPath($this->sourceNames[$this->sourceIndex]) . " on line $line, at column $column" + : "line: $line, column: $column"; - if (func_num_args() > 1) { - $msg = call_user_func_array('sprintf', func_get_args()); + $msg = "$msg: $loc"; + + $callStackMsg = $this->callStackMessage(); + + if ($callStackMsg) { + $msg .= "\nCall Stack:\n" . $callStackMsg; + } } - $msg = "$msg: $loc"; + return new CompilerException($msg); + } - $callStackMsg = $this->callStackMessage(); + /** + * @param string $functionName + * @param array $ExpectedArgs + * @param int $nbActual + * @return CompilerException + */ + public function errorArgsNumber($functionName, $ExpectedArgs, $nbActual) + { + $nbExpected = \count($ExpectedArgs); - if ($callStackMsg) { - $msg .= "\nCall Stack:\n" . $callStackMsg; + if ($nbActual > $nbExpected) { + return $this->error( + 'Error: Only %d arguments allowed in %s(), but %d were passed.', + $nbExpected, + $functionName, + $nbActual + ); + } else { + $missing = []; + + while (count($ExpectedArgs) && count($ExpectedArgs) > $nbActual) { + array_unshift($missing, array_pop($ExpectedArgs)); + } + + return $this->error( + 'Error: %s() argument%s %s missing.', + $functionName, + count($missing) > 1 ? 's' : '', + implode(', ', $missing) + ); } - - throw new CompilerException($msg); } /** @@ -4668,15 +5651,15 @@ class Compiler if ($this->callStack) { foreach (array_reverse($this->callStack) as $call) { if ($all || (isset($call['n']) && $call['n'])) { - $msg = "#" . $ncall++ . " " . $call['n'] . " "; + $msg = '#' . $ncall++ . ' ' . $call['n'] . ' '; $msg .= (isset($this->sourceNames[$call[Parser::SOURCE_INDEX]]) - ? $this->sourceNames[$call[Parser::SOURCE_INDEX]] + ? $this->getPrettyPath($this->sourceNames[$call[Parser::SOURCE_INDEX]]) : '(unknown file)'); - $msg .= " on line " . $call[Parser::SOURCE_LINE]; + $msg .= ' on line ' . $call[Parser::SOURCE_LINE]; $callStackMsg[] = $msg; - if (! is_null($limit) && $ncall > $limit) { + if (! \is_null($limit) && $ncall > $limit) { break; } } @@ -4703,8 +5686,7 @@ class Compiler $file = $this->sourceNames[$env->block->sourceIndex]; if (realpath($file) === $name) { - $this->throwError('An @import loop has been found: %s imports %s', $file, basename($file)); - break; + throw $this->error('An @import loop has been found: %s imports %s', $file, basename($file)); } } } @@ -4712,80 +5694,66 @@ class Compiler /** * Call SCSS @function * - * @param string $name + * @param Object $func * @param array $argValues - * @param array $returnValue * - * @return boolean Returns true if returnValue is set; otherwise, false + * @return array */ - protected function callScssFunction($name, $argValues, &$returnValue) + protected function callScssFunction($func, $argValues) { - $func = $this->get(static::$namespaces['function'] . $name, false); - if (! $func) { - return false; + return static::$defaultValue; } + $name = $func->name; $this->pushEnv(); - $storeEnv = $this->storeEnv; - $this->storeEnv = $this->env; - // set the args if (isset($func->args)) { $this->applyArguments($func->args, $argValues); } // throw away lines and children - $tmp = new OutputBlock; + $tmp = new OutputBlock(); $tmp->lines = []; $tmp->children = []; $this->env->marker = 'function'; + if (! empty($func->parentEnv)) { $this->env->declarationScopeParent = $func->parentEnv; } else { - $this->throwError("@function $name() without parentEnv"); + throw $this->error("@function $name() without parentEnv"); } - $ret = $this->compileChildren($func->children, $tmp, $this->env->marker . " " . $name); - - $this->storeEnv = $storeEnv; + $ret = $this->compileChildren($func->children, $tmp, $this->env->marker . ' ' . $name); $this->popEnv(); - $returnValue = ! isset($ret) ? static::$defaultValue : $ret; - - return true; + return ! isset($ret) ? static::$defaultValue : $ret; } /** * Call built-in and registered (PHP) functions * * @param string $name + * @param string|array $function + * @param array $prototype * @param array $args - * @param array $returnValue * - * @return boolean Returns true if returnValue is set; otherwise, false + * @return array|Number|null */ - protected function callNativeFunction($name, $args, &$returnValue) + protected function callNativeFunction($name, $function, $prototype, $args) { - // try a lib function - $name = $this->normalizeName($name); + $libName = (is_array($function) ? end($function) : null); + $sorted_kwargs = $this->sortNativeFunctionArgs($libName, $prototype, $args); - if (isset($this->userFunctions[$name])) { - // see if we can find a user function - list($f, $prototype) = $this->userFunctions[$name]; - } elseif (($f = $this->getBuiltinFunction($name)) && is_callable($f)) { - $libName = $f[1]; - $prototype = isset(static::$$libName) ? static::$$libName : null; - } else { - return false; + if (\is_null($sorted_kwargs)) { + return null; } + @list($sorted, $kwargs) = $sorted_kwargs; - @list($sorted, $kwargs) = $this->sortNativeFunctionArgs($libName, $prototype, $args); - - if ($name !== 'if' && $name !== 'call') { + if ($name !== 'if') { $inExp = true; if ($name === 'join') { @@ -4797,15 +5765,13 @@ class Compiler } } - $returnValue = call_user_func($f, $sorted, $kwargs); + $returnValue = \call_user_func($function, $sorted, $kwargs); if (! isset($returnValue)) { - return false; + return null; } - $returnValue = $this->coerceValue($returnValue); - - return true; + return $this->coerceValue($returnValue); } /** @@ -4817,6 +5783,18 @@ class Compiler */ protected function getBuiltinFunction($name) { + $libName = self::normalizeNativeFunctionName($name); + return [$this, $libName]; + } + + /** + * Normalize native function name + * @param string $name + * @return string + */ + public static function normalizeNativeFunctionName($name) + { + $name = str_replace("-", "_", $name); $libName = 'lib' . preg_replace_callback( '/_(.)/', function ($m) { @@ -4824,8 +5802,17 @@ class Compiler }, ucfirst($name) ); + return $libName; + } - return [$this, $libName]; + /** + * Check if a function is a native built-in scss function, for css parsing + * @param string $name + * @return bool + */ + public static function isNativeFunction($name) + { + return method_exists(Compiler::class, self::normalizeNativeFunctionName($name)); } /** @@ -4835,7 +5822,7 @@ class Compiler * @param array $prototypes * @param array $args * - * @return array + * @return array|null */ protected function sortNativeFunctionArgs($functionName, $prototypes, $args) { @@ -4845,16 +5832,18 @@ class Compiler $keyArgs = []; $posArgs = []; + if (\is_array($args) && \count($args) && \end($args) === static::$null) { + array_pop($args); + } + // separate positional and keyword arguments foreach ($args as $arg) { list($key, $value) = $arg; - $key = $key[1]; - - if (empty($key)) { + if (empty($key) or empty($key[1])) { $posArgs[] = empty($arg[2]) ? $value : $arg; } else { - $keyArgs[$key] = $value; + $keyArgs[$key[1]] = $value; } } @@ -4862,10 +5851,10 @@ class Compiler } // specific cases ? - if (in_array($functionName, ['libRgb', 'libRgba', 'libHsl', 'libHsla'])) { + if (\in_array($functionName, ['libRgb', 'libRgba', 'libHsl', 'libHsla'])) { // notation 100 127 255 / 0 is in fact a simple list of 4 values foreach ($args as $k => $arg) { - if ($arg[1][0] === Type::T_LIST && count($arg[1][2]) === 3) { + if ($arg[1][0] === Type::T_LIST && \count($arg[1][2]) === 3) { $last = end($arg[1][2]); if ($last[0] === Type::T_EXPRESSION && $last[1] === '/') { @@ -4880,7 +5869,7 @@ class Compiler $finalArgs = []; - if (! is_array(reset($prototypes))) { + if (! \is_array(reset($prototypes))) { $prototypes = [$prototypes]; } @@ -4898,14 +5887,14 @@ class Compiler $p = explode(':', $p, 2); $name = array_shift($p); - if (count($p)) { + if (\count($p)) { $p = trim(reset($p)); if ($p === 'null') { // differentiate this null from the static::$null $default = [Type::T_KEYWORD, 'null']; } else { - if (is_null($parser)) { + if (\is_null($parser)) { $parser = $this->parserFactory(__METHOD__); } @@ -4923,7 +5912,18 @@ class Compiler $argDef[] = [$name, $default, $isVariable]; } + $ignoreCallStackMessage = $this->ignoreCallStackMessage; + $this->ignoreCallStackMessage = true; + try { + if (\count($args) > \count($argDef)) { + $lastDef = end($argDef); + + // check that last arg is not a ... + if (empty($lastDef[2])) { + throw $this->errorArgsNumber($functionName, $argDef, \count($args)); + } + } $vars = $this->applyArguments($argDef, $args, false, false); // ensure all args are populated @@ -4960,10 +5960,20 @@ class Compiler } catch (CompilerException $e) { $exceptionMessage = $e->getMessage(); } + $this->ignoreCallStackMessage = $ignoreCallStackMessage; } if ($exceptionMessage && ! $prototypeHasMatch) { - $this->throwError($exceptionMessage); + if (\in_array($functionName, ['libRgb', 'libRgba', 'libHsl', 'libHsla'])) { + // if var() or calc() is used as an argument, return as a css function + foreach ($args as $arg) { + if ($arg[1][0] === Type::T_FUNCTION_CALL && in_array($arg[1][1], ['var'])) { + return null; + } + } + } + + throw $this->error($exceptionMessage); } return [$finalArgs, $keyArgs]; @@ -4985,14 +5995,15 @@ class Compiler protected function applyArguments($argDef, $argValues, $storeInEnv = true, $reduce = true) { $output = []; - if (is_array($argValues) && count($argValues) && end($argValues) === static::$null) { + + if (\is_array($argValues) && \count($argValues) && end($argValues) === static::$null) { array_pop($argValues); } if ($storeInEnv) { $storeEnv = $this->getStoreEnv(); - $env = new Environment; + $env = new Environment(); $env->store = $storeEnv->store; } @@ -5009,6 +6020,7 @@ class Compiler $splatSeparator = null; $keywordArgs = []; $deferredKeywordArgs = []; + $deferredNamedKeywordArgs = []; $remaining = []; $hasKeywordArgument = false; @@ -5018,28 +6030,29 @@ class Compiler $hasKeywordArgument = true; $name = $arg[0][1]; + if (! isset($args[$name])) { foreach (array_keys($args) as $an) { - if (str_replace("_", "-", $an) === str_replace("_", "-", $name)) { + if (str_replace('_', '-', $an) === str_replace('_', '-', $name)) { $name = $an; break; } } } + if (! isset($args[$name]) || $args[$name][3]) { if ($hasVariable) { - $deferredKeywordArgs[$name] = $arg[1]; + $deferredNamedKeywordArgs[$name] = $arg[1]; } else { - $this->throwError("Mixin or function doesn't have an argument named $%s.", $arg[0][1]); - break; + throw $this->error("Mixin or function doesn't have an argument named $%s.", $arg[0][1]); } - } elseif ($args[$name][0] < count($remaining)) { - $this->throwError("The argument $%s was passed both by position and by name.", $arg[0][1]); - break; + } elseif ($args[$name][0] < \count($remaining)) { + throw $this->error("The argument $%s was passed both by position and by name.", $arg[0][1]); } else { $keywordArgs[$name] = $arg[1]; } - } elseif ($arg[2] === true) { + } elseif (! empty($arg[2])) { + // $arg[2] means a var followed by ... in the arg ($list... ) $val = $this->reduce($arg[1], true); if ($val[0] === Type::T_LIST) { @@ -5047,7 +6060,7 @@ class Compiler if (! is_numeric($name)) { if (! isset($args[$name])) { foreach (array_keys($args) as $an) { - if (str_replace("_", "-", $an) === str_replace("_", "-", $name)) { + if (str_replace('_', '-', $an) === str_replace('_', '-', $name)) { $name = $an; break; } @@ -5060,7 +6073,7 @@ class Compiler $keywordArgs[$name] = $item; } } else { - if (is_null($splatSeparator)) { + if (\is_null($splatSeparator)) { $splatSeparator = $val[1]; } @@ -5075,7 +6088,7 @@ class Compiler if (! is_numeric($name)) { if (! isset($args[$name])) { foreach (array_keys($args) as $an) { - if (str_replace("_", "-", $an) === str_replace("_", "-", $name)) { + if (str_replace('_', '-', $an) === str_replace('_', '-', $name)) { $name = $an; break; } @@ -5088,7 +6101,7 @@ class Compiler $keywordArgs[$name] = $item; } } else { - if (is_null($splatSeparator)) { + if (\is_null($splatSeparator)) { $splatSeparator = $val[1]; } @@ -5099,8 +6112,7 @@ class Compiler $remaining[] = $val; } } elseif ($hasKeywordArgument) { - $this->throwError('Positional arguments must come before keyword arguments.'); - break; + throw $this->error('Positional arguments must come before keyword arguments.'); } else { $remaining[] = $arg[1]; } @@ -5110,15 +6122,27 @@ class Compiler list($i, $name, $default, $isVariable) = $arg; if ($isVariable) { - $val = [Type::T_LIST, is_null($splatSeparator) ? ',' : $splatSeparator , [], $isVariable]; + // only if more than one arg : can not be passed as position and value + // see https://github.com/sass/libsass/issues/2927 + if (count($args) > 1) { + if (isset($remaining[$i]) && isset($deferredNamedKeywordArgs[$name])) { + throw $this->error("The argument $%s was passed both by position and by name.", $name); + } + } - for ($count = count($remaining); $i < $count; $i++) { + $val = [Type::T_LIST, \is_null($splatSeparator) ? ',' : $splatSeparator , [], $isVariable]; + + for ($count = \count($remaining); $i < $count; $i++) { $val[2][] = $remaining[$i]; } foreach ($deferredKeywordArgs as $itemName => $item) { $val[2][$itemName] = $item; } + + foreach ($deferredNamedKeywordArgs as $itemName => $item) { + $val[2][$itemName] = $item; + } } elseif (isset($remaining[$i])) { $val = $remaining[$i]; } elseif (isset($keywordArgs[$name])) { @@ -5126,8 +6150,7 @@ class Compiler } elseif (! empty($default)) { continue; } else { - $this->throwError("Missing argument $name"); - break; + throw $this->error("Missing argument $name"); } if ($storeInEnv) { @@ -5163,24 +6186,24 @@ class Compiler * * @param mixed $value * - * @return array|\ScssPhp\ScssPhp\Node\Number + * @return array|Number */ protected function coerceValue($value) { - if (is_array($value) || $value instanceof \ArrayAccess) { + if (\is_array($value) || $value instanceof \ArrayAccess) { return $value; } - if (is_bool($value)) { + if (\is_bool($value)) { return $this->toBool($value); } - if (is_null($value)) { + if (\is_null($value)) { return static::$null; } if (is_numeric($value)) { - return new Node\Number($value, ''); + return new Number($value, ''); } if ($value === '') { @@ -5200,9 +6223,9 @@ class Compiler /** * Coerce something to map * - * @param array $item + * @param array|Number $item * - * @return array + * @return array|Number */ protected function coerceMap($item) { @@ -5210,26 +6233,34 @@ class Compiler return $item; } - if ($item[0] === static::$emptyList[0] - && $item[1] === static::$emptyList[1] - && $item[2] === static::$emptyList[2]) { + if ( + $item[0] === static::$emptyList[0] && + $item[1] === static::$emptyList[1] && + $item[2] === static::$emptyList[2] + ) { return static::$emptyMap; } - return [Type::T_MAP, [$item], [static::$null]]; + return $item; } /** * Coerce something to list * - * @param array $item - * @param string $delim + * @param array $item + * @param string $delim + * @param boolean $removeTrailingNull * * @return array */ - protected function coerceList($item, $delim = ',') + protected function coerceList($item, $delim = ',', $removeTrailingNull = false) { if (isset($item) && $item[0] === Type::T_LIST) { + // remove trailing null from the list + if ($removeTrailingNull && end($item[2]) === static::$null) { + array_pop($item[2]); + } + return $item; } @@ -5238,7 +6269,7 @@ class Compiler $values = $item[2]; $list = []; - for ($i = 0, $s = count($keys); $i < $s; $i++) { + for ($i = 0, $s = \count($keys); $i < $s; $i++) { $key = $keys[$i]; $value = $values[$i]; @@ -5246,6 +6277,7 @@ class Compiler case Type::T_LIST: case Type::T_MAP: case Type::T_STRING: + case Type::T_NULL: break; default: @@ -5263,15 +6295,15 @@ class Compiler return [Type::T_LIST, ',', $list]; } - return [Type::T_LIST, $delim, ! isset($item) ? []: [$item]]; + return [Type::T_LIST, $delim, ! isset($item) ? [] : [$item]]; } /** * Coerce color for expression * - * @param array $value + * @param array|Number $value * - * @return array|null + * @return array|Number */ protected function coerceForExpression($value) { @@ -5285,7 +6317,8 @@ class Compiler /** * Coerce value to color * - * @param array $value + * @param array|Number $value + * @param bool $inRGBFunction * * @return array|null */ @@ -5321,7 +6354,7 @@ class Compiler case Type::T_LIST: if ($inRGBFunction) { - if (count($value[2]) == 3 || count($value[2]) == 4) { + if (\count($value[2]) == 3 || \count($value[2]) == 4) { $color = $value[2]; array_unshift($color, Type::T_COLOR); @@ -5332,16 +6365,17 @@ class Compiler return null; case Type::T_KEYWORD: - if (! is_string($value[1])) { + if (! \is_string($value[1])) { return null; } $name = strtolower($value[1]); + // hexa color? if (preg_match('/^#([0-9a-f]+)$/i', $name, $m)) { - $nofValues = strlen($m[1]); + $nofValues = \strlen($m[1]); - if (in_array($nofValues, [3, 4, 6, 8])) { + if (\in_array($nofValues, [3, 4, 6, 8])) { $nbChannels = 3; $color = []; $num = hexdec($m[1]); @@ -5375,7 +6409,7 @@ class Compiler if ($color[3] === 255) { $color[3] = 1; // fully opaque } else { - $color[3] = round($color[3] / 255, 3); + $color[3] = round($color[3] / 255, Number::PRECISION); } } @@ -5398,8 +6432,8 @@ class Compiler } /** - * @param integer|\ScssPhp\ScssPhp\Node\Number $value - * @param boolean $isAlpha + * @param integer|Number $value + * @param boolean $isAlpha * * @return integer|mixed */ @@ -5417,40 +6451,31 @@ class Compiler * @param integer|float $min * @param integer|float $max * @param boolean $isInt - * @param boolean $clamp - * @param boolean $modulo * * @return integer|mixed */ - protected function compileColorPartValue($value, $min, $max, $isInt = true, $clamp = true, $modulo = false) + protected function compileColorPartValue($value, $min, $max, $isInt = true) { if (! is_numeric($value)) { - if (is_array($value)) { + if (\is_array($value)) { $reduced = $this->reduce($value); - if (is_object($reduced) && $value->type === Type::T_NUMBER) { + if ($reduced instanceof Number) { $value = $reduced; } } - if (is_object($value) && $value->type === Type::T_NUMBER) { - $num = $value->dimension; - - if (count($value->units)) { - $unit = array_keys($value->units); - $unit = reset($unit); - - switch ($unit) { - case '%': - $num *= $max / 100; - break; - default: - break; - } + if ($value instanceof Number) { + if ($value->unitless()) { + $num = $value->getDimension(); + } elseif ($value->hasUnit('%')) { + $num = $max * $value->getDimension() / 100; + } else { + throw $this->error('Expected %s to have no units or "%%".', $value); } $value = $num; - } elseif (is_array($value)) { + } elseif (\is_array($value)) { $value = $this->compileValue($value); } } @@ -5460,18 +6485,7 @@ class Compiler $value = round($value); } - if ($clamp) { - $value = min($max, max($min, $value)); - } - - if ($modulo) { - $value = $value % $max; - - // still negative? - while ($value < $min) { - $value += $max; - } - } + $value = min($max, max($min, $value)); return $value; } @@ -5482,9 +6496,9 @@ class Compiler /** * Coerce value to string * - * @param array $value + * @param array|Number $value * - * @return array|null + * @return array */ protected function coerceString($value) { @@ -5495,21 +6509,51 @@ class Compiler return [Type::T_STRING, '', [$this->compileValue($value)]]; } + /** + * Assert value is a string (or keyword) + * + * @api + * + * @param array|Number $value + * @param string $varName + * + * @return array + * + * @throws \Exception + */ + public function assertString($value, $varName = null) + { + // case of url(...) parsed a a function + if ($value[0] === Type::T_FUNCTION) { + $value = $this->coerceString($value); + } + + if (! \in_array($value[0], [Type::T_STRING, Type::T_KEYWORD])) { + $value = $this->compileValue($value); + $var_display = ($varName ? " \${$varName}:" : ''); + throw $this->error("Error:{$var_display} $value is not a string."); + } + + $value = $this->coerceString($value); + + return $value; + } + /** * Coerce value to a percentage * - * @param array $value + * @param array|Number $value * * @return integer|float */ protected function coercePercent($value) { - if ($value[0] === Type::T_NUMBER) { - if (! empty($value[2]['%'])) { - return $value[1] / 100; + if ($value instanceof Number) { + if ($value->hasUnit('%')) { + return $value->getDimension() / 100; } - return $value[1]; + return $value->getDimension(); } return 0; @@ -5520,7 +6564,7 @@ class Compiler * * @api * - * @param array $value + * @param array|Number $value * * @return array * @@ -5531,7 +6575,7 @@ class Compiler $value = $this->coerceMap($value); if ($value[0] !== Type::T_MAP) { - $this->throwError('expecting map, %s received', $value[0]); + throw $this->error('expecting map, %s received', $value[0]); } return $value; @@ -5542,7 +6586,7 @@ class Compiler * * @api * - * @param array $value + * @param array|Number $value * * @return array * @@ -5551,7 +6595,7 @@ class Compiler public function assertList($value) { if ($value[0] !== Type::T_LIST) { - $this->throwError('expecting list, %s received', $value[0]); + throw $this->error('expecting list, %s received', $value[0]); } return $value; @@ -5562,7 +6606,7 @@ class Compiler * * @api * - * @param array $value + * @param array|Number $value * * @return array * @@ -5574,7 +6618,7 @@ class Compiler return $color; } - $this->throwError('expecting color, %s received', $value[0]); + throw $this->error('expecting color, %s received', $value[0]); } /** @@ -5582,21 +6626,49 @@ class Compiler * * @api * - * @param array $value + * @param array|Number $value + * @param string $varName * - * @return integer|float + * @return Number * * @throws \Exception */ - public function assertNumber($value) + public function assertNumber($value, $varName = null) { - if ($value[0] !== Type::T_NUMBER) { - $this->throwError('expecting number, %s received', $value[0]); + if (!$value instanceof Number) { + $value = $this->compileValue($value); + $var_display = ($varName ? " \${$varName}:" : ''); + throw $this->error("Error:{$var_display} $value is not a number."); } - return $value[1]; + return $value; } + /** + * Assert value is a integer + * + * @api + * + * @param array|Number $value + * @param string $varName + * + * @return integer + * + * @throws \Exception + */ + public function assertInteger($value, $varName = null) + { + + $value = $this->assertNumber($value, $varName)->getDimension(); + if (round($value - \intval($value), Number::PRECISION) > 0) { + $var_display = ($varName ? " \${$varName}:" : ''); + throw $this->error("Error:{$var_display} $value is not an integer."); + } + + return intval($value); + } + + /** * Make sure a color's components don't go out of bounds * @@ -5685,7 +6757,7 @@ class Compiler } if ($h * 3 < 2) { - return $m1 + ($m2 - $m1) * (2/3 - $h) * 6; + return $m1 + ($m2 - $m1) * (2 / 3 - $h) * 6; } return $m1; @@ -5715,9 +6787,9 @@ class Compiler $m2 = $l <= 0.5 ? $l * ($s + 1) : $l + $s - $l * $s; $m1 = $l * 2 - $m2; - $r = $this->hueToRGB($m1, $m2, $h + 1/3) * 255; + $r = $this->hueToRGB($m1, $m2, $h + 1 / 3) * 255; $g = $this->hueToRGB($m1, $m2, $h) * 255; - $b = $this->hueToRGB($m1, $m2, $h - 1/3) * 255; + $b = $this->hueToRGB($m1, $m2, $h - 1 / 3) * 255; $out = [Type::T_COLOR, $r, $g, $b]; @@ -5726,10 +6798,27 @@ class Compiler // Built in functions - protected static $libCall = ['name', 'args...']; + protected static $libCall = ['function', 'args...']; protected function libCall($args, $kwargs) { - $name = $this->compileStringContent($this->coerceString($this->reduce(array_shift($args), true))); + $functionReference = array_shift($args); + + if (in_array($functionReference[0], [Type::T_STRING, Type::T_KEYWORD])) { + $name = $this->compileStringContent($this->coerceString($functionReference)); + $warning = "DEPRECATION WARNING: Passing a string to call() is deprecated and will be illegal\n" + . "in Sass 4.0. Use call(function-reference($name)) instead."; + fwrite($this->stderr, "$warning\n\n"); + $functionReference = $this->libGetFunction([$functionReference]); + } + + if ($functionReference === static::$null) { + return static::$null; + } + + if (! in_array($functionReference[0], [Type::T_FUNCTION_REFERENCE, Type::T_FUNCTION])) { + throw $this->error('Function reference expected, got ' . $functionReference[0]); + } + $callArgs = []; // $kwargs['args'] is [Type::T_LIST, ',', [..]] @@ -5743,7 +6832,29 @@ class Compiler $callArgs[] = [$varname, $arg, false]; } - return $this->reduce([Type::T_FUNCTION_CALL, $name, $callArgs]); + return $this->reduce([Type::T_FUNCTION_CALL, $functionReference, $callArgs]); + } + + + protected static $libGetFunction = [ + ['name'], + ['name', 'css'] + ]; + protected function libGetFunction($args) + { + $name = $this->compileStringContent($this->coerceString(array_shift($args))); + $isCss = false; + + if (count($args)) { + $isCss = array_shift($args); + $isCss = (($isCss === static::$true) ? true : false); + } + + if ($isCss) { + return [Type::T_FUNCTION, $name, [Type::T_LIST, ',', []]]; + } + + return $this->getFunctionReference($name, true); } protected static $libIf = ['condition', 'if-true', 'if-false:']; @@ -5763,11 +6874,8 @@ class Compiler { list($list, $value) = $args; - if ($value[0] === Type::T_MAP) { - return static::$null; - } - - if ($list[0] === Type::T_MAP || + if ( + $list[0] === Type::T_MAP || $list[0] === Type::T_STRING || $list[0] === Type::T_KEYWORD || $list[0] === Type::T_INTERPOLATE @@ -5779,8 +6887,25 @@ class Compiler return static::$null; } + // Numbers are represented with value objects, for which the PHP equality operator does not + // match the Sass rules (and we cannot overload it). As they are the only type of values + // represented with a value object for now, they require a special case. + if ($value instanceof Number) { + $key = 0; + foreach ($list[2] as $item) { + $key++; + $itemValue = $this->normalizeValue($item); + + if ($itemValue instanceof Number && $value->equals($itemValue)) { + return new Number($key, ''); + } + } + return static::$null; + } + $values = []; + foreach ($list[2] as $item) { $values[] = $this->normalizeValue($item); } @@ -5798,7 +6923,7 @@ class Compiler ['red', 'green', 'blue', 'alpha'] ]; protected function libRgb($args, $kwargs, $funcName = 'rgb') { - switch (count($args)) { + switch (\count($args)) { case 1: if (! $color = $this->coerceColor($args[0], true)) { $color = [Type::T_STRING, '', [$funcName . '(', $args[0], ')']]; @@ -5809,7 +6934,7 @@ class Compiler $color = [Type::T_COLOR, $args[0], $args[1], $args[2]]; if (! $color = $this->coerceColor($color)) { - $color = [Type::T_STRING, '', [$funcName .'(', $args[0], ', ', $args[1], ', ', $args[2], ')']]; + $color = [Type::T_STRING, '', [$funcName . '(', $args[0], ', ', $args[1], ', ', $args[2], ')']]; } return $color; @@ -5854,20 +6979,27 @@ class Compiler return $this->libRgb($args, $kwargs, 'rgba'); } - // helper function for adjust_color, change_color, and scale_color + /** + * Helper function for adjust_color, change_color, and scale_color + * + * @param array $args + * @param callable $fn + * + * @return array + */ protected function alterColor($args, $fn) { $color = $this->assertColor($args[0]); foreach ([1 => 1, 2 => 2, 3 => 3, 7 => 4] as $iarg => $irgba) { if (isset($args[$iarg])) { - $val = $this->assertNumber($args[$iarg]); + $val = $this->assertNumber($args[$iarg])->getDimension(); if (! isset($color[$irgba])) { $color[$irgba] = (($irgba < 4) ? 0 : 1); } - $color[$irgba] = call_user_func($fn, $color[$irgba], $val, $iarg); + $color[$irgba] = \call_user_func($fn, $color[$irgba], $val, $iarg); } } @@ -5876,8 +7008,8 @@ class Compiler foreach ([4 => 1, 5 => 2, 6 => 3] as $iarg => $ihsl) { if (! empty($args[$iarg])) { - $val = $this->assertNumber($args[$iarg]); - $hsl[$ihsl] = call_user_func($fn, $hsl[$ihsl], $val, $iarg); + $val = $this->assertNumber($args[$iarg])->getDimension(); + $hsl[$ihsl] = \call_user_func($fn, $hsl[$ihsl], $val, $iarg); } } @@ -5958,6 +7090,11 @@ class Compiler protected function libIeHexStr($args) { $color = $this->coerceColor($args[0]); + + if (\is_null($color)) { + throw $this->error('Error: argument `$color` of `ie-hex-str($color)` must be a color'); + } + $color[4] = isset($color[4]) ? round(255 * $color[4]) : 255; return [Type::T_STRING, '', [sprintf('#%02X%02X%02X%02X', $color[4], $color[1], $color[2], $color[3])]]; @@ -5968,6 +7105,10 @@ class Compiler { $color = $this->coerceColor($args[0]); + if (\is_null($color)) { + throw $this->error('Error: argument `$color` of `red($color)` must be a color'); + } + return $color[1]; } @@ -5976,6 +7117,10 @@ class Compiler { $color = $this->coerceColor($args[0]); + if (\is_null($color)) { + throw $this->error('Error: argument `$color` of `green($color)` must be a color'); + } + return $color[2]; } @@ -5984,6 +7129,10 @@ class Compiler { $color = $this->coerceColor($args[0]); + if (\is_null($color)) { + throw $this->error('Error: argument `$color` of `blue($color)` must be a color'); + } + return $color[3]; } @@ -6003,7 +7152,7 @@ class Compiler { $value = $args[0]; - if ($value[0] === Type::T_NUMBER) { + if ($value instanceof Number) { return null; } @@ -6011,7 +7160,10 @@ class Compiler } // mix two colors - protected static $libMix = ['color-1', 'color-2', 'weight:0.5']; + protected static $libMix = [ + ['color1', 'color2', 'weight:0.5'], + ['color-1', 'color-2', 'weight:0.5'] + ]; protected function libMix($args) { list($first, $second, $weight) = $args; @@ -6047,42 +7199,74 @@ class Compiler return $this->fixColor($new); } - protected static $libHsl =[ + protected static $libHsl = [ ['channels'], ['hue', 'saturation', 'lightness'], ['hue', 'saturation', 'lightness', 'alpha'] ]; protected function libHsl($args, $kwargs, $funcName = 'hsl') { - if (count($args) == 1) { - if ($args[0][0] !== Type::T_LIST || count($args[0][2]) < 3 || count($args[0][2]) > 4) { + $args_to_check = $args; + + if (\count($args) == 1) { + if ($args[0][0] !== Type::T_LIST || \count($args[0][2]) < 3 || \count($args[0][2]) > 4) { return [Type::T_STRING, '', [$funcName . '(', $args[0], ')']]; } $args = $args[0][2]; + $args_to_check = $kwargs['channels'][2]; } - $hue = $this->compileColorPartValue($args[0], 0, 360, false, false, true); - $saturation = $this->compileColorPartValue($args[1], 0, 100, false); - $lightness = $this->compileColorPartValue($args[2], 0, 100, false); + foreach ($kwargs as $k => $arg) { + if (in_array($arg[0], [Type::T_FUNCTION_CALL]) && in_array($arg[1], ['min', 'max'])) { + return null; + } + } + foreach ($args_to_check as $k => $arg) { + if (in_array($arg[0], [Type::T_FUNCTION_CALL]) && in_array($arg[1], ['min', 'max'])) { + if (count($kwargs) > 1 || ($k >= 2 && count($args) === 4)) { + return null; + } + + $args[$k] = $this->stringifyFncallArgs($arg); + } + + if ( + $k >= 2 && count($args) === 4 && + in_array($arg[0], [Type::T_FUNCTION_CALL, Type::T_FUNCTION]) && + in_array($arg[1], ['calc','env']) + ) { + return null; + } + } + + $hue = $this->reduce($args[0]); + $saturation = $this->reduce($args[1]); + $lightness = $this->reduce($args[2]); $alpha = null; - if (count($args) === 4) { + if (\count($args) === 4) { $alpha = $this->compileColorPartValue($args[3], 0, 100, false); - if (! is_numeric($hue) || ! is_numeric($saturation) || ! is_numeric($lightness) || ! is_numeric($alpha)) { + if (!$hue instanceof Number || !$saturation instanceof Number || ! $lightness instanceof Number || ! is_numeric($alpha)) { return [Type::T_STRING, '', [$funcName . '(', $args[0], ', ', $args[1], ', ', $args[2], ', ', $args[3], ')']]; } } else { - if (! is_numeric($hue) || ! is_numeric($saturation) || ! is_numeric($lightness)) { + if (!$hue instanceof Number || !$saturation instanceof Number || ! $lightness instanceof Number) { return [Type::T_STRING, '', [$funcName . '(', $args[0], ', ', $args[1], ', ', $args[2], ')']]; } } - $color = $this->toRGB($hue, $saturation, $lightness); + $hueValue = $hue->getDimension() % 360; - if (! is_null($alpha)) { + while ($hueValue < 0) { + $hueValue += 360; + } + + $color = $this->toRGB($hueValue, max(0, min($saturation->getDimension(), 100)), max(0, min($lightness->getDimension(), 100))); + + if (! \is_null($alpha)) { $color[4] = $alpha; } @@ -6091,7 +7275,8 @@ class Compiler protected static $libHsla = [ ['channels'], - ['hue', 'saturation', 'lightness', 'alpha:1'] ]; + ['hue', 'saturation', 'lightness'], + ['hue', 'saturation', 'lightness', 'alpha']]; protected function libHsla($args, $kwargs) { return $this->libHsl($args, $kwargs, 'hsla'); @@ -6103,7 +7288,7 @@ class Compiler $color = $this->assertColor($args[0]); $hsl = $this->toHSL($color[1], $color[2], $color[3]); - return new Node\Number($hsl[1], 'deg'); + return new Number($hsl[1], 'deg'); } protected static $libSaturation = ['color']; @@ -6112,7 +7297,7 @@ class Compiler $color = $this->assertColor($args[0]); $hsl = $this->toHSL($color[1], $color[2], $color[3]); - return new Node\Number($hsl[2], '%'); + return new Number($hsl[2], '%'); } protected static $libLightness = ['color']; @@ -6121,7 +7306,7 @@ class Compiler $color = $this->assertColor($args[0]); $hsl = $this->toHSL($color[1], $color[2], $color[3]); - return new Node\Number($hsl[3], '%'); + return new Number($hsl[3], '%'); } protected function adjustHsl($color, $idx, $amount) @@ -6141,7 +7326,7 @@ class Compiler protected function libAdjustHue($args) { $color = $this->assertColor($args[0]); - $degrees = $this->assertNumber($args[1]); + $degrees = $this->assertNumber($args[1])->getDimension(); return $this->adjustHsl($color, 1, $degrees); } @@ -6164,15 +7349,20 @@ class Compiler return $this->adjustHsl($color, 3, -$amount); } - protected static $libSaturate = [['color', 'amount'], ['number']]; + protected static $libSaturate = [['color', 'amount'], ['amount']]; protected function libSaturate($args) { $value = $args[0]; - if ($value[0] === Type::T_NUMBER) { + if ($value instanceof Number) { return null; } + if (count($args) === 1) { + $val = $this->compileValue($value); + throw $this->error("\$amount: $val is not a number"); + } + $color = $this->assertColor($value); $amount = 100 * $this->coercePercent($args[1]); @@ -6193,7 +7383,7 @@ class Compiler { $value = $args[0]; - if ($value[0] === Type::T_NUMBER) { + if ($value instanceof Number) { return null; } @@ -6217,7 +7407,7 @@ class Compiler $weight = $this->coercePercent($weight); } - if ($value[0] === Type::T_NUMBER) { + if ($value instanceof Number) { return null; } @@ -6228,7 +7418,7 @@ class Compiler $inverted[3] = 255 - $inverted[3]; if ($weight < 1) { - return $this->libMix([$inverted, $color, [Type::T_NUMBER, $weight]]); + return $this->libMix([$inverted, $color, new Number($weight, '')]); } return $inverted; @@ -6290,131 +7480,120 @@ class Compiler $value = $args[0]; if ($value[0] === Type::T_STRING && ! empty($value[1])) { + $value[1] = '"'; return $value; } return [Type::T_STRING, '"', [$value]]; } - protected static $libPercentage = ['value']; + protected static $libPercentage = ['number']; protected function libPercentage($args) { - return new Node\Number($this->coercePercent($args[0]) * 100, '%'); + $num = $this->assertNumber($args[0], 'number'); + $num->assertNoUnits('number'); + + return new Number($num->getDimension() * 100, '%'); } - protected static $libRound = ['value']; + protected static $libRound = ['number']; protected function libRound($args) { - $num = $args[0]; + $num = $this->assertNumber($args[0], 'number'); - return new Node\Number(round($num[1]), $num[2]); + return new Number(round($num->getDimension()), $num->getNumeratorUnits(), $num->getDenominatorUnits()); } - protected static $libFloor = ['value']; + protected static $libFloor = ['number']; protected function libFloor($args) { - $num = $args[0]; + $num = $this->assertNumber($args[0], 'number'); - return new Node\Number(floor($num[1]), $num[2]); + return new Number(floor($num->getDimension()), $num->getNumeratorUnits(), $num->getDenominatorUnits()); } - protected static $libCeil = ['value']; + protected static $libCeil = ['number']; protected function libCeil($args) { - $num = $args[0]; + $num = $this->assertNumber($args[0], 'number'); - return new Node\Number(ceil($num[1]), $num[2]); + return new Number(ceil($num->getDimension()), $num->getNumeratorUnits(), $num->getDenominatorUnits()); } - protected static $libAbs = ['value']; + protected static $libAbs = ['number']; protected function libAbs($args) { - $num = $args[0]; + $num = $this->assertNumber($args[0], 'number'); - return new Node\Number(abs($num[1]), $num[2]); + return new Number(abs($num->getDimension()), $num->getNumeratorUnits(), $num->getDenominatorUnits()); } protected function libMin($args) { - $numbers = $this->getNormalizedNumbers($args); + /** + * @var Number|null + */ $min = null; - foreach ($numbers as $key => $number) { - if (is_null($min) || $number[1] <= $min[1]) { - $min = [$key, $number[1]]; + foreach ($args as $arg) { + $number = $this->assertNumber($arg); + + if (\is_null($min) || $min->greaterThan($number)) { + $min = $number; } } - return $args[$min[0]]; + if (!\is_null($min)) { + return $min; + } + + throw $this->error('At least one argument must be passed.'); } protected function libMax($args) { - $numbers = $this->getNormalizedNumbers($args); + /** + * @var Number|null + */ $max = null; - foreach ($numbers as $key => $number) { - if (is_null($max) || $number[1] >= $max[1]) { - $max = [$key, $number[1]]; + foreach ($args as $arg) { + $number = $this->assertNumber($arg); + + if (\is_null($max) || $max->lessThan($number)) { + $max = $number; } } - return $args[$max[0]]; - } - - /** - * Helper to normalize args containing numbers - * - * @param array $args - * - * @return array - */ - protected function getNormalizedNumbers($args) - { - $unit = null; - $originalUnit = null; - $numbers = []; - - foreach ($args as $key => $item) { - if ($item[0] !== Type::T_NUMBER) { - $this->throwError('%s is not a number', $item[0]); - break; - } - - $number = $item->normalize(); - - if (is_null($unit)) { - $unit = $number[2]; - $originalUnit = $item->unitStr(); - } elseif ($number[1] && $unit !== $number[2]) { - $this->throwError('Incompatible units: "%s" and "%s".', $originalUnit, $item->unitStr()); - break; - } - - $numbers[$key] = $number; + if (!\is_null($max)) { + return $max; } - return $numbers; + throw $this->error('At least one argument must be passed.'); } protected static $libLength = ['list']; protected function libLength($args) { - $list = $this->coerceList($args[0]); + $list = $this->coerceList($args[0], ',', true); - return count($list[2]); + return \count($list[2]); } //protected static $libListSeparator = ['list...']; protected function libListSeparator($args) { - if (count($args) > 1) { + if (\count($args) > 1) { return 'comma'; } + if (! \in_array($args[0][0], [Type::T_LIST, Type::T_MAP])) { + return 'space'; + } + $list = $this->coerceList($args[0]); - if (count($list[2]) <= 1) { + if (\count($list[2]) <= 1 && empty($list['enclosing'])) { return 'space'; } @@ -6428,13 +7607,13 @@ class Compiler protected static $libNth = ['list', 'n']; protected function libNth($args) { - $list = $this->coerceList($args[0]); - $n = $this->assertNumber($args[1]); + $list = $this->coerceList($args[0], ',', false); + $n = $this->assertNumber($args[1])->getDimension(); if ($n > 0) { $n--; } elseif ($n < 0) { - $n += count($list[2]); + $n += \count($list[2]); } return isset($list[2][$n]) ? $list[2][$n] : static::$defaultValue; @@ -6444,18 +7623,16 @@ class Compiler protected function libSetNth($args) { $list = $this->coerceList($args[0]); - $n = $this->assertNumber($args[1]); + $n = $this->assertNumber($args[1])->getDimension(); if ($n > 0) { $n--; } elseif ($n < 0) { - $n += count($list[2]); + $n += \count($list[2]); } if (! isset($list[2][$n])) { - $this->throwError('Invalid argument for "n"'); - - return null; + throw $this->error('Invalid argument for "n"'); } $list[2][$n] = $args[2]; @@ -6469,10 +7646,10 @@ class Compiler $map = $this->assertMap($args[0]); $key = $args[1]; - if (! is_null($key)) { + if (! \is_null($key)) { $key = $this->compileStringContent($this->coerceString($key)); - for ($i = count($map[1]) - 1; $i >= 0; $i--) { + for ($i = \count($map[1]) - 1; $i >= 0; $i--) { if ($key === $this->compileStringContent($this->coerceString($map[1][$i]))) { return $map[2][$i]; } @@ -6500,14 +7677,20 @@ class Compiler return [Type::T_LIST, ',', $values]; } - protected static $libMapRemove = ['map', 'key']; + protected static $libMapRemove = ['map', 'key...']; protected function libMapRemove($args) { $map = $this->assertMap($args[0]); - $key = $this->compileStringContent($this->coerceString($args[1])); + $keyList = $this->assertList($args[1]); - for ($i = count($map[1]) - 1; $i >= 0; $i--) { - if ($key === $this->compileStringContent($this->coerceString($map[1][$i]))) { + $keys = []; + + foreach ($keyList[2] as $key) { + $keys[] = $this->compileStringContent($this->coerceString($key)); + } + + for ($i = \count($map[1]) - 1; $i >= 0; $i--) { + if (in_array($this->compileStringContent($this->coerceString($map[1][$i])), $keys)) { array_splice($map[1], $i, 1); array_splice($map[2], $i, 1); } @@ -6522,7 +7705,7 @@ class Compiler $map = $this->assertMap($args[0]); $key = $this->compileStringContent($this->coerceString($args[1])); - for ($i = count($map[1]) - 1; $i >= 0; $i--) { + for ($i = \count($map[1]) - 1; $i >= 0; $i--) { if ($key === $this->compileStringContent($this->coerceString($map[1][$i]))) { return true; } @@ -6531,7 +7714,10 @@ class Compiler return false; } - protected static $libMapMerge = ['map-1', 'map-2']; + protected static $libMapMerge = [ + ['map1', 'map2'], + ['map-1', 'map-2'] + ]; protected function libMapMerge($args) { $map1 = $this->assertMap($args[0]); @@ -6575,13 +7761,21 @@ class Compiler { $list = $args[0]; $this->coerceList($list, ' '); + if (! empty($list['enclosing']) && $list['enclosing'] === 'bracket') { return true; } + return false; } - + /** + * @param array $list1 + * @param array|Number|null $sep + * + * @return string + * @throws CompilerException + */ protected function listSeparatorForJoin($list1, $sep) { if (! isset($sep)) { @@ -6605,8 +7799,8 @@ class Compiler { list($list1, $list2, $sep, $bracketed) = $args; - $list1 = $this->coerceList($list1, ' '); - $list2 = $this->coerceList($list2, ' '); + $list1 = $this->coerceList($list1, ' ', true); + $list2 = $this->coerceList($list2, ' ', true); $sep = $this->listSeparatorForJoin($list1, $sep); if ($bracketed === static::$true) { @@ -6620,6 +7814,7 @@ class Compiler } else { $bracketed = $this->compileValue($bracketed); $bracketed = ! ! $bracketed; + if ($bracketed === true) { $bracketed = true; } @@ -6627,18 +7822,22 @@ class Compiler if ($bracketed === 'auto') { $bracketed = false; + if (! empty($list1['enclosing']) && $list1['enclosing'] === 'bracket') { $bracketed = true; } } $res = [Type::T_LIST, $sep, array_merge($list1[2], $list2[2])]; + if (isset($list1['enclosing'])) { $res['enlcosing'] = $list1['enclosing']; } + if ($bracketed) { $res['enclosing'] = 'bracket'; } + return $res; } @@ -6647,13 +7846,14 @@ class Compiler { list($list1, $value, $sep) = $args; - $list1 = $this->coerceList($list1, ' '); - $sep = $this->listSeparatorForJoin($list1, $sep); + $list1 = $this->coerceList($list1, ' ', true); + $sep = $this->listSeparatorForJoin($list1, $sep); + $res = [Type::T_LIST, $sep, array_merge($list1[2], [$value])]; - $res = [Type::T_LIST, $sep, array_merge($list1[2], [$value])]; if (isset($list1['enclosing'])) { $res['enclosing'] = $list1['enclosing']; } + return $res; } @@ -6666,21 +7866,28 @@ class Compiler $lists = []; $firstList = array_shift($args); - foreach ($firstList[2] as $key => $item) { - $list = [Type::T_LIST, '', [$item]]; + $result = [Type::T_LIST, ',', $lists]; + if (! \is_null($firstList)) { + foreach ($firstList[2] as $key => $item) { + $list = [Type::T_LIST, '', [$item]]; - foreach ($args as $arg) { - if (isset($arg[2][$key])) { - $list[2][] = $arg[2][$key]; - } else { - break 2; + foreach ($args as $arg) { + if (isset($arg[2][$key])) { + $list[2][] = $arg[2][$key]; + } else { + break 2; + } } + + $lists[] = $list; } - $lists[] = $list; + $result[2] = $lists; + } else { + $result['enclosing'] = 'parent'; } - return [Type::T_LIST, ',', $lists]; + return $result; } protected static $libTypeOf = ['value']; @@ -6702,6 +7909,9 @@ class Compiler case Type::T_FUNCTION: return 'string'; + case Type::T_FUNCTION_REFERENCE: + return 'function'; + case Type::T_LIST: if (isset($value[3]) && $value[3]) { return 'arglist'; @@ -6718,7 +7928,7 @@ class Compiler { $num = $args[0]; - if ($num[0] === Type::T_NUMBER) { + if ($num instanceof Number) { return [Type::T_STRING, '"', [$num->unitStr()]]; } @@ -6730,54 +7940,67 @@ class Compiler { $value = $args[0]; - return $value[0] === Type::T_NUMBER && $value->unitless(); + return $value instanceof Number && $value->unitless(); } - protected static $libComparable = ['number-1', 'number-2']; + protected static $libComparable = [ + ['number1', 'number2'], + ['number-1', 'number-2'] + ]; protected function libComparable($args) { list($number1, $number2) = $args; - if (! isset($number1[0]) || $number1[0] !== Type::T_NUMBER || - ! isset($number2[0]) || $number2[0] !== Type::T_NUMBER + if ( + ! $number1 instanceof Number || + ! $number2 instanceof Number ) { - $this->throwError('Invalid argument(s) for "comparable"'); - - return null; + throw $this->error('Invalid argument(s) for "comparable"'); } - $number1 = $number1->normalize(); - $number2 = $number2->normalize(); - - return $number1[2] === $number2[2] || $number1->unitless() || $number2->unitless(); + return $number1->isComparableTo($number2); } protected static $libStrIndex = ['string', 'substring']; protected function libStrIndex($args) { - $string = $this->coerceString($args[0]); + $string = $this->assertString($args[0], 'string'); $stringContent = $this->compileStringContent($string); - $substring = $this->coerceString($args[1]); + $substring = $this->assertString($args[1], 'substring'); $substringContent = $this->compileStringContent($substring); - $result = strpos($stringContent, $substringContent); + if (! \strlen($substringContent)) { + $result = 0; + } else { + $result = Util::mbStrpos($stringContent, $substringContent); + } - return $result === false ? static::$null : new Node\Number($result + 1, ''); + return $result === false ? static::$null : new Number($result + 1, ''); } protected static $libStrInsert = ['string', 'insert', 'index']; protected function libStrInsert($args) { - $string = $this->coerceString($args[0]); + $string = $this->assertString($args[0], 'string'); $stringContent = $this->compileStringContent($string); - $insert = $this->coerceString($args[1]); + $insert = $this->assertString($args[1], 'insert'); $insertContent = $this->compileStringContent($insert); - list(, $index) = $args[2]; + $index = $this->assertInteger($args[2], 'index'); + if ($index > 0) { + $index = $index - 1; + } + if ($index < 0) { + $index = Util::mbStrlen($stringContent) + 1 + $index; + } - $string[2] = [substr_replace($stringContent, $insertContent, $index - 1, 0)]; + $string[2] = [ + Util::mbSubstr($stringContent, 0, $index), + $insertContent, + Util::mbSubstr($stringContent, $index) + ]; return $string; } @@ -6785,10 +8008,10 @@ class Compiler protected static $libStrLength = ['string']; protected function libStrLength($args) { - $string = $this->coerceString($args[0]); + $string = $this->assertString($args[0], 'string'); $stringContent = $this->compileStringContent($string); - return new Node\Number(strlen($stringContent), ''); + return new Number(Util::mbStrlen($stringContent), ''); } protected static $libStrSlice = ['string', 'start-at', 'end-at:-1']; @@ -6823,7 +8046,7 @@ class Compiler $string = $this->coerceString($args[0]); $stringContent = $this->compileStringContent($string); - $string[2] = [function_exists('mb_strtolower') ? mb_strtolower($stringContent) : strtolower($stringContent)]; + $string[2] = [$this->stringTransformAsciiOnly($stringContent, 'strtolower')]; return $string; } @@ -6834,11 +8057,38 @@ class Compiler $string = $this->coerceString($args[0]); $stringContent = $this->compileStringContent($string); - $string[2] = [function_exists('mb_strtoupper') ? mb_strtoupper($stringContent) : strtoupper($stringContent)]; + $string[2] = [$this->stringTransformAsciiOnly($stringContent, 'strtoupper')]; return $string; } + /** + * Apply a filter on a string content, only on ascii chars + * let extended chars untouched + * + * @param string $stringContent + * @param callable $filter + * @return string + */ + protected function stringTransformAsciiOnly($stringContent, $filter) + { + $mblength = Util::mbStrlen($stringContent); + if ($mblength === strlen($stringContent)) { + return $filter($stringContent); + } + $filteredString = ""; + for ($i = 0; $i < $mblength; $i++) { + $char = Util::mbSubstr($stringContent, $i, 1); + if (strlen($char) > 1) { + $filteredString .= $char; + } else { + $filteredString .= $filter($char); + } + } + + return $filteredString; + } + protected static $libFeatureExists = ['feature']; protected function libFeatureExists($args) { @@ -6846,7 +8096,7 @@ class Compiler $name = $this->compileStringContent($string); return $this->toBool( - array_key_exists($name, $this->registeredFeatures) ? $this->registeredFeatures[$name] : false + \array_key_exists($name, $this->registeredFeatures) ? $this->registeredFeatures[$name] : false ); } @@ -6870,7 +8120,7 @@ class Compiler // built-in functions $f = $this->getBuiltinFunction($name); - return $this->toBool(is_callable($f)); + return $this->toBool(\is_callable($f)); } protected static $libGlobalVariableExists = ['name']; @@ -6914,22 +8164,25 @@ class Compiler return [Type::T_STRING, '', ['counter(' . implode(',', $list) . ')']]; } - protected static $libRandom = ['limit']; + protected static $libRandom = ['limit:null']; protected function libRandom($args) { - if (isset($args[0])) { - $n = $this->assertNumber($args[0]); + if (isset($args[0]) & $args[0] !== static::$null) { + $n = $this->assertNumber($args[0])->getDimension(); if ($n < 1) { - $this->throwError("limit must be greater than or equal to 1"); - - return null; + throw $this->error("\$limit must be greater than or equal to 1"); } - return new Node\Number(mt_rand(1, $n), ''); + if (round($n - \intval($n), Number::PRECISION) > 0) { + throw $this->error("Expected \$limit to be an integer but got $n for `random`"); + } + + return new Number(mt_rand(1, \intval($n)), ''); } - return new Node\Number(mt_rand(1, mt_getrandmax()), ''); + $max = mt_getrandmax(); + return new Number(mt_rand(0, $max - 1) / $max, ''); } protected function libUniqueId() @@ -6937,7 +8190,9 @@ class Compiler static $id; if (! isset($id)) { - $id = mt_rand(0, pow(36, 8)); + $id = PHP_INT_SIZE === 4 + ? mt_rand(0, pow(36, 5)) . str_pad(mt_rand(0, pow(36, 5)) % 10000000, 7, '0', STR_PAD_LEFT) + : mt_rand(0, pow(36, 8)); } $id += mt_rand(0, 10) + 1; @@ -6950,24 +8205,30 @@ class Compiler if ($value === static::$null) { $value = [Type::T_KEYWORD, 'null']; } + $stringValue = [$value]; + if ($value[0] === Type::T_LIST) { if (end($value[2]) === static::$null) { array_pop($value[2]); $value[2][] = [Type::T_STRING, '', ['']]; $force_enclosing_display = true; } - if (! empty($value['enclosing'])) { - if ($force_enclosing_display - || ($value['enclosing'] === 'bracket' ) - || !count($value[2])) { - $value['enclosing'] = 'forced_'.$value['enclosing']; - $force_enclosing_display = true; - } + + if ( + ! empty($value['enclosing']) && + ($force_enclosing_display || + ($value['enclosing'] === 'bracket') || + ! \count($value[2])) + ) { + $value['enclosing'] = 'forced_' . $value['enclosing']; + $force_enclosing_display = true; } + foreach ($value[2] as $k => $listelement) { $value[2][$k] = $this->inspectFormatValue($listelement, $force_enclosing_display); } + $stringValue = [$value]; } @@ -6978,6 +8239,7 @@ class Compiler protected function libInspect($args) { $value = $args[0]; + return $this->inspectFormatValue($value); } @@ -6988,27 +8250,68 @@ class Compiler * * @return array|boolean */ - protected function getSelectorArg($arg) + protected function getSelectorArg($arg, $varname = null, $allowParent = false) { static $parser = null; - if (is_null($parser)) { + if (\is_null($parser)) { $parser = $this->parserFactory(__METHOD__); } + if (! $this->checkSelectorArgType($arg)) { + $var_display = ($varname ? ' $' . $varname . ':' : ''); + $var_value = $this->compileValue($arg); + throw $this->error("Error:{$var_display} $var_value is not a valid selector: it must be a string," + . " a list of strings, or a list of lists of strings"); + } + $arg = $this->libUnquote([$arg]); $arg = $this->compileValue($arg); $parsedSelector = []; - if ($parser->parseSelector($arg, $parsedSelector)) { + if ($parser->parseSelector($arg, $parsedSelector, true)) { $selector = $this->evalSelectors($parsedSelector); $gluedSelector = $this->glueFunctionSelectors($selector); + if (! $allowParent) { + foreach ($gluedSelector as $selector) { + foreach ($selector as $s) { + if (in_array(static::$selfSelector, $s)) { + $var_display = ($varname ? ' $' . $varname . ':' : ''); + throw $this->error("Error:{$var_display} Parent selectors aren't allowed here."); + } + } + } + } + return $gluedSelector; } - return false; + $var_display = ($varname ? ' $' . $varname . ':' : ''); + throw $this->error("Error:{$var_display} expected more input, invalid selector."); + } + + /** + * Check variable type for getSelectorArg() function + * @param array $arg + * @param int $maxDepth + * @return bool + */ + protected function checkSelectorArgType($arg, $maxDepth = 2) + { + if ($arg[0] === Type::T_LIST && $maxDepth > 0) { + foreach ($arg[2] as $elt) { + if (! $this->checkSelectorArgType($elt, $maxDepth - 1)) { + return false; + } + } + return true; + } + if (!in_array($arg[0], [Type::T_STRING, Type::T_KEYWORD])) { + return false; + } + return true; } /** @@ -7030,8 +8333,8 @@ class Compiler { list($super, $sub) = $args; - $super = $this->getSelectorArg($super); - $sub = $this->getSelectorArg($sub); + $super = $this->getSelectorArg($super, 'super'); + $sub = $this->getSelectorArg($sub, 'sub'); return $this->isSuperSelector($super, $sub); } @@ -7047,12 +8350,30 @@ class Compiler protected function isSuperSelector($super, $sub) { // one and only one selector for each arg - if (! $super || count($super) !== 1) { - $this->throwError("Invalid super selector for isSuperSelector()"); + if (! $super) { + throw $this->error('Invalid super selector for isSuperSelector()'); } - if (! $sub || count($sub) !== 1) { - $this->throwError("Invalid sub selector for isSuperSelector()"); + if (! $sub) { + throw $this->error('Invalid sub selector for isSuperSelector()'); + } + + if (count($sub) > 1) { + foreach ($sub as $s) { + if (! $this->isSuperSelector($super, [$s])) { + return false; + } + } + return true; + } + + if (count($super) > 1) { + foreach ($super as $s) { + if ($this->isSuperSelector([$s], $sub)) { + return true; + } + } + return false; } $super = reset($super); @@ -7079,7 +8400,7 @@ class Compiler $nextMustMatch = true; $i++; } else { - while ($i < count($sub) && ! $this->isSuperPart($node, $sub[$i])) { + while ($i < \count($sub) && ! $this->isSuperPart($node, $sub[$i])) { if ($nextMustMatch) { return false; } @@ -7087,7 +8408,7 @@ class Compiler $i++; } - if ($i >= count($sub)) { + if ($i >= \count($sub)) { return false; } @@ -7112,11 +8433,11 @@ class Compiler $i = 0; foreach ($superParts as $superPart) { - while ($i < count($subParts) && $subParts[$i] !== $superPart) { + while ($i < \count($subParts) && $subParts[$i] !== $superPart) { $i++; } - if ($i >= count($subParts)) { + if ($i >= \count($subParts)) { return false; } @@ -7133,11 +8454,14 @@ class Compiler $args = reset($args); $args = $args[2]; - if (count($args) < 1) { - $this->throwError("selector-append() needs at least 1 argument"); + if (\count($args) < 1) { + throw $this->error('selector-append() needs at least 1 argument'); } - $selectors = array_map([$this, 'getSelectorArg'], $args); + $selectors = []; + foreach ($args as $arg) { + $selectors[] = $this->getSelectorArg($arg, 'selector'); + } return $this->formatOutputSelector($this->selectorAppend($selectors)); } @@ -7156,14 +8480,14 @@ class Compiler $lastSelectors = array_pop($selectors); if (! $lastSelectors) { - $this->throwError("Invalid selector list in selector-append()"); + throw $this->error('Invalid selector list in selector-append()'); } - while (count($selectors)) { + while (\count($selectors)) { $previousSelectors = array_pop($selectors); if (! $previousSelectors) { - $this->throwError("Invalid selector list in selector-append()"); + throw $this->error('Invalid selector list in selector-append()'); } // do the trick, happening $lastSelector to $previousSelector @@ -7193,17 +8517,20 @@ class Compiler return $lastSelectors; } - protected static $libSelectorExtend = ['selectors', 'extendee', 'extender']; + protected static $libSelectorExtend = [ + ['selector', 'extendee', 'extender'], + ['selectors', 'extendee', 'extender'] + ]; protected function libSelectorExtend($args) { list($selectors, $extendee, $extender) = $args; - $selectors = $this->getSelectorArg($selectors); - $extendee = $this->getSelectorArg($extendee); - $extender = $this->getSelectorArg($extender); + $selectors = $this->getSelectorArg($selectors, 'selector'); + $extendee = $this->getSelectorArg($extendee, 'extendee'); + $extender = $this->getSelectorArg($extender, 'extender'); if (! $selectors || ! $extendee || ! $extender) { - $this->throwError("selector-extend() invalid arguments"); + throw $this->error('selector-extend() invalid arguments'); } $extended = $this->extendOrReplaceSelectors($selectors, $extendee, $extender); @@ -7211,17 +8538,20 @@ class Compiler return $this->formatOutputSelector($extended); } - protected static $libSelectorReplace = ['selectors', 'original', 'replacement']; + protected static $libSelectorReplace = [ + ['selector', 'original', 'replacement'], + ['selectors', 'original', 'replacement'] + ]; protected function libSelectorReplace($args) { list($selectors, $original, $replacement) = $args; - $selectors = $this->getSelectorArg($selectors); - $original = $this->getSelectorArg($original); - $replacement = $this->getSelectorArg($replacement); + $selectors = $this->getSelectorArg($selectors, 'selector'); + $original = $this->getSelectorArg($original, 'original'); + $replacement = $this->getSelectorArg($replacement, 'replacement'); if (! $selectors || ! $original || ! $replacement) { - $this->throwError("selector-replace() invalid arguments"); + throw $this->error('selector-replace() invalid arguments'); } $replaced = $this->extendOrReplaceSelectors($selectors, $original, $replacement, true); @@ -7260,12 +8590,12 @@ class Compiler $extended[] = $selector; } - $n = count($extended); + $n = \count($extended); $this->matchExtends($selector, $extended); // if didnt match, keep the original selector if we are in a replace operation - if ($replace and count($extended) === $n) { + if ($replace && \count($extended) === $n) { $extended[] = $selector; } } @@ -7283,11 +8613,15 @@ class Compiler $args = reset($args); $args = $args[2]; - if (count($args) < 1) { - $this->throwError("selector-nest() needs at least 1 argument"); + if (\count($args) < 1) { + throw $this->error('selector-nest() needs at least 1 argument'); + } + + $selectorsMap = []; + foreach ($args as $arg) { + $selectorsMap[] = $this->getSelectorArg($arg, 'selector', true); } - $selectorsMap = array_map([$this, 'getSelectorArg'], $args); $envs = []; foreach ($selectorsMap as $selectors) { @@ -7304,11 +8638,14 @@ class Compiler return $this->formatOutputSelector($outputSelectors); } - protected static $libSelectorParse = ['selectors']; + protected static $libSelectorParse = [ + ['selector'], + ['selectors'] + ]; protected function libSelectorParse($args) { $selectors = reset($args); - $selectors = $this->getSelectorArg($selectors); + $selectors = $this->getSelectorArg($selectors, 'selector'); return $this->formatOutputSelector($selectors); } @@ -7318,11 +8655,11 @@ class Compiler { list($selectors1, $selectors2) = $args; - $selectors1 = $this->getSelectorArg($selectors1); - $selectors2 = $this->getSelectorArg($selectors2); + $selectors1 = $this->getSelectorArg($selectors1, 'selectors1'); + $selectors2 = $this->getSelectorArg($selectors2, 'selectors2'); if (! $selectors1 || ! $selectors2) { - $this->throwError("selector-unify() invalid arguments"); + throw $this->error('selector-unify() invalid arguments'); } // only consider the first compound of each @@ -7346,11 +8683,11 @@ class Compiler */ protected function unifyCompoundSelectors($compound1, $compound2) { - if (! count($compound1)) { + if (! \count($compound1)) { return $compound2; } - if (! count($compound2)) { + if (! \count($compound2)) { return $compound1; } @@ -7367,7 +8704,7 @@ class Compiler $unifiedSelectors = [$unifiedCompound]; // do the rest - while (count($compound1) || count($compound2)) { + while (\count($compound1) || \count($compound2)) { $part1 = end($compound1); $part2 = end($compound2); @@ -7458,7 +8795,7 @@ class Compiler * @param array $part * @param array $compound * - * @return array|boolean + * @return array|false */ protected function matchPartInCompound($part, $compound) { @@ -7467,7 +8804,7 @@ class Compiler $after = []; // try to find a match by tag name first - while (count($before)) { + while (\count($before)) { $p = array_pop($before); if ($partTag && $partTag !== '*' && $partTag == $this->findTagName($p)) { @@ -7481,11 +8818,11 @@ class Compiler $before = $compound; $after = []; - while (count($before)) { + while (\count($before)) { $p = array_pop($before); if ($this->checkCompatibleTags($partTag, $this->findTagName($p))) { - if (count(array_intersect($part, $p))) { + if (\count(array_intersect($part, $p))) { return [$before, $p, $after]; } } @@ -7553,7 +8890,7 @@ class Compiler * @param string $tag1 * @param string $tag2 * - * @return array|boolean + * @return array|false */ protected function checkCompatibleTags($tag1, $tag2) { @@ -7561,12 +8898,12 @@ class Compiler $tags = array_unique($tags); $tags = array_filter($tags); - if (count($tags) > 1) { + if (\count($tags) > 1) { $tags = array_diff($tags, ['*']); } // not compatible nodes - if (count($tags) > 1) { + if (\count($tags) > 1) { return false; } @@ -7595,7 +8932,7 @@ class Compiler protected function libSimpleSelectors($args) { $selector = reset($args); - $selector = $this->getSelectorArg($selector); + $selector = $this->getSelectorArg($selector, 'selector'); // remove selectors list layer, keeping the first one $selector = reset($selector); @@ -7611,4 +8948,23 @@ class Compiler return [Type::T_LIST, ',', $listParts]; } + + protected static $libScssphpGlob = ['pattern']; + protected function libScssphpGlob($args) + { + $string = $this->coerceString($args[0]); + $pattern = $this->compileStringContent($string); + $matches = glob($pattern); + $listParts = []; + + foreach ($matches as $match) { + if (! is_file($match)) { + continue; + } + + $listParts[] = [Type::T_STRING, '"', [$match]]; + } + + return [Type::T_LIST, ',', $listParts]; + } } diff --git a/lib/scssphp/Compiler/Environment.php b/lib/scssphp/Compiler/Environment.php index 03eb86a5d57..dc2f86c1fbe 100644 --- a/lib/scssphp/Compiler/Environment.php +++ b/lib/scssphp/Compiler/Environment.php @@ -1,8 +1,9 @@ */ -class CompilerException extends \Exception +class CompilerException extends \Exception implements SassException { } diff --git a/lib/scssphp/Exception/ParserException.php b/lib/scssphp/Exception/ParserException.php index 2fa12dd7a8a..5237f30795d 100644 --- a/lib/scssphp/Exception/ParserException.php +++ b/lib/scssphp/Exception/ParserException.php @@ -1,8 +1,9 @@ */ -class ParserException extends \Exception +class ParserException extends \Exception implements SassException { + /** + * @var array + */ + private $sourcePosition; + + /** + * Get source position + * + * @api + */ + public function getSourcePosition() + { + return $this->sourcePosition; + } + + /** + * Set source position + * + * @api + * + * @param array $sourcePosition + */ + public function setSourcePosition($sourcePosition) + { + $this->sourcePosition = $sourcePosition; + } } diff --git a/lib/scssphp/Exception/RangeException.php b/lib/scssphp/Exception/RangeException.php index ee36c97e186..b18c32d6c3d 100644 --- a/lib/scssphp/Exception/RangeException.php +++ b/lib/scssphp/Exception/RangeException.php @@ -1,8 +1,9 @@ */ -class RangeException extends \Exception +class RangeException extends \Exception implements SassException { } diff --git a/lib/scssphp/Exception/SassException.php b/lib/scssphp/Exception/SassException.php new file mode 100644 index 00000000000..9f62b3cd24b --- /dev/null +++ b/lib/scssphp/Exception/SassException.php @@ -0,0 +1,7 @@ + */ -class ServerException extends \Exception +class ServerException extends \Exception implements SassException { } diff --git a/lib/scssphp/Formatter.php b/lib/scssphp/Formatter.php index e17770458a9..d52a6744a28 100644 --- a/lib/scssphp/Formatter.php +++ b/lib/scssphp/Formatter.php @@ -1,8 +1,9 @@ assignSeparator . $value . ';'; } + /** + * Return custom property assignment + * differs in that you have to keep spaces in the value as is + * + * @api + * + * @param string $name + * @param mixed $value + * + * @return string + */ + public function customProperty($name, $value) + { + return rtrim($name) . trim($this->assignSeparator) . $value . ';'; + } + /** * Output lines inside a block * @@ -126,8 +143,7 @@ abstract class Formatter protected function blockLines(OutputBlock $block) { $inner = $this->indentStr(); - - $glue = $this->break . $inner; + $glue = $this->break . $inner; $this->write($inner . implode($glue, $block->lines)); @@ -282,7 +298,8 @@ abstract class Formatter * Maybe Strip semi-colon appended by property(); it's a separator, not a terminator * will be striped for real before a closing, otherwise displayed unchanged starting the next write */ - if (! $this->keepSemicolons && + if ( + ! $this->keepSemicolons && $str && (strpos($str, ';') !== false) && (substr($str, -1) === ';') @@ -293,22 +310,39 @@ abstract class Formatter } if ($this->sourceMapGenerator) { - $this->sourceMapGenerator->addMapping( - $this->currentLine, - $this->currentColumn, - $this->currentBlock->sourceLine, - //columns from parser are off by one - $this->currentBlock->sourceColumn > 0 ? $this->currentBlock->sourceColumn - 1 : 0, - $this->currentBlock->sourceName - ); - $lines = explode("\n", $str); - $lineCount = count($lines); - $this->currentLine += $lineCount-1; - $lastLine = array_pop($lines); - $this->currentColumn = ($lineCount === 1 ? $this->currentColumn : 0) + strlen($lastLine); + foreach ($lines as $line) { + // If the written line starts is empty, adding a mapping would add it for + // a non-existent column as we are at the end of the line + if ($line !== '') { + $this->sourceMapGenerator->addMapping( + $this->currentLine, + $this->currentColumn, + $this->currentBlock->sourceLine, + //columns from parser are off by one + $this->currentBlock->sourceColumn > 0 ? $this->currentBlock->sourceColumn - 1 : 0, + $this->currentBlock->sourceName + ); + } + + $this->currentLine++; + $this->currentColumn = 0; + } + + if ($lastLine !== '') { + $this->sourceMapGenerator->addMapping( + $this->currentLine, + $this->currentColumn, + $this->currentBlock->sourceLine, + //columns from parser are off by one + $this->currentBlock->sourceColumn > 0 ? $this->currentBlock->sourceColumn - 1 : 0, + $this->currentBlock->sourceName + ); + } + + $this->currentColumn += \strlen($lastLine); } echo $str; diff --git a/lib/scssphp/Formatter/Compact.php b/lib/scssphp/Formatter/Compact.php index 591f0c92ef9..249920ef535 100644 --- a/lib/scssphp/Formatter/Compact.php +++ b/lib/scssphp/Formatter/Compact.php @@ -1,8 +1,9 @@ + * + * @deprecated since 1.4.0. Use the Compressed formatter instead. */ class Compact extends Formatter { @@ -25,6 +28,8 @@ class Compact extends Formatter */ public function __construct() { + @trigger_error('The Compact formatter is deprecated since 1.4.0. Use the Compressed formatter instead.', E_USER_DEPRECATED); + $this->indentLevel = 0; $this->indentChar = ''; $this->break = ''; diff --git a/lib/scssphp/Formatter/Compressed.php b/lib/scssphp/Formatter/Compressed.php index ec4722eaf4b..d666a66564d 100644 --- a/lib/scssphp/Formatter/Compressed.php +++ b/lib/scssphp/Formatter/Compressed.php @@ -1,8 +1,9 @@ + * + * @deprecated since 1.4.0. Use the Compressed formatter instead. */ class Crunched extends Formatter { @@ -26,6 +28,8 @@ class Crunched extends Formatter */ public function __construct() { + @trigger_error('The Crunched formatter is deprecated since 1.4.0. Use the Compressed formatter instead.', E_USER_DEPRECATED); + $this->indentLevel = 0; $this->indentChar = ' '; $this->break = ''; diff --git a/lib/scssphp/Formatter/Debug.php b/lib/scssphp/Formatter/Debug.php index 94e70c815df..c676601bb52 100644 --- a/lib/scssphp/Formatter/Debug.php +++ b/lib/scssphp/Formatter/Debug.php @@ -1,8 +1,9 @@ + * + * @deprecated since 1.4.0. */ class Debug extends Formatter { @@ -26,6 +28,8 @@ class Debug extends Formatter */ public function __construct() { + @trigger_error('The Debug formatter is deprecated since 1.4.0.', E_USER_DEPRECATED); + $this->indentLevel = 0; $this->indentChar = ''; $this->break = "\n"; diff --git a/lib/scssphp/Formatter/Expanded.php b/lib/scssphp/Formatter/Expanded.php index 9549c6cfa26..b7cbde18d83 100644 --- a/lib/scssphp/Formatter/Expanded.php +++ b/lib/scssphp/Formatter/Expanded.php @@ -1,8 +1,9 @@ lines as $index => $line) { if (substr($line, 0, 2) === '/*') { - $block->lines[$index] = preg_replace('/[\r\n]+/', $glue, $line); + $block->lines[$index] = preg_replace('/\r\n?|\n|\f/', $this->break, $line); } } diff --git a/lib/scssphp/Formatter/Nested.php b/lib/scssphp/Formatter/Nested.php index f9e7f037fd2..3249c18285e 100644 --- a/lib/scssphp/Formatter/Nested.php +++ b/lib/scssphp/Formatter/Nested.php @@ -1,8 +1,9 @@ + * + * @deprecated since 1.4.0. Use the Expanded formatter instead. */ class Nested extends Formatter { @@ -32,6 +34,8 @@ class Nested extends Formatter */ public function __construct() { + @trigger_error('The Nested formatter is deprecated since 1.4.0. Use the Expanded formatter instead.', E_USER_DEPRECATED); + $this->indentLevel = 0; $this->indentChar = ' '; $this->break = "\n"; @@ -58,12 +62,11 @@ class Nested extends Formatter protected function blockLines(OutputBlock $block) { $inner = $this->indentStr(); - - $glue = $this->break . $inner; + $glue = $this->break . $inner; foreach ($block->lines as $index => $line) { if (substr($line, 0, 2) === '/*') { - $block->lines[$index] = preg_replace('/[\r\n]+/', $glue, $line); + $block->lines[$index] = preg_replace('/\r\n?|\n|\f/', $this->break, $line); } } @@ -90,7 +93,7 @@ class Nested extends Formatter $previousHasSelector = false; } - $isMediaOrDirective = in_array($block->type, [Type::T_DIRECTIVE, Type::T_MEDIA]); + $isMediaOrDirective = \in_array($block->type, [Type::T_DIRECTIVE, Type::T_MEDIA]); $isSupport = ($block->type === Type::T_DIRECTIVE && $block->selectors && strpos(implode('', $block->selectors), '@supports') !== false); @@ -98,7 +101,8 @@ class Nested extends Formatter array_pop($depths); $this->depth--; - if (! $this->depth && ($block->depth <= 1 || (! $this->indentLevel && $block->type === Type::T_COMMENT)) && + if ( + ! $this->depth && ($block->depth <= 1 || (! $this->indentLevel && $block->type === Type::T_COMMENT)) && (($block->selectors && ! $isMediaOrDirective) || $previousHasSelector) ) { $downLevel = $this->break; @@ -119,10 +123,12 @@ class Nested extends Formatter if ($block->depth > end($depths)) { if (! $previousEmpty || $this->depth < 1) { $this->depth++; + $depths[] = $block->depth; } else { // keep the current depth unchanged but take the block depth as a new reference for following blocks array_pop($depths); + $depths[] = $block->depth; } } diff --git a/lib/scssphp/Formatter/OutputBlock.php b/lib/scssphp/Formatter/OutputBlock.php index 3e6fd9289d1..fe0321bde58 100644 --- a/lib/scssphp/Formatter/OutputBlock.php +++ b/lib/scssphp/Formatter/OutputBlock.php @@ -1,8 +1,9 @@ [ 'in' => 1, 'pc' => 6, @@ -64,75 +69,75 @@ class Number extends Node implements \ArrayAccess ], 'dpi' => [ 'dpi' => 1, - 'dpcm' => 2.54, - 'dppx' => 96, + 'dpcm' => 1 / 2.54, + 'dppx' => 1 / 96, ], ]; /** * @var integer|float */ - public $dimension; + private $dimension; /** - * @var array + * @var string[] + * @phpstan-var list */ - public $units; + private $numeratorUnits; + + /** + * @var string[] + * @phpstan-var list + */ + private $denominatorUnits; /** * Initialize number * - * @param mixed $dimension - * @param mixed $initialUnit + * @param integer|float $dimension + * @param string[]|string $numeratorUnits + * @param string[] $denominatorUnits + * + * @phpstan-param list|string $numeratorUnits + * @phpstan-param list $denominatorUnits */ - public function __construct($dimension, $initialUnit) + public function __construct($dimension, $numeratorUnits, array $denominatorUnits = []) { - $this->type = Type::T_NUMBER; + if (is_string($numeratorUnits)) { + $numeratorUnits = $numeratorUnits ? [$numeratorUnits] : []; + } elseif (isset($numeratorUnits['numerator_units'], $numeratorUnits['denominator_units'])) { + // TODO get rid of this once `$number[2]` is not used anymore + $denominatorUnits = $numeratorUnits['denominator_units']; + $numeratorUnits = $numeratorUnits['numerator_units']; + } + $this->dimension = $dimension; - $this->units = is_array($initialUnit) - ? $initialUnit - : ($initialUnit ? [$initialUnit => 1] - : []); + $this->numeratorUnits = $numeratorUnits; + $this->denominatorUnits = $denominatorUnits; } /** - * Coerce number to target units - * - * @param array $units - * - * @return \ScssPhp\ScssPhp\Node\Number + * @return float|int */ - public function coerce($units) + public function getDimension() { - if ($this->unitless()) { - return new Number($this->dimension, $units); - } - - $dimension = $this->dimension; - - foreach (static::$unitTable['in'] as $unit => $conv) { - $from = isset($this->units[$unit]) ? $this->units[$unit] : 0; - $to = isset($units[$unit]) ? $units[$unit] : 0; - $factor = pow($conv, $from - $to); - $dimension /= $factor; - } - - return new Number($dimension, $units); + return $this->dimension; } /** - * Normalize number - * - * @return \ScssPhp\ScssPhp\Node\Number + * @return string[] */ - public function normalize() + public function getNumeratorUnits() { - $dimension = $this->dimension; - $units = []; + return $this->numeratorUnits; + } - $this->normalizeUnits($dimension, $units, 'in'); - - return new Number($dimension, $units); + /** + * @return string[] + */ + public function getDenominatorUnits() + { + return $this->denominatorUnits; } /** @@ -141,14 +146,15 @@ class Number extends Node implements \ArrayAccess public function offsetExists($offset) { if ($offset === -3) { - return ! is_null($this->sourceColumn); + return ! \is_null($this->sourceColumn); } if ($offset === -2) { - return ! is_null($this->sourceLine); + return ! \is_null($this->sourceLine); } - if ($offset === -1 || + if ( + $offset === -1 || $offset === 0 || $offset === 1 || $offset === 2 @@ -175,13 +181,13 @@ class Number extends Node implements \ArrayAccess return $this->sourceIndex; case 0: - return $this->type; + return Type::T_NUMBER; case 1: return $this->dimension; case 2: - return $this->units; + return array('numerator_units' => $this->numeratorUnits, 'denominator_units' => $this->denominatorUnits); } } @@ -190,17 +196,7 @@ class Number extends Node implements \ArrayAccess */ public function offsetSet($offset, $value) { - if ($offset === 1) { - $this->dimension = $value; - } elseif ($offset === 2) { - $this->units = $value; - } elseif ($offset == -1) { - $this->sourceIndex = $value; - } elseif ($offset == -2) { - $this->sourceLine = $value; - } elseif ($offset == -3) { - $this->sourceColumn = $value; - } + throw new \BadMethodCallException('Number is immutable'); } /** @@ -208,17 +204,7 @@ class Number extends Node implements \ArrayAccess */ public function offsetUnset($offset) { - if ($offset === 1) { - $this->dimension = null; - } elseif ($offset === 2) { - $this->units = null; - } elseif ($offset === -1) { - $this->sourceIndex = null; - } elseif ($offset === -2) { - $this->sourceLine = null; - } elseif ($offset === -3) { - $this->sourceColumn = null; - } + throw new \BadMethodCallException('Number is immutable'); } /** @@ -228,7 +214,19 @@ class Number extends Node implements \ArrayAccess */ public function unitless() { - return ! array_sum($this->units); + return \count($this->numeratorUnits) === 0 && \count($this->denominatorUnits) === 0; + } + + /** + * Checks whether the number has exactly this unit + * + * @param string $unit + * + * @return bool + */ + public function hasUnit($unit) + { + return \count($this->numeratorUnits) === 1 && \count($this->denominatorUnits) === 0 && $this->numeratorUnits[0] === $unit; } /** @@ -238,22 +236,234 @@ class Number extends Node implements \ArrayAccess */ public function unitStr() { - $numerators = []; - $denominators = []; - - foreach ($this->units as $unit => $unitSize) { - if ($unitSize > 0) { - $numerators = array_pad($numerators, count($numerators) + $unitSize, $unit); - continue; - } - - if ($unitSize < 0) { - $denominators = array_pad($denominators, count($denominators) + $unitSize, $unit); - continue; - } + if ($this->unitless()) { + return ''; } - return implode('*', $numerators) . (count($denominators) ? '/' . implode('*', $denominators) : ''); + return self::getUnitString($this->numeratorUnits, $this->denominatorUnits); + } + + /** + * @param string|null $varName + * + * @return void + */ + public function assertNoUnits($varName = null) + { + if ($this->unitless()) { + return; + } + + throw SassScriptException::forArgument(sprintf('Expected %s to have no units', $this), $varName); + } + + /** + * @param Number $other + * + * @return void + */ + public function assertSameUnitOrUnitless(Number $other) + { + if ($other->unitless()) { + return; + } + + if ($this->numeratorUnits === $other->numeratorUnits && $this->denominatorUnits === $other->denominatorUnits) { + return; + } + + throw new SassScriptException(sprintf( + 'Incompatible units %s and %s.', + self::getUnitString($this->numeratorUnits, $this->denominatorUnits), + self::getUnitString($other->numeratorUnits, $other->denominatorUnits) + )); + } + + /** + * @param Number $other + * + * @return bool + */ + public function isComparableTo(Number $other) + { + if ($this->unitless() || $other->unitless()) { + return true; + } + + try { + $this->greaterThan($other); + return true; + } catch (SassScriptException $e) { + return false; + } + } + + /** + * @param Number $other + * + * @return bool + */ + public function lessThan(Number $other) + { + return $this->coerceUnits($other, function ($num1, $num2) { + return $num1 < $num2; + }); + } + + /** + * @param Number $other + * + * @return bool + */ + public function lessThanOrEqual(Number $other) + { + return $this->coerceUnits($other, function ($num1, $num2) { + return $num1 <= $num2; + }); + } + + /** + * @param Number $other + * + * @return bool + */ + public function greaterThan(Number $other) + { + return $this->coerceUnits($other, function ($num1, $num2) { + return $num1 > $num2; + }); + } + + /** + * @param Number $other + * + * @return bool + */ + public function greaterThanOrEqual(Number $other) + { + return $this->coerceUnits($other, function ($num1, $num2) { + return $num1 >= $num2; + }); + } + + /** + * @param Number $other + * + * @return Number + */ + public function plus(Number $other) + { + return $this->coerceNumber($other, function ($num1, $num2) { + return $num1 + $num2; + }); + } + + /** + * @param Number $other + * + * @return Number + */ + public function minus(Number $other) + { + return $this->coerceNumber($other, function ($num1, $num2) { + return $num1 - $num2; + }); + } + + /** + * @return Number + */ + public function unaryMinus() + { + return new Number(-$this->dimension, $this->numeratorUnits, $this->denominatorUnits); + } + + /** + * @param Number $other + * + * @return Number + */ + public function modulo(Number $other) + { + return $this->coerceNumber($other, function ($num1, $num2) { + if ($num2 == 0) { + return NAN; + } + + $result = fmod($num1, $num2); + + if ($result == 0) { + return 0; + } + + if ($num2 < 0 xor $num1 < 0) { + $result += $num2; + } + + return $result; + }); + } + + /** + * @param Number $other + * + * @return Number + */ + public function times(Number $other) + { + return $this->multiplyUnits($this->dimension * $other->dimension, $this->numeratorUnits, $this->denominatorUnits, $other->numeratorUnits, $other->denominatorUnits); + } + + /** + * @param Number $other + * + * @return Number + */ + public function dividedBy(Number $other) + { + if ($other->dimension == 0) { + if ($this->dimension == 0) { + $value = NAN; + } elseif ($this->dimension > 0) { + $value = INF; + } else { + $value = -INF; + } + } else { + $value = $this->dimension / $other->dimension; + } + + return $this->multiplyUnits($value, $this->numeratorUnits, $this->denominatorUnits, $other->denominatorUnits, $other->numeratorUnits); + } + + /** + * @param Number $other + * + * @return bool + */ + public function equals(Number $other) + { + // Unitless numbers are convertable to unit numbers, but not equal, so we special-case unitless here. + if ($this->unitless() !== $other->unitless()) { + return false; + } + + // In Sass, neither NaN nor Infinity are equal to themselves, while PHP defines INF==INF + if (is_nan($this->dimension) || is_nan($other->dimension) || !is_finite($this->dimension) || !is_finite($other->dimension)) { + return false; + } + + if ($this->unitless()) { + return round($this->dimension, self::PRECISION) == round($other->dimension, self::PRECISION); + } + + try { + return $this->coerceUnits($other, function ($num1, $num2) { + return round($num1,self::PRECISION) == round($num2, self::PRECISION); + }); + } catch (SassScriptException $e) { + return false; + } } /** @@ -265,35 +475,31 @@ class Number extends Node implements \ArrayAccess */ public function output(Compiler $compiler = null) { - $dimension = round($this->dimension, static::$precision); + $dimension = round($this->dimension, self::PRECISION); - $units = array_filter($this->units, function ($unitSize) { - return $unitSize; - }); - - if (count($units) > 1 && array_sum($units) === 0) { - $dimension = $this->dimension; - $units = []; - - $this->normalizeUnits($dimension, $units, 'in'); - - $dimension = round($dimension, static::$precision); - $units = array_filter($units, function ($unitSize) { - return $unitSize; - }); + if (is_nan($dimension)) { + return 'NaN'; } - $unitSize = array_sum($units); - - if ($compiler && ($unitSize > 1 || $unitSize < 0 || count($units) > 1)) { - $compiler->throwError((string) $dimension . $this->unitStr() . " isn't a valid CSS value."); + if ($dimension === INF) { + return 'Infinity'; } - reset($units); - $unit = key($units); - $dimension = number_format($dimension, static::$precision, '.', ''); + if ($dimension === -INF) { + return '-Infinity'; + } - return (static::$precision ? rtrim(rtrim($dimension, '0'), '.') : $dimension) . $unit; + if ($compiler) { + $unit = $this->unitStr(); + } elseif (isset($this->numeratorUnits[0])) { + $unit = $this->numeratorUnits[0]; + } else { + $unit = ''; + } + + $dimension = number_format($dimension, self::PRECISION, '.', ''); + + return rtrim(rtrim($dimension, '0'), '.') . $unit; } /** @@ -305,26 +511,227 @@ class Number extends Node implements \ArrayAccess } /** - * Normalize units + * @param Number $other + * @param callable $operation * - * @param integer|float $dimension - * @param array $units - * @param string $baseUnit + * @return Number + * + * @phpstan-param callable(int|float, int|float): (int|float) $operation */ - private function normalizeUnits(&$dimension, &$units, $baseUnit = 'in') + private function coerceNumber(Number $other, $operation) { - $dimension = $this->dimension; - $units = []; + $result = $this->coerceUnits($other, $operation); - foreach ($this->units as $unit => $exp) { - if (isset(static::$unitTable[$baseUnit][$unit])) { - $factor = pow(static::$unitTable[$baseUnit][$unit], $exp); + if (!$this->unitless()) { + return new Number($result, $this->numeratorUnits, $this->denominatorUnits); + } - $unit = $baseUnit; - $dimension /= $factor; + return new Number($result, $other->numeratorUnits, $other->denominatorUnits); + } + + /** + * @param Number $other + * @param callable $operation + * + * @return mixed + * + * @phpstan-template T + * @phpstan-param callable(int|float, int|float): T $operation + * @phpstan-return T + */ + private function coerceUnits(Number $other, $operation) + { + if (!$this->unitless()) { + $num1 = $this->dimension; + $num2 = $other->valueInUnits($this->numeratorUnits, $this->denominatorUnits); + } else { + $num1 = $this->valueInUnits($other->numeratorUnits, $other->denominatorUnits); + $num2 = $other->dimension; + } + + return \call_user_func($operation, $num1, $num2); + } + + /** + * @param string[] $numeratorUnits + * @param string[] $denominatorUnits + * + * @return int|float + * + * @phpstan-param list $numeratorUnits + * @phpstan-param list $denominatorUnits + */ + private function valueInUnits(array $numeratorUnits, array $denominatorUnits) + { + if ( + $this->unitless() + || (\count($numeratorUnits) === 0 && \count($denominatorUnits) === 0) + || ($this->numeratorUnits === $numeratorUnits && $this->denominatorUnits === $denominatorUnits) + ) { + return $this->dimension; + } + + $value = $this->dimension; + $oldNumerators = $this->numeratorUnits; + + foreach ($numeratorUnits as $newNumerator) { + foreach ($oldNumerators as $key => $oldNumerator) { + $conversionFactor = self::getConversionFactor($newNumerator, $oldNumerator); + + if (\is_null($conversionFactor)) { + continue; + } + + $value *= $conversionFactor; + unset($oldNumerators[$key]); + continue 2; } - $units[$unit] = $exp + (isset($units[$unit]) ? $units[$unit] : 0); + throw new SassScriptException(sprintf( + 'Incompatible units %s and %s.', + self::getUnitString($this->numeratorUnits, $this->denominatorUnits), + self::getUnitString($numeratorUnits, $denominatorUnits) + )); } + + $oldDenominators = $this->denominatorUnits; + + foreach ($denominatorUnits as $newDenominator) { + foreach ($oldDenominators as $key => $oldDenominator) { + $conversionFactor = self::getConversionFactor($newDenominator, $oldDenominator); + + if (\is_null($conversionFactor)) { + continue; + } + + $value /= $conversionFactor; + unset($oldDenominators[$key]); + continue 2; + } + + throw new SassScriptException(sprintf( + 'Incompatible units %s and %s.', + self::getUnitString($this->numeratorUnits, $this->denominatorUnits), + self::getUnitString($numeratorUnits, $denominatorUnits) + )); + } + + if (\count($oldNumerators) || \count($oldDenominators)) { + throw new SassScriptException(sprintf( + 'Incompatible units %s and %s.', + self::getUnitString($this->numeratorUnits, $this->denominatorUnits), + self::getUnitString($numeratorUnits, $denominatorUnits) + )); + } + + return $value; + } + + /** + * @param int|float $value + * @param string[] $numerators1 + * @param string[] $denominators1 + * @param string[] $numerators2 + * @param string[] $denominators2 + * + * @return Number + * + * @phpstan-param list $numerators1 + * @phpstan-param list $denominators1 + * @phpstan-param list $numerators2 + * @phpstan-param list $denominators2 + */ + private function multiplyUnits($value, array $numerators1, array $denominators1, array $numerators2, array $denominators2) + { + $newNumerators = array(); + + foreach ($numerators1 as $numerator) { + foreach ($denominators2 as $key => $denominator) { + $conversionFactor = self::getConversionFactor($numerator, $denominator); + + if (\is_null($conversionFactor)) { + continue; + } + + $value /= $conversionFactor; + unset($denominators2[$key]); + continue 2; + } + + $newNumerators[] = $numerator; + } + + foreach ($numerators2 as $numerator) { + foreach ($denominators1 as $key => $denominator) { + $conversionFactor = self::getConversionFactor($numerator, $denominator); + + if (\is_null($conversionFactor)) { + continue; + } + + $value /= $conversionFactor; + unset($denominators1[$key]); + continue 2; + } + + $newNumerators[] = $numerator; + } + + $newDenominators = array_values(array_merge($denominators1, $denominators2)); + + return new Number($value, $newNumerators, $newDenominators); + } + + /** + * Returns the number of [unit1]s per [unit2]. + * + * Equivalently, `1unit1 * conversionFactor(unit1, unit2) = 1unit2`. + * + * @param string $unit1 + * @param string $unit2 + * + * @return float|int|null + */ + private static function getConversionFactor($unit1, $unit2) + { + if ($unit1 === $unit2) { + return 1; + } + + foreach (static::$unitTable as $unitVariants) { + if (isset($unitVariants[$unit1]) && isset($unitVariants[$unit2])) { + return $unitVariants[$unit1] / $unitVariants[$unit2]; + } + } + + return null; + } + + /** + * Returns unit(s) as the product of numerator units divided by the product of denominator units + * + * @param string[] $numerators + * @param string[] $denominators + * + * @phpstan-param list $numerators + * @phpstan-param list $denominators + * + * @return string + */ + private static function getUnitString(array $numerators, array $denominators) + { + if (!\count($numerators)) { + if (\count($denominators) === 0) { + return 'no units'; + } + + if (\count($denominators) === 1) { + return $denominators[0] . '^-1'; + } + + return '(' . implode('*', $denominators) . ')^-1'; + } + + return implode('*', $numerators) . (\count($denominators) ? '/' . implode('*', $denominators) : ''); } } diff --git a/lib/scssphp/OutputStyle.php b/lib/scssphp/OutputStyle.php new file mode 100644 index 00000000000..c284639c13f --- /dev/null +++ b/lib/scssphp/OutputStyle.php @@ -0,0 +1,9 @@ + */ protected static $precedence = [ '=' => 0, @@ -38,7 +34,6 @@ class Parser 'and' => 2, '==' => 3, '!=' => 3, - '<=>' => 3, '<=' => 4, '>=' => 4, '<' => 4, @@ -50,38 +45,83 @@ class Parser '%' => 6, ]; + /** + * @var string + */ protected static $commentPattern; + /** + * @var string + */ protected static $operatorPattern; + /** + * @var string + */ protected static $whitePattern; + /** + * @var Cache|null + */ protected $cache; private $sourceName; private $sourceIndex; + /** + * @var array + */ private $sourcePositions; + /** + * @var array|null + */ private $charset; + /** + * The current offset in the buffer + * + * @var int + */ private $count; + /** + * @var Block + */ private $env; + /** + * @var bool + */ private $inParens; + /** + * @var bool + */ private $eatWhiteDefault; + /** + * @var bool + */ private $discardComments; + private $allowVars; + /** + * @var string + */ private $buffer; private $utf8; + /** + * @var string|null + */ private $encoding; private $patternModifiers; private $commentsSeen; + private $cssOnly; + /** * Constructor * * @api * - * @param string $sourceName - * @param integer $sourceIndex - * @param string $encoding - * @param \ScssPhp\ScssPhp\Cache $cache + * @param string $sourceName + * @param integer $sourceIndex + * @param string|null $encoding + * @param Cache|null $cache + * @param bool $cssOnly */ - public function __construct($sourceName, $sourceIndex = 0, $encoding = 'utf-8', $cache = null) + public function __construct($sourceName, $sourceIndex = 0, $encoding = 'utf-8', Cache $cache = null, $cssOnly = false) { $this->sourceName = $sourceName ?: '(stdin)'; $this->sourceIndex = $sourceIndex; @@ -89,10 +129,12 @@ class Parser $this->utf8 = ! $encoding || strtolower($encoding) === 'utf-8'; $this->patternModifiers = $this->utf8 ? 'Aisu' : 'Ais'; $this->commentsSeen = []; - $this->discardComments = false; + $this->commentsSeen = []; + $this->allowVars = true; + $this->cssOnly = $cssOnly; if (empty(static::$operatorPattern)) { - static::$operatorPattern = '([*\/%+-]|[!=]\=|\>\=?|\<\=\>|\<\=?|and|or)'; + static::$operatorPattern = '([*\/%+-]|[!=]\=|\>\=?|\<\=?|and|or)'; $commentSingle = '\/\/'; $commentMultiLeft = '\/\*'; @@ -104,9 +146,7 @@ class Parser : '/' . $commentSingle . '[^\n]*\s*|(' . static::$commentPattern . ')\s*|\s+/AisS'; } - if ($cache) { - $this->cache = $cache; - } + $this->cache = $cache; } /** @@ -128,9 +168,30 @@ class Parser * * @param string $msg * - * @throws \ScssPhp\ScssPhp\Exception\ParserException + * @throws ParserException + * + * @deprecated use "parseError" and throw the exception in the caller instead. */ public function throwParseError($msg = 'parse error') + { + @trigger_error( + 'The method "throwParseError" is deprecated. Use "parseError" and throw the exception in the caller instead', + E_USER_DEPRECATED + ); + + throw $this->parseError($msg); + } + + /** + * Creates a parser error + * + * @api + * + * @param string $msg + * + * @return ParserException + */ + public function parseError($msg = 'parse error') { list($line, $column) = $this->getSourcePosition($this->count); @@ -138,11 +199,21 @@ class Parser ? "line: $line, column: $column" : "$this->sourceName on line $line, at column $column"; - if ($this->peek("(.*?)(\n|$)", $m, $this->count)) { - throw new ParserException("$msg: failed at `$m[1]` $loc"); + if ($this->peek('(.*?)(\n|$)', $m, $this->count)) { + $this->restoreEncoding(); + + $e = new ParserException("$msg: failed at `$m[1]` $loc"); + $e->setSourcePosition([$this->sourceName, $line, $column]); + + return $e; } - throw new ParserException("$msg: $loc"); + $this->restoreEncoding(); + + $e = new ParserException("$msg: $loc"); + $e->setSourcePosition([$this->sourceName, $line, $column]); + + return $e; } /** @@ -152,19 +223,19 @@ class Parser * * @param string $buffer * - * @return \ScssPhp\ScssPhp\Block + * @return Block */ public function parse($buffer) { if ($this->cache) { - $cacheKey = $this->sourceName . ":" . md5($buffer); + $cacheKey = $this->sourceName . ':' . md5($buffer); $parseOptions = [ 'charset' => $this->charset, 'utf8' => $this->utf8, ]; - $v = $this->cache->getCache("parse", $cacheKey, $parseOptions); + $v = $this->cache->getCache('parse', $cacheKey, $parseOptions); - if (! is_null($v)) { + if (! \is_null($v)) { return $v; } } @@ -192,12 +263,12 @@ class Parser ; } - if ($this->count !== strlen($this->buffer)) { - $this->throwParseError(); + if ($this->count !== \strlen($this->buffer)) { + throw $this->parseError(); } if (! empty($this->env->parent)) { - $this->throwParseError('unclosed block'); + throw $this->parseError('unclosed block'); } if ($this->charset) { @@ -207,7 +278,7 @@ class Parser $this->restoreEncoding(); if ($this->cache) { - $this->cache->setCache("parse", $cacheKey, $this->env, $parseOptions); + $this->cache->setCache('parse', $cacheKey, $this->env, $parseOptions); } return $this->env; @@ -232,6 +303,7 @@ class Parser $this->buffer = (string) $buffer; $this->saveEncoding(); + $this->extractLineNumbers($this->buffer); $list = $this->valueList($out); @@ -247,10 +319,11 @@ class Parser * * @param string $buffer * @param string|array $out + * @param bool $shouldValidate * * @return boolean */ - public function parseSelector($buffer, &$out) + public function parseSelector($buffer, &$out, $shouldValidate = true) { $this->count = 0; $this->env = null; @@ -259,11 +332,21 @@ class Parser $this->buffer = (string) $buffer; $this->saveEncoding(); + $this->extractLineNumbers($this->buffer); + + // discard space/comments at the start + $this->discardComments = true; + $this->whitespace(); + $this->discardComments = false; $selector = $this->selectors($out); $this->restoreEncoding(); + if ($shouldValidate && $this->count !== strlen($buffer)) { + throw $this->parseError("`" . substr($buffer, $this->count) . "` is not a valid Selector in `$buffer`"); + } + return $selector; } @@ -286,6 +369,7 @@ class Parser $this->buffer = (string) $buffer; $this->saveEncoding(); + $this->extractLineNumbers($this->buffer); $isMediaQuery = $this->mediaQueryList($out); @@ -339,14 +423,17 @@ class Parser // the directives if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] === '@') { - if ($this->literal('@at-root', 8) && + if ( + $this->literal('@at-root', 8) && ($this->selectors($selector) || true) && ($this->map($with) || true) && - (($this->matchChar('(') - && $this->interpolation($with) - && $this->matchChar(')')) || true) && + (($this->matchChar('(') && + $this->interpolation($with) && + $this->matchChar(')')) || true) && $this->matchChar('{', false) ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + $atRoot = $this->pushSpecialBlock(Type::T_AT_ROOT, $s); $atRoot->selector = $selector; $atRoot->with = $with; @@ -356,7 +443,11 @@ class Parser $this->seek($s); - if ($this->literal('@media', 6) && $this->mediaQueryList($mediaQueryList) && $this->matchChar('{', false)) { + if ( + $this->literal('@media', 6) && + $this->mediaQueryList($mediaQueryList) && + $this->matchChar('{', false) + ) { $media = $this->pushSpecialBlock(Type::T_MEDIA, $s); $media->queryList = $mediaQueryList[2]; @@ -365,11 +456,14 @@ class Parser $this->seek($s); - if ($this->literal('@mixin', 6) && + if ( + $this->literal('@mixin', 6) && $this->keyword($mixinName) && ($this->argumentDef($args) || true) && $this->matchChar('{', false) ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + $mixin = $this->pushSpecialBlock(Type::T_MIXIN, $s); $mixin->name = $mixinName; $mixin->args = $args; @@ -379,17 +473,20 @@ class Parser $this->seek($s); - if ($this->literal('@include', 8) && - $this->keyword($mixinName) && - ($this->matchChar('(') && + if ( + ($this->literal('@include', 8) && + $this->keyword($mixinName) && + ($this->matchChar('(') && ($this->argValues($argValues) || true) && $this->matchChar(')') || true) && - ($this->end() || - ($this->literal('using', 5) && - $this->argumentDef($argUsing) && - ($this->end() || $this->matchChar('{') && $hasBlock = true)) || - $this->matchChar('{') && $hasBlock = true) + ($this->end()) || + ($this->literal('using', 5) && + $this->argumentDef($argUsing) && + ($this->end() || $this->matchChar('{') && $hasBlock = true)) || + $this->matchChar('{') && $hasBlock = true) ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + $child = [ Type::T_INCLUDE, $mixinName, @@ -410,10 +507,13 @@ class Parser $this->seek($s); - if ($this->literal('@scssphp-import-once', 20) && + if ( + $this->literal('@scssphp-import-once', 20) && $this->valueList($importPath) && $this->end() ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + $this->append([Type::T_SCSSPHP_IMPORT_ONCE, $importPath], $s); return true; @@ -421,10 +521,18 @@ class Parser $this->seek($s); - if ($this->literal('@import', 7) && + if ( + $this->literal('@import', 7) && $this->valueList($importPath) && + $importPath[0] !== Type::T_FUNCTION_CALL && $this->end() ) { + if ($this->cssOnly) { + $this->assertPlainCssValid([Type::T_IMPORT, $importPath], $s); + $this->append([Type::T_COMMENT, rtrim(substr($this->buffer, $s, $this->count - $s))]); + return true; + } + $this->append([Type::T_IMPORT, $importPath], $s); return true; @@ -432,10 +540,17 @@ class Parser $this->seek($s); - if ($this->literal('@import', 7) && + if ( + $this->literal('@import', 7) && $this->url($importPath) && $this->end() ) { + if ($this->cssOnly) { + $this->assertPlainCssValid([Type::T_IMPORT, $importPath], $s); + $this->append([Type::T_COMMENT, rtrim(substr($this->buffer, $s, $this->count - $s))]); + return true; + } + $this->append([Type::T_IMPORT, $importPath], $s); return true; @@ -443,10 +558,13 @@ class Parser $this->seek($s); - if ($this->literal('@extend', 7) && + if ( + $this->literal('@extend', 7) && $this->selectors($selectors) && $this->end() ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + // check for '!flag' $optional = $this->stripOptionalFlag($selectors); $this->append([Type::T_EXTEND, $selectors, $optional], $s); @@ -456,11 +574,14 @@ class Parser $this->seek($s); - if ($this->literal('@function', 9) && + if ( + $this->literal('@function', 9) && $this->keyword($fnName) && $this->argumentDef($args) && $this->matchChar('{', false) ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + $func = $this->pushSpecialBlock(Type::T_FUNCTION, $s); $func->name = $fnName; $func->args = $args; @@ -470,23 +591,13 @@ class Parser $this->seek($s); - if ($this->literal('@break', 6) && $this->end()) { - $this->append([Type::T_BREAK], $s); + if ( + $this->literal('@return', 7) && + ($this->valueList($retVal) || true) && + $this->end() + ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); - return true; - } - - $this->seek($s); - - if ($this->literal('@continue', 9) && $this->end()) { - $this->append([Type::T_CONTINUE], $s); - - return true; - } - - $this->seek($s); - - if ($this->literal('@return', 7) && ($this->valueList($retVal) || true) && $this->end()) { $this->append([Type::T_RETURN, isset($retVal) ? $retVal : [Type::T_NULL]], $s); return true; @@ -494,12 +605,15 @@ class Parser $this->seek($s); - if ($this->literal('@each', 5) && + if ( + $this->literal('@each', 5) && $this->genericList($varNames, 'variable', ',', false) && $this->literal('in', 2) && $this->valueList($list) && $this->matchChar('{', false) ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + $each = $this->pushSpecialBlock(Type::T_EACH, $s); foreach ($varNames[2] as $varName) { @@ -513,10 +627,22 @@ class Parser $this->seek($s); - if ($this->literal('@while', 6) && + if ( + $this->literal('@while', 6) && $this->expression($cond) && $this->matchChar('{', false) ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + + while ( + $cond[0] === Type::T_LIST && + ! empty($cond['enclosing']) && + $cond['enclosing'] === 'parent' && + \count($cond[2]) == 1 + ) { + $cond = reset($cond[2]); + } + $while = $this->pushSpecialBlock(Type::T_WHILE, $s); $while->cond = $cond; @@ -525,7 +651,8 @@ class Parser $this->seek($s); - if ($this->literal('@for', 4) && + if ( + $this->literal('@for', 4) && $this->variable($varName) && $this->literal('from', 4) && $this->expression($start) && @@ -534,6 +661,8 @@ class Parser $this->expression($end) && $this->matchChar('{', false) ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + $for = $this->pushSpecialBlock(Type::T_FOR, $s); $for->var = $varName[1]; $for->start = $start; @@ -545,14 +674,23 @@ class Parser $this->seek($s); - if ($this->literal('@if', 3) && $this->valueList($cond) && $this->matchChar('{', false)) { + if ( + $this->literal('@if', 3) && + $this->functionCallArgumentsList($cond, false, '{', false) + ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + $if = $this->pushSpecialBlock(Type::T_IF, $s); - while ($cond[0] === Type::T_LIST - && !empty($cond['enclosing']) - && $cond['enclosing'] === 'parent' - && count($cond[2]) == 1) { + + while ( + $cond[0] === Type::T_LIST && + ! empty($cond['enclosing']) && + $cond['enclosing'] === 'parent' && + \count($cond[2]) == 1 + ) { $cond = reset($cond[2]); } + $if->cond = $cond; $if->cases = []; @@ -561,10 +699,12 @@ class Parser $this->seek($s); - if ($this->literal('@debug', 6) && - $this->valueList($value) && - $this->end() + if ( + $this->literal('@debug', 6) && + $this->functionCallArgumentsList($value, false) ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + $this->append([Type::T_DEBUG, $value], $s); return true; @@ -572,10 +712,12 @@ class Parser $this->seek($s); - if ($this->literal('@warn', 5) && - $this->valueList($value) && - $this->end() + if ( + $this->literal('@warn', 5) && + $this->functionCallArgumentsList($value, false) ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + $this->append([Type::T_WARN, $value], $s); return true; @@ -583,10 +725,12 @@ class Parser $this->seek($s); - if ($this->literal('@error', 6) && - $this->valueList($value) && - $this->end() + if ( + $this->literal('@error', 6) && + $this->functionCallArgumentsList($value, false) ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + $this->append([Type::T_ERROR, $value], $s); return true; @@ -594,14 +738,16 @@ class Parser $this->seek($s); - #if ($this->literal('@content', 8)) - - if ($this->literal('@content', 8) && + if ( + $this->literal('@content', 8) && ($this->end() || $this->matchChar('(') && - $this->argValues($argContent) && - $this->matchChar(')') && - $this->end())) { + $this->argValues($argContent) && + $this->matchChar(')') && + $this->end()) + ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + $this->append([Type::T_MIXIN_CONTENT, isset($argContent) ? $argContent : null], $s); return true; @@ -617,7 +763,10 @@ class Parser if ($this->literal('@else', 5)) { if ($this->matchChar('{', false)) { $else = $this->pushSpecialBlock(Type::T_ELSE, $s); - } elseif ($this->literal('if', 2) && $this->valueList($cond) && $this->matchChar('{', false)) { + } elseif ( + $this->literal('if', 2) && + $this->functionCallArgumentsList($cond, false, '{', false) + ) { $else = $this->pushSpecialBlock(Type::T_ELSEIF, $s); $else->cond = $cond; } @@ -634,7 +783,8 @@ class Parser } // only retain the first @charset directive encountered - if ($this->literal('@charset', 8) && + if ( + $this->literal('@charset', 8) && $this->valueList($charset) && $this->end() ) { @@ -655,9 +805,10 @@ class Parser $this->seek($s); - if ($this->literal('@supports', 9) && - ($t1=$this->supportsQuery($supportQuery)) && - ($t2=$this->matchChar('{', false)) + if ( + $this->literal('@supports', 9) && + ($t1 = $this->supportsQuery($supportQuery)) && + ($t2 = $this->matchChar('{', false)) ) { $directive = $this->pushSpecialBlock(Type::T_DIRECTIVE, $s); $directive->name = 'supports'; @@ -669,11 +820,16 @@ class Parser $this->seek($s); // doesn't match built in directive, do generic one - if ($this->matchChar('@', false) && - $this->keyword($dirName) && - ($this->variable($dirValue) || $this->openString('{', $dirValue) || true) && - $this->matchChar('{', false) + if ( + $this->matchChar('@', false) && + $this->mixedKeyword($dirName) && + $this->directiveValue($dirValue, '{') ) { + if (count($dirName) === 1 && is_string(reset($dirName))) { + $dirName = reset($dirName); + } else { + $dirName = [Type::T_STRING, '', $dirName]; + } if ($dirName === 'media') { $directive = $this->pushSpecialBlock(Type::T_MEDIA, $s); } else { @@ -682,6 +838,7 @@ class Parser } if (isset($dirValue)) { + ! $this->cssOnly || ($dirValue = $this->assertPlainCssValid($dirValue)); $directive->value = $dirValue; } @@ -691,12 +848,38 @@ class Parser $this->seek($s); // maybe it's a generic blockless directive - if ($this->matchChar('@', false) && - $this->keyword($dirName) && - $this->valueList($dirValue) && - $this->end() + if ( + $this->matchChar('@', false) && + $this->mixedKeyword($dirName) && + ! $this->isKnownGenericDirective($dirName) && + ($this->end(false) || ($this->directiveValue($dirValue, '') && $this->end(false))) ) { - $this->append([Type::T_DIRECTIVE, [$dirName, $dirValue]], $s); + if (\count($dirName) === 1 && \is_string(\reset($dirName))) { + $dirName = \reset($dirName); + } else { + $dirName = [Type::T_STRING, '', $dirName]; + } + if ( + ! empty($this->env->parent) && + $this->env->type && + ! \in_array($this->env->type, [Type::T_DIRECTIVE, Type::T_MEDIA]) + ) { + $plain = \trim(\substr($this->buffer, $s, $this->count - $s)); + throw $this->parseError( + "Unknown directive `{$plain}` not allowed in `" . $this->env->type . "` block" + ); + } + // blockless directives with a blank line after keeps their blank lines after + // sass-spec compliance purpose + $s = $this->count; + $hasBlankLine = false; + if ($this->match('\s*?\n\s*\n', $out, false)) { + $hasBlankLine = true; + $this->seek($s); + } + $isNotRoot = ! empty($this->env->parent); + $this->append([Type::T_DIRECTIVE, [$dirName, $dirValue, $hasBlankLine, $isNotRoot]], $s); + $this->whitespace(); return true; } @@ -706,9 +889,60 @@ class Parser return false; } + $inCssSelector = null; + if ($this->cssOnly) { + $inCssSelector = (! empty($this->env->parent) && + ! in_array($this->env->type, [Type::T_DIRECTIVE, Type::T_MEDIA])); + } + // custom properties : right part is static + if (($this->customProperty($name) ) && $this->matchChar(':', false)) { + $start = $this->count; + + // but can be complex and finish with ; or } + foreach ([';','}'] as $ending) { + if ( + $this->openString($ending, $stringValue, '(', ')', false) && + $this->end() + ) { + $end = $this->count; + $value = $stringValue; + + // check if we have only a partial value due to nested [] or { } to take in account + $nestingPairs = [['[', ']'], ['{', '}']]; + + foreach ($nestingPairs as $nestingPair) { + $p = strpos($this->buffer, $nestingPair[0], $start); + + if ($p && $p < $end) { + $this->seek($start); + + if ( + $this->openString($ending, $stringValue, $nestingPair[0], $nestingPair[1], false) && + $this->end() && + $this->count > $end + ) { + $end = $this->count; + $value = $stringValue; + } + } + } + + $this->seek($end); + $this->append([Type::T_CUSTOM_PROPERTY, $name, $value], $s); + + return true; + } + } + + // TODO: output an error here if nothing found according to sass spec + } + + $this->seek($s); + // property shortcut // captures most properties before having to parse a selector - if ($this->keyword($name, false) && + if ( + $this->keyword($name, false) && $this->literal(': ', 2) && $this->valueList($value) && $this->end() @@ -722,11 +956,14 @@ class Parser $this->seek($s); // variable assigns - if ($this->variable($name) && + if ( + $this->variable($name) && $this->matchChar(':') && $this->valueList($value) && $this->end() ) { + ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + // check for '!flag' $assignmentFlags = $this->stripAssignmentFlags($value); $this->append([Type::T_ASSIGN, $name, $value, $assignmentFlags], $s); @@ -742,7 +979,12 @@ class Parser } // opening css block - if ($this->selectors($selectors) && $this->matchChar('{', false)) { + if ( + $this->selectors($selectors) && + $this->matchChar('{', false) + ) { + ! $this->cssOnly || ! $inCssSelector || $this->assertPlainCssValid(false); + $this->pushBlock($selectors, $s); if ($this->eatWhiteDefault) { @@ -756,12 +998,15 @@ class Parser $this->seek($s); // property assign, or nested assign - if ($this->propertyName($name) && $this->matchChar(':')) { + if ( + $this->propertyName($name) && + $this->matchChar(':') + ) { $foundSomething = false; if ($this->valueList($value)) { if (empty($this->env->parent)) { - $this->throwParseError('expected "{"'); + throw $this->parseError('expected "{"'); } $this->append([Type::T_ASSIGN, $name, $value], $s); @@ -769,6 +1014,8 @@ class Parser } if ($this->matchChar('{', false)) { + ! $this->cssOnly || $this->assertPlainCssValid(false); + $propBlock = $this->pushSpecialBlock(Type::T_NESTED_PROPERTY, $s); $propBlock->prefix = $name; $propBlock->hasValue = $foundSomething; @@ -818,7 +1065,8 @@ class Parser } // extra stuff - if ($this->matchChar(';') || + if ( + $this->matchChar(';') || $this->literal('