Add SourceSpan.subspan() (#54)

This is useful when a span may cover multiple logical tokens, and a
user wants to single out a single specific token.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c24535b..5da330e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,7 @@
-# 1.6.1-dev
+# 1.7.0
+
+* Add a `SourceSpan.subspan()` extension method which returns a slice of an
+  existing source span.
 
 # 1.6.0
 
diff --git a/lib/src/file.dart b/lib/src/file.dart
index a03a875..9ad595c 100644
--- a/lib/src/file.dart
+++ b/lib/src/file.dart
@@ -423,4 +423,25 @@
       return _FileSpan(file, start, end);
     }
   }
+
+  /// See `SourceSpanExtension.subspan`.
+  FileSpan subspan(int start, [int end]) {
+    RangeError.checkValidRange(start, end, length);
+    if (start == 0 && (end == null || end == length)) return this;
+    return file.span(_start + start, end == null ? _end : _start + end);
+  }
+}
+
+// TODO(#52): Move these to instance methods in the next breaking release.
+/// Extension methods on the [FileSpan] API.
+extension FileSpanExtension on FileSpan {
+  /// See `SourceSpanExtension.subspan`.
+  FileSpan subspan(int start, [int end]) {
+    RangeError.checkValidRange(start, end, length);
+    if (start == 0 && (end == null || end == length)) return this;
+
+    final startOffset = this.start.offset;
+    return file.span(
+        startOffset + start, end == null ? this.end.offset : startOffset + end);
+  }
 }
diff --git a/lib/src/span.dart b/lib/src/span.dart
index 51e81ab..15f0d34 100644
--- a/lib/src/span.dart
+++ b/lib/src/span.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:path/path.dart' as p;
 import 'package:term_glyph/term_glyph.dart' as glyph;
 
@@ -179,4 +180,55 @@
               primaryColor: primaryColor,
               secondaryColor: secondaryColor)
           .highlight();
+
+  /// Returns a span from [start] code units (inclusive) to [end] code units
+  /// (exclusive) after the beginning of this span.
+  SourceSpan subspan(int start, [int end]) {
+    RangeError.checkValidRange(start, end, length);
+    if (start == 0 && (end == null || end == length)) return this;
+
+    final text = this.text;
+    final startLocation = this.start;
+    var line = startLocation.line;
+    var column = startLocation.column;
+
+    // Adjust [line] and [column] as necessary if the character at [i] in [text]
+    // is a newline.
+    void consumeCodePoint(int i) {
+      final codeUnit = text.codeUnitAt(i);
+      if (codeUnit == $lf ||
+          // A carriage return counts as a newline, but only if it's not
+          // followed by a line feed.
+          (codeUnit == $cr &&
+              (i + 1 == text.length || text.codeUnitAt(i + 1) != $lf))) {
+        line += 1;
+        column = 0;
+      } else {
+        column += 1;
+      }
+    }
+
+    for (var i = 0; i < start; i++) {
+      consumeCodePoint(i);
+    }
+
+    final newStartLocation = SourceLocation(startLocation.offset + start,
+        sourceUrl: sourceUrl, line: line, column: column);
+
+    SourceLocation newEndLocation;
+    if (end == null || end == length) {
+      newEndLocation = this.end;
+    } else if (end == start) {
+      newEndLocation = newStartLocation;
+    } else if (end != null && end != length) {
+      for (var i = start; i < end; i++) {
+        consumeCodePoint(i);
+      }
+      newEndLocation = SourceLocation(startLocation.offset + end,
+          sourceUrl: sourceUrl, line: line, column: column);
+    }
+
+    return SourceSpan(
+        newStartLocation, newEndLocation, text.substring(start, end));
+  }
 }
diff --git a/pubspec.yaml b/pubspec.yaml
index fce2124..126d9c0 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: source_span
-version: 1.6.1-dev
+version: 1.7.0
 
 description: A library for identifying source spans and locations.
 homepage: https://github.com/dart-lang/source_span
diff --git a/test/file_test.dart b/test/file_test.dart
index 9192200..63b523f 100644
--- a/test/file_test.dart
+++ b/test/file_test.dart
@@ -405,5 +405,126 @@
         expect(span.expand(other), equals(other));
       });
     });
