dart-sass/lib/src/visitor/serialize.dart
2016-10-20 17:50:02 -07:00

843 lines
24 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 'dart:typed_data';
import 'package:charcode/charcode.dart';
import 'package:string_scanner/string_scanner.dart';
import '../ast/css.dart';
import '../ast/selector.dart';
import '../exception.dart';
import '../util/character.dart';
import '../util/number.dart';
import '../value.dart';
import 'interface/css.dart';
import 'interface/selector.dart';
import 'interface/value.dart';
/// Converts [node] to a CSS string.
///
/// If [style] is passed, it controls the style of the resulting CSS. It
/// defaults to [OutputStyle.expanded].
///
/// If [inspect] is `true`, this will emit an unambiguous representation of the
/// source structure. Note however that, although this will be valid SCSS, it
/// may not be valid CSS. If [inspect] is `false` and [node] contains any values
/// that can't be represented in plain CSS, throws a [SassException].
String toCss(CssNode node, {OutputStyle style, bool inspect: false}) {
var visitor = new _SerializeCssVisitor(style: style, inspect: inspect);
node.accept(visitor);
var result = visitor._buffer.toString();
if (result.codeUnits.any((codeUnit) => codeUnit > 0x7F)) {
result = '@charset "UTF-8";\n$result';
}
// TODO(nweiz): Do this in a way that's not O(n), maybe using a custom buffer
// that's not append-only.
return result.trim();
}
/// Converts [value] to a CSS string.
///
/// If [inspect] is `true`, this will emit an unambiguous representation of the
/// source structure. Note however that, although this will be valid SCSS, it
/// may not be valid CSS. If [inspect] is `false` and [value] can't be
/// represented in plain CSS, throws a [SassScriptException].
///
/// If [quote] is `false`, quoted strings are emitted without quotes.
String valueToCss(Value value, {bool inspect: false, bool quote: true}) {
var visitor = new _SerializeCssVisitor(inspect: inspect, quote: quote);
value.accept(visitor);
return visitor._buffer.toString();
}
/// Converts [selector] to a CSS string.
///
/// If [inspect] is `true`, this will emit an unambiguous representation of the
/// source structure. Note however that, although this will be valid SCSS, it
/// may not be valid CSS. If [inspect] is `false` and [selector] can't be
/// represented in plain CSS, throws a [SassScriptException].
String selectorToCss(Selector selector, {bool inspect: false}) {
var visitor = new _SerializeCssVisitor(inspect: inspect);
selector.accept(visitor);
return visitor._buffer.toString();
}
/// A visitor that converts CSS syntax trees to plain strings.
class _SerializeCssVisitor
implements CssVisitor, ValueVisitor, SelectorVisitor {
/// A buffer that contains the CSS produced so far.
final _buffer = new StringBuffer();
/// The current indentation of the CSS output.
var _indentation = 0;
/// Whether we're emitting an unambiguous representation of the source
/// structure, as opposed to valid CSS.
final bool _inspect;
/// Whether quoted strings should be emitted with quotes.
final bool _quote;
_SerializeCssVisitor(
{OutputStyle style, bool inspect: false, bool quote: true})
: _inspect = inspect,
_quote = quote;
void visitStylesheet(CssStylesheet node) {
for (var child in node.children) {
if (_isInvisible(child)) continue;
child.accept(this);
_buffer.writeln();
}
}
void visitComment(CssComment node) {
var minimumIndentation = _minimumIndentation(node.text);
if (minimumIndentation == null) {
_writeIndentation();
_buffer.writeln(node.text);
return;
}
if (node.span != null) {
minimumIndentation = math.min(minimumIndentation, node.span.start.column);
}
_writeIndentation();
_writeWithIndent(node.text, minimumIndentation);
}
void visitAtRule(CssAtRule node) {
_writeIndentation();
_buffer.writeCharCode($at);
_buffer.write(node.name);
if (node.value != null) {
_buffer.writeCharCode($space);
_buffer.write(node.value.value);
}
if (node.isChildless) {
_buffer.writeCharCode($semicolon);
} else {
_buffer.writeCharCode($space);
_visitChildren(node.children);
}
}
void visitMediaRule(CssMediaRule node) {
_writeIndentation();
_buffer.write("@media ");
_writeBetween(node.queries, ", ", visitMediaQuery);
_buffer.writeCharCode($space);
_visitChildren(node.children);
}
void visitImport(CssImport node) {
_writeIndentation();
_buffer.write("@import ");
_buffer.write(node.url.value);
_buffer.writeCharCode($semicolon);
}
void visitMediaQuery(CssMediaQuery query) {
if (query.modifier != null) {
_buffer.write(query.modifier.value);
_buffer.writeCharCode($space);
}
if (query.type != null) {
_buffer.write(query.type.value);
if (query.features.isNotEmpty) _buffer.write(" and ");
}
_writeBetween(query.features, " and ", _buffer.write);
}
void visitStyleRule(CssStyleRule node) {
_writeIndentation();
node.selector.value.accept(this);
_buffer.writeCharCode($space);
_visitChildren(node.children);
// TODO: only add an extra newline if this is a group end
_buffer.writeln();
}
void visitSupportsRule(CssSupportsRule node) {
_writeIndentation();
_buffer.write("@supports ");
_buffer.write(node.condition.value);
_buffer.writeCharCode($space);
_visitChildren(node.children);
// TODO: only add an extra newline if this is a group end
_buffer.writeln();
}
void visitDeclaration(CssDeclaration node) {
_writeIndentation();
_buffer.write(node.name.value);
_buffer.writeCharCode($colon);
if (node.isCustomProperty) {
_writeCustomPropertyValue(node);
} else {
_buffer.writeCharCode($space);
_visitValue(node.value);
}
_buffer.writeCharCode($semicolon);
}
/// Emits the value of [node] as a custom property value.
///
/// This re-indents [node]'s value relative to the current indentation.
void _writeCustomPropertyValue(CssDeclaration node) {
var value = (node.value.value as SassString).text;
var minimumIndentation = _minimumIndentation(value);
if (minimumIndentation == null) {
_buffer.write(value);
return;
}
if (node.value.span != null) {
minimumIndentation =
math.min(minimumIndentation, node.name.span.start.column);
}
_writeWithIndent(value, minimumIndentation);
}
/// Returns the indentation level of the least-indented, non-empty line in
/// [text].
int _minimumIndentation(String text) {
var scanner = new LineScanner(text);
while (!scanner.isDone && scanner.readChar() != $lf) {}
if (scanner.isDone) return null;
int min;
while (!scanner.isDone) {
while (!scanner.isDone && scanner.scanChar($space)) {}
if (scanner.isDone || scanner.scanChar($lf)) continue;
min = min == null ? scanner.column : math.min(min, scanner.column);
while (!scanner.isDone && scanner.readChar() != $lf) {}
}
return min;
}
/// Writes [text] to [_buffer], adding [minimumIndentation] to each non-empty
/// line.
void _writeWithIndent(String text, int minimumIndentation) {
var scanner = new LineScanner(text);
while (!scanner.isDone && scanner.peekChar() != $lf) {
_buffer.writeCharCode(scanner.readChar());
}
while (!scanner.isDone) {
_buffer.writeCharCode(scanner.readChar());
for (var i = 0; i < minimumIndentation; i++) scanner.readChar();
_writeIndentation();
while (!scanner.isDone && scanner.peekChar() != $lf) {
_buffer.writeCharCode(scanner.readChar());
}
}
}
// ## Values
/// Converts [value] to a plain CSS string, converting any
/// [SassScriptException]s to [SassException]s.
void _visitValue(CssValue<Value> value) {
try {
value.value.accept(this);
} on SassScriptException catch (error) {
throw new SassException(error.message, value.span);
}
}
void visitBoolean(SassBoolean value) => _buffer.write(value.value.toString());
void visitColor(SassColor value) {
// TODO(nweiz): Use color names for named colors.
if (value.alpha == 1) {
_buffer.writeCharCode($hash);
_writeHexComponent(value.red);
_writeHexComponent(value.green);
_writeHexComponent(value.blue);
} else {
_buffer.write("rgba(${value.red}, ${value.green}, ${value.blue}, ");
_writeNumber(value.alpha);
_buffer.writeCharCode($rparen);
}
}
/// Emits [color] as a hex character pair.
void _writeHexComponent(int color) {
assert(color < 0x100);
_buffer.writeCharCode(hexCharFor(color >> 4));
_buffer.writeCharCode(hexCharFor(color & 0xF));
}
void visitList(SassList value) {
if (value.hasBrackets) {
_buffer.writeCharCode($lbracket);
} else if (value.contents.isEmpty) {
if (!_inspect) {
throw new SassScriptException("() isn't a valid CSS value");
}
_buffer.write("()");
return;
}
var singleton = _inspect &&
value.contents.length == 1 &&
value.separator == ListSeparator.comma;
if (singleton) _buffer.writeCharCode($lparen);
_writeBetween/*<Value>*/(
_inspect
? value.contents
: value.contents.where((element) => !element.isBlank),
value.separator == ListSeparator.space ? " " : ", ",
_inspect
? (element) {
var needsParens = _elementNeedsParens(value.separator, element);
if (needsParens) _buffer.writeCharCode($lparen);
element.accept(this);
if (needsParens) _buffer.writeCharCode($rparen);
}
: (element) {
element.accept(this);
});
if (singleton) {
_buffer.writeCharCode($comma);
_buffer.writeCharCode($rparen);
}
if (value.hasBrackets) _buffer.writeCharCode($rbracket);
}
/// Returns whether [value] needs parentheses as an element in a list with the
/// given [separator].
bool _elementNeedsParens(ListSeparator separator, Value value) {
if (value is SassList) {
if (value.contents.length < 2) return false;
if (value.hasBrackets) return false;
return separator == ListSeparator.comma
? value.separator == ListSeparator.comma
: value.separator != ListSeparator.undecided;
}
return false;
}
void visitMap(SassMap map) {
if (!_inspect) {
throw new SassScriptException("$map isn't a valid CSS value.");
}
_buffer.writeCharCode($lparen);
_writeBetween/*<Value>*/(map.contents.keys, ", ", (key) {
_writeMapElement(key);
_buffer.write(": ");
_writeMapElement(map.contents[key]);
});
_buffer.writeCharCode($rparen);
}
/// Writes [value] as key or value in a map, with parentheses as necessary.
void _writeMapElement(Value value) {
var needsParens = value is SassList &&
value.separator == ListSeparator.comma &&
!value.hasBrackets;
if (needsParens) _buffer.writeCharCode($lparen);
value.accept(this);
if (needsParens) _buffer.writeCharCode($rparen);
}
void visitNull(SassNull value) {
if (_inspect) _buffer.write("null");
}
void visitNumber(SassNumber value) {
if (value.asSlash != null) {
_buffer.write(value.asSlash);
return;
}
_writeNumber(value.value);
if (!_inspect) {
if (value.numeratorUnits.length > 1 ||
value.denominatorUnits.isNotEmpty) {
throw new SassScriptException("$value isn't a valid CSS value.");
}
if (value.numeratorUnits.isNotEmpty) {
_buffer.write(value.numeratorUnits.first);
}
} else {
_buffer.write(value.unitString);
}
}
/// Writes [number] without exponent notation and with at most
/// [SassNumber.precision] digits after the decimal point.
void _writeNumber(num number) {
// Dart always converts integers to strings in the obvious way, so all we
// have to do is clamp doubles that are close to being integers.
var integer = fuzzyAsInt(number);
if (integer != null) {
_buffer.write(integer);
return;
}
var text = number.toString();
if (text.contains("e")) text = _removeExponent(text);
// Any double that doesn't contain "e" and is less than
// `SassNumber.precision + 2` digits long is guaranteed to be safe to emit
// directly, since it'll contain at most `0.` followed by
// [SassNumber.precision] digits.
if (text.length < SassNumber.precision + 2) {
_buffer.write(text);
return;
}
_writeDecimal(text);
}
/// Assuming [text] is a double written in exponent notation, returns a string
/// representation of that double without exponent notation.
String _removeExponent(String text) {
var buffer = new StringBuffer();
int exponent;
for (var i = 0; i < text.length; i++) {
var codeUnit = text.codeUnitAt(i);
if (codeUnit == $e) {
exponent = int.parse(text.substring(i + 1, text.length));
break;
} else if (codeUnit != $dot) {
buffer.writeCharCode(codeUnit);
}
}
if (exponent > 0) {
for (var i = 0; i < exponent; i++) {
buffer.writeCharCode($0);
}
return buffer.toString();
} else {
var result = new StringBuffer();
var negative = text.codeUnitAt(0) == $minus;
if (negative) result.writeCharCode($minus);
result.write("0.");
for (var i = -1; i > exponent; i--) {
result.writeCharCode($0);
}
result.write(negative ? buffer.toString().substring(1) : buffer);
return result.toString();
}
}
/// Assuming [text] is a double written without exponent notation, writes it
/// to [_buffer] with at most [SassNumber.precision] digits after the decimal.
void _writeDecimal(String text) {
var textIndex = 0;
for (; textIndex < text.length; textIndex++) {
var codeUnit = text.codeUnitAt(textIndex);
_buffer.writeCharCode(codeUnit);
if (codeUnit == $dot) {
textIndex++;
break;
}
}
if (textIndex == text.length) return;
// We need to ensure that we write at most [SassNumber.precision] digits
// after the decimal point, and that we round appropriately if necessary. To
// do this, we maintain an intermediate buffer of decimal digits, which we
// then convert to text.
var digits = new Uint8List(SassNumber.precision);
var digitsIndex = 0;
while (textIndex < text.length && digitsIndex < digits.length) {
digits[digitsIndex++] = asDecimal(text.codeUnitAt(textIndex++));
}
// Round the trailing digits in [digits] up if necessary. We can be
// confident this won't cause us to need to round anything before the
// decimal, because otherwise the number would be [fuzzyIsInt].
if (textIndex != text.length &&
asDecimal(text.codeUnitAt(textIndex)) >= 5) {
while (digitsIndex >= 0) {
var newDigit = ++digits[digitsIndex - 1];
if (newDigit != 10) break;
digitsIndex--;
}
}
// Remove trailing zeros.
while (digitsIndex >= 0 && digits[digitsIndex - 1] == 0) {
digitsIndex--;
}
for (var i = 0; i < digitsIndex; i++) {
_buffer.writeCharCode(decimalCharFor(digits[i]));
}
}
void visitString(SassString string) {
if (_quote && string.hasQuotes) {
_visitQuotedString(string.text);
} else {
_visitUnquotedString(string.text);
}
}
/// Writes a quoted string with [string] contents to [_buffer].
///
/// By default, this detects which type of quote to use based on the contents
/// of the string. If [forceDoubleQuote] is `true`, this always uses a double
/// quote.
void _visitQuotedString(String string, {bool forceDoubleQuote: false}) {
var includesSingleQuote = false;
var includesDoubleQuote = false;
var buffer = forceDoubleQuote ? _buffer : new StringBuffer();
if (forceDoubleQuote) buffer.writeCharCode($double_quote);
for (var i = 0; i < string.length; i++) {
var char = string.codeUnitAt(i);
switch (char) {
case $single_quote:
if (forceDoubleQuote) {
buffer.writeCharCode($single_quote);
} else if (includesDoubleQuote) {
_visitQuotedString(string, forceDoubleQuote: true);
return;
} else {
includesSingleQuote = true;
buffer.writeCharCode($single_quote);
}
break;
case $double_quote:
if (forceDoubleQuote) {
buffer.writeCharCode($backslash);
buffer.writeCharCode($double_quote);
} else if (includesSingleQuote) {
_visitQuotedString(string, forceDoubleQuote: true);
return;
} else {
includesDoubleQuote = true;
buffer.writeCharCode($double_quote);
}
break;
// Write newline characters and unprintable ASCII characters as escapes.
case $nul:
case $soh:
case $stx:
case $etx:
case $eot:
case $enq:
case $ack:
case $bel:
case $bs:
case $lf:
case $vt:
case $ff:
case $cr:
case $so:
case $si:
case $dle:
case $dc1:
case $dc2:
case $dc3:
case $dc4:
case $nak:
case $syn:
case $etb:
case $can:
case $em:
case $sub:
case $esc:
case $fs:
case $gs:
case $rs:
case $us:
buffer.writeCharCode($backslash);
if (char > 0xF) buffer.writeCharCode(hexCharFor(char >> 4));
buffer.writeCharCode(hexCharFor(char & 0xF));
if (string.length == i + 1) break;
var next = string.codeUnitAt(i + 1);
if (isHex(next) || next == $space || next == $tab) {
buffer.writeCharCode($space);
}
break;
case $backslash:
buffer.writeCharCode($backslash);
buffer.writeCharCode($backslash);
break;
default:
buffer.writeCharCode(char);
break;
}
}
if (forceDoubleQuote) {
buffer.writeCharCode($double_quote);
} else {
var quote = includesDoubleQuote ? $single_quote : $double_quote;
_buffer.writeCharCode(quote);
_buffer.write(buffer);
_buffer.writeCharCode(quote);
}
}
/// Writes an unquoted string with [string] contents to [_buffer].
void _visitUnquotedString(String string) {
var afterNewline = false;
for (var i = 0; i < string.length; i++) {
var char = string.codeUnitAt(i);
switch (char) {
case $lf:
_buffer.writeCharCode($space);
afterNewline = true;
break;
case $space:
if (!afterNewline) _buffer.writeCharCode($space);
break;
default:
_buffer.writeCharCode(char);
afterNewline = false;
break;
}
}
}
// ## Selectors
void visitAttributeSelector(AttributeSelector attribute) {
_buffer.writeCharCode($lbracket);
_buffer.write(attribute.name);
if (attribute.op != null) {
_buffer.write(attribute.op);
if (_isIdentifier(attribute.value)) {
_buffer.write(attribute.value);
} else {
_visitQuotedString(attribute.value);
}
}
_buffer.writeCharCode($rbracket);
}
void visitClassSelector(ClassSelector klass) {
_buffer.writeCharCode($dot);
_buffer.write(klass.name);
}
void visitComplexSelector(ComplexSelector complex) {
_writeBetween(complex.components, " ", (component) {
if (component is CompoundSelector) {
visitCompoundSelector(component);
} else {
_buffer.write(component);
}
});
}
void visitCompoundSelector(CompoundSelector compound) {
for (var simple in compound.components) {
simple.accept(this);
}
}
void visitIDSelector(IDSelector id) {
_buffer.writeCharCode($hash);
_buffer.write(id.name);
}
void visitSelectorList(SelectorList list) {
var complexes = _inspect
? list.components
: list.components.where((complex) => !complex.containsPlaceholder);
var first = true;
for (var complex in complexes) {
if (first) {
first = false;
} else {
_buffer.writeCharCode($comma);
_buffer.writeCharCode(complex.lineBreak ? $lf : $space);
}
visitComplexSelector(complex);
}
}
void visitParentSelector(ParentSelector parent) {
_buffer.writeCharCode($ampersand);
if (parent.suffix != null) _buffer.write(parent.suffix);
}
void visitPlaceholderSelector(PlaceholderSelector placeholder) {
_buffer.writeCharCode($percent);
_buffer.write(placeholder.name);
}
void visitPseudoSelector(PseudoSelector pseudo) {
_buffer.writeCharCode($colon);
if (pseudo.isElement) _buffer.writeCharCode($colon);
_buffer.write(pseudo.name);
if (pseudo.argument == null && pseudo.selector == null) return;
_buffer.writeCharCode($lparen);
if (pseudo.argument != null) {
_buffer.write(pseudo.argument);
if (pseudo.selector != null) _buffer.writeCharCode($space);
}
if (pseudo.selector != null) _buffer.write(pseudo.selector);
_buffer.writeCharCode($rparen);
}
void visitTypeSelector(TypeSelector type) {
_buffer.write(type.name);
}
void visitUniversalSelector(UniversalSelector universal) {
if (universal.namespace != null) {
_buffer.write(universal.namespace);
_buffer.writeCharCode($pipe);
}
_buffer.writeCharCode($asterisk);
}
// ## Utilities
/// Emits [children] in a block.
void _visitChildren(Iterable<CssNode> children) {
_buffer.writeCharCode($lbrace);
if (children.every(_isInvisible)) {
_buffer.writeCharCode($rbrace);
return;
}
_buffer.writeln();
_indent(() {
for (var child in children) {
if (_isInvisible(child)) continue;
child.accept(this);
_buffer.writeln();
}
});
_writeIndentation();
_buffer.writeCharCode($rbrace);
}
/// Writes indentation based on [_indentation].
void _writeIndentation() {
for (var i = 0; i < _indentation; i++) {
_buffer.writeCharCode($space);
_buffer.writeCharCode($space);
}
}
/// Calls [callback] to write each value in [iterable], and writes [text] in
/// between each one.
void _writeBetween/*<T>*/(
Iterable/*<T>*/ iterable, String text, void callback(/*=T*/ value)) {
var first = true;
for (var value in iterable) {
if (first) {
first = false;
} else {
_buffer.write(text);
}
callback(value);
}
}
/// Runs [callback] with indentation increased one level.
void _indent(void callback()) {
_indentation++;
callback();
_indentation--;
}
/// Returns whether [node] is considered invisible.
bool _isInvisible(CssNode node) => !_inspect && node.isInvisible;
/// Returns whether [text] is a valid identifier.
bool _isIdentifier(String text) {
var scanner = new StringScanner(text);
while (scanner.scanChar($dash)) {}
var first = scanner.readChar();
if (first == null) return false;
if (isNameStart(first)) {
scanner.readChar();
} else if (first == $backslash) {
if (!_consumeEscape(scanner)) return false;
} else {
return false;
}
while (true) {
var next = scanner.peekChar();
if (next == null) return true;
if (isName(next)) {
scanner.readChar();
} else if (next == $backslash) {
if (!_consumeEscape(scanner)) return false;
} else {
return false;
}
}
}
/// Consumes an escape sequence in [scanner].
///
/// Returns whether a valid escape was consumed.
bool _consumeEscape(StringScanner scanner) {
scanner.expectChar($backslash);
var first = scanner.peekChar();
if (first == null || isNewline(first)) return false;
if (isHex(first)) {
for (var i = 0; i < 6; i++) {
var next = scanner.peekChar();
if (next == null || !isHex(next)) break;
scanner.readChar();
}
if (isWhitespace(scanner.peekChar())) scanner.readChar();
} else {
scanner.readChar();
}
return true;
}
}
/// An enum of generated CSS styles.
class OutputStyle {
/// The standard CSS style, with each declaration on its own line.
static const expanded = const OutputStyle._("expanded");
/// The name of the style.
final String _name;
const OutputStyle._(this._name);
String toString() => _name;
}