mirror of
https://github.com/danog/dart-sass.git
synced 2024-12-11 16:49:48 +01:00
1401 lines
39 KiB
Dart
1401 lines
39 KiB
Dart
// Copyright 2016 Google Inc. Use of this source code is governed by an
|
|
// MIT-style license that can be found in the LICENSE file or at
|
|
// https://opensource.org/licenses/MIT.
|
|
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:charcode/charcode.dart';
|
|
import 'package:string_scanner/string_scanner.dart';
|
|
|
|
import 'ast/sass/expression.dart';
|
|
import 'ast/sass/statement.dart';
|
|
import 'ast/selector.dart';
|
|
import 'interpolation_buffer.dart';
|
|
import 'util/character.dart';
|
|
import 'utils.dart';
|
|
import 'value.dart';
|
|
|
|
final _selectorPseudoClasses =
|
|
new Set.from(["not" "matches" "current" "any" "has" "host" "host-context"]);
|
|
|
|
final _prefixedSelectorPseudoClasses =
|
|
new Set.from(["nth-child" "nth-last-child"]);
|
|
|
|
class Parser {
|
|
final SpanScanner _scanner;
|
|
|
|
Parser(String contents, {url})
|
|
: _scanner = new SpanScanner(contents, sourceUrl: url);
|
|
|
|
// Conventions:
|
|
//
|
|
// * All statement functions consume through following whitespace, including
|
|
// comments. No other functions do so unless explicitly specified.
|
|
|
|
// ## Statements
|
|
|
|
Stylesheet parse() {
|
|
var start = _scanner.state;
|
|
var children = <Statement>[];
|
|
while (true) {
|
|
children.addAll(_comments());
|
|
if (_scanner.isDone) break;
|
|
switch (_scanner.peekChar()) {
|
|
case $dollar:
|
|
children.add(_variableDeclaration());
|
|
break;
|
|
|
|
case $at:
|
|
children.add(_atRule());
|
|
break;
|
|
|
|
case $semicolon:
|
|
_scanner.readChar();
|
|
break;
|
|
|
|
default:
|
|
children.add(_styleRule());
|
|
break;
|
|
}
|
|
}
|
|
|
|
_scanner.expectDone();
|
|
return new Stylesheet(children, span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
SelectorList parseSelector() {
|
|
var selector = _selectorList();
|
|
_scanner.expectDone();
|
|
return selector;
|
|
}
|
|
|
|
SimpleSelector parseSimpleSelector() {
|
|
var simple = _simpleSelector();
|
|
_scanner.expectDone();
|
|
return simple;
|
|
}
|
|
|
|
VariableDeclaration _variableDeclaration() {
|
|
if (!_scanner.scanChar($dollar)) return null;
|
|
|
|
var start = _scanner.state;
|
|
var name = _identifier();
|
|
_ignoreComments();
|
|
_scanner.expectChar($colon);
|
|
_ignoreComments();
|
|
|
|
var expression = _expression();
|
|
|
|
var guarded = false;
|
|
var global = false;
|
|
while (_scanner.scanChar($exclamation)) {
|
|
var flagStart = _scanner.position - 1;
|
|
var flag = _identifier();
|
|
if (flag == 'default') {
|
|
guarded = true;
|
|
} else if (flag == 'global') {
|
|
global = true;
|
|
} else {
|
|
_scanner.error("Invalid flag name.",
|
|
position: flagStart,
|
|
length: _scanner.position - flagStart);
|
|
}
|
|
|
|
_ignoreComments();
|
|
}
|
|
|
|
return new VariableDeclaration(name, expression,
|
|
guarded: guarded, global: global, span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
Statement _atRule() {
|
|
var start = _scanner.state;
|
|
_scanner.expectChar($at);
|
|
var name = _identifier();
|
|
_ignoreComments();
|
|
|
|
switch (name) {
|
|
case "media":
|
|
return new MediaRule(_mediaQueryList(), _ruleChildren(),
|
|
span: _scanner.spanFrom(start));
|
|
case "extend":
|
|
return new ExtendRule(_almostAnyValue(),
|
|
span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
InterpolationExpression value;
|
|
var next = _scanner.peekChar();
|
|
if (next != $exclamation && next != $semicolon && next != $lbrace &&
|
|
next != $rbrace && next != null) {
|
|
value = _almostAnyValue();
|
|
}
|
|
|
|
return new AtRule(name,
|
|
value: value,
|
|
children: _scanner.peekChar() == $lbrace ? _ruleChildren() : null,
|
|
span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
StyleRule _styleRule() {
|
|
var start = _scanner.state;
|
|
var selector = _almostAnyValue();
|
|
var children = _ruleChildren();
|
|
return new StyleRule(selector, children,
|
|
span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
List<Statement> _ruleChildren() {
|
|
_scanner.expectChar($lbrace);
|
|
var children = <Statement>[];
|
|
loop: while (true) {
|
|
children.addAll(_comments());
|
|
switch (_scanner.peekChar()) {
|
|
case $dollar:
|
|
children.add(_variableDeclaration());
|
|
break;
|
|
|
|
case $at:
|
|
children.add(_atRule());
|
|
break;
|
|
|
|
case $semicolon:
|
|
_scanner.readChar();
|
|
break;
|
|
|
|
case $rbrace:
|
|
break loop;
|
|
|
|
default:
|
|
children.add(_declarationOrStyleRule());
|
|
break;
|
|
}
|
|
}
|
|
|
|
children.addAll(_comments());
|
|
_scanner.expectChar($rbrace);
|
|
return children;
|
|
}
|
|
|
|
Expression _declarationExpression() {
|
|
if (_scanner.peekChar() == $lbrace) {
|
|
return new StringExpression(
|
|
new InterpolationExpression([], span: _scanner.emptySpan));
|
|
}
|
|
|
|
// TODO: parse static values specially?
|
|
return _expression();
|
|
}
|
|
|
|
/// Parses a [Declaration] or a [StyleRule].
|
|
///
|
|
/// When parsing the contents of a style rule, it can be difficult to tell
|
|
/// declarations apart from nested style rules. Since we don't thoroughly
|
|
/// parse selectors until after resolving interpolation, we can share a bunch
|
|
/// of the parsing of the two, but we need to disambiguate them first. We use
|
|
/// the following criteria:
|
|
///
|
|
/// * If the entity doesn't start with an identifier followed by a colon,
|
|
/// it's a selector. There are some additional mostly-unimportant cases
|
|
/// here to support various declaration hacks.
|
|
///
|
|
/// * If the colon is followed by another colon, it's a selector.
|
|
///
|
|
/// * Otherwise, if the colon is followed by anything other than
|
|
/// interpolation or a character that's valid as the beginning of an
|
|
/// identifier, it's a declaration.
|
|
///
|
|
/// * If the colon is followed by interpolation or a valid identifier, try
|
|
/// parsing it as a declaration value. If this fails, backtrack and parse
|
|
/// it as a selector.
|
|
///
|
|
/// * If the declaration value value valid but is followed by "{", backtrack
|
|
/// and parse it as a selector anyway. This ensures that ".foo:bar {" is
|
|
/// always parsed as a selector and never as a property with nested
|
|
/// properties beneath it.
|
|
Statement _declarationOrStyleRule() {
|
|
var start = _scanner.state;
|
|
var declarationOrBuffer = _declarationOrBuffer();
|
|
|
|
if (declarationOrBuffer is Declaration) return declarationOrBuffer;
|
|
var buffer = declarationOrBuffer as InterpolationBuffer;
|
|
buffer.addInterpolation(_almostAnyValue());
|
|
|
|
var children = _ruleChildren();
|
|
return new StyleRule(
|
|
buffer.interpolation(_scanner.spanFrom(start)), children,
|
|
span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Tries to parse a declaration, and returns the value parsed so far if it
|
|
/// fails.
|
|
///
|
|
/// This can return either an [InterpolationBuffer], indicating that it
|
|
/// couldn't consume a declaration and that selector parsing should be
|
|
/// attempted; or it can return a [Declaration], indicating that it
|
|
/// successfully consumed a declaration.
|
|
dynamic _declarationOrBuffer() {
|
|
var nameStart = _scanner.state;
|
|
var nameBuffer = new InterpolationBuffer();
|
|
|
|
// Allow the "*prop: val", ":prop: val", "#prop: val", and ".prop: val"
|
|
// hacks.
|
|
var first = _scanner.peekChar();
|
|
if (first == $colon || first == $asterisk || first == $dot ||
|
|
(first == $hash && _scanner.peekChar(1) != $lbrace)) {
|
|
nameBuffer.writeCharCode(_scanner.readChar());
|
|
nameBuffer.write(_commentText());
|
|
}
|
|
|
|
if (!_lookingAtInterpolatedIdentifier()) return nameBuffer;
|
|
nameBuffer.addInterpolation(_interpolatedIdentifier());
|
|
nameBuffer.write(_rawText(_tryComment));
|
|
|
|
var midBuffer = new StringBuffer();
|
|
midBuffer.write(_commentText());
|
|
if (!_scanner.scanChar($colon)) return nameBuffer;
|
|
midBuffer.writeCharCode($colon);
|
|
|
|
// Parse custom properties as declarations no matter what.
|
|
var name = nameBuffer.interpolation(_scanner.spanFrom(nameStart));
|
|
if (name.initialPlain.startsWith('--')) {
|
|
var value = _declarationValue();
|
|
var next = _scanner.peekChar();
|
|
if (next != $semicolon && next != $rbrace) {
|
|
_scanner.expectChar($semicolon);
|
|
}
|
|
return new Declaration(name, value);
|
|
}
|
|
|
|
if (_scanner.scanChar($colon)) {
|
|
return nameBuffer..write(midBuffer)..writeCharCode($colon);
|
|
}
|
|
|
|
var postColonWhitespace = _commentText();
|
|
midBuffer.write(postColonWhitespace);
|
|
var couldBeSelector =
|
|
postColonWhitespace.isEmpty && _lookingAtInterpolatedIdentifier();
|
|
|
|
Expression value;
|
|
try {
|
|
value = _declarationExpression();
|
|
var next = _scanner.peekChar();
|
|
if (next == $lbrace) {
|
|
// Properties that are ambiguous with selectors can't have additional
|
|
// properties nested beneath them, so we force an error.
|
|
if (couldBeSelector) _scanner.expectChar($semicolon);
|
|
} else if (next != $semicolon && next != $rbrace) {
|
|
// Force an exception if there isn't a valid end-of-property character
|
|
// but don't consume that character.
|
|
_scanner.expectChar($semicolon);
|
|
}
|
|
} on FormatException catch (_) {
|
|
if (!couldBeSelector) rethrow;
|
|
|
|
// If the value would be followed by a semicolon, it's definitely supposed
|
|
// to be a property, not a selector.
|
|
var additional = _almostAnyValue();
|
|
if (_scanner.peekChar() == $semicolon) rethrow;
|
|
|
|
nameBuffer.write(midBuffer);
|
|
nameBuffer.addInterpolation(additional);
|
|
return nameBuffer;
|
|
}
|
|
|
|
_ignoreComments();
|
|
// TODO: nested properties
|
|
return new Declaration(name, value);
|
|
}
|
|
|
|
/// Consumes whitespace if available and returns any comments it contained.
|
|
List<Comment> _comments() {
|
|
var nodes = <Comment>[];
|
|
while (true) {
|
|
_whitespace();
|
|
|
|
var comment = _tryComment();
|
|
if (comment == null) return nodes;
|
|
|
|
nodes.add(comment);
|
|
}
|
|
}
|
|
|
|
// ## Expressions
|
|
|
|
Expression _expression() => _list();
|
|
|
|
ListExpression _list({bool bracketed: false}) {
|
|
var start = _scanner.state;
|
|
if (bracketed) _scanner.expectChar($lbracket);
|
|
|
|
var spaceStart = _scanner.state;
|
|
var spaceExpressions = _spaceList();
|
|
|
|
if (spaceExpressions.isEmpty &&
|
|
(!bracketed || _scanner.peekChar() == $comma)) {
|
|
// A bracketed list starting with a comma is invalid, and an unbracketed
|
|
// list that doesn't start with an expression is invalid.
|
|
_scanner.error("Expected expression.");
|
|
}
|
|
|
|
if (!_scanner.scanChar($comma)) {
|
|
if (bracketed) _scanner.expectChar($rbracket);
|
|
return new ListExpression(
|
|
spaceExpressions,
|
|
spaceExpressions.length < 2
|
|
? ListSeparator.undecided
|
|
: ListSeparator.space,
|
|
bracketed: bracketed,
|
|
span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
var commaExpressions = [
|
|
spaceExpressions.length == 1
|
|
? spaceExpressions.single
|
|
: new ListExpression(spaceExpressions, ListSeparator.space,
|
|
span: _scanner.spanFrom(spaceStart))
|
|
];
|
|
_ignoreComments();
|
|
while (true) {
|
|
spaceStart = _scanner.state;
|
|
spaceExpressions = _spaceList();
|
|
|
|
if (spaceExpressions.isEmpty) {
|
|
break;
|
|
} else if (spaceExpressions.length == 1) {
|
|
commaExpressions.add(spaceExpressions.single);
|
|
} else {
|
|
commaExpressions.add(
|
|
new ListExpression(spaceExpressions, ListSeparator.space,
|
|
span: _scanner.spanFrom(spaceStart)));
|
|
}
|
|
|
|
if (!_scanner.scanChar($comma)) break;
|
|
_ignoreComments();
|
|
}
|
|
|
|
if (bracketed) _scanner.expectChar($rbracket);
|
|
return new ListExpression(commaExpressions, ListSeparator.comma,
|
|
bracketed: bracketed,
|
|
span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
List<Expression> _spaceList() {
|
|
var spaceExpressions = <Expression>[];
|
|
while (!_scanner.isDone && isExpressionStart(_scanner.peekChar())) {
|
|
spaceExpressions.add(_singleExpression());
|
|
_ignoreComments();
|
|
}
|
|
return spaceExpressions;
|
|
}
|
|
|
|
Expression _singleExpression() {
|
|
var first = _scanner.peekChar();
|
|
switch (first) {
|
|
// Note: when adding a new case, make sure it's reflected in
|
|
// [isExpressionStart].
|
|
case $lparen: return _parentheses();
|
|
case $slash: return _unaryOperator();
|
|
case $dot: return _number();
|
|
case $lbracket: return _list(bracketed: true);
|
|
case $dollar: return _variable();
|
|
|
|
case $single_quote:
|
|
case $double_quote:
|
|
return _string();
|
|
|
|
case $hash:
|
|
if (_scanner.peekChar(1) == $lbrace) return _identifierLike();
|
|
return _hexColorOrID();
|
|
|
|
case $plus:
|
|
var next = _scanner.peekChar(1);
|
|
if (isDigit(next) || next == $dot) return _number();
|
|
|
|
return _unaryOperator();
|
|
|
|
case $minus:
|
|
var next = _scanner.peekChar(1);
|
|
if (isDigit(next) || next == $dot) return _number();
|
|
if (_lookingAtInterpolatedIdentifier()) return _identifierLike();
|
|
|
|
return _unaryOperator();
|
|
|
|
default:
|
|
if (first == null) _scanner.error("Expected expression.");
|
|
|
|
if (isNameStart(first) || first == $backslash) {
|
|
return _identifierLike();
|
|
}
|
|
if (isDigit(first)) return _number();
|
|
|
|
_scanner.error("Expected expression");
|
|
throw "Unreachable";
|
|
}
|
|
}
|
|
|
|
Expression _parentheses() {
|
|
var start = _scanner.state;
|
|
_scanner.expectChar($lparen);
|
|
_ignoreComments();
|
|
if (!isExpressionStart(_scanner.peekChar())) {
|
|
_scanner.expectChar($rparen);
|
|
return new ListExpression([], ListSeparator.undecided,
|
|
span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
// TODO: support maps
|
|
var result = _expression();
|
|
_scanner.expectChar($rparen);
|
|
return result;
|
|
}
|
|
|
|
UnaryOperatorExpression _unaryOperator() {
|
|
var start = _scanner.state;
|
|
var operator = _unaryOperatorFor(_scanner.readChar());
|
|
if (operator == null) {
|
|
_scanner.error("Expected unary operator",
|
|
position: _scanner.position - 1);
|
|
}
|
|
|
|
_ignoreComments();
|
|
var operand = _singleExpression();
|
|
return new UnaryOperatorExpression(operator, operand,
|
|
span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
NumberExpression _number() {
|
|
var start = _scanner.state;
|
|
var first = _scanner.peekChar();
|
|
var sign = first == $dash ? -1 : 1;
|
|
if (first == $plus || first == $minus) _scanner.readChar();
|
|
|
|
num number = 0;
|
|
var second = _scanner.peekChar();
|
|
if (!isDigit(second) && second != $dot) _scanner.error("Expected number.");
|
|
|
|
while (isDigit(_scanner.peekChar())) {
|
|
number *= 10;
|
|
number += _scanner.readChar() - $0;
|
|
}
|
|
|
|
if (_scanner.peekChar() == $dot) {
|
|
_scanner.readChar();
|
|
if (!isDigit(_scanner.peekChar())) _scanner.error("Expected digit.");
|
|
|
|
var decimal = 0.1;
|
|
while (isDigit(_scanner.peekChar())) {
|
|
number += (_scanner.readChar() - $0) * decimal;
|
|
decimal /= 10;
|
|
}
|
|
}
|
|
|
|
var next = _scanner.peekChar();
|
|
if (next == $e || next == $E) {
|
|
_scanner.readChar();
|
|
next = _scanner.peekChar();
|
|
var exponentSign = next == $dash ? -1 : 1;
|
|
if (next == $plus || next == $minus) _scanner.readChar();
|
|
if (!isDigit(_scanner.peekChar())) _scanner.error("Expected digit.");
|
|
|
|
var exponent = 0.0;
|
|
while (isDigit(_scanner.peekChar())) {
|
|
exponent *= 10;
|
|
exponent += _scanner.readChar() - $0;
|
|
}
|
|
|
|
number = number * math.pow(10, exponentSign * exponent);
|
|
}
|
|
|
|
return new NumberExpression(sign * number, span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
VariableExpression _variable() {
|
|
var start = _scanner.state;
|
|
_scanner.expectChar($dollar);
|
|
var name = _identifier();
|
|
return new VariableExpression(name, span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
StringExpression _string({bool static: false}) {
|
|
var start = _scanner.state;
|
|
var quote = _scanner.readChar();
|
|
|
|
if (quote != $single_quote && quote != $double_quote) {
|
|
_scanner.error("Expected string.", position: start.position);
|
|
}
|
|
|
|
var buffer = new InterpolationBuffer();
|
|
while (true) {
|
|
var next = _scanner.peekChar();
|
|
if (next == quote) {
|
|
_scanner.readChar();
|
|
break;
|
|
} else if (next == null || isNewline(next)) {
|
|
_scanner.error("Expected ${new String.fromCharCode(quote)}.");
|
|
} else if (next == $backslash) {
|
|
if (isNewline(_scanner.peekChar(1))) {
|
|
_scanner.readChar();
|
|
_scanner.readChar();
|
|
} else {
|
|
buffer.writeCharCode(_escape());
|
|
}
|
|
} else if (next == $hash && !static) {
|
|
if (_scanner.peekChar(1) == $lbrace) {
|
|
buffer.add(_singleInterpolation());
|
|
} else {
|
|
buffer.writeCharCode(_scanner.readChar());
|
|
}
|
|
} else {
|
|
buffer.writeCharCode(_scanner.readChar());
|
|
}
|
|
}
|
|
|
|
return new StringExpression(buffer.interpolation(_scanner.spanFrom(start)));
|
|
}
|
|
|
|
Expression _hexColorOrID() {
|
|
var start = _scanner.state;
|
|
_scanner.expectChar($hash);
|
|
|
|
var first = _scanner.peekChar();
|
|
if (first != null && isDigit(first)) {
|
|
return new ColorExpression(_hexColorContents(),
|
|
span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
var afterHash = _scanner.state;
|
|
var identifier = _interpolatedIdentifier();
|
|
if (_isHexColor(identifier)) {
|
|
_scanner.state = afterHash;
|
|
return new ColorExpression(_hexColorContents(),
|
|
span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
var buffer = new InterpolationBuffer();
|
|
buffer.writeCharCode($hash);
|
|
buffer.addInterpolation(identifier);
|
|
return new IdentifierExpression(
|
|
buffer.interpolation(_scanner.spanFrom(start)));
|
|
}
|
|
|
|
SassColor _hexColorContents() {
|
|
var red = _hexDigit();
|
|
var green = _hexDigit();
|
|
var blue = _hexDigit();
|
|
|
|
var next = _scanner.peekChar();
|
|
if (next != null && isHex(next)) {
|
|
red = (red << 4) + green;
|
|
green = (blue << 4) + _hexDigit();
|
|
blue = (_hexDigit() << 4) + _hexDigit();
|
|
} else {
|
|
red = (red << 4) + red;
|
|
green = (green << 4) + green;
|
|
blue = (blue << 4) + blue;
|
|
}
|
|
|
|
return new SassColor.rgb(red, green, blue);
|
|
}
|
|
|
|
bool _isHexColor(InterpolationExpression interpolation) {
|
|
var plain = interpolation.asPlain;
|
|
if (plain == null) return false;
|
|
if (plain.length != 3 && plain.length != 6) return false;
|
|
return plain.codeUnits.every(isHex);
|
|
}
|
|
|
|
Expression _identifierLike() {
|
|
// TODO: url(), functions
|
|
var identifier = _interpolatedIdentifier();
|
|
switch (identifier.asPlain) {
|
|
case "not":
|
|
_ignoreComments();
|
|
return new UnaryOperatorExpression(
|
|
UnaryOperator.not, _singleExpression(), span: identifier.span);
|
|
|
|
case "true": return new BooleanExpression(true, span: identifier.span);
|
|
case "false": return new BooleanExpression(false, span: identifier.span);
|
|
|
|
default:
|
|
return new IdentifierExpression(identifier);
|
|
}
|
|
}
|
|
|
|
/// Consumes tokens up to "{", "}", ";", or "!".
|
|
///
|
|
/// This respects string boundaries and supports interpolation. Once this
|
|
/// interpolation is evaluated, it's expected to be re-parsed.
|
|
InterpolationExpression _almostAnyValue() {
|
|
var start = _scanner.state;
|
|
var buffer = new InterpolationBuffer();
|
|
|
|
loop: while (true) {
|
|
var next = _scanner.peekChar();
|
|
switch (next) {
|
|
case $backslash:
|
|
// Write a literal backslash because this text will be re-parsed.
|
|
buffer.writeCharCode(_scanner.readChar());
|
|
buffer.writeCharCode(_scanner.readChar());
|
|
break;
|
|
|
|
case $double_quote:
|
|
case $single_quote:
|
|
buffer.addInterpolation(_string().asInterpolation());
|
|
break;
|
|
|
|
case $slash:
|
|
switch (_scanner.peekChar(1)) {
|
|
case $slash:
|
|
buffer.write(_rawText(() => _silentComment()));
|
|
break;
|
|
|
|
case $asterisk:
|
|
buffer.write(_rawText(() => _loudComment()));
|
|
break;
|
|
|
|
default:
|
|
buffer.writeCharCode(_scanner.readChar());
|
|
break;
|
|
}
|
|
break;
|
|
|
|
case $hash:
|
|
if (_scanner.peekChar(1) == $lbrace) {
|
|
buffer.add(_singleInterpolation());
|
|
} else {
|
|
buffer.writeCharCode(_scanner.readChar());
|
|
}
|
|
break;
|
|
|
|
case $exclamation:
|
|
case $semicolon:
|
|
case $lbrace:
|
|
case $rbrace:
|
|
break loop;
|
|
|
|
default:
|
|
if (next == null) break loop;
|
|
|
|
// TODO: support url()
|
|
buffer.writeCharCode(_scanner.readChar());
|
|
break;
|
|
}
|
|
}
|
|
|
|
return buffer.interpolation(_scanner.spanFrom(start));
|
|
}
|
|
|
|
IdentifierExpression _declarationValue({bool static: false}) {
|
|
var start = _scanner.state;
|
|
var buffer = new InterpolationBuffer();
|
|
|
|
var brackets = <int>[];
|
|
var wroteNewline = false;
|
|
loop: while (true) {
|
|
var next = _scanner.peekChar();
|
|
switch (next) {
|
|
case $backslash:
|
|
buffer.writeCharCode(_escape());
|
|
wroteNewline = false;
|
|
break;
|
|
|
|
case $double_quote:
|
|
case $single_quote:
|
|
buffer.addInterpolation(
|
|
_string(static: static)
|
|
.asInterpolation(static: static, quote: next));
|
|
wroteNewline = false;
|
|
break;
|
|
|
|
case $slash:
|
|
switch (_scanner.peekChar(1)) {
|
|
case $slash:
|
|
buffer.write(_rawText(() => _silentComment()));
|
|
break;
|
|
|
|
case $asterisk:
|
|
buffer.write(_rawText(() => _loudComment()));
|
|
break;
|
|
|
|
default:
|
|
buffer.writeCharCode(_scanner.readChar());
|
|
break;
|
|
}
|
|
wroteNewline = false;
|
|
break;
|
|
|
|
case $hash:
|
|
if (!static && _scanner.peekChar(1) == $lbrace) {
|
|
buffer.add(_singleInterpolation());
|
|
} else {
|
|
buffer.writeCharCode(_scanner.readChar());
|
|
}
|
|
wroteNewline = false;
|
|
break;
|
|
|
|
case $space:
|
|
case $tab:
|
|
if (wroteNewline || !isWhitespace(_scanner.peekChar(1))) {
|
|
buffer.writeCharCode($space);
|
|
}
|
|
_scanner.readChar();
|
|
break;
|
|
|
|
case $lf:
|
|
case $cr:
|
|
case $ff:
|
|
if (!isNewline(_scanner.peekChar(-1))) buffer.writeln();
|
|
_scanner.readChar();
|
|
wroteNewline = true;
|
|
break;
|
|
|
|
case $lparen:
|
|
case $lbrace:
|
|
case $lbracket:
|
|
buffer.writeCharCode(next);
|
|
brackets.add(opposite(_scanner.readChar()));
|
|
wroteNewline = false;
|
|
break;
|
|
|
|
case $rparen:
|
|
case $rbrace:
|
|
case $rbracket:
|
|
if (brackets.isEmpty) break loop;
|
|
buffer.writeCharCode(next);
|
|
_scanner.expectChar(brackets.removeLast());
|
|
wroteNewline = false;
|
|
break;
|
|
|
|
case $exclamation:
|
|
case $semicolon:
|
|
break loop;
|
|
|
|
default:
|
|
if (next == null) break loop;
|
|
|
|
// TODO: support url()
|
|
buffer.writeCharCode(_scanner.readChar());
|
|
wroteNewline = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (brackets.isNotEmpty) _scanner.expectChar(brackets.last);
|
|
return new IdentifierExpression(
|
|
buffer.interpolation(_scanner.spanFrom(start)));
|
|
}
|
|
|
|
InterpolationExpression _interpolatedIdentifier() {
|
|
var start = _scanner.state;
|
|
var buffer = new InterpolationBuffer();
|
|
|
|
while (_scanner.scanChar($dash)) {
|
|
buffer.writeCharCode($dash);
|
|
}
|
|
|
|
var first = _scanner.peekChar();
|
|
if (first == null) {
|
|
_scanner.error("Expected identifier.");
|
|
} else if (isNameStart(first)) {
|
|
buffer.writeCharCode(_scanner.readChar());
|
|
} else if (first == $backslash) {
|
|
buffer.writeCharCode(_escape());
|
|
} else if (first == $hash && _scanner.peekChar(1) == $lbrace) {
|
|
buffer.add(_singleInterpolation());
|
|
}
|
|
|
|
while (true) {
|
|
var next = _scanner.peekChar();
|
|
if (next == null) {
|
|
break;
|
|
} else if (next == $underscore || next == $dash ||
|
|
isAlphanumeric(next) || next >= 0x0080) {
|
|
buffer.writeCharCode(_scanner.readChar());
|
|
} else if (next == $backslash) {
|
|
buffer.writeCharCode(_escape());
|
|
} else if (next == $hash && _scanner.peekChar(1) == $lbrace) {
|
|
buffer.add(_singleInterpolation());
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return buffer.interpolation(_scanner.spanFrom(start));
|
|
}
|
|
|
|
Expression _singleInterpolation() {
|
|
_scanner.expect('#{');
|
|
var expression = _expression();
|
|
_scanner.expectChar($rbrace);
|
|
return expression;
|
|
}
|
|
|
|
// ## Selectors
|
|
|
|
SelectorList _selectorList() {
|
|
var components = <ComplexSelector>[];
|
|
var lineBreaks = <int>[];
|
|
|
|
_ignoreComments();
|
|
var previousLine = _scanner.line;
|
|
do {
|
|
_ignoreComments();
|
|
var next = _scanner.peekChar();
|
|
if (next == $comma) continue;
|
|
if (next == $lbrace) break;
|
|
|
|
if (_scanner.line != previousLine) {
|
|
lineBreaks.add(components.length);
|
|
previousLine = _scanner.line;
|
|
}
|
|
components.add(_complexSelector());
|
|
} while (_scanner.scanChar($comma));
|
|
|
|
return new SelectorList(components, lineBreaks: lineBreaks);
|
|
}
|
|
|
|
ComplexSelector _complexSelector() {
|
|
var components = <ComplexSelectorComponent>[];
|
|
var lineBreaks = <int>[];
|
|
|
|
var previousLine = _scanner.line;
|
|
loop: while (true) {
|
|
_ignoreComments();
|
|
|
|
ComplexSelectorComponent component;
|
|
var next = _scanner.peekChar();
|
|
switch (next) {
|
|
case $plus:
|
|
component = Combinator.nextSibling;
|
|
break;
|
|
|
|
case $gt:
|
|
component = Combinator.child;
|
|
break;
|
|
|
|
case $tilde:
|
|
component = Combinator.followingSibling;
|
|
break;
|
|
|
|
case $lbrace:
|
|
case $comma:
|
|
break loop;
|
|
|
|
default:
|
|
if (next == null) break loop;
|
|
component = _compoundSelector();
|
|
break;
|
|
}
|
|
|
|
if (_scanner.line != previousLine) {
|
|
lineBreaks.add(components.length);
|
|
previousLine = _scanner.line;
|
|
}
|
|
components.add(component);
|
|
}
|
|
|
|
return new ComplexSelector(components, lineBreaks: lineBreaks);
|
|
}
|
|
|
|
CompoundSelector _compoundSelector() {
|
|
var components = <SimpleSelector>[_simpleSelector()];
|
|
|
|
while (isSimpleSelectorStart(_scanner.peekChar())) {
|
|
components.add(_simpleSelector());
|
|
}
|
|
|
|
// TODO: support "*E".
|
|
return new CompoundSelector(components);
|
|
}
|
|
|
|
SimpleSelector _simpleSelector() {
|
|
switch (_scanner.peekChar()) {
|
|
case $lbracket: return _attributeSelector();
|
|
case $dot: return _classSelector();
|
|
case $hash: return _idSelector();
|
|
case $percent: return _placeholderSelector();
|
|
case $colon: return _pseudoSelector();
|
|
default: return _typeOrUniversalSelector();
|
|
}
|
|
}
|
|
|
|
AttributeSelector _attributeSelector() {
|
|
_scanner.expectChar($lbracket);
|
|
_ignoreComments();
|
|
|
|
var name = _attributeName();
|
|
_ignoreComments();
|
|
if (_scanner.scanChar($rbracket)) {
|
|
_scanner.readChar();
|
|
return new AttributeSelector(name);
|
|
}
|
|
|
|
var operator = _attributeOperator();
|
|
_ignoreComments();
|
|
|
|
var next = _scanner.peekChar();
|
|
var value = next == $single_quote || next == $double_quote
|
|
? _string(static: true).text.asPlain
|
|
: _identifier();
|
|
_ignoreComments();
|
|
|
|
_scanner.expectChar($rbracket);
|
|
return new AttributeSelector.withOperator(name, operator, value);
|
|
}
|
|
|
|
NamespacedIdentifier _attributeName() {
|
|
if (_scanner.scanChar($asterisk)) {
|
|
_scanner.expectChar($pipe);
|
|
return new NamespacedIdentifier(_identifier(), namespace: "*");
|
|
}
|
|
|
|
var nameOrNamespace = _identifier();
|
|
if (_scanner.peekChar() != $pipe || _scanner.peekChar(1) == $equal) {
|
|
return new NamespacedIdentifier(nameOrNamespace);
|
|
}
|
|
|
|
_scanner.readChar();
|
|
return new NamespacedIdentifier(_identifier(), namespace: nameOrNamespace);
|
|
}
|
|
|
|
AttributeOperator _attributeOperator() {
|
|
var start = _scanner.state;
|
|
switch (_scanner.readChar()) {
|
|
case $equal: return AttributeOperator.equal;
|
|
|
|
case $tilde:
|
|
_scanner.expectChar($equal);
|
|
return AttributeOperator.include;
|
|
|
|
case $pipe:
|
|
_scanner.expectChar($equal);
|
|
return AttributeOperator.dash;
|
|
|
|
case $caret:
|
|
_scanner.expectChar($equal);
|
|
return AttributeOperator.prefix;
|
|
|
|
case $dollar:
|
|
_scanner.expectChar($equal);
|
|
return AttributeOperator.suffix;
|
|
|
|
case $asterisk:
|
|
_scanner.expectChar($equal);
|
|
return AttributeOperator.substring;
|
|
|
|
default:
|
|
_scanner.error('Expected "]".', position: start.position);
|
|
throw "Unreachable";
|
|
}
|
|
}
|
|
|
|
ClassSelector _classSelector() {
|
|
_scanner.expectChar($dot);
|
|
var name = _identifier();
|
|
return new ClassSelector(name);
|
|
}
|
|
|
|
IDSelector _idSelector() {
|
|
_scanner.expectChar($hash);
|
|
var name = _identifier();
|
|
return new IDSelector(name);
|
|
}
|
|
|
|
PlaceholderSelector _placeholderSelector() {
|
|
_scanner.expectChar($percent);
|
|
var name = _identifier();
|
|
return new PlaceholderSelector(name);
|
|
}
|
|
|
|
PseudoSelector _pseudoSelector() {
|
|
_scanner.expectChar($colon);
|
|
var type =
|
|
_scanner.scanChar($colon) ? PseudoType.element : PseudoType.klass;
|
|
var name = _identifier();
|
|
|
|
if (!_scanner.scanChar($lparen)) {
|
|
return new PseudoSelector(name, type);
|
|
}
|
|
_ignoreComments();
|
|
|
|
var unvendored = unvendor(name);
|
|
String argument;
|
|
SelectorList selector;
|
|
if (type == PseudoType.element) {
|
|
argument = _pseudoArgument();
|
|
} else if (_selectorPseudoClasses.contains(unvendored)) {
|
|
selector = _selectorList();
|
|
} else if (_prefixedSelectorPseudoClasses.contains(unvendored)) {
|
|
argument = _rawText(_aNPlusB);
|
|
if (_scanWhitespace()) {
|
|
_expectCaseInsensitive("of");
|
|
argument += " of";
|
|
_ignoreComments();
|
|
|
|
selector = _selectorList();
|
|
}
|
|
} else {
|
|
argument = _pseudoArgument();
|
|
}
|
|
_scanner.expectChar($rparen);
|
|
|
|
return new PseudoSelector(name, type,
|
|
argument: argument, selector: selector);
|
|
}
|
|
|
|
String _pseudoArgument() => _declarationValue(static: true).text.asPlain;
|
|
|
|
void _aNPlusB() {
|
|
switch (_scanner.peekChar()) {
|
|
case $e:
|
|
case $E:
|
|
_expectCaseInsensitive("even");
|
|
return;
|
|
|
|
case $o:
|
|
case $O:
|
|
_expectCaseInsensitive("odd");
|
|
return;
|
|
|
|
case $plus:
|
|
case $minus:
|
|
_scanner.readChar();
|
|
break;
|
|
}
|
|
|
|
var first = _scanner.peekChar();
|
|
if (first != null && isDigit(first)) {
|
|
while (isDigit(_scanner.peekChar())) {
|
|
_scanner.readChar();
|
|
}
|
|
_ignoreComments();
|
|
if (!_scanCharCaseInsensitive($n)) return;
|
|
} else {
|
|
_expectCharCaseInsensitive($n);
|
|
}
|
|
_ignoreComments();
|
|
|
|
var next = _scanner.peekChar();
|
|
if (next != $plus && next != $minus) return;
|
|
_scanner.readChar();
|
|
_ignoreComments();
|
|
|
|
var last = _scanner.peekChar();
|
|
if (last == null || !isDigit(last)) _scanner.error("Expected a number.");
|
|
while (isDigit(_scanner.peekChar())) {
|
|
_scanner.readChar();
|
|
}
|
|
}
|
|
|
|
SimpleSelector _typeOrUniversalSelector() {
|
|
var first = _scanner.peekChar();
|
|
if (first == $asterisk) {
|
|
if (!_scanner.scanChar($pipe)) return new UniversalSelector();
|
|
if (_scanner.scanChar($asterisk)) {
|
|
return new UniversalSelector(namespace: "*");
|
|
} else {
|
|
return new TypeSelector(
|
|
new NamespacedIdentifier(_identifier(), namespace: "*"));
|
|
}
|
|
} else if (first == $pipe) {
|
|
if (_scanner.scanChar($asterisk)) {
|
|
return new UniversalSelector( namespace: "");
|
|
} else {
|
|
return new TypeSelector(
|
|
new NamespacedIdentifier(_identifier(), namespace: ""));
|
|
}
|
|
}
|
|
|
|
var nameOrNamespace = _identifier();
|
|
if (!_scanner.scanChar($pipe)) {
|
|
return new TypeSelector(new NamespacedIdentifier(nameOrNamespace));
|
|
}
|
|
|
|
return new TypeSelector(
|
|
new NamespacedIdentifier(_identifier(), namespace: nameOrNamespace));
|
|
}
|
|
|
|
// ## Media Queries
|
|
|
|
List<MediaQuery> _mediaQueryList() {
|
|
var queries = <MediaQuery>[];
|
|
do {
|
|
_ignoreComments();
|
|
queries.add(_mediaQuery());
|
|
} while (_scanner.scanChar($comma));
|
|
return queries;
|
|
}
|
|
|
|
MediaQuery _mediaQuery() {
|
|
InterpolationExpression modifier;
|
|
InterpolationExpression type;
|
|
if (_scanner.peekChar() != $lparen) {
|
|
var identifier1 = _interpolatedIdentifier();
|
|
_ignoreComments();
|
|
|
|
if (!_lookingAtInterpolatedIdentifier()) {
|
|
// For example, "@media screen {"
|
|
return new MediaQuery(identifier1);
|
|
}
|
|
|
|
var identifier2 = _interpolatedIdentifier();
|
|
_ignoreComments();
|
|
|
|
if (equalsIgnoreCase(identifier2.asPlain, "and")) {
|
|
// For example, "@media screen and ..."
|
|
type = identifier1;
|
|
} else {
|
|
modifier = identifier1;
|
|
type = identifier2;
|
|
if (_scanCaseInsensitive("and")) {
|
|
// For example, "@media only screen and ..."
|
|
_ignoreComments();
|
|
} else {
|
|
// For example, "@media only screen {"
|
|
return new MediaQuery(type, modifier: modifier);
|
|
}
|
|
}
|
|
}
|
|
|
|
// We've consumed either `IDENTIFIER "and"` or
|
|
// `IDENTIFIER IDENTIFIER "and"`.
|
|
|
|
var features = <InterpolationExpression>[];
|
|
do {
|
|
_ignoreComments();
|
|
features.add(_mediaExpression());
|
|
} while (_scanCaseInsensitive("and"));
|
|
|
|
if (type == null) {
|
|
return new MediaQuery.condition(features);
|
|
} else {
|
|
return new MediaQuery(type, modifier: modifier, features: features);
|
|
}
|
|
}
|
|
|
|
InterpolationExpression _mediaExpression() {
|
|
if (_scanner.peekChar() == $hash) {
|
|
var interpolation = _singleInterpolation();
|
|
return new InterpolationExpression([interpolation],
|
|
span: interpolation.span);
|
|
}
|
|
|
|
var start = _scanner.state;
|
|
var buffer = new InterpolationBuffer();
|
|
_scanner.expectChar($lparen);
|
|
buffer.writeCharCode($lparen);
|
|
_ignoreComments();
|
|
|
|
buffer.add(_expression());
|
|
if (_scanner.scanChar($colon)) {
|
|
_ignoreComments();
|
|
buffer.writeCharCode($colon);
|
|
buffer.writeCharCode($space);
|
|
buffer.add(_expression());
|
|
}
|
|
|
|
_scanner.expectChar($rparen);
|
|
_ignoreComments();
|
|
buffer.writeCharCode($rparen);
|
|
|
|
return buffer.interpolation(_scanner.spanFrom(start));
|
|
}
|
|
|
|
// ## Tokens
|
|
|
|
String _commentText() => _rawText(_ignoreComments);
|
|
|
|
bool _scanWhitespace() {
|
|
var start = _scanner.position;
|
|
_ignoreComments();
|
|
return _scanner.position != start;
|
|
}
|
|
|
|
void _ignoreComments() {
|
|
do {
|
|
_whitespace();
|
|
} while (_tryComment() != null);
|
|
}
|
|
|
|
Comment _tryComment() {
|
|
if (_scanner.peekChar() != $slash) return null;
|
|
switch (_scanner.peekChar(1)) {
|
|
case $slash: return _silentComment();
|
|
case $asterisk: return _loudComment();
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
Comment _silentComment() {
|
|
var start = _scanner.state;
|
|
_scanner.expect("//");
|
|
|
|
do {
|
|
while (!_scanner.isDone && !isNewline(_scanner.readChar())) {}
|
|
if (_scanner.isDone) break;
|
|
_whitespace();
|
|
} while (_scanner.scan("//"));
|
|
|
|
return new Comment(_scanner.substring(start.position),
|
|
silent: true,
|
|
span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
Comment _loudComment() {
|
|
var start = _scanner.state;
|
|
_scanner.expect("/*");
|
|
do {
|
|
while (_scanner.readChar() != $asterisk) {}
|
|
} while (_scanner.readChar() != $slash);
|
|
|
|
return new Comment(_scanner.substring(start.position),
|
|
silent: false,
|
|
span: _scanner.spanFrom(start));
|
|
}
|
|
|
|
void _whitespace() {
|
|
while (!_scanner.isDone && isWhitespace(_scanner.peekChar())) {
|
|
_scanner.readChar();
|
|
}
|
|
}
|
|
|
|
String _identifier() {
|
|
var text = new StringBuffer();
|
|
while (_scanner.scanChar($dash)) {
|
|
text.writeCharCode($dash);
|
|
}
|
|
|
|
var first = _scanner.peekChar();
|
|
if (first == null) {
|
|
_scanner.error("Expected identifier.");
|
|
} else if (isNameStart(first)) {
|
|
text.writeCharCode(_scanner.readChar());
|
|
} else if (first == $backslash) {
|
|
text.writeCharCode(_escape());
|
|
} else {
|
|
_scanner.error("Expected identifier.");
|
|
}
|
|
|
|
while (true) {
|
|
var next = _scanner.peekChar();
|
|
if (next == null) {
|
|
break;
|
|
} else if (next == $underscore || next == $dash ||
|
|
isAlphanumeric(next) || next >= 0x0080) {
|
|
text.writeCharCode(_scanner.readChar());
|
|
} else if (next == $backslash) {
|
|
text.writeCharCode(_escape());
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return text.toString();
|
|
}
|
|
|
|
// ## Characters
|
|
|
|
UnaryOperator _unaryOperatorFor(int character) {
|
|
switch (character) {
|
|
case $plus: return UnaryOperator.plus;
|
|
case $minus: return UnaryOperator.minus;
|
|
case $slash: return UnaryOperator.divide;
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
int _escape() {
|
|
// See https://drafts.csswg.org/css-syntax-3/#consume-escaped-code-point.
|
|
|
|
_scanner.expectChar($backslash);
|
|
var first = _scanner.peekChar();
|
|
if (first == null) {
|
|
return 0xFFFD;
|
|
} else if (isNewline(first)) {
|
|
_scanner.error("Expected escape sequence.");
|
|
return 0;
|
|
} else if (isHex(first)) {
|
|
var value = 0;
|
|
for (var i = 0; i < 6; i++) {
|
|
var next = _scanner.peekChar();
|
|
if (next == null || !isHex(next)) break;
|
|
value = (value << 4) + asHex(_scanner.readChar());
|
|
}
|
|
if (isWhitespace(_scanner.peekChar())) _scanner.readChar();
|
|
|
|
if (value == 0 || (value >= 0xD800 && value <= 0xDFFF) ||
|
|
value >= 0x10FFFF) {
|
|
return 0xFFFD;
|
|
} else {
|
|
return value;
|
|
}
|
|
} else {
|
|
return _scanner.readChar();
|
|
}
|
|
}
|
|
|
|
int _hexDigit() {
|
|
var char = _scanner.peekChar();
|
|
if (char == null || !isHex(char)) _scanner.error("Expected hex digit.");
|
|
return asHex(_scanner.readChar());
|
|
}
|
|
|
|
bool _scanCharCaseInsensitive(int character) {
|
|
assert(character >= $a && character <= $z);
|
|
var actual = _scanner.readChar();
|
|
return actual == character || actual == character + $A - $a;
|
|
}
|
|
|
|
void _expectCharCaseInsensitive(int character) {
|
|
assert(character >= $a && character <= $z);
|
|
var actual = _scanner.readChar();
|
|
if (actual == character || actual == character + $A - $a) return;
|
|
|
|
_scanner.error('Expected "${new String.fromCharCode(character)}".',
|
|
position: actual == null ? _scanner.position : _scanner.position - 1);
|
|
}
|
|
|
|
bool _scanCaseInsensitive(String expected) {
|
|
var start = _scanner.position;
|
|
for (var i = 0; i < expected.length; i++) {
|
|
if (_scanCharCaseInsensitive(expected.codeUnitAt(i))) continue;
|
|
_scanner.position = start;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void _expectCaseInsensitive(String expected) {
|
|
var start = _scanner.position;
|
|
for (var i = 0; i < expected.length; i++) {
|
|
if (_scanCharCaseInsensitive(expected.codeUnitAt(i))) continue;
|
|
_scanner.error('Expected "$expected".', position: start, length: i);
|
|
}
|
|
}
|
|
|
|
// ## Utilities
|
|
|
|
/// 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
|
|
bool _lookingAtInterpolatedIdentifier() {
|
|
var first = _scanner.peekChar();
|
|
if (isNameStart(first) || first == $backslash) return true;
|
|
if (first == $hash) return _scanner.peekChar(1) == $lbrace;
|
|
|
|
if (first != $dash) return false;
|
|
var second = _scanner.peekChar();
|
|
if (isNameStart(second) || second == $dash || second == $backslash) {
|
|
return true;
|
|
}
|
|
return second == $hash && _scanner.peekChar(2) == $lbrace;
|
|
}
|
|
|
|
String _rawText(void consumer()) {
|
|
var start = _scanner.position;
|
|
consumer();
|
|
return _scanner.substring(start);
|
|
}
|
|
}
|