Merge pull request #25 from dart-lang/better-highlight

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53a700c..2248a39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,14 @@
+# 1.5.0
+
+* Improve the output of `SourceSpan.highlight()` and `SourceSpan.message()`:
+
+  * They now include line numbers.
+  * They will now print every line of a multiline span.
+  * They will now use Unicode box-drawing characters by default (this can be
+    controlled using [`term_glyph.ascii`][]).
+
+[`term_glyph.ascii`]: https://pub.dartlang.org/documentation/term_glyph/latest/term_glyph/ascii.html
+
 # 1.4.1
 
 * Set max SDK version to `<3.0.0`, and adjust other dependencies.
diff --git a/lib/src/colors.dart b/lib/src/colors.dart
index b9afab0..2931eea 100644
--- a/lib/src/colors.dart
+++ b/lib/src/colors.dart
@@ -7,4 +7,6 @@
 
 const String YELLOW = '\u001b[33m';
 
+const String BLUE = '\u001b[34m';
+
 const String NONE = '\u001b[0m';
diff --git a/lib/src/highlighter.dart b/lib/src/highlighter.dart
new file mode 100644
index 0000000..a05412a
--- /dev/null
+++ b/lib/src/highlighter.dart
@@ -0,0 +1,362 @@
+// Copyright (c) 2018, 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 'dart:math' as math;
+
+import 'package:charcode/charcode.dart';
+import 'package:term_glyph/term_glyph.dart' as glyph;
+
+import 'colors.dart' as colors;
+import 'location.dart';
+import 'span.dart';
+import 'span_with_context.dart';
+import 'utils.dart';
+
+/// A class for writing a chunk of text with a particular span highlighted.
+class Highlighter {
+  /// The span to highlight.
+  final SourceSpanWithContext _span;
+
+  /// The color to highlight [_span] within its context, or `null` if the span
+  /// should not be colored.
+  final String _color;
+
+  /// Whether [_span] covers multiple lines.
+  final bool _multiline;
+
+  /// The number of characters before the bar in the sidebar.
+  final int _paddingBeforeSidebar;
+
+  // The number of characters between the bar in the sidebar and the text
+  // being highlighted.
+  int get _paddingAfterSidebar =>
+      // This is just a space for a single-line span, but for a multi-line span
+      // needs to accommodate " | ".
+      _multiline ? 3 : 1;
+
+  /// The buffer to which to write the result.
+  final _buffer = new StringBuffer();
+
+  /// The number of spaces to render for hard tabs that appear in `_span.text`.
+  ///
+  /// We don't want to render raw tabs, because they'll mess up our character
+  /// alignment.
+  static const _spacesPerTab = 4;
+
+  /// Creats a [Highlighter] that will return a message associated with [span]
+  /// when [write] is called.
+  ///
+  /// [color] may either be a [String], a [bool], or `null`. If it's a string,
+  /// it indicates an [ANSI terminal color
+  /// escape](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors) that should
+  /// be used to highlight the span's text (for example, `"\u001b[31m"` will
+  /// color red). If it's `true`, it indicates that the text should be
+  /// highlighted using the default color. If it's `false` or `null`, it
+  /// indicates that the text shouldn't be highlighted.
+  factory Highlighter(SourceSpan span, {color}) {
+    if (color == true) color = colors.RED;
+    if (color == false) color = null;
+
+    // Normalize [span] to ensure that it's a [SourceSpanWithContext] whose
+    // context actually contains its text at the expected column. If it's not,
+    // adjust the start and end locations' line and column fields so that the
+    // highlighter can assume they match up with the context.
+    SourceSpanWithContext newSpan;
+    if (span is SourceSpanWithContext &&
+        findLineStart(span.context, span.text, span.start.column) != null) {
+      newSpan = span;
+    } else {
+      newSpan = new SourceSpanWithContext(
+          new SourceLocation(span.start.offset,
+              sourceUrl: span.sourceUrl, line: 0, column: 0),
+          new SourceLocation(span.end.offset,
+              sourceUrl: span.sourceUrl,
+              line: countCodeUnits(span.text, $lf),
+              column: _lastColumn(span.text)),
+          span.text,
+          span.text);
+    }
+
+    // Normalize [span] to remove a trailing newline from `span.context`. If
+    // necessary, also adjust `span.end` so that it doesn't point past where the
+    // trailing newline used to be.
+    if (newSpan.context.endsWith("\n")) {
+      var context = newSpan.context.substring(0, newSpan.context.length - 1);
+
+      var text = newSpan.text;
+      var start = newSpan.start;
+      var end = newSpan.end;
+      if (newSpan.text.endsWith("\n") && _isTextAtEndOfContext(newSpan)) {
+        text = newSpan.text.substring(0, newSpan.text.length - 1);
+        end = new SourceLocation(newSpan.end.offset - 1,
+            sourceUrl: newSpan.sourceUrl,
+            line: newSpan.end.line - 1,
+            column: _lastColumn(text));
+        start =
+            newSpan.start.offset == newSpan.end.offset ? end : newSpan.start;
+      }
+      newSpan = new SourceSpanWithContext(start, end, text, context);
+    }
+
+    return new Highlighter._(newSpan, color);
+  }
+
+  /// Returns the (0-based) column number of the last column of the last line in [text].
+  static int _lastColumn(String text) =>
+      text.length - text.lastIndexOf("\n") + 1;
+
+  /// Returns whether [span]'s text runs all the way to the end of its context.
+  static bool _isTextAtEndOfContext(SourceSpanWithContext span) =>
+      findLineStart(span.context, span.text, span.start.column) +
+          span.start.column +
+          span.length ==
+      span.context.length;
+
+  Highlighter._(this._span, this._color)
+      : _multiline = _span.start.line != _span.end.line,
+        // In a purely mathematical world, floor(log10(n)) would give the number of
+        // digits in n, but floating point errors render that unreliable in
+        // practice.
+        _paddingBeforeSidebar = _span.end.line.toString().length + 1;
+
+  /// Returns the highlighted span text.
+  ///
+  /// This method should only be called once.
+  String highlight() {
+    _writeSidebar(end: glyph.downEnd);
+    _buffer.writeln();
+
+    // If [_span.context] contains lines prior to the one [_span.text] appears
+    // on, write those first.
+    var lineStart =
+        findLineStart(_span.context, _span.text, _span.start.column);
+    assert(lineStart != null); // enforced by [new Highlighter]
+
+    var context = _span.context;
+    if (lineStart > 0) {
+      // Take a substring to one character *before* [lineStart] because
+      // [findLineStart] is guaranteed to return a position immediately after a
+      // newline. Including that newline would add an extra empty line to the
+      // end of [lines].
+      var lines = context.substring(0, lineStart - 1).split("\n");
+      var lineNumber = _span.start.line - lines.length;
+      for (var line in lines) {
+        _writeSidebar(line: lineNumber);
+        _buffer.write(" " * _paddingAfterSidebar);
+        _writeText(line);
+        _buffer.writeln();
+        lineNumber++;
+      }
+      context = context.substring(lineStart);
+    }
+
+    var lines = context.split("\n");
+
+    // Trim a trailing newline so we don't add an empty line to the end of the
+    // highlight.
+    if (lines.last.isEmpty && lines.length > 1) lines.removeLast();
+
+    _writeFirstLine(lines.first);
+    var lastLineIndex = _span.end.line - _span.start.line;
+    if (_multiline) {
+      _writeIntermediateLines(lines.skip(1).take(lastLineIndex - 1));
+      _writeLastLine(lines[lastLineIndex]);
+    }
+    _writeTrailingLines(lines.skip(lastLineIndex + 1));
+
+    _writeSidebar(end: glyph.upEnd);
+
+    return _buffer.toString();
+  }
+
+  // Writes the first (and possibly only) line highlighted by the span.
+  void _writeFirstLine(String line) {
+    _writeSidebar(line: _span.start.line);
+
+    var startColumn = math.min(_span.start.column, line.length);
+    var endColumn = math.min(
+        startColumn + _span.end.offset - _span.start.offset, line.length);
+    var textBefore = line.substring(0, startColumn);
+
+    // If the span covers the entire first line other than initial whitespace,
+    // don't bother pointing out exactly where it begins.
+    if (_multiline && _isOnlyWhitespace(textBefore)) {
+      _buffer.write(" ");
+      _colorize(() {
+        _buffer.write(glyph.glyphOrAscii("┌", "/"));
+        _buffer.write(" ");
+        _writeText(line);
+      });
+      _buffer.writeln();
+      return;
+    }
+
+    _buffer.write(" " * _paddingAfterSidebar);
+    _writeText(textBefore);
+    var textInside = line.substring(startColumn, endColumn);
+    _colorize(() => _writeText(textInside));
+    _writeText(line.substring(endColumn));
+    _buffer.writeln();
+
+    // Adjust the start and end column to account for any tabs that were
+    // converted to spaces.
+    var tabsBefore = _countTabs(textBefore);
+    var tabsInside = _countTabs(textInside);
+    startColumn = startColumn + tabsBefore * (_spacesPerTab - 1);
+    endColumn = endColumn + (tabsBefore + tabsInside) * (_spacesPerTab - 1);
+
+    // Write the highlight for the first line. This is a series of carets for a
+    // single-line span, and a pointer to the beginning of a multi-line span.
+    _writeSidebar();
+    if (_multiline) {
+      _buffer.write(" ");
+      _colorize(() {
+        _buffer.write(glyph.topLeftCorner);
+        _buffer.write(glyph.horizontalLine * (startColumn + 1));
+        _buffer.write("^");
+      });
+    } else {
+      _buffer.write(" " * (startColumn + 1));
+      _colorize(
+          () => _buffer.write("^" * math.max(endColumn - startColumn, 1)));
+    }
+    _buffer.writeln();
+  }
+
+  /// Writes the lines between the first and last lines highlighted by the span.
+  void _writeIntermediateLines(Iterable<String> lines) {
+    assert(_multiline);
+
+    // +1 because the first line was already written.
+    var lineNumber = _span.start.line + 1;
+    for (var line in lines) {
+      _writeSidebar(line: lineNumber);
+
+      _buffer.write(" ");
+      _colorize(() {
+        _buffer.write(glyph.verticalLine);
+        _buffer.write(" ");
+        _writeText(line);
+      });
+      _buffer.writeln();
+
+      lineNumber++;
+    }
+  }
+
+  // Writes the last line highlighted by the span.
+  void _writeLastLine(String line) {
+    assert(_multiline);
+
+    _writeSidebar(line: _span.end.line);
+
+    var endColumn = math.min(_span.end.column, line.length);
+
+    // If the span covers the entire last line, don't bother pointing out
+    // exactly where it ends.
+    if (_multiline && endColumn == line.length) {
+      _buffer.write(" ");
+      _colorize(() {
+        _buffer.write(glyph.glyphOrAscii("└", "\\"));
+        _buffer.write(" ");
+        _writeText(line);
+      });
+      _buffer.writeln();
+      return;
+    }
+
+    _buffer.write(" ");
+    var textInside = line.substring(0, endColumn);
+    _colorize(() {
+      _buffer.write(glyph.verticalLine);
+      _buffer.write(" ");
+      _writeText(textInside);
+    });
+    _writeText(line.substring(endColumn));
+    _buffer.writeln();
+
+    // Adjust the end column to account for any tabs that were converted to
+    // spaces.
+    var tabsInside = _countTabs(textInside);
+    endColumn = endColumn + tabsInside * (_spacesPerTab - 1);
+
+    // Write the highlight for the final line, which is an arrow pointing to the
+    // end of the span.
+    _writeSidebar();
+    _buffer.write(" ");
+    _colorize(() {
+      _buffer.write(glyph.bottomLeftCorner);
+      _buffer.write(glyph.horizontalLine * endColumn);
+      _buffer.write("^");
+    });
+    _buffer.writeln();
+  }
+
+  /// Writes lines that appear in the context string but come after the span.
+  void _writeTrailingLines(Iterable<String> lines) {
+    // +1 because this comes after any lines covered by the span.
+    var lineNumber = _span.end.line + 1;
+    for (var line in lines) {
+      _writeSidebar(line: lineNumber);
+      _buffer.write(" " * _paddingAfterSidebar);
+      _writeText(line);
+      _buffer.writeln();
+      lineNumber++;
+    }
+  }
+
+  /// Writes a snippet from the source text, converting hard tab characters into
+  /// plain indentation.
+  void _writeText(String text) {
+    for (var char in text.codeUnits) {
+      if (char == $tab) {
+        _buffer.write(" " * _spacesPerTab);
+      } else {
+        _buffer.writeCharCode(char);
+      }
+    }
+  }
+
+  // Writes a sidebar to [buffer] that includes [line] as the line number if
+  // given and writes [end] at the end (defaults to [glyphs.verticalLine]).
+  void _writeSidebar({int line, String end}) {
+    _colorize(() {
+      if (line != null) {
+        // Add 1 to line to convert from computer-friendly 0-indexed line
+        // numbers to human-friendly 1-indexed line numbers.
+        _buffer.write((line + 1).toString().padRight(_paddingBeforeSidebar));
+      } else {
+        _buffer.write(" " * _paddingBeforeSidebar);
+      }
+      _buffer.write(end ?? glyph.verticalLine);
+    }, color: colors.BLUE);
+  }
+
+  /// Returns the number of hard tabs in [text].
+  int _countTabs(String text) {
+    var count = 0;
+    for (var char in text.codeUnits) {
+      if (char == $tab) count++;
+    }
+    return count;
+  }
+
+  /// Returns whether [text] contains only space or tab characters.
+  bool _isOnlyWhitespace(String text) {
+    for (var char in text.codeUnits) {
+      if (char != $space && char != $tab) return false;
+    }
+    return true;
+  }
+
+  /// Colors all text written to [_buffer] during [callback], if colorization is
+  /// enabled.
+  ///
+  /// If [color] is passed, it's used as the color; otherwise, [_color] is used.
+  void _colorize(void callback(), {String color}) {
+    if (_color != null) _buffer.write(color ?? _color);
+    callback();
+    if (_color != null) _buffer.write(colors.NONE);
+  }
+}
diff --git a/lib/src/span.dart b/lib/src/span.dart
index 19655a5..57ffe79 100644
--- a/lib/src/span.dart
+++ b/lib/src/span.dart
@@ -2,6 +2,8 @@
 // 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:term_glyph/term_glyph.dart' as glyph;
