Merge pull request #20 from bcko/patch-1

Update .gitignore to new `dart_tool` pub cache
diff --git a/.analysis_options b/.analysis_options
deleted file mode 100644
index a10d4c5..0000000
--- a/.analysis_options
+++ /dev/null
@@ -1,2 +0,0 @@
-analyzer:
-  strong-mode: true
diff --git a/.travis.yml b/.travis.yml
index 44a0542..18267fc 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,7 +2,7 @@
 
 dart:
   - dev
-  - stable
+  - 2.0.0
 
 dart_task:
   - test: --platform vm,chrome
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68eafaa..ddb4ff0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,54 @@
+# 1.5.5
+
+* Fix a bug where `FileSpan.highlight()` would crash for spans that covered a
+  trailing newline and a single additional empty line.
+
+# 1.5.4
+
+* `FileSpan.highlight()` now properly highlights point spans at the beginning of
+  lines.
+
+# 1.5.3
+
+* Fix an edge case where `FileSpan.highlight()` would put the highlight
+  indicator in the wrong position when highlighting a point span after the end
+  of a file.
+
+# 1.5.2
+
+* `SourceFile.span()` now goes to the end of the file by default, rather than
+  ending one character before the end of the file. This matches the documented
+  behavior.
+
+* `FileSpan.context` now includes the full line on which the span appears for
+  empty spans at the beginning and end of lines.
+
+* Fix an edge case where `FileSpan.highlight()` could crash when highlighting a
+  span that ended with an empty line.
+
+# 1.5.1
+
+* Produce better source span highlights for multi-line spans that cover the
+  entire last line of the span, including the newline.
+
+* Produce better source span highlights for spans that contain Windows-style
+  newlines.
+
+# 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.
+
 # 1.4.0
 
 * The `new SourceFile()` constructor is deprecated. This constructed a source
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/file.dart b/lib/src/file.dart
index 6154e13..27dae5d 100644
--- a/lib/src/file.dart
+++ b/lib/src/file.dart
@@ -88,7 +88,7 @@
   ///
   /// If [end] isn't passed, it defaults to the end of the file.
   FileSpan span(int start, [int end]) {
-    if (end == null) end = length - 1;
+    if (end == null) end = length;
     return new _FileSpan(this, start, end);
   }
 
@@ -291,8 +291,39 @@
   FileLocation get start => new FileLocation._(file, _start);
   FileLocation get end => new FileLocation._(file, _end);
   String get text => file.getText(_start, _end);
