diff --git a/modules/sass/readme.md b/modules/sass/readme.md index 285c30d5..6b21db62 100644 --- a/modules/sass/readme.md +++ b/modules/sass/readme.md @@ -1,3 +1,8 @@ # Sass Compile [Sass](https://sass-lang.com/) to CSS. + +- Based on [SCSS-PHP](https://github.com/scssphp/scssphp/) 1.12.1 +- To upgrade: + - Copy `src` folder and rename as `scssphp` + - Run the script `replace-namespace.sh` to use unique namescape in all files, in case other plugins are using a different version of SCSS-PHP diff --git a/modules/sass/replace-namespace.sh b/modules/sass/replace-namespace.sh index d6605b49..18e3e78a 100755 --- a/modules/sass/replace-namespace.sh +++ b/modules/sass/replace-namespace.sh @@ -8,47 +8,52 @@ set -eou pipefail # of the same module. # -OS="`uname`" +main() { -CURRENT_FOLDER="scssphp" + local OS + local CURRENT_FOLDER="scssphp" + OS="$(uname)" -namespace-files() { + namespace-files() { - echo "Folder: $CURRENT_FOLDER" - local RESTORE_FOLDER="$CURRENT_FOLDER" + echo "Folder: $CURRENT_FOLDER" + local RESTORE_FOLDER="$CURRENT_FOLDER" - for file in *.php; do + for file in *.php; do - echo " File: $file"; + echo " File: $file"; - if [ "$OS" == "Darwin" ]; then - # macOS-specific options for sed - # @see https://stackoverflow.com/questions/5694228/sed-in-place-flag-that-works-both-on-mac-bsd-and-linux#22084103 - sed -i '' -e 's/namespace\ ScssPhp\\/namespace\ Tangible\\/g' "$file" - sed -i '' -e 's/use\ ScssPhp\\/use\ Tangible\\/g' "$file" - else - # Linux (or WSL2 - Windows Subsystem for Linux) - sed -i -e 's/namespace\ ScssPhp\\/namespace\ Tangible\\/g' "$file" - sed -i -e 's/use\ ScssPhp\\/use\ Tangible\\/g' "$file" - fi - done + if [ "$OS" == "Darwin" ]; then + # macOS-specific options for sed + # @see https://stackoverflow.com/questions/5694228/sed-in-place-flag-that-works-both-on-mac-bsd-and-linux#22084103 + sed -i '' -e 's/namespace\ ScssPhp\\/namespace\ Tangible\\/g' "$file" + sed -i '' -e 's/use\ ScssPhp\\/use\ Tangible\\/g' "$file" + else + # Linux or WSL2 - Windows Subsystem for Linux + sed -i -e 's/namespace\ ScssPhp\\/namespace\ Tangible\\/g' "$file" + sed -i -e 's/use\ ScssPhp\\/use\ Tangible\\/g' "$file" + fi + done - for folder in *; do - if [ -d $folder ]; then + for folder in *; do + if [ -d "$folder" ]; then - CURRENT_FOLDER="$RESTORE_FOLDER/$folder" + CURRENT_FOLDER="$RESTORE_FOLDER/$folder" - cd $folder - namespace-files - cd .. - fi - done + cd "$folder" + namespace-files + cd .. + fi + done - CURRENT_FOLDER="$RESTORE_FOLDER" -} + CURRENT_FOLDER="$RESTORE_FOLDER" + } + + cd scssphp -cd scssphp + namespace-files -namespace-files + cd .. +} -cd .. +main diff --git a/modules/sass/scssphp/Ast/AstNode.php b/modules/sass/scssphp/Ast/AstNode.php index 7daaf4c7..166fa3f6 100644 --- a/modules/sass/scssphp/Ast/AstNode.php +++ b/modules/sass/scssphp/Ast/AstNode.php @@ -19,9 +19,7 @@ * * @internal */ -interface AstNode +interface AstNode extends \Stringable { public function getSpan(): FileSpan; - - public function __toString(): string; } diff --git a/modules/sass/scssphp/Ast/Css/CssMediaQuery.php b/modules/sass/scssphp/Ast/Css/CssMediaQuery.php index ca2b4861..08778780 100644 --- a/modules/sass/scssphp/Ast/Css/CssMediaQuery.php +++ b/modules/sass/scssphp/Ast/Css/CssMediaQuery.php @@ -14,6 +14,7 @@ use Tangible\ScssPhp\Exception\SassFormatException; use Tangible\ScssPhp\Logger\LoggerInterface; +use Tangible\ScssPhp\Parser\InterpolationMap; use Tangible\ScssPhp\Parser\MediaQueryParser; /** @@ -21,30 +22,21 @@ * * @internal */ -final class CssMediaQuery +final class CssMediaQuery implements MediaQueryMergeResult { - public const MERGE_RESULT_EMPTY = 'empty'; - public const MERGE_RESULT_UNREPRESENTABLE = 'unrepresentable'; - /** * The modifier, probably either "not" or "only". * * This may be `null` if no modifier is in use. - * - * @var string|null - * @readonly */ - private $modifier; + private readonly ?string $modifier; /** * The media type, for example "screen" or "print". * * This may be `null`. If so, {@see $conditions} will not be empty. - * - * @var string|null - * @readonly */ - private $type; + private readonly ?string $type; /** * Whether {@see $conditions} is a conjunction or a disjunction. @@ -54,11 +46,8 @@ final class CssMediaQuery * condition in {@see $conditions} is met. * * If this is `false`, {@see $modifier} and {@see $type} will both be `null`. - * - * @var bool - * @readonly */ - private $conjunction; + private readonly bool $conjunction; /** * Media conditions, including parentheses. @@ -68,9 +57,8 @@ final class CssMediaQuery * [``]: https://drafts.csswg.org/mediaqueries-4/#typedef-media-in-parens * * @var list - * @readonly */ - private $conditions; + private readonly array $conditions; /** * Parses a media query from $contents. @@ -81,9 +69,9 @@ final class CssMediaQuery * * @throws SassFormatException if parsing fails */ - public static function parseList(string $contents, ?LoggerInterface $logger = null, ?string $url = null): array + public static function parseList(string $contents, ?LoggerInterface $logger = null, ?string $url = null, ?InterpolationMap $interpolationMap = null): array { - return (new MediaQueryParser($contents, $logger, $url))->parse(); + return (new MediaQueryParser($contents, $logger, $url, $interpolationMap))->parse(); } /** @@ -161,14 +149,11 @@ public function matchesAllTypes(): bool /** * Merges this with $other to return a query that matches the intersection * of both inputs. - * - * @return CssMediaQuery|string - * @phpstan-return CssMediaQuery|CssMediaQuery::* */ - public function merge(CssMediaQuery $other) + public function merge(CssMediaQuery $other): MediaQueryMergeResult { if (!$this->conjunction || !$other->conjunction) { - return self::MERGE_RESULT_UNREPRESENTABLE; + return MediaQuerySingletonMergeResult::unrepresentable; } $ourModifier = $this->modifier !== null ? strtolower($this->modifier) : null; @@ -193,14 +178,14 @@ public function merge(CssMediaQuery $other) // (grid)`, because it means `not (screen and (color))` and so it allows // a screen with no color but with a grid. if (empty(array_diff($negativeConditions, $positiveConditions))) { - return self::MERGE_RESULT_EMPTY; + return MediaQuerySingletonMergeResult::empty; } - return self::MERGE_RESULT_UNREPRESENTABLE; + return MediaQuerySingletonMergeResult::unrepresentable; } if ($this->matchesAllTypes() || $other->matchesAllTypes()) { - return self::MERGE_RESULT_UNREPRESENTABLE; + return MediaQuerySingletonMergeResult::unrepresentable; } if ($ourModifier === 'not') { @@ -215,7 +200,7 @@ public function merge(CssMediaQuery $other) } elseif ($ourModifier === 'not') { // CSS has no way of representing "neither screen nor print". if ($ourType !== $theirType) { - return self::MERGE_RESULT_UNREPRESENTABLE; + return MediaQuerySingletonMergeResult::unrepresentable; } $moreConditions = \count($this->conditions) > \count($other->conditions) ? $this->conditions : $other->conditions; @@ -229,7 +214,7 @@ public function merge(CssMediaQuery $other) $conditions = $moreConditions; } else { // Otherwise, there's no way to represent the intersection. - return self::MERGE_RESULT_UNREPRESENTABLE; + return MediaQuerySingletonMergeResult::unrepresentable; } } elseif ($this->matchesAllTypes()) { $modifier = $theirModifier; @@ -242,7 +227,7 @@ public function merge(CssMediaQuery $other) $type = $ourType; $conditions = array_merge($this->conditions, $other->conditions); } elseif ($ourType !== $theirType) { - return self::MERGE_RESULT_EMPTY; + return MediaQuerySingletonMergeResult::empty; } else { $modifier = $ourModifier ?? $theirModifier; $type = $ourType; diff --git a/modules/sass/scssphp/Ast/Css/CssNode.php b/modules/sass/scssphp/Ast/Css/CssNode.php index 41e52b35..d10246c4 100644 --- a/modules/sass/scssphp/Ast/Css/CssNode.php +++ b/modules/sass/scssphp/Ast/Css/CssNode.php @@ -37,7 +37,7 @@ public function isGroupEnd(): bool; * * @return T */ - public function accept($visitor); + public function accept(CssVisitor $visitor); /** * Whether this is invisible and won't be emitted to the compiled stylesheet. diff --git a/modules/sass/scssphp/Ast/Css/CssStyleRule.php b/modules/sass/scssphp/Ast/Css/CssStyleRule.php index efd9d1f8..b1dd4a5a 100644 --- a/modules/sass/scssphp/Ast/Css/CssStyleRule.php +++ b/modules/sass/scssphp/Ast/Css/CssStyleRule.php @@ -27,10 +27,8 @@ interface CssStyleRule extends CssParentNode { /** * The selector for this rule. - * - * @return CssValue */ - public function getSelector(): CssValue; + public function getSelector(): SelectorList; /** * The selector for this rule, before any extensions were applied. diff --git a/modules/sass/scssphp/Ast/Css/CssValue.php b/modules/sass/scssphp/Ast/Css/CssValue.php index bcd0a69a..af6c6aff 100644 --- a/modules/sass/scssphp/Ast/Css/CssValue.php +++ b/modules/sass/scssphp/Ast/Css/CssValue.php @@ -13,35 +13,34 @@ namespace Tangible\ScssPhp\Ast\Css; use Tangible\ScssPhp\Ast\AstNode; +use Tangible\ScssPhp\Ast\Selector\Combinator; use Tangible\ScssPhp\SourceSpan\FileSpan; +use Tangible\ScssPhp\Util\Equatable; +use Tangible\ScssPhp\Util\EquatableUtil; /** * A value in a plain CSS tree. * * This is used to associate a span with a value that doesn't otherwise track - * its span. + * its span. It has value equality semantics. * - * @template T + * @template-covariant T of string|\Stringable|array|Combinator|null * * @internal */ -class CssValue implements AstNode +final class CssValue implements AstNode, Equatable { /** * @phpstan-var T */ - protected $value; + private readonly mixed $value; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param T $value */ - public function __construct($value, FileSpan $span) + public function __construct(mixed $value, FileSpan $span) { $this->value = $value; $this->span = $span; @@ -50,7 +49,7 @@ public function __construct($value, FileSpan $span) /** * @return T */ - public function getValue() + public function getValue(): mixed { return $this->value; } @@ -60,8 +59,17 @@ public function getSpan(): FileSpan return $this->span; } + public function equals(object $other): bool + { + return $other instanceof CssValue && EquatableUtil::equals($this->value, $other->value); + } + public function __toString(): string { + if ($this->value instanceof Combinator) { + return $this->value->getText(); + } + if (\is_array($this->value)) { return implode($this->value); } diff --git a/modules/sass/scssphp/Ast/Css/IsInvisibleVisitor.php b/modules/sass/scssphp/Ast/Css/IsInvisibleVisitor.php index 17b8e682..cc80af91 100644 --- a/modules/sass/scssphp/Ast/Css/IsInvisibleVisitor.php +++ b/modules/sass/scssphp/Ast/Css/IsInvisibleVisitor.php @@ -23,19 +23,13 @@ final class IsInvisibleVisitor extends EveryCssVisitor { /** * Whether to consider selectors with bogus combinators invisible. - * - * @var bool - * @readonly */ - private $includeBogus; + private readonly bool $includeBogus; /** * Whether to consider comments invisible. - * - * @var bool - * @readonly */ - private $includeComments; + private readonly bool $includeComments; public function __construct(bool $includeBogus, bool $includeComments) { @@ -43,7 +37,7 @@ public function __construct(bool $includeBogus, bool $includeComments) $this->includeComments = $includeComments; } - public function visitCssAtRule($node): bool + public function visitCssAtRule(CssAtRule $node): bool { // An unknown at-rule is never invisible. Because we don't know the semantics // of unknown rules, we can't guarantee that (for example) `@foo {}` isn't @@ -51,13 +45,13 @@ public function visitCssAtRule($node): bool return false; } - public function visitCssComment($node): bool + public function visitCssComment(CssComment $node): bool { return $this->includeComments && !$node->isPreserved(); } - public function visitCssStyleRule($node): bool + public function visitCssStyleRule(CssStyleRule $node): bool { - return ($this->includeBogus ? $node->getSelector()->getValue()->isInvisible() : $node->getSelector()->getValue()->isInvisibleOtherThanBogusCombinators()) || parent::visitCssStyleRule($node); + return ($this->includeBogus ? $node->getSelector()->isInvisible() : $node->getSelector()->isInvisibleOtherThanBogusCombinators()) || parent::visitCssStyleRule($node); } } diff --git a/modules/sass/scssphp/Ast/Css/MediaQueryMergeResult.php b/modules/sass/scssphp/Ast/Css/MediaQueryMergeResult.php new file mode 100644 index 00000000..54ca6eab --- /dev/null +++ b/modules/sass/scssphp/Ast/Css/MediaQueryMergeResult.php @@ -0,0 +1,23 @@ + */ - private $name; + private readonly CssValue $name; /** * @var CssValue|null */ - private $value; + private readonly ?CssValue $value; - /** - * @var bool - * @readonly - */ - private $childless; + private readonly bool $childless; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** - * @param CssValue $name + * @param CssValue $name * @param CssValue|null $value - * @param bool $childless - * @param FileSpan $span */ public function __construct(CssValue $name, FileSpan $span, bool $childless = false, ?CssValue $value = null) { @@ -79,15 +71,17 @@ public function getSpan(): FileSpan return $this->span; } - public function accept($visitor) + public function accept(ModifiableCssVisitor $visitor) { return $visitor->visitCssAtRule($this); } - /** - * @phpstan-return ModifiableCssAtRule - */ - public function copyWithoutChildren(): ModifiableCssParentNode + public function equalsIgnoringChildren(ModifiableCssNode $other): bool + { + return $other instanceof ModifiableCssAtRule && EquatableUtil::equals($this->name, $other->name) && EquatableUtil::equals($this->value, $other->value) && $this->childless === $other->childless; + } + + public function copyWithoutChildren(): ModifiableCssAtRule { return new ModifiableCssAtRule($this->name, $this->span, $this->childless, $this->value); } diff --git a/modules/sass/scssphp/Ast/Css/ModifiableCssComment.php b/modules/sass/scssphp/Ast/Css/ModifiableCssComment.php index 20764b1e..8b08f142 100644 --- a/modules/sass/scssphp/Ast/Css/ModifiableCssComment.php +++ b/modules/sass/scssphp/Ast/Css/ModifiableCssComment.php @@ -13,6 +13,7 @@ namespace Tangible\ScssPhp\Ast\Css; use Tangible\ScssPhp\SourceSpan\FileSpan; +use Tangible\ScssPhp\Visitor\ModifiableCssVisitor; /** * A modifiable version of {@see CssComment} for use in the evaluation step. @@ -21,17 +22,9 @@ */ final class ModifiableCssComment extends ModifiableCssNode implements CssComment { - /** - * @var string - * @readonly - */ - private $text; - - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly string $text; + + private readonly FileSpan $span; public function __construct(string $text, FileSpan $span) { @@ -54,7 +47,7 @@ public function isPreserved(): bool return $this->text[2] === '!'; } - public function accept($visitor) + public function accept(ModifiableCssVisitor $visitor) { return $visitor->visitCssComment($this); } diff --git a/modules/sass/scssphp/Ast/Css/ModifiableCssDeclaration.php b/modules/sass/scssphp/Ast/Css/ModifiableCssDeclaration.php index 115f2300..66ef2532 100644 --- a/modules/sass/scssphp/Ast/Css/ModifiableCssDeclaration.php +++ b/modules/sass/scssphp/Ast/Css/ModifiableCssDeclaration.php @@ -15,6 +15,7 @@ use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Value\SassString; use Tangible\ScssPhp\Value\Value; +use Tangible\ScssPhp\Visitor\ModifiableCssVisitor; /** * A modifiable version of {@see CssDeclaration} for use in the evaluation step. @@ -25,42 +26,26 @@ final class ModifiableCssDeclaration extends ModifiableCssNode implements CssDec { /** * @var CssValue - * @readonly */ - private $name; + private readonly CssValue $name; /** * @var CssValue - * @readonly */ - private $value; + private readonly CssValue $value; - /** - * @var bool - * @readonly - */ - private $parsedAsCustomProperty; + private readonly bool $parsedAsCustomProperty; - /** - * @var FileSpan - * @readonly - */ - private $valueSpanForMap; + private readonly FileSpan $valueSpanForMap; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param CssValue $name - * @param CssValue $value - * @param bool $parsedAsCustomProperty - * @param FileSpan $valueSpanForMap - * @param FileSpan $span + * @param CssValue $value */ - public function __construct(CssValue $name, CssValue $value, FileSpan $span, bool $parsedAsCustomProperty, ?FileSpan $valueSpanForMap = null) { + public function __construct(CssValue $name, CssValue $value, FileSpan $span, bool $parsedAsCustomProperty, ?FileSpan $valueSpanForMap = null) + { $this->name = $name; $this->value = $value; $this->parsedAsCustomProperty = $parsedAsCustomProperty; @@ -73,7 +58,7 @@ public function __construct(CssValue $name, CssValue $value, FileSpan $span, boo } if (!$value->getValue() instanceof SassString) { - throw new \InvalidArgumentException(sprintf('If parsedAsCustomProperty is true, value must contain a SassString (was %s).', get_class($value->getValue()))); + throw new \InvalidArgumentException(sprintf('If parsedAsCustomProperty is true, value must contain a SassString (was %s).', get_debug_type($value->getValue()))); } } } @@ -105,10 +90,10 @@ public function getSpan(): FileSpan public function isCustomProperty(): bool { - return 0 === strpos($this->name->getValue(), '--'); + return str_starts_with($this->name->getValue(), '--'); } - public function accept($visitor) + public function accept(ModifiableCssVisitor $visitor) { return $visitor->visitCssDeclaration($this); } diff --git a/modules/sass/scssphp/Ast/Css/ModifiableCssImport.php b/modules/sass/scssphp/Ast/Css/ModifiableCssImport.php index babc6eef..decac389 100644 --- a/modules/sass/scssphp/Ast/Css/ModifiableCssImport.php +++ b/modules/sass/scssphp/Ast/Css/ModifiableCssImport.php @@ -13,6 +13,7 @@ namespace Tangible\ScssPhp\Ast\Css; use Tangible\ScssPhp\SourceSpan\FileSpan; +use Tangible\ScssPhp\Visitor\ModifiableCssVisitor; /** * A modifiable version of {@see CssImport} for use in the evaluation step. @@ -27,26 +28,19 @@ final class ModifiableCssImport extends ModifiableCssNode implements CssImport * This includes quotes. * * @var CssValue - * @readonly */ - private $url; + private readonly CssValue $url; /** * @var CssValue|null - * @readonly */ - private $modifiers; + private readonly ?CssValue $modifiers; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** - * @param CssValue $url - * @param FileSpan $span - * @param CssValue|null $modifiers + * @param CssValue $url + * @param CssValue|null $modifiers */ public function __construct(CssValue $url, FileSpan $span, ?CssValue $modifiers = null) { @@ -70,7 +64,7 @@ public function getSpan(): FileSpan return $this->span; } - public function accept($visitor) + public function accept(ModifiableCssVisitor $visitor) { return $visitor->visitCssImport($this); } diff --git a/modules/sass/scssphp/Ast/Css/ModifiableCssKeyframeBlock.php b/modules/sass/scssphp/Ast/Css/ModifiableCssKeyframeBlock.php index 2b01a118..24215a14 100644 --- a/modules/sass/scssphp/Ast/Css/ModifiableCssKeyframeBlock.php +++ b/modules/sass/scssphp/Ast/Css/ModifiableCssKeyframeBlock.php @@ -13,6 +13,8 @@ namespace Tangible\ScssPhp\Ast\Css; use Tangible\ScssPhp\SourceSpan\FileSpan; +use Tangible\ScssPhp\Util\EquatableUtil; +use Tangible\ScssPhp\Visitor\ModifiableCssVisitor; /** * A modifiable version of {@see CssKeyframeBlock} for use in the evaluation step. @@ -23,19 +25,13 @@ final class ModifiableCssKeyframeBlock extends ModifiableCssParentNode implement { /** * @var CssValue> - * @readonly */ - private $selector; + private readonly CssValue $selector; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param CssValue> $selector - * @param FileSpan $span */ public function __construct(CssValue $selector, FileSpan $span) { @@ -54,15 +50,17 @@ public function getSpan(): FileSpan return $this->span; } - public function accept($visitor) + public function accept(ModifiableCssVisitor $visitor) { return $visitor->visitCssKeyframeBlock($this); } - /** - * @phpstan-return ModifiableCssKeyframeBlock - */ - public function copyWithoutChildren(): ModifiableCssParentNode + public function equalsIgnoringChildren(ModifiableCssNode $other): bool + { + return $other instanceof ModifiableCssKeyframeBlock && EquatableUtil::listEquals($this->selector->getValue(), $other->selector->getValue()); + } + + public function copyWithoutChildren(): ModifiableCssKeyframeBlock { return new ModifiableCssKeyframeBlock($this->selector, $this->span); } diff --git a/modules/sass/scssphp/Ast/Css/ModifiableCssMediaRule.php b/modules/sass/scssphp/Ast/Css/ModifiableCssMediaRule.php index cbab7796..6b4eeb31 100644 --- a/modules/sass/scssphp/Ast/Css/ModifiableCssMediaRule.php +++ b/modules/sass/scssphp/Ast/Css/ModifiableCssMediaRule.php @@ -13,6 +13,8 @@ namespace Tangible\ScssPhp\Ast\Css; use Tangible\ScssPhp\SourceSpan\FileSpan; +use Tangible\ScssPhp\Util\EquatableUtil; +use Tangible\ScssPhp\Visitor\ModifiableCssVisitor; /** * A modifiable version of {@see CssMediaRule} for use in the evaluation step. @@ -24,17 +26,12 @@ final class ModifiableCssMediaRule extends ModifiableCssParentNode implements Cs /** * @var list */ - private $queries; + private readonly array $queries; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** - * @param CssMediaQuery[] $queries - * @param FileSpan $span + * @param list $queries */ public function __construct(array $queries, FileSpan $span) { @@ -53,12 +50,17 @@ public function getSpan(): FileSpan return $this->span; } - public function accept($visitor) + public function accept(ModifiableCssVisitor $visitor) { return $visitor->visitCssMediaRule($this); } - public function copyWithoutChildren(): ModifiableCssParentNode + public function equalsIgnoringChildren(ModifiableCssNode $other): bool + { + return $other instanceof ModifiableCssMediaRule && EquatableUtil::listEquals($this->queries, $other->queries); + } + + public function copyWithoutChildren(): ModifiableCssMediaRule { return new ModifiableCssMediaRule($this->queries, $this->span); } diff --git a/modules/sass/scssphp/Ast/Css/ModifiableCssNode.php b/modules/sass/scssphp/Ast/Css/ModifiableCssNode.php index 390ec14d..f34ae113 100644 --- a/modules/sass/scssphp/Ast/Css/ModifiableCssNode.php +++ b/modules/sass/scssphp/Ast/Css/ModifiableCssNode.php @@ -26,24 +26,16 @@ */ abstract class ModifiableCssNode implements CssNode { - /** - * @var ModifiableCssParentNode|null - */ - private $parent; + private ?ModifiableCssParentNode $parent = null; /** * The index of `$this` in parent's children. * * This makes {@see remove} more efficient. - * - * @var int|null */ - private $indexInParent; + private ?int $indexInParent = null; - /** - * @var bool - */ - private $groupEnd = false; + private bool $groupEnd = false; public function getParent(): ?ModifiableCssParentNode { @@ -115,7 +107,7 @@ public function isInvisibleHidingComments(): bool * * @return T */ - abstract public function accept($visitor); + abstract public function accept(ModifiableCssVisitor $visitor); /** * Removes $this from {@see parent}'s child list. @@ -144,6 +136,15 @@ public function remove(): void $this->indexInParent = null; } + /** + * @@internal + */ + protected function resetParentReferences(): void + { + $this->parent = null; + $this->indexInParent = null; + } + public function __toString(): string { return Serializer::serialize($this, true)->getCss(); diff --git a/modules/sass/scssphp/Ast/Css/ModifiableCssParentNode.php b/modules/sass/scssphp/Ast/Css/ModifiableCssParentNode.php index 5171a7e0..56405bd4 100644 --- a/modules/sass/scssphp/Ast/Css/ModifiableCssParentNode.php +++ b/modules/sass/scssphp/Ast/Css/ModifiableCssParentNode.php @@ -22,7 +22,7 @@ abstract class ModifiableCssParentNode extends ModifiableCssNode implements CssP /** * @var list */ - private $children; + private array $children; /** * @param list $children @@ -45,6 +45,11 @@ public function isChildless(): bool return false; } + /** + * Returns whether $this is equal to $other, ignoring their child nodes. + */ + abstract public function equalsIgnoringChildren(ModifiableCssNode $other): bool; + /** * Returns a copy of $this with an empty {@see children} list. * @@ -66,4 +71,15 @@ public function removeChildAt(int $index): void { array_splice($this->children, $index, 1); } + + /** + * Destructively removes all elements from {@see children}. + */ + public function clearChildren(): void + { + foreach ($this->children as $child) { + $child->resetParentReferences(); + } + $this->children = []; + } } diff --git a/modules/sass/scssphp/Ast/Css/ModifiableCssStyleRule.php b/modules/sass/scssphp/Ast/Css/ModifiableCssStyleRule.php index cb6ca8dc..b2650f34 100644 --- a/modules/sass/scssphp/Ast/Css/ModifiableCssStyleRule.php +++ b/modules/sass/scssphp/Ast/Css/ModifiableCssStyleRule.php @@ -14,6 +14,9 @@ use Tangible\ScssPhp\Ast\Selector\SelectorList; use Tangible\ScssPhp\SourceSpan\FileSpan; +use Tangible\ScssPhp\Util\Box; +use Tangible\ScssPhp\Util\EquatableUtil; +use Tangible\ScssPhp\Visitor\ModifiableCssVisitor; /** * A modifiable version of {@see CssStyleRule} for use in the evaluation step. @@ -23,29 +26,21 @@ final class ModifiableCssStyleRule extends ModifiableCssParentNode implements CssStyleRule { /** - * @var ModifiableCssValue - * @readonly + * A reference to the modifiable selector list provided by the extension + * store, which may update it over time as new extensions are applied. + * + * @var Box */ - private $selector; + private readonly Box $selector; - /** - * @var SelectorList - * @readonly - */ - private $originalSelector; + private readonly SelectorList $originalSelector; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** - * @param ModifiableCssValue $selector - * @param FileSpan $span - * @param SelectorList|null $originalSelector + * @param Box $selector */ - public function __construct(ModifiableCssValue $selector, FileSpan $span, ?SelectorList $originalSelector = null) + public function __construct(Box $selector, FileSpan $span, ?SelectorList $originalSelector = null) { parent::__construct(); $this->selector = $selector; @@ -53,12 +48,9 @@ public function __construct(ModifiableCssValue $selector, FileSpan $span, ?Selec $this->span = $span; } - /** - * @phpstan-return ModifiableCssValue - */ - public function getSelector(): CssValue + public function getSelector(): SelectorList { - return $this->selector; + return $this->selector->getValue(); } public function getOriginalSelector(): SelectorList @@ -71,15 +63,17 @@ public function getSpan(): FileSpan return $this->span; } - public function accept($visitor) + public function accept(ModifiableCssVisitor $visitor) { return $visitor->visitCssStyleRule($this); } - /** - * @phpstan-return ModifiableCssStyleRule - */ - public function copyWithoutChildren(): ModifiableCssParentNode + public function equalsIgnoringChildren(ModifiableCssNode $other): bool + { + return $other instanceof ModifiableCssStyleRule && EquatableUtil::equals($this->selector, $other->selector); + } + + public function copyWithoutChildren(): ModifiableCssStyleRule { return new ModifiableCssStyleRule($this->selector, $this->span, $this->originalSelector); } diff --git a/modules/sass/scssphp/Ast/Css/ModifiableCssStylesheet.php b/modules/sass/scssphp/Ast/Css/ModifiableCssStylesheet.php index f90ba845..8a51db51 100644 --- a/modules/sass/scssphp/Ast/Css/ModifiableCssStylesheet.php +++ b/modules/sass/scssphp/Ast/Css/ModifiableCssStylesheet.php @@ -13,6 +13,7 @@ namespace Tangible\ScssPhp\Ast\Css; use Tangible\ScssPhp\SourceSpan\FileSpan; +use Tangible\ScssPhp\Visitor\ModifiableCssVisitor; /** * A modifiable version of {@see CssStylesheet} for use in the evaluation step. @@ -21,14 +22,9 @@ */ final class ModifiableCssStylesheet extends ModifiableCssParentNode implements CssStylesheet { - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** - * @param FileSpan $span * @param list $children */ public function __construct(FileSpan $span, array $children = []) @@ -42,15 +38,17 @@ public function getSpan(): FileSpan return $this->span; } - public function accept($visitor) + public function accept(ModifiableCssVisitor $visitor) { return $visitor->visitCssStylesheet($this); } - /** - * @phpstan-return ModifiableCssStylesheet - */ - public function copyWithoutChildren(): ModifiableCssParentNode + public function equalsIgnoringChildren(ModifiableCssNode $other): bool + { + return $other instanceof ModifiableCssStylesheet; + } + + public function copyWithoutChildren(): ModifiableCssStylesheet { return new ModifiableCssStylesheet($this->span); } diff --git a/modules/sass/scssphp/Ast/Css/ModifiableCssSupportsRule.php b/modules/sass/scssphp/Ast/Css/ModifiableCssSupportsRule.php index 59fe4ec9..2d427e4d 100644 --- a/modules/sass/scssphp/Ast/Css/ModifiableCssSupportsRule.php +++ b/modules/sass/scssphp/Ast/Css/ModifiableCssSupportsRule.php @@ -13,6 +13,8 @@ namespace Tangible\ScssPhp\Ast\Css; use Tangible\ScssPhp\SourceSpan\FileSpan; +use Tangible\ScssPhp\Util\EquatableUtil; +use Tangible\ScssPhp\Visitor\ModifiableCssVisitor; /** * A modifiable version of {@see CssSupportsRule} for use in the evaluation step. @@ -23,19 +25,13 @@ final class ModifiableCssSupportsRule extends ModifiableCssParentNode implements { /** * @var CssValue - * @readonly */ - private $condition; + private readonly CssValue $condition; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param CssValue $condition - * @param FileSpan $span */ public function __construct(CssValue $condition, FileSpan $span) { @@ -54,15 +50,17 @@ public function getSpan(): FileSpan return $this->span; } - public function accept($visitor) + public function accept(ModifiableCssVisitor $visitor) { return $visitor->visitCssSupportsRule($this); } - /** - * @phpstan-return ModifiableCssSupportsRule - */ - public function copyWithoutChildren(): ModifiableCssParentNode + public function equalsIgnoringChildren(ModifiableCssNode $other): bool + { + return $other instanceof ModifiableCssSupportsRule && EquatableUtil::equals($this->condition, $other->condition); + } + + public function copyWithoutChildren(): ModifiableCssSupportsRule { return new ModifiableCssSupportsRule($this->condition, $this->span); } diff --git a/modules/sass/scssphp/Ast/Css/ModifiableCssValue.php b/modules/sass/scssphp/Ast/Css/ModifiableCssValue.php deleted file mode 100644 index 1c4fcc4f..00000000 --- a/modules/sass/scssphp/Ast/Css/ModifiableCssValue.php +++ /dev/null @@ -1,32 +0,0 @@ - - * - * @internal - */ -final class ModifiableCssValue extends CssValue -{ - /** - * @param T $value - */ - public function setValue($value): void - { - $this->value = $value; - } -} diff --git a/modules/sass/scssphp/Ast/FakeAstNode.php b/modules/sass/scssphp/Ast/FakeAstNode.php index 8a205597..2cce1129 100644 --- a/modules/sass/scssphp/Ast/FakeAstNode.php +++ b/modules/sass/scssphp/Ast/FakeAstNode.php @@ -22,17 +22,16 @@ final class FakeAstNode implements AstNode { /** - * @var callable(): FileSpan - * @readonly + * @var \Closure(): FileSpan */ - private $callback; + private readonly \Closure $callback; /** * @param callable(): FileSpan $callback */ public function __construct(callable $callback) { - $this->callback = $callback; + $this->callback = $callback(...); } public function getSpan(): FileSpan diff --git a/modules/sass/scssphp/Ast/Sass/Argument.php b/modules/sass/scssphp/Ast/Sass/Argument.php index a0c5ecc7..5ce720b4 100644 --- a/modules/sass/scssphp/Ast/Sass/Argument.php +++ b/modules/sass/scssphp/Ast/Sass/Argument.php @@ -23,23 +23,11 @@ */ final class Argument implements SassNode, SassDeclaration { - /** - * @var string - * @readonly - */ - private $name; + private readonly string $name; - /** - * @var Expression|null - * @readonly - */ - private $defaultValue; + private readonly ?Expression $defaultValue; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(string $name, FileSpan $span, ?Expression $defaultValue = null) { diff --git a/modules/sass/scssphp/Ast/Sass/ArgumentDeclaration.php b/modules/sass/scssphp/Ast/Sass/ArgumentDeclaration.php index 171f8b66..14941c4e 100644 --- a/modules/sass/scssphp/Ast/Sass/ArgumentDeclaration.php +++ b/modules/sass/scssphp/Ast/Sass/ArgumentDeclaration.php @@ -27,26 +27,15 @@ final class ArgumentDeclaration implements SassNode { /** * @var list - * @readonly */ - private $arguments; + private readonly array $arguments; - /** - * @var string|null - * @readonly - */ - private $restArgument; + private readonly ?string $restArgument; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param list $arguments - * @param FileSpan $span - * @param string|null $restArgument */ public function __construct(array $arguments, FileSpan $span, ?string $restArgument = null) { @@ -97,7 +86,6 @@ public function getSpan(): FileSpan } /** - * @param int $positional * @param array $names Only keys are relevant * * @throws SassScriptException if $positional and $names aren't valid for this argument declaration. @@ -137,9 +125,7 @@ public function verify(int $positional, array $names): void } if ($nameUsed < \count($names)) { - $unknownNames = array_values(array_diff(array_keys($names), array_map(function ($argument) { - return $argument->getName(); - }, $this->arguments))); + $unknownNames = array_values(array_diff(array_keys($names), array_map(fn($argument) => $argument->getName(), $this->arguments))); $lastName = array_pop($unknownNames); $message = sprintf( 'No argument%s named $%s%s.', @@ -177,10 +163,7 @@ private function originalArgumentName(string $name): string * Returns whether $positional and $names are valid for this argument * declaration. * - * @param int $positional * @param array $names Only keys are relevant - * - * @return bool */ public function matches(int $positional, array $names): bool { diff --git a/modules/sass/scssphp/Ast/Sass/ArgumentInvocation.php b/modules/sass/scssphp/Ast/Sass/ArgumentInvocation.php index 8cad7152..65cb2c0b 100644 --- a/modules/sass/scssphp/Ast/Sass/ArgumentInvocation.php +++ b/modules/sass/scssphp/Ast/Sass/ArgumentInvocation.php @@ -12,7 +12,9 @@ namespace Tangible\ScssPhp\Ast\Sass; +use Tangible\ScssPhp\Ast\Sass\Expression\ListExpression; use Tangible\ScssPhp\SourceSpan\FileSpan; +use Tangible\ScssPhp\Value\ListSeparator; /** * A set of arguments passed in to a function or mixin. @@ -23,39 +25,23 @@ final class ArgumentInvocation implements SassNode { /** * @var list - * @readonly */ - private $positional; + private readonly array $positional; /** * @var array - * @readonly */ - private $named; + private readonly array $named; - /** - * @var Expression|null - * @readonly - */ - private $rest; + private readonly ?Expression $rest; - /** - * @var Expression|null - */ - private $keywordRest; + private readonly ?Expression $keywordRest; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param list $positional * @param array $named - * @param FileSpan $span - * @param Expression|null $rest - * @param Expression|null $keywordRest */ public function __construct(array $positional, array $named, FileSpan $span, ?Expression $rest = null, ?Expression $keywordRest = null) { @@ -111,17 +97,29 @@ public function getSpan(): FileSpan public function __toString(): string { - $parts = $this->positional; + $parts = []; + foreach ($this->positional as $argument) { + $parts[] = $this->parenthesizeArgument($argument); + } foreach ($this->named as $name => $arg) { - $parts[] = "\$$name: $arg"; + $parts[] = "\$$name: {$this->parenthesizeArgument($arg)}"; } if ($this->rest !== null) { - $parts[] = "$this->rest..."; + $parts[] = "{$this->parenthesizeArgument($this->rest)}..."; } if ($this->keywordRest !== null) { - $parts[] = "$this->keywordRest..."; + $parts[] = "{$this->parenthesizeArgument($this->keywordRest)}..."; } return '(' . implode(', ', $parts) . ')'; } + + private function parenthesizeArgument(Expression $argument): string + { + if ($argument instanceof ListExpression && $argument->getSeparator() === ListSeparator::COMMA && !$argument->hasBrackets() && \count($argument->getContents()) > 1) { + return "($argument)"; + } + + return (string) $argument; + } } diff --git a/modules/sass/scssphp/Ast/Sass/AtRootQuery.php b/modules/sass/scssphp/Ast/Sass/AtRootQuery.php index 48131a56..d576e648 100644 --- a/modules/sass/scssphp/Ast/Sass/AtRootQuery.php +++ b/modules/sass/scssphp/Ast/Sass/AtRootQuery.php @@ -20,6 +20,7 @@ use Tangible\ScssPhp\Exception\SassFormatException; use Tangible\ScssPhp\Logger\LoggerInterface; use Tangible\ScssPhp\Parser\AtRootQueryParser; +use Tangible\ScssPhp\Parser\InterpolationMap; /** * A query for the `@at-root` rule. @@ -30,11 +31,8 @@ final class AtRootQuery { /** * Whether the query includes or excludes rules with the specified names. - * - * @var bool - * @readonly */ - private $include; + private readonly bool $include; /** * The names of the rules included or excluded by this query. @@ -43,25 +41,18 @@ final class AtRootQuery * or excluded, and "rule" indicates style rules are included or excluded. * * @var string[] - * @readonly */ - private $names; + private readonly array $names; /** * Whether this includes or excludes *all* rules. - * - * @var bool - * @readonly */ - private $all; + private readonly bool $all; /** * Whether this includes or excludes style rules. - * - * @var bool - * @readonly */ - private $rule; + private readonly bool $rule; /** * Parses an at-root query from $contents. @@ -70,14 +61,13 @@ final class AtRootQuery * * @throws SassFormatException if parsing fails */ - public static function parse(string $contents, ?LoggerInterface $logger = null, ?string $url = null): AtRootQuery + public static function parse(string $contents, ?LoggerInterface $logger = null, ?string $url = null, ?InterpolationMap $interpolationMap = null): AtRootQuery { - return (new AtRootQueryParser($contents, $logger, $url))->parse(); + return (new AtRootQueryParser($contents, $logger, $url, $interpolationMap))->parse(); } /** * @param string[] $names - * @param bool $include */ public static function create(array $names, bool $include): AtRootQuery { @@ -94,9 +84,6 @@ public static function getDefault(): AtRootQuery /** * @param string[] $names - * @param bool $include - * @param bool $all - * @param bool $rule */ private function __construct(array $names, bool $include, bool $all, bool $rule) { diff --git a/modules/sass/scssphp/Ast/Sass/ConfiguredVariable.php b/modules/sass/scssphp/Ast/Sass/ConfiguredVariable.php index 3b43880d..30dbdbfd 100644 --- a/modules/sass/scssphp/Ast/Sass/ConfiguredVariable.php +++ b/modules/sass/scssphp/Ast/Sass/ConfiguredVariable.php @@ -22,29 +22,13 @@ */ final class ConfiguredVariable implements SassNode, SassDeclaration { - /** - * @var string - * @readonly - */ - private $name; + private readonly string $name; - /** - * @var Expression - * @readonly - */ - private $expression; + private readonly Expression $expression; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; - /** - * @var bool - * @readonly - */ - private $guarded; + private readonly bool $guarded; public function __construct(string $name, Expression $expression, FileSpan $span, bool $guarded = false) { diff --git a/modules/sass/scssphp/Ast/Sass/Expression/BinaryOperationExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/BinaryOperationExpression.php index 8fa7969f..ea06faf5 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/BinaryOperationExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/BinaryOperationExpression.php @@ -14,6 +14,7 @@ use Tangible\ScssPhp\Ast\Sass\Expression; use Tangible\ScssPhp\SourceSpan\FileSpan; +use Tangible\ScssPhp\Util\SpanUtil; use Tangible\ScssPhp\Visitor\ExpressionVisitor; /** @@ -23,35 +24,18 @@ */ final class BinaryOperationExpression implements Expression { - /** - * @var BinaryOperator::* - * @readonly - */ - private $operator; + private readonly BinaryOperator $operator; - /** - * @var Expression - * @readonly - */ - private $left; + private readonly Expression $left; - /** - * @var Expression - * @readonly - */ - private $right; + private readonly Expression $right; /** * Whether this is a dividedBy operation that may be interpreted as slash-separated numbers. - * - * @var bool */ - private $allowsSlash = false; + private bool $allowsSlash = false; - /** - * @param BinaryOperator::* $operator - */ - public function __construct(string $operator, Expression $left, Expression $right) + public function __construct(BinaryOperator $operator, Expression $left, Expression $right) { $this->operator = $operator; $this->left = $left; @@ -69,10 +53,7 @@ public static function slash(Expression $left, Expression $right): self return $operation; } - /** - * @return BinaryOperator::* - */ - public function getOperator(): string + public function getOperator(): BinaryOperator { return $this->operator; } @@ -112,6 +93,23 @@ public function getSpan(): FileSpan return $leftSpan->expand($rightSpan); } + /** + * Returns the span that covers only {@see $operator}. + * + * @internal + */ + public function getOperatorSpan(): FileSpan + { + $leftSpan = $this->left->getSpan(); + $rightSpan = $this->right->getSpan(); + + if ($leftSpan->getFile() === $rightSpan->getFile() && $leftSpan->getEnd()->getOffset() < $rightSpan->getStart()->getOffset()) { + return SpanUtil::trim($leftSpan->getFile()->span($leftSpan->getEnd()->getOffset(), $rightSpan->getStart()->getOffset())); + } + + return $this->getSpan(); + } + public function accept(ExpressionVisitor $visitor) { return $visitor->visitBinaryOperationExpression($this); @@ -121,7 +119,7 @@ public function __toString(): string { $buffer = ''; - $leftNeedsParens = $this->left instanceof BinaryOperationExpression && BinaryOperator::getPrecedence($this->left->getOperator()) < BinaryOperator::getPrecedence($this->operator); + $leftNeedsParens = ($this->left instanceof BinaryOperationExpression && $this->left->getOperator()->getPrecedence() < $this->operator->getPrecedence()) || ($this->left instanceof ListExpression && !$this->left->hasBrackets() && \count($this->left->getContents()) > 1); if ($leftNeedsParens) { $buffer .= '('; } @@ -131,10 +129,10 @@ public function __toString(): string } $buffer .= ' '; - $buffer .= $this->operator; + $buffer .= $this->operator->getOperator(); $buffer .= ' '; - $rightNeedsParens = $this->right instanceof BinaryOperationExpression && BinaryOperator::getPrecedence($this->right->getOperator()) <= BinaryOperator::getPrecedence($this->operator); + $rightNeedsParens = ($this->right instanceof BinaryOperationExpression && $this->right->getOperator()->getPrecedence() <= $this->operator->getPrecedence() && !($this->right->operator === $this->operator && $this->operator->isAssociative())) || ($this->right instanceof ListExpression && !$this->right->hasBrackets() && \count($this->right->getContents()) > 1); if ($rightNeedsParens) { $buffer .= '('; } diff --git a/modules/sass/scssphp/Ast/Sass/Expression/BinaryOperator.php b/modules/sass/scssphp/Ast/Sass/Expression/BinaryOperator.php index a7eeaa93..3812bdc0 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/BinaryOperator.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/BinaryOperator.php @@ -15,58 +15,69 @@ /** * @internal */ -final class BinaryOperator +enum BinaryOperator { - const SINGLE_EQUALS = '='; - const OR = 'or'; - const AND = 'and'; - const EQUALS = '=='; - const NOT_EQUALS = '!='; - const GREATER_THAN = '>'; - const GREATER_THAN_OR_EQUALS = '>='; - const LESS_THAN = '<'; - const LESS_THAN_OR_EQUALS = '<='; - const PLUS = '+'; - const MINUS = '-'; - const TIMES = '*'; - const DIVIDED_BY = '/'; - const MODULO = '%'; + case SINGLE_EQUALS; + case OR; + case AND; + case EQUALS; + case NOT_EQUALS; + case GREATER_THAN; + case GREATER_THAN_OR_EQUALS; + case LESS_THAN; + case LESS_THAN_OR_EQUALS; + case PLUS; + case MINUS; + case TIMES; + case DIVIDED_BY; + case MODULO; /** - * @param BinaryOperator::* $operator + * The Sass syntax for this operator */ - public static function getPrecedence(string $operator): int + public function getOperator(): string { - switch ($operator) { - case self::SINGLE_EQUALS: - return 0; - - case self::OR: - return 1; - - case self::AND: - return 2; - - case self::EQUALS: - case self::NOT_EQUALS: - return 3; - - case self::GREATER_THAN: - case self::GREATER_THAN_OR_EQUALS: - case self::LESS_THAN: - case self::LESS_THAN_OR_EQUALS: - return 4; - - case self::PLUS: - case self::MINUS: - return 5; + return match ($this) { + self::SINGLE_EQUALS => '=', + self::OR => 'or', + self::AND => 'and', + self::EQUALS => '==', + self::NOT_EQUALS => '!=', + self::GREATER_THAN => '>', + self::GREATER_THAN_OR_EQUALS => '>=', + self::LESS_THAN => '<', + self::LESS_THAN_OR_EQUALS => '<=', + self::PLUS => '+', + self::MINUS => '-', + self::TIMES => '*', + self::DIVIDED_BY => '/', + self::MODULO => '%', + }; + } - case self::TIMES: - case self::DIVIDED_BY: - case self::MODULO: - return 6; - } + public function getPrecedence(): int + { + return match ($this) { + self::SINGLE_EQUALS => 0, + self::OR => 1, + self::AND => 2, + self::EQUALS, self::NOT_EQUALS => 3, + self::GREATER_THAN, self::GREATER_THAN_OR_EQUALS, self::LESS_THAN, self::LESS_THAN_OR_EQUALS => 4, + self::PLUS, self::MINUS => 5, + self::TIMES, self::DIVIDED_BY, self::MODULO => 6, + }; + } - throw new \InvalidArgumentException(sprintf('Unknown operator "%s".', $operator)); + /** + * Whether this operation has the [associative property]. + * + * [associative property]: https://en.wikipedia.org/wiki/Associative_property + */ + public function isAssociative(): bool + { + return match ($this) { + self::OR, self::AND, self::PLUS, self::TIMES => true, + default => false, + }; } } diff --git a/modules/sass/scssphp/Ast/Sass/Expression/BooleanExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/BooleanExpression.php index ffc5d0d1..ca0ffff5 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/BooleanExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/BooleanExpression.php @@ -23,17 +23,9 @@ */ final class BooleanExpression implements Expression { - /** - * @var bool - * @readonly - */ - private $value; - - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly bool $value; + + private readonly FileSpan $span; public function __construct(bool $value, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Expression/CalculationExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/CalculationExpression.php deleted file mode 100644 index 2fdccdce..00000000 --- a/modules/sass/scssphp/Ast/Sass/Expression/CalculationExpression.php +++ /dev/null @@ -1,235 +0,0 @@ - - * @readonly - */ - private $arguments; - - /** - * @var FileSpan - * @readonly - */ - private $span; - - /** - * Returns a `calc()` calculation expression. - * - * @param Expression $argument - * @param FileSpan $span - * - * @return CalculationExpression - */ - public static function calc(Expression $argument, FileSpan $span): CalculationExpression - { - return new CalculationExpression('calc', [$argument], $span); - } - - /** - * Returns a `min()` calculation expression. - * - * @param list $arguments - * @param FileSpan $span - * - * @return CalculationExpression - */ - public static function min(array $arguments, FileSpan $span): CalculationExpression - { - if (!$arguments) { - throw new \InvalidArgumentException('min() requires at least one argument.'); - } - - return new CalculationExpression('min', $arguments, $span); - } - - /** - * Returns a `max()` calculation expression. - * - * @param list $arguments - * @param FileSpan $span - * - * @return CalculationExpression - */ - public static function max(array $arguments, FileSpan $span): CalculationExpression - { - if (!$arguments) { - throw new \InvalidArgumentException('max() requires at least one argument.'); - } - - return new CalculationExpression('max', $arguments, $span); - } - - /** - * Returns a `clamp()` calculation expression. - * - * @param Expression $min - * @param Expression $value - * @param Expression $max - * @param FileSpan $span - * - * @return CalculationExpression - */ - public static function clamp(Expression $min, Expression $value, Expression $max, FileSpan $span): CalculationExpression - { - return new CalculationExpression('clamp', [$min, $value, $max], $span); - } - - /** - * Returns a calculation expression with the given name and arguments. - * - * Unlike the other constructors, this doesn't verify that the arguments are - * valid for the name. - * - * @param string $name - * @param list $arguments - * @param FileSpan $span - */ - public function __construct(string $name, array $arguments, FileSpan $span) - { - self::verifyArguments($arguments); - $this->name = $name; - $this->arguments = $arguments; - $this->span = $span; - } - - /** - * @return string - */ - public function getName(): string - { - return $this->name; - } - - /** - * @return list - */ - public function getArguments(): array - { - return $this->arguments; - } - - public function getSpan(): FileSpan - { - return $this->span; - } - - public function accept(ExpressionVisitor $visitor) - { - return $visitor->visitCalculationExpression($this); - } - - /** - * @param list $arguments - * - * @throws \InvalidArgumentException if $arguments aren't valid calculation arguments. - */ - private static function verifyArguments(array $arguments): void - { - foreach ($arguments as $argument) { - self::verify($argument); - } - } - - /** - * @throws \InvalidArgumentException if $expression isn't a valid calculation argument. - */ - private static function verify(Expression $expression): void - { - if ($expression instanceof NumberExpression) { - return; - } - - if ($expression instanceof CalculationExpression) { - return; - } - - if ($expression instanceof VariableExpression) { - return; - } - - if ($expression instanceof FunctionExpression) { - return; - } - - if ($expression instanceof IfExpression) { - return; - } - - if ($expression instanceof StringExpression) { - if ($expression->hasQuotes()) { - throw new \InvalidArgumentException('Invalid calculation argument.'); - } - - return; - } - - if ($expression instanceof ParenthesizedExpression) { - self::verify($expression->getExpression()); - - return; - } - - if ($expression instanceof BinaryOperationExpression) { - self::verify($expression->getLeft()); - self::verify($expression->getRight()); - - if ($expression->getOperator() === BinaryOperator::PLUS) { - return; - } - - if ($expression->getOperator() === BinaryOperator::MINUS) { - return; - } - - if ($expression->getOperator() === BinaryOperator::TIMES) { - return; - } - - if ($expression->getOperator() === BinaryOperator::DIVIDED_BY) { - return; - } - - throw new \InvalidArgumentException('Invalid calculation argument.'); - } - - throw new \InvalidArgumentException('Invalid calculation argument.'); - } - - public function __toString(): string - { - return $this->name . '(' . implode(', ', $this->arguments) . ')'; - } -} diff --git a/modules/sass/scssphp/Ast/Sass/Expression/ColorExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/ColorExpression.php index 2ff6bf43..4d070575 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/ColorExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/ColorExpression.php @@ -24,17 +24,9 @@ */ final class ColorExpression implements Expression { - /** - * @var SassColor - * @readonly - */ - private $value; + private readonly SassColor $value; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(SassColor $value, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Expression/FunctionExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/FunctionExpression.php index 515bc80f..9678488b 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/FunctionExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/FunctionExpression.php @@ -32,34 +32,21 @@ final class FunctionExpression implements Expression, CallableInvocation, SassRe { /** * The name of the function being invoked, with underscores left as-is. - * - * @var string - * @readonly */ - private $originalName; + private readonly string $originalName; /** * The arguments to pass to the function. - * - * @var ArgumentInvocation - * @readonly */ - private $arguments; + private readonly ArgumentInvocation $arguments; /** * The namespace of the function being invoked, or `null` if it's invoked * without a namespace. - * - * @var string|null - * @readonly */ - private $namespace; + private readonly ?string $namespace; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(string $originalName, ArgumentInvocation $arguments, FileSpan $span, ?string $namespace = null) { @@ -69,9 +56,6 @@ public function __construct(string $originalName, ArgumentInvocation $arguments, $this->namespace = $namespace; } - /** - * @return string - */ public function getOriginalName(): string { return $this->originalName; diff --git a/modules/sass/scssphp/Ast/Sass/Expression/IfExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/IfExpression.php index 0362bf4d..207e8222 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/IfExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/IfExpression.php @@ -32,22 +32,12 @@ final class IfExpression implements Expression, CallableInvocation { /** * The arguments passed to `if()`. - * - * @var ArgumentInvocation - * @readonly */ - private $arguments; + private readonly ArgumentInvocation $arguments; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; - /** - * @var ArgumentDeclaration|null - */ - private static $declaration; + private static ?ArgumentDeclaration $declaration; public function __construct(ArgumentInvocation $arguments, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Expression/InterpolatedFunctionExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/InterpolatedFunctionExpression.php index 5e015306..c198c5a7 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/InterpolatedFunctionExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/InterpolatedFunctionExpression.php @@ -30,25 +30,15 @@ final class InterpolatedFunctionExpression implements Expression, CallableInvoca { /** * The name of the function being invoked. - * - * @var Interpolation - * @readonly */ - private $name; + private readonly Interpolation $name; /** * The arguments to pass to the function. - * - * @var ArgumentInvocation - * @readonly */ - private $arguments; + private readonly ArgumentInvocation $arguments; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(Interpolation $name, ArgumentInvocation $arguments, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Expression/IsCalculationSafeVisitor.php b/modules/sass/scssphp/Ast/Sass/Expression/IsCalculationSafeVisitor.php new file mode 100644 index 00000000..f45e0c59 --- /dev/null +++ b/modules/sass/scssphp/Ast/Sass/Expression/IsCalculationSafeVisitor.php @@ -0,0 +1,129 @@ + + * + * @internal + */ +final class IsCalculationSafeVisitor implements ExpressionVisitor +{ + public function visitBinaryOperationExpression(BinaryOperationExpression $node): bool + { + return \in_array($node->getOperator(), [BinaryOperator::TIMES, BinaryOperator::DIVIDED_BY, BinaryOperator::PLUS, BinaryOperator::MINUS], true) && ($node->getLeft()->accept($this) || $node->getRight()->accept($this)); + } + + public function visitBooleanExpression(BooleanExpression $node): bool + { + return false; + } + + public function visitColorExpression(ColorExpression $node): bool + { + return false; + } + + public function visitFunctionExpression(FunctionExpression $node): bool + { + return true; + } + + public function visitInterpolatedFunctionExpression(InterpolatedFunctionExpression $node): bool + { + return true; + } + + public function visitIfExpression(IfExpression $node): bool + { + return true; + } + + public function visitListExpression(ListExpression $node): bool + { + return $node->getSeparator() === ListSeparator::SPACE && !$node->hasBrackets() && \count($node->getContents()) > 1 && IterableUtil::every($node->getContents(), fn(Expression $expression) => $expression->accept($this)); + } + + public function visitMapExpression(MapExpression $node): bool + { + return false; + } + + public function visitNullExpression(NullExpression $node): bool + { + return false; + } + + public function visitNumberExpression(NumberExpression $node): bool + { + return true; + } + + public function visitParenthesizedExpression(ParenthesizedExpression $node): bool + { + return $node->getExpression()->accept($this); + } + + public function visitSelectorExpression(SelectorExpression $node): bool + { + return false; + } + + public function visitStringExpression(StringExpression $node): bool + { + if ($node->hasQuotes()) { + return false; + } + + /** + * Exclude non-identifier constructs that are parsed as {@see StringExpression}s. + * We could just check if they parse as valid identifiers, but this is + * cheaper. + */ + $text = $node->getText()->getInitialPlain(); + + // !important + return !str_starts_with($text, '!') + // ID-style identifiers + && !str_starts_with($text, '#') + // Unicode ranges + && ($text[1] ?? null) !== '+' + // url() + && ($text[3] ?? null) !== '('; + } + + public function visitSupportsExpression(SupportsExpression $node): bool + { + return false; + } + + public function visitUnaryOperationExpression(UnaryOperationExpression $node): bool + { + return false; + } + + public function visitValueExpression(ValueExpression $node): bool + { + return false; + } + + public function visitVariableExpression(VariableExpression $node): bool + { + return true; + } +} diff --git a/modules/sass/scssphp/Ast/Sass/Expression/ListExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/ListExpression.php index 33e9345d..8bd30af0 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/ListExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/ListExpression.php @@ -25,36 +25,22 @@ final class ListExpression implements Expression { /** - * @var Expression[] - * @readonly + * @var list */ - private $contents; + private readonly array $contents; - /** - * @var ListSeparator::* - * @readonly - */ - private $separator; + private readonly ListSeparator $separator; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; - /** - * @var bool - * @readonly - */ - private $brackets; + private readonly bool $brackets; /** * ListExpression constructor. * - * @param Expression[] $contents - * @param ListSeparator::* $separator + * @param list $contents */ - public function __construct(array $contents, string $separator, FileSpan $span, bool $brackets = false) + public function __construct(array $contents, ListSeparator $separator, FileSpan $span, bool $brackets = false) { $this->contents = $contents; $this->separator = $separator; @@ -63,17 +49,14 @@ public function __construct(array $contents, string $separator, FileSpan $span, } /** - * @return Expression[] + * @return list */ public function getContents(): array { return $this->contents; } - /** - * @return ListSeparator::* - */ - public function getSeparator(): string + public function getSeparator(): ListSeparator { return $this->separator; } @@ -98,14 +81,21 @@ public function __toString(): string $buffer = ''; if ($this->hasBrackets()) { $buffer .= '['; + } elseif (\count($this->contents) === 0 || (\count($this->contents) === 1 && $this->separator === ListSeparator::COMMA)) { + $buffer .= '('; } - $buffer .= implode($this->separator === ListSeparator::COMMA ? ', ' : ' ', array_map(function ($element) { - return $this->elementNeedsParens($element) ? "($element)" : (string) $element; - }, $this->contents)); + $buffer .= implode( + $this->separator === ListSeparator::COMMA ? ', ' : ' ', + array_map(fn($element) => $this->elementNeedsParens($element) ? "($element)" : (string) $element, $this->contents) + ); if ($this->hasBrackets()) { $buffer .= ']'; + } elseif (\count($this->contents) === 0) { + $buffer .= ')'; + } elseif (\count($this->contents) === 1 && $this->separator === ListSeparator::COMMA) { + $buffer .= ',)'; } return $buffer; diff --git a/modules/sass/scssphp/Ast/Sass/Expression/MapExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/MapExpression.php index 00fd21b5..61932e6a 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/MapExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/MapExpression.php @@ -25,15 +25,10 @@ final class MapExpression implements Expression { /** * @var list - * @readonly */ - private $pairs; + private readonly array $pairs; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param list $pairs @@ -64,8 +59,6 @@ public function accept(ExpressionVisitor $visitor) public function __toString(): string { - return '(' . implode(', ', array_map(function ($pair) { - return $pair[0] . ': ' . $pair[1]; - }, $this->pairs)) . ')'; + return '(' . implode(', ', array_map(fn($pair) => $pair[0] . ': ' . $pair[1], $this->pairs)) . ')'; } } diff --git a/modules/sass/scssphp/Ast/Sass/Expression/NullExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/NullExpression.php index f16d2fdd..cb699b47 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/NullExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/NullExpression.php @@ -23,11 +23,7 @@ */ final class NullExpression implements Expression { - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Expression/NumberExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/NumberExpression.php index ef2fe55c..ffc3bac0 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/NumberExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/NumberExpression.php @@ -24,23 +24,11 @@ */ final class NumberExpression implements Expression { - /** - * @var float - * @readonly - */ - private $value; + private readonly float $value; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; - /** - * @var string|null - * @readonly - */ - private $unit; + private readonly ?string $unit; public function __construct(float $value, FileSpan $span, ?string $unit = null) { diff --git a/modules/sass/scssphp/Ast/Sass/Expression/ParenthesizedExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/ParenthesizedExpression.php index a33cb26a..4a3acbc7 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/ParenthesizedExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/ParenthesizedExpression.php @@ -23,17 +23,9 @@ */ final class ParenthesizedExpression implements Expression { - /** - * @var Expression - * @readonly - */ - private $expression; - - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly Expression $expression; + + private readonly FileSpan $span; public function __construct(Expression $expression, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Expression/SelectorExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/SelectorExpression.php index cab126a6..6fe93228 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/SelectorExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/SelectorExpression.php @@ -23,11 +23,7 @@ */ final class SelectorExpression implements Expression { - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Expression/StringExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/StringExpression.php index 1741dcb3..226e9f86 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/StringExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/StringExpression.php @@ -26,17 +26,9 @@ */ final class StringExpression implements Expression { - /** - * @var Interpolation - * @readonly - */ - private $text; + private readonly Interpolation $text; - /** - * @var bool - * @readonly - */ - private $quotes; + private readonly bool $quotes; public function __construct(Interpolation $text, bool $quotes = false) { @@ -151,8 +143,6 @@ private static function quoteInnerText(string $value, string $quote, bool $stati /** * @param array $parts - * - * @return string */ private static function bestQuote(array $parts): string { @@ -163,16 +153,16 @@ private static function bestQuote(array $parts): string continue; } - if (false !== strpos($part, "'")) { + if (str_contains($part, "'")) { return '"'; } - if (false !== strpos($part, '"')) { + if (str_contains($part, '"')) { $containsDoubleQuote = true; } } - return $containsDoubleQuote ? "'": '"'; + return $containsDoubleQuote ? "'" : '"'; } public function __toString(): string diff --git a/modules/sass/scssphp/Ast/Sass/Expression/SupportsExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/SupportsExpression.php index 58abeaab..d06907d0 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/SupportsExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/SupportsExpression.php @@ -27,11 +27,7 @@ */ final class SupportsExpression implements Expression { - /** - * @var SupportsCondition - * @readonly - */ - private $condition; + private readonly SupportsCondition $condition; public function __construct(SupportsCondition $condition) { diff --git a/modules/sass/scssphp/Ast/Sass/Expression/UnaryOperationExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/UnaryOperationExpression.php index 6c917203..36e1aae3 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/UnaryOperationExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/UnaryOperationExpression.php @@ -23,38 +23,20 @@ */ final class UnaryOperationExpression implements Expression { - /** - * @var UnaryOperator::* - * @readonly - */ - private $operator; - - /** - * @var Expression - * @readonly - */ - private $operand; - - /** - * @var FileSpan - * @readonly - */ - private $span; - - /** - * @param UnaryOperator::* $operator - */ - public function __construct(string $operator, Expression $operand, FileSpan $span) + private readonly UnaryOperator $operator; + + private readonly Expression $operand; + + private readonly FileSpan $span; + + public function __construct(UnaryOperator $operator, Expression $operand, FileSpan $span) { $this->operator = $operator; $this->operand = $operand; $this->span = $span; } - /** - * @return UnaryOperator::* - */ - public function getOperator() + public function getOperator(): UnaryOperator { return $this->operator; } @@ -76,12 +58,25 @@ public function accept(ExpressionVisitor $visitor) public function __toString(): string { - $buffer = $this->operator; + $buffer = $this->operator->getOperator(); if ($this->operator === UnaryOperator::NOT) { $buffer .= ' '; } + + $needsParens = $this->operand instanceof BinaryOperationExpression + || $this->operand instanceof UnaryOperationExpression + || ($this->operand instanceof ListExpression && !$this->operand->hasBrackets() && \count($this->operand->getContents()) > 1); + + if ($needsParens) { + $buffer .= '('; + } + $buffer .= $this->operand; + if ($needsParens) { + $buffer .= ')'; + } + return $buffer; } } diff --git a/modules/sass/scssphp/Ast/Sass/Expression/UnaryOperator.php b/modules/sass/scssphp/Ast/Sass/Expression/UnaryOperator.php index 5534bef5..b1a26155 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/UnaryOperator.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/UnaryOperator.php @@ -15,10 +15,23 @@ /** * @internal */ -final class UnaryOperator +enum UnaryOperator { - const PLUS = '+'; - const MINUS = '-'; - const DIVIDE = '/'; - const NOT = 'not'; + case PLUS; + case MINUS; + case DIVIDE; + case NOT; + + /** + * The Sass syntax for this operator + */ + public function getOperator(): string + { + return match ($this) { + self::PLUS => '+', + self::MINUS => '-', + self::DIVIDE => '/', + self::NOT => 'not', + }; + } } diff --git a/modules/sass/scssphp/Ast/Sass/Expression/ValueExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/ValueExpression.php index c1fb5b67..4faa6627 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/ValueExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/ValueExpression.php @@ -27,17 +27,9 @@ */ final class ValueExpression implements Expression { - /** - * @var Value - * @readonly - */ - private $value; + private readonly Value $value; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(Value $value, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Expression/VariableExpression.php b/modules/sass/scssphp/Ast/Sass/Expression/VariableExpression.php index aafd0ea8..c217184f 100644 --- a/modules/sass/scssphp/Ast/Sass/Expression/VariableExpression.php +++ b/modules/sass/scssphp/Ast/Sass/Expression/VariableExpression.php @@ -27,26 +27,16 @@ final class VariableExpression implements Expression, SassReference { /** * The name of this variable, with underscores converted to hyphens. - * - * @var string - * @readonly */ - private $name; + private readonly string $name; /** * The namespace of the variable being referenced, or `null` if it's * referenced without a namespace. - * - * @var string|null - * @readonly */ - private $namespace; + private ?string $namespace; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(string $name, FileSpan $span, ?string $namespace = null) { diff --git a/modules/sass/scssphp/Ast/Sass/Import/DynamicImport.php b/modules/sass/scssphp/Ast/Sass/Import/DynamicImport.php index b6da2a15..3fceb26b 100644 --- a/modules/sass/scssphp/Ast/Sass/Import/DynamicImport.php +++ b/modules/sass/scssphp/Ast/Sass/Import/DynamicImport.php @@ -12,6 +12,8 @@ namespace Tangible\ScssPhp\Ast\Sass\Import; +use League\Uri\Contracts\UriInterface; +use League\Uri\Uri; use Tangible\ScssPhp\Ast\Sass\Expression\StringExpression; use Tangible\ScssPhp\Ast\Sass\Import; use Tangible\ScssPhp\SourceSpan\FileSpan; @@ -27,17 +29,10 @@ final class DynamicImport implements Import * The URI of the file to import. * * If this is relative, it's relative to the containing file. - * - * @var string - * @readonly */ - private $urlString; + private readonly string $urlString; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(string $urlString, FileSpan $span) { @@ -45,6 +40,11 @@ public function __construct(string $urlString, FileSpan $span) $this->span = $span; } + public function getUrl(): UriInterface + { + return Uri::new($this->urlString); + } + public function getUrlString(): string { return $this->urlString; diff --git a/modules/sass/scssphp/Ast/Sass/Import/StaticImport.php b/modules/sass/scssphp/Ast/Sass/Import/StaticImport.php index c5bfa281..7eab9a6d 100644 --- a/modules/sass/scssphp/Ast/Sass/Import/StaticImport.php +++ b/modules/sass/scssphp/Ast/Sass/Import/StaticImport.php @@ -27,26 +27,16 @@ final class StaticImport implements Import * The URL for this import. * * This already contains quotes. - * - * @var Interpolation - * @readonly */ - private $url; + private readonly Interpolation $url; /** * The modifiers (such as media or supports queries) attached to this import, * or `null` if none are attached. - * - * @var Interpolation|null - * @readonly */ - private $modifiers; + private readonly ?Interpolation $modifiers; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(Interpolation $url, FileSpan $span, ?Interpolation $modifiers = null) { diff --git a/modules/sass/scssphp/Ast/Sass/Interpolation.php b/modules/sass/scssphp/Ast/Sass/Interpolation.php index b0228894..7e6b0ca3 100644 --- a/modules/sass/scssphp/Ast/Sass/Interpolation.php +++ b/modules/sass/scssphp/Ast/Sass/Interpolation.php @@ -24,15 +24,10 @@ final class Interpolation implements SassNode { /** * @var list - * @readonly */ - private $contents; + private readonly array $contents; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * Creates a new {@see Interpolation} by concatenating a sequence of strings, @@ -52,7 +47,7 @@ public static function concat(array $contents, FileSpan $span): Interpolation } elseif ($element instanceof Interpolation) { $buffer->addInterpolation($element); } else { - throw new \InvalidArgumentException(sprintf('The elements in $contents may only contains strings, Expressions, or Interpolations, "%s" given.', \is_object($element) ? get_class($element) : gettype($element))); + throw new \InvalidArgumentException(sprintf('The elements in $contents may only contains strings, Expressions, or Interpolations, "%s" given.', get_debug_type($element))); } } @@ -91,6 +86,14 @@ public function getSpan(): FileSpan return $this->span; } + /** + * Returns whether this contains no interpolated expressions. + */ + public function isPlain(): bool + { + return $this->getAsPlain() !== null; + } + /** * If this contains no interpolated expressions, returns its text contents. * @@ -131,8 +134,6 @@ public function getInitialPlain(): string public function __toString(): string { - return implode('', array_map(function ($value) { - return \is_string($value) ? $value : '#{' . $value .'}'; - }, $this->contents)); + return implode('', array_map(fn($value) => \is_string($value) ? $value : '#{' . $value . '}', $this->contents)); } } diff --git a/modules/sass/scssphp/Ast/Sass/Statement/AtRootRule.php b/modules/sass/scssphp/Ast/Sass/Statement/AtRootRule.php index 8cb4c8b3..5086f763 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/AtRootRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/AtRootRule.php @@ -28,17 +28,9 @@ */ final class AtRootRule extends ParentStatement { - /** - * @var Interpolation|null - * @readonly - */ - private $query; + private readonly ?Interpolation $query; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param Statement[] $children diff --git a/modules/sass/scssphp/Ast/Sass/Statement/AtRule.php b/modules/sass/scssphp/Ast/Sass/Statement/AtRule.php index 97069a8e..da392f77 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/AtRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/AtRule.php @@ -26,23 +26,11 @@ */ final class AtRule extends ParentStatement { - /** - * @var Interpolation - * @readonly - */ - private $name; + private readonly Interpolation $name; - /** - * @var Interpolation|null - * @readonly - */ - private $value; + private readonly ?Interpolation $value; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param Statement[]|null $children diff --git a/modules/sass/scssphp/Ast/Sass/Statement/CallableDeclaration.php b/modules/sass/scssphp/Ast/Sass/Statement/CallableDeclaration.php index 47c2abd9..de906acb 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/CallableDeclaration.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/CallableDeclaration.php @@ -26,29 +26,13 @@ */ abstract class CallableDeclaration extends ParentStatement { - /** - * @var string - * @readonly - */ - private $name; + private readonly string $name; - /** - * @var ArgumentDeclaration - * @readonly - */ - private $arguments; + private readonly ArgumentDeclaration $arguments; - /** - * @var SilentComment|null - * @readonly - */ - private $comment; + private readonly ?SilentComment $comment; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param Statement[] $children @@ -75,9 +59,6 @@ final public function getArguments(): ArgumentDeclaration return $this->arguments; } - /** - * @return SilentComment|null - */ final public function getComment(): ?SilentComment { return $this->comment; diff --git a/modules/sass/scssphp/Ast/Sass/Statement/ContentRule.php b/modules/sass/scssphp/Ast/Sass/Statement/ContentRule.php index 06aa422f..5d1ad56a 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/ContentRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/ContentRule.php @@ -31,17 +31,10 @@ final class ContentRule implements Statement * The arguments pass to this `@content` rule. * * This will be an empty invocation if `@content` has no arguments. - * - * @var ArgumentInvocation - * @readonly */ - private $arguments; + private readonly ArgumentInvocation $arguments; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(ArgumentInvocation $arguments, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Statement/DebugRule.php b/modules/sass/scssphp/Ast/Sass/Statement/DebugRule.php index 504bd024..c05e100a 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/DebugRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/DebugRule.php @@ -26,17 +26,9 @@ */ final class DebugRule implements Statement { - /** - * @var Expression - * @readonly - */ - private $expression; + private readonly Expression $expression; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(Expression $expression, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Statement/Declaration.php b/modules/sass/scssphp/Ast/Sass/Statement/Declaration.php index c681942a..59963b5a 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/Declaration.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/Declaration.php @@ -28,28 +28,17 @@ */ final class Declaration extends ParentStatement { - /** - * @var Interpolation - * @readonly - */ - private $name; + private readonly Interpolation $name; /** * The value of this declaration. * * If {@see getChildren} is `null`, this is never `null`. Otherwise, it may or may * not be `null`. - * - * @var Expression|null - * @readonly */ - private $value; + private readonly ?Expression $value; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param Statement[]|null $children @@ -64,13 +53,7 @@ private function __construct(Interpolation $name, ?Expression $value, FileSpan $ public static function create(Interpolation $name, Expression $value, FileSpan $span): self { - $declaration = new self($name, $value, $span); - - if ($declaration->isCustomProperty() && !$value instanceof StringExpression) { - throw new \InvalidArgumentException(sprintf('Declarations whose names begin with "--" must have StringExpression values (got %s)', get_class($value))); - } - - return $declaration; + return new self($name, $value, $span); } /** @@ -78,13 +61,7 @@ public static function create(Interpolation $name, Expression $value, FileSpan $ */ public static function nested(Interpolation $name, array $children, FileSpan $span, ?Expression $value = null): self { - $declaration = new self($name, $value, $span, $children); - - if ($declaration->isCustomProperty() && !$value instanceof StringExpression) { - throw new \InvalidArgumentException('Declarations whose names begin with "--" may not be nested.'); - } - - return $declaration; + return new self($name, $value, $span, $children); } public function getName(): Interpolation @@ -108,7 +85,7 @@ public function getValue(): ?Expression */ public function isCustomProperty(): bool { - return 0 === strpos($this->name->getInitialPlain(), '--'); + return str_starts_with($this->name->getInitialPlain(), '--'); } public function getSpan(): FileSpan diff --git a/modules/sass/scssphp/Ast/Sass/Statement/EachRule.php b/modules/sass/scssphp/Ast/Sass/Statement/EachRule.php index 731d0805..1cafcadd 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/EachRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/EachRule.php @@ -29,25 +29,16 @@ final class EachRule extends ParentStatement { /** - * @var string[] - * @readonly + * @var list */ - private $variables; + private readonly array $variables; - /** - * @var Expression - * @readonly - */ - private $list; + private readonly Expression $list; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** - * @param string[] $variables + * @param list $variables * @param Statement[] $children */ public function __construct(array $variables, Expression $list, array $children, FileSpan $span) @@ -59,7 +50,7 @@ public function __construct(array $variables, Expression $list, array $children, } /** - * @return string[] + * @return list */ public function getVariables(): array { @@ -83,6 +74,6 @@ public function accept(StatementVisitor $visitor) public function __toString(): string { - return '@each ' . implode(', ', array_map(function ($variable) { return '$' . $variable; }, $this->variables)) . ' in ' . $this->list . ' {' . implode(' ', $this->getChildren()) . '}'; + return '@each ' . implode(', ', array_map(fn($variable) => '$' . $variable, $this->variables)) . ' in ' . $this->list . ' {' . implode(' ', $this->getChildren()) . '}'; } } diff --git a/modules/sass/scssphp/Ast/Sass/Statement/ErrorRule.php b/modules/sass/scssphp/Ast/Sass/Statement/ErrorRule.php index 453eac74..86abd31d 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/ErrorRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/ErrorRule.php @@ -26,17 +26,9 @@ */ final class ErrorRule implements Statement { - /** - * @var Expression - * @readonly - */ - private $expression; + private readonly Expression $expression; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(Expression $expression, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Statement/ExtendRule.php b/modules/sass/scssphp/Ast/Sass/Statement/ExtendRule.php index bac72438..ff46b103 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/ExtendRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/ExtendRule.php @@ -26,23 +26,11 @@ */ final class ExtendRule implements Statement { - /** - * @var Interpolation - * @readonly - */ - private $selector; + private readonly Interpolation $selector; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; - /** - * @var bool - * @readonly - */ - private $optional; + private readonly bool $optional; public function __construct(Interpolation $selector, FileSpan $span, bool $optional = false) { diff --git a/modules/sass/scssphp/Ast/Sass/Statement/ForRule.php b/modules/sass/scssphp/Ast/Sass/Statement/ForRule.php index 6839eb3e..a8680eed 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/ForRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/ForRule.php @@ -28,35 +28,15 @@ */ final class ForRule extends ParentStatement { - /** - * @var string - * @readonly - */ - private $variable; + private readonly string $variable; - /** - * @var Expression - * @readonly - */ - private $from; + private readonly Expression $from; - /** - * @var Expression - * @readonly - */ - private $to; + private readonly Expression $to; - /** - * @var bool - * @readonly - */ - private $exclusive; + private readonly bool $exclusive; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param Statement[] $children diff --git a/modules/sass/scssphp/Ast/Sass/Statement/IfClause.php b/modules/sass/scssphp/Ast/Sass/Statement/IfClause.php index ab0410c2..1badebed 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/IfClause.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/IfClause.php @@ -22,11 +22,7 @@ */ final class IfClause extends IfRuleClause { - /** - * @var Expression - * @readonly - */ - private $expression; + private readonly Expression $expression; /** * @param Statement[] $children diff --git a/modules/sass/scssphp/Ast/Sass/Statement/IfRule.php b/modules/sass/scssphp/Ast/Sass/Statement/IfRule.php index 59108065..62118e2b 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/IfRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/IfRule.php @@ -26,25 +26,16 @@ final class IfRule implements Statement { /** - * @var IfClause[] - * @readonly + * @var list */ - private $clauses; + private readonly array $clauses; - /** - * @var ElseClause|null - * @readonly - */ - private $lastClause; + private readonly ?ElseClause $lastClause; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** - * @param IfClause[] $clauses + * @param list $clauses */ public function __construct(array $clauses, FileSpan $span, ?ElseClause $lastClause = null) { @@ -60,7 +51,7 @@ public function __construct(array $clauses, FileSpan $span, ?ElseClause $lastCla * statements executed. If no expression evaluates to `true`, `lastClause` * will be executed if it's not `null`. * - * @return IfClause[] + * @return list */ public function getClauses(): array { @@ -71,8 +62,6 @@ public function getClauses(): array * The final, unconditional `@else` clause. * * This is `null` if there is no unconditional `@else`. - * - * @return ElseClause|null */ public function getLastClause(): ?ElseClause { diff --git a/modules/sass/scssphp/Ast/Sass/Statement/IfRuleClause.php b/modules/sass/scssphp/Ast/Sass/Statement/IfRuleClause.php index c350dbdf..3ebf07f8 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/IfRuleClause.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/IfRuleClause.php @@ -14,6 +14,7 @@ use Tangible\ScssPhp\Ast\Sass\Import\DynamicImport; use Tangible\ScssPhp\Ast\Sass\Statement; +use Tangible\ScssPhp\Util\IterableUtil; /** * The superclass of `@if` and `@else` clauses. @@ -24,15 +25,10 @@ abstract class IfRuleClause { /** * @var Statement[] - * @readonly */ - private $children; + private readonly array $children; - /** - * @var bool - * @readonly - */ - private $declarations = false; + private readonly bool $declarations; /** * @param Statement[] $children @@ -40,22 +36,17 @@ abstract class IfRuleClause public function __construct(array $children) { $this->children = $children; - - foreach ($children as $child) { + $this->declarations = IterableUtil::any($children, function (Statement $child) { if ($child instanceof VariableDeclaration || $child instanceof FunctionRule || $child instanceof MixinRule) { - $this->declarations = true; - break; + return true; } if ($child instanceof ImportRule) { - foreach ($child->getImports() as $import) { - if ($import instanceof DynamicImport) { - $this->declarations = true; - break 2; - } - } + return IterableUtil::any($child->getImports(), fn ($import) => $import instanceof DynamicImport); } - } + + return false; + }); } /** diff --git a/modules/sass/scssphp/Ast/Sass/Statement/ImportRule.php b/modules/sass/scssphp/Ast/Sass/Statement/ImportRule.php index 1681371d..568c61e1 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/ImportRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/ImportRule.php @@ -25,19 +25,14 @@ final class ImportRule implements Statement { /** - * @var Import[] - * @readonly + * @var list */ - private $imports; + private readonly array $imports; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** - * @param Import[] $imports + * @param list $imports */ public function __construct(array $imports, FileSpan $span) { @@ -46,7 +41,7 @@ public function __construct(array $imports, FileSpan $span) } /** - * @return Import[] + * @return list */ public function getImports(): array { diff --git a/modules/sass/scssphp/Ast/Sass/Statement/IncludeRule.php b/modules/sass/scssphp/Ast/Sass/Statement/IncludeRule.php index 38884344..ba4710d3 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/IncludeRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/IncludeRule.php @@ -27,37 +27,17 @@ */ final class IncludeRule implements Statement, CallableInvocation, SassReference { - /** - * @var string|null - * @readonly - */ - private $namespace; - - /** - * @var string - * @readonly - */ - private $name; - - /** - * @var ArgumentInvocation - * @readonly - */ - private $arguments; - - /** - * @var ContentBlock|null - * @readonly - */ - private $content; - - /** - * @var FileSpan - * @readonly - */ - private $span; - - public function __construct(string $name, ArgumentInvocation $arguments, FileSpan $span, ?string $namespace = null,?ContentBlock $content = null) + private readonly ?string $namespace; + + private readonly string $name; + + private readonly ArgumentInvocation $arguments; + + private readonly ?ContentBlock $content; + + private readonly FileSpan $span; + + public function __construct(string $name, ArgumentInvocation $arguments, FileSpan $span, ?string $namespace = null, ?ContentBlock $content = null) { $this->name = $name; $this->arguments = $arguments; diff --git a/modules/sass/scssphp/Ast/Sass/Statement/LoudComment.php b/modules/sass/scssphp/Ast/Sass/Statement/LoudComment.php index d856caac..48465653 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/LoudComment.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/LoudComment.php @@ -24,11 +24,7 @@ */ final class LoudComment implements Statement { - /** - * @var Interpolation - * @readonly - */ - private $text; + private readonly Interpolation $text; public function __construct(Interpolation $text) { diff --git a/modules/sass/scssphp/Ast/Sass/Statement/MediaRule.php b/modules/sass/scssphp/Ast/Sass/Statement/MediaRule.php index e6b0c2b5..364ba644 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/MediaRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/MediaRule.php @@ -26,17 +26,9 @@ */ final class MediaRule extends ParentStatement { - /** - * @var Interpolation - * @readonly - */ - private $query; + private readonly Interpolation $query; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param Statement[] $children diff --git a/modules/sass/scssphp/Ast/Sass/Statement/MixinRule.php b/modules/sass/scssphp/Ast/Sass/Statement/MixinRule.php index 4d33dcf0..172c4802 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/MixinRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/MixinRule.php @@ -30,10 +30,8 @@ final class MixinRule extends CallableDeclaration implements SassDeclaration { /** * Whether the mixin contains a `@content` rule. - * - * @var bool|null */ - private $content; + private ?bool $content; /** * @param Statement[] $children diff --git a/modules/sass/scssphp/Ast/Sass/Statement/ParentStatement.php b/modules/sass/scssphp/Ast/Sass/Statement/ParentStatement.php index 52e2ba7a..f4747a11 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/ParentStatement.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/ParentStatement.php @@ -30,15 +30,10 @@ abstract class ParentStatement implements Statement { /** * @var T - * @readonly */ - private $children; + private readonly ?array $children; - /** - * @var bool - * @readonly - */ - private $declarations; + private readonly bool $declarations; /** * @param T $children @@ -74,7 +69,7 @@ public function __construct(?array $children) /** * @return T */ - final public function getChildren() + final public function getChildren(): ?array { return $this->children; } diff --git a/modules/sass/scssphp/Ast/Sass/Statement/ReturnRule.php b/modules/sass/scssphp/Ast/Sass/Statement/ReturnRule.php index 47877ce3..3ac91e1c 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/ReturnRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/ReturnRule.php @@ -26,17 +26,9 @@ */ final class ReturnRule implements Statement { - /** - * @var Expression - * @readonly - */ - private $expression; + private readonly Expression $expression; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(Expression $expression, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Statement/SilentComment.php b/modules/sass/scssphp/Ast/Sass/Statement/SilentComment.php index a984dc4a..878c55c2 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/SilentComment.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/SilentComment.php @@ -23,17 +23,9 @@ */ final class SilentComment implements Statement { - /** - * @var string - * @readonly - */ - private $text; - - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly string $text; + + private readonly FileSpan $span; public function __construct(string $text, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Statement/StyleRule.php b/modules/sass/scssphp/Ast/Sass/Statement/StyleRule.php index 08c1dccd..eb0d7cff 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/StyleRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/StyleRule.php @@ -28,17 +28,9 @@ */ final class StyleRule extends ParentStatement { - /** - * @var Interpolation - * @readonly - */ - private $selector; + private readonly Interpolation $selector; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param Statement[] $children diff --git a/modules/sass/scssphp/Ast/Sass/Statement/Stylesheet.php b/modules/sass/scssphp/Ast/Sass/Statement/Stylesheet.php index 71e2fb58..1bad3558 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/Stylesheet.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/Stylesheet.php @@ -33,17 +33,9 @@ */ final class Stylesheet extends ParentStatement { - /** - * @var bool - * @readonly - */ - private $plainCss; + private readonly bool $plainCss; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param Statement[] $children @@ -71,25 +63,15 @@ public function accept(StatementVisitor $visitor) } /** - * @param Syntax::* $syntax - * * @throws SassFormatException when parsing fails */ - public static function parse(string $contents, string $syntax, ?LoggerInterface $logger = null, ?string $sourceUrl = null): self + public static function parse(string $contents, Syntax $syntax, ?LoggerInterface $logger = null, ?string $sourceUrl = null): self { - switch ($syntax) { - case Syntax::SASS: - return self::parseSass($contents, $logger, $sourceUrl); - - case Syntax::SCSS: - return self::parseScss($contents, $logger, $sourceUrl); - - case Syntax::CSS: - return self::parseCss($contents, $logger, $sourceUrl); - - default: - throw new \InvalidArgumentException("Unknown syntax $syntax."); - } + return match ($syntax) { + Syntax::SASS => self::parseSass($contents, $logger, $sourceUrl), + Syntax::SCSS => self::parseScss($contents, $logger, $sourceUrl), + Syntax::CSS => self::parseCss($contents, $logger, $sourceUrl), + }; } /** diff --git a/modules/sass/scssphp/Ast/Sass/Statement/SupportsRule.php b/modules/sass/scssphp/Ast/Sass/Statement/SupportsRule.php index cc9b4b1b..ec202480 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/SupportsRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/SupportsRule.php @@ -26,17 +26,9 @@ */ final class SupportsRule extends ParentStatement { - /** - * @var SupportsCondition - * @readonly - */ - private $condition; + private readonly SupportsCondition $condition; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param Statement[] $children diff --git a/modules/sass/scssphp/Ast/Sass/Statement/VariableDeclaration.php b/modules/sass/scssphp/Ast/Sass/Statement/VariableDeclaration.php index 3f096191..685813a7 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/VariableDeclaration.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/VariableDeclaration.php @@ -29,47 +29,19 @@ */ final class VariableDeclaration implements Statement, SassDeclaration { - /** - * @var string|null - * @readonly - */ - private $namespace; + private readonly ?string $namespace; - /** - * @var string - * @readonly - */ - private $name; + private readonly string $name; - /** - * @var SilentComment|null - * @readonly - */ - private $comment; + private readonly ?SilentComment $comment; - /** - * @var Expression - * @readonly - */ - private $expression; + private readonly Expression $expression; - /** - * @var bool - * @readonly - */ - private $guarded; + private readonly bool $guarded; - /** - * @var bool - * @readonly - */ - private $global; + private readonly bool $global; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(string $name, Expression $expression, FileSpan $span, ?string $namespace = null, bool $guarded = false, bool $global = false, ?SilentComment $comment = null) { diff --git a/modules/sass/scssphp/Ast/Sass/Statement/WarnRule.php b/modules/sass/scssphp/Ast/Sass/Statement/WarnRule.php index f6ffa518..9b67a833 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/WarnRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/WarnRule.php @@ -26,17 +26,9 @@ */ final class WarnRule implements Statement { - /** - * @var Expression - * @readonly - */ - private $expression; + private readonly Expression $expression; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(Expression $expression, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/Statement/WhileRule.php b/modules/sass/scssphp/Ast/Sass/Statement/WhileRule.php index 0e03f1d5..a4177908 100644 --- a/modules/sass/scssphp/Ast/Sass/Statement/WhileRule.php +++ b/modules/sass/scssphp/Ast/Sass/Statement/WhileRule.php @@ -29,17 +29,9 @@ */ final class WhileRule extends ParentStatement { - /** - * @var Expression - * @readonly - */ - private $condition; + private readonly Expression $condition; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; /** * @param Statement[] $children diff --git a/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsAnything.php b/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsAnything.php index 5730cfc0..ae8e3726 100644 --- a/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsAnything.php +++ b/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsAnything.php @@ -26,17 +26,10 @@ final class SupportsAnything implements SupportsCondition { /** * The contents of the condition. - * - * @var Interpolation - * @readonly */ - private $contents; + private readonly Interpolation $contents; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(Interpolation $contents, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsDeclaration.php b/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsDeclaration.php index 80d22e19..60c6d5bf 100644 --- a/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsDeclaration.php +++ b/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsDeclaration.php @@ -16,7 +16,6 @@ use Tangible\ScssPhp\Ast\Sass\Expression\StringExpression; use Tangible\ScssPhp\Ast\Sass\SupportsCondition; use Tangible\ScssPhp\SourceSpan\FileSpan; -use Tangible\ScssPhp\Util\StringUtil; /** * A condition that selects for browsers where a given declaration is @@ -28,25 +27,15 @@ final class SupportsDeclaration implements SupportsCondition { /** * The name of the declaration being tested. - * - * @var Expression - * @readonly */ - private $name; + private readonly Expression $name; /** * The value of the declaration being tested. - * - * @var Expression - * @readonly */ - private $value; + private readonly Expression $value; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(Expression $name, Expression $value, FileSpan $span) { @@ -81,7 +70,7 @@ public function getSpan(): FileSpan */ public function isCustomProperty(): bool { - return $this->name instanceof StringExpression && !$this->name->hasQuotes() && StringUtil::startsWith($this->name->getText()->getInitialPlain(), '--'); + return $this->name instanceof StringExpression && !$this->name->hasQuotes() && str_starts_with($this->name->getText()->getInitialPlain(), '--'); } public function __toString(): string diff --git a/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsFunction.php b/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsFunction.php index 2f3bc46d..1c0ec0a5 100644 --- a/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsFunction.php +++ b/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsFunction.php @@ -25,25 +25,15 @@ final class SupportsFunction implements SupportsCondition { /** * The name of the function. - * - * @var Interpolation - * @readonly */ - private $name; + private readonly Interpolation $name; /** * The arguments of the function. - * - * @var Interpolation - * @readonly */ - private $arguments; + private readonly Interpolation $arguments; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(Interpolation $name, Interpolation $arguments, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsInterpolation.php b/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsInterpolation.php index b49285b0..b6dd44be 100644 --- a/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsInterpolation.php +++ b/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsInterpolation.php @@ -25,17 +25,10 @@ final class SupportsInterpolation implements SupportsCondition { /** * The expression in the interpolation. - * - * @var Expression - * @readonly */ - private $expression; + private readonly Expression $expression; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(Expression $expression, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsNegation.php b/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsNegation.php index 25b8eb47..b4b519a5 100644 --- a/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsNegation.php +++ b/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsNegation.php @@ -24,17 +24,10 @@ final class SupportsNegation implements SupportsCondition { /** * The condition that's been negated. - * - * @var SupportsCondition - * @readonly */ - private $condition; + private readonly SupportsCondition $condition; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(SupportsCondition $condition, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsOperation.php b/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsOperation.php index 0e12683b..3e2d068c 100644 --- a/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsOperation.php +++ b/modules/sass/scssphp/Ast/Sass/SupportsCondition/SupportsOperation.php @@ -24,31 +24,17 @@ final class SupportsOperation implements SupportsCondition { /** * The left-hand operand. - * - * @var SupportsCondition - * @readonly */ - private $left; + private readonly SupportsCondition $left; /** * The right-hand operand. - * - * @var SupportsCondition - * @readonly */ - private $right; + private readonly SupportsCondition $right; - /** - * @var string - * @readonly - */ - private $operator; + private readonly string $operator; - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(SupportsCondition $left, SupportsCondition $right, string $operator, FileSpan $span) { diff --git a/modules/sass/scssphp/Ast/Selector/AttributeOperator.php b/modules/sass/scssphp/Ast/Selector/AttributeOperator.php index 922e03fe..5bd74ce2 100644 --- a/modules/sass/scssphp/Ast/Selector/AttributeOperator.php +++ b/modules/sass/scssphp/Ast/Selector/AttributeOperator.php @@ -14,38 +14,52 @@ /** * An operator that defines the semantics of an {@see AttributeSelector}. + * + * @internal */ -final class AttributeOperator +enum AttributeOperator { /** * The attribute value exactly equals the given value. */ - public const EQUAL = '='; + case EQUAL; /** * The attribute value is a whitespace-separated list of words, one of which * is the given value. */ - public const INCLUDE = '~='; + case INCLUDE; /** * The attribute value is either exactly the given value, or starts with the * given value followed by a dash. */ - public const DASH = '|='; + case DASH; /** * The attribute value begins with the given value. */ - public const PREFIX = '^='; + case PREFIX; /** * The attribute value ends with the given value. */ - public const SUFFIX = '$='; + case SUFFIX; /** * The attribute value contains the given value. */ - public const SUBSTRING = '*='; + case SUBSTRING; + + public function getText(): string + { + return match ($this) { + self::EQUAL => '=', + self::INCLUDE => '~=', + self::DASH => '|=', + self::PREFIX => '^=', + self::SUFFIX => '$=', + self::SUBSTRING => '*=', + }; + } } diff --git a/modules/sass/scssphp/Ast/Selector/AttributeSelector.php b/modules/sass/scssphp/Ast/Selector/AttributeSelector.php index 8d8d157d..5d32e041 100644 --- a/modules/sass/scssphp/Ast/Selector/AttributeSelector.php +++ b/modules/sass/scssphp/Ast/Selector/AttributeSelector.php @@ -12,6 +12,7 @@ namespace Tangible\ScssPhp\Ast\Selector; +use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Visitor\SelectorVisitor; /** @@ -19,28 +20,23 @@ * * This selects for elements with the given attribute, and optionally with a * value matching certain conditions as well. + * + * @internal */ final class AttributeSelector extends SimpleSelector { /** * The name of the attribute being selected for. - * - * @var QualifiedName - * @readonly */ - private $name; + private readonly QualifiedName $name; /** * The operator that defines the semantics of {@see value}. * * If this is `null`, this matches any element with the given property, * regardless of this value. It's `null` if and only if {@see value} is `null`. - * - * @var string|null - * @phpstan-var AttributeOperator::*|null - * @readonly */ - private $op; + private readonly ?AttributeOperator $op; /** * An assertion about the value of {@see name}. @@ -49,11 +45,8 @@ final class AttributeSelector extends SimpleSelector * * If this is `null`, this matches any element with the given property, * regardless of this value. It's `null` if and only if {@see op} is `null`. - * - * @var string|null - * @readonly */ - private $value; + private readonly ?string $value; /** * The modifier which indicates how the attribute selector should be @@ -64,41 +57,34 @@ final class AttributeSelector extends SimpleSelector * [case-sensitivity]: https://www.w3.org/TR/selectors-4/#attribute-case * * If {@see op} is `null`, this is always `null` as well. - * - * @var string|null - * @readonly */ - private $modifier; + private readonly ?string $modifier; /** * Creates an attribute selector that matches any element with a property of * the given name. */ - public static function create(QualifiedName $name): AttributeSelector + public static function create(QualifiedName $name, FileSpan $span): AttributeSelector { - return new AttributeSelector($name, null, null, null); + return new AttributeSelector($name, $span, null, null, null); } /** * Creates an attribute selector that matches an element with a property * named $name, whose value matches $value based on the semantics of $op. - * - * @phpstan-param AttributeOperator::*|null $op */ - public static function withOperator(QualifiedName $name, ?string $op, ?string $value, ?string $modifier = null): AttributeSelector + public static function withOperator(QualifiedName $name, ?AttributeOperator $op, ?string $value, FileSpan $span, ?string $modifier = null): AttributeSelector { - return new AttributeSelector($name, $op, $value, $modifier); + return new AttributeSelector($name, $span, $op, $value, $modifier); } - /** - * @phpstan-param AttributeOperator::*|null $op - */ - private function __construct(QualifiedName $name, ?string $op, ?string $value, ?string $modifier) + private function __construct(QualifiedName $name, FileSpan $span, ?AttributeOperator $op, ?string $value, ?string $modifier) { $this->name = $name; $this->op = $op; $this->value = $value; $this->modifier = $modifier; + parent::__construct($span); } public function getName(): QualifiedName @@ -106,10 +92,7 @@ public function getName(): QualifiedName return $this->name; } - /** - * @phpstan-return AttributeOperator::*|null - */ - public function getOp(): ?string + public function getOp(): ?AttributeOperator { return $this->op; } diff --git a/modules/sass/scssphp/Ast/Selector/ClassSelector.php b/modules/sass/scssphp/Ast/Selector/ClassSelector.php index f6feadb1..12164317 100644 --- a/modules/sass/scssphp/Ast/Selector/ClassSelector.php +++ b/modules/sass/scssphp/Ast/Selector/ClassSelector.php @@ -12,6 +12,7 @@ namespace Tangible\ScssPhp\Ast\Selector; +use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Visitor\SelectorVisitor; /** @@ -19,20 +20,20 @@ * * This selects elements whose `class` attribute contains an identifier with * the given name. + * + * @internal */ final class ClassSelector extends SimpleSelector { /** * The class name this selects for. - * - * @var string - * @readonly */ - private $name; + private readonly string $name; - public function __construct(string $name) + public function __construct(string $name, FileSpan $span) { $this->name = $name; + parent::__construct($span); } public function getName(): string @@ -52,6 +53,6 @@ public function equals(object $other): bool public function addSuffix(string $suffix): SimpleSelector { - return new ClassSelector($this->name . $suffix); + return new ClassSelector($this->name . $suffix, $this->getSpan()); } } diff --git a/modules/sass/scssphp/Ast/Selector/Combinator.php b/modules/sass/scssphp/Ast/Selector/Combinator.php index 02bfc8a8..dadd4f9b 100644 --- a/modules/sass/scssphp/Ast/Selector/Combinator.php +++ b/modules/sass/scssphp/Ast/Selector/Combinator.php @@ -15,24 +15,35 @@ /** * A combinator that defines the relationship between selectors in a * {@see ComplexSelector}. + * + * @internal */ -final class Combinator +enum Combinator { /** * Matches the right-hand selector if it's immediately adjacent to the * left-hand selector in the DOM tree. */ - public const NEXT_SIBLING = '+'; + case NEXT_SIBLING; /** * Matches the right-hand selector if it's a direct child of the left-hand * selector in the DOM tree. */ - public const CHILD = '>'; + case CHILD; /** * Matches the right-hand selector if it comes after the left-hand selector * in the DOM tree. */ - public const FOLLOWING_SIBLING = '~'; + case FOLLOWING_SIBLING; + + public function getText(): string + { + return match ($this) { + self::NEXT_SIBLING => '+', + self::CHILD => '>', + self::FOLLOWING_SIBLING => '~', + }; + } } diff --git a/modules/sass/scssphp/Ast/Selector/ComplexSelector.php b/modules/sass/scssphp/Ast/Selector/ComplexSelector.php index 351a092e..3e2b596c 100644 --- a/modules/sass/scssphp/Ast/Selector/ComplexSelector.php +++ b/modules/sass/scssphp/Ast/Selector/ComplexSelector.php @@ -12,10 +12,12 @@ namespace Tangible\ScssPhp\Ast\Selector; +use Tangible\ScssPhp\Ast\Css\CssValue; use Tangible\ScssPhp\Exception\SassFormatException; use Tangible\ScssPhp\Extend\ExtendUtil; use Tangible\ScssPhp\Logger\LoggerInterface; use Tangible\ScssPhp\Parser\SelectorParser; +use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Util\EquatableUtil; use Tangible\ScssPhp\Util\ListUtil; use Tangible\ScssPhp\Visitor\SelectorVisitor; @@ -25,6 +27,8 @@ * * A complex selector is composed of {@see CompoundSelector}s separated by * {@see Combinator}s. It selects elements based on their parent selectors. + * + * @internal */ final class ComplexSelector extends Selector { @@ -35,11 +39,9 @@ final class ComplexSelector extends Selector * it's more than one element, that means it's invalid CSS; however, we still * support this for backwards-compatibility purposes. * - * @var list - * @phpstan-var list - * @readonly + * @var list> */ - private $leadingCombinators; + private readonly array $leadingCombinators; /** * The components of this selector. @@ -54,31 +56,21 @@ final class ComplexSelector extends Selector * This isn't valid CSS, but Sass supports it for CSS hack purposes. * * @var list - * @readonly */ - private $components; + private readonly array $components; /** * Whether a line break should be emitted *before* this selector. - * - * @var bool - * @readonly */ - private $lineBreak; + private readonly bool $lineBreak; - /** - * @var int|null - */ - private $specificity; + private ?int $specificity = null; /** - * @param list $leadingCombinators + * @param list> $leadingCombinators * @param list $components - * @param bool $lineBreak - * - * @phpstan-param list $leadingCombinators */ - public function __construct(array $leadingCombinators, array $components, bool $lineBreak = false) + public function __construct(array $leadingCombinators, array $components, FileSpan $span, bool $lineBreak = false) { if ($leadingCombinators === [] && $components === []) { throw new \InvalidArgumentException('leadingCombinators and components may not both be empty.'); @@ -87,6 +79,7 @@ public function __construct(array $leadingCombinators, array $components, bool $ $this->leadingCombinators = $leadingCombinators; $this->components = $components; $this->lineBreak = $lineBreak; + parent::__construct($span); } /** @@ -104,8 +97,7 @@ public static function parse(string $contents, ?LoggerInterface $logger = null, } /** - * @return list - * @phpstan-return list + * @return list> */ public function getLeadingCombinators(): array { @@ -186,12 +178,12 @@ public function accept(SelectorVisitor $visitor) */ public function isSuperselector(ComplexSelector $other): bool { - return \count($this->leadingCombinators) === 0 && \count($other->leadingCombinators) ===0 && ExtendUtil::complexIsSuperselector($this->components, $other->components); + return \count($this->leadingCombinators) === 0 && \count($other->leadingCombinators) === 0 && ExtendUtil::complexIsSuperselector($this->components, $other->components); } public function equals(object $other): bool { - return $other instanceof ComplexSelector && $this->leadingCombinators === $other->leadingCombinators && EquatableUtil::listEquals($this->components, $other->components); + return $other instanceof ComplexSelector && EquatableUtil::listEquals($this->leadingCombinators, $other->leadingCombinators) && EquatableUtil::listEquals($this->components, $other->components); } /** @@ -201,12 +193,7 @@ public function equals(object $other): bool * If $forceLineBreak is `true`, this will mark the new complex selector as * having a line break. * - * @param list $combinators - * @param bool $forceLineBreak - * - * @return ComplexSelector - * - * @phpstan-param list $combinators + * @param list> $combinators */ public function withAdditionalCombinators(array $combinators, bool $forceLineBreak = false): ComplexSelector { @@ -215,7 +202,7 @@ public function withAdditionalCombinators(array $combinators, bool $forceLineBre } if ($this->components === []) { - return new ComplexSelector(array_merge($this->leadingCombinators, $combinators), [], $this->lineBreak || $forceLineBreak); + return new ComplexSelector(array_merge($this->leadingCombinators, $combinators), [], $this->getSpan(), $this->lineBreak || $forceLineBreak); } return new ComplexSelector( @@ -224,6 +211,7 @@ public function withAdditionalCombinators(array $combinators, bool $forceLineBre ListUtil::exceptLast($this->components), [ListUtil::last($this->components)->withAdditionalCombinators($combinators)] ), + $this->getSpan(), $this->lineBreak || $forceLineBreak ); } @@ -233,15 +221,10 @@ public function withAdditionalCombinators(array $combinators, bool $forceLineBre * * If $forceLineBreak is `true`, this will mark the new complex selector as * having a line break. - * - * @param ComplexSelectorComponent $component - * @param bool $forceLineBreak - * - * @return ComplexSelector */ - public function withAdditionalComponent(ComplexSelectorComponent $component, bool $forceLineBreak = false): ComplexSelector + public function withAdditionalComponent(ComplexSelectorComponent $component, FileSpan $span, bool $forceLineBreak = false): ComplexSelector { - return new ComplexSelector($this->leadingCombinators, array_merge($this->components, [$component]), $this->lineBreak || $forceLineBreak); + return new ComplexSelector($this->leadingCombinators, array_merge($this->components, [$component]), $span, $this->lineBreak || $forceLineBreak); } /** @@ -252,18 +235,14 @@ public function withAdditionalComponent(ComplexSelectorComponent $component, boo * * If $forceLineBreak is `true`, this will mark the new complex selector as * having a line break. - * - * @param ComplexSelector $child - * @param bool $forceLineBreak - * - * @return ComplexSelector */ - public function concatenate(ComplexSelector $child, bool $forceLineBreak = false): ComplexSelector + public function concatenate(ComplexSelector $child, FileSpan $span, bool $forceLineBreak = false): ComplexSelector { if (\count($child->leadingCombinators) === 0) { return new ComplexSelector( $this->leadingCombinators, array_merge($this->components, $child->components), + $span, $this->lineBreak || $child->lineBreak || $forceLineBreak ); } @@ -272,6 +251,7 @@ public function concatenate(ComplexSelector $child, bool $forceLineBreak = false return new ComplexSelector( array_merge($this->leadingCombinators, $child->leadingCombinators), $child->components, + $span, $this->lineBreak || $child->lineBreak || $forceLineBreak ); } @@ -283,6 +263,7 @@ public function concatenate(ComplexSelector $child, bool $forceLineBreak = false [ListUtil::last($this->components)->withAdditionalCombinators($child->leadingCombinators)], $child->components ), + $span, $this->lineBreak || $child->lineBreak || $forceLineBreak ); } diff --git a/modules/sass/scssphp/Ast/Selector/ComplexSelectorComponent.php b/modules/sass/scssphp/Ast/Selector/ComplexSelectorComponent.php index 874e7246..1a4dd980 100644 --- a/modules/sass/scssphp/Ast/Selector/ComplexSelectorComponent.php +++ b/modules/sass/scssphp/Ast/Selector/ComplexSelectorComponent.php @@ -12,7 +12,10 @@ namespace Tangible\ScssPhp\Ast\Selector; +use Tangible\ScssPhp\Ast\Css\CssValue; +use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Util\Equatable; +use Tangible\ScssPhp\Util\EquatableUtil; /** * A component of a {@see ComplexSelector}. @@ -25,11 +28,8 @@ final class ComplexSelectorComponent implements Equatable { /** * This component's compound selector. - * - * @var CompoundSelector - * @readonly */ - private $selector; + private readonly CompoundSelector $selector; /** * This selector's combinators. @@ -38,22 +38,20 @@ final class ComplexSelectorComponent implements Equatable * combinator. If it's more than one element, that means it's invalid CSS; * however, we still support this for backwards-compatibility purposes. * - * @var list - * @phpstan-var list - * @readonly + * @var list> */ - private $combinators; + private readonly array $combinators; + + private readonly FileSpan $span; /** - * @param CompoundSelector $selector - * @param list $combinators - * - * @phpstan-param list $combinators + * @param list> $combinators */ - public function __construct(CompoundSelector $selector, array $combinators) + public function __construct(CompoundSelector $selector, array $combinators, FileSpan $span) { $this->selector = $selector; $this->combinators = $combinators; + $this->span = $span; } public function getSelector(): CompoundSelector @@ -61,9 +59,13 @@ public function getSelector(): CompoundSelector return $this->selector; } + public function getSpan(): FileSpan + { + return $this->span; + } + /** - * @return list - * @phpstan-return list + * @return list> */ public function getCombinators(): array { @@ -72,18 +74,14 @@ public function getCombinators(): array public function equals(object $other): bool { - return $other instanceof ComplexSelectorComponent && $this->selector->equals($other->selector) && $this->combinators === $other->combinators; + return $other instanceof ComplexSelectorComponent && $this->selector->equals($other->selector) && EquatableUtil::listEquals($this->combinators, $other->combinators); } /** * Returns a copy of $this with $combinators added to the end of * `$this->combinators`. * - * @param list $combinators - * - * @return ComplexSelectorComponent - * - * @phpstan-param list $combinators + * @param list> $combinators */ public function withAdditionalCombinators(array $combinators): ComplexSelectorComponent { @@ -91,6 +89,11 @@ public function withAdditionalCombinators(array $combinators): ComplexSelectorCo return $this; } - return new ComplexSelectorComponent($this->selector, array_merge($this->combinators, $combinators)); + return new ComplexSelectorComponent($this->selector, array_merge($this->combinators, $combinators), $this->span); + } + + public function __toString(): string + { + return $this->selector . implode('', array_map(fn ($combinator) => ' ' . $combinator, $this->combinators)); } } diff --git a/modules/sass/scssphp/Ast/Selector/CompoundSelector.php b/modules/sass/scssphp/Ast/Selector/CompoundSelector.php index 9ce713f8..3fd6a657 100644 --- a/modules/sass/scssphp/Ast/Selector/CompoundSelector.php +++ b/modules/sass/scssphp/Ast/Selector/CompoundSelector.php @@ -16,6 +16,7 @@ use Tangible\ScssPhp\Extend\ExtendUtil; use Tangible\ScssPhp\Logger\LoggerInterface; use Tangible\ScssPhp\Parser\SelectorParser; +use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Util\EquatableUtil; use Tangible\ScssPhp\Visitor\SelectorVisitor; @@ -24,6 +25,8 @@ * * A compound selector is composed of {@see SimpleSelector}s. It matches an element * that matches all of the component simple selectors. + * + * @internal */ final class CompoundSelector extends Selector { @@ -34,12 +37,9 @@ final class CompoundSelector extends Selector * * @var list */ - private $components; + private readonly array $components; - /** - * @var int|null - */ - private $specificity; + private ?int $specificity = null; /** * Parses a compound selector from $contents. @@ -58,13 +58,14 @@ public static function parse(string $contents, ?LoggerInterface $logger = null, /** * @param list $components */ - public function __construct(array $components) + public function __construct(array $components, FileSpan $span) { if ($components === []) { throw new \InvalidArgumentException('components may not be empty.'); } $this->components = $components; + parent::__construct($span); } /** diff --git a/modules/sass/scssphp/Ast/Selector/IDSelector.php b/modules/sass/scssphp/Ast/Selector/IDSelector.php index cee69fdf..3f1a9166 100644 --- a/modules/sass/scssphp/Ast/Selector/IDSelector.php +++ b/modules/sass/scssphp/Ast/Selector/IDSelector.php @@ -12,26 +12,27 @@ namespace Tangible\ScssPhp\Ast\Selector; +use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Visitor\SelectorVisitor; /** * An ID selector. * * This selects elements whose `id` attribute exactly matches the given name. + * + * @internal */ final class IDSelector extends SimpleSelector { /** * The ID name this selects for. - * - * @var string - * @readonly */ - private $name; + private readonly string $name; - public function __construct(string $name) + public function __construct(string $name, FileSpan $span) { $this->name = $name; + parent::__construct($span); } public function getName(): string @@ -51,7 +52,7 @@ public function accept(SelectorVisitor $visitor) public function addSuffix(string $suffix): SimpleSelector { - return new IDSelector($this->name . $suffix); + return new IDSelector($this->name . $suffix, $this->getSpan()); } public function unify(array $compound): ?array diff --git a/modules/sass/scssphp/Ast/Selector/IsBogusVisitor.php b/modules/sass/scssphp/Ast/Selector/IsBogusVisitor.php index 0c111b57..4e48c954 100644 --- a/modules/sass/scssphp/Ast/Selector/IsBogusVisitor.php +++ b/modules/sass/scssphp/Ast/Selector/IsBogusVisitor.php @@ -23,11 +23,8 @@ final class IsBogusVisitor extends AnySelectorVisitor { /** * Whether to consider selectors with leading combinators as bogus. - * - * @var bool - * @readonly */ - private $includeLeadingCombinator; + private readonly bool $includeLeadingCombinator; public function __construct(bool $includeLeadingCombinator) { diff --git a/modules/sass/scssphp/Ast/Selector/IsInvisibleVisitor.php b/modules/sass/scssphp/Ast/Selector/IsInvisibleVisitor.php index 82ea28a5..5a482522 100644 --- a/modules/sass/scssphp/Ast/Selector/IsInvisibleVisitor.php +++ b/modules/sass/scssphp/Ast/Selector/IsInvisibleVisitor.php @@ -23,11 +23,8 @@ final class IsInvisibleVisitor extends AnySelectorVisitor { /** * Whether to consider selectors with bogus combinators invisible. - * - * @var bool - * @readonly */ - private $includeBogus; + private readonly bool $includeBogus; public function __construct(bool $includeBogus) { diff --git a/modules/sass/scssphp/Ast/Selector/ParentSelector.php b/modules/sass/scssphp/Ast/Selector/ParentSelector.php index 9f9ee020..61a19047 100644 --- a/modules/sass/scssphp/Ast/Selector/ParentSelector.php +++ b/modules/sass/scssphp/Ast/Selector/ParentSelector.php @@ -12,6 +12,7 @@ namespace Tangible\ScssPhp\Ast\Selector; +use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Visitor\SelectorVisitor; /** @@ -19,6 +20,8 @@ * * This is not a plain CSS selector—it should be removed before emitting a CSS * document. + * + * @internal */ final class ParentSelector extends SimpleSelector { @@ -28,15 +31,13 @@ final class ParentSelector extends SimpleSelector * * This is assumed to be a valid identifier suffix. It may be `null`, * indicating that the parent selector will not be modified. - * - * @var string|null - * @readonly */ - private $suffix; + private readonly ?string $suffix; - public function __construct(?string $suffix) + public function __construct(FileSpan $span, ?string $suffix = null) { $this->suffix = $suffix; + parent::__construct($span); } public function getSuffix(): ?string diff --git a/modules/sass/scssphp/Ast/Selector/ParentSelectorVisitor.php b/modules/sass/scssphp/Ast/Selector/ParentSelectorVisitor.php new file mode 100644 index 00000000..217e8944 --- /dev/null +++ b/modules/sass/scssphp/Ast/Selector/ParentSelectorVisitor.php @@ -0,0 +1,20 @@ + + * + * @internal + */ +final class ParentSelectorVisitor extends SelectorSearchVisitor +{ + public function visitParentSelector(ParentSelector $selector): ParentSelector + { + return $selector; + } +} diff --git a/modules/sass/scssphp/Ast/Selector/PlaceholderSelector.php b/modules/sass/scssphp/Ast/Selector/PlaceholderSelector.php index cc706d40..d9fcd4d7 100644 --- a/modules/sass/scssphp/Ast/Selector/PlaceholderSelector.php +++ b/modules/sass/scssphp/Ast/Selector/PlaceholderSelector.php @@ -12,6 +12,7 @@ namespace Tangible\ScssPhp\Ast\Selector; +use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Util\Character; use Tangible\ScssPhp\Visitor\SelectorVisitor; @@ -21,20 +22,20 @@ * This doesn't match any elements. It's intended to be extended using * `@extend`. It's not a plain CSS selector—it should be removed before * emitting a CSS document. + * + * @internal */ final class PlaceholderSelector extends SimpleSelector { /** * The name of the placeholder. - * - * @var string - * @readonly */ - private $name; + private readonly string $name; - public function __construct(string $name) + public function __construct(string $name, FileSpan $span) { $this->name = $name; + parent::__construct($span); } public function getName(): string @@ -58,7 +59,7 @@ public function accept(SelectorVisitor $visitor) public function addSuffix(string $suffix): SimpleSelector { - return new PlaceholderSelector($this->name . $suffix); + return new PlaceholderSelector($this->name . $suffix, $this->getSpan()); } public function equals(object $other): bool diff --git a/modules/sass/scssphp/Ast/Selector/PseudoSelector.php b/modules/sass/scssphp/Ast/Selector/PseudoSelector.php index 47641881..1a28f0ca 100644 --- a/modules/sass/scssphp/Ast/Selector/PseudoSelector.php +++ b/modules/sass/scssphp/Ast/Selector/PseudoSelector.php @@ -12,6 +12,7 @@ namespace Tangible\ScssPhp\Ast\Selector; +use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Util; use Tangible\ScssPhp\Util\EquatableUtil; use Tangible\ScssPhp\Visitor\SelectorVisitor; @@ -23,65 +24,44 @@ * selectors take arguments, including other selectors. Sass manually encodes * logic for each pseudo selector that takes a selector as an argument, to * ensure that extension and other selector operations work properly. + * + * @internal */ final class PseudoSelector extends SimpleSelector { /** * The name of this selector. - * - * @var string - * @readonly */ - private $name; + private readonly string $name; /** * Like {@see name}, but without any vendor prefixes. - * - * @var string - * @readonly */ - private $normalizedName; + private readonly string $normalizedName; - /** - * @var bool - * @readonly - */ - private $isClass; + private readonly bool $isClass; - /** - * @var bool - * @readonly - */ - private $isSyntacticClass; + private readonly bool $isSyntacticClass; /** * The non-selector argument passed to this selector. * * This is `null` if there's no argument. If {@see argument} and {@see selector} are * both non-`null`, the selector follows the argument. - * - * @var string|null - * @readonly */ - private $argument; + private readonly ?string $argument; /** * The selector argument passed to this selector. * * This is `null` if there's no selector. If {@see argument} and {@see selector} are * both non-`null`, the selector follows the argument. - * - * @var SelectorList|null - * @readonly */ - private $selector; + private readonly ?SelectorList $selector; - /** - * @var int|null - */ - private $specificity; + private ?int $specificity = null; - public function __construct(string $name, bool $element = false, ?string $argument = null, ?SelectorList $selector = null) + public function __construct(string $name, FileSpan $span, bool $element = false, ?string $argument = null, ?SelectorList $selector = null) { $this->name = $name; $this->isClass = !$element && !self::isFakePseudoElement($name); @@ -89,6 +69,7 @@ public function __construct(string $name, bool $element = false, ?string $argume $this->argument = $argument; $this->selector = $selector; $this->normalizedName = Util::unvendor($name); + parent::__construct($span); } /** @@ -258,7 +239,7 @@ private function computeSpecificity(): int public function withSelector(SelectorList $selector): PseudoSelector { - return new PseudoSelector($this->name, $this->isElement(), $this->argument, $selector); + return new PseudoSelector($this->name, $this->getSpan(), $this->isElement(), $this->argument, $selector); } public function addSuffix(string $suffix): SimpleSelector @@ -267,7 +248,7 @@ public function addSuffix(string $suffix): SimpleSelector parent::addSuffix($suffix); } - return new PseudoSelector($this->name . $suffix, $this->isElement()); + return new PseudoSelector($this->name . $suffix, $this->getSpan(), $this->isElement()); } public function unify(array $compound): ?array @@ -339,7 +320,7 @@ public function isSuperselector(SimpleSelector $other): bool // Fall back to the logic defined in ExtendUtil, which knows how to // compare selector pseudoclasses against raw selectors. - return (new CompoundSelector([$this]))->isSuperselector(new CompoundSelector([$other])); + return (new CompoundSelector([$this], $this->getSpan()))->isSuperselector(new CompoundSelector([$other], $this->getSpan())); } public function accept(SelectorVisitor $visitor) diff --git a/modules/sass/scssphp/Ast/Selector/QualifiedName.php b/modules/sass/scssphp/Ast/Selector/QualifiedName.php index bc8f1164..1c41a522 100644 --- a/modules/sass/scssphp/Ast/Selector/QualifiedName.php +++ b/modules/sass/scssphp/Ast/Selector/QualifiedName.php @@ -18,16 +18,15 @@ * A [qualified name][]. * * [qualified name]: https://www.w3.org/TR/css3-namespace/#css-qnames + * + * @internal */ final class QualifiedName implements Equatable { /** * The identifier name. - * - * @var string - * @readonly */ - private $name; + private readonly string $name; /** * The namespace name. @@ -35,11 +34,8 @@ final class QualifiedName implements Equatable * If this is `null`, {@see name} belongs to the default namespace. If it's the * empty string, {@see name} belongs to no namespace. If it's `*`, {@see name} belongs * to any namespace. Otherwise, {@see name} belongs to the given namespace. - * - * @var string|null - * @readonly */ - private $namespace; + private readonly ?string $namespace; public function __construct(string $name, ?string $namespace = null) { diff --git a/modules/sass/scssphp/Ast/Selector/Selector.php b/modules/sass/scssphp/Ast/Selector/Selector.php index 2d005d01..444903aa 100644 --- a/modules/sass/scssphp/Ast/Selector/Selector.php +++ b/modules/sass/scssphp/Ast/Selector/Selector.php @@ -12,7 +12,11 @@ namespace Tangible\ScssPhp\Ast\Selector; +use Tangible\ScssPhp\Ast\AstNode; +use Tangible\ScssPhp\Deprecation; +use Tangible\ScssPhp\Exception\SassException; use Tangible\ScssPhp\Serializer\Serializer; +use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Util\Equatable; use Tangible\ScssPhp\Visitor\SelectorVisitor; use Tangible\ScssPhp\Warn; @@ -24,9 +28,23 @@ * {@see ParentSelector} or a {@see PlaceholderSelector}. * * Selectors have structural equality semantics. + * + * @internal */ -abstract class Selector implements Equatable +abstract class Selector implements AstNode, Equatable { + private readonly FileSpan $span; + + public function __construct(FileSpan $span) + { + $this->span = $span; + } + + public function getSpan(): FileSpan + { + return $this->span; + } + /** * Whether this selector, and complex selectors containing it, should not be * emitted. @@ -78,7 +96,7 @@ public function isUseless(): bool * Prints a warning if $this is a bogus selector. * * This may only be called from within a custom Sass function. This will - * throw a {@see SassScriptException} in a future major version. + * throw a {@see SassException} in a future major version. */ public function assertNotBogus(?string $name = null): void { @@ -86,7 +104,7 @@ public function assertNotBogus(?string $name = null): void return; } - Warn::deprecation(($name === null ? '' : "\$$name: ") . "$this is not valid CSS.\nThis will be an error in Dart Sass 2.0.0.\n\nMore info: https://sass-lang.com/d/bogus-combinators"); + Warn::forDeprecation(($name === null ? '' : "\$$name: ") . "$this is not valid CSS.\nThis will be an error in Dart Sass 2.0.0.\n\nMore info: https://sass-lang.com/d/bogus-combinators", Deprecation::bogusCombinators); } /** diff --git a/modules/sass/scssphp/Ast/Selector/SelectorList.php b/modules/sass/scssphp/Ast/Selector/SelectorList.php index d2df1633..40b051df 100644 --- a/modules/sass/scssphp/Ast/Selector/SelectorList.php +++ b/modules/sass/scssphp/Ast/Selector/SelectorList.php @@ -12,11 +12,15 @@ namespace Tangible\ScssPhp\Ast\Selector; +use Tangible\ScssPhp\Ast\Css\CssValue; use Tangible\ScssPhp\Exception\SassFormatException; +use Tangible\ScssPhp\Exception\SassRuntimeException; use Tangible\ScssPhp\Exception\SassScriptException; use Tangible\ScssPhp\Extend\ExtendUtil; use Tangible\ScssPhp\Logger\LoggerInterface; +use Tangible\ScssPhp\Parser\InterpolationMap; use Tangible\ScssPhp\Parser\SelectorParser; +use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Util\EquatableUtil; use Tangible\ScssPhp\Util\ListUtil; use Tangible\ScssPhp\Value\ListSeparator; @@ -29,6 +33,8 @@ * * A selector list is composed of {@see ComplexSelector}s. It matches any element * that matches any of the component selectors. + * + * @internal */ final class SelectorList extends Selector { @@ -38,9 +44,8 @@ final class SelectorList extends Selector * This is never empty. * * @var list - * @readonly */ - private $components; + private readonly array $components; /** * Parses a selector list from $contents. @@ -51,21 +56,22 @@ final class SelectorList extends Selector * * @throws SassFormatException if parsing fails. */ - public static function parse(string $contents, ?LoggerInterface $logger = null, ?string $url = null, bool $allowParent = true, bool $allowPlaceholder = true): SelectorList + public static function parse(string $contents, ?LoggerInterface $logger = null, ?InterpolationMap $interpolationMap = null, ?string $url = null, bool $allowParent = true, bool $allowPlaceholder = true): SelectorList { - return (new SelectorParser($contents, $logger, $url, $allowParent, $allowPlaceholder))->parse(); + return (new SelectorParser($contents, $logger, $url, $allowParent, $interpolationMap, $allowPlaceholder))->parse(); } /** * @param list $components */ - public function __construct(array $components) + public function __construct(array $components, FileSpan $span) { if ($components === []) { throw new \InvalidArgumentException('components may not be empty.'); } $this->components = $components; + parent::__construct($span); } /** @@ -117,7 +123,7 @@ public function unify(SelectorList $other): ?SelectorList foreach ($this->components as $complex1) { foreach ($other->components as $complex2) { - $unified = ExtendUtil::unifyComplex([$complex1, $complex2]); + $unified = ExtendUtil::unifyComplex([$complex1, $complex2], $complex1->getSpan()); if ($unified === null) { continue; @@ -129,7 +135,7 @@ public function unify(SelectorList $other): ?SelectorList } } - return \count($contents) === 0 ? null : new SelectorList($contents); + return \count($contents) === 0 ? null : new SelectorList($contents, $this->getSpan()); } /** @@ -145,22 +151,21 @@ public function unify(SelectorList $other): ?SelectorList public function resolveParentSelectors(?SelectorList $parent, bool $implicitParent = true): SelectorList { if ($parent === null) { - if (!$this->containsParentSelector()) { + $parentSelector = $this->accept(new ParentSelectorVisitor()); + if ($parentSelector === null) { return $this; } - throw new SassScriptException('Top-level selectors may not contain the parent selector "&".'); + throw new SassRuntimeException('Top-level selectors may not contain the parent selector "&".', $parentSelector->getSpan()); } return new SelectorList(ListUtil::flattenVertically(array_map(function (ComplexSelector $complex) use ($parent, $implicitParent) { - if (!self::complexContainsParentSelector($complex)) { + if (!self::containsParentSelector($complex)) { if (!$implicitParent) { return [$complex]; } - return array_map(function (ComplexSelector $parentComplex) use ($complex) { - return $parentComplex->concatenate($complex); - }, $parent->getComponents()); + return array_map(fn(ComplexSelector $parentComplex) => $parentComplex->concatenate($complex, $complex->getSpan()), $parent->getComponents()); } /** @var list $newComplexes */ @@ -170,11 +175,9 @@ public function resolveParentSelectors(?SelectorList $parent, bool $implicitPare $resolved = self::resolveParentSelectorsCompound($component, $parent); if ($resolved === null) { if (\count($newComplexes) === 0) { - $newComplexes[] = new ComplexSelector($complex->getLeadingCombinators(), [$component], false); + $newComplexes[] = new ComplexSelector($complex->getLeadingCombinators(), [$component], $complex->getSpan(), false); } else { - foreach ($newComplexes as $i => $newComplex) { - $newComplexes[$i] = $newComplex->withAdditionalComponent($component); - } + $newComplexes = array_map(fn ($newComplex) => $newComplex->withAdditionalComponent($component, $complex->getSpan()), $newComplexes); } } elseif (\count($newComplexes) === 0) { $newComplexes = $resolved; @@ -184,14 +187,14 @@ public function resolveParentSelectors(?SelectorList $parent, bool $implicitPare foreach ($previousComplexes as $newComplex) { foreach ($resolved as $resolvedComplex) { - $newComplexes[] = $newComplex->concatenate($resolvedComplex); + $newComplexes[] = $newComplex->concatenate($resolvedComplex, $newComplex->getSpan()); } } } } return $newComplexes; - }, $this->components))); + }, $this->components)), $this->getSpan()); } /** @@ -210,45 +213,6 @@ public function equals(object $other): bool return $other instanceof SelectorList && EquatableUtil::listEquals($this->components, $other->components); } - /** - * Whether this contains a {@see ParentSelector}. - */ - private function containsParentSelector(): bool - { - foreach ($this->components as $component) { - if (self::complexContainsParentSelector($component)) { - return true; - } - } - - return false; - } - - /** - * Returns whether $complex contains a {@see ParentSelector}. - */ - private static function complexContainsParentSelector(ComplexSelector $complex): bool - { - foreach ($complex->getComponents() as $component) { - foreach ($component->getSelector()->getComponents() as $simple) { - if ($simple instanceof ParentSelector) { - return true; - } - - if (!$simple instanceof PseudoSelector) { - continue; - } - - $selector = $simple->getSelector(); - if ($selector !== null && $selector->containsParentSelector()) { - return true; - } - } - } - - return false; - } - /** * Returns a new selector list based on $component with all * {@see ParentSelector}s replaced with $parent. @@ -267,7 +231,7 @@ private static function resolveParentSelectorsCompound(ComplexSelectorComponent } $selector = $simple->getSelector(); - if ($selector !== null && $selector->containsParentSelector()) { + if ($selector !== null && self::containsParentSelector($selector)) { $containsSelectorPseudo = true; break; } @@ -287,7 +251,7 @@ private static function resolveParentSelectorsCompound(ComplexSelectorComponent if ($selector === null) { return $simple; } - if (!$selector->containsParentSelector()) { + if (!self::containsParentSelector($selector)) { return $simple; } @@ -299,8 +263,18 @@ private static function resolveParentSelectorsCompound(ComplexSelectorComponent $parentSelector = $simples[0]; + // TODO add the span on exceptions in those 2 ifs + if (!$parentSelector instanceof ParentSelector) { - return [new ComplexSelector([], [new ComplexSelectorComponent(new CompoundSelector($resolvedSimples), $component->getCombinators())])]; + return [ + new ComplexSelector([], [ + new ComplexSelectorComponent( + new CompoundSelector($resolvedSimples, $component->getSelector()->getSpan()), + $component->getCombinators(), + $component->getSpan() + ), + ], $component->getSpan()), + ]; } if (\count($simples) === 1 && $parentSelector->getSuffix() === null) { @@ -308,6 +282,7 @@ private static function resolveParentSelectorsCompound(ComplexSelectorComponent } return array_map(function (ComplexSelector $complex) use ($parentSelector, $resolvedSimples, $component) { + // TODO add the span on exceptions in this callback $lastComponent = $complex->getLastComponent(); if (\count($lastComponent->getCombinators()) !== 0) { @@ -322,15 +297,15 @@ private static function resolveParentSelectorsCompound(ComplexSelectorComponent ListUtil::exceptLast($lastSimples), [ListUtil::last($lastSimples)->addSuffix($suffix)], array_slice($resolvedSimples, 1) - )); + ), $component->getSelector()->getSpan()); } else { - $last = new CompoundSelector(array_merge($lastSimples, array_slice($resolvedSimples, 1))); + $last = new CompoundSelector(array_merge($lastSimples, array_slice($resolvedSimples, 1)), $component->getSelector()->getSpan()); } $components = ListUtil::exceptLast($complex->getComponents()); - $components[] = new ComplexSelectorComponent($last, $component->getCombinators()); + $components[] = new ComplexSelectorComponent($last, $component->getCombinators(), $component->getSpan()); - return new ComplexSelector($complex->getLeadingCombinators(), $components, $complex->getLineBreak()); + return new ComplexSelector($complex->getLeadingCombinators(), $components, $component->getSpan(), $complex->getLineBreak()); }, $parent->getComponents()); } @@ -338,9 +313,7 @@ private static function resolveParentSelectorsCompound(ComplexSelectorComponent * Returns a copy of `this` with $combinators added to the end of each * complex selector in {@see components}]. * - * @param list $combinators - * - * @phpstan-param list $combinators + * @param list> $combinators */ public function withAdditionalCombinators(array $combinators): SelectorList { @@ -348,8 +321,14 @@ public function withAdditionalCombinators(array $combinators): SelectorList return $this; } - return new SelectorList(array_map(function (ComplexSelector $complex) use ($combinators) { - return $complex->withAdditionalCombinators($combinators); - }, $this->components)); + return new SelectorList(array_map(fn(ComplexSelector $complex) => $complex->withAdditionalCombinators($combinators), $this->components), $this->getSpan()); + } + + /** + * Returns whether $selector recursively contains a parent selector. + */ + private static function containsParentSelector(Selector $selector): bool + { + return $selector->accept(new ParentSelectorVisitor()) !== null; } } diff --git a/modules/sass/scssphp/Ast/Selector/SimpleSelector.php b/modules/sass/scssphp/Ast/Selector/SimpleSelector.php index c5568167..02189848 100644 --- a/modules/sass/scssphp/Ast/Selector/SimpleSelector.php +++ b/modules/sass/scssphp/Ast/Selector/SimpleSelector.php @@ -21,6 +21,8 @@ /** * An abstract superclass for simple selectors. + * + * @internal */ abstract class SimpleSelector extends Selector { @@ -76,6 +78,7 @@ public function getSpecificity(): int */ public function addSuffix(string $suffix): SimpleSelector { + // TODO use a multi-span exception throw new SassScriptException("Invalid parent selector \"$this\""); } diff --git a/modules/sass/scssphp/Ast/Selector/TypeSelector.php b/modules/sass/scssphp/Ast/Selector/TypeSelector.php index f551a7ea..2d573b91 100644 --- a/modules/sass/scssphp/Ast/Selector/TypeSelector.php +++ b/modules/sass/scssphp/Ast/Selector/TypeSelector.php @@ -13,26 +13,27 @@ namespace Tangible\ScssPhp\Ast\Selector; use Tangible\ScssPhp\Extend\ExtendUtil; +use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Visitor\SelectorVisitor; /** * A type selector. * * This selects elements whose name equals the given name. + * + * @internal */ final class TypeSelector extends SimpleSelector { /** * The element name being selected. - * - * @var QualifiedName - * @readonly */ - private $name; + private readonly QualifiedName $name; - public function __construct(QualifiedName $name) + public function __construct(QualifiedName $name, FileSpan $span) { $this->name = $name; + parent::__construct($span); } public function getName(): QualifiedName @@ -52,7 +53,7 @@ public function accept(SelectorVisitor $visitor) public function addSuffix(string $suffix): SimpleSelector { - return new TypeSelector(new QualifiedName($this->name->getName() . $suffix, $this->name->getNamespace())); + return new TypeSelector(new QualifiedName($this->name->getName() . $suffix, $this->name->getNamespace()), $this->getSpan()); } public function unify(array $compound): ?array diff --git a/modules/sass/scssphp/Ast/Selector/UniversalSelector.php b/modules/sass/scssphp/Ast/Selector/UniversalSelector.php index 02bbfe51..1957e9f0 100644 --- a/modules/sass/scssphp/Ast/Selector/UniversalSelector.php +++ b/modules/sass/scssphp/Ast/Selector/UniversalSelector.php @@ -13,10 +13,13 @@ namespace Tangible\ScssPhp\Ast\Selector; use Tangible\ScssPhp\Extend\ExtendUtil; +use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\Visitor\SelectorVisitor; /** * Matches any element in the given namespace. + * + * @internal */ final class UniversalSelector extends SimpleSelector { @@ -27,15 +30,13 @@ final class UniversalSelector extends SimpleSelector * it's the empty string, this matches all elements that aren't in any * namespace. If it's `*`, this matches all elements in any namespace. * Otherwise, it matches all elements in the given namespace. - * - * @var string|null - * @readonly */ - private $namespace; + private readonly ?string $namespace; - public function __construct(?string $namespace = null) + public function __construct(FileSpan $span, ?string $namespace = null) { $this->namespace = $namespace; + parent::__construct($span); } public function getNamespace(): ?string diff --git a/modules/sass/scssphp/Block/CallableBlock.php b/modules/sass/scssphp/Block/CallableBlock.php index 5762b99c..4c436a00 100644 --- a/modules/sass/scssphp/Block/CallableBlock.php +++ b/modules/sass/scssphp/Block/CallableBlock.php @@ -14,6 +14,7 @@ use Tangible\ScssPhp\Block; use Tangible\ScssPhp\Compiler\Environment; +use Tangible\ScssPhp\Node\Number; /** * @internal @@ -26,7 +27,7 @@ final class CallableBlock extends Block public $name; /** - * @var array|null + * @var list|null */ public $args; diff --git a/modules/sass/scssphp/Collection/Map.php b/modules/sass/scssphp/Collection/Map.php index 7a51c7ea..0f2182b4 100644 --- a/modules/sass/scssphp/Collection/Map.php +++ b/modules/sass/scssphp/Collection/Map.php @@ -27,17 +27,14 @@ */ final class Map implements \Countable, \IteratorAggregate { - /** - * @var bool - */ - private $modifiable = true; + private bool $modifiable = true; // TODO implement a better internal storage to allow reading keys in O(1). /** * @var array */ - private $pairs = []; + private array $pairs = []; /** * Returns a modifiable version of the Map. diff --git a/modules/sass/scssphp/Colors.php b/modules/sass/scssphp/Colors.php index d81a00ce..76f04db9 100644 --- a/modules/sass/scssphp/Colors.php +++ b/modules/sass/scssphp/Colors.php @@ -190,7 +190,7 @@ public static function colorNameToColor(string $colorName): ?SassColor return null; } - return SassColor::rgb($rgba[0], $rgba[1], $rgba[2], $rgba[3] ?? null); + return SassColor::rgb($rgba[0], $rgba[1], $rgba[2], $rgba[3] ?? 1.0); } /** diff --git a/modules/sass/scssphp/Compiler.php b/modules/sass/scssphp/Compiler.php index 261e4728..a97599e4 100644 --- a/modules/sass/scssphp/Compiler.php +++ b/modules/sass/scssphp/Compiler.php @@ -333,10 +333,33 @@ public function setLogger(LoggerInterface $logger): void } /** - * Compile scss + * Compiles the provided scss file into CSS. + * + * @param string $path + * + * @return CompilationResult + * + * @throws SassException when the source fails to compile + */ + public function compileFile($path) + { + $source = file_get_contents($path); + + if ($source === false) { + throw new \RuntimeException('Could not read the file content'); + } + + return $this->compileString($source, $path); + } + + /** + * Compiles the provided scss source code into CSS. + * + * If provided, the path is considered to be the path from which the source code comes + * from, which will be used to resolve relative imports. * * @param string $source - * @param string|null $path + * @param string|null $path The path for the source, used to resolve relative imports * * @return CompilationResult * @@ -386,7 +409,7 @@ public function compileString(string $source, ?string $path = null): Compilation $this->rootEnv = $this->pushEnv($tree); $warnCallback = function ($message, $deprecation) { - $this->logger->warn($message, $deprecation); + $this->logger->warn($message, $deprecation !== null); }; $previousWarnCallback = Warn::setCallback($warnCallback); @@ -421,7 +444,7 @@ public function compileString(string $source, ?string $path = null): Compilation $sourceMap = null; - if (! empty($out) && $this->sourceMap !== self::SOURCE_MAP_NONE) { + if (! empty($out) && $this->sourceMap !== self::SOURCE_MAP_NONE && $this->sourceMap) { assert($sourceMapGenerator !== null); $sourceMap = $sourceMapGenerator->generateJson($prefix); $sourceMapUrl = null; @@ -1380,6 +1403,7 @@ private function filterScopeWithWithout(OutputBlock $scope, array $with, array $ // start from the root while ($scope->parent && $scope->parent->type !== Type::T_ROOT) { array_unshift($childStash, $scope); + \assert($scope->parent !== null); $scope = $scope->parent; } @@ -1954,6 +1978,11 @@ private function collapseSelectors(array $selectors): string foreach ($selector as $node) { $compound = ''; + if (!is_array($node)) { + $output[] = $node; + continue; + } + array_walk_recursive( $node, function ($value, $key) use (&$compound) { @@ -1988,12 +2017,16 @@ private function collapseSelectorsAsList(array $selectors): array foreach ($selector as $node) { $compound = ''; - array_walk_recursive( - $node, - function ($value, $key) use (&$compound) { - $compound .= $value; - } - ); + if (!is_array($node)) { + $compound .= $node; + } else { + array_walk_recursive( + $node, + function ($value, $key) use (&$compound) { + $compound .= $value; + } + ); + } if ($this->isImmediateRelationshipCombinator($compound)) { if (\count($output)) { @@ -2745,7 +2778,7 @@ private function compileChild($child, OutputBlock $out) { if (isset($child[Parser::SOURCE_LINE])) { $this->sourceIndex = isset($child[Parser::SOURCE_INDEX]) ? $child[Parser::SOURCE_INDEX] : null; - $this->sourceLine = isset($child[Parser::SOURCE_LINE]) ? $child[Parser::SOURCE_LINE] : -1; + $this->sourceLine = $child[Parser::SOURCE_LINE]; $this->sourceColumn = isset($child[Parser::SOURCE_COLUMN]) ? $child[Parser::SOURCE_COLUMN] : -1; } elseif (\is_array($child) && isset($child[1]->sourceLine) && $child[1] instanceof Block) { $this->sourceIndex = $child[1]->sourceIndex; @@ -4278,8 +4311,10 @@ public function compileValue($value, bool $quote = true): string return $colorName; } - if (is_numeric($alpha)) { + if (\is_int($alpha) || \is_float($alpha)) { $a = new Number($alpha, ''); + } elseif (is_numeric($alpha)) { + $a = new Number((float) $alpha, ''); } else { $a = $alpha; } @@ -5462,15 +5497,15 @@ private function checkImportPathConflicts(array $paths): ?string private function tryImportPathWithExtensions(string $path): array { $result = array_merge( - $this->tryImportPath($path.'.sass'), - $this->tryImportPath($path.'.scss') + $this->tryImportPath($path . '.sass'), + $this->tryImportPath($path . '.scss') ); if ($result) { return $result; } - return $this->tryImportPath($path.'.css'); + return $this->tryImportPath($path . '.css'); } /** @@ -5480,7 +5515,7 @@ private function tryImportPathWithExtensions(string $path): array */ private function tryImportPath(string $path): array { - $partial = dirname($path).'/_'.basename($path); + $partial = dirname($path) . '/_' . basename($path); $candidates = []; @@ -5506,7 +5541,7 @@ private function tryImportPathAsDirectory(string $path): ?string return null; } - return $this->checkImportPathConflicts($this->tryImportPathWithExtensions($path.'/index')); + return $this->checkImportPathConflicts($this->tryImportPathWithExtensions($path . '/index')); } /** @@ -5521,7 +5556,7 @@ private function getPrettyPath(?string $path): string } $normalizedPath = $path; - $normalizedRootDirectory = $this->rootDirectory.'/'; + $normalizedRootDirectory = $this->rootDirectory . '/'; if (\DIRECTORY_SEPARATOR === '\\') { $normalizedRootDirectory = str_replace('\\', '/', $normalizedRootDirectory); @@ -5933,7 +5968,7 @@ private function parseFunctionPrototype(array $prototype): array * * @return array * - * @phpstan-param non-empty-list, rest_argument: string|null}> $prototypes + * @phpstan-param non-empty-array, rest_argument: string|null}> $prototypes * @phpstan-return array{arguments: list, rest_argument: string|null} */ private function selectFunctionPrototype(array $prototypes, int $positional, array $names): array @@ -6391,10 +6426,14 @@ private function coerceValue($value) return self::$null; } - if (is_numeric($value)) { + if (\is_int($value) || \is_float($value)) { return new Number($value, ''); } + if (is_numeric($value)) { + return new Number((float) $value, ''); + } + if ($value === '') { return self::$emptyString; } @@ -7040,9 +7079,9 @@ private function HWBtoRGB($hue, $whiteness, $blackness) $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); + for ($i = 1; $i < 4; $i++) { + $rgb[$i] *= (1.0 - $w - $b); + $rgb[$i] = round($rgb[$i] + 255 * $w + 0.0001); } return $rgb; @@ -7067,7 +7106,6 @@ private function RGBtoHWB($red, $green, $blue) if ((int) $d === 0) { $h = 0; } else { - if ($red == $max) { $h = 60 * ($green - $blue) / $d; } elseif ($green == $max) { @@ -7077,7 +7115,7 @@ private function RGBtoHWB($red, $green, $blue) } } - return [Type::T_HWB, fmod($h, 360), $min / 255 * 100, 100 - $max / 255 *100]; + return [Type::T_HWB, fmod($h, 360), $min / 255 * 100, 100 - $max / 255 * 100]; } @@ -7286,7 +7324,13 @@ private function alterColor(array $args, string $operation, callable $fn): array $scale = $operation === 'scale'; $change = $operation === 'change'; - /** @phpstan-var callable(string, float|int, bool=, bool=): (float|int|null) $getParam */ + /** + * @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; @@ -7428,7 +7472,7 @@ private function libAdjustColor($args) private static $libChangeColor = ['color', 'kwargs...']; private function libChangeColor($args) { - return $this->alterColor($args,'change', function ($base, $alter, $max) { + return $this->alterColor($args, 'change', function ($base, $alter, $max) { if ($alter === null) { return $base; } diff --git a/modules/sass/scssphp/Deprecation.php b/modules/sass/scssphp/Deprecation.php new file mode 100644 index 00000000..585bd06f --- /dev/null +++ b/modules/sass/scssphp/Deprecation.php @@ -0,0 +1,133 @@ + 'Passing a string directly to meta.call().', + self::elseif => '@elseif.', + self::mozDocument => '@-moz-document.', + self::newGlobal => 'Declaring new variables with !global.', + self::slashDiv => '/ operator for division.', + self::bogusCombinators => 'Leading, trailing, and repeated combinators.', + self::strictUnary => 'Ambiguous + and - operators.', + self::functionUnits => 'Passing invalid units to built-in functions.', + self::absPercent => 'Passing percentages to the Sass abs() function.', + self::duplicateVariableFlags => 'Using !default or !global multiple times for one variable.', + self::userAuthored => null, + }; + } + + /** + * The version in which this feature was first deprecated. + */ + public function getDeprecatedIn(): ?string + { + return match ($this) { + self::callString => '1.2.0', + self::elseif => '2.0.0', + self::mozDocument => '2.0.0', + self::newGlobal => '2.0.0', + self::slashDiv => null, + self::bogusCombinators => '2.0.0', + self::strictUnary => '2.0.0', + self::functionUnits => '2.0.0', + self::absPercent => '2.0.0', + self::duplicateVariableFlags => '2.0.0', + self::userAuthored => null, + }; + } + + public function isFuture(): bool + { + if ($this === self::userAuthored) { + return false; + } + + return $this->getDeprecatedIn() === null; + } + + public function getStatus(): DeprecationStatus + { + if ($this === self::userAuthored) { + return DeprecationStatus::user; + } + + if ($this->isFuture()) { + return DeprecationStatus::future; + } + + return DeprecationStatus::active; + } +} diff --git a/modules/sass/scssphp/DeprecationStatus.php b/modules/sass/scssphp/DeprecationStatus.php new file mode 100644 index 00000000..e28d2e7a --- /dev/null +++ b/modules/sass/scssphp/DeprecationStatus.php @@ -0,0 +1,11 @@ + + * + * @internal + */ +final class ComplexSelectorMap extends \SplObjectStorage +{ + public function getHash(object $object): string + { + \assert($object instanceof ComplexSelector); + // For ComplexSelector, selectors that are equal by value semantic are exactly the ones that have the same string representation. + return (string) $object; + } + + /** + * @return iterable + */ + public function getValues(): iterable + { + foreach ($this as $selector) { + yield $this[$selector]; + } + } +} diff --git a/modules/sass/scssphp/Extend/ConcreteExtensionStore.php b/modules/sass/scssphp/Extend/ConcreteExtensionStore.php new file mode 100644 index 00000000..55f8d0e3 --- /dev/null +++ b/modules/sass/scssphp/Extend/ConcreteExtensionStore.php @@ -0,0 +1,1242 @@ +>> + */ + private readonly SimpleSelectorMap $selectors; + /** + * A map from all extended simple selectors to the sources of those + * extensions. + * + * @var SimpleSelectorMap> + */ + private SimpleSelectorMap $extensions; + /** + * A map from all simple selectors in extenders to the extensions that those + * extenders define. + * + * @var SimpleSelectorMap> + */ + private SimpleSelectorMap $extensionsByExtender; + /** + * A map from CSS selectors to the media query contexts they're defined in. + * + * This tracks the contexts in which each selector's style rule is defined. + * If a rule is defined at the top level, it doesn't have an entry. + * + * @var \SplObjectStorage, list> + */ + private readonly \SplObjectStorage $mediaContexts; + /** + * @var \SplObjectStorage + */ + private \SplObjectStorage $sourceSpecificity; + /** + * @var \SplObjectStorage + */ + private readonly \SplObjectStorage $originals; + private readonly ExtendMode $mode; + + /** + * Extends $selector with $source extender and $targets extendees. + * + * This works as though `source {@extend target}` were written in the + * stylesheet, with the exception that $target can contain compound + * selectors which must be extended as a unit. + */ + public static function extend(SelectorList $selector, SelectorList $source, SelectorList $targets, FileSpan $span): SelectorList + { + return self::extendOrReplace($selector, $source, $targets, ExtendMode::allTargets, $span); + } + + /** + * Returns a copy of $selector with $targets replaced by $source. + */ + public static function replace(SelectorList $selector, SelectorList $source, SelectorList $targets, FileSpan $span): SelectorList + { + return self::extendOrReplace($selector, $source, $targets, ExtendMode::replace, $span); + } + + /** + * A helper function for {@see extend} and {@see replace}. + */ + private static function extendOrReplace(SelectorList $selector, SelectorList $source, SelectorList $targets, ExtendMode $mode, FileSpan $span): SelectorList + { + $extender = ConcreteExtensionStore::createForMode($mode); + + if (!$selector->isInvisible()) { + foreach ($selector->getComponents() as $component) { + $extender->originals->attach($component); + } + } + + foreach ($targets->getComponents() as $complex) { + $compound = $complex->getSingleCompound(); + + if ($compound === null) { + throw new SassScriptException("Can't extend complex selector $complex."); + } + + $extensions = new SimpleSelectorMap(); + foreach ($compound->getComponents() as $simple) { + $extensionMap = new ComplexSelectorMap(); + + foreach ($source->getComponents() as $sourceComplex) { + $extensionMap[$sourceComplex] = new Extension($sourceComplex, $simple, $span, optional: true); + } + + $extensions[$simple] = $extensionMap; + } + + $selector = $extender->extendList($selector, $extensions); + } + + return $selector; + } + + /** + * @param SimpleSelectorMap>> $selectors + * @param SimpleSelectorMap> $extensions + * @param SimpleSelectorMap> $extensionsByExtender + * @param \SplObjectStorage, list> $mediaContexts + * @param \SplObjectStorage $sourceSpecificity + * @param \SplObjectStorage $originals + */ + private function __construct( + SimpleSelectorMap $selectors, + SimpleSelectorMap $extensions, + SimpleSelectorMap $extensionsByExtender, + \SplObjectStorage $mediaContexts, + \SplObjectStorage $sourceSpecificity, + \SplObjectStorage $originals, + ExtendMode $mode, + ) { + $this->selectors = $selectors; + $this->extensions = $extensions; + $this->extensionsByExtender = $extensionsByExtender; + $this->mediaContexts = $mediaContexts; + $this->sourceSpecificity = $sourceSpecificity; + $this->originals = $originals; + $this->mode = $mode; + } + + public static function create(): self + { + return self::createForMode(ExtendMode::normal); + } + + private static function createForMode(ExtendMode $mode): self + { + /** @var \SplObjectStorage, list> $mediaContexts */ + $mediaContexts = new \SplObjectStorage(); + /** @var \SplObjectStorage $sourceSpecificity */ + $sourceSpecificity = new \SplObjectStorage(); + /** @var \SplObjectStorage $originals */ + $originals = new \SplObjectStorage(); + + return new self( + new SimpleSelectorMap(), + new SimpleSelectorMap(), + new SimpleSelectorMap(), + $mediaContexts, + $sourceSpecificity, + $originals, + $mode, + ); + } + + public function isEmpty(): bool + { + return \count($this->extensions) === 0; + } + + public function getSimpleSelectors(): array + { + return iterator_to_array($this->selectors); + } + + public function extensionsWhereTarget(callable $callback): iterable + { + foreach ($this->extensions as $simple) { + if (!$callback($simple)) { + continue; + } + + $sources = $this->extensions[$simple]; + + foreach ($sources->getValues() as $extension) { + if ($extension instanceof MergedExtension) { + foreach ($extension->unmerge() as $leafExtension) { + if (!$leafExtension->isOptional) { + yield $leafExtension; + } + } + } elseif (!$extension->isOptional) { + yield $extension; + } + } + } + } + + public function addSelector(SelectorList $selector, ?array $mediaContext): Box + { + $originalSelector = $selector; + + if (!$originalSelector->isInvisible()) { + foreach ($originalSelector->getComponents() as $component) { + $this->originals->attach($component); + } + } + + if (\count($this->extensions) !== 0) { + try { + $selector = $this->extendList($originalSelector, $this->extensions, $mediaContext); + } catch (SassException $e) { + // TODO + throw $e; + } + } + + $modifiableSelector = new ModifiableBox($selector); + + if ($mediaContext !== null) { + $this->mediaContexts->attach($modifiableSelector, $mediaContext); + } + + $this->registerSelector($selector, $modifiableSelector); + + return $modifiableSelector->seal(); + } + + /** + * Registers the {@see SimpleSelector}s in $list to point to $selector in + * {@see selectors}. + * + * @param ModifiableBox $selector + */ + private function registerSelector(SelectorList $list, ModifiableBox $selector): void + { + foreach ($list->getComponents() as $complex) { + foreach ($complex->getComponents() as $component) { + foreach ($component->getSelector()->getComponents() as $simple) { + if (!isset($this->selectors[$simple])) { + /** @var ObjectSet> $set */ + $set = new ObjectSet(); + $this->selectors->attach($simple, $set); + } + $this->selectors[$simple]->add($selector); + + if ($simple instanceof PseudoSelector && $simple->getSelector() !== null) { + $this->registerSelector($simple->getSelector(), $selector); + } + } + } + } + } + + public function addExtension(SelectorList $extender, SimpleSelector $target, ExtendRule $extend, ?array $mediaContext): void + { + $selectors = $this->selectors[$target] ?? null; + $existingExtensions = $this->extensionsByExtender[$target] ?? null; + + $newExtensions = null; + $sources = $this->extensions[$target] ??= new ComplexSelectorMap(); + + foreach ($extender->getComponents() as $complex) { + if ($complex->isUseless()) { + continue; + } + + $extension = new Extension($complex, $target, $extend->getSpan(), $mediaContext, $extend->isOptional()); + + $existingExtension = $sources[$complex] ?? null; + + if ($existingExtension !== null) { + // If there's already an extend from $extender to $target, we don't need + // to re-run the extension. We may need to mark the extension as + // mandatory, though. + $sources[$complex] = MergedExtension::merge($existingExtension, $extension); + continue; + } + + $sources[$complex] = $extension; + + foreach ($this->simpleSelectors($complex) as $simple) { + $this->extensionsByExtender[$simple] ??= []; + $this->extensionsByExtender[$simple][] = $extension; + + // Only source specificity for the original selector is relevant. + // Selectors generated by `@extend` don't get new specificity. + $this->sourceSpecificity[$simple] ??= $complex->getSpecificity(); + } + + if ($selectors !== null || $existingExtensions !== null) { + /** @var ComplexSelectorMap $newExtensions */ + $newExtensions ??= new ComplexSelectorMap(); + $newExtensions[$complex] = $extension; + } + } + + if ($newExtensions === null) { + return; + } + + /** @var SimpleSelectorMap> $newExtensionsByTarget */ + $newExtensionsByTarget = new SimpleSelectorMap(); + $newExtensionsByTarget[$target] = $newExtensions; + + if ($existingExtensions !== null) { + $additionalExtensions = $this->extendExistingExtensions($existingExtensions, $newExtensionsByTarget); + + if ($additionalExtensions !== null) { + Util::mapAddAll2($newExtensionsByTarget, $additionalExtensions); + } + } + + if ($selectors !== null) { + $this->extendExistingSelectors($selectors, $newExtensionsByTarget); + } + } + + /** + * Returns an iterable of all simple selectors in $complex. + * + * @return iterable + */ + private function simpleSelectors(ComplexSelector $complex): iterable + { + foreach ($complex->getComponents() as $component) { + foreach ($component->getSelector()->getComponents() as $simple) { + yield $simple; + + if ($simple instanceof PseudoSelector && $simple->getSelector() !== null) { + foreach ($simple->getSelector()->getComponents() as $pseudoComplex) { + yield from $this->simpleSelectors($pseudoComplex); + } + } + } + } + } + + /** + * Extend $extensions using $newExtensions. + * + * Note that this does duplicate some work done by + * {@see extendExistingSelectors}, but it's necessary to expand each extension's + * extender separately without reference to the full selector list, so that + * relevant results don't get trimmed too early. + * + * Returns extensions that should be added to $newExtensions before + * extending selectors in order to properly handle extension loops such as: + * + * .c {x: y; @extend .a} + * .x.y.a {@extend .b} + * .z.b {@extend .c} + * + * Returns `null` if there are no extensions to add. + * + * @param list $extensions + * @param SimpleSelectorMap> $newExtensions + * @return SimpleSelectorMap>|null + */ + private function extendExistingExtensions(array $extensions, SimpleSelectorMap $newExtensions): ?SimpleSelectorMap + { + $additionalExtensions = null; + + foreach ($extensions as $extension) { + $sources = $this->extensions[$extension->target]; + + try { + $selectors = $this->extendComplex($extension->extender->selector, $newExtensions, $extension->mediaContext); + + if ($selectors === null) { + continue; + } + } catch (SassException $e) { + // TODO add additional span + throw $e; + } + + // If the output contains the original complex selector, there's no need + // to recreate it. + $containsExtension = EquatableUtil::equals($selectors[0], $extension->extender->selector); + if ($containsExtension) { + $selectors = array_slice($selectors, 1); + } + + foreach ($selectors as $complex) { + $withExtender = $extension->withExtender($complex); + + $existingExtension = $sources[$complex] ?? null; + if ($existingExtension !== null) { + $sources[$complex] = MergedExtension::merge($existingExtension, $withExtender); + } else { + $sources[$complex] = $withExtender; + + foreach ($complex->getComponents() as $component) { + foreach ($component->getSelector()->getComponents() as $simple) { + $this->extensionsByExtender[$simple] ??= []; + $this->extensionsByExtender[$simple][] = $withExtender; + } + } + + if ($newExtensions->contains($extension->target)) { + /** @var SimpleSelectorMap> $additionalExtensions */ + $additionalExtensions ??= new SimpleSelectorMap(); + + if (!isset($additionalExtensions[$extension->target])) { + /** @var ComplexSelectorMap $additionalSources */ + $additionalSources = new ComplexSelectorMap(); + $additionalExtensions[$extension->target] = $additionalSources; + } else { + $additionalSources = $additionalExtensions[$extension->target]; + } + + $additionalSources[$complex] = $withExtender; + } + } + } + } + + return $additionalExtensions; + } + + /** + * Extend $selectors using $newExtensions. + * + * @param ObjectSet> $selectors + * @param SimpleSelectorMap> $newExtensions + */ + private function extendExistingSelectors(ObjectSet $selectors, SimpleSelectorMap $newExtensions): void + { + foreach ($selectors as $selector) { + $oldValue = $selector->getValue(); + + try { + $selector->setValue($this->extendList($selector->getValue(), $newExtensions, $this->mediaContexts[$selector])); + } catch (SassException $e) { + // TODO throw the appropriate exception. + throw $e; + } + + // If no extends actually happened (for example because unification + // failed), we don't need to re-register the selector. + if ($oldValue === $selector->getValue()) { + continue; + } + $this->registerSelector($selector->getValue(), $selector); + } + } + + /** + * @param iterable $extensionStores + */ + public function addExtensions(iterable $extensionStores): void + { + /** @var list|null $extensionsToExtend */ + $extensionsToExtend = null; + $selectorsToExtend = null; + $newExtensions = null; + + foreach ($extensionStores as $extensionStore) { + if ($extensionStore->isEmpty()) { + continue; + } + \assert($extensionStore instanceof ConcreteExtensionStore); + $this->sourceSpecificity->addAll($extensionStore->sourceSpecificity); + + foreach ($extensionStore->extensions as $target) { + $newSources = $extensionStore->extensions->getInfo(); + + // Private selectors can't be extended across module boundaries. + if ($target instanceof PlaceholderSelector && $target->isPrivate()) { + continue; + } + + $extensionsForTarget = $this->extensionsByExtender[$target] ?? null; + if ($extensionsForTarget !== null) { + $extensionsToExtend ??= []; + array_push($extensionsToExtend, ...$extensionsForTarget); + } + + // Find existing selectors to extend. + $selectorsForTarget = $this->selectors[$target] ?? null; + if ($selectorsForTarget !== null) { + if ($selectorsToExtend === null) { + /** @var ObjectSet> $selectorsToExtend */ + $selectorsToExtend = new ObjectSet(); + } + $selectorsToExtend->addAll($selectorsForTarget); + } + + $existingSources = $this->extensions[$target] ?? null; + if ($existingSources !== null) { + foreach ($newSources as $extender) { + $extension = $newSources->getInfo(); + + if (isset($existingSources[$extender])) { + $extension = MergedExtension::merge($existingSources[$extender], $extension); + } + $existingSources[$extender] = $extension; + + if ($extensionsForTarget !== null || $selectorsForTarget !== null) { + /** @var SimpleSelectorMap> $newExtensions */ + $newExtensions ??= new SimpleSelectorMap(); + + if (!isset($newExtensions[$target])) { + /** @var ComplexSelectorMap $newMap */ + $newMap = new ComplexSelectorMap(); + $newExtensions[$target] = $newMap; + } + $newExtensions[$target][$extender] = $extension; + } + } + } else { + $this->extensions[$target] = clone $newSources; + if ($extensionsForTarget !== null || $selectorsForTarget !== null) { + /** @var SimpleSelectorMap> $newExtensions */ + $newExtensions ??= new SimpleSelectorMap(); + $newExtensions[$target] = clone $newSources; + } + } + } + } + + if ($newExtensions !== null) { + // We can ignore the return value here because it's only useful for extend + // loops, which can't exist across module boundaries. + if ($extensionsToExtend !== null) { + $this->extendExistingExtensions($extensionsToExtend, $newExtensions); + } + + if ($selectorsToExtend !== null) { + $this->extendExistingSelectors($selectorsToExtend, $newExtensions); + } + } + } + + /** + * Extends $list using $extensions. + * + * @param SimpleSelectorMap> $extensions + * @param list|null $mediaQueryContext + */ + private function extendList(SelectorList $list, SimpleSelectorMap $extensions, ?array $mediaQueryContext = null): SelectorList + { + $extended = null; + + foreach ($list->getComponents() as $i => $complex) { + $result = $this->extendComplex($complex, $extensions, $mediaQueryContext); + + \assert($result === null || \count($result) > 0, "extendComplex($complex) should return null rather than [] if extension fails."); + + if ($result === null) { + if ($extended !== null) { + $extended[] = $complex; + } + } else { + $extended ??= $i === 0 ? [] : array_slice($list->getComponents(), 0, $i); + array_push($extended, ...$result); + } + } + + if ($extended === null) { + return $list; + } + + return new SelectorList($this->trim($extended, $this->originals->contains(...)), $list->getSpan()); + } + + /** + * Extends $complex using $extensions, and returns the contents of a + * {@see SelectorList}. + * + * @param SimpleSelectorMap> $extensions + * @param list|null $mediaQueryContext + * @return list|null + */ + private function extendComplex(ComplexSelector $complex, SimpleSelectorMap $extensions, ?array $mediaQueryContext): ?array + { + if (\count($complex->getLeadingCombinators()) > 1) { + return null; + } + + // The complex selectors that each compound selector in $complex->getComponents() + // can expand to. + // + // For example, given + // + // .a .b {...} + // .x .y {@extend .b} + // + // this will contain + // + // [ + // [.a], + // [.b, .x .y] + // ] + // + $extendedNotExpanded = null; + $isOriginal = $this->originals->contains($complex); + + foreach ($complex->getComponents() as $i => $component) { + $extended = $this->extendCompound($component, $extensions, $mediaQueryContext, $isOriginal); + + \assert($extended === null || \count($extended) > 0, "extendCompound($component) should return null rather than [] if extension fails."); + + if ($extended === null) { + if ($extendedNotExpanded !== null) { + $extendedNotExpanded[] = [ + new ComplexSelector( + [], + [$component], + $complex->getSpan(), + $complex->getLineBreak() + ), + ]; + } + } elseif ($extendedNotExpanded !== null) { + $extendedNotExpanded[] = $extended; + } elseif ($i !== 0) { + $extendedNotExpanded = [ + [ + new ComplexSelector( + $complex->getLeadingCombinators(), + array_slice($complex->getComponents(), 0, $i), + $complex->getSpan(), + $complex->getLineBreak(), + ), + ], + $extended, + ]; + } elseif (\count($complex->getLeadingCombinators()) === 0) { + $extendedNotExpanded = [$extended]; + } else { + $newExtended = []; + + foreach ($extended as $newComplex) { + if ( + \count($newComplex->getLeadingCombinators()) === 0 + || EquatableUtil::listEquals($complex->getLeadingCombinators(), $newComplex->getLeadingCombinators()) + ) { + $newExtended[] = new ComplexSelector( + $complex->getLeadingCombinators(), + $newComplex->getComponents(), + $complex->getSpan(), + $complex->getLineBreak() || $newComplex->getLineBreak(), + ); + } + } + + $extendedNotExpanded = [$newExtended]; + } + } + + if ($extendedNotExpanded === null) { + return null; + } + + $first = true; + + return iterator_to_array(self::expandIterable(ExtendUtil::paths($extendedNotExpanded), function ($path) use (&$first, $complex) { + return array_map(function (ComplexSelector $outputComplex) use (&$first, $complex) { + // Make sure that copies of $complex retain their status as "original" + // selectors. This includes selectors that are modified because a :not() + // was extended into. + if ($first && $this->originals->contains($complex)) { + $this->originals->attach($outputComplex); + } + + $first = false; + + return $outputComplex; + }, ExtendUtil::weave($path, $complex->getSpan(), $complex->getLineBreak())); + }), false); + } + + /** + * Extends $component using $extensions, and returns the contents of a + * {@see SelectorList}. + * + * The $inOriginal parameter indicates whether this is in an original + * complex selector, meaning that the compound should not be trimmed out. + * + * @param SimpleSelectorMap> $extensions + * @param list|null $mediaQueryContext + * @return list|null + */ + private function extendCompound(ComplexSelectorComponent $component, SimpleSelectorMap $extensions, ?array $mediaQueryContext, bool $inOriginal): ?array + { + // If there's more than one target and they all need to match, we track + // which targets are actually extended. + $targetsUsed = $this->mode === ExtendMode::normal || \count($extensions) < 2 ? null : new SimpleSelectorMap(); + + $simples = $component->getSelector()->getComponents(); + + // The complex selectors produced from each simple selector in the compound selector. + $options = null; + + foreach ($simples as $i => $simple) { + $extended = $this->extendSimple($simple, $extensions, $mediaQueryContext, $targetsUsed); + + \assert($extended === null || \count($extended) > 0, "extendSimple($simple) should return null rather than [] if extension fails."); + + if ($extended === null) { + if ($options !== null) { + $options[] = [$this->extenderForSimple($simple)]; + } + } else { + if ($options === null) { + $options = []; + + if ($i !== 0) { + $options[] = [$this->extenderForCompound(array_slice($simples, 0, $i), $component->getSpan())]; + } + } + + array_push($options, ...$extended); + } + } + + if ($options === null) { + return null; + } + + /** + * If {@see mode} isn't {@see ExtendMode::normal} and we didn't use all the targets in + * $extensions, extension fails for $component. + */ + if ($targetsUsed !== null && \count($targetsUsed) !== \count($extensions)) { + return null; + } + + // Optimize for the simple case of a single simple selector that doesn't + // need any unification. + if (\count($options) === 1) { + $extenders = $options[0]; + $result = null; + foreach ($extenders as $extender) { + $extender->assertCompatibleMediaContext($mediaQueryContext); + + $complex = $extender->selector->withAdditionalCombinators($component->getCombinators()); + if ($complex->isUseless()) { + continue; + } + + $result ??= []; + $result[] = $complex; + } + + return $result; + } + + // Find all paths through $options. In this case, each path represents a + // different unification of the base selector. For example, if we have: + // + // .a.b {...} + // .w .x {@extend .a} + // .y .z {@extend .b} + // + // then $options is `[[.a, .w .x], [.b, .y .z]]` and `paths($options)` is + // + // [ + // [.a, .b], + // [.a, .y .z], + // [.w .x, .b], + // [.w .x, .y .z] + // ] + // + // We then unify each path to get a list of complex selectors: + // + // [ + // [.a.b], + // [.y .a.z], + // [.w .x.b], + // [.w .y .x.z, .y .w .x.z] + // ] + // + // And finally flatten them to get: + // + // [ + // .a.b, + // .y .a.z, + // .w .x.b, + // .w .y .x.z, + // .y .w .x.z + // ] + $extenderPaths = ExtendUtil::paths($options); + + $result = []; + + if ($this->mode !== ExtendMode::replace) { + // The first path is always the original selector. We can't just return + // $component directly because selector pseudos may be modified, but we + // don't have to do any unification. + $result[] = new ComplexSelector([], [new ComplexSelectorComponent( + new CompoundSelector(iterator_to_array(self::expandIterable($extenderPaths[0], function (Extender $extender) { + \assert(\count($extender->selector->getComponents()) === 1); + return $extender->selector->getComponents()[0]->getSelector()->getComponents(); + }), false), $component->getSelector()->getSpan()), + $component->getCombinators(), + $component->getSpan(), + )], $component->getSpan()); + } + + foreach (array_slice($extenderPaths, $this->mode === ExtendMode::replace ? 0 : 1) as $path) { + $extended = $this->unifyExtenders($path, $mediaQueryContext, $component->getSpan()); + + if ($extended === null) { + continue; + } + + foreach ($extended as $complex) { + $withCombinators = $complex->withAdditionalCombinators($component->getCombinators()); + + if (!$withCombinators->isUseless()) { + $result[] = $withCombinators; + } + } + } + + // If we're preserving the original selector, mark the first unification as + // such so {@see trim} doesn't get rid of it. + $isOriginal = fn (ComplexSelector $complex) => false; + if ($inOriginal && $this->mode !== ExtendMode::replace) { + $original = $result[0]; + $isOriginal = fn (ComplexSelector $complex) => EquatableUtil::equals($complex, $original); + } + + return $this->trim($result, $isOriginal); + } + + /** + * Returns a list of {@see ComplexSelector}s that match the intersection of + * elements matched by all of $extenders' selectors. + * + * The $span will be used for the new selectors. + * + * @param list $extenders + * @param list|null $mediaQueryContext + * @return list|null + */ + private function unifyExtenders(array $extenders, ?array $mediaQueryContext, FileSpan $span): ?array + { + $toUnify = []; + $originals = null; + $originalsLineBreak = false; + + foreach ($extenders as $extender) { + if ($extender->isOriginal) { + $originals ??= []; + $finalExtenderComponent = ListUtil::last($extender->selector->getComponents()); + \assert(\count($finalExtenderComponent->getCombinators()) === 0); + + foreach ($finalExtenderComponent->getSelector()->getComponents() as $component) { + $originals[] = $component; + } + $originalsLineBreak = $originalsLineBreak || $extender->selector->getLineBreak(); + } elseif ($extender->selector->isUseless()) { + return null; + } else { + $toUnify[] = $extender->selector; + } + } + + if ($originals !== null) { + array_unshift($toUnify, new ComplexSelector([], [ + new ComplexSelectorComponent(new CompoundSelector($originals, $span), [], $span), + ], $span, $originalsLineBreak)); + } + + $complexes = ExtendUtil::unifyComplex($toUnify, $span); + if ($complexes === null) { + return null; + } + + foreach ($extenders as $extender) { + $extender->assertCompatibleMediaContext($mediaQueryContext); + } + + return $complexes; + } + + /** + * @param SimpleSelectorMap> $extensions + * @param list|null $mediaQueryContext + * @param SimpleSelectorMap|null $targetsUsed + * @return list>|null + */ + private function extendSimple(SimpleSelector $simple, SimpleSelectorMap $extensions, ?array $mediaQueryContext, ?SimpleSelectorMap $targetsUsed): ?array + { + // Extends $simple without extending the contents of any selector pseudos + // it contains. + $withoutPseudo = function (SimpleSelector $simple) use ($extensions, $targetsUsed) { + $extensionsForSimple = $extensions[$simple] ?? null; + + if ($extensionsForSimple === null) { + return null; + } + $targetsUsed?->attach($simple); + + $result = []; + + if ($this->mode !== ExtendMode::replace) { + $result[] = $this->extenderForSimple($simple); + } + + /** @var Extension $extension */ + foreach ($extensionsForSimple->getValues() as $extension) { + $result[] = $extension->extender; + } + + return $result; + }; + + if ($simple instanceof PseudoSelector && $simple->getSelector() !== null) { + $extended = $this->extendPseudo($simple, $extensions, $mediaQueryContext); + + if ($extended !== null) { + return array_map(fn ($pseudo) => $withoutPseudo($pseudo) ?? [$this->extenderForSimple($pseudo)], $extended); + } + } + + $result = $withoutPseudo($simple); + + if ($result === null) { + return null; + } + + return [$result]; + } + + /** + * Returns an {@see Extender} composed solely of a compound selector containing + * $simples. + * + * @param list $simples + */ + private function extenderForCompound(array $simples, FileSpan $span): Extender + { + $compound = new CompoundSelector($simples, $span); + + return Extender::create( + new ComplexSelector([], [new ComplexSelectorComponent($compound, [], $span)], $span), + $this->sourceSpecificityFor($compound), + true, + ); + } + + /** + * Returns an {@see Extender} composed solely of $simple. + */ + private function extenderForSimple(SimpleSelector $simple): Extender + { + return Extender::create( + new ComplexSelector([], [new ComplexSelectorComponent( + new CompoundSelector([$simple], $simple->getSpan()), + [], + $simple->getSpan(), + )], $simple->getSpan()), + $this->sourceSpecificity[$simple] ?? 0, + true, + ); + } + + /** + * Extends $pseudo using $extensions, and returns a list of resulting + * pseudo selectors. + * + * This requires that $pseudo have a selector argument. + * + * @param SimpleSelectorMap> $extensions + * @param list|null $mediaQueryContext + * @return list|null + */ + private function extendPseudo(PseudoSelector $pseudo, SimpleSelectorMap $extensions, ?array $mediaQueryContext): ?array + { + $selector = $pseudo->getSelector(); + + if ($selector === null) { + throw new \InvalidArgumentException("Selector $pseudo must have a selector argument."); + } + + $extended = $this->extendList($selector, $extensions, $mediaQueryContext); + + if ($extended === $selector) { + return null; + } + + // For `:not()`, we usually want to get rid of any complex selectors because + // that will cause the selector to fail to parse on all browsers at time of + // writing. We can keep them if either the original selector had a complex + // selector, or the result of extending has only complex selectors, because + // either way we aren't breaking anything that isn't already broken. + $complexes = $extended->getComponents(); + if ( + $pseudo->getNormalizedName() === 'not' + && !IterableUtil::any($selector->getComponents(), fn ($complex) => \count($complex->getComponents()) > 1) + && IterableUtil::any($extended->getComponents(), fn ($complex) => \count($complex->getComponents()) === 1) + ) { + $complexes = array_filter($extended->getComponents(), fn ($complex) => \count($complex->getComponents()) <= 1); + } + + $complexes = iterator_to_array(self::expandIterable($complexes, function (ComplexSelector $complex) use ($pseudo) { + $innerPseudo = $complex->getSingleCompound()?->getSingleSimple(); + if (!$innerPseudo instanceof PseudoSelector) { + return [$complex]; + } + + $innerSelector = $innerPseudo->getSelector(); + if ($innerSelector === null) { + return [$complex]; + } + + switch ($pseudo->getNormalizedName()) { + case 'not': + // In theory, if there's a `:not` nested within another `:not`, the + // inner `:not`'s contents should be unified with the return value. + // For example, if `:not(.foo)` extends `.bar`, `:not(.bar)` should + // become `.foo:not(.bar)`. However, this is a narrow edge case and + // supporting it properly would make this code and the code calling it + // a lot more complicated, so it's not supported for now. + if (!\in_array($innerPseudo->getNormalizedName(), ['is', 'matches', 'where'], true)) { + return []; + } + + return $innerSelector->getComponents(); + + case 'is': + case 'matches': + case 'where': + case 'any': + case 'current': + case 'nth-child': + case 'nth-last-child': + // As above, we could theoretically support :not within :matches, but + // doing so would require this method and its callers to handle much + // more complex cases that likely aren't worth the pain. + if ($innerPseudo->getName() !== $pseudo->getName()) { + return []; + } + if ($innerPseudo->getArgument() !== $pseudo->getArgument()) { + return []; + } + + return $innerSelector->getComponents(); + + case 'has': + case 'host': + case 'host-context': + case 'slotted': + // We can't expand nested selectors here, because each layer adds an + // additional layer of semantics. For example, `:has(:has(img))` + // doesn't match `
` but `:has(img)` does. + return [$complex]; + + default: + return []; + } + }), false); + + // Older browsers support `:not`, but only with a single complex selector. + // In order to support those browsers, we break up the contents of a `:not` + // unless it originally contained a selector list. + if ($pseudo->getNormalizedName() === 'not' && \count($selector->getComponents()) === 1) { + $result = array_map(fn (ComplexSelector $complex) => $pseudo->withSelector(new SelectorList([$complex], $selector->getSpan())), $complexes); + + return \count($result) === 0 ? null : $result; + } + + return [$pseudo->withSelector(new SelectorList($complexes, $selector->getSpan()))]; + } + + /** + * @template E + * @template T + * @param iterable $elements + * @param callable(E): iterable $callback + * @return \Traversable + */ + private static function expandIterable(iterable $elements, callable $callback): \Traversable + { + foreach ($elements as $element) { + yield from $callback($element); + } + } + + /** + * Removes elements from $selectors if they're subselectors of other + * elements. + * + * The $isOriginal callback indicates which selectors are original to the + * document, and thus should never be trimmed. + * + * @param list $selectors + * @param callable(ComplexSelector): bool $isOriginal + * @return list + */ + private function trim(array $selectors, callable $isOriginal): array + { + // Avoid truly horrific quadratic behavior. + if (\count($selectors) > 100) { + return $selectors; + } + + // This is n² on the sequences, but only comparing between separate + // sequences should limit the quadratic behavior. We iterate from last to + // first and reverse the result so that, if two selectors are identical, we + // keep the first one. + /** @var list $result */ + $result = []; + $numOriginals = 0; + + for ($i = \count($selectors) - 1; $i >= 0; $i--) { + $complex1 = $selectors[$i]; + + if ($isOriginal($complex1)) { + for ($j = 0; $j < $numOriginals; $j++) { + if (EquatableUtil::equals($result[$j], $complex1)) { + // Rotates the slice one index higher + $element = $result[$j]; + for ($k = 0; $k <= $j; $k++) { + $next = $result[$k]; + $result[$k] = $element; + $element = $next; + } + // Rotating the slice preserves the list status of the array, but phpstan does not recognize it. + \assert(array_is_list($result)); + + continue 2; + } + } + + $numOriginals++; + array_unshift($result, $complex1); + continue; + } + + // The maximum specificity of the sources that caused $complex1 to be + // generated. In order for $complex1 to be removed, there must be another + // selector that's a superselector of it *and* that has specificity + // greater or equal to this. + $maxSpecificity = 0; + foreach ($complex1->getComponents() as $component) { + $maxSpecificity = max($maxSpecificity, $this->sourceSpecificityFor($component->getSelector())); + } + + // Look in $result rather than $selectors for selectors after $i. This + // ensures that we aren't comparing against a selector that's already been + // trimmed, and thus that if there are two identical selectors only one is + // trimmed. + if (IterableUtil::any($result, fn (ComplexSelector $complex2) => $complex2->getSpecificity() >= $maxSpecificity && $complex2->isSuperselector($complex1))) { + continue; + } + + if (IterableUtil::any(array_slice($selectors, 0, $i), fn (ComplexSelector $complex2) => $complex2->getSpecificity() >= $maxSpecificity && $complex2->isSuperselector($complex1))) { + continue; + } + + array_unshift($result, $complex1); + } + + return $result; + } + + /** + * Returns the maximum specificity for sources that went into producing + * $compound. + */ + private function sourceSpecificityFor(CompoundSelector $compound): int + { + $specificity = 0; + + foreach ($compound->getComponents() as $simple) { + $specificity = max($specificity, $this->sourceSpecificity[$simple] ?? 0); + } + + return $specificity; + } + + public function clone(): array + { + /** @var SimpleSelectorMap>> $newSelectors */ + $newSelectors = new SimpleSelectorMap(); + /** @var \SplObjectStorage, list> $newMediaContexts */ + $newMediaContexts = new \SplObjectStorage(); + /** @var \SplObjectStorage> $oldToNewSelectors */ + $oldToNewSelectors = new \SplObjectStorage(); + + foreach ($this->selectors as $simple) { + $selectors = $this->selectors->getInfo(); + + /** @var ObjectSet> $newSelectorSet */ + $newSelectorSet = new ObjectSet(); + $newSelectors[$simple] = $newSelectorSet; + + foreach ($selectors as $selector) { + $newSelector = new ModifiableBox($selector->getValue()); + $newSelectorSet->add($newSelector); + $oldToNewSelectors[$selector->getValue()] = $newSelector->seal(); + + if (isset($this->mediaContexts[$selector])) { + $newMediaContexts[$newSelector] = $this->mediaContexts[$selector]; + } + } + } + + /** @var SimpleSelectorMap> $newExtensions */ + $newExtensions = new SimpleSelectorMap(); + foreach ($this->extensions as $simple) { + $newExtensions[$simple] = clone $this->extensions->getInfo(); + } + + return [new ConcreteExtensionStore( + $newSelectors, + $newExtensions, + clone $this->extensionsByExtender, + $newMediaContexts, + clone $this->sourceSpecificity, + clone $this->originals, + ExtendMode::normal, + ), $oldToNewSelectors]; + } +} diff --git a/modules/sass/scssphp/Extend/EmptyExtensionStore.php b/modules/sass/scssphp/Extend/EmptyExtensionStore.php new file mode 100644 index 00000000..ea2cc2c1 --- /dev/null +++ b/modules/sass/scssphp/Extend/EmptyExtensionStore.php @@ -0,0 +1,66 @@ +> $map */ + $map = new \SplObjectStorage(); + + return [new EmptyExtensionStore(), $map]; + } +} diff --git a/modules/sass/scssphp/Extend/ExtendMode.php b/modules/sass/scssphp/Extend/ExtendMode.php new file mode 100644 index 00000000..6cafc5d1 --- /dev/null +++ b/modules/sass/scssphp/Extend/ExtendMode.php @@ -0,0 +1,44 @@ +|null */ - public static function unifyComplex(array $complexes): ?array + public static function unifyComplex(array $complexes, FileSpan $span): ?array { if (\count($complexes) === 1) { return $complexes; @@ -65,7 +68,7 @@ public static function unifyComplex(array $complexes): ?array if (\count($complex->getComponents()) === 1 && \count($complex->getLeadingCombinators()) !== 0) { $newLeadingCombinator = \count($complex->getLeadingCombinators()) === 1 ? $complex->getLeadingCombinators()[0] : null; - if ($leadingCombinator !== null && $newLeadingCombinator !== $leadingCombinator) { + if ($leadingCombinator !== null && !EquatableUtil::equals($newLeadingCombinator, $leadingCombinator)) { return null; } @@ -101,7 +104,7 @@ public static function unifyComplex(array $complexes): ?array $hasLineBreak = false; foreach ($complexes as $complex) { if (\count($complex->getComponents()) > 1) { - $withoutBases[] = new ComplexSelector($complex->getLeadingCombinators(), array_slice($complex->getComponents(), 0, \count($complex->getComponents()) - 1), $complex->getLineBreak()); + $withoutBases[] = new ComplexSelector($complex->getLeadingCombinators(), array_slice($complex->getComponents(), 0, \count($complex->getComponents()) - 1), $complex->getSpan(), $complex->getLineBreak()); } if ($complex->getLineBreak()) { @@ -113,11 +116,12 @@ public static function unifyComplex(array $complexes): ?array $base = new ComplexSelector( $leadingCombinator === null ? [] : [$leadingCombinator], - [new ComplexSelectorComponent(new CompoundSelector($unifiedBase), $trailingCombinator === null ? [] : [$trailingCombinator])], + [new ComplexSelectorComponent(new CompoundSelector($unifiedBase, $span), $trailingCombinator === null ? [] : [$trailingCombinator], $span)], + $span, $hasLineBreak ); - return self::weave($withoutBases === [] ? [$base] : array_merge(ListUtil::exceptLast($withoutBases), [ListUtil::last($withoutBases)->concatenate($base)])); + return self::weave($withoutBases === [] ? [$base] : array_merge(ListUtil::exceptLast($withoutBases), [ListUtil::last($withoutBases)->concatenate($base, $span)]), $span); } /** @@ -125,17 +129,12 @@ public static function unifyComplex(array $complexes): ?array * both $compound1 and $compound2. * * If no such selector can be produced, returns `null`. - * - * @param list $compound1 - * @param list $compound2 - * - * @return CompoundSelector|null */ - public static function unifyCompound(array $compound1, array $compound2): ?CompoundSelector + public static function unifyCompound(CompoundSelector $compound1, CompoundSelector $compound2): ?CompoundSelector { - $result = $compound2; + $result = $compound2->getComponents(); - foreach ($compound1 as $simple) { + foreach ($compound1->getComponents() as $simple) { $unified = $simple->unify($result); if ($unified === null) { @@ -145,7 +144,7 @@ public static function unifyCompound(array $compound1, array $compound2): ?Compo $result = $unified; } - return new CompoundSelector($result); + return new CompoundSelector($result, $compound1->getSpan()); } /** @@ -157,25 +156,8 @@ public static function unifyCompound(array $compound1, array $compound2): ?Compo */ public static function unifyUniversalAndElement(SimpleSelector $selector1, SimpleSelector $selector2): ?SimpleSelector { - $name1 = null; - if ($selector1 instanceof UniversalSelector) { - $namespace1 = $selector1->getNamespace(); - } elseif ($selector1 instanceof TypeSelector) { - $namespace1 = $selector1->getName()->getNamespace(); - $name1 = $selector1->getName()->getName(); - } else { - throw new \InvalidArgumentException('selector1 must be a UniversalSelector or a TypeSelector'); - } - - $name2 = null; - if ($selector2 instanceof UniversalSelector) { - $namespace2 = $selector2->getNamespace(); - } elseif ($selector2 instanceof TypeSelector) { - $namespace2 = $selector2->getName()->getNamespace(); - $name2 = $selector2->getName()->getName(); - } else { - throw new \InvalidArgumentException('selector2 must be a UniversalSelector or a TypeSelector'); - } + [$namespace1, $name1] = self::namespaceAndName($selector1, 'selector1'); + [$namespace2, $name2] = self::namespaceAndName($selector2, 'selector2'); if ($namespace1 === $namespace2 || $namespace2 === '*') { $namespace = $namespace1; @@ -194,10 +176,31 @@ public static function unifyUniversalAndElement(SimpleSelector $selector1, Simpl } if ($name === null) { - return new UniversalSelector($namespace); + return new UniversalSelector($selector1->getSpan(), $namespace); + } + + return new TypeSelector(new QualifiedName($name, $namespace), $selector1->getSpan()); + } + + /** + * Returns the namespace and name for $selector, which must be a + * {@see UniversalSelector} or a {@see TypeSelector}. + * + * The $name parameter is used for error reporting. + * + * @return array{string|null, string|null} The namespace and the name + */ + private static function namespaceAndName(SimpleSelector $selector, string $name): array + { + if ($selector instanceof UniversalSelector) { + return [$selector->getNamespace(), null]; } - return new TypeSelector(new QualifiedName($name, $namespace)); + if ($selector instanceof TypeSelector) { + return [$selector->getName()->getNamespace(), $selector->getName()->getName()]; + } + + throw new \InvalidArgumentException("Argument $name must be a UniversalSelector or a TypeSelector."); } /** @@ -211,6 +214,8 @@ public static function unifyUniversalAndElement(SimpleSelector $selector1, Simpl * * The selector `.D (.A .B)` is represented as the list `[.D, .A .B]`. * + * The $span will be used for any new combined selectors. + * * If $forceLineBreak is `true`, this will mark all returned complex selectors * as having line breaks. * @@ -218,7 +223,7 @@ public static function unifyUniversalAndElement(SimpleSelector $selector1, Simpl * * @return list */ - public static function weave(array $complexes, bool $forceLineBreak = false): array + public static function weave(array $complexes, FileSpan $span, bool $forceLineBreak = false): array { if (\count($complexes) === 1) { $complex = $complexes[0]; @@ -228,18 +233,16 @@ public static function weave(array $complexes, bool $forceLineBreak = false): ar } return [ - new ComplexSelector($complex->getLeadingCombinators(), $complex->getComponents(), true), + new ComplexSelector($complex->getLeadingCombinators(), $complex->getComponents(), $complex->getSpan(), true), ]; } $prefixes = [$complexes[0]]; foreach (array_slice($complexes, 1) as $complex) { - $target = ListUtil::last($complex->getComponents()); - if (\count($complex->getComponents()) === 1) { foreach ($prefixes as $i => $prefix) { - $prefixes[$i] = $prefix->concatenate($complex, $forceLineBreak); + $prefixes[$i] = $prefix->concatenate($complex, $span, $forceLineBreak); } continue; @@ -248,8 +251,8 @@ public static function weave(array $complexes, bool $forceLineBreak = false): ar $newPrefixes = []; foreach ($prefixes as $prefix) { - foreach (self::weaveParents($prefix, $complex) ?? [] as $parentPrefix) { - $newPrefixes[] = $parentPrefix->withAdditionalComponent($target, $forceLineBreak); + foreach (self::weaveParents($prefix, $complex, $span) ?? [] as $parentPrefix) { + $newPrefixes[] = $parentPrefix->withAdditionalComponent(ListUtil::last($complex->getComponents()), $span, $forceLineBreak); } } @@ -276,14 +279,13 @@ public static function weave(array $complexes, bool $forceLineBreak = false): ar * elements matched by `P`. Some `PC_i` are elided to reduce the size of the * output. * - * Returns `null` if this intersection is empty. + * The $span will be used for any new combined selectors. * - * @param ComplexSelector $prefix - * @param ComplexSelector $base + * Returns `null` if this intersection is empty. * * @return list|null */ - private static function weaveParents(ComplexSelector $prefix, ComplexSelector $base): ?array + private static function weaveParents(ComplexSelector $prefix, ComplexSelector $base, FileSpan $span): ?array { $leadingCombinators = self::mergeLeadingCombinators($prefix->getLeadingCombinators(), $base->getLeadingCombinators()); if ($leadingCombinators === null) { @@ -296,7 +298,7 @@ private static function weaveParents(ComplexSelector $prefix, ComplexSelector $b $queue1 = $prefix->getComponents(); $queue2 = ListUtil::exceptLast($base->getComponents()); - $finalCombinators = self::mergeTrailingCombinators($queue1, $queue2); + $finalCombinators = self::mergeTrailingCombinators($queue1, $queue2, $span); if ($finalCombinators === null) { return null; } @@ -307,14 +309,14 @@ private static function weaveParents(ComplexSelector $prefix, ComplexSelector $b $rootish2 = self::firstIfRootish($queue2); if ($rootish1 !== null && $rootish2 !== null) { - $rootish = self::unifyCompound($rootish1->getSelector()->getComponents(), $rootish2->getSelector()->getComponents()); + $rootish = self::unifyCompound($rootish1->getSelector(), $rootish2->getSelector()); if ($rootish === null) { return null; } - array_unshift($queue1, new ComplexSelectorComponent($rootish, $rootish1->getCombinators())); - array_unshift($queue2, new ComplexSelectorComponent($rootish, $rootish2->getCombinators())); + array_unshift($queue1, new ComplexSelectorComponent($rootish, $rootish1->getCombinators(), $rootish1->getSpan())); + array_unshift($queue2, new ComplexSelectorComponent($rootish, $rootish2->getCombinators(), $rootish2->getSpan())); } elseif ($rootish1 !== null || $rootish2 !== null) { // If there's only one rootish selector, it should only appear in the first // position of the resulting selector. We can ensure that happens by adding @@ -328,7 +330,7 @@ private static function weaveParents(ComplexSelector $prefix, ComplexSelector $b $groups2 = self::groupSelectors($queue2); /** @phpstan-var list> $lcs */ - $lcs = ListUtil::longestCommonSubsequence($groups2, $groups1, function ($group1, $group2) { + $lcs = ListUtil::longestCommonSubsequence($groups2, $groups1, function ($group1, $group2) use ($span) { if (EquatableUtil::listEquals($group1, $group2)) { return $group1; } @@ -345,7 +347,7 @@ private static function weaveParents(ComplexSelector $prefix, ComplexSelector $b return null; } - $unified = self::unifyComplex([new ComplexSelector([], $group1), new ComplexSelector([], $group2)]); + $unified = self::unifyComplex([new ComplexSelector([], $group1, $span), new ComplexSelector([], $group2, $span)], $span); if ($unified === null) { return null; @@ -362,9 +364,7 @@ private static function weaveParents(ComplexSelector $prefix, ComplexSelector $b foreach ($lcs as $group) { $newChoice = []; /** @var list>> $chunks */ - $chunks = self::chunks($groups1, $groups2, function ($sequence) use ($group) { - return self::complexIsParentSuperselector($sequence[0], $group); - }); + $chunks = self::chunks($groups1, $groups2, fn($sequence) => self::complexIsParentSuperselector($sequence[0], $group)); foreach ($chunks as $chunk) { $flattened = []; foreach ($chunk as $chunkGroup) { @@ -383,9 +383,7 @@ private static function weaveParents(ComplexSelector $prefix, ComplexSelector $b $newChoice = []; /** @var list>> $chunks */ - $chunks = self::chunks($groups1, $groups2, function ($sequence) { - return count($sequence) === 0; - }); + $chunks = self::chunks($groups1, $groups2, fn($sequence) => count($sequence) === 0); foreach ($chunks as $chunk) { $flattened = []; foreach ($chunk as $chunkGroup) { @@ -400,20 +398,18 @@ private static function weaveParents(ComplexSelector $prefix, ComplexSelector $b $choices[] = $finalCombinator; } - $choices = array_filter($choices, function ($choice) { - return $choice !== []; - }); + $choices = array_filter($choices, fn($choice) => $choice !== []); $paths = self::paths($choices); - return array_map(function (array $path) use ($leadingCombinators, $prefix, $base) { + return array_map(function (array $path) use ($leadingCombinators, $prefix, $base, $span) { $result = []; foreach ($path as $group) { $result = array_merge($result, $group); } - return new ComplexSelector($leadingCombinators, $result, $prefix->getLineBreak() || $base->getLineBreak()); + return new ComplexSelector($leadingCombinators, $result, $span, $prefix->getLineBreak() || $base->getLineBreak()); }, $paths); } @@ -450,15 +446,10 @@ private static function firstIfRootish(array &$queue): ?ComplexSelectorComponent * * Returns `null` if the combinator lists can't be unified. * - * @param list|null $combinators1 - * @param list|null $combinators2 - * - * @return list|null - * - * @phpstan-param list $combinators1 - * @phpstan-param list $combinators2 + * @param list>|null $combinators1 + * @param list>|null $combinators2 * - * @phpstan-return list|null + * @return list>|null */ private static function mergeLeadingCombinators(?array $combinators1, ?array $combinators2): ?array { @@ -502,13 +493,15 @@ private static function mergeLeadingCombinators(?array $combinators1, ?array $co * If there are no combinators to be merged, returns an empty list. If the * sequences can't be merged, returns `null`. * + * The $span will be used for any new combined selectors. + * * @param list $components1 * @param list $components2 * @param list>> $result * * @return list>>|null */ - private static function mergeTrailingCombinators(array &$components1, array &$components2, array $result = []): ?array + private static function mergeTrailingCombinators(array &$components1, array &$components2, FileSpan $span, array $result = []): ?array { $combinators1 = \count($components1) === 0 ? [] : ListUtil::last($components1)->getCombinators(); $combinators2 = \count($components2) === 0 ? [] : ListUtil::last($components2)->getCombinators(); @@ -532,7 +525,7 @@ private static function mergeTrailingCombinators(array &$components1, array &$co $component2 = array_pop($components2); assert($component2 instanceof ComplexSelectorComponent); - if ($combinator1 === Combinator::FOLLOWING_SIBLING && $combinator2 === Combinator::FOLLOWING_SIBLING) { + if ($combinator1->getValue() === Combinator::FOLLOWING_SIBLING && $combinator2->getValue() === Combinator::FOLLOWING_SIBLING) { if ($component1->getSelector()->isSuperselector($component2->getSelector())) { array_unshift($result, [[$component2]]); } elseif ($component2->getSelector()->isSuperselector($component1->getSelector())) { @@ -543,78 +536,78 @@ private static function mergeTrailingCombinators(array &$components1, array &$co [$component2, $component1], ]; - $unified = self::unifyCompound($component1->getSelector()->getComponents(), $component2->getSelector()->getComponents()); + $unified = self::unifyCompound($component1->getSelector(), $component2->getSelector()); if ($unified !== null) { - $choices[] = [new ComplexSelectorComponent($unified, [Combinator::FOLLOWING_SIBLING])]; + $choices[] = [new ComplexSelectorComponent($unified, [$combinator1], $span)]; } array_unshift($result, $choices); } - } elseif (($combinator1 === Combinator::FOLLOWING_SIBLING && $combinator2 === Combinator::NEXT_SIBLING) || ($combinator1 === Combinator::NEXT_SIBLING && $combinator2 === Combinator::FOLLOWING_SIBLING)) { - $followingSiblingComponent = $combinator1 === Combinator::FOLLOWING_SIBLING ? $component1 : $component2; - $nextSiblingComponent = $combinator1 === Combinator::FOLLOWING_SIBLING ? $component2 : $component1; + } elseif (($combinator1->getValue() === Combinator::FOLLOWING_SIBLING && $combinator2->getValue() === Combinator::NEXT_SIBLING) || ($combinator1->getValue() === Combinator::NEXT_SIBLING && $combinator2->getValue() === Combinator::FOLLOWING_SIBLING)) { + $followingSiblingComponent = $combinator1->getValue() === Combinator::FOLLOWING_SIBLING ? $component1 : $component2; + $nextSiblingComponent = $combinator1->getValue() === Combinator::FOLLOWING_SIBLING ? $component2 : $component1; if ($followingSiblingComponent->getSelector()->isSuperselector($nextSiblingComponent->getSelector())) { array_unshift($result, [[$nextSiblingComponent]]); } else { - $unified = self::unifyCompound($component1->getSelector()->getComponents(), $component2->getSelector()->getComponents()); + $unified = self::unifyCompound($followingSiblingComponent->getSelector(), $nextSiblingComponent->getSelector()); $choices = [ [$followingSiblingComponent, $nextSiblingComponent], ]; if ($unified !== null) { - $choices[] = [new ComplexSelectorComponent($unified, [Combinator::NEXT_SIBLING])]; + $choices[] = [new ComplexSelectorComponent($unified, $nextSiblingComponent->getCombinators(), $span)]; } array_unshift($result, $choices); } - } elseif ($combinator1 === Combinator::CHILD && ($combinator2 === Combinator::NEXT_SIBLING || $combinator2 === Combinator::FOLLOWING_SIBLING)) { + } elseif ($combinator1->getValue() === Combinator::CHILD && ($combinator2->getValue() === Combinator::NEXT_SIBLING || $combinator2->getValue() === Combinator::FOLLOWING_SIBLING)) { array_unshift($result, [[$component2]]); $components1[] = $component1; - } elseif ($combinator2 === Combinator::CHILD && ($combinator1 === Combinator::NEXT_SIBLING || $combinator1 === Combinator::FOLLOWING_SIBLING)) { + } elseif ($combinator2->getValue() === Combinator::CHILD && ($combinator1->getValue() === Combinator::NEXT_SIBLING || $combinator1->getValue() === Combinator::FOLLOWING_SIBLING)) { array_unshift($result, [[$component1]]); $components2[] = $component2; - } elseif ($combinator1 === $combinator2) { - $unified = self::unifyCompound($component1->getSelector()->getComponents(), $component2->getSelector()->getComponents()); + } elseif (EquatableUtil::equals($combinator1, $combinator2)) { + $unified = self::unifyCompound($component1->getSelector(), $component2->getSelector()); if ($unified === null) { return null; } - array_unshift($result, [[new ComplexSelectorComponent($unified, [$combinator1])]]); + array_unshift($result, [[new ComplexSelectorComponent($unified, [$combinator1], $span)]]); } else { return null; } - return self::mergeTrailingCombinators($components1, $components2, $result); + return self::mergeTrailingCombinators($components1, $components2, $span, $result); } if ($combinator1 !== null) { $component1 = array_pop($components1); \assert($component1 instanceof ComplexSelectorComponent); - if ($combinator1 === Combinator::CHILD && \count($components2) > 0 && ListUtil::last($components2)->getSelector()->isSuperselector($component1->getSelector())) { + if ($combinator1->getValue() === Combinator::CHILD && \count($components2) > 0 && ListUtil::last($components2)->getSelector()->isSuperselector($component1->getSelector())) { array_pop($components2); } array_unshift($result, [[$component1]]); - return self::mergeTrailingCombinators($components1, $components2, $result); + return self::mergeTrailingCombinators($components1, $components2, $span, $result); } $component2 = array_pop($components2); \assert($component2 instanceof ComplexSelectorComponent); assert($combinator2 !== null); - if ($combinator2 === Combinator::CHILD && \count($components1) > 0 && ListUtil::last($components1)->getSelector()->isSuperselector($component2->getSelector())) { + if ($combinator2->getValue() === Combinator::CHILD && \count($components1) > 0 && ListUtil::last($components1)->getSelector()->isSuperselector($component2->getSelector())) { array_pop($components1); } array_unshift($result, [[$component2]]); - return self::mergeTrailingCombinators($components2, $components1, $result); + return self::mergeTrailingCombinators($components1, $components2, $span, $result); } /** @@ -837,7 +830,9 @@ private static function complexIsParentSuperselector(array $complex1, array $com return false; } - $base = new ComplexSelectorComponent(new CompoundSelector([new PlaceholderSelector('')]), []); + $bogusSpan = SpanUtil::bogusSpan(); + + $base = new ComplexSelectorComponent(new CompoundSelector([new PlaceholderSelector('', $bogusSpan)], $bogusSpan), [], $bogusSpan); $complex1[] = $base; $complex2[] = $base; @@ -868,6 +863,7 @@ public static function complexIsSuperselector(array $complex1, array $complex2): $i1 = 0; $i2 = 0; + $previousCombinator = null; while (true) { $remaining1 = \count($complex1) - $i1; @@ -924,6 +920,10 @@ public static function complexIsSuperselector(array $complex1, array $complex2): $parents[] = $component2; } + if (!self::compatibleWithPreviousCombinator($previousCombinator, $parents ?? [])) { + return false; + } + $component2 = $complex2[$endOfSubselector]; $combinator1 = $component1->getCombinators()[0] ?? null; $combinator2 = $component2->getCombinators()[0] ?? null; @@ -934,9 +934,10 @@ public static function complexIsSuperselector(array $complex1, array $complex2): $i1++; $i2 = $endOfSubselector + 1; + $previousCombinator = $combinator1; if (\count($complex1) - $i1 === 1) { - if ($combinator1 === Combinator::FOLLOWING_SIBLING) { + if ($combinator1 !== null && $combinator1->getValue() === Combinator::FOLLOWING_SIBLING) { // The selector `.foo ~ .bar` is only a superselector of selectors that // *exclusively* contain subcombinators of `~`. for ($index = $i2; $index < \count($complex2) - 1; $index++) { @@ -957,17 +958,51 @@ public static function complexIsSuperselector(array $complex1, array $complex2): } } + /** + * @param CssValue|null $previous + * @param list $parents + */ + private static function compatibleWithPreviousCombinator(?CssValue $previous, array $parents): bool + { + if ($parents === []) { + return true; + } + + if ($previous === null) { + return true; + } + + // The child and next sibling combinators require that the *immediate* + // following component be a superselector. + if ($previous->getValue() !== Combinator::FOLLOWING_SIBLING) { + return false; + } + + // The following sibling combinator does allow intermediate components, but + // only if they're all siblings. + foreach ($parents as $component) { + $firstCombinator = $component->getCombinators()[0] ?? null; + $firstCombinatorValue = $firstCombinator?->getValue(); + + if ($firstCombinatorValue !== Combinator::FOLLOWING_SIBLING && $firstCombinatorValue !== Combinator::NEXT_SIBLING) { + return false; + } + } + + return true; + } + /** * Returns whether $combinator1 is a supercombinator of $combinator2. * * That is, whether `X $combinator1 Y` is a superselector of `X $combinator2 Y`. * - * @phpstan-param Combinator::*|null $combinator1 - * @phpstan-param Combinator::*|null $combinator2 + * @param CssValue|null $combinator1 + * @param CssValue|null $combinator2 */ - private static function isSupercombinator(?string $combinator1, ?string $combinator2): bool + private static function isSupercombinator(?CssValue $combinator1, ?CssValue $combinator2): bool { - return $combinator1 === $combinator2 || ($combinator1 === null && $combinator2 === Combinator::CHILD) || ($combinator1 === Combinator::FOLLOWING_SIBLING && $combinator2 === Combinator::NEXT_SIBLING); + return EquatableUtil::equals($combinator1, $combinator2) || ($combinator1 === null && $combinator2 !== null && $combinator2->getValue() === Combinator::CHILD) || ($combinator1 !== null && $combinator1->getValue() === Combinator::FOLLOWING_SIBLING && $combinator2 !== null && $combinator2->getValue() === Combinator::NEXT_SIBLING); } /** @@ -1068,11 +1103,13 @@ private static function compoundComponentsIsSuperselector(array $compound1, arra return true; } + $bogusSpan = SpanUtil::bogusSpan(); + if (\count($compound2) === 0) { - $compound2 = [new UniversalSelector('*')]; + $compound2 = [new UniversalSelector($bogusSpan, '*')]; } - return self::compoundIsSuperselector(new CompoundSelector($compound1), new CompoundSelector($compound2), $parents); + return self::compoundIsSuperselector(new CompoundSelector($compound1, $bogusSpan), new CompoundSelector($compound2, $bogusSpan), $parents); } /** @@ -1111,7 +1148,7 @@ private static function selectorPseudoIsSuperselector(PseudoSelector $pseudo1, C } $componentWithParents = $parents; - $componentWithParents[] = new ComplexSelectorComponent($compound2, []); + $componentWithParents[] = new ComplexSelectorComponent($compound2, [], $compound2->getSpan()); foreach ($selector1->getComponents() as $complex1) { if (\count($complex1->getLeadingCombinators()) === 0 && self::complexIsSuperselector($complex1->getComponents(), $componentWithParents)) { diff --git a/modules/sass/scssphp/Extend/Extender.php b/modules/sass/scssphp/Extend/Extender.php new file mode 100644 index 00000000..e46e7f4a --- /dev/null +++ b/modules/sass/scssphp/Extend/Extender.php @@ -0,0 +1,87 @@ +selector = $selector; + $this->specificity = $specificity ?? $selector->getSpecificity(); + $this->isOriginal = $original; + $this->extension = $extension; + } + + public static function create(ComplexSelector $selector, ?int $specificity = null, bool $original = false): self + { + return new Extender($selector, $specificity, $original); + } + + public static function forExtension(ComplexSelector $selector, Extension $extension): self + { + return new Extender($selector, extension: $extension); + } + + /** + * @param list|null $mediaContext + */ + public function assertCompatibleMediaContext(?array $mediaContext): void + { + if ($this->extension === null) { + return; + } + + $expectedMediaContext = $this->extension->mediaContext; + if ($expectedMediaContext === null) { + return; + } + + if ($mediaContext !== null && EquatableUtil::listEquals($expectedMediaContext, $mediaContext)) { + return; + } + + // TODO check the exception type + throw new SassRuntimeException('You may not @extend selectors across media queries.', $this->extension->span); + } +} diff --git a/modules/sass/scssphp/Extend/Extension.php b/modules/sass/scssphp/Extend/Extension.php new file mode 100644 index 00000000..32a19973 --- /dev/null +++ b/modules/sass/scssphp/Extend/Extension.php @@ -0,0 +1,68 @@ +|null + */ + public readonly ?array $mediaContext; + + public readonly bool $isOptional; + + public readonly FileSpan $span; + + /** + * @param list|null $mediaContext + */ + public function __construct(ComplexSelector $extender, SimpleSelector $target, FileSpan $span, ?array $mediaContext = null, bool $optional = false) + { + $this->extender = Extender::forExtension($extender, $this); + $this->target = $target; + $this->mediaContext = $mediaContext; + $this->isOptional = $optional; + $this->span = $span; + } + + public function withExtender(ComplexSelector $newExtender): Extension + { + return new Extension($newExtender, $this->target, $this->span, $this->mediaContext, $this->isOptional); + } +} diff --git a/modules/sass/scssphp/Extend/ExtensionStore.php b/modules/sass/scssphp/Extend/ExtensionStore.php new file mode 100644 index 00000000..73e5cfba --- /dev/null +++ b/modules/sass/scssphp/Extend/ExtensionStore.php @@ -0,0 +1,61 @@ + + */ + public function extensionsWhereTarget(callable $callback): iterable; + + /** + * @param list|null $mediaContext + * @return Box + */ + public function addSelector(SelectorList $selector, ?array $mediaContext): Box; + + /** + * @param list|null $mediaContext + */ + public function addExtension(SelectorList $extender, SimpleSelector $target, ExtendRule $extend, ?array $mediaContext): void; + + /** + * @param iterable $extensionStores + */ + public function addExtensions(iterable $extensionStores): void; + + /** + * @return array{ExtensionStore, \SplObjectStorage>} + */ + public function clone(): array; +} diff --git a/modules/sass/scssphp/Extend/MergedExtension.php b/modules/sass/scssphp/Extend/MergedExtension.php new file mode 100644 index 00000000..31e86632 --- /dev/null +++ b/modules/sass/scssphp/Extend/MergedExtension.php @@ -0,0 +1,84 @@ +left = $left; + $this->right = $right; + + parent::__construct($left->extender->selector, $left->target, $left->span, $left->mediaContext ?? $right->mediaContext, true); + } + + public static function merge(Extension $left, Extension $right): Extension + { + if (!EquatableUtil::equals($left->extender->selector, $right->extender->selector) || !EquatableUtil::equals($left->target, $right->target)) { + throw new \InvalidArgumentException('$left and $right aren\'t the same extension.'); + } + + if ($left->mediaContext !== null && $right->mediaContext !== null && !EquatableUtil::listEquals($left->mediaContext, $right->mediaContext)) { + $location = $left->span->message(''); + + // TODO check the exception type + throw new SassRuntimeException("From $location\nYou may not @extend the same selector from within different media queries.", $right->span); + } + + // If one extension is optional and doesn't add a special media context, it + // doesn't need to be merged. + if ($right->isOptional && $right->mediaContext === null) { + return $left; + } + if ($left->isOptional && $left->mediaContext === null) { + return $right; + } + + return new MergedExtension($left, $right); + } + + /** + * Returns all leaf-node [Extension]s in the tree of [MergedExtension]s. + * + * @return \Traversable + */ + public function unmerge(): \Traversable + { + if ($this->left instanceof MergedExtension) { + yield from $this->left->unmerge(); + } else { + yield $this->left; + } + + if ($this->right instanceof MergedExtension) { + yield from $this->right->unmerge(); + } else { + yield $this->right; + } + } +} diff --git a/modules/sass/scssphp/Extend/ObjectSet.php b/modules/sass/scssphp/Extend/ObjectSet.php new file mode 100644 index 00000000..13ca8178 --- /dev/null +++ b/modules/sass/scssphp/Extend/ObjectSet.php @@ -0,0 +1,59 @@ + + */ +class ObjectSet implements \IteratorAggregate +{ + /** + * @var \SplObjectStorage + */ + private readonly \SplObjectStorage $storage; + + public function __construct() + { + $this->storage = new \SplObjectStorage(); + } + + /** + * @param T $value + */ + public function contains(object $value): bool + { + return $this->storage->contains($value); + } + + /** + * @param T $value + */ + public function add(object $value): void + { + $this->storage->attach($value); + } + + /** + * @param ObjectSet $set + */ + public function addAll(self $set): void + { + $this->storage->addAll($set->storage); + } + + public function getIterator(): \Traversable + { + return $this->storage; + } +} diff --git a/modules/sass/scssphp/Extend/SimpleSelectorMap.php b/modules/sass/scssphp/Extend/SimpleSelectorMap.php new file mode 100644 index 00000000..26489cf2 --- /dev/null +++ b/modules/sass/scssphp/Extend/SimpleSelectorMap.php @@ -0,0 +1,31 @@ + + * + * @internal + */ +final class SimpleSelectorMap extends \SplObjectStorage +{ + public function getHash(object $object): string + { + \assert($object instanceof SimpleSelector); + // For SimpleSelector, selectors that are equal by value semantic are exactly the ones that have the same string representation. + return (string) $object; + } +} diff --git a/modules/sass/scssphp/Importer/CanonicalizeResult.php b/modules/sass/scssphp/Importer/CanonicalizeResult.php new file mode 100644 index 00000000..276960b3 --- /dev/null +++ b/modules/sass/scssphp/Importer/CanonicalizeResult.php @@ -0,0 +1,28 @@ +loadPath = Path::absolute($loadPath); + } + + public function canonicalize(UriInterface $url): ?UriInterface + { + if ($url->getScheme() !== 'file' && $url->getScheme() !== null) { + return null; + } + + $path = ImportUtil::resolveImportPath(Path::join($this->loadPath, Path::fromUri($url))); + + if ($path === null) { + return null; + } + + return Path::toUri(Path::canonicalize($path)); + } + + public function load(UriInterface $url): ?ImporterResult + { + $path = Path::fromUri($url); + $content = file_get_contents($path); + + if ($content === false) { + throw new \Exception("Could not read file $path"); + } + + return new ImporterResult($content, Syntax::forPath($path), $url); + } + + public function couldCanonicalize(UriInterface $url, UriInterface $canonicalUrl): bool + { + if ($url->getScheme() !== 'file' && $url->getScheme() !== null) { + return false; + } + + if ($canonicalUrl->getScheme() !== 'file') { + return false; + } + + $basename = basename((string) $url); + $canonicalBasename = basename((string) $canonicalUrl); + + if (!str_starts_with($basename, '_') && str_starts_with($canonicalBasename, '_')) { + $canonicalBasename = substr($canonicalBasename, 1); + } + + return $basename === $canonicalBasename || $basename === Path::withoutExtension($canonicalBasename); + } + + public function __toString(): string + { + return $this->loadPath; + } +} diff --git a/modules/sass/scssphp/Importer/ImportCache.php b/modules/sass/scssphp/Importer/ImportCache.php new file mode 100644 index 00000000..5818e289 --- /dev/null +++ b/modules/sass/scssphp/Importer/ImportCache.php @@ -0,0 +1,238 @@ + + */ + private readonly array $importers; + + private readonly DeprecationAwareLoggerInterface $logger; + + /** + * The canonicalized URLs for each non-canonical URL. + * + * The `forImport` in each key is true when this canonicalization is for an + * `@import` rule. Otherwise, it's for a `@use` or `@forward` rule. + * + * This cache isn't used for relative imports, because they depend on the + * specific base importer. That's stored separately in + * {@see $relativeCanonicalizeCache}. + * + * @var array> + */ + private array $canonicalizeCache = []; + + /** + * @var array>>> + */ + private array $relativeCanonicalizeCache = []; + + /** + * The parsed stylesheets for each canonicalized import URL. + * + * @var array + */ + private array $importCache = []; + + /** + * The import results for each canonicalized import URL. + * + * @var array + */ + private array $resultsCache = []; + + /** + * @param list $importers + */ + public function __construct(array $importers, DeprecationAwareLoggerInterface $logger) + { + $this->importers = $importers; + $this->logger = $logger; + } + + public function canonicalize(UriInterface $url, ?Importer $baseImporter = null, ?UriInterface $baseUrl = null, bool $forImport = false): ?CanonicalizeResult + { + $urlCacheKey = (string) $url; + $forImportCacheKey = (int) $forImport; + + if ($baseImporter !== null && $url->getScheme() === null) { + $baseUrlCacheKey = (string) $baseUrl; + + $this->relativeCanonicalizeCache[$urlCacheKey][$forImportCacheKey][$baseUrlCacheKey] ??= self::createStorage(); + + $relativeResult = $this->relativeCanonicalizeCache[$urlCacheKey][$forImportCacheKey][$baseUrlCacheKey][$baseImporter] ??= $this->doCanonicalize($baseImporter, self::resolveUri($baseUrl, $url), $baseUrl, $forImport) ?? SpecialCacheValue::null; + + if ($relativeResult !== SpecialCacheValue::null) { + return $relativeResult; + } + } + + $cacheResult = $this->canonicalizeCache[$urlCacheKey][$forImportCacheKey] ??= $this->doCanonicalizeWithImporters($url, $baseUrl, $forImport) ?? SpecialCacheValue::null; + + if ($cacheResult !== SpecialCacheValue::null) { + return $cacheResult; + } + + return null; + } + + private static function resolveUri(?UriInterface $baseUrl, UriInterface $url): UriInterface + { + if ($baseUrl === null) { + return $url; + } + + return UriUtil::resolveUri($baseUrl, $url); + } + + /** + * Creates a new storage for the importer-level cache + * + * This is in a dedicated method because phpstan cannot infer the generic types from the constructor. + * + * @return \SplObjectStorage + */ + private static function createStorage(): \SplObjectStorage + { + /** @var \SplObjectStorage $storage */ + $storage = new \SplObjectStorage(); + return $storage; + } + + private function doCanonicalizeWithImporters(UriInterface $url, ?UriInterface $baseUrl, bool $forImport): ?CanonicalizeResult + { + foreach ($this->importers as $importer) { + $result = $this->doCanonicalize($importer, $url, $baseUrl, $forImport); + + if ($result !== null) { + return $result; + } + } + + return null; + } + + private function doCanonicalize(Importer $importer, UriInterface $url, ?UriInterface $baseUrl, bool $forImport): ?CanonicalizeResult + { + $canonicalize = $forImport + ? fn () => ImportContext::inImportRule(fn () => $importer->canonicalize($url)) + : fn () => $importer->canonicalize($url); + + $passContainingUrl = $baseUrl !== null && ($url->getScheme() === null || $importer->isNonCanonicalScheme($url->getScheme())); + $result = ImportContext::withContainingUrl($passContainingUrl ? $baseUrl : null, $canonicalize); + + if ($result === null) { + return null; + } + + if ($result->getScheme() === null) { + throw new \UnexpectedValueException("Importer $importer canonicalized $url to $result but canonical URLs must be absolute."); + } + + if ($importer->isNonCanonicalScheme($result->getScheme())) { + throw new \UnexpectedValueException("Importer $importer canonicalized $url to $result, which uses a scheme declared as non-canonical."); + } + + return new CanonicalizeResult($importer, $result, $url); + } + + /** + * Tries to load the canonicalized $canonicalUrl using $importer. + * + * If $importer can import $canonicalUrl, returns the imported {@see Stylesheet}. + * Otherwise returns `null`. + * + * If passed, the $originalUrl represents the URL that was canonicalized + * into $canonicalUrl. It's used to resolve a relative canonical URL, which + * importers may return for legacy reasons. + * + * If $quiet is `true`, this will disable logging warnings when parsing the + * newly imported stylesheet. + * + * Caches the result of the import and uses cached results if possible. + */ + public function importCanonical(Importer $importer, UriInterface $canonicalUrl, ?UriInterface $originalUrl = null, bool $quiet = false): ?Stylesheet + { + $result = $this->importCache[(string) $canonicalUrl] ??= $this->doImportCanonical($importer, $canonicalUrl, $originalUrl, $quiet) ?? SpecialCacheValue::null; + + if ($result !== SpecialCacheValue::null) { + return $result; + } + + return null; + } + + private function doImportCanonical(Importer $importer, UriInterface $canonicalUrl, ?UriInterface $originalUrl = null, bool $quiet = false): ?Stylesheet + { + $result = $importer->load($canonicalUrl); + + if ($result === null) { + return null; + } + + $this->resultsCache[(string) $canonicalUrl] = $result; + + return Stylesheet::parse($result->getContents(), $result->getSyntax(), $quiet ? new QuietLogger() : $this->logger, $originalUrl); + } + + public function humanize(UriInterface $canonicalUrl): UriInterface + { + $shortestUrl = null; + $shortestLength = \PHP_INT_MAX; + + foreach ($this->canonicalizeCache as $cacheValues) { + foreach ($cacheValues as $cacheValue) { + if ($cacheValue === SpecialCacheValue::null) { + continue; + } + + if ($cacheValue->canonicalUrl->toString() !== $canonicalUrl->toString()) { + continue; + } + + $originalUrlLength = \strlen($cacheValue->originalUrl->getPath()); + + if ($shortestUrl === null || $originalUrlLength < $shortestLength) { + $shortestUrl = $cacheValue->originalUrl; + $shortestLength = $originalUrlLength; + } + } + } + + if ($shortestUrl !== null) { + // TODO check if basename is safe to use for the URL context + return UriUtil::resolve($shortestUrl, basename($canonicalUrl->getPath())); + } + + return $canonicalUrl; + } + + public function sourceMapUrl(UriInterface $canonicalUrl): UriInterface + { + return ($this->resultsCache[(string) $canonicalUrl] ?? null)?->getSourceMapUrl() ?? $canonicalUrl; + } +} diff --git a/modules/sass/scssphp/Importer/ImportContext.php b/modules/sass/scssphp/Importer/ImportContext.php new file mode 100644 index 00000000..a93dd527 --- /dev/null +++ b/modules/sass/scssphp/Importer/ImportContext.php @@ -0,0 +1,90 @@ + self::exactlyOne(self::tryPath(Path::withoutExtension($path) . '.import' . $extension))) + ?? self::exactlyOne(self::tryPath($path)); + } + + return self::ifInImport(fn () => self::exactlyOne(self::tryPathWithExtensions($path . '.import'))) + ?? self::exactlyOne(self::tryPathWithExtensions($path)) + ?? self::tryPathAsDirectory($path); + } + + /** + * Like {@see tryPath}, but checks `.sass`, `.scss`, and `.css` extensions. + * + * @return list + */ + private static function tryPathWithExtensions(string $path): array + { + $result = array_merge( + self::tryPath($path . '.sass'), + self::tryPath($path . '.scss'), + ); + + if ($result !== []) { + return $result; + } + + return self::tryPath($path . '.css'); + } + + /** + * Returns the $path and/or the partial with the same name, if either or both + * exists. + * + * If neither exists, returns an empty list. + * + * @return list + */ + private static function tryPath(string $path): array + { + $partial = Path::join(dirname($path), '_' . basename($path)); + $candidates = []; + + if (is_file($partial)) { + $candidates[] = $partial; + } + + if (is_file($path)) { + $candidates[] = $path; + } + + return $candidates; + } + + /** + * Returns the resolved index file for $path if $path is a directory and the + * index file exists. + * + * Otherwise, returns `null`. + */ + private static function tryPathAsDirectory(string $path): ?string + { + if (!is_dir($path)) { + return null; + } + + return self::ifInImport(fn () => self::exactlyOne(self::tryPathWithExtensions(Path::join($path, 'index.import')))) + ?? self::exactlyOne(self::tryPathWithExtensions(Path::join($path, 'index'))); + } + + /** + * @param list $paths + */ + private static function exactlyOne(array $paths): ?string + { + if (\count($paths) === 0) { + return null; + } + + if (\count($paths) === 1) { + return $paths[0]; + } + + $formattedPrettyPaths = []; + + foreach ($paths as $path) { + $formattedPrettyPaths[] = ' ' . Path::prettyUri($path); + } + + throw new \Exception("It's not clear which file to import. Found:\n" . implode("\n", $formattedPrettyPaths)); + } + + /** + * If {@see ImportContext::isFromImport} is `true`, invokes callback and returns the result. + * + * Otherwise, returns `null`. + * + * @template T + * + * @param callable(): T $callback + * @return T|null + */ + private static function ifInImport(callable $callback) + { + if (ImportContext::isFromImport()) { + return $callback(); + } + + return null; + } +} diff --git a/modules/sass/scssphp/Importer/Importer.php b/modules/sass/scssphp/Importer/Importer.php new file mode 100644 index 00000000..759a463b --- /dev/null +++ b/modules/sass/scssphp/Importer/Importer.php @@ -0,0 +1,170 @@ +contents = $contents; + $this->syntax = $syntax; + $this->sourceMapUrl = $sourceMapUrl; + } + + public function getContents(): string + { + return $this->contents; + } + + /** + * An absolute, browser-accessible URL indicating the resolved location of + * the imported stylesheet. + * + * This should be a `file:` URL if one is available, but an `http:` URL is + * acceptable as well. If no URL is supplied, a `data:` URL is generated + * automatically from {@see contents}. + */ + public function getSourceMapUrl(): UriInterface + { + return $this->sourceMapUrl ?? Uri::fromData($this->contents, '', 'charset=utf-8'); + } + + /** + * The syntax to use to parse the stylesheet. + */ + public function getSyntax(): Syntax + { + return $this->syntax; + } +} diff --git a/modules/sass/scssphp/Importer/NoOpImporter.php b/modules/sass/scssphp/Importer/NoOpImporter.php new file mode 100644 index 00000000..6631cfd2 --- /dev/null +++ b/modules/sass/scssphp/Importer/NoOpImporter.php @@ -0,0 +1,44 @@ +isFuture()) { + return; + } + + $this->logger->warn($message, true, $span, $trace); + } + + public function warn(string $message, bool $deprecation = false, ?FileSpan $span = null, ?Trace $trace = null): void + { + $this->logger->warn($message, $deprecation, $span, $trace); + } + + public function debug(string $message, FileSpan $span = null): void + { + $this->logger->debug($message, $span); + } +} diff --git a/modules/sass/scssphp/Logger/DeprecationAwareLoggerInterface.php b/modules/sass/scssphp/Logger/DeprecationAwareLoggerInterface.php new file mode 100644 index 00000000..d8466945 --- /dev/null +++ b/modules/sass/scssphp/Logger/DeprecationAwareLoggerInterface.php @@ -0,0 +1,25 @@ +, int> + */ + private array $warningCounts = []; + + /** + * @param Deprecation[] $fatalDeprecations + * @param Deprecation[] $futureDeprecations + */ + public function __construct( + private readonly LocationAwareLoggerInterface $inner, + private readonly array $fatalDeprecations, + private readonly array $futureDeprecations, + private readonly bool $limitRepetition = true + ) { + } + + public function warn(string $message, bool $deprecation = false, ?FileSpan $span = null, ?Trace $trace = null): void + { + $this->inner->warn($message, $deprecation, $span, $trace); + } + + /** + * Processes a deprecation warning. + * + * If $deprecation is in {@see $fatalDeprecations}, this shows an error. + * + * If it's a future deprecation that hasn't been opted into or it's a + * deprecation that's already been warned for {@see self::MAX_REPETITIONS} times and + * {@see limitRepetitions} is true, the warning is dropped. + * + * Otherwise, this is passed on to {@see warn}. + */ + public function warnForDeprecation(Deprecation $deprecation, string $message, ?FileSpan $span = null, ?Trace $trace = null): void + { + if (\in_array($deprecation, $this->fatalDeprecations, true)) { + $message .= "\n\nThis is only an error because you've set the {$deprecation->value} deprecation to be fatal.\nRemove this setting if you need to keep using this feature."; + + if ($span !== null && $trace !== null) { + throw new SassRuntimeException($message, $span); // TODO add trace + } + + if ($span !== null) { + throw new SassRuntimeException($message, $span); // TODO use the right exception type + } + + throw new SassScriptException($message); + } + + if ($deprecation->isFuture() && !\in_array($deprecation, $this->futureDeprecations, true)) { + return; + } + + if ($this->limitRepetition) { + $count = $this->warningCounts[$deprecation->value] = ($this->warningCounts[$deprecation->value] ?? 0) + 1; + + if ($count > self::MAX_REPETITIONS) { + return; + } + } + + $this->warn($message, true, $span, $trace); + } + + public function debug(string $message, ?FileSpan $span = null): void + { + $this->inner->debug($message, $span); + } + + /** + * Prints a warning indicating the number of deprecation warnings that were + * omitted due to repetition. + */ + public function summarize(): void + { + $total = 0; + + foreach ($this->warningCounts as $count) { + if ($count > self::MAX_REPETITIONS) { + $total += $count - self::MAX_REPETITIONS; + } + } + + if ($total > 0) { + $this->inner->warn("$total repetitive deprecation warnings omitted.\nRun in verbose mode to see all warnings."); + } + } +} diff --git a/modules/sass/scssphp/Logger/LocationAwareLoggerInterface.php b/modules/sass/scssphp/Logger/LocationAwareLoggerInterface.php index cf2027b3..004427b5 100644 --- a/modules/sass/scssphp/Logger/LocationAwareLoggerInterface.php +++ b/modules/sass/scssphp/Logger/LocationAwareLoggerInterface.php @@ -45,5 +45,5 @@ public function warn(string $message, bool $deprecation = false, ?FileSpan $span * * @return void */ - public function debug(string $message, FileSpan $span = null); + public function debug(string $message, ?FileSpan $span = null); } diff --git a/modules/sass/scssphp/Logger/QuietLogger.php b/modules/sass/scssphp/Logger/QuietLogger.php index e7b3daa6..3593ca0a 100644 --- a/modules/sass/scssphp/Logger/QuietLogger.php +++ b/modules/sass/scssphp/Logger/QuietLogger.php @@ -12,18 +12,26 @@ namespace Tangible\ScssPhp\Logger; +use Tangible\ScssPhp\Deprecation; use Tangible\ScssPhp\SourceSpan\FileSpan; use Tangible\ScssPhp\StackTrace\Trace; /** * A logger that silently ignores all messages. */ -final class QuietLogger implements LocationAwareLoggerInterface +final class QuietLogger implements LocationAwareLoggerInterface, DeprecationAwareLoggerInterface { public function warn(string $message, bool $deprecation = false, ?FileSpan $span = null, ?Trace $trace = null): void { } + /** + * @internal + */ + public function warnForDeprecation(Deprecation $deprecation, string $message, ?FileSpan $span = null, ?Trace $trace = null): void + { + } + public function debug(string $message, FileSpan $span = null): void { } diff --git a/modules/sass/scssphp/Node/Number.php b/modules/sass/scssphp/Node/Number.php index 07a4b135..6258b9f0 100644 --- a/modules/sass/scssphp/Node/Number.php +++ b/modules/sass/scssphp/Node/Number.php @@ -33,7 +33,7 @@ * * @template-implements \ArrayAccess */ -final class Number extends Node implements \ArrayAccess +final class Number extends Node implements \ArrayAccess, \JsonSerializable { const PRECISION = 10; @@ -125,7 +125,7 @@ public function getDimension() } /** - * @return string[] + * @return list */ public function getNumeratorUnits() { @@ -133,13 +133,23 @@ public function getNumeratorUnits() } /** - * @return string[] + * @return list */ public function getDenominatorUnits() { return $this->denominatorUnits; } + /** + * @return mixed + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + // Passing a compiler instance makes the method output a Sass representation instead of a CSS one, supporting full units. + return $this->output(new Compiler()); + } + /** * @return bool */ @@ -548,7 +558,7 @@ public function equals(Number $other) try { return $this->coerceUnits($other, function ($num1, $num2) { - return round($num1,self::PRECISION) == round($num2, self::PRECISION); + return round($num1, self::PRECISION) == round($num2, self::PRECISION); }); } catch (SassScriptException $e) { return false; diff --git a/modules/sass/scssphp/OutputStyle.php b/modules/sass/scssphp/OutputStyle.php index cc45f204..3f22cb35 100644 --- a/modules/sass/scssphp/OutputStyle.php +++ b/modules/sass/scssphp/OutputStyle.php @@ -1,9 +1,65 @@ saveEncoding(); $this->extractLineNumbers($buffer); + if (!preg_match('//u', $buffer)) { + $message = $this->sourceName ? 'Invalid UTF-8 file: ' . $this->sourceName : 'Invalid UTF-8 file'; + throw new ParserException($message); + } + $this->pushBlock(null); // root block $this->whitespace(); $this->pushBlock(null); @@ -283,6 +288,10 @@ public function parseValue(string $buffer, &$out): bool $list = $this->valueList($out); + if ($this->count !== \strlen($this->buffer)) { + throw $this->parseError('Expected end of value'); + } + $this->restoreEncoding(); return $list; @@ -339,10 +348,13 @@ public function parseMediaQueryList(string $buffer, &$out): bool $this->inParens = false; $this->eatWhiteDefault = true; $this->buffer = $buffer; + $this->discardComments = true; $this->saveEncoding(); $this->extractLineNumbers($this->buffer); + $this->whitespace(); + $isMediaQuery = $this->mediaQueryList($out); $this->restoreEncoding(); @@ -1614,9 +1626,9 @@ private function whitespace(): bool */ private function appendComment(array $comment): void { - assert($this->env !== null); - if (! $this->discardComments) { + assert($this->env !== null); + $this->env->comments[] = $comment; } } diff --git a/modules/sass/scssphp/Parser/AtRootQueryParser.php b/modules/sass/scssphp/Parser/AtRootQueryParser.php index f06f9f08..5337d9b0 100644 --- a/modules/sass/scssphp/Parser/AtRootQueryParser.php +++ b/modules/sass/scssphp/Parser/AtRootQueryParser.php @@ -27,7 +27,7 @@ final class AtRootQueryParser extends Parser */ public function parse(): AtRootQuery { - try { + return $this->wrapSpanFormatException(function () { $this->scanner->expectChar('('); $this->whitespace(); $include = $this->scanIdentifier('with'); @@ -49,8 +49,6 @@ public function parse(): AtRootQuery $this->scanner->expectDone(); return AtRootQuery::create($atRules, $include); - } catch (FormatException $e) { - throw $this->wrapException($e); - } + }); } } diff --git a/modules/sass/scssphp/Parser/CssParser.php b/modules/sass/scssphp/Parser/CssParser.php index 220b1657..af5a25c5 100644 --- a/modules/sass/scssphp/Parser/CssParser.php +++ b/modules/sass/scssphp/Parser/CssParser.php @@ -14,7 +14,8 @@ use Tangible\ScssPhp\Ast\Sass\ArgumentInvocation; use Tangible\ScssPhp\Ast\Sass\Expression; -use Tangible\ScssPhp\Ast\Sass\Expression\InterpolatedFunctionExpression; +use Tangible\ScssPhp\Ast\Sass\Expression\FunctionExpression; +use Tangible\ScssPhp\Ast\Sass\Expression\ParenthesizedExpression; use Tangible\ScssPhp\Ast\Sass\Expression\StringExpression; use Tangible\ScssPhp\Ast\Sass\Import\StaticImport; use Tangible\ScssPhp\Ast\Sass\Interpolation; @@ -35,6 +36,7 @@ final class CssParser extends ScssParser private const CSS_ALLOWED_FUNCTIONS = [ 'rgb' => true, 'rgba' => true, 'hsl' => true, 'hsla' => true, 'grayscale' => true, 'invert' => true, 'alpha' => true, 'opacity' => true, 'saturate' => true, + 'min' => true, 'max' => true, 'round' => true, 'abs' => true, ]; protected function isPlainCss(): bool @@ -89,7 +91,6 @@ protected function atRule(callable $child, bool $root = false): Statement default: return $this->unknownAtRule($start, $name); - } } @@ -114,6 +115,19 @@ private function cssImportRule(int $start): ImportRule ], $this->scanner->spanFrom($start)); } + protected function parentheses(): Expression + { + // Expressions are only allowed within calculations, but we verify this at + // evaluation time. + $start = $this->scanner->getPosition(); + $this->scanner->expectChar('('); + $this->whitespace(); + $expression = $this->expressionUntilComma(); + $this->scanner->expectChar(')'); + + return new ParenthesizedExpression($expression, $this->scanner->spanFrom($start)); + } + protected function identifierLike(): Expression { $start = $this->scanner->getPosition(); @@ -129,6 +143,10 @@ protected function identifierLike(): Expression } $beforeArguments = $this->scanner->getPosition(); + // `namespacedExpression()` is just here to throw a clearer error. + if ($this->scanner->scanChar('.')) { + return $this->namespacedExpression($plain, $start); + } if (!$this->scanner->scanChar('(')) { return new StringExpression($identifier); } @@ -155,10 +173,8 @@ protected function identifierLike(): Expression $this->error("This function isn't allowed in plain CSS.", $this->scanner->spanFrom($start)); } - return new InterpolatedFunctionExpression( - // Create a fake interpolation to force the function to be interpreted - // as plain CSS, rather than calling a user-defined function. - new Interpolation([new StringExpression($identifier)], $identifier->getSpan()), + return new FunctionExpression( + $plain, new ArgumentInvocation($arguments, [], $this->scanner->spanFrom($beforeArguments)), $this->scanner->spanFrom($start) ); diff --git a/modules/sass/scssphp/Parser/FormatException.php b/modules/sass/scssphp/Parser/FormatException.php index af0b8438..fac79877 100644 --- a/modules/sass/scssphp/Parser/FormatException.php +++ b/modules/sass/scssphp/Parser/FormatException.php @@ -19,11 +19,7 @@ */ final class FormatException extends \Exception { - /** - * @var FileSpan - * @readonly - */ - private $span; + private readonly FileSpan $span; public function __construct(string $message, FileSpan $span, ?\Throwable $previous = null) { diff --git a/modules/sass/scssphp/Parser/InterpolationBuffer.php b/modules/sass/scssphp/Parser/InterpolationBuffer.php index 416faec3..6f5df3cc 100644 --- a/modules/sass/scssphp/Parser/InterpolationBuffer.php +++ b/modules/sass/scssphp/Parser/InterpolationBuffer.php @@ -23,15 +23,12 @@ */ final class InterpolationBuffer { - /** - * @var string - */ - private $text = ''; + private string $text = ''; /** * @var list */ - private $contents = []; + private array $contents = []; /** * Returns the substring of the buffer string after the last interpolation. diff --git a/modules/sass/scssphp/Parser/InterpolationMap.php b/modules/sass/scssphp/Parser/InterpolationMap.php new file mode 100644 index 00000000..06b37034 --- /dev/null +++ b/modules/sass/scssphp/Parser/InterpolationMap.php @@ -0,0 +1,216 @@ +getContents()}. Its length is always one less than + * {@see $interpolation->getContents()} because the last element always ends the string. + * + * @var list + */ + private readonly array $targetLocations; + + /** + * @param list $targetLocations + */ + public function __construct(Interpolation $interpolation, array $targetLocations) + { + $this->interpolation = $interpolation; + $this->targetLocations = $targetLocations; + + $expectedLocations = max(0, \count($interpolation->getContents()) - 1); + if (\count($targetLocations) !== $expectedLocations) { + $interpolationParts = \count($interpolation->getContents()); + throw new \InvalidArgumentException("InterpolationMap must have $expectedLocations targetLocations if the interpolation has $interpolationParts components."); + } + } + + public function mapException(FormatException $exception): FormatException + { + $source = $this->mapSpan($exception->getSpan()); + + // TODO implement the Multi-span support here + return new FormatException($exception->getMessage(), $source, $exception); + } + + public function mapSpan(FileSpan $target): FileSpan + { + $start = $this->mapLocation($target->getStart()); + $end = $this->mapLocation($target->getEnd()); + + if ($start instanceof FileSpan) { + if ($end instanceof FileSpan) { + return $start->expand($end); + } + + return $this->interpolation->getSpan()->getFile()->span($this->expandInterpolationSpanLeft($start->getStart()), $end->getOffset()); + } + + if ($end instanceof FileSpan) { + return $this->interpolation->getSpan()->getFile()->span($start->getOffset(), $this->expandInterpolationSpanRight($end->getEnd())); + } + + return $this->interpolation->getSpan()->getFile()->span($start->getOffset(), $end->getOffset()); + } + + /** + * @return FileSpan|SourceLocation + */ + private function mapLocation(SourceLocation $target): object + { + $index = $this->indexInContents($target); + + $components = $this->interpolation->getContents(); + + if ($components[$index] instanceof Expression) { + return $components[$index]->getSpan(); + } + + if ($index === 0) { + $previousLocation = $this->interpolation->getSpan()->getStart(); + } else { + $previousComponent = $components[$index - 1]; + \assert($previousComponent instanceof Expression); + $previousLocation = $this->interpolation->getSpan()->getFile()->location($this->expandInterpolationSpanRight($previousComponent->getSpan()->getEnd())); + } + + $offsetInString = $target->getOffset() - ($index === 0 ? 0 : $this->targetLocations[$index - 1]->getOffset()); + + return $previousLocation->getFile()->location($previousLocation->getOffset() + $offsetInString); + } + + private function indexInContents(SourceLocation $target): int + { + foreach ($this->targetLocations as $i => $location) { + if ($target->getOffset() < $location->getOffset()) { + return $i; + } + } + + return \count($this->interpolation->getContents()) - 1; + } + + /** + * Given the start of a {@see FileSpan} covering an interpolated expression, returns + * the offset of the interpolation's opening `#`. + * + * Note that this can be tricked by a `#{` that appears within a single-line + * comment before the expression, but since it's only used for error + * reporting that's probably fine. + * + * @param SourceLocation $start + * @return int + */ + private function expandInterpolationSpanLeft(SourceLocation $start): int + { + $source = $start->getFile()->getString(); + $i = $start->getOffset() - 1; + + while ($i >= 0) { + $prev = $source[$i--]; + + if ($prev === '{') { + if ($source[$i] === '#') { + break; + } + } elseif ($prev === '/') { + $second = $source[$i--]; + + if ($second === '*') { + while ($i >= 0) { + $char = $source[$i--]; + + if ($char !== '*') { + continue; + } + + do { + $char = $source[$i--]; + } while ($char === '*' && $i >= 0); + + if ($char === '/') { + break; + } + } + } + } + } + + return $i; + } + + /** + * Given the end of a {@see FileSpan} covering an interpolated expression, returns + * the offset of the interpolation's closing `}`. + */ + private function expandInterpolationSpanRight(SourceLocation $end): int + { + $source = $end->getFile()->getString(); + $i = $end->getOffset(); + + while ($i < \strlen($source)) { + $next = $source[$i++]; + + if ($next === '}') { + break; + } + + if ($next === '/') { + $second = $source[$i++]; + if ($second === '/') { + while (!Character::isNewline($source[$i++] ?? null)) { + // Move forward + } + } elseif ($second === '*') { + while (true) { + $char = $source[$i++] ?? null; + + if ($char !== '*') { + continue; + } + + do { + $char = $source[$i++] ?? null; + } while ($char === '*'); + + if ($char === '/') { + break; + } + } + } + } + } + + return $i; + } +} diff --git a/modules/sass/scssphp/Parser/KeyframeSelectorParser.php b/modules/sass/scssphp/Parser/KeyframeSelectorParser.php index 8ea54d76..04ddae7e 100644 --- a/modules/sass/scssphp/Parser/KeyframeSelectorParser.php +++ b/modules/sass/scssphp/Parser/KeyframeSelectorParser.php @@ -29,7 +29,7 @@ final class KeyframeSelectorParser extends Parser */ public function parse(): array { - try { + return $this->wrapSpanFormatException(function () { $selectors = []; do { @@ -49,9 +49,7 @@ public function parse(): array $this->scanner->expectDone(); return $selectors; - } catch (FormatException $e) { - throw $this->wrapException($e); - } + }); } private function percentage(): string diff --git a/modules/sass/scssphp/Parser/LineScanner.php b/modules/sass/scssphp/Parser/LineScanner.php index bc07beca..33367bf7 100644 --- a/modules/sass/scssphp/Parser/LineScanner.php +++ b/modules/sass/scssphp/Parser/LineScanner.php @@ -22,12 +22,12 @@ final class LineScanner extends StringScanner /** * @var int */ - private $line = 0; + private int $line = 0; /** * @var int */ - private $column = 0; + private int $column = 0; /** * Whether the current position is between a CR character and an LF diff --git a/modules/sass/scssphp/Parser/MediaQueryParser.php b/modules/sass/scssphp/Parser/MediaQueryParser.php index d6591738..967678ed 100644 --- a/modules/sass/scssphp/Parser/MediaQueryParser.php +++ b/modules/sass/scssphp/Parser/MediaQueryParser.php @@ -29,7 +29,7 @@ final class MediaQueryParser extends Parser */ public function parse(): array { - try { + return $this->wrapSpanFormatException(function () { $queries = []; do { @@ -40,9 +40,7 @@ public function parse(): array $this->scanner->expectDone(); return $queries; - } catch (FormatException $e) { - throw $this->wrapException($e); - } + }); } /** diff --git a/modules/sass/scssphp/Parser/Parser.php b/modules/sass/scssphp/Parser/Parser.php index 41d363a7..34c1e411 100644 --- a/modules/sass/scssphp/Parser/Parser.php +++ b/modules/sass/scssphp/Parser/Parser.php @@ -13,11 +13,13 @@ namespace Tangible\ScssPhp\Parser; use Tangible\ScssPhp\Exception\SassFormatException; -use Tangible\ScssPhp\Logger\AdaptingLogger; -use Tangible\ScssPhp\Logger\LocationAwareLoggerInterface; +use Tangible\ScssPhp\Logger\AdaptingDeprecationAwareLogger; +use Tangible\ScssPhp\Logger\DeprecationAwareLoggerInterface; use Tangible\ScssPhp\Logger\LoggerInterface; use Tangible\ScssPhp\Logger\QuietLogger; use Tangible\ScssPhp\SourceSpan\FileSpan; +use Tangible\ScssPhp\SourceSpan\LazyFileSpan; +use Tangible\ScssPhp\SourceSpan\SourceLocation; use Tangible\ScssPhp\Util; use Tangible\ScssPhp\Util\Character; use Tangible\ScssPhp\Util\ParserUtil; @@ -27,23 +29,18 @@ */ class Parser { - /** - * @var StringScanner - * @readonly - */ - protected $scanner; + protected readonly StringScanner $scanner; - /** - * @var LocationAwareLoggerInterface - * @readonly - */ - protected $logger; + protected readonly DeprecationAwareLoggerInterface $logger; /** - * @var string|null - * @readonly + * A map used to map source spans in the text being parsed back to their + * original locations in the source file, if this isn't being parsed directly + * from source. */ - protected $sourceUrl; + private readonly ?InterpolationMap $interpolationMap; + + protected readonly ?string $sourceUrl; /** * Parses $text as a CSS identifier and returns the result. @@ -64,16 +61,17 @@ public static function isIdentifier(string $text, ?LoggerInterface $logger = nul self::parseIdentifier($text, $logger); return true; - } catch (SassFormatException $e) { + } catch (SassFormatException) { return false; } } - public function __construct(string $contents, ?LoggerInterface $logger = null, ?string $sourceUrl = null) + public function __construct(string $contents, ?LoggerInterface $logger = null, ?string $sourceUrl = null, ?InterpolationMap $interpolationMap = null) { $this->scanner = new StringScanner($contents); - $this->logger = AdaptingLogger::adaptLogger($logger ?? new QuietLogger()); + $this->logger = AdaptingDeprecationAwareLogger::adaptLogger($logger ?? new QuietLogger()); $this->sourceUrl = $sourceUrl; + $this->interpolationMap = $interpolationMap; } /** @@ -81,14 +79,12 @@ public function __construct(string $contents, ?LoggerInterface $logger = null, ? */ private function doParseIdentifier(): string { - try { + return $this->wrapSpanFormatException(function () { $result = $this->identifier(); $this->scanner->expectDone(); return $result; - } catch (FormatException $e) { - throw $this->wrapException($e); - } + }); } /** @@ -386,13 +382,13 @@ protected function declarationValue(bool $allowEmpty = false): string case '"': case "'": - $buffer .= $this->rawText([$this, 'string']); + $buffer .= $this->rawText($this->string(...)); $wroteNewline = false; break; case '/': if ($this->scanner->peekChar(1) === '*') { - $buffer .= $this->rawText([$this, 'loudComment']); + $buffer .= $this->rawText($this->loudComment(...)); } else { $buffer .= $this->scanner->readChar(); } @@ -582,7 +578,7 @@ protected function escape(bool $identifierStart = false): string assert(\is_int($value)); } - $this->scanCharIf([Character::class, 'isWhitespace']); + $this->scanCharIf(Character::isWhitespace(...)); $valueText = Util::mbChr($value); } else { $valueText = $this->scanner->readUtf8Char(); @@ -824,7 +820,7 @@ protected function matchesIdentifier(string $text, bool $caseSensitive = false): } /** - * Consumes $text as an identifer, but doesn't verify whether there's + * Consumes $text as an identifier, but doesn't verify whether there's * additional identifier text afterwards. * * Returns `true` if the full $text is consumed and `false` otherwise, but @@ -850,7 +846,7 @@ private function consumeIdentifier(string $text, bool $caseSensitive): bool */ protected function expectIdentifier(string $text, ?string $name = null, bool $caseSensitive = false): void { - $name = $name ?? "\"$text\""; + $name ??= "\"$text\""; $start = $this->scanner->getPosition(); @@ -872,7 +868,7 @@ protected function expectIdentifier(string $text, ?string $name = null, bool $ca /** * Runs $consumer and returns the source text that it consumes. * - * @param callable(): void $consumer + * @param callable(): (mixed|void) $consumer */ protected function rawText(callable $consumer): string { @@ -882,6 +878,22 @@ protected function rawText(callable $consumer): string return $this->scanner->substring($start); } + /** + * Like {@see StringScanner::spanFrom()} but passes the span through {@see $interpolationMap} if it's available. + */ + protected function spanFrom(int $position): FileSpan + { + $span = $this->scanner->spanFrom($position); + + if ($this->interpolationMap === null) { + return $span; + } + + $interpolationMap = $this->interpolationMap; + + return new LazyFileSpan(static fn() => $interpolationMap->mapSpan($span)); + } + /** * Prints a warning to standard error, associated with $span. */ @@ -894,54 +906,85 @@ protected function warn(string $message, FileSpan $span): void * Throws an error associated with $position. * * @throws FormatException - * - * @return never-returns */ - protected function error(string $message, FileSpan $span, ?\Throwable $previous = null): void + protected function error(string $message, FileSpan $span, ?\Throwable $previous = null): never { throw new FormatException($message, $span, $previous); } - protected function wrapException(FormatException $error): SassFormatException + /** + * Runs $callback and wraps any {@see FormatException} it throws in a + * {@see SassFormatException} + * + * @template T + * @param callable(): T $callback + * @return T + * + * @throws SassFormatException + */ + protected function wrapSpanFormatException(callable $callback) { - $span = $error->getSpan(); + try { + try { + return $callback(); + } catch (FormatException $e) { + if ($this->interpolationMap === null) { + throw $e; + } - if ($span->getLength() === 0 && 0 === stripos($error->getMessage(), 'expected')) { - $startPosition = $this->firstNewlineBefore($span->getStart()->getOffset()); + throw $this->interpolationMap->mapException($e); + } + } catch (FormatException $error) { + $span = $error->getSpan(); - if ($startPosition !== $span->getStart()->getOffset()) { - $span = $span->getFile()->span($startPosition, $startPosition); + if (0 === stripos($error->getMessage(), 'expected')) { + $span = $this->adjustExceptionSpan($span); } + + throw new SassFormatException($error->getMessage(), $span, $error); + } + // TODO handle multi-span exceptions + } + + /** + * Moves span to {@see firstNewlineBefore} if necessary. + */ + private function adjustExceptionSpan(FileSpan $span): FileSpan + { + if ($span->getLength() > 0) { + return $span; } - return new SassFormatException($error->getMessage(), $span, $error); + $start = $this->firstNewlineBefore($span->getStart()); + + if ($start === $span->getStart()) { + return $span; + } + + return $start->pointSpan(); } /** - * If [position] is separated from the previous non-whitespace character in - * `$scanner->getString()` by one or more newlines, returns the offset of the last + * If $location is separated from the previous non-whitespace character in + * `$scanner->getString()` by one or more newlines, returns the location of the last * separating newline. * - * Otherwise returns $position. + * Otherwise returns $location. * * This helps avoid missing token errors pointing at the next closing bracket * rather than the line where the problem actually occurred. - * - * @param int $position - * - * @return int */ - private function firstNewlineBefore(int $position): int + private function firstNewlineBefore(SourceLocation $location): SourceLocation { - $index = $position - 1; + $text = $location->getFile()->getText(0, $location->getOffset()); + $index = $location->getOffset() - 1; $lastNewline = null; - $string = $this->scanner->getString(); while ($index >= 0) { - $char = $string[$index]; + $char = $text[$index]; if (!Character::isWhitespace($char)) { - return $lastNewline ?? $position; + return $lastNewline === null ? $location : $location->getFile()->location($lastNewline); } if (Character::isNewline($char)) { @@ -950,9 +993,9 @@ private function firstNewlineBefore(int $position): int $index--; } - // If the document *only* contains whitespace before $position, always - // return $position. + // If the document *only* contains whitespace before $location, always + // return $location. - return $position; + return $location; } } diff --git a/modules/sass/scssphp/Parser/ScssParser.php b/modules/sass/scssphp/Parser/ScssParser.php index 39be4e9e..d56fb19e 100644 --- a/modules/sass/scssphp/Parser/ScssParser.php +++ b/modules/sass/scssphp/Parser/ScssParser.php @@ -15,6 +15,7 @@ use Tangible\ScssPhp\Ast\Sass\Interpolation; use Tangible\ScssPhp\Ast\Sass\Statement\LoudComment; use Tangible\ScssPhp\Ast\Sass\Statement\SilentComment; +use Tangible\ScssPhp\Deprecation; use Tangible\ScssPhp\Util\Character; /** @@ -80,7 +81,7 @@ protected function scanElse(int $ifIndentation): bool } if ($this->scanIdentifier('elseif', true)) { - $this->logger->warn("@elseif is deprecated and will not be supported in future Sass versions.\n\nRecommendation: @else if", true, $this->scanner->spanFrom($beforeAt)); + $this->logger->warnForDeprecation(Deprecation::elseif, "@elseif is deprecated and will not be supported in future Sass versions.\n\nRecommendation: @else if", $this->scanner->spanFrom($beforeAt)); $this->scanner->setPosition($this->scanner->getPosition() - 2); @@ -209,7 +210,7 @@ private function silentCommentStatement(): SilentComment break; } - $this->whitespaceWithoutComments(); + $this->spaces(); } while ($this->scanner->scan('//')); if ($this->isPlainCss()) { diff --git a/modules/sass/scssphp/Parser/SelectorParser.php b/modules/sass/scssphp/Parser/SelectorParser.php index 3210f0c9..a02ee021 100644 --- a/modules/sass/scssphp/Parser/SelectorParser.php +++ b/modules/sass/scssphp/Parser/SelectorParser.php @@ -12,6 +12,7 @@ namespace Tangible\ScssPhp\Parser; +use Tangible\ScssPhp\Ast\Css\CssValue; use Tangible\ScssPhp\Ast\Selector\AttributeOperator; use Tangible\ScssPhp\Ast\Selector\AttributeSelector; use Tangible\ScssPhp\Ast\Selector\ClassSelector; @@ -49,28 +50,20 @@ final class SelectorParser extends Parser */ private const SELECTOR_PSEUDO_ELEMENTS = ['slotted']; - /** - * @var bool - * @readonly - */ - private $allowParent; + private readonly bool $allowParent; - /** - * @var bool - * @readonly - */ - private $allowPlaceholder; + private readonly bool $allowPlaceholder; - public function __construct(string $contents, ?LoggerInterface $logger = null, ?string $url = null, bool $allowParent = true, bool $allowPlaceholder = true) + public function __construct(string $contents, ?LoggerInterface $logger = null, ?string $url = null, bool $allowParent = true, ?InterpolationMap $interpolationMap = null, bool $allowPlaceholder = true) { $this->allowParent = $allowParent; $this->allowPlaceholder = $allowPlaceholder; - parent::__construct($contents, $logger, $url); + parent::__construct($contents, $logger, $url, $interpolationMap); } public function parse(): SelectorList { - try { + return $this->wrapSpanFormatException(function () { $selector = $this->selectorList(); if (!$this->scanner->isDone()) { @@ -78,14 +71,12 @@ public function parse(): SelectorList } return $selector; - } catch (FormatException $e) { - throw $this->wrapException($e); - } + }); } public function parseComplexSelector(): ComplexSelector { - try { + return $this->wrapSpanFormatException(function () { $complex = $this->complexSelector(); if (!$this->scanner->isDone()) { @@ -93,14 +84,12 @@ public function parseComplexSelector(): ComplexSelector } return $complex; - } catch (FormatException $e) { - throw $this->wrapException($e); - } + }); } public function parseCompoundSelector(): CompoundSelector { - try { + return $this->wrapSpanFormatException(function () { $compound = $this->compoundSelector(); if (!$this->scanner->isDone()) { @@ -108,14 +97,12 @@ public function parseCompoundSelector(): CompoundSelector } return $compound; - } catch (FormatException $e) { - throw $this->wrapException($e); - } + }); } public function parseSimpleSelector(): SimpleSelector { - try { + return $this->wrapSpanFormatException(function () { $simple = $this->simpleSelector(); if (!$this->scanner->isDone()) { @@ -123,9 +110,7 @@ public function parseSimpleSelector(): SimpleSelector } return $simple; - } catch (FormatException $e) { - throw $this->wrapException($e); - } + }); } /** @@ -133,6 +118,7 @@ public function parseSimpleSelector(): SimpleSelector */ private function selectorList(): SelectorList { + $start = $this->scanner->getPosition(); $previousLine = $this->scanner->getLine(); $components = [$this->complexSelector()]; @@ -158,7 +144,7 @@ private function selectorList(): SelectorList $components[] = $this->complexSelector($lineBreak); } - return new SelectorList($components); + return new SelectorList($components, $this->spanFrom($start)); } /** @@ -169,7 +155,11 @@ private function selectorList(): SelectorList */ private function complexSelector(bool $lineBreak = false): ComplexSelector { + $start = $this->scanner->getPosition(); + + $componentStart = $this->scanner->getPosition(); $lastCompound = null; + /** @var list> $combinators */ $combinators = []; $initialCombinators = null; @@ -182,18 +172,21 @@ private function complexSelector(bool $lineBreak = false): ComplexSelector switch ($next) { case '+': + $combinatorStart = $this->scanner->getPosition(); $this->scanner->readChar(); - $combinators[] = Combinator::NEXT_SIBLING; + $combinators[] = new CssValue(Combinator::NEXT_SIBLING, $this->spanFrom($combinatorStart)); break; case '>': + $combinatorStart = $this->scanner->getPosition(); $this->scanner->readChar(); - $combinators[] = Combinator::CHILD; + $combinators[] = new CssValue(Combinator::CHILD, $this->spanFrom($combinatorStart)); break; case '~': + $combinatorStart = $this->scanner->getPosition(); $this->scanner->readChar(); - $combinators[] = Combinator::FOLLOWING_SIBLING; + $combinators[] = new CssValue(Combinator::FOLLOWING_SIBLING, $this->spanFrom($combinatorStart)); break; default: @@ -202,10 +195,11 @@ private function complexSelector(bool $lineBreak = false): ComplexSelector } if ($lastCompound !== null) { - $components[] = new ComplexSelectorComponent($lastCompound, $combinators); + $components[] = new ComplexSelectorComponent($lastCompound, $combinators, $this->spanFrom($componentStart)); } elseif (\count($combinators) !== 0) { \assert($initialCombinators === null); $initialCombinators = $combinators; + $componentStart = $this->scanner->getPosition(); } $lastCompound = $this->compoundSelector(); $combinators = []; @@ -218,14 +212,14 @@ private function complexSelector(bool $lineBreak = false): ComplexSelector } if ($lastCompound !== null) { - $components[] = new ComplexSelectorComponent($lastCompound, $combinators); + $components[] = new ComplexSelectorComponent($lastCompound, $combinators, $this->spanFrom($componentStart)); } elseif (\count($combinators) !== 0) { $initialCombinators = $combinators; } else { $this->scanner->error('expected selector.'); } - return new ComplexSelector($initialCombinators ?? [], $components, $lineBreak); + return new ComplexSelector($initialCombinators ?? [], $components, $this->spanFrom($start), $lineBreak); } /** @@ -233,13 +227,14 @@ private function complexSelector(bool $lineBreak = false): ComplexSelector */ private function compoundSelector(): CompoundSelector { + $start = $this->scanner->getPosition(); $components = [$this->simpleSelector()]; while (Character::isSimpleSelectorStart($this->scanner->peekChar())) { $components[] = $this->simpleSelector(false); } - return new CompoundSelector($components); + return new CompoundSelector($components, $this->spanFrom($start)); } /** @@ -251,7 +246,7 @@ private function compoundSelector(): CompoundSelector private function simpleSelector(?bool $allowParent = null): SimpleSelector { $start = $this->scanner->getPosition(); - $allowParent = $allowParent ?? $this->allowParent; + $allowParent ??= $this->allowParent; switch ($this->scanner->peekChar()) { case '[': @@ -290,6 +285,7 @@ private function simpleSelector(?bool $allowParent = null): SimpleSelector */ private function attributeSelector(): AttributeSelector { + $start = $this->scanner->getPosition(); $this->scanner->expectChar('['); $this->whitespace(); @@ -297,7 +293,7 @@ private function attributeSelector(): AttributeSelector $this->whitespace(); if ($this->scanner->scanChar(']')) { - return AttributeSelector::create($name); + return AttributeSelector::create($name, $this->spanFrom($start)); } $operator = $this->attributeOperator(); @@ -312,7 +308,7 @@ private function attributeSelector(): AttributeSelector $this->scanner->expectChar(']'); - return AttributeSelector::withOperator($name, $operator, $value, $modifier); + return AttributeSelector::withOperator($name, $operator, $value, $this->spanFrom($start), $modifier); } /** @@ -339,10 +335,8 @@ private function attributeName(): QualifiedName /** * Consumes an attribute selector's operator. - * - * @phpstan-return AttributeOperator::* */ - private function attributeOperator(): string + private function attributeOperator(): AttributeOperator { $start = $this->scanner->getPosition(); @@ -380,10 +374,11 @@ private function attributeOperator(): string */ private function classSelector(): ClassSelector { + $start = $this->scanner->getPosition(); $this->scanner->expectChar('.'); $name = $this->identifier(); - return new ClassSelector($name); + return new ClassSelector($name, $this->spanFrom($start)); } /** @@ -391,10 +386,11 @@ private function classSelector(): ClassSelector */ private function idSelector(): IDSelector { + $start = $this->scanner->getPosition(); $this->scanner->expectChar('#'); $name = $this->identifier(); - return new IDSelector($name); + return new IDSelector($name, $this->spanFrom($start)); } /** @@ -402,10 +398,11 @@ private function idSelector(): IDSelector */ private function placeholderSelector(): PlaceholderSelector { + $start = $this->scanner->getPosition(); $this->scanner->expectChar('%'); $name = $this->identifier(); - return new PlaceholderSelector($name); + return new PlaceholderSelector($name, $this->spanFrom($start)); } /** @@ -413,10 +410,11 @@ private function placeholderSelector(): PlaceholderSelector */ private function parentSelector(): ParentSelector { + $start = $this->scanner->getPosition(); $this->scanner->expectChar('&'); $suffix = $this->lookingAtIdentifierBody() ? $this->identifierBody() : null; - return new ParentSelector($suffix); + return new ParentSelector($this->spanFrom($start), $suffix); } /** @@ -424,12 +422,13 @@ private function parentSelector(): ParentSelector */ private function pseudoSelector(): PseudoSelector { + $start = $this->scanner->getPosition(); $this->scanner->expectChar(':'); $element = $this->scanner->scanChar(':'); $name = $this->identifier(); if (!$this->scanner->scanChar('(')) { - return new PseudoSelector($name, $element); + return new PseudoSelector($name, $this->spanFrom($start), $element); } $this->whitespace(); @@ -462,7 +461,7 @@ private function pseudoSelector(): PseudoSelector $this->scanner->expectChar(')'); - return new PseudoSelector($name, $element, $argument, $selector); + return new PseudoSelector($name, $this->spanFrom($start), $element, $argument, $selector); } /** @@ -533,42 +532,43 @@ private function aNPlusB(): string */ private function typeOrUniversalSelector(): SimpleSelector { + $start = $this->scanner->getPosition(); $first = $this->scanner->peekChar(); if ($first === '*') { $this->scanner->readChar(); if (!$this->scanner->scanChar('|')) { - return new UniversalSelector(); + return new UniversalSelector($this->spanFrom($start)); } if ($this->scanner->scanChar('*')) { - return new UniversalSelector('*'); + return new UniversalSelector($this->spanFrom($start), '*'); } - return new TypeSelector(new QualifiedName($this->identifier(), '*')); + return new TypeSelector(new QualifiedName($this->identifier(), '*'), $this->spanFrom($start)); } if ($first === '|') { $this->scanner->readChar(); if ($this->scanner->scanChar('*')) { - return new UniversalSelector(''); + return new UniversalSelector($this->spanFrom($start), ''); } - return new TypeSelector(new QualifiedName($this->identifier(), '')); + return new TypeSelector(new QualifiedName($this->identifier(), ''), $this->spanFrom($start)); } $nameOrNamespace = $this->identifier(); if (!$this->scanner->scanChar('|')) { - return new TypeSelector(new QualifiedName($nameOrNamespace)); + return new TypeSelector(new QualifiedName($nameOrNamespace), $this->spanFrom($start)); } if ($this->scanner->scanChar('*')) { - return new UniversalSelector($nameOrNamespace); + return new UniversalSelector($this->spanFrom($start), $nameOrNamespace); } - return new TypeSelector(new QualifiedName($this->identifier(), $nameOrNamespace)); + return new TypeSelector(new QualifiedName($this->identifier(), $nameOrNamespace), $this->spanFrom($start)); } } diff --git a/modules/sass/scssphp/Parser/StringScanner.php b/modules/sass/scssphp/Parser/StringScanner.php index 7e3c5436..58ff2fe3 100644 --- a/modules/sass/scssphp/Parser/StringScanner.php +++ b/modules/sass/scssphp/Parser/StringScanner.php @@ -21,8 +21,8 @@ * The scanner only supports UTF-8 strings. * * Differences with Dart: - * - reading a character is reading a byte, not an UTF-16 code unit (as PHP strings are not UTF-16). The - * {@see readUtf8Char} method can be used to consume an UTF-8 char. + * - reading a character is reading a byte, not a UTF-16 code unit (as PHP strings are not UTF-16). The + * {@see readUtf8Char} method can be used to consume a UTF-8 char. * - characters are represented as a single-char string, not as an integer with their UTF-16 char code * - offsets are based on bytes, not on UTF-16 code units. In practice, parsing Sass generally needs * to peak following chars only when already knowing that the current char is an ASCII one, which @@ -36,22 +36,11 @@ */ class StringScanner { - /** - * @var string - * @readonly - */ - private $string; + private readonly string $string; - /** - * @var int - */ - private $position = 0; + private int $position = 0; - /** - * @var SourceFile - * @readonly - */ - private $sourceFile; + private readonly SourceFile $sourceFile; public function __construct(string $content, ?string $sourceUrl = null) { @@ -76,9 +65,7 @@ public function setPosition(int $position): void public function spanFrom(int $start, ?int $end = null): FileSpan { - $end = $end ?? $this->position; - - return $this->sourceFile->span($start, $end); + return $this->sourceFile->span($start, $end ?? $this->position); } /** @@ -133,9 +120,7 @@ public function readUtf8Char(): string } /** - * Consumes the next character in the string if it the provided character. - * - * @param string $char + * Consumes the next character in the string if it is the provided character. * * @return bool Whether the character was consumed. * @@ -159,8 +144,6 @@ public function scanChar(string $char): bool /** * Consumes the provided string if it appears at the current position. * - * @param string $string - * * @return bool Whether the string was consumed. * * @phpstan-impure @@ -198,11 +181,6 @@ public function matches(string $string): bool * the expected name of the character being matched; if it's `null`, the * character itself is used instead. * - * @param string $character - * @param string|null $name - * - * @return void - * * @throws FormatException * * @phpstan-impure @@ -221,10 +199,6 @@ public function expectChar(string $character, ?string $name = null): void } /** - * @param string $string - * - * @return void - * * @throws FormatException * * @phpstan-impure @@ -256,10 +230,6 @@ public function expectDone(): void * The offset can be negative to peek already seen characters. * Returns null if the offset goes out of range. * This does not affect the position or the last match. - * - * @param int $offset - * - * @return string|null */ public function peekChar(int $offset = 0): ?string { @@ -276,11 +246,6 @@ public function peekChar(int $offset = 0): ?string * Returns the substring of the string between $start and $end (excluded). * * $end defaults to the current position. - * - * @param int $start - * @param int|null $end - * - * @return string */ public function substring(int $start, ?int $end = null): string { @@ -297,8 +262,6 @@ public function substring(int $start, ?int $end = null): string /** * The scanner's current (zero-based) line number. - * - * @return int */ public function getLine(): int { @@ -307,8 +270,6 @@ public function getLine(): int /** * The scanner's current (zero-based) column number. - * - * @return int */ public function getColumn(): int { @@ -316,18 +277,12 @@ public function getColumn(): int } /** - * @param string $message - * @param int|null $position - * @param int|null $length - * * @throws FormatException - * - * @return never-returns */ - public function error(string $message, ?int $position = null, ?int $length = null) + public function error(string $message, ?int $position = null, ?int $length = null): never { - $position = $position ?? $this->position; - $length = $length ?? 0; + $position ??= $this->position; + $length ??= 0; $span = $this->sourceFile->span($position, $position + $length); @@ -335,13 +290,9 @@ public function error(string $message, ?int $position = null, ?int $length = nul } /** - * @param string $message - * * @throws FormatException - * - * @return never-returns */ - private function fail(string $message) + private function fail(string $message): never { $this->error("expected $message."); } diff --git a/modules/sass/scssphp/Parser/StylesheetParser.php b/modules/sass/scssphp/Parser/StylesheetParser.php index 1aa693a3..77912c86 100644 --- a/modules/sass/scssphp/Parser/StylesheetParser.php +++ b/modules/sass/scssphp/Parser/StylesheetParser.php @@ -21,7 +21,6 @@ use Tangible\ScssPhp\Ast\Sass\Expression\BinaryOperationExpression; use Tangible\ScssPhp\Ast\Sass\Expression\BinaryOperator; use Tangible\ScssPhp\Ast\Sass\Expression\BooleanExpression; -use Tangible\ScssPhp\Ast\Sass\Expression\CalculationExpression; use Tangible\ScssPhp\Ast\Sass\Expression\ColorExpression; use Tangible\ScssPhp\Ast\Sass\Expression\FunctionExpression; use Tangible\ScssPhp\Ast\Sass\Expression\IfExpression; @@ -76,6 +75,7 @@ use Tangible\ScssPhp\Ast\Sass\SupportsCondition\SupportsNegation; use Tangible\ScssPhp\Ast\Sass\SupportsCondition\SupportsOperation; use Tangible\ScssPhp\Colors; +use Tangible\ScssPhp\Deprecation; use Tangible\ScssPhp\Exception\SassFormatException; use Tangible\ScssPhp\Logger\LoggerInterface; use Tangible\ScssPhp\SourceSpan\FileSpan; @@ -94,60 +94,44 @@ abstract class StylesheetParser extends Parser { /** * The silent comment this parser encountered previously. - * - * @var SilentComment|null */ - protected $lastSilentComment; + protected ?SilentComment $lastSilentComment = null; /** * Whether we've consumed a rule other than `@charset`, `@forward`, or `@use`. - * - * @var bool */ - private $isUseAllowed = true; + private bool $isUseAllowed = true; /** * Whether the parser is currently parsing the contents of a mixin declaration. - * - * @var bool */ - private $inMixin = false; + private bool $inMixin = false; /** * Whether the parser is currently parsing a content block passed to a mixin. - * - * @var bool */ - private $inContentBlock = false; + private bool $inContentBlock = false; /** * Whether the parser is currently parsing a control directive such as `@if` * or `@each`. - * - * @var bool */ - private $inControlDirective = false; + private bool $inControlDirective = false; /** * Whether the parser is currently parsing an unknown rule. - * - * @var bool */ - private $inUnknownAtRule = false; + private bool $inUnknownAtRule = false; /** * Whether the parser is currently parsing a style rule. - * - * @var bool */ - private $inStyleRule = false; + private bool $inStyleRule = false; /** * Whether the parser is currently within a parenthesized expression. - * - * @var bool */ - private $inParentheses = false; + private bool $inParentheses = false; /** * A map from all variable names that are assigned with `!global` in the @@ -160,35 +144,11 @@ abstract class StylesheetParser extends Parser * * @var array */ - private $globalVariables = []; - - /** - * @var \Closure - * @readonly - */ - private $statementCallable; - - /** - * @var \Closure - * @readonly - */ - private $declarationChildCallable; - - /** - * @var \Closure - * @readonly - */ - private $functionChildCallable; + private array $globalVariables = []; public function __construct(string $contents, ?LoggerInterface $logger = null, ?string $sourceUrl = null) { parent::__construct($contents, $logger, $sourceUrl); - - // Store callables for some private methods, to ensure they pass callable typehints when passed - // to parent methods expecting a callable, due to the semantic of PHP array callables. - $this->statementCallable = \Closure::fromCallable([$this, 'statement']); - $this->declarationChildCallable = \Closure::fromCallable([$this, 'declarationChild']); - $this->functionChildCallable = \Closure::fromCallable([$this, 'functionChild']); } /** @@ -196,7 +156,7 @@ public function __construct(string $contents, ?LoggerInterface $logger = null, ? */ public function parse(): Stylesheet { - try { + return $this->wrapSpanFormatException(function () { $start = $this->scanner->getPosition(); // Allow a byte-order mark at the beginning of the document. @@ -223,14 +183,12 @@ public function parse(): Stylesheet } return new Stylesheet($statements, $this->scanner->spanFrom($start), $this->isPlainCss()); - } catch (FormatException $e) { - throw $this->wrapException($e); - } + }); } public function parseArgumentDeclaration(): ArgumentDeclaration { - try { + return $this->wrapSpanFormatException(function () { $this->scanner->expectChar('@', '@-rule'); $this->identifier(); $this->whitespace(); @@ -242,9 +200,7 @@ public function parseArgumentDeclaration(): ArgumentDeclaration $this->scanner->expectDone(); return $arguments; - } catch (FormatException $e) { - throw $this->wrapException($e); - } + }); } /** @@ -258,7 +214,7 @@ private function statement(bool $root = false): Statement { switch ($this->scanner->peekChar()) { case '@': - return $this->atRule($this->statementCallable, $root); + return $this->atRule($this->statement(...), $root); case '+': if (!$this->isIndented()) { @@ -312,9 +268,7 @@ protected function variableDeclarationWithoutNamespace(?string $namespace = null $name = $this->variableName(); if ($namespace !== null) { - $this->assertPublic($name, function () use ($start) { - return $this->scanner->spanFrom($start); - }); + $this->assertPublic($name, fn() => $this->scanner->spanFrom($start)); } $this->whitespace(); @@ -330,10 +284,16 @@ protected function variableDeclarationWithoutNamespace(?string $namespace = null while ($this->scanner->scanChar('!')) { $flag = $this->identifier(); if ($flag === 'default') { + if ($guarded) { + $this->logger->warn("!default should only be written once for each variable.\nThis will be an error in Dart Sass 2.0.0.", true, $this->scanner->spanFrom($flagStart)); + } + $guarded = true; } elseif ($flag === 'global') { if ($namespace !== null) { $this->error("!global isn't allowed for variables in other modules.", $this->scanner->spanFrom($flagStart)); + } elseif ($global) { + $this->logger->warn("!global should only be written once for each variable.\nThis will be an error in Dart Sass 2.0.0.", true, $this->scanner->spanFrom($flagStart)); } $global = true; @@ -414,10 +374,8 @@ private function declarationOrStyleRule(): Statement * couldn't consume a declaration and that selector parsing should be * attempted; or it can return a {@see Declaration} or a {@see VariableDeclaration}, * indicating that it successfully consumed a declaration. - * - * @return Statement|InterpolationBuffer */ - private function declarationOrBuffer() + private function declarationOrBuffer(): Statement|InterpolationBuffer { $start = $this->scanner->getPosition(); $nameBuffer = new InterpolationBuffer(); @@ -429,7 +387,7 @@ private function declarationOrBuffer() if ($first === ':' || $first === '*' || $first === '.' || ($first === '#' && $this->scanner->peekChar(1) !== '{')) { $startsWithPunctuation = true; $nameBuffer->write($this->scanner->readChar()); - $nameBuffer->write($this->rawText([$this, 'whitespace'])); + $nameBuffer->write($this->rawText($this->whitespace(...))); } if (!$this->lookingAtInterpolatedIdentifier()) { @@ -447,10 +405,10 @@ private function declarationOrBuffer() $this->isUseAllowed = false; if ($this->scanner->matches('/*')) { - $nameBuffer->write($this->rawText([$this, 'loudComment'])); + $nameBuffer->write($this->rawText($this->loudComment(...))); } - $midBuffer = $this->rawText([$this, 'whitespace']); + $midBuffer = $this->rawText($this->whitespace(...)); $beforeColon = $this->scanner->getPosition(); if (!$this->scanner->scanChar(':')) { @@ -466,7 +424,7 @@ private function declarationOrBuffer() // Parse custom properties as declarations no matter what. $name = $nameBuffer->buildInterpolation($this->scanner->spanFrom($start, $beforeColon)); - if (0 === strpos($name->getInitialPlain(), '--')) { + if (str_starts_with($name->getInitialPlain(), '--')) { $value = new StringExpression($this->interpolatedDeclarationValue()); $this->expectStatementSeparator('custom property'); @@ -486,12 +444,10 @@ private function declarationOrBuffer() return $nameBuffer; } - $postColonWhitespace = $this->rawText([$this, 'whitespace']); + $postColonWhitespace = $this->rawText($this->whitespace(...)); if ($this->lookingAtChildren()) { - return $this->withChildren($this->declarationChildCallable, $start, function (array $children, FileSpan $span) use ($name) { - return Declaration::nested($name, $children, $span); - }); + return $this->withChildren($this->declarationChild(...), $start, fn(array $children, FileSpan $span) => Declaration::nested($name, $children, $span)); } $midBuffer .= $postColonWhitespace; @@ -515,7 +471,6 @@ private function declarationOrBuffer() // reparsed. $this->expectStatementSeparator(); } - } catch (FormatException $e) { if (!$couldBeSelector) { throw $e; @@ -538,9 +493,7 @@ private function declarationOrBuffer() } if ($this->lookingAtChildren()) { - return $this->withChildren($this->declarationChildCallable, $start, function (array $children, FileSpan $span) use ($name, $value) { - return Declaration::nested($name, $children, $span, $value); - }); + return $this->withChildren($this->declarationChild(...), $start, fn(array $children, FileSpan $span) => Declaration::nested($name, $children, $span, $value)); } $this->expectStatementSeparator(); @@ -556,10 +509,8 @@ private function declarationOrBuffer() * consume a variable declaration and that property declaration or selector * parsing should be attempted; or it can return a {@see VariableDeclaration}, * indicating that it successfully consumed a variable declaration. - * - * @return Interpolation|VariableDeclaration */ - private function variableDeclarationOrInterpolation() + private function variableDeclarationOrInterpolation(): Interpolation|VariableDeclaration { if (!$this->lookingAtIdentifier()) { return $this->interpolatedIdentifier(); @@ -606,7 +557,7 @@ private function styleRule(?InterpolationBuffer $buffer = null, ?int $start = nu $wasInStyleRule = $this->inStyleRule; $this->inStyleRule = true; - return $this->withChildren($this->statementCallable, $start, function (array $children) use ($wasInStyleRule, $start, $interpolation) { + return $this->withChildren($this->statement(...), $start, function (array $children) use ($wasInStyleRule, $start, $interpolation) { $this->inStyleRule = $wasInStyleRule; return new StyleRule($interpolation, $children, $this->scanner->spanFrom($start)); @@ -633,7 +584,7 @@ private function propertyOrVariableDeclaration(bool $parseCustomProperties = tru if ($first === ':' || $first === '*' || $first === '.' || ($first === '#' && $this->scanner->peekChar(1) !== '{')) { $nameBuffer = new InterpolationBuffer(); $nameBuffer->write($this->scanner->readChar()); - $nameBuffer->write($this->rawText([$this, 'whitespace'])); + $nameBuffer->write($this->rawText($this->whitespace(...))); $nameBuffer->addInterpolation($this->interpolatedIdentifier()); $name = $nameBuffer->buildInterpolation($this->scanner->spanFrom($start)); } elseif (!$this->isPlainCss()) { @@ -651,7 +602,7 @@ private function propertyOrVariableDeclaration(bool $parseCustomProperties = tru $this->whitespace(); $this->scanner->expectChar(':'); - if ($parseCustomProperties && 0 === strpos($name->getInitialPlain(), '--')) { + if ($parseCustomProperties && str_starts_with($name->getInitialPlain(), '--')) { $value = new StringExpression($this->interpolatedDeclarationValue()); $this->expectStatementSeparator('custom property'); @@ -665,9 +616,7 @@ private function propertyOrVariableDeclaration(bool $parseCustomProperties = tru $this->scanner->error("Nested declarations aren't allowed in plain CSS."); } - return $this->withChildren($this->declarationChildCallable, $start, function (array $children, FileSpan $span) use ($name) { - return Declaration::nested($name, $children, $span); - }); + return $this->withChildren($this->declarationChild(...), $start, fn(array $children, FileSpan $span) => Declaration::nested($name, $children, $span)); } $value = $this->expression(); @@ -677,9 +626,11 @@ private function propertyOrVariableDeclaration(bool $parseCustomProperties = tru $this->scanner->error("Nested declarations aren't allowed in plain CSS."); } - return $this->withChildren($this->declarationChildCallable, $start, function (array $children, FileSpan $span) use ($name, $value) { - return Declaration::nested($name, $children, $span, $value); - }); + return $this->withChildren( + $this->declarationChild(...), + $start, + fn(array $children, FileSpan $span) => Declaration::nested($name, $children, $span, $value) + ); } $this->expectStatementSeparator(); @@ -797,21 +748,21 @@ private function declarationAtRule(): Statement case 'debug': return $this->debugRule($start); case 'each': - return $this->eachRule($start, $this->declarationChildCallable); + return $this->eachRule($start, $this->declarationChild(...)); case 'else': $this->disallowedAtRule($start); case 'error': return $this->errorRule($start); case 'for': - return $this->forRule($start, $this->declarationChildCallable); + return $this->forRule($start, $this->declarationChild(...)); case 'if': - return $this->ifRule($start, $this->declarationChildCallable); + return $this->ifRule($start, $this->declarationChild(...)); case 'include': return $this->includeRule($start); case 'warn': return $this->warnRule($start); case 'while': - return $this->whileRule($start, $this->declarationChildCallable); + return $this->whileRule($start, $this->declarationChild(...)); default: $this->disallowedAtRule($start); } @@ -840,7 +791,7 @@ private function functionChild(): Statement // function. If so, throw a more helpful error message. try { $statement = $this->declarationOrStyleRule(); - } catch (FormatException $e) { + } catch (FormatException) { throw $variableDeclarationError; } @@ -854,21 +805,21 @@ private function functionChild(): Statement case 'debug': return $this->debugRule($start); case 'each': - return $this->eachRule($start, $this->functionChildCallable); + return $this->eachRule($start, $this->functionChild(...)); case 'else': $this->disallowedAtRule($start); case 'error': return $this->errorRule($start); case 'for': - return $this->forRule($start, $this->functionChildCallable); + return $this->forRule($start, $this->functionChild(...)); case 'if': - return $this->ifRule($start, $this->functionChildCallable); + return $this->ifRule($start, $this->functionChild(...)); case 'return': return $this->returnRule($start); case 'warn': return $this->warnRule($start); case 'while': - return $this->whileRule($start, $this->functionChildCallable); + return $this->whileRule($start, $this->functionChild(...)); default: $this->disallowedAtRule($start); } @@ -898,15 +849,11 @@ private function atRootRule(int $start): AtRootRule $query = $this->atRootQuery(); $this->whitespace(); - return $this->withChildren($this->statementCallable, $start, function (array $children, FileSpan $span) use ($query) { - return new AtRootRule($children, $span, $query); - }); + return $this->withChildren($this->statement(...), $start, fn(array $children, FileSpan $span) => new AtRootRule($children, $span, $query)); } if ($this->lookingAtChildren()) { - return $this->withChildren($this->statementCallable, $start, function (array $children, FileSpan $span) { - return new AtRootRule($children, $span); - }); + return $this->withChildren($this->statement(...), $start, fn(array $children, FileSpan $span) => new AtRootRule($children, $span)); } $child = $this->styleRule(); @@ -919,12 +866,6 @@ private function atRootRule(int $start): AtRootRule */ private function atRootQuery(): Interpolation { - if ($this->scanner->peekChar() === '#') { - $interpolation = $this->singleInterpolation(); - - return new Interpolation([$interpolation], $interpolation->getSpan()); - } - $start = $this->scanner->getPosition(); $buffer = new InterpolationBuffer(); $this->scanner->expectChar('('); @@ -1085,9 +1026,11 @@ private function functionRule(int $start): FunctionRule $this->whitespace(); - return $this->withChildren($this->functionChildCallable, $start, function (array $children, FileSpan $span) use ($name, $precedingComment, $arguments) { - return new FunctionRule($name, $arguments, $span, $children, $precedingComment); - }); + return $this->withChildren( + $this->functionChild(...), + $start, + fn(array $children, FileSpan $span) => new FunctionRule($name, $arguments, $span, $children, $precedingComment) + ); } /** @@ -1252,10 +1195,10 @@ protected function parseImportUrl(string $url): string // Backwards-compatibility for implementations that allow absolute Windows // paths in imports. if (Path::isWindowsAbsolute($url) && !self::isRootRelativeUrl($url)) { - return (string) Uri::createFromWindowsPath($url); + return (string) Uri::fromWindowsPath($url); } - Uri::createFromString($url); + Uri::new($url); return $url; } @@ -1273,7 +1216,7 @@ protected function isPlainImportUrl(string $url): bool return false; } - if (substr($url, -4) === '.css') { + if (str_ends_with($url, '.css')) { return true; } @@ -1285,7 +1228,7 @@ protected function isPlainImportUrl(string $url): bool return false; } - return 0 === strpos($url, 'http://') || 0 === strpos($url, 'https://'); + return str_starts_with($url, 'http://') || str_starts_with($url, 'https://'); } /** @@ -1447,9 +1390,7 @@ private function includeRule(int $start): IncludeRule $wasInContentBlock = $this->inContentBlock; $this->inContentBlock = true; - $content = $this->withChildren($this->statementCallable, $start, function (array $children, FileSpan $span) use ($contentArguments) { - return new ContentBlock($contentArguments, $children, $span); - }); + $content = $this->withChildren($this->statement(...), $start, fn(array $children, FileSpan $span) => new ContentBlock($contentArguments, $children, $span)); $this->inContentBlock = $wasInContentBlock; } else { @@ -1475,9 +1416,7 @@ protected function mediaRule(int $start): MediaRule { $query = $this->mediaQueryList(); - return $this->withChildren($this->statementCallable, $start, function (array $children, FileSpan $span) use ($query) { - return new MediaRule($query, $children, $span); - }); + return $this->withChildren($this->statement(...), $start, fn(array $children, FileSpan $span) => new MediaRule($query, $children, $span)); } /** @@ -1506,7 +1445,7 @@ private function mixinRule(int $start): MixinRule $this->whitespace(); $this->inMixin = true; - return $this->withChildren($this->statementCallable, $start, function (array $children, FileSpan $span) use ($name, $arguments, $precedingComment) { + return $this->withChildren($this->statement(...), $start, function (array $children, FileSpan $span) use ($name, $arguments, $precedingComment) { $this->inMixin = false; return new MixinRule($name, $arguments, $span, $children, $precedingComment); @@ -1559,7 +1498,7 @@ protected function mozDocumentRule(int $start, Interpolation $name): AtRule // A url-prefix with no argument, or with an empty string as an // argument, is not (yet) deprecated. $trailing = $buffer->getTrailingString(); - if (!StringUtil::endsWith($trailing, 'url-prefix()') && !StringUtil::endsWith($trailing, "url-prefix('')") && !StringUtil::endsWith($trailing, 'url-prefix("")')) { + if (!str_ends_with($trailing, 'url-prefix()') && !str_ends_with($trailing, "url-prefix('')") && !str_ends_with($trailing, 'url-prefix("")')) { $needsDeprecationWarning = true; } break; @@ -1585,14 +1524,14 @@ protected function mozDocumentRule(int $start, Interpolation $name): AtRule } $buffer->write(','); - $buffer->write($this->rawText([$this, 'whitespace'])); + $buffer->write($this->rawText($this->whitespace(...))); } $value = $buffer->buildInterpolation($this->scanner->spanFrom($valueStart)); - return $this->withChildren($this->statementCallable, $start, function (array $children, FileSpan $span) use ($name, $value, $needsDeprecationWarning) { + return $this->withChildren($this->statement(...), $start, function (array $children, FileSpan $span) use ($name, $value, $needsDeprecationWarning) { if ($needsDeprecationWarning) { - $this->logger->warn("@-moz-document is deprecated and support will be removed in Dart Sass 2.0.0.\n\nFor details, see https://sass-lang.com/d/moz-document.", true, $span); + $this->logger->warnForDeprecation(Deprecation::mozDocument, "@-moz-document is deprecated and support will be removed in Dart Sass 2.0.0.\n\nFor details, see https://sass-lang.com/d/moz-document.", $span); } return new AtRule($name, $span, $value, $children); @@ -1622,9 +1561,7 @@ protected function supportsRule(int $start): SupportsRule $condition = $this->supportsCondition(); $this->whitespace(); - return $this->withChildren($this->statementCallable, $start, function (array $children, FileSpan $span) use ($condition) { - return new SupportsRule($condition, $children, $span); - }); + return $this->withChildren($this->statement(...), $start, fn(array $children, FileSpan $span) => new SupportsRule($condition, $children, $span)); } /** @@ -1679,9 +1616,7 @@ protected function unknownAtRule(int $start, Interpolation $name): AtRule } if ($this->lookingAtChildren()) { - $rule = $this->withChildren($this->statementCallable, $start, function (array $children, FileSpan $span) use ($name, $value) { - return new AtRule($name, $span, $value, $children); - }); + $rule = $this->withChildren($this->statement(...), $start, fn(array $children, FileSpan $span) => new AtRule($name, $span, $value, $children)); } else { $this->expectStatementSeparator(); $rule = new AtRule($name, $this->scanner->spanFrom($start), $value); @@ -1827,7 +1762,6 @@ private function argumentInvocation(bool $mixin = false, bool $allowEmptySecondA * Consumes an expression. * * @param (callable(): bool)|null $until - * * @phpstan-impure */ private function expression(?callable $until = null, bool $singleEquals = false, bool $bracketList = false): Expression @@ -1851,11 +1785,11 @@ private function expression(?callable $until = null, bool $singleEquals = false, $start = $this->scanner->getPosition(); $wasInParentheses = $this->inParentheses; /** - * @var Expression[]|null $commaExpressions + * @var list|null $commaExpressions */ $commaExpressions = null; /** - * @var Expression[]|null $spaceExpressions + * @var list|null $spaceExpressions */ $spaceExpressions = null; /** @@ -1864,7 +1798,7 @@ private function expression(?callable $until = null, bool $singleEquals = false, * parsing to finish for all preceding higher-precedence $operators, this is * naturally ordered from lowest to highest precedence. * - * @phpstan-var list|null $operators + * @var list|null $operators */ $operators = null; /** @@ -1920,7 +1854,7 @@ private function expression(?callable $until = null, bool $singleEquals = false, $right = $singleExpression; if ($right === null) { - $this->scanner->error('Expected expression.', $this->scanner->getPosition() - \strlen($operator), \strlen($operator)); + $this->scanner->error('Expected expression.', $this->scanner->getPosition() - \strlen($operator->getOperator()), \strlen($operator->getOperator())); } if ($allowSlash && !$this->inParentheses && $operator === BinaryOperator::DIVIDED_BY && self::isSlashOperand($left) && self::isSlashOperand($right)) { @@ -1931,26 +1865,27 @@ private function expression(?callable $until = null, bool $singleEquals = false, if ($operator === BinaryOperator::PLUS || $operator === BinaryOperator::MINUS) { if ( - $this->scanner->substring($right->getSpan()->getStart()->getOffset() - 1, $right->getSpan()->getStart()->getOffset()) === $operator + $this->scanner->substring($right->getSpan()->getStart()->getOffset() - 1, $right->getSpan()->getStart()->getOffset()) === $operator->getOperator() && Character::isWhitespace($this->scanner->getString()[$left->getSpan()->getEnd()->getOffset()]) ) { + $operatorText = $operator->getOperator(); $message = <<logger->warn($message, true, $singleExpression->getSpan()); + $this->logger->warnForDeprecation(Deprecation::strictUnary, $message, $singleExpression->getSpan()); } } } @@ -1991,38 +1926,42 @@ private function expression(?callable $until = null, bool $singleEquals = false, $singleExpression = $expression; }; - $addOperator = - /** - * @param BinaryOperator::* $operator - */ - function (string $operator) use (&$allowSlash, &$operators, &$operands, &$singleExpression, $resolveOneOperation): void { - /** @var BinaryOperator::* $operator */ - if ($this->isPlainCss() && $operator !== BinaryOperator::DIVIDED_BY && $operator !== BinaryOperator::SINGLE_EQUALS) { - $this->scanner->error("Operators aren't allowed in plain CSS.", $this->scanner->getPosition() - \strlen($operator), \strlen($operator)); - } + $addOperator = function (BinaryOperator $operator) use (&$allowSlash, &$operators, &$operands, &$singleExpression, $resolveOneOperation): void { + if ( + $this->isPlainCss() + && $operator !== BinaryOperator::SINGLE_EQUALS + // These are allowed in calculations, so we have to check them at + // evaluation time. + && $operator !== BinaryOperator::PLUS + && $operator !== BinaryOperator::MINUS + && $operator !== BinaryOperator::TIMES + && $operator !== BinaryOperator::DIVIDED_BY + ) { + $this->scanner->error("Operators aren't allowed in plain CSS.", $this->scanner->getPosition() - \strlen($operator->getOperator()), \strlen($operator->getOperator())); + } - $allowSlash = $allowSlash && $operator === BinaryOperator::DIVIDED_BY; + $allowSlash = $allowSlash && $operator === BinaryOperator::DIVIDED_BY; - $operators = $operators ?? []; - $operands = $operands ?? []; + $operators = $operators ?? []; + $operands = $operands ?? []; - $precedence = BinaryOperator::getPrecedence($operator); + $precedence = $operator->getPrecedence(); - while ($operators && BinaryOperator::getPrecedence($operators[\count($operators) - 1]) >= $precedence) { - $resolveOneOperation(); - } + while ($operators && $operators[\count($operators) - 1]->getPrecedence() >= $precedence) { + $resolveOneOperation(); + } - $operators[] = $operator; + $operators[] = $operator; - if ($singleExpression === null) { - $this->scanner->error('Expected expression.', $this->scanner->getPosition() - \strlen($operator), \strlen($operator)); - } + if ($singleExpression === null) { + $this->scanner->error('Expected expression.', $this->scanner->getPosition() - \strlen($operator->getOperator()), \strlen($operator->getOperator())); + } - $operands[] = $singleExpression; + $operands[] = $singleExpression; - $this->whitespace(); - $singleExpression = $this->singleExpression(); - }; + $this->whitespace(); + $singleExpression = $this->singleExpression(); + }; $resolveSpaceExpressions = function () use (&$spaceExpressions, &$singleExpression, $resolveOperations): void { $resolveOperations(); @@ -2336,9 +2275,7 @@ function (string $operator) use (&$allowSlash, &$operators, &$operands, &$single */ protected function expressionUntilComma(bool $singleEquals = false): Expression { - return $this->expression(function () { - return $this->scanner->peekChar() === ','; - }, $singleEquals); + return $this->expression(fn() => $this->scanner->peekChar() === ',', $singleEquals); } /** @@ -2347,7 +2284,7 @@ protected function expressionUntilComma(bool $singleEquals = false): Expression */ private static function isSlashOperand(Expression $expression): bool { - return $expression instanceof NumberExpression || $expression instanceof CalculationExpression || ($expression instanceof BinaryOperationExpression && $expression->allowsSlash()); + return $expression instanceof NumberExpression || $expression instanceof FunctionExpression || ($expression instanceof BinaryOperationExpression && $expression->allowsSlash()); } /** @@ -2473,7 +2410,7 @@ private function singleExpression(): Expression /** * Consumes a parenthesized expression. */ - private function parentheses(): Expression + protected function parentheses(): Expression { if ($this->isPlainCss()) { $this->scanner->error("Parentheses aren't allowed in plain CSS."); @@ -2636,7 +2573,7 @@ private function hexColorContents(int $start): SassColor // Don't emit four- or eight-digit hex colors as hex, since that's not // yet well-supported in browsers. - return SassColor::rgbInternal($red, $green, $blue, $alpha, $alpha === null ? new SpanColorFormat($this->scanner->spanFrom($start)) : null); + return SassColor::rgbInternal($red, $green, $blue, $alpha ?? 1.0, $alpha === null ? new SpanColorFormat($this->scanner->spanFrom($start)) : null); } private function isHexColor(Interpolation $interpolation): bool @@ -2752,24 +2689,15 @@ private function unaryOperation(): UnaryOperationExpression /** * Returns the unary operator corresponding to $character, or `null` if * the character is not a unary operator. - * - * @return UnaryOperator::*|null */ - private function unaryOperatorFor(string $character): ?string + private function unaryOperatorFor(string $character): ?UnaryOperator { - switch ($character) { - case '+': - return UnaryOperator::PLUS; - - case '-': - return UnaryOperator::MINUS; - - case '/': - return UnaryOperator::DIVIDE; - - default: - return null; - } + return match ($character) { + '+' => UnaryOperator::PLUS, + '-' => UnaryOperator::MINUS, + '/' => UnaryOperator::DIVIDE, + default => null, + }; } /** @@ -2791,7 +2719,7 @@ private function number(): NumberExpression // Don't complain about a dot after a number unless the number starts with a // dot. We don't allow a plain ".", but we need to allow "1." so that // "1..." will work as a rest argument. - $this->tryDecimal($this->scanner->getPosition() !== $start); + $this->tryDecimal($this->scanner->getPosition() !== $start && $first !== '+' && $first !== '-'); $this->tryExponent(); // Use PHP's built-in double parsing so that we don't accumulate @@ -2892,7 +2820,7 @@ private function unicodeRange(): StringExpression $this->scanner->expectChar('+'); $firstRangeLength = 0; - while ($this->scanCharIf([Character::class, 'isHex'])) { + while ($this->scanCharIf(Character::isHex(...))) { $firstRangeLength++; } @@ -2914,7 +2842,7 @@ private function unicodeRange(): StringExpression if ($this->scanner->scanChar('-')) { $secondRangeStart = $this->scanner->getPosition(); $secondRangeLength = 0; - while ($this->scanCharIf([Character::class, 'isHex'])) { + while ($this->scanCharIf(Character::isHex(...))) { $secondRangeLength++; } @@ -3039,7 +2967,9 @@ protected function identifierLike(): Expression if ($plain === 'not') { $this->whitespace(); - return new UnaryOperationExpression(UnaryOperator::NOT, $this->singleExpression(), $identifier->getSpan()); + $expression = $this->singleExpression(); + + return new UnaryOperationExpression(UnaryOperator::NOT, $expression, $identifier->getSpan()->expand($expression->getSpan())); } $lower = strtolower($plain); @@ -3107,9 +3037,7 @@ protected function namespacedExpression(string $namespace, int $start): Expressi { if ($this->scanner->peekChar() === '$') { $name = $this->variableName(); - $this->assertPublic($name, function () use ($start) { - return $this->scanner->spanFrom($start); - }); + $this->assertPublic($name, fn() => $this->scanner->spanFrom($start)); // TODO remove this when implementing modules $this->error('Sass modules are not implemented yet.', $this->scanner->spanFrom($start)); @@ -3120,7 +3048,6 @@ protected function namespacedExpression(string $namespace, int $start): Expressi $this->publicIdentifier(); $this->error('Sass modules are not implemented yet.', $this->scanner->spanFrom($start)); // return new FunctionExpression($this->publicIdentifier(), $this->argumentInvocation(), $this->scanner->spanFrom($start), $plain); - } /** @@ -3130,16 +3057,15 @@ protected function namespacedExpression(string $namespace, int $start): Expressi */ protected function trySpecialFunction(string $name, int $start): ?Expression { - $calculation = $this->scanner->peekChar() === '(' ? $this->tryCalculation($name, $start) : null; - - if ($calculation !== null) { - return $calculation; - } - $normalized = Util::unvendor($name); switch ($normalized) { case 'calc': + if ($normalized === $name) { + return null; + } + + // fall through case 'element': case 'expression': if (!$this->scanner->scanChar('(')) { @@ -3191,281 +3117,6 @@ protected function trySpecialFunction(string $name, int $start): ?Expression return new StringExpression($buffer->buildInterpolation($this->scanner->spanFrom($start))); } - /** - * If $name is the name of a calculation expression, parses the - * corresponding calculation and returns it. - * - * Assumes the scanner is positioned immediately before the opening - * parenthesis of the argument list. - */ - private function tryCalculation(string $name, int $start): ?CalculationExpression - { - assert($this->scanner->peekChar() === '('); - - switch ($name) { - case 'calc': - $arguments = $this->calculationArguments(1); - - return new CalculationExpression($name, $arguments, $this->scanner->spanFrom($start)); - - case 'min': - case 'max': - // min() and max() are parsed as calculations if possible, and otherwise - // are parsed as normal Sass functions. - $beforeArguments = $this->scanner->getPosition(); - - try { - $arguments = $this->calculationArguments(); - } catch (FormatException $e) { - $this->scanner->setPosition($beforeArguments); - - return null; - } - - return new CalculationExpression($name, $arguments, $this->scanner->spanFrom($start)); - - case 'clamp': - $arguments = $this->calculationArguments(3); - - return new CalculationExpression($name, $arguments, $this->scanner->spanFrom($start)); - - default: - return null; - } - } - - /** - * Consumes and returns arguments for a calculation expression, including the - * opening and closing parentheses. - * - * If $maxArgs is passed, at most that many arguments are consumed. - * Otherwise, any number greater than zero are consumed. - * - * @param int|null $maxArgs - * - * @return list - * - * @throws FormatException - */ - private function calculationArguments(?int $maxArgs = null): array - { - $this->scanner->expectChar('('); - $interpolation = $this->tryCalculationInterpolation(); - - if ($interpolation !== null) { - $this->scanner->expectChar(')'); - - return [$interpolation]; - } - - $this->whitespace(); - - $arguments = [$this->calculationSum()]; - - while (($maxArgs === null || \count($arguments) < $maxArgs) && $this->scanner->scanChar(',')) { - $this->whitespace(); - $arguments[] = $this->calculationSum(); - } - - $this->scanner->expectChar(')', \count($arguments) === $maxArgs ? '"+", "-", "*", "/", or ")"' : '"+", "-", "*", "/", ",", or ")"'); - - return $arguments; - } - - /** - * Parses a calculation operation or value expression. - */ - private function calculationSum(): Expression - { - $sum = $this->calculationProduct(); - - while (true) { - $next = $this->scanner->peekChar(); - - if ($next === '+' || $next === '-') { - if (!Character::isWhitespace($this->scanner->peekChar(-1)) || !Character::isWhitespace($this->scanner->peekChar(1))) { - $this->scanner->error('"+" and "-" must be surrounded by whitespace in calculations.'); - } - - $this->scanner->readChar(); - $this->whitespace(); - $sum = new BinaryOperationExpression( - $next === '+' ? BinaryOperator::PLUS : BinaryOperator::MINUS, - $sum, - $this->calculationProduct() - ); - } else { - return $sum; - } - } - } - - /** - * Parses a calculation product or value expression. - */ - private function calculationProduct(): Expression - { - $product = $this->calculationValue(); - - while (true) { - $this->whitespace(); - $next = $this->scanner->peekChar(); - - if ($next === '*' || $next === '/') { - $this->scanner->readChar(); - $this->whitespace(); - $product = new BinaryOperationExpression( - $next === '*' ? BinaryOperator::TIMES : BinaryOperator::DIVIDED_BY, - $product, - $this->calculationValue() - ); - } else { - return $product; - } - } - } - - /** - * Parses a single calculation value. - */ - private function calculationValue(): Expression - { - $next = $this->scanner->peekChar(); - - if ($next === '+' || $next === '-' || $next === '.' || Character::isDigit($next)) { - return $this->number(); - } - - if ($next === '$') { - return $this->variable(); - } - - if ($next === '(') { - $start = $this->scanner->getPosition(); - $this->scanner->readChar(); - - $value = $this->tryCalculationInterpolation(); - - if ($value === null) { - $this->whitespace(); - $value = $this->calculationSum(); - } - - $this->whitespace(); - $this->scanner->expectChar(')'); - - return new ParenthesizedExpression($value, $this->scanner->spanFrom($start)); - } - - if (!$this->lookingAtIdentifier()) { - $this->scanner->error('Expected number, variable, function, or calculation.'); - } - - $start = $this->scanner->getPosition(); - $ident = $this->identifier(); - - if ($this->scanner->scanChar('.')) { - return $this->namespacedExpression($ident, $start); - } - - if ($this->scanner->peekChar() !== '(') { - $this->scanner->error('Expected "(" or ".".'); - } - - $lowercase = strtolower($ident); - $calculation = $this->tryCalculation($lowercase, $start); - - if ($calculation !== null) { - return $calculation; - } - - if ($lowercase === 'if') { - return new IfExpression($this->argumentInvocation(), $this->scanner->spanFrom($start)); - } - - return new FunctionExpression($ident, $this->argumentInvocation(), $this->scanner->spanFrom($start)); - } - - /** - * If the following text up to the next unbalanced `")"`, `"]"`, or `"}"` - * contains interpolation, parses that interpolation as an unquoted - * {@see StringExpression} and returns it. - */ - private function tryCalculationInterpolation(): ?StringExpression - { - return $this->containsCalculationInterpolation() ? new StringExpression($this->interpolatedDeclarationValue()) : null; - } - - /** - * Returns whether the following text up to the next unbalanced `")"`, `"]"`, - * or `"}"` contains interpolation. - */ - private function containsCalculationInterpolation(): bool - { - $parens = 0; - $brackets = []; - - $start = $this->scanner->getPosition(); - while (!$this->scanner->isDone()) { - $next = $this->scanner->peekChar(); - - switch ($next) { - case '\\': - $this->scanner->readChar(); - $this->scanner->readUtf8Char(); - break; - - case '/': - if (!$this->scanComment()) { - $this->scanner->readChar(); - } - break; - - case "'": - case '"': - $this->interpolatedString(); - break; - - case '#': - if ($parens === 0 && $this->scanner->peekChar(1) === '{') { - $this->scanner->setPosition($start); - return true; - } - $this->scanner->readChar(); - break; - - case '(': - $parens++; - // fallthrough - case '{': - case '[': - assert($next !== null); // https://github.com/phpstan/phpstan/issues/5678 - $brackets[] = Character::opposite($next); - $this->scanner->readChar(); - break; - - case ')': - $parens--; - // fallthrough - case '}': - case ']': - if (empty($brackets) || array_pop($brackets) !== $next) { - $this->scanner->setPosition($start); - return false; - } - $this->scanner->readChar(); - break; - - default: - $this->scanner->readUtf8Char(); - } - } - - $this->scanner->setPosition($start); - - return false; - } - private function tryUrlContents(int $start, ?string $name = null): ?Interpolation { $beginningOfContents = $this->scanner->getPosition(); @@ -3684,7 +3335,7 @@ private function interpolatedDeclarationValue(bool $allowEmpty = false, bool $al case '/': if ($this->scanner->peekChar(1) === '*') { - $buffer->write($this->rawText([$this, 'loudComment'])); + $buffer->write($this->rawText($this->loudComment(...))); } else { $buffer->write($this->scanner->readChar()); } @@ -3772,7 +3423,6 @@ private function interpolatedDeclarationValue(bool $allowEmpty = false, bool $al $buffer->write($this->scanner->readChar()); $wroteNewline = false; break; - } $contents = $this->tryUrlContents($beforeUrl); @@ -4043,49 +3693,55 @@ private function mediaInParens(InterpolationBuffer $buffer): void $buffer->write('('); $this->whitespace(); - $needsParenDeprecation = $this->scanner->peekChar() === '('; - $needsNotDeprecation = $this->matchesIdentifier('not'); - $expression = $this->expressionUntilComparison(); - - if ($needsParenDeprecation || $needsNotDeprecation) { - $this->logger->warn(sprintf( - "Starting a @media query with \"%s\" is deprecated because it conflicts with official CSS syntax.\n\nTo preserve existing behavior: #{%s}\nTo migrate to new behavior: #{\"%s\"}\n\nFor details, see https://sass-lang.com/d/media-logic", - $needsParenDeprecation ? '(' : 'not', - $expression, - $expression - ), true, $expression->getSpan()); - } - - $buffer->add($expression); - - if ($this->scanner->scanChar(':')) { + if ($this->scanner->peekChar() === '(') { + $this->mediaInParens($buffer); $this->whitespace(); - $buffer->write(': '); - $buffer->add($this->expression()); - } else { - $next = $this->scanner->peekChar(); - if ($next === '<' || $next === '>' || $next === '=') { - $buffer->write(' '); - $buffer->write($this->scanner->readChar()); - if (($next === '<' || $next === '>') && $this->scanner->scanChar('=')) { - $buffer->write('='); - } - $buffer->write(' '); + if ($this->scanIdentifier('and')) { + $buffer->write(' and '); + $this->expectWhitespace(); + $this->mediaLogicSequence($buffer, 'and'); + } elseif ($this->scanIdentifier('or')) { + $buffer->write(' or '); + $this->expectWhitespace(); + $this->mediaLogicSequence($buffer, 'or'); + } + } elseif ($this->scanIdentifier('not')) { + $buffer->write('not '); + $this->expectWhitespace(); + $this->mediaOrInterp($buffer); + } else { + $buffer->add($this->expressionUntilComparison()); + if ($this->scanner->scanChar(':')) { $this->whitespace(); - $buffer->add($this->expressionUntilComparison()); + $buffer->write(': '); + $buffer->add($this->expression()); + } else { + $next = $this->scanner->peekChar(); - if (($next === '<' || $next === '>') && $this->scanner->scanChar($next)) { + if ($next === '<' || $next === '>' || $next === '=') { $buffer->write(' '); - $buffer->write($next); - if ($this->scanner->scanChar('=')) { + $buffer->write($this->scanner->readChar()); + if (($next === '<' || $next === '>') && $this->scanner->scanChar('=')) { $buffer->write('='); } $buffer->write(' '); $this->whitespace(); $buffer->add($this->expressionUntilComparison()); + + if (($next === '<' || $next === '>') && $this->scanner->scanChar($next)) { + $buffer->write(' '); + $buffer->write($next); + if ($this->scanner->scanChar('=')) { + $buffer->write('='); + } + $buffer->write(' '); + + $this->whitespace(); + $buffer->add($this->expressionUntilComparison()); + } } } } @@ -4256,7 +3912,7 @@ private function supportsConditionInParens(): SupportsCondition private function supportsDeclarationValue(Expression $name, int $start): SupportsDeclaration { - if ($name instanceof StringExpression && !$name->hasQuotes() && StringUtil::startsWith($name->getText()->getInitialPlain(), '--')) { + if ($name instanceof StringExpression && !$name->hasQuotes() && str_starts_with($name->getText()->getInitialPlain(), '--')) { $value = new StringExpression($this->interpolatedDeclarationValue()); } else { $this->whitespace(); @@ -4436,9 +4092,7 @@ private function publicIdentifier(): string { $start = $this->scanner->getPosition(); $result = $this->identifier(true); - $this->assertPublic($result, function () use ($start) { - return $this->scanner->spanFrom($start); - }); + $this->assertPublic($result, fn() => $this->scanner->spanFrom($start)); return $result; } diff --git a/modules/sass/scssphp/Serializer/SerializeResult.php b/modules/sass/scssphp/Serializer/SerializeResult.php index d82b390d..d2f1e020 100644 --- a/modules/sass/scssphp/Serializer/SerializeResult.php +++ b/modules/sass/scssphp/Serializer/SerializeResult.php @@ -19,11 +19,7 @@ */ final class SerializeResult { - /** - * @var string - * @readonly - */ - private $css; + private readonly string $css; public function __construct(string $css) { diff --git a/modules/sass/scssphp/Serializer/SerializeVisitor.php b/modules/sass/scssphp/Serializer/SerializeVisitor.php index 5c48e3a6..2697afbd 100644 --- a/modules/sass/scssphp/Serializer/SerializeVisitor.php +++ b/modules/sass/scssphp/Serializer/SerializeVisitor.php @@ -13,14 +13,22 @@ namespace Tangible\ScssPhp\Serializer; use Tangible\ScssPhp\Ast\AstNode; +use Tangible\ScssPhp\Ast\Css\CssAtRule; use Tangible\ScssPhp\Ast\Css\CssComment; use Tangible\ScssPhp\Ast\Css\CssDeclaration; +use Tangible\ScssPhp\Ast\Css\CssImport; +use Tangible\ScssPhp\Ast\Css\CssKeyframeBlock; use Tangible\ScssPhp\Ast\Css\CssMediaQuery; +use Tangible\ScssPhp\Ast\Css\CssMediaRule; use Tangible\ScssPhp\Ast\Css\CssNode; use Tangible\ScssPhp\Ast\Css\CssParentNode; +use Tangible\ScssPhp\Ast\Css\CssStyleRule; +use Tangible\ScssPhp\Ast\Css\CssStylesheet; +use Tangible\ScssPhp\Ast\Css\CssSupportsRule; use Tangible\ScssPhp\Ast\Css\CssValue; use Tangible\ScssPhp\Ast\Selector\AttributeSelector; use Tangible\ScssPhp\Ast\Selector\ClassSelector; +use Tangible\ScssPhp\Ast\Selector\Combinator; use Tangible\ScssPhp\Ast\Selector\ComplexSelector; use Tangible\ScssPhp\Ast\Selector\CompoundSelector; use Tangible\ScssPhp\Ast\Selector\IDSelector; @@ -42,10 +50,9 @@ use Tangible\ScssPhp\Util\NumberUtil; use Tangible\ScssPhp\Util\SpanUtil; use Tangible\ScssPhp\Util\StringUtil; -use Tangible\ScssPhp\Value\CalculationInterpolation; use Tangible\ScssPhp\Value\CalculationOperation; use Tangible\ScssPhp\Value\CalculationOperator; -use Tangible\ScssPhp\Value\ColorFormat; +use Tangible\ScssPhp\Value\ColorFormatEnum; use Tangible\ScssPhp\Value\ListSeparator; use Tangible\ScssPhp\Value\SassBoolean; use Tangible\ScssPhp\Value\SassCalculation; @@ -55,6 +62,7 @@ use Tangible\ScssPhp\Value\SassMap; use Tangible\ScssPhp\Value\SassNumber; use Tangible\ScssPhp\Value\SassString; +use Tangible\ScssPhp\Value\SpanColorFormat; use Tangible\ScssPhp\Value\Value; use Tangible\ScssPhp\Visitor\CssVisitor; use Tangible\ScssPhp\Visitor\SelectorVisitor; @@ -69,37 +77,27 @@ */ final class SerializeVisitor implements CssVisitor, ValueVisitor, SelectorVisitor { - /** - * @var StringBuffer - */ - private $buffer; + private readonly StringBuffer $buffer; /** * The current indentation of the CSS output. * * @var int */ - private $indentation = 0; + private int $indentation = 0; /** * Whether we're emitting an unambiguous representation of the source * structure, as opposed to valid CSS. - * - * @var bool */ - private $inspect; + private readonly bool $inspect; /** * Whether quoted strings should be emitted with quotes. - * - * @var bool */ - private $quote; + private readonly bool $quote; - /** - * @var bool - */ - private $compressed; + private readonly bool $compressed; /** * @phpstan-param OutputStyle::* $style @@ -112,15 +110,12 @@ public function __construct(bool $inspect = false, bool $quote = true, string $s $this->compressed = $style === OutputStyle::COMPRESSED; } - /** - * @return StringBuffer - */ public function getBuffer(): StringBuffer { return $this->buffer; } - public function visitCssStylesheet($node): void + public function visitCssStylesheet(CssStylesheet $node): void { $previous = null; @@ -154,7 +149,7 @@ public function visitCssStylesheet($node): void } } - public function visitCssComment($node): void + public function visitCssComment(CssComment $node): void { $this->for($node, function () use ($node) { // Preserve comments that start with `/*!`. @@ -162,6 +157,11 @@ public function visitCssComment($node): void return; } + // Ignore sourceMappingURL and sourceURL comments. + if (preg_match('{^/\*# source(Mapping)?URL=}', $node->getText())) { + return; + } + $minimumIndentation = $this->minimumIndentation($node->getText()); assert($minimumIndentation !== -1); @@ -177,7 +177,7 @@ public function visitCssComment($node): void }); } - public function visitCssAtRule($node): void + public function visitCssAtRule(CssAtRule $node): void { $this->writeIndentation(); @@ -199,7 +199,7 @@ public function visitCssAtRule($node): void }); } - public function visitCssMediaRule($node): void + public function visitCssMediaRule(CssMediaRule $node): void { $this->writeIndentation(); @@ -208,18 +208,18 @@ public function visitCssMediaRule($node): void $firstQuery = $node->getQueries()[0]; - if (!$this->compressed || $firstQuery->getModifier() !== null || $firstQuery->getType() !== null || (\count($firstQuery->getConditions()) === 1) && StringUtil::startsWith($firstQuery->getConditions()[0], '(not ')) { + if (!$this->compressed || $firstQuery->getModifier() !== null || $firstQuery->getType() !== null || (\count($firstQuery->getConditions()) === 1) && str_starts_with($firstQuery->getConditions()[0], '(not ')) { $this->buffer->writeChar(' '); } - $this->writeBetween($node->getQueries(), $this->getCommaSeparator(), [$this, 'visitMediaQuery']); + $this->writeBetween($node->getQueries(), $this->getCommaSeparator(), $this->visitMediaQuery(...)); }); $this->writeOptionalSpace(); $this->visitChildren($node); } - public function visitCssImport($node): void + public function visitCssImport(CssImport $node): void { $this->writeIndentation(); @@ -260,12 +260,12 @@ private function writeImportUrl(string $url): void } } - public function visitCssKeyframeBlock($node): void + public function visitCssKeyframeBlock(CssKeyframeBlock $node): void { $this->writeIndentation(); $this->for($node->getSelector(), function () use ($node) { - $this->writeBetween($node->getSelector()->getValue(), $this->getCommaSeparator(), [$this->buffer, 'write']); + $this->writeBetween($node->getSelector()->getValue(), $this->getCommaSeparator(), $this->buffer->write(...)); }); $this->writeOptionalSpace(); $this->visitChildren($node); @@ -286,29 +286,29 @@ private function visitMediaQuery(CssMediaQuery $query): void } } - if (\count($query->getConditions()) === 1 && StringUtil::startsWith($query->getConditions()[0], '(not ')) { + if (\count($query->getConditions()) === 1 && str_starts_with($query->getConditions()[0], '(not ')) { $this->buffer->write('not '); $condition = $query->getConditions()[0]; $this->buffer->write(substr($condition, \strlen('(not '), \strlen($condition) - (\strlen('(not ') + 1))); } else { $operator = $query->isConjunction() ? 'and' : 'or'; - $this->writeBetween($query->getConditions(), $this->compressed ? "$operator " : " $operator ", [$this->buffer, 'write']); + $this->writeBetween($query->getConditions(), $this->compressed ? "$operator " : " $operator ", $this->buffer->write(...)); } } - public function visitCssStyleRule($node): void + public function visitCssStyleRule(CssStyleRule $node): void { $this->writeIndentation(); $this->for($node->getSelector(), function () use ($node) { - $node->getSelector()->getValue()->accept($this); + $node->getSelector()->accept($this); }); $this->writeOptionalSpace(); $this->visitChildren($node); } - public function visitCssSupportsRule($node): void + public function visitCssSupportsRule(CssSupportsRule $node): void { $this->writeIndentation(); @@ -325,7 +325,7 @@ public function visitCssSupportsRule($node): void $this->visitChildren($node); } - public function visitCssDeclaration($node): void + public function visitCssDeclaration(CssDeclaration $node): void { $this->writeIndentation(); $this->write($node->getName()); @@ -509,12 +509,12 @@ private function writeWithIndent(string $text, int $minimumIndentation): void // ## Values - public function visitBoolean(SassBoolean $value) + public function visitBoolean(SassBoolean $value): void { - $this->buffer->write($value->getValue() ? 'true': 'false'); + $this->buffer->write($value->getValue() ? 'true' : 'false'); } - public function visitCalculation(SassCalculation $value) + public function visitCalculation(SassCalculation $value): void { $this->buffer->write($value->getName()); $this->buffer->writeChar('('); @@ -535,13 +535,40 @@ public function visitCalculation(SassCalculation $value) private function writeCalculationValue(object $value): void { - if ($value instanceof Value) { + if ($value instanceof SassNumber && !is_finite($value->getValue())) { + if (\count($value->getNumeratorUnits()) > 1 || \count($value->getDenominatorUnits()) > 0) { + if (!$this->inspect) { + throw new SassScriptException("$value is not a valid CSS value."); + } + + $this->writeNumber($value->getValue()); + $this->buffer->write($value->getUnitString()); + + return; + } + + if (is_nan($value->getValue())) { + $this->buffer->write('NaN'); + } elseif ($value->getValue() > 0) { + $this->buffer->write('infinity'); + } else { + $this->buffer->write('-infinity'); + } + + $unit = $value->getNumeratorUnits()[0] ?? null; + + if ($unit !== null) { + $this->writeOptionalSpace(); + $this->buffer->writeChar('*'); + $this->writeOptionalSpace(); + $this->buffer->writeChar('1'); + $this->buffer->write($unit); + } + } elseif ($value instanceof Value) { $value->accept($this); - } elseif ($value instanceof CalculationInterpolation) { - $this->buffer->write($value->getValue()); } elseif ($value instanceof CalculationOperation) { $left = $value->getLeft(); - $parenthesizeLeft = $left instanceof CalculationInterpolation || ($left instanceof CalculationOperation && CalculationOperator::getPrecedence($left->getOperator()) < CalculationOperator::getPrecedence($value->getOperator())); + $parenthesizeLeft = $left instanceof CalculationOperation && $left->getOperator()->getPrecedence() < $value->getOperator()->getPrecedence(); if ($parenthesizeLeft) { $this->buffer->writeChar('('); @@ -551,17 +578,18 @@ private function writeCalculationValue(object $value): void $this->buffer->writeChar(')'); } - $operatorWhitespace = !$this->compressed || CalculationOperator::getPrecedence($value->getOperator()) === 1; + $operatorWhitespace = !$this->compressed || $value->getOperator()->getPrecedence() === 1; if ($operatorWhitespace) { $this->buffer->writeChar(' '); } - $this->buffer->write($value->getOperator()); + $this->buffer->write($value->getOperator()->getOperator()); if ($operatorWhitespace) { $this->buffer->writeChar(' '); } $right = $value->getRight(); - $parenthesizeRight = $right instanceof CalculationInterpolation || ($right instanceof CalculationOperation && $this->parenthesizeCalculationRhs($value->getOperator(), $right->getOperator())); + $parenthesizeRight = ($right instanceof CalculationOperation && $this->parenthesizeCalculationRhs($value->getOperator(), $right->getOperator())) + || ($value->getOperator() === CalculationOperator::DIVIDED_BY && $right instanceof SassNumber && !is_finite($right->getValue()) && $right->hasUnits()); if ($parenthesizeRight) { $this->buffer->writeChar('('); @@ -578,11 +606,8 @@ private function writeCalculationValue(object $value): void * parenthesized. * * In `a ? (b # c)`, `outer` is `?` and `right` is `#`. - * - * @phpstan-param CalculationOperator::* $outer - * @phpstan-param CalculationOperator::* $right */ - private function parenthesizeCalculationRhs(string $outer, string $right): bool + private function parenthesizeCalculationRhs(CalculationOperator $outer, CalculationOperator $right): bool { if ($outer === CalculationOperator::DIVIDED_BY) { return true; @@ -595,7 +620,7 @@ private function parenthesizeCalculationRhs(string $outer, string $right): bool return $right === CalculationOperator::PLUS || $right === CalculationOperator::MINUS; } - public function visitColor(SassColor $value) + public function visitColor(SassColor $value): void { $name = Colors::RGBaToColorName($value->getRed(), $value->getGreen(), $value->getBlue(), $value->getAlpha()); @@ -628,14 +653,18 @@ public function visitColor(SassColor $value) $format = $value->getFormat(); if ($format !== null) { - if ($format === ColorFormat::RGB_FUNCTION) { + if ($format === ColorFormatEnum::rgbFunction) { $this->writeRgb($value); - } elseif ($format === ColorFormat::HSL_FUNCTION) { + } elseif ($format === ColorFormatEnum::hslFunction) { $this->writeHsl($value); - } else { + } elseif ($format instanceof SpanColorFormat) { $this->buffer->write($format->getOriginal()); + } else { + // should not happen as our interface is sealed. + \assert(false, 'unknown format'); } - } elseif ($name !== null && + } elseif ( + $name !== null && // Always emit generated transparent colors in rgba format. This works // around an IE bug. See https://github.com/sass/sass/issues/1782. !NumberUtil::fuzzyEquals($value->getAlpha(), 0) @@ -680,7 +709,6 @@ private function writeHsl(SassColor $value): void $opaque = NumberUtil::fuzzyEquals($value->getAlpha(), 1); $this->buffer->write($opaque ? 'hsl(' : 'hsla('); $this->writeNumber($value->getHue()); - $this->buffer->write('deg'); $this->buffer->write($this->getCommaSeparator()); $this->writeNumber($value->getSaturation()); $this->buffer->writeChar('%'); @@ -721,7 +749,7 @@ private function writeHexComponent(int $color): void $this->buffer->write(str_pad(dechex($color), 2, '0', STR_PAD_LEFT)); } - public function visitFunction(SassFunction $value) + public function visitFunction(SassFunction $value): void { if (!$this->inspect) { throw new SassScriptException("$value is not a valid CSS value."); @@ -732,7 +760,7 @@ public function visitFunction(SassFunction $value) $this->buffer->writeChar(')'); } - public function visitList(SassList $value) + public function visitList(SassList $value): void { if ($value->hasBrackets()) { $this->buffer->writeChar('['); @@ -780,7 +808,8 @@ public function visitList(SassList $value) } if ($singleton) { - $this->buffer->write($value->getSeparator()); + \assert($value->getSeparator()->getSeparator() !== null, 'The list separator is not undecided at that point.'); + $this->buffer->write($value->getSeparator()->getSeparator()); if (!$value->hasBrackets()) { $this->buffer->writeChar(')'); @@ -792,42 +821,25 @@ public function visitList(SassList $value) } } - /** - * @phpstan-param ListSeparator::* $separator - */ - private function separatorString(string $separator): string + private function separatorString(ListSeparator $separator): string { - switch ($separator) { - case ListSeparator::COMMA: - return $this->getCommaSeparator(); - - case ListSeparator::SLASH: - return $this->compressed ? '/' : ' / '; - - case ListSeparator::SPACE: - return ' '; - - default: - /** - * This should never be used, but it may still be returned since - * {@see separatorString} is invoked eagerly by {@see writeList} even for lists - * with only one element. - */ - return ''; - } + return match ($separator) { + ListSeparator::COMMA => $this->getCommaSeparator(), + ListSeparator::SLASH => $this->compressed ? '/' : ' / ', + ListSeparator::SPACE => ' ', + /** + * This should never be used, but it may still be returned since + * {@see separatorString} is invoked eagerly by {@see writeList} even for lists + * with only one element. + */ + default => '', + }; } /** * Returns whether the value needs parentheses as an element in a list with the given separator. - * - * @param string $separator - * @param Value $value - * - * @return bool - * - * @phpstan-param ListSeparator::* $separator */ - private static function elementNeedsParens(string $separator, Value $value): bool + private static function elementNeedsParens(ListSeparator $separator, Value $value): bool { if (!$value instanceof SassList) { return false; @@ -841,19 +853,14 @@ private static function elementNeedsParens(string $separator, Value $value): boo return false; } - switch ($separator) { - case ListSeparator::COMMA: - return $value->getSeparator() === ListSeparator::COMMA; - - case ListSeparator::SLASH: - return $value->getSeparator() === ListSeparator::COMMA || $value->getSeparator() === ListSeparator::SLASH; - - default: - return $value->getSeparator() !== ListSeparator::UNDECIDED; - } + return match ($separator) { + ListSeparator::COMMA => $value->getSeparator() === ListSeparator::COMMA, + ListSeparator::SLASH => $value->getSeparator() === ListSeparator::COMMA || $value->getSeparator() === ListSeparator::SLASH, + default => $value->getSeparator() !== ListSeparator::UNDECIDED, + }; } - public function visitMap(SassMap $value) + public function visitMap(SassMap $value): void { if (!$this->inspect) { throw new SassScriptException("$value is not a valid CSS value."); @@ -894,14 +901,14 @@ private function writeMapElement(Value $value): void } } - public function visitNull() + public function visitNull(): void { if ($this->inspect) { $this->buffer->write('null'); } } - public function visitNumber(SassNumber $value) + public function visitNumber(SassNumber $value): void { $asSlash = $value->getAsSlash(); @@ -913,6 +920,11 @@ public function visitNumber(SassNumber $value) return; } + if (!is_finite($value->getValue())) { + $this->visitCalculation(SassCalculation::unsimplified('calc', [$value])); + return; + } + $this->writeNumber($value->getValue()); if (!$this->inspect) { @@ -931,8 +943,6 @@ public function visitNumber(SassNumber $value) /** * Writes $number without exponent notation and with at most * {@see SassNumber::PRECISION} digits after the decimal point. - * - * @param float $number */ private function writeNumber(float $number): void { @@ -963,7 +973,7 @@ private function writeNumber(float $number): void $this->buffer->write(rtrim(rtrim($output, '0'), '.')); } - public function visitString(SassString $value) + public function visitString(SassString $value): void { if ($this->quote && $value->hasQuotes()) { $this->visitQuotedString($value->getText()); @@ -974,8 +984,8 @@ public function visitString(SassString $value) private function visitQuotedString(string $string): void { - $includesDoubleQuote = false !== strpos($string, '"'); - $includesSingleQuote = false !== strpos($string, '\''); + $includesDoubleQuote = str_contains($string, '"'); + $includesSingleQuote = str_contains($string, '\''); $forceDoubleQuotes = $includesSingleQuote && $includesDoubleQuote; $quote = $forceDoubleQuotes || !$includesDoubleQuote ? '"' : "'"; @@ -1132,7 +1142,8 @@ private function tryPrivateUseCharacter(StringBuffer $buffer, string $char, stri $charCode = $firstByteCode; } - if ($charCode >= 0xE000 && $charCode <= 0xF8FF || // PUA of the BMP + if ( + $charCode >= 0xE000 && $charCode <= 0xF8FF || // PUA of the BMP $charCode >= 0xF0000 && $charCode <= 0x10FFFF // Supplementary PUAs of the planes 15 and 16 ) { $this->writeEscape($buffer, $fullChar, $string, $i + $extraBytes); @@ -1169,7 +1180,7 @@ private function writeEscape(StringBuffer $buffer, string $character, string $st // ## Selectors - public function visitAttributeSelector(AttributeSelector $attribute) + public function visitAttributeSelector(AttributeSelector $attribute): void { $this->buffer->writeChar('['); $this->buffer->write($attribute->getName()); @@ -1178,11 +1189,11 @@ public function visitAttributeSelector(AttributeSelector $attribute) if ($value !== null) { assert($attribute->getOp() !== null); - $this->buffer->write($attribute->getOp()); + $this->buffer->write($attribute->getOp()->getText()); // Emit identifiers that start with `--` with quotes, because IE11 // doesn't consider them to be valid identifiers. - if (Parser::isIdentifier($value) && 0 !== strpos($value, '--')) { + if (Parser::isIdentifier($value) && !str_starts_with($value, '--')) { $this->buffer->write($value); if ($attribute->getModifier() !== null) { @@ -1204,13 +1215,13 @@ public function visitAttributeSelector(AttributeSelector $attribute) $this->buffer->writeChar(']'); } - public function visitClassSelector(ClassSelector $klass) + public function visitClassSelector(ClassSelector $klass): void { $this->buffer->writeChar('.'); $this->buffer->write($klass->getName()); } - public function visitComplexSelector(ComplexSelector $complex) + public function visitComplexSelector(ComplexSelector $complex): void { $this->writeCombinators($complex->getLeadingCombinators()); @@ -1237,9 +1248,7 @@ public function visitComplexSelector(ComplexSelector $complex) * Writes $combinators to {@see buffer}, with spaces in between in expanded * mode. * - * @param string[] $combinators - * - * @return void + * @param list> $combinators */ private function writeCombinators(array $combinators): void { @@ -1248,7 +1257,7 @@ private function writeCombinators(array $combinators): void }); } - public function visitCompoundSelector(CompoundSelector $compound) + public function visitCompoundSelector(CompoundSelector $compound): void { $start = $this->buffer->getLength(); @@ -1264,13 +1273,13 @@ public function visitCompoundSelector(CompoundSelector $compound) } } - public function visitIDSelector(IDSelector $id) + public function visitIDSelector(IDSelector $id): void { $this->buffer->writeChar('#'); $this->buffer->write($id->getName()); } - public function visitSelectorList(SelectorList $list) + public function visitSelectorList(SelectorList $list): void { $first = true; @@ -1286,6 +1295,7 @@ public function visitSelectorList(SelectorList $list) if ($complex->getLineBreak()) { $this->writeLineFeed(); + $this->writeIndentation(); } else { $this->writeOptionalSpace(); } @@ -1295,7 +1305,7 @@ public function visitSelectorList(SelectorList $list) } } - public function visitParentSelector(ParentSelector $parent) + public function visitParentSelector(ParentSelector $parent): void { $this->buffer->writeChar('&'); @@ -1304,13 +1314,13 @@ public function visitParentSelector(ParentSelector $parent) } } - public function visitPlaceholderSelector(PlaceholderSelector $placeholder) + public function visitPlaceholderSelector(PlaceholderSelector $placeholder): void { $this->buffer->writeChar('%'); $this->buffer->write($placeholder->getName()); } - public function visitPseudoSelector(PseudoSelector $pseudo) + public function visitPseudoSelector(PseudoSelector $pseudo): void { $innerSelector = $pseudo->getSelector(); @@ -1346,12 +1356,12 @@ public function visitPseudoSelector(PseudoSelector $pseudo) $this->buffer->writeChar(')'); } - public function visitTypeSelector(TypeSelector $type) + public function visitTypeSelector(TypeSelector $type): void { $this->buffer->write($type->getName()); } - public function visitUniversalSelector(UniversalSelector $universal) + public function visitUniversalSelector(UniversalSelector $universal): void { if ($universal->getNamespace() !== null) { $this->buffer->write($universal->getNamespace()); @@ -1367,7 +1377,6 @@ public function visitUniversalSelector(UniversalSelector $universal) * * @template T * - * @param AstNode $node * @param callable(): T $callback * * @return T @@ -1409,7 +1418,7 @@ private function visitChildren(CssParentNode $parent): void if ($this->isTrailingComment($child, $previous ?? $parent)) { $this->writeOptionalSpace(); - $this->withoutIndendation(function () use ($child) { + $this->withoutIndentation(function () use ($child) { $child->accept($this); }); } else { @@ -1463,6 +1472,10 @@ private function isTrailingComment(CssNode $node, CssNode $previous): bool return false; } + if ($node->getSpan()->getSourceUrl() !== $previous->getSpan()->getSourceUrl()) { + return false; + } + if (!SpanUtil::contains($previous->getSpan(), $node->getSpan())) { return $node->getSpan()->getStart()->getLine() === $previous->getSpan()->getEnd()->getLine(); } @@ -1530,7 +1543,6 @@ private function writeTimes(string $char, int $times): void * @template T * * @param iterable $iterable - * @param string $text * @param callable(T): void $callback */ private function writeBetween(iterable $iterable, string $text, callable $callback): void @@ -1553,7 +1565,7 @@ private function writeBetween(iterable $iterable, string $text, callable $callba */ private function getCommaSeparator(): string { - return $this->compressed ? ',': ', '; + return $this->compressed ? ',' : ', '; } /** @@ -1573,7 +1585,7 @@ private function indent(callable $callback): void * * @param callable(): void $callback */ - private function withoutIndendation(callable $callback): void + private function withoutIndentation(callable $callback): void { $savedIndentation = $this->indentation; $this->indentation = 0; diff --git a/modules/sass/scssphp/Serializer/SimpleStringBuffer.php b/modules/sass/scssphp/Serializer/SimpleStringBuffer.php index 604662c1..c9419a63 100644 --- a/modules/sass/scssphp/Serializer/SimpleStringBuffer.php +++ b/modules/sass/scssphp/Serializer/SimpleStringBuffer.php @@ -17,10 +17,7 @@ */ final class SimpleStringBuffer implements StringBuffer { - /** - * @var string - */ - private $text = ''; + private string $text = ''; public function getLength(): int { diff --git a/modules/sass/scssphp/Serializer/StringBuffer.php b/modules/sass/scssphp/Serializer/StringBuffer.php index c3a28a3d..c4b7f1ae 100644 --- a/modules/sass/scssphp/Serializer/StringBuffer.php +++ b/modules/sass/scssphp/Serializer/StringBuffer.php @@ -15,7 +15,7 @@ /** * @internal */ -interface StringBuffer +interface StringBuffer extends \Stringable { /** * Returns the length of the content that has been accumulated so far. @@ -28,6 +28,4 @@ public function write(string $string): void; * Writes a single char to the buffer. */ public function writeChar(string $char): void; - - public function __toString(): string; } diff --git a/modules/sass/scssphp/SourceSpan/ConcreteFileSpan.php b/modules/sass/scssphp/SourceSpan/ConcreteFileSpan.php new file mode 100644 index 00000000..ab1a9073 --- /dev/null +++ b/modules/sass/scssphp/SourceSpan/ConcreteFileSpan.php @@ -0,0 +1,112 @@ +file = $file; + $this->start = $start; + $this->end = $end; + } + + public function getFile(): SourceFile + { + return $this->file; + } + + public function getSourceUrl(): ?string + { + return $this->file->getSourceUrl(); + } + + public function getLength(): int + { + return $this->end - $this->start; + } + + public function getStart(): SourceLocation + { + return new SourceLocation($this->file, $this->start); + } + + public function getEnd(): SourceLocation + { + return new SourceLocation($this->file, $this->end); + } + + public function getText(): string + { + return $this->file->getText($this->start, $this->end); + } + + public function expand(FileSpan $other): FileSpan + { + if ($this->file->getSourceUrl() !== $other->getFile()->getSourceUrl()) { + throw new \InvalidArgumentException('Source map URLs don\'t match.'); + } + + $start = min($this->start, $other->getStart()->getOffset()); + $end = max($this->end, $other->getEnd()->getOffset()); + + return new ConcreteFileSpan($this->file, $start, $end); + } + + public function message(string $message): string + { + $startLine = $this->getStart()->getLine() + 1; + $startColumn = $this->getStart()->getColumn() + 1; + $sourceUrl = $this->file->getSourceUrl(); + + $buffer = "line $startLine, column $startColumn"; + + if ($sourceUrl !== null) { + $prettyUri = Path::prettyUri($sourceUrl); + $buffer .= " of $prettyUri"; + } + + $buffer .= ": $message"; + + // TODO implement the highlighting of a code snippet + + return $buffer; + } + + public function subspan(int $start, ?int $end = null): FileSpan + { + ErrorUtil::checkValidRange($start, $end, $this->getLength()); + + if ($start === 0 && ($end === null || $end === $this->getLength())) { + return $this; + } + + return $this->file->span($this->start + $start, $end === null ? $this->end : $this->start + $end); + } +} diff --git a/modules/sass/scssphp/SourceSpan/FileSpan.php b/modules/sass/scssphp/SourceSpan/FileSpan.php index ea1199c9..2837a74f 100644 --- a/modules/sass/scssphp/SourceSpan/FileSpan.php +++ b/modules/sass/scssphp/SourceSpan/FileSpan.php @@ -12,80 +12,24 @@ namespace Tangible\ScssPhp\SourceSpan; -use Tangible\ScssPhp\Util\ErrorUtil; -use Tangible\ScssPhp\Util\Path; - /** * @internal */ -final class FileSpan +interface FileSpan { - /** - * @var SourceFile - * @readonly - */ - private $file; - - /** - * @var int - * @readonly - */ - private $start; + public function getFile(): SourceFile; - /** - * @var int - * @readonly - */ - private $end; - - /** - * @param SourceFile $file - * @param int $start The offset of the beginning of the span. - * @param int $end The offset of the end of the span. - */ - public function __construct(SourceFile $file, int $start, int $end) - { - $this->file = $file; - $this->start = $start; - $this->end = $end; - } + public function getSourceUrl(): ?string; - public function getFile(): SourceFile - { - return $this->file; - } + public function getLength(): int; - public function getLength(): int - { - return $this->end - $this->start; - } + public function getStart(): SourceLocation; - public function getStart(): SourceLocation - { - return new SourceLocation($this->file, $this->start); - } + public function getEnd(): SourceLocation; - public function getEnd(): SourceLocation - { - return new SourceLocation($this->file, $this->end); - } + public function getText(): string; - public function getText(): string - { - return $this->file->getText($this->start, $this->end); - } - - public function expand(FileSpan $other): FileSpan - { - if ($this->file->getSourceUrl() !== $other->file->getSourceUrl()) { - throw new \InvalidArgumentException('Source map URLs don\'t match.'); - } - - $start = min($this->start, $other->start); - $end = max($this->end, $other->end); - - return new FileSpan($this->file, $start, $end); - } + public function expand(FileSpan $other): FileSpan; /** * Formats $message in a human-friendly way associated with this span. @@ -94,38 +38,11 @@ public function expand(FileSpan $other): FileSpan * * @return string */ - public function message(string $message): string - { - $startLine = $this->getStart()->getLine() + 1; - $startColumn = $this->getStart()->getColumn() + 1; - $sourceUrl = $this->file->getSourceUrl(); - - $buffer = "line $startLine, column $startColumn"; - - if ($sourceUrl !== null) { - $prettyUri = Path::prettyUri($sourceUrl); - $buffer .= " of $prettyUri"; - } - - $buffer .= ": $message"; - - // TODO implement the highlighting of a code snippet - - return $buffer; - } + public function message(string $message): string; /** * Return a span from $start bytes (inclusive) to $end bytes * (exclusive) after the beginning of this span */ - public function subspan(int $start, ?int $end = null): FileSpan - { - ErrorUtil::checkValidRange($start, $end, $this->getLength()); - - if ($start === 0 && ($end === null || $end === $this->getLength())) { - return $this; - } - - return $this->file->span($this->start + $start, $end === null ? $this->end : $this->start + $end); - } + public function subspan(int $start, ?int $end = null): FileSpan; } diff --git a/modules/sass/scssphp/SourceSpan/LazyFileSpan.php b/modules/sass/scssphp/SourceSpan/LazyFileSpan.php new file mode 100644 index 00000000..c32ce9ee --- /dev/null +++ b/modules/sass/scssphp/SourceSpan/LazyFileSpan.php @@ -0,0 +1,97 @@ +builder = $builder; + } + + public function getSpan(): FileSpan + { + if ($this->span === null) { + $this->span = ($this->builder)(); + } + + return $this->span; + } + + public function getFile(): SourceFile + { + return $this->getSpan()->getFile(); + } + + public function getSourceUrl(): ?string + { + return $this->getSpan()->getSourceUrl(); + } + + public function getLength(): int + { + return $this->getSpan()->getLength(); + } + + public function getStart(): SourceLocation + { + return $this->getSpan()->getStart(); + } + + public function getEnd(): SourceLocation + { + return $this->getSpan()->getEnd(); + } + + public function getText(): string + { + return $this->getSpan()->getText(); + } + + public function expand(FileSpan $other): FileSpan + { + return $this->getSpan()->expand($other); + } + + public function message(string $message): string + { + return $this->getSpan()->message($message); + } + + public function subspan(int $start, ?int $end = null): FileSpan + { + return $this->getSpan()->subspan($start, $end); + } +} diff --git a/modules/sass/scssphp/SourceSpan/SourceFile.php b/modules/sass/scssphp/SourceSpan/SourceFile.php index 3c1951f7..99d01983 100644 --- a/modules/sass/scssphp/SourceSpan/SourceFile.php +++ b/modules/sass/scssphp/SourceSpan/SourceFile.php @@ -17,23 +17,14 @@ */ final class SourceFile { - /** - * @var string - * @readonly - */ - private $string; + private readonly string $string; - /** - * @var string|null - * @readonly - */ - private $sourceUrl; + private readonly ?string $sourceUrl; /** * @var int[] - * @readonly */ - private $lineStarts; + private readonly array $lineStarts; /** * The 0-based last line that was returned by {@see getLine} @@ -45,32 +36,35 @@ final class SourceFile * * @var int|null */ - private $cachedLine; + private ?int $cachedLine = null; - public function __construct(string $content, ?string $sourceUrl) + public function __construct(string $content, ?string $sourceUrl = null) { $this->string = $content; $this->sourceUrl = $sourceUrl; // Extract line starts - $this->lineStarts = [0]; + $lineStarts = [0]; if ($content === '') { + $this->lineStarts = $lineStarts; return; } $prev = 0; while (($pos = strpos($content, "\n", $prev)) !== false) { - $this->lineStarts[] = $pos; + $lineStarts[] = $pos; $prev = $pos + 1; } - $this->lineStarts[] = \strlen($content); + $lineStarts[] = \strlen($content); - if (substr($content, -1) !== "\n") { - $this->lineStarts[] = \strlen($content) + 1; + if (!str_ends_with($content, "\n")) { + $lineStarts[] = \strlen($content) + 1; } + + $this->lineStarts = $lineStarts; } public function span(int $start, ?int $end = null): FileSpan @@ -79,7 +73,22 @@ public function span(int $start, ?int $end = null): FileSpan $end = \strlen($this->string); } - return new FileSpan($this, $start, $end); + return new ConcreteFileSpan($this, $start, $end); + } + + public function location(int $offset): SourceLocation + { + if ($offset < 0) { + throw new \RangeException("Offset may not be negative, was $offset."); + } + + if ($offset > \strlen($this->string)) { + $fileLength = \strlen($this->string); + + throw new \RangeException("Offset $offset must not be greater than the number of characters in the file, $fileLength."); + } + + return new SourceLocation($this, $offset); } public function getSourceUrl(): ?string @@ -87,12 +96,13 @@ public function getSourceUrl(): ?string return $this->sourceUrl; } + public function getString(): string + { + return $this->string; + } + /** * The 0-based line - * - * @param int $position - * - * @return int */ public function getLine(int $position): int { @@ -141,10 +151,6 @@ public function getLine(int $position): int * * Checks on {@see $cachedLine} and the next line. If it's on the next line, it * updates {@see $cachedLine} to point to that. - * - * @param int $position - * - * @return bool */ private function isNearCacheLine(int $position): bool { @@ -156,13 +162,15 @@ private function isNearCacheLine(int $position): bool return false; } - if ($this->cachedLine >= \count($this->lineStarts) - 1 || + if ( + $this->cachedLine >= \count($this->lineStarts) - 1 || $position < $this->lineStarts[$this->cachedLine + 1] ) { return true; } - if ($this->cachedLine >= \count($this->lineStarts) - 2 || + if ( + $this->cachedLine >= \count($this->lineStarts) - 2 || $position < $this->lineStarts[$this->cachedLine + 2] ) { ++$this->cachedLine; @@ -175,10 +183,6 @@ private function isNearCacheLine(int $position): bool /** * The 0-based column of that position - * - * @param int $position - * - * @return int */ public function getColumn(int $position): int { diff --git a/modules/sass/scssphp/SourceSpan/SourceLocation.php b/modules/sass/scssphp/SourceSpan/SourceLocation.php index 455133bb..e0807362 100644 --- a/modules/sass/scssphp/SourceSpan/SourceLocation.php +++ b/modules/sass/scssphp/SourceSpan/SourceLocation.php @@ -17,17 +17,9 @@ */ final class SourceLocation { - /** - * @var SourceFile - * @readonly - */ - private $file; + private readonly SourceFile $file; - /** - * @var int - * @readonly - */ - private $offset; + private readonly int $offset; public function __construct(SourceFile $file, int $offset) { @@ -65,4 +57,12 @@ public function getSourceUrl(): ?string { return $this->file->getSourceUrl(); } + + /** + * Returns a span that covers only a single point: this location. + */ + public function pointSpan(): FileSpan + { + return new ConcreteFileSpan($this->file, $this->offset, $this->offset); + } } diff --git a/modules/sass/scssphp/StackTrace/Frame.php b/modules/sass/scssphp/StackTrace/Frame.php index d0558c58..fbfe92d6 100644 --- a/modules/sass/scssphp/StackTrace/Frame.php +++ b/modules/sass/scssphp/StackTrace/Frame.php @@ -21,41 +21,29 @@ final class Frame { /** * The URI of the file in which the code is located. - * - * @var string - * @readonly */ - private $url; + private readonly string $url; /** * The line number on which the code location is located. * * This can be null, indicating that the line number is unknown or * unimportant. - * - * @var int|null - * @readonly */ - private $line; + private readonly ?int $line; /** * The column number of the code location. * * This can be null, indicating that the column number is unknown or * unimportant. - * - * @var int|null - * @readonly */ - private $column; + private readonly ?int $column; /** * The name of the member in which the code location occurs. - * - * @var string|null - * @readonly */ - private $member; + private readonly ?string $member; public function __construct(string $url, ?int $line, ?int $column, ?string $member) { diff --git a/modules/sass/scssphp/StackTrace/Trace.php b/modules/sass/scssphp/StackTrace/Trace.php index 2b40f589..258c3730 100644 --- a/modules/sass/scssphp/StackTrace/Trace.php +++ b/modules/sass/scssphp/StackTrace/Trace.php @@ -21,7 +21,7 @@ final class Trace * @var list * @readonly */ - private $frames; + private readonly array $frames; /** * @param list $frames @@ -48,8 +48,6 @@ public function getFormattedTrace(): string $longest = max($longest, $length); } - return implode(array_map(function (Frame $frame) use ($longest) { - return str_pad($frame->getLocation(), $longest) . ' ' . $frame->getMember() . "\n"; - }, $this->frames)); + return implode(array_map(fn(Frame $frame) => str_pad($frame->getLocation(), $longest) . ' ' . $frame->getMember() . "\n", $this->frames)); } } diff --git a/modules/sass/scssphp/Syntax.php b/modules/sass/scssphp/Syntax.php index 34b787b2..993c3d62 100644 --- a/modules/sass/scssphp/Syntax.php +++ b/modules/sass/scssphp/Syntax.php @@ -12,33 +12,30 @@ namespace Tangible\ScssPhp; -final class Syntax +enum Syntax { /** * The CSS-superset SCSS syntax. */ - const SCSS = 'scss'; + case SCSS; /** * The whitespace-sensitive indented syntax. */ - const SASS = 'sass'; + case SASS; /** * The plain CSS syntax, which disallows special Sass features. */ - const CSS = 'css'; + case CSS; - /** - * @return Syntax::* - */ - public static function forPath(string $path): string + public static function forPath(string $path): self { - if (substr($path, -5) === '.sass') { + if (str_ends_with($path, '.sass')) { return self::SASS; } - if (substr($path, -4) === '.css') { + if (str_ends_with($path, '.css')) { return self::CSS; } diff --git a/modules/sass/scssphp/Util.php b/modules/sass/scssphp/Util.php index 30d3c6be..15d54c82 100644 --- a/modules/sass/scssphp/Util.php +++ b/modules/sass/scssphp/Util.php @@ -76,10 +76,6 @@ public static function checkRange(string $name, Range $range, $value, string $un /** * Encode URI component - * - * @param string $string - * - * @return string */ public static function encodeURIComponent(string $string): string { @@ -108,10 +104,6 @@ public static function declarationName(FileSpan $span): string * Returns $name without a vendor prefix. * * If $name has no vendor prefix, it's returned as-is. - * - * @param string $name - * - * @return string */ public static function unvendor(string $name): string { @@ -140,10 +132,6 @@ public static function unvendor(string $name): string /** * mb_chr() wrapper - * - * @param int $code - * - * @return string */ public static function mbChr(int $code): string { @@ -168,10 +156,6 @@ public static function mbChr(int $code): string /** * mb_ord() wrapper - * - * @param string $string - * - * @return int */ public static function mbOrd(string $string): int { @@ -205,9 +189,6 @@ public static function mbOrd(string $string): int /** * mb_strlen() wrapper - * - * @param string $string - * @return int */ public static function mbStrlen(string $string): int { @@ -225,10 +206,6 @@ public static function mbStrlen(string $string): int /** * mb_substr() wrapper - * @param string $string - * @param int $start - * @param null|int $length - * @return string */ public static function mbSubstr(string $string, int $start, ?int $length = null): string { @@ -262,13 +239,8 @@ public static function mbSubstr(string $string, int $start, ?int $length = null) /** * mb_strpos wrapper - * @param string $haystack - * @param string $needle - * @param int $offset - * - * @return int|false */ - public static function mbStrpos(string $haystack, string $needle, int $offset = 0) + public static function mbStrpos(string $haystack, string $needle, int $offset = 0): int|false { if (\function_exists('mb_strpos')) { return mb_strpos($haystack, $needle, $offset, 'UTF-8'); @@ -280,4 +252,32 @@ public static function mbStrpos(string $haystack, string $needle, int $offset = throw new \LogicException('Either mbstring (recommended) or iconv is necessary to use Scssphp.'); } + + /** + * Like {@see \SplObjectStorage::addAll()}, but for two-layer maps. + * + * This avoids copying inner maps from $source if possible. + * + * @template K1 of object + * @template K2 of object + * @template V + * @template Inner of \SplObjectStorage + * + * @param \SplObjectStorage $destination + * @param \SplObjectStorage $source + */ + public static function mapAddAll2(\SplObjectStorage $destination, \SplObjectStorage $source): void + { + foreach ($source as $key) { + $inner = $source->getInfo(); + + $innerDestination = $destination[$key] ?? null; + + if ($innerDestination !== null) { + $innerDestination->addAll($inner); + } else { + $destination[$key] = $inner; + } + } + } } diff --git a/modules/sass/scssphp/Util/Box.php b/modules/sass/scssphp/Util/Box.php new file mode 100644 index 00000000..9cb478b5 --- /dev/null +++ b/modules/sass/scssphp/Util/Box.php @@ -0,0 +1,50 @@ + + */ + private readonly ModifiableBox $inner; + + /** + * @param ModifiableBox $inner + */ + public function __construct(ModifiableBox $inner) + { + $this->inner = $inner; + } + + /** + * @return T + */ + public function getValue() + { + return $this->inner->getValue(); + } + + public function equals(object $other): bool + { + return $other instanceof Box && $this->inner === $other->inner; + } +} diff --git a/modules/sass/scssphp/Util/Character.php b/modules/sass/scssphp/Util/Character.php index 5bc7b432..c0b5011b 100644 --- a/modules/sass/scssphp/Util/Character.php +++ b/modules/sass/scssphp/Util/Character.php @@ -142,18 +142,11 @@ public static function isPrivate(string $identifier): bool */ public static function opposite(string $character): string { - switch ($character) { - case '(': - return ')'; - - case '{': - return '}'; - - case '[': - return ']'; - - default: - throw new \InvalidArgumentException(sprintf('Expected a brace character. Got "%s"', $character)); - } + return match ($character) { + '(' => ')', + '{' => '}', + '[' => ']', + default => throw new \InvalidArgumentException(sprintf('Expected a brace character. Got "%s"', $character)), + }; } } diff --git a/modules/sass/scssphp/Util/EquatableUtil.php b/modules/sass/scssphp/Util/EquatableUtil.php index dae1f9ee..0da7d969 100644 --- a/modules/sass/scssphp/Util/EquatableUtil.php +++ b/modules/sass/scssphp/Util/EquatableUtil.php @@ -47,13 +47,8 @@ public static function listContains(array $list, Equatable $item): bool * Values implementing {@see Equatable} are still compared with `===` first to * optimize comparisons to the same object, as an object is always expected to * be equal to itself. - * - * @param mixed $item1 - * @param mixed $item2 - * - * @return bool */ - public static function equals($item1, $item2): bool + public static function equals(mixed $item1, mixed $item2): bool { if ($item1 === $item2) { return true; diff --git a/modules/sass/scssphp/Util/ErrorUtil.php b/modules/sass/scssphp/Util/ErrorUtil.php index 3f035ea6..f8ec998f 100644 --- a/modules/sass/scssphp/Util/ErrorUtil.php +++ b/modules/sass/scssphp/Util/ErrorUtil.php @@ -43,16 +43,15 @@ public static function checkIntInInterval(int $value, int $minValue, int $maxVal public static function checkValidRange(int $start, ?int $end, int $length, ?string $startName = null, ?string $endName = null): void { if ($start < 0 || $start > $length) { - $startName = $startName ?? 'start'; + $startName ??= 'start'; $startNameDisplay = $startName ? " $startName" : ''; throw new \OutOfRangeException("Invalid value:$startNameDisplay must be between 0 and $length: $start."); } if ($end !== null) { - if ($end < $start || $end > $length) { - $endName = $endName ?? 'end'; + $endName ??= 'end'; $endNameDisplay = $endName ? " $endName" : ''; throw new \OutOfRangeException("Invalid value:$endNameDisplay must be between $start and $length: $end."); diff --git a/modules/sass/scssphp/Util/IterableUtil.php b/modules/sass/scssphp/Util/IterableUtil.php new file mode 100644 index 00000000..b5d3cf6b --- /dev/null +++ b/modules/sass/scssphp/Util/IterableUtil.php @@ -0,0 +1,77 @@ + $list + * @param callable(T): bool $callback + */ + public static function any(iterable $list, callable $callback): bool + { + foreach ($list as $item) { + if ($callback($item)) { + return true; + } + } + + return false; + } + + /** + * @template T + * + * @param iterable $list + * @param callable(T): bool $callback + */ + public static function every(iterable $list, callable $callback): bool + { + foreach ($list as $item) { + if (!$callback($item)) { + return false; + } + } + + return true; + } + + /** + * Returns the first `T` returned by $callback for an element of $iterable, + * or `null` if it returns `null` for every element. + * + * @template T + * @template E + * @param iterable $iterable + * @param callable(E): (T|null) $callback + * + * @return T|null + */ + public static function search(iterable $iterable, callable $callback) + { + foreach ($iterable as $element) { + $value = $callback($element); + + if ($value !== null) { + return $value; + } + } + + return null; + } +} diff --git a/modules/sass/scssphp/Util/ListUtil.php b/modules/sass/scssphp/Util/ListUtil.php index a9616adb..563acf74 100644 --- a/modules/sass/scssphp/Util/ListUtil.php +++ b/modules/sass/scssphp/Util/ListUtil.php @@ -76,9 +76,7 @@ public static function flattenVertically(array $queues): array public static function longestCommonSubsequence(array $list1, array $list2, ?callable $select = null): array { if ($select === null) { - $select = function ($element1, $element2) { - return EquatableUtil::equals($element1, $element2) ? $element1 : null; - }; + $select = fn($element1, $element2) => EquatableUtil::equals($element1, $element2) ? $element1 : null; } $lengths = array_fill(0, \count($list1) + 1, array_fill(0, \count($list2) + 1, 0)); @@ -94,7 +92,12 @@ public static function longestCommonSubsequence(array $list1, array $list2, ?cal } } - $backtrack = function (int $i, int $j) use ($selections, $lengths, &$backtrack): array { + /** + * @param int $i + * @param int $j + * @return list + */ + $backtrack = function (int $i, int $j) use ($selections, $lengths, &$backtrack) { if ($i === -1 || $j === -1) { return []; } diff --git a/modules/sass/scssphp/Util/ModifiableBox.php b/modules/sass/scssphp/Util/ModifiableBox.php new file mode 100644 index 00000000..7701b34f --- /dev/null +++ b/modules/sass/scssphp/Util/ModifiableBox.php @@ -0,0 +1,67 @@ +value = $value; + } + + /** + * @return T + */ + public function getValue() + { + return $this->value; + } + + /** + * @param T $value + */ + public function setValue(mixed $value): void + { + $this->value = $value; + } + + /** + * Returns an unmodifiable reference to this box. + * + * The underlying modifiable box may still be modified. + * + * @return Box + */ + public function seal(): Box + { + return new Box($this); + } +} diff --git a/modules/sass/scssphp/Util/NumberUtil.php b/modules/sass/scssphp/Util/NumberUtil.php index 9abb9da4..2878ff75 100644 --- a/modules/sass/scssphp/Util/NumberUtil.php +++ b/modules/sass/scssphp/Util/NumberUtil.php @@ -110,13 +110,6 @@ public static function fuzzyCheckRange(float $number, float $min, float $max): ? } /** - * @param float $number - * @param float $min - * @param float $max - * @param string|null $name - * - * @return float - * * @throws \OutOfRangeException */ public static function fuzzyAssertRange(float $number, float $min, float $max, ?string $name = null): float @@ -136,11 +129,6 @@ public static function fuzzyAssertRange(float $number, float $min, float $max, ? * Returns $num1 / $num2, using Sass's division semantic. * * Sass allows dividing by 0. - * - * @param float $num1 - * @param float $num2 - * - * @return float */ public static function divideLikeSass(float $num1, float $num2): float { @@ -167,6 +155,14 @@ public static function divideLikeSass(float $num1, float $num2): float */ public static function moduloLikeSass(float $num1, float $num2): float { + if (is_infinite($num1)) { + return NAN; + } + + if (is_infinite($num2)) { + return self::signIncludingZero($num1) === self::sign($num2) ? $num1 : NAN; + } + if ($num2 == 0) { return NAN; } @@ -184,4 +180,103 @@ public static function moduloLikeSass(float $num1, float $num2): float return $result; } + + public static function sqrt(SassNumber $number): SassNumber + { + $number->assertNoUnits('number'); + + return SassNumber::create(sqrt($number->getValue())); + } + + public static function sin(SassNumber $number): SassNumber + { + return SassNumber::create(sin($number->coerceValueToUnit('rad', 'number'))); + } + + public static function cos(SassNumber $number): SassNumber + { + return SassNumber::create(cos($number->coerceValueToUnit('rad', 'number'))); + } + + public static function tan(SassNumber $number): SassNumber + { + return SassNumber::create(tan($number->coerceValueToUnit('rad', 'number'))); + } + + public static function atan(SassNumber $number): SassNumber + { + $number->assertNoUnits('number'); + return self::radiansToDegrees(atan($number->getValue())); + } + + public static function asin(SassNumber $number): SassNumber + { + $number->assertNoUnits('number'); + return self::radiansToDegrees(asin($number->getValue())); + } + + public static function acos(SassNumber $number): SassNumber + { + $number->assertNoUnits('number'); + return self::radiansToDegrees(acos($number->getValue())); + } + + public static function abs(SassNumber $number): SassNumber + { + return SassNumber::create(abs($number->getValue()))->coerceToMatch($number); + } + + public static function log(SassNumber $number, ?SassNumber $base): SassNumber + { + if ($base !== null) { + return SassNumber::create(log($number->getValue(), $base->getValue())); + } + + return SassNumber::create(log($number->getValue())); + } + + public static function pow(SassNumber $base, SassNumber $exponent): SassNumber + { + $base->assertNoUnits('base'); + $exponent->assertNoUnits('exponent'); + + return SassNumber::create($base->getValue() ** $exponent->getValue()); + } + + public static function atan2(SassNumber $x, SassNumber $y): SassNumber + { + return self::radiansToDegrees(atan2($y->getValue(), $x->convertValueToMatch($y, 'x', 'y'))); + } + + private static function radiansToDegrees(float $radians): SassNumber + { + return SassNumber::withUnits($radians * (180 / \M_PI), ['deg']); + } + + public static function sign(float $num): int + { + if ($num > 0) { + return 1; + } + + if ($num < 0) { + return -1; + } + + return 0; + } + + public static function signIncludingZero(float $num): int + { + // In PHP, negative 0 and positive 0 are equal even for strict equality, so we need a different detection + if ($num ** -1 === -INF) { + return -1; + } + + if ($num === 0.0) { + return 1; + } + + return self::sign($num); + } } diff --git a/modules/sass/scssphp/Util/Path.php b/modules/sass/scssphp/Util/Path.php index c4b2d776..260afd1c 100644 --- a/modules/sass/scssphp/Util/Path.php +++ b/modules/sass/scssphp/Util/Path.php @@ -2,16 +2,38 @@ namespace Tangible\ScssPhp\Util; +use League\Uri\BaseUri; +use League\Uri\Contracts\UriInterface; +use League\Uri\Uri; +use Symfony\Component\Filesystem\Path as SymfonyPath; + /** * @internal */ final class Path { /** - * @param string $path - * - * @return bool + * @var array */ + private static array $realCaseCache = []; + public static function toUri(string $path): UriInterface + { + if (\DIRECTORY_SEPARATOR === '\\') { + return Uri::fromWindowsPath($path); + } + + return Uri::fromUnixPath($path); + } + + public static function fromUri(UriInterface $uri): string + { + if (\DIRECTORY_SEPARATOR === '\\') { + return BaseUri::from($uri)->windowsPath() ?? throw new \InvalidArgumentException("Uri $uri must have scheme 'file:'."); + } + + return BaseUri::from($uri)->unixPath() ?? throw new \InvalidArgumentException("Uri $uri must have scheme 'file:'."); + } + public static function isAbsolute(string $path): bool { if ($path === '') { @@ -29,11 +51,77 @@ public static function isAbsolute(string $path): bool return false; } - /** - * @param string $path - * - * @return bool - */ + public static function canonicalize(string $path): string + { + return self::realCasePath(self::normalize(self::absolute($path))); + } + + private static function normalize(string $path): string + { + $normalized = SymfonyPath::canonicalize($path); + + // The Symfony Path class always uses / as separator, while we want to use the platform one to get a real path + if (\DIRECTORY_SEPARATOR === '\\') { + $normalized = str_replace('/', '\\', $normalized); + } + + return $normalized; + } + + private static function realCasePath(string $path): string + { + if (!(\PHP_OS_FAMILY === 'Windows' || \PHP_OS_FAMILY === 'Darwin')) { + return $path; + } + + if (\PHP_OS_FAMILY === 'Windows') { + // Drive names are *always* case-insensitive, so convert them to uppercase. + if (self::isAbsolute($path) && Character::isAlphabetic($path[0])) { + $path = strtoupper(substr($path, 0, 3)) . substr($path, 3); + } + } + + return self::realCasePathHelper($path); + } + + private static function realCasePathHelper(string $path): string + { + $dirname = dirname($path); + + if ($dirname === $path || $dirname === '.') { + return $path; + } + + return self::$realCaseCache[$path] ??= self::computeRealCasePath($path); + } + + private static function computeRealCasePath(string $path): string + { + $realDirname = self::realCasePathHelper(dirname($path)); + $basename = basename($path); + + $files = @scandir($realDirname); + + if ($files === false) { + // If there's an error listing a directory, it's likely because we're + // trying to reach too far out of the current directory into something + // we don't have permissions for. In that case, just assume we have the + // real path. + return $path; + } + + $matches = array_values(array_filter($files, fn ($realPath) => StringUtil::equalsIgnoreCase(basename($realPath), $basename))); + + if (\count($matches) === 1) { + return $matches[0]; + } + + // If the file doesn't exist, or if there are multiple options + // (meaning the filesystem isn't actually case-insensitive), use + // `basename` as-is. + return self::join($realDirname, $basename); + } + public static function isWindowsAbsolute(string $path): bool { if ($path === '') { @@ -67,12 +155,6 @@ public static function isWindowsAbsolute(string $path): bool return true; } - /** - * @param string $part1 - * @param string $part2 - * - * @return string - */ public static function join(string $part1, string $part2): string { if ($part1 === '' || self::isAbsolute($part2)) { @@ -93,17 +175,55 @@ public static function join(string $part1, string $part2): string return $part1 . $separator . $part2; } + public static function absolute(string $path): string + { + $cwd = getcwd(); + + if ($cwd === false) { + return $path; + } + + return self::join($cwd, $path); + } + /** - * Returns a pretty URI for a path - * - * @param string $path + * Gets the file extension of $path: the portion of basename from the last + * `.` to the end (including the `.` itself). * - * @return string + * If the file name starts with a `.`, then that is not considered the + * extension + */ + public static function extension(string $path): string + { + $basename = basename($path); + + $lastDot = strrpos($basename, '.'); + + if ($lastDot === false || $lastDot === 0) { + return ''; + } + + return substr($basename, $lastDot); + } + + public static function withoutExtension(string $path): string + { + $extension = self::extension($path); + + if ($extension === '') { + return $path; + } + + return substr($path, 0, -\strlen($extension)); + } + + /** + * Returns a pretty URI for a path */ public static function prettyUri(string $path): string { $normalizedPath = $path; - $normalizedRootDirectory = getcwd().'/'; + $normalizedRootDirectory = getcwd() . '/'; if (\DIRECTORY_SEPARATOR === '\\') { $normalizedRootDirectory = str_replace('\\', '/', $normalizedRootDirectory); @@ -112,7 +232,7 @@ public static function prettyUri(string $path): string // TODO add support for returning a relative path using ../ in some cases, like Dart's path.prettyUri method - if (0 === strpos($normalizedPath, $normalizedRootDirectory)) { + if (str_starts_with($normalizedPath, $normalizedRootDirectory)) { return substr($path, \strlen($normalizedRootDirectory)); } diff --git a/modules/sass/scssphp/Util/SpanUtil.php b/modules/sass/scssphp/Util/SpanUtil.php index 2925dbff..81f24f93 100644 --- a/modules/sass/scssphp/Util/SpanUtil.php +++ b/modules/sass/scssphp/Util/SpanUtil.php @@ -14,12 +14,18 @@ use Tangible\ScssPhp\Parser\StringScanner; use Tangible\ScssPhp\SourceSpan\FileSpan; +use Tangible\ScssPhp\SourceSpan\SourceFile; /** * @internal */ final class SpanUtil { + public static function bogusSpan(): FileSpan + { + return (new SourceFile(''))->span(0); + } + /** * Returns this span with all whitespace trimmed from both sides. */ diff --git a/modules/sass/scssphp/Util/StringUtil.php b/modules/sass/scssphp/Util/StringUtil.php index 8f0ed099..063ed845 100644 --- a/modules/sass/scssphp/Util/StringUtil.php +++ b/modules/sass/scssphp/Util/StringUtil.php @@ -17,44 +17,6 @@ */ final class StringUtil { - /** - * Checks whether $haystack starts with $needle. - * - * This is a userland implementation of `str_starts_with` of PHP 8+. - * - * @param string $haystack - * @param string $needle - * - * @return bool - */ - public static function startsWith(string $haystack, string $needle): bool - { - if (\PHP_VERSION_ID >= 80000) { - return str_starts_with($haystack, $needle); - } - - return '' === $needle || ('' !== $haystack && 0 === substr_compare($haystack, $needle, 0, \strlen($needle))); - } - - /** - * Checks whether $haystack ends with $needle. - * - * This is a userland implementation of `str_ends_with` of PHP 8+. - * - * @param string $haystack - * @param string $needle - * - * @return bool - */ - public static function endsWith(string $haystack, string $needle): bool - { - if (\PHP_VERSION_ID >= 80000) { - return str_ends_with($haystack, $needle); - } - - return '' === $needle || ('' !== $haystack && 0 === substr_compare($haystack, $needle, -\strlen($needle))); - } - public static function trimAsciiRight(string $string, bool $excludeEscape = false): string { $end = self::lastNonWhitespace($string, $excludeEscape); @@ -92,11 +54,6 @@ private static function lastNonWhitespace(string $string, bool $excludeEscape = /** * Returns whether $string1 and $string2 are equal, ignoring ASCII case. - * - * @param string|null $string1 - * @param string $string2 - * - * @return bool */ public static function equalsIgnoreCase(?string $string1, string $string2): bool { @@ -118,10 +75,6 @@ public static function equalsIgnoreCase(?string $string1, string $string2): bool * rather than operating on ASCII. * Passing an input string in an encoding that it is not ASCII compatible is * unsupported, and will probably generate garbage. - * - * @param string $string - * - * @return string */ public static function toAsciiLowerCase(string $string): string { diff --git a/modules/sass/scssphp/Util/UriUtil.php b/modules/sass/scssphp/Util/UriUtil.php new file mode 100644 index 00000000..1d112e45 --- /dev/null +++ b/modules/sass/scssphp/Util/UriUtil.php @@ -0,0 +1,40 @@ +resolve($reference)->getUri(); + + \assert($resolvedUri instanceof UriInterface); + + return $resolvedUri; + } + + public static function resolveUri(UriInterface $baseUrl, UriInterface $url): UriInterface + { + $resolvedUri = BaseUri::from($baseUrl)->resolve($url)->getUri(); + + \assert($resolvedUri instanceof UriInterface); + + return $resolvedUri; + } +} diff --git a/modules/sass/scssphp/Value/CalculationInterpolation.php b/modules/sass/scssphp/Value/CalculationInterpolation.php deleted file mode 100644 index bbdf5e69..00000000 --- a/modules/sass/scssphp/Value/CalculationInterpolation.php +++ /dev/null @@ -1,45 +0,0 @@ -value = $value; - } - - public function getValue(): string - { - return $this->value; - } - - public function equals(object $other): bool - { - return $other instanceof CalculationInterpolation && $this->value === $other->value; - } -} diff --git a/modules/sass/scssphp/Value/CalculationOperation.php b/modules/sass/scssphp/Value/CalculationOperation.php index 218c7fb9..175b0665 100644 --- a/modules/sass/scssphp/Value/CalculationOperation.php +++ b/modules/sass/scssphp/Value/CalculationOperation.php @@ -12,59 +12,40 @@ namespace Tangible\ScssPhp\Value; +use Tangible\ScssPhp\Serializer\Serializer; use Tangible\ScssPhp\Util\Equatable; /** * A binary operation that can appear in a {@see SassCalculation}. */ -final class CalculationOperation implements Equatable +final class CalculationOperation implements Equatable, \Stringable { - /** - * @phpstan-var CalculationOperator::* - * @readonly - */ - private $operator; + private readonly CalculationOperator $operator; /** * The left-hand operand. * * This is either a {@see SassNumber}, a {@see SassCalculation}, an unquoted - * {@see SassString}, a {@see CalculationOperation}, or a {@see CalculationInterpolation}. - * - * @var object - * @readonly + * {@see SassString}, or a {@see CalculationOperation}. */ - private $left; + private readonly object $left; /** * The right-hand operand. * * This is either a {@see SassNumber}, a {@see SassCalculation}, an unquoted - * {@see SassString}, a {@see CalculationOperation}, or a {@see CalculationInterpolation}. - * - * @var object - * @readonly + * {@see SassString}, or a {@see CalculationOperation}. */ - private $right; + private readonly object $right; - /** - * @param string $operator - * @param object $left - * @param object $right - * - * @phpstan-param CalculationOperator::* $operator - */ - public function __construct(string $operator, object $left, object $right) + public function __construct(CalculationOperator $operator, object $left, object $right) { $this->operator = $operator; $this->left = $left; $this->right = $right; } - /** - * @phpstan-return CalculationOperator::* - */ - public function getOperator(): string + public function getOperator(): CalculationOperator { return $this->operator; } @@ -86,4 +67,11 @@ public function equals(object $other): bool return $other instanceof CalculationOperation && $this->operator === $other->operator && $this->left->equals($other->left) && $this->right->equals($other->right); } + + public function __toString(): string + { + $parenthesized = Serializer::serializeValue(SassCalculation::unsimplified('', [$this]), true); + + return substr($parenthesized, 1, \strlen($parenthesized) - 2); + } } diff --git a/modules/sass/scssphp/Value/CalculationOperator.php b/modules/sass/scssphp/Value/CalculationOperator.php index 93e93ce3..85694ec8 100644 --- a/modules/sass/scssphp/Value/CalculationOperator.php +++ b/modules/sass/scssphp/Value/CalculationOperator.php @@ -15,34 +15,35 @@ /** * An enumeration of possible operators for {@see CalculationOperation}. */ -final class CalculationOperator +enum CalculationOperator { - public const PLUS = '+'; - public const MINUS = '-'; - public const TIMES = '*'; - public const DIVIDED_BY = '/'; + case PLUS; + case MINUS; + case TIMES; + case DIVIDED_BY; + + public function getOperator(): string + { + return match ($this) { + self::PLUS => '+', + self::MINUS => '-', + self::TIMES => '*', + self::DIVIDED_BY => '/', + }; + } /** * The precedence of the operator * * An operator with higher precedence binds tighter. * - * @phpstan-param CalculationOperator::* $operator - * * @internal */ - public static function getPrecedence(string $operator): int + public function getPrecedence(): int { - switch ($operator) { - case self::PLUS: - case self::MINUS: - return 1; - - case self::TIMES: - case self::DIVIDED_BY: - return 2; - } - - throw new \InvalidArgumentException(sprintf('Unknown operator "%s".', $operator)); + return match ($this) { + self::PLUS, self::MINUS => 1, + self::TIMES, self::DIVIDED_BY => 2, + }; } } diff --git a/modules/sass/scssphp/Value/ColorFormat.php b/modules/sass/scssphp/Value/ColorFormat.php index c935d32c..4f0c67c3 100644 --- a/modules/sass/scssphp/Value/ColorFormat.php +++ b/modules/sass/scssphp/Value/ColorFormat.php @@ -12,11 +12,12 @@ namespace Tangible\ScssPhp\Value; +use JiriPudil\SealedClasses\Sealed; + /** * @internal */ -final class ColorFormat +#[Sealed(permits: [ColorFormatEnum::class, SpanColorFormat::class])] +interface ColorFormat { - const RGB_FUNCTION = 'rgbFunction'; - const HSL_FUNCTION = 'hslFunction'; } diff --git a/modules/sass/scssphp/Value/ColorFormatEnum.php b/modules/sass/scssphp/Value/ColorFormatEnum.php new file mode 100644 index 00000000..03750538 --- /dev/null +++ b/modules/sass/scssphp/Value/ColorFormatEnum.php @@ -0,0 +1,28 @@ + - * @readonly */ - private $numeratorUnits; + private readonly array $numeratorUnits; /** * @var list - * @readonly */ - private $denominatorUnits; + private readonly array $denominatorUnits; /** - * @param float $value * @param list $numeratorUnits * @param list $denominatorUnits * @param array{SassNumber, SassNumber}|null $asSlash diff --git a/modules/sass/scssphp/Value/ListSeparator.php b/modules/sass/scssphp/Value/ListSeparator.php index b5b72ec7..d103ae22 100644 --- a/modules/sass/scssphp/Value/ListSeparator.php +++ b/modules/sass/scssphp/Value/ListSeparator.php @@ -15,10 +15,20 @@ /** * An enum of list separator types. */ -final class ListSeparator +enum ListSeparator { - const COMMA = ','; - const SPACE = ' '; - const SLASH = '/'; - const UNDECIDED = ''; + case COMMA; + case SPACE; + case SLASH; + case UNDECIDED; + + public function getSeparator(): ?string + { + return match ($this) { + self::COMMA => ',', + self::SPACE => ' ', + self::SLASH => '/', + self::UNDECIDED => null, + }; + } } diff --git a/modules/sass/scssphp/Value/SassArgumentList.php b/modules/sass/scssphp/Value/SassArgumentList.php index a7e9b30d..a1dd9724 100644 --- a/modules/sass/scssphp/Value/SassArgumentList.php +++ b/modules/sass/scssphp/Value/SassArgumentList.php @@ -23,25 +23,18 @@ final class SassArgumentList extends SassList { /** * @var array - * @readonly */ - private $keywords; + private readonly array $keywords; - /** - * @var bool - */ - private $keywordAccessed = false; + private bool $keywordAccessed = false; /** * SassArgumentList constructor. * - * @param list $contents + * @param list $contents * @param array $keywords - * @param string $separator - * - * @phpstan-param ListSeparator::* $separator */ - public function __construct(array $contents, array $keywords, string $separator) + public function __construct(array $contents, array $keywords, ListSeparator $separator) { parent::__construct($contents, $separator); $this->keywords = $keywords; diff --git a/modules/sass/scssphp/Value/SassBoolean.php b/modules/sass/scssphp/Value/SassBoolean.php index 62326166..58bb2df4 100644 --- a/modules/sass/scssphp/Value/SassBoolean.php +++ b/modules/sass/scssphp/Value/SassBoolean.php @@ -19,37 +19,19 @@ */ final class SassBoolean extends Value { - /** - * @var SassBoolean|null - */ - private static $trueInstance; - - /** - * @var SassBoolean|null - */ - private static $falseInstance; - - /** - * @var bool - * @readonly - */ - private $value; + private static SassBoolean $trueInstance; + + private static SassBoolean $falseInstance; + + private readonly bool $value; public static function create(bool $value): SassBoolean { if ($value) { - if (self::$trueInstance === null) { - self::$trueInstance = new self(true); - } - - return self::$trueInstance; - } - - if (self::$falseInstance === null) { - self::$falseInstance = new self(false); + return self::$trueInstance ??= new self(true); } - return self::$falseInstance; + return self::$falseInstance ??= new self(false); } private function __construct(bool $value) diff --git a/modules/sass/scssphp/Value/SassCalculation.php b/modules/sass/scssphp/Value/SassCalculation.php index 66537a6c..511e884a 100644 --- a/modules/sass/scssphp/Value/SassCalculation.php +++ b/modules/sass/scssphp/Value/SassCalculation.php @@ -12,10 +12,13 @@ namespace Tangible\ScssPhp\Value; +use Tangible\ScssPhp\Deprecation; use Tangible\ScssPhp\Exception\SassScriptException; +use Tangible\ScssPhp\Util\Character; use Tangible\ScssPhp\Util\Equatable; use Tangible\ScssPhp\Util\NumberUtil; use Tangible\ScssPhp\Visitor\ValueVisitor; +use Tangible\ScssPhp\Warn; /** * A SassScript calculation. @@ -29,35 +32,28 @@ final class SassCalculation extends Value { /** * The calculation's name, such as `"calc"`. - * - * @var string - * @readonly */ - private $name; + private readonly string $name; /** * The calculation's arguments. * * Each argument is either a {@see SassNumber}, a {@see SassCalculation}, an unquoted - * {@see SassString}, a {@see CalculationOperation}, or a {@see CalculationInterpolation}. + * {@see SassString}, or a {@see CalculationOperation}. * * @var list - * @readonly */ - private $arguments; + private readonly array $arguments; /** - * Creates a new calculation with the given [name] and [arguments] + * Creates a new calculation with the given $name and $arguments * that will not be simplified. * - * @param string $name * @param list $arguments * - * @return Value - * * @internal */ - public static function unsimplified(string $name, array $arguments): Value + public static function unsimplified(string $name, array $arguments): SassCalculation { return new SassCalculation($name, $arguments); } @@ -66,17 +62,12 @@ public static function unsimplified(string $name, array $arguments): Value * Creates a `calc()` calculation with the given $argument. * * The $argument must be either a {@see SassNumber}, a {@see SassCalculation}, an - * unquoted {@see SassString}, a {@see CalculationOperation}, or a - * {@see CalculationInterpolation}. + * unquoted {@see SassString}, or a {@see CalculationOperation}. * * This automatically simplifies the calculation, so it may return a * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it * can determine that the calculation will definitely produce invalid CSS. * - * @param object $argument - * - * @return Value - * * @throws SassScriptException */ public static function calc(object $argument): Value @@ -98,8 +89,8 @@ public static function calc(object $argument): Value * Creates a `min()` calculation with the given $arguments. * * Each argument must be either a {@see SassNumber}, a {@see SassCalculation}, an - * unquoted {@see SassString}, a {@see CalculationOperation}, or a - * {@see CalculationInterpolation}. It must be passed at least one argument. + * unquoted {@see SassString}, or a {@see CalculationOperation}. It must be passed at + * least one argument. * * This automatically simplifies the calculation, so it may return a * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it @@ -107,8 +98,6 @@ public static function calc(object $argument): Value * * @param list $arguments * - * @return Value - * * @throws SassScriptException */ public static function min(array $arguments): Value @@ -146,8 +135,8 @@ public static function min(array $arguments): Value * Creates a `max()` calculation with the given $arguments. * * Each argument must be either a {@see SassNumber}, a {@see SassCalculation}, an - * unquoted {@see SassString}, a {@see CalculationOperation}, or a - * {@see CalculationInterpolation}. It must be passed at least one argument. + * unquoted {@see SassString}, or a {@see CalculationOperation}. It must be passed at + * least one argument. * * This automatically simplifies the calculation, so it may return a * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it @@ -155,8 +144,6 @@ public static function min(array $arguments): Value * * @param list $arguments * - * @return Value - * * @throws SassScriptException */ public static function max(array $arguments): Value @@ -190,12 +177,245 @@ public static function max(array $arguments): Value return new SassCalculation('max', $args); } + /** + * Creates a `hypot()` calculation with the given $arguments. + * + * Each argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. It must be passed at + * least one argument. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + * + * @param list $arguments + */ + public static function hypot(array $arguments): Value + { + $args = self::simplifyArguments($arguments); + + if (!$args) { + throw new \InvalidArgumentException('hypot() must have at least one argument.'); + } + + self::verifyCompatibleNumbers($args); + + $subTotal = 0.0; + $first = $args[0]; + + if (!$first instanceof SassNumber || $first->hasUnit('%')) { + return new SassCalculation('hypot', $args); + } + + foreach ($args as $i => $number) { + if (!$number instanceof SassNumber || !$number->hasCompatibleUnits($first)) { + return new SassCalculation('hypot', $args); + } + + $sassIndex = $i + 1; + $value = $number->convertValueToMatch($first, "number[$sassIndex]", 'numbers[1]'); + $subTotal += $value * $value; + } + + return SassNumber::withUnits(sqrt($subTotal), $first->getNumeratorUnits(), $first->getDenominatorUnits()); + } + + /** + * Creates a `sqrt()` calculation with the given $argument. + * + * The $argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + */ + public static function sqrt(object $argument): Value + { + return self::singleArgument('sqrt', $argument, NumberUtil::class . '::sqrt', true); + } + + /** + * Creates a `sin()` calculation with the given $argument. + * + * The $argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + */ + public static function sin(object $argument): Value + { + return self::singleArgument('sin', $argument, NumberUtil::class . '::sin'); + } + + /** + * Creates a `cos()` calculation with the given $argument. + * + * The $argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + */ + public static function cos(object $argument): Value + { + return self::singleArgument('cos', $argument, NumberUtil::class . '::cos'); + } + + /** + * Creates a `tan()` calculation with the given $argument. + * + * The $argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + */ + public static function tan(object $argument): Value + { + return self::singleArgument('tan', $argument, NumberUtil::class . '::tan'); + } + + /** + * Creates an `atan()` calculation with the given $argument. + * + * The $argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + */ + public static function atan(object $argument): Value + { + return self::singleArgument('atan', $argument, NumberUtil::class . '::atan', true); + } + + /** + * Creates an `asin()` calculation with the given $argument. + * + * The $argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + */ + public static function asin(object $argument): Value + { + return self::singleArgument('asin', $argument, NumberUtil::class . '::asin', true); + } + + /** + * Creates an `acos()` calculation with the given $argument. + * + * The $argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + */ + public static function acos(object $argument): Value + { + return self::singleArgument('acos', $argument, NumberUtil::class . '::acos', true); + } + + /** + * Creates an `abs()` calculation with the given $argument. + * + * The $argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + */ + public static function abs(object $argument): Value + { + $argument = self::simplify($argument); + + if (!$argument instanceof SassNumber) { + return new SassCalculation('abs', [$argument]); + } + + if ($argument->hasUnit('%')) { + $message = <<assertNoUnits(); + + return NumberUtil::pow(SassNumber::create(M_E), $argument); + } + + /** + * Creates a `sign()` calculation with the given $argument. + * + * The $argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + */ + public static function sign(object $argument): Value + { + $argument = self::simplify($argument); + + if (!$argument instanceof SassNumber) { + return new SassCalculation('exp', [$argument]); + } + + if (!$argument->hasUnits() && (is_nan($argument->getValue()) || $argument->getValue() === 0.0)) { + return $argument; + } + + if (!$argument->hasUnit('%')) { + SassNumber::create(NumberUtil::sign($argument->getValue()))->coerceValueToMatch($argument); + } + + return new SassCalculation('exp', [$argument]); + } + /** * Creates a `clamp()` calculation with the given $min, $value, and $max. * * Each argument must be either a {@see SassNumber}, a {@see SassCalculation}, an - * unquoted {@see SassString}, a {@see CalculationOperation}, or a - * {@see CalculationInterpolation}. + * unquoted {@see SassString}, or a {@see CalculationOperation}. * * This automatically simplifies the calculation, so it may return a * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it @@ -204,15 +424,9 @@ public static function max(array $arguments): Value * This may be passed fewer than three arguments, but only if one of the * arguments is an unquoted `var()` string. * - * @param object $min - * @param object|null $value - * @param object|null $max - * - * @return Value - * * @throws SassScriptException */ - public static function clamp(object $min, object $value = null, object $max = null): Value + public static function clamp(object $min, ?object $value = null, ?object $max = null): Value { if ($value === null && $max !== null) { throw new \InvalidArgumentException('If value is null, max must also be null.'); @@ -240,13 +454,271 @@ public static function clamp(object $min, object $value = null, object $max = nu return $value; } - $args = array_filter([$min, $value, $max]); + $args = array_values(array_filter([$min, $value, $max])); self::verifyCompatibleNumbers($args); self::verifyLength($args, 3); return new SassCalculation('clamp', $args); } + /** + * Creates a `pow()` calculation with the given $base and $exponent. + * + * Each argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + * + * This may be passed fewer than two arguments, but only if one of the + * arguments is an unquoted `var()` string. + */ + public static function pow(object $base, ?object $exponent): Value + { + $args = [$base]; + if ($exponent !== null) { + $args[] = $exponent; + } + self::verifyLength($args, 2); + $base = self::simplify($base); + if ($exponent !== null) { + $exponent = self::simplify($exponent); + } + + if (!$base instanceof SassNumber || !$exponent instanceof SassNumber) { + return new SassCalculation('pow', $args); + } + + $base->assertNoUnits(); + $exponent->assertNoUnits(); + + return NumberUtil::pow($base, $exponent); + } + + /** + * Creates a `log()` calculation with the given $number and $base. + * + * Each argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + * + * If arguments contains exactly a single argument, the base is set to + * `math.e` by default. + */ + public static function log(object $number, ?object $base): Value + { + $number = self::simplify($number); + $args = [$number]; + if ($base !== null) { + $base = self::simplify($base); + $args[] = $base; + } + + if (!$number instanceof SassNumber || ($base !== null && !$base instanceof SassNumber)) { + return new SassCalculation('log', $args); + } + + $number->assertNoUnits(); + + if ($base instanceof SassNumber) { + $base->assertNoUnits(); + + return NumberUtil::log($number, $base); + } + + return NumberUtil::log($number, null); + } + + /** + * Creates a `atan2()` calculation for $y and $x. + * + * Each argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + * + * This may be passed fewer than two arguments, but only if one of the + * arguments is an unquoted `var()` string. + */ + public static function atan2(object $y, ?object $x): Value + { + $y = self::simplify($y); + $args = [$y]; + + if ($x !== null) { + $x = self::simplify($x); + $args[] = $x; + } + self::verifyLength($args, 2); + self::verifyCompatibleNumbers($args); + + if (!$y instanceof SassNumber || !$x instanceof SassNumber || $y->hasUnit('%') || $x->hasUnit('%') || !$y->hasCompatibleUnits($x)) { + return new SassCalculation('atan2', $args); + } + + return NumberUtil::atan2($y, $x); + } + + /** + * Creates a `rem()` calculation with the given $dividend and $modulus. + * + * Each argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + * + * This may be passed fewer than two arguments, but only if one of the + * arguments is an unquoted `var()` string. + */ + public static function rem(object $dividend, ?object $modulus): Value + { + $dividend = self::simplify($dividend); + $args = [$dividend]; + + if ($modulus !== null) { + $modulus = self::simplify($modulus); + $args[] = $modulus; + } + self::verifyLength($args, 2); + self::verifyCompatibleNumbers($args); + + if (!$dividend instanceof SassNumber || !$modulus instanceof SassNumber || !$dividend->hasCompatibleUnits($modulus)) { + return new SassCalculation('rem', $args); + } + + $result = $dividend->modulo($modulus); + + if (NumberUtil::signIncludingZero($modulus->getValue()) !== NumberUtil::signIncludingZero($dividend->getValue())) { + if (is_infinite($modulus->getValue())) { + return $dividend; + } + + if ($result->getValue() === 0.0) { + return $result->unaryMinus(); + } + + return $result->minus($modulus); + } + + return $result; + } + + /** + * Creates a `mod()` calculation with the given $dividend and $modulus. + * + * Each argument must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + * + * This may be passed fewer than two arguments, but only if one of the + * arguments is an unquoted `var()` string. + */ + public static function mod(object $dividend, ?object $modulus): Value + { + $dividend = self::simplify($dividend); + $args = [$dividend]; + + if ($modulus !== null) { + $modulus = self::simplify($modulus); + $args[] = $modulus; + } + self::verifyLength($args, 2); + self::verifyCompatibleNumbers($args); + + if (!$dividend instanceof SassNumber || !$modulus instanceof SassNumber || !$dividend->hasCompatibleUnits($modulus)) { + return new SassCalculation('mod', $args); + } + + return $dividend->modulo($modulus); + } + + /** + * Creates a `round()` calculation with the given $strategyOrNumber, + * $numberOrStep, and $step. Strategy must be either nearest, up, down or + * to-zero. + * + * Number and step must be either a {@see SassNumber}, a {@see SassCalculation}, an + * unquoted {@see SassString}, or a {@see CalculationOperation}. + * + * This automatically simplifies the calculation, so it may return a + * {@see SassNumber} rather than a {@see SassCalculation}. It throws an exception if it + * can determine that the calculation will definitely produce invalid CSS. + * + * This may be passed fewer than two arguments, but only if one of the + * arguments is an unquoted `var()` string. + */ + public static function round(object $strategyOrNumber, ?object $numberOrStep = null, ?object $step = null): Value + { + $strategyOrNumber = self::simplify($strategyOrNumber); + if ($numberOrStep !== null) { + $numberOrStep = self::simplify($numberOrStep); + } + if ($step !== null) { + $step = self::simplify($step); + } + + switch (true) { + case $strategyOrNumber instanceof SassNumber && $numberOrStep === null && $step === null: + return self::matchUnits(round($strategyOrNumber->getValue()), $strategyOrNumber); + + case $strategyOrNumber instanceof SassNumber && $numberOrStep instanceof SassNumber && $step === null: + self::verifyCompatibleNumbers([$strategyOrNumber, $numberOrStep]); + + if (!$strategyOrNumber->hasCompatibleUnits($numberOrStep)) { + return new SassCalculation('round', [$strategyOrNumber, $numberOrStep]); + } + + return self::roundWithStep('nearest', $strategyOrNumber, $numberOrStep); + + case $strategyOrNumber instanceof SassString && \in_array($strategyOrNumber->getText(), ['nearest', 'up', 'down', 'to-zero'], true) && $numberOrStep instanceof SassNumber && $step instanceof SassNumber: + self::verifyCompatibleNumbers([$numberOrStep, $step]); + + if (!$numberOrStep->hasCompatibleUnits($step)) { + return new SassCalculation('round', [$strategyOrNumber, $numberOrStep, $step]); + } + + return self::roundWithStep($strategyOrNumber->getText(), $numberOrStep, $step); + + case $strategyOrNumber instanceof SassString && \in_array($strategyOrNumber->getText(), ['nearest', 'up', 'down', 'to-zero'], true) && $numberOrStep instanceof SassString && $step === null: + return new SassCalculation('round', [$strategyOrNumber, $numberOrStep]); + + case $strategyOrNumber instanceof SassString && \in_array($strategyOrNumber->getText(), ['nearest', 'up', 'down', 'to-zero'], true) && $numberOrStep !== null && $step === null: + throw new SassScriptException('If strategy is not null, step is required.'); + + case $strategyOrNumber instanceof SassString && \in_array($strategyOrNumber->getText(), ['nearest', 'up', 'down', 'to-zero'], true) && $numberOrStep === null && $step === null: + throw new SassScriptException('Number to round and step arguments are required.'); + + case $strategyOrNumber instanceof SassString && $numberOrStep === null && $step === null: + return new SassCalculation('round', [$strategyOrNumber]); + + case $numberOrStep === null && $step === null: + throw new SassScriptException("Single argument $strategyOrNumber expected to be simplifiable."); + + case $step === null: + return new SassCalculation('round', [$strategyOrNumber, $numberOrStep]); + + case $strategyOrNumber instanceof SassString && (\in_array($strategyOrNumber->getText(), ['nearest', 'up', 'down', 'to-zero'], true) || $strategyOrNumber->isVar()) && $numberOrStep !== null: + return new SassCalculation('round', [$strategyOrNumber, $numberOrStep, $step]); + + case $numberOrStep !== null: + throw new SassScriptException("$strategyOrNumber must be either nearest, up, down or to-zero."); + + default: + throw new SassScriptException('Invalid parameters.'); + } + } + /** * Creates and simplifies a {@see CalculationOperation} with the given $operator, * $left, and $right. @@ -255,48 +727,29 @@ public static function clamp(object $min, object $value = null, object $max = nu * {@see SassNumber} rather than a {@see CalculationOperation}. * * Each of $left and $right must be either a {@see SassNumber}, a - * {@see SassCalculation}, an unquoted {@see SassString}, a {@see CalculationOperation}, or - * a {@see CalculationInterpolation}. - * - * @param string $operator - * @param object $left - * @param object $right - * - * @return object - * - * @phpstan-param CalculationOperator::* $operator + * {@see SassCalculation}, an unquoted {@see SassString}, or a {@see CalculationOperation}. * * @throws SassScriptException */ - public static function operate(string $operator, object $left, object $right): object + public static function operate(CalculationOperator $operator, object $left, object $right): object { return self::operateInternal($operator, $left, $right, false, true); } /** - * Like {@see operate}, but with the internal-only $inMinMax parameter. + * Like {@see operate}, but with the internal-only $inLegacySassFunction parameter. * - * If $inMinMax is `true`, this allows unitless numbers to be added and + * If $inLegacySassFunction is `true`, this allows unitless numbers to be added and * subtracted with numbers with units, for backwards-compatibility with the * old global `min()` and `max()` functions. * * If $simplify is `false`, no simplification will be done. * - * @param string $operator - * @param object $left - * @param object $right - * @param bool $inMinMax - * @param bool $simplify - * - * @return object - * * @throws SassScriptException * - * @phpstan-param CalculationOperator::* $operator - * * @internal */ - public static function operateInternal(string $operator, object $left, object $right, bool $inMinMax, bool $simplify): object + public static function operateInternal(CalculationOperator $operator, object $left, object $right, bool $inLegacySassFunction, bool $simplify): object { if (!$simplify) { return new CalculationOperation($operator, $left, $right); @@ -306,7 +759,7 @@ public static function operateInternal(string $operator, object $left, object $r $right = self::simplify($right); if ($operator === CalculationOperator::PLUS || $operator === CalculationOperator::MINUS) { - if ($left instanceof SassNumber && $right instanceof SassNumber && ($inMinMax ? $left->isComparableTo($right) : $left->hasCompatibleUnits($right))) { + if ($left instanceof SassNumber && $right instanceof SassNumber && ($inLegacySassFunction ? $left->isComparableTo($right) : $left->hasCompatibleUnits($right))) { return $operator === CalculationOperator::PLUS ? $left->plus($right) : $left->minus($right); } @@ -331,7 +784,6 @@ public static function operateInternal(string $operator, object $left, object $r * An internal constructor that doesn't perform any validation or * simplification. * - * @param string $name * @param list $arguments */ private function __construct(string $name, array $arguments) @@ -414,6 +866,83 @@ public function equals(object $other): bool return true; } + /** + * Returns $value coerced to $number's units. + */ + private static function matchUnits(float $value, SassNumber $number): SassNumber + { + return SassNumber::withUnits($value, $number->getNumeratorUnits(), $number->getDenominatorUnits()); + } + + /** + * Returns a rounded $number based on a selected rounding $strategy, + * to the nearest integer multiple of $step. + */ + private static function roundWithStep(string $strategy, SassNumber $number, SassNumber $step): SassNumber + { + if (!\in_array($strategy, ['nearest', 'up', 'down', 'to-zero'], true)) { + throw new \InvalidArgumentException('$strategy must be either nearest, up, down or to-zero.'); + } + + if (is_infinite($number->getValue()) && is_infinite($step->getValue()) || $step->getValue() === 0.0 || is_nan($number->getValue()) || is_nan($step->getValue())) { + return self::matchUnits(NAN, $number); + } + + if (is_infinite($number->getValue())) { + return $number; + } + + if (is_infinite($step->getValue())) { + if ($number->getValue() === 0.0) { + return $number; + } + + switch ($strategy) { + case 'nearest': + case 'to-zero': + if ($number->getValue() > 0) { + return self::matchUnits(0.0, $number); + } + + return self::matchUnits(-0.0, $number); + + case 'up': + if ($number->getValue() > 0) { + return self::matchUnits(INF, $number); + } + + return self::matchUnits(-0.0, $number); + + case 'down': + if ($number->getValue() < 0) { + return self::matchUnits(-INF, $number); + } + + return self::matchUnits(0.0, $number); + } + } + + $stepWithNumberUnit = $step->convertValueToMatch($number); + + switch ($strategy) { + case 'nearest': + return self::matchUnits(round($number->getValue() / $stepWithNumberUnit) * $stepWithNumberUnit, $number); + case 'up': + return self::matchUnits(($step->getValue() < 0 ? floor($number->getValue() / $stepWithNumberUnit) : ceil($number->getValue() / $stepWithNumberUnit)) * $stepWithNumberUnit, $number); + case 'down': + return self::matchUnits(($step->getValue() < 0 ? ceil($number->getValue() / $stepWithNumberUnit) : floor($number->getValue() / $stepWithNumberUnit)) * $stepWithNumberUnit, $number); + case 'to-zero': + if ($number->getValue() < 0) { + return self::matchUnits(ceil($number->getValue() / $stepWithNumberUnit) * $stepWithNumberUnit, $number); + } + + return self::matchUnits(floor($number->getValue() / $stepWithNumberUnit) * $stepWithNumberUnit, $number); + + default: + return self::matchUnits(NAN, $number); + } + } + /** * @param list $args * @@ -427,11 +956,13 @@ private static function simplifyArguments(array $args): array } /** + * @return SassNumber|CalculationOperation|SassString|SassCalculation + * * @throws SassScriptException */ private static function simplify(object $arg): object { - if ($arg instanceof SassNumber || $arg instanceof CalculationInterpolation || $arg instanceof CalculationOperation) { + if ($arg instanceof SassNumber || $arg instanceof CalculationOperation) { return $arg; } @@ -444,14 +975,87 @@ private static function simplify(object $arg): object } if ($arg instanceof SassCalculation) { - return $arg->getName() === 'calc' ? $arg->getArguments()[0] : $arg; + if ($arg->getName() === 'calc') { + $argument = $arg->getArguments()[0]; + + if ($argument instanceof SassString && !$argument->hasQuotes() && self::needsParentheses($argument->getText())) { + return new SassString("({$argument->getText()})", false); + } + + \assert($argument instanceof SassNumber || $argument instanceof SassString || $argument instanceof SassCalculation || $argument instanceof CalculationOperation); + + return $argument; + } + + return $arg; } if ($arg instanceof Value) { throw new SassScriptException("Value $arg can't be used in a calculation."); } - throw new \InvalidArgumentException(sprintf('Unexpected calculation argument %s.', get_class($arg))); + throw new \InvalidArgumentException(sprintf('Unexpected calculation argument %s.', get_debug_type($arg))); + } + + /** + * Returns whether $text needs parentheses if it's the contents of a + * `calc()` being embedded in another calculation. + */ + private static function needsParentheses(string $text): bool + { + $first = $text[0]; + if (self::charNeedsParentheses($first)) { + return true; + } + + $couldBeVar = \strlen($text) > 4 && ($first === 'v' || $first === 'V'); + + if (\strlen($text) < 2) { + return false; + } + $second = $text[1]; + if (self::charNeedsParentheses($second)) { + return true; + } + $couldBeVar = $couldBeVar && ($second === 'a' || $second === 'A'); + + if (\strlen($text) < 3) { + return false; + } + $third = $text[2]; + if (self::charNeedsParentheses($third)) { + return true; + } + $couldBeVar = $couldBeVar && ($third === 'r' || $third === 'R'); + + if (\strlen($text) < 4) { + return false; + } + $fourth = $text[3]; + if ($couldBeVar && $fourth === '(') { + return true; + } + if (self::charNeedsParentheses($fourth)) { + return true; + } + + for ($i = 4; $i < \strlen($text); ++$i) { + if (self::charNeedsParentheses($text[$i])) { + return true; + } + } + + return false; + } + + /** + * Returns whether $character intrinsically needs parentheses if it appears + * in the unquoted string argument of a `calc()` being embedded in another + * calculation. + */ + private static function charNeedsParentheses(string $character): bool + { + return $character === '/' || $character === '*' || Character::isWhitespace($character); } /** @@ -500,10 +1104,9 @@ private static function verifyCompatibleNumbers(array $args): void /** * Throws a {@see SassScriptException} if $args isn't $expectedLength *and* - * doesn't contain either a {@see SassString} or a {@see CalculationInterpolation}. + * doesn't contain either a {@see SassString}. * * @param list $args - * @param int $expectedLength * * @throws SassScriptException */ @@ -514,7 +1117,7 @@ private static function verifyLength(array $args, int $expectedLength): void } foreach ($args as $arg) { - if ($arg instanceof SassString || $arg instanceof CalculationInterpolation) { + if ($arg instanceof SassString) { return; } } @@ -524,4 +1127,22 @@ private static function verifyLength(array $args, int $expectedLength): void throw new SassScriptException("$expectedLength arguments required, but only $length $verb passed."); } + + /** + * @param callable(SassNumber): SassNumber $mathFunc + */ + private static function singleArgument(string $name, object $argument, callable $mathFunc, bool $forbidUnits = false): Value + { + $argument = self::simplify($argument); + + if (!$argument instanceof SassNumber) { + return new SassCalculation($name, [$argument]); + } + + if ($forbidUnits) { + $argument->assertNoUnits(); + } + + return $mathFunc($argument); + } } diff --git a/modules/sass/scssphp/Value/SassColor.php b/modules/sass/scssphp/Value/SassColor.php index 9dc29ec3..1f3df4a9 100644 --- a/modules/sass/scssphp/Value/SassColor.php +++ b/modules/sass/scssphp/Value/SassColor.php @@ -24,74 +24,47 @@ final class SassColor extends Value { /** * This color's red channel, between `0` and `255`. - * - * @var int|null */ - private $red; + private ?int $red; /** * This color's blue channel, between `0` and `255`. - * - * @var int|null */ - private $blue; + private ?int $blue; /** * This color's green channel, between `0` and `255`. - * - * @var int|null */ - private $green; + private ?int $green; /** * This color's hue, between `0` and `360`. - * - * @var float|null */ - private $hue; + private ?float $hue; /** * This color's saturation, a percentage between `0` and `100`. - * - * @var float|null */ - private $saturation; + private ?float $saturation; /** * This color's lightness, a percentage between `0` and `100`. - * - * @var float|null */ - private $lightness; + private ?float $lightness; /** * This color's alpha channel, between `0` and `1`. - * - * @var float - * @readonly */ - private $alpha; + private readonly float $alpha; - /** - * @var SpanColorFormat|string|null - * @phpstan-var SpanColorFormat|ColorFormat::*|null - * @readonly - */ - private $format; + private readonly ?ColorFormat $format; /** * Creates a RGB color * - * @param int $red - * @param int $blue - * @param int $green - * @param float|null $alpha - * - * @return SassColor - * * @throws \OutOfRangeException if values are outside the expected range. */ - public static function rgb(int $red, int $green, int $blue, ?float $alpha = null): SassColor + public static function rgb(int $red, int $green, int $blue, float $alpha = 1.0): SassColor { return self::rgbInternal($red, $green, $blue, $alpha); } @@ -101,25 +74,11 @@ public static function rgb(int $red, int $green, int $blue, ?float $alpha = null * * @internal * - * @param int $red - * @param int $blue - * @param int $green - * @param float|null $alpha - * @param SpanColorFormat|string|null $format - * - * @return SassColor - * - * @phpstan-param SpanColorFormat|ColorFormat::*|null $format - * * @throws \OutOfRangeException if values are outside the expected range. */ - public static function rgbInternal(int $red, int $green, int $blue, ?float $alpha = null, $format = null): SassColor + public static function rgbInternal(int $red, int $green, int $blue, float $alpha = 1.0, ?ColorFormat $format = null): SassColor { - if ($alpha === null) { - $alpha = 1.0; - } else { - $alpha = NumberUtil::fuzzyAssertRange($alpha, 0, 1, 'alpha'); - } + $alpha = NumberUtil::fuzzyAssertRange($alpha, 0, 1, 'alpha'); ErrorUtil::checkIntInInterval($red, 0, 255, 'red'); ErrorUtil::checkIntInInterval($green, 0, 255, 'green'); @@ -129,16 +88,9 @@ public static function rgbInternal(int $red, int $green, int $blue, ?float $alph } /** - * @param float $hue - * @param float $saturation - * @param float $lightness - * @param float|null $alpha - * - * @return SassColor - * * @throws \OutOfRangeException if values are outside the expected range. */ - public static function hsl(float $hue, float $saturation, float $lightness, ?float $alpha = null): SassColor + public static function hsl(float $hue, float $saturation, float $lightness, float $alpha = 1.0): SassColor { return self::hslInternal($hue, $saturation, $lightness, $alpha); } @@ -148,44 +100,22 @@ public static function hsl(float $hue, float $saturation, float $lightness, ?flo * * @internal * - * @param float $hue - * @param float $saturation - * @param float $lightness - * @param float|null $alpha - * @param SpanColorFormat|string|null $format - * - * @return SassColor - * * @throws \OutOfRangeException if values are outside the expected range. - * - * @phpstan-param SpanColorFormat|ColorFormat::*|null $format */ - public static function hslInternal(float $hue, float $saturation, float $lightness, ?float $alpha = null, $format = null): SassColor + public static function hslInternal(float $hue, float $saturation, float $lightness, float $alpha = 1.0, ColorFormat $format = null): SassColor { - if ($alpha === null) { - $alpha = 1.0; - } else { - $alpha = NumberUtil::fuzzyAssertRange($alpha, 0, 1, 'alpha'); - } + $alpha = NumberUtil::fuzzyAssertRange($alpha, 0, 1, 'alpha'); - $hue = fmod($hue , 360); + $hue = fmod($hue, 360); $saturation = NumberUtil::fuzzyAssertRange($saturation, 0, 100, 'saturation'); $lightness = NumberUtil::fuzzyAssertRange($lightness, 0, 100, 'lightness'); return new self(null, null, null, $hue, $saturation, $lightness, $alpha, $format); } - /** - * @param float $hue - * @param float $whiteness - * @param float $blackness - * @param float|null $alpha - * - * @return SassColor - */ - public static function hwb(float $hue, float $whiteness, float $blackness, ?float $alpha = null): SassColor + public static function hwb(float $hue, float $whiteness, float $blackness, float $alpha = 1.0): SassColor { - $scaledHue = fmod($hue , 360) / 360; + $scaledHue = fmod($hue, 360) / 360; $scaledWhiteness = NumberUtil::fuzzyAssertRange($whiteness, 0, 100, 'whiteness') / 100; $scaledBlackness = NumberUtil::fuzzyAssertRange($blackness, 0, 100, 'blackness') / 100; @@ -204,7 +134,7 @@ public static function hwb(float $hue, float $whiteness, float $blackness, ?floa return NumberUtil::fuzzyRound($channel * 255); }; - return self::rgb($toRgb($scaledHue + 1/3), $toRgb($scaledHue), $toRgb($scaledHue - 1/3), $alpha); + return self::rgb($toRgb($scaledHue + 1 / 3), $toRgb($scaledHue), $toRgb($scaledHue - 1 / 3), $alpha); } /** @@ -212,19 +142,8 @@ public static function hwb(float $hue, float $whiteness, float $blackness, ?floa * If they are all provided, they are expected to be in sync and this not * revalidated. This constructor does not revalidate ranges either. * Use named factories when this cannot be guaranteed. - * - * @param int|null $red - * @param int|null $green - * @param int|null $blue - * @param float|null $hue - * @param float|null $saturation - * @param float|null $lightness - * @param float $alpha - * @param SpanColorFormat|string|null $format - * - * @phpstan-param SpanColorFormat|ColorFormat::*|null $format */ - private function __construct(?int $red, ?int $green, ?int $blue, ?float $hue, ?float $saturation, ?float $lightness, float $alpha, $format = null) + private function __construct(?int $red, ?int $green, ?int $blue, ?float $hue, ?float $saturation, ?float $lightness, float $alpha, ?ColorFormat $format = null) { $this->red = $red; $this->green = $green; @@ -317,11 +236,8 @@ public function getAlpha(): float * supported format. * * @internal - * - * @return SpanColorFormat|string|null - * @phpstan-return SpanColorFormat|ColorFormat::*|null */ - public function getFormat() + public function getFormat(): ?ColorFormat { return $this->format; } @@ -336,50 +252,21 @@ public function assertColor(?string $name = null): SassColor return $this; } - /** - * @param int|null $red - * @param int|null $green - * @param int|null $blue - * @param float|null $alpha - * - * @return SassColor - */ public function changeRgb(?int $red = null, ?int $green = null, ?int $blue = null, ?float $alpha = null): SassColor { return self::rgb($red ?? $this->getRed(), $green ?? $this->getGreen(), $blue ?? $this->getBlue(), $alpha ?? $this->alpha); } - /** - * @param float|null $hue - * @param float|null $saturation - * @param float|null $lightness - * @param float|null $alpha - * - * @return SassColor - */ public function changeHsl(?float $hue = null, ?float $saturation = null, ?float $lightness = null, ?float $alpha = null): SassColor { return self::hsl($hue ?? $this->getHue(), $saturation ?? $this->getSaturation(), $lightness ?? $this->getLightness(), $alpha ?? $this->alpha); } - /** - * @param float|null $hue - * @param float|null $whiteness - * @param float|null $blackness - * @param float|null $alpha - * - * @return SassColor - */ public function changeHwb(?float $hue = null, ?float $whiteness = null, ?float $blackness = null, ?float $alpha = null): SassColor { return self::hwb($hue ?? $this->getHue(), $whiteness ?? $this->getWhiteness(), $blackness ?? $this->getBlackness(), $alpha ?? $this->alpha); } - /** - * @param float $alpha - * - * @return SassColor - */ public function changeAlpha(float $alpha): SassColor { return new self( @@ -434,9 +321,6 @@ public function equals(object $other): bool return $other instanceof SassColor && $this->getRed() === $other->getRed() && $this->getGreen() === $other->getGreen() && $this->getBlue() === $other->getBlue() && $this->alpha === $other->alpha; } - /** - * @return void - */ private function rgbToHsl(): void { $scaledRed = $this->getRed() / 255; @@ -468,9 +352,6 @@ private function rgbToHsl(): void } } - /** - * @return void - */ private function hslToRgb(): void { $scaledHue = $this->getHue() / 360; diff --git a/modules/sass/scssphp/Value/SassList.php b/modules/sass/scssphp/Value/SassList.php index 9cf033dc..a8cde52b 100644 --- a/modules/sass/scssphp/Value/SassList.php +++ b/modules/sass/scssphp/Value/SassList.php @@ -12,53 +12,33 @@ namespace Tangible\ScssPhp\Value; +use JiriPudil\SealedClasses\Sealed; use Tangible\ScssPhp\Visitor\ValueVisitor; /** * A SassScript list. */ +#[Sealed(permits: [SassArgumentList::class])] class SassList extends Value { /** * @var list - * @readonly */ - private $contents; + private readonly array $contents; - /** - * @var string - * @phpstan-var ListSeparator::* - * @readonly - */ - private $separator; + private readonly ListSeparator $separator; - /** - * @var bool - * @readonly - */ - private $brackets; + private readonly bool $brackets; - /** - * @param string $separator - * @param bool $brackets - * - * @return SassList - * - * @phpstan-param ListSeparator::* $separator - */ - public static function createEmpty(string $separator = ListSeparator::UNDECIDED, bool $brackets = false): SassList + public static function createEmpty(ListSeparator $separator = ListSeparator::UNDECIDED, bool $brackets = false): SassList { return new self(array(), $separator, $brackets); } /** * @param list $contents - * @param string $separator - * @param bool $brackets - * - * @phpstan-param ListSeparator::* $separator */ - public function __construct(array $contents, string $separator, bool $brackets = false) + public function __construct(array $contents, ListSeparator $separator, bool $brackets = false) { if ($separator === ListSeparator::UNDECIDED && count($contents) > 1) { throw new \InvalidArgumentException('A list with more than one element must have an explicit separator.'); @@ -69,7 +49,7 @@ public function __construct(array $contents, string $separator, bool $brackets = $this->brackets = $brackets; } - public function getSeparator(): string + public function getSeparator(): ListSeparator { return $this->separator; } diff --git a/modules/sass/scssphp/Value/SassMap.php b/modules/sass/scssphp/Value/SassMap.php index 2dbe3ecb..7f6a3237 100644 --- a/modules/sass/scssphp/Value/SassMap.php +++ b/modules/sass/scssphp/Value/SassMap.php @@ -22,9 +22,8 @@ final class SassMap extends Value { /** * @var Map - * @readonly */ - private $contents; + private readonly Map $contents; /** * @param Map $contents @@ -41,8 +40,6 @@ public static function createEmpty(): SassMap /** * @param Map $contents - * - * @return SassMap */ public static function create(Map $contents): SassMap { @@ -59,7 +56,7 @@ public function getContents(): Map return $this->contents; } - public function getSeparator(): string + public function getSeparator(): ListSeparator { return \count($this->contents) === 0 ? ListSeparator::UNDECIDED : ListSeparator::COMMA; } diff --git a/modules/sass/scssphp/Value/SassNull.php b/modules/sass/scssphp/Value/SassNull.php index 79ebcda3..c8a3bc1a 100644 --- a/modules/sass/scssphp/Value/SassNull.php +++ b/modules/sass/scssphp/Value/SassNull.php @@ -19,18 +19,11 @@ */ final class SassNull extends Value { - /** - * @var SassNull|null - */ - private static $instance; + private static SassNull $instance; public static function create(): SassNull { - if (self::$instance === null) { - self::$instance = new self(); - } - - return self::$instance; + return self::$instance ??= new self(); } private function __construct() diff --git a/modules/sass/scssphp/Value/SassNumber.php b/modules/sass/scssphp/Value/SassNumber.php index 11c72997..c3b5e411 100644 --- a/modules/sass/scssphp/Value/SassNumber.php +++ b/modules/sass/scssphp/Value/SassNumber.php @@ -12,6 +12,7 @@ namespace Tangible\ScssPhp\Value; +use JiriPudil\SealedClasses\Sealed; use Tangible\ScssPhp\Exception\SassScriptException; use Tangible\ScssPhp\Util\NumberUtil; use Tangible\ScssPhp\Visitor\ValueVisitor; @@ -24,9 +25,10 @@ * `miles/hour`). These are expected to be resolved before being emitted to * CSS. */ +#[Sealed(permits: [UnitlessSassNumber::class, SingleUnitSassNumber::class, ComplexSassNumber::class])] abstract class SassNumber extends Value { - const PRECISION = 10; + final const PRECISION = 10; /** * @see https://www.w3.org/TR/css-values-3/ @@ -63,7 +65,7 @@ abstract class SassNumber extends Value ]; /** - * A map from human-readable names of unit types to the convertable units that + * A map from human-readable names of unit types to the convertible units that * fall into those types. */ private const UNITS_BY_TYPE = [ @@ -98,23 +100,17 @@ abstract class SassNumber extends Value 'dppx' => 'pixel density', ]; - /** - * @var float - * @readonly - */ - private $value; + private readonly float $value; /** * The representation of this number as two slash-separated numbers, if it has one. * * @var array{SassNumber, SassNumber}|null - * @readonly * @internal */ - private $asSlash; + private readonly ?array $asSlash; /** - * @param float $value * @param array{SassNumber, SassNumber}|null $asSlash */ protected function __construct(float $value, array $asSlash = null) @@ -128,11 +124,6 @@ protected function __construct(float $value, array $asSlash = null) * * This matches the numbers that can be written as literals. * {@see SassNumber::withUnits} can be used to construct more complex units. - * - * @param float $value - * @param string|null $unit - * - * @return self */ final public static function create(float $value, ?string $unit = null): SassNumber { @@ -146,11 +137,8 @@ final public static function create(float $value, ?string $unit = null): SassNum /** * Creates a number with full $numeratorUnits and $denominatorUnits. * - * @param float $value * @param list $numeratorUnits * @param list $denominatorUnits - * - * @return self */ final public static function withUnits(float $value, array $numeratorUnits = [], array $denominatorUnits = []): SassNumber { @@ -246,24 +234,15 @@ public function accept(ValueVisitor $visitor) /** * Returns a SassNumber with this value and the same units. - * - * @param float $value - * - * @return self */ abstract protected function withValue(float $value): SassNumber; /** - * @param SassNumber $numerator - * @param SassNumber $denominator - * - * @return SassNumber - * * @internal */ abstract public function withSlash(SassNumber $numerator, SassNumber $denominator): SassNumber; - public function withoutSlash(): Value + public function withoutSlash(): SassNumber { if ($this->asSlash === null) { return $this; @@ -282,7 +261,7 @@ public function assertNumber(?string $name = null): SassNumber */ public function getUnitString(): string { - return $this->hasUnits() ? self::buildUnitString($this->getNumeratorUnits(), $this->getDenominatorUnits()): ''; + return $this->hasUnits() ? self::buildUnitString($this->getNumeratorUnits(), $this->getDenominatorUnits()) : ''; } /** @@ -335,12 +314,6 @@ public function assertInt(?string $name = null): int * came from a function argument, $name is the argument name (without the * `$`). It's used for error reporting. * - * @param float $min - * @param float $max - * @param string|null $name - * - * @return float - * * @throws SassScriptException if the value is outside the range */ public function valueInRange(float $min, float $max, ?string $name = null): float @@ -364,13 +337,6 @@ public function valueInRange(float $min, float $max, ?string $name = null): floa * and should be removed once https://github.com/sass/sass/issues/3374 fully lands and unitless values * are required in these positions. * - * @param float $min - * @param float $max - * @param string $name - * @param string $unit - * - * @return float - * * @throws SassScriptException if the value is outside the range * * @internal @@ -388,17 +354,11 @@ public function valueInRangeWithUnit(float $min, float $max, string $name, strin /** * Returns true if the number has units. - * - * @return boolean */ abstract public function hasUnits(): bool; /** * Returns whether $this has $unit as its only unit (and as a numerator). - * - * @param string $unit - * - * @return bool */ abstract public function hasUnit(string $unit): bool; @@ -433,10 +393,6 @@ abstract public function hasPossiblyCompatibleUnits(SassNumber $other): bool; * Returns whether $this can be coerced to the given unit. * * This always returns `true` for a unitless number. - * - * @param string $unit - * - * @return bool */ abstract public function compatibleWithUnit(string $unit): bool; @@ -483,9 +439,6 @@ public function assertNoUnits(?string $varName = null): void * * @param list $newNumeratorUnits * @param list $newDenominatorUnits - * @param string|null $name The argument name if this is a function argument - * - * @return SassNumber * * @throws SassScriptException if this number's units are not compatible with $newNumeratorUnits and $newDenominatorUnits, or if either number is unitless but the other is not. */ @@ -499,9 +452,6 @@ public function convert(array $newNumeratorUnits, array $newDenominatorUnits, ?s * * @param list $newNumeratorUnits * @param list $newDenominatorUnits - * @param string|null $name The argument name if this is a function argument - * - * @return float * * @throws SassScriptException if this number's units are not compatible with $newNumeratorUnits and $newDenominatorUnits, or if either number is unitless but the other is not. */ @@ -516,12 +466,9 @@ public function convertValue(array $newNumeratorUnits, array $newDenominatorUnit * Note that {@see convertValueToMatch} is generally more efficient if the value * is going to be accessed directly. * - * @param SassNumber $other * @param string|null $name The argument name if this is a function argument * @param string|null $otherName The argument name for $other if this is a function argument * - * @return SassNumber - * * @throws SassScriptException if the units are not compatible or if either number is unitless but the other is not. */ public function convertToMatch(SassNumber $other, ?string $name = null, ?string $otherName = null): SassNumber @@ -532,12 +479,9 @@ public function convertToMatch(SassNumber $other, ?string $name = null, ?string /** * Returns {@see value}, converted to the same units as $other. * - * @param SassNumber $other * @param string|null $name The argument name if this is a function argument * @param string|null $otherName The argument name for $other if this is a function argument * - * @return float - * * @throws SassScriptException if the units are not compatible or if either number is unitless but the other is not. */ public function convertValueToMatch(SassNumber $other, ?string $name = null, ?string $otherName = null): float @@ -558,9 +502,6 @@ public function convertValueToMatch(SassNumber $other, ?string $name = null, ?st * * @param list $newNumeratorUnits * @param list $newDenominatorUnits - * @param string|null $name The argument name if this is a function argument - * - * @return SassNumber * * @throws SassScriptException if this number's units are not compatible with $newNumeratorUnits and $newDenominatorUnits */ @@ -579,9 +520,6 @@ public function coerce(array $newNumeratorUnits, array $newDenominatorUnits, ?st * * @param list $newNumeratorUnits * @param list $newDenominatorUnits - * @param string|null $name The argument name if this is a function argument - * - * @return float * * @throws SassScriptException if this number's units are not compatible with $newNumeratorUnits and $newDenominatorUnits */ @@ -592,11 +530,6 @@ public function coerceValue(array $newNumeratorUnits, array $newDenominatorUnits /** * A shorthand for {@see coerceValue} with a single unit - * - * @param string $unit - * @param string|null $name The argument name if this is a function argument - * - * @return float */ public function coerceValueToUnit(string $unit, ?string $name = null): float { @@ -613,12 +546,9 @@ public function coerceValueToUnit(string $unit, ?string $name = null): float * Note that {@see coerceValueToMatch} is generally more efficient if the value * is going to be accessed directly. * - * @param SassNumber $other * @param string|null $name The argument name if this is a function argument * @param string|null $otherName The argument name for $other if this is a function argument * - * @return SassNumber - * * @throws SassScriptException if the units are not compatible */ public function coerceToMatch(SassNumber $other, ?string $name = null, ?string $otherName = null): SassNumber @@ -633,12 +563,9 @@ public function coerceToMatch(SassNumber $other, ?string $name = null, ?string $ * is unitless and $other is not, or vice versa. Instead, it treats all unitless * numbers as convertible to and from all units without changing the value. * - * @param SassNumber $other * @param string|null $name The argument name if this is a function argument * @param string|null $otherName The argument name for $other if this is a function argument * - * @return float - * * @throws SassScriptException if the units are not compatible */ public function coerceValueToMatch(SassNumber $other, ?string $name = null, ?string $otherName = null): float @@ -652,10 +579,6 @@ public function coerceValueToMatch(SassNumber $other, ?string $name = null, ?str * Two numbers can be compared if they have compatible units, or if either * number has no units. * - * @param SassNumber $other - * - * @return bool - * * @internal */ public function isComparableTo(SassNumber $other): bool @@ -667,7 +590,7 @@ public function isComparableTo(SassNumber $other): bool try { $this->greaterThan($other); return true; - } catch (SassScriptException $e) { + } catch (SassScriptException) { return false; } } @@ -675,7 +598,7 @@ public function isComparableTo(SassNumber $other): bool public function greaterThan(Value $other): SassBoolean { if ($other instanceof SassNumber) { - return SassBoolean::create($this->coerceUnits($other, [NumberUtil::class, 'fuzzyGreaterThan'])); + return SassBoolean::create($this->coerceUnits($other, NumberUtil::fuzzyGreaterThan(...))); } throw new SassScriptException("Undefined operation \"$this > $other\"."); @@ -684,7 +607,7 @@ public function greaterThan(Value $other): SassBoolean public function greaterThanOrEquals(Value $other): SassBoolean { if ($other instanceof SassNumber) { - return SassBoolean::create($this->coerceUnits($other, [NumberUtil::class, 'fuzzyGreaterThanOrEquals'])); + return SassBoolean::create($this->coerceUnits($other, NumberUtil::fuzzyGreaterThanOrEquals(...))); } throw new SassScriptException("Undefined operation \"$this >= $other\"."); @@ -693,7 +616,7 @@ public function greaterThanOrEquals(Value $other): SassBoolean public function lessThan(Value $other): SassBoolean { if ($other instanceof SassNumber) { - return SassBoolean::create($this->coerceUnits($other, [NumberUtil::class, 'fuzzyLessThan'])); + return SassBoolean::create($this->coerceUnits($other, NumberUtil::fuzzyLessThan(...))); } throw new SassScriptException("Undefined operation \"$this < $other\"."); @@ -702,16 +625,16 @@ public function lessThan(Value $other): SassBoolean public function lessThanOrEquals(Value $other): SassBoolean { if ($other instanceof SassNumber) { - return SassBoolean::create($this->coerceUnits($other, [NumberUtil::class, 'fuzzyLessThanOrEquals'])); + return SassBoolean::create($this->coerceUnits($other, NumberUtil::fuzzyLessThanOrEquals(...))); } throw new SassScriptException("Undefined operation \"$this > $other\"."); } - public function modulo(Value $other): Value + public function modulo(Value $other): SassNumber { if ($other instanceof SassNumber) { - return $this->withValue($this->coerceUnits($other, [NumberUtil::class, 'moduloLikeSass'])); + return $this->withValue($this->coerceUnits($other, NumberUtil::moduloLikeSass(...))); } throw new SassScriptException("Undefined operation \"$this % $other\"."); @@ -720,9 +643,7 @@ public function modulo(Value $other): Value public function plus(Value $other): Value { if ($other instanceof SassNumber) { - return $this->withValue($this->coerceUnits($other, function ($num1, $num2) { - return $num1 + $num2; - })); + return $this->withValue($this->coerceUnits($other, fn($num1, $num2) => $num1 + $num2)); } if (!$other instanceof SassColor) { @@ -735,9 +656,7 @@ public function plus(Value $other): Value public function minus(Value $other): Value { if ($other instanceof SassNumber) { - return $this->withValue($this->coerceUnits($other, function ($num1, $num2) { - return $num1 - $num2; - })); + return $this->withValue($this->coerceUnits($other, fn($num1, $num2) => $num1 - $num2)); } if (!$other instanceof SassColor) { @@ -799,7 +718,8 @@ public function equals(object $other): bool return NumberUtil::fuzzyEquals($this->value, $other->value); } - if (self::canonicalizeUnitList($this->getNumeratorUnits()) !== self::canonicalizeUnitList($other->getNumeratorUnits()) || + if ( + self::canonicalizeUnitList($this->getNumeratorUnits()) !== self::canonicalizeUnitList($other->getNumeratorUnits()) || self::canonicalizeUnitList($this->getDenominatorUnits()) !== self::canonicalizeUnitList($other->getDenominatorUnits()) ) { return false; @@ -816,9 +736,7 @@ public function equals(object $other): bool */ private static function getCanonicalMultiplier(array $units): float { - return array_reduce($units, function ($multiplier, $unit) { - return $multiplier * self::getCanonicalMultiplierForUnit($unit); - }, 1.0); + return array_reduce($units, fn($multiplier, $unit) => $multiplier * self::getCanonicalMultiplierForUnit($unit), 1.0); } private static function getCanonicalMultiplierForUnit(string $unit): float @@ -873,7 +791,6 @@ private static function canonicalizeUnitList(array $units): array /** * @template T * - * @param SassNumber $other * @param callable(float, float): T $operation * * @return T @@ -893,14 +810,10 @@ private function coerceUnits(SassNumber $other, callable $operation) } /** - * @param list $newNumeratorUnits - * @param list $newDenominatorUnits - * @param bool $coerceUnitless - * @param string|null $name The argument name if this is a function argument - * @param SassNumber|null $other - * @param string|null $otherName The argument name for $other if this is a function argument - * - * @return float + * @param list $newNumeratorUnits + * @param list $newDenominatorUnits + * @param string|null $name The argument name if this is a function argument + * @param string|null $otherName The argument name for $other if this is a function argument * * @throws SassScriptException if this number's units are not compatible with $newNumeratorUnits and $newDenominatorUnits */ @@ -963,16 +876,10 @@ private function convertOrCoerceValue(array $newNumeratorUnits, array $newDenomi } /** - * @param bool $otherHasUnits - * @param list $newNumeratorUnits - * @param list $newDenominatorUnits - * @param string|null $name - * @param SassNumber|null $other - * @param string|null $otherName - * - * @return SassScriptException + * @param list $newNumeratorUnits + * @param list $newDenominatorUnits */ - private function compatibilityException(bool $otherHasUnits, array $newNumeratorUnits, array $newDenominatorUnits, ?string $name, SassNumber $other = null, ?string $otherName = null): SassScriptException + private function compatibilityException(bool $otherHasUnits, array $newNumeratorUnits, array $newDenominatorUnits, ?string $name, ?SassNumber $other = null, ?string $otherName = null): SassScriptException { if ($other !== null) { $message = "$this and"; @@ -1006,11 +913,8 @@ private function compatibilityException(bool $otherHasUnits, array $newNumerator } /** - * @param float $value * @param list $otherNumerators * @param list $otherDenominators - * - * @return SassNumber */ protected function multiplyUnits(float $value, array $otherNumerators, array $otherDenominators): SassNumber { @@ -1059,11 +963,6 @@ protected function multiplyUnits(float $value, array $otherNumerators, array $ot * Returns the number of [unit1]s per [unit2]. * * Equivalently, `1unit2 * conversionFactor(unit1, unit2) = 1unit1`. - * - * @param string $unit1 - * @param string $unit2 - * - * @return float|null */ protected static function getConversionFactor(string $unit1, string $unit2): ?float { @@ -1085,8 +984,6 @@ protected static function getConversionFactor(string $unit1, string $unit2): ?fl * * @param list $numerators * @param list $denominators - * - * @return string */ private static function buildUnitString(array $numerators, array $denominators): string { @@ -1104,4 +1001,26 @@ private static function buildUnitString(array $numerators, array $denominators): return implode('*', $numerators) . (\count($denominators) ? '/' . implode('*', $denominators) : ''); } + + /** + * Returns a suggested Sass snippet for converting a variable named $name + * (without `%`) containing this number into a number with the same value and + * the given $unit. + * + * If $unit is null, this forces the number to be unitless. + * + * This is used for deprecation warnings when restricting which units are + * allowed for a given function. + * + * @internal + */ + public function unitSuggestion(string $name, ?string $unit = null): string + { + $result = "\$$name" + . implode(array_map(fn($unit) => " * 1$unit", $this->getDenominatorUnits())) + . implode(array_map(fn($unit) => " / 1$unit", $this->getNumeratorUnits())) + . ($unit === null ? '' : " * 1$unit"); + + return $this->getNumeratorUnits() === [] ? $result : "calc($result)"; + } } diff --git a/modules/sass/scssphp/Value/SassString.php b/modules/sass/scssphp/Value/SassString.php index 68ebad2b..50c9c41c 100644 --- a/modules/sass/scssphp/Value/SassString.php +++ b/modules/sass/scssphp/Value/SassString.php @@ -37,19 +37,13 @@ final class SassString extends Value * contain characters that aren't valid in identifiers, such as * `url(http://example.com)`. Unfortunately, it also means that we don't * consider `foo` and `f\6F\6F` the same string. - * - * @var string - * @readonly */ - private $text; + private readonly string $text; /** * Whether this string has quotes. - * - * @var bool - * @readonly */ - private $quotes; + private readonly bool $quotes; public function __construct(string $text, bool $quotes = true) { diff --git a/modules/sass/scssphp/Value/SingleUnitSassNumber.php b/modules/sass/scssphp/Value/SingleUnitSassNumber.php index 81bbb7d4..bac986be 100644 --- a/modules/sass/scssphp/Value/SingleUnitSassNumber.php +++ b/modules/sass/scssphp/Value/SingleUnitSassNumber.php @@ -21,23 +21,59 @@ */ final class SingleUnitSassNumber extends SassNumber { + private const COMPATIBLE_LENGTH_UNITS = ['em', 'rem', 'ex', 'rex', 'cap', 'rcap', 'ch', 'rch', 'ic', 'ric', 'lh', 'rlh', 'vw', 'lvw', 'svw', 'dvw', 'vh', 'lvh', 'svh', 'dvh', 'vi', 'lvi', 'svi', 'dvi', 'vb', 'lvb', 'svb', 'dvb', 'vmin', 'lvmin', 'svmin', 'dvmin', 'vmax', 'lvmax', 'svmax', 'dvmax', 'cqw', 'cqh', 'cqi', 'cqb', 'cqmin', 'cqmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px']; + private const KNOWN_COMPATIBILITIES_BY_UNIT = [ // length - 'em' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'ex' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'ch' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'rem' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'vw' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'vh' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'vmin' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'vmax' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'cm' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'mm' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'q' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'in' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'pc' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'pt' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], - 'px' => ['em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', 'cm', 'mm', 'q', 'in', 'pc', 'pt', 'px'], + 'em' => self::COMPATIBLE_LENGTH_UNITS, + 'rem' => self::COMPATIBLE_LENGTH_UNITS, + 'ex' => self::COMPATIBLE_LENGTH_UNITS, + 'rex' => self::COMPATIBLE_LENGTH_UNITS, + 'cap' => self::COMPATIBLE_LENGTH_UNITS, + 'rcap' => self::COMPATIBLE_LENGTH_UNITS, + 'ch' => self::COMPATIBLE_LENGTH_UNITS, + 'rch' => self::COMPATIBLE_LENGTH_UNITS, + 'ic' => self::COMPATIBLE_LENGTH_UNITS, + 'ric' => self::COMPATIBLE_LENGTH_UNITS, + 'lh' => self::COMPATIBLE_LENGTH_UNITS, + 'rlh' => self::COMPATIBLE_LENGTH_UNITS, + 'vw' => self::COMPATIBLE_LENGTH_UNITS, + 'lvw' => self::COMPATIBLE_LENGTH_UNITS, + 'svw' => self::COMPATIBLE_LENGTH_UNITS, + 'dvw' => self::COMPATIBLE_LENGTH_UNITS, + 'vh' => self::COMPATIBLE_LENGTH_UNITS, + 'lvh' => self::COMPATIBLE_LENGTH_UNITS, + 'svh' => self::COMPATIBLE_LENGTH_UNITS, + 'dvh' => self::COMPATIBLE_LENGTH_UNITS, + 'vi' => self::COMPATIBLE_LENGTH_UNITS, + 'lvi' => self::COMPATIBLE_LENGTH_UNITS, + 'svi' => self::COMPATIBLE_LENGTH_UNITS, + 'dvi' => self::COMPATIBLE_LENGTH_UNITS, + 'vb' => self::COMPATIBLE_LENGTH_UNITS, + 'lvb' => self::COMPATIBLE_LENGTH_UNITS, + 'svb' => self::COMPATIBLE_LENGTH_UNITS, + 'dvb' => self::COMPATIBLE_LENGTH_UNITS, + 'vmin' => self::COMPATIBLE_LENGTH_UNITS, + 'lvmin' => self::COMPATIBLE_LENGTH_UNITS, + 'svmin' => self::COMPATIBLE_LENGTH_UNITS, + 'dvmin' => self::COMPATIBLE_LENGTH_UNITS, + 'vmax' => self::COMPATIBLE_LENGTH_UNITS, + 'lvmax' => self::COMPATIBLE_LENGTH_UNITS, + 'svmax' => self::COMPATIBLE_LENGTH_UNITS, + 'dvmax' => self::COMPATIBLE_LENGTH_UNITS, + 'cqw' => self::COMPATIBLE_LENGTH_UNITS, + 'cqh' => self::COMPATIBLE_LENGTH_UNITS, + 'cqi' => self::COMPATIBLE_LENGTH_UNITS, + 'cqb' => self::COMPATIBLE_LENGTH_UNITS, + 'cqmin' => self::COMPATIBLE_LENGTH_UNITS, + 'cqmax' => self::COMPATIBLE_LENGTH_UNITS, + 'cm' => self::COMPATIBLE_LENGTH_UNITS, + 'mm' => self::COMPATIBLE_LENGTH_UNITS, + 'q' => self::COMPATIBLE_LENGTH_UNITS, + 'in' => self::COMPATIBLE_LENGTH_UNITS, + 'pc' => self::COMPATIBLE_LENGTH_UNITS, + 'pt' => self::COMPATIBLE_LENGTH_UNITS, + 'px' => self::COMPATIBLE_LENGTH_UNITS, // angle 'deg' => ['deg', 'grad', 'rad', 'turn'], 'grad' => ['deg', 'grad', 'rad', 'turn'], @@ -55,15 +91,9 @@ final class SingleUnitSassNumber extends SassNumber 'dppx' => ['dpi', 'dpcm', 'dppx'], ]; - /** - * @var string - * @readonly - */ - private $unit; + private readonly string $unit; /** - * @param float $value - * @param string $unit * @param array{SassNumber, SassNumber}|null $asSlash */ public function __construct(float $value, string $unit, array $asSlash = null) @@ -266,13 +296,11 @@ protected function multiplyUnits(float $value, array $otherNumerators, array $ot break; } - if ($removed) { - $otherDenominators = array_values($otherDenominators); - } else { + if (!$removed) { array_unshift($newNumerators, $this->unit); } - return SassNumber::withUnits($value, $newNumerators, $otherDenominators); + return SassNumber::withUnits($value, $newNumerators, array_values($otherDenominators)); } private function tryCoerceToUnit(string $unit): ?SassNumber diff --git a/modules/sass/scssphp/Value/SpanColorFormat.php b/modules/sass/scssphp/Value/SpanColorFormat.php index 59fed897..548450cb 100644 --- a/modules/sass/scssphp/Value/SpanColorFormat.php +++ b/modules/sass/scssphp/Value/SpanColorFormat.php @@ -17,12 +17,9 @@ /** * @internal */ -final class SpanColorFormat +final class SpanColorFormat implements ColorFormat { - /** - * @var FileSpan - */ - private $span; + private readonly FileSpan $span; public function __construct(FileSpan $span) { diff --git a/modules/sass/scssphp/Value/UnitlessSassNumber.php b/modules/sass/scssphp/Value/UnitlessSassNumber.php index 225d361b..4f48e013 100644 --- a/modules/sass/scssphp/Value/UnitlessSassNumber.php +++ b/modules/sass/scssphp/Value/UnitlessSassNumber.php @@ -22,7 +22,6 @@ final class UnitlessSassNumber extends SassNumber { /** - * @param float $value * @param array{SassNumber, SassNumber}|null $asSlash */ public function __construct(float $value, array $asSlash = null) @@ -156,7 +155,7 @@ public function lessThanOrEquals(Value $other): SassBoolean return parent::lessThanOrEquals($other); } - public function modulo(Value $other): Value + public function modulo(Value $other): SassNumber { if ($other instanceof SassNumber) { return $other->withValue(NumberUtil::moduloLikeSass($this->getValue(), $other->getValue())); diff --git a/modules/sass/scssphp/Value/Value.php b/modules/sass/scssphp/Value/Value.php index 5e0ba050..c36f96d3 100644 --- a/modules/sass/scssphp/Value/Value.php +++ b/modules/sass/scssphp/Value/Value.php @@ -12,15 +12,18 @@ namespace Tangible\ScssPhp\Value; +use JiriPudil\SealedClasses\Sealed; use Tangible\ScssPhp\Ast\Selector\ComplexSelector; use Tangible\ScssPhp\Ast\Selector\CompoundSelector; use Tangible\ScssPhp\Ast\Selector\SelectorList; use Tangible\ScssPhp\Ast\Selector\SimpleSelector; +use Tangible\ScssPhp\Deprecation; use Tangible\ScssPhp\Exception\SassFormatException; use Tangible\ScssPhp\Exception\SassScriptException; use Tangible\ScssPhp\Serializer\Serializer; use Tangible\ScssPhp\Util\Equatable; use Tangible\ScssPhp\Visitor\ValueVisitor; +use Tangible\ScssPhp\Warn; /** * A SassScript value. @@ -30,12 +33,11 @@ * particular types using `assert*()` functions like {@see assertString}, which * throw user-friendly error messages if they fail. */ -abstract class Value implements Equatable +#[Sealed(permits: [SassBoolean::class, SassCalculation::class, SassColor::class, SassFunction::class, SassList::class, SassMap::class, SassNull::class, SassNumber::class, SassString::class])] +abstract class Value implements Equatable, \Stringable { /** * Whether the value counts as `true` in an `@if` statement and other contexts - * - * @return bool */ public function isTruthy(): bool { @@ -47,12 +49,8 @@ public function isTruthy(): bool * * All SassScript values can be used as lists. Maps count as lists of pairs, * and all other values count as single-value lists. - * - * @return string - * - * @phpstan-return ListSeparator::* */ - public function getSeparator(): string + public function getSeparator(): ListSeparator { return ListSeparator::UNDECIDED; } @@ -62,8 +60,6 @@ public function getSeparator(): string * * All SassScript values can be used as lists. Maps count as lists of pairs, * and all other values count as single-value lists. - * - * @return bool */ public function hasBrackets(): bool { @@ -121,7 +117,21 @@ abstract public function accept(ValueVisitor $visitor); */ public function sassIndexToListIndex(Value $sassIndex, ?string $name = null): int { - $index = $sassIndex->assertNumber($name)->assertInt($name); + $indexValue = $sassIndex->assertNumber($name); + + if ($indexValue->hasUnits()) { + $message = <<getUnitString()} is deprecated. + +To preserve current behavior: {$indexValue->unitSuggestion($name ?? 'index')} + +More info: https://sass-lang.com/d/function-units +WARNING; + + Warn::forDeprecation($message, Deprecation::functionUnits); + } + + $index = $indexValue->assertInt($name); if ($index === 0) { throw SassScriptException::forArgument('List index may not be 0.', $name); @@ -145,10 +155,6 @@ public function sassIndexToListIndex(Value $sassIndex, ?string $name = null): in * If this came from a function argument, $name is the argument name * (without the `$`). It's used for error reporting. * - * @param string|null $name - * - * @return SassBoolean - * * @throws SassScriptException */ public function assertBoolean(?string $name = null): SassBoolean @@ -162,10 +168,6 @@ public function assertBoolean(?string $name = null): SassBoolean * If this came from a function argument, $name is the argument name * (without the `$`). It's used for error reporting. * - * @param string|null $name - * - * @return SassCalculation - * * @throws SassScriptException */ public function assertCalculation(?string $name = null): SassCalculation @@ -179,10 +181,6 @@ public function assertCalculation(?string $name = null): SassCalculation * If this came from a function argument, $name is the argument name * (without the `$`). It's used for error reporting. * - * @param string|null $name - * - * @return SassColor - * * @throws SassScriptException */ public function assertColor(?string $name = null): SassColor @@ -196,10 +194,6 @@ public function assertColor(?string $name = null): SassColor * If this came from a function argument, $name is the argument name * (without the `$`). It's used for error reporting. * - * @param string|null $name - * - * @return SassFunction - * * @throws SassScriptException */ public function assertFunction(?string $name = null): SassFunction @@ -213,10 +207,6 @@ public function assertFunction(?string $name = null): SassFunction * If this came from a function argument, $name is the argument name * (without the `$`). It's used for error reporting. * - * @param string|null $name - * - * @return SassMap - * * @throws SassScriptException */ public function assertMap(?string $name = null): SassMap @@ -226,8 +216,6 @@ public function assertMap(?string $name = null): SassMap /** * Return $this as a SassMap if it is one (including empty lists) or null otherwise. - * - * @return SassMap|null */ public function tryMap(): ?SassMap { @@ -240,10 +228,6 @@ public function tryMap(): ?SassMap * If this came from a function argument, $name is the argument name * (without the `$`). It's used for error reporting. * - * @param string|null $name - * - * @return SassNumber - * * @throws SassScriptException */ public function assertNumber(?string $name = null): SassNumber @@ -257,10 +241,6 @@ public function assertNumber(?string $name = null): SassNumber * If this came from a function argument, $name is the argument name * (without the `$`). It's used for error reporting. * - * @param string|null $name - * - * @return SassString - * * @throws SassScriptException */ public function assertString(?string $name = null): SassString @@ -286,7 +266,7 @@ public function assertSelector(?string $name = null, bool $allowParent = false): $string = $this->selectorString($name); try { - return SelectorList::parse($string, null, null, $allowParent); + return SelectorList::parse($string, null, null, null, $allowParent); } catch (SassFormatException $e) { throw SassScriptException::forArgument($e->getMessage(), $name, $e); } @@ -444,8 +424,6 @@ private function selectorStringOrNull(): ?string /** * Whether the value will be represented in CSS as the empty string. * - * @return bool - * * @internal */ public function isBlank(): bool @@ -459,8 +437,6 @@ public function isBlank(): bool * Functions that shadow plain CSS functions need to gracefully handle when * these arguments are passed in. * - * @return bool - * * @internal */ public function isSpecialNumber(): bool @@ -474,8 +450,6 @@ public function isSpecialNumber(): bool * Functions that shadow plain CSS functions need to gracefully handle when * these arguments are passed in. * - * @return bool - * * @internal */ public function isVar(): bool @@ -488,14 +462,8 @@ public function isVar(): bool * separator and brackets. * * @param list $contents - * @param string|null $separator - * @param bool|null $brackets - * - * @return SassList - * - * @phpstan-param ListSeparator::*|null $separator */ - public function withListContents(array $contents, ?string $separator = null, ?bool $brackets = null): SassList + public function withListContents(array $contents, ?ListSeparator $separator = null, ?bool $brackets = null): SassList { return new SassList($contents, $separator ?? $this->getSeparator(), $brackets ?? $this->hasBrackets()); } @@ -503,10 +471,6 @@ public function withListContents(array $contents, ?string $separator = null, ?bo /** * The SassScript = operation * - * @param Value $other - * - * @return Value - * * @internal */ public function singleEquals(Value $other): Value @@ -517,10 +481,6 @@ public function singleEquals(Value $other): Value /** * The SassScript `>` operation. * - * @param Value $other - * - * @return SassBoolean - * * @internal */ public function greaterThan(Value $other): SassBoolean @@ -531,10 +491,6 @@ public function greaterThan(Value $other): SassBoolean /** * The SassScript `>=` operation. * - * @param Value $other - * - * @return SassBoolean - * * @internal */ public function greaterThanOrEquals(Value $other): SassBoolean @@ -545,10 +501,6 @@ public function greaterThanOrEquals(Value $other): SassBoolean /** * The SassScript `<` operation. * - * @param Value $other - * - * @return SassBoolean - * * @internal */ public function lessThan(Value $other): SassBoolean @@ -559,10 +511,6 @@ public function lessThan(Value $other): SassBoolean /** * The SassScript `<=` operation. * - * @param Value $other - * - * @return SassBoolean - * * @internal */ public function lessThanOrEquals(Value $other): SassBoolean @@ -573,10 +521,6 @@ public function lessThanOrEquals(Value $other): SassBoolean /** * The SassScript `*` operation. * - * @param Value $other - * - * @return Value - * * @internal */ public function times(Value $other): Value @@ -587,10 +531,6 @@ public function times(Value $other): Value /** * The SassScript `%` operation. * - * @param Value $other - * - * @return Value - * * @internal */ public function modulo(Value $other): Value @@ -601,10 +541,6 @@ public function modulo(Value $other): Value /** * The SassScript `+` operation. * - * @param Value $other - * - * @return Value - * * @internal */ public function plus(Value $other): Value @@ -623,10 +559,6 @@ public function plus(Value $other): Value /** * The SassScript `-` operation. * - * @param Value $other - * - * @return Value - * * @internal */ public function minus(Value $other): Value @@ -641,10 +573,6 @@ public function minus(Value $other): Value /** * The SassScript `/` operation. * - * @param Value $other - * - * @return Value - * * @internal */ public function dividedBy(Value $other): Value @@ -655,8 +583,6 @@ public function dividedBy(Value $other): Value /** * The SassScript unary `+` operation. * - * @return Value - * * @internal */ public function unaryPlus(): Value @@ -667,8 +593,6 @@ public function unaryPlus(): Value /** * The SassScript unary `-` operation. * - * @return Value - * * @internal */ public function unaryMinus(): Value @@ -679,8 +603,6 @@ public function unaryMinus(): Value /** * The SassScript unary `/` operation. * - * @return Value - * * @internal */ public function unaryDivide(): Value @@ -691,8 +613,6 @@ public function unaryDivide(): Value /** * The SassScript unary `not` operation. * - * @return Value - * * @internal */ public function unaryNot(): Value @@ -705,8 +625,6 @@ public function unaryNot(): Value * * If this isn't a SassNumber, return it as-is. * - * @return Value - * * @internal */ public function withoutSlash(): Value diff --git a/modules/sass/scssphp/Version.php b/modules/sass/scssphp/Version.php index 71717a75..4a5177a1 100644 --- a/modules/sass/scssphp/Version.php +++ b/modules/sass/scssphp/Version.php @@ -19,5 +19,5 @@ */ final class Version { - const VERSION = '1.11.0'; + const VERSION = '1.12.1'; } diff --git a/modules/sass/scssphp/Visitor/AnySelectorVisitor.php b/modules/sass/scssphp/Visitor/AnySelectorVisitor.php index 4cfaccfa..278749f6 100644 --- a/modules/sass/scssphp/Visitor/AnySelectorVisitor.php +++ b/modules/sass/scssphp/Visitor/AnySelectorVisitor.php @@ -15,14 +15,17 @@ use Tangible\ScssPhp\Ast\Selector\AttributeSelector; use Tangible\ScssPhp\Ast\Selector\ClassSelector; use Tangible\ScssPhp\Ast\Selector\ComplexSelector; +use Tangible\ScssPhp\Ast\Selector\ComplexSelectorComponent; use Tangible\ScssPhp\Ast\Selector\CompoundSelector; use Tangible\ScssPhp\Ast\Selector\IDSelector; use Tangible\ScssPhp\Ast\Selector\ParentSelector; use Tangible\ScssPhp\Ast\Selector\PlaceholderSelector; use Tangible\ScssPhp\Ast\Selector\PseudoSelector; use Tangible\ScssPhp\Ast\Selector\SelectorList; +use Tangible\ScssPhp\Ast\Selector\SimpleSelector; use Tangible\ScssPhp\Ast\Selector\TypeSelector; use Tangible\ScssPhp\Ast\Selector\UniversalSelector; +use Tangible\ScssPhp\Util\IterableUtil; /** * A visitor that visits each selector in a Sass selector AST and returns @@ -37,24 +40,12 @@ abstract class AnySelectorVisitor implements SelectorVisitor { public function visitComplexSelector(ComplexSelector $complex): bool { - foreach ($complex->getComponents() as $component) { - if ($this->visitCompoundSelector($component->getSelector())) { - return true; - } - } - - return false; + return IterableUtil::any($complex->getComponents(), fn (ComplexSelectorComponent $component) => $this->visitCompoundSelector($component->getSelector())); } public function visitCompoundSelector(CompoundSelector $compound): bool { - foreach ($compound->getComponents() as $simple) { - if ($simple->accept($this)) { - return true; - } - } - - return false; + return IterableUtil::any($compound->getComponents(), fn (SimpleSelector $simple) => $simple->accept($this)); } public function visitPseudoSelector(PseudoSelector $pseudo): bool @@ -66,13 +57,7 @@ public function visitPseudoSelector(PseudoSelector $pseudo): bool public function visitSelectorList(SelectorList $list): bool { - foreach ($list->getComponents() as $complex) { - if ($this->visitComplexSelector($complex)) { - return true; - } - } - - return false; + return IterableUtil::any($list->getComponents(), $this->visitComplexSelector(...)); } public function visitAttributeSelector(AttributeSelector $attribute): bool diff --git a/modules/sass/scssphp/Visitor/CssVisitor.php b/modules/sass/scssphp/Visitor/CssVisitor.php index 84aeb15e..a72e9863 100644 --- a/modules/sass/scssphp/Visitor/CssVisitor.php +++ b/modules/sass/scssphp/Visitor/CssVisitor.php @@ -33,66 +33,47 @@ interface CssVisitor extends ModifiableCssVisitor { /** - * @param CssAtRule $node - * * @return T */ - public function visitCssAtRule($node); + public function visitCssAtRule(CssAtRule $node); /** - * @param CssComment $node - * * @return T */ - public function visitCssComment($node); + public function visitCssComment(CssComment $node); /** - * @param CssDeclaration $node - * * @return T */ - public function visitCssDeclaration($node); + public function visitCssDeclaration(CssDeclaration $node); /** - * @param CssImport $node - * * @return T */ - public function visitCssImport($node); + public function visitCssImport(CssImport $node); /** - * @param CssKeyframeBlock $node - * * @return T */ - public function visitCssKeyframeBlock($node); + public function visitCssKeyframeBlock(CssKeyframeBlock $node); /** - * @param CssMediaRule $node - * * @return T */ - public function visitCssMediaRule($node); + public function visitCssMediaRule(CssMediaRule $node); /** - * @param CssStyleRule $node - * * @return T */ - public function visitCssStyleRule($node); + public function visitCssStyleRule(CssStyleRule $node); /** - * @param CssStylesheet $node - * * @return T */ - public function visitCssStylesheet($node); + public function visitCssStylesheet(CssStylesheet $node); /** - * @param CssSupportsRule $node - * * @return T */ - public function visitCssSupportsRule($node); - + public function visitCssSupportsRule(CssSupportsRule $node); } diff --git a/modules/sass/scssphp/Visitor/EveryCssVisitor.php b/modules/sass/scssphp/Visitor/EveryCssVisitor.php index 5feb6a1e..23786d6a 100644 --- a/modules/sass/scssphp/Visitor/EveryCssVisitor.php +++ b/modules/sass/scssphp/Visitor/EveryCssVisitor.php @@ -12,8 +12,20 @@ namespace Tangible\ScssPhp\Visitor; +use Tangible\ScssPhp\Ast\Css\CssAtRule; +use Tangible\ScssPhp\Ast\Css\CssComment; +use Tangible\ScssPhp\Ast\Css\CssDeclaration; +use Tangible\ScssPhp\Ast\Css\CssImport; +use Tangible\ScssPhp\Ast\Css\CssKeyframeBlock; +use Tangible\ScssPhp\Ast\Css\CssMediaRule; +use Tangible\ScssPhp\Ast\Css\CssNode; +use Tangible\ScssPhp\Ast\Css\CssStyleRule; +use Tangible\ScssPhp\Ast\Css\CssStylesheet; +use Tangible\ScssPhp\Ast\Css\CssSupportsRule; +use Tangible\ScssPhp\Util\IterableUtil; + /** - * A visitor that visits each statements in a CSS AST and returns `true` if all + * A visitor that visits each statement in a CSS AST and returns `true` if all * of the individual methods return `true`. * * Each method returns `false` by default. @@ -23,84 +35,48 @@ */ abstract class EveryCssVisitor implements CssVisitor { - public function visitCssAtRule($node): bool + public function visitCssAtRule(CssAtRule $node): bool { - foreach ($node->getChildren() as $child) { - if (!$child->accept($this)) { - return false; - } - } - - return true; + return IterableUtil::every($node->getChildren(), fn (CssNode $child) => $child->accept($this)); } - public function visitCssComment($node): bool + public function visitCssComment(CssComment $node): bool { return false; } - public function visitCssDeclaration($node): bool + public function visitCssDeclaration(CssDeclaration $node): bool { return false; } - public function visitCssImport($node): bool + public function visitCssImport(CssImport $node): bool { return false; } - public function visitCssKeyframeBlock($node): bool + public function visitCssKeyframeBlock(CssKeyframeBlock $node): bool { - foreach ($node->getChildren() as $child) { - if (!$child->accept($this)) { - return false; - } - } - - return true; + return IterableUtil::every($node->getChildren(), fn (CssNode $child) => $child->accept($this)); } - public function visitCssMediaRule($node): bool + public function visitCssMediaRule(CssMediaRule $node): bool { - foreach ($node->getChildren() as $child) { - if (!$child->accept($this)) { - return false; - } - } - - return true; + return IterableUtil::every($node->getChildren(), fn (CssNode $child) => $child->accept($this)); } - public function visitCssStyleRule($node): bool + public function visitCssStyleRule(CssStyleRule $node): bool { - foreach ($node->getChildren() as $child) { - if (!$child->accept($this)) { - return false; - } - } - - return true; + return IterableUtil::every($node->getChildren(), fn (CssNode $child) => $child->accept($this)); } - public function visitCssStylesheet($node): bool + public function visitCssStylesheet(CssStylesheet $node): bool { - foreach ($node->getChildren() as $child) { - if (!$child->accept($this)) { - return false; - } - } - - return true; + return IterableUtil::every($node->getChildren(), fn (CssNode $child) => $child->accept($this)); } - public function visitCssSupportsRule($node): bool + public function visitCssSupportsRule(CssSupportsRule $node): bool { - foreach ($node->getChildren() as $child) { - if (!$child->accept($this)) { - return false; - } - } - - return true; + return IterableUtil::every($node->getChildren(), fn (CssNode $child) => $child->accept($this)); } } diff --git a/modules/sass/scssphp/Visitor/ExpressionVisitor.php b/modules/sass/scssphp/Visitor/ExpressionVisitor.php index 5081e1ab..7cfb846e 100644 --- a/modules/sass/scssphp/Visitor/ExpressionVisitor.php +++ b/modules/sass/scssphp/Visitor/ExpressionVisitor.php @@ -14,7 +14,6 @@ use Tangible\ScssPhp\Ast\Sass\Expression\BinaryOperationExpression; use Tangible\ScssPhp\Ast\Sass\Expression\BooleanExpression; -use Tangible\ScssPhp\Ast\Sass\Expression\CalculationExpression; use Tangible\ScssPhp\Ast\Sass\Expression\ColorExpression; use Tangible\ScssPhp\Ast\Sass\Expression\FunctionExpression; use Tangible\ScssPhp\Ast\Sass\Expression\IfExpression; @@ -50,11 +49,6 @@ public function visitBinaryOperationExpression(BinaryOperationExpression $node); */ public function visitBooleanExpression(BooleanExpression $node); - /** - * @return T - */ - public function visitCalculationExpression(CalculationExpression $node); - /** * @return T */ diff --git a/modules/sass/scssphp/Visitor/ModifiableCssVisitor.php b/modules/sass/scssphp/Visitor/ModifiableCssVisitor.php index fc7f6c68..ec5c2177 100644 --- a/modules/sass/scssphp/Visitor/ModifiableCssVisitor.php +++ b/modules/sass/scssphp/Visitor/ModifiableCssVisitor.php @@ -32,65 +32,47 @@ interface ModifiableCssVisitor { /** - * @param ModifiableCssAtRule $node - * * @return T */ - public function visitCssAtRule($node); + public function visitCssAtRule(ModifiableCssAtRule $node); /** - * @param ModifiableCssComment $node - * * @return T */ - public function visitCssComment($node); + public function visitCssComment(ModifiableCssComment $node); /** - * @param ModifiableCssDeclaration $node - * * @return T */ - public function visitCssDeclaration($node); + public function visitCssDeclaration(ModifiableCssDeclaration $node); /** - * @param ModifiableCssImport $node - * * @return T */ - public function visitCssImport($node); + public function visitCssImport(ModifiableCssImport $node); /** - * @param ModifiableCssKeyframeBlock $node - * * @return T */ - public function visitCssKeyframeBlock($node); + public function visitCssKeyframeBlock(ModifiableCssKeyframeBlock $node); /** - * @param ModifiableCssMediaRule $node - * * @return T */ - public function visitCssMediaRule($node); + public function visitCssMediaRule(ModifiableCssMediaRule $node); /** - * @param ModifiableCssStyleRule $node - * * @return T */ - public function visitCssStyleRule($node); + public function visitCssStyleRule(ModifiableCssStyleRule $node); /** - * @param ModifiableCssStylesheet $node - * * @return T */ - public function visitCssStylesheet($node); + public function visitCssStylesheet(ModifiableCssStylesheet $node); /** - * @param ModifiableCssSupportsRule $node - * * @return T */ - public function visitCssSupportsRule($node); + public function visitCssSupportsRule(ModifiableCssSupportsRule $node); } diff --git a/modules/sass/scssphp/Visitor/SelectorSearchVisitor.php b/modules/sass/scssphp/Visitor/SelectorSearchVisitor.php new file mode 100644 index 00000000..bee52763 --- /dev/null +++ b/modules/sass/scssphp/Visitor/SelectorSearchVisitor.php @@ -0,0 +1,92 @@ + + * + * @internal + */ +abstract class SelectorSearchVisitor implements SelectorVisitor +{ + public function visitAttributeSelector(AttributeSelector $attribute) + { + return null; + } + + public function visitClassSelector(ClassSelector $klass) + { + return null; + } + + public function visitIDSelector(IDSelector $id) + { + return null; + } + + public function visitParentSelector(ParentSelector $parent) + { + return null; + } + + public function visitPlaceholderSelector(PlaceholderSelector $placeholder) + { + return null; + } + + public function visitTypeSelector(TypeSelector $type) + { + return null; + } + + public function visitUniversalSelector(UniversalSelector $universal) + { + return null; + } + + public function visitComplexSelector(ComplexSelector $complex) + { + return IterableUtil::search($complex->getComponents(), fn(ComplexSelectorComponent $component) => $this->visitCompoundSelector($component->getSelector())); + } + + public function visitCompoundSelector(CompoundSelector $compound) + { + return IterableUtil::search($compound->getComponents(), fn(SimpleSelector $simple) => $simple->accept($this)); + } + + public function visitPseudoSelector(PseudoSelector $pseudo) + { + if ($pseudo->getSelector() !== null) { + return $this->visitSelectorList($pseudo->getSelector()); + } + + return null; + } + + public function visitSelectorList(SelectorList $list) + { + return IterableUtil::search($list->getComponents(), $this->visitComplexSelector(...)); + } +} diff --git a/modules/sass/scssphp/Visitor/StatementSearchVisitor.php b/modules/sass/scssphp/Visitor/StatementSearchVisitor.php index d2de6a17..83578333 100644 --- a/modules/sass/scssphp/Visitor/StatementSearchVisitor.php +++ b/modules/sass/scssphp/Visitor/StatementSearchVisitor.php @@ -41,6 +41,7 @@ use Tangible\ScssPhp\Ast\Sass\Statement\VariableDeclaration; use Tangible\ScssPhp\Ast\Sass\Statement\WarnRule; use Tangible\ScssPhp\Ast\Sass\Statement\WhileRule; +use Tangible\ScssPhp\Util\IterableUtil; /** * A StatementVisitor whose `visit*` methods default to returning `null`, but @@ -121,16 +122,10 @@ public function visitFunctionRule(FunctionRule $node) public function visitIfRule(IfRule $node) { - $value = $this->searchIterable($node->getClauses(), function (IfClause $clause) { - return $this->searchIterable($clause->getChildren(), function (Statement $child) { - return $child->accept($this); - }); - }); + $value = IterableUtil::search($node->getClauses(), fn(IfClause $clause) => IterableUtil::search($clause->getChildren(), fn(Statement $child) => $child->accept($this))); if ($node->getLastClause() !== null) { - $value = $value ?? $this->searchIterable($node->getLastClause()->getChildren(), function (Statement $child) { - return $child->accept($this); - }); + $value ??= IterableUtil::search($node->getLastClause()->getChildren(), fn(Statement $child) => $child->accept($this)); } return $value; @@ -230,37 +225,6 @@ protected function visitCallableDeclaration(CallableDeclaration $node) */ protected function visitChildren(array $children) { - foreach ($children as $child) { - $result = $child->accept($this); - - if ($result !== null) { - return $result; - } - } - - return null; - } - - /** - * Returns the first `T` returned by $callback for an element of $iterable, - * or `null` if it returns `null` for every element. - * - * @template E - * @param iterable $iterable - * @param callable(E): (T|null) $callback - * - * @return T|null - */ - private function searchIterable(iterable $iterable, callable $callback) - { - foreach ($iterable as $element) { - $value = $callback($element); - - if ($value !== null) { - return $value; - } - } - - return null; + return IterableUtil::search($children, fn (Statement $child) => $child->accept($this)); } } diff --git a/modules/sass/scssphp/Warn.php b/modules/sass/scssphp/Warn.php index a80137a1..6197ed19 100644 --- a/modules/sass/scssphp/Warn.php +++ b/modules/sass/scssphp/Warn.php @@ -16,7 +16,7 @@ final class Warn { /** * @var callable|null - * @phpstan-var (callable(string, bool): void)|null + * @phpstan-var (callable(string, ?Deprecation): void)|null */ private static $callback; @@ -31,7 +31,7 @@ final class Warn */ public static function warning(string $message): void { - self::reportWarning($message, false); + self::reportWarning($message, null); } /** @@ -45,7 +45,12 @@ public static function warning(string $message): void */ public static function deprecation(string $message): void { - self::reportWarning($message, true); + self::reportWarning($message, Deprecation::userAuthored); + } + + public static function forDeprecation(string $message, Deprecation $deprecation): void + { + self::reportWarning($message, $deprecation); } /** @@ -53,9 +58,9 @@ public static function deprecation(string $message): void * * @return callable|null The previous warn callback * - * @phpstan-param (callable(string, bool): void)|null $callback + * @phpstan-param (callable(string, ?Deprecation): void)|null $callback * - * @phpstan-return (callable(string, bool): void)|null + * @phpstan-return (callable(string, ?Deprecation): void)|null * * @internal */ @@ -67,13 +72,7 @@ public static function setCallback(callable $callback = null): ?callable return $previousCallback; } - /** - * @param string $message - * @param bool $deprecation - * - * @return void - */ - private static function reportWarning(string $message, bool $deprecation): void + private static function reportWarning(string $message, ?Deprecation $deprecation): void { if (self::$callback === null) { throw new \BadMethodCallException('The warning Reporter may only be called within a custom function or importer callback.'); diff --git a/tests/modules/sass.php b/tests/modules/sass.php new file mode 100644 index 00000000..2858bbbe --- /dev/null +++ b/tests/modules/sass.php @@ -0,0 +1,84 @@ +assertTrue( isset($html->sass) && is_callable($html->sass) ); + + /** + * Dynamic color property from variable + */ + + $result = $html->sass(<<<'SCSS' + $test: 0.4; + a.latest-post__link:hover { + box-shadow: 0 4px 8px rgba(var(--clr-text), $test); + } + SCSS); + $expected = 'a.latest-post__link:hover{box-shadow:0 4px 8px rgba(var(--clr-text), 0.4)}'; + + $this->assertNull( $error ); + $this->assertEquals( $expected, $result ); + + $error = null; + + /** + * Known issue: The handling of / is not spec compliant + * @see https://github.com/scssphp/scssphp/issues/146 + * + * For CSS properties that use a slash for any purpose other than division, + * SCSS-PHP doesn't yet support the syntax and replaces with divided value. + */ + + $result = $html->sass(<<<'SCSS' + $test: 1; + a { + grid-area: $test / $test / 2 / 2; + } + SCSS); + $expected = 'a{grid-area:1/1/2/2}'; + + // Wrong: 0.5 + // $this->assertEquals( $expected, $result ); + + $result = $html->sass(<<<'SCSS' + @media (min-aspect-ratio: 5/8) { + a { color: green } + } + SCSS); + $expected = '@media (min-aspect-ratio:5/8){a{color:green}}'; + + // Wrong: 0.625 + // $this->assertEquals( $expected, $result ); + + // Workaround is to use unquote('..') + + $result = $html->sass(<<<'SCSS' + a { + grid-area: unquote('1 / 1 / 2 / 2'); + } + SCSS); + $expected = 'a{grid-area:1 / 1 / 2 / 2}'; + + $this->assertEquals( $expected, $result ); + + $result = $html->sass(<<<'SCSS' + @media (min-aspect-ratio: unquote('5/8')) { + a { color: green } + } + SCSS); + $expected = '@media (min-aspect-ratio:5/8){a{color:green}}'; + + $this->assertEquals( $expected, $result ); + + } +}