+
+    group('subspan()', () {
+      FileSpan span;
+      setUp(() {
+        span = file.span(5, 11); // "ar baz"
+      });
+
+      group('errors', () {
+        test('start must be greater than zero', () {
+          expect(() => span.subspan(-1), throwsRangeError);
+        });
+
+        test('start must be less than or equal to length', () {
+          expect(() => span.subspan(span.length + 1), throwsRangeError);
+        });
+
+        test('end must be greater than start', () {
+          expect(() => span.subspan(2, 1), throwsRangeError);
+        });
+
+        test('end must be less than or equal to length', () {
+          expect(() => span.subspan(0, span.length + 1), throwsRangeError);
+        });
+      });
+
+      test('preserves the source URL', () {
+        final result = span.subspan(1, 2);
+        expect(result.start.sourceUrl, equals(span.sourceUrl));
+        expect(result.end.sourceUrl, equals(span.sourceUrl));
+      });
+
+      group('returns the original span', () {
+        test('with an implicit end',
+            () => expect(span.subspan(0), equals(span)));
+
+        test('with an explicit end',
+            () => expect(span.subspan(0, span.length), equals(span)));
+      });
+
+      group('within a single line', () {
+        test('returns a strict substring of the original span', () {
+          final result = span.subspan(1, 5);
+          expect(result.text, equals('r ba'));
+          expect(result.start.offset, equals(6));
+          expect(result.start.line, equals(0));
+          expect(result.start.column, equals(6));
+          expect(result.end.offset, equals(10));
+          expect(result.end.line, equals(0));
+          expect(result.end.column, equals(10));
+        });
+
+        test('an implicit end goes to the end of the original span', () {
+          final result = span.subspan(1);
+          expect(result.text, equals('r baz'));
+          expect(result.start.offset, equals(6));
+          expect(result.start.line, equals(0));
+          expect(result.start.column, equals(6));
+          expect(result.end.offset, equals(11));
+          expect(result.end.line, equals(0));
+          expect(result.end.column, equals(11));
+        });
+
+        test('can return an empty span', () {
+          final result = span.subspan(3, 3);
+          expect(result.text, isEmpty);
+          expect(result.start.offset, equals(8));
+          expect(result.start.line, equals(0));
+          expect(result.start.column, equals(8));
+          expect(result.end, equals(result.start));
+        });
+      });
+
+      group('across multiple lines', () {
+        setUp(() {
+          span = file.span(22, 30); // "boom\nzip"
+        });
+
+        test('with start and end in the middle of a line', () {
+          final result = span.subspan(3, 6);
+          expect(result.text, equals('m\nz'));
+          expect(result.start.offset, equals(25));
+          expect(result.start.line, equals(1));
+          expect(result.start.column, equals(13));
+          expect(result.end.offset, equals(28));
+          expect(result.end.line, equals(2));
+          expect(result.end.column, equals(1));
+        });
+
+        test('with start at the end of a line', () {
+          final result = span.subspan(4, 6);
+          expect(result.text, equals('\nz'));
+          expect(result.start.offset, equals(26));
+          expect(result.start.line, equals(1));
+          expect(result.start.column, equals(14));
+        });
+
+        test('with start at the beginning of a line', () {
+          final result = span.subspan(5, 6);
+          expect(result.text, equals('z'));
+          expect(result.start.offset, equals(27));
+          expect(result.start.line, equals(2));
+          expect(result.start.column, equals(0));
+        });
+
+        test('with end at the end of a line', () {
+          final result = span.subspan(3, 4);
+          expect(result.text, equals('m'));
+          expect(result.end.offset, equals(26));
+          expect(result.end.line, equals(1));
+          expect(result.end.column, equals(14));
+        });
+
+        test('with end at the beginning of a line', () {
+          final result = span.subspan(3, 5);
+          expect(result.text, equals('m\n'));
+          expect(result.end.offset, equals(27));
+          expect(result.end.line, equals(2));
+          expect(result.end.column, equals(0));
+        });
+      });
+    });
   });
 }
diff --git a/test/span_test.dart b/test/span_test.dart
index f44b02f..838d6b7 100644
--- a/test/span_test.dart
+++ b/test/span_test.dart
@@ -181,6 +181,126 @@
     });
   });
 
