%PDF- %PDF-
| Direktori : /home/q/g/b/qgbqkvz/www/wp-content/plugins/wp-scss/scssphp/src/Parser/ |
| Current File : /home/q/g/b/qgbqkvz/www/wp-content/plugins/wp-scss/scssphp/src/Parser/StylesheetParser.php |
<?php
/**
* SCSSPHP
*
* @copyright 2012-2020 Leaf Corcoran
*
* @license http://opensource.org/licenses/MIT MIT
*
* @link http://scssphp.github.io/scssphp
*/
namespace ScssPhp\ScssPhp\Parser;
use League\Uri\Exceptions\SyntaxError;
use League\Uri\Uri;
use ScssPhp\ScssPhp\Ast\Sass\Argument;
use ScssPhp\ScssPhp\Ast\Sass\ArgumentDeclaration;
use ScssPhp\ScssPhp\Ast\Sass\ArgumentInvocation;
use ScssPhp\ScssPhp\Ast\Sass\Expression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\BinaryOperationExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\BinaryOperator;
use ScssPhp\ScssPhp\Ast\Sass\Expression\BooleanExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\CalculationExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\ColorExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\FunctionExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\IfExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\InterpolatedFunctionExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\ListExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\MapExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\NullExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\NumberExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\ParenthesizedExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\SelectorExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\StringExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\SupportsExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\UnaryOperationExpression;
use ScssPhp\ScssPhp\Ast\Sass\Expression\UnaryOperator;
use ScssPhp\ScssPhp\Ast\Sass\Expression\VariableExpression;
use ScssPhp\ScssPhp\Ast\Sass\Import;
use ScssPhp\ScssPhp\Ast\Sass\Import\DynamicImport;
use ScssPhp\ScssPhp\Ast\Sass\Import\StaticImport;
use ScssPhp\ScssPhp\Ast\Sass\Interpolation;
use ScssPhp\ScssPhp\Ast\Sass\Statement;
use ScssPhp\ScssPhp\Ast\Sass\Statement\AtRootRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\AtRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\ContentBlock;
use ScssPhp\ScssPhp\Ast\Sass\Statement\ContentRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\DebugRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\Declaration;
use ScssPhp\ScssPhp\Ast\Sass\Statement\EachRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\ElseClause;
use ScssPhp\ScssPhp\Ast\Sass\Statement\ErrorRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\ExtendRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\ForRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\FunctionRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\IfClause;
use ScssPhp\ScssPhp\Ast\Sass\Statement\IfRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\ImportRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\IncludeRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\MediaRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\MixinRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\ReturnRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\SilentComment;
use ScssPhp\ScssPhp\Ast\Sass\Statement\StyleRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\Stylesheet;
use ScssPhp\ScssPhp\Ast\Sass\Statement\SupportsRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\VariableDeclaration;
use ScssPhp\ScssPhp\Ast\Sass\Statement\WarnRule;
use ScssPhp\ScssPhp\Ast\Sass\Statement\WhileRule;
use ScssPhp\ScssPhp\Ast\Sass\SupportsCondition;
use ScssPhp\ScssPhp\Ast\Sass\SupportsCondition\SupportsAnything;
use ScssPhp\ScssPhp\Ast\Sass\SupportsCondition\SupportsDeclaration;
use ScssPhp\ScssPhp\Ast\Sass\SupportsCondition\SupportsFunction;
use ScssPhp\ScssPhp\Ast\Sass\SupportsCondition\SupportsInterpolation;
use ScssPhp\ScssPhp\Ast\Sass\SupportsCondition\SupportsNegation;
use ScssPhp\ScssPhp\Ast\Sass\SupportsCondition\SupportsOperation;
use ScssPhp\ScssPhp\Colors;
use ScssPhp\ScssPhp\Exception\SassFormatException;
use ScssPhp\ScssPhp\Logger\LoggerInterface;
use ScssPhp\ScssPhp\SourceSpan\FileSpan;
use ScssPhp\ScssPhp\Util;
use ScssPhp\ScssPhp\Util\Character;
use ScssPhp\ScssPhp\Util\Path;
use ScssPhp\ScssPhp\Util\StringUtil;
use ScssPhp\ScssPhp\Value\ListSeparator;
use ScssPhp\ScssPhp\Value\SassColor;
use ScssPhp\ScssPhp\Value\SpanColorFormat;
/**
* @internal
*/
abstract class StylesheetParser extends Parser
{
/**
* The silent comment this parser encountered previously.
*
* @var SilentComment|null
*/
protected $lastSilentComment;
/**
* Whether we've consumed a rule other than `@charset`, `@forward`, or `@use`.
*
* @var bool
*/
private $isUseAllowed = true;
/**
* Whether the parser is currently parsing the contents of a mixin declaration.
*
* @var bool
*/
private $inMixin = false;
/**
* Whether the parser is currently parsing a content block passed to a mixin.
*
* @var bool
*/
private $inContentBlock = false;
/**
* Whether the parser is currently parsing a control directive such as `@if`
* or `@each`.
*
* @var bool
*/
private $inControlDirective = false;
/**
* Whether the parser is currently parsing an unknown rule.
*
* @var bool
*/
private $inUnknownAtRule = false;
/**
* Whether the parser is currently parsing a style rule.
*
* @var bool
*/
private $inStyleRule = false;
/**
* Whether the parser is currently within a parenthesized expression.
*
* @var bool
*/
private $inParentheses = false;
/**
* A map from all variable names that are assigned with `!global` in the
* current stylesheet to the nodes where they're defined.
*
* These are collected at parse time because they affect the variables
* exposed by the module generated for this stylesheet, *even if they aren't
* evaluated*. This allows us to ensure that the stylesheet always exposes
* the same set of variable names no matter how it's evaluated.
*
* @var array<string, VariableDeclaration>
*/
private $globalVariables = [];
/**
* @var \Closure
* @readonly
*/
private $statementCallable;
/**
* @var \Closure
* @readonly
*/
private $declarationChildCallable;
/**
* @var \Closure
* @readonly
*/
private $functionChildCallable;
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']);
}
/**
* @throws SassFormatException when parsing fails
*/
public function parse(): Stylesheet
{
try {
$start = $this->scanner->getPosition();
// Allow a byte-order mark at the beginning of the document.
$this->scanner->scan("\u{FEFF}");
$statements = $this->statements(function () {
// Handle this specially so that {@see atRule} always returns a non-nullable Statement.
if ($this->scanner->scan('@charset')) {
$this->whitespace();
$this->string();
return null;
}
return $this->statement(true);
});
$this->scanner->expectDone();
// Ensure that all global variable assignments produce a variable in this
// stylesheet, even if they aren't evaluated. See sass/language#50.
foreach ($this->globalVariables as $declaration) {
$statements[] = new VariableDeclaration($declaration->getName(), new NullExpression($declaration->getExpression()->getSpan()), $declaration->getSpan(), null, true);
}
return new Stylesheet($statements, $this->scanner->spanFrom($start), $this->isPlainCss());
} catch (FormatException $e) {
throw $this->wrapException($e);
}
}
public function parseArgumentDeclaration(): ArgumentDeclaration
{
try {
$this->scanner->expectChar('@', '@-rule');
$this->identifier();
$this->whitespace();
$this->identifier();
$arguments = $this->argumentDeclaration();
$this->whitespace();
$this->scanner->expectChar('{');
$this->scanner->expectDone();
return $arguments;
} catch (FormatException $e) {
throw $this->wrapException($e);
}
}
/**
* Consumes a statement that's allowed at the top level of the stylesheet or
* within nested style and at rules.
*
* If $root is `true`, this parses at-rules that are allowed only at the
* root of the stylesheet.
*/
private function statement(bool $root = false): Statement
{
switch ($this->scanner->peekChar()) {
case '@':
return $this->atRule($this->statementCallable, $root);
case '+':
if (!$this->isIndented()) {
return $this->styleRule();
}
throw new \BadMethodCallException('The parsing of the indented syntax is not implemented.');
case '=':
if (!$this->isIndented()) {
return $this->styleRule();
}
throw new \BadMethodCallException('The parsing of the indented syntax is not implemented.');
case '}':
$this->scanner->error('unmatched "}".');
default:
if ($this->inStyleRule || $this->inUnknownAtRule || $this->inMixin || $this->inContentBlock) {
return $this->declarationOrStyleRule();
}
return $this->variableDeclarationOrStyleRule();
}
}
/**
* Consumes a namespaced variable declaration.
*
* @throws FormatException
*/
private function variableDeclarationWithNamespace(): VariableDeclaration
{
$start = $this->scanner->getPosition();
$namespace = $this->identifier();
$this->scanner->expectChar('.');
return $this->variableDeclarationWithoutNamespace($namespace, $start);
}
/**
* Consumes a variable declaration.
*/
protected function variableDeclarationWithoutNamespace(?string $namespace = null, ?int $start = null): VariableDeclaration
{
$precedingComment = $this->lastSilentComment;
$this->lastSilentComment = null;
$start = $start ?? $this->scanner->getPosition();
$name = $this->variableName();
if ($namespace !== null) {
$this->assertPublic($name, function () use ($start) {
return $this->scanner->spanFrom($start);
});
}
$this->whitespace();
$this->scanner->expectChar(':');
$this->whitespace();
$value = $this->expression();
$guarded = false;
$global = false;
$flagStart = $this->scanner->getPosition();
while ($this->scanner->scanChar('!')) {
$flag = $this->identifier();
if ($flag === 'default') {
$guarded = true;
} elseif ($flag === 'global') {
if ($namespace !== null) {
$this->error("!global isn't allowed for variables in other modules.", $this->scanner->spanFrom($flagStart));
}
$global = true;
} else {
$this->error('Invalid flag name.', $this->scanner->spanFrom($flagStart));
}
$this->whitespace();
$flagStart = $this->scanner->getPosition();
}
$this->expectStatementSeparator('variable declaration');
// TODO remove this when implementing modules
if ($namespace !== null) {
$this->error('Sass modules are not implemented yet.', $this->scanner->spanFrom($start));
}
$declaration = new VariableDeclaration($name, $value, $this->scanner->spanFrom($start), $namespace, $guarded, $global, $precedingComment);
if ($global && !isset($this->globalVariables[$name])) {
$this->globalVariables[$name] = $declaration;
}
return $declaration;
}
private function variableDeclarationOrStyleRule(): Statement
{
if ($this->isPlainCss()) {
return $this->styleRule();
}
if (!$this->lookingAtIdentifier()) {
return $this->styleRule();
}
$start = $this->scanner->getPosition();
$variableOrInterpolation = $this->variableDeclarationOrInterpolation();
if ($variableOrInterpolation instanceof VariableDeclaration) {
return $variableOrInterpolation;
}
$buffer = new InterpolationBuffer();
$buffer->addInterpolation($variableOrInterpolation);
return $this->styleRule($buffer, $start);
}
/**
* Consumes a {@see VariableDeclaration}, a {@see Declaration}, or a {@see StyleRule}.
*
* @throws FormatException
*/
private function declarationOrStyleRule(): Statement
{
if ($this->isPlainCss() && $this->inStyleRule && !$this->inUnknownAtRule) {
return $this->propertyOrVariableDeclaration();
}
$start = $this->scanner->getPosition();
$declarationBuffer = $this->declarationOrBuffer();
if ($declarationBuffer instanceof Statement) {
return $declarationBuffer;
}
return $this->styleRule($declarationBuffer, $start);
}
/**
* Tries to parse a variable or property declaration, and returns the value
* parsed so far if it fails.
*
* This can return either an {@see InterpolationBuffer}, indicating that it
* 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()
{
$start = $this->scanner->getPosition();
$nameBuffer = new InterpolationBuffer();
$first = $this->scanner->peekChar();
$startsWithPunctuation = false;
// Allow the "*prop: val", ":prop: val", "#prop: val", and ".prop: val"
// hacks.
if ($first === ':' || $first === '*' || $first === '.' || ($first === '#' && $this->scanner->peekChar(1) !== '{')) {
$startsWithPunctuation = true;
$nameBuffer->write($this->scanner->readChar());
$nameBuffer->write($this->rawText([$this, 'whitespace']));
}
if (!$this->lookingAtInterpolatedIdentifier()) {
return $nameBuffer;
}
$variableOrInterpolation = $startsWithPunctuation ? $this->interpolatedIdentifier() : $this->variableDeclarationOrInterpolation();
if ($variableOrInterpolation instanceof VariableDeclaration) {
return $variableOrInterpolation;
}
$nameBuffer->addInterpolation($variableOrInterpolation);
$this->isUseAllowed = false;
if ($this->scanner->matches('/*')) {
$nameBuffer->write($this->rawText([$this, 'loudComment']));
}
$midBuffer = $this->rawText([$this, 'whitespace']);
$beforeColon = $this->scanner->getPosition();
if (!$this->scanner->scanChar(':')) {
if ($midBuffer !== '') {
$nameBuffer->write(' ');
}
return $nameBuffer;
}
$midBuffer .= ':';
// Parse custom properties as declarations no matter what.
$name = $nameBuffer->buildInterpolation($this->scanner->spanFrom($start, $beforeColon));
if (0 === strpos($name->getInitialPlain(), '--')) {
$value = new StringExpression($this->interpolatedDeclarationValue());
$this->expectStatementSeparator('custom property');
return Declaration::create($name, $value, $this->scanner->spanFrom($start));
}
if ($this->scanner->scanChar(':')) {
$nameBuffer->write($midBuffer);
$nameBuffer->write(':');
return $nameBuffer;
}
if ($this->isIndented() && $this->lookingAtInterpolatedIdentifier()) {
$nameBuffer->write($midBuffer);
return $nameBuffer;
}
$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);
});
}
$midBuffer .= $postColonWhitespace;
$couldBeSelector = $postColonWhitespace === '' && $this->lookingAtInterpolatedIdentifier();
$beforeDeclaration = $this->scanner->getPosition();
try {
$value = $this->expression();
if ($this->lookingAtChildren()) {
// Properties that are ambiguous with selectors can't have additional
// properties nested beneath them, so we force an error. This will be
// caught below and cause the text to be reparsed as a selector.
if ($couldBeSelector) {
$this->expectStatementSeparator();
}
} elseif (!$this->atEndOfStatement()) {
// Force an exception if there isn't a valid end-of-property character
// but don't consume that character. This will also cause the text to be
// reparsed.
$this->expectStatementSeparator();
}
} catch (FormatException $e) {
if (!$couldBeSelector) {
throw $e;
}
// If the value would be followed by a semicolon, it's definitely supposed
// to be a property, not a selector.
$this->scanner->setPosition($beforeDeclaration);
$additional = $this->almostAnyValue();
if (!$this->isIndented() && $this->scanner->peekChar() === ';') {
throw $e;
}
$nameBuffer->write($midBuffer);
$nameBuffer->addInterpolation($additional);
return $nameBuffer;
}
if ($this->lookingAtChildren()) {
return $this->withChildren($this->declarationChildCallable, $start, function (array $children, FileSpan $span) use ($name, $value) {
return Declaration::nested($name, $children, $span, $value);
});
}
$this->expectStatementSeparator();
return Declaration::create($name, $value, $this->scanner->spanFrom($start));
}
/**
* Tries to parse a namespaced {@see VariableDeclaration}, and returns the value
* parsed so far if it fails.
*
* This can return either an {@see Interpolation}, indicating that it couldn't
* 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()
{
if (!$this->lookingAtIdentifier()) {
return $this->interpolatedIdentifier();
}
$start = $this->scanner->getPosition();
$identifier = $this->identifier();
if ($this->scanner->matches('.$')) {
$this->scanner->readChar();
return $this->variableDeclarationWithoutNamespace($identifier, $start);
}
$buffer = new InterpolationBuffer();
$buffer->write($identifier);
// Parse the rest of an interpolated identifier if one exists, so callers
// don't have to.
if ($this->lookingAtInterpolatedIdentifierBody()) {
$buffer->addInterpolation($this->interpolatedIdentifier());
}
return $buffer->buildInterpolation($this->scanner->spanFrom($start));
}
/**
* Consumes a StyleRule
*/
private function styleRule(?InterpolationBuffer $buffer = null, ?int $start = null): StyleRule
{
$start = $start ?? $this->scanner->getPosition();
$interpolation = $this->styleRuleSelector();
if ($buffer !== null) {
$buffer->addInterpolation($interpolation);
$interpolation = $buffer->buildInterpolation($this->scanner->spanFrom($start));
}
if (!$interpolation->getContents()) {
$this->scanner->error('expected "}".');
}
$wasInStyleRule = $this->inStyleRule;
$this->inStyleRule = true;
return $this->withChildren($this->statementCallable, $start, function (array $children) use ($wasInStyleRule, $start, $interpolation) {
$this->inStyleRule = $wasInStyleRule;
return new StyleRule($interpolation, $children, $this->scanner->spanFrom($start));
});
}
/**
* Consumes either a property declaration or a namespaced variable declaration.
*
* This is only used in contexts where declarations are allowed but style
* rules are not, such as nested declarations. Otherwise,
* {@see declarationOrStyleRule} is used instead.
*
* If $parseCustomProperties is `true`, properties that begin with `--` will
* be parsed using custom property parsing rules.
*/
private function propertyOrVariableDeclaration(bool $parseCustomProperties = true): Statement
{
$start = $this->scanner->getPosition();
// Allow the "*prop: val", ":prop: val", "#prop: val", and ".prop: val"
// hacks.
$first = $this->scanner->peekChar();
if ($first === ':' || $first === '*' || $first === '.' || ($first === '#' && $this->scanner->peekChar(1) !== '{')) {
$nameBuffer = new InterpolationBuffer();
$nameBuffer->write($this->scanner->readChar());
$nameBuffer->write($this->rawText([$this, 'whitespace']));
$nameBuffer->addInterpolation($this->interpolatedIdentifier());
$name = $nameBuffer->buildInterpolation($this->scanner->spanFrom($start));
} elseif (!$this->isPlainCss()) {
$variableOrInterpolation = $this->variableDeclarationOrInterpolation();
if ($variableOrInterpolation instanceof VariableDeclaration) {
return $variableOrInterpolation;
}
$name = $variableOrInterpolation;
} else {
$name = $this->interpolatedIdentifier();
}
$this->whitespace();
$this->scanner->expectChar(':');
if ($parseCustomProperties && 0 === strpos($name->getInitialPlain(), '--')) {
$value = new StringExpression($this->interpolatedDeclarationValue());
$this->expectStatementSeparator('custom property');
return Declaration::create($name, $value, $this->scanner->spanFrom($start));
}
$this->whitespace();
if ($this->lookingAtChildren()) {
if ($this->isPlainCss()) {
$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);
});
}
$value = $this->expression();
if ($this->lookingAtChildren()) {
if ($this->isPlainCss()) {
$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);
});
}
$this->expectStatementSeparator();
return Declaration::create($name, $value, $this->scanner->spanFrom($start));
}
/**
* Consumes a statement that's allowed within a declaration.
*/
private function declarationChild(): Statement
{
if ($this->scanner->peekChar() === '@') {
return $this->declarationAtRule();
}
return $this->propertyOrVariableDeclaration(false);
}
/**
* Consumes an at-rule.
*
* This consumes at-rules that are allowed at all levels of the document; the
* $child parameter is called to consume any at-rules that are specifically
* allowed in the caller's context.
*
* If $root is `true`, this parses at-rules that are allowed only at the
* root of the stylesheet.
*
* @param callable(): Statement $child
*/
protected function atRule(callable $child, bool $root = false): Statement
{
$start = $this->scanner->getPosition();
$this->scanner->expectChar('@', '@-rule');
$name = $this->interpolatedIdentifier();
$this->whitespace();
$wasUseAllowed = $this->isUseAllowed;
$this->isUseAllowed = false;
switch ($name->getAsPlain()) {
case 'at-root':
return $this->atRootRule($start);
case 'content':
return $this->contentRule($start);
case 'debug':
return $this->debugRule($start);
case 'each':
return $this->eachRule($start, $child);
case 'else':
$this->disallowedAtRule($start);
case 'error':
return $this->errorRule($start);
case 'extend':
return $this->extendRule($start);
case 'for':
return $this->forRule($start, $child);
case 'forward':
$this->isUseAllowed = $wasUseAllowed;
if (!$root) {
$this->disallowedAtRule($start);
}
// TODO remove this when implementing modules
$this->error('Sass modules are not implemented yet.', $this->scanner->spanFrom($start));
case 'function':
return $this->functionRule($start);
case 'if':
return $this->ifRule($start, $child);
case 'import':
return $this->importRule($start);
case 'include':
return $this->includeRule($start);
case 'media':
return $this->mediaRule($start);
case 'mixin':
return $this->mixinRule($start);
case '-moz-document':
return $this->mozDocumentRule($start, $name);
case 'return':
$this->disallowedAtRule($start);
case 'supports':
return $this->supportsRule($start);
case 'use':
$this->isUseAllowed = $wasUseAllowed;
if (!$root) {
$this->disallowedAtRule($start);
}
// TODO remove this when implementing modules
$this->error('Sass modules are not implemented yet.', $this->scanner->spanFrom($start));
case 'warn':
return $this->warnRule($start);
case 'while':
return $this->whileRule($start, $child);
default:
return $this->unknownAtRule($start, $name);
}
}
/**
* Consumes an at-rule allowed within a property declaration.
*/
private function declarationAtRule(): Statement
{
$start = $this->scanner->getPosition();
$name = $this->plainAtRuleName();
switch ($name) {
case 'content':
return $this->contentRule($start);
case 'debug':
return $this->debugRule($start);
case 'each':
return $this->eachRule($start, $this->declarationChildCallable);
case 'else':
$this->disallowedAtRule($start);
case 'error':
return $this->errorRule($start);
case 'for':
return $this->forRule($start, $this->declarationChildCallable);
case 'if':
return $this->ifRule($start, $this->declarationChildCallable);
case 'include':
return $this->includeRule($start);
case 'warn':
return $this->warnRule($start);
case 'while':
return $this->whileRule($start, $this->declarationChildCallable);
default:
$this->disallowedAtRule($start);
}
}
/**
* Consumes a statement allowed within a function.
*/
private function functionChild(): Statement
{
if ($this->scanner->peekChar() !== '@') {
$start = $this->scanner->getPosition();
try {
return $this->variableDeclarationWithNamespace();
} catch (FormatException $variableDeclarationError) {
// TODO remove this when implementing modules
if ($variableDeclarationError->getMessage() === 'Sass modules are not implemented yet.') {
throw $variableDeclarationError;
}
$this->scanner->setPosition($start);
// If a variable declaration failed to parse, it's possible the user
// thought they could write a style rule or property declaration in a
// function. If so, throw a more helpful error message.
try {
$statement = $this->declarationOrStyleRule();
} catch (FormatException $e) {
throw $variableDeclarationError;
}
$this->error('@function rules may not contain ' . ($statement instanceof StyleRule ? 'style rules.' : 'declarations.'), $statement->getSpan());
}
}
$start = $this->scanner->getPosition();
switch ($this->plainAtRuleName()) {
case 'debug':
return $this->debugRule($start);
case 'each':
return $this->eachRule($start, $this->functionChildCallable);
case 'else':
$this->disallowedAtRule($start);
case 'error':
return $this->errorRule($start);
case 'for':
return $this->forRule($start, $this->functionChildCallable);
case 'if':
return $this->ifRule($start, $this->functionChildCallable);
case 'return':
return $this->returnRule($start);
case 'warn':
return $this->warnRule($start);
case 'while':
return $this->whileRule($start, $this->functionChildCallable);
default:
$this->disallowedAtRule($start);
}
}
/**
* Consumes an at-rule's name, with interpolation disallowed.
*/
private function plainAtRuleName(): string
{
$this->scanner->expectChar('@', '@-rule');
$name = $this->identifier();
$this->whitespace();
return $name;
}
/**
* Consumes an `@at-root` rule.
*
* $start should point before the `@`.
*/
private function atRootRule(int $start): AtRootRule
{
if ($this->scanner->peekChar() === '(') {
$query = $this->atRootQuery();
$this->whitespace();
return $this->withChildren($this->statementCallable, $start, function (array $children, FileSpan $span) use ($query) {
return new AtRootRule($children, $span, $query);
});
}
if ($this->lookingAtChildren()) {
return $this->withChildren($this->statementCallable, $start, function (array $children, FileSpan $span) {
return new AtRootRule($children, $span);
});
}
$child = $this->styleRule();
return new AtRootRule([$child], $this->scanner->spanFrom($start));
}
/**
* Consumes a query expression of the form `(foo: bar)`.
*/
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('(');
$buffer->write('(');
$this->whitespace();
$buffer->add($this->expression());
if ($this->scanner->scanChar(':')) {
$this->whitespace();
$buffer->write(': ');
$buffer->add($this->expression());
}
$this->scanner->expectChar(')');
$this->whitespace();
$buffer->write(')');
return $buffer->buildInterpolation($this->scanner->spanFrom($start));
}
/**
* Consumes a `@content` rule.
*
* $start should point before the `@`.
*/
private function contentRule(int $start): ContentRule
{
if (!$this->inMixin) {
$this->error('@content is only allowed within mixin declarations.', $this->scanner->spanFrom($start));
}
$this->whitespace();
$arguments = $this->scanner->peekChar() === '(' ? $this->argumentInvocation(true) : ArgumentInvocation::createEmpty($this->scanner->getEmptySpan());
$this->expectStatementSeparator('@content rule');
return new ContentRule($arguments, $this->scanner->spanFrom($start));
}
/**
* Consumes a `@debug` rule.
*
* $start should point before the `@`.
*/
private function debugRule(int $start): DebugRule
{
$value = $this->expression();
$this->expectStatementSeparator('@debug rule');
return new DebugRule($value, $this->scanner->spanFrom($start));
}
/**
* Consumes a `@each` rule.
*
* $start should point before the `@`. $child is called to consume any
* children that are specifically allowed in the caller's context.
*
* @param callable(): Statement $child
*/
private function eachRule(int $start, callable $child): EachRule
{
$wasInControlDirective = $this->inControlDirective;
$this->inControlDirective = true;
$variables = [$this->variableName()];
$this->whitespace();
while ($this->scanner->scanChar(',')) {
$this->whitespace();
$variables[] = $this->variableName();
$this->whitespace();
}
$this->expectIdentifier('in');
$this->whitespace();
$list = $this->expression();
return $this->withChildren($child, $start, function (array $children, FileSpan $span) use ($variables, $wasInControlDirective, $list) {
$this->inControlDirective = $wasInControlDirective;
return new EachRule($variables, $list, $children, $span);
});
}
/**
* Consumes a `@error` rule.
*
* $start should point before the `@`.
*/
private function errorRule(int $start): ErrorRule
{
$value = $this->expression();
$this->expectStatementSeparator('@error rule');
return new ErrorRule($value, $this->scanner->spanFrom($start));
}
/**
* Consumes a `@extend` rule.
*
* $start should point before the `@`.
*/
private function extendRule(int $start): ExtendRule
{
if (!$this->inStyleRule && !$this->inMixin && !$this->inContentBlock) {
$this->error('@extend may only be used within style rules.', $this->scanner->spanFrom($start));
}
$value = $this->almostAnyValue();
$optional = $this->scanner->scanChar('!');
if ($optional) {
$this->expectIdentifier('optional');
}
$this->expectStatementSeparator('@extend rule');
return new ExtendRule($value, $this->scanner->spanFrom($start), $optional);
}
/**
* Consumes a function declaration.
*
* $start should point before the `@`.
*/
private function functionRule(int $start): FunctionRule
{
$precedingComment = $this->lastSilentComment;
$this->lastSilentComment = null;
$name = $this->identifier(true);
$this->whitespace();
$arguments = $this->argumentDeclaration();
if ($this->inMixin || $this->inContentBlock) {
$this->error('Mixins may not contain function declarations.', $this->scanner->spanFrom($start));
}
if ($this->inControlDirective) {
$this->error('Functions may not be declared in control directives.', $this->scanner->spanFrom($start));
}
switch (Util::unvendor($name)) {
case 'calc':
case 'element':
case 'expression':
case 'url':
case 'and':
case 'or':
case 'not':
case 'clamp':
$this->error('Invalid function name.', $this->scanner->spanFrom($start));
}
$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);
});
}
/**
* Consumes a `@for` rule.
*
* $start should point before the `@`. $child is called to consume any
* children that are specifically allowed in the caller's context.
*
* @param callable(): Statement $child
*/
private function forRule(int $start, callable $child): ForRule
{
$wasInControlDirective = $this->inControlDirective;
$this->inControlDirective = true;
$variable = $this->variableName();
$this->whitespace();
$this->expectIdentifier('from');
$this->whitespace();
$exclusive = null;
$from = $this->expression(function () use (&$exclusive) {
if (!$this->lookingAtIdentifier()) {
return false;
}
if ($this->scanIdentifier('to')) {
$exclusive = true;
return true;
}
if ($this->scanIdentifier('through')) {
$exclusive = false;
return true;
}
return false;
});
if ($exclusive === null) {
$this->scanner->error('Expected "to" or "through".');
}
$this->whitespace();
$to = $this->expression();
return $this->withChildren($child, $start, function (array $children, FileSpan $span) use ($variable, $from, $to, $exclusive, $wasInControlDirective) {
$this->inControlDirective = $wasInControlDirective;
return new ForRule($variable, $from, $to, $children, $span, $exclusive);
});
}
/**
* Consumes a `@if` rule.
*
* $start should point before the `@`. $child is called to consume any
* children that are specifically allowed in the caller's context.
*
* @param callable(): Statement $child
*/
private function ifRule(int $start, callable $child): IfRule
{
$ifIndentation = $this->getCurrentIndentation();
$wasInControlDirective = $this->inControlDirective;
$this->inControlDirective = true;
$condition = $this->expression();
$children = $this->children($child);
$this->whitespaceWithoutComments();
$clauses = [new IfClause($condition, $children)];
$lastClause = null;
while ($this->scanElse($ifIndentation)) {
$this->whitespace();
if ($this->scanIdentifier('if')) {
$this->whitespace();
$clauses[] = new IfClause($this->expression(), $this->children($child));
} else {
$lastClause = new ElseClause($this->children($child));
break;
}
}
$this->inControlDirective = $wasInControlDirective;
$span = $this->scanner->spanFrom($start);
$this->whitespaceWithoutComments();
return new IfRule($clauses, $span, $lastClause);
}
/**
* Consumes an `@import` rule.
*
* $start should point before the `@`.
*/
private function importRule(int $start): ImportRule
{
$imports = [];
do {
$this->whitespace();
$argument = $this->importArgument();
if (($this->inControlDirective || $this->inMixin) && $argument instanceof DynamicImport) {
$this->disallowedAtRule($start);
}
$imports[] = $argument;
$this->whitespace();
} while ($this->scanner->scanChar(','));
$this->expectStatementSeparator('@import rule');
return new ImportRule($imports, $this->scanner->spanFrom($start));
}
/**
* Consumes an argument to an `@import` rule.
*/
protected function importArgument(): Import
{
$start = $this->scanner->getPosition();
$next = $this->scanner->peekChar();
if ($next === 'u' || $next === 'U') {
$url = $this->dynamicUrl();
$this->whitespace();
$modifiers = $this->tryImportModifiers();
return new StaticImport(new Interpolation([$url], $this->scanner->spanFrom($start)), $this->scanner->spanFrom($start), $modifiers);
}
$url = $this->string();
$urlSpan = $this->scanner->spanFrom($start);
$this->whitespace();
$modifiers = $this->tryImportModifiers();
if ($this->isPlainImportUrl($url) || $modifiers !== null) {
return new StaticImport(new Interpolation([$urlSpan->getText()], $urlSpan), $this->scanner->spanFrom($start), $modifiers);
}
try {
return new DynamicImport($this->parseImportUrl($url), $urlSpan);
} catch (SyntaxError $e) {
$this->error('Invalid URL: ' . $e->getMessage(), $urlSpan, $e);
}
}
/**
* Parses $url as an import URL.
*
* @throws SyntaxError
*/
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);
}
Uri::createFromString($url);
return $url;
}
private static function isRootRelativeUrl(string $path): bool
{
return $path !== '' && $path[0] === '/';
}
/**
* Returns whether $url indicates that an `@import` is a plain CSS import.
*/
protected function isPlainImportUrl(string $url): bool
{
if (\strlen($url) < 5) {
return false;
}
if (substr($url, -4) === '.css') {
return true;
}
if ($url[0] === '/') {
return $url[1] === '/';
}
if ($url[0] !== 'h') {
return false;
}
return 0 === strpos($url, 'http://') || 0 === strpos($url, 'https://');
}
/**
* Returns `null` if there are no modifiers.
*/
protected function tryImportModifiers(): ?Interpolation
{
// Exit before allocating anything if we're not looking at any modifiers, as
// is the most common case.
if (!$this->lookingAtInterpolatedIdentifier() && $this->scanner->peekChar() !== '(') {
return null;
}
$start = $this->scanner->getPosition();
$buffer = new InterpolationBuffer();
while (true) {
if ($this->lookingAtInterpolatedIdentifier()) {
if (!$buffer->isEmpty()) {
$buffer->write(' ');
}
$identifier = $this->interpolatedIdentifier();
$buffer->addInterpolation($identifier);
$name = $identifier->getAsPlain() !== null ? strtolower($identifier->getAsPlain()) : null;
if ($name !== 'and' && $this->scanner->scanChar('(')) {
if ($name === 'supports') {
$query = $this->importSupportsQuery();
if (!$query instanceof SupportsDeclaration) {
$buffer->write('(');
}
$buffer->add(new SupportsExpression($query));
if (!$query instanceof SupportsDeclaration) {
$buffer->write(')');
}
} else {
$buffer->write('(');
$buffer->addInterpolation($this->interpolatedDeclarationValue(true, true));
$buffer->write(')');
}
$this->scanner->expectChar(')');
$this->whitespace();
} else {
$this->whitespace();
if ($this->scanner->scanChar(',')) {
$buffer->write(', ');
$buffer->addInterpolation($this->mediaQueryList());
return $buffer->buildInterpolation($this->scanner->spanFrom($start));
}
}
} elseif ($this->scanner->peekChar() === '(') {
if (!$buffer->isEmpty()) {
$buffer->write(' ');
}
$buffer->addInterpolation($this->mediaQueryList());
return $buffer->buildInterpolation($this->scanner->spanFrom($start));
} else {
return $buffer->buildInterpolation($this->scanner->spanFrom($start));
}
}
}
/**
* Consumes the contents of a `supports()` function after an `@import` rule
* (but not the function name or parentheses).
*/
private function importSupportsQuery(): SupportsCondition
{
if ($this->scanIdentifier('not')) {
$this->whitespace();
$start = $this->scanner->getPosition();
return new SupportsNegation($this->supportsConditionInParens(), $this->scanner->spanFrom($start));
}
if ($this->scanner->peekChar() === '(') {
return $this->supportsCondition();
}
$function = $this->tryImportSupportsFunction();
if ($function !== null) {
return $function;
}
$start = $this->scanner->getPosition();
$name = $this->expression();
$this->scanner->expectChar(':');
return $this->supportsDeclarationValue($name, $start);
}
/**
* Consumes a function call within a `supports()` function after an
* `@import` if available.
*/
private function tryImportSupportsFunction(): ?SupportsCondition
{
if (!$this->lookingAtInterpolatedIdentifier()) {
return null;
}
$start = $this->scanner->getPosition();
$name = $this->interpolatedIdentifier();
assert($name->getAsPlain() !== 'not');
if (!$this->scanner->scanChar('(')) {
$this->scanner->setPosition($start);
return null;
}
$value = $this->interpolatedDeclarationValue(true, true);
$this->scanner->expectChar(')');
return new SupportsFunction($name, $value, $this->scanner->spanFrom($start));
}
/**
* Consumes a `@include` rule.
*
* $start should point before the `@`.
*/
private function includeRule(int $start): IncludeRule
{
$namespace = null;
$name = $this->identifier();
if ($this->scanner->scanChar('.')) {
$namespace = $name;
$name = $this->publicIdentifier();
} else {
$name = str_replace('_', '-', $name);
}
$this->whitespace();
$arguments = $this->scanner->peekChar() === '(' ? $this->argumentInvocation(true) : ArgumentInvocation::createEmpty($this->scanner->getEmptySpan());
$this->whitespace();
$contentArguments = null;
if ($this->scanIdentifier('using')) {
$this->whitespace();
$contentArguments = $this->argumentDeclaration();
$this->whitespace();
}
$content = null;
if ($contentArguments !== null || $this->lookingAtChildren()) {
$contentArguments = $contentArguments ?? ArgumentDeclaration::createEmpty($this->scanner->getEmptySpan());
$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);
});
$this->inContentBlock = $wasInContentBlock;
} else {
$this->expectStatementSeparator();
}
$span = $this->scanner->spanFrom($start, $start)->expand(($content ?? $arguments)->getSpan());
// TODO remove this when implementing modules
if ($namespace !== null) {
$this->error('Sass modules are not implemented yet.', $this->scanner->spanFrom($start));
}
return new IncludeRule($name, $arguments, $span, $namespace, $content);
}
/**
* Consumes a `@media` rule.
*
* $start should point before the `@`.
*/
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);
});
}
/**
* Consumes a mixin declaration.
*
* $start should point before the `@`.
*/
private function mixinRule(int $start): MixinRule
{
$precedingComment = $this->lastSilentComment;
$this->lastSilentComment = null;
$name = $this->identifier(true);
$this->whitespace();
$arguments = $this->scanner->peekChar() === '(' ? $this->argumentDeclaration() : ArgumentDeclaration::createEmpty($this->scanner->getEmptySpan());
if ($this->inMixin || $this->inContentBlock) {
$this->error('Mixins may not contain mixin declarations.', $this->scanner->spanFrom($start));
}
if ($this->inControlDirective) {
$this->error('Mixins may not be declared in control directives.', $this->scanner->spanFrom($start));
}
$this->whitespace();
$this->inMixin = true;
return $this->withChildren($this->statementCallable, $start, function (array $children, FileSpan $span) use ($name, $arguments, $precedingComment) {
$this->inMixin = false;
return new MixinRule($name, $arguments, $span, $children, $precedingComment);
});
}
/**
* Consumes a `@moz-document` rule.
*
* Gecko's `@-moz-document` diverges from [the specification][] allows the
* `url-prefix` and `domain` functions to omit quotation marks, contrary to
* the standard.
*
* [the specification]: https://www.w3.org/TR/css3-conditional/
*/
protected function mozDocumentRule(int $start, Interpolation $name): AtRule
{
$valueStart = $this->scanner->getPosition();
$buffer = new InterpolationBuffer();
$needsDeprecationWarning = false;
while (true) {
if ($this->scanner->peekChar() === '#') {
$buffer->add($this->singleInterpolation());
$needsDeprecationWarning = true;
} else {
$identifierStart = $this->scanner->getPosition();
$identifier = $this->identifier();
switch ($identifier) {
case 'url':
case 'url-prefix':
case 'domain':
$contents = $this->tryUrlContents($identifierStart, $identifier);
if ($contents !== null) {
$buffer->addInterpolation($contents);
} else {
$this->scanner->expectChar('(');
$this->whitespace();
$argument = $this->interpolatedString();
$this->scanner->expectChar(')');
$buffer->write($identifier);
$buffer->write('(');
$buffer->addInterpolation($argument->asInterpolation());
$buffer->write(')');
}
// 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("")')) {
$needsDeprecationWarning = true;
}
break;
case 'regexp':
$buffer->write('regexp(');
$this->scanner->expectChar('(');
$buffer->addInterpolation($this->interpolatedString()->asInterpolation());
$this->scanner->expectChar(')');
$buffer->write(')');
$needsDeprecationWarning = true;
break;
default:
$this->error('Invalid function name.', $this->scanner->spanFrom($identifierStart));
}
}
$this->whitespace();
if (!$this->scanner->scanChar(',')) {
break;
}
$buffer->write(',');
$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) {
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);
}
return new AtRule($name, $span, $value, $children);
});
}
/**
* Consumes a `@return` rule.
*
* $start should point before the `@`.
*/
private function returnRule(int $start): ReturnRule
{
$value = $this->expression();
$this->expectStatementSeparator('@return rule');
return new ReturnRule($value, $this->scanner->spanFrom($start));
}
/**
* Consumes a `@supports` rule.
*
* $start should point before the `@`.
*/
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);
});
}
/**
* Consumes a `@warn` rule.
*
* $start should point before the `@`.
*/
private function warnRule(int $start): WarnRule
{
$value = $this->expression();
$this->expectStatementSeparator('@warn rule');
return new WarnRule($value, $this->scanner->spanFrom($start));
}
/**
* Consumes a `@while` rule.
*
* $start should point before the `@`. $child is called to consume any
* children that are specifically allowed in the caller's context.
*
* @param callable(): Statement $child
*/
private function whileRule(int $start, callable $child): WhileRule
{
$wasInControlDirective = $this->inControlDirective;
$this->inControlDirective = true;
$condition = $this->expression();
return $this->withChildren($child, $start, function (array $children, FileSpan $span) use ($condition, $wasInControlDirective) {
$this->inControlDirective = $wasInControlDirective;
return new WhileRule($condition, $children, $span);
});
}
/**
* Consumes an at-rule that's not explicitly supported by Sass.
*
* $start should point before the `@`. $name is the name of the at-rule.
*/
protected function unknownAtRule(int $start, Interpolation $name): AtRule
{
$wasInUnknownAtRule = $this->inUnknownAtRule;
$this->inUnknownAtRule = true;
$value = null;
$next = $this->scanner->peekChar();
if ($next !== '!' && !$this->atEndOfStatement()) {
$value = $this->almostAnyValue();
}
if ($this->lookingAtChildren()) {
$rule = $this->withChildren($this->statementCallable, $start, function (array $children, FileSpan $span) use ($name, $value) {
return new AtRule($name, $span, $value, $children);
});
} else {
$this->expectStatementSeparator();
$rule = new AtRule($name, $this->scanner->spanFrom($start), $value);
}
$this->inUnknownAtRule = $wasInUnknownAtRule;
return $rule;
}
/**
* Throws an exception indicating that the at-rule starting at $start is
* not allowed in the current context.
*
* @return never-return
*/
private function disallowedAtRule(int $start)
{
$this->almostAnyValue();
$this->error('This at-rule is not allowed here.', $this->scanner->spanFrom($start));
}
/**
* Consumes an argument declaration.
*/
private function argumentDeclaration(): ArgumentDeclaration
{
$start = $this->scanner->getPosition();
$this->scanner->expectChar('(');
$this->whitespace();
$arguments = [];
$named = [];
$restArgument = null;
while ($this->scanner->peekChar() === '$') {
$variableStart = $this->scanner->getPosition();
$name = $this->variableName();
$this->whitespace();
$defaultValue = null;
if ($this->scanner->scanChar(':')) {
$this->whitespace();
$defaultValue = $this->expressionUntilComma();
} elseif ($this->scanner->scanChar('.')) {
$this->scanner->expectChar('.');
$this->scanner->expectChar('.');
$this->whitespace();
$restArgument = $name;
break;
}
$argument = new Argument($name, $this->scanner->spanFrom($variableStart), $defaultValue);
$arguments[] = $argument;
if (isset($named[$name])) {
$this->error('Duplicate argument.', $argument->getSpan());
}
$named[$name] = true;
if (!$this->scanner->scanChar(',')) {
break;
}
$this->whitespace();
}
$this->scanner->expectChar(')');
return new ArgumentDeclaration($arguments, $this->scanner->spanFrom($start), $restArgument);
}
/**
* Consumes an argument invocation.
*
* If $mixin is `true`, this is parsed as a mixin invocation. Mixin
* invocations don't allow the Microsoft-style `=` operator at the top level,
* but function invocations do.
*
* If $allowEmptySecondArg is `true`, this allows the second argument to be
* omitted, in which case an unquoted empty string will be passed in its
* place.
*/
private function argumentInvocation(bool $mixin = false, bool $allowEmptySecondArg = false): ArgumentInvocation
{
$start = $this->scanner->getPosition();
$this->scanner->expectChar('(');
$this->whitespace();
$positional = [];
$named = [];
$rest = null;
$keywordRest = null;
while ($this->lookingAtExpression()) {
$expression = $this->expressionUntilComma(!$mixin);
$this->whitespace();
if ($expression instanceof VariableExpression && $this->scanner->scanChar(':')) {
$this->whitespace();
if (isset($named[$expression->getName()])) {
$this->error('Duplicate argument.', $expression->getSpan());
}
$named[$expression->getName()] = $this->expressionUntilComma(!$mixin);
} elseif ($this->scanner->scanChar('.')) {
$this->scanner->expectChar('.');
$this->scanner->expectChar('.');
if ($rest === null) {
$rest = $expression;
} else {
$keywordRest = $expression;
$this->whitespace();
break;
}
} elseif ($named) {
$this->error('Positional arguments must come before keyword arguments.', $expression->getSpan());
} else {
$positional[] = $expression;
}
$this->whitespace();
if (!$this->scanner->scanChar(',')) {
break;
}
$this->whitespace();
if ($allowEmptySecondArg && \count($positional) === 1 && \count($named) === 0 && $rest === null && $this->scanner->peekChar() === ')') {
$positional[] = StringExpression::plain('', $this->scanner->getEmptySpan());
break;
}
}
$this->scanner->expectChar(')');
return new ArgumentInvocation($positional, $named, $this->scanner->spanFrom($start), $rest, $keywordRest);
}
/**
* Consumes an expression.
*
* @param (callable(): bool)|null $until
*
* @phpstan-impure
*/
private function expression(?callable $until = null, bool $singleEquals = false, bool $bracketList = false): Expression
{
if ($until !== null && $until()) {
$this->scanner->error('Expected expression.');
}
$beforeBracket = null;
if ($bracketList) {
$beforeBracket = $this->scanner->getPosition();
$this->scanner->expectChar('[');
$this->whitespace();
if ($this->scanner->scanChar(']')) {
return new ListExpression([], ListSeparator::UNDECIDED, $this->scanner->spanFrom($beforeBracket), true);
}
}
$start = $this->scanner->getPosition();
$wasInParentheses = $this->inParentheses;
/**
* @var Expression[]|null $commaExpressions
*/
$commaExpressions = null;
/**
* @var Expression[]|null $spaceExpressions
*/
$spaceExpressions = null;
/**
* Operators whose right-hand $operands are not fully parsed yet, in order of
* appearance in the document. Because a low-precedence operator will cause
* parsing to finish for all preceding higher-precedence $operators, this is
* naturally ordered from lowest to highest precedence.
*
* @phpstan-var list<BinaryOperator::*>|null $operators
*/
$operators = null;
/**
* The left-hand sides of $operators. `$operands[n]` is the left-hand side
* of `$operators[n]`.
*
* @var list<Expression>|null $operands
*/
$operands = null;
/**
* Whether the single expression parsed so far may be interpreted as
* slash-separated numbers.
*/
$allowSlash = true;
/**
* The leftmost expression that's been fully-parsed. This can be null in
* special cases where the expression begins with a sub-expression but has
* a later character that indicates that the outer expression isn't done,
* as here:
*
* foo, bar
* ^
*
* @var Expression|null $singleExpression
*/
$singleExpression = $this->singleExpression();
/**
* Resets the scanner state to the state it was at the beginning of the
* expression, except for {@see $inParentheses}.
*/
$resetState = function () use (&$commaExpressions, &$spaceExpressions, &$operators, &$operands, &$allowSlash, &$singleExpression, $start): void {
$commaExpressions = null;
$spaceExpressions = null;
$operators = null;
$operands = null;
$this->scanner->setPosition($start);
$allowSlash = true;
$singleExpression = $this->singleExpression();
};
$resolveOneOperation = function () use (&$operands, &$operators, &$singleExpression, &$allowSlash): void {
assert($operands !== null);
assert($operators !== null);
$operator = array_pop($operators);
assert($operator !== null, 'The list of operators must not be empty');
$left = array_pop($operands);
assert($left !== null, 'The list of operands must not be empty');
$right = $singleExpression;
if ($right === null) {
$this->scanner->error('Expected expression.', $this->scanner->getPosition() - \strlen($operator), \strlen($operator));
}
if ($allowSlash && !$this->inParentheses && $operator === BinaryOperator::DIVIDED_BY && self::isSlashOperand($left) && self::isSlashOperand($right)) {
$singleExpression = BinaryOperationExpression::slash($left, $right);
} else {
$singleExpression = new BinaryOperationExpression($operator, $left, $right);
$allowSlash = false;
if ($operator === BinaryOperator::PLUS || $operator === BinaryOperator::MINUS) {
if (
$this->scanner->substring($right->getSpan()->getStart()->getOffset() - 1, $right->getSpan()->getStart()->getOffset()) === $operator
&& Character::isWhitespace($this->scanner->getString()[$left->getSpan()->getEnd()->getOffset()])
) {
$message = <<<WARNING
This operation is parsed as:
$left $operator $right
but you may have intended it to mean:
$left ($operator$right)
Add a space after $operator to clarify that it's meant to be a binary operation, or wrap
it in parentheses to make it a unary operation. This will be an error in future
versions of Sass.
More info and automated migrator: https://sass-lang.com/d/strict-unary
WARNING;
$this->logger->warn($message, true, $singleExpression->getSpan());
}
}
}
};
$resolveOperations = function () use (&$operators, $resolveOneOperation): void {
if ($operators === null) {
return;
}
while ($operators) {
$resolveOneOperation();
}
};
$addSingleExpression = function (Expression $expression) use (&$singleExpression, &$allowSlash, &$spaceExpressions, $resetState, $resolveOperations): void {
if ($singleExpression !== null) {
// If we discover we're parsing a list whose first element is a division
// operation, and we're in parentheses, reparse outside of a paren
// context. This ensures that `(1/2 1)` doesn't perform division on its
// first element.
if ($this->inParentheses) {
$this->inParentheses = false;
if ($allowSlash) {
$resetState();
return;
}
}
$spaceExpressions = $spaceExpressions ?? [];
$resolveOperations();
$spaceExpressions[] = $singleExpression;
$allowSlash = true;
}
$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));
}
$allowSlash = $allowSlash && $operator === BinaryOperator::DIVIDED_BY;
$operators = $operators ?? [];
$operands = $operands ?? [];
$precedence = BinaryOperator::getPrecedence($operator);
while ($operators && BinaryOperator::getPrecedence($operators[\count($operators) - 1]) >= $precedence) {
$resolveOneOperation();
}
$operators[] = $operator;
if ($singleExpression === null) {
$this->scanner->error('Expected expression.', $this->scanner->getPosition() - \strlen($operator), \strlen($operator));
}
$operands[] = $singleExpression;
$this->whitespace();
$singleExpression = $this->singleExpression();
};
$resolveSpaceExpressions = function () use (&$spaceExpressions, &$singleExpression, $resolveOperations): void {
$resolveOperations();
if ($spaceExpressions !== null) {
if ($singleExpression === null) {
$this->scanner->error('Expected expression.');
}
$spaceExpressions[] = $singleExpression;
$singleExpression = new ListExpression(
$spaceExpressions,
ListSeparator::SPACE,
$spaceExpressions[0]->getSpan()->expand($spaceExpressions[\count($spaceExpressions) - 1]->getSpan())
);
$spaceExpressions = null;
}
};
while (true) {
$this->whitespace();
if ($until !== null && $until()) {
break;
}
$first = $this->scanner->peekChar();
switch ($first) {
case '(':
// Parenthesized numbers can't be slash-separated.
$addSingleExpression($this->parentheses());
break;
case '[':
$addSingleExpression($this->expression(null, false, true));
break;
case '$':
$addSingleExpression($this->variable());
break;
case '&':
$addSingleExpression($this->selector());
break;
case "'":
case '"':
$addSingleExpression($this->interpolatedString());
break;
case '#':
$addSingleExpression($this->hashExpression());
break;
case '=':
$this->scanner->readChar();
if ($singleEquals && $this->scanner->peekChar() !== '=') {
$addOperator(BinaryOperator::SINGLE_EQUALS);
} else {
$this->scanner->expectChar('=');
$addOperator(BinaryOperator::EQUALS);
}
break;
case '!':
$next = $this->scanner->peekChar(1);
if ($next === '=') {
$this->scanner->readChar();
$this->scanner->readChar();
$addOperator(BinaryOperator::NOT_EQUALS);
} elseif ($next === null || $next === 'i' || $next === 'I' || Character::isWhitespace($next)) {
$addSingleExpression($this->importantExpression());
} else {
break 2;
}
break;
case '<':
$this->scanner->readChar();
$addOperator($this->scanner->scanChar('=') ? BinaryOperator::LESS_THAN_OR_EQUALS : BinaryOperator::LESS_THAN);
break;
case '>':
$this->scanner->readChar();
$addOperator($this->scanner->scanChar('=') ? BinaryOperator::GREATER_THAN_OR_EQUALS : BinaryOperator::GREATER_THAN);
break;
case '*':
$this->scanner->readChar();
$addOperator(BinaryOperator::TIMES);
break;
case '+':
if ($singleExpression === null) {
$addSingleExpression($this->unaryOperation());
} else {
$this->scanner->readChar();
$addOperator(BinaryOperator::PLUS);
}
break;
case '-':
$next = $this->scanner->peekChar(1);
// Make sure `1-2` parses as `1 - 2`, not `1 (-2)`.
if ((Character::isDigit($next) || $next === '.') && ($singleExpression === null || Character::isWhitespace($this->scanner->peekChar(-1)))) {
$addSingleExpression($this->number());
} elseif ($this->lookingAtInterpolatedIdentifier()) {
$addSingleExpression($this->identifierLike());
} elseif ($singleExpression === null) {
$addSingleExpression($this->unaryOperation());
} else {
$this->scanner->readChar();
$addOperator(BinaryOperator::MINUS);
}
break;
case '/':
if ($singleExpression === null) {
$addSingleExpression($this->unaryOperation());
} else {
$this->scanner->readChar();
$addOperator(BinaryOperator::DIVIDED_BY);
}
break;
case '%':
$this->scanner->readChar();
$addOperator(BinaryOperator::MODULO);
break;
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
$addSingleExpression($this->number());
break;
case '.':
if ($this->scanner->peekChar(1) === '.') {
break 2;
}
$addSingleExpression($this->number());
break;
case 'a':
if (!$this->isPlainCss() && $this->scanIdentifier('and')) {
$addOperator(BinaryOperator::AND);
} else {
$addSingleExpression($this->identifierLike());
}
break;
case 'o':
if (!$this->isPlainCss() && $this->scanIdentifier('or')) {
$addOperator(BinaryOperator::OR);
} else {
$addSingleExpression($this->identifierLike());
}
break;
case 'u':
case 'U':
if ($this->scanner->peekChar(1) === '+') {
$addSingleExpression($this->unicodeRange());
} else {
$addSingleExpression($this->identifierLike());
}
break;
case 'b':
case 'c':
case 'd':
case 'e':
case 'f':
case 'g':
case 'h':
case 'i':
case 'j':
case 'k':
case 'l':
case 'm':
case 'n':
case 'p':
case 'q':
case 'r':
case 's':
case 't':
case 'v':
case 'w':
case 'x':
case 'y':
case 'z':
case 'A':
case 'B':
case 'C':
case 'D':
case 'E':
case 'F':
case 'G':
case 'H':
case 'I':
case 'J':
case 'K':
case 'L':
case 'M':
case 'N':
case 'O':
case 'P':
case 'Q':
case 'R':
case 'S':
case 'T':
case 'V':
case 'W':
case 'X':
case 'Y':
case 'Z':
case '_':
case '\\':
$addSingleExpression($this->identifierLike());
break;
case ',':
// If we discover we're parsing a list whose first element is a
// division operation, and we're in parentheses, reparse outside of a
// paren context. This ensures that `(1/2, 1)` doesn't perform division
// on its first element.
if ($this->inParentheses) {
$this->inParentheses = false;
if ($allowSlash) {
$resetState();
break;
}
}
$commaExpressions = $commaExpressions ?? [];
if ($singleExpression === null) {
$this->scanner->error('Expected expression.');
}
$resolveSpaceExpressions();
$commaExpressions[] = $singleExpression;
$this->scanner->readChar();
$allowSlash = true;
$singleExpression = null;
break;
default:
if ($first !== null && \ord($first) >= 0x80) {
$addSingleExpression($this->identifierLike());
break;
}
break 2;
}
}
if ($bracketList) {
$this->scanner->expectChar(']');
}
if ($commaExpressions !== null) {
$resolveSpaceExpressions();
$this->inParentheses = $wasInParentheses;
if ($singleExpression !== null) {
$commaExpressions[] = $singleExpression;
}
return new ListExpression($commaExpressions, ListSeparator::COMMA, $this->scanner->spanFrom($beforeBracket ?? $start), $bracketList);
}
if ($bracketList && $spaceExpressions !== null) {
$resolveOperations();
assert($singleExpression !== null);
$spaceExpressions[] = $singleExpression;
return new ListExpression($spaceExpressions, ListSeparator::SPACE, $this->scanner->spanFrom($beforeBracket), true);
}
$resolveSpaceExpressions();
assert($singleExpression !== null);
if ($bracketList) {
assert($beforeBracket !== null);
$singleExpression = new ListExpression([$singleExpression], ListSeparator::UNDECIDED, $this->scanner->spanFrom($beforeBracket), true);
}
return $singleExpression;
}
/**
* Consumes an expression until it reaches a top-level comma.
*
* If $singleEquals is true, this will allow the Microsoft-style `=`
* operator at the top level.
*
* @phpstan-impure
*/
protected function expressionUntilComma(bool $singleEquals = false): Expression
{
return $this->expression(function () {
return $this->scanner->peekChar() === ',';
}, $singleEquals);
}
/**
* Whether $expression is allowed as an operand of a `/` expression that
* produces a potentially slash-separated number.
*/
private static function isSlashOperand(Expression $expression): bool
{
return $expression instanceof NumberExpression || $expression instanceof CalculationExpression || ($expression instanceof BinaryOperationExpression && $expression->allowsSlash());
}
/**
* Consumes an expression that doesn't contain any top-level whitespace.
*/
private function singleExpression(): Expression
{
$first = $this->scanner->peekChar();
switch ($first) {
case '(':
return $this->parentheses();
case '/':
return $this->unaryOperation();
case '.':
return $this->number();
case '[':
return $this->expression(null, false, true);
case '$':
return $this->variable();
case '&':
return $this->selector();
case "'":
case '"':
return $this->interpolatedString();
case '#':
return $this->hashExpression();
case '+':
return $this->plusExpression();
case '-':
return $this->minusExpression();
case '!':
return $this->importantExpression();
case 'u':
case 'U':
if ($this->scanner->peekChar(1) === '+') {
return $this->unicodeRange();
}
return $this->identifierLike();
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
return $this->number();
case 'a':
case 'b':
case 'c':
case 'd':
case 'e':
case 'f':
case 'g':
case 'h':
case 'i':
case 'j':
case 'k':
case 'l':
case 'm':
case 'n':
case 'o':
case 'p':
case 'q':
case 'r':
case 's':
case 't':
case 'v':
case 'w':
case 'x':
case 'y':
case 'z':
case 'A':
case 'B':
case 'C':
case 'D':
case 'E':
case 'F':
case 'G':
case 'H':
case 'I':
case 'J':
case 'K':
case 'L':
case 'M':
case 'N':
case 'O':
case 'P':
case 'Q':
case 'R':
case 'S':
case 'T':
case 'V':
case 'W':
case 'X':
case 'Y':
case 'Z':
case '_':
case '\\':
return $this->identifierLike();
default:
if ($first !== null && \ord($first) >= 0x80) {
return $this->identifierLike();
}
$this->scanner->error('Expected expression.');
}
}
/**
* Consumes a parenthesized expression.
*/
private function parentheses(): Expression
{
if ($this->isPlainCss()) {
$this->scanner->error("Parentheses aren't allowed in plain CSS.");
}
$wasInParentheses = $this->inParentheses;
$this->inParentheses = true;
try {
$start = $this->scanner->getPosition();
$this->scanner->expectChar('(');
$this->whitespace();
if (!$this->lookingAtExpression()) {
$this->scanner->expectChar(')');
return new ListExpression([], ListSeparator::UNDECIDED, $this->scanner->spanFrom($start));
}
$first = $this->expressionUntilComma();
if ($this->scanner->scanChar(':')) {
$this->whitespace();
return $this->map($first, $start);
}
if (!$this->scanner->scanChar(',')) {
$this->scanner->expectChar(')');
return new ParenthesizedExpression($first, $this->scanner->spanFrom($start));
}
$this->whitespace();
$expressions = [$first];
while (true) {
if (!$this->lookingAtExpression()) {
break;
}
$expressions[] = $this->expressionUntilComma();
if (!$this->scanner->scanChar(',')) {
break;
}
$this->whitespace();
}
$this->scanner->expectChar(')');
return new ListExpression($expressions, ListSeparator::COMMA, $this->scanner->spanFrom($start));
} finally {
$this->inParentheses = $wasInParentheses;
}
}
/**
* Consumes a map expression.
*
* This expects to be called after the first colon in the map, with $first
* as the expression before the colon and $start the point before the
* opening parenthesis.
*/
private function map(Expression $first, int $start): MapExpression
{
$pairs = [
[$first, $this->expressionUntilComma()],
];
while ($this->scanner->scanChar(',')) {
$this->whitespace();
if (!$this->lookingAtExpression()) {
break;
}
$key = $this->expressionUntilComma();
$this->scanner->expectChar(':');
$this->whitespace();
$value = $this->expressionUntilComma();
$pairs[] = [$key, $value];
}
$this->scanner->expectChar(')');
return new MapExpression($pairs, $this->scanner->spanFrom($start));
}
/**
* Consumes an expression that starts with a `#`.
*/
private function hashExpression(): Expression
{
assert($this->scanner->peekChar() === '#');
if ($this->scanner->peekChar(1) === '{') {
return $this->identifierLike();
}
$start = $this->scanner->getPosition();
$this->scanner->expectChar('#');
$first = $this->scanner->peekChar();
if ($first !== null && Character::isDigit($first)) {
return new ColorExpression($this->hexColorContents($start), $this->scanner->spanFrom($start));
}
$afterHash = $this->scanner->getPosition();
$identifier = $this->interpolatedIdentifier();
if ($this->isHexColor($identifier)) {
$this->scanner->setPosition($afterHash);
return new ColorExpression($this->hexColorContents($start), $this->scanner->spanFrom($start));
}
$buffer = new InterpolationBuffer();
$buffer->write('#');
$buffer->addInterpolation($identifier);
return new StringExpression($buffer->buildInterpolation($this->scanner->spanFrom($start)));
}
/**
* Consumes the contents of a hex color, after the `#`.
*/
private function hexColorContents(int $start): SassColor
{
$digit1 = $this->hexDigit();
$digit2 = $this->hexDigit();
$digit3 = $this->hexDigit();
$alpha = null;
if (!Character::isHex($this->scanner->peekChar())) {
// #abc
$red = ($digit1 << 4) + $digit1;
$green = ($digit2 << 4) + $digit2;
$blue = ($digit3 << 4) + $digit3;
} else {
$digit4 = $this->hexDigit();
if (!Character::isHex($this->scanner->peekChar())) {
#abcd
$red = ($digit1 << 4) + $digit1;
$green = ($digit2 << 4) + $digit2;
$blue = ($digit3 << 4) + $digit3;
$alpha = (($digit4 << 4) + $digit4) / 0xff;
} else {
$red = ($digit1 << 4) + $digit2;
$green = ($digit3 << 4) + $digit4;
$blue = ($this->hexDigit() << 4) + $this->hexDigit();
if (Character::isHex($this->scanner->peekChar())) {
$alpha = (($this->hexDigit() << 4) + $this->hexDigit()) / 0xff;
}
}
}
// 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);
}
private function isHexColor(Interpolation $interpolation): bool
{
$plain = $interpolation->getAsPlain();
if ($plain === null) {
return false;
}
$length = \strlen($plain);
if ($length !== 3 && $length !== 4 && $length !== 6 && $length !== 8) {
return false;
}
for ($i = 0; $i < $length; $i++) {
if (!Character::isHex($plain[$i])) {
return false;
}
}
return true;
}
/**
* Consumes a single hexadecimal digit.
*
* @phpstan-impure
*/
private function hexDigit(): int
{
$char = $this->scanner->peekChar();
if ($char === null || !Character::isHex($char)) {
$this->scanner->error('Expected hex digit.');
}
return (int) hexdec($this->scanner->readChar());
}
/**
* Consumes an expression that starts with a `+`.
*/
private function plusExpression(): Expression
{
assert($this->scanner->peekChar() === '+');
$next = $this->scanner->peekChar(1);
if (Character::isDigit($next) || $next === '.') {
return $this->number();
}
return $this->unaryOperation();
}
/**
* Consumes an expression that starts with a `-`.
*/
private function minusExpression(): Expression
{
assert($this->scanner->peekChar() === '-');
$next = $this->scanner->peekChar(1);
if (Character::isDigit($next) || $next === '.') {
return $this->number();
}
if ($this->lookingAtInterpolatedIdentifier()) {
return $this->identifierLike();
}
return $this->unaryOperation();
}
/**
* Consumes an `!important` expression.
*/
private function importantExpression(): Expression
{
assert($this->scanner->peekChar() === '!');
$start = $this->scanner->getPosition();
$this->scanner->readChar();
$this->whitespace();
$this->expectIdentifier('important');
return StringExpression::plain('!important', $this->scanner->spanFrom($start));
}
/**
* Consumes a unary operation expression.
*/
private function unaryOperation(): UnaryOperationExpression
{
$start = $this->scanner->getPosition();
$operator = $this->unaryOperatorFor($this->scanner->readChar());
if ($operator === null) {
$this->scanner->error('Expected unary operator.', $this->scanner->getPosition() - 1);
}
if ($this->isPlainCss() && $operator !== UnaryOperator::DIVIDE) {
$this->scanner->error("Operators aren't allowed in plain CSS.", $this->scanner->getPosition() - 1, 1);
}
$this->whitespace();
$operand = $this->singleExpression();
return new UnaryOperationExpression($operator, $operand, $this->scanner->spanFrom($start));
}
/**
* 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
{
switch ($character) {
case '+':
return UnaryOperator::PLUS;
case '-':
return UnaryOperator::MINUS;
case '/':
return UnaryOperator::DIVIDE;
default:
return null;
}
}
/**
* Consumes a number expression.
*/
private function number(): NumberExpression
{
$start = $this->scanner->getPosition();
$first = $this->scanner->peekChar();
if ($first === '+' || $first === '-') {
$this->scanner->readChar();
}
if ($this->scanner->peekChar() !== '.') {
$this->consumeNaturalNumber();
}
// 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->tryExponent();
// Use PHP's built-in double parsing so that we don't accumulate
// floating-point errors for numbers with lots of digits.
$number = floatval($this->scanner->substring($start));
$unit = null;
if ($this->scanner->scanChar('%')) {
$unit = '%';
} elseif ($this->lookingAtIdentifier() && ($this->scanner->peekChar() !== '-' || $this->scanner->peekChar(1) !== '-')) {
$unit = $this->identifier(false, true);
}
return new NumberExpression($number, $this->scanner->spanFrom($start), $unit);
}
/**
* Consumes a natural number (that is, a non-negative integer).
*
* Doesn't support scientific notation.
*/
private function consumeNaturalNumber(): void
{
if (!Character::isDigit($this->scanner->readChar())) {
$this->scanner->error('Expected digit.', $this->scanner->getPosition() - 1);
}
while (Character::isDigit($this->scanner->peekChar())) {
$this->scanner->readChar();
}
}
/**
* Consumes the decimal component of a number if it exists.
*
* If $allowTrailingDot is `false`, this will throw an error if there's a
* dot without any numbers following it. Otherwise, it will ignore the dot
* without consuming it.
*/
private function tryDecimal(bool $allowTrailingDot = false): void
{
if ($this->scanner->peekChar() !== '.') {
return;
}
if (!Character::isDigit($this->scanner->peekChar(1))) {
if ($allowTrailingDot) {
return;
}
$this->scanner->error('Expected digit.', $this->scanner->getPosition() + 1);
}
$this->scanner->readChar();
while (Character::isDigit($this->scanner->peekChar())) {
$this->scanner->readChar();
}
}
/**
* Consumes the exponent component of a number if it exists.
*/
private function tryExponent(): void
{
$first = $this->scanner->peekChar();
if ($first !== 'e' && $first !== 'E') {
return;
}
$next = $this->scanner->peekChar(1);
if (!Character::isDigit($next) && $next !== '-' && $next !== '+') {
return;
}
$this->scanner->readChar();
if ($next === '+' || $next === '-') {
$this->scanner->readChar();
}
if (!Character::isDigit($this->scanner->peekChar())) {
$this->scanner->error('Expected digit.');
}
while (Character::isDigit($this->scanner->peekChar())) {
$this->scanner->readChar();
}
}
/**
* Consumes a unicode range expression.
*/
private function unicodeRange(): StringExpression
{
$start = $this->scanner->getPosition();
$this->expectIdentChar('u');
$this->scanner->expectChar('+');
$firstRangeLength = 0;
while ($this->scanCharIf([Character::class, 'isHex'])) {
$firstRangeLength++;
}
$hasQuestionMark = false;
while ($this->scanner->scanChar('?')) {
$hasQuestionMark = true;
$firstRangeLength++;
}
if ($firstRangeLength === 0) {
$this->scanner->error('Expected hex digit or "?".');
} elseif ($firstRangeLength > 6) {
$this->error('Expected at most 6 digits.', $this->scanner->spanFrom($start));
} elseif ($hasQuestionMark) {
return StringExpression::plain($this->scanner->substring($start), $this->scanner->spanFrom($start));
}
if ($this->scanner->scanChar('-')) {
$secondRangeStart = $this->scanner->getPosition();
$secondRangeLength = 0;
while ($this->scanCharIf([Character::class, 'isHex'])) {
$secondRangeLength++;
}
if ($secondRangeLength === 0) {
$this->scanner->error('Expected hex digit.');
} elseif ($secondRangeLength > 6) {
$this->error('Expected at most 6 digits.', $this->scanner->spanFrom($secondRangeStart));
}
}
if ($this->lookingAtInterpolatedIdentifierBody()) {
$this->scanner->error('Expected end of identifier.');
}
return StringExpression::plain($this->scanner->substring($start), $this->scanner->spanFrom($start));
}
/**
* Consumes a variable expression.
*/
private function variable(): VariableExpression
{
$start = $this->scanner->getPosition();
$name = $this->variableName();
if ($this->isPlainCss()) {
$this->error('Sass variables aren\'t allowed in plain CSS.', $this->scanner->spanFrom($start));
}
return new VariableExpression($name, $this->scanner->spanFrom($start));
}
/**
* Consumes a selector expression.
*/
private function selector(): SelectorExpression
{
if ($this->isPlainCss()) {
$this->scanner->error("The parent selector isn't allowed in plain CSS.", null, 1);
}
$start = $this->scanner->getPosition();
$this->scanner->expectChar('&');
if ($this->scanner->scanChar('&')) {
$this->warn('In Sass, "&&" means two copies of the parent selector. You probably want to use "and" instead.', $this->scanner->spanFrom($start));
$this->scanner->setPosition($this->scanner->getPosition() - 1);
}
return new SelectorExpression($this->scanner->spanFrom($start));
}
/**
* Consumes a quoted string expression.
*/
protected function interpolatedString(): StringExpression
{
$start = $this->scanner->getPosition();
$quote = $this->scanner->readChar();
if ($quote !== "'" && $quote !== '"') {
$this->scanner->error('Expected string.', $start);
}
$buffer = new InterpolationBuffer();
while (true) {
$next = $this->scanner->peekChar();
if ($next === $quote) {
$this->scanner->readChar();
break;
}
if ($next === null || Character::isNewline($next)) {
$this->scanner->error("Expected $quote.");
}
if ($next === '\\') {
$second = $this->scanner->peekChar(1);
if (Character::isNewline($second)) {
$this->scanner->readChar();
$this->scanner->readChar();
if ($second === "\r") {
$this->scanner->scanChar("\n");
}
} else {
$buffer->write($this->escapeCharacter());
}
} elseif ($next === '#') {
if ($this->scanner->peekChar(1) === '{') {
$buffer->add($this->singleInterpolation());
} else {
$buffer->write($this->scanner->readChar());
}
} else {
$buffer->write($this->scanner->readUtf8Char());
}
}
return new StringExpression($buffer->buildInterpolation($this->scanner->spanFrom($start)), true);
}
/**
* Consumes an expression that starts like an identifier.
*/
protected function identifierLike(): Expression
{
$start = $this->scanner->getPosition();
$identifier = $this->interpolatedIdentifier();
$plain = $identifier->getAsPlain();
if ($plain !== null) {
if ($plain === 'if' && $this->scanner->peekChar() === '(') {
$invocation = $this->argumentInvocation();
return new IfExpression($invocation, $identifier->getSpan()->expand($invocation->getSpan()));
}
if ($plain === 'not') {
$this->whitespace();
return new UnaryOperationExpression(UnaryOperator::NOT, $this->singleExpression(), $identifier->getSpan());
}
$lower = strtolower($plain);
if ($this->scanner->peekChar() !== '(') {
switch ($plain) {
case 'false':
return new BooleanExpression(false, $identifier->getSpan());
case 'null':
return new NullExpression($identifier->getSpan());
case 'true':
return new BooleanExpression(true, $identifier->getSpan());
}
$color = Colors::colorNameToColor($lower);
if ($color !== null) {
return new ColorExpression(
SassColor::rgbInternal($color->getRed(), $color->getGreen(), $color->getBlue(), $color->getAlpha(), new SpanColorFormat($identifier->getSpan())),
$identifier->getSpan()
);
}
}
$specialFunction = $this->trySpecialFunction($lower, $start);
if ($specialFunction !== null) {
return $specialFunction;
}
}
switch ($this->scanner->peekChar()) {
case '.':
if ($this->scanner->peekChar(1) === '.') {
return new StringExpression($identifier);
}
$this->scanner->readChar();
if ($plain !== null) {
return $this->namespacedExpression($plain, $start);
}
$this->error("Interpolation isn't allowed in namespaces.", $identifier->getSpan());
case '(':
if ($plain === null) {
return new InterpolatedFunctionExpression($identifier, $this->argumentInvocation(), $this->scanner->spanFrom($start));
}
return new FunctionExpression($plain, $this->argumentInvocation(false, $lower === 'var'), $this->scanner->spanFrom($start));
default:
return new StringExpression($identifier);
}
}
/**
* Consumes an expression after a namespace.
*
* This assumes the scanner is positioned immediately after the `.`. The
* $start should refer to the state at the beginning of the namespace.
*/
protected function namespacedExpression(string $namespace, int $start): Expression
{
if ($this->scanner->peekChar() === '$') {
$name = $this->variableName();
$this->assertPublic($name, function () use ($start) {
return $this->scanner->spanFrom($start);
});
// TODO remove this when implementing modules
$this->error('Sass modules are not implemented yet.', $this->scanner->spanFrom($start));
// return new VariableExpression($name, $this->scanner->spanFrom($start), $plain);
}
// TODO remove this when implementing modules
$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);
}
/**
* If $name is the name of a function with special syntax, consumes it.
*
* Otherwise, returns `null`. $start is the location before the beginning of $name.
*/
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':
case 'element':
case 'expression':
if (!$this->scanner->scanChar('(')) {
return null;
}
$buffer = new InterpolationBuffer();
$buffer->write($name);
$buffer->write('(');
break;
case 'progid':
if (!$this->scanner->scanChar(':')) {
return null;
}
$buffer = new InterpolationBuffer();
$buffer->write($name);
$buffer->write(':');
$next = $this->scanner->peekChar();
while ($next !== null && (Character::isAlphabetic($next) || $next === '.')) {
$buffer->write($this->scanner->readChar());
$next = $this->scanner->peekChar();
}
$this->scanner->expectChar('(');
$buffer->write('(');
break;
case 'url':
$contents = $this->tryUrlContents($start);
if ($contents === null) {
return null;
}
return new StringExpression($contents);
default:
return null;
}
$buffer->addInterpolation($this->interpolatedDeclarationValue(true));
$this->scanner->expectChar(')');
$buffer->write(')');
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<Expression>
*
* @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();
if (!$this->scanner->scanChar('(')) {
return null;
}
$this->whitespaceWithoutComments();
$buffer = new InterpolationBuffer();
$buffer->write($name ?? 'url');
$buffer->write('(');
while (true) {
$next = $this->scanner->peekChar();
if ($next === null) {
break;
}
if ($next === '\\') {
$buffer->write($this->escape());
} elseif ($next === '!' || $next === '%' || $next === '&' || (\ord($next) >= \ord('*') && \ord($next) <= \ord('~')) || \ord($next) >= 0x80) {
$buffer->write($this->scanner->readUtf8Char());
} elseif ($next === '#') {
if ($this->scanner->peekChar(1) === '{') {
$buffer->add($this->singleInterpolation());
} else {
$buffer->write($this->scanner->readChar());
}
} elseif (Character::isWhitespace($next)) {
$this->whitespaceWithoutComments();
if ($this->scanner->peekChar() !== ')') {
break;
}
} elseif ($next === ')') {
$buffer->write($this->scanner->readChar());
return $buffer->buildInterpolation($this->scanner->spanFrom($start));
} else {
break;
}
}
$this->scanner->setPosition($beginningOfContents);
return null;
}
/**
* Consumes a `url` token that's allowed to contain SassScript.
*/
protected function dynamicUrl(): Expression
{
$start = $this->scanner->getPosition();
$this->expectIdentifier('url');
$contents = $this->tryUrlContents($start);
if ($contents !== null) {
return new StringExpression($contents);
}
return new InterpolatedFunctionExpression(new Interpolation(['url'], $this->scanner->spanFrom($start)), $this->argumentInvocation(), $this->scanner->spanFrom($start));
}
/**
* Consumes tokens up to "{", "}", ";", or "!".
*
* This respects string and comment boundaries and supports interpolation.
* Once this interpolation is evaluated, it's expected to be re-parsed.
*
* If $omitComments is true, comments will still be consumed, but they will
* not be included in the returned interpolation.
*
* Differences from {@see interpolatedDeclarationValue} include:
*
* - This does not balance brackets.
* - This does not interpret backslashes, since the text is expected to be
* re-parsed.
* - This supports Sass-style single-line comments.
* - This does not compress adjacent whitespace characters.
*/
protected function almostAnyValue(bool $omitComments = false): Interpolation
{
$start = $this->scanner->getPosition();
$buffer = new InterpolationBuffer();
while (true) {
$next = $this->scanner->peekChar();
switch ($next) {
case '\\':
// Write a literal backslash because this text will be re-parsed.
$buffer->write($this->scanner->readChar());
$buffer->write($this->scanner->readUtf8Char());
break;
case '"':
case "'":
$buffer->addInterpolation($this->interpolatedString()->asInterpolation());
break;
case '/':
$commentStart = $this->scanner->getPosition();
if ($this->scanComment()) {
if (!$omitComments) {
$buffer->write($this->scanner->substring($commentStart));
}
} else {
$buffer->write($this->scanner->readChar());
}
break;
case '#':
if ($this->scanner->peekChar(1) === '{') {
// Add a full interpolated identifier to handle cases like
// "#{...}--1", since "--1" isn't a valid identifier on its own.
$buffer->addInterpolation($this->interpolatedIdentifier());
} else {
$buffer->write($this->scanner->readChar());
}
break;
case "\r":
case "\n":
case "\f":
if ($this->isIndented()) {
break 2;
}
$buffer->write($this->scanner->readChar());
break;
case '!':
case ';':
case '{':
case '}':
break 2;
case 'u':
case 'U':
$beforeUrl = $this->scanner->getPosition();
if (!$this->scanIdentifier('url')) {
$buffer->write($this->scanner->readChar());
break;
}
$contents = $this->tryUrlContents($beforeUrl);
if ($contents === null) {
$this->scanner->setPosition($beforeUrl);
$buffer->write($this->scanner->readChar());
} else {
$buffer->addInterpolation($contents);
}
break;
default:
if ($next === null) {
break 2;
}
if ($this->lookingAtIdentifier()) {
$buffer->write($this->identifier());
} else {
$buffer->write($this->scanner->readUtf8Char());
}
break;
}
}
return $buffer->buildInterpolation($this->scanner->spanFrom($start));
}
/**
* Consumes tokens until it reaches a top-level `";"`, `")"`, `"]"`,
* or `"}"` and returns their contents as a string.
*
* If $allowEmpty is `false` (the default), this requires at least one token.
*
* If $allowSemicolon is `true`, this doesn't stop at semicolons and instead
* includes them in the interpolated output.
*
* If $allowColon is `false`, this stops at top-level colons.
*
* Unlike {@see declarationValue}, this allows interpolation.
*/
private function interpolatedDeclarationValue(bool $allowEmpty = false, bool $allowSemicolon = false, bool $allowColon = true): Interpolation
{
$start = $this->scanner->getPosition();
$buffer = new InterpolationBuffer();
$brackets = [];
$wroteNewline = false;
while (true) {
$next = $this->scanner->peekChar();
if ($next === null) {
break;
}
switch ($next) {
case '\\':
$buffer->write($this->escape(true));
$wroteNewline = false;
break;
case '"':
case "'":
$buffer->addInterpolation($this->interpolatedString()->asInterpolation());
$wroteNewline = false;
break;
case '/':
if ($this->scanner->peekChar(1) === '*') {
$buffer->write($this->rawText([$this, 'loudComment']));
} else {
$buffer->write($this->scanner->readChar());
}
$wroteNewline = false;
break;
case '#':
if ($this->scanner->peekChar(1) === '{') {
// Add a full interpolated identifier to handle cases like
// "#{...}--1", since "--1" isn't a valid identifier on its own.
$buffer->addInterpolation($this->interpolatedIdentifier());
} else {
$buffer->write($this->scanner->readChar());
}
$wroteNewline = false;
break;
case ' ':
case "\t":
$second = $this->scanner->peekChar(1);
if ($wroteNewline || $second === null || !Character::isWhitespace($second)) {
$buffer->write($this->scanner->readChar());
} else {
$this->scanner->readChar();
}
break;
case "\n":
case "\r":
case "\f":
if ($this->isIndented()) {
break 2;
}
$prev = $this->scanner->peekChar(-1);
if ($prev === null || !Character::isNewline($prev)) {
$buffer->write("\n");
}
$this->scanner->readChar();
$wroteNewline = true;
break;
case '(':
case '{':
case '[':
$buffer->write($next);
$brackets[] = Character::opposite($this->scanner->readChar());
$wroteNewline = false;
break;
case ')':
case '}':
case ']':
if (empty($brackets)) {
break 2;
}
$buffer->write($next);
$this->scanner->expectChar(array_pop($brackets));
$wroteNewline = false;
break;
case ';':
if (!$allowSemicolon && empty($brackets)) {
break 2;
}
$buffer->write($this->scanner->readChar());
$wroteNewline = false;
break;
case ':':
if (!$allowColon && empty($brackets)) {
break 2;
}
$buffer->write($this->scanner->readChar());
$wroteNewline = false;
break;
case 'u':
case 'U':
$beforeUrl = $this->scanner->getPosition();
if (!$this->scanIdentifier('url')) {
$buffer->write($this->scanner->readChar());
$wroteNewline = false;
break;
}
$contents = $this->tryUrlContents($beforeUrl);
if ($contents === null) {
$this->scanner->setPosition($beforeUrl);
$buffer->write($this->scanner->readChar());
} else {
$buffer->addInterpolation($contents);
}
$wroteNewline = false;
break;
default:
if ($this->lookingAtIdentifier()) {
$buffer->write($this->identifier());
} else {
$buffer->write($this->scanner->readUtf8Char());
}
$wroteNewline = false;
break;
}
}
if (!empty($brackets)) {
$this->scanner->expectChar(array_pop($brackets));
}
if (!$allowEmpty && $buffer->isEmpty()) {
$this->scanner->error('Expected token.');
}
return $buffer->buildInterpolation($this->scanner->spanFrom($start));
}
/**
* Consumes an identifier that may contain interpolation.
*/
protected function interpolatedIdentifier(): Interpolation
{
$start = $this->scanner->getPosition();
$buffer = new InterpolationBuffer();
if ($this->scanner->scanChar('-')) {
$buffer->write('-');
if ($this->scanner->scanChar('-')) {
$buffer->write('-');
$this->interpolatedIdentifierBody($buffer);
return $buffer->buildInterpolation($this->scanner->spanFrom($start));
}
}
$first = $this->scanner->peekChar();
if ($first === null) {
$this->scanner->error('Expected identifier.');
}
if (Character::isNameStart($first)) {
$buffer->write($this->scanner->readUtf8Char());
} elseif ($first === '\\') {
$buffer->write($this->escape(true));
} elseif ($first === '#' && $this->scanner->peekChar(1) === '{') {
$buffer->add($this->singleInterpolation());
} else {
$this->scanner->error('Expected identifier.');
}
$this->interpolatedIdentifierBody($buffer);
return $buffer->buildInterpolation($this->scanner->spanFrom($start));
}
/**
* Consumes a chunk of a possibly-interpolated CSS identifier after the name
* start, and adds the contents to the $buffer buffer.
*/
private function interpolatedIdentifierBody(InterpolationBuffer $buffer): void
{
while (true) {
$next = $this->scanner->peekChar();
if ($next === null) {
break;
}
if ($next === '_' || $next === '-' || Character::isAlphanumeric($next) || \ord($next) >= 0x80) {
$buffer->write($this->scanner->readUtf8Char());
} elseif ($next === '\\') {
$buffer->write($this->escape());
} elseif ($next === '#' && $this->scanner->peekChar(1) === '{') {
$buffer->add($this->singleInterpolation());
} else {
break;
}
}
}
/**
* Consumes interpolation.
*/
protected function singleInterpolation(): Expression
{
$start = $this->scanner->getPosition();
$this->scanner->expect('#{');
$this->whitespace();
$contents = $this->expression();
$this->scanner->expectChar('}');
if ($this->isPlainCss()) {
$this->error('Interpolation isn\'t allowed in plain CSS.', $this->scanner->spanFrom($start));
}
return $contents;
}
/**
* Consumes a list of media queries.
*/
private function mediaQueryList(): Interpolation
{
$start = $this->scanner->getPosition();
$buffer = new InterpolationBuffer();
while (true) {
$this->whitespace();
$this->mediaQuery($buffer);
$this->whitespace();
if (!$this->scanner->scanChar(',')) {
break;
}
$buffer->write(', ');
}
return $buffer->buildInterpolation($this->scanner->spanFrom($start));
}
/**
* Consumes a single media query.
*/
private function mediaQuery(InterpolationBuffer $buffer): void
{
if ($this->scanner->peekChar() === '(') {
$this->mediaInParens($buffer);
$this->whitespace();
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');
}
return;
}
$identifier1 = $this->interpolatedIdentifier();
if (StringUtil::equalsIgnoreCase($identifier1->getAsPlain(), 'not')) {
// For example, "@media not (...) {"
$this->expectWhitespace();
if (!$this->lookingAtInterpolatedIdentifier()) {
$buffer->write('not ');
$this->mediaOrInterp($buffer);
return;
}
}
$this->whitespace();
$buffer->addInterpolation($identifier1);
if (!$this->lookingAtInterpolatedIdentifier()) {
// For example, "@media screen {".
return;
}
$buffer->write(' ');
$identifier2 = $this->interpolatedIdentifier();
if (StringUtil::equalsIgnoreCase($identifier2->getAsPlain(), 'and')) {
$this->expectWhitespace();
// For example, "@media screen and ..."
$buffer->write(' and ');
} else {
$this->whitespace();
$buffer->addInterpolation($identifier2);
if ($this->scanIdentifier('and')) {
// For example, "@media only screen and ..."
$this->expectWhitespace();
$buffer->write(' and ');
} else {
// For example, "@media only screen {"
return;
}
}
// We've consumed either `IDENTIFIER "and"` or
// `IDENTIFIER IDENTIFIER "and"`.
if ($this->scanIdentifier('not')) {
// For example, "@media screen and not (...) {"
$this->expectWhitespace();
$buffer->write('not ');
$this->mediaOrInterp($buffer);
return;
}
$this->mediaLogicSequence($buffer, 'and');
}
/**
* Consumes one or more `MediaOrInterp` expressions separated by $operator
* and writes them to $buffer.
*/
private function mediaLogicSequence(InterpolationBuffer $buffer, string $operator): void
{
while (true) {
$this->mediaOrInterp($buffer);
$this->whitespace();
if (!$this->scanIdentifier($operator)) {
return;
}
$this->expectWhitespace();
$buffer->write(' ');
$buffer->write($operator);
$buffer->write(' ');
}
}
/**
* Consumes a `MediaOrInterp` expression and writes it to $buffer.
*/
private function mediaOrInterp(InterpolationBuffer $buffer): void
{
if ($this->scanner->peekChar() === '#') {
$interpolation = $this->singleInterpolation();
$buffer->addInterpolation(new Interpolation([$interpolation], $interpolation->getSpan()));
} else {
$this->mediaInParens($buffer);
}
}
/**
* Consumes a `MediaInParens` expression and writes it to $buffer.
*/
private function mediaInParens(InterpolationBuffer $buffer): void
{
$this->scanner->expectChar('(', 'media condition in parentheses');
$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(':')) {
$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(' ');
$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());
}
}
}
$this->scanner->expectChar(')');
$this->whitespace();
$buffer->write(')');
}
/**
* Consumes an expression until it reaches a top-level `<`, `>`, or a `=`
* that's not `==`.
*/
private function expressionUntilComparison(): Expression
{
return $this->expression(function () {
$next = $this->scanner->peekChar();
if ($next === '=') {
return $this->scanner->peekChar(1) !== '=';
}
return $next === '<' || $next === '>';
});
}
/**
* Consumes a `@supports` condition.
*/
private function supportsCondition(): SupportsCondition
{
$start = $this->scanner->getPosition();
if ($this->scanIdentifier('not')) {
$this->whitespace();
return new SupportsNegation($this->supportsConditionInParens(), $this->scanner->spanFrom($start));
}
$condition = $this->supportsConditionInParens();
$this->whitespace();
$operator = null;
while ($this->lookingAtIdentifier()) {
if ($operator !== null) {
$this->expectIdentifier($operator);
} elseif ($this->scanIdentifier('or')) {
$operator = 'or';
} else {
$this->expectIdentifier('and');
$operator = 'and';
}
$this->whitespace();
$right = $this->supportsConditionInParens();
$condition = new SupportsOperation($condition, $right, $operator, $this->scanner->spanFrom($start));
$this->whitespace();
}
return $condition;
}
/**
* Consumes a parenthesized supports condition, or an interpolation.
*/
private function supportsConditionInParens(): SupportsCondition
{
$start = $this->scanner->getPosition();
if ($this->lookingAtInterpolatedIdentifier()) {
$identifier = $this->interpolatedIdentifier();
if ($identifier->getAsPlain() !== null && strtolower($identifier->getAsPlain()) === 'not') {
$this->error('"not" is not a valid identifier here.', $identifier->getSpan());
}
if ($this->scanner->scanChar('(')) {
$arguments = $this->interpolatedDeclarationValue(true, true);
$this->scanner->expectChar(')');
return new SupportsFunction($identifier, $arguments, $this->scanner->spanFrom($start));
}
if (\count($identifier->getContents()) !== 1 || !$identifier->getContents()[0] instanceof Expression) {
$this->error('Expected @supports condition.', $identifier->getSpan());
} else {
return new SupportsInterpolation($identifier->getContents()[0], $identifier->getSpan());
}
}
$this->scanner->expectChar('(');
$this->whitespace();
if ($this->scanIdentifier('not')) {
$this->whitespace();
$condition = $this->supportsConditionInParens();
$this->scanner->expectChar(')');
return new SupportsNegation($condition, $this->scanner->spanFrom($start));
}
if ($this->scanner->peekChar() === '(') {
$condition = $this->supportsCondition();
$this->scanner->expectChar(')');
return $condition;
}
// Unfortunately, we may have to backtrack here. The grammar is:
//
// Expression ":" Expression
// | InterpolatedIdentifier InterpolatedAnyValue?
//
// These aren't ambiguous because this `InterpolatedAnyValue` is forbidden
// from containing a top-level colon, but we still have to parse the full
// expression to figure out if there's a colon after it.
//
// We could avoid the overhead of a full expression parse by looking ahead
// for a colon (outside of balanced brackets), but in practice we expect the
// vast majority of real uses to be `Expression ":" Expression`, so it makes
// sense to parse that case faster in exchange for less code complexity and
// a slower backtracking case.
$nameStart = $this->scanner->getPosition();
$wasInParentheses = $this->inParentheses;
try {
$name = $this->expression();
$this->scanner->expectChar(':');
} catch (FormatException $e) {
$this->scanner->setPosition($nameStart);
$this->inParentheses = $wasInParentheses;
$identifier = $this->interpolatedIdentifier();
$operation = $this->trySupportsOperation($identifier, $nameStart);
if ($operation !== null) {
$this->scanner->expectChar(')');
return $operation;
}
// If parsing an expression fails, try to parse an
// `InterpolatedAnyValue` instead. But if that value runs into a
// top-level colon, then this is probably intended to be a declaration
// after all, so we rethrow the declaration-parsing error.
$buffer = new InterpolationBuffer();
$buffer->addInterpolation($identifier);
$buffer->addInterpolation($this->interpolatedDeclarationValue(true, true, false));
$contents = $buffer->buildInterpolation($this->scanner->spanFrom($nameStart));
if ($this->scanner->peekChar() === ':') {
throw $e;
}
$this->scanner->expectChar(')');
return new SupportsAnything($contents, $this->scanner->spanFrom($start));
}
$declaration = $this->supportsDeclarationValue($name, $start);
$this->scanner->expectChar(')');
return $declaration;
}
private function supportsDeclarationValue(Expression $name, int $start): SupportsDeclaration
{
if ($name instanceof StringExpression && !$name->hasQuotes() && StringUtil::startsWith($name->getText()->getInitialPlain(), '--')) {
$value = new StringExpression($this->interpolatedDeclarationValue());
} else {
$this->whitespace();
$value = $this->expression();
}
return new SupportsDeclaration($name, $value, $this->scanner->spanFrom($start));
}
/**
* If $interpolation is followed by `"and"` or `"or"`, parse it as a supports operation.
*
* Otherwise, return `null` without moving the scanner position.
*/
private function trySupportsOperation(Interpolation $interpolation, int $start): ?SupportsOperation
{
if (\count($interpolation->getContents()) !== 1) {
return null;
}
$expression = $interpolation->getContents()[0];
if (!$expression instanceof Expression) {
return null;
}
$beforeWhitespace = $this->scanner->getPosition();
$this->whitespace();
$operation = null;
$operator = null;
while ($this->lookingAtIdentifier()) {
if ($operator !== null) {
$this->expectIdentifier($operator);
} elseif ($this->scanIdentifier('and')) {
$operator = 'and';
} elseif ($this->scanIdentifier('or')) {
$operator = 'or';
} else {
$this->scanner->setPosition($beforeWhitespace);
return null;
}
$this->whitespace();
$right = $this->supportsConditionInParens();
$operation = new SupportsOperation($operation ?? new SupportsInterpolation($expression, $interpolation->getSpan()), $right, $operator, $this->scanner->spanFrom($start));
$this->whitespace();
}
return $operation;
}
/**
* Returns whether the scanner is immediately before an identifier that may
* contain interpolation.
*
* This is based on [the CSS algorithm][], but it assumes all backslashes
* start escapes and it considers interpolation to be valid in an identifier.
*
* [the CSS algorithm]: https://drafts.csswg.org/css-syntax-3/#would-start-an-identifier
*/
private function lookingAtInterpolatedIdentifier(): bool
{
$first = $this->scanner->peekChar();
if ($first === null) {
return false;
}
if ($first === '\\' || Character::isNameStart($first)) {
return true;
}
if ($first === '#' && $this->scanner->peekChar(1) === '{') {
return true;
}
if ($first !== '-') {
return false;
}
$second = $this->scanner->peekChar(1);
if ($second === null) {
return false;
}
if ($second === '#') {
return $this->scanner->peekChar(2) === '{';
}
return $second === '\\' || $second === '-' || Character::isNameStart($second);
}
/**
* Returns whether the scanner is immediately before a sequence of characters
* that could be part of an CSS identifier body.
*
* The identifier body may include interpolation.
*/
private function lookingAtInterpolatedIdentifierBody(): bool
{
$first = $this->scanner->peekChar();
if ($first === null) {
return false;
}
if ($first === '\\' || Character::isName($first)) {
return true;
}
return $first === '#' && $this->scanner->peekChar(1) === '{';
}
/**
* Returns whether the scanner is immediately before a SassScript expression.
*/
private function lookingAtExpression(): bool
{
$character = $this->scanner->peekChar();
if ($character === null) {
return false;
}
if ($character === '.') {
return $this->scanner->peekChar(1) !== '.';
}
if ($character === '!') {
$next = $this->scanner->peekChar(1);
return $next === null || $next === 'i' || $next === 'I' || Character::isWhitespace($next);
}
return $character === '(' ||
$character === '/' ||
$character === '[' ||
$character === "'" ||
$character === '"' ||
$character === '#' ||
$character === '+' ||
$character === '-' ||
$character === '\\' ||
$character === '$' ||
$character === '&' ||
Character::isNameStart($character) ||
Character::isDigit($character);
}
/**
* Consumes a block of $child statements and passes them, as well as the
* span from $start to the end of the child block, to $create.
*
* @template T
* @param callable(): Statement $child
* @param callable(Statement[], FileSpan): T $create
* @return T
*/
private function withChildren(callable $child, int $start, callable $create)
{
$children = $this->children($child);
$result = $create($children, $this->scanner->spanFrom($start));
$this->whitespaceWithoutComments();
return $result;
}
/**
* Like {@see identifier}, but rejects identifiers that begin with `_` or `-`.
*/
private function publicIdentifier(): string
{
$start = $this->scanner->getPosition();
$result = $this->identifier(true);
$this->assertPublic($result, function () use ($start) {
return $this->scanner->spanFrom($start);
});
return $result;
}
/**
* Throws an error if $identifier isn't public.
*
* Calls $span to provide the span for an error if one occurs.
*
* @param callable(): FileSpan $span
*/
private function assertPublic(string $identifier, callable $span): void
{
if (!Character::isPrivate($identifier)) {
return;
}
$this->error("Private members can't be accessed from outside their modules.", $span());
}
/**
* Whether this is parsing the indented syntax.
*/
abstract protected function isIndented(): bool;
/**
* Whether this is a plain CSS stylesheet.
*/
protected function isPlainCss(): bool
{
return false;
}
/**
* The indentation level at the current scanner position.
*
* This value isn't used directly by StylesheetParser; it's just passed to
* {@see scanElse}.
*/
abstract protected function getCurrentIndentation(): int;
/**
* Parses and returns a selector used in a style rule.
*/
abstract protected function styleRuleSelector(): Interpolation;
/**
* Asserts that the scanner is positioned before a statement separator, or at
* the end of a list of statements.
*
* If the name of the parent rule is passed, it's used for error reporting.
*
* This consumes whitespace, but nothing else, including comments.
*
* @throws FormatException
*/
abstract protected function expectStatementSeparator(?string $name = null): void;
/**
* Whether the scanner is positioned at the end of a statement.
*/
abstract protected function atEndOfStatement(): bool;
/**
* Whether the scanner is positioned before a block of children that can be
* parsed with {@see children}.
*/
abstract protected function lookingAtChildren(): bool;
/**
* Tries to scan an `@else` rule after an `@if` block, and returns whether that succeeded.
*
* This should just scan the rule name, not anything afterwards.
* $ifIndentation is the result of {@see getCurrentIndentation} from before the
* corresponding `@if` was parsed.
*/
abstract protected function scanElse(int $ifIndentation): bool;
/**
* Consumes a block of child statements.
*
* Unlike most production consumers, this does *not* consume trailing
* whitespace. This is necessary to ensure that the source span for the
* parent rule doesn't cover whitespace after the rule.
*
* @param callable(): Statement $child
*
* @return Statement[]
*/
abstract protected function children(callable $child): array;
/**
* Consumes top-level statements.
*
* The $statement callback may return `null`, indicating that a statement
* was consumed that shouldn't be added to the AST.
*
* @param callable(): ?Statement $statement
*
* @return Statement[]
*/
abstract protected function statements(callable $statement): array;
}