Extract out a source_span package from source_maps.

This is just the first step; future CLs will add support for the new API to
various packages currently using the old one.

BUG=19930
R=sigmund@google.com

Review URL: https://codereview.chromium.org//381363002

git-svn-id: https://dart.googlecode.com/svn/branches/bleeding_edge/dart/pkg/source_span@38360 260f80e4-7a28-3924-810f-c04153c831b5
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..04e4be2
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,33 @@
+# 1.0.0
+
+This package was extracted from the
+[`source_maps`](http://pub.dartlang.org/packages/source_maps) package, but the
+API has many differences. Among them:
+
+* `Span` has been renamed to `SourceSpan` and `Location` has been renamed to
+  `SourceLocation` to clarify their purpose and maintain consistency with the
+  package name. Likewise, `SpanException` is now `SourceSpanException` and
+  `SpanFormatException` is not `SourceSpanFormatException`.
+
+* `FixedSpan` and `FixedLocation` have been rolled into the `Span` and
+  `Location` classes, respectively.
+
+* `SourceFile` is more aggressive about validating its arguments. Out-of-bounds
+  lines, columns, and offsets will now throw errors rather than be silently
+  clamped.
+
+* `SourceSpan.sourceUrl`, `SourceLocation.sourceUrl`, and `SourceFile.url` now
+  return `Uri` objects rather than `String`s. The constructors allow either
+  `String`s or `Uri`s.
+
+* `Span.getLocationMessage` and `SourceFile.getLocationMessage` are now
+  `SourceSpan.message` and `SourceFile.message`, respectively. Rather than
+  taking both a `useColor` and a `color` parameter, they now take a single
+  `color` parameter that controls both whether and which color is used.
+
+* `Span.isIdentifier` has been removed. This property doesn't make sense outside
+  of a source map context.
+
+* `SourceFileSegment` has been removed. This class wasn't widely used and was
+  inconsistent in its choice of which parameters were considered relative and
+  which absolute.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5c60afe
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,26 @@
+Copyright 2014, the Dart project authors. All rights reserved.
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+    * Neither the name of Google Inc. nor the names of its
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4e2547e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,15 @@
+`source_span` is a library for tracking locations in source code. It's designed
+to provide a standard representation for source code locations and spans so that
+disparate packages can easily pass them among one another, and to make it easy
+to generate human-friendly messages associated with a given piece of code.
+
+The most commonly-used class is the package's namesake, `SourceSpan`. It
+represents a span of characters in some source file, and is often attached to an
+object that has been parsed to indicate where it was parsed from. It provides
+access to the text of the span via `SourceSpan.text` and can be used to produce
+human-friendly messages using `SourceSpan.message()`.
+
+When parsing code from a file, `SourceFile` is useful. Not only does it provide
+an efficient means of computing line and column numbers, `SourceFile.span()`
+returns special `FileSpan`s that are able to provide more context for their
+error messages.
diff --git a/lib/source_span.dart b/lib/source_span.dart
new file mode 100644
index 0000000..e9646b1
--- /dev/null
+++ b/lib/source_span.dart
@@ -0,0 +1,11 @@
+// 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;
+
+export "src/file.dart";
+export "src/location.dart";
+export "src/span.dart";
+export "src/span_exception.dart";
+export "src/span_mixin.dart";
diff --git a/lib/src/colors.dart b/lib/src/colors.dart
new file mode 100644
index 0000000..274fc92
--- /dev/null
+++ b/lib/src/colors.dart
@@ -0,0 +1,13 @@
+// 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.
+
+// Color constants used for generating messages.
+library source_span.colors;
+
+const String RED = '\u001b[31m';
+
+const String YELLOW = '\u001b[33m';
+
+const String NONE = '\u001b[0m';
+
diff --git a/lib/src/file.dart b/lib/src/file.dart
new file mode 100644
index 0000000..0d2d6f6
--- /dev/null
+++ b/lib/src/file.dart
@@ -0,0 +1,266 @@
+// 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.file;
+
+import 'dart:math' as math;
+import 'dart:typed_data';
+
+import 'package:path/path.dart' as p;
+
+import 'colors.dart' as colors;
+import 'location.dart';
+import 'span.dart';
+import 'span_mixin.dart';
+import 'utils.dart';
+
+// Constants to determine end-of-lines.
+const int _LF = 10;
+const int _CR = 13;
+
+/// A class representing a source file.
+///
+/// This doesn't necessarily have to correspond to a file on disk, just a chunk
+/// of text usually with a URL associated with it.
+class SourceFile {
+  /// The URL where the source file is located.
+  ///
+  /// This may be null, indicating that the URL is unknown or unavailable.
+  final Uri url;
+
+  /// An array of offsets for each line beginning in the file.
+  ///
+  /// Each offset refers to the first character *after* the newline. If the
+  /// source file has a trailing newline, the final offset won't actually be in
+  /// the file.
+  final _lineStarts = <int>[0];
+
+  /// The code points of the characters in the file.
+  final Uint32List _decodedChars;
+
+  /// The length of the file in characters.
+  int get length => _decodedChars.length;
+
+  /// The number of lines in the file.
+  int get lines => _lineStarts.length;
+
+  /// Creates a new source file from [text].
+  ///
+  /// [url] may be either a [String], a [Uri], or `null`.
+  SourceFile(String text, {url})
+      : this.decoded(text.runes, url: url);
+
+  /// Creates a new source file from a list of decoded characters.
+  ///
+  /// [url] may be either a [String], a [Uri], or `null`.
+  SourceFile.decoded(Iterable<int> decodedChars, {url})
+      : url = url is String ? Uri.parse(url) : url,
+        _decodedChars = new Uint32List.fromList(decodedChars.toList()) {
+    for (var i = 0; i < _decodedChars.length; i++) {
+      var c = _decodedChars[i];
+      if (c == _CR) {
+        // Return not followed by newline is treated as a newline
+        var j = i + 1;
+        if (j >= _decodedChars.length || _decodedChars[j] != _LF) c = _LF;
+      }
+      if (c == _LF) _lineStarts.add(i + 1);
+    }
+  }
+
+  /// Returns a span in [this] from [start] to [end] (exclusive).
+  ///
+  /// 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;
+    return new FileSpan._(this, location(start), location(end));
+  }
+
+  /// Returns a location in [this] at [offset].
+  FileLocation location(int offset) => new FileLocation._(this, offset);
+
+  /// Gets the 0-based line corresponding to [offset].
+  int getLine(int offset) {
+    if (offset < 0) {
+      throw new RangeError("Offset may not be negative, was $offset.");
+    } else if (offset > length) {
+      throw new RangeError("Offset $offset must not be greater than the number "
+          "of characters in the file, $length.");
+    }
+    return binarySearch(_lineStarts, (o) => o > offset) - 1;
+  }
+
+  /// Gets the 0-based column corresponding to [offset].
+  ///
+  /// If [line] is passed, it's assumed to be the line containing [offset] and
+  /// is used to more efficiently compute the column.
+  int getColumn(int offset, {int line}) {
+    if (offset < 0) {
+      throw new RangeError("Offset may not be negative, was $offset.");
+    } else if (offset > length) {
+      throw new RangeError("Offset $offset must be not be greater than the "
+          "number of characters in the file, $length.");
+    }
+
+    if (line == null) {
+      line = getLine(offset);
+    } else if (line < 0) {
+      throw new RangeError("Line may not be negative, was $line.");
+    } else if (line >= lines) {
+      throw new RangeError("Line $line must be less than the number of "
+          "lines in the file, $lines.");
+    }
+
+    var lineStart = _lineStarts[line];
+    if (lineStart > offset) {
+      throw new RangeError("Line $line comes after offset $offset.");
+    }
+
+    return offset - lineStart;
+  }
+
+  /// Gets the offset for a [line] and [column].
+  ///
+  /// [column] defaults to 0.
+  int getOffset(int line, [int column]) {
+    if (column == null) column = 0;
+
+    if (line < 0) {
+      throw new RangeError("Line may not be negative, was $line.");
+    } else if (line >= lines) {
+      throw new RangeError("Line $line must be less than the number of "
+          "lines in the file, $lines.");
+    } else if (column < 0) {
+      throw new RangeError("Column may not be negative, was $column.");
+    }
+
+    var result = _lineStarts[line] + column;
+    if (result > length ||
+        (line + 1 < lines && result >= _lineStarts[line + 1])) {
+      throw new RangeError("Line $line doesn't have $column columns.");
+    }
+
+    return result;
+  }
+
+  /// Returns the text of the file from [start] to [end] (exclusive).
+  ///
+  /// If [end] isn't passed, it defaults to the end of the file.
+  String getText(int start, [int end]) =>
+      new String.fromCharCodes(_decodedChars.sublist(start, end));
+}
+
+/// A [SourceLocation] within a [SourceFile].
+///
+/// Unlike the base [SourceLocation], [FileLocation] lazily computes its line
+/// and column values based on its offset and the contents of [file].
+///
+/// A [FileLocation] can be created using [SourceFile.location].
+class FileLocation extends SourceLocation {
+  /// The [file] that [this] belongs to.
+  final SourceFile file;
+
+  Uri get sourceUrl => file.url;
+  int get line => file.getLine(offset);
+  int get column => file.getColumn(offset);
+
+  FileLocation._(this.file, int offset)
+      : super(offset) {
+    if (offset > file.length) {
+      throw new RangeError("Offset $offset must not be greater than the number "
+          "of characters in the file, ${file.length}.");
+    }
+  }
+
+  FileSpan pointSpan() => new FileSpan._(file, this, this);
+}
+
+/// A [SourceSpan] within a [SourceFile].
+///
+/// Unlike the base [SourceSpan], [FileSpan] lazily computes its line and column
+/// values based on its offset and the contents of [file]. [FileSpan.message] is
+/// also able to provide more context then [SourceSpan.message], and
+/// [FileSpan.union] will return a [FileSpan] if possible.
+///
+/// A [FileSpan] can be created using [SourceFile.span].
+class FileSpan extends SourceSpanMixin {
+  /// The [file] that [this] belongs to.
+  final SourceFile file;
+
+  final FileLocation start;
+  final FileLocation end;
+
+  String get text => file.getText(start.offset, end.offset);
+
+  FileSpan._(this.file, this.start, this.end) {
+    if (end.offset < start.offset) {
+      throw new ArgumentError('End $end must come after start $start.');
+    }
+  }
+
+  SourceSpan union(SourceSpan other) {
+    if (other is! FileSpan) return super.union(other);
+
+    var span = expand(other);
+    var beginSpan = span.start == this.start ? this : other;
+    var endSpan = span.end == this.end ? this : other;
+
+    if (beginSpan.end.compareTo(endSpan.start) < 0) {
+      throw new ArgumentError("Spans $this and $other are disjoint.");
+    }
+
+    return span;
+  }
+
+  /// Returns a new span that covers both [this] and [other].
+  ///
+  /// Unlike [union], [other] may be disjoint from [this]. If it is, the text
+  /// between the two will be covered by the returned span.
+  FileSpan expand(FileSpan other) {
+    if (sourceUrl != other.sourceUrl) {
+      throw new ArgumentError("Source URLs \"${sourceUrl}\" and "
+          " \"${other.sourceUrl}\" don't match.");
+    }
+
+    var start = min(this.start, other.start);
+    var end = 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/location.dart b/lib/src/location.dart
new file mode 100644
index 0000000..41f2518
--- /dev/null
+++ b/lib/src/location.dart
@@ -0,0 +1,86 @@
+// 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.location;
+
+import 'span.dart';
+
+// A class that describes a single location within a source file.
+class SourceLocation implements Comparable<SourceLocation> {
+  /// URL of the source containing this location.
+  ///
+  /// This may be null, indicating that the source URL is unknown or
+  /// unavailable.
+  final Uri sourceUrl;
+
+  /// The 0-based offset of this location in the source.
+  final int offset;
+
+  /// The 0-based line of this location in the source.
+  final int line;
+
+  /// The 0-based column of this location in the source
+  final int column;
+
+  /// Returns a representation of this location in the `source:line:column`
+  /// format used by text editors.
+  ///
+  /// This prints 1-based lines and columns.
+  String get toolString {
+    var source = sourceUrl == null ? 'unknown source' : sourceUrl;
+    return '$source:${line + 1}:${column + 1}';
+  }
+
+  /// Creates a new location indicating [offset] within [sourceUrl].
+  ///
+  /// [line] and [column] default to assuming the source is a single line. This
+  /// means that [line] defaults to 0 and [column] defaults to [offset].
+  ///
+  /// [sourceUrl] may be either a [String], a [Uri], or `null`.
+  SourceLocation(int offset, {sourceUrl, int line, int column})
+      : sourceUrl = sourceUrl is String ? Uri.parse(sourceUrl) : sourceUrl,
+        offset = offset,
+        line = line == null ? 0 : line,
+        column = column == null ? offset : column {
+    if (this.offset < 0) {
+      throw new RangeError("Offset may not be negative, was $offset.");
+    } else if (this.line < 0) {
+      throw new RangeError("Line may not be negative, was $line.");
+    } else if (this.column < 0) {
+      throw new RangeError("Column may not be negative, was $column.");
+    }
+  }
+
+  /// Returns the distance in characters between [this] and [other].
+  ///
+  /// This always returns a non-negative value.
+  int distance(SourceLocation other) {
+    if (sourceUrl != other.sourceUrl) {
+      throw new ArgumentError("Source URLs \"${sourceUrl}\" and "
+          "\"${other.sourceUrl}\" don't match.");
+    }
+    return (offset - other.offset).abs();
+  }
+
+  /// Returns a span that covers only a single point: this location.
+  SourceSpan pointSpan() => new SourceSpan(this, this, "");
+
+  /// Compares two locations.
+  ///
+  /// [other] must have the same source URL as [this].
+  int compareTo(SourceLocation other) {
+    if (sourceUrl != other.sourceUrl) {
+      throw new ArgumentError("Source URLs \"${sourceUrl}\" and "
+          "\"${other.sourceUrl}\" don't match.");
+    }
+    return offset - other.offset;
+  }
+
+  bool operator ==(SourceLocation other) =>
+      sourceUrl == other.sourceUrl && offset == other.offset;
+
+  int get hashCode => sourceUrl.hashCode + offset;
+
+  String toString() => '<$runtimeType: $offset $toolString>';
+}
diff --git a/lib/src/span.dart b/lib/src/span.dart
new file mode 100644
index 0000000..9f15048
--- /dev/null
+++ b/lib/src/span.dart
@@ -0,0 +1,78 @@
+// 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_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;
+
+  /// The end location of this span, exclusive.
+  final SourceLocation end;
+
+  /// The source text for this span.
+  final String 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;
+
+  /// The length of this span, in characters.
+  final int length;
+
+  /// Creates a new span from [start] to [end] (exclusive) containing [text].
+  ///
+  /// [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].
+  factory SourceSpan(SourceLocation start, SourceLocation end, String text) =>
+      new SourceSpanBase(start, end, text);
+
+  /// Creates a new span that's the union of [this] and [other].
+  ///
+  /// The two spans must have the same source URL and may not be disjoint.
+  /// [text] is computed by combining [this.text] and [other.text].
+  SourceSpan union(SourceSpan other);
+
+  /// Compares two spans.
+  ///
+  /// [other] must have the same source URL as [this]. This orders spans by
+  /// [start] then [length].
+  int compareTo(SourceSpan other);
+
+  /// 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.
+  String message(String message, {color});
+}
+
+/// A base class for source spans with [start], [end], and [text] known at
+/// construction time.
+class SourceSpanBase extends SourceSpanMixin {
+  final SourceLocation start;
+  final SourceLocation end;
+  final String text;
+
+  SourceSpanBase(this.start, this.end, this.text) {
+    if (end.sourceUrl != start.sourceUrl) {
+      throw new ArgumentError("Source URLs \"${start.sourceUrl}\" and "
+          " \"${end.sourceUrl}\" don't match.");
+    } else if (end.offset < start.offset) {
+      throw new ArgumentError('End $end must come after start $start.');
+    } else if (text.length != start.distance(end)) {
+      throw new ArgumentError('Text "$text" must be ${start.distance(end)} '
+          'characters long.');
+    }
+  }
+}
diff --git a/lib/src/span_exception.dart b/lib/src/span_exception.dart
new file mode 100644
index 0000000..af64241
--- /dev/null
+++ b/lib/src/span_exception.dart
@@ -0,0 +1,43 @@
+// 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_exception;
+
+import 'span.dart';
+
+/// A class for exceptions that have source span information attached.
+class SourceSpanException implements Exception {
+  /// A message describing the exception.
+  final String message;
+
+  /// The span associated with this exception.
+  ///
+  /// This may be `null` if the source location can't be determined.
+  final SourceSpan span;
+
+  SourceSpanException(this.message, this.span);
+
+  /// Returns a string representation of [this].
+  ///
+  /// [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.
+  String toString({color}) {
+    if (span == null) return message;
+    return "Error on " + span.message(message, color: color);
+  }
+}
+
+/// A [SourceSpanException] that's also a [FormatException].
+class SourceSpanFormatException extends SourceSpanException
+    implements FormatException {
+  final source;
+
+  int get position => span == null ? null : span.start.offset;
+
+  SourceSpanFormatException(String message, SourceSpan span, [this.source])
+      : super(message, span);
+}
diff --git a/lib/src/span_mixin.dart b/lib/src/span_mixin.dart
new file mode 100644
index 0000000..95a720a
--- /dev/null
+++ b/lib/src/span_mixin.dart
@@ -0,0 +1,74 @@
+// 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_mixin;
+
+import 'package:path/path.dart' as p;
+
+import 'colors.dart' as colors;
+import 'span.dart';
+import 'utils.dart';
+
+/// A mixin for easily implementing [SourceSpan].
+///
+/// This implements the [SourceSpan] methods in terms of [start], [end], and
+/// [text]. This assumes that [start] and [end] have the same source URL, that
+/// [start] comes before [end], and that [text] has a number of characters equal
+/// to the distance between [start] and [end].
+abstract class SourceSpanMixin implements SourceSpan {
+  Uri get sourceUrl => start.sourceUrl;
+  int get length => end.offset - start.offset;
+
+  int compareTo(SourceSpan other) {
+    int d = start.compareTo(other.start);
+    return d == 0 ? end.compareTo(other.end) : d;
+  }
+
+  SourceSpan union(SourceSpan other) {
+    if (sourceUrl != other.sourceUrl) {
+      throw new ArgumentError("Source URLs \"${sourceUrl}\" and "
+          " \"${other.sourceUrl}\" don't match.");
+    }
+
+    var start = min(this.start, other.start);
+    var end = max(this.end, other.end);
+    var beginSpan = start == this.start ? this : other;
+    var endSpan = end == this.end ? this : other;
+
+    if (beginSpan.end.compareTo(endSpan.start) < 0) {
+      throw new ArgumentError("Spans $this and $other are disjoint.");
+    }
+
+    var text = beginSpan.text +
+        endSpan.text.substring(beginSpan.end.distance(endSpan.start));
+    return new SourceSpan(start, end, text);
+  }
+
+  String message(String message, {color}) {
+    if (color == true) color = colors.RED;
+    if (color == false) color = null;
+
+    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');
+    if (length == 0) return buffer.toString();
+
+    buffer.write("\n");
+    var textLine = text.split("\n").first;
+    if (color != null) buffer.write(color);
+    buffer.write(textLine);
+    buffer.write("\n");
+    buffer.write('^' * textLine.length);
+    if (color != null) buffer.write(colors.NONE);
+    return buffer.toString();
+  }
+
+  bool operator ==(SourceSpan other) =>
+      start == other.start && end == other.end;
+
+  int get hashCode => start.hashCode + (31 * end.hashCode);
+
+  String toString() => '<$runtimeType: from $start to $end "$text">';
+}
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
new file mode 100644
index 0000000..4a8eb55
--- /dev/null
+++ b/lib/src/utils.dart
@@ -0,0 +1,39 @@
+// 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.utils;
+
+/// Returns the minimum of [obj1] and [obj2] according to
+/// [Comparable.compareTo].
+Comparable min(Comparable obj1, Comparable obj2) =>
+    obj1.compareTo(obj2) > 0 ? obj2 : obj1;
+
+/// Returns the maximum of [obj1] and [obj2] according to
+/// [Comparable.compareTo].
+Comparable max(Comparable obj1, Comparable obj2) =>
+    obj1.compareTo(obj2) > 0 ? obj1 : obj2;
+
+/// Find the first entry in a sorted [list] that matches a monotonic predicate.
+///
+/// Given a result `n`, that all items before `n` will not match, `n` matches,
+/// and all items after `n` match too. The result is -1 when there are no
+/// items, 0 when all items match, and list.length when none does.
+int binarySearch(List list, bool matches(item)) {
+  if (list.length == 0) return -1;
+  if (matches(list.first)) return 0;
+  if (!matches(list.last)) return list.length;
+
+  int min = 0;
+  int max = list.length - 1;
+  while (min < max) {
+    var half = min + ((max - min) ~/ 2);
+    if (matches(list[half])) {
+      max = half;
+    } else {
+      min = half + 1;
+    }
+  }
+  return max;
+}
+
diff --git a/pubspec.yaml b/pubspec.yaml
new file mode 100644
index 0000000..66e5811
--- /dev/null
+++ b/pubspec.yaml
@@ -0,0 +1,12 @@
+name: source_span
+
+version: 1.0.0
+author: Dart Team <misc@dartlang.org>
+description: A library for identifying source spans and locations.
+homepage: http://www.dartlang.org
+dependencies:
+  path: '>=1.2.0 <2.0.0'
+environment:
+  sdk: '>=0.8.10+6 <2.0.0'
+dev_dependencies:
+  unittest: '>=0.9.0 <0.10.0'
diff --git a/test/file_message_test.dart b/test/file_message_test.dart
new file mode 100644
index 0000000..18b34e7
--- /dev/null
+++ b/test/file_message_test.dart
@@ -0,0 +1,100 @@
+// 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.
+
+import 'package:unittest/unittest.dart';
+import 'package:source_span/source_span.dart';
+import 'package:source_span/src/colors.dart' as colors;
+
+main() {
+  var file;
+  setUp(() {
+    file = new SourceFile("""
+foo bar baz
+whiz bang boom
+zip zap zop
+""", url: "foo.dart");
+  });
+
+  test("points to the span in the source", () {
+    expect(file.span(4, 7).message("oh no"), equals("""
+line 1, column 5 of foo.dart: oh no
+foo bar baz
+    ^^^"""));
+  });
+
+  test("gracefully handles a missing source URL", () {
+    var span = new SourceFile("foo bar baz").span(4, 7);
+    expect(span.message("oh no"), equals("""
+line 1, column 5: oh no
+foo bar baz
+    ^^^"""));
+  });
+
+  test("highlights the first line of a multiline span", () {
+    expect(file.span(4, 20).message("oh no"), equals("""
+line 1, column 5 of foo.dart: oh no
+foo bar baz
+    ^^^^^^^^"""));
+  });
+
+  test("works for a point span", () {
+    expect(file.location(4).pointSpan().message("oh no"), equals("""
+line 1, column 5 of foo.dart: oh no
+foo bar baz
+    ^"""));
+  });
+
+  test("works for a point span at the end of a line", () {
+    expect(file.location(11).pointSpan().message("oh no"), equals("""
+line 1, column 12 of foo.dart: oh no
+foo bar baz
+           ^"""));
+  });
+
+  test("works for a point span at the end of the file", () {
+    expect(file.location(38).pointSpan().message("oh no"), equals("""
+line 3, column 12 of foo.dart: oh no
+zip zap zop
+           ^"""));
+  });
+
+  test("works for a point span in an empty file", () {
+    expect(new SourceFile("").location(0).pointSpan().message("oh no"),
+        equals("""
+line 1, column 1: oh no
+
+^"""));
+  });
+
+  test("works for a single-line file without a newline", () {
+    expect(new SourceFile("foo bar").span(0, 7).message("oh no"),
+        equals("""
+line 1, column 1: oh no
+foo bar
+^^^^^^^"""));
+  });
+
+  group("colors", () {
+    test("doesn't colorize if color is false", () {
+      expect(file.span(4, 7).message("oh no", color: false), equals("""
+line 1, column 5 of foo.dart: oh no
+foo bar baz
+    ^^^"""));
+    });
+
+    test("colorizes if color is true", () {
+      expect(file.span(4, 7).message("oh no", color: true), equals("""
+line 1, column 5 of foo.dart: oh no
+foo ${colors.RED}bar${colors.NONE} baz
+    ${colors.RED}^^^${colors.NONE}"""));
+    });
+
+    test("uses the given color if it's passed", () {
+      expect(file.span(4, 7).message("oh no", color: colors.YELLOW), equals("""
+line 1, column 5 of foo.dart: oh no
+foo ${colors.YELLOW}bar${colors.NONE} baz
+    ${colors.YELLOW}^^^${colors.NONE}"""));
+    });
+  });
+}
diff --git a/test/file_test.dart b/test/file_test.dart
new file mode 100644
index 0000000..114a17e
--- /dev/null
+++ b/test/file_test.dart
@@ -0,0 +1,370 @@
+// 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.
+
+import 'package:unittest/unittest.dart';
+import 'package:source_span/source_span.dart';
+
+main() {
+  var file;
+  setUp(() {
+    file = new SourceFile("""
+foo bar baz
+whiz bang boom
+zip zap zop""", url: "foo.dart");
+  });
+
+  group("errors", () {
+    group("for span()", () {
+      test("end must come after start", () {
+        expect(() => file.span(10, 5), throwsArgumentError);
+      });
+
+      test("start may not be negative", () {
+        expect(() => file.span(-1, 5), throwsRangeError);
+      });
+
+      test("end may not be outside the file", () {
+        expect(() => file.span(10, 100), throwsRangeError);
+      });
+    });
+
+    group("for location()", () {
+      test("offset may not be negative", () {
+        expect(() => file.location(-1), throwsRangeError);
+      });
+
+      test("offset may not be outside the file", () {
+        expect(() => file.location(100), throwsRangeError);
+      });
+    });
+
+    group("for getLine()", () {
+      test("offset may not be negative", () {
+        expect(() => file.getLine(-1), throwsRangeError);
+      });
+
+      test("offset may not be outside the file", () {
+        expect(() => file.getLine(100), throwsRangeError);
+      });
+    });
+
+    group("for getColumn()", () {
+      test("offset may not be negative", () {
+        expect(() => file.getColumn(-1), throwsRangeError);
+      });
+
+      test("offset may not be outside the file", () {
+        expect(() => file.getColumn(100), throwsRangeError);
+      });
+
+      test("line may not be negative", () {
+        expect(() => file.getColumn(1, line: -1), throwsRangeError);
+      });
+
+      test("line may not be outside the file", () {
+        expect(() => file.getColumn(1, line: 100), throwsRangeError);
+      });
+
+      test("line must be accurate", () {
+        expect(() => file.getColumn(1, line: 1), throwsRangeError);
+      });
+    });
+
+    group("getOffset()", () {
+      test("line may not be negative", () {
+        expect(() => file.getOffset(-1), throwsRangeError);
+      });
+
+      test("column may not be negative", () {
+        expect(() => file.getOffset(1, -1), throwsRangeError);
+      });
+
+      test("line may not be outside the file", () {
+        expect(() => file.getOffset(100), throwsRangeError);
+      });
+
+      test("column may not be outside the file", () {
+        expect(() => file.getOffset(2, 100), throwsRangeError);
+      });
+ 
+      test("column may not be outside the line", () {
+        expect(() => file.getOffset(1, 20), throwsRangeError);
+      });
+    });
+
+    group("for getText()", () {
+      test("end must come after start", () {
+        expect(() => file.getText(10, 5), throwsArgumentError);
+      });
+
+      test("start may not be negative", () {
+        expect(() => file.getText(-1, 5), throwsRangeError);
+      });
+
+      test("end may not be outside the file", () {
+        expect(() => file.getText(10, 100), throwsRangeError);
+      });
+    });
+
+    group("for span().union()", () {
+      test("source URLs must match", () {
+        var other = new SourceSpan(
+            new SourceLocation(10), new SourceLocation(11), "_");
+
+        expect(() => file.span(9, 10).union(other), throwsArgumentError);
+      });
+
+      test("spans may not be disjoint", () {
+        expect(() => file.span(9, 10).union(file.span(11, 12)),
+            throwsArgumentError);
+      });
+    });
+
+    test("for span().expand() source URLs must match", () {
+      var other = new SourceFile("""
+foo bar baz
+whiz bang boom
+zip zap zop""", url: "bar.dart").span(10, 11);
+
+      expect(() => file.span(9, 10).expand(other), throwsArgumentError);
+    });
+  });
+
+  test('fields work correctly', () {
+    expect(file.url, equals(Uri.parse("foo.dart")));
+    expect(file.lines, equals(3));
+    expect(file.length, equals(38));
+  });
+
+  group("new SourceFile()", () {
+    test("handles CRLF correctly", () {
+      expect(new SourceFile("foo\r\nbar").getLine(6), equals(1));
+    });
+
+    test("handles a lone CR correctly", () {
+      expect(new SourceFile("foo\rbar").getLine(5), equals(1));
+    });
+  });
+
+  group("span()", () {
+    test("returns a span between the given offsets", () {
+      var span = file.span(5, 10);
+      expect(span.start, equals(file.location(5)));
+      expect(span.end, equals(file.location(10)));
+    });
+
+    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)));
+    });
+  });
+
+  group("getLine()", () {
+    test("works for a middle character on the line", () {
+      expect(file.getLine(15), equals(1));
+    });
+
+    test("works for the first character of a line", () {
+      expect(file.getLine(12), equals(1));
+    });
+
+    test("works for a newline character", () {
+      expect(file.getLine(11), equals(0));
+    });
+
+    test("works for the last offset", () {
+      expect(file.getLine(file.length), equals(2));
+    });
+  });
+
+  group("getColumn()", () {
+    test("works for a middle character on the line", () {
+      expect(file.getColumn(15), equals(3));
+    });
+
+    test("works for the first character of a line", () {
+      expect(file.getColumn(12), equals(0));
+    });
+
+    test("works for a newline character", () {
+      expect(file.getColumn(11), equals(11));
+    });
+
+    test("works when line is passed as well", () {
+      expect(file.getColumn(12, line: 1), equals(0));
+    });
+
+    test("works for the last offset", () {
+      expect(file.getColumn(file.length), equals(11));
+    });
+  });
+
+  group("getOffset()", () {
+    test("works for a middle character on the line", () {
+      expect(file.getOffset(1, 3), equals(15));
+    });
+
+    test("works for the first character of a line", () {
+      expect(file.getOffset(1), equals(12));
+    });
+
+    test("works for a newline character", () {
+      expect(file.getOffset(0, 11), equals(11));
+    });
+
+    test("works for the last offset", () {
+      expect(file.getOffset(2, 11), equals(file.length));
+    });
+  });
+
+  group("getText()", () {
+    test("returns a substring of the source", () {
+      expect(file.getText(8, 15), equals("baz\nwhi"));
+    });
+
+    test("end defaults to the end of the file", () {
+      expect(file.getText(20), equals("g boom\nzip zap zop"));
+    });
+  });
+
+  group("FileLocation", () {
+    test("reports the correct line number", () {
+      expect(file.location(15).line, equals(1));
+    });
+
+    test("reports the correct column number", () {
+      expect(file.location(15).column, equals(3));
+    });
+
+    test("pointSpan() returns a FileSpan", () {
+      var location = file.location(15);
+      var span = location.pointSpan();
+      expect(span, new isInstanceOf<FileSpan>());
+      expect(span.start, equals(location));
+      expect(span.end, equals(location));
+      expect(span.text, isEmpty);
+    });
+  });
+
+  group("FileSpan", () {
+    test("text returns a substring of the source", () {
+      expect(file.span(8, 15).text, equals("baz\nwhi"));
+    });
+
+    group("union()", () {
+      var span;
+      setUp(() {
+        span = file.span(5, 12);
+      });
+
+      test("works with a preceding adjacent span", () {
+        var other = file.span(0, 5);
+        var result = span.union(other);
+        expect(result.start, equals(other.start));
+        expect(result.end, equals(span.end));
+        expect(result.text, equals("foo bar baz\n"));
+      });
+
+      test("works with a preceding overlapping span", () {
+        var other = file.span(0, 8);
+        var result = span.union(other);
+        expect(result.start, equals(other.start));
+        expect(result.end, equals(span.end));
+        expect(result.text, equals("foo bar baz\n"));
+      });
+
+      test("works with a following adjacent span", () {
+        var other = file.span(12, 16);
+        var result = span.union(other);
+        expect(result.start, equals(span.start));
+        expect(result.end, equals(other.end));
+        expect(result.text, equals("ar baz\nwhiz"));
+      });
+
+      test("works with a following overlapping span", () {
+        var other = file.span(9, 16);
+        var result = span.union(other);
+        expect(result.start, equals(span.start));
+        expect(result.end, equals(other.end));
+        expect(result.text, equals("ar baz\nwhiz"));
+      });
+
+      test("works with an internal overlapping span", () {
+        var other = file.span(7, 10);
+        expect(span.union(other), equals(span));
+      });
+
+      test("works with an external overlapping span", () {
+        var other = file.span(0, 16);
+        expect(span.union(other), equals(other));
+      });
+
+      test("returns a FileSpan for a FileSpan input", () {
+        expect(span.union(file.span(0, 5)), new isInstanceOf<FileSpan>());
+      });
+
+      test("returns a base SourceSpan for a SourceSpan input", () {
+        var other = new SourceSpan(
+            new SourceLocation(0, sourceUrl: "foo.dart"),
+            new SourceLocation(5, sourceUrl: "foo.dart"),
+            "hey, ");
+        var result = span.union(other);
+        expect(result, isNot(new isInstanceOf<FileSpan>()));
+        expect(result.start, equals(other.start));
+        expect(result.end, equals(span.end));
+        expect(result.text, equals("hey, ar baz\n"));
+      });
+    });
+
+    group("expand()", () {
+      var span;
+      setUp(() {
+        span = file.span(5, 12);
+      });
+
+      test("works with a preceding nonadjacent span", () {
+        var other = file.span(0, 3);
+        var result = span.expand(other);
+        expect(result.start, equals(other.start));
+        expect(result.end, equals(span.end));
+        expect(result.text, equals("foo bar baz\n"));
+      });
+
+      test("works with a preceding overlapping span", () {
+        var other = file.span(0, 8);
+        var result = span.expand(other);
+        expect(result.start, equals(other.start));
+        expect(result.end, equals(span.end));
+        expect(result.text, equals("foo bar baz\n"));
+      });
+
+      test("works with a following nonadjacent span", () {
+        var other = file.span(14, 16);
+        var result = span.expand(other);
+        expect(result.start, equals(span.start));
+        expect(result.end, equals(other.end));
+        expect(result.text, equals("ar baz\nwhiz"));
+      });
+
+      test("works with a following overlapping span", () {
+        var other = file.span(9, 16);
+        var result = span.expand(other);
+        expect(result.start, equals(span.start));
+        expect(result.end, equals(other.end));
+        expect(result.text, equals("ar baz\nwhiz"));
+      });
+
+      test("works with an internal overlapping span", () {
+        var other = file.span(7, 10);
+        expect(span.expand(other), equals(span));
+      });
+
+      test("works with an external overlapping span", () {
+        var other = file.span(0, 16);
+        expect(span.expand(other), equals(other));
+      });
+    });
+  });
+}
\ No newline at end of file
diff --git a/test/location_test.dart b/test/location_test.dart
new file mode 100644
index 0000000..1eedec4
--- /dev/null
+++ b/test/location_test.dart
@@ -0,0 +1,101 @@
+// 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.
+
+import 'package:unittest/unittest.dart';
+import 'package:source_span/source_span.dart';
+
+main() {
+  var location;
+  setUp(() {
+    location = new SourceLocation(15,
+        line: 2, column: 6, sourceUrl: "foo.dart");
+  });
+
+  group('errors', () {
+    group('for new SourceLocation()', () {
+      test('offset may not be negative', () {
+        expect(() => new SourceLocation(-1), throwsRangeError);
+      });
+
+      test('line may not be negative', () {
+        expect(() => new SourceLocation(0, line: -1), throwsRangeError);
+      });
+
+      test('column may not be negative', () {
+        expect(() => new SourceLocation(0, column: -1), throwsRangeError);
+      });
+    });
+
+    test('for distance() source URLs must match', () {
+      expect(() => location.distance(new SourceLocation(0)),
+          throwsArgumentError);
+    });
+
+    test('for compareTo() source URLs must match', () {
+      expect(() => location.compareTo(new SourceLocation(0)),
+          throwsArgumentError);
+    });
+  });
+
+  test('fields work correctly', () {
+    expect(location.sourceUrl, equals(Uri.parse("foo.dart")));
+    expect(location.offset, equals(15));
+    expect(location.line, equals(2));
+    expect(location.column, equals(6));
+  });
+
+  group('toolString', () {
+    test('returns a computer-readable representation', () {
+      expect(location.toolString, equals('foo.dart:3:7'));
+    });
+
+    test('gracefully handles a missing source URL', () {
+      var location = new SourceLocation(15, line: 2, column: 6);
+      expect(location.toolString, equals('unknown source:3:7'));
+    });
+  });
+
+  test("distance returns the absolute distance between locations", () {
+    var other = new SourceLocation(10, sourceUrl: "foo.dart");
+    expect(location.distance(other), equals(5));
+    expect(other.distance(location), equals(5));
+  });
+
+  test("pointSpan returns an empty span at location", () {
+    var span = location.pointSpan();
+    expect(span.start, equals(location));
+    expect(span.end, equals(location));
+    expect(span.text, isEmpty);
+  });
+
+  group("compareTo()", () {
+    test("sorts by offset", () {
+      var other = new SourceLocation(20, sourceUrl: "foo.dart");
+      expect(location.compareTo(other), lessThan(0));
+      expect(other.compareTo(location), greaterThan(0));
+    });
+
+    test("considers equal locations equal", () {
+      expect(location.compareTo(location), equals(0));
+    });
+  });
+
+
+  group("equality", () {
+    test("two locations with the same offset and source are equal", () {
+      var other = new SourceLocation(15, sourceUrl: "foo.dart");
+      expect(location, equals(other));
+    });
+
+    test("a different offset isn't equal", () {
+      var other = new SourceLocation(10, sourceUrl: "foo.dart");
+      expect(location, isNot(equals(other)));
+    });
+
+    test("a different source isn't equal", () {
+      var other = new SourceLocation(15, sourceUrl: "bar.dart");
+      expect(location, isNot(equals(other)));
+    });
+  });
+}
diff --git a/test/span_test.dart b/test/span_test.dart
new file mode 100644
index 0000000..c62753b
--- /dev/null
+++ b/test/span_test.dart
@@ -0,0 +1,256 @@
+// 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.
+
+import 'package:unittest/unittest.dart';
+import 'package:source_span/source_span.dart';
+import 'package:source_span/src/colors.dart' as colors;
+
+main() {
+  var span;
+  setUp(() {
+    span = new SourceSpan(
+        new SourceLocation(5, sourceUrl: "foo.dart"),
+        new SourceLocation(12, sourceUrl: "foo.dart"),
+        "foo bar");
+  });
+
+  group('errors', () {
+    group('for new SourceSpan()', () {
+      test('source URLs must match', () {
+        var start = new SourceLocation(0, sourceUrl: "foo.dart");
+        var end = new SourceLocation(1, sourceUrl: "bar.dart");
+        expect(() => new SourceSpan(start, end, "_"), throwsArgumentError);
+      });
+
+      test('end must come after start', () {
+        var start = new SourceLocation(1);
+        var end = new SourceLocation(0);
+        expect(() => new SourceSpan(start, end, "_"), throwsArgumentError);
+      });
+
+      test('text must be the right length', () {
+        var start = new SourceLocation(0);
+        var end = new SourceLocation(1);
+        expect(() => new SourceSpan(start, end, "abc"), throwsArgumentError);
+      });
+    });
+
+    group('for union()', () {
+      test('source URLs must match', () {
+        var other = new SourceSpan(
+            new SourceLocation(12, sourceUrl: "bar.dart"),
+            new SourceLocation(13, sourceUrl: "bar.dart"),
+            "_");
+
+        expect(() => span.union(other), throwsArgumentError);
+      });
+
+      test('spans may not be disjoint', () {
+        var other = new SourceSpan(
+            new SourceLocation(13, sourceUrl: 'foo.dart'),
+            new SourceLocation(14, sourceUrl: 'foo.dart'),
+            "_");
+
+        expect(() => span.union(other), throwsArgumentError);
+      });
+    });
+
+    test('for compareTo() source URLs must match', () {
+      var other = new SourceSpan(
+          new SourceLocation(12, sourceUrl: "bar.dart"),
+          new SourceLocation(13, sourceUrl: "bar.dart"),
+          "_");
+
+      expect(() => span.compareTo(other), throwsArgumentError);
+    });
+  });
+
+  test('fields work correctly', () {
+    expect(span.start, equals(new SourceLocation(5, sourceUrl: "foo.dart")));
+    expect(span.end, equals(new SourceLocation(12, sourceUrl: "foo.dart")));
+    expect(span.sourceUrl, equals(Uri.parse("foo.dart")));
+    expect(span.length, equals(7));
+  });
+
+  group("union()", () {
+    test("works with a preceding adjacent span", () {
+      var other = new SourceSpan(
+          new SourceLocation(0, sourceUrl: "foo.dart"),
+          new SourceLocation(5, sourceUrl: "foo.dart"),
+          "hey, ");
+
+      var result = span.union(other);
+      expect(result.start, equals(other.start));
+      expect(result.end, equals(span.end));
+      expect(result.text, equals("hey, foo bar"));
+    });
+
+    test("works with a preceding overlapping span", () {
+      var other = new SourceSpan(
+          new SourceLocation(0, sourceUrl: "foo.dart"),
+          new SourceLocation(8, sourceUrl: "foo.dart"),
+          "hey, foo");
+
+      var result = span.union(other);
+      expect(result.start, equals(other.start));
+      expect(result.end, equals(span.end));
+      expect(result.text, equals("hey, foo bar"));
+    });
+
+    test("works with a following adjacent span", () {
+      var other = new SourceSpan(
+          new SourceLocation(12, sourceUrl: "foo.dart"),
+          new SourceLocation(16, sourceUrl: "foo.dart"),
+          " baz");
+
+      var result = span.union(other);
+      expect(result.start, equals(span.start));
+      expect(result.end, equals(other.end));
+      expect(result.text, equals("foo bar baz"));
+    });
+
+    test("works with a following overlapping span", () {
+      var other = new SourceSpan(
+          new SourceLocation(9, sourceUrl: "foo.dart"),
+          new SourceLocation(16, sourceUrl: "foo.dart"),
+          "bar baz");
+
+      var result = span.union(other);
+      expect(result.start, equals(span.start));
+      expect(result.end, equals(other.end));
+      expect(result.text, equals("foo bar baz"));
+    });
+
+    test("works with an internal overlapping span", () {
+      var other = new SourceSpan(
+          new SourceLocation(7, sourceUrl: "foo.dart"),
+          new SourceLocation(10, sourceUrl: "foo.dart"),
+          "o b");
+
+      expect(span.union(other), equals(span));
+    });
+
+    test("works with an external overlapping span", () {
+      var other = new SourceSpan(
+          new SourceLocation(0, sourceUrl: "foo.dart"),
+          new SourceLocation(16, sourceUrl: "foo.dart"),
+          "hey, foo bar baz");
+
+      expect(span.union(other), equals(other));
+    });
+  });
+
+  group("message()", () {
+    test("prints the text being described", () {
+      expect(span.message("oh no"), equals("""
+line 1, column 6 of foo.dart: oh no
+foo bar
+^^^^^^^"""));
+    });
+
+    test("gracefully handles a missing source URL", () {
+      var span = new SourceSpan(
+          new SourceLocation(5), new SourceLocation(12), "foo bar");
+
+      expect(span.message("oh no"), equalsIgnoringWhitespace("""
+line 1, column 6: oh no
+foo bar
+^^^^^^^"""));
+    });
+
+    test("gracefully handles empty text", () {
+      var span = new SourceSpan(
+          new SourceLocation(5), new SourceLocation(5), "");
+
+      expect(span.message("oh no"),
+          equals("line 1, column 6: oh no"));
+    });
+
+    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
+^^^^^^^"""));
+    });
+
+    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}"""));
+    });
+
+    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}"""));
+    });
+  });
+
+  group("compareTo()", () {
+    test("sorts by start location first", () {
+      var other = new SourceSpan(
+          new SourceLocation(6, sourceUrl: "foo.dart"),
+          new SourceLocation(14, sourceUrl: "foo.dart"),
+          "oo bar b");
+
+      expect(span.compareTo(other), lessThan(0));
+      expect(other.compareTo(span), greaterThan(0));
+    });
+
+    test("sorts by length second", () {
+      var other = new SourceSpan(
+          new SourceLocation(5, sourceUrl: "foo.dart"),
+          new SourceLocation(14, sourceUrl: "foo.dart"),
+          "foo bar b");
+
+      expect(span.compareTo(other), lessThan(0));
+      expect(other.compareTo(span), greaterThan(0));
+    });
+
+    test("considers equal spans equal", () {
+      expect(span.compareTo(span), equals(0));
+    });
+  });
+
+  group("equality", () {
+    test("two spans with the same locations are equal", () {
+      var other = new SourceSpan(
+          new SourceLocation(5, sourceUrl: "foo.dart"),
+          new SourceLocation(12, sourceUrl: "foo.dart"),
+          "foo bar");
+
+      expect(span, equals(other));
+    });
+
+    test("a different start isn't equal", () {
+      var other = new SourceSpan(
+          new SourceLocation(0, sourceUrl: "foo.dart"),
+          new SourceLocation(12, sourceUrl: "foo.dart"),
+          "hey, foo bar");
+
+      expect(span, isNot(equals(other)));
+    });
+
+    test("a different end isn't equal", () {
+      var other = new SourceSpan(
+          new SourceLocation(5, sourceUrl: "foo.dart"),
+          new SourceLocation(16, sourceUrl: "foo.dart"),
+          "foo bar baz");
+
+      expect(span, isNot(equals(other)));
+    });
+
+    test("a different source URL isn't equal", () {
+      var other = new SourceSpan(
+          new SourceLocation(5, sourceUrl: "bar.dart"),
+          new SourceLocation(12, sourceUrl: "bar.dart"),
+          "foo bar");
+
+      expect(span, isNot(equals(other)));
+    });
+  });
+}
diff --git a/test/utils_test.dart b/test/utils_test.dart
new file mode 100644
index 0000000..3921111
--- /dev/null
+++ b/test/utils_test.dart
@@ -0,0 +1,51 @@
+// Copyright (c) 2013, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:unittest/unittest.dart';
+import 'package:source_span/src/utils.dart';
+
+main() {
+  group('binary search', () {
+    test('empty', () {
+      expect(binarySearch([], (x) => true), -1);
+    });
+
+    test('single element', () {
+      expect(binarySearch([1], (x) => true), 0);
+      expect(binarySearch([1], (x) => false), 1);
+    });
+
+    test('no matches', () {
+      var list = [1, 2, 3, 4, 5, 6, 7];
+      expect(binarySearch(list, (x) => false), list.length);
+    });
+
+    test('all match', () {
+      var list = [1, 2, 3, 4, 5, 6, 7];
+      expect(binarySearch(list, (x) => true), 0);
+    });
+
+    test('compare with linear search', () {
+      for (int size = 0; size < 100; size++) {
+        var list = [];
+        for (int i = 0; i < size; i++) {
+          list.add(i);
+        }
+        for (int pos = 0; pos <= size; pos++) {
+          expect(binarySearch(list, (x) => x >= pos),
+              _linearSearch(list, (x) => x >= pos));
+        }
+      }
+    });
+  });
+}
+
+_linearSearch(list, predicate) {
+  if (list.length == 0) return -1;
+  for (int i = 0; i < list.length; i++) {
+    if (predicate(list[i])) return i;
+  }
+  return list.length;
+}
+