mirror of
https://github.com/danog/dart-sass.git
synced 2025-01-10 06:48:36 +01:00
2520 lines
75 KiB
Dart
2520 lines
75 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 'package:tuple/tuple.dart';
|
|
|
|
import '../ast/sass.dart';
|
|
import '../exception.dart';
|
|
import '../color_names.dart';
|
|
import '../interpolation_buffer.dart';
|
|
import '../util/character.dart';
|
|
import '../utils.dart';
|
|
import '../value.dart';
|
|
import '../value/color.dart';
|
|
import 'parser.dart';
|
|
|
|
/// The base class for both the SCSS and indented syntax parsers.
|
|
///
|
|
/// Having a base class that's separate from both parsers allows us to make
|
|
/// explicit exactly which methods are different between the two. This allows
|
|
/// the author to know that if they're modifying the base class, the subclasses
|
|
/// generally won't need modification. Conversely, if they're modifying one
|
|
/// subclass, the other will likely need a parallel change.
|
|
///
|
|
/// All methods that are not intended to be accessed by external callers are
|
|
/// private, except where they have to be public for subclasses to refer to
|
|
/// them.
|
|
abstract class StylesheetParser extends Parser {
|
|
/// Whether the parser is currently parsing the contents of a mixin
|
|
/// declaration.
|
|
var _inMixin = false;
|
|
|
|
/// Whether the current mixin contains at least one `@content` rule.
|
|
///
|
|
/// This is `null` unless [_inMixin] is `true`.
|
|
bool _mixinHasContent;
|
|
|
|
/// Whether the parser is currently parsing a content block passed to a mixin.
|
|
var _inContentBlock = false;
|
|
|
|
/// Whether the parser is currently parsing a control directive such as `@if`
|
|
/// or `@each`.
|
|
var _inControlDirective = false;
|
|
|
|
/// Whether the parser is currently within a parenthesized expression.
|
|
var _inParentheses = false;
|
|
|
|
/// Whether warnings should be emitted using terminal colors.
|
|
///
|
|
/// This is protected and shouldn't be accessed except by subclasses.
|
|
final bool color;
|
|
|
|
StylesheetParser(String contents, {url, this.color: false})
|
|
: super(contents, url: url);
|
|
|
|
// ## Statements
|
|
|
|
Stylesheet parse() {
|
|
return wrapSpanFormatException(() {
|
|
var start = scanner.state;
|
|
var statements = this.statements(_topLevelStatement);
|
|
scanner.expectDone();
|
|
return new Stylesheet(statements, scanner.spanFrom(start));
|
|
});
|
|
}
|
|
|
|
ArgumentDeclaration parseArgumentDeclaration() {
|
|
return wrapSpanFormatException(() {
|
|
var declaration = _argumentDeclaration();
|
|
scanner.expectDone();
|
|
return declaration;
|
|
});
|
|
}
|
|
|
|
/// Consumes a statement that's allowed at the top level of the stylesheet.
|
|
Statement _topLevelStatement() {
|
|
if (scanner.peekChar() == $at) {
|
|
return _atRule(_topLevelStatement, root: true);
|
|
} else {
|
|
return _styleRule();
|
|
}
|
|
}
|
|
|
|
/// Consumes a variable declaration.
|
|
VariableDeclaration variableDeclaration() {
|
|
var start = scanner.state;
|
|
var name = variableName();
|
|
whitespace();
|
|
scanner.expectChar($colon);
|
|
whitespace();
|
|
|
|
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);
|
|
}
|
|
|
|
whitespace();
|
|
}
|
|
|
|
expectStatementSeparator();
|
|
|
|
return new VariableDeclaration(name, expression, scanner.spanFrom(start),
|
|
guarded: guarded, global: global);
|
|
}
|
|
|
|
/// Consumes a style rule.
|
|
StyleRule _styleRule() {
|
|
var start = scanner.state;
|
|
var selector = _almostAnyValue();
|
|
var children = this.children(_ruleChild);
|
|
return new StyleRule(selector, children, scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes a statement that's allowed within a style rule.
|
|
Statement _ruleChild() {
|
|
if (scanner.peekChar() == $at) return _atRule(_ruleChild);
|
|
return _declarationOrStyleRule();
|
|
}
|
|
|
|
/// Consumes 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 selectorSpan = scanner.spanFrom(start);
|
|
|
|
var children = this.children(_ruleChild);
|
|
if (indented && children.isEmpty) {
|
|
warn("This selector doesn't have any properties and won't be rendered.",
|
|
selectorSpan,
|
|
color: color);
|
|
}
|
|
|
|
return new StyleRule(
|
|
buffer.interpolation(selectorSpan), children, 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 start = 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(rawText(whitespace));
|
|
}
|
|
|
|
if (!_lookingAtInterpolatedIdentifier()) return nameBuffer;
|
|
nameBuffer.addInterpolation(_interpolatedIdentifier());
|
|
if (scanner.matches("/*")) nameBuffer.write(rawText(loudComment));
|
|
|
|
var midBuffer = new StringBuffer();
|
|
midBuffer.write(rawText(whitespace));
|
|
if (!scanner.scanChar($colon)) {
|
|
if (midBuffer.isNotEmpty) nameBuffer.writeCharCode($space);
|
|
return nameBuffer;
|
|
}
|
|
midBuffer.writeCharCode($colon);
|
|
|
|
// Parse custom properties as declarations no matter what.
|
|
var name = nameBuffer.interpolation(scanner.spanFrom(start));
|
|
if (name.initialPlain.startsWith('--')) {
|
|
var value = _interpolatedDeclarationValue();
|
|
if (!atEndOfStatement()) {
|
|
if (!indented) scanner.expectChar($semicolon);
|
|
scanner.error("Expected newline.");
|
|
}
|
|
return new Declaration(name, scanner.spanFrom(start), value: value);
|
|
}
|
|
|
|
if (scanner.scanChar($colon)) {
|
|
return nameBuffer
|
|
..write(midBuffer)
|
|
..writeCharCode($colon);
|
|
} else if (indented && _lookingAtInterpolatedIdentifier()) {
|
|
// In the indented syntax, `foo:bar` is always considered a selector
|
|
// rather than a property.
|
|
return nameBuffer..write(midBuffer);
|
|
}
|
|
|
|
var postColonWhitespace = rawText(whitespace);
|
|
if (lookingAtChildren()) {
|
|
return new Declaration(name, scanner.spanFrom(start),
|
|
children: this.children(_declarationChild));
|
|
}
|
|
|
|
midBuffer.write(postColonWhitespace);
|
|
var couldBeSelector =
|
|
postColonWhitespace.isEmpty && _lookingAtInterpolatedIdentifier();
|
|
|
|
var beforeDeclaration = scanner.state;
|
|
Expression value;
|
|
try {
|
|
value = _declarationExpression();
|
|
if (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) scanner.expectChar($semicolon);
|
|
} else if (!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.
|
|
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.
|
|
scanner.state = beforeDeclaration;
|
|
var additional = _almostAnyValue();
|
|
if (!indented && scanner.peekChar() == $semicolon) rethrow;
|
|
|
|
nameBuffer.write(midBuffer);
|
|
nameBuffer.addInterpolation(additional);
|
|
return nameBuffer;
|
|
}
|
|
|
|
var children =
|
|
lookingAtChildren() ? this.children(_declarationChild) : null;
|
|
if (children == null) expectStatementSeparator();
|
|
|
|
return new Declaration(name, scanner.spanFrom(start),
|
|
value: value, children: children);
|
|
}
|
|
|
|
/// Consumes a property declaration.
|
|
///
|
|
/// This is only used in contexts where declarations are allowed but style
|
|
/// rules are not, such as nested declarations. Otherwise,
|
|
/// [_declarationOrStyleRule] is used instead.
|
|
Declaration _declaration() {
|
|
var start = scanner.state;
|
|
var name = _interpolatedIdentifier();
|
|
whitespace();
|
|
scanner.expectChar($colon);
|
|
whitespace();
|
|
|
|
if (lookingAtChildren()) {
|
|
return new Declaration(name, scanner.spanFrom(start),
|
|
children: this.children(_declarationChild));
|
|
}
|
|
|
|
var value = _declarationExpression();
|
|
var children =
|
|
lookingAtChildren() ? this.children(_declarationChild) : null;
|
|
if (children == null) expectStatementSeparator();
|
|
return new Declaration(name, scanner.spanFrom(start),
|
|
value: value, children: children);
|
|
}
|
|
|
|
/// Consumes an expression after a property declaration.
|
|
///
|
|
/// This parses an empty identifier expression if the declaration has no value
|
|
/// but has children.
|
|
Expression _declarationExpression() {
|
|
if (lookingAtChildren()) {
|
|
return new StringExpression(new Interpolation([], scanner.emptySpan),
|
|
quotes: true);
|
|
}
|
|
|
|
return _expression();
|
|
}
|
|
|
|
/// Consumes a statement that's allowed within a declaration.
|
|
Statement _declarationChild() {
|
|
if (scanner.peekChar() == $at) return _declarationAtRule();
|
|
return _declaration();
|
|
}
|
|
|
|
// ## At Rules
|
|
|
|
/// 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.
|
|
Statement _atRule(Statement child(), {bool root: false}) {
|
|
var start = scanner.state;
|
|
var name = _atRuleName();
|
|
|
|
switch (name) {
|
|
case "at-root":
|
|
return _atRootRule(start);
|
|
case "charset":
|
|
if (!root) _disallowedAtRule(start);
|
|
string();
|
|
return null;
|
|
case "content":
|
|
return _contentRule(start);
|
|
case "debug":
|
|
return _debugRule(start);
|
|
case "each":
|
|
return _eachRule(start, child);
|
|
case "else":
|
|
return _disallowedAtRule(start);
|
|
case "error":
|
|
return _errorRule(start);
|
|
case "extend":
|
|
return _extendRule(start);
|
|
case "for":
|
|
return _forRule(start, child);
|
|
case "function":
|
|
return _functionRule(start);
|
|
case "if":
|
|
return _ifRule(start, child);
|
|
case "import":
|
|
return _importRule(start);
|
|
case "include":
|
|
return _includeRule(start);
|
|
case "media":
|
|
return _mediaRule(start);
|
|
case "mixin":
|
|
return _mixinRule(start);
|
|
case "return":
|
|
return _disallowedAtRule(start);
|
|
case "supports":
|
|
return _supportsRule(start);
|
|
case "warn":
|
|
return _warnRule(start);
|
|
case "while":
|
|
return _whileRule(start, child);
|
|
default:
|
|
return _unknownAtRule(start, name);
|
|
}
|
|
}
|
|
|
|
/// Consumes an at-rule allowed within a property declaration.
|
|
Statement _declarationAtRule() {
|
|
var start = scanner.state;
|
|
var name = _atRuleName();
|
|
|
|
switch (name) {
|
|
case "content":
|
|
return _contentRule(start);
|
|
case "debug":
|
|
return _debugRule(start);
|
|
case "each":
|
|
return _eachRule(start, _declarationChild);
|
|
case "else":
|
|
return _disallowedAtRule(start);
|
|
case "error":
|
|
return _errorRule(start);
|
|
case "for":
|
|
return _forRule(start, _declarationAtRule);
|
|
case "if":
|
|
return _ifRule(start, _declarationChild);
|
|
case "include":
|
|
return _includeRule(start);
|
|
case "warn":
|
|
return _warnRule(start);
|
|
case "while":
|
|
return _whileRule(start, _declarationChild);
|
|
default:
|
|
return _disallowedAtRule(start);
|
|
}
|
|
}
|
|
|
|
/// Consumes an at-rule allowed within a function.
|
|
Statement _functionAtRule() {
|
|
var start = scanner.state;
|
|
switch (_atRuleName()) {
|
|
case "debug":
|
|
return _debugRule(start);
|
|
case "each":
|
|
return _eachRule(start, _functionAtRule);
|
|
case "else":
|
|
return _disallowedAtRule(start);
|
|
case "error":
|
|
return _errorRule(start);
|
|
case "for":
|
|
return _forRule(start, _functionAtRule);
|
|
case "if":
|
|
return _ifRule(start, _functionAtRule);
|
|
case "return":
|
|
return _returnRule(start);
|
|
case "warn":
|
|
return _warnRule(start);
|
|
case "while":
|
|
return _whileRule(start, _functionAtRule);
|
|
default:
|
|
return _disallowedAtRule(start);
|
|
}
|
|
}
|
|
|
|
/// Consumes an at-rule's name.
|
|
String _atRuleName() {
|
|
scanner.expectChar($at);
|
|
var name = identifier();
|
|
whitespace();
|
|
return name;
|
|
}
|
|
|
|
/// Consumes an `@at-root` rule.
|
|
///
|
|
/// [start] should point before the `@`.
|
|
AtRootRule _atRootRule(LineScannerState start) {
|
|
if (scanner.peekChar() == $lparen) {
|
|
var query = _queryExpression();
|
|
whitespace();
|
|
return new AtRootRule(children(_ruleChild), scanner.spanFrom(start),
|
|
query: query);
|
|
} else if (lookingAtChildren()) {
|
|
return new AtRootRule(children(_ruleChild), scanner.spanFrom(start));
|
|
} else {
|
|
var child = _styleRule();
|
|
return new AtRootRule([child], scanner.spanFrom(start));
|
|
}
|
|
}
|
|
|
|
/// Consumes a `@content` rule.
|
|
///
|
|
/// [start] should point before the `@`.
|
|
ContentRule _contentRule(LineScannerState start) {
|
|
if (_inMixin) {
|
|
_mixinHasContent = true;
|
|
return new ContentRule(scanner.spanFrom(start));
|
|
}
|
|
|
|
scanner.error("@content is only allowed within mixin declarations.",
|
|
position: start.position, length: "@content".length);
|
|
return null;
|
|
}
|
|
|
|
/// Consumes a `@debug` rule.
|
|
///
|
|
/// [start] should point before the `@`.
|
|
DebugRule _debugRule(LineScannerState start) {
|
|
var expression = _expression();
|
|
expectStatementSeparator();
|
|
return new DebugRule(expression, scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes an `@each` rule.
|
|
///
|
|
/// [start] should point before the `@`. [child] is called to consume any
|
|
/// children that are specifically allowed in the caller's context.
|
|
EachRule _eachRule(LineScannerState start, Statement child()) {
|
|
var wasInControlDirective = _inControlDirective;
|
|
_inControlDirective = true;
|
|
|
|
var variables = [variableName()];
|
|
whitespace();
|
|
while (scanner.scanChar($comma)) {
|
|
whitespace();
|
|
variables.add(variableName());
|
|
whitespace();
|
|
}
|
|
|
|
expectIdentifier("in");
|
|
whitespace();
|
|
|
|
var list = _expression();
|
|
var children = this.children(child);
|
|
_inControlDirective = wasInControlDirective;
|
|
|
|
return new EachRule(variables, list, children, scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes an `@error` rule.
|
|
///
|
|
/// [start] should point before the `@`.
|
|
ErrorRule _errorRule(LineScannerState start) {
|
|
var expression = _expression();
|
|
expectStatementSeparator();
|
|
return new ErrorRule(expression, scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes an `@extend` rule.
|
|
///
|
|
/// [start] should point before the `@`.
|
|
ExtendRule _extendRule(LineScannerState start) {
|
|
var value = _almostAnyValue();
|
|
var optional = scanner.scanChar($exclamation);
|
|
if (optional) expectIdentifier("optional");
|
|
expectStatementSeparator();
|
|
return new ExtendRule(value, scanner.spanFrom(start), optional: optional);
|
|
}
|
|
|
|
/// Consumes a function declaration.
|
|
///
|
|
/// [start] should point before the `@`.
|
|
FunctionRule _functionRule(LineScannerState start) {
|
|
var name = identifier();
|
|
whitespace();
|
|
var arguments = _argumentDeclaration();
|
|
|
|
if (_inMixin || _inContentBlock) {
|
|
throw new StringScannerException(
|
|
"Mixins may not contain function declarations.",
|
|
scanner.spanFrom(start),
|
|
scanner.string);
|
|
}
|
|
|
|
whitespace();
|
|
var children = this.children(_functionAtRule);
|
|
|
|
return new FunctionRule(name, arguments, children, scanner.spanFrom(start));
|
|
}
|
|
|
|
/// 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.
|
|
ForRule _forRule(LineScannerState start, Statement child()) {
|
|
var wasInControlDirective = _inControlDirective;
|
|
_inControlDirective = true;
|
|
var variable = variableName();
|
|
whitespace();
|
|
|
|
expectIdentifier("from");
|
|
whitespace();
|
|
|
|
bool exclusive;
|
|
var from = _expression(until: () {
|
|
if (!lookingAtIdentifier()) return false;
|
|
if (scanIdentifier("to")) {
|
|
exclusive = true;
|
|
return true;
|
|
} else if (scanIdentifier("through")) {
|
|
exclusive = false;
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
});
|
|
if (exclusive == null) scanner.error('Expected "to" or "through".');
|
|
|
|
whitespace();
|
|
var to = _expression();
|
|
|
|
var children = this.children(child);
|
|
_inControlDirective = wasInControlDirective;
|
|
|
|
return new ForRule(variable, from, to, children, scanner.spanFrom(start),
|
|
exclusive: exclusive);
|
|
}
|
|
|
|
/// Consumes an `@if` rule.
|
|
///
|
|
/// [start] should point before the `@`. [child] is called to consume any
|
|
/// children that are specifically allowed in the caller's context.
|
|
IfRule _ifRule(LineScannerState start, Statement child()) {
|
|
var ifIndentation = currentIndentation;
|
|
var wasInControlDirective = _inControlDirective;
|
|
_inControlDirective = true;
|
|
var expression = _expression();
|
|
var children = this.children(child);
|
|
|
|
var clauses = [new Tuple2(expression, children)];
|
|
List<Statement> lastClause;
|
|
|
|
while (scanElse(ifIndentation)) {
|
|
whitespace();
|
|
if (scanIdentifier("if")) {
|
|
whitespace();
|
|
clauses.add(new Tuple2(_expression(), this.children(child)));
|
|
} else {
|
|
lastClause = this.children(child);
|
|
break;
|
|
}
|
|
}
|
|
_inControlDirective = wasInControlDirective;
|
|
|
|
return new IfRule(clauses, scanner.spanFrom(start), lastClause: lastClause);
|
|
}
|
|
|
|
/// Consumes an `@import` rule.
|
|
///
|
|
/// [start] should point before the `@`.
|
|
ImportRule _importRule(LineScannerState start) {
|
|
var imports = <Import>[];
|
|
do {
|
|
whitespace();
|
|
imports.add(_importArgument());
|
|
whitespace();
|
|
} while (scanner.scanChar($comma));
|
|
expectStatementSeparator();
|
|
|
|
return new ImportRule(imports, scanner.spanFrom(start));
|
|
}
|
|
|
|
Import _importArgument() {
|
|
var start = scanner.state;
|
|
var next = scanner.peekChar();
|
|
if (next == $u || next == $U) {
|
|
var url = _dynamicUrl();
|
|
whitespace();
|
|
var queries = _tryImportQueries();
|
|
return new StaticImport(new Interpolation([url], scanner.spanFrom(start)),
|
|
scanner.spanFrom(start),
|
|
supports: queries?.item1, media: queries?.item2);
|
|
}
|
|
|
|
var url = string();
|
|
var urlSpan = scanner.spanFrom(start);
|
|
whitespace();
|
|
var queries = _tryImportQueries();
|
|
if (_isPlainImportUrl(url)) {
|
|
var interpolation =
|
|
new Interpolation([scanner.substring(start.position)], urlSpan);
|
|
return new StaticImport(interpolation, scanner.spanFrom(start),
|
|
supports: queries?.item1, media: queries?.item2);
|
|
} else if (_inControlDirective || _inMixin) {
|
|
_disallowedAtRule(start);
|
|
return null;
|
|
} else {
|
|
try {
|
|
return new DynamicImport(Uri.parse(url), urlSpan);
|
|
} on FormatException catch (error) {
|
|
throw new SassFormatException("Invalid URL: ${error.message}", urlSpan);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns whether [url] indicates that an `@import` is a plain CSS import.
|
|
bool _isPlainImportUrl(String url) {
|
|
if (url.length < 5) return false;
|
|
if (url.endsWith(".css")) return true;
|
|
|
|
var first = url.codeUnitAt(0);
|
|
if (first == $slash) return url.codeUnitAt(1) == $slash;
|
|
if (first != $h) return false;
|
|
return url.startsWith("http://") || url.startsWith("https://");
|
|
}
|
|
|
|
/// Consumes a supports condition and/or a media query after an `@import`.
|
|
///
|
|
/// Returns `null` if neither type of query can be found.
|
|
Tuple2<SupportsCondition, Interpolation> _tryImportQueries() {
|
|
SupportsCondition supports;
|
|
if (scanIdentifier("supports", ignoreCase: true)) {
|
|
scanner.expectChar($lparen);
|
|
var start = scanner.state;
|
|
if (scanIdentifier("not", ignoreCase: true)) {
|
|
whitespace();
|
|
supports = new SupportsNegation(
|
|
_supportsConditionInParens(), scanner.spanFrom(start));
|
|
} else {
|
|
var name = _expression();
|
|
scanner.expectChar($colon);
|
|
whitespace();
|
|
var value = _expression();
|
|
supports =
|
|
new SupportsDeclaration(name, value, scanner.spanFrom(start));
|
|
}
|
|
scanner.expectChar($rparen);
|
|
whitespace();
|
|
}
|
|
|
|
var media =
|
|
_lookingAtInterpolatedIdentifier() || scanner.peekChar() == $lparen
|
|
? _mediaQueryList()
|
|
: null;
|
|
if (supports == null && media == null) return null;
|
|
return new Tuple2(supports, media);
|
|
}
|
|
|
|
/// Consumes an `@include` rule.
|
|
///
|
|
/// [start] should point before the `@`.
|
|
IncludeRule _includeRule(LineScannerState start) {
|
|
var name = identifier();
|
|
whitespace();
|
|
var arguments = scanner.peekChar() == $lparen
|
|
? _argumentInvocation(mixin: true)
|
|
: new ArgumentInvocation.empty(scanner.emptySpan);
|
|
whitespace();
|
|
|
|
List<Statement> children;
|
|
if (lookingAtChildren()) {
|
|
_inContentBlock = true;
|
|
children = this.children(_ruleChild);
|
|
_inContentBlock = false;
|
|
} else {
|
|
expectStatementSeparator();
|
|
}
|
|
|
|
return new IncludeRule(name, arguments, scanner.spanFrom(start),
|
|
children: children);
|
|
}
|
|
|
|
/// Consumes a `@media` rule.
|
|
///
|
|
/// [start] should point before the `@`.
|
|
MediaRule _mediaRule(LineScannerState start) {
|
|
var query = _mediaQueryList();
|
|
var children = this.children(_ruleChild);
|
|
return new MediaRule(query, children, scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes a mixin declaration.
|
|
///
|
|
/// [start] should point before the `@`.
|
|
MixinRule _mixinRule(LineScannerState start) {
|
|
var name = identifier();
|
|
whitespace();
|
|
var arguments = scanner.peekChar() == $lparen
|
|
? _argumentDeclaration()
|
|
: new ArgumentDeclaration.empty(span: scanner.emptySpan);
|
|
|
|
if (_inMixin || _inContentBlock) {
|
|
throw new StringScannerException(
|
|
"Mixins may not contain mixin declarations.",
|
|
scanner.spanFrom(start),
|
|
scanner.string);
|
|
}
|
|
|
|
whitespace();
|
|
_inMixin = true;
|
|
_mixinHasContent = false;
|
|
var children = this.children(_ruleChild);
|
|
var hadContent = _mixinHasContent;
|
|
_inMixin = false;
|
|
_mixinHasContent = null;
|
|
|
|
return new MixinRule(name, arguments, children, scanner.spanFrom(start),
|
|
hasContent: hadContent);
|
|
}
|
|
|
|
/// Consumes a `@return` rule.
|
|
///
|
|
/// [start] should point before the `@`.
|
|
ReturnRule _returnRule(LineScannerState start) {
|
|
var expression = _expression();
|
|
expectStatementSeparator();
|
|
return new ReturnRule(expression, scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes a `@supports` rule.
|
|
///
|
|
/// [start] should point before the `@`.
|
|
SupportsRule _supportsRule(LineScannerState start) {
|
|
var condition = _supportsCondition();
|
|
whitespace();
|
|
return new SupportsRule(
|
|
condition, children(_ruleChild), scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes a `@warn` rule.
|
|
///
|
|
/// [start] should point before the `@`.
|
|
WarnRule _warnRule(LineScannerState start) {
|
|
var expression = _expression();
|
|
expectStatementSeparator();
|
|
return new WarnRule(expression, 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.
|
|
WhileRule _whileRule(LineScannerState start, Statement child()) {
|
|
var wasInControlDirective = _inControlDirective;
|
|
_inControlDirective = true;
|
|
var expression = _expression();
|
|
var children = this.children(child);
|
|
_inControlDirective = wasInControlDirective;
|
|
return new WhileRule(expression, children, scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes an at-rule that's not explicitly supported by Sass.
|
|
///
|
|
/// [start] should point before the `@`. [name] is the name of the at-rule.
|
|
AtRule _unknownAtRule(LineScannerState start, String name) {
|
|
Interpolation value;
|
|
var next = scanner.peekChar();
|
|
if (next != $exclamation && !atEndOfStatement()) value = _almostAnyValue();
|
|
|
|
var children = lookingAtChildren() ? this.children(_ruleChild) : null;
|
|
if (children == null) expectStatementSeparator();
|
|
|
|
return new AtRule(name, scanner.spanFrom(start),
|
|
value: value, children: children);
|
|
}
|
|
|
|
/// Throws a [StringScannerException] indicating that the at-rule starting at
|
|
/// [start] is not allowed in the current context.
|
|
///
|
|
/// This declares a return type of [Statement] so that it can be returned
|
|
/// within case statements.
|
|
Statement _disallowedAtRule(LineScannerState start) {
|
|
_almostAnyValue();
|
|
scanner.error("This at-rule is not allowed here.",
|
|
position: start.position,
|
|
length: scanner.state.position - start.position);
|
|
return null;
|
|
}
|
|
|
|
/// Consumes an argument declaration.
|
|
ArgumentDeclaration _argumentDeclaration() {
|
|
var start = scanner.state;
|
|
scanner.expectChar($lparen);
|
|
whitespace();
|
|
var arguments = <Argument>[];
|
|
var named = normalizedSet();
|
|
String restArgument;
|
|
while (scanner.peekChar() == $dollar) {
|
|
var variableStart = scanner.state;
|
|
var name = variableName();
|
|
whitespace();
|
|
|
|
Expression defaultValue;
|
|
if (scanner.scanChar($colon)) {
|
|
whitespace();
|
|
defaultValue = _expressionUntilComma();
|
|
} else if (scanner.scanChar($dot)) {
|
|
scanner.expectChar($dot);
|
|
scanner.expectChar($dot);
|
|
restArgument = name;
|
|
break;
|
|
}
|
|
|
|
arguments.add(new Argument(name,
|
|
span: scanner.spanFrom(variableStart), defaultValue: defaultValue));
|
|
if (!named.add(name)) {
|
|
scanner.error("Duplicate argument.",
|
|
position: arguments.last.span.start.offset,
|
|
length: arguments.last.span.length);
|
|
}
|
|
|
|
if (!scanner.scanChar($comma)) break;
|
|
whitespace();
|
|
}
|
|
scanner.expectChar($rparen);
|
|
return new ArgumentDeclaration(arguments,
|
|
restArgument: restArgument, span: scanner.spanFrom(start));
|
|
}
|
|
|
|
// ## Expressions
|
|
|
|
/// 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.
|
|
ArgumentInvocation _argumentInvocation({bool mixin: false}) {
|
|
var wasInParentheses = _inParentheses;
|
|
_inParentheses = true;
|
|
var start = scanner.state;
|
|
scanner.expectChar($lparen);
|
|
whitespace();
|
|
|
|
var positional = <Expression>[];
|
|
var named = normalizedMap/*<Expression>*/();
|
|
Expression rest;
|
|
Expression keywordRest;
|
|
while (_lookingAtExpression()) {
|
|
var expression = _expressionUntilComma(singleEquals: !mixin);
|
|
whitespace();
|
|
|
|
if (expression is VariableExpression && scanner.scanChar($colon)) {
|
|
whitespace();
|
|
if (named.containsKey(expression.name)) {
|
|
scanner.error("Duplicate argument.",
|
|
position: expression.span.start.offset,
|
|
length: expression.span.length);
|
|
}
|
|
named[expression.name] = _expressionUntilComma(singleEquals: !mixin);
|
|
} else if (scanner.scanChar($dot)) {
|
|
scanner.expectChar($dot);
|
|
scanner.expectChar($dot);
|
|
if (rest == null) {
|
|
rest = expression;
|
|
} else {
|
|
keywordRest = expression;
|
|
whitespace();
|
|
break;
|
|
}
|
|
} else if (named.isNotEmpty) {
|
|
scanner.expect("...");
|
|
} else {
|
|
positional.add(expression);
|
|
}
|
|
|
|
whitespace();
|
|
if (!scanner.scanChar($comma)) break;
|
|
whitespace();
|
|
}
|
|
scanner.expectChar($rparen);
|
|
|
|
_inParentheses = wasInParentheses;
|
|
return new ArgumentInvocation(positional, named, scanner.spanFrom(start),
|
|
rest: rest, keywordRest: keywordRest);
|
|
}
|
|
|
|
/// Consumes an expression.
|
|
///
|
|
/// If [bracketList] is true, this parses this expression as the contents of a
|
|
/// bracketed list.
|
|
///
|
|
/// If [singleEquals] is true, this will allow the Microsoft-style `=`
|
|
/// operator at the top level.
|
|
///
|
|
/// If [until] is passed, it's called each time the expression could end and
|
|
/// still be a valid expression. When it returns `true`, this returns the
|
|
/// expression.
|
|
Expression _expression(
|
|
{bool bracketList: false, bool singleEquals: false, bool until()}) {
|
|
if (until != null && until()) scanner.error("Expected expression.");
|
|
|
|
LineScannerState beforeBracket;
|
|
if (bracketList) {
|
|
beforeBracket = scanner.state;
|
|
scanner.expectChar($lbracket);
|
|
whitespace();
|
|
|
|
if (scanner.scanChar($rbracket)) {
|
|
return new ListExpression([], ListSeparator.undecided,
|
|
brackets: true, span: scanner.spanFrom(beforeBracket));
|
|
}
|
|
}
|
|
|
|
var start = scanner.state;
|
|
var wasInParentheses = _inParentheses;
|
|
|
|
List<Expression> commaExpressions;
|
|
|
|
Expression singleEqualsOperand;
|
|
|
|
List<Expression> spaceExpressions;
|
|
|
|
// 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.
|
|
List<BinaryOperator> operators;
|
|
|
|
// The left-hand sides of [operators]. `operands[n]` is the left-hand side
|
|
// of `operators[n]`.
|
|
List<Expression> operands;
|
|
|
|
/// Whether the single expression parsed so far may be interpreted as
|
|
/// slash-separated numbers.
|
|
var allowSlash = lookingAtNumber();
|
|
|
|
/// The leftmost expression that's been fully-parsed. Never `null`.
|
|
var singleExpression = _singleExpression();
|
|
|
|
// Resets the scanner state to the state it was at at the beginning of the
|
|
// expression, except for [_inParentheses].
|
|
resetState() {
|
|
commaExpressions = null;
|
|
spaceExpressions = null;
|
|
operators = null;
|
|
operands = null;
|
|
scanner.state = start;
|
|
allowSlash = lookingAtNumber();
|
|
singleExpression = _singleExpression();
|
|
}
|
|
|
|
resolveOneOperation() {
|
|
var operator = operators.removeLast();
|
|
if (operator != BinaryOperator.dividedBy) allowSlash = false;
|
|
if (allowSlash && !_inParentheses) {
|
|
singleExpression = new BinaryOperationExpression.slash(
|
|
operands.removeLast(), singleExpression);
|
|
} else {
|
|
singleExpression = new BinaryOperationExpression(
|
|
operator, operands.removeLast(), singleExpression);
|
|
}
|
|
}
|
|
|
|
resolveOperations() {
|
|
if (operators == null) return;
|
|
while (!operators.isEmpty) {
|
|
resolveOneOperation();
|
|
}
|
|
}
|
|
|
|
addSingleExpression(Expression expression, {bool number: false}) {
|
|
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 (_inParentheses) {
|
|
_inParentheses = false;
|
|
if (allowSlash) {
|
|
resetState();
|
|
return;
|
|
}
|
|
}
|
|
|
|
spaceExpressions ??= [];
|
|
resolveOperations();
|
|
spaceExpressions.add(singleExpression);
|
|
allowSlash = number;
|
|
} else if (!number) {
|
|
allowSlash = false;
|
|
}
|
|
|
|
singleExpression = expression;
|
|
}
|
|
|
|
addOperator(BinaryOperator operator) {
|
|
operators ??= [];
|
|
operands ??= [];
|
|
while (operators.isNotEmpty &&
|
|
operators.last.precedence >= operator.precedence) {
|
|
resolveOneOperation();
|
|
}
|
|
operators.add(operator);
|
|
|
|
assert(singleExpression != null);
|
|
operands.add(singleExpression);
|
|
whitespace();
|
|
allowSlash = allowSlash && lookingAtNumber();
|
|
singleExpression = _singleExpression();
|
|
allowSlash = allowSlash && singleExpression is NumberExpression;
|
|
}
|
|
|
|
resolveSpaceExpressions() {
|
|
resolveOperations();
|
|
|
|
if (spaceExpressions != null) {
|
|
spaceExpressions.add(singleExpression);
|
|
singleExpression =
|
|
new ListExpression(spaceExpressions, ListSeparator.space);
|
|
spaceExpressions = null;
|
|
}
|
|
|
|
if (singleEqualsOperand != null) {
|
|
singleExpression = new BinaryOperationExpression(
|
|
BinaryOperator.singleEquals, singleEqualsOperand, singleExpression);
|
|
singleEqualsOperand = null;
|
|
}
|
|
}
|
|
|
|
loop:
|
|
while (true) {
|
|
whitespace();
|
|
if (until != null && until()) break;
|
|
|
|
var first = scanner.peekChar();
|
|
switch (first) {
|
|
case $lparen:
|
|
// Parenthesized numbers can't be slash-separated.
|
|
addSingleExpression(_parentheses());
|
|
break;
|
|
|
|
case $lbracket:
|
|
addSingleExpression(_expression(bracketList: true));
|
|
break;
|
|
|
|
case $dollar:
|
|
addSingleExpression(_variable());
|
|
break;
|
|
|
|
case $ampersand:
|
|
addSingleExpression(_selector());
|
|
break;
|
|
|
|
case $single_quote:
|
|
case $double_quote:
|
|
addSingleExpression(interpolatedString());
|
|
break;
|
|
|
|
case $hash:
|
|
addSingleExpression(_hashExpression());
|
|
break;
|
|
|
|
case $equal:
|
|
scanner.readChar();
|
|
if (singleEquals && scanner.peekChar() != $equal) {
|
|
resolveSpaceExpressions();
|
|
singleEqualsOperand = singleExpression;
|
|
singleExpression = null;
|
|
} else {
|
|
scanner.expectChar($equal);
|
|
addOperator(BinaryOperator.equals);
|
|
}
|
|
break;
|
|
|
|
case $exclamation:
|
|
var next = scanner.peekChar(1);
|
|
if (next == $equal) {
|
|
scanner.readChar();
|
|
scanner.readChar();
|
|
addOperator(BinaryOperator.notEquals);
|
|
} else if (next == null ||
|
|
equalsLetterIgnoreCase($i, next) ||
|
|
isWhitespace(next)) {
|
|
addSingleExpression(_importantExpression());
|
|
} else {
|
|
break loop;
|
|
}
|
|
break;
|
|
|
|
case $langle:
|
|
scanner.readChar();
|
|
addOperator(scanner.scanChar($equal)
|
|
? BinaryOperator.lessThanOrEquals
|
|
: BinaryOperator.lessThan);
|
|
break;
|
|
|
|
case $rangle:
|
|
scanner.readChar();
|
|
addOperator(scanner.scanChar($equal)
|
|
? BinaryOperator.greaterThanOrEquals
|
|
: BinaryOperator.greaterThan);
|
|
break;
|
|
|
|
case $asterisk:
|
|
scanner.readChar();
|
|
addOperator(BinaryOperator.times);
|
|
break;
|
|
|
|
case $plus:
|
|
scanner.readChar();
|
|
addOperator(BinaryOperator.plus);
|
|
break;
|
|
|
|
case $minus:
|
|
var next = scanner.peekChar(1);
|
|
if ((isDigit(next) || next == $dot) &&
|
|
// Make sure `1-2` parses as `1 - 2`, not `1 (-2)`.
|
|
(singleExpression == null ||
|
|
isWhitespace(scanner.peekChar(-1)))) {
|
|
addSingleExpression(_number(), number: true);
|
|
} else if (lookingAtIdentifier()) {
|
|
addSingleExpression(_identifierLike());
|
|
} else {
|
|
scanner.readChar();
|
|
addOperator(BinaryOperator.minus);
|
|
}
|
|
break;
|
|
|
|
case $slash:
|
|
scanner.readChar();
|
|
addOperator(BinaryOperator.dividedBy);
|
|
break;
|
|
|
|
case $percent:
|
|
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(_number(), number: true);
|
|
break;
|
|
|
|
case $dot:
|
|
if (scanner.peekChar(1) == $dot) break loop;
|
|
addSingleExpression(_number(), number: true);
|
|
break;
|
|
|
|
case $a:
|
|
if (scanIdentifier("and")) {
|
|
addOperator(BinaryOperator.and);
|
|
} else {
|
|
addSingleExpression(_identifierLike());
|
|
}
|
|
break;
|
|
|
|
case $o:
|
|
if (scanIdentifier("or")) {
|
|
addOperator(BinaryOperator.or);
|
|
} else {
|
|
addSingleExpression(_identifierLike());
|
|
}
|
|
break;
|
|
|
|
case $u:
|
|
case $U:
|
|
if (scanner.peekChar(1) == $plus) {
|
|
addSingleExpression(_unicodeRange());
|
|
} else {
|
|
addSingleExpression(_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 $backslash:
|
|
addSingleExpression(_identifierLike());
|
|
break;
|
|
|
|
case $comma:
|
|
// 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 (_inParentheses) {
|
|
_inParentheses = false;
|
|
if (allowSlash) {
|
|
resetState();
|
|
break;
|
|
}
|
|
}
|
|
|
|
commaExpressions ??= [];
|
|
if (singleExpression == null) scanner.error("Expected expression.");
|
|
|
|
resolveSpaceExpressions();
|
|
commaExpressions.add(singleExpression);
|
|
scanner.readChar();
|
|
allowSlash = true;
|
|
singleExpression = null;
|
|
break;
|
|
|
|
default:
|
|
if (first != null && first >= 0x80) {
|
|
addSingleExpression(_identifierLike());
|
|
break;
|
|
} else {
|
|
break loop;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bracketList) scanner.expectChar($rbracket);
|
|
if (commaExpressions != null) {
|
|
resolveSpaceExpressions();
|
|
_inParentheses = wasInParentheses;
|
|
if (singleExpression != null) commaExpressions.add(singleExpression);
|
|
return new ListExpression(commaExpressions, ListSeparator.comma,
|
|
brackets: bracketList,
|
|
span: bracketList ? scanner.spanFrom(beforeBracket) : null);
|
|
} else if (bracketList &&
|
|
spaceExpressions != null &&
|
|
singleEqualsOperand == null) {
|
|
resolveOperations();
|
|
return new ListExpression(
|
|
spaceExpressions..add(singleExpression), ListSeparator.space,
|
|
brackets: true, span: scanner.spanFrom(beforeBracket));
|
|
} else {
|
|
resolveSpaceExpressions();
|
|
if (bracketList) {
|
|
singleExpression = new ListExpression(
|
|
[singleExpression], ListSeparator.undecided,
|
|
brackets: true, span: scanner.spanFrom(beforeBracket));
|
|
}
|
|
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.
|
|
Expression _expressionUntilComma({bool singleEquals: false}) => _expression(
|
|
singleEquals: singleEquals, until: () => scanner.peekChar() == $comma);
|
|
|
|
/// Consumes an expression that doesn't contain any top-level whitespace.
|
|
Expression _singleExpression() {
|
|
var first = scanner.peekChar();
|
|
switch (first) {
|
|
// Note: when adding a new case, make sure it's reflected in
|
|
// [_lookingAtExpression] and [_expression].
|
|
case $lparen:
|
|
return _parentheses();
|
|
case $slash:
|
|
return _unaryOperation();
|
|
case $dot:
|
|
return _number();
|
|
case $lbracket:
|
|
return _expression(bracketList: true);
|
|
case $dollar:
|
|
return _variable();
|
|
case $ampersand:
|
|
return _selector();
|
|
|
|
case $single_quote:
|
|
case $double_quote:
|
|
return interpolatedString();
|
|
|
|
case $hash:
|
|
return _hashExpression();
|
|
|
|
case $plus:
|
|
return _plusExpression();
|
|
|
|
case $minus:
|
|
return _minusExpression();
|
|
|
|
case $exclamation:
|
|
return _importantExpression();
|
|
|
|
case $u:
|
|
case $U:
|
|
if (scanner.peekChar(1) == $plus) {
|
|
return _unicodeRange();
|
|
} else {
|
|
return _identifierLike();
|
|
}
|
|
break;
|
|
|
|
case $0:
|
|
case $1:
|
|
case $2:
|
|
case $3:
|
|
case $4:
|
|
case $5:
|
|
case $6:
|
|
case $7:
|
|
case $8:
|
|
case $9:
|
|
return _number();
|
|
break;
|
|
|
|
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 $backslash:
|
|
return _identifierLike();
|
|
break;
|
|
|
|
default:
|
|
if (first != null && first >= 0x80) return _identifierLike();
|
|
scanner.error("Expected expression.");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Consumes a parenthesized expression.
|
|
Expression _parentheses() {
|
|
var wasInParentheses = _inParentheses;
|
|
_inParentheses = true;
|
|
try {
|
|
var start = scanner.state;
|
|
scanner.expectChar($lparen);
|
|
whitespace();
|
|
if (!_lookingAtExpression()) {
|
|
scanner.expectChar($rparen);
|
|
return new ListExpression([], ListSeparator.undecided,
|
|
span: scanner.spanFrom(start));
|
|
}
|
|
|
|
var first = _expressionUntilComma();
|
|
if (scanner.scanChar($colon)) {
|
|
whitespace();
|
|
return _map(first, start);
|
|
}
|
|
|
|
if (!scanner.scanChar($comma)) {
|
|
scanner.expectChar($rparen);
|
|
return first;
|
|
}
|
|
whitespace();
|
|
|
|
var expressions = [first];
|
|
while (true) {
|
|
if (!_lookingAtExpression()) break;
|
|
expressions.add(_expressionUntilComma());
|
|
if (!scanner.scanChar($comma)) break;
|
|
whitespace();
|
|
}
|
|
|
|
scanner.expectChar($rparen);
|
|
return new ListExpression(expressions, ListSeparator.comma,
|
|
span: scanner.spanFrom(start));
|
|
} finally {
|
|
_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.
|
|
MapExpression _map(Expression first, LineScannerState start) {
|
|
var pairs = [new Tuple2(first, _expressionUntilComma())];
|
|
|
|
while (scanner.scanChar($comma)) {
|
|
whitespace();
|
|
if (!_lookingAtExpression()) break;
|
|
|
|
var key = _expressionUntilComma();
|
|
scanner.expectChar($colon);
|
|
whitespace();
|
|
var value = _expressionUntilComma();
|
|
pairs.add(new Tuple2(key, value));
|
|
}
|
|
|
|
scanner.expectChar($rparen);
|
|
return new MapExpression(pairs, scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes an expression that starts with a `#`.
|
|
Expression _hashExpression() {
|
|
assert(scanner.peekChar() == $hash);
|
|
if (scanner.peekChar(1) == $lbrace) return _identifierLike();
|
|
|
|
var start = scanner.state;
|
|
scanner.expectChar($hash);
|
|
|
|
var first = scanner.peekChar();
|
|
if (first != null && isDigit(first)) {
|
|
var color = _hexColorContents();
|
|
var span = scanner.spanFrom(start);
|
|
setOriginalSpan(color, span);
|
|
return new ColorExpression(color, span);
|
|
}
|
|
|
|
var afterHash = scanner.state;
|
|
var identifier = _interpolatedIdentifier();
|
|
if (_isHexColor(identifier)) {
|
|
scanner.state = afterHash;
|
|
var color = _hexColorContents();
|
|
var span = scanner.spanFrom(start);
|
|
setOriginalSpan(color, span);
|
|
return new ColorExpression(color, span);
|
|
}
|
|
|
|
var buffer = new InterpolationBuffer();
|
|
buffer.writeCharCode($hash);
|
|
buffer.addInterpolation(identifier);
|
|
return new StringExpression(buffer.interpolation(scanner.spanFrom(start)));
|
|
}
|
|
|
|
/// Consumes the contents of a hex color, after the `#`.
|
|
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);
|
|
}
|
|
|
|
/// Returns whether [interpolation] is a plain string that can be parsed as a
|
|
/// hex color.
|
|
bool _isHexColor(Interpolation interpolation) {
|
|
var plain = interpolation.asPlain;
|
|
if (plain == null) return false;
|
|
if (plain.length != 3 && plain.length != 6) return false;
|
|
return plain.codeUnits.every(isHex);
|
|
}
|
|
|
|
// Consumes a single hexadecimal digit.
|
|
int _hexDigit() {
|
|
var char = scanner.peekChar();
|
|
if (char == null || !isHex(char)) scanner.error("Expected hex digit.");
|
|
return asHex(scanner.readChar());
|
|
}
|
|
|
|
/// Consumes an expression that starts with a `+`.
|
|
Expression _plusExpression() {
|
|
assert(scanner.peekChar() == $plus);
|
|
var next = scanner.peekChar(1);
|
|
return isDigit(next) || next == $dot ? _number() : _unaryOperation();
|
|
}
|
|
|
|
/// Consumes an expression that starts with a `-`.
|
|
Expression _minusExpression() {
|
|
assert(scanner.peekChar() == $minus);
|
|
var next = scanner.peekChar(1);
|
|
if (isDigit(next) || next == $dot) return _number();
|
|
if (_lookingAtInterpolatedIdentifier()) return _identifierLike();
|
|
return _unaryOperation();
|
|
}
|
|
|
|
/// Consumes an `!important` expression.
|
|
Expression _importantExpression() {
|
|
assert(scanner.peekChar() == $exclamation);
|
|
|
|
var start = scanner.state;
|
|
scanner.readChar();
|
|
whitespace();
|
|
expectIdentifier("important", ignoreCase: true);
|
|
return new StringExpression.plain("!important", scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes a unary operation expression.
|
|
UnaryOperationExpression _unaryOperation() {
|
|
var start = scanner.state;
|
|
var operator = _unaryOperatorFor(scanner.readChar());
|
|
if (operator == null) {
|
|
scanner.error("Expected unary operator", position: scanner.position - 1);
|
|
}
|
|
|
|
whitespace();
|
|
var operand = _singleExpression();
|
|
return new UnaryOperationExpression(
|
|
operator, operand, scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Returns the unsary operator corresponding to [character], or `null` if
|
|
/// the character is not a unary operator.
|
|
UnaryOperator _unaryOperatorFor(int character) {
|
|
switch (character) {
|
|
case $plus:
|
|
return UnaryOperator.plus;
|
|
case $minus:
|
|
return UnaryOperator.minus;
|
|
case $slash:
|
|
return UnaryOperator.divide;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Consumes a number expression.
|
|
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 += asDecimal(scanner.readChar());
|
|
}
|
|
|
|
if (scanner.peekChar() == $dot) {
|
|
scanner.readChar();
|
|
if (!isDigit(scanner.peekChar())) scanner.error("Expected digit.");
|
|
|
|
var decimal = 0.1;
|
|
while (isDigit(scanner.peekChar())) {
|
|
number += asDecimal(scanner.readChar()) * decimal;
|
|
decimal /= 10;
|
|
}
|
|
}
|
|
|
|
if (scanIdentifier("e", ignoreCase: true)) {
|
|
scanner.readChar();
|
|
var 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);
|
|
}
|
|
|
|
String unit;
|
|
if (scanner.scanChar($percent)) {
|
|
unit = "%";
|
|
} else if (lookingAtIdentifier() &&
|
|
// Disallow units beginning with `--`.
|
|
(scanner.peekChar() != $dash || scanner.peekChar(1) != $dash)) {
|
|
unit = identifier(unit: true);
|
|
}
|
|
|
|
return new NumberExpression(sign * number, scanner.spanFrom(start),
|
|
unit: unit);
|
|
}
|
|
|
|
/// Consumes a unicode range expression.
|
|
StringExpression _unicodeRange() {
|
|
var start = scanner.state;
|
|
expectCharIgnoreCase($u);
|
|
scanner.expectChar($plus);
|
|
|
|
var i = 0;
|
|
for (; i < 6; i++) {
|
|
if (!scanCharIf((char) => char != null && isHex(char))) break;
|
|
}
|
|
|
|
if (scanner.scanChar($question)) {
|
|
i++;
|
|
for (; i < 6; i++) {
|
|
if (!scanner.scanChar($question)) break;
|
|
}
|
|
return new StringExpression.plain(
|
|
scanner.substring(start.position), scanner.spanFrom(start));
|
|
}
|
|
if (i == 0) scanner.error('Expected hex digit or "?".');
|
|
|
|
if (scanner.scanChar($minus)) {
|
|
var j = 0;
|
|
for (; j < 6; j++) {
|
|
if (!scanCharIf((char) => char != null && isHex(char))) break;
|
|
}
|
|
if (j == 0) scanner.error("Expected hex digit.");
|
|
}
|
|
|
|
if (_lookingAtInterpolatedIdentifierBody()) {
|
|
scanner.error("Expected end of identifier.");
|
|
}
|
|
|
|
return new StringExpression.plain(
|
|
scanner.substring(start.position), scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes a variable expression.
|
|
VariableExpression _variable() {
|
|
var start = scanner.state;
|
|
return new VariableExpression(variableName(), scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes a selector expression.
|
|
SelectorExpression _selector() {
|
|
var start = scanner.state;
|
|
scanner.expectChar($ampersand);
|
|
return new SelectorExpression(scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes a quoted string expression.
|
|
StringExpression interpolatedString() {
|
|
// NOTE: this logic is largely duplicated in ScssParser.interpolatedString.
|
|
// Most changes here should be mirrored there.
|
|
|
|
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(escapeCharacter());
|
|
}
|
|
} else if (next == $hash) {
|
|
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)),
|
|
quotes: true);
|
|
}
|
|
|
|
/// Consumes an expression that starts like an identifier.
|
|
Expression _identifierLike() {
|
|
var start = scanner.state;
|
|
var identifier = _interpolatedIdentifier();
|
|
var plain = identifier.asPlain;
|
|
if (plain != null) {
|
|
if (plain == "if") {
|
|
var invocation = _argumentInvocation();
|
|
return new IfExpression(
|
|
invocation, spanForList([identifier, invocation]));
|
|
}
|
|
|
|
var lower = plain.toLowerCase();
|
|
if (scanner.peekChar() != $lparen) {
|
|
switch (plain) {
|
|
case "false":
|
|
return new BooleanExpression(false, identifier.span);
|
|
case "not":
|
|
whitespace();
|
|
return new UnaryOperationExpression(
|
|
UnaryOperator.not, _singleExpression(), identifier.span);
|
|
case "null":
|
|
return new NullExpression(identifier.span);
|
|
case "true":
|
|
return new BooleanExpression(true, identifier.span);
|
|
}
|
|
|
|
var color = colorsByName[lower];
|
|
if (color != null) {
|
|
// TODO(nweiz): Avoid copying the color in compressed mode.
|
|
color = new SassColor.rgb(
|
|
color.red, color.green, color.blue, color.alpha);
|
|
setOriginalSpan(color, identifier.span);
|
|
return new ColorExpression(color, identifier.span);
|
|
}
|
|
}
|
|
|
|
var specialFunction = _trySpecialFunction(lower, start);
|
|
if (specialFunction != null) return specialFunction;
|
|
}
|
|
|
|
return scanner.peekChar() == $lparen
|
|
? new FunctionExpression(identifier, _argumentInvocation())
|
|
: new StringExpression(identifier);
|
|
}
|
|
|
|
/// 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].
|
|
Expression _trySpecialFunction(String name, LineScannerState start) {
|
|
var normalized = unvendor(name);
|
|
|
|
InterpolationBuffer buffer;
|
|
switch (normalized) {
|
|
case "calc":
|
|
case "element":
|
|
case "expression":
|
|
if (!scanner.scanChar($lparen)) return null;
|
|
buffer = new InterpolationBuffer()
|
|
..write(name)
|
|
..writeCharCode($lparen);
|
|
break;
|
|
|
|
case "progid":
|
|
if (!scanner.scanChar($colon)) return null;
|
|
buffer = new InterpolationBuffer()
|
|
..write(name)
|
|
..writeCharCode($colon);
|
|
var next = scanner.peekChar();
|
|
while (next != null && (isAlphabetic(next) || next == $dot)) {
|
|
buffer.writeCharCode(scanner.readChar());
|
|
next = scanner.peekChar();
|
|
}
|
|
scanner.expectChar($lparen);
|
|
buffer.writeCharCode($lparen);
|
|
break;
|
|
|
|
case "url":
|
|
var contents = _tryUrlContents(start);
|
|
if (contents != null) return new StringExpression(contents);
|
|
return new FunctionExpression(
|
|
new Interpolation(["url"], scanner.spanFrom(start)),
|
|
_argumentInvocation());
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
|
|
buffer.addInterpolation(_interpolatedDeclarationValue().text);
|
|
scanner.expectChar($rparen);
|
|
buffer.writeCharCode($rparen);
|
|
|
|
return new StringExpression(buffer.interpolation(scanner.spanFrom(start)));
|
|
}
|
|
|
|
/// Consumes the contents of a `url()` token (after the name).
|
|
///
|
|
/// [start] is the position before the beginning of the name.
|
|
Interpolation _tryUrlContents(LineScannerState start) {
|
|
// NOTE: this logic is largely duplicated in Parser.tryUrl. Most changes
|
|
// here should be mirrored there.
|
|
|
|
var start = scanner.state;
|
|
if (!scanner.scanChar($lparen)) return null;
|
|
whitespaceWithoutComments();
|
|
|
|
// Match Ruby Sass's behavior: parse a raw URL() if possible, and if not
|
|
// backtrack and re-parse as a function expression.
|
|
var buffer = new InterpolationBuffer()..write("url(");
|
|
while (true) {
|
|
var next = scanner.peekChar();
|
|
if (next == null) {
|
|
break;
|
|
} else if (next == $percent ||
|
|
next == $ampersand ||
|
|
(next >= $asterisk && next <= $tilde) ||
|
|
next >= 0x0080) {
|
|
buffer.writeCharCode(scanner.readChar());
|
|
} else if (next == $backslash) {
|
|
buffer.write(escape());
|
|
} else if (next == $hash) {
|
|
if (scanner.peekChar(1) == $lbrace) {
|
|
buffer.add(singleInterpolation());
|
|
} else {
|
|
buffer.writeCharCode(scanner.readChar());
|
|
}
|
|
} else if (isWhitespace(next)) {
|
|
whitespaceWithoutComments();
|
|
if (scanner.peekChar() != $rparen) break;
|
|
} else if (next == $rparen) {
|
|
buffer.writeCharCode(scanner.readChar());
|
|
return buffer.interpolation(scanner.spanFrom(start));
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
scanner.state = start;
|
|
return null;
|
|
}
|
|
|
|
/// Consumes a [url] token that's allowed to contain SassScript.
|
|
Expression _dynamicUrl() {
|
|
var start = scanner.state;
|
|
expectIdentifier("url", ignoreCase: true);
|
|
var contents = _tryUrlContents(start);
|
|
if (contents != null) return new StringExpression(contents);
|
|
|
|
return new FunctionExpression(
|
|
new Interpolation(["url"], scanner.spanFrom(start)),
|
|
_argumentInvocation());
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// Differences from [_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.
|
|
Interpolation _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(interpolatedString().asInterpolation());
|
|
break;
|
|
|
|
case $slash:
|
|
var commentStart = scanner.position;
|
|
if (scanComment()) {
|
|
buffer.write(scanner.substring(commentStart));
|
|
} else {
|
|
buffer.writeCharCode(scanner.readChar());
|
|
}
|
|
break;
|
|
|
|
case $hash:
|
|
if (scanner.peekChar(1) == $lbrace) {
|
|
// Add a full interpolated identifier to handle cases like
|
|
// "#{...}--1", since "--1" isn't a valid identifier on its own.
|
|
buffer.addInterpolation(_interpolatedIdentifier());
|
|
} else {
|
|
buffer.writeCharCode(scanner.readChar());
|
|
}
|
|
break;
|
|
|
|
case $cr:
|
|
case $lf:
|
|
case $ff:
|
|
if (indented) break loop;
|
|
buffer.writeCharCode(scanner.readChar());
|
|
break;
|
|
|
|
case $exclamation:
|
|
case $semicolon:
|
|
case $lbrace:
|
|
case $rbrace:
|
|
break loop;
|
|
|
|
case $u:
|
|
case $U:
|
|
var beforeUrl = scanner.state;
|
|
if (!scanIdentifier("url", ignoreCase: true)) {
|
|
buffer.writeCharCode(scanner.readChar());
|
|
break;
|
|
}
|
|
|
|
var contents = _tryUrlContents(beforeUrl);
|
|
if (contents == null) {
|
|
scanner.state = beforeUrl;
|
|
buffer.writeCharCode(scanner.readChar());
|
|
} else {
|
|
buffer.addInterpolation(contents);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
if (next == null) break loop;
|
|
|
|
if (lookingAtIdentifier()) {
|
|
buffer.write(identifier());
|
|
} else {
|
|
buffer.writeCharCode(scanner.readChar());
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return buffer.interpolation(scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes tokens until it reaches a top-level `":"`, `"!"`, `")"`, `"]"`,
|
|
/// or `"}"` and returns their contents as a string.
|
|
///
|
|
/// Unlike [declarationValue], this allows interpolation.
|
|
StringExpression _interpolatedDeclarationValue() {
|
|
// NOTE: this logic is largely duplicated in Parser.declarationValue. Most
|
|
// changes here should be mirrored there.
|
|
|
|
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.write(escape());
|
|
wroteNewline = false;
|
|
break;
|
|
|
|
case $double_quote:
|
|
case $single_quote:
|
|
buffer.addInterpolation(interpolatedString().asInterpolation());
|
|
wroteNewline = false;
|
|
break;
|
|
|
|
case $slash:
|
|
if (scanner.peekChar(1) == $asterisk) {
|
|
buffer.write(rawText(loudComment));
|
|
} else {
|
|
buffer.writeCharCode(scanner.readChar());
|
|
}
|
|
wroteNewline = false;
|
|
break;
|
|
|
|
case $hash:
|
|
if (scanner.peekChar(1) == $lbrace) {
|
|
// Add a full interpolated identifier to handle cases like
|
|
// "#{...}--1", since "--1" isn't a valid identifier on its own.
|
|
buffer.addInterpolation(_interpolatedIdentifier());
|
|
} 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 (indented) break loop;
|
|
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;
|
|
|
|
case $u:
|
|
case $U:
|
|
var beforeUrl = scanner.state;
|
|
if (!scanIdentifier("url", ignoreCase: true)) {
|
|
buffer.writeCharCode(scanner.readChar());
|
|
wroteNewline = false;
|
|
break;
|
|
}
|
|
|
|
var contents = _tryUrlContents(beforeUrl);
|
|
if (contents == null) {
|
|
scanner.state = beforeUrl;
|
|
buffer.writeCharCode(scanner.readChar());
|
|
} else {
|
|
buffer.addInterpolation(contents);
|
|
}
|
|
wroteNewline = false;
|
|
break;
|
|
|
|
default:
|
|
if (next == null) break loop;
|
|
|
|
if (lookingAtIdentifier()) {
|
|
buffer.write(identifier());
|
|
} else {
|
|
buffer.writeCharCode(scanner.readChar());
|
|
}
|
|
wroteNewline = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (brackets.isNotEmpty) scanner.expectChar(brackets.last);
|
|
return new StringExpression(buffer.interpolation(scanner.spanFrom(start)));
|
|
}
|
|
|
|
/// Consumes an identifier that may contain interpolation.
|
|
Interpolation _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.write(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.write(escape());
|
|
} else if (next == $hash && scanner.peekChar(1) == $lbrace) {
|
|
buffer.add(singleInterpolation());
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return buffer.interpolation(scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes interpolation.
|
|
Expression singleInterpolation() {
|
|
scanner.expect('#{');
|
|
whitespace();
|
|
var expression = _expression();
|
|
scanner.expectChar($rbrace);
|
|
return expression;
|
|
}
|
|
|
|
/// Consumes a query expression of the form `(foo: bar)`.
|
|
Interpolation _queryExpression() {
|
|
if (scanner.peekChar() == $hash) {
|
|
var interpolation = singleInterpolation();
|
|
return new Interpolation([interpolation], interpolation.span);
|
|
}
|
|
|
|
var start = scanner.state;
|
|
var buffer = new InterpolationBuffer();
|
|
scanner.expectChar($lparen);
|
|
buffer.writeCharCode($lparen);
|
|
whitespace();
|
|
|
|
buffer.add(_expression());
|
|
if (scanner.scanChar($colon)) {
|
|
whitespace();
|
|
buffer.writeCharCode($colon);
|
|
buffer.writeCharCode($space);
|
|
buffer.add(_expression());
|
|
}
|
|
|
|
scanner.expectChar($rparen);
|
|
whitespace();
|
|
buffer.writeCharCode($rparen);
|
|
|
|
return buffer.interpolation(scanner.spanFrom(start));
|
|
}
|
|
|
|
// ## Media Queries
|
|
|
|
/// Consumes a list of media queries.
|
|
Interpolation _mediaQueryList() {
|
|
var start = scanner.state;
|
|
var buffer = new InterpolationBuffer();
|
|
while (true) {
|
|
whitespace();
|
|
_mediaQuery(buffer);
|
|
if (!scanner.scanChar($comma)) break;
|
|
buffer.writeCharCode($comma);
|
|
buffer.writeCharCode($space);
|
|
}
|
|
return buffer.interpolation(scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Consumes a single media query.
|
|
void _mediaQuery(InterpolationBuffer buffer) {
|
|
// This is somewhat duplicated in MediaQueryParser._mediaQuery.
|
|
if (scanner.peekChar() != $lparen) {
|
|
buffer.addInterpolation(_interpolatedIdentifier());
|
|
whitespace();
|
|
|
|
if (!_lookingAtInterpolatedIdentifier()) {
|
|
// For example, "@media screen {".
|
|
return;
|
|
}
|
|
|
|
buffer.writeCharCode($space);
|
|
var identifier = _interpolatedIdentifier();
|
|
whitespace();
|
|
|
|
if (equalsIgnoreCase(identifier.asPlain, "and")) {
|
|
// For example, "@media screen and ..."
|
|
buffer.write(" and ");
|
|
} else {
|
|
buffer.addInterpolation(identifier);
|
|
if (scanIdentifier("and", ignoreCase: true)) {
|
|
// For example, "@media only screen and ..."
|
|
whitespace();
|
|
buffer.write(" and ");
|
|
} else {
|
|
// For example, "@media only screen {"
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// We've consumed either `IDENTIFIER "and"` or
|
|
// `IDENTIFIER IDENTIFIER "and"`.
|
|
|
|
while (true) {
|
|
whitespace();
|
|
buffer.addInterpolation(_queryExpression());
|
|
whitespace();
|
|
if (!scanIdentifier("and", ignoreCase: true)) break;
|
|
buffer.write(" and ");
|
|
}
|
|
}
|
|
|
|
// ## Supports Conditions
|
|
|
|
/// Consumes a `@supports` condition.
|
|
SupportsCondition _supportsCondition() {
|
|
var start = scanner.state;
|
|
var first = scanner.peekChar();
|
|
if (first != $lparen && first != $hash) {
|
|
var start = scanner.state;
|
|
expectIdentifier("not", ignoreCase: true);
|
|
whitespace();
|
|
return new SupportsNegation(
|
|
_supportsConditionInParens(), scanner.spanFrom(start));
|
|
}
|
|
|
|
var condition = _supportsConditionInParens();
|
|
whitespace();
|
|
while (lookingAtIdentifier()) {
|
|
String operator;
|
|
if (scanIdentifier("or", ignoreCase: true)) {
|
|
operator = "or";
|
|
} else {
|
|
expectIdentifier("and", ignoreCase: true);
|
|
operator = "and";
|
|
}
|
|
|
|
whitespace();
|
|
var right = _supportsConditionInParens();
|
|
condition = new SupportsOperation(
|
|
condition, right, operator, scanner.spanFrom(start));
|
|
whitespace();
|
|
}
|
|
return condition;
|
|
}
|
|
|
|
/// Consumes a parenthesized supports condition, or an interpolation.
|
|
SupportsCondition _supportsConditionInParens() {
|
|
var start = scanner.state;
|
|
if (scanner.peekChar() == $hash) {
|
|
return new SupportsInterpolation(
|
|
singleInterpolation(), scanner.spanFrom(start));
|
|
}
|
|
|
|
scanner.expectChar($lparen);
|
|
whitespace();
|
|
var next = scanner.peekChar();
|
|
if (next == $lparen || next == $hash) {
|
|
var condition = _supportsCondition();
|
|
whitespace();
|
|
scanner.expectChar($rparen);
|
|
return condition;
|
|
}
|
|
|
|
if (next == $n || next == $N) {
|
|
var negation = _trySupportsNegation();
|
|
if (negation != null) return negation;
|
|
}
|
|
|
|
var name = _expression();
|
|
scanner.expectChar($colon);
|
|
whitespace();
|
|
var value = _expression();
|
|
scanner.expectChar($rparen);
|
|
return new SupportsDeclaration(name, value, scanner.spanFrom(start));
|
|
}
|
|
|
|
/// Tries to consume a negated supports condition.
|
|
///
|
|
/// Returns `null` if it fails.
|
|
SupportsNegation _trySupportsNegation() {
|
|
var start = scanner.state;
|
|
if (!scanIdentifier("not", ignoreCase: true) || scanner.isDone) {
|
|
scanner.state = start;
|
|
return null;
|
|
}
|
|
|
|
var next = scanner.peekChar();
|
|
if (!isWhitespace(next) && next != $lparen) {
|
|
scanner.state = start;
|
|
return null;
|
|
}
|
|
|
|
return new SupportsNegation(
|
|
_supportsConditionInParens(), scanner.spanFrom(start));
|
|
}
|
|
|
|
// ## Characters
|
|
|
|
/// 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
|
|
bool _lookingAtInterpolatedIdentifier() {
|
|
// See also [ScssParser._lookingAtIdentifier].
|
|
|
|
var first = scanner.peekChar();
|
|
if (first == null) return false;
|
|
if (isNameStart(first) || first == $backslash) return true;
|
|
if (first == $hash) return scanner.peekChar(1) == $lbrace;
|
|
|
|
if (first != $dash) return false;
|
|
var second = scanner.peekChar(1);
|
|
if (isNameStart(second) || second == $dash || second == $backslash) {
|
|
return true;
|
|
}
|
|
return second == $hash && scanner.peekChar(2) == $lbrace;
|
|
}
|
|
|
|
/// 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.
|
|
bool _lookingAtInterpolatedIdentifierBody() {
|
|
var first = scanner.peekChar();
|
|
if (first == null) return false;
|
|
if (isName(first) || first == $backslash) return true;
|
|
return first == $hash && scanner.peekChar(1) == $lbrace;
|
|
}
|
|
|
|
/// Returns whether the scanner is immediately before a SassScript expression.
|
|
bool _lookingAtExpression() {
|
|
var character = scanner.peekChar();
|
|
if (character == null) return false;
|
|
if (character == $dot) return scanner.peekChar(1) != $dot;
|
|
if (character == $exclamation) {
|
|
var next = scanner.peekChar(1);
|
|
return next == null ||
|
|
equalsLetterIgnoreCase($i, next) ||
|
|
isWhitespace(next);
|
|
}
|
|
|
|
return character == $lparen ||
|
|
character == $slash ||
|
|
character == $lbracket ||
|
|
character == $single_quote ||
|
|
character == $double_quote ||
|
|
character == $hash ||
|
|
character == $plus ||
|
|
character == $minus ||
|
|
character == $backslash ||
|
|
character == $dollar ||
|
|
character == $ampersand ||
|
|
isNameStart(character) ||
|
|
isDigit(character);
|
|
}
|
|
|
|
// ## Utilities
|
|
|
|
// ## Abstract Methods
|
|
|
|
/// Whether this is parsing the indented syntax.
|
|
bool get indented;
|
|
|
|
/// The indentation level at the current scanner position.
|
|
///
|
|
/// This value isn't used directly by [StylesheetParser]; it's just passed to
|
|
/// [scanElse].
|
|
int get currentIndentation;
|
|
|
|
/// Asserts that the scanner is positioned before a statement separator, or at
|
|
/// the end of a list of statements.
|
|
///
|
|
/// This consumes whitespace, but nothing else, including comments.
|
|
void expectStatementSeparator();
|
|
|
|
/// Whether the scanner is positioned at the end of a statement.
|
|
bool atEndOfStatement();
|
|
|
|
/// Whether the scanner is positioned before a block of children that can be
|
|
/// parsed with [children].
|
|
bool lookingAtChildren();
|
|
|
|
/// 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 [currentIndentation] from before the
|
|
/// corresponding `@if` was parsed.
|
|
bool scanElse(int ifIndentation);
|
|
|
|
/// Consumes a block of child statements.
|
|
List<Statement> children(Statement child());
|
|
|
|
/// Consumes top-level statements.
|
|
///
|
|
/// The [statement] callback may return `null`, indicating that a statement
|
|
/// was consumed that shouldn't be added to the AST.
|
|
List<Statement> statements(Statement statement());
|
|
}
|