// 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 'package:charcode/charcode.dart'; import 'package:source_span/source_span.dart'; import 'package:string_scanner/string_scanner.dart'; import '../exception.dart'; import '../util/character.dart'; /// The abstract base class for all parsers. /// /// This provides utility methods and common token parsing. Unless specified /// otherwise, a parse method throws a [SassFormatException] if it fails to /// parse. abstract class Parser { /// The scanner that scans through the text being parsed. final SpanScanner scanner; Parser(String contents, {url}) : scanner = new SpanScanner(contents, sourceUrl: url); // ## Tokens /// Consumes whitespace, including any comments. void whitespace() { do { whitespaceWithoutComments(); } while (scanComment()); } /// Like [whitespace], but returns whether any was consumed. bool scanWhitespace() { var start = scanner.position; whitespace(); return scanner.position != start; } /// Consumes whitespace, but not comments. void whitespaceWithoutComments() { while (!scanner.isDone && isWhitespace(scanner.peekChar())) { scanner.readChar(); } } /// Consumes and ignores a comment if possible. /// /// Returns whether the comment was consumed. bool scanComment() { if (scanner.peekChar() != $slash) return false; var next = scanner.peekChar(1); if (next == $slash) { silentComment(); return true; } else if (next == $asterisk) { loudComment(); return true; } else { return false; } } /// Consumes and ignores a silent (Sass-style) comment. void silentComment() { scanner.expect("//"); while (!scanner.isDone && !isNewline(scanner.peekChar())) { scanner.readChar(); } } /// Consumes and ignores a loud (CSS-style) comment. void loudComment() { scanner.expect("/*"); do { while (scanner.readChar() != $asterisk) {} } while (scanner.readChar() != $slash); } /// Consumes a plain CSS identifier. String identifier() { var text = new StringBuffer(); while (scanner.scanChar($dash)) { text.writeCharCode($dash); } var first = scanner.peekChar(); if (first == null) { scanner.error("Expected identifier."); } else if (isNameStart(first)) { text.writeCharCode(scanner.readChar()); } else if (first == $backslash) { text.writeCharCode(escape()); } else { scanner.error("Expected identifier."); } while (true) { var next = scanner.peekChar(); if (next == null) { break; } else if (isName(next)) { text.writeCharCode(scanner.readChar()); } else if (next == $backslash) { text.writeCharCode(escape()); } else { break; } } return text.toString(); } /// Consumes a plain CSS string. /// /// This returns the parsed contents of the string—that is, it doesn't include /// quotes and its escapes are resolved. String string() { // NOTE: this logic is largely duplicated in ScssParser._interpolatedString. // Most changes here should be mirrored there. var quote = scanner.readChar(); if (quote != $single_quote && quote != $double_quote) { scanner.error("Expected string.", position: quote == null ? scanner.position : scanner.position - 1); } var buffer = new StringBuffer(); while (true) { var next = scanner.peekChar(); if (next == quote) { scanner.readChar(); break; } else if (next == null || isNewline(next)) { scanner.error("Expected ${new String.fromCharCode(quote)}."); } else if (next == $backslash) { if (isNewline(scanner.peekChar(1))) { scanner.readChar(); scanner.readChar(); } else { buffer.writeCharCode(escape()); } } else { buffer.writeCharCode(scanner.readChar()); } } return buffer.toString(); } /// Consumes tokens until it reaches a top-level `":"`, `"!"`, `")"`, `"]"`, /// or `"}"` and returns their contents as a string. String declarationValue() { // NOTE: this logic is largely duplicated in // StylesheetParser._interpolatedDeclarationValue. Most changes here should // be mirrored there. var buffer = new StringBuffer(); var brackets = []; var wroteNewline = false; loop: while (true) { var next = scanner.peekChar(); switch (next) { case $backslash: buffer.writeCharCode(escape()); wroteNewline = false; break; case $double_quote: case $single_quote: buffer.write(rawText(string)); wroteNewline = false; break; case $slash: if (scanner.peekChar(1) == $asterisk) { buffer.write(rawText(loudComment)); } else { buffer.writeCharCode(scanner.readChar()); } wroteNewline = false; break; case $space: case $tab: if (wroteNewline || !isWhitespace(scanner.peekChar(1))) { buffer.writeCharCode($space); } scanner.readChar(); break; case $lf: case $cr: case $ff: if (!isNewline(scanner.peekChar(-1))) buffer.writeln(); scanner.readChar(); wroteNewline = true; break; case $lparen: case $lbrace: case $lbracket: buffer.writeCharCode(next); brackets.add(opposite(scanner.readChar())); wroteNewline = false; break; case $rparen: case $rbrace: case $rbracket: if (brackets.isEmpty) break loop; buffer.writeCharCode(next); scanner.expectChar(brackets.removeLast()); wroteNewline = false; break; case $exclamation: case $semicolon: break loop; case $u: case $U: var url = tryUrl(); if (url != null) { buffer.write(url); } else { buffer.writeCharCode(scanner.readChar()); } 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 buffer.toString(); } /// Consumes a `url()` token if possible, and returns `null` otherwise. String tryUrl() { // NOTE: this logic is largely duplicated in ScssParser._tryUrlContents. // Most changes here should be mirrored there. var start = scanner.state; if (!scanIdentifier("url", ignoreCase: true)) return null; if (!scanner.scanChar($lparen)) { scanner.state = start; return null; } whitespace(); // 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 StringBuffer()..write("url("); while (true) { var next = scanner.peekChar(); if (next == null) { break; } else if (next == $percent || next == $ampersand || next == $hash || (next >= $asterisk && next <= $tilde) || next >= 0x0080) { buffer.writeCharCode(scanner.readChar()); } else if (next == $backslash) { buffer.writeCharCode(escape()); } else if (isWhitespace(next)) { whitespace(); if (scanner.peekChar() != $rparen) break; } else if (next == $rparen) { buffer.writeCharCode(scanner.readChar()); return buffer.toString(); } else { break; } } scanner.state = start; return null; } /// Consumes a Sass variable name, and returns its name without the dollar /// sign. String variableName() { scanner.expectChar($dollar); return identifier(); } // ## Characters /// Consumes an escape sequence and returns the character it represents. int escape() { // See https://drafts.csswg.org/css-syntax-3/#consume-escaped-code-point. scanner.expectChar($backslash); var first = scanner.peekChar(); if (first == null) { return 0xFFFD; } else if (isNewline(first)) { scanner.error("Expected escape sequence."); return 0; } else if (isHex(first)) { var value = 0; for (var i = 0; i < 6; i++) { var next = scanner.peekChar(); if (next == null || !isHex(next)) break; value = (value << 4) + asHex(scanner.readChar()); } if (isWhitespace(scanner.peekChar())) scanner.readChar(); if (value == 0 || (value >= 0xD800 && value <= 0xDFFF) || value >= 0x10FFFF) { return 0xFFFD; } else { return value; } } else { return scanner.readChar(); } } /// Consumes and returns the next character. /// /// If the next character starts an escape sequence, parses the escape /// sequence and returns the character it represents instead. int readCharOrEscape() { var next = scanner.readChar(); return next == $backslash ? escape() : next; } /// Consumes the next character or escape sequence, and returns whether it /// equals [expected]. /// /// If [ignoreCase] is `true`, this ignores ASCII case when comparing the /// scanner character to [expected]. /// /// Note that this conusmes the next character whether or not it matches /// [expected]. bool _scanCharOrEscape(int expected, {bool ignoreCase: false}) { // TODO(nweiz): Test if it's faster to split this into separate methods for // case-sensitivity rather than checking the boolean each time. var actual = readCharOrEscape(); return ignoreCase ? characterEqualsIgnoreCase(actual, expected) : actual == expected; } // Consumes the next character if it matches [condition]. // // Returns whether or not the character was consumed. bool scanCharIf(bool condition(int character)) { var next = scanner.peekChar(); if (!condition(next)) return false; scanner.readChar(); return true; } /// Consumes the next character if it's equal to [letter], ignoring ASCII /// case. bool scanCharIgnoreCase(int letter) { if (!equalsLetterIgnoreCase(letter, scanner.peekChar())) return false; scanner.readChar(); return true; } /// Consumes the next character and asserts that it's equal to [letter], /// ignoring ASCII case. void expectCharIgnoreCase(int letter) { var actual = scanner.readChar(); if (equalsLetterIgnoreCase(letter, actual)) return; scanner.error('Expected "${new String.fromCharCode(letter)}".', position: actual == null ? scanner.position : scanner.position - 1); } // ## Utilities /// Returns whether the scanner is immediately before a plain CSS identifier. /// /// This is based on [the CSS algorithm][], but it assumes all backslashes /// start escapes. /// /// [the CSS algorithm]: https://drafts.csswg.org/css-syntax-3/#would-start-an-identifier bool lookingAtIdentifier() { // See also [ScssParser._lookingAtInterpolatedIdentifier]. var first = scanner.peekChar(); if (isNameStart(first) || first == $backslash) return true; if (first != $dash) return false; var second = scanner.peekChar(1); return isNameStart(second) || second == $dash || second == $backslash; } /// Consumes an identifier if its name exactly matches [text]. /// /// If [ignoreCase] is `true`, does a case-insensitive match. bool scanIdentifier(String text, {bool ignoreCase: false}) { if (!lookingAtIdentifier()) return false; var start = scanner.state; for (var i = 0; i < text.length; i++) { var next = text.codeUnitAt(i); if (_scanCharOrEscape(next, ignoreCase: ignoreCase)) continue; scanner.state = start; return false; } var next = scanner.peekChar(); if (next == null) return true; if (!isName(next) && next != $backslash) return true; scanner.state = start; return false; } /// Consumes an identifier and asserts that its name exactly matches [text]. /// /// If [ignoreCase] is `true`, does a case-insensitive match. void expectIdentifier(String text, {String name, bool ignoreCase: false}) { name ??= '"$text"'; var start = scanner.position; for (var i = 0; i < text.length; i++) { var next = text.codeUnitAt(i); if (_scanCharOrEscape(next, ignoreCase: ignoreCase)) continue; scanner.error("Expected $name.", position: start); } var next = scanner.peekChar(); if (next == null) return; if (!isName(next) && next != $backslash) return; scanner.error("Expected $name", position: start); } /// Runs [consumer] and returns the source text that it consumes. String rawText(void consumer()) { var start = scanner.position; consumer(); return scanner.substring(start); } /// Runs [callback] and wraps any [SourceSpanFormatException] it throws in a /// [SassFormatException]. /*=T*/ wrapSpanFormatException/**/(/*=T*/ callback()) { try { return callback(); } on SourceSpanFormatException catch (error) { throw new SassFormatException(error.message, error.span as FileSpan); } } }