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;
+}
+