Add SpanScanner.within().

This is useful for doing more detailed parses of sub-sections of larger
text.

R=rnystrom@google.com

Review URL: https://codereview.chromium.org//2039163002 .
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5650cc..ea1268c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.1.5
+
+* Add `new SpanScanner.within()`, which scans within a existing `FileSpan`.
+
 ## 0.1.4+1
 
 * Remove the dependency on `path`, since we don't actually import it.
diff --git a/lib/src/relative_span_scanner.dart b/lib/src/relative_span_scanner.dart
new file mode 100644
index 0000000..fdcd03f
--- /dev/null
+++ b/lib/src/relative_span_scanner.dart
@@ -0,0 +1,112 @@
+// Copyright (c) 2016, 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.
+
+import 'package:source_span/source_span.dart';
+
+import 'exception.dart';
+import 'line_scanner.dart';
+import 'span_scanner.dart';
+import 'string_scanner.dart';
+import 'utils.dart';
+
+/// A [SpanScanner] that scans within an existing [FileSpan].
+///
+/// This re-implements chunks of [SpanScanner] rather than using a dummy span or
+/// inheritance because scanning is often a performance-critical operation, so
+/// it's important to avoid adding extra overhead when relative scanning isn't
+/// needed.
+class RelativeSpanScanner extends StringScanner implements SpanScanner {
+  /// The source of the scanner.
+  ///
+  /// This caches line break information and is used to generate [Span]s.
+  final SourceFile _sourceFile;
+
+  /// The start location of the span within which this scanner is scanning.
+  ///
+  /// This is used to convert between span-relative and file-relative fields.
+  final FileLocation _startLocation;
+
+  int get line => _sourceFile.getLine(_startLocation.offset + position) -
+      _startLocation.line;
+
+  int get column {
+    var line = _sourceFile.getLine(_startLocation.offset + position);
+    var column = _sourceFile.getColumn(_startLocation.offset + position,
+        line: line);
+    return line == _startLocation.line
+        ? column - _startLocation.column
+        : column;
+  }
+
+  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;
+  }
+
+  FileSpan get lastSpan => _lastSpan;
+  FileSpan _lastSpan;
+
+  FileLocation get location =>
+      _sourceFile.location(_startLocation.offset + position);
+
+  FileSpan get emptySpan => location.pointSpan();
+
+  RelativeSpanScanner(FileSpan span)
+      : _sourceFile = span.file,
+        _startLocation = span.start,
+        super(span.text, sourceUrl: span.sourceUrl);
+
+  FileSpan spanFrom(LineScannerState startState, [LineScannerState endState]) {
+    var endPosition = endState == null ? position : endState.position;
+    return _sourceFile.span(
+        _startLocation.offset + startState.position,
+        _startLocation.offset + endPosition);
+  }
+
+  bool matches(Pattern pattern) {
+    if (!super.matches(pattern)) {
+      _lastSpan = null;
+      return false;
+    }
+
+    _lastSpan = _sourceFile.span(
+        _startLocation.offset + position,
+        _startLocation.offset + 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(
+        _startLocation.offset + position,
+        _startLocation.offset + position + length);
+    throw new StringScannerException(message, span, string);
+  }
+}
+
+/// A class representing the state of a [SpanScanner].
+class _SpanScannerState implements LineScannerState {
+  /// The [SpanScanner] that created this.
+  final RelativeSpanScanner _scanner;
+
+  final int position;
+  int get line => _scanner._sourceFile.getLine(position);
+  int get column => _scanner._sourceFile.getColumn(position);
+
+  _SpanScannerState(this._scanner, this.position);
+}
diff --git a/lib/src/span_scanner.dart b/lib/src/span_scanner.dart
index dd16e47..dd7f0e4 100644
--- a/lib/src/span_scanner.dart
+++ b/lib/src/span_scanner.dart
@@ -7,6 +7,7 @@
 import 'eager_span_scanner.dart';
 import 'exception.dart';
 import 'line_scanner.dart';
+import 'relative_span_scanner.dart';
 import 'string_scanner.dart';
 import 'utils.dart';
 
