Add StringScanner.scanChar() and .expectChar().

R=jmesserly@google.com

Review URL: https://codereview.chromium.org//2041813002 .
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea1268c..db73686 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 * Add `new SpanScanner.within()`, which scans within a existing `FileSpan`.
 
+* Add `StringScanner.scanChar()` and `StringScanner.expectChar()`.
+
 ## 0.1.4+1
 
 * Remove the dependency on `path`, since we don't actually import it.
diff --git a/lib/src/eager_span_scanner.dart b/lib/src/eager_span_scanner.dart
index c537b0c..f80dce5 100644
--- a/lib/src/eager_span_scanner.dart
+++ b/lib/src/eager_span_scanner.dart
@@ -67,15 +67,26 @@
   EagerSpanScanner(String string, {sourceUrl, int position})
       : super(string, sourceUrl: sourceUrl, position: position);
 
+  bool scanChar(int character) {
+    if (!super.scanChar(character)) return false;
+    _adjustLineAndColumn(character);
+    return true;
+  }
+
   int readChar() {
-    var char = super.readChar();
-    if (char == $lf || (char == $cr && peekChar() != $lf)) {
+    var character = super.readChar();
+    _adjustLineAndColumn(character);
+    return character;
+  }
+
+  /// Adjusts [_line] and [_column] after having consumed [character].
+  void _adjustLineAndColumn(int character) {
+    if (character == $lf || (character == $cr && peekChar() != $lf)) {
       _line += 1;
       _column = 0;
     } else {
       _column += 1;
     }
-    return char;
   }
 
   bool scan(Pattern pattern) {
diff --git a/lib/src/line_scanner.dart b/lib/src/line_scanner.dart
index b439193..fe63592 100644
--- a/lib/src/line_scanner.dart
+++ b/lib/src/line_scanner.dart
@@ -73,15 +73,26 @@
   LineScanner(String string, {sourceUrl, int position})
       : super(string, sourceUrl: sourceUrl, position: position);
 
+  bool scanChar(int character) {
+    if (!super.scanChar(character)) return false;
+    _adjustLineAndColumn(character);
+    return true;
+  }
+
   int readChar() {
-    var char = super.readChar();
-    if (char == $lf || (char == $cr && peekChar() != $lf)) {
+    var character = super.readChar();
+    _adjustLineAndColumn(character);
+    return character;
+  }
+
+  /// Adjusts [_line] and [_column] after having consumed [character].
+  void _adjustLineAndColumn(int character) {
+    if (character == $lf || (character == $cr && peekChar() != $lf)) {
       _line += 1;
       _column = 0;
     } else {
       _column += 1;
     }
-    return char;
   }
 
   bool scan(Pattern pattern) {
diff --git a/lib/src/string_scanner.dart b/lib/src/string_scanner.dart
index 775dd5e..8334ccb 100644
--- a/lib/src/string_scanner.dart
+++ b/lib/src/string_scanner.dart
@@ -2,6 +2,7 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
+import 'package:charcode/charcode.dart';
 import 'package:source_span/source_span.dart';
 
 import 'exception.dart';
@@ -79,6 +80,38 @@
     return string.codeUnitAt(index);
   }
 
+  /// If the next character in the string is [character], consumes it.
+  ///
+  /// Returns whether or not [character] was consumed.
+  bool scanChar(int character) {
+    if (isDone) return false;
+    if (string.codeUnitAt(_position) != character) return false;
+    _position++;
+    return true;
+  }
+
+  /// If the next character in the string is [character], consumes it.
+  ///
+  /// If [character] could not be consumed, throws a [FormatException]
+  /// describing the position of the failure. [name] is used in this error as
+  /// the expected name of the character being matched; if it's `null`, the
+  /// character itself is used instead.
+  void expectChar(int character, {String name}) {
+    if (scanChar(character)) return;
+
+    if (name == null) {
+      if (character == $backslash) {
+        name = r'"\"';
+      } else if (character == $double_quote) {
+        name = r'"\""';
+      } else {
+        name = '"${new String.fromCharCode(character)}"';
+      }
+    }
+
+    _fail('Expected $name.');
+  }
+
   /// If [pattern] matches at the current position of the string, scans forward
   /// until the end of the match.
   ///
diff --git a/pubspec.yaml b/pubspec.yaml
index 24bd6bb..7f7e33a 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: string_scanner
-version: 0.1.5-dev
+version: 0.1.5
 author: "Dart Team <misc@dartlang.org>"
 homepage: https://github.com/dart-lang/string_scanner
 description: >
diff --git a/test/line_scanner_test.dart b/test/line_scanner_test.dart
index 9874cb3..ed04b37 100644
--- a/test/line_scanner_test.dart
+++ b/test/line_scanner_test.dart
@@ -2,6 +2,7 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
+import 'package:charcode/charcode.dart';
 import 'package:string_scanner/string_scanner.dart';
 import 'package:test/test.dart';
 
@@ -80,6 +81,39 @@
     });
   });
 
