Add LineScanner and SpanScanner classes to string_scanner.

R=rnystrom@google.com

Review URL: https://codereview.chromium.org//299973002

git-svn-id: https://dart.googlecode.com/svn/branches/bleeding_edge/dart/pkg/string_scanner@36815 260f80e4-7a28-3924-810f-c04153c831b5
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..2c41c9d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,19 @@
+## 0.0.2
+
+* `new StringScanner()` now takes an optional `sourceUrl` argument that provides
+  the URL of the source file. This is used for error reporting.
+
+* Add `StringScanner.readChar()` and `StringScanner.peekChar()` methods for
+  doing character-by-character scanning.
+
+* Scanners now throw `StringScannerException`s which provide more detailed
+  access to information about the errors that were thrown and can provide
+  terminal-colored messages.
+
+* Add a `LineScanner` subclass of `StringScanner` that automatically tracks line
+  and column information of the text being scanned.
+
+* Add a `SpanScanner` subclass of `LineScanner` that exposes matched ranges as
+  [source map][] `Span` objects.
+
+[source_map]: http://pub.dartlang.org/packages/source_maps
diff --git a/lib/src/exception.dart b/lib/src/exception.dart
new file mode 100644
index 0000000..c398687
--- /dev/null
+++ b/lib/src/exception.dart
@@ -0,0 +1,37 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// 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.
+
+library string_scanner.exception;
+
+import 'package:source_maps/source_maps.dart';
+
+/// An exception thrown by a [StringScanner] that failed to parse a string.
+class StringScannerException implements FormatException {
+  /// The error message.
+  final String message;
+
+  /// The source string being parsed.
+  final String string;
+
+  /// The URL of the source file being parsed.
+  ///
+  /// This may be `null`, indicating that the source URL is unknown.
+  final Uri sourceUrl;
+
+  /// The span within [string] that caused the exception.
+  final Span span;
+
+  StringScannerException(this.message, this.string, this.sourceUrl, this.span);
+
+  /// Returns a detailed description of this exception.
+  ///
+  /// If [useColors] is true, the section of the source that caused the
+  /// exception will be colored using ANSI color codes. By default, it's colored
+  /// red, but a different ANSI code may passed via [color].
+  String toString({bool useColors: false, String color}) {
+    return "Error on " + span.getLocationMessage(
+        message, useColors: useColors, color: color);
+  }
+}
+
diff --git a/lib/src/line_scanner.dart b/lib/src/line_scanner.dart
new file mode 100644
index 0000000..4e12173
--- /dev/null
+++ b/lib/src/line_scanner.dart
@@ -0,0 +1,108 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// 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.
+
+library string_scanner.line_scanner;
+
+import 'string_scanner.dart';
+
+/// A subclass of [StringScanner] that tracks line and column information.
+class LineScanner extends StringScanner {
+  /// The scanner's current (zero-based) line number.
+  int get line => _line;
+  int _line = 0;
+
+  /// The scanner's current (zero-based) column number.
+  int get column => _column;
+  int _column = 0;
+
+  /// The scanner's state, including line and column information.
+  ///
+  /// This can be used to efficiently save and restore the state of the scanner
+  /// when backtracking. A given [LineScannerState] is only valid for the
+  /// [LineScanner] that created it.
+  LineScannerState get state =>
+      new LineScannerState._(this, position, line, column);
+
+  set state(LineScannerState state) {
+    if (!identical(state._scanner, this)) {
+      throw new ArgumentError("The given LineScannerState was not returned by "
+          "this LineScanner.");
+    }
+
+    super.position = state.position;
+    _line = state.line;
+    _column = state.column;
+  }
+
+  set position(int newPosition) {
+    var oldPosition = position;
+    super.position = newPosition;
+
+    if (newPosition > oldPosition) {
+      var newlines = "\n".allMatches(string.substring(oldPosition, newPosition))
+          .toList();
+      _line += newlines.length;
+      if (newlines.isEmpty) {
+        _column += newPosition - oldPosition;
+      } else {
+        _column = newPosition - newlines.last.end;
+      }
+    } else {
+      var newlines = "\n".allMatches(string.substring(newPosition, oldPosition))
+          .toList();
+      _line -= newlines.length;
+      if (newlines.isEmpty) {
+        _column -= oldPosition - newPosition;
+      } else {
+        _column = newPosition - string.lastIndexOf("\n", newPosition) - 1;
+      }
+    }
+  }
+
+  LineScanner(String string, {sourceUrl, int position})
+      : super(string, sourceUrl: sourceUrl, position: position);
+
+  int readChar() {
+    var char = super.readChar();
+    if (char == 0xA) {
+      _line += 1;
+      _column = 0;
+    } else {
+      _column += 1;
+    }
+    return char;
+  }
+
+  bool scan(Pattern pattern) {
+    var oldPosition = position;
+    if (!super.scan(pattern)) return false;
+
+    var newlines = "\n".allMatches(lastMatch[0]).toList();
+    _line += newlines.length;
+    if (newlines.isEmpty) {
+      _column += lastMatch[0].length;
+    } else {
+      _column = lastMatch[0].length - newlines.last.end;
+    }
+
+    return true;
+  }
+}
+
+/// A class representing the state of a [LineScanner].
+class LineScannerState {
+  /// The [LineScanner] that created this.
+  final LineScanner _scanner;
+
+  /// The position of the scanner in this state.
+  final int position;
+
+  /// The zero-based line number of the scanner in this state.
+  final int line;
+
+  /// The zero-based column number of the scanner in this state.
+  final int column;
+
+  LineScannerState._(this._scanner, this.position, this.line, this.column);
+}
diff --git a/lib/src/span_scanner.dart b/lib/src/span_scanner.dart
new file mode 100644
index 0000000..9060e62
--- /dev/null
+++ b/lib/src/span_scanner.dart
@@ -0,0 +1,95 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// 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.
+
+library string_scanner.span_scanner;
+
+import 'package:source_maps/source_maps.dart';
+
+import 'exception.dart';
+import 'line_scanner.dart';
+import 'string_scanner.dart';
+import 'utils.dart';
+
+/// A subclass of [LineScanner] that exposes matched ranges as source map
+/// [Span]s.
+class SpanScanner extends StringScanner implements LineScanner {
+  /// The source of the scanner.
+  ///
+  /// This caches line break information and is used to generate [Span]s.
+  final SourceFile _sourceFile;
+
+  int get line => _sourceFile.getLine(position);
+  int get column => _sourceFile.getColumn(line, position);
+
+  LineScannerState get state => new _SpanScannerState(this, position);
+
+  set state(LineScannerState state) {
+    if (state is! _SpanScannerState ||
+        !identical((state as _SpanScannerState)._scanner, this)) {
+      throw new ArgumentError("The given LineScannerState was not returned by "
+          "this LineScanner.");
+    }
+
+    this.position = state.position;
+  }
+
+  /// The [Span] for [lastMatch].
+  ///
+  /// This is the span for the entire match. There's no way to get spans for
+  /// subgroups since [Match] exposes no information about their positions.
+  Span get lastSpan => _lastSpan;
+  Span _lastSpan;
+
+  /// Returns an empty span at the current location.
+  Span get emptySpan => _sourceFile.span(position);
+
+  /// Creates a new [SpanScanner] that starts scanning from [position].
+  ///
+  /// [sourceUrl] is used as [Location.sourceUrl] for the returned [Span]s as
+  /// well as for error reporting.
+  SpanScanner(String string, sourceUrl, {int position})
+      : _sourceFile = new SourceFile.text(
+            sourceUrl is Uri ? sourceUrl.toString() : sourceUrl, string),
+        super(string, sourceUrl: sourceUrl, position: position);
+
+  /// Creates a [Span] representing the source range between [startState] and
+  /// the current position.
+  Span spanFrom(LineScannerState startState) =>
+      _sourceFile.span(startState.position, position);
+
+  bool matches(Pattern pattern) {
+    if (!super.matches(pattern)) {
+      _lastSpan = null;
+      return false;
+    }
+
+    _lastSpan = _sourceFile.span(position, lastMatch.end);
+    return true;
+  }
+
+  void error(String message, {Match match, int position, int length}) {
+    validateErrorArgs(string, match, position, length);
+
+    if (match == null && position == null && length == null) match = lastMatch;
+    if (position == null) {
+      position = match == null ? this.position : match.start;
+    }
+    if (length == null) length = match == null ? 1 : match.end - match.start;
+
+    var span = _sourceFile.span(position, position + length);
+    throw new StringScannerException(message, string, sourceUrl, span);
+  }
+}
+
+/// A class representing the state of a [SpanScanner].
+class _SpanScannerState implements LineScannerState {
+  /// The [SpanScanner] that created this.
+  final SpanScanner _scanner;
+
+  final int position;
+  int get line => _scanner._sourceFile.getLine(position);
+  int get column => _scanner._sourceFile.getColumn(line, position);
+
+  _SpanScannerState(this._scanner, this.position);
+}
diff --git a/lib/src/string_scanner.dart b/lib/src/string_scanner.dart
new file mode 100644
index 0000000..c9e7459
--- /dev/null
+++ b/lib/src/string_scanner.dart
@@ -0,0 +1,168 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// 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.
+
+library string_scanner.string_scanner;
+
+import 'package:source_maps/source_maps.dart';
+
+import 'exception.dart';
+import 'utils.dart';
+
+/// When compiled to JS, forward slashes are always escaped in [RegExp.pattern].
+///
+/// See issue 17998.
+final _slashAutoEscape = new RegExp("/").pattern == "\\/";
+
+/// A class that scans through a string using [Pattern]s.
+class StringScanner {
+  /// The URL of the source of the string being scanned.
+  ///
+  /// This is used for error reporting. It may be `null`, indicating that the
+  /// source URL is unknown or unavailable.
+  final Uri sourceUrl;
+
+  /// The string being scanned through.
+  final String string;
+
+  /// The current position of the scanner in the string, in characters.
+  int get position => _position;
+  set position(int position) {
+    if (position < 0 || position > string.length) {
+      throw new ArgumentError("Invalid position $position");
+    }
+
+    _position = position;
+  }
+  int _position = 0;
+
+  /// The data about the previous match made by the scanner.
+  ///
+  /// If the last match failed, this will be `null`.
+  Match get lastMatch => _lastMatch;
+  Match _lastMatch;
+
+  /// The portion of the string that hasn't yet been scanned.
+  String get rest => string.substring(position);
+
+  /// Whether the scanner has completely consumed [string].
+  bool get isDone => position == string.length;
+
+  /// Creates a new [StringScanner] that starts scanning from [position].
+  ///
+  /// [position] defaults to 0, the beginning of the string. [sourceUrl] is the
+  /// URL of the source of the string being scanned, if available. It can be
+  /// either a [String] or a [Uri].
+  StringScanner(this.string, {sourceUrl, int position})
+      : sourceUrl = sourceUrl is String ? Uri.parse(sourceUrl) : sourceUrl {
+    if (position != null) this.position = position;
+  }
+
+  /// Consumes a single character and returns its character code.
+  ///
+  /// This throws a [FormatException] if the string has been fully consumed. It
+  /// doesn't affect [lastMatch].
+  int readChar() {
+    if (isDone) _fail("more input");
+    return string.codeUnitAt(_position++);
+  }
+
+  /// Returns the character code of the character [offset] away from [position].
+  ///
+  /// [offset] defaults to zero, and may be negative to inspect already-consumed
+  /// characters.
+  ///
+  /// This returns `null` if [offset] points outside the string. It doesn't
+  /// affect [lastMatch].
+  int peekChar([int offset]) {
+    if (offset == null) offset = 0;
+    var index = position + offset;
+    if (index < 0 || index >= string.length) return null;
+    return string.codeUnitAt(index);
+  }
+
+  /// If [pattern] matches at the current position of the string, scans forward
+  /// until the end of the match.
+  ///
+  /// Returns whether or not [pattern] matched.
+  bool scan(Pattern pattern) {
+    var success = matches(pattern);
+    if (success) _position = _lastMatch.end;
+    return success;
+  }
+
+  /// If [pattern] matches at the current position of the string, scans forward
+  /// until the end of the match.
+  ///
+  /// If [pattern] did not match, throws a [FormatException] describing the
+  /// position of the failure. [name] is used in this error as the expected name
+  /// of the pattern being matched; if it's `null`, the pattern itself is used
+  /// instead.
+  void expect(Pattern pattern, {String name}) {
+    if (scan(pattern)) return;
+
+    if (name == null) {
+      if (pattern is RegExp) {
+        var source = pattern.pattern;
+        if (!_slashAutoEscape) source = source.replaceAll("/", "\\/");
+        name = "/$source/";
+      } else {
+        name = pattern.toString()
+            .replaceAll("\\", "\\\\").replaceAll('"', '\\"');
+        name = '"$name"';
+      }
+    }
+    _fail(name);
+  }
+
+  /// If the string has not been fully consumed, this throws a
+  /// [FormatException].
+  void expectDone() {
+    if (isDone) return;
+    _fail("no more input");
+  }
+
+  /// Returns whether or not [pattern] matches at the current position of the
+  /// string.
+  ///
+  /// This doesn't move the scan pointer forward.
+  bool matches(Pattern pattern) {
+    _lastMatch = pattern.matchAsPrefix(string, position);
+    return _lastMatch != null;
+  }
+
+  /// Throws a [FormatException] with [message] as well as a detailed
+  /// description of the location of the error in the string.
+  ///
+  /// [match] is the match information for the span of the string with which the
+  /// error is associated. This should be a match returned by this scanner's
+  /// [lastMatch] property. By default, the error is associated with the last
+  /// match.
+  ///
+  /// If [position] and/or [length] are passed, they are used as the error span
+  /// instead. If only [length] is passed, [position] defaults to the current
+  /// position; if only [position] is passed, [length] defaults to 1.
+  ///
+  /// It's an error to pass [match] at the same time as [position] or [length].
+  void error(String message, {Match match, int position, int length}) {
+    validateErrorArgs(string, match, position, length);
+
+    if (match == null && position == null && length == null) match = lastMatch;
+    if (position == null) {
+      position = match == null ? this.position : match.start;
+    }
+    if (length == null) length = match == null ? 1 : match.end - match.start;
+
+    var url = sourceUrl == null ? null : sourceUrl.toString();
+    var sourceFile = new SourceFile.text(url, string);
+    var span = sourceFile.span(position, position + length);
+    throw new StringScannerException(message, string, sourceUrl, span);
+  }
+
+  // TODO(nweiz): Make this handle long lines more gracefully.
+  /// Throws a [FormatException] describing that [name] is expected at the
+  /// current position in the string.
+  void _fail(String name) {
+    error("expected $name.", position: this.position, length: 0);
+  }
+}
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
new file mode 100644
index 0000000..e556236
--- /dev/null
+++ b/lib/src/utils.dart
@@ -0,0 +1,30 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// 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.
+
+library string_scanner.utils;
+
+/// Validates the arguments passed to [StringScanner.error].
+void validateErrorArgs(String string, Match match, int position, int length) {
+  if (match != null && (position != null || length != null)) {
+    throw new ArgumentError("Can't pass both match and position/length.");
+  }
+
+  if (position != null) {
+    if (position < 0) {
+      throw new RangeError("position must be greater than or equal to 0.");
+    } else if (position > string.length) {
+      throw new RangeError("position must be less than or equal to the "
+          "string length.");
+    }
+  }
+
+  if (length != null && length < 0) {
+    throw new RangeError("length must be greater than or equal to 0.");
+  }
+
+  if (position != null && length != null && position + length > string.length) {
+    throw new RangeError("position plus length must not go beyond the end of "
+        "the string.");
+  }
+}
\ No newline at end of file
diff --git a/lib/string_scanner.dart b/lib/string_scanner.dart
index 3e3913b..be294f4 100644
--- a/lib/string_scanner.dart
+++ b/lib/string_scanner.dart
@@ -5,165 +5,7 @@
 /// A library for parsing strings using a sequence of patterns.
 library string_scanner;
 