+  group('subspan()', () {
+    group('errors', () {
+      test('start must be greater than zero', () {
+        expect(() => span.subspan(-1), throwsRangeError);
+      });
+
+      test('start must be less than or equal to length', () {
+        expect(() => span.subspan(span.length + 1), throwsRangeError);
+      });
+
+      test('end must be greater than start', () {
+        expect(() => span.subspan(2, 1), throwsRangeError);
+      });
+
+      test('end must be less than or equal to length', () {
+        expect(() => span.subspan(0, span.length + 1), throwsRangeError);
+      });
+    });
+
+    test('preserves the source URL', () {
+      final result = span.subspan(1, 2);
+      expect(result.start.sourceUrl, equals(span.sourceUrl));
+      expect(result.end.sourceUrl, equals(span.sourceUrl));
+    });
+
+    group('returns the original span', () {
+      test('with an implicit end', () => expect(span.subspan(0), equals(span)));
+
+      test('with an explicit end',
+          () => expect(span.subspan(0, span.length), equals(span)));
+    });
+
+    group('within a single line', () {
+      test('returns a strict substring of the original span', () {
+        final result = span.subspan(1, 5);
+        expect(result.text, equals('oo b'));
+        expect(result.start.offset, equals(6));
+        expect(result.start.line, equals(0));
+        expect(result.start.column, equals(6));
+        expect(result.end.offset, equals(10));
+        expect(result.end.line, equals(0));
+        expect(result.end.column, equals(10));
+      });
+
+      test('an implicit end goes to the end of the original span', () {
+        final result = span.subspan(1);
+        expect(result.text, equals('oo bar'));
+        expect(result.start.offset, equals(6));
+        expect(result.start.line, equals(0));
+        expect(result.start.column, equals(6));
+        expect(result.end.offset, equals(12));
+        expect(result.end.line, equals(0));
+        expect(result.end.column, equals(12));
+      });
+
+      test('can return an empty span', () {
+        final result = span.subspan(3, 3);
+        expect(result.text, isEmpty);
+        expect(result.start.offset, equals(8));
+        expect(result.start.line, equals(0));
+        expect(result.start.column, equals(8));
+        expect(result.end, equals(result.start));
+      });
+    });
+
+    group('across multiple lines', () {
+      setUp(() {
+        span = SourceSpan(
+            SourceLocation(5, line: 2, column: 0),
+            SourceLocation(16, line: 4, column: 3),
+            'foo\n'
+            'bar\n'
+            'baz');
+      });
+
+      test('with start and end in the middle of a line', () {
+        final result = span.subspan(2, 5);
+        expect(result.text, equals('o\nb'));
+        expect(result.start.offset, equals(7));
+        expect(result.start.line, equals(2));
+        expect(result.start.column, equals(2));
+        expect(result.end.offset, equals(10));
+        expect(result.end.line, equals(3));
+        expect(result.end.column, equals(1));
+      });
+
+      test('with start at the end of a line', () {
+        final result = span.subspan(3, 5);
+        expect(result.text, equals('\nb'));
+        expect(result.start.offset, equals(8));
+        expect(result.start.line, equals(2));
+        expect(result.start.column, equals(3));
+      });
+
+      test('with start at the beginning of a line', () {
+        final result = span.subspan(4, 5);
+        expect(result.text, equals('b'));
+        expect(result.start.offset, equals(9));
+        expect(result.start.line, equals(3));
+        expect(result.start.column, equals(0));
+      });
+
+      test('with end at the end of a line', () {
+        final result = span.subspan(2, 3);
+        expect(result.text, equals('o'));
+        expect(result.end.offset, equals(8));
+        expect(result.end.line, equals(2));
+        expect(result.end.column, equals(3));
+      });
+
+      test('with end at the beginning of a line', () {
+        final result = span.subspan(2, 4);
+        expect(result.text, equals('o\n'));
+        expect(result.end.offset, equals(9));
+        expect(result.end.line, equals(3));
+        expect(result.end.column, equals(0));
+      });
+    });
+  });
+
   group('message()', () {
     test('prints the text being described', () {
       expect(span.message('oh no'), equals("""