@@ -69,6 +70,14 @@
   factory SpanScanner.eager(String string, {sourceUrl, int position}) =
       EagerSpanScanner;
 
+  /// Creates a new [SpanScanner] that scans within [span].
+  ///
+  /// This scans through [span.text], but emits new spans from [span.file] in
+  /// their appropriate relative positions. The [string] field contains only
+  /// [span.text], and [position], [line], and [column] are all relative to the
+  /// span.
+  factory SpanScanner.within(FileSpan span) = RelativeSpanScanner;
+
   /// Creates a [FileSpan] representing the source range between [startState]
   /// and the current position.
   FileSpan spanFrom(LineScannerState startState, [LineScannerState endState]) {
diff --git a/pubspec.yaml b/pubspec.yaml
index b249240..24bd6bb 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: string_scanner
-version: 0.1.4+1
+version: 0.1.5-dev
 author: "Dart Team <misc@dartlang.org>"
 homepage: https://github.com/dart-lang/string_scanner
 description: >
diff --git a/test/span_scanner_test.dart b/test/span_scanner_test.dart
index b078f6e..84d7b94 100644
--- a/test/span_scanner_test.dart
+++ b/test/span_scanner_test.dart
@@ -2,9 +2,12 @@
 // 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:source_span/source_span.dart';
 import 'package:string_scanner/string_scanner.dart';
 import 'package:test/test.dart';
 
+import 'utils.dart';
+
 void main() {
   testForImplementation("lazy", () {
     return new SpanScanner('foo\nbar\nbaz', sourceUrl: 'source');
@@ -13,6 +16,91 @@
   testForImplementation("eager", () {
     return new SpanScanner.eager('foo\nbar\nbaz', sourceUrl: 'source');
   });
+
+  group("within", () {
+    var text = 'first\nbefore: foo\nbar\nbaz :after\nlast';
+    var startOffset = text.indexOf('foo');
+
+    var scanner;
+    setUp(() {
+      var file = new SourceFile(text, url: 'source');
+      scanner = new SpanScanner.within(
+          file.span(startOffset, text.indexOf(' :after')));
+    });
+
+    test("string only includes the span text", () {
+      expect(scanner.string, equals("foo\nbar\nbaz"));
+    });
+
+    test("line and column are span-relative", () {
+      expect(scanner.line, equals(0));
+      expect(scanner.column, equals(0));
+
+      scanner.scan("foo");
+      expect(scanner.line, equals(0));
+      expect(scanner.column, equals(3));
+
+      scanner.scan("\n");
+      expect(scanner.line, equals(1));
+      expect(scanner.column, equals(0));
+    });
+
+    test("tracks the span for the last match", () {
+      scanner.scan('fo');
+      scanner.scan('o\nba');
+
+      var span = scanner.lastSpan;
+      expect(span.start.offset, equals(startOffset + 2));
+      expect(span.start.line, equals(1));
+      expect(span.start.column, equals(10));
+      expect(span.start.sourceUrl, equals(Uri.parse('source')));
+
+      expect(span.end.offset, equals(startOffset + 6));
+      expect(span.end.line, equals(2));
+      expect(span.end.column, equals(2));
+      expect(span.start.sourceUrl, equals(Uri.parse('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(startOffset + 6));
+      expect(span.start.line, equals(2));
+      expect(span.start.column, equals(2));
+      expect(span.start.sourceUrl, equals(Uri.parse('source')));
+
+      expect(span.end.offset, equals(startOffset + 6));
+      expect(span.end.line, equals(2));
+      expect(span.end.column, equals(2));
+      expect(span.start.sourceUrl, equals(Uri.parse('source')));
+
+      expect(span.text, equals(''));
+    });
+
+    test(".error() uses an absolute span", () {
+      scanner.expect("foo");
+      expect(() => scanner.error('oh no!'),
+          throwsStringScannerException("foo"));
+    });
+
+    test(".isDone returns true at the end of the span", () {
+      scanner.expect("foo\nbar\nbaz");
+      expect(scanner.isDone, isTrue);
+    });
+  });
 }
 
 void testForImplementation(String name, SpanScanner create()) {