Introduce span with line context

R=nweiz@google.com

Review URL: https://codereview.chromium.org//1028813002
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 195bcf8..6ab52c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+# 1.1.0
+
+* Added `SourceSpanWithContext`: a span that also includes the full line of text
+  that contains the span.
+
 # 1.0.3
 
 * Cleanup equality operator to accept any Object rather than just a
diff --git a/lib/source_span.dart b/lib/source_span.dart
index e9646b1..89b1650 100644
--- a/lib/source_span.dart
+++ b/lib/source_span.dart
@@ -9,3 +9,4 @@
 export "src/span.dart";
 export "src/span_exception.dart";
 export "src/span_mixin.dart";
+export "src/span_with_context.dart";
diff --git a/lib/src/file.dart b/lib/src/file.dart
index ed5f6a8..c7e5898 100644
--- a/lib/src/file.dart
+++ b/lib/src/file.dart
@@ -13,6 +13,7 @@
 import 'location.dart';
 import 'span.dart';
 import 'span_mixin.dart';
+import 'span_with_context.dart';
 import 'utils.dart';
 
 // Constants to determine end-of-lines.
@@ -183,7 +184,7 @@
 /// [FileSpan.union] will return a [FileSpan] if possible.
 ///
 /// A [FileSpan] can be created using [SourceFile.span].