-import 'dart:math' as math;
-
-/// When compiled to JS, forward slashes are always escaped in [RegExp.pattern].
-///
-/// See issue 17998.
-final _slashAutoEscape = new RegExp("/").pattern == "\\/";
-
-// TODO(nweiz): Add some integration between this and source maps.
-/// A class that scans through a string using [Pattern]s.
-class StringScanner {
-  /// The string being scanned through.
-  final String string;
-
-  /// The current position of the scanner in the string, in characters.
-  int get position => _position;
-  set position(int position) {
-    if (position < 0 || position > string.length) {
-      throw new ArgumentError("Invalid position $position");
-    }
-
-    _position = position;
-  }
-  int _position = 0;
-
-  /// The data about the previous match made by the scanner.
-  ///
-  /// If the last match failed, this will be `null`.
-  Match get lastMatch => _lastMatch;
-  Match _lastMatch;
-
-  /// The portion of the string that hasn't yet been scanned.
-  String get rest => string.substring(position);
-
-  /// Whether the scanner has completely consumed [string].
-  bool get isDone => position == string.length;
-
-  /// Creates a new [StringScanner] that starts scanning from [position].
-  ///
-  /// [position] defaults to 0, the beginning of the string.
-  StringScanner(this.string, {int position}) {
-    if (position != null) this.position = position;
-  }
-
-  /// If [pattern] matches at the current position of the string, scans forward
-  /// until the end of the match.
-  ///
-  /// Returns whether or not [pattern] matched.
-  bool scan(Pattern pattern) {
-    var success = matches(pattern);
-    if (success) _position = _lastMatch.end;
-    return success;
-  }
-
-  /// If [pattern] matches at the current position of the string, scans forward
-  /// until the end of the match.
-  ///
-  /// If [pattern] did not match, throws a [FormatException] describing the
-  /// position of the failure. [name] is used in this error as the expected name
-  /// of the pattern being matched; if it's `null`, the pattern itself is used
-  /// instead.
-  void expect(Pattern pattern, {String name}) {
-    if (scan(pattern)) return;
-
-    if (name == null) {
-      if (pattern is RegExp) {
-        var source = pattern.pattern;
-        if (!_slashAutoEscape) source = source.replaceAll("/", "\\/");
-        name = "/$source/";
-      } else {
-        name = pattern.toString()
-            .replaceAll("\\", "\\\\").replaceAll('"', '\\"');
-        name = '"$name"';
-      }
-    }
-    _fail(name);
-  }
-
-  /// If the string has not been fully consumed, this throws a
-  /// [FormatException].
-  void expectDone() {
-    if (isDone) return;
-    _fail("no more input");
-  }
-
-  /// Returns whether or not [pattern] matches at the current position of the
-  /// string.
-  ///
-  /// This doesn't move the scan pointer forward.
-  bool matches(Pattern pattern) {
-    _lastMatch = pattern.matchAsPrefix(string, position);
-    return _lastMatch != null;
-  }
-
-  /// Throws a [FormatException] with [message] as well as a detailed
-  /// description of the location of the error in the string.
-  ///
-  /// [match] is the match information for the span of the string with which the
-  /// error is associated. This should be a match returned by this scanner's
-  /// [lastMatch] property. By default, the error is associated with the last
-  /// match.
-  ///
-  /// If [position] and/or [length] are passed, they are used as the error span
-  /// instead. If only [length] is passed, [position] defaults to the current
-  /// position; if only [position] is passed, [length] defaults to 1.
-  ///
-  /// It's an error to pass [match] at the same time as [position] or [length].
-  void error(String message, {Match match, int position, int length}) {
-    if (match != null && (position != null || length != null)) {
-      throw new ArgumentError("Can't pass both match and position/length.");
-    }
-
-    if (position != null && position < 0) {
-      throw new RangeError("position must be greater than or equal to 0.");
-    }
-
-    if (length != null && length < 1) {
-      throw new RangeError("length must be greater than or equal to 0.");
-    }
-
-    if (match == null && position == null && length == null) match = lastMatch;
-    if (position == null) {
-      position = match == null ? this.position : match.start;
-    }
-    if (length == null) length = match == null ? 1 : match.end - match.start;
-
-    var newlines = "\n".allMatches(string.substring(0, position)).toList();
-    var line = newlines.length + 1;
-    var column;
-    var lastLine;
-    if (newlines.isEmpty) {
-      column = position + 1;
-      lastLine = string.substring(0, position);
-    } else {
-      column = position - newlines.last.end + 1;
-      lastLine = string.substring(newlines.last.end, position);
-    }
-
-    var remaining = string.substring(position);
-    var nextNewline = remaining.indexOf("\n");
-    if (nextNewline == -1) {
-      lastLine += remaining;
-    } else {
-      length = math.min(length, nextNewline);
-      lastLine += remaining.substring(0, nextNewline);
-    }
-
-    var spaces = new List.filled(column - 1, ' ').join();
-    var underline = new List.filled(length, '^').join();
-
-    throw new FormatException(
-        "Error on line $line, column $column: $message\n"
-        "$lastLine\n"
-        "$spaces$underline");
-  }
-
-  // TODO(nweiz): Make this handle long lines more gracefully.
-  /// Throws a [FormatException] describing that [name] is expected at the
-  /// current position in the string.
-  void _fail(String name) {
-    error("expected $name.", position: this.position, length: 1);
-  }
-}
+export 'src/exception.dart';
+export 'src/line_scanner.dart';
+export 'src/span_scanner.dart';
+export 'src/string_scanner.dart';
diff --git a/pubspec.yaml b/pubspec.yaml
index 3035901..86ae238 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,9 +1,12 @@
 name: string_scanner