+
 import 'location.dart';
 import 'span_mixin.dart';
 
@@ -54,6 +56,10 @@
   /// color red). If it's `true`, it indicates that the text should be
   /// highlighted using the default color. If it's `false` or `null`, it
   /// indicates that the text shouldn't be highlighted.
+  ///
+  /// This uses the full range of Unicode characters to highlight the source
+  /// span if [glyph.ascii] is `false` (the default), but only uses ASCII
+  /// characters if it's `true`.
   String message(String message, {color});
 
   /// Prints the text associated with this span in a user-friendly way.
@@ -69,6 +75,10 @@
   /// color red). If it's `true`, it indicates that the text should be
   /// highlighted using the default color. If it's `false` or `null`, it
   /// indicates that the text shouldn't be highlighted.
+  ///
+  /// This uses the full range of Unicode characters to highlight the source
+  /// span if [glyph.ascii] is `false` (the default), but only uses ASCII
+  /// characters if it's `true`.
   String highlight({color});
 }
 
diff --git a/lib/src/span_mixin.dart b/lib/src/span_mixin.dart
index 1f7799d..d8ac8f2 100644
--- a/lib/src/span_mixin.dart
+++ b/lib/src/span_mixin.dart
@@ -2,12 +2,9 @@
 // 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 'dart:math' as math;