-class FileSpan extends SourceSpanMixin {
+class FileSpan extends SourceSpanMixin implements SourceSpanWithContext {
   /// The [file] that [this] belongs to.
   final SourceFile file;
 
@@ -205,6 +206,12 @@
   FileLocation get end => new FileLocation._(file, _end);
   String get text => file.getText(_start, _end);
 
+  String get context {
+    var line = start.line;
+    return file.getText(file.getOffset(line),
+        line == file.lines - 1 ? null : file.getOffset(line + 1));
+  }
+
   FileSpan._(this.file, this._start, this._end) {
     if (_end < _start) {
       throw new ArgumentError('End $_end must come after start $_start.');
@@ -261,41 +268,4 @@
     var end = math.max(this._end, other._end);
     return new FileSpan._(file, start, end);
   }
-
-  String message(String message, {color}) {
-    if (color == true) color = colors.RED;
-    if (color == false) color = null;
-
-    var line = start.line;
-    var column = start.column;
-
-    var buffer = new StringBuffer();
-    buffer.write('line ${start.line + 1}, column ${start.column + 1}');
-    if (sourceUrl != null) buffer.write(' of ${p.prettyUri(sourceUrl)}');
-    buffer.write(': $message\n');
-
-    var textLine = file.getText(file.getOffset(line),
-        line == file.lines - 1 ? null : file.getOffset(line + 1));
-
-    column = math.min(column, textLine.length - 1);
-    var toColumn =
-        math.min(column + end.offset - start.offset, textLine.length);
-
-    if (color != null) {
-      buffer.write(textLine.substring(0, column));
-      buffer.write(color);
-      buffer.write(textLine.substring(column, toColumn));
-      buffer.write(colors.NONE);
-      buffer.write(textLine.substring(toColumn));
-    } else {
-      buffer.write(textLine);
-    }
-    if (!textLine.endsWith('\n')) buffer.write('\n');
-
-    buffer.write(' ' * column);
-    if (color != null) buffer.write(color);
-    buffer.write('^' * math.max(toColumn - column, 1));
-    if (color != null) buffer.write(colors.NONE);
-    return buffer.toString();
-  }
 }
diff --git a/lib/src/span_mixin.dart b/lib/src/span_mixin.dart
index 716e6e0..a93723f 100644
--- a/lib/src/span_mixin.dart
+++ b/lib/src/span_mixin.dart
@@ -4,10 +4,12 @@
 
 library source_span.span_mixin;
 
+import 'dart:math' as math;
 import 'package:path/path.dart' as p;
 
 import 'colors.dart' as colors;
 import 'span.dart';
+import 'span_with_context.dart';
 import 'utils.dart';
 
 /// A mixin for easily implementing [SourceSpan].
@@ -49,18 +51,49 @@
     if (color == true) color = colors.RED;
     if (color == false) color = null;
 
+    var line = start.line;
+    var column = start.column;
+
     var buffer = new StringBuffer();
-    buffer.write('line ${start.line + 1}, column ${start.column + 1}');
+    buffer.write('line ${line + 1}, column ${column + 1}');
     if (sourceUrl != null) buffer.write(' of ${p.prettyUri(sourceUrl)}');
     buffer.write(': $message');
-    if (length == 0) return buffer.toString();
 
+    if (length == 0 && this is! SourceSpanWithContext) return buffer.toString();
     buffer.write("\n");
-    var textLine = text.split("\n").first;
+
+    var textLine;
+    if (this is SourceSpanWithContext) {
+      var context = (this as SourceSpanWithContext).context;
+      var textIndex = context.indexOf(text.split('\n').first);
+      var lineStart = context.lastIndexOf('\n', textIndex);
+      if (lineStart != -1) {
+        buffer.write(context.substring(0, lineStart + 1));
+        context = context.substring(lineStart + 1);
+      }
+      var endIndex = context.indexOf('\n');
+      textLine = endIndex == -1 ? context : context.substring(0, endIndex + 1);
+      column = math.min(column, textLine.length - 1);
+    } else {
+      textLine = text.split("\n").first;
+      column = 0;
+    }
+
+    var toColumn =
+        math.min(column + end.offset - start.offset, textLine.length);
+    if (color != null) {
+      buffer.write(textLine.substring(0, column));
+      buffer.write(color);
+      buffer.write(textLine.substring(column, toColumn));
+      buffer.write(colors.NONE);
+      buffer.write(textLine.substring(toColumn));
+    } else {
+      buffer.write(textLine);
+    }
+    if (!textLine.endsWith('\n')) buffer.write('\n');
+    buffer.write(' ' * column);
     if (color != null) buffer.write(color);
-    buffer.write(textLine);
-    buffer.write("\n");
-    buffer.write('^' * textLine.length);
+    buffer.write('^' * math.max(toColumn - column, 1));
     if (color != null) buffer.write(colors.NONE);
     return buffer.toString();
   }
diff --git a/lib/src/span_with_context.dart b/lib/src/span_with_context.dart
new file mode 100644
index 0000000..4d279de
--- /dev/null
+++ b/lib/src/span_with_context.dart
@@ -0,0 +1,38 @@
+// 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 source_span.span;
+
+import 'location.dart';
+import 'span.dart';
+
+/// A class that describes a segment of source text with additional context.
+class SourceSpanWithContext extends SourceSpanBase {
+  /// Text around the span, which includes the line containing this span.
+  final String context;
+
+  /// Creates a new span from [start] to [end] (exclusive) containing [text], in
+  /// the given [context].
+  ///
+  /// [start] and [end] must have the same source URL and [start] must come
+  /// before [end]. [text] must have a number of characters equal to the
+  /// distance between [start] and [end]. [context] must contain [text], and
+  /// [text] should start at `start.column` from the beginning of a line in
+  /// [context].
+  SourceSpanWithContext(
+      SourceLocation start, SourceLocation end, String text, this.context)
+      : super(start, end, text) {
+    var index = context.indexOf(text);
+    if (index == -1) {
+      throw new ArgumentError(
+          'The context line "$context" must contain "$text".');
+    }
+
+    var beginningOfLine = context.lastIndexOf('\n', index) + 1;
+    if (start.column != index - beginningOfLine) {
+      throw new ArgumentError('The span text "$text" must start at '
+          'column ${start.column + 1} in a line within "$context".');
+    }
+  }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 03b290b..887c040 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,6 +1,5 @@
 name: source_span
-
-version: 1.0.3
+version: 1.1.0
 author: Dart Team <misc@dartlang.org>
 description: A library for identifying source spans and locations.
 homepage: http://github.com/dart-lang/source_span
@@ -9,4 +8,4 @@
 environment:
   sdk: '>=0.8.10+6 <2.0.0'
 dev_dependencies:
-  unittest: '>=0.9.0 <0.10.0'
+  unittest: '>=0.9.0 <0.12.0'
diff --git a/test/span_test.dart b/test/span_test.dart
index c62753b..39b0b94 100644
--- a/test/span_test.dart
+++ b/test/span_test.dart
@@ -36,6 +36,37 @@
       });
     });
 