-  String get context => file.getText(file.getOffset(start.line),
-      end.line == file.lines - 1 ? null : file.getOffset(end.line + 1));
+
+  String get context {
+    var endLine = file.getLine(_end);
+    var endColumn = file.getColumn(_end);
+
+    int endOffset;
+    if (endColumn == 0 && endLine != 0) {
+      // If [end] is at the very beginning of the line, the span covers the
+      // previous newline, so we only want to include the previous line in the
+      // context...
+
+      if (length == 0) {
+        // ...unless this is a point span, in which case we want to include the
+        // next line (or the empty string if this is the end of the file).
+        return endLine == file.lines - 1
+            ? ""
+            : file.getText(
+                file.getOffset(endLine), file.getOffset(endLine + 1));
+      }
+
+      endOffset = _end;
+    } else if (endLine == file.lines - 1) {
+      // If the span covers the last line of the file, the context should go all
+      // the way to the end of the file.
+      endOffset = file.length;
+    } else {
+      // Otherwise, the context should cover the full line on which [end]
+      // appears.
+      endOffset = file.getOffset(endLine + 1);
+    }
+
+    return file.getText(file.getOffset(file.getLine(_start)), endOffset);
+  }
 
   _FileSpan(this.file, this._start, this._end) {
     if (_end < _start) {
diff --git a/lib/src/highlighter.dart b/lib/src/highlighter.dart
new file mode 100644
index 0000000..17a47bc
--- /dev/null
+++ b/lib/src/highlighter.dart
@@ -0,0 +1,427 @@
+// 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;
+
+    var newSpan = _normalizeContext(span);
+    newSpan = _normalizeNewlines(newSpan);
+    newSpan = _normalizeTrailingNewline(newSpan);
+    newSpan = _normalizeEndOfLine(newSpan);
+
+    return new Highlighter._(newSpan, color);
+  }
+
+  /// Normalizes [span] to ensure that it's a [SourceSpanWithContext] whose
+  /// context actually contains its text at the expected column.
+  ///
+  /// If it's not already a [SourceSpanWithContext], adjust the start and end
+  /// locations' line and column fields so that the highlighter can assume they
+  /// match up with the context.
+  static SourceSpanWithContext _normalizeContext(SourceSpan span) =>
+      span is SourceSpanWithContext &&
+              findLineStart(span.context, span.text, span.start.column) != null
+          ? span
+          : 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: _lastLineLength(span.text)),
+              span.text,
+              span.text);
+
+  /// Normalizes [span] to replace Windows-style newlines with Unix-style
+  /// newlines.
+  static SourceSpanWithContext _normalizeNewlines(SourceSpanWithContext span) {
+    var text = span.text;
+    if (!text.contains("\r\n")) return span;
+
+    var endOffset = span.end.offset;
+    for (var i = 0; i < text.length - 1; i++) {
+      if (text.codeUnitAt(i) == $cr && text.codeUnitAt(i + 1) == $lf) {
+        endOffset--;
+      }
+    }
+
+    return new SourceSpanWithContext(
+        span.start,
+        new SourceLocation(endOffset,
+            sourceUrl: span.sourceUrl,
+            line: span.end.line,
+            column: span.end.column),
+        text.replaceAll("\r\n", "\n"),
+        span.context.replaceAll("\r\n", "\n"));
+  }
+
+  /// Normalizes [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.
+  static SourceSpanWithContext _normalizeTrailingNewline(
+      SourceSpanWithContext span) {
+    if (!span.context.endsWith("\n")) return span;
+
+    // If there's a full blank line on the end of [span.context], it's probably
+    // significant, so we shouldn't trim it.
+    if (span.text.endsWith("\n\n")) return span;
+
+    var context = span.context.substring(0, span.context.length - 1);
+    var text = span.text;
+    var start = span.start;
+    var end = span.end;
+    if (span.text.endsWith("\n") && _isTextAtEndOfContext(span)) {
+      text = span.text.substring(0, span.text.length - 1);
+      end = new SourceLocation(span.end.offset - 1,
+          sourceUrl: span.sourceUrl,
+          line: span.end.line - 1,
+          column: _lastLineLength(text));
+      start = span.start.offset == span.end.offset ? end : span.start;
+    }
+    return new SourceSpanWithContext(start, end, text, context);
+  }
+
+  /// Normalizes [span] so that the end location is at the end of a line rather
+  /// than at the beginning of the next line.
+  static SourceSpanWithContext _normalizeEndOfLine(SourceSpanWithContext span) {
+    if (span.end.column != 0) return span;
+    if (span.end.line == span.start.line) return span;
+
+    var text = span.text.substring(0, span.text.length - 1);
+
+    return new SourceSpanWithContext(
+        span.start,
+        new SourceLocation(span.end.offset - 1,
+            sourceUrl: span.sourceUrl,
+            line: span.end.line - 1,
+            column: _lastLineLength(text)),
+        text,
+        span.context);
+  }
+
+  /// Returns the length of the last line in [text], whether or not it ends in a
+  /// newline.
+  static int _lastLineLength(String text) {
+    if (text.isEmpty) return 0;
+
+    // The "- 1" here avoids counting the newline itself.
+    if (text.codeUnitAt(text.length - 1) == $lf) {
+      return text.length == 1
+          ? 0
+          : text.length - text.lastIndexOf("\n", text.length - 2) - 1;
+    } else {
+      return 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 [_normalizeContext]
+
+    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");
+
+    var lastLineIndex = _span.end.line - _span.start.line;
+    if (lines.last.isEmpty && lines.length > lastLineIndex + 1) {
+      // Trim a trailing newline so we don't add an empty line to the end of the
+      // highlight.
+      lines.removeLast();
+    }
+
+    _writeFirstLine(lines.first);
+    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 599d668..57ffe79 100644
--- a/lib/src/span.dart
+++ b/lib/src/span.dart
@@ -2,28 +2,30 @@
 // 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';
 
 /// A class that describes a segment of source text.
 abstract class SourceSpan implements Comparable<SourceSpan> {
   /// The start location of this span.
-  final SourceLocation start;
+  SourceLocation get start;
 
   /// The end location of this span, exclusive.
-  final SourceLocation end;
+  SourceLocation get end;
 
   /// The source text for this span.
-  final String text;
+  String get text;
 
   /// The URL of the source (typically a file) of this span.
   ///
   /// This may be null, indicating that the source URL is unknown or
   /// unavailable.
-  final Uri sourceUrl;
+  Uri get sourceUrl;
 
   /// The length of this span, in characters.
-  final int length;
+  int get length;
 
   /// Creates a new span from [start] to [end] (exclusive) containing [text].
   ///
@@ -48,10 +50,16 @@
   /// Formats [message] in a human-friendly way associated with this span.
   ///
   /// [color] may either be a [String], a [bool], or `null`. If it's a string,
-  /// it indicates an ANSII terminal color escape that should be used to
-  /// highlight the span's text. 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.
+  /// 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.
+  ///
+  /// 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.
@@ -61,10 +69,16 @@
   /// isn't a [SourceSpanWithContext], returns an empty string.
   ///
   /// [color] may either be a [String], a [bool], or `null`. If it's a string,
-  /// it indicates an ANSII terminal color escape that should be used to
-  /// highlight the span's text. 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.
+  /// 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.
+  ///
+  /// 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 46e3d52..813dd3b 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,12 +1,17 @@
 name: source_span
-version: 1.4.0
-author: Dart Team <misc@dartlang.org>
+version: 1.5.6-dev
+
 description: A library for identifying source spans and locations.
+author: Dart Team <misc@dartlang.org>
 homepage: https://github.com/dart-lang/source_span
-dependencies:
-  charcode: '^1.0.0'
-  path: '>=1.2.0 <2.0.0'
+
 environment:
-  sdk: '>=1.8.0 <2.0.0'
+  sdk: '>=2.0.0 <3.0.0'
+
+dependencies:
+  charcode: ^1.0.0
+  path: '>=1.2.0 <2.0.0'
+  term_glyph: ^1.0.0
+
 dev_dependencies:
-  test: '>=0.12.0 <0.13.0'
+  test: ^1.0.0
diff --git a/test/file_test.dart b/test/file_test.dart
index 3f02a8b..e043ac3 100644
--- a/test/file_test.dart
+++ b/test/file_test.dart
@@ -8,7 +8,7 @@
 main() {
   var file;
   setUp(() {
-    file = new SourceFile("""
+    file = new SourceFile.fromString("""
 foo bar baz
 whiz bang boom
 zip zap zop""", url: "foo.dart");
@@ -122,7 +122,7 @@
     });
 
     test("for span().expand() source URLs must match", () {
-      var other = new SourceFile("""
+      var other = new SourceFile.fromString("""
 foo bar baz
 whiz bang boom
 zip zap zop""", url: "bar.dart").span(10, 11);
@@ -139,11 +139,11 @@
 
   group("new SourceFile()", () {
     test("handles CRLF correctly", () {
-      expect(new SourceFile("foo\r\nbar").getLine(6), equals(1));
+      expect(new SourceFile.fromString("foo\r\nbar").getLine(6), equals(1));
     });
 
     test("handles a lone CR correctly", () {
-      expect(new SourceFile("foo\rbar").getLine(5), equals(1));
+      expect(new SourceFile.fromString("foo\rbar").getLine(5), equals(1));
     });
   });
 
@@ -157,7 +157,7 @@
     test("end defaults to the end of the file", () {
       var span = file.span(5);
       expect(span.start, equals(file.location(5)));
-      expect(span.end, equals(file.location(file.length - 1)));
+      expect(span.end, equals(file.location(file.length)));
     });
   });
 
@@ -253,10 +253,46 @@
       expect(file.span(8, 15).text, equals("baz\nwhi"));
     });
 
-    test("context contains the span's text", () {
-      var span = file.span(8, 15);
-      expect(span.context.contains(span.text), isTrue);
-      expect(span.context, equals('foo bar baz\nwhiz bang boom\n'));
+    test("text includes the last char when end is defaulted to EOF", () {
+      expect(file.span(29).text, equals("p zap zop"));
+    });
+
+    group("context", () {
+      test("contains the span's text", () {
+        var span = file.span(8, 15);
+        expect(span.context.contains(span.text), isTrue);
+        expect(span.context, equals('foo bar baz\nwhiz bang boom\n'));
+      });
+
+      test("contains the previous line for a point span at the end of a line",
+          () {
+        var span = file.span(25, 25);
+        expect(span.context, equals('whiz bang boom\n'));
+      });
+
+      test("contains the next line for a point span at the beginning of a line",
+          () {
+        var span = file.span(12, 12);
+        expect(span.context, equals('whiz bang boom\n'));
+      });
+
+      group("for a point span at the end of a file", () {
+        test("without a newline, contains the last line", () {
+          var span = file.span(file.length, file.length);
+          expect(span.context, equals('zip zap zop'));
+        });
+
+        test("with a newline, contains an empty line", () {
+          file = new SourceFile.fromString("""
+foo bar baz
+whiz bang boom
+zip zap zop
+""", url: "foo.dart");
+
+          var span = file.span(file.length, file.length);
+          expect(span.context, isEmpty);
+        });
+      });
     });
 
     group("union()", () {
diff --git a/test/highlight_test.dart b/test/highlight_test.dart
index 08d2e17..5fdf622 100644
--- a/test/highlight_test.dart
+++ b/test/highlight_test.dart
@@ -2,14 +2,26 @@
 // 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("""
+    file = new SourceFile.fromString("""
 foo bar baz
 whiz bang boom
 zip zap zop
@@ -18,98 +30,504 @@
 
   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("foo bar baz").span(4, 7);
+    var span = new SourceFile.fromString("foo bar baz").span(4, 7);
     expect(span.highlight(), equals("""
-foo bar baz
-    ^^^"""));
+  ,
+1 | foo bar baz
+  |     ^^^
+  '"""));
   });
 
-  test("highlights the first line of a multiline span", () {
-    expect(file.span(4, 20).highlight(), equals("""
-foo bar baz
-    ^^^^^^^^"""));
+  group("highlights a point span", () {
+    test("in the middle of a line", () {
+      expect(file.location(4).pointSpan().highlight(), equals("""
+  ,
+1 | foo bar baz
+  |     ^
+  '"""));
+    });
+
+    test("at the beginning of the file", () {
+      expect(file.location(0).pointSpan().highlight(), equals("""
+  ,
+1 | foo bar baz
+  | ^
+  '"""));
+    });
+
+    test("at the beginning of a line", () {
+      expect(file.location(12).pointSpan().highlight(), equals("""
+  ,
+2 | whiz bang boom
+  | ^
+  '"""));
+    });
+
+    test("at the end of a line", () {
+      expect(file.location(11).pointSpan().highlight(), equals("""
+  ,
+1 | foo bar baz
+  |            ^
+  '"""));
+    });
+
+    test("at the end of the file", () {
+      expect(file.location(38).pointSpan().highlight(), equals("""
+  ,
+3 | zip zap zop
+  |            ^
+  '"""));
+    });
+
+    test("after the end of the file", () {
+      expect(file.location(39).pointSpan().highlight(), equals("""
+  ,
+4 | 
+  | ^
+  '"""));
+    });
+
+    test("at the end of the file with no trailing newline", () {
+      file = new SourceFile.fromString("zip zap zop");
+      expect(file.location(10).pointSpan().highlight(), equals("""
+  ,
+1 | zip zap zop
+  |           ^
+  '"""));
+    });
+
+    test("after the end of the file with no trailing newline", () {
+      file = new SourceFile.fromString("zip zap zop");
+      expect(file.location(11).pointSpan().highlight(), equals("""
+  ,
+1 | zip zap zop
+  |            ^
+  '"""));
+    });
+
+    test("in an empty file", () {
+      expect(new SourceFile.fromString("").location(0).pointSpan().highlight(),
+          equals("""
+  ,
+1 | 
+  | ^
+  '"""));
+    });
+
+    test("on an empty line", () {
+      var file = new SourceFile.fromString("foo\n\nbar");
+      expect(file.location(4).pointSpan().highlight(), equals("""
+  ,
+2 | 
+  | ^
+  '"""));
+    });
   });
 
-  test("works for a point span", () {
-    expect(file.location(4).pointSpan().highlight(), equals("""
-foo bar baz
-    ^"""));
+  test("highlights a single-line file without a newline", () {
+    expect(
+        new SourceFile.fromString("foo bar").span(0, 7).highlight(), equals("""
+  ,
+1 | foo bar
+  | ^^^^^^^
+  '"""));
   });
 
-  test("works for a point span at the end of a line", () {
-    expect(file.location(11).pointSpan().highlight(), equals("""
-foo bar baz
-           ^"""));
+  test("highlights a single empty line", () {
+    expect(new SourceFile.fromString("foo\n\nbar").span(4, 5).highlight(),
+        equals("""
+  ,
+2 | 
+  | ^
+  '"""));
   });
 
-  test("works for a point span at the end of the file", () {
-    expect(file.location(38).pointSpan().highlight(), equals("""
+  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 first line if it's empty", () {
+      var file = new SourceFile.fromString("""
+foo
+
+bar
+""");
+
+      expect(file.span(4, 9).highlight(), equals("""
+  ,
+2 | / 
+3 | \\ bar
+  '"""));
+    });
+
+    test("highlights the full last line", () {
+      expect(file.span(4, 27).highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | \\ whiz bang boom
+  '"""));
+    });
+
+    test("highlights the full last line with no trailing newline", () {
+      expect(file.span(4, 26).highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | \\ whiz bang boom
+  '"""));
+    });
+
+    test("highlights the full last line with a trailing Windows newline", () {
+      var file = new SourceFile.fromString("""
+foo bar baz\r
+whiz bang boom\r
+zip zap zop\r
+""");
+
+      expect(file.span(4, 29).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 at the end of the file 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("highlights the full last line if it's empty", () {
+      var file = new SourceFile.fromString("""
+foo
+
+bar
+""");
+
+      expect(file.span(0, 5).highlight(), equals("""
+  ,
+1 | / foo
+2 | \\ 
+  '"""));
+    });
+
+    test("highlights multiple empty lines", () {
+      var file = new SourceFile.fromString("foo\n\n\n\nbar");
+      expect(file.span(4, 7).highlight(), equals("""
+  ,
+2 | / 
+3 | | 
+4 | \\ 
+  '"""));
+    });
+
+    // Regression test for #32
+    test("highlights the end of a line and an empty line", () {
+      var file = new SourceFile.fromString("foo\n\n");
+      expect(file.span(3, 5).highlight(), equals("""
+  ,
+1 |   foo
+  | ,----^
+2 | \\ 
+  '"""));
+    });
+  });
+
+  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("works for a point span at the end of the file with no trailing newline",
-      () {
-    file = new SourceFile("zip zap zop");
-    expect(file.location(11).pointSpan().highlight(), equals("""
-zip zap zop
-           ^"""));
-  });
+  group("supports lines of preceding and following context for a span", () {
+    test("within a single line", () {
+      var span = new SourceSpanWithContext(
+          new SourceLocation(20, line: 2, column: 5, sourceUrl: "foo.dart"),
+          new SourceLocation(27, line: 2, column: 12, sourceUrl: "foo.dart"),
+          "foo bar",
+          "previous\nlines\n-----foo bar-----\nfollowing line\n");
 
-  test("works for a point span in an empty file", () {
-    expect(new SourceFile("").location(0).pointSpan().highlight(), equals("""
+      expect(span.highlight(), equals("""
+  ,
+1 | previous
+2 | lines
+3 | -----foo bar-----
+  |      ^^^^^^^
+4 | following line
+  '"""));
+    });
 
-^"""));
-  });
+    test("covering a full line", () {
+      var span = new SourceSpanWithContext(
+          new SourceLocation(15, line: 2, column: 0, sourceUrl: "foo.dart"),
+          new SourceLocation(33, line: 3, column: 0, sourceUrl: "foo.dart"),
+          "-----foo bar-----\n",
+          "previous\nlines\n-----foo bar-----\nfollowing line\n");
 
-  test("works for a single-line file without a newline", () {
-    expect(new SourceFile("foo bar").span(0, 7).highlight(), equals("""
-foo bar
-^^^^^^^"""));
-  });
+      expect(span.highlight(), equals("""
+  ,
+1 | previous
+2 | lines
+3 | -----foo bar-----
+  | ^^^^^^^^^^^^^^^^^
+4 | following line
+  '"""));
+    });
 
-  test("emits tabs for tabs", () {
-    expect(new SourceFile(" \t \t\tfoo bar").span(5, 8).highlight(), equals("""
- \t \t\tfoo bar
- \t \t\t^^^"""));
-  });
+    test("covering multiple full lines", () {
+      var span = new SourceSpanWithContext(
+          new SourceLocation(15, line: 2, column: 0, sourceUrl: "foo.dart"),
+          new SourceLocation(23, line: 4, column: 0, sourceUrl: "foo.dart"),
+          "foo\nbar\n",
+          "previous\nlines\nfoo\nbar\nfollowing line\n");
 
-  test("supports lines of preceding 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.highlight(color: colors.YELLOW), equals("""
-previous
-lines
------${colors.YELLOW}foo bar${colors.NONE}-----
-     ${colors.YELLOW}^^^^^^^${colors.NONE}"""));
+      expect(span.highlight(), equals("""
+  ,
+1 |   previous
+2 |   lines
+3 | / foo
+4 | \\ bar
+5 |   following line
+  '"""));
+    });
   });
 
   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);