+  group("scanChar()", () {
+    test("on a non-newline character increases the column but not the line",
+        () {
+      scanner.scanChar($f);
+      expect(scanner.line, equals(0));
+      expect(scanner.column, equals(1));
+    });
+
+    test("consuming a newline resets the column and increases the line", () {
+      scanner.expect('foo');
+      expect(scanner.line, equals(0));
+      expect(scanner.column, equals(3));
+
+      scanner.scanChar($lf);
+      expect(scanner.line, equals(1));
+      expect(scanner.column, equals(0));
+    });
+
+    test("consuming halfway through a CR LF doesn't count as a line", () {
+      scanner.expect('foo\nbar');
+      expect(scanner.line, equals(1));
+      expect(scanner.column, equals(3));
+
+      scanner.scanChar($cr);
+      expect(scanner.line, equals(1));
+      expect(scanner.column, equals(4));
+
+      scanner.scanChar($lf);
+      expect(scanner.line, equals(2));
+      expect(scanner.column, equals(0));
+    });
+  });
+
   group("position=", () {
     test("forward through newlines sets the line and column", () {
       scanner.position = 10; // "foo\nbar\r\nb"
diff --git a/test/string_scanner_test.dart b/test/string_scanner_test.dart
index 10e622a..0b4d482 100644
--- a/test/string_scanner_test.dart
+++ b/test/string_scanner_test.dart
@@ -2,6 +2,7 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
+import 'package:charcode/charcode.dart';
 import 'package:string_scanner/string_scanner.dart';
 import 'package:test/test.dart';
 
@@ -41,6 +42,18 @@
       expect(scanner.position, equals(0));
     });
 
+    test("scanChar returns false and doesn't change the state", () {
+      expect(scanner.scanChar($f), isFalse);
+      expect(scanner.lastMatch, isNull);
+      expect(scanner.position, equals(0));
+    });
+
+    test("expectChar fails and doesn't change the state", () {
+      expect(() => scanner.expectChar($f), throwsFormatException);
+      expect(scanner.lastMatch, isNull);
+      expect(scanner.position, equals(0));
+    });
+
     test("scan returns false and doesn't change the state", () {
       expect(scanner.scan(new RegExp('.')), isFalse);
       expect(scanner.lastMatch, isNull);
@@ -117,6 +130,30 @@
       expect(scanner.position, equals(0));
     });
 
+    test("a matching scanChar returns true moves forward", () {
+      expect(scanner.scanChar($f), isTrue);
+      expect(scanner.lastMatch, isNull);
+      expect(scanner.position, equals(1));
+    });
+
+    test("a non-matching scanChar returns false and does nothing", () {
+      expect(scanner.scanChar($x), isFalse);
+      expect(scanner.lastMatch, isNull);
+      expect(scanner.position, equals(0));
+    });
+
+    test("a matching expectChar moves forward", () {
+      scanner.expectChar($f);
+      expect(scanner.lastMatch, isNull);
+      expect(scanner.position, equals(1));
+    });
+
+    test("a non-matching expectChar fails", () {
+      expect(() => scanner.expectChar($x), throwsFormatException);
+      expect(scanner.lastMatch, isNull);
+      expect(scanner.position, equals(0));
+    });
+
     test("a matching scan returns true and changes the state", () {
       expect(scanner.scan(new RegExp('f(..)')), isTrue);
       expect(scanner.lastMatch[1], equals('oo'));
@@ -256,6 +293,18 @@
       expect(scanner.position, equals(7));
     });
 
+    test("scanChar returns false and doesn't change the state", () {
+      expect(scanner.scanChar($f), isFalse);
+      expect(scanner.lastMatch, isNotNull);
+      expect(scanner.position, equals(7));
+    });
+
+    test("expectChar fails and doesn't change the state", () {
+      expect(() => scanner.expectChar($f), throwsFormatException);
+      expect(scanner.lastMatch, isNotNull);
+      expect(scanner.position, equals(7));
+    });
+
     test("scan returns false and sets lastMatch to null", () {
       expect(scanner.scan(new RegExp('.')), isFalse);
       expect(scanner.lastMatch, isNull);