+    group('for new SourceSpanWithContext()', () {
+      test('context must contain text', () {
+        var start = new SourceLocation(2);
+        var end = new SourceLocation(5);
+        expect(() => new SourceSpanWithContext(
+              start, end, "abc", "--axc--"), throwsArgumentError);
+      });
+
+      test('text starts at start.column in context', () {
+        var start = new SourceLocation(3);
+        var end = new SourceLocation(5);
+        expect(() => new SourceSpanWithContext(
+              start, end, "abc", "--abc--"), throwsArgumentError);
+      });
+
+      test('text starts at start.column of line in multi-line context', () {
+        var start = new SourceLocation(4, line: 55, column: 3);
+        var end = new SourceLocation(7, line: 55, column: 6);
+        expect(() => new SourceSpanWithContext(
+              start, end, "abc", "\n--abc--"), throwsArgumentError);
+        expect(() => new SourceSpanWithContext(
+              start, end, "abc", "\n----abc--"), throwsArgumentError);
+        expect(() => new SourceSpanWithContext(
+              start, end, "abc", "\n\n--abc--"), throwsArgumentError);
+
+        // However, these are valid:
+        new SourceSpanWithContext(start, end, "abc", "\n---abc--");
+        new SourceSpanWithContext(start, end, "abc", "\n\n---abc--");
+      });
+    });
+
     group('for union()', () {
       test('source URLs must match', () {
         var other = new SourceSpan(
@@ -178,15 +209,48 @@
       expect(span.message("oh no", color: true),
           equals("""
 line 1, column 6 of foo.dart: oh no
-${colors.RED}foo bar
-^^^^^^^${colors.NONE}"""));
+${colors.RED}foo bar${colors.NONE}
+${colors.RED}^^^^^^^${colors.NONE}"""));
     });
 
     test("uses the given color if it's passed", () {
       expect(span.message("oh no", color: colors.YELLOW), equals("""
 line 1, column 6 of foo.dart: oh no
-${colors.YELLOW}foo bar
-^^^^^^^${colors.NONE}"""));
+${colors.YELLOW}foo bar${colors.NONE}
+${colors.YELLOW}^^^^^^^${colors.NONE}"""));
+    });
+  });
+
+  group("message() with context", () {
+    var spanWithContext;
+    setUp(() {
+      spanWithContext = new SourceSpanWithContext(
+          new SourceLocation(5, sourceUrl: "foo.dart"),
+          new SourceLocation(12, sourceUrl: "foo.dart"),
+          "foo bar",
+          "-----foo bar-----");
+    });
+
+    test("underlines under the right column", () {
+      expect(spanWithContext.message("oh no", color: colors.YELLOW), equals("""
+line 1, column 6 of foo.dart: oh no
+-----${colors.YELLOW}foo bar${colors.NONE}-----
+     ${colors.YELLOW}^^^^^^^${colors.NONE}"""));
+    });
+
+    test("supports lines of preceeding context", () {
+      var span = new SourceSpanWithContext(
+          new SourceLocation(5, line: 3, column: 5, sourceUrl: "foo.dart"),
+          new SourceLocation(12, line: 3, column: 12, sourceUrl: "foo.dart"),
+          "foo bar",
+          "previous\nlines\n-----foo bar-----\nfollowing line\n");
+
+      expect(span.message("oh no", color: colors.YELLOW), equals("""
+line 4, column 6 of foo.dart: oh no
+previous
+lines
+-----${colors.YELLOW}foo bar${colors.NONE}-----
+     ${colors.YELLOW}^^^^^^^${colors.NONE}"""));
     });
   });
 
diff --git a/test/utils_test.dart b/test/utils_test.dart
index 3921111..a998847 100644
--- a/test/utils_test.dart
+++ b/test/utils_test.dart
@@ -48,4 +48,3 @@
   }
   return list.length;
 }
-