-
-import 'package:charcode/charcode.dart';
 import 'package:path/path.dart' as p;
 
-import 'colors.dart' as colors;
+import 'highlighter.dart';
 import 'span.dart';
 import 'span_with_context.dart';
 import 'utils.dart';
@@ -63,54 +60,8 @@
   }
 
   String highlight({color}) {
-    if (color == true) color = colors.RED;
-    if (color == false) color = null;
-
-    var column = start.column;
-    var buffer = new StringBuffer();
-    String textLine;
-    if (this is SourceSpanWithContext) {
-      var context = (this as SourceSpanWithContext).context;
-      var lineStart = findLineStart(context, text, column);
-      if (lineStart != null && lineStart > 0) {
-        buffer.write(context.substring(0, lineStart));
-        context = context.substring(lineStart);
-      }
-      var endIndex = context.indexOf('\n');
-      textLine = endIndex == -1 ? context : context.substring(0, endIndex + 1);
-      column = math.min(column, textLine.length);
-    } else if (length == 0) {
-      return "";
-    } 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');
-
-    for (var i = 0; i < column; i++) {
-      if (textLine.codeUnitAt(i) == $tab) {
-        buffer.writeCharCode($tab);
-      } else {
-        buffer.writeCharCode($space);
-      }
-    }
-
-    if (color != null) buffer.write(color);
-    buffer.write('^' * math.max(toColumn - column, 1));
-    if (color != null) buffer.write(colors.NONE);
-    return buffer.toString();
+    if (this is! SourceSpanWithContext && this.length == 0) return "";
+    return new Highlighter(this, color: color).highlight();
   }
 
   bool operator ==(other) =>
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 6938547..228b240 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -12,19 +12,43 @@
 Comparable max(Comparable obj1, Comparable obj2) =>
     obj1.compareTo(obj2) > 0 ? obj1 : obj2;
 
