From bd854aa792e9adabd0a0d96f68354137986aabd7 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Thu, 30 Dec 2021 12:10:34 +0000 Subject: [PATCH] MDL-71706 libraries: upgrade to version 1.10.0 of ScssPhp. --- lib/scssphp/Base/Range.php | 17 +- lib/scssphp/Block.php | 14 +- lib/scssphp/Block/AtRootBlock.php | 37 + lib/scssphp/Block/CallableBlock.php | 45 + lib/scssphp/Block/ContentBlock.php | 38 + lib/scssphp/Block/DirectiveBlock.php | 37 + lib/scssphp/Block/EachBlock.php | 37 + lib/scssphp/Block/ElseBlock.php | 27 + lib/scssphp/Block/ElseifBlock.php | 32 + lib/scssphp/Block/ForBlock.php | 47 + lib/scssphp/Block/IfBlock.php | 37 + lib/scssphp/Block/MediaBlock.php | 37 + lib/scssphp/Block/NestedPropertyBlock.php | 37 + lib/scssphp/Block/WhileBlock.php | 32 + lib/scssphp/Cache.php | 48 +- lib/scssphp/Colors.php | 18 +- lib/scssphp/CompilationResult.php | 69 + lib/scssphp/Compiler.php | 3177 +++++++++++++----- lib/scssphp/Compiler/CachedResult.php | 77 + lib/scssphp/Compiler/Environment.php | 28 +- lib/scssphp/Exception/CompilerException.php | 2 + lib/scssphp/Exception/ParserException.php | 2 + lib/scssphp/Exception/RangeException.php | 2 + lib/scssphp/Exception/ServerException.php | 4 + lib/scssphp/Formatter.php | 26 +- lib/scssphp/Formatter/Compact.php | 2 + lib/scssphp/Formatter/Compressed.php | 6 +- lib/scssphp/Formatter/Crunched.php | 4 + lib/scssphp/Formatter/Debug.php | 2 + lib/scssphp/Formatter/Expanded.php | 2 + lib/scssphp/Formatter/Nested.php | 6 +- lib/scssphp/Formatter/OutputBlock.php | 16 +- lib/scssphp/Logger/LoggerInterface.php | 48 + lib/scssphp/Logger/QuietLogger.php | 27 + lib/scssphp/Logger/StreamLogger.php | 60 + lib/scssphp/Node.php | 8 +- lib/scssphp/Node/Number.php | 85 +- lib/scssphp/Parser.php | 371 +- lib/scssphp/SourceMap/Base64.php | 10 +- lib/scssphp/SourceMap/Base64VLQ.php | 16 +- lib/scssphp/SourceMap/SourceMapGenerator.php | 35 +- lib/scssphp/Type.php | 139 +- lib/scssphp/Util.php | 23 +- lib/scssphp/ValueConverter.php | 95 + lib/scssphp/Version.php | 2 +- lib/scssphp/Warn.php | 84 + 46 files changed, 3832 insertions(+), 1136 deletions(-) create mode 100644 lib/scssphp/Block/AtRootBlock.php create mode 100644 lib/scssphp/Block/CallableBlock.php create mode 100644 lib/scssphp/Block/ContentBlock.php create mode 100644 lib/scssphp/Block/DirectiveBlock.php create mode 100644 lib/scssphp/Block/EachBlock.php create mode 100644 lib/scssphp/Block/ElseBlock.php create mode 100644 lib/scssphp/Block/ElseifBlock.php create mode 100644 lib/scssphp/Block/ForBlock.php create mode 100644 lib/scssphp/Block/IfBlock.php create mode 100644 lib/scssphp/Block/MediaBlock.php create mode 100644 lib/scssphp/Block/NestedPropertyBlock.php create mode 100644 lib/scssphp/Block/WhileBlock.php create mode 100644 lib/scssphp/CompilationResult.php create mode 100644 lib/scssphp/Compiler/CachedResult.php create mode 100644 lib/scssphp/Logger/LoggerInterface.php create mode 100644 lib/scssphp/Logger/QuietLogger.php create mode 100644 lib/scssphp/Logger/StreamLogger.php create mode 100644 lib/scssphp/ValueConverter.php create mode 100644 lib/scssphp/Warn.php diff --git a/lib/scssphp/Base/Range.php b/lib/scssphp/Base/Range.php index 2846746d8e9..31d5ec565d6 100644 --- a/lib/scssphp/Base/Range.php +++ b/lib/scssphp/Base/Range.php @@ -16,17 +16,26 @@ namespace ScssPhp\ScssPhp\Base; * Range * * @author Anthon Pang + * + * @internal */ class Range { + /** + * @var float|int + */ public $first; + + /** + * @var float|int + */ public $last; /** * Initialize range * - * @param integer|float $first - * @param integer|float $last + * @param int|float $first + * @param int|float $last */ public function __construct($first, $last) { @@ -37,9 +46,9 @@ class Range /** * Test for inclusion in range * - * @param integer|float $value + * @param int|float $value * - * @return boolean + * @return bool */ public function includes($value) { diff --git a/lib/scssphp/Block.php b/lib/scssphp/Block.php index f7f4571e795..96668dc6689 100644 --- a/lib/scssphp/Block.php +++ b/lib/scssphp/Block.php @@ -16,16 +16,18 @@ namespace ScssPhp\ScssPhp; * Block * * @author Anthon Pang + * + * @internal */ class Block { /** - * @var string + * @var string|null */ public $type; /** - * @var \ScssPhp\ScssPhp\Block + * @var Block|null */ public $parent; @@ -35,17 +37,17 @@ class Block public $sourceName; /** - * @var integer + * @var int */ public $sourceIndex; /** - * @var integer + * @var int */ public $sourceLine; /** - * @var integer + * @var int */ public $sourceColumn; @@ -65,7 +67,7 @@ class Block public $children; /** - * @var \ScssPhp\ScssPhp\Block + * @var Block|null */ public $selfParent; } diff --git a/lib/scssphp/Block/AtRootBlock.php b/lib/scssphp/Block/AtRootBlock.php new file mode 100644 index 00000000000..41842c26924 --- /dev/null +++ b/lib/scssphp/Block/AtRootBlock.php @@ -0,0 +1,37 @@ +type = Type::T_AT_ROOT; + } +} diff --git a/lib/scssphp/Block/CallableBlock.php b/lib/scssphp/Block/CallableBlock.php new file mode 100644 index 00000000000..a18a87c2add --- /dev/null +++ b/lib/scssphp/Block/CallableBlock.php @@ -0,0 +1,45 @@ +type = $type; + } +} diff --git a/lib/scssphp/Block/ContentBlock.php b/lib/scssphp/Block/ContentBlock.php new file mode 100644 index 00000000000..87084980038 --- /dev/null +++ b/lib/scssphp/Block/ContentBlock.php @@ -0,0 +1,38 @@ +type = Type::T_INCLUDE; + } +} diff --git a/lib/scssphp/Block/DirectiveBlock.php b/lib/scssphp/Block/DirectiveBlock.php new file mode 100644 index 00000000000..b1d3d1a8161 --- /dev/null +++ b/lib/scssphp/Block/DirectiveBlock.php @@ -0,0 +1,37 @@ +type = Type::T_DIRECTIVE; + } +} diff --git a/lib/scssphp/Block/EachBlock.php b/lib/scssphp/Block/EachBlock.php new file mode 100644 index 00000000000..b3289579db3 --- /dev/null +++ b/lib/scssphp/Block/EachBlock.php @@ -0,0 +1,37 @@ +type = Type::T_EACH; + } +} diff --git a/lib/scssphp/Block/ElseBlock.php b/lib/scssphp/Block/ElseBlock.php new file mode 100644 index 00000000000..6abb4d775a8 --- /dev/null +++ b/lib/scssphp/Block/ElseBlock.php @@ -0,0 +1,27 @@ +type = Type::T_ELSE; + } +} diff --git a/lib/scssphp/Block/ElseifBlock.php b/lib/scssphp/Block/ElseifBlock.php new file mode 100644 index 00000000000..4622bca7967 --- /dev/null +++ b/lib/scssphp/Block/ElseifBlock.php @@ -0,0 +1,32 @@ +type = Type::T_ELSEIF; + } +} diff --git a/lib/scssphp/Block/ForBlock.php b/lib/scssphp/Block/ForBlock.php new file mode 100644 index 00000000000..a9cf6733baa --- /dev/null +++ b/lib/scssphp/Block/ForBlock.php @@ -0,0 +1,47 @@ +type = Type::T_FOR; + } +} diff --git a/lib/scssphp/Block/IfBlock.php b/lib/scssphp/Block/IfBlock.php new file mode 100644 index 00000000000..9f21bf88ac9 --- /dev/null +++ b/lib/scssphp/Block/IfBlock.php @@ -0,0 +1,37 @@ + + */ + public $cases = []; + + public function __construct() + { + $this->type = Type::T_IF; + } +} diff --git a/lib/scssphp/Block/MediaBlock.php b/lib/scssphp/Block/MediaBlock.php new file mode 100644 index 00000000000..c49ee1b2bfd --- /dev/null +++ b/lib/scssphp/Block/MediaBlock.php @@ -0,0 +1,37 @@ +type = Type::T_MEDIA; + } +} diff --git a/lib/scssphp/Block/NestedPropertyBlock.php b/lib/scssphp/Block/NestedPropertyBlock.php new file mode 100644 index 00000000000..1ea4a6c8aaa --- /dev/null +++ b/lib/scssphp/Block/NestedPropertyBlock.php @@ -0,0 +1,37 @@ +type = Type::T_NESTED_PROPERTY; + } +} diff --git a/lib/scssphp/Block/WhileBlock.php b/lib/scssphp/Block/WhileBlock.php new file mode 100644 index 00000000000..ac18d4e02b9 --- /dev/null +++ b/lib/scssphp/Block/WhileBlock.php @@ -0,0 +1,32 @@ +type = Type::T_WHILE; + } +} diff --git a/lib/scssphp/Cache.php b/lib/scssphp/Cache.php index 4738ee4ee87..9731c60a701 100644 --- a/lib/scssphp/Cache.php +++ b/lib/scssphp/Cache.php @@ -30,30 +30,54 @@ use ScssPhp\ScssPhp\Version; * SCSS cache * * @author Cedric Morin + * + * @internal */ class Cache { const CACHE_VERSION = 1; - // directory used for storing data + /** + * directory used for storing data + * + * @var string|false + */ public static $cacheDir = false; - // prefix for the storing data + /** + * prefix for the storing data + * + * @var string + */ public static $prefix = 'scssphp_'; - // force a refresh : 'once' for refreshing the first hit on a cache only, true to never use the cache in this hit + /** + * force a refresh : 'once' for refreshing the first hit on a cache only, true to never use the cache in this hit + * + * @var bool|string + */ public static $forceRefresh = false; - // specifies the number of seconds after which data cached will be seen as 'garbage' and potentially cleaned up + /** + * specifies the number of seconds after which data cached will be seen as 'garbage' and potentially cleaned up + * + * @var int + */ public static $gcLifetime = 604800; - // array of already refreshed cache if $forceRefresh==='once' + /** + * array of already refreshed cache if $forceRefresh==='once' + * + * @var array + */ protected static $refreshed = []; /** * Constructor * * @param array $options + * + * @phpstan-param array{cacheDir?: string, prefix?: string, forceRefresh?: string} $options */ public function __construct($options) { @@ -85,10 +109,10 @@ class Cache * Get the cached result of $operation on $what, * which is known as dependant from the content of $options * - * @param string $operation parse, compile... - * @param mixed $what content key (e.g., filename to be treated) - * @param array $options any option that affect the operation result on the content - * @param integer $lastModified last modified timestamp + * @param string $operation parse, compile... + * @param mixed $what content key (e.g., filename to be treated) + * @param array $options any option that affect the operation result on the content + * @param int|null $lastModified last modified timestamp * * @return mixed * @@ -128,6 +152,8 @@ class Cache * @param mixed $what * @param mixed $value * @param array $options + * + * @return void */ public function setCache($operation, $what, $value, $options = []) { @@ -174,6 +200,8 @@ class Cache /** * Check that the cache dir exists and is writeable * + * @return void + * * @throws \Exception */ public static function checkCacheDir() @@ -192,6 +220,8 @@ class Cache /** * Delete unused cached files + * + * @return void */ public static function cleanCache() { diff --git a/lib/scssphp/Colors.php b/lib/scssphp/Colors.php index 4b62c361c52..2df39992bb7 100644 --- a/lib/scssphp/Colors.php +++ b/lib/scssphp/Colors.php @@ -16,6 +16,8 @@ namespace ScssPhp\ScssPhp; * CSS Colors * * @author Leaf Corcoran + * + * @internal */ class Colors { @@ -24,13 +26,13 @@ class Colors * * @see http://www.w3.org/TR/css3-color * - * @var array + * @var array */ protected static $cssColors = [ 'aliceblue' => '240,248,255', 'antiquewhite' => '250,235,215', - 'cyan' => '0,255,255', 'aqua' => '0,255,255', + 'cyan' => '0,255,255', 'aquamarine' => '127,255,212', 'azure' => '240,255,255', 'beige' => '245,245,220', @@ -75,8 +77,8 @@ class Colors 'firebrick' => '178,34,34', 'floralwhite' => '255,250,240', 'forestgreen' => '34,139,34', - 'magenta' => '255,0,255', 'fuchsia' => '255,0,255', + 'magenta' => '255,0,255', 'gainsboro' => '220,220,220', 'ghostwhite' => '248,248,255', 'gold' => '255,215,0', @@ -183,7 +185,7 @@ class Colors * * @param string $colorName * - * @return array|null + * @return int[]|null */ public static function colorNameToRGBa($colorName) { @@ -202,10 +204,10 @@ class Colors /** * Reverse conversion : from RGBA to a color name if possible * - * @param integer $r - * @param integer $g - * @param integer $b - * @param integer $a + * @param int $r + * @param int $g + * @param int $b + * @param int|float $a * * @return string|null */ diff --git a/lib/scssphp/CompilationResult.php b/lib/scssphp/CompilationResult.php new file mode 100644 index 00000000000..36adb0da463 --- /dev/null +++ b/lib/scssphp/CompilationResult.php @@ -0,0 +1,69 @@ +css = $css; + $this->sourceMap = $sourceMap; + $this->includedFiles = $includedFiles; + } + + /** + * @return string + */ + public function getCss() + { + return $this->css; + } + + /** + * @return string[] + */ + public function getIncludedFiles() + { + return $this->includedFiles; + } + + /** + * The sourceMap content, if it was generated + * + * @return null|string + */ + public function getSourceMap() + { + return $this->sourceMap; + } +} diff --git a/lib/scssphp/Compiler.php b/lib/scssphp/Compiler.php index 0997814eef0..b6ef02727a8 100644 --- a/lib/scssphp/Compiler.php +++ b/lib/scssphp/Compiler.php @@ -13,13 +13,28 @@ namespace ScssPhp\ScssPhp; use ScssPhp\ScssPhp\Base\Range; +use ScssPhp\ScssPhp\Block\AtRootBlock; +use ScssPhp\ScssPhp\Block\CallableBlock; +use ScssPhp\ScssPhp\Block\DirectiveBlock; +use ScssPhp\ScssPhp\Block\EachBlock; +use ScssPhp\ScssPhp\Block\ElseBlock; +use ScssPhp\ScssPhp\Block\ElseifBlock; +use ScssPhp\ScssPhp\Block\ForBlock; +use ScssPhp\ScssPhp\Block\IfBlock; +use ScssPhp\ScssPhp\Block\MediaBlock; +use ScssPhp\ScssPhp\Block\NestedPropertyBlock; +use ScssPhp\ScssPhp\Block\WhileBlock; +use ScssPhp\ScssPhp\Compiler\CachedResult; use ScssPhp\ScssPhp\Compiler\Environment; use ScssPhp\ScssPhp\Exception\CompilerException; use ScssPhp\ScssPhp\Exception\ParserException; +use ScssPhp\ScssPhp\Exception\SassException; use ScssPhp\ScssPhp\Exception\SassScriptException; use ScssPhp\ScssPhp\Formatter\Compressed; use ScssPhp\ScssPhp\Formatter\Expanded; use ScssPhp\ScssPhp\Formatter\OutputBlock; +use ScssPhp\ScssPhp\Logger\LoggerInterface; +use ScssPhp\ScssPhp\Logger\StreamLogger; use ScssPhp\ScssPhp\Node\Number; use ScssPhp\ScssPhp\SourceMap\SourceMapGenerator; use ScssPhp\ScssPhp\Util\Path; @@ -55,6 +70,8 @@ use ScssPhp\ScssPhp\Util\Path; * SCSS compiler * * @author Leaf Corcoran + * + * @final Extending the Compiler is deprecated */ class Compiler { @@ -131,6 +148,7 @@ class Compiler public static $emptyString = [Type::T_STRING, '"', []]; public static $with = [Type::T_KEYWORD, 'with']; public static $without = [Type::T_KEYWORD, 'without']; + private static $emptyArgumentList = [Type::T_LIST, '', [], []]; /** * @var array @@ -140,11 +158,20 @@ class Compiler * @var array */ protected $importCache = []; + /** * @var string[] */ protected $importedFiles = []; + + /** + * @var array + * @phpstan-var array + */ protected $userFunctions = []; + /** + * @var array + */ protected $registeredVars = []; /** * @var array @@ -161,6 +188,7 @@ class Compiler */ protected $encoding = null; /** + * @var null * @deprecated */ protected $lineNumberStyle = null; @@ -170,8 +198,18 @@ class Compiler * @phpstan-var self::SOURCE_MAP_*|SourceMapGenerator */ protected $sourceMap = self::SOURCE_MAP_NONE; + + /** + * @var array + * @phpstan-var array{sourceRoot?: string, sourceMapFilename?: string|null, sourceMapURL?: string|null, sourceMapWriteTo?: string|null, outputSourceFiles?: bool, sourceMapRootpath?: string, sourceMapBasepath?: string} + */ protected $sourceMapOptions = []; + /** + * @var bool + */ + private $charset = true; + /** * @var string|\ScssPhp\ScssPhp\Formatter */ @@ -200,10 +238,12 @@ class Compiler protected $storeEnv; /** * @var bool|null + * + * @deprecated */ protected $charsetSeen; /** - * @var array + * @var array */ protected $sourceNames; @@ -212,6 +252,11 @@ class Compiler */ protected $cache; + /** + * @var bool + */ + protected $cacheCheckImportResolutions = false; + /** * @var int */ @@ -224,10 +269,12 @@ class Compiler * @var array */ protected $extendsMap; + /** * @var array */ - protected $parsedFiles; + protected $parsedFiles = []; + /** * @var Parser|null */ @@ -244,10 +291,6 @@ class Compiler * @var int|null */ protected $sourceColumn; - /** - * @var resource - */ - protected $stderr; /** * @var bool|null */ @@ -267,6 +310,12 @@ class Compiler */ protected $callStack = []; + /** + * @var array + * @phpstan-var list + */ + private $resolvedImports = []; + /** * The directory of the currently processed file * @@ -281,29 +330,47 @@ class Compiler */ private $rootDirectory; + /** + * @var bool + */ private $legacyCwdImportPath = true; + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @var array + */ + private $warnedChildFunctions = []; + /** * Constructor * * @param array|null $cacheOptions + * @phpstan-param array{cacheDir?: string, prefix?: string, forceRefresh?: string, checkImportResolutions?: bool}|null $cacheOptions */ public function __construct($cacheOptions = null) { - $this->parsedFiles = []; $this->sourceNames = []; if ($cacheOptions) { $this->cache = new Cache($cacheOptions); + if (!empty($cacheOptions['checkImportResolutions'])) { + $this->cacheCheckImportResolutions = true; + } } - $this->stderr = fopen('php://stderr', 'w'); + $this->logger = new StreamLogger(fopen('php://stderr', 'w'), true); } /** * Get compiler options * * @return array + * + * @internal */ public function getCompileOptions() { @@ -321,50 +388,90 @@ class Compiler return $options; } + /** + * Sets an alternative logger. + * + * Changing the logger in the middle of the compilation is not + * supported and will result in an undefined behavior. + * + * @param LoggerInterface $logger + * + * @return void + */ + public function setLogger(LoggerInterface $logger) + { + $this->logger = $logger; + } + /** * Set an alternative error output stream, for testing purpose only * * @param resource $handle * * @return void + * + * @deprecated Use {@see setLogger} instead */ public function setErrorOuput($handle) { - $this->stderr = $handle; + @trigger_error('The method "setErrorOuput" is deprecated. Use "setLogger" instead.', E_USER_DEPRECATED); + + $this->logger = new StreamLogger($handle); } /** * Compile scss * - * @api - * - * @param string $code - * @param string $path + * @param string $code + * @param string|null $path * * @return string + * + * @throws SassException when the source fails to compile + * + * @deprecated Use {@see compileString} instead. */ public function compile($code, $path = null) { - if ($this->cache) { - $cacheKey = ($path ? $path : '(stdin)') . ':' . md5($code); - $compileOptions = $this->getCompileOptions(); - $cache = $this->cache->getCache('compile', $cacheKey, $compileOptions); + @trigger_error(sprintf('The "%s" method is deprecated. Use "compileString" instead.', __METHOD__), E_USER_DEPRECATED); - 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) { - unset($cache); - break; - } - } + $result = $this->compileString($code, $path); - if (isset($cache)) { - return $cache['out']; - } + $sourceMap = $result->getSourceMap(); + + if ($sourceMap !== null) { + if ($this->sourceMap instanceof SourceMapGenerator) { + $this->sourceMap->saveMap($sourceMap); + } elseif ($this->sourceMap === self::SOURCE_MAP_FILE) { + $sourceMapGenerator = new SourceMapGenerator($this->sourceMapOptions); + $sourceMapGenerator->saveMap($sourceMap); } } + return $result->getCss(); + } + + /** + * Compile scss + * + * @param string $source + * @param string|null $path + * + * @return CompilationResult + * + * @throws SassException when the source fails to compile + */ + public function compileString($source, $path = null) + { + if ($this->cache) { + $cacheKey = ($path ? $path : '(stdin)') . ':' . md5($source); + $compileOptions = $this->getCompileOptions(); + $cachedResult = $this->cache->getCache('compile', $cacheKey, $compileOptions); + + if ($cachedResult instanceof CachedResult && $this->isFreshCachedResult($cachedResult)) { + return $cachedResult->getResult(); + } + } $this->indentLevel = -1; $this->extends = []; @@ -375,9 +482,11 @@ class Compiler $this->env = null; $this->scope = null; $this->storeEnv = null; - $this->charsetSeen = null; $this->shouldEvaluate = null; $this->ignoreCallStackMessage = false; + $this->parsedFiles = []; + $this->importedFiles = []; + $this->resolvedImports = []; if (!\is_null($path) && is_file($path)) { $path = realpath($path) ?: $path; @@ -390,16 +499,25 @@ class Compiler try { $this->parser = $this->parserFactory($path); - $tree = $this->parser->parse($code); + $tree = $this->parser->parse($source); $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(); + $warnCallback = function ($message, $deprecation) { + $this->logger->warn($message, $deprecation); + }; + $previousWarnCallback = Warn::setCallback($warnCallback); + + try { + $this->injectVariables($this->registeredVars); + $this->compileRoot($tree); + $this->popEnv(); + } finally { + Warn::setCallback($previousWarnCallback); + } $sourceMapGenerator = null; @@ -416,15 +534,15 @@ class Compiler $prefix = ''; - if (!$this->charsetSeen) { - if (strlen($out) !== Util::mbStrlen($out)) { - $prefix = '@charset "UTF-8";' . "\n"; - $out = $prefix . $out; - } + if ($this->charset && strlen($out) !== Util::mbStrlen($out)) { + $prefix = '@charset "UTF-8";' . "\n"; + $out = $prefix . $out; } + $sourceMap = null; + if (! empty($out) && $this->sourceMap && $this->sourceMap !== self::SOURCE_MAP_NONE) { - $sourceMap = $sourceMapGenerator->generateJson($prefix); + $sourceMap = $sourceMapGenerator->generateJson($prefix); $sourceMapUrl = null; switch ($this->sourceMap) { @@ -433,32 +551,80 @@ class Compiler break; case self::SOURCE_MAP_FILE: - $sourceMapUrl = $sourceMapGenerator->saveMap($sourceMap); + if (isset($this->sourceMapOptions['sourceMapURL'])) { + $sourceMapUrl = $this->sourceMapOptions['sourceMapURL']; + } break; } - $out .= sprintf('/*# sourceMappingURL=%s */', $sourceMapUrl); + if ($sourceMapUrl !== null) { + $out .= sprintf('/*# sourceMappingURL=%s */', $sourceMapUrl); + } } } catch (SassScriptException $e) { - throw $this->error($e->getMessage()); + throw new CompilerException($this->addLocationToMessage($e->getMessage()), 0, $e); } + $includedFiles = []; + + foreach ($this->resolvedImports as $resolvedImport) { + $includedFiles[$resolvedImport['filePath']] = $resolvedImport['filePath']; + } + + $result = new CompilationResult($out, $sourceMap, array_values($includedFiles)); + if ($this->cache && isset($cacheKey) && isset($compileOptions)) { - $v = [ - 'dependencies' => $this->getParsedFiles(), - 'out' => &$out, - ]; - - $this->cache->setCache('compile', $cacheKey, $v, $compileOptions); + $this->cache->setCache('compile', $cacheKey, new CachedResult($result, $this->parsedFiles, $this->resolvedImports), $compileOptions); } - return $out; + // Reset state to free memory + // TODO in 2.0, reset parsedFiles as well when the getter is removed. + $this->resolvedImports = []; + $this->importedFiles = []; + + return $result; + } + + /** + * @param CachedResult $result + * + * @return bool + */ + private function isFreshCachedResult(CachedResult $result) + { + // check if any dependency file changed since the result was compiled + foreach ($result->getParsedFiles() as $file => $mtime) { + if (! is_file($file) || filemtime($file) !== $mtime) { + return false; + } + } + + if ($this->cacheCheckImportResolutions) { + $resolvedImports = []; + + foreach ($result->getResolvedImports() as $import) { + $currentDir = $import['currentDir']; + $path = $import['path']; + // store the check across all the results in memory to avoid multiple findImport() on the same path + // with same context. + // this is happening in a same hit with multiple compilations (especially with big frameworks) + if (empty($resolvedImports[$currentDir][$path])) { + $resolvedImports[$currentDir][$path] = $this->findImport($path, $currentDir); + } + + if ($resolvedImports[$currentDir][$path] !== $import['filePath']) { + return false; + } + } + } + + return true; } /** * Instantiate parser * - * @param string $path + * @param string|null $path * * @return \ScssPhp\ScssPhp\Parser */ @@ -471,11 +637,11 @@ class Compiler // Otherwise, the CSS will be rendered as-is. It can even be extended! $cssOnly = false; - if (substr($path, '-4') === '.css') { + if ($path !== null && substr($path, -4) === '.css') { $cssOnly = true; } - $parser = new Parser($path, \count($this->sourceNames), $this->encoding, $this->cache, $cssOnly); + $parser = new Parser($path, \count($this->sourceNames), $this->encoding, $this->cache, $cssOnly, $this->logger); $this->sourceNames[] = $path; $this->addParsedFile($path); @@ -489,7 +655,7 @@ class Compiler * @param array $target * @param array $origin * - * @return boolean + * @return bool */ protected function isSelfExtend($target, $origin) { @@ -528,8 +694,8 @@ class Compiler /** * Make output block * - * @param string $type - * @param array $selectors + * @param string|null $type + * @param string[]|null $selectors * * @return \ScssPhp\ScssPhp\Formatter\OutputBlock */ @@ -661,7 +827,7 @@ class Compiler } /** - * Glue parts of :not( or :nth-child( ... that are in general splitted in selectors parts + * Glue parts of :not( or :nth-child( ... that are in general split in selectors parts * * @param array $parts * @@ -698,10 +864,10 @@ class Compiler /** * Match extends * - * @param array $selector - * @param array $out - * @param integer $from - * @param boolean $initial + * @param array $selector + * @param array $out + * @param int $from + * @param bool $initial * * @return void */ @@ -834,7 +1000,7 @@ class Compiler * @param string $part * @param array $matches * - * @return boolean + * @return bool */ protected function isPseudoSelector($part, &$matches) { @@ -896,11 +1062,11 @@ class Compiler /** * Match extends single * - * @param array $rawSingle - * @param array $outOrigin - * @param boolean $initial + * @param array $rawSingle + * @param array $outOrigin + * @param bool $initial * - * @return boolean + * @return bool */ protected function matchExtendsSingle($rawSingle, &$outOrigin, $initial = true) { @@ -1115,11 +1281,12 @@ class Compiler */ protected function compileMedia(Block $media) { + assert($media instanceof MediaBlock); $this->pushEnv($media); $mediaQueries = $this->compileMediaQuery($this->multiplyMedia($this->env)); - if (! empty($mediaQueries) && $mediaQueries) { + if (! empty($mediaQueries)) { $previousScope = $this->scope; $parentScope = $this->mediaParent($this->scope); @@ -1192,7 +1359,7 @@ class Compiler /** * Compile directive * - * @param \ScssPhp\ScssPhp\Block|array $block + * @param DirectiveBlock|array $directive * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $out * * @return void @@ -1237,7 +1404,7 @@ class Compiler * directive names can include some interpolation * * @param string|array $directiveName - * @return array|string + * @return string * @throws CompilerException */ protected function compileDirectiveName($directiveName) @@ -1258,6 +1425,7 @@ class Compiler */ protected function compileAtRoot(Block $block) { + assert($block instanceof AtRootBlock); $env = $this->pushEnv($block); $envs = $this->compactEnv($env); list($with, $without) = $this->compileWith(isset($block->with) ? $block->with : null); @@ -1280,9 +1448,10 @@ class Compiler } $selfParent = $block->selfParent; + assert($selfParent !== null, 'at-root blocks must have a selfParent set.'); if ( - ! $block->selfParent->selectors && + ! $selfParent->selectors && isset($block->parent) && $block->parent && isset($block->parent->selectors) && $block->parent->selectors ) { @@ -1305,7 +1474,7 @@ class Compiler } /** - * Filter at-root scope depending of with/without option + * Filter at-root scope depending on with/without option * * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $scope * @param array $with @@ -1318,12 +1487,12 @@ class Compiler $filteredScopes = []; $childStash = []; - if ($scope->type === TYPE::T_ROOT) { + if ($scope->type === Type::T_ROOT) { return $scope; } // start from the root - while ($scope->parent && $scope->parent->type !== TYPE::T_ROOT) { + while ($scope->parent && $scope->parent->type !== Type::T_ROOT) { array_unshift($childStash, $scope); $scope = $scope->parent; } @@ -1405,7 +1574,7 @@ class Compiler * Find a selector by the depth node in the scope * * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $scope - * @param integer $depth + * @param int $depth * * @return array */ @@ -1429,9 +1598,11 @@ class Compiler /** * Compile @at-root's with: inclusion / without: exclusion into 2 lists uses to filter scope/env later * - * @param array $withCondition + * @param array|null $withCondition * * @return array + * + * @phpstan-return array{array, array} */ protected function compileWith($withCondition) { @@ -1451,9 +1622,10 @@ class Compiler } } - if ($this->libMapHasKey([$withCondition, static::$with])) { + $withConfig = $this->mapGet($withCondition, static::$with); + if ($withConfig !== null) { $without = []; // cancel the default - $list = $this->coerceList($this->libMapGet([$withCondition, static::$with])); + $list = $this->coerceList($withConfig); foreach ($list[2] as $item) { $keyword = $this->compileStringContent($this->coerceString($item)); @@ -1462,9 +1634,10 @@ class Compiler } } - if ($this->libMapHasKey([$withCondition, static::$without])) { + $withoutConfig = $this->mapGet($withCondition, static::$without); + if ($withoutConfig !== null) { $without = []; // cancel the default - $list = $this->coerceList($this->libMapGet([$withCondition, static::$without])); + $list = $this->coerceList($withoutConfig); foreach ($list[2] as $item) { $keyword = $this->compileStringContent($this->coerceString($item)); @@ -1514,7 +1687,7 @@ class Compiler * @param array $with * @param array $without * - * @return boolean + * @return bool */ protected function isWith($block, $with, $without) { @@ -1524,6 +1697,7 @@ class Compiler } if ($block->type === Type::T_DIRECTIVE) { + assert($block instanceof DirectiveBlock || $block instanceof OutputBlock); if (isset($block->name)) { return $this->testWithWithout($this->compileDirectiveName($block->name), $with, $without); } elseif (isset($block->selectors) && preg_match(',@(\w+),ims', json_encode($block->selectors), $m)) { @@ -1559,7 +1733,7 @@ class Compiler * @param array $with * @param array $without * - * @return boolean + * @return bool * true if the block should be kept, false to reject */ protected function testWithWithout($what, $with, $without) @@ -1578,7 +1752,7 @@ class Compiler * Compile keyframe block * * @param \ScssPhp\ScssPhp\Block $block - * @param array $selectors + * @param string[] $selectors * * @return void */ @@ -1614,6 +1788,7 @@ class Compiler */ protected function compileNestedPropertiesBlock(Block $block, OutputBlock $out) { + assert($block instanceof NestedPropertyBlock); $prefix = $this->compileValue($block->prefix) . '-'; $nested = $this->makeOutputBlock($block->type); @@ -1632,6 +1807,7 @@ class Compiler break; case Type::T_NESTED_PROPERTY: + assert($child[1] instanceof NestedPropertyBlock); array_unshift($child[1]->prefix[2], $prefix); break; } @@ -1644,7 +1820,7 @@ class Compiler * Compile nested block * * @param \ScssPhp\ScssPhp\Block $block - * @param array $selectors + * @param string[] $selectors * * @return void */ @@ -1657,7 +1833,7 @@ class Compiler // wrap assign children in a block // except for @font-face - if ($block->type !== Type::T_DIRECTIVE || $this->compileDirectiveName($block->name) !== 'font-face') { + if (!$block instanceof DirectiveBlock || $this->compileDirectiveName($block->name) !== 'font-face') { // need wrapping? $needWrapping = false; @@ -1746,8 +1922,8 @@ class Compiler /** * Compile the value of a comment that can have interpolation * - * @param array $value - * @param boolean $pushEnv + * @param array $value + * @param bool $pushEnv * * @return string */ @@ -1760,17 +1936,16 @@ class Compiler $this->pushEnv(); } - $ignoreCallStackMessage = $this->ignoreCallStackMessage; - $this->ignoreCallStackMessage = true; - try { $c = $this->compileValue($value[2]); - } catch (\Exception $e) { + } catch (SassScriptException $e) { + $this->logger->warn('Ignoring interpolation errors in multiline comments is deprecated and will be removed in ScssPhp 2.0. ' . $this->addLocationToMessage($e->getMessage()), true); + // ignore error in comment compilation which are only interpolation + } catch (SassException $e) { + $this->logger->warn('Ignoring interpolation errors in multiline comments is deprecated and will be removed in ScssPhp 2.0. ' . $e->getMessage(), true); // ignore error in comment compilation which are only interpolation } - $this->ignoreCallStackMessage = $ignoreCallStackMessage; - if ($pushEnv) { $this->popEnv(); } @@ -1871,14 +2046,44 @@ class Compiler /** * Collapse selectors * - * @param array $selectors - * @param boolean $selectorFormat - * if false return a collapsed string - * if true return an array description of a structured selector + * @param array $selectors * * @return string */ - protected function collapseSelectors($selectors, $selectorFormat = false) + protected function collapseSelectors($selectors) + { + $parts = []; + + foreach ($selectors as $selector) { + $output = []; + + foreach ($selector as $node) { + $compound = ''; + + array_walk_recursive( + $node, + function ($value, $key) use (&$compound) { + $compound .= $value; + } + ); + + $output[] = $compound; + } + + $parts[] = implode(' ', $output); + } + + return implode(', ', $parts); + } + + /** + * Collapse selectors + * + * @param array $selectors + * + * @return array + */ + private function collapseSelectorsAsList($selectors) { $parts = []; @@ -1896,7 +2101,7 @@ class Compiler } ); - if ($selectorFormat && $this->isImmediateRelationshipCombinator($compound)) { + if ($this->isImmediateRelationshipCombinator($compound)) { if (\count($output)) { $output[\count($output) - 1] .= ' ' . $compound; } else { @@ -1912,32 +2117,21 @@ class Compiler } } - if ($selectorFormat) { - foreach ($output as &$o) { - $o = [Type::T_STRING, '', [$o]]; - } - - $output = [Type::T_LIST, ' ', $output]; - } else { - $output = implode(' ', $output); + foreach ($output as &$o) { + $o = [Type::T_STRING, '', [$o]]; } - $parts[] = $output; + $parts[] = [Type::T_LIST, ' ', $output]; } - if ($selectorFormat) { - $parts = [Type::T_LIST, ',', $parts]; - } else { - $parts = implode(', ', $parts); - } - - return $parts; + return [Type::T_LIST, ',', $parts]; } /** * Parse down the selector and revert [self] to "&" before a reparsing * - * @param array $selectors + * @param array $selectors + * @param string|null $replace * * @return array */ @@ -2046,7 +2240,7 @@ class Compiler * * @param array $selector * - * @return boolean + * @return bool */ protected function hasSelectorPlaceholder($selector) { @@ -2104,7 +2298,7 @@ class Compiler * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $out * @param string $traceName * - * @return array|null + * @return array|Number|null */ protected function compileChildren($stms, OutputBlock $out, $traceName = '') { @@ -2146,7 +2340,7 @@ class Compiler $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']); @@ -2234,7 +2428,7 @@ class Compiler * * @param array $queryList * - * @return array + * @return string[] */ protected function compileMediaQuery($queryList) { @@ -2463,19 +2657,21 @@ class Compiler * * @param array $rawPath * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $out - * @param boolean $once + * @param bool $once * - * @return boolean + * @return bool */ protected function compileImport($rawPath, OutputBlock $out, $once = false) { if ($rawPath[0] === Type::T_STRING) { $path = $this->compileStringContent($rawPath); - if (strpos($path, 'url(') !== 0 && $path = $this->findImport($path)) { - if (! $once || ! \in_array($path, $this->importedFiles)) { - $this->importFile($path, $out); - $this->importedFiles[] = $path; + if (strpos($path, 'url(') !== 0 && $filePath = $this->findImport($path, $this->currentDirectory)) { + $this->registerImport($this->currentDirectory, $path, $filePath); + + if (! $once || ! \in_array($filePath, $this->importedFiles)) { + $this->importFile($filePath, $out); + $this->importedFiles[] = $filePath; } return true; @@ -2598,7 +2794,7 @@ class Compiler // insert the directive as a comment $child = $this->makeOutputBlock(Type::T_COMMENT); $child->lines[] = $line; - $child->sourceName = $this->sourceNames[$this->sourceIndex]; + $child->sourceName = $this->sourceNames[$this->sourceIndex] ?: '(stdin)'; $child->sourceLine = $this->sourceLine; $child->sourceColumn = $this->sourceColumn; @@ -2616,7 +2812,7 @@ class Compiler * * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $out * @param string $type - * @param string|mixed $line + * @param string $line * * @return void */ @@ -2667,12 +2863,13 @@ class Compiler $this->sourceColumn = $child[1]->sourceColumn; } elseif (! empty($out->sourceLine) && ! empty($out->sourceName)) { $this->sourceLine = $out->sourceLine; - $this->sourceIndex = array_search($out->sourceName, $this->sourceNames); + $sourceIndex = array_search($out->sourceName, $this->sourceNames); $this->sourceColumn = $out->sourceColumn; - if ($this->sourceIndex === false) { - $this->sourceIndex = null; + if ($sourceIndex === false) { + $sourceIndex = null; } + $this->sourceIndex = $sourceIndex; } switch ($child[0]) { @@ -2705,10 +2902,6 @@ class Compiler break; case Type::T_CHARSET: - if (! $this->charsetSeen) { - $this->charsetSeen = true; - $this->appendRootDirective('@charset ' . $this->compileValue($child[1]) . ';', $out); - } break; case Type::T_CUSTOM_PROPERTY: @@ -2872,6 +3065,7 @@ class Compiler case Type::T_MIXIN: case Type::T_FUNCTION: list(, $block) = $child; + assert($block instanceof CallableBlock); // the block need to be able to go up to it's parent env to resolve vars $block->parentEnv = $this->getStoreEnv(); $this->set(static::$namespaces[$block->type] . $block->name, $block, true); @@ -2879,18 +3073,42 @@ class Compiler case Type::T_EXTEND: foreach ($child[1] as $sel) { - $sel = $this->replaceSelfSelector($sel); + $replacedSel = $this->replaceSelfSelector($sel); + + if ($replacedSel !== $sel) { + throw $this->error('Parent selectors aren\'t allowed here.'); + } + $results = $this->evalSelectors([$sel]); foreach ($results as $result) { + if (\count($result) !== 1) { + throw $this->error('complex selectors may not be extended.'); + } + // only use the first one - $result = current($result); + $result = $result[0]; $selectors = $out->selectors; if (! $selectors && isset($child['selfParent'])) { $selectors = $this->multiplySelectors($this->env, $child['selfParent']); } + if (\count($result) > 1) { + $replacement = implode(', ', $result); + $fname = $this->getPrettyPath($this->sourceNames[$this->sourceIndex]); + $line = $this->sourceLine; + + $message = <<logger->warn($message); + } + $this->pushExtends($result, $selectors, $child); } } @@ -2898,6 +3116,7 @@ class Compiler case Type::T_IF: list(, $if) = $child; + assert($if instanceof IfBlock); if ($this->isTruthy($this->reduce($if->cond, true))) { return $this->compileChildren($if->children, $out); @@ -2905,8 +3124,8 @@ class Compiler foreach ($if->cases as $case) { if ( - $case->type === Type::T_ELSE || - $case->type === Type::T_ELSEIF && $this->isTruthy($this->reduce($case->cond)) + $case instanceof ElseBlock || + $case instanceof ElseifBlock && $this->isTruthy($this->reduce($case->cond)) ) { return $this->compileChildren($case->children, $out); } @@ -2915,6 +3134,7 @@ class Compiler case Type::T_EACH: list(, $each) = $child; + assert($each instanceof EachBlock); $list = $this->coerceList($this->reduce($each->list), ',', true); @@ -2949,6 +3169,7 @@ class Compiler case Type::T_WHILE: list(, $while) = $child; + assert($while instanceof WhileBlock); while ($this->isTruthy($this->reduce($while->cond, true))) { $ret = $this->compileChildren($while->children, $out); @@ -2961,25 +3182,17 @@ class Compiler case Type::T_FOR: list(, $for) = $child; + assert($for instanceof ForBlock); - $start = $this->reduce($for->start, true); - $end = $this->reduce($for->end, true); + $startNumber = $this->assertNumber($this->reduce($for->start, true)); + $endNumber = $this->assertNumber($this->reduce($for->end, true)); - if (! $start instanceof Number) { - throw $this->error('%s is not a number', $start[0]); - } + $start = $this->assertInteger($startNumber); - if (! $end instanceof Number) { - throw $this->error('%s is not a number', $end[0]); - } + $numeratorUnits = $startNumber->getNumeratorUnits(); + $denominatorUnits = $startNumber->getDenominatorUnits(); - $start->assertSameUnitOrUnitless($end); - - $numeratorUnits = $start->getNumeratorUnits(); - $denominatorUnits = $start->getDenominatorUnits(); - - $start = $start->getDimension(); - $end = $end->getDimension(); + $end = $this->assertInteger($endNumber->coerce($numeratorUnits, $denominatorUnits)); $d = $start < $end ? 1 : -1; @@ -3030,6 +3243,8 @@ class Compiler throw $this->error("Undefined mixin $name"); } + assert($mixin instanceof CallableBlock); + $callingScope = $this->getStoreEnv(); // push scope, apply args @@ -3131,7 +3346,7 @@ class Compiler $line = $this->sourceLine; $value = $this->compileDebugValue($value); - fwrite($this->stderr, "$fname:$line DEBUG: $value\n"); + $this->logger->debug("$fname:$line DEBUG: $value"); break; case Type::T_WARN: @@ -3141,7 +3356,7 @@ class Compiler $line = $this->sourceLine; $value = $this->compileDebugValue($value); - fwrite($this->stderr, "WARNING: $value\n on line $line of $fname\n\n"); + $this->logger->warn("$value\n on line $line of $fname"); break; case Type::T_ERROR: @@ -3202,9 +3417,9 @@ class Compiler * * @param array|Number $value * - * @return boolean + * @return bool */ - protected function isTruthy($value) + public function isTruthy($value) { return $value !== static::$false && $value !== static::$null; } @@ -3214,7 +3429,7 @@ class Compiler * * @param string $value * - * @return boolean + * @return bool */ protected function isImmediateRelationshipCombinator($value) { @@ -3226,7 +3441,7 @@ class Compiler * * @param array $value * - * @return boolean + * @return bool */ protected function shouldEval($value) { @@ -3249,14 +3464,14 @@ class Compiler * Reduce value * * @param array|Number $value - * @param boolean $inExp + * @param bool $inExp * - * @return null|string|array|Number + * @return array|Number */ protected function reduce($value, $inExp = false) { - if (\is_null($value)) { - return null; + if ($value instanceof Number) { + return $value; } switch ($value[0]) { @@ -3357,6 +3572,14 @@ class Compiler foreach ($value[2] as &$item) { $item = $this->reduce($item); } + unset($item); + + if (isset($value[3]) && \is_array($value[3])) { + foreach ($value[3] as &$item) { + $item = $this->reduce($item); + } + unset($item); + } return $value; @@ -3373,7 +3596,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 Number) { $item = $this->reduce($item); } } @@ -3384,7 +3607,7 @@ class Compiler $value[1] = $this->reduce($value[1]); if ($inExp) { - return $value[1]; + return [Type::T_KEYWORD, $this->compileValue($value, false)]; } return $value; @@ -3395,7 +3618,7 @@ class Compiler case Type::T_SELF: $selfParent = ! empty($this->env->block->selfParent) ? $this->env->block->selfParent : null; $selfSelector = $this->multiplySelectors($this->env, $selfParent); - $selfSelector = $this->collapseSelectors($selfSelector, true); + $selfSelector = $this->collapseSelectorsAsList($selfSelector); return $selfSelector; @@ -3407,8 +3630,8 @@ class Compiler /** * Function caller * - * @param string $name - * @param array $argValues + * @param string|array $functionReference + * @param array $argValues * * @return array|Number */ @@ -3455,7 +3678,7 @@ class Compiler // special cases of css valid functions min/max $name = strtolower($name); - if (\in_array($name, ['min', 'max'])) { + if (\in_array($name, ['min', 'max']) && count($argValues) >= 1) { $cssFunction = $this->cssValidArg( [Type::T_FUNCTION_CALL, $name, $argValues], ['min', 'max', 'calc', 'env', 'var'] @@ -3477,8 +3700,19 @@ class Compiler } } + /** + * @param array|Number $arg + * @param string[] $allowed_function + * @param bool $inFunction + * + * @return array|Number|false + */ protected function cssValidArg($arg, $allowed_function = [], $inFunction = false) { + if ($arg instanceof Number) { + return $this->stringifyFncallArgs($arg); + } + switch ($arg[0]) { case Type::T_INTERPOLATE: return [Type::T_KEYWORD, $this->CompileValue($arg)]; @@ -3523,9 +3757,6 @@ class Compiler } return $this->stringifyFncallArgs($arg); - case Type::T_NUMBER: - return $this->stringifyFncallArgs($arg); - case Type::T_LIST: if (!$inFunction) { return false; @@ -3564,12 +3795,15 @@ class Compiler /** * Reformat fncall arguments to proper css function output * - * @param $arg + * @param array|Number $arg * - * @return array|\ArrayAccess|Number|string|null + * @return array|Number */ protected function stringifyFncallArgs($arg) { + if ($arg instanceof Number) { + return $arg; + } switch ($arg[0]) { case Type::T_LIST: @@ -3621,7 +3855,6 @@ class Compiler // 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 @@ -3630,10 +3863,60 @@ class Compiler return [Type::T_FUNCTION_REFERENCE, 'user', $name, $f, $prototype]; } + $lowercasedName = strtolower($normalizedName); + + // Special functions overriding a CSS function are case-insensitive. We normalize them as lowercase + // to avoid the deprecation warning about the wrong case being used. + if ($lowercasedName === 'min' || $lowercasedName === 'max') { + $normalizedName = $lowercasedName; + } + if (($f = $this->getBuiltinFunction($normalizedName)) && \is_callable($f)) { $libName = $f[1]; $prototype = isset(static::$$libName) ? static::$$libName : null; + // All core functions have a prototype defined. Not finding the + // prototype can mean 2 things: + // - the function comes from a child class (deprecated just after) + // - the function was found with a different case, which relates to calling the + // wrong Sass function due to our camelCase usage (`fade-in()` vs `fadein()`), + // because PHP method names are case-insensitive while property names are + // case-sensitive. + if ($prototype === null || strtolower($normalizedName) !== $normalizedName) { + $r = new \ReflectionMethod($this, $libName); + $actualLibName = $r->name; + + if ($actualLibName !== $libName || strtolower($normalizedName) !== $normalizedName) { + $kebabCaseName = preg_replace('~(?<=\\w)([A-Z])~', '-$1', substr($actualLibName, 3)); + assert($kebabCaseName !== null); + $originalName = strtolower($kebabCaseName); + $warning = "Calling built-in functions with a non-standard name is deprecated since Scssphp 1.8.0 and will not work anymore in 2.0 (they will be treated as CSS function calls instead).\nUse \"$originalName\" instead of \"$name\"."; + @trigger_error($warning, E_USER_DEPRECATED); + $fname = $this->getPrettyPath($this->sourceNames[$this->sourceIndex]); + $line = $this->sourceLine; + Warn::deprecation("$warning\n on line $line of $fname"); + + // Use the actual function definition + $prototype = isset(static::$$actualLibName) ? static::$$actualLibName : null; + $f[1] = $libName = $actualLibName; + } + } + + if (\get_class($this) !== __CLASS__ && !isset($this->warnedChildFunctions[$libName])) { + $r = new \ReflectionMethod($this, $libName); + $declaringClass = $r->getDeclaringClass()->name; + + $needsWarning = $this->warnedChildFunctions[$libName] = $declaringClass !== __CLASS__; + + if ($needsWarning) { + if (method_exists(__CLASS__, $libName)) { + @trigger_error(sprintf('Overriding the "%s" core function by extending the Compiler is deprecated and will be unsupported in 2.0. Remove the "%s::%s" method.', $normalizedName, $declaringClass, $libName), E_USER_DEPRECATED); + } else { + @trigger_error(sprintf('Registering custom functions by extending the Compiler and using the lib* discovery mechanism is deprecated and will be removed in 2.0. Replace the "%s::%s" method with registering the "%s" function through "Compiler::registerFunction".', $declaringClass, $libName, $normalizedName), E_USER_DEPRECATED); + } + } + } + return [Type::T_FUNCTION_REFERENCE, 'native', $name, $f, $prototype]; } @@ -3656,6 +3939,8 @@ class Compiler /** * Normalize value * + * @internal + * * @param array|Number $value * * @return array|Number @@ -3664,6 +3949,10 @@ class Compiler { $value = $this->coerceForExpression($this->reduce($value)); + if ($value instanceof Number) { + return $value; + } + switch ($value[0]) { case Type::T_LIST: $value = $this->extractInterpolation($value); @@ -3680,6 +3969,10 @@ class Compiler unset($value['enclosing']); } + if ($value[1] === '' && count($value[2]) > 1) { + $value[1] = ' '; + } + return $value; case Type::T_STRING: @@ -3795,8 +4088,8 @@ class Compiler * Boolean and * * @param array|Number $left - * @param array|Number $right - * @param boolean $shouldEval + * @param array|Number $right + * @param bool $shouldEval * * @return array|Number|null */ @@ -3824,7 +4117,7 @@ class Compiler * * @param array|Number $left * @param array|Number $right - * @param boolean $shouldEval + * @param bool $shouldEval * * @return array|Number|null */ @@ -3864,7 +4157,7 @@ class Compiler $fname = $this->getPrettyPath($this->sourceNames[$this->sourceIndex]); $line = $this->sourceLine; - fwrite($this->stderr, "DEPRECATION WARNING: $warning\n on line $line of $fname\n\n"); + Warn::deprecation("$warning\n on line $line of $fname"); } $out = [Type::T_COLOR]; @@ -4103,7 +4396,7 @@ class Compiler * * @api * - * @param mixed $thing + * @param bool $thing * * @return array */ @@ -4114,7 +4407,12 @@ class Compiler /** * Escape non printable chars in strings output as in dart-sass + * + * @internal + * * @param string $string + * @param bool $inKeyword + * * @return string */ public function escapeNonPrintableChars($string, $inKeyword = false) @@ -4167,20 +4465,22 @@ class Compiler * * @api * - * @param array|Number|string $value + * @param array|Number $value + * @param bool $quote * * @return string */ - public function compileValue($value) + public function compileValue($value, $quote = true) { $value = $this->reduce($value); + if ($value instanceof Number) { + return $value->output($this); + } + switch ($value[0]) { case Type::T_KEYWORD: - if (is_string($value[1])) { - $value[1] = $this->escapeNonPrintableChars($value[1], true); - } - return $value[1]; + return $this->escapeNonPrintableChars($value[1], true); case Type::T_COLOR: // [1] - red component (either number for a %) @@ -4232,13 +4532,10 @@ class Compiler return $h; - case Type::T_NUMBER: - return $value->output($this); - case Type::T_STRING: - $content = $this->compileStringContent($value); + $content = $this->compileStringContent($value, $quote); - if ($value[1]) { + if ($value[1] && $quote) { $content = str_replace('\\', '\\\\', $content); $content = $this->escapeNonPrintableChars($content); @@ -4246,8 +4543,7 @@ class Compiler // 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 + (strpos($content, '"') === false or strpos($content, "'") !== false) ) { $value[1] = '"'; } elseif ( @@ -4263,7 +4559,7 @@ class Compiler return $value[1] . $content . $value[1]; case Type::T_FUNCTION: - $args = ! empty($value[2]) ? $this->compileValue($value[2]) : ''; + $args = ! empty($value[2]) ? $this->compileValue($value[2], $quote) : ''; return "$value[1]($args)"; @@ -4276,7 +4572,7 @@ class Compiler $value = $this->extractInterpolation($value); if ($value[0] !== Type::T_LIST) { - return $this->compileValue($value); + return $this->compileValue($value, $quote); } list(, $delim, $items) = $value; @@ -4300,6 +4596,8 @@ class Compiler } } + $separator = $delim === '/' ? ' /' : $delim; + $prefix_value = ''; if ($delim !== ' ') { @@ -4329,7 +4627,7 @@ class Compiler $item[1] = $same_string_quote; } - $compiled = $this->compileValue($item); + $compiled = $this->compileValue($item, $quote); if ($prefix_value && \strlen($compiled)) { $compiled = $prefix_value . $compiled; @@ -4338,7 +4636,7 @@ class Compiler $filtered[] = $compiled; } - return $pre . substr(implode("$delim", $filtered), \strlen($prefix_value)) . $post; + return $pre . substr(implode($separator, $filtered), \strlen($prefix_value)) . $post; case Type::T_MAP: $keys = $value[1]; @@ -4346,7 +4644,7 @@ class Compiler $filtered = []; for ($i = 0, $s = \count($keys); $i < $s; $i++) { - $filtered[$this->compileValue($keys[$i])] = $this->compileValue($values[$i]); + $filtered[$this->compileValue($keys[$i], $quote)] = $this->compileValue($values[$i], $quote); } array_walk($filtered, function (&$value, $key) { @@ -4367,7 +4665,7 @@ class Compiler } $left = \count($left[2]) > 0 - ? $this->compileValue($left) . $delim . $whiteLeft + ? $this->compileValue($left, $quote) . $delim . $whiteLeft : ''; $delim = $right[1]; @@ -4377,14 +4675,18 @@ class Compiler } $right = \count($right[2]) > 0 ? - $whiteRight . $delim . $this->compileValue($right) : ''; + $whiteRight . $delim . $this->compileValue($right, $quote) : ''; - return $left . $this->compileValue($interpolate) . $right; + return $left . $this->compileValue($interpolate, $quote) . $right; case Type::T_INTERPOLATE: // strip quotes if it's a string $reduced = $this->reduce($value[1]); + if ($reduced instanceof Number) { + return $this->compileValue($reduced, $quote); + } + switch ($reduced[0]) { case Type::T_LIST: $reduced = $this->extractInterpolation($reduced); @@ -4406,14 +4708,12 @@ class Compiler continue; } - $temp = $this->compileValue([Type::T_KEYWORD, $item]); - - if ($temp[0] === Type::T_STRING) { - $filtered[] = $this->compileStringContent($temp); - } elseif ($temp[0] === Type::T_KEYWORD) { - $filtered[] = $temp[1]; + if ($item[0] === Type::T_STRING) { + $filtered[] = $this->compileStringContent($item, $quote); + } elseif ($item[0] === Type::T_KEYWORD) { + $filtered[] = $item[1]; } else { - $filtered[] = $this->compileValue($temp); + $filtered[] = $this->compileValue($item, $quote); } } @@ -4428,7 +4728,7 @@ class Compiler $reduced = [Type::T_KEYWORD, '']; } - return $this->compileValue($reduced); + return $this->compileValue($reduced, $quote); case Type::T_NULL: return 'null'; @@ -4442,14 +4742,18 @@ class Compiler } /** - * @param array $value + * @param array|Number $value * - * @return array|string + * @return string */ protected function compileDebugValue($value) { $value = $this->reduce($value, true); + if ($value instanceof Number) { + return $this->compileValue($value); + } + switch ($value[0]) { case Type::T_STRING: return $this->compileStringContent($value); @@ -4465,26 +4769,50 @@ class Compiler * @param array $list * * @return string + * + * @deprecated */ protected function flattenList($list) { + @trigger_error(sprintf('The "%s" method is deprecated.', __METHOD__), E_USER_DEPRECATED); + return $this->compileValue($list); } + /** + * Gets the text of a Sass string + * + * Calling this method on anything else than a SassString is unsupported. Use {@see assertString} first + * to ensure that the value is indeed a string. + * + * @param array $value + * + * @return string + */ + public function getStringText(array $value) + { + if ($value[0] !== Type::T_STRING) { + throw new \InvalidArgumentException('The argument is not a sass string. Did you forgot to use "assertString"?'); + } + + return $this->compileStringContent($value); + } + /** * Compile string content * * @param array $string + * @param bool $quote * * @return string */ - protected function compileStringContent($string) + protected function compileStringContent($string, $quote = true) { $parts = []; foreach ($string[2] as $part) { - if (\is_array($part) || $part instanceof \ArrayAccess) { - $parts[] = $this->compileValue($part); + if (\is_array($part) || $part instanceof Number) { + $parts[] = $this->compileValue($part, $quote); } else { $parts[] = $part; } @@ -4580,10 +4908,10 @@ class Compiler /** * Join selectors; looks for & to replace, or append parent before child * - * @param array $parent - * @param array $child - * @param boolean $stillHasSelf - * @param array $selfParentSelectors + * @param array $parent + * @param array $child + * @param bool $stillHasSelf + * @param array $selfParentSelectors * @return array */ @@ -4661,6 +4989,8 @@ class Compiler return $this->multiplyMedia($env->parent, $childQueries); } + assert($env->block instanceof MediaBlock); + $parentQueries = isset($env->block->queryList) ? $env->block->queryList : [[[Type::T_MEDIA_VALUE, $env->block->value]]]; @@ -4795,7 +5125,7 @@ class Compiler * * @param string $name * @param mixed $value - * @param boolean $shadow + * @param bool $shadow * @param \ScssPhp\ScssPhp\Compiler\Environment $env * @param mixed $valueUnreduced * @@ -4898,12 +5228,12 @@ class Compiler /** * Get variable * - * @api + * @internal * * @param string $name - * @param boolean $shouldThrow + * @param bool $shouldThrow * @param \ScssPhp\ScssPhp\Compiler\Environment $env - * @param boolean $unreduced + * @param bool $unreduced * * @return mixed|null */ @@ -4970,7 +5300,7 @@ class Compiler * @param string $name * @param \ScssPhp\ScssPhp\Compiler\Environment $env * - * @return boolean + * @return bool */ protected function has($name, Environment $env = null) { @@ -4997,7 +5327,7 @@ class Compiler $name = substr($name, 1); } - if (! $parser->parseValue($strValue, $value)) { + if (!\is_string($strValue) || ! $parser->parseValue($strValue, $value)) { $value = $this->coerceValue($strValue); } @@ -5005,6 +5335,43 @@ class Compiler } } + /** + * Replaces variables. + * + * @param array $variables + * + * @return void + */ + public function replaceVariables(array $variables) + { + $this->registeredVars = []; + $this->addVariables($variables); + } + + /** + * Replaces variables. + * + * @param array $variables + * + * @return void + */ + public function addVariables(array $variables) + { + $triggerWarning = false; + + foreach ($variables as $name => $value) { + if (!$value instanceof Number && !\is_array($value)) { + $triggerWarning = true; + } + + $this->registeredVars[$name] = $value; + } + + if ($triggerWarning) { + @trigger_error('Passing raw values to as custom variables to the Compiler is deprecated. Use "\ScssPhp\ScssPhp\ValueConverter::parseValue" or "\ScssPhp\ScssPhp\ValueConverter::fromPhp" to convert them instead.', E_USER_DEPRECATED); + } + } + /** * Set variables * @@ -5013,10 +5380,14 @@ class Compiler * @param array $variables * * @return void + * + * @deprecated Use "addVariables" or "replaceVariables" instead. */ public function setVariables(array $variables) { - $this->registeredVars = array_merge($this->registeredVars, $variables); + @trigger_error('The method "setVariables" of the Compiler is deprecated. Use the "addVariables" method for the equivalent behavior or "replaceVariables" if merging with previous variables was not desired.'); + + $this->addVariables($variables); } /** @@ -5048,15 +5419,15 @@ class Compiler /** * Adds to list of parsed files * - * @api + * @internal * - * @param string $path + * @param string|null $path * * @return void */ public function addParsedFile($path) { - if (isset($path) && is_file($path)) { + if (! \is_null($path) && is_file($path)) { $this->parsedFiles[realpath($path)] = filemtime($path); } } @@ -5064,12 +5435,12 @@ class Compiler /** * Returns list of parsed files * - * @api - * - * @return array + * @deprecated + * @return array */ public function getParsedFiles() { + @trigger_error('The method "getParsedFiles" of the Compiler is deprecated. Use the "getIncludedFiles" method on the CompilationResult instance returned by compileString() instead. Be careful that the signature of the method is different.', E_USER_DEPRECATED); return $this->parsedFiles; } @@ -5108,7 +5479,7 @@ class Compiler $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); + @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 "compileString()" instead.', E_USER_DEPRECATED); } $this->importPaths = $actualImportPaths; @@ -5119,7 +5490,7 @@ class Compiler * * @api * - * @param integer $numberPrecision + * @param int $numberPrecision * * @return void * @@ -5196,12 +5567,31 @@ class Compiler . 'Use source maps instead.', E_USER_DEPRECATED); } + /** + * Configures the handling of non-ASCII outputs. + * + * If $charset is `true`, this will include a `@charset` declaration or a + * UTF-8 [byte-order mark][] if the stylesheet contains any non-ASCII + * characters. Otherwise, it will never include a `@charset` declaration or a + * byte-order mark. + * + * [byte-order mark]: https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8 + * + * @param bool $charset + * + * @return void + */ + public function setCharset($charset) + { + $this->charset = $charset; + } + /** * Enable/disable source maps * * @api * - * @param integer $sourceMap + * @param int $sourceMap * * @return void * @@ -5219,6 +5609,8 @@ class Compiler * * @param array $sourceMapOptions * + * @phpstan-param array{sourceRoot?: string, sourceMapFilename?: string|null, sourceMapURL?: string|null, sourceMapWriteTo?: string|null, outputSourceFiles?: bool, sourceMapRootpath?: string, sourceMapBasepath?: string} $sourceMapOptions + * * @return void */ public function setSourceMapOptions($sourceMapOptions) @@ -5231,15 +5623,23 @@ class Compiler * * @api * - * @param string $name - * @param callable $func - * @param array|null $prototype + * @param string $name + * @param callable $callback + * @param string[]|null $argumentDeclaration * * @return void */ - public function registerFunction($name, $func, $prototype = null) + public function registerFunction($name, $callback, $argumentDeclaration = null) { - $this->userFunctions[$this->normalizeName($name)] = [$func, $prototype]; + if (self::isNativeFunction($name)) { + @trigger_error(sprintf('The "%s" function is a core sass function. Overriding it with a custom implementation through "%s" is deprecated and won\'t be supported in ScssPhp 2.0 anymore.', $name, __METHOD__), E_USER_DEPRECATED); + } + + if ($argumentDeclaration === null) { + @trigger_error('Omitting the argument declaration when registering custom function is deprecated and won\'t be supported in ScssPhp 2.0 anymore.', E_USER_DEPRECATED); + } + + $this->userFunctions[$this->normalizeName($name)] = [$callback, $argumentDeclaration]; } /** @@ -5288,6 +5688,15 @@ class Compiler // see if tree is cached $realPath = realpath($path); + if (substr($path, -5) === '.sass') { + $this->sourceIndex = \count($this->sourceNames); + $this->sourceNames[] = $path; + $this->sourceLine = 1; + $this->sourceColumn = 1; + + throw $this->error('The Sass indented syntax is not implemented.'); + } + if (isset($this->importCache[$realPath])) { $this->handleImportLoop($realPath); @@ -5309,19 +5718,51 @@ class Compiler } /** - * Return the file path for an import url if it exists + * Save the imported files with their resolving path context * - * @api + * @param string|null $currentDirectory + * @param string $path + * @param string $filePath + * + * @return void + */ + private function registerImport($currentDirectory, $path, $filePath) + { + $this->resolvedImports[] = ['currentDir' => $currentDirectory, 'path' => $path, 'filePath' => $filePath]; + } + + /** + * Detects whether the import is a CSS import. + * + * For legacy reasons, custom importers are called for those, allowing them + * to replace them with an actual Sass import. However this behavior is + * deprecated. Custom importers are expected to return null when they receive + * a CSS import. * * @param string $url * + * @return bool + */ + public static function isCssImport($url) + { + return 1 === preg_match('~\.css$|^https?://|^//~', $url); + } + + /** + * Return the file path for an import url if it exists + * + * @internal + * + * @param string $url + * @param string|null $currentDir + * * @return string|null */ - public function findImport($url) + public function findImport($url, $currentDir = null) { - // for "normal" scss imports (ignore vanilla css and external requests) + // Vanilla css and external requests. These are not meant to be Sass imports. // Callback importers are still called for BC. - if (preg_match('~\.css$|^https?://|^//~', $url)) { + if (self::isCssImport($url)) { foreach ($this->importPaths as $dir) { if (\is_string($dir)) { continue; @@ -5332,6 +5773,24 @@ class Compiler $file = \call_user_func($dir, $url); if (! \is_null($file)) { + if (\is_array($dir)) { + $callableDescription = (\is_object($dir[0]) ? \get_class($dir[0]) : $dir[0]).'::'.$dir[1]; + } elseif ($dir instanceof \Closure) { + $r = new \ReflectionFunction($dir); + if (false !== strpos($r->name, '{closure}')) { + $callableDescription = sprintf('closure{%s:%s}', $r->getFileName(), $r->getStartLine()); + } elseif ($class = $r->getClosureScopeClass()) { + $callableDescription = $class->name.'::'.$r->name; + } else { + $callableDescription = $r->name; + } + } elseif (\is_object($dir)) { + $callableDescription = \get_class($dir) . '::__invoke'; + } else { + $callableDescription = 'callable'; // Fallback if we don't have a dedicated description + } + @trigger_error(sprintf('Returning a file to import for CSS or external references in custom importer callables is deprecated and will not be supported anymore in ScssPhp 2.0. This behavior is not compliant with the Sass specification. Update your "%s" importer.', $callableDescription), E_USER_DEPRECATED); + return $file; } } @@ -5339,8 +5798,8 @@ class Compiler return null; } - if (!\is_null($this->currentDirectory)) { - $relativePath = $this->resolveImportPath($url, $this->currentDirectory); + if (!\is_null($currentDir)) { + $relativePath = $this->resolveImportPath($url, $currentDir); if (!\is_null($relativePath)) { return $relativePath; @@ -5368,7 +5827,7 @@ class Compiler $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); + @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 "compileString()" instead.', E_USER_DEPRECATED); return $path; } @@ -5387,7 +5846,7 @@ class Compiler { $path = Path::join($baseDir, $url); - $hasExtension = preg_match('/.scss$/', $url); + $hasExtension = preg_match('/.s[ac]ss$/', $url); if ($hasExtension) { return $this->checkImportPathConflicts($this->tryImportPath($path)); @@ -5433,7 +5892,10 @@ class Compiler */ private function tryImportPathWithExtensions($path) { - $result = $this->tryImportPath($path.'.scss'); + $result = array_merge( + $this->tryImportPath($path.'.sass'), + $this->tryImportPath($path.'.scss') + ); if ($result) { return $result; @@ -5479,12 +5941,16 @@ class Compiler } /** - * @param string $path + * @param string|null $path * * @return string */ private function getPrettyPath($path) { + if ($path === null) { + return '(unknown file)'; + } + $normalizedPath = $path; $normalizedRootDirectory = $this->rootDirectory.'/'; @@ -5494,7 +5960,7 @@ class Compiler } if (0 === strpos($normalizedPath, $normalizedRootDirectory)) { - return substr($normalizedPath, \strlen($normalizedRootDirectory)); + return substr($path, \strlen($normalizedRootDirectory)); } return $path; @@ -5505,12 +5971,20 @@ class Compiler * * @api * - * @param string $encoding + * @param string|null $encoding * * @return void + * + * @deprecated Non-compliant support for other encodings than UTF-8 is deprecated. */ public function setEncoding($encoding) { + if (!$encoding || strtolower($encoding) === 'utf-8') { + @trigger_error(sprintf('The "%s" method is deprecated.', __METHOD__), E_USER_DEPRECATED); + } else { + @trigger_error(sprintf('The "%s" method is deprecated. Parsing will only support UTF-8 in ScssPhp 2.0. The non-UTF-8 parsing of ScssPhp 1.x is not spec compliant.', __METHOD__), E_USER_DEPRECATED); + } + $this->encoding = $encoding; } @@ -5519,7 +5993,7 @@ class Compiler * * @api * - * @param boolean $ignoreErrors + * @param bool $ignoreErrors * * @return \ScssPhp\ScssPhp\Compiler * @@ -5538,9 +6012,13 @@ class Compiler * @api * * @return array + * + * @deprecated */ public function getSourcePosition() { + @trigger_error(sprintf('The "%s" method is deprecated.', __METHOD__), E_USER_DEPRECATED); + $sourceFile = isset($this->sourceNames[$this->sourceIndex]) ? $this->sourceNames[$this->sourceIndex] : ''; return [$sourceFile, $this->sourceLine, $this->sourceColumn]; @@ -5570,7 +6048,7 @@ class Compiler /** * Build an error (exception) * - * @api + * @internal * * @param string $msg Message with optional sprintf()-style vararg parameters * @@ -5583,33 +6061,49 @@ class Compiler } if (! $this->ignoreCallStackMessage) { - $line = $this->sourceLine; - $column = $this->sourceColumn; - - $loc = isset($this->sourceNames[$this->sourceIndex]) - ? $this->getPrettyPath($this->sourceNames[$this->sourceIndex]) . " on line $line, at column $column" - : "line: $line, column: $column"; - - $msg = "$msg: $loc"; - - $callStackMsg = $this->callStackMessage(); - - if ($callStackMsg) { - $msg .= "\nCall Stack:\n" . $callStackMsg; - } + $msg = $this->addLocationToMessage($msg); } return new CompilerException($msg); } + /** + * @param string $msg + * + * @return string + */ + private function addLocationToMessage($msg) + { + $line = $this->sourceLine; + $column = $this->sourceColumn; + + $loc = isset($this->sourceNames[$this->sourceIndex]) + ? $this->getPrettyPath($this->sourceNames[$this->sourceIndex]) . " on line $line, at column $column" + : "line: $line, column: $column"; + + $msg = "$msg: $loc"; + + $callStackMsg = $this->callStackMessage(); + + if ($callStackMsg) { + $msg .= "\nCall Stack:\n" . $callStackMsg; + } + + return $msg; + } + /** * @param string $functionName * @param array $ExpectedArgs * @param int $nbActual * @return CompilerException + * + * @deprecated */ public function errorArgsNumber($functionName, $ExpectedArgs, $nbActual) { + @trigger_error(sprintf('The "%s" method is deprecated.', __METHOD__), E_USER_DEPRECATED); + $nbExpected = \count($ExpectedArgs); if ($nbActual > $nbExpected) { @@ -5638,8 +6132,8 @@ class Compiler /** * Beautify call stack for output * - * @param boolean $all - * @param null $limit + * @param bool $all + * @param int|null $limit * * @return string */ @@ -5685,6 +6179,10 @@ class Compiler $file = $this->sourceNames[$env->block->sourceIndex]; + if ($file === null) { + continue; + } + if (realpath($file) === $name) { throw $this->error('An @import loop has been found: %s imports %s', $file, basename($file)); } @@ -5694,10 +6192,10 @@ class Compiler /** * Call SCSS @function * - * @param Object $func - * @param array $argValues + * @param CallableBlock|null $func + * @param array $argValues * - * @return array + * @return array|Number */ protected function callScssFunction($func, $argValues) { @@ -5737,7 +6235,7 @@ class Compiler * Call built-in and registered (PHP) functions * * @param string $name - * @param string|array $function + * @param callable $function * @param array $prototype * @param array $args * @@ -5754,14 +6252,10 @@ class Compiler @list($sorted, $kwargs) = $sorted_kwargs; if ($name !== 'if') { - $inExp = true; - - if ($name === 'join') { - $inExp = false; - } - foreach ($sorted as &$val) { - $val = $this->reduce($val, $inExp); + if ($val !== null) { + $val = $this->reduce($val, true); + } } } @@ -5771,6 +6265,12 @@ class Compiler return null; } + if (\is_array($returnValue) || $returnValue instanceof Number) { + return $returnValue; + } + + @trigger_error(sprintf('Returning a PHP value from the "%s" custom function is deprecated. A sass value must be returned instead.', $name), E_USER_DEPRECATED); + return $this->coerceValue($returnValue); } @@ -5789,7 +6289,11 @@ class Compiler /** * Normalize native function name + * + * @internal + * * @param string $name + * * @return string */ public static function normalizeNativeFunctionName($name) @@ -5807,7 +6311,11 @@ class Compiler /** * Check if a function is a native built-in scss function, for css parsing + * + * @internal + * * @param string $name + * * @return bool */ public static function isNativeFunction($name) @@ -5819,7 +6327,7 @@ class Compiler * Sorts keyword arguments * * @param string $functionName - * @param array $prototypes + * @param array|null $prototypes * @param array $args * * @return array|null @@ -5855,140 +6363,416 @@ class Compiler // 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) { - $last = end($arg[1][2]); - - if ($last[0] === Type::T_EXPRESSION && $last[1] === '/') { - array_pop($arg[1][2]); - $arg[1][2][] = $last[2]; - $arg[1][2][] = $last[3]; - $args[$k] = $arg; - } + $args[$k][1][2] = $this->extractSlashAlphaInColorFunction($arg[1][2]); } } } - $finalArgs = []; + list($positionalArgs, $namedArgs, $names, $separator, $hasSplat) = $this->evaluateArguments($args, false); if (! \is_array(reset($prototypes))) { $prototypes = [$prototypes]; } + $parsedPrototypes = array_map([$this, 'parseFunctionPrototype'], $prototypes); + assert(!empty($parsedPrototypes)); + $matchedPrototype = $this->selectFunctionPrototype($parsedPrototypes, \count($positionalArgs), $names); + + $this->verifyPrototype($matchedPrototype, \count($positionalArgs), $names, $hasSplat); + + $vars = $this->applyArgumentsToDeclaration($matchedPrototype, $positionalArgs, $namedArgs, $separator); + + $finalArgs = []; $keyArgs = []; - // trying each prototypes - $prototypeHasMatch = false; - $exceptionMessage = ''; + foreach ($matchedPrototype['arguments'] as $argument) { + list($normalizedName, $originalName, $default) = $argument; - foreach ($prototypes as $prototype) { - $argDef = []; - - foreach ($prototype as $i => $p) { - $default = null; - $p = explode(':', $p, 2); - $name = array_shift($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)) { - $parser = $this->parserFactory(__METHOD__); - } - - $parser->parseValue($p, $default); - } - } - - $isVariable = false; - - if (substr($name, -3) === '...') { - $isVariable = true; - $name = substr($name, 0, -3); - } - - $argDef[] = [$name, $default, $isVariable]; + if (isset($vars[$normalizedName])) { + $value = $vars[$normalizedName]; + } else { + $value = $default; } - $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 - foreach ($prototype as $i => $p) { - $name = explode(':', $p)[0]; - - if (! isset($finalArgs[$i])) { - $finalArgs[$i] = null; - } - } - - // apply positional args - foreach (array_values($vars) as $i => $val) { - $finalArgs[$i] = $val; - } - - $keyArgs = array_merge($keyArgs, $vars); - $prototypeHasMatch = true; - - // overwrite positional args with keyword args - foreach ($prototype as $i => $p) { - $name = explode(':', $p)[0]; - - if (isset($keyArgs[$name])) { - $finalArgs[$i] = $keyArgs[$name]; - } - - // special null value as default: translate to real null here - if ($finalArgs[$i] === [Type::T_KEYWORD, 'null']) { - $finalArgs[$i] = null; - } - } - // should we break if this prototype seems fulfilled? - } catch (CompilerException $e) { - $exceptionMessage = $e->getMessage(); + // special null value as default: translate to real null here + if ($value === [Type::T_KEYWORD, 'null']) { + $value = null; } - $this->ignoreCallStackMessage = $ignoreCallStackMessage; + + $finalArgs[] = $value; + $keyArgs[$originalName] = $value; } - if ($exceptionMessage && ! $prototypeHasMatch) { - 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; - } - } - } + if ($matchedPrototype['rest_argument'] !== null) { + $value = $vars[$matchedPrototype['rest_argument']]; - throw $this->error($exceptionMessage); + $finalArgs[] = $value; + $keyArgs[$matchedPrototype['rest_argument']] = $value; } return [$finalArgs, $keyArgs]; } /** - * Apply argument values per definition + * Parses a function prototype to the internal representation of arguments. * - * @param array $argDef - * @param array $argValues - * @param boolean $storeInEnv - * @param boolean $reduce - * only used if $storeInEnv = false + * The input is an array of strings describing each argument, as supported + * in {@see registerFunction}. Argument names don't include the `$`. + * The output contains the list of positional argument, with their normalized + * name (underscores are replaced by dashes), their original name (to be used + * in case of error reporting) and their default value. The output also contains + * the normalized name of the rest argument, or null if the function prototype + * is not variadic. + * + * @param string[] $prototype * * @return array + * @phpstan-return array{arguments: list, rest_argument: string|null} + */ + private function parseFunctionPrototype(array $prototype) + { + static $parser = null; + + $arguments = []; + $restArgument = null; + + foreach ($prototype as $p) { + if (null !== $restArgument) { + throw new \InvalidArgumentException('The argument declaration is invalid. The rest argument must be the last one.'); + } + + $default = null; + $p = explode(':', $p, 2); + $name = str_replace('_', '-', $p[0]); + + if (isset($p[1])) { + $defaultSource = trim($p[1]); + + if ($defaultSource === 'null') { + // differentiate this null from the static::$null + $default = [Type::T_KEYWORD, 'null']; + } else { + if (\is_null($parser)) { + $parser = $this->parserFactory(__METHOD__); + } + + $parser->parseValue($defaultSource, $default); + } + } + + if (substr($name, -3) === '...') { + $restArgument = substr($name, 0, -3); + } else { + $arguments[] = [$name, $p[0], $default]; + } + } + + return [ + 'arguments' => $arguments, + 'rest_argument' => $restArgument, + ]; + } + + /** + * Returns the function prototype for the given positional and named arguments. + * + * If no exact match is found, finds the closest approximation. Note that this + * doesn't guarantee that $positional and $names are valid for the returned + * prototype. + * + * @param array[] $prototypes + * @param int $positional + * @param array $names A set of names, as both keys and values + * + * @return array + * + * @phpstan-param non-empty-list, rest_argument: string|null}> $prototypes + * @phpstan-return array{arguments: list, rest_argument: string|null} + */ + private function selectFunctionPrototype(array $prototypes, $positional, array $names) + { + $fuzzyMatch = null; + $minMismatchDistance = null; + + foreach ($prototypes as $prototype) { + // Ideally, find an exact match. + if ($this->checkPrototypeMatches($prototype, $positional, $names)) { + return $prototype; + } + + $mismatchDistance = \count($prototype['arguments']) - $positional; + + if ($minMismatchDistance !== null) { + if (abs($mismatchDistance) > abs($minMismatchDistance)) { + continue; + } + + // If two overloads have the same mismatch distance, favor the overload + // that has more arguments. + if (abs($mismatchDistance) === abs($minMismatchDistance) && $mismatchDistance < 0) { + continue; + } + } + + $minMismatchDistance = $mismatchDistance; + $fuzzyMatch = $prototype; + } + + return $fuzzyMatch; + } + + /** + * Checks whether the argument invocation matches the callable prototype. + * + * The rules are similar to {@see verifyPrototype}. The boolean return value + * avoids the overhead of building and catching exceptions when the reason of + * not matching the prototype does not need to be known. + * + * @param array $prototype + * @param int $positional + * @param array $names + * + * @return bool + * + * @phpstan-param array{arguments: list, rest_argument: string|null} $prototype + */ + private function checkPrototypeMatches(array $prototype, $positional, array $names) + { + $nameUsed = 0; + + foreach ($prototype['arguments'] as $i => $argument) { + list ($name, $originalName, $default) = $argument; + + if ($i < $positional) { + if (isset($names[$name])) { + return false; + } + } elseif (isset($names[$name])) { + $nameUsed++; + } elseif ($default === null) { + return false; + } + } + + if ($prototype['rest_argument'] !== null) { + return true; + } + + if ($positional > \count($prototype['arguments'])) { + return false; + } + + if ($nameUsed < \count($names)) { + return false; + } + + return true; + } + + /** + * Verifies that the argument invocation is valid for the callable prototype. + * + * @param array $prototype + * @param int $positional + * @param array $names + * @param bool $hasSplat + * + * @return void + * + * @throws SassScriptException + * + * @phpstan-param array{arguments: list, rest_argument: string|null} $prototype + */ + private function verifyPrototype(array $prototype, $positional, array $names, $hasSplat) + { + $nameUsed = 0; + + foreach ($prototype['arguments'] as $i => $argument) { + list ($name, $originalName, $default) = $argument; + + if ($i < $positional) { + if (isset($names[$name])) { + throw new SassScriptException(sprintf('Argument $%s was passed both by position and by name.', $originalName)); + } + } elseif (isset($names[$name])) { + $nameUsed++; + } elseif ($default === null) { + throw new SassScriptException(sprintf('Missing argument $%s', $originalName)); + } + } + + if ($prototype['rest_argument'] !== null) { + return; + } + + if ($positional > \count($prototype['arguments'])) { + $message = sprintf( + 'Only %d %sargument%s allowed, but %d %s passed.', + \count($prototype['arguments']), + empty($names) ? '' : 'positional ', + \count($prototype['arguments']) === 1 ? '' : 's', + $positional, + $positional === 1 ? 'was' : 'were' + ); + if (!$hasSplat) { + throw new SassScriptException($message); + } + + $message = $this->addLocationToMessage($message); + $message .= "\nThis will be an error in future versions of Sass."; + $this->logger->warn($message, true); + } + + if ($nameUsed < \count($names)) { + $unknownNames = array_values(array_diff($names, array_column($prototype['arguments'], 0))); + $lastName = array_pop($unknownNames); + $message = sprintf( + 'No argument%s named $%s%s.', + $unknownNames ? 's' : '', + $unknownNames ? implode(', $', $unknownNames) . ' or $' : '', + $lastName + ); + throw new SassScriptException($message); + } + } + + /** + * Evaluates the argument from the invocation. + * + * This returns several things about this invocation: + * - the list of positional arguments + * - the map of named arguments, indexed by normalized names + * - the set of names used in the arguments (that's an array using the normalized names as keys for O(1) access) + * - the separator used by the list using the splat operator, if any + * - a boolean indicator whether any splat argument (list or map) was used, to support the incomplete error reporting. + * + * @param array[] $args + * @param bool $reduce Whether arguments should be reduced to their value + * + * @return array + * + * @throws SassScriptException + * + * @phpstan-return array{0: list, 1: array, 2: array, 3: string|null, 4: bool} + */ + private function evaluateArguments(array $args, $reduce = true) + { + // this represents trailing commas + if (\count($args) && end($args) === static::$null) { + array_pop($args); + } + + $splatSeparator = null; + $keywordArgs = []; + $names = []; + $positionalArgs = []; + $hasKeywordArgument = false; + $hasSplat = false; + + foreach ($args as $arg) { + if (!empty($arg[0])) { + $hasKeywordArgument = true; + + assert(\is_string($arg[0][1])); + $name = str_replace('_', '-', $arg[0][1]); + + if (isset($keywordArgs[$name])) { + throw new SassScriptException(sprintf('Duplicate named argument $%s.', $arg[0][1])); + } + + $keywordArgs[$name] = $this->maybeReduce($reduce, $arg[1]); + $names[$name] = $name; + } elseif (! empty($arg[2])) { + // $arg[2] means a var followed by ... in the arg ($list... ) + $val = $this->reduce($arg[1], true); + $hasSplat = true; + + if ($val[0] === Type::T_LIST) { + foreach ($val[2] as $item) { + if (\is_null($splatSeparator)) { + $splatSeparator = $val[1]; + } + + $positionalArgs[] = $this->maybeReduce($reduce, $item); + } + + if (isset($val[3]) && \is_array($val[3])) { + foreach ($val[3] as $name => $item) { + assert(\is_string($name)); + + $normalizedName = str_replace('_', '-', $name); + + if (isset($keywordArgs[$normalizedName])) { + throw new SassScriptException(sprintf('Duplicate named argument $%s.', $name)); + } + + $keywordArgs[$normalizedName] = $this->maybeReduce($reduce, $item); + $names[$normalizedName] = $normalizedName; + $hasKeywordArgument = true; + } + } + } elseif ($val[0] === Type::T_MAP) { + foreach ($val[1] as $i => $name) { + $name = $this->compileStringContent($this->coerceString($name)); + $item = $val[2][$i]; + + if (! is_numeric($name)) { + $normalizedName = str_replace('_', '-', $name); + + if (isset($keywordArgs[$normalizedName])) { + throw new SassScriptException(sprintf('Duplicate named argument $%s.', $name)); + } + + $keywordArgs[$normalizedName] = $this->maybeReduce($reduce, $item); + $names[$normalizedName] = $normalizedName; + $hasKeywordArgument = true; + } else { + if (\is_null($splatSeparator)) { + $splatSeparator = $val[1]; + } + + $positionalArgs[] = $this->maybeReduce($reduce, $item); + } + } + } elseif ($val[0] !== Type::T_NULL) { // values other than null are treated a single-element lists, while null is the empty list + $positionalArgs[] = $this->maybeReduce($reduce, $val); + } + } elseif ($hasKeywordArgument) { + throw new SassScriptException('Positional arguments must come before keyword arguments.'); + } else { + $positionalArgs[] = $this->maybeReduce($reduce, $arg[1]); + } + } + + return [$positionalArgs, $keywordArgs, $names, $splatSeparator, $hasSplat]; + } + + /** + * @param bool $reduce + * @param array|Number $value + * + * @return array|Number + */ + private function maybeReduce($reduce, $value) + { + if ($reduce) { + return $this->reduce($value, true); + } + + return $value; + } + + /** + * Apply argument values per definition + * + * @param array[] $argDef + * @param array|null $argValues + * @param bool $storeInEnv + * @param bool $reduce only used if $storeInEnv = false + * + * @return array + * + * @phpstan-param list $argDef * * @throws \Exception */ @@ -5996,8 +6780,8 @@ class Compiler { $output = []; - if (\is_array($argValues) && \count($argValues) && end($argValues) === static::$null) { - array_pop($argValues); + if (\is_null($argValues)) { + $argValues = []; } if ($storeInEnv) { @@ -6007,151 +6791,47 @@ class Compiler $env->store = $storeEnv->store; } - $hasVariable = false; - $args = []; + $prototype = ['arguments' => [], 'rest_argument' => null]; + $originalRestArgumentName = null; foreach ($argDef as $i => $arg) { - list($name, $default, $isVariable) = $argDef[$i]; - - $args[$name] = [$i, $name, $default, $isVariable]; - $hasVariable |= $isVariable; - } - - $splatSeparator = null; - $keywordArgs = []; - $deferredKeywordArgs = []; - $deferredNamedKeywordArgs = []; - $remaining = []; - $hasKeywordArgument = false; - - // assign the keyword args - foreach ((array) $argValues as $arg) { - if (! empty($arg[0])) { - $hasKeywordArgument = true; - - $name = $arg[0][1]; - - if (! isset($args[$name])) { - foreach (array_keys($args) as $an) { - if (str_replace('_', '-', $an) === str_replace('_', '-', $name)) { - $name = $an; - break; - } - } - } - - if (! isset($args[$name]) || $args[$name][3]) { - if ($hasVariable) { - $deferredNamedKeywordArgs[$name] = $arg[1]; - } else { - throw $this->error("Mixin or function doesn't have an argument named $%s.", $arg[0][1]); - } - } 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 (! 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) { - foreach ($val[2] as $name => $item) { - if (! is_numeric($name)) { - if (! isset($args[$name])) { - foreach (array_keys($args) as $an) { - if (str_replace('_', '-', $an) === str_replace('_', '-', $name)) { - $name = $an; - break; - } - } - } - - if ($hasVariable) { - $deferredKeywordArgs[$name] = $item; - } else { - $keywordArgs[$name] = $item; - } - } else { - if (\is_null($splatSeparator)) { - $splatSeparator = $val[1]; - } - - $remaining[] = $item; - } - } - } elseif ($val[0] === Type::T_MAP) { - foreach ($val[1] as $i => $name) { - $name = $this->compileStringContent($this->coerceString($name)); - $item = $val[2][$i]; - - if (! is_numeric($name)) { - if (! isset($args[$name])) { - foreach (array_keys($args) as $an) { - if (str_replace('_', '-', $an) === str_replace('_', '-', $name)) { - $name = $an; - break; - } - } - } - - if ($hasVariable) { - $deferredKeywordArgs[$name] = $item; - } else { - $keywordArgs[$name] = $item; - } - } else { - if (\is_null($splatSeparator)) { - $splatSeparator = $val[1]; - } - - $remaining[] = $item; - } - } - } else { - $remaining[] = $val; - } - } elseif ($hasKeywordArgument) { - throw $this->error('Positional arguments must come before keyword arguments.'); - } else { - $remaining[] = $arg[1]; - } - } - - foreach ($args as $arg) { - list($i, $name, $default, $isVariable) = $arg; + list($name, $default, $isVariable) = $arg; + $normalizedName = str_replace('_', '-', $name); if ($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); - } - } - - $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])) { - $val = $keywordArgs[$name]; - } elseif (! empty($default)) { - continue; + $originalRestArgumentName = $name; + $prototype['rest_argument'] = $normalizedName; } else { - throw $this->error("Missing argument $name"); + $prototype['arguments'][] = [$normalizedName, $name, !empty($default) ? $default : null]; } + } + + list($positionalArgs, $namedArgs, $names, $splatSeparator, $hasSplat) = $this->evaluateArguments($argValues, $reduce); + + $this->verifyPrototype($prototype, \count($positionalArgs), $names, $hasSplat); + + $vars = $this->applyArgumentsToDeclaration($prototype, $positionalArgs, $namedArgs, $splatSeparator); + + foreach ($prototype['arguments'] as $argument) { + list($normalizedName, $name) = $argument; + + if (!isset($vars[$normalizedName])) { + continue; + } + + $val = $vars[$normalizedName]; + + if ($storeInEnv) { + $this->set($name, $this->reduce($val, true), true, $env); + } else { + $output[$name] = ($reduce ? $this->reduce($val, true) : $val); + } + } + + if ($prototype['rest_argument'] !== null) { + assert($originalRestArgumentName !== null); + $name = $originalRestArgumentName; + $val = $vars[$prototype['rest_argument']]; if ($storeInEnv) { $this->set($name, $this->reduce($val, true), true, $env); @@ -6164,12 +6844,13 @@ class Compiler $storeEnv->store = $env->store; } - foreach ($args as $arg) { - list($i, $name, $default, $isVariable) = $arg; + foreach ($prototype['arguments'] as $argument) { + list($normalizedName, $name, $default) = $argument; - if ($isVariable || isset($remaining[$i]) || isset($keywordArgs[$name]) || empty($default)) { + if (isset($vars[$normalizedName])) { continue; } + assert($default !== null); if ($storeInEnv) { $this->set($name, $this->reduce($default, true), true); @@ -6181,6 +6862,67 @@ class Compiler return $output; } + /** + * Apply argument values per definition. + * + * This method assumes that the arguments are valid for the provided prototype. + * The validation with {@see verifyPrototype} must have been run before calling + * it. + * Arguments are returned as a map from the normalized argument names to the + * value. Additional arguments are collected in a sass argument list available + * under the name of the rest argument in the result. + * + * Defaults are not applied as they are resolved in a different environment. + * + * @param array $prototype + * @param array $positionalArgs + * @param array $namedArgs + * @param string|null $splatSeparator + * + * @return array + * + * @phpstan-param array{arguments: list, rest_argument: string|null} $prototype + */ + private function applyArgumentsToDeclaration(array $prototype, array $positionalArgs, array $namedArgs, $splatSeparator) + { + $output = []; + $minLength = min(\count($positionalArgs), \count($prototype['arguments'])); + + for ($i = 0; $i < $minLength; $i++) { + list($name) = $prototype['arguments'][$i]; + $val = $positionalArgs[$i]; + + $output[$name] = $val; + } + + $restNamed = $namedArgs; + + for ($i = \count($positionalArgs); $i < \count($prototype['arguments']); $i++) { + $argument = $prototype['arguments'][$i]; + list($name) = $argument; + + if (isset($namedArgs[$name])) { + $val = $namedArgs[$name]; + unset($restNamed[$name]); + } else { + continue; + } + + $output[$name] = $val; + } + + if ($prototype['rest_argument'] !== null) { + $name = $prototype['rest_argument']; + $rest = array_values(array_slice($positionalArgs, \count($prototype['arguments']))); + + $val = [Type::T_LIST, \is_null($splatSeparator) ? ',' : $splatSeparator , $rest, $restNamed]; + + $output[$name] = $val; + } + + return $output; + } + /** * Coerce a php value into a scss one * @@ -6190,7 +6932,7 @@ class Compiler */ protected function coerceValue($value) { - if (\is_array($value) || $value instanceof \ArrayAccess) { + if (\is_array($value) || $value instanceof Number) { return $value; } @@ -6220,6 +6962,33 @@ class Compiler return $value; } + /** + * Tries to convert an item to a Sass map + * + * @param Number|array $item + * + * @return array|null + */ + private function tryMap($item) + { + if ($item instanceof Number) { + return null; + } + + if ($item[0] === Type::T_MAP) { + return $item; + } + + if ( + $item[0] === Type::T_LIST && + $item[2] === [] + ) { + return static::$emptyMap; + } + + return null; + } + /** * Coerce something to map * @@ -6229,16 +6998,10 @@ class Compiler */ protected function coerceMap($item) { - if ($item[0] === Type::T_MAP) { - return $item; - } + $map = $this->tryMap($item); - if ( - $item[0] === static::$emptyList[0] && - $item[1] === static::$emptyList[1] && - $item[2] === static::$emptyList[2] - ) { - return static::$emptyMap; + if ($map !== null) { + return $map; } return $item; @@ -6247,15 +7010,19 @@ class Compiler /** * Coerce something to list * - * @param array $item - * @param string $delim - * @param boolean $removeTrailingNull + * @param array|Number $item + * @param string $delim + * @param bool $removeTrailingNull * * @return array */ protected function coerceList($item, $delim = ',', $removeTrailingNull = false) { - if (isset($item) && $item[0] === Type::T_LIST) { + if ($item instanceof Number) { + return [Type::T_LIST, '', [$item]]; + } + + if ($item[0] === Type::T_LIST) { // remove trailing null from the list if ($removeTrailingNull && end($item[2]) === static::$null) { array_pop($item[2]); @@ -6264,7 +7031,7 @@ class Compiler return $item; } - if (isset($item) && $item[0] === Type::T_MAP) { + if ($item[0] === Type::T_MAP) { $keys = $item[1]; $values = $item[2]; $list = []; @@ -6273,29 +7040,17 @@ class Compiler $key = $keys[$i]; $value = $values[$i]; - switch ($key[0]) { - case Type::T_LIST: - case Type::T_MAP: - case Type::T_STRING: - case Type::T_NULL: - break; - - default: - $key = [Type::T_KEYWORD, $this->compileStringContent($this->coerceString($key))]; - break; - } - $list[] = [ Type::T_LIST, - '', + ' ', [$key, $value] ]; } - return [Type::T_LIST, ',', $list]; + return [Type::T_LIST, $list ? ',' : '', $list]; } - return [Type::T_LIST, $delim, ! isset($item) ? [] : [$item]]; + return [Type::T_LIST, '', [$item]]; } /** @@ -6324,6 +7079,10 @@ class Compiler */ protected function coerceColor($value, $inRGBFunction = false) { + if ($value instanceof Number) { + return null; + } + switch ($value[0]) { case Type::T_COLOR: for ($i = 1; $i <= 3; $i++) { @@ -6432,10 +7191,10 @@ class Compiler } /** - * @param integer|Number $value - * @param boolean $isAlpha + * @param int|Number $value + * @param bool $isAlpha * - * @return integer|mixed + * @return int|mixed */ protected function compileRGBAValue($value, $isAlpha = false) { @@ -6447,12 +7206,12 @@ class Compiler } /** - * @param mixed $value - * @param integer|float $min - * @param integer|float $max - * @param boolean $isInt + * @param mixed $value + * @param int|float $min + * @param int|float $max + * @param bool $isInt * - * @return integer|mixed + * @return int|mixed */ protected function compileColorPartValue($value, $min, $max, $isInt = true) { @@ -6510,16 +7269,21 @@ class Compiler } /** - * Assert value is a string (or keyword) + * Assert value is a string + * + * This method deals with internal implementation details of the value + * representation where unquoted strings can sometimes be stored under + * other types. + * The returned value is always using the T_STRING type. * * @api * * @param array|Number $value - * @param string $varName + * @param string|null $varName * * @return array * - * @throws \Exception + * @throws SassScriptException */ public function assertString($value, $varName = null) { @@ -6530,13 +7294,10 @@ class Compiler 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."); + throw SassScriptException::forArgument("$value is not a string.", $varName); } - $value = $this->coerceString($value); - - return $value; + return $this->coerceString($value); } /** @@ -6544,10 +7305,14 @@ class Compiler * * @param array|Number $value * - * @return integer|float + * @return int|float + * + * @deprecated */ protected function coercePercent($value) { + @trigger_error(sprintf('"%s" is deprecated since 1.7.0.', __METHOD__), E_USER_DEPRECATED); + if ($value instanceof Number) { if ($value->hasUnit('%')) { return $value->getDimension() / 100; @@ -6565,20 +7330,23 @@ class Compiler * @api * * @param array|Number $value + * @param string|null $varName * * @return array * - * @throws \Exception + * @throws SassScriptException */ - public function assertMap($value) + public function assertMap($value, $varName = null) { - $value = $this->coerceMap($value); + $map = $this->tryMap($value); - if ($value[0] !== Type::T_MAP) { - throw $this->error('expecting map, %s received', $value[0]); + if ($map === null) { + $value = $this->compileValue($value); + + throw SassScriptException::forArgument("$value is not a map.", $varName); } - return $value; + return $map; } /** @@ -6601,24 +7369,48 @@ class Compiler return $value; } + /** + * Gets the keywords of an argument list. + * + * Keys in the returned array are normalized names (underscores are replaced with dashes) + * without the leading `$`. + * Calling this helper with anything that an argument list received for a rest argument + * of the function argument declaration is not supported. + * + * @param array|Number $value + * + * @return array + */ + public function getArgumentListKeywords($value) + { + if ($value[0] !== Type::T_LIST || !isset($value[3]) || !\is_array($value[3])) { + throw new \InvalidArgumentException('The argument is not a sass argument list.'); + } + + return $value[3]; + } + /** * Assert value is a color * * @api * * @param array|Number $value + * @param string|null $varName * * @return array * - * @throws \Exception + * @throws SassScriptException */ - public function assertColor($value) + public function assertColor($value, $varName = null) { if ($color = $this->coerceColor($value)) { return $color; } - throw $this->error('expecting color, %s received', $value[0]); + $value = $this->compileValue($value); + + throw SassScriptException::forArgument("$value is not a color.", $varName); } /** @@ -6627,18 +7419,17 @@ class Compiler * @api * * @param array|Number $value - * @param string $varName + * @param string|null $varName * * @return Number * - * @throws \Exception + * @throws SassScriptException */ public function assertNumber($value, $varName = null) { if (!$value instanceof Number) { $value = $this->compileValue($value); - $var_display = ($varName ? " \${$varName}:" : ''); - throw $this->error("Error:{$var_display} $value is not a number."); + throw SassScriptException::forArgument("$value is not a number.", $varName); } return $value; @@ -6650,24 +7441,40 @@ class Compiler * @api * * @param array|Number $value - * @param string $varName + * @param string|null $varName * - * @return integer + * @return int * - * @throws \Exception + * @throws SassScriptException */ 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."); + throw SassScriptException::forArgument("$value is not an integer.", $varName); } return intval($value); } + /** + * Extract the ... / alpha on the last argument of channel arg + * in color functions + * + * @param array $args + * @return array + */ + private function extractSlashAlphaInColorFunction($args) + { + $last = end($args); + if (\count($args) === 3 && $last[0] === Type::T_EXPRESSION && $last[1] === '/') { + array_pop($args); + $args[] = $last[2]; + $args[] = $last[3]; + } + return $args; + } + /** * Make sure a color's components don't go out of bounds @@ -6686,6 +7493,10 @@ class Compiler if ($c[$i] > 255) { $c[$i] = 255; } + + if (!\is_int($c[$i])) { + $c[$i] = round($c[$i]); + } } return $c; @@ -6694,11 +7505,11 @@ class Compiler /** * Convert RGB to HSL * - * @api + * @internal * - * @param integer $red - * @param integer $green - * @param integer $blue + * @param int $red + * @param int $green + * @param int $blue * * @return array */ @@ -6728,7 +7539,7 @@ class Compiler } } - return [Type::T_HSL, fmod($h, 360), $s * 100, $l / 5.1]; + return [Type::T_HSL, fmod($h + 360, 360), $s * 100, $l / 5.1]; } /** @@ -6766,11 +7577,11 @@ class Compiler /** * Convert HSL to RGB * - * @api + * @internal * - * @param integer $hue H from 0 to 360 - * @param integer $saturation S from 0 to 100 - * @param integer $lightness L from 0 to 100 + * @param int|float $hue H from 0 to 360 + * @param int|float $saturation S from 0 to 100 + * @param int|float $lightness L from 0 to 100 * * @return array */ @@ -6796,19 +7607,87 @@ class Compiler return $out; } + /** + * Convert HWB to RGB + * https://www.w3.org/TR/css-color-4/#hwb-to-rgb + * + * @api + * + * @param int $hue H from 0 to 360 + * @param int $whiteness W from 0 to 100 + * @param int $blackness B from 0 to 100 + * + * @return array + */ + private function HWBtoRGB($hue, $whiteness, $blackness) + { + $w = min(100, max(0, $whiteness)) / 100; + $b = min(100, max(0, $blackness)) / 100; + + $sum = $w + $b; + if ($sum > 1.0) { + $w = $w / $sum; + $b = $b / $sum; + } + $b = min(1.0 - $w, $b); + + $rgb = $this->toRGB($hue, 100, 50); + for($i = 1; $i < 4; $i++) { + $rgb[$i] *= (1.0 - $w - $b); + $rgb[$i] = round($rgb[$i] + 255 * $w + 0.0001); + } + + return $rgb; + } + + /** + * Convert RGB to HWB + * + * @api + * + * @param int $red + * @param int $green + * @param int $blue + * + * @return array + */ + private function RGBtoHWB($red, $green, $blue) + { + $min = min($red, $green, $blue); + $max = max($red, $green, $blue); + + $d = $max - $min; + + if ((int) $d === 0) { + $h = 0; + } else { + + if ($red == $max) { + $h = 60 * ($green - $blue) / $d; + } elseif ($green == $max) { + $h = 60 * ($blue - $red) / $d + 120; + } elseif ($blue == $max) { + $h = 60 * ($red - $green) / $d + 240; + } + } + + return [Type::T_HWB, fmod($h, 360), $min / 255 * 100, 100 - $max / 255 *100]; + } + + // Built in functions protected static $libCall = ['function', 'args...']; - protected function libCall($args, $kwargs) + protected function libCall($args) { - $functionReference = array_shift($args); + $functionReference = $args[0]; 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" + $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]); + Warn::deprecation($warning); + $functionReference = $this->libGetFunction([$this->assertString($functionReference, 'function')]); } if ($functionReference === static::$null) { @@ -6819,18 +7698,9 @@ class Compiler throw $this->error('Function reference expected, got ' . $functionReference[0]); } - $callArgs = []; - - // $kwargs['args'] is [Type::T_LIST, ',', [..]] - foreach ($kwargs['args'][2] as $varname => $arg) { - if (is_numeric($varname)) { - $varname = null; - } else { - $varname = [ 'var', $varname]; - } - - $callArgs[] = [$varname, $arg, false]; - } + $callArgs = [ + [null, $args[1], true] + ]; return $this->reduce([Type::T_FUNCTION_CALL, $functionReference, $callArgs]); } @@ -6842,7 +7712,7 @@ class Compiler ]; protected function libGetFunction($args) { - $name = $this->compileStringContent($this->coerceString(array_shift($args))); + $name = $this->compileStringContent($this->assertString(array_shift($args), 'name')); $isCss = false; if (count($args)) { @@ -6905,14 +7775,13 @@ class Compiler $values = []; - foreach ($list[2] as $item) { $values[] = $this->normalizeValue($item); } $key = array_search($this->normalizeValue($value), $values); - return false === $key ? static::$null : $key + 1; + return false === $key ? static::$null : new Number($key + 1, ''); } protected static $libRgb = [ @@ -6950,7 +7819,7 @@ class Compiler [$funcName . '(', $color[1], ', ', $color[2], ', ', $color[3], ', ', $alpha, ')']]; } } else { - $color = [Type::T_STRING, '', [$funcName . '(', $args[0], ')']]; + $color = [Type::T_STRING, '', [$funcName . '(', $args[0], ', ', $args[1], ')']]; } break; @@ -6983,36 +7852,130 @@ class Compiler * Helper function for adjust_color, change_color, and scale_color * * @param array $args + * @param string $operation * @param callable $fn * * @return array + * + * @phpstan-param callable(float|int, float|int|null, float|int): (float|int) $fn */ - protected function alterColor($args, $fn) + protected function alterColor(array $args, $operation, $fn) { - $color = $this->assertColor($args[0]); + $color = $this->assertColor($args[0], 'color'); - foreach ([1 => 1, 2 => 2, 3 => 3, 7 => 4] as $iarg => $irgba) { - if (isset($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); - } + if ($args[1][2]) { + throw new SassScriptException('Only one positional argument is allowed. All other arguments must be passed by name.'); } - if (! empty($args[4]) || ! empty($args[5]) || ! empty($args[6])) { - $hsl = $this->toHSL($color[1], $color[2], $color[3]); + $kwargs = $this->getArgumentListKeywords($args[1]); - foreach ([4 => 1, 5 => 2, 6 => 3] as $iarg => $ihsl) { - if (! empty($args[$iarg])) { - $val = $this->assertNumber($args[$iarg])->getDimension(); - $hsl[$ihsl] = \call_user_func($fn, $hsl[$ihsl], $val, $iarg); + $scale = $operation === 'scale'; + $change = $operation === 'change'; + + /** + * @param string $name + * @param float|int $max + * @param bool $checkPercent + * @param bool $assertPercent + * + * @return float|int|null + */ + $getParam = function ($name, $max, $checkPercent = false, $assertPercent = false) use (&$kwargs, $scale, $change) { + if (!isset($kwargs[$name])) { + return null; + } + + $number = $this->assertNumber($kwargs[$name], $name); + unset($kwargs[$name]); + + if (!$scale && $checkPercent) { + if (!$number->hasUnit('%')) { + $warning = $this->error("{$name} Passing a number `$number` without unit % is deprecated."); + $this->logger->warn($warning->getMessage(), true); } } + if ($scale || $assertPercent) { + $number->assertUnit('%', $name); + } + + if ($scale) { + $max = 100; + } + + return $number->valueInRange($change ? 0 : -$max, $max, $name); + }; + + $alpha = $getParam('alpha', 1); + $red = $getParam('red', 255); + $green = $getParam('green', 255); + $blue = $getParam('blue', 255); + + if ($scale || !isset($kwargs['hue'])) { + $hue = null; + } else { + $hueNumber = $this->assertNumber($kwargs['hue'], 'hue'); + unset($kwargs['hue']); + $hue = $hueNumber->getDimension(); + } + $saturation = $getParam('saturation', 100, true); + $lightness = $getParam('lightness', 100, true); + $whiteness = $getParam('whiteness', 100, false, true); + $blackness = $getParam('blackness', 100, false, true); + + if (!empty($kwargs)) { + $unknownNames = array_keys($kwargs); + $lastName = array_pop($unknownNames); + $message = sprintf( + 'No argument%s named $%s%s.', + $unknownNames ? 's' : '', + $unknownNames ? implode(', $', $unknownNames) . ' or $' : '', + $lastName + ); + throw new SassScriptException($message); + } + + $hasRgb = $red !== null || $green !== null || $blue !== null; + $hasSL = $saturation !== null || $lightness !== null; + $hasWB = $whiteness !== null || $blackness !== null; + $found = false; + + if ($hasRgb && ($hasSL || $hasWB || $hue !== null)) { + throw new SassScriptException(sprintf('RGB parameters may not be passed along with %s parameters.', $hasWB ? 'HWB' : 'HSL')); + } + + if ($hasWB && $hasSL) { + throw new SassScriptException('HSL parameters may not be passed along with HWB parameters.'); + } + + if ($hasRgb) { + $color[1] = round($fn($color[1], $red, 255)); + $color[2] = round($fn($color[2], $green, 255)); + $color[3] = round($fn($color[3], $blue, 255)); + } elseif ($hasWB) { + $hwb = $this->RGBtoHWB($color[1], $color[2], $color[3]); + if ($hue !== null) { + $hwb[1] = $change ? $hue : $hwb[1] + $hue; + } + $hwb[2] = $fn($hwb[2], $whiteness, 100); + $hwb[3] = $fn($hwb[3], $blackness, 100); + + $rgb = $this->HWBtoRGB($hwb[1], $hwb[2], $hwb[3]); + + if (isset($color[4])) { + $rgb[4] = $color[4]; + } + + $color = $rgb; + } elseif ($hue !== null || $hasSL) { + $hsl = $this->toHSL($color[1], $color[2], $color[3]); + + if ($hue !== null) { + $hsl[1] = $change ? $hue : $hsl[1] + $hue; + } + $hsl[2] = $fn($hsl[2], $saturation, 100); + $hsl[3] = $fn($hsl[3], $lightness, 100); + $rgb = $this->toRGB($hsl[1], $hsl[2], $hsl[3]); if (isset($color[4])) { @@ -7022,58 +7985,54 @@ class Compiler $color = $rgb; } + if ($alpha !== null) { + $existingAlpha = isset($color[4]) ? $color[4] : 1; + $color[4] = $fn($existingAlpha, $alpha, 1); + } + return $color; } - protected static $libAdjustColor = [ - 'color', 'red:null', 'green:null', 'blue:null', - 'hue:null', 'saturation:null', 'lightness:null', 'alpha:null' - ]; + protected static $libAdjustColor = ['color', 'kwargs...']; protected function libAdjustColor($args) { - return $this->alterColor($args, function ($base, $alter, $i) { - return $base + $alter; + return $this->alterColor($args, 'adjust', function ($base, $alter, $max) { + if ($alter === null) { + return $base; + } + + $new = $base + $alter; + + if ($new < 0) { + return 0; + } + + if ($new > $max) { + return $max; + } + + return $new; }); } - protected static $libChangeColor = [ - 'color', 'red:null', 'green:null', 'blue:null', - 'hue:null', 'saturation:null', 'lightness:null', 'alpha:null' - ]; + protected static $libChangeColor = ['color', 'kwargs...']; protected function libChangeColor($args) { - return $this->alterColor($args, function ($base, $alter, $i) { + return $this->alterColor($args,'change', function ($base, $alter, $max) { + if ($alter === null) { + return $base; + } + return $alter; }); } - protected static $libScaleColor = [ - 'color', 'red:null', 'green:null', 'blue:null', - 'hue:null', 'saturation:null', 'lightness:null', 'alpha:null' - ]; + protected static $libScaleColor = ['color', 'kwargs...']; protected function libScaleColor($args) { - return $this->alterColor($args, function ($base, $scale, $i) { - // 1, 2, 3 - rgb - // 4, 5, 6 - hsl - // 7 - a - switch ($i) { - case 1: - case 2: - case 3: - $max = 255; - break; - - case 4: - $max = 360; - break; - - case 7: - $max = 1; - break; - - default: - $max = 100; + return $this->alterColor($args, 'scale', function ($base, $scale, $max) { + if ($scale === null) { + return $base; } $scale = $scale / 100; @@ -7109,7 +8068,7 @@ class Compiler throw $this->error('Error: argument `$color` of `red($color)` must be a color'); } - return $color[1]; + return new Number((int) $color[1], ''); } protected static $libGreen = ['color']; @@ -7121,7 +8080,7 @@ class Compiler throw $this->error('Error: argument `$color` of `green($color)` must be a color'); } - return $color[2]; + return new Number((int) $color[2], ''); } protected static $libBlue = ['color']; @@ -7133,14 +8092,14 @@ class Compiler throw $this->error('Error: argument `$color` of `blue($color)` must be a color'); } - return $color[3]; + return new Number((int) $color[3], ''); } protected static $libAlpha = ['color']; protected function libAlpha($args) { if ($color = $this->coerceColor($args[0])) { - return isset($color[4]) ? $color[4] : 1; + return new Number(isset($color[4]) ? $color[4] : 1, ''); } // this might be the IE function, so return value unchanged @@ -7161,39 +8120,35 @@ class Compiler // mix two colors protected static $libMix = [ - ['color1', 'color2', 'weight:0.5'], - ['color-1', 'color-2', 'weight:0.5'] + ['color1', 'color2', 'weight:50%'], + ['color-1', 'color-2', 'weight:50%'] ]; protected function libMix($args) { list($first, $second, $weight) = $args; - $first = $this->assertColor($first); - $second = $this->assertColor($second); - - if (! isset($weight)) { - $weight = 0.5; - } else { - $weight = $this->coercePercent($weight); - } + $first = $this->assertColor($first, 'color1'); + $second = $this->assertColor($second, 'color2'); + $weightScale = $this->assertNumber($weight, 'weight')->valueInRange(0, 100, 'weight') / 100; $firstAlpha = isset($first[4]) ? $first[4] : 1; $secondAlpha = isset($second[4]) ? $second[4] : 1; - $w = $weight * 2 - 1; - $a = $firstAlpha - $secondAlpha; + $normalizedWeight = $weightScale * 2 - 1; + $alphaDistance = $firstAlpha - $secondAlpha; - $w1 = (($w * $a === -1 ? $w : ($w + $a) / (1 + $w * $a)) + 1) / 2.0; - $w2 = 1.0 - $w1; + $combinedWeight = $normalizedWeight * $alphaDistance == -1 ? $normalizedWeight : ($normalizedWeight + $alphaDistance) / (1 + $normalizedWeight * $alphaDistance); + $weight1 = ($combinedWeight + 1) / 2.0; + $weight2 = 1.0 - $weight1; $new = [Type::T_COLOR, - $w1 * $first[1] + $w2 * $second[1], - $w1 * $first[2] + $w2 * $second[2], - $w1 * $first[3] + $w2 * $second[3], + $weight1 * $first[1] + $weight2 * $second[1], + $weight1 * $first[2] + $weight2 * $second[2], + $weight1 * $first[3] + $weight2 * $second[3], ]; if ($firstAlpha != 1.0 || $secondAlpha != 1.0) { - $new[] = $firstAlpha * $weight + $secondAlpha * (1 - $weight); + $new[] = $firstAlpha * $weightScale + $secondAlpha * (1 - $weightScale); } return $this->fixColor($new); @@ -7201,6 +8156,7 @@ class Compiler protected static $libHsl = [ ['channels'], + ['hue', 'saturation'], ['hue', 'saturation', 'lightness'], ['hue', 'saturation', 'lightness', 'alpha'] ]; protected function libHsl($args, $kwargs, $funcName = 'hsl') @@ -7216,14 +8172,25 @@ class Compiler $args_to_check = $kwargs['channels'][2]; } + if (\count($args) === 2) { + // if var() is used as an argument, return as a css function + foreach ($args as $arg) { + if ($arg[0] === Type::T_FUNCTION && in_array($arg[1], ['var'])) { + return null; + } + } + + throw new SassScriptException('Missing argument $lightness.'); + } + foreach ($kwargs as $k => $arg) { - if (in_array($arg[0], [Type::T_FUNCTION_CALL]) && in_array($arg[1], ['min', 'max'])) { + if (in_array($arg[0], [Type::T_FUNCTION_CALL, Type::T_FUNCTION]) && 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 (in_array($arg[0], [Type::T_FUNCTION_CALL, Type::T_FUNCTION]) && in_array($arg[1], ['min', 'max'])) { if (count($kwargs) > 1 || ($k >= 2 && count($args) === 4)) { return null; } @@ -7258,7 +8225,7 @@ class Compiler } } - $hueValue = $hue->getDimension() % 360; + $hueValue = fmod($hue->getDimension(), 360); while ($hueValue < 0) { $hueValue += 360; @@ -7275,6 +8242,7 @@ class Compiler protected static $libHsla = [ ['channels'], + ['hue', 'saturation'], ['hue', 'saturation', 'lightness'], ['hue', 'saturation', 'lightness', 'alpha']]; protected function libHsla($args, $kwargs) @@ -7285,7 +8253,7 @@ class Compiler protected static $libHue = ['color']; protected function libHue($args) { - $color = $this->assertColor($args[0]); + $color = $this->assertColor($args[0], 'color'); $hsl = $this->toHSL($color[1], $color[2], $color[3]); return new Number($hsl[1], 'deg'); @@ -7294,7 +8262,7 @@ class Compiler protected static $libSaturation = ['color']; protected function libSaturation($args) { - $color = $this->assertColor($args[0]); + $color = $this->assertColor($args[0], 'color'); $hsl = $this->toHSL($color[1], $color[2], $color[3]); return new Number($hsl[2], '%'); @@ -7303,16 +8271,155 @@ class Compiler protected static $libLightness = ['color']; protected function libLightness($args) { - $color = $this->assertColor($args[0]); + $color = $this->assertColor($args[0], 'color'); $hsl = $this->toHSL($color[1], $color[2], $color[3]); return new Number($hsl[3], '%'); } + /* + * Todo : a integrer dans le futur module color + protected static $libHwb = [ + ['channels'], + ['hue', 'whiteness', 'blackness'], + ['hue', 'whiteness', 'blackness', 'alpha'] ]; + protected function libHwb($args, $kwargs, $funcName = 'hwb') + { + $args_to_check = $args; + + if (\count($args) == 1) { + if ($args[0][0] !== Type::T_LIST) { + throw $this->error("Missing elements \$whiteness and \$blackness"); + } + + if (\trim($args[0][1])) { + throw $this->error("\$channels must be a space-separated list."); + } + + if (! empty($args[0]['enclosing'])) { + throw $this->error("\$channels must be an unbracketed list."); + } + + $args = $args[0][2]; + if (\count($args) > 3) { + throw $this->error("hwb() : Only 3 elements are allowed but ". \count($args) . "were passed"); + } + + $args_to_check = $this->extractSlashAlphaInColorFunction($kwargs['channels'][2]); + if (\count($args_to_check) !== \count($kwargs['channels'][2])) { + $args = $args_to_check; + } + } + + if (\count($args_to_check) < 2) { + throw $this->error("Missing elements \$whiteness and \$blackness"); + } + if (\count($args_to_check) < 3) { + throw $this->error("Missing element \$blackness"); + } + if (\count($args_to_check) > 4) { + throw $this->error("hwb() : Only 4 elements are allowed but ". \count($args) . "were passed"); + } + + 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]); + $whiteness = $this->reduce($args[1]); + $blackness = $this->reduce($args[2]); + $alpha = null; + + if (\count($args) === 4) { + $alpha = $this->compileColorPartValue($args[3], 0, 1, false); + + if (! \is_numeric($alpha)) { + $val = $this->compileValue($args[3]); + throw $this->error("\$alpha: $val is not a number"); + } + } + + $this->assertNumber($hue, 'hue'); + $this->assertUnit($whiteness, ['%'], 'whiteness'); + $this->assertUnit($blackness, ['%'], 'blackness'); + + $this->assertRange($whiteness, 0, 100, "0% and 100%", "whiteness"); + $this->assertRange($blackness, 0, 100, "0% and 100%", "blackness"); + + $w = $whiteness->getDimension(); + $b = $blackness->getDimension(); + + $hueValue = $hue->getDimension() % 360; + + while ($hueValue < 0) { + $hueValue += 360; + } + + $color = $this->HWBtoRGB($hueValue, $w, $b); + + if (! \is_null($alpha)) { + $color[4] = $alpha; + } + + return $color; + } + + protected static $libWhiteness = ['color']; + protected function libWhiteness($args, $kwargs, $funcName = 'whiteness') { + + $color = $this->assertColor($args[0]); + $hwb = $this->RGBtoHWB($color[1], $color[2], $color[3]); + + return new Number($hwb[2], '%'); + } + + protected static $libBlackness = ['color']; + protected function libBlackness($args, $kwargs, $funcName = 'blackness') { + + $color = $this->assertColor($args[0]); + $hwb = $this->RGBtoHWB($color[1], $color[2], $color[3]); + + return new Number($hwb[3], '%'); + } + */ + + /** + * @param array $color + * @param int $idx + * @param int|float $amount + * + * @return array + */ protected function adjustHsl($color, $idx, $amount) { $hsl = $this->toHSL($color[1], $color[2], $color[3]); $hsl[$idx] += $amount; + + if ($idx !== 1) { + // Clamp the saturation and lightness + $hsl[$idx] = min(max(0, $hsl[$idx]), 100); + } + $out = $this->toRGB($hsl[1], $hsl[2], $hsl[3]); if (isset($color[4])) { @@ -7325,8 +8432,8 @@ class Compiler protected static $libAdjustHue = ['color', 'degrees']; protected function libAdjustHue($args) { - $color = $this->assertColor($args[0]); - $degrees = $this->assertNumber($args[1])->getDimension(); + $color = $this->assertColor($args[0], 'color'); + $degrees = $this->assertNumber($args[1], 'degrees')->getDimension(); return $this->adjustHsl($color, 1, $degrees); } @@ -7334,7 +8441,7 @@ class Compiler protected static $libLighten = ['color', 'amount']; protected function libLighten($args) { - $color = $this->assertColor($args[0]); + $color = $this->assertColor($args[0], 'color'); $amount = Util::checkRange('amount', new Range(0, 100), $args[1], '%'); return $this->adjustHsl($color, 3, $amount); @@ -7343,7 +8450,7 @@ class Compiler protected static $libDarken = ['color', 'amount']; protected function libDarken($args) { - $color = $this->assertColor($args[0]); + $color = $this->assertColor($args[0], 'color'); $amount = Util::checkRange('amount', new Range(0, 100), $args[1], '%'); return $this->adjustHsl($color, 3, -$amount); @@ -7354,28 +8461,25 @@ class Compiler { $value = $args[0]; - if ($value instanceof Number) { + if (count($args) === 1) { + $this->assertNumber($args[0], 'amount'); + return null; } - if (count($args) === 1) { - $val = $this->compileValue($value); - throw $this->error("\$amount: $val is not a number"); - } + $color = $this->assertColor($args[0], 'color'); + $amount = $this->assertNumber($args[1], 'amount'); - $color = $this->assertColor($value); - $amount = 100 * $this->coercePercent($args[1]); - - return $this->adjustHsl($color, 2, $amount); + return $this->adjustHsl($color, 2, $amount->valueInRange(0, 100, 'amount')); } protected static $libDesaturate = ['color', 'amount']; protected function libDesaturate($args) { - $color = $this->assertColor($args[0]); - $amount = 100 * $this->coercePercent($args[1]); + $color = $this->assertColor($args[0], 'color'); + $amount = $this->assertNumber($args[1], 'amount'); - return $this->adjustHsl($color, 2, -$amount); + return $this->adjustHsl($color, 2, -$amount->valueInRange(0, 100, 'amount')); } protected static $libGrayscale = ['color']; @@ -7387,51 +8491,47 @@ class Compiler return null; } - return $this->adjustHsl($this->assertColor($value), 2, -100); + return $this->adjustHsl($this->assertColor($value, 'color'), 2, -100); } protected static $libComplement = ['color']; protected function libComplement($args) { - return $this->adjustHsl($this->assertColor($args[0]), 1, 180); + return $this->adjustHsl($this->assertColor($args[0], 'color'), 1, 180); } - protected static $libInvert = ['color', 'weight:1']; + protected static $libInvert = ['color', 'weight:100%']; protected function libInvert($args) { - list($value, $weight) = $args; + $value = $args[0]; - if (! isset($weight)) { - $weight = 1; - } else { - $weight = $this->coercePercent($weight); - } + $weight = $this->assertNumber($args[1], 'weight'); if ($value instanceof Number) { + if ($weight->getDimension() != 100 || !$weight->hasUnit('%')) { + throw new SassScriptException('Only one argument may be passed to the plain-CSS invert() function.'); + } + return null; } - $color = $this->assertColor($value); + $color = $this->assertColor($value, 'color'); $inverted = $color; $inverted[1] = 255 - $inverted[1]; $inverted[2] = 255 - $inverted[2]; $inverted[3] = 255 - $inverted[3]; - if ($weight < 1) { - return $this->libMix([$inverted, $color, new Number($weight, '')]); - } - - return $inverted; + return $this->libMix([$inverted, $color, $weight]); } // increases opacity by amount protected static $libOpacify = ['color', 'amount']; protected function libOpacify($args) { - $color = $this->assertColor($args[0]); - $amount = $this->coercePercent($args[1]); + $color = $this->assertColor($args[0], 'color'); + $amount = $this->assertNumber($args[1], 'amount'); - $color[4] = (isset($color[4]) ? $color[4] : 1) + $amount; + $color[4] = (isset($color[4]) ? $color[4] : 1) + $amount->valueInRange(0, 1, 'amount'); $color[4] = min(1, max(0, $color[4])); return $color; @@ -7447,10 +8547,10 @@ class Compiler protected static $libTransparentize = ['color', 'amount']; protected function libTransparentize($args) { - $color = $this->assertColor($args[0]); - $amount = $this->coercePercent($args[1]); + $color = $this->assertColor($args[0], 'color'); + $amount = $this->assertNumber($args[1], 'amount'); - $color[4] = (isset($color[4]) ? $color[4] : 1) - $amount; + $color[4] = (isset($color[4]) ? $color[4] : 1) - $amount->valueInRange(0, 1, 'amount'); $color[4] = min(1, max(0, $color[4])); return $color; @@ -7465,26 +8565,34 @@ class Compiler protected static $libUnquote = ['string']; protected function libUnquote($args) { - $str = $args[0]; + try { + $str = $this->assertString($args[0], 'string'); + } catch (SassScriptException $e) { + $value = $this->compileValue($args[0]); + $fname = $this->getPrettyPath($this->sourceNames[$this->sourceIndex]); + $line = $this->sourceLine; - if ($str[0] === Type::T_STRING) { - $str[1] = ''; + $message = "Passing $value, a non-string value, to unquote() +will be an error in future versions of Sass.\n on line $line of $fname"; + + $this->logger->warn($message, true); + + return $args[0]; } + $str[1] = ''; + return $str; } protected static $libQuote = ['string']; protected function libQuote($args) { - $value = $args[0]; + $value = $this->assertString($args[0], 'string'); - if ($value[0] === Type::T_STRING && ! empty($value[1])) { - $value[1] = '"'; - return $value; - } + $value[1] = '"'; - return [Type::T_STRING, '"', [$value]]; + return $value; } protected static $libPercentage = ['number']; @@ -7528,6 +8636,7 @@ class Compiler return new Number(abs($num->getDimension()), $num->getNumeratorUnits(), $num->getDenominatorUnits()); } + protected static $libMin = ['numbers...']; protected function libMin($args) { /** @@ -7535,7 +8644,7 @@ class Compiler */ $min = null; - foreach ($args as $arg) { + foreach ($args[0][2] as $arg) { $number = $this->assertNumber($arg); if (\is_null($min) || $min->greaterThan($number)) { @@ -7550,6 +8659,7 @@ class Compiler throw $this->error('At least one argument must be passed.'); } + protected static $libMax = ['numbers...']; protected function libMax($args) { /** @@ -7557,7 +8667,7 @@ class Compiler */ $max = null; - foreach ($args as $arg) { + foreach ($args[0][2] as $arg) { $number = $this->assertNumber($arg); if (\is_null($max) || $max->lessThan($number)) { @@ -7577,31 +8687,31 @@ class Compiler { $list = $this->coerceList($args[0], ',', true); - return \count($list[2]); + return new Number(\count($list[2]), ''); } - //protected static $libListSeparator = ['list...']; + protected static $libListSeparator = ['list']; protected function libListSeparator($args) { - if (\count($args) > 1) { - return 'comma'; - } - if (! \in_array($args[0][0], [Type::T_LIST, Type::T_MAP])) { - return 'space'; + return [Type::T_KEYWORD, 'space']; } $list = $this->coerceList($args[0]); - if (\count($list[2]) <= 1 && empty($list['enclosing'])) { - return 'space'; + if ($list[1] === '' && \count($list[2]) <= 1 && empty($list['enclosing'])) { + return [Type::T_KEYWORD, 'space']; } if ($list[1] === ',') { - return 'comma'; + return [Type::T_KEYWORD, 'comma']; } - return 'space'; + if ($list[1] === '/') { + return [Type::T_KEYWORD, 'slash']; + } + + return [Type::T_KEYWORD, 'space']; } protected static $libNth = ['list', 'n']; @@ -7640,29 +8750,78 @@ class Compiler return $list; } - protected static $libMapGet = ['map', 'key']; + protected static $libMapGet = ['map', 'key', 'keys...']; protected function libMapGet($args) { - $map = $this->assertMap($args[0]); - $key = $args[1]; + $map = $this->assertMap($args[0], 'map'); + if (!isset($args[2])) { + // BC layer for usages of the function from PHP code rather than from the Sass function + $args[2] = self::$emptyArgumentList; + } + $keys = array_merge([$args[1]], $args[2][2]); + $value = static::$null; - if (! \is_null($key)) { - $key = $this->compileStringContent($this->coerceString($key)); + foreach ($keys as $key) { + if (!\is_array($map) || $map[0] !== Type::T_MAP) { + return static::$null; + } - for ($i = \count($map[1]) - 1; $i >= 0; $i--) { - if ($key === $this->compileStringContent($this->coerceString($map[1][$i]))) { - return $map[2][$i]; - } + $map = $this->mapGet($map, $key); + + if ($map === null) { + return static::$null; + } + + $value = $map; + } + + return $value; + } + + /** + * Gets the value corresponding to that key in the map + * + * @param array $map + * @param Number|array $key + * + * @return Number|array|null + */ + private function mapGet(array $map, $key) + { + $index = $this->mapGetEntryIndex($map, $key); + + if ($index !== null) { + return $map[2][$index]; + } + + return null; + } + + /** + * Gets the index corresponding to that key in the map entries + * + * @param array $map + * @param Number|array $key + * + * @return int|null + */ + private function mapGetEntryIndex(array $map, $key) + { + $key = $this->compileStringContent($this->coerceString($key)); + + for ($i = \count($map[1]) - 1; $i >= 0; $i--) { + if ($key === $this->compileStringContent($this->coerceString($map[1][$i]))) { + return $i; } } - return static::$null; + return null; } protected static $libMapKeys = ['map']; protected function libMapKeys($args) { - $map = $this->assertMap($args[0]); + $map = $this->assertMap($args[0], 'map'); $keys = $map[1]; return [Type::T_LIST, ',', $keys]; @@ -7671,21 +8830,28 @@ class Compiler protected static $libMapValues = ['map']; protected function libMapValues($args) { - $map = $this->assertMap($args[0]); + $map = $this->assertMap($args[0], 'map'); $values = $map[2]; return [Type::T_LIST, ',', $values]; } - protected static $libMapRemove = ['map', 'key...']; + protected static $libMapRemove = [ + ['map'], + ['map', 'key', 'keys...'], + ]; protected function libMapRemove($args) { - $map = $this->assertMap($args[0]); - $keyList = $this->assertList($args[1]); + $map = $this->assertMap($args[0], 'map'); + + if (\count($args) === 1) { + return $map; + } $keys = []; + $keys[] = $this->compileStringContent($this->coerceString($args[1])); - foreach ($keyList[2] as $key) { + foreach ($args[2][2] as $key) { $keys[] = $this->compileStringContent($this->coerceString($key)); } @@ -7699,11 +8865,38 @@ class Compiler return $map; } - protected static $libMapHasKey = ['map', 'key']; + protected static $libMapHasKey = ['map', 'key', 'keys...']; protected function libMapHasKey($args) { - $map = $this->assertMap($args[0]); - $key = $this->compileStringContent($this->coerceString($args[1])); + $map = $this->assertMap($args[0], 'map'); + if (!isset($args[2])) { + // BC layer for usages of the function from PHP code rather than from the Sass function + $args[2] = self::$emptyArgumentList; + } + $keys = array_merge([$args[1]], $args[2][2]); + $lastKey = array_pop($keys); + + foreach ($keys as $key) { + $value = $this->mapGet($map, $key); + + if ($value === null || $value instanceof Number || $value[0] !== Type::T_MAP) { + return self::$false; + } + + $map = $value; + } + + return $this->toBool($this->mapHasKey($map, $lastKey)); + } + + /** + * @param array|Number $keyValue + * + * @return bool + */ + private function mapHasKey(array $map, $keyValue) + { + $key = $this->compileStringContent($this->coerceString($keyValue)); for ($i = \count($map[1]) - 1; $i >= 0; $i--) { if ($key === $this->compileStringContent($this->coerceString($map[1][$i]))) { @@ -7716,24 +8909,127 @@ class Compiler protected static $libMapMerge = [ ['map1', 'map2'], - ['map-1', 'map-2'] + ['map-1', 'map-2'], + ['map1', 'args...'] ]; protected function libMapMerge($args) { - $map1 = $this->assertMap($args[0]); - $map2 = $this->assertMap($args[1]); + $map1 = $this->assertMap($args[0], 'map1'); + $map2 = $args[1]; + $keys = []; + if ($map2[0] === Type::T_LIST && isset($map2[3]) && \is_array($map2[3])) { + // This is an argument list for the variadic signature + if (\count($map2[2]) === 0) { + throw new SassScriptException('Expected $args to contain a key.'); + } + if (\count($map2[2]) === 1) { + throw new SassScriptException('Expected $args to contain a value.'); + } + $keys = $map2[2]; + $map2 = array_pop($keys); + } + $map2 = $this->assertMap($map2, 'map2'); - foreach ($map2[1] as $i2 => $key2) { - $key = $this->compileStringContent($this->coerceString($key2)); + return $this->modifyMap($map1, $keys, function ($oldValue) use ($map2) { + $nestedMap = $this->tryMap($oldValue); - foreach ($map1[1] as $i1 => $key1) { - if ($key === $this->compileStringContent($this->coerceString($key1))) { - $map1[2][$i1] = $map2[2][$i2]; - continue 2; - } + if ($nestedMap === null) { + return $map2; } - $map1[1][] = $map2[1][$i2]; + return $this->mergeMaps($nestedMap, $map2); + }); + } + + /** + * @param array $map + * @param array $keys + * @param callable $modify + * @param bool $addNesting + * + * @return Number|array + * + * @phpstan-param array $keys + * @phpstan-param callable(Number|array): (Number|array) $modify + */ + private function modifyMap(array $map, array $keys, callable $modify, $addNesting = true) + { + if ($keys === []) { + return $modify($map); + } + + return $this->modifyNestedMap($map, $keys, $modify, $addNesting); + } + + /** + * @param array $map + * @param array $keys + * @param callable $modify + * @param bool $addNesting + * + * @return array + * + * @phpstan-param non-empty-array $keys + * @phpstan-param callable(Number|array): (Number|array) $modify + */ + private function modifyNestedMap(array $map, array $keys, callable $modify, $addNesting) + { + $key = array_shift($keys); + + $nestedValueIndex = $this->mapGetEntryIndex($map, $key); + + if ($keys === []) { + if ($nestedValueIndex !== null) { + $map[2][$nestedValueIndex] = $modify($map[2][$nestedValueIndex]); + } else { + $map[1][] = $key; + $map[2][] = $modify(self::$null); + } + + return $map; + } + + $nestedMap = $nestedValueIndex !== null ? $this->tryMap($map[2][$nestedValueIndex]) : null; + + if ($nestedMap === null && !$addNesting) { + return $map; + } + + if ($nestedMap === null) { + $nestedMap = self::$emptyMap; + } + + $newNestedMap = $this->modifyNestedMap($nestedMap, $keys, $modify, $addNesting); + + if ($nestedValueIndex !== null) { + $map[2][$nestedValueIndex] = $newNestedMap; + } else { + $map[1][] = $key; + $map[2][] = $newNestedMap; + } + + return $map; + } + + /** + * Merges 2 Sass maps together + * + * @param array $map1 + * @param array $map2 + * + * @return array + */ + private function mergeMaps(array $map1, array $map2) + { + foreach ($map2[1] as $i2 => $key2) { + $map1EntryIndex = $this->mapGetEntryIndex($map1, $key2); + + if ($map1EntryIndex !== null) { + $map1[2][$map1EntryIndex] = $map2[2][$i2]; + continue; + } + + $map1[1][] = $key2; $map1[2][] = $map2[2][$i2]; } @@ -7743,12 +9039,18 @@ class Compiler protected static $libKeywords = ['args']; protected function libKeywords($args) { - $this->assertList($args[0]); + $value = $args[0]; + + if ($value[0] !== Type::T_LIST || !isset($value[3]) || !\is_array($value[3])) { + $compiledValue = $this->compileValue($value); + + throw SassScriptException::forArgument($compiledValue . ' is not an argument list.', 'args'); + } $keys = []; $values = []; - foreach ($args[0][2] as $name => $arg) { + foreach ($this->getArgumentListKeywords($value) as $name => $arg) { $keys[] = [Type::T_KEYWORD, $name]; $values[] = $arg; } @@ -7763,10 +9065,10 @@ class Compiler $this->coerceList($list, ' '); if (! empty($list['enclosing']) && $list['enclosing'] === 'bracket') { - return true; + return self::$true; } - return false; + return self::$false; } /** @@ -7775,9 +9077,13 @@ class Compiler * * @return string * @throws CompilerException + * + * @deprecated */ protected function listSeparatorForJoin($list1, $sep) { + @trigger_error(sprintf('The "%s" method is deprecated.', __METHOD__), E_USER_DEPRECATED); + if (! isset($sep)) { return $list1[1]; } @@ -7794,14 +9100,40 @@ class Compiler } } - protected static $libJoin = ['list1', 'list2', 'separator:null', 'bracketed:auto']; + protected static $libJoin = ['list1', 'list2', 'separator:auto', 'bracketed:auto']; protected function libJoin($args) { list($list1, $list2, $sep, $bracketed) = $args; $list1 = $this->coerceList($list1, ' ', true); $list2 = $this->coerceList($list2, ' ', true); - $sep = $this->listSeparatorForJoin($list1, $sep); + + switch ($this->compileStringContent($this->assertString($sep, 'separator'))) { + case 'comma': + $separator = ','; + break; + + case 'space': + $separator = ' '; + break; + + case 'slash': + $separator = '/'; + break; + + case 'auto': + if ($list1[1] !== '' || count($list1[2]) > 1 || !empty($list1['enclosing']) && $list1['enclosing'] !== 'parent') { + $separator = $list1[1] ?: ' '; + } elseif ($list2[1] !== '' || count($list2[2]) > 1 || !empty($list2['enclosing']) && $list2['enclosing'] !== 'parent') { + $separator = $list2[1] ?: ' '; + } else { + $separator = ' '; + } + break; + + default: + throw SassScriptException::forArgument('Must be "space", "comma", "slash", or "auto".', 'separator'); + } if ($bracketed === static::$true) { $bracketed = true; @@ -7828,11 +9160,7 @@ class Compiler } } - $res = [Type::T_LIST, $sep, array_merge($list1[2], $list2[2])]; - - if (isset($list1['enclosing'])) { - $res['enlcosing'] = $list1['enclosing']; - } + $res = [Type::T_LIST, $separator, array_merge($list1[2], $list2[2])]; if ($bracketed) { $res['enclosing'] = 'bracket'; @@ -7841,14 +9169,35 @@ class Compiler return $res; } - protected static $libAppend = ['list', 'val', 'separator:null']; + protected static $libAppend = ['list', 'val', 'separator:auto']; protected function libAppend($args) { list($list1, $value, $sep) = $args; $list1 = $this->coerceList($list1, ' ', true); - $sep = $this->listSeparatorForJoin($list1, $sep); - $res = [Type::T_LIST, $sep, array_merge($list1[2], [$value])]; + + switch ($this->compileStringContent($this->assertString($sep, 'separator'))) { + case 'comma': + $separator = ','; + break; + + case 'space': + $separator = ' '; + break; + + case 'slash': + $separator = '/'; + break; + + case 'auto': + $separator = $list1[1] === '' && \count($list1[2]) <= 1 && (empty($list1['enclosing']) || $list1['enclosing'] === 'parent') ? ' ' : $list1[1]; + break; + + default: + throw SassScriptException::forArgument('Must be "space", "comma", "slash", or "auto".', 'separator'); + } + + $res = [Type::T_LIST, $separator, array_merge($list1[2], [$value])]; if (isset($list1['enclosing'])) { $res['enclosing'] = $list1['enclosing']; @@ -7857,21 +9206,23 @@ class Compiler return $res; } + protected static $libZip = ['lists...']; protected function libZip($args) { - foreach ($args as $key => $arg) { - $args[$key] = $this->coerceList($arg); + $argLists = []; + foreach ($args[0][2] as $arg) { + $argLists[] = $this->coerceList($arg); } $lists = []; - $firstList = array_shift($args); + $firstList = array_shift($argLists); $result = [Type::T_LIST, ',', $lists]; if (! \is_null($firstList)) { foreach ($firstList[2] as $key => $item) { - $list = [Type::T_LIST, '', [$item]]; + $list = [Type::T_LIST, ' ', [$item]]; - foreach ($args as $arg) { + foreach ($argLists as $arg) { if (isset($arg[2][$key])) { $list[2][] = $arg[2][$key]; } else { @@ -7895,6 +9246,16 @@ class Compiler { $value = $args[0]; + return [Type::T_KEYWORD, $this->getTypeOf($value)]; + } + + /** + * @param array|Number $value + * + * @return string + */ + private function getTypeOf($value) + { switch ($value[0]) { case Type::T_KEYWORD: if ($value === static::$true || $value === static::$false) { @@ -7913,7 +9274,7 @@ class Compiler return 'function'; case Type::T_LIST: - if (isset($value[3]) && $value[3]) { + if (isset($value[3]) && \is_array($value[3])) { return 'arglist'; } @@ -7926,21 +9287,17 @@ class Compiler protected static $libUnit = ['number']; protected function libUnit($args) { - $num = $args[0]; + $num = $this->assertNumber($args[0], 'number'); - if ($num instanceof Number) { - return [Type::T_STRING, '"', [$num->unitStr()]]; - } - - return ''; + return [Type::T_STRING, '"', [$num->unitStr()]]; } protected static $libUnitless = ['number']; protected function libUnitless($args) { - $value = $args[0]; + $value = $this->assertNumber($args[0], 'number'); - return $value instanceof Number && $value->unitless(); + return $this->toBool($value->unitless()); } protected static $libComparable = [ @@ -7958,7 +9315,7 @@ class Compiler throw $this->error('Invalid argument(s) for "comparable"'); } - return $number1->isComparableTo($number2); + return $this->toBool($number1->isComparableTo($number2)); } protected static $libStrIndex = ['string', 'substring']; @@ -8017,25 +9374,37 @@ class Compiler protected static $libStrSlice = ['string', 'start-at', 'end-at:-1']; protected function libStrSlice($args) { - if (isset($args[2]) && ! $args[2][1]) { - return static::$nullString; - } - - $string = $this->coerceString($args[0]); + $string = $this->assertString($args[0], 'string'); $stringContent = $this->compileStringContent($string); - $start = (int) $args[1][1]; + $start = $this->assertNumber($args[1], 'start-at'); + $start->assertNoUnits('start-at'); + $startInt = $this->assertInteger($start, 'start-at'); + $end = $this->assertNumber($args[2], 'end-at'); + $end->assertNoUnits('end-at'); + $endInt = $this->assertInteger($end, 'end-at'); - if ($start > 0) { - $start--; + if ($endInt === 0) { + return [Type::T_STRING, $string[1], []]; } - $end = isset($args[2]) ? (int) $args[2][1] : -1; - $length = $end < 0 ? $end + 1 : ($end > 0 ? $end - $start : $end); + if ($startInt > 0) { + $startInt--; + } - $string[2] = $length - ? [substr($stringContent, $start, $length)] - : [substr($stringContent, $start)]; + if ($endInt < 0) { + $endInt = Util::mbStrlen($stringContent) + $endInt; + } else { + $endInt--; + } + + if ($endInt < $startInt) { + return [Type::T_STRING, $string[1], []]; + } + + $length = $endInt - $startInt + 1; // The end of the slice is inclusive + + $string[2] = [Util::mbSubstr($stringContent, $startInt, $length)]; return $string; } @@ -8043,7 +9412,7 @@ class Compiler protected static $libToLowerCase = ['string']; protected function libToLowerCase($args) { - $string = $this->coerceString($args[0]); + $string = $this->assertString($args[0], 'string'); $stringContent = $this->compileStringContent($string); $string[2] = [$this->stringTransformAsciiOnly($stringContent, 'strtolower')]; @@ -8054,7 +9423,7 @@ class Compiler protected static $libToUpperCase = ['string']; protected function libToUpperCase($args) { - $string = $this->coerceString($args[0]); + $string = $this->assertString($args[0], 'string'); $stringContent = $this->compileStringContent($string); $string[2] = [$this->stringTransformAsciiOnly($stringContent, 'strtoupper')]; @@ -8092,7 +9461,7 @@ class Compiler protected static $libFeatureExists = ['feature']; protected function libFeatureExists($args) { - $string = $this->coerceString($args[0]); + $string = $this->assertString($args[0], 'feature'); $name = $this->compileStringContent($string); return $this->toBool( @@ -8103,18 +9472,18 @@ class Compiler protected static $libFunctionExists = ['name']; protected function libFunctionExists($args) { - $string = $this->coerceString($args[0]); + $string = $this->assertString($args[0], 'name'); $name = $this->compileStringContent($string); // user defined functions if ($this->has(static::$namespaces['function'] . $name)) { - return true; + return self::$true; } $name = $this->normalizeName($name); if (isset($this->userFunctions[$name])) { - return true; + return self::$true; } // built-in functions @@ -8126,30 +9495,31 @@ class Compiler protected static $libGlobalVariableExists = ['name']; protected function libGlobalVariableExists($args) { - $string = $this->coerceString($args[0]); + $string = $this->assertString($args[0], 'name'); $name = $this->compileStringContent($string); - return $this->has($name, $this->rootEnv); + return $this->toBool($this->has($name, $this->rootEnv)); } protected static $libMixinExists = ['name']; protected function libMixinExists($args) { - $string = $this->coerceString($args[0]); + $string = $this->assertString($args[0], 'name'); $name = $this->compileStringContent($string); - return $this->has(static::$namespaces['mixin'] . $name); + return $this->toBool($this->has(static::$namespaces['mixin'] . $name)); } protected static $libVariableExists = ['name']; protected function libVariableExists($args) { - $string = $this->coerceString($args[0]); + $string = $this->assertString($args[0], 'name'); $name = $this->compileStringContent($string); - return $this->has($name); + return $this->toBool($this->has($name)); } + protected static $libCounter = ['args...']; /** * Workaround IE7's content counter bug. * @@ -8159,7 +9529,7 @@ class Compiler */ protected function libCounter($args) { - $list = array_map([$this, 'compileValue'], $args); + $list = array_map([$this, 'compileValue'], $args[0][2]); return [Type::T_STRING, '', ['counter(' . implode(',', $list) . ')']]; } @@ -8167,24 +9537,21 @@ class Compiler protected static $libRandom = ['limit:null']; protected function libRandom($args) { - if (isset($args[0]) & $args[0] !== static::$null) { - $n = $this->assertNumber($args[0])->getDimension(); + if (isset($args[0]) && $args[0] !== static::$null) { + $n = $this->assertInteger($args[0], 'limit'); if ($n < 1) { - throw $this->error("\$limit must be greater than or equal to 1"); + throw new SassScriptException("\$limit: Must be greater than 0, was $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 Number(mt_rand(1, $n), ''); } $max = mt_getrandmax(); return new Number(mt_rand(0, $max - 1) / $max, ''); } + protected static $libUniqueId = []; protected function libUniqueId() { static $id; @@ -8200,6 +9567,12 @@ class Compiler return [Type::T_STRING, '', ['u' . str_pad(base_convert($id, 10, 36), 8, '0', STR_PAD_LEFT)]]; } + /** + * @param array|Number $value + * @param bool $force_enclosing_display + * + * @return array + */ protected function inspectFormatValue($value, $force_enclosing_display = false) { if ($value === static::$null) { @@ -8208,6 +9581,10 @@ class Compiler $stringValue = [$value]; + if ($value instanceof Number) { + return [Type::T_STRING, '', $stringValue]; + } + if ($value[0] === Type::T_LIST) { if (end($value[2]) === static::$null) { array_pop($value[2]); @@ -8223,6 +9600,8 @@ class Compiler ) { $value['enclosing'] = 'forced_' . $value['enclosing']; $force_enclosing_display = true; + } elseif (! \count($value[2])) { + $value['enclosing'] = 'forced_parent'; } foreach ($value[2] as $k => $listelement) { @@ -8246,9 +9625,11 @@ class Compiler /** * Preprocess selector args * - * @param array $arg + * @param array $arg + * @param string|null $varname + * @param bool $allowParent * - * @return array|boolean + * @return array */ protected function getSelectorArg($arg, $varname = null, $allowParent = false) { @@ -8259,13 +9640,14 @@ class Compiler } 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"); + throw SassScriptException::forArgument("$var_value is not a valid selector: it must be a string, a list of strings, or a list of lists of strings", $varname); } - $arg = $this->libUnquote([$arg]); + + if ($arg[0] === Type::T_STRING) { + $arg[1] = ''; + } $arg = $this->compileValue($arg); $parsedSelector = []; @@ -8278,8 +9660,7 @@ class Compiler 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."); + throw SassScriptException::forArgument("Parent selectors aren't allowed here.", $varname); } } } @@ -8288,8 +9669,7 @@ class Compiler return $gluedSelector; } - $var_display = ($varname ? ' $' . $varname . ':' : ''); - throw $this->error("Error:{$var_display} expected more input, invalid selector."); + throw SassScriptException::forArgument("expected more input, invalid selector.", $varname); } /** @@ -8319,11 +9699,11 @@ class Compiler * * @param array $selectors * - * @return string + * @return array */ protected function formatOutputSelector($selectors) { - $selectors = $this->collapseSelectors($selectors, true); + $selectors = $this->collapseSelectorsAsList($selectors); return $selectors; } @@ -8336,7 +9716,7 @@ class Compiler $super = $this->getSelectorArg($super, 'super'); $sub = $this->getSelectorArg($sub, 'sub'); - return $this->isSuperSelector($super, $sub); + return $this->toBool($this->isSuperSelector($super, $sub)); } /** @@ -8345,7 +9725,7 @@ class Compiler * @param array $super * @param array $sub * - * @return boolean + * @return bool */ protected function isSuperSelector($super, $sub) { @@ -8426,7 +9806,7 @@ class Compiler * @param array $superParts * @param array $subParts * - * @return boolean + * @return bool */ protected function isSuperPart($superParts, $subParts) { @@ -8493,21 +9873,18 @@ class Compiler // do the trick, happening $lastSelector to $previousSelector $appended = []; - foreach ($lastSelectors as $lastSelector) { - $previous = $previousSelectors; - - foreach ($lastSelector as $lastSelectorParts) { - foreach ($lastSelectorParts as $lastSelectorPart) { - foreach ($previous as $i => $previousSelector) { - foreach ($previousSelector as $j => $previousSelectorParts) { - $previous[$i][$j][] = $lastSelectorPart; + foreach ($previousSelectors as $previousSelector) { + foreach ($lastSelectors as $lastSelector) { + $previous = $previousSelector; + foreach ($previousSelector as $j => $previousSelectorParts) { + foreach ($lastSelector as $lastSelectorParts) { + foreach ($lastSelectorParts as $lastSelectorPart) { + $previous[$j][] = $lastSelectorPart; } } } - } - foreach ($previous as $ps) { - $appended[] = $ps; + $appended[] = $previous; } } @@ -8563,10 +9940,10 @@ class Compiler * Extend/replace in selectors * used by selector-extend and selector-replace that use the same logic * - * @param array $selectors - * @param array $extendee - * @param array $extender - * @param boolean $replace + * @param array $selectors + * @param array $extendee + * @param array $extender + * @param bool $replace * * @return array */ @@ -8579,6 +9956,10 @@ class Compiler $this->extendsMap = []; foreach ($extendee as $es) { + if (\count($es) !== 1) { + throw $this->error('Can\'t extend complex selector.'); + } + // only use the first one $this->pushExtends(reset($es), $extender, null); } @@ -8679,7 +10060,7 @@ class Compiler * @param array $compound1 * @param array $compound2 * - * @return array|mixed + * @return array */ protected function unifyCompoundSelectors($compound1, $compound2) { @@ -8913,9 +10294,9 @@ class Compiler /** * Find the html tag name in a selector parts list * - * @param array $parts + * @param string[] $parts * - * @return mixed|string + * @return string */ protected function findTagName($parts) { @@ -8952,7 +10333,11 @@ class Compiler protected static $libScssphpGlob = ['pattern']; protected function libScssphpGlob($args) { - $string = $this->coerceString($args[0]); + @trigger_error(sprintf('The "scssphp-glob" function is deprecated an will be removed in ScssPhp 2.0. Register your own alternative through "%s::registerFunction', __CLASS__), E_USER_DEPRECATED); + + $this->logger->warn('The "scssphp-glob" function is deprecated an will be removed in ScssPhp 2.0.', true); + + $string = $this->assertString($args[0], 'pattern'); $pattern = $this->compileStringContent($string); $matches = glob($pattern); $listParts = []; diff --git a/lib/scssphp/Compiler/CachedResult.php b/lib/scssphp/Compiler/CachedResult.php new file mode 100644 index 00000000000..a662919962a --- /dev/null +++ b/lib/scssphp/Compiler/CachedResult.php @@ -0,0 +1,77 @@ + + */ + private $parsedFiles; + + /** + * @var array + * @phpstan-var list + */ + private $resolvedImports; + + /** + * @param CompilationResult $result + * @param array $parsedFiles + * @param array $resolvedImports + * + * @phpstan-param list $resolvedImports + */ + public function __construct(CompilationResult $result, array $parsedFiles, array $resolvedImports) + { + $this->result = $result; + $this->parsedFiles = $parsedFiles; + $this->resolvedImports = $resolvedImports; + } + + /** + * @return CompilationResult + */ + public function getResult() + { + return $this->result; + } + + /** + * @return array + */ + public function getParsedFiles() + { + return $this->parsedFiles; + } + + /** + * @return array + * + * @phpstan-return list + */ + public function getResolvedImports() + { + return $this->resolvedImports; + } +} diff --git a/lib/scssphp/Compiler/Environment.php b/lib/scssphp/Compiler/Environment.php index dc2f86c1fbe..b205a077f22 100644 --- a/lib/scssphp/Compiler/Environment.php +++ b/lib/scssphp/Compiler/Environment.php @@ -16,19 +16,41 @@ namespace ScssPhp\ScssPhp\Compiler; * Compiler environment * * @author Anthon Pang + * + * @internal */ class Environment { /** - * @var \ScssPhp\ScssPhp\Block + * @var \ScssPhp\ScssPhp\Block|null */ public $block; /** - * @var \ScssPhp\ScssPhp\Compiler\Environment + * @var \ScssPhp\ScssPhp\Compiler\Environment|null */ public $parent; + /** + * @var Environment|null + */ + public $declarationScopeParent; + + /** + * @var Environment|null + */ + public $parentStore; + + /** + * @var array|null + */ + public $selectors; + + /** + * @var string|null + */ + public $marker; + /** * @var array */ @@ -40,7 +62,7 @@ class Environment public $storeUnreduced; /** - * @var integer + * @var int */ public $depth; } diff --git a/lib/scssphp/Exception/CompilerException.php b/lib/scssphp/Exception/CompilerException.php index 343da4c7028..0b00cf525c8 100644 --- a/lib/scssphp/Exception/CompilerException.php +++ b/lib/scssphp/Exception/CompilerException.php @@ -16,6 +16,8 @@ namespace ScssPhp\ScssPhp\Exception; * Compiler exception * * @author Oleksandr Savchenko + * + * @internal */ class CompilerException extends \Exception implements SassException { diff --git a/lib/scssphp/Exception/ParserException.php b/lib/scssphp/Exception/ParserException.php index 5237f30795d..00d77ec98ed 100644 --- a/lib/scssphp/Exception/ParserException.php +++ b/lib/scssphp/Exception/ParserException.php @@ -16,6 +16,8 @@ namespace ScssPhp\ScssPhp\Exception; * Parser Exception * * @author Oleksandr Savchenko + * + * @internal */ class ParserException extends \Exception implements SassException { diff --git a/lib/scssphp/Exception/RangeException.php b/lib/scssphp/Exception/RangeException.php index b18c32d6c3d..4be4dee70a2 100644 --- a/lib/scssphp/Exception/RangeException.php +++ b/lib/scssphp/Exception/RangeException.php @@ -16,6 +16,8 @@ namespace ScssPhp\ScssPhp\Exception; * Range exception * * @author Anthon Pang + * + * @internal */ class RangeException extends \Exception implements SassException { diff --git a/lib/scssphp/Exception/ServerException.php b/lib/scssphp/Exception/ServerException.php index ad5b37998f5..e593c4014d5 100644 --- a/lib/scssphp/Exception/ServerException.php +++ b/lib/scssphp/Exception/ServerException.php @@ -12,10 +12,14 @@ namespace ScssPhp\ScssPhp\Exception; +@trigger_error(sprintf('The "%s" class is deprecated.', ServerException::class), E_USER_DEPRECATED); + /** * Server Exception * * @author Anthon Pang + * + * @deprecated The Scssphp server should define its own exception instead. */ class ServerException extends \Exception implements SassException { diff --git a/lib/scssphp/Formatter.php b/lib/scssphp/Formatter.php index d52a6744a28..c88ddba9db7 100644 --- a/lib/scssphp/Formatter.php +++ b/lib/scssphp/Formatter.php @@ -19,11 +19,13 @@ use ScssPhp\ScssPhp\SourceMap\SourceMapGenerator; * Base formatter * * @author Leaf Corcoran + * + * @internal */ abstract class Formatter { /** - * @var integer + * @var int */ public $indentLevel; @@ -58,7 +60,7 @@ abstract class Formatter public $assignSeparator; /** - * @var boolean + * @var bool */ public $keepSemicolons; @@ -68,17 +70,17 @@ abstract class Formatter protected $currentBlock; /** - * @var integer + * @var int */ protected $currentLine; /** - * @var integer + * @var int */ protected $currentColumn; /** - * @var \ScssPhp\ScssPhp\SourceMap\SourceMapGenerator + * @var \ScssPhp\ScssPhp\SourceMap\SourceMapGenerator|null */ protected $sourceMapGenerator; @@ -139,6 +141,8 @@ abstract class Formatter * Output lines inside a block * * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $block + * + * @return void */ protected function blockLines(OutputBlock $block) { @@ -156,9 +160,13 @@ abstract class Formatter * Output block selectors * * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $block + * + * @return void */ protected function blockSelectors(OutputBlock $block) { + assert(! empty($block->selectors)); + $inner = $this->indentStr(); $this->write($inner @@ -170,6 +178,8 @@ abstract class Formatter * Output block children * * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $block + * + * @return void */ protected function blockChildren(OutputBlock $block) { @@ -182,6 +192,8 @@ abstract class Formatter * Output non-empty block * * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $block + * + * @return void */ protected function block(OutputBlock $block) { @@ -227,7 +239,7 @@ abstract class Formatter * * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $block * - * @return boolean + * @return bool */ protected function testEmptyChildren($block) { @@ -285,6 +297,8 @@ abstract class Formatter * Output content * * @param string $str + * + * @return void */ protected function write($str) { diff --git a/lib/scssphp/Formatter/Compact.php b/lib/scssphp/Formatter/Compact.php index 249920ef535..22f226889fc 100644 --- a/lib/scssphp/Formatter/Compact.php +++ b/lib/scssphp/Formatter/Compact.php @@ -20,6 +20,8 @@ use ScssPhp\ScssPhp\Formatter; * @author Leaf Corcoran * * @deprecated since 1.4.0. Use the Compressed formatter instead. + * + * @internal */ class Compact extends Formatter { diff --git a/lib/scssphp/Formatter/Compressed.php b/lib/scssphp/Formatter/Compressed.php index d666a66564d..58ebe3f1134 100644 --- a/lib/scssphp/Formatter/Compressed.php +++ b/lib/scssphp/Formatter/Compressed.php @@ -18,6 +18,8 @@ use ScssPhp\ScssPhp\Formatter; * Compressed formatter * * @author Leaf Corcoran + * + * @internal */ class Compressed extends Formatter { @@ -48,8 +50,6 @@ class Compressed extends Formatter foreach ($block->lines as $index => $line) { if (substr($line, 0, 2) === '/*' && substr($line, 2, 1) !== '!') { unset($block->lines[$index]); - } elseif (substr($line, 0, 3) === '/*!') { - $block->lines[$index] = '/*' . substr($line, 3); } } @@ -67,6 +67,8 @@ class Compressed extends Formatter */ protected function blockSelectors(OutputBlock $block) { + assert(! empty($block->selectors)); + $inner = $this->indentStr(); $this->write( diff --git a/lib/scssphp/Formatter/Crunched.php b/lib/scssphp/Formatter/Crunched.php index 91c31443a9f..2bc1e92998d 100644 --- a/lib/scssphp/Formatter/Crunched.php +++ b/lib/scssphp/Formatter/Crunched.php @@ -20,6 +20,8 @@ use ScssPhp\ScssPhp\Formatter; * @author Anthon Pang * * @deprecated since 1.4.0. Use the Compressed formatter instead. + * + * @internal */ class Crunched extends Formatter { @@ -69,6 +71,8 @@ class Crunched extends Formatter */ protected function blockSelectors(OutputBlock $block) { + assert(! empty($block->selectors)); + $inner = $this->indentStr(); $this->write( diff --git a/lib/scssphp/Formatter/Debug.php b/lib/scssphp/Formatter/Debug.php index c676601bb52..b3f4422538c 100644 --- a/lib/scssphp/Formatter/Debug.php +++ b/lib/scssphp/Formatter/Debug.php @@ -20,6 +20,8 @@ use ScssPhp\ScssPhp\Formatter; * @author Anthon Pang * * @deprecated since 1.4.0. + * + * @internal */ class Debug extends Formatter { diff --git a/lib/scssphp/Formatter/Expanded.php b/lib/scssphp/Formatter/Expanded.php index b7cbde18d83..a280416de21 100644 --- a/lib/scssphp/Formatter/Expanded.php +++ b/lib/scssphp/Formatter/Expanded.php @@ -18,6 +18,8 @@ use ScssPhp\ScssPhp\Formatter; * Expanded formatter * * @author Leaf Corcoran + * + * @internal */ class Expanded extends Formatter { diff --git a/lib/scssphp/Formatter/Nested.php b/lib/scssphp/Formatter/Nested.php index 3249c18285e..c11ea8adfde 100644 --- a/lib/scssphp/Formatter/Nested.php +++ b/lib/scssphp/Formatter/Nested.php @@ -21,11 +21,13 @@ use ScssPhp\ScssPhp\Type; * @author Leaf Corcoran * * @deprecated since 1.4.0. Use the Expanded formatter instead. + * + * @internal */ class Nested extends Formatter { /** - * @var integer + * @var int */ private $depth; @@ -219,7 +221,7 @@ class Nested extends Formatter * * @param \ScssPhp\ScssPhp\Formatter\OutputBlock $block * - * @return boolean + * @return bool */ private function hasFlatChild($block) { diff --git a/lib/scssphp/Formatter/OutputBlock.php b/lib/scssphp/Formatter/OutputBlock.php index fe0321bde58..5cc91a080ab 100644 --- a/lib/scssphp/Formatter/OutputBlock.php +++ b/lib/scssphp/Formatter/OutputBlock.php @@ -16,6 +16,8 @@ namespace ScssPhp\ScssPhp\Formatter; * Output block * * @author Anthon Pang + * + * @internal */ class OutputBlock { @@ -25,22 +27,22 @@ class OutputBlock public $type; /** - * @var integer + * @var int */ public $depth; /** - * @var array + * @var array|null */ public $selectors; /** - * @var array + * @var string[] */ public $lines; /** - * @var array + * @var OutputBlock[] */ public $children; @@ -50,17 +52,17 @@ class OutputBlock public $parent; /** - * @var string + * @var string|null */ public $sourceName; /** - * @var integer + * @var int|null */ public $sourceLine; /** - * @var integer + * @var int|null */ public $sourceColumn; } diff --git a/lib/scssphp/Logger/LoggerInterface.php b/lib/scssphp/Logger/LoggerInterface.php new file mode 100644 index 00000000000..7c0a2f76e9a --- /dev/null +++ b/lib/scssphp/Logger/LoggerInterface.php @@ -0,0 +1,48 @@ +stream = $stream; + $this->closeOnDestruct = $closeOnDestruct; + } + + /** + * @internal + */ + public function __destruct() + { + if ($this->closeOnDestruct) { + fclose($this->stream); + } + } + + /** + * @inheritDoc + */ + public function warn($message, $deprecation = false) + { + $prefix = ($deprecation ? 'DEPRECATION ' : '') . 'WARNING: '; + + fwrite($this->stream, $prefix . $message . "\n\n"); + } + + /** + * @inheritDoc + */ + public function debug($message) + { + fwrite($this->stream, $message . "\n"); + } +} diff --git a/lib/scssphp/Node.php b/lib/scssphp/Node.php index 60d357e0ad8..fcaf8a95f3f 100644 --- a/lib/scssphp/Node.php +++ b/lib/scssphp/Node.php @@ -16,6 +16,8 @@ namespace ScssPhp\ScssPhp; * Base node * * @author Anthon Pang + * + * @internal */ abstract class Node { @@ -25,17 +27,17 @@ abstract class Node public $type; /** - * @var integer + * @var int */ public $sourceIndex; /** - * @var integer + * @var int|null */ public $sourceLine; /** - * @var integer + * @var int|null */ public $sourceColumn; } diff --git a/lib/scssphp/Node/Number.php b/lib/scssphp/Node/Number.php index 166de50d8ea..ca9b5b652d4 100644 --- a/lib/scssphp/Node/Number.php +++ b/lib/scssphp/Node/Number.php @@ -12,10 +12,13 @@ namespace ScssPhp\ScssPhp\Node; +use ScssPhp\ScssPhp\Base\Range; use ScssPhp\ScssPhp\Compiler; +use ScssPhp\ScssPhp\Exception\RangeException; use ScssPhp\ScssPhp\Exception\SassScriptException; use ScssPhp\ScssPhp\Node; use ScssPhp\ScssPhp\Type; +use ScssPhp\ScssPhp\Util; /** * Dimension + optional units @@ -27,13 +30,15 @@ use ScssPhp\ScssPhp\Type; * }} * * @author Anthon Pang + * + * @template-implements \ArrayAccess */ class Number extends Node implements \ArrayAccess { const PRECISION = 10; /** - * @var integer + * @var int * @deprecated use {Number::PRECISION} instead to read the precision. Configuring it is not supported anymore. */ public static $precision = self::PRECISION; @@ -42,6 +47,7 @@ class Number extends Node implements \ArrayAccess * @see http://www.w3.org/TR/2012/WD-css3-values-20120308/ * * @var array + * @phpstan-var array> */ protected static $unitTable = [ 'in' => [ @@ -75,7 +81,7 @@ class Number extends Node implements \ArrayAccess ]; /** - * @var integer|float + * @var int|float */ private $dimension; @@ -94,7 +100,7 @@ class Number extends Node implements \ArrayAccess /** * Initialize number * - * @param integer|float $dimension + * @param int|float $dimension * @param string[]|string $numeratorUnits * @param string[] $denominatorUnits * @@ -141,8 +147,9 @@ class Number extends Node implements \ArrayAccess } /** - * {@inheritdoc} + * @return bool */ + #[\ReturnTypeWillChange] public function offsetExists($offset) { if ($offset === -3) { @@ -166,8 +173,9 @@ class Number extends Node implements \ArrayAccess } /** - * {@inheritdoc} + * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($offset) { switch ($offset) { @@ -192,16 +200,18 @@ class Number extends Node implements \ArrayAccess } /** - * {@inheritdoc} + * @return void */ + #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { throw new \BadMethodCallException('Number is immutable'); } /** - * {@inheritdoc} + * @return void */ + #[\ReturnTypeWillChange] public function offsetUnset($offset) { throw new \BadMethodCallException('Number is immutable'); @@ -210,7 +220,7 @@ class Number extends Node implements \ArrayAccess /** * Returns true if the number is unitless * - * @return boolean + * @return bool */ public function unitless() { @@ -243,6 +253,23 @@ class Number extends Node implements \ArrayAccess return self::getUnitString($this->numeratorUnits, $this->denominatorUnits); } + /** + * @param float|int $min + * @param float|int $max + * @param string|null $name + * + * @return float|int + * @throws SassScriptException + */ + public function valueInRange($min, $max, $name = null) + { + try { + return Util::checkRange('', new Range($min, $max), $this); + } catch (RangeException $e) { + throw SassScriptException::forArgument(sprintf('Expected %s to be within %s%s and %s%3$s', $this, $min, $this->unitStr(), $max), $name); + } + } + /** * @param string|null $varName * @@ -254,7 +281,22 @@ class Number extends Node implements \ArrayAccess return; } - throw SassScriptException::forArgument(sprintf('Expected %s to have no units', $this), $varName); + throw SassScriptException::forArgument(sprintf('Expected %s to have no units.', $this), $varName); + } + + /** + * @param string $unit + * @param string|null $varName + * + * @return void + */ + public function assertUnit($unit, $varName = null) + { + if ($this->hasUnit($unit)) { + return; + } + + throw SassScriptException::forArgument(sprintf('Expected %s to have unit "%s".', $this, $unit), $varName); } /** @@ -279,6 +321,29 @@ class Number extends Node implements \ArrayAccess )); } + /** + * Returns a copy of this number, converted to the units represented by $newNumeratorUnits and $newDenominatorUnits. + * + * This does not throw an error if this number is unitless and + * $newNumeratorUnits/$newDenominatorUnits are not empty, or vice versa. Instead, + * it treats all unitless numbers as convertible to and from all units without + * changing the value. + * + * @param string[] $newNumeratorUnits + * @param string[] $newDenominatorUnits + * + * @return Number + * + * @phpstan-param list $newNumeratorUnits + * @phpstan-param list $newDenominatorUnits + * + * @throws SassScriptException if this number's units are not compatible with $newNumeratorUnits and $newDenominatorUnits + */ + public function coerce(array $newNumeratorUnits, array $newDenominatorUnits) + { + return new Number($this->valueInUnits($newNumeratorUnits, $newDenominatorUnits), $newNumeratorUnits, $newDenominatorUnits); + } + /** * @param Number $other * @@ -560,6 +625,8 @@ class Number extends Node implements \ArrayAccess * * @phpstan-param list $numeratorUnits * @phpstan-param list $denominatorUnits + * + * @throws SassScriptException if this number's units are not compatible with $numeratorUnits and $denominatorUnits */ private function valueInUnits(array $numeratorUnits, array $denominatorUnits) { diff --git a/lib/scssphp/Parser.php b/lib/scssphp/Parser.php index 1faa82f8a8b..36e7ac03615 100644 --- a/lib/scssphp/Parser.php +++ b/lib/scssphp/Parser.php @@ -12,12 +12,28 @@ namespace ScssPhp\ScssPhp; +use ScssPhp\ScssPhp\Block\AtRootBlock; +use ScssPhp\ScssPhp\Block\CallableBlock; +use ScssPhp\ScssPhp\Block\ContentBlock; +use ScssPhp\ScssPhp\Block\DirectiveBlock; +use ScssPhp\ScssPhp\Block\EachBlock; +use ScssPhp\ScssPhp\Block\ElseBlock; +use ScssPhp\ScssPhp\Block\ElseifBlock; +use ScssPhp\ScssPhp\Block\ForBlock; +use ScssPhp\ScssPhp\Block\IfBlock; +use ScssPhp\ScssPhp\Block\MediaBlock; +use ScssPhp\ScssPhp\Block\NestedPropertyBlock; +use ScssPhp\ScssPhp\Block\WhileBlock; use ScssPhp\ScssPhp\Exception\ParserException; +use ScssPhp\ScssPhp\Logger\LoggerInterface; +use ScssPhp\ScssPhp\Logger\QuietLogger; /** * Parser * * @author Leaf Corcoran + * + * @internal */ class Parser { @@ -80,7 +96,7 @@ class Parser */ private $count; /** - * @var Block + * @var Block|null */ private $env; /** @@ -110,18 +126,24 @@ class Parser private $cssOnly; + /** + * @var LoggerInterface + */ + private $logger; + /** * Constructor * * @api * - * @param string $sourceName - * @param integer $sourceIndex - * @param string|null $encoding - * @param Cache|null $cache - * @param bool $cssOnly + * @param string|null $sourceName + * @param int $sourceIndex + * @param string|null $encoding + * @param Cache|null $cache + * @param bool $cssOnly + * @param LoggerInterface|null $logger */ - public function __construct($sourceName, $sourceIndex = 0, $encoding = 'utf-8', Cache $cache = null, $cssOnly = false) + public function __construct($sourceName, $sourceIndex = 0, $encoding = 'utf-8', Cache $cache = null, $cssOnly = false, LoggerInterface $logger = null) { $this->sourceName = $sourceName ?: '(stdin)'; $this->sourceIndex = $sourceIndex; @@ -132,6 +154,7 @@ class Parser $this->commentsSeen = []; $this->allowVars = true; $this->cssOnly = $cssOnly; + $this->logger = $logger ?: new QuietLogger(); if (empty(static::$operatorPattern)) { static::$operatorPattern = '([*\/%+-]|[!=]\=|\>\=?|\<\=?|and|or)'; @@ -168,6 +191,8 @@ class Parser * * @param string $msg * + * @phpstan-return never-return + * * @throws ParserException * * @deprecated use "parseError" and throw the exception in the caller instead. @@ -292,7 +317,7 @@ class Parser * @param string $buffer * @param string|array $out * - * @return boolean + * @return bool */ public function parseValue($buffer, &$out) { @@ -321,7 +346,7 @@ class Parser * @param string|array $out * @param bool $shouldValidate * - * @return boolean + * @return bool */ public function parseSelector($buffer, &$out, $shouldValidate = true) { @@ -358,7 +383,7 @@ class Parser * @param string $buffer * @param string|array $out * - * @return boolean + * @return bool */ public function parseMediaQueryList($buffer, &$out) { @@ -415,7 +440,7 @@ class Parser * position into $s. Then if a chain fails, use $this->seek($s) to * go back where we started. * - * @return boolean + * @return bool */ protected function parseChunk() { @@ -434,7 +459,8 @@ class Parser ) { ! $this->cssOnly || $this->assertPlainCssValid(false, $s); - $atRoot = $this->pushSpecialBlock(Type::T_AT_ROOT, $s); + $atRoot = new AtRootBlock(); + $this->registerPushedBlock($atRoot, $s); $atRoot->selector = $selector; $atRoot->with = $with; @@ -448,7 +474,8 @@ class Parser $this->mediaQueryList($mediaQueryList) && $this->matchChar('{', false) ) { - $media = $this->pushSpecialBlock(Type::T_MEDIA, $s); + $media = new MediaBlock(); + $this->registerPushedBlock($media, $s); $media->queryList = $mediaQueryList[2]; return true; @@ -464,7 +491,8 @@ class Parser ) { ! $this->cssOnly || $this->assertPlainCssValid(false, $s); - $mixin = $this->pushSpecialBlock(Type::T_MIXIN, $s); + $mixin = new CallableBlock(Type::T_MIXIN); + $this->registerPushedBlock($mixin, $s); $mixin->name = $mixinName; $mixin->args = $args; @@ -496,7 +524,8 @@ class Parser ]; if (! empty($hasBlock)) { - $include = $this->pushSpecialBlock(Type::T_INCLUDE, $s); + $include = new ContentBlock(); + $this->registerPushedBlock($include, $s); $include->child = $child; } else { $this->append($child, $s); @@ -514,6 +543,10 @@ class Parser ) { ! $this->cssOnly || $this->assertPlainCssValid(false, $s); + list($line, $column) = $this->getSourcePosition($s); + $file = $this->sourceName; + $this->logger->warn("The \"@scssphp-import-once\" directive is deprecated and will be removed in ScssPhp 2.0, in \"$file\", line $line, column $column.", true); + $this->append([Type::T_SCSSPHP_IMPORT_ONCE, $importPath], $s); return true; @@ -582,7 +615,8 @@ class Parser ) { ! $this->cssOnly || $this->assertPlainCssValid(false, $s); - $func = $this->pushSpecialBlock(Type::T_FUNCTION, $s); + $func = new CallableBlock(Type::T_FUNCTION); + $this->registerPushedBlock($func, $s); $func->name = $fnName; $func->args = $args; @@ -614,7 +648,8 @@ class Parser ) { ! $this->cssOnly || $this->assertPlainCssValid(false, $s); - $each = $this->pushSpecialBlock(Type::T_EACH, $s); + $each = new EachBlock(); + $this->registerPushedBlock($each, $s); foreach ($varNames[2] as $varName) { $each->vars[] = $varName[1]; @@ -643,7 +678,8 @@ class Parser $cond = reset($cond[2]); } - $while = $this->pushSpecialBlock(Type::T_WHILE, $s); + $while = new WhileBlock(); + $this->registerPushedBlock($while, $s); $while->cond = $cond; return true; @@ -663,7 +699,8 @@ class Parser ) { ! $this->cssOnly || $this->assertPlainCssValid(false, $s); - $for = $this->pushSpecialBlock(Type::T_FOR, $s); + $for = new ForBlock(); + $this->registerPushedBlock($for, $s); $for->var = $varName[1]; $for->start = $start; $for->end = $end; @@ -680,7 +717,8 @@ class Parser ) { ! $this->cssOnly || $this->assertPlainCssValid(false, $s); - $if = $this->pushSpecialBlock(Type::T_IF, $s); + $if = new IfBlock(); + $this->registerPushedBlock($if, $s); while ( $cond[0] === Type::T_LIST && @@ -759,20 +797,21 @@ class Parser if (isset($last) && $last[0] === Type::T_IF) { list(, $if) = $last; + assert($if instanceof IfBlock); if ($this->literal('@else', 5)) { if ($this->matchChar('{', false)) { - $else = $this->pushSpecialBlock(Type::T_ELSE, $s); + $else = new ElseBlock(); } elseif ( $this->literal('if', 2) && $this->functionCallArgumentsList($cond, false, '{', false) ) { - $else = $this->pushSpecialBlock(Type::T_ELSEIF, $s); + $else = new ElseifBlock(); $else->cond = $cond; } if (isset($else)) { - $else->dontAppend = true; + $this->registerPushedBlock($else, $s); $if->cases[] = $else; return true; @@ -810,7 +849,8 @@ class Parser ($t1 = $this->supportsQuery($supportQuery)) && ($t2 = $this->matchChar('{', false)) ) { - $directive = $this->pushSpecialBlock(Type::T_DIRECTIVE, $s); + $directive = new DirectiveBlock(); + $this->registerPushedBlock($directive, $s); $directive->name = 'supports'; $directive->value = $supportQuery; @@ -831,11 +871,12 @@ class Parser $dirName = [Type::T_STRING, '', $dirName]; } if ($dirName === 'media') { - $directive = $this->pushSpecialBlock(Type::T_MEDIA, $s); + $directive = new MediaBlock(); } else { - $directive = $this->pushSpecialBlock(Type::T_DIRECTIVE, $s); + $directive = new DirectiveBlock(); $directive->name = $dirName; } + $this->registerPushedBlock($directive, $s); if (isset($dirValue)) { ! $this->cssOnly || ($dirValue = $this->assertPlainCssValid($dirValue)); @@ -973,11 +1014,6 @@ class Parser $this->seek($s); - // misc - if ($this->literal('-->', 3)) { - return true; - } - // opening css block if ( $this->selectors($selectors) && @@ -1016,7 +1052,8 @@ class Parser if ($this->matchChar('{', false)) { ! $this->cssOnly || $this->assertPlainCssValid(false); - $propBlock = $this->pushSpecialBlock(Type::T_NESTED_PROPERTY, $s); + $propBlock = new NestedPropertyBlock(); + $this->registerPushedBlock($propBlock, $s); $propBlock->prefix = $name; $propBlock->hasValue = $foundSomething; @@ -1042,12 +1079,13 @@ class Parser } } - if (isset($block->type) && $block->type === Type::T_INCLUDE) { + if ($block instanceof ContentBlock) { $include = $block->child; + assert(\is_array($include)); unset($block->child); $include[3] = $block; $this->append($include, $s); - } elseif (empty($block->dontAppend)) { + } elseif (!$block instanceof ElseBlock && !$block instanceof ElseifBlock) { $type = isset($block->type) ? $block->type : Type::T_BLOCK; $this->append([$type, $block], $s); } @@ -1065,10 +1103,7 @@ class Parser } // extra stuff - if ( - $this->matchChar(';') || - $this->literal('