-version: 0.0.1
+version: 0.0.2
 author: "Dart Team <misc@dartlang.org>"
 homepage: http://www.dartlang.org
 description: >
   A class for parsing strings using a sequence of patterns.
+dependencies:
+  path: ">=1.2.0 <2.0.0"
+  source_maps: ">=0.9.0 <0.10.0"
 dev_dependencies:
   unittest: ">=0.10.0 <0.11.0"
 environment:
diff --git a/test/error_test.dart b/test/error_test.dart
index 6432197..4fe9083 100644
--- a/test/error_test.dart
+++ b/test/error_test.dart
@@ -14,10 +14,7 @@
     var scanner = new StringScanner('foo bar baz');
     scanner.expect('foo ');
     scanner.expect('bar');
-    expect(() => scanner.error('oh no!'), throwsFormattedError('''
-Error on line 1, column 5: oh no!
-foo bar baz
-    ^^^'''));
+    expect(() => scanner.error('oh no!'), throwsStringScannerException('bar'));
   });
 
   group("with match", () {
@@ -27,10 +24,7 @@
       var match = scanner.lastMatch;
       scanner.expect('bar');
       expect(() => scanner.error('oh no!', match: match),
-          throwsFormattedError('''
-Error on line 1, column 1: oh no!
-foo bar baz
-^^^^'''));
+          throwsStringScannerException('foo '));
     });
 
     test('supports a match on a previous line', () {
@@ -40,10 +34,7 @@
       var match = scanner.lastMatch;
       scanner.expect(' mi\nearth ');
       expect(() => scanner.error('oh no!', match: match),
-          throwsFormattedError('''
-Error on line 2, column 4: oh no!
-do re mi
-   ^^'''));
+          throwsStringScannerException('re'));
     });
 
     test('supports a multiline match', () {
@@ -53,10 +44,7 @@
       var match = scanner.lastMatch;
       scanner.expect(' re mi');
       expect(() => scanner.error('oh no!', match: match),
-          throwsFormattedError('''
-Error on line 1, column 9: oh no!
-foo bar baz
-        ^^^'''));
+          throwsStringScannerException('baz\ndo'));
     });
 
     test('supports a match after position', () {
@@ -66,10 +54,7 @@
       var match = scanner.lastMatch;
       scanner.position = 0;
       expect(() => scanner.error('oh no!', match: match),
-          throwsFormattedError('''
-Error on line 1, column 5: oh no!
-foo bar baz
-    ^^^'''));
+          throwsStringScannerException('bar'));
     });
   });
 