+/// Returns the number of instances of [codeUnit] in [string].
+int countCodeUnits(String string, int codeUnit) {
+  var count = 0;
+  for (var codeUnitToCheck in string.codeUnits) {
+    if (codeUnitToCheck == codeUnit) count++;
+  }
+  return count;
+}
+
 /// Finds a line in [context] containing [text] at the specified [column].
 ///
 /// Returns the index in [context] where that line begins, or null if none
 /// exists.
 int findLineStart(String context, String text, int column) {
-  var isEmpty = text == '';
+  // If the text is empty, we just want to find the first line that has at least
+  // [column] characters.
+  if (text.isEmpty) {
+    var beginningOfLine = 0;
+    while (true) {
+      var index = context.indexOf("\n", beginningOfLine);
+      if (index == -1) {
+        return context.length - beginningOfLine >= column
+            ? beginningOfLine
+            : null;
+      }
+
+      if (index - beginningOfLine >= column) return beginningOfLine;
+      beginningOfLine = index + 1;
+    }
+  }
+
   var index = context.indexOf(text);
   while (index != -1) {
-    var lineStart = context.lastIndexOf('\n', index) + 1;
+    // Start looking before [index] in case [text] starts with a newline.
+    var lineStart = index == 0 ? 0 : context.lastIndexOf('\n', index - 1) + 1;
     var textColumn = index - lineStart;
-    if (column == textColumn || (isEmpty && column == textColumn + 1)) {
-      return lineStart;
-    }
+    if (column == textColumn) return lineStart;
     index = context.indexOf(text, index + 1);
   }
   return null;
diff --git a/pubspec.yaml b/pubspec.yaml
index 1d53210..ebd3a72 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: source_span
-version: 1.4.1
+version: 1.5.0
 
 description: A library for identifying source spans and locations.
 author: Dart Team <misc@dartlang.org>
@@ -11,6 +11,7 @@
 dependencies:
   charcode: ^1.0.0
   path: '>=1.2.0 <2.0.0'
+  term_glyph: ^1.0.0
 
 dev_dependencies:
   test: '>=0.12.0 <2.0.0'
diff --git a/test/highlight_test.dart b/test/highlight_test.dart
index ac8334b..19f8ca8 100644
--- a/test/highlight_test.dart
+++ b/test/highlight_test.dart
@@ -2,11 +2,23 @@
 // 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:term_glyph/term_glyph.dart' as glyph;
 import 'package:test/test.dart';
+
 import 'package:source_span/source_span.dart';
 import 'package:source_span/src/colors.dart' as colors;
 
 main() {
+  bool oldAscii;
+  setUpAll(() {
+    oldAscii = glyph.ascii;
+    glyph.ascii = true;
+  });
+
+  tearDownAll(() {
+    glyph.ascii = oldAscii;
+  });
+
   var file;
   setUp(() {
     file = new SourceFile.fromString("""
@@ -18,101 +30,341 @@
 
   test("points to the span in the source", () {
     expect(file.span(4, 7).highlight(), equals("""
-foo bar baz
-    ^^^"""));
+  ,
+1 | foo bar baz
+  |     ^^^
+  '"""));
   });
 
   test("gracefully handles a missing source URL", () {
     var span = new SourceFile.fromString("foo bar baz").span(4, 7);
     expect(span.highlight(), equals("""
-foo bar baz
-    ^^^"""));
-  });
-
-  test("highlights the first line of a multiline span", () {
-    expect(file.span(4, 20).highlight(), equals("""
-foo bar baz
-    ^^^^^^^^"""));
+  ,
+1 | foo bar baz
+  |     ^^^
+  '"""));
   });
 
   test("works for a point span", () {
     expect(file.location(4).pointSpan().highlight(), equals("""
-foo bar baz
-    ^"""));
+  ,
+1 | foo bar baz
+  |     ^
+  '"""));
   });
 
   test("works for a point span at the end of a line", () {
     expect(file.location(11).pointSpan().highlight(), equals("""
-foo bar baz
-           ^"""));
+  ,
+1 | foo bar baz
+  |            ^
+  '"""));
   });
 
   test("works for a point span at the end of the file", () {
     expect(file.location(38).pointSpan().highlight(), equals("""
-zip zap zop
-           ^"""));
+  ,
+3 | zip zap zop
+  |            ^
+  '"""));
   });
 
   test("works for a point span at the end of the file with no trailing newline",
       () {
     file = new SourceFile.fromString("zip zap zop");
     expect(file.location(11).pointSpan().highlight(), equals("""
-zip zap zop
-           ^"""));
+  ,
+1 | zip zap zop
+  |            ^
+  '"""));
   });
 
   test("works for a point span in an empty file", () {
     expect(new SourceFile.fromString("").location(0).pointSpan().highlight(),
         equals("""
-
-^"""));
+  ,
+1 | 
+  | ^
+  '"""));
   });
 
   test("works for a single-line file without a newline", () {
     expect(
         new SourceFile.fromString("foo bar").span(0, 7).highlight(), equals("""
-foo bar
-^^^^^^^"""));
+  ,
+1 | foo bar
+  | ^^^^^^^
+  '"""));
   });
 
-  test("emits tabs for tabs", () {
-    expect(new SourceFile.fromString(" \t \t\tfoo bar").span(5, 8).highlight(),
-        equals("""
- \t \t\tfoo bar
- \t \t\t^^^"""));
+  group("with a multiline span", () {
+    test("highlights the middle of the first and last lines", () {
+      expect(file.span(4, 34).highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz bang boom
+3 | | zip zap zop
+  | '-------^
+  '"""));
+    });
+
+    test("works when it begins at the end of a line", () {
+      expect(file.span(11, 34).highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,------------^
+2 | | whiz bang boom
+3 | | zip zap zop
+  | '-------^
+  '"""));
+    });
+
+    test("works when it ends at the beginning of a line", () {
+      expect(file.span(4, 28).highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz bang boom
+3 | | zip zap zop
+  | '-^
+  '"""));
+    });
+
+    test("highlights the full first line", () {
+      expect(file.span(0, 34).highlight(), equals("""
+  ,
+1 | / foo bar baz
+2 | | whiz bang boom
+3 | | zip zap zop
+  | '-------^
+  '"""));
+    });
+
+    test("highlights the full first line even if it's indented", () {
+      var file = new SourceFile.fromString("""
+  foo bar baz
+  whiz bang boom
+  zip zap zop
+""");
+
+      expect(file.span(2, 38).highlight(), equals("""
+  ,
+1 | /   foo bar baz
+2 | |   whiz bang boom
+3 | |   zip zap zop
+  | '-------^
+  '"""));
+    });
+
+    test("highlights the full last line", () {
+      expect(file.span(4, 26).highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | \\ whiz bang boom
+  '"""));
+    });
+
+    test("highlights the full last line at the end of the file", () {
+      expect(file.span(4, 39).highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz bang boom
+3 | \\ zip zap zop
+  '"""));
+    });
+
+    test("highlights the full last line with no trailing newline", () {
+      var file = new SourceFile.fromString("""
+foo bar baz
+whiz bang boom
+zip zap zop""");
+
+      expect(file.span(4, 38).highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz bang boom
+3 | \\ zip zap zop
+  '"""));
+    });
   });
 
-  test("supports lines of preceding context", () {
+  group("prints tabs as spaces", () {
+    group("in a single-line span", () {
+      test("before the highlighted section", () {
+        var span = new SourceFile.fromString("foo\tbar baz").span(4, 7);
+
+        expect(span.highlight(), equals("""
+  ,
+1 | foo    bar baz
+  |        ^^^
+  '"""));
+      });
+
+      test("within the highlighted section", () {
+        var span = new SourceFile.fromString("foo bar\tbaz bang").span(4, 11);
+
+        expect(span.highlight(), equals("""
+  ,
+1 | foo bar    baz bang
+  |     ^^^^^^^^^^
+  '"""));
+      });
+
+      test("after the highlighted section", () {
+        var span = new SourceFile.fromString("foo bar\tbaz").span(4, 7);
+
+        expect(span.highlight(), equals("""
+  ,
+1 | foo bar    baz
+  |     ^^^
+  '"""));
+      });
+    });
+
+    group("in a multi-line span", () {
+      test("before the highlighted section", () {
+        var span = new SourceFile.fromString("""
+foo\tbar baz
+whiz bang boom
+""").span(4, 21);
+
+        expect(span.highlight(), equals("""
+  ,
+1 |   foo    bar baz
+  | ,--------^
+2 | | whiz bang boom
+  | '---------^
+  '"""));
+      });
+
+      test("within the first highlighted line", () {
+        var span = new SourceFile.fromString("""
+foo bar\tbaz
+whiz bang boom
+""").span(4, 21);
+
+        expect(span.highlight(), equals("""
+  ,
+1 |   foo bar    baz
+  | ,-----^
+2 | | whiz bang boom
+  | '---------^
+  '"""));
+      });
+
+      test("within a middle highlighted line", () {
+        var span = new SourceFile.fromString("""
+foo bar baz
+whiz\tbang boom
+zip zap zop
+""").span(4, 34);
+
+        expect(span.highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz    bang boom
+3 | | zip zap zop
+  | '-------^
+  '"""));
+      });
+
+      test("within the last highlighted line", () {
+        var span = new SourceFile.fromString("""
+foo bar baz
+whiz\tbang boom
+""").span(4, 21);
+
+        expect(span.highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz    bang boom
+  | '------------^
+  '"""));
+      });
+
+      test("after the highlighted section", () {
+        var span = new SourceFile.fromString("""
+foo bar baz
+whiz bang\tboom
+""").span(4, 21);
+
+        expect(span.highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz bang    boom
+  | '---------^
+  '"""));
+      });
+    });
+  });
+
+  test("supports lines of preceding and following 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"),
+        new SourceLocation(5, line: 2, column: 5, sourceUrl: "foo.dart"),
+        new SourceLocation(12, line: 2, column: 12, sourceUrl: "foo.dart"),
         "foo bar",
         "previous\nlines\n-----foo bar-----\nfollowing line\n");
 
     expect(span.highlight(color: colors.YELLOW), equals("""
-previous
-lines
------${colors.YELLOW}foo bar${colors.NONE}-----
-     ${colors.YELLOW}^^^^^^^${colors.NONE}"""));
+${colors.BLUE}  ,${colors.NONE}
+${colors.BLUE}1 |${colors.NONE} previous
+${colors.BLUE}2 |${colors.NONE} lines
+${colors.BLUE}3 |${colors.NONE} -----${colors.YELLOW}foo bar${colors.NONE}-----
+${colors.BLUE}  |${colors.NONE}      ${colors.YELLOW}^^^^^^^${colors.NONE}
+${colors.BLUE}4 |${colors.NONE} following line
+${colors.BLUE}  '${colors.NONE}"""));
   });
 
   group("colors", () {
     test("doesn't colorize if color is false", () {
       expect(file.span(4, 7).highlight(color: false), equals("""
-foo bar baz
-    ^^^"""));
+  ,
+1 | foo bar baz
+  |     ^^^
+  '"""));
     });
 
     test("colorizes if color is true", () {
       expect(file.span(4, 7).highlight(color: true), equals("""
-foo ${colors.RED}bar${colors.NONE} baz
-    ${colors.RED}^^^${colors.NONE}"""));
+${colors.BLUE}  ,${colors.NONE}
+${colors.BLUE}1 |${colors.NONE} foo ${colors.RED}bar${colors.NONE} baz
+${colors.BLUE}  |${colors.NONE}     ${colors.RED}^^^${colors.NONE}
+${colors.BLUE}  '${colors.NONE}"""));
     });
 
     test("uses the given color if it's passed", () {
       expect(file.span(4, 7).highlight(color: colors.YELLOW), equals("""
-foo ${colors.YELLOW}bar${colors.NONE} baz
-    ${colors.YELLOW}^^^${colors.NONE}"""));
+${colors.BLUE}  ,${colors.NONE}
+${colors.BLUE}1 |${colors.NONE} foo ${colors.YELLOW}bar${colors.NONE} baz
+${colors.BLUE}  |${colors.NONE}     ${colors.YELLOW}^^^${colors.NONE}
+${colors.BLUE}  '${colors.NONE}"""));
+    });
+
+    test("colorizes a multiline span", () {
+      expect(file.span(4, 34).highlight(color: true), equals("""
+${colors.BLUE}  ,${colors.NONE}
+${colors.BLUE}1 |${colors.NONE}   foo ${colors.RED}bar baz${colors.NONE}
+${colors.BLUE}  |${colors.NONE} ${colors.RED},-----^${colors.NONE}
+${colors.BLUE}2 |${colors.NONE} ${colors.RED}| whiz bang boom${colors.NONE}
+${colors.BLUE}3 |${colors.NONE} ${colors.RED}| zip zap${colors.NONE} zop
+${colors.BLUE}  |${colors.NONE} ${colors.RED}'-------^${colors.NONE}
+${colors.BLUE}  '${colors.NONE}"""));
+    });
+
+    test("colorizes a multiline span that highlights full lines", () {
+      expect(file.span(0, 39).highlight(color: true), equals("""
+${colors.BLUE}  ,${colors.NONE}
+${colors.BLUE}1 |${colors.NONE} ${colors.RED}/ foo bar baz${colors.NONE}
+${colors.BLUE}2 |${colors.NONE} ${colors.RED}| whiz bang boom${colors.NONE}
+${colors.BLUE}3 |${colors.NONE} ${colors.RED}\\ zip zap zop${colors.NONE}
+${colors.BLUE}  '${colors.NONE}"""));
     });
   });
 }
diff --git a/test/span_test.dart b/test/span_test.dart
index b7637cf..9989516 100644
--- a/test/span_test.dart
+++ b/test/span_test.dart
@@ -3,10 +3,22 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'package:test/test.dart';
+import 'package:term_glyph/term_glyph.dart' as glyph;
+
 import 'package:source_span/source_span.dart';
 import 'package:source_span/src/colors.dart' as colors;
 
 main() {
+  bool oldAscii;
+  setUpAll(() {
+    oldAscii = glyph.ascii;
+    glyph.ascii = true;
+  });
+
+  tearDownAll(() {
+    glyph.ascii = oldAscii;
+  });
+
   var span;
   setUp(() {
     span = new SourceSpan(new SourceLocation(5, sourceUrl: "foo.dart"),
@@ -181,8 +193,10 @@
     test("prints the text being described", () {
       expect(span.message("oh no"), equals("""
 line 1, column 6 of foo.dart: oh no
-foo bar
-^^^^^^^"""));
+  ,
+1 | foo bar
+  | ^^^^^^^
+  '"""));
     });
 
     test("gracefully handles a missing source URL", () {
@@ -191,8 +205,10 @@
 
       expect(span.message("oh no"), equalsIgnoringWhitespace("""
 line 1, column 6: oh no
-foo bar
-^^^^^^^"""));
+  ,
+1 | foo bar
+  | ^^^^^^^
+  '"""));
     });
 
     test("gracefully handles empty text", () {
@@ -205,22 +221,28 @@
     test("doesn't colorize if color is false", () {
       expect(span.message("oh no", color: false), equals("""
 line 1, column 6 of foo.dart: oh no
-foo bar
-^^^^^^^"""));
+  ,
+1 | foo bar
+  | ^^^^^^^
+  '"""));
     });
 
     test("colorizes if color is true", () {
       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}^^^^^^^${colors.NONE}"""));
+${colors.BLUE}  ,${colors.NONE}
+${colors.BLUE}1 |${colors.NONE} ${colors.RED}foo bar${colors.NONE}
+${colors.BLUE}  |${colors.NONE} ${colors.RED}^^^^^^^${colors.NONE}
+${colors.BLUE}  '${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}^^^^^^^${colors.NONE}"""));
+${colors.BLUE}  ,${colors.NONE}
+${colors.BLUE}1 |${colors.NONE} ${colors.YELLOW}foo bar${colors.NONE}
+${colors.BLUE}  |${colors.NONE} ${colors.YELLOW}^^^^^^^${colors.NONE}
+${colors.BLUE}  '${colors.NONE}"""));
     });
 
     test("with context, underlines the right column", () {
@@ -232,8 +254,10 @@
 
       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}"""));
+${colors.BLUE}  ,${colors.NONE}
+${colors.BLUE}1 |${colors.NONE} -----${colors.YELLOW}foo bar${colors.NONE}-----
+${colors.BLUE}  |${colors.NONE}      ${colors.YELLOW}^^^^^^^${colors.NONE}
+${colors.BLUE}  '${colors.NONE}"""));
     });
   });
 
diff --git a/test/utils_test.dart b/test/utils_test.dart
index 2a86cc0..a8146e3 100644
--- a/test/utils_test.dart
+++ b/test/utils_test.dart
@@ -32,12 +32,23 @@
       expect(index, 4);
     });
 
+    test('empty text in empty context', () {
+      var index = findLineStart('', '', 0);
+      expect(index, 0);
+    });
+
     test('found on the first line', () {
       var context = '0\n2\n45\n';
       var index = findLineStart(context, '0', 0);
       expect(index, 0);
     });
 
+    test('finds text that starts with a newline', () {
+      var context = '0\n2\n45\n';
+      var index = findLineStart(context, '\n2', 1);
+      expect(index, 0);
+    });
+
     test('not found', () {
       var context = '0\n2\n45\n';
       var index = findLineStart(context, '0', 1);