%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/Parser.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 ScssPhp\ScssPhp\Exception\SassFormatException;
use ScssPhp\ScssPhp\Logger\AdaptingLogger;
use ScssPhp\ScssPhp\Logger\LocationAwareLoggerInterface;
use ScssPhp\ScssPhp\Logger\LoggerInterface;
use ScssPhp\ScssPhp\Logger\QuietLogger;
use ScssPhp\ScssPhp\SourceSpan\FileSpan;
use ScssPhp\ScssPhp\Util;
use ScssPhp\ScssPhp\Util\Character;
use ScssPhp\ScssPhp\Util\ParserUtil;
/**
* @internal
*/
class Parser
{
/**
* @var StringScanner
* @readonly
*/
protected $scanner;
/**
* @var LocationAwareLoggerInterface
* @readonly
*/
protected $logger;
/**
* @var string|null
* @readonly
*/
protected $sourceUrl;
/**
* Parses $text as a CSS identifier and returns the result.
*
* @throws SassFormatException if parsing fails.
*/
public static function parseIdentifier(string $text, ?LoggerInterface $logger = null): string
{
return (new Parser($text, $logger))->doParseIdentifier();
}
/**
* Returns whether $text is a valid CSS identifier.
*/
public static function isIdentifier(string $text, ?LoggerInterface $logger = null): bool
{
try {
self::parseIdentifier($text, $logger);
return true;
} catch (SassFormatException $e) {
return false;
}
}
public function __construct(string $contents, ?LoggerInterface $logger = null, ?string $sourceUrl = null)
{
$this->scanner = new StringScanner($contents);
$this->logger = AdaptingLogger::adaptLogger($logger ?? new QuietLogger());
$this->sourceUrl = $sourceUrl;
}
/**
* @throws SassFormatException
*/
private function doParseIdentifier(): string
{
try {
$result = $this->identifier();
$this->scanner->expectDone();
return $result;
} catch (FormatException $e) {
throw $this->wrapException($e);
}
}
/**
* Consumes whitespace, including any comments.
*/
protected function whitespace(): void
{
do {
$this->whitespaceWithoutComments();
} while ($this->scanComment());
}
/**
* Consumes whitespace, but not comments.
*/
protected function whitespaceWithoutComments(): void
{
while (!$this->scanner->isDone() && Character::isWhitespace($this->scanner->peekChar())) {
$this->scanner->readChar();
}
}
/**
* Consumes spaces and tabs.
*/
protected function spaces(): void
{
while (!$this->scanner->isDone() && Character::isSpaceOrTab($this->scanner->peekChar())) {
$this->scanner->readChar();
}
}
/**
* Consumes and ignores a comment if possible.
*
* Returns whether the comment was consumed.
*/
protected function scanComment(): bool
{
if ($this->scanner->peekChar() !== '/') {
return false;
}
$next = $this->scanner->peekChar(1);
if ($next === '/') {
$this->silentComment();
return true;
}
if ($next === '*') {
$this->loudComment();
return true;
}
return false;
}
/**
* Like {@see whitespace}, but throws an error if no whitespace is consumed.
*/
protected function expectWhitespace(): void
{
if ($this->scanner->isDone() || !(Character::isWhitespace($this->scanner->peekChar()) || $this->scanComment())) {
$this->scanner->error('Expected whitespace.');
}
$this->whitespace();
}
/**
* Consumes and ignores a silent (Sass-style) comment.
*/
protected function silentComment(): void
{
$this->scanner->expect('//');
while (!$this->scanner->isDone() && !Character::isNewline($this->scanner->peekChar())) {
$this->scanner->readChar();
}
}
/**
* Consumes and ignores a loud (CSS-style) comment.
*/
protected function loudComment(): void
{
$this->scanner->expect('/*');
while (true) {
$next = $this->scanner->readChar();
if ($next !== '*') {
continue;
}
do {
$next = $this->scanner->readChar();
} while ($next === '*');
if ($next === '/') {
break;
}
}
}
/**
* Consumes a plain CSS identifier.
*
* If $normalize is `true`, this converts underscores into hyphens.
*
* If $unit is `true`, this doesn't parse a `-` followed by a digit. This
* ensures that `1px-2px` parses as subtraction rather than the unit
* `px-2px`.
*/
protected function identifier(bool $normalize = false, bool $unit = false): string
{
$text = '';
if ($this->scanner->scanChar('-')) {
$text .= '-';
if ($this->scanner->scanChar('-')) {
$text .= '-';
$text .= $this->consumeIdentifierBody($normalize, $unit);
return $text;
}
}
$first = $this->scanner->peekChar();
if ($first === null) {
$this->scanner->error('Expected identifier.');
}
if ($normalize && $first === '_') {
$this->scanner->readChar();
$text .= '-';
} elseif (Character::isNameStart($first)) {
$text .= $this->scanner->readUtf8Char();
} elseif ($first === '\\') {
$text .= $this->escape(true);
} else {
$this->scanner->error('Expected identifier.');
}
$text .= $this->consumeIdentifierBody($normalize, $unit);
return $text;
}
/**
* Consumes a chunk of a plain CSS identifier after the name start.
*/
public function identifierBody(): string
{
$text = $this->consumeIdentifierBody();
if ($text === '') {
$this->scanner->error('Expected identifier body.');
}
return $text;
}
private function consumeIdentifierBody(bool $normalize = false, bool $unit = false): string
{
$text = '';
while (true) {
$next = $this->scanner->peekChar();
if ($next === null) {
break;
}
if ($unit && $next === '-') {
$second = $this->scanner->peekChar(1);
if ($second !== null && ($second === '.' || Character::isDigit($second))) {
break;
}
$text .= $this->scanner->readChar();
} elseif ($normalize && $next === '_') {
$this->scanner->readChar();
$text .= '-';
} elseif (Character::isName($next)) {
$text .= $this->scanner->readUtf8Char();
} elseif ($next === '\\') {
$text .= $this->escape();
} else {
break;
}
}
return $text;
}
/**
* Consumes a plain CSS string.
*
* This returns the parsed contents of the string—that is, it doesn't include
* quotes and its escapes are resolved.
*/
protected function string(): string
{
$quote = $this->scanner->readChar();
if ($quote !== '"' && $quote !== "'") {
$this->scanner->error('Expected string.');
}
$buffer = '';
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 ($second !== null && Character::isNewline($second)) {
$this->scanner->readChar();
$this->scanner->readChar();
} else {
$buffer .= $this->escapeCharacter();
}
} else {
$buffer .= $this->scanner->readUtf8Char();
}
}
return $buffer;
}
/**
* Consumes and returns a natural number (that is, a non-negative integer) as a double.
*
* Doesn't support scientific notation.
*/
protected function naturalNumber(): float
{
$first = $this->scanner->readChar();
if (!Character::isDigit($first)) {
$this->scanner->error('Expected digit.', $this->scanner->getPosition() - 1);
}
$number = (float) intval($first);
while (Character::isDigit($this->scanner->peekChar())) {
$number *= 10;
$number += intval($this->scanner->readChar());
}
return $number;
}
/**
* 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.
*/
protected function declarationValue(bool $allowEmpty = false): string
{
$buffer = '';
$brackets = [];
$wroteNewline = false;
while (true) {
$next = $this->scanner->peekChar();
if ($next === null) {
break;
}
switch ($next) {
case '\\':
$buffer .= $this->escape(true);
$wroteNewline = false;
break;
case '"':
case "'":
$buffer .= $this->rawText([$this, 'string']);
$wroteNewline = false;
break;
case '/':
if ($this->scanner->peekChar(1) === '*') {
$buffer .= $this->rawText([$this, 'loudComment']);
} else {
$buffer .= $this->scanner->readChar();
}
$wroteNewline = false;
break;
case ' ':
case "\t":
$second = $this->scanner->peekChar(1);
if ($wroteNewline || $second === null || !Character::isWhitespace($second)) {
$buffer .= ' ';
}
$this->scanner->readChar();
break;
case "\n":
case "\r":
case "\f":
$prev = $this->scanner->peekChar(-1);
if ($prev === null || !Character::isNewline($prev)) {
$buffer .= "\n";
}
$this->scanner->readChar();
$wroteNewline = true;
break;
case '(':
case '{':
case '[':
$buffer .= $next;
$brackets[] = Character::opposite($this->scanner->readChar());
$wroteNewline = false;
break;
case ')':
case '}':
case ']':
if (empty($brackets)) {
break 2;
}
$buffer .= $next;
$this->scanner->expectChar(array_pop($brackets));
$wroteNewline = false;
break;
case ';':
if (empty($brackets)) {
break 2;
}
$buffer .= $this->scanner->readChar();
break;
case 'u':
case 'U':
$url = $this->tryUrl();
if ($url !== null) {
$buffer .= $url;
} else {
$buffer .= $this->scanner->readChar();
}
$wroteNewline = false;
break;
default:
if ($this->lookingAtIdentifier()) {
$buffer .= $this->identifier();
} else {
$buffer .= $this->scanner->readUtf8Char();
}
$wroteNewline = false;
break;
}
}
if (!empty($brackets)) {
$this->scanner->expectChar(array_pop($brackets));
}
if (!$allowEmpty && $buffer === '') {
$this->scanner->error('Expected token.');
}
return $buffer;
}
/**
* Consumes a `url()` token if possible, and returns `null` otherwise.
*/
protected function tryUrl(): ?string
{
$start = $this->scanner->getPosition();
if (!$this->scanIdentifier('url')) {
return null;
}
if (!$this->scanner->scanChar('(')) {
$this->scanner->setPosition($start);
return null;
}
$this->whitespace();
$buffer = 'url(';
while (true) {
$next = $this->scanner->peekChar();
if ($next === null) {
break;
}
$nextCharCode = \ord($next);
if ($next === '\\') {
$buffer .= $this->escape();
} elseif ($next === '%' || $next === '&' || $next === '#' || ($nextCharCode >= \ord('*') && $nextCharCode <= \ord('~')) || $nextCharCode >= 0x80) {
$buffer .= $this->scanner->readUtf8Char();
} elseif (Character::isWhitespace($next)) {
$this->whitespace();
if ($this->scanner->peekChar() !== ')') {
break;
}
} elseif ($next === ')') {
$buffer .= $this->scanner->readChar();
return $buffer;
} else {
break;
}
}
$this->scanner->setPosition($start);
return null;
}
/**
* Consumes a Sass variable name, and returns its name without the dollar sign.
*/
protected function variableName(): string
{
$this->scanner->expectChar('$');
return $this->identifier(true);
}
/**
* Consumes an escape sequence and returns the text that defines it.
*
* If $identifierStart is true, this normalizes the escape sequence as
* though it were at the beginning of an identifier.
*/
protected function escape(bool $identifierStart = false): string
{
$start = $this->scanner->getPosition();
$this->scanner->expectChar('\\');
$first = $this->scanner->peekChar();
if ($first === null) {
$this->scanner->error('Expected escape sequence.');
}
if (Character::isNewline($first)) {
$this->scanner->error('Expected escape sequence.');
}
if (Character::isHex($first)) {
$value = 0;
for ($i = 0; $i < 6; $i++) {
$next = $this->scanner->peekChar();
if ($next === null || !Character::isHex($next)) {
break;
}
$value *= 16;
$value += hexdec($this->scanner->readChar());
assert(\is_int($value));
}
$this->scanCharIf([Character::class, 'isWhitespace']);
$valueText = Util::mbChr($value);
} else {
$valueText = $this->scanner->readUtf8Char();
$value = Util::mbOrd($valueText);
}
if ($identifierStart ? Character::isNameStart($valueText) : Character::isName($valueText)) {
if ($value > 0x10ffff) {
$this->scanner->error('Invalid Unicode code point.', $start);
}
return $valueText;
}
if ($value < 0x1f || $valueText === "\x7f" || ($identifierStart && Character::isDigit($valueText))) {
return '\\' . bin2hex($valueText) . ' ';
}
return '\\' . $valueText;
}
/**
* Consumes an escape sequence and returns the character it represents.
*/
protected function escapeCharacter(): string
{
return ParserUtil::consumeEscapedCharacter($this->scanner);
}
/**
* @param callable(string): bool $condition
*
* @phpstan-impure
*/
protected function scanCharIf(callable $condition): bool
{
$next = $this->scanner->peekChar();
if ($next === null || !$condition($next)) {
return false;
}
$this->scanner->readChar();
return true;
}
/**
* Consumes the next character or escape sequence if it matches $character.
*
* Matching will be case-insensitive unless $caseSensitive is true.
* When matching case-insensitively, $character must be passed in lowercase.
*
* This only supports ASCII identifier characters.
*/
protected function scanIdentChar(string $character, bool $caseSensitive = false): bool
{
$matches = function (string $actual) use ($character, $caseSensitive): bool {
if ($caseSensitive) {
return $actual === $character;
}
return \strtolower($actual) === $character;
};
$next = $this->scanner->peekChar();
if ($next !== null && $matches($next)) {
$this->scanner->readChar();
return true;
}
if ($next === '\\') {
$start = $this->scanner->getPosition();
if ($matches($this->escapeCharacter())) {
return true;
}
$this->scanner->setPosition($start);
}
return false;
}
/**
* Consumes the next character or escape sequence and asserts it matches $char.
*
* Matching will be case-insensitive unless $caseSensitive is true.
* When matching case-insensitively, $char must be passed in lowercase.
*
* This only supports ASCII identifier characters.
*/
protected function expectIdentChar(string $char, bool $caseSensitive = false): void
{
if ($this->scanIdentChar($char, $caseSensitive)) {
return;
}
$this->scanner->error("Expected \"$char\"");
}
/**
* Returns whether the scanner is immediately before a number.
*
* This follows [the CSS algorithm][].
*
* [the CSS algorithm]: https://drafts.csswg.org/css-syntax-3/#starts-with-a-number
*/
protected function lookingAtNumber(): bool
{
$first = $this->scanner->peekChar();
if ($first === null) {
return false;
}
if (Character::isDigit($first)) {
return true;
}
if ($first === '.') {
$second = $this->scanner->peekChar(1);
return $second !== null && Character::isDigit($second);
}
if ($first === '+' || $first === '-') {
$second = $this->scanner->peekChar(1);
if ($second === null) {
return false;
}
if (Character::isDigit($second)) {
return true;
}
if ($second !== '.') {
return false;
}
$third = $this->scanner->peekChar(2);
return $third !== null && Character::isDigit($third);
}
return false;
}
/**
* Returns whether the scanner is immediately before a plain CSS identifier.
*
* If $forward is passed, this looks that many characters forward instead.
*
* This is based on [the CSS algorithm][], but it assumes all backslashes
* start escapes.
*
* [the CSS algorithm]: https://drafts.csswg.org/css-syntax-3/#would-start-an-identifier
*/
protected function lookingAtIdentifier(int $forward = 0): bool
{
$first = $this->scanner->peekChar($forward);
if ($first === null) {
return false;
}
if ($first === '\\' || Character::isNameStart($first)) {
return true;
}
if ($first !== '-') {
return false;
}
$second = $this->scanner->peekChar($forward + 1);
if ($second === null) {
return false;
}
return $second === '\\' || $second === '-' || Character::isNameStart($second);
}
/**
* Returns whether the scanner is immediately before a sequence of characters
* that could be part of a plain CSS identifier body.
*/
protected function lookingAtIdentifierBody(): bool
{
$next = $this->scanner->peekChar();
return $next !== null && ($next === '\\' || Character::isName($next));
}
/**
* Consumes an identifier if its name exactly matches $text.
*
* When matching case-insensitively, $text must be passed in lowercase.
*
* This only supports ASCII identifiers.
*/
protected function scanIdentifier(string $text, bool $caseSensitive = false): bool
{
if (!$this->lookingAtIdentifier()) {
return false;
}
$start = $this->scanner->getPosition();
if ($this->consumeIdentifier($text, $caseSensitive) && !$this->lookingAtIdentifierBody()) {
return true;
}
$this->scanner->setPosition($start);
return false;
}
/**
* Returns whether an identifier whose name exactly matches $text is at the
* current scanner position.
*
* This doesn't move the scan pointer forward
*/
protected function matchesIdentifier(string $text, bool $caseSensitive = false): bool
{
if (!$this->lookingAtIdentifier()) {
return false;
}
$start = $this->scanner->getPosition();
$result = $this->consumeIdentifier($text, $caseSensitive) && !$this->lookingAtIdentifierBody();
$this->scanner->setPosition($start);
return $result;
}
/**
* Consumes $text as an identifer, but doesn't verify whether there's
* additional identifier text afterwards.
*
* Returns `true` if the full $text is consumed and `false` otherwise, but
* doesn't reset the scan pointer.
*/
private function consumeIdentifier(string $text, bool $caseSensitive): bool
{
for ($i = 0; $i < \strlen($text); $i++) {
if (!$this->scanIdentChar($text[$i], $caseSensitive)) {
return false;
}
}
return true;
}
/**
* Consumes an identifier asserts that its name exactly matches $text.
*
* When matching case-insensitively, $text must be passed in lowercase.
*
* This only supports ASCII identifiers.
*/
protected function expectIdentifier(string $text, ?string $name = null, bool $caseSensitive = false): void
{
$name = $name ?? "\"$text\"";
$start = $this->scanner->getPosition();
for ($i = 0; $i < \strlen($text); $i++) {
if ($this->scanIdentChar($text[$i], $caseSensitive)) {
continue;
}
$this->scanner->error("Expected $name.", $start);
}
if (!$this->lookingAtIdentifierBody()) {
return;
}
$this->scanner->error("Expected $name.", $start);
}
/**
* Runs $consumer and returns the source text that it consumes.
*
* @param callable(): void $consumer
*/
protected function rawText(callable $consumer): string
{
$start = $this->scanner->getPosition();
$consumer();
return $this->scanner->substring($start);
}
/**
* Prints a warning to standard error, associated with $span.
*/
protected function warn(string $message, FileSpan $span): void
{
$this->logger->warn($message, false, $span);
}
/**
* Throws an error associated with $position.
*
* @throws FormatException
*
* @return never-returns
*/
protected function error(string $message, FileSpan $span, ?\Throwable $previous = null): void
{
throw new FormatException($message, $span, $previous);
}
protected function wrapException(FormatException $error): SassFormatException
{
$span = $error->getSpan();
if ($span->getLength() === 0 && 0 === stripos($error->getMessage(), 'expected')) {
$startPosition = $this->firstNewlineBefore($span->getStart()->getOffset());
if ($startPosition !== $span->getStart()->getOffset()) {
$span = $span->getFile()->span($startPosition, $startPosition);
}
}
return new SassFormatException($error->getMessage(), $span, $error);
}
/**
* If [position] is separated from the previous non-whitespace character in
* `$scanner->getString()` by one or more newlines, returns the offset of the last
* separating newline.
*
* Otherwise returns $position.
*
* This helps avoid missing token errors pointing at the next closing bracket
* rather than the line where the problem actually occurred.
*
* @param int $position
*
* @return int
*/
private function firstNewlineBefore(int $position): int
{
$index = $position - 1;
$lastNewline = null;
$string = $this->scanner->getString();
while ($index >= 0) {
$char = $string[$index];
if (!Character::isWhitespace($char)) {
return $lastNewline ?? $position;
}
if (Character::isNewline($char)) {
$lastNewline = $index;
}
$index--;
}
// If the document *only* contains whitespace before $position, always
// return $position.
return $position;
}
}