@@ -78,59 +63,47 @@
       var scanner = new StringScanner('foo bar baz');
       scanner.expect('foo ');
       expect(() => scanner.error('oh no!', position: 1),
-          throwsFormattedError('''
-Error on line 1, column 2: oh no!
-foo bar baz
- ^'''));
+          throwsStringScannerException('o'));
     });
 
     test('defaults to the current position', () {
       var scanner = new StringScanner('foo bar baz');
       scanner.expect('foo ');
       expect(() => scanner.error('oh no!', length: 3),
-          throwsFormattedError('''
-Error on line 1, column 5: oh no!
-foo bar baz
-    ^^^'''));
+          throwsStringScannerException('bar'));
     });
 
     test('supports an earlier position', () {
       var scanner = new StringScanner('foo bar baz');
       scanner.expect('foo ');
       expect(() => scanner.error('oh no!', position: 1, length: 2),
-          throwsFormattedError('''
-Error on line 1, column 2: oh no!
-foo bar baz
- ^^'''));
+          throwsStringScannerException('oo'));
     });
 
     test('supports a position on a previous line', () {
       var scanner = new StringScanner('foo bar baz\ndo re mi\nearth fire water');
       scanner.expect('foo bar baz\ndo re mi\nearth');
       expect(() => scanner.error('oh no!', position: 15, length: 2),
-          throwsFormattedError('''
-Error on line 2, column 4: oh no!
-do re mi
-   ^^'''));
+          throwsStringScannerException('re'));
     });
 
     test('supports a multiline length', () {
       var scanner = new StringScanner('foo bar baz\ndo re mi\nearth fire water');
       scanner.expect('foo bar baz\ndo re mi\nearth');
       expect(() => scanner.error('oh no!', position: 8, length: 8),
-          throwsFormattedError('''
-Error on line 1, column 9: oh no!
-foo bar baz
-        ^^^'''));
+          throwsStringScannerException('baz\ndo r'));
     });
 
     test('supports a position after the current one', () {
       var scanner = new StringScanner('foo bar baz');
       expect(() => scanner.error('oh no!', position: 4, length: 3),
-          throwsFormattedError('''
-Error on line 1, column 5: oh no!
-foo bar baz
-    ^^^'''));
+          throwsStringScannerException('bar'));
+    });
+
+    test('supports a length of zero', () {
+      var scanner = new StringScanner('foo bar baz');
+      expect(() => scanner.error('oh no!', position: 4, length: 0),
+          throwsStringScannerException(''));
     });
   });
 
@@ -161,8 +134,13 @@
       expect(() => scanner.error("oh no!", position: 100), throwsArgumentError);
     });
 
-    test("if length is zero", () {
-      expect(() => scanner.error("oh no!", length: 0), throwsArgumentError);
+    test("if position + length is outside the string", () {
+      expect(() => scanner.error("oh no!", position: 7, length: 7),
+          throwsArgumentError);
+    });
+
+    test("if length is negative", () {
+      expect(() => scanner.error("oh no!", length: -1), throwsArgumentError);
     });
   });
 }
diff --git a/test/expect_error_test.dart b/test/expect_error_test.dart
deleted file mode 100644
index 3596e15..0000000
--- a/test/expect_error_test.dart
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
-// 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.
-
-library string_scanner.expect_error_test;
-
-import 'package:string_scanner/string_scanner.dart';
-import 'package:unittest/unittest.dart';
-
-import 'utils.dart';
-
-void main() {
-  test('points to the first unconsumed character', () {
-    var scanner = new StringScanner('foo bar baz');
-    scanner.expect('foo ');
-    expect(() => scanner.expect('foo'), throwsFormattedError('''
-Error on line 1, column 5: expected "foo".
-foo bar baz
-    ^'''));
-  });
-
-  test('prints the correct line', () {
-    var scanner = new StringScanner('foo bar baz\ndo re mi\nearth fire water');
-    scanner.expect('foo bar baz\ndo ');
-    expect(() => scanner.expect('foo'), throwsFormattedError('''
-Error on line 2, column 4: expected "foo".
-do re mi
-   ^'''));
-  });
-
-  test('handles the beginning of the string correctly', () {
-    var scanner = new StringScanner('foo bar baz');
-    expect(() => scanner.expect('zap'), throwsFormattedError('''
-Error on line 1, column 1: expected "zap".
-foo bar baz
-^'''));
-  });
-
-  test('handles the end of the string correctly', () {
-    var scanner = new StringScanner('foo bar baz');
-    scanner.expect('foo bar baz');
-    expect(() => scanner.expect('bang'), throwsFormattedError('''
-Error on line 1, column 12: expected "bang".
-foo bar baz
-           ^'''));
-  });
-
-  test('handles an empty string correctly', () {
-    expect(() => new StringScanner('').expect('foo'), throwsFormattedError('''
-Error on line 1, column 1: expected "foo".
-
-^'''));
-  });
-
-  group("expected name", () {
-    test("uses the provided name", () {
-      expect(() => new StringScanner('').expect('foo bar', name: 'zap'),
-          throwsFormattedError('''
-Error on line 1, column 1: expected zap.
-
-^'''));
-    });
-
-    test("escapes string quotes", () {
-      expect(() => new StringScanner('').expect('foo"bar'),
-          throwsFormattedError('''
-Error on line 1, column 1: expected "foo\\"bar".
-
-^'''));
-    });
-
-    test("escapes string backslashes", () {
-      expect(() => new StringScanner('').expect('foo\\bar'),
-          throwsFormattedError('''
-Error on line 1, column 1: expected "foo\\\\bar".
-
-^'''));
-    });
-
-    test("prints PERL-style regexps", () {
-      expect(() => new StringScanner('').expect(new RegExp(r'foo')),
-          throwsFormattedError('''
-Error on line 1, column 1: expected /foo/.
-
-^'''));
-    });
-
-    test("escape regexp forward slashes", () {
-      expect(() => new StringScanner('').expect(new RegExp(r'foo/bar')),
-          throwsFormattedError('''
-Error on line 1, column 1: expected /foo\\/bar/.
-
-^'''));
-    });
-
-    test("does not escape regexp backslashes", () {
-      expect(() => new StringScanner('').expect(new RegExp(r'foo\bar')),
-          throwsFormattedError('''
-Error on line 1, column 1: expected /foo\\bar/.
-
-^'''));
-    });
-  });
-}
diff --git a/test/line_scanner_test.dart b/test/line_scanner_test.dart
new file mode 100644
index 0000000..8cc79a6
--- /dev/null
+++ b/test/line_scanner_test.dart
@@ -0,0 +1,106 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// 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.
+
+library string_scanner.line_scanner_test;
+
+import 'package:string_scanner/string_scanner.dart';
+import 'package:unittest/unittest.dart';
+
+void main() {
+  var scanner;
+  setUp(() {
+    scanner = new LineScanner('foo\nbar\nbaz');
+  });
+
+  test('begins with line and column 0', () {
+    expect(scanner.line, equals(0));
+    expect(scanner.column, equals(0));
+  });
+
+  group("scan()", () {
+    test("consuming no newlines increases the column but not the line", () {
+      scanner.scan('foo');
+      expect(scanner.line, equals(0));
+      expect(scanner.column, equals(3));
+    });
+
+    test("consuming a newline resets the column and increases the line", () {
+      scanner.expect('foo\nba');
+      expect(scanner.line, equals(1));
+      expect(scanner.column, equals(2));
+    });
+
+    test("consuming multiple newlines resets the column and increases the line",
+        () {
+      scanner.expect('foo\nbar\nb');
+      expect(scanner.line, equals(2));
+      expect(scanner.column, equals(1));
+    });
+  });
+
+  group("readChar()", () {
+    test("on a non-newline character increases the column but not the line",
+        () {
+      scanner.readChar();
+      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.readChar();
+      expect(scanner.line, equals(1));
+      expect(scanner.column, equals(0));
+    });
+  });
+
+  group("position=", () {
+    test("forward through newlines sets the line and column", () {
+      scanner.position = 9; // "foo\nbar\nb"
+      expect(scanner.line, equals(2));
+      expect(scanner.column, equals(1));
+    });
+
+    test("forward through no newlines sets the column", () {
+      scanner.position = 2; // "fo"
+      expect(scanner.line, equals(0));
+      expect(scanner.column, equals(2));
+    });
+
+    test("backward through newlines sets the line and column", () {
+      scanner.scan("foo\nbar\nbaz");
+      scanner.position = 2; // "fo"
+      expect(scanner.line, equals(0));
+      expect(scanner.column, equals(2));
+    });
+
+    test("backward through no newlines sets the column", () {
+      scanner.scan("foo\nbar\nbaz");
+      scanner.position = 9; // "foo\nbar\nb"
+      expect(scanner.line, equals(2));
+      expect(scanner.column, equals(1));
+    });
+  });
+
+  test("state= restores the line, column, and position", () {
+    scanner.scan('foo\nb');
+    var state = scanner.state;
+
+    scanner.scan('ar\nba');
+    scanner.state = state;
+    expect(scanner.rest, equals('ar\nbaz'));
+    expect(scanner.line, equals(1));
+    expect(scanner.column, equals(1));
+  });
+
+  test("state= rejects a foreign state", () {
+    scanner.scan('foo\nb');
+
+    expect(() => new LineScanner(scanner.string).state = scanner.state,
+        throwsArgumentError);
+  });
+}
diff --git a/test/span_scanner_test.dart b/test/span_scanner_test.dart
new file mode 100644
index 0000000..93ba0b6
--- /dev/null
+++ b/test/span_scanner_test.dart
@@ -0,0 +1,60 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// 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.
+
+library string_scanner.span_scanner_test;
+
+import 'package:string_scanner/string_scanner.dart';
+import 'package:unittest/unittest.dart';
+
+void main() {
+  var scanner;
+  setUp(() {
+    scanner = new SpanScanner('foo\nbar\nbaz', 'source');
+  });
+
+  test("tracks the span for the last match", () {
+    scanner.scan('fo');
+    scanner.scan('o\nba');
+
+    var span = scanner.lastSpan;
+    expect(span.start.offset, equals(2));
+    expect(span.start.line, equals(0));
+    expect(span.start.column, equals(2));
+    expect(span.start.sourceUrl, equals('source'));
+
+    expect(span.end.offset, equals(6));
+    expect(span.end.line, equals(1));
+    expect(span.end.column, equals(2));
+    expect(span.start.sourceUrl, equals('source'));
+
+    expect(span.text, equals('o\nba'));
+  });
+
+  test(".spanFrom() returns a span from a previous state", () {
+    scanner.scan('fo');
+    var state = scanner.state;
+    scanner.scan('o\nba');
+    scanner.scan('r\nba');
+
+    var span = scanner.spanFrom(state);
+    expect(span.text, equals('o\nbar\nba'));
+  });
+
+  test(".emptySpan returns an empty span at the current location", () {
+    scanner.scan('foo\nba');
+
+    var span = scanner.emptySpan;
+    expect(span.start.offset, equals(6));
+    expect(span.start.line, equals(1));
+    expect(span.start.column, equals(2));
+    expect(span.start.sourceUrl, equals('source'));
+
+    expect(span.end.offset, equals(6));
+    expect(span.end.line, equals(1));
+    expect(span.end.column, equals(2));
+    expect(span.start.sourceUrl, equals('source'));
+
+    expect(span.text, equals(''));
+  });
+}
diff --git a/test/string_scanner_test.dart b/test/string_scanner_test.dart
index 0cab627..6144bf9 100644
--- a/test/string_scanner_test.dart
+++ b/test/string_scanner_test.dart
@@ -31,6 +31,18 @@
       expect(scanner.position, equals(0));
     });
 
+    test("readChar fails and doesn't change the state", () {
+      expect(scanner.readChar, throwsFormatException);
+      expect(scanner.lastMatch, isNull);
+      expect(scanner.position, equals(0));
+    });
+
+    test("peekChar returns null and doesn't change the state", () {
+      expect(scanner.peekChar(), isNull);
+      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);
@@ -85,6 +97,24 @@
       expect(scanner.position, equals(0));
     });
 
+    test('readChar returns the first character and moves forward', () {
+      expect(scanner.readChar(), equals(0x66));
+      expect(scanner.lastMatch, isNull);
+      expect(scanner.position, equals(1));
+    });
+
+    test('peekChar returns the first character', () {
+      expect(scanner.peekChar(), equals(0x66));
+      expect(scanner.lastMatch, isNull);
+      expect(scanner.position, equals(0));
+    });
+
+    test('peekChar with an argument returns the nth character', () {
+      expect(scanner.peekChar(4), equals(0x62));
+      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'));
@@ -200,6 +230,18 @@
       expect(scanner.position, equals(7));
     });
 
+    test("readChar fails and doesn't change the state", () {
+      expect(scanner.readChar, throwsFormatException);
+      expect(scanner.lastMatch, isNotNull);
+      expect(scanner.position, equals(7));
+    });
+
+    test("peekChar returns null and doesn't change the state", () {
+      expect(scanner.peekChar(), isNull);
+      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);
diff --git a/test/utils.dart b/test/utils.dart
index eee93d8..3de601c 100644
--- a/test/utils.dart
+++ b/test/utils.dart
@@ -4,14 +4,15 @@
 
 library string_scanner.test.utils;
 
+import 'package:string_scanner/string_scanner.dart';
 import 'package:unittest/unittest.dart';
 
 /// Returns a matcher that asserts that a closure throws a [FormatException]
 /// with the given [message].
-Matcher throwsFormattedError(String message) {
+Matcher throwsStringScannerException(String text) {
   return throwsA(predicate((error) {
-    expect(error, isFormatException);
-    expect(error.message, equals(message));
+    expect(error, new isInstanceOf<StringScannerException>());
+    expect(error.span.text, equals(text));
     return true;
   }));
 }