Add support for highlighting multiple spans at once (#49)
This makes it possible for applications to provide additional context
in their span-based messages. Like the existing highlight format, it's
heavily inspired by [Rust's error messages][].
[Rust's error messages]: https://blog.rust-lang.org/images/2016-08-09-Errors/new_errors.png
For maximal backwards-compatibility, this doesn't change the
formatting of any single-span highlights and it uses extension methods
rather than adding new methods to existing classes.
diff --git a/.travis.yml b/.travis.yml
index edb90e6..9738ed5 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,7 +2,7 @@
dart:
- dev
- - 2.1.0
+ - 2.6.0
dart_task:
- test: --platform vm,chrome
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0dfe483..014cc4c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,11 @@
+# 1.6.0
+
+* Add support for highlighting multiple source spans at once, providing more
+ context for span-based messages. This is exposed through the new APIs
+ `SourceSpan.highlightMultiple()` and `SourceSpan.messageMultiple()` (both
+ extension methods), `MultiSourceSpanException`, and
+ `MultiSourceSpanFormatException`.
+
# 1.5.6
* Fix padding around line numbers that are powers of 10 in
diff --git a/analysis_options.yaml b/analysis_options.yaml
index a94bb50..0ce7911 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -2,6 +2,9 @@
analyzer:
strong-mode:
implicit-casts: false
+ errors:
+ # TODO(natebosch): fix and re-enable.
+ prefer_single_quotes: ignore
linter:
rules:
- always_declare_return_types
@@ -26,7 +29,8 @@
- await_only_futures
- camel_case_types
- cancel_subscriptions
- - cascade_invocations
+ # TODO(natebosch): fix and re-enable.
+ #- cascade_invocations
- comment_references
- constant_identifier_names
- control_flow_in_finally
@@ -59,14 +63,15 @@
- prefer_contains
- prefer_equal_for_default_values
- prefer_final_fields
- - prefer_final_locals
+ # TODO(natebosch): fix and re-enable.
+ #- prefer_final_locals
- prefer_generic_function_type_aliases
- prefer_initializing_formals
- - prefer_interpolation_to_compose_strings
+ # TODO(natebosch): fix and re-enable.
+ #- prefer_interpolation_to_compose_strings
- prefer_is_empty
- prefer_is_not_empty
- prefer_null_aware_operators
- - prefer_single_quotes
- prefer_typing_uninitialized_variables
- recursive_getters
- slash_for_doc_comments
diff --git a/lib/src/highlighter.dart b/lib/src/highlighter.dart
index 3ebe0df..feb5594 100644
--- a/lib/src/highlighter.dart
+++ b/lib/src/highlighter.dart
@@ -5,6 +5,9 @@
import 'dart:math' as math;
import 'package:charcode/charcode.dart';
+import 'package:collection/collection.dart';
+import 'package:meta/meta.dart';
+import 'package:path/path.dart' as p;
import 'package:term_glyph/term_glyph.dart' as glyph;
import 'colors.dart' as colors;
@@ -15,25 +18,26 @@
/// A class for writing a chunk of text with a particular span highlighted.
class Highlighter {
- /// The span to highlight.
- final SourceSpanWithContext _span;
+ /// The lines to display, including context around the highlighted spans.
+ final List<_Line> _lines;
- /// The color to highlight [_span] within its context, or `null` if the span
- /// should not be colored.
- final String _color;
+ /// The color to highlight the primary [_Highlight] within its context, or
+ /// `null` if it should not be colored.
+ final String _primaryColor;
- /// Whether [_span] covers multiple lines.
- final bool _multiline;
+ /// The color to highlight the secondary [_Highlight]s within their context,
+ /// or `null` if they should not be colored.
+ final String _secondaryColor;
/// The number of characters before the bar in the sidebar.
final int _paddingBeforeSidebar;
- // The number of characters between the bar in the sidebar and the text
- // being highlighted.
- int get _paddingAfterSidebar =>
- // This is just a space for a single-line span, but for a multi-line span
- // needs to accommodate " | ".
- _multiline ? 3 : 1;
+ /// The maximum number of multiline spans that cover any part of a single
+ /// line in [_lines].
+ final int _maxMultilineSpans;
+
+ /// Whether [_lines] includes lines from multiple different files.
+ final bool _multipleFiles;
/// The buffer to which to write the result.
final _buffer = StringBuffer();
@@ -44,29 +48,479 @@
/// alignment.
static const _spacesPerTab = 4;
- /// Creates a [Highlighter] that will return a message associated with [span]
- /// when [highlight] is called.
+ /// Creates a [Highlighter] that will return a string highlighting [span]
+ /// within the text of its file when [highlight] is called.
///
/// [color] may either be a [String], a [bool], or `null`. If it's a string,
- /// it indicates an [ANSI terminal color escape][] that should
- /// be used to highlight the span's text (for example, `"\u001b[31m"` will
- /// color red). If it's `true`, it indicates that the text should be
- /// highlighted using the default color. If it's `false` or `null`, it
- /// indicates that the text shouldn't be highlighted.
+ /// it indicates an [ANSI terminal color escape][] that should be used to
+ /// highlight [span]'s text (for example, `"\u001b[31m"` will color red). If
+ /// it's `true`, it indicates that the text should be highlighted using the
+ /// default color. If it's `false` or `null`, it indicates that no color
+ /// should be used.
///
/// [ANSI terminal color escape]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
- factory Highlighter(SourceSpan span, {color}) {
- if (color == true) color = colors.red;
- if (color == false) color = null;
+ Highlighter(SourceSpan span, {color})
+ : this._(_collateLines([_Highlight(span, primary: true)]), () {
+ if (color == true) return colors.red;
+ if (color == false) return null;
+ return color as String;
+ }(), null);
- var newSpan = _normalizeContext(span);
- newSpan = _normalizeNewlines(newSpan);
- newSpan = _normalizeTrailingNewline(newSpan);
- newSpan = _normalizeEndOfLine(newSpan);
+ /// Creates a [Highlighter] that will return a string highlighting
+ /// [primarySpan] as well as all the spans in [secondarySpans] within the text
+ /// of their file when [highlight] is called.
+ ///
+ /// Each span has an associated label that will be written alongside it. For
+ /// [primarySpan] this message is [primaryLabel], and for [secondarySpans] the
+ /// labels are the map values.
+ ///
+ /// If [color] is `true`, this will use [ANSI terminal color escapes][] to
+ /// highlight the text. The [primarySpan] will be highlighted with
+ /// [primaryColor] (which defaults to red), and the [secondarySpans] will be
+ /// highlighted with [secondaryColor] (which defaults to blue). These
+ /// arguments are ignored if [color] is `false`.
+ ///
+ /// [ANSI terminal color escape]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
+ Highlighter.multiple(SourceSpan primarySpan, String primaryLabel,
+ Map<SourceSpan, String> secondarySpans,
+ {bool color = false, String primaryColor, String secondaryColor})
+ : this._(
+ _collateLines([
+ _Highlight(primarySpan, label: primaryLabel, primary: true),
+ for (var entry in secondarySpans.entries)
+ _Highlight(entry.key, label: entry.value)
+ ]),
+ color ? (primaryColor ?? colors.red) : null,
+ color ? (secondaryColor ?? colors.blue) : null);
- return Highlighter._(newSpan, color as String);
+ Highlighter._(this._lines, this._primaryColor, this._secondaryColor)
+ : _paddingBeforeSidebar = 1 +
+ math.max<int>(
+ // In a purely mathematical world, floor(log10(n)) would give the
+ // number of digits in n, but floating point errors render that
+ // unreliable in practice.
+ (_lines.last.number + 1).toString().length,
+ // If [_lines] aren't contiguous, we'll write "..." in place of a
+ // line number.
+ _contiguous(_lines) ? 0 : 3),
+ _maxMultilineSpans = _lines
+ .map((line) => line.highlights
+ .where((highlight) => isMultiline(highlight.span))
+ .length)
+ .reduce(math.max),
+ _multipleFiles = !isAllTheSame(_lines.map((line) => line.url));
+
+ /// Returns whether [lines] contains any adjacent lines from the same source
+ /// file that aren't adjacent in the original file.
+ static bool _contiguous(List<_Line> lines) {
+ for (var i = 0; i < lines.length - 1; i++) {
+ var thisLine = lines[i];
+ var nextLine = lines[i + 1];
+ if (thisLine.number + 1 != nextLine.number &&
+ thisLine.url == nextLine.url) {
+ return false;
+ }
+ }
+ return true;
}
+ /// Collect all the source lines from the contexts of all spans in
+ /// [highlights], and associates them with the highlights that cover them.
+ static List<_Line> _collateLines(List<_Highlight> highlights) {
+ var highlightsByUrl =
+ groupBy(highlights, (highlight) => highlight.span.sourceUrl);
+ for (var list in highlightsByUrl.values) {
+ list.sort((highlight1, highlight2) =>
+ highlight1.span.compareTo(highlight2.span));
+ }
+
+ return highlightsByUrl.values.expand((highlightsForFile) {
+ // First, create a list of all the lines in the current file that we have
+ // context for along with their line numbers.
+ var lines = <_Line>[];
+ for (var highlight in highlightsForFile) {
+ var context = highlight.span.context;
+ // If [highlight.span.context] contains lines prior to the one
+ // [highlight.span.text] appears on, write those first.
+ var lineStart = findLineStart(
+ context, highlight.span.text, highlight.span.start.column);
+ assert(lineStart != null); // enforced by [_normalizeContext]
+
+ var linesBeforeSpan =
+ '\n'.allMatches(context.substring(0, lineStart)).length;
+
+ var url = highlight.span.sourceUrl;
+ var lineNumber = highlight.span.start.line - linesBeforeSpan;
+ for (var line in context.split('\n')) {
+ // Only add a line if it hasn't already been added for a previous span.
+ if (lines.isEmpty || lineNumber > lines.last.number) {
+ lines.add(_Line(line, lineNumber, url));
+ }
+ lineNumber++;
+ }
+ }
+
+ // Next, associate each line with each highlights that covers it.
+ var activeHighlights = <_Highlight>[];
+ var highlightIndex = 0;
+ for (var line in lines) {
+ activeHighlights.removeWhere((highlight) =>
+ highlight.span.sourceUrl != line.url ||
+ highlight.span.end.line < line.number);
+
+ var oldHighlightLength = activeHighlights.length;
+ for (var highlight in highlightsForFile.skip(highlightIndex)) {
+ if (highlight.span.start.line > line.number) break;
+ if (highlight.span.sourceUrl != line.url) break;
+ activeHighlights.add(highlight);
+ }
+ highlightIndex += activeHighlights.length - oldHighlightLength;
+
+ line.highlights.addAll(activeHighlights);
+ }
+
+ return lines;
+ }).toList();
+ }
+
+ /// Returns the highlighted span text.
+ ///
+ /// This method should only be called once.
+ String highlight() {
+ _writeFileStart(_lines.first.url);
+
+ // Each index of this list represents a column after the sidebar that could
+ // contain a line indicating an active highlight. If it's `null`, that
+ // column is empty; if it contains a highlight, it should be drawn for that column.
+ var highlightsByColumn = List<_Highlight>(_maxMultilineSpans);
+
+ for (var i = 0; i < _lines.length; i++) {
+ var line = _lines[i];
+ if (i > 0) {
+ var lastLine = _lines[i - 1];
+ if (lastLine.url != line.url) {
+ _writeSidebar(end: glyph.upEnd);
+ _buffer.writeln();
+ _writeFileStart(line.url);
+ } else if (lastLine.number + 1 != line.number) {
+ _writeSidebar(text: '...');
+ _buffer.writeln();
+ }
+ }
+
+ // If a highlight covers the entire first line other than initial
+ // whitespace, don't bother pointing out exactly where it begins. Iterate
+ // in reverse so that longer highlights (which are sorted after shorter
+ // highlights) appear further out, leading to fewer crossed lines.
+ for (var highlight in line.highlights.reversed) {
+ if (isMultiline(highlight.span) &&
+ highlight.span.start.line == line.number &&
+ _isOnlyWhitespace(
+ line.text.substring(0, highlight.span.start.column))) {
+ replaceFirstNull(highlightsByColumn, highlight);
+ }
+ }
+
+ _writeSidebar(line: line.number);
+ _buffer.write(' ');
+ _writeMultilineHighlights(line, highlightsByColumn);
+ if (highlightsByColumn.isNotEmpty) _buffer.write(' ');
+
+ var primary = line.highlights
+ .firstWhere((highlight) => highlight.isPrimary, orElse: () => null);
+ if (primary != null) {
+ _writeHighlightedText(
+ line.text,
+ primary.span.start.line == line.number
+ ? primary.span.start.column
+ : 0,
+ primary.span.end.line == line.number
+ ? primary.span.end.column
+ : line.text.length,
+ color: _primaryColor);
+ } else {
+ _writeText(line.text);
+ }
+ _buffer.writeln();
+
+ // Always write the primary span's indicator first so that it's right next
+ // to the highlighted text.
+ if (primary != null) _writeIndicator(line, primary, highlightsByColumn);
+ for (var highlight in line.highlights) {
+ if (highlight.isPrimary) continue;
+ _writeIndicator(line, highlight, highlightsByColumn);
+ }
+ }
+
+ _writeSidebar(end: glyph.upEnd);
+ return _buffer.toString();
+ }
+
+ /// Writes the beginning of the file highlight for the file with the given
+ /// [url].
+ void _writeFileStart(Uri url) {
+ if (!_multipleFiles || url == null) {
+ _writeSidebar(end: glyph.downEnd);
+ } else {
+ _writeSidebar(end: glyph.topLeftCorner);
+ _colorize(() => _buffer.write("${glyph.horizontalLine * 2}>"),
+ color: colors.blue);
+ _buffer.write(" ${p.prettyUri(url)}");
+ }
+ _buffer.writeln();
+ }
+
+ /// Writes the post-sidebar highlight bars for [line] according to
+ /// [highlightsByColumn].
+ ///
+ /// If [current] is passed, it's the highlight for which an indicator is being
+ /// written. If it appears in [highlightsByColumn], a horizontal line is
+ /// written from its column to the rightmost column.
+ void _writeMultilineHighlights(
+ _Line line, List<_Highlight> highlightsByColumn,
+ {_Highlight current}) {
+ // Whether we've written a sidebar indicator for opening a new span on this
+ // line, and which color should be used for that indicator's rightward line.
+ var openedOnThisLine = false;
+ String openedOnThisLineColor;
+
+ var currentColor = current == null
+ ? null
+ : current.isPrimary ? _primaryColor : _secondaryColor;
+ var foundCurrent = false;
+ for (var highlight in highlightsByColumn) {
+ var startLine = highlight?.span?.start?.line;
+ var endLine = highlight?.span?.end?.line;
+ if (current != null && highlight == current) {
+ foundCurrent = true;
+ assert(startLine == line.number || endLine == line.number);
+ _colorize(() {
+ _buffer.write(startLine == line.number
+ ? glyph.topLeftCorner
+ : glyph.bottomLeftCorner);
+ }, color: currentColor);
+ } else if (foundCurrent) {
+ _colorize(() {
+ _buffer.write(highlight == null ? glyph.horizontalLine : glyph.cross);
+ }, color: currentColor);
+ } else if (highlight == null) {
+ if (openedOnThisLine) {
+ _colorize(() => _buffer.write(glyph.horizontalLine),
+ color: openedOnThisLineColor);
+ } else {
+ _buffer.write(' ');
+ }
+ } else {
+ _colorize(() {
+ var vertical = openedOnThisLine ? glyph.cross : glyph.verticalLine;
+ if (current != null) {
+ _buffer.write(vertical);
+ } else if (startLine == line.number) {
+ _colorize(() {
+ _buffer
+ .write(glyph.glyphOrAscii(openedOnThisLine ? '┬' : '┌', '/'));
+ }, color: openedOnThisLineColor);
+ openedOnThisLine = true;
+ openedOnThisLineColor ??=
+ highlight.isPrimary ? _primaryColor : _secondaryColor;
+ } else if (endLine == line.number &&
+ highlight.span.end.column == line.text.length) {
+ _buffer.write(highlight.label == null
+ ? glyph.glyphOrAscii('└', '\\')
+ : vertical);
+ } else {
+ _colorize(() {
+ _buffer.write(vertical);
+ }, color: openedOnThisLineColor);
+ }
+ }, color: highlight.isPrimary ? _primaryColor : _secondaryColor);
+ }
+ }
+ }
+
+ // Writes [text], with text between [startColumn] and [endColumn] colorized in
+ // the same way as [_colorize].
+ void _writeHighlightedText(String text, int startColumn, int endColumn,
+ {@required String color}) {
+ _writeText(text.substring(0, startColumn));
+ _colorize(() => _writeText(text.substring(startColumn, endColumn)),
+ color: color);
+ _writeText(text.substring(endColumn, text.length));
+ }
+
+ /// Writes an indicator for where [highlight] starts, ends, or both below
+ /// [line].
+ ///
+ /// This may either add or remove [highlight] from [highlightsByColumn].
+ void _writeIndicator(
+ _Line line, _Highlight highlight, List<_Highlight> highlightsByColumn) {
+ var color = highlight.isPrimary ? _primaryColor : _secondaryColor;
+ if (!isMultiline(highlight.span)) {
+ _writeSidebar();
+ _buffer.write(' ');
+ _writeMultilineHighlights(line, highlightsByColumn, current: highlight);
+ if (highlightsByColumn.isNotEmpty) _buffer.write(' ');
+
+ _colorize(() {
+ _writeUnderline(line, highlight.span,
+ highlight.isPrimary ? "^" : glyph.horizontalLineBold);
+ _writeLabel(highlight.label);
+ }, color: color);
+ _buffer.writeln();
+ } else if (highlight.span.start.line == line.number) {
+ if (highlightsByColumn.contains(highlight)) return;
+ replaceFirstNull(highlightsByColumn, highlight);
+
+ _writeSidebar();
+ _buffer.write(' ');
+ _writeMultilineHighlights(line, highlightsByColumn, current: highlight);
+ _colorize(() => _writeArrow(line, highlight.span.start.column),
+ color: color);
+ _buffer.writeln();
+ } else if (highlight.span.end.line == line.number) {
+ var coversWholeLine = highlight.span.end.column == line.text.length;
+ if (coversWholeLine && highlight.label == null) {
+ replaceWithNull(highlightsByColumn, highlight);
+ return;
+ }
+
+ _writeSidebar();
+ _buffer.write(' ');
+ _writeMultilineHighlights(line, highlightsByColumn, current: highlight);
+
+ _colorize(() {
+ if (coversWholeLine) {
+ _buffer.write(glyph.horizontalLine * 3);
+ } else {
+ _writeArrow(line, math.max(highlight.span.end.column - 1, 0),
+ beginning: false);
+ }
+ _writeLabel(highlight.label);
+ }, color: color);
+ _buffer.writeln();
+ replaceWithNull(highlightsByColumn, highlight);
+ }
+ }
+
+ /// Underlines the portion of [line] covered by [span] with repeated instances
+ /// of [character].
+ void _writeUnderline(_Line line, SourceSpan span, String character) {
+ assert(!isMultiline(span));
+ assert(line.text.contains(span.text));
+
+ var startColumn = span.start.column;
+ var endColumn = span.end.column;
+
+ // Adjust the start and end columns to account for any tabs that were
+ // converted to spaces.
+ var tabsBefore = _countTabs(line.text.substring(0, startColumn));
+ var tabsInside = _countTabs(line.text.substring(startColumn, endColumn));
+ startColumn += tabsBefore * (_spacesPerTab - 1);
+ endColumn += (tabsBefore + tabsInside) * (_spacesPerTab - 1);
+
+ _buffer.write(" " * startColumn);
+ _buffer.write(character * math.max(endColumn - startColumn, 1));
+ }
+
+ /// Write an arrow pointing to column [column] in [line].
+ ///
+ /// If the arrow points to a tab character, this will point to the beginning
+ /// of the tab if [beginning] is `true` and the end if it's `false`.
+ void _writeArrow(_Line line, int column, {bool beginning = true}) {
+ var tabs = _countTabs(line.text.substring(0, column + (beginning ? 0 : 1)));
+ _buffer
+ ..write(glyph.horizontalLine * (1 + column + tabs * (_spacesPerTab - 1)))
+ ..write("^");
+ }
+
+ /// Writes a space followed by [label] if [label] isn't `null`.
+ void _writeLabel(String label) {
+ if (label != null) _buffer.write(" $label");
+ }
+
+ /// Writes a snippet from the source text, converting hard tab characters into
+ /// plain indentation.
+ void _writeText(String text) {
+ for (var char in text.codeUnits) {
+ if (char == $tab) {
+ _buffer.write(' ' * _spacesPerTab);
+ } else {
+ _buffer.writeCharCode(char);
+ }
+ }
+ }
+
+ // Writes a sidebar to [buffer] that includes [line] as the line number if
+ // given and writes [end] at the end (defaults to [glyphs.verticalLine]).
+ //
+ // If [text] is given, it's used in place of the line number. It can't be
+ // passed at the same time as [line].
+ void _writeSidebar({int line, String text, String end}) {
+ assert(line == null || text == null);
+
+ // Add 1 to line to convert from computer-friendly 0-indexed line numbers to
+ // human-friendly 1-indexed line numbers.
+ if (line != null) text = (line + 1).toString();
+ _colorize(() {
+ _buffer.write((text ?? '').padRight(_paddingBeforeSidebar));
+ _buffer.write(end ?? glyph.verticalLine);
+ }, color: colors.blue);
+ }
+
+ /// Returns the number of hard tabs in [text].
+ int _countTabs(String text) {
+ var count = 0;
+ for (var char in text.codeUnits) {
+ if (char == $tab) count++;
+ }
+ return count;
+ }
+
+ /// Returns whether [text] contains only space or tab characters.
+ bool _isOnlyWhitespace(String text) {
+ for (var char in text.codeUnits) {
+ if (char != $space && char != $tab) return false;
+ }
+ return true;
+ }
+
+ /// Colors all text written to [_buffer] during [callback], if colorization is
+ /// enabled and [color] is not `null`.
+ void _colorize(void Function() callback, {@required String color}) {
+ if (_primaryColor != null && color != null) _buffer.write(color);
+ callback();
+ if (_primaryColor != null && color != null) _buffer.write(colors.none);
+ }
+}
+
+/// Information about how to highlight a single section of a source file.
+class _Highlight {
+ /// The section of the source file to highlight.
+ ///
+ /// This is normalized to make it easier for [Highlighter] to work with.
+ final SourceSpanWithContext span;
+
+ /// Whether this is the primary span in the highlight.
+ ///
+ /// The primary span is highlighted with a different character and colored
+ /// differently than non-primary spans.
+ final bool isPrimary;
+
+ /// The label to include inline when highlighting [span].
+ ///
+ /// This helps distinguish clarify what each highlight means when multiple are
+ /// used in the same message.
+ final String label;
+
+ _Highlight(SourceSpan span, {this.label, bool primary = false})
+ : span = (() {
+ var newSpan = _normalizeContext(span);
+ newSpan = _normalizeNewlines(newSpan);
+ newSpan = _normalizeTrailingNewline(newSpan);
+ return _normalizeEndOfLine(newSpan);
+ })(),
+ isPrimary = primary;
+
/// Normalizes [span] to ensure that it's a [SourceSpanWithContext] whose
/// context actually contains its text at the expected column.
///
@@ -128,11 +582,15 @@
var end = span.end;
if (span.text.endsWith('\n') && _isTextAtEndOfContext(span)) {
text = span.text.substring(0, span.text.length - 1);
- end = SourceLocation(span.end.offset - 1,
- sourceUrl: span.sourceUrl,
- line: span.end.line - 1,
- column: _lastLineLength(text));
- start = span.start.offset == span.end.offset ? end : span.start;
+ if (text.isEmpty) {
+ end = start;
+ } else {
+ end = SourceLocation(span.end.offset - 1,
+ sourceUrl: span.sourceUrl,
+ line: span.end.line - 1,
+ column: _lastLineLength(context));
+ start = span.start.offset == span.end.offset ? end : span.start;
+ }
}
return SourceSpanWithContext(start, end, text, context);
}
@@ -150,18 +608,21 @@
SourceLocation(span.end.offset - 1,
sourceUrl: span.sourceUrl,
line: span.end.line - 1,
- column: _lastLineLength(text)),
+ column: text.length - text.lastIndexOf('\n') - 1),
text,
- span.context);
+ // If the context also ends with a newline, it's possible that we don't
+ // have the full context for that line, so we shouldn't print it at all.
+ span.context.endsWith("\n")
+ ? span.context.substring(0, span.context.length - 1)
+ : span.context);
}
/// Returns the length of the last line in [text], whether or not it ends in a
/// newline.
static int _lastLineLength(String text) {
- if (text.isEmpty) return 0;
-
- // The "- 1" here avoids counting the newline itself.
- if (text.codeUnitAt(text.length - 1) == $lf) {
+ if (text.isEmpty) {
+ return 0;
+ } else if (text.codeUnitAt(text.length - 1) == $lf) {
return text.length == 1
? 0
: text.length - text.lastIndexOf('\n', text.length - 2) - 1;
@@ -177,250 +638,35 @@
span.length ==
span.context.length;
- Highlighter._(this._span, this._color)
- : _multiline = _span.start.line != _span.end.line,
- // In a purely mathematical world, floor(log10(n)) would give the number of
- // digits in n, but floating point errors render that unreliable in
- // practice.
- _paddingBeforeSidebar = (_span.end.line + 1).toString().length + 1;
+ @override
+ String toString() {
+ var buffer = StringBuffer();
+ if (isPrimary) buffer.write("primary ");
+ buffer.write("${span.start.line}:${span.start.column}-"
+ "${span.end.line}:${span.end.column}");
+ if (label != null) buffer.write(" ($label)");
+ return buffer.toString();
+ }
+}
- /// Returns the highlighted span text.
+/// A single line of the source file being highlighted.
+class _Line {
+ /// The text of the line, not including the trailing newline.
+ final String text;
+
+ /// The 0-based line number in the source file.
+ final int number;
+
+ /// The URL of the source file in which this line appears.
+ final Uri url;
+
+ /// All highlights that cover any portion of this line, in source span order.
///
- /// This method should only be called once.
- String highlight() {
- _writeSidebar(end: glyph.downEnd);
- _buffer.writeln();
+ /// This is populated after the initial line is created.
+ final highlights = <_Highlight>[];
- // If [_span.context] contains lines prior to the one [_span.text] appears
- // on, write those first.
- final lineStart =
- findLineStart(_span.context, _span.text, _span.start.column);
- assert(lineStart != null); // enforced by [_normalizeContext]
+ _Line(this.text, this.number, this.url);
- var context = _span.context;
- if (lineStart > 0) {
- // Take a substring to one character *before* [lineStart] because
- // [findLineStart] is guaranteed to return a position immediately after a
- // newline. Including that newline would add an extra empty line to the
- // end of [lines].
- final lines = context.substring(0, lineStart - 1).split('\n');
- var lineNumber = _span.start.line - lines.length;
- for (var line in lines) {
- _writeSidebar(line: lineNumber);
- _buffer.write(' ' * _paddingAfterSidebar);
- _writeText(line);
- _buffer.writeln();
- lineNumber++;
- }
- context = context.substring(lineStart);
- }
-
- final lines = context.split('\n');
-
- final lastLineIndex = _span.end.line - _span.start.line;
- if (lines.last.isEmpty && lines.length > lastLineIndex + 1) {
- // Trim a trailing newline so we don't add an empty line to the end of the
- // highlight.
- lines.removeLast();
- }
-
- _writeFirstLine(lines.first);
- if (_multiline) {
- _writeIntermediateLines(lines.skip(1).take(lastLineIndex - 1));
- _writeLastLine(lines[lastLineIndex]);
- }
- _writeTrailingLines(lines.skip(lastLineIndex + 1));
-
- _writeSidebar(end: glyph.upEnd);
-
- return _buffer.toString();
- }
-
- // Writes the first (and possibly only) line highlighted by the span.
- void _writeFirstLine(String line) {
- _writeSidebar(line: _span.start.line);
-
- var startColumn = math.min(_span.start.column, line.length);
- var endColumn = math.min(
- startColumn + _span.end.offset - _span.start.offset, line.length);
- final textBefore = line.substring(0, startColumn);
-
- // If the span covers the entire first line other than initial whitespace,
- // don't bother pointing out exactly where it begins.
- if (_multiline && _isOnlyWhitespace(textBefore)) {
- _buffer.write(' ');
- _colorize(() {
- _buffer..write(glyph.glyphOrAscii('┌', '/'))..write(' ');
- _writeText(line);
- });
- _buffer.writeln();
- return;
- }
-
- _buffer.write(' ' * _paddingAfterSidebar);
- _writeText(textBefore);
- final textInside = line.substring(startColumn, endColumn);
- _colorize(() => _writeText(textInside));
- _writeText(line.substring(endColumn));
- _buffer.writeln();
-
- // Adjust the start and end column to account for any tabs that were
- // converted to spaces.
- final tabsBefore = _countTabs(textBefore);
- final tabsInside = _countTabs(textInside);
- startColumn = startColumn + tabsBefore * (_spacesPerTab - 1);
- endColumn = endColumn + (tabsBefore + tabsInside) * (_spacesPerTab - 1);
-
- // Write the highlight for the first line. This is a series of carets for a
- // single-line span, and a pointer to the beginning of a multi-line span.
- _writeSidebar();
- if (_multiline) {
- _buffer.write(' ');
- _colorize(() {
- _buffer
- ..write(glyph.topLeftCorner)
- ..write(glyph.horizontalLine * (startColumn + 1))
- ..write('^');
- });
- } else {
- _buffer.write(' ' * (startColumn + 1));
- _colorize(
- () => _buffer.write('^' * math.max(endColumn - startColumn, 1)));
- }
- _buffer.writeln();
- }
-
- /// Writes the lines between the first and last lines highlighted by the span.
- void _writeIntermediateLines(Iterable<String> lines) {
- assert(_multiline);
-
- // +1 because the first line was already written.
- var lineNumber = _span.start.line + 1;
- for (var line in lines) {
- _writeSidebar(line: lineNumber);
-
- _buffer.write(' ');
- _colorize(() {
- _buffer..write(glyph.verticalLine)..write(' ');
- _writeText(line);
- });
- _buffer.writeln();
-
- lineNumber++;
- }
- }
-
- // Writes the last line highlighted by the span.
- void _writeLastLine(String line) {
- assert(_multiline);
-
- _writeSidebar(line: _span.end.line);
-
- var endColumn = math.min(_span.end.column, line.length);
-
- // If the span covers the entire last line, don't bother pointing out
- // exactly where it ends.
- if (_multiline && endColumn == line.length) {
- _buffer.write(' ');
- _colorize(() {
- _buffer..write(glyph.glyphOrAscii('└', '\\'))..write(' ');
- _writeText(line);
- });
- _buffer.writeln();
- return;
- }
-
- _buffer.write(' ');
- final textInside = line.substring(0, endColumn);
- _colorize(() {
- _buffer..write(glyph.verticalLine)..write(' ');
- _writeText(textInside);
- });
- _writeText(line.substring(endColumn));
- _buffer.writeln();
-
- // Adjust the end column to account for any tabs that were converted to
- // spaces.
- final tabsInside = _countTabs(textInside);
- endColumn = endColumn + tabsInside * (_spacesPerTab - 1);
-
- // Write the highlight for the final line, which is an arrow pointing to the
- // end of the span.
- _writeSidebar();
- _buffer.write(' ');
- _colorize(() {
- _buffer
- ..write(glyph.bottomLeftCorner)
- ..write(glyph.horizontalLine * endColumn)
- ..write('^');
- });
- _buffer.writeln();
- }
-
- /// Writes lines that appear in the context string but come after the span.
- void _writeTrailingLines(Iterable<String> lines) {
- // +1 because this comes after any lines covered by the span.
- var lineNumber = _span.end.line + 1;
- for (var line in lines) {
- _writeSidebar(line: lineNumber);
- _buffer.write(' ' * _paddingAfterSidebar);
- _writeText(line);
- _buffer.writeln();
- lineNumber++;
- }
- }
-
- /// Writes a snippet from the source text, converting hard tab characters into
- /// plain indentation.
- void _writeText(String text) {
- for (var char in text.codeUnits) {
- if (char == $tab) {
- _buffer.write(' ' * _spacesPerTab);
- } else {
- _buffer.writeCharCode(char);
- }
- }
- }
-
- // Writes a sidebar to [buffer] that includes [line] as the line number if
- // given and writes [end] at the end (defaults to [glyphs.verticalLine]).
- void _writeSidebar({int line, String end}) {
- _colorize(() {
- if (line != null) {
- // Add 1 to line to convert from computer-friendly 0-indexed line
- // numbers to human-friendly 1-indexed line numbers.
- _buffer.write((line + 1).toString().padRight(_paddingBeforeSidebar));
- } else {
- _buffer.write(' ' * _paddingBeforeSidebar);
- }
- _buffer.write(end ?? glyph.verticalLine);
- }, color: colors.blue);
- }
-
- /// Returns the number of hard tabs in [text].
- int _countTabs(String text) {
- var count = 0;
- for (var char in text.codeUnits) {
- if (char == $tab) count++;
- }
- return count;
- }
-
- /// Returns whether [text] contains only space or tab characters.
- bool _isOnlyWhitespace(String text) {
- for (var char in text.codeUnits) {
- if (char != $space && char != $tab) return false;
- }
- return true;
- }
-
- /// Colors all text written to [_buffer] during [callback], if colorization is
- /// enabled.
- ///
- /// If [color] is passed, it's used as the color; otherwise, [_color] is used.
- void _colorize(void Function() callback, {String color}) {
- if (_color != null) _buffer.write(color ?? _color);
- callback();
- if (_color != null) _buffer.write(colors.none);
- }
+ @override
+ String toString() => '$number: "$text" (${highlights.join(', ')})';
}
diff --git a/lib/src/span.dart b/lib/src/span.dart
index f329e37..51e81ab 100644
--- a/lib/src/span.dart
+++ b/lib/src/span.dart
@@ -2,8 +2,11 @@
// 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:path/path.dart' as p;
import 'package:term_glyph/term_glyph.dart' as glyph;
+import 'file.dart';
+import 'highlighter.dart';
import 'location.dart';
import 'span_mixin.dart';
import 'span_with_context.dart';
@@ -108,3 +111,72 @@
}
}
}
+
+// TODO(#52): Move these to instance methods in the next breaking release.
+/// Extension methods on the base [SourceSpan] API.
+extension SourceSpanExtension on SourceSpan {
+ /// Like [SourceSpan.message], but also highlights [secondarySpans] to provide
+ /// the user with additional context.
+ ///
+ /// Each span takes a label ([label] for this span, and the values of the
+ /// [secondarySpans] map for the secondary spans) that's used to indicate to
+ /// the user what that particular span represents.
+ ///
+ /// If [color] is `true`, [ANSI terminal color escapes][] are used to color
+ /// the resulting string. By default this span is colored red and the
+ /// secondary spans are colored blue, but that can be customized by passing
+ /// ANSI escape strings to [primaryColor] or [secondaryColor].
+ ///
+ /// [ANSI terminal color escapes]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
+ ///
+ /// Each span in [secondarySpans] must refer to the same document as this
+ /// span. Throws an [ArgumentError] if any secondary span has a different
+ /// source URL than this span.
+ ///
+ /// Note that while this will work with plain [SourceSpan]s, it will produce
+ /// much more useful output with [SourceSpanWithContext]s (including
+ /// [FileSpan]s).
+ String messageMultiple(
+ String message, String label, Map<SourceSpan, String> secondarySpans,
+ {bool color = false, String primaryColor, String secondaryColor}) {
+ final buffer = StringBuffer()
+ ..write('line ${start.line + 1}, column ${start.column + 1}');
+ if (sourceUrl != null) buffer.write(' of ${p.prettyUri(sourceUrl)}');
+ buffer
+ ..writeln(': $message')
+ ..write(highlightMultiple(label, secondarySpans,
+ color: color,
+ primaryColor: primaryColor,
+ secondaryColor: secondaryColor));
+ return buffer.toString();
+ }
+
+ /// Like [SourceSpan.highlight], but also highlights [secondarySpans] to
+ /// provide the user with additional context.
+ ///
+ /// Each span takes a label ([label] for this span, and the values of the
+ /// [secondarySpans] map for the secondary spans) that's used to indicate to
+ /// the user what that particular span represents.
+ ///
+ /// If [color] is `true`, [ANSI terminal color escapes][] are used to color
+ /// the resulting string. By default this span is colored red and the
+ /// secondary spans are colored blue, but that can be customized by passing
+ /// ANSI escape strings to [primaryColor] or [secondaryColor].
+ ///
+ /// [ANSI terminal color escapes]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
+ ///
+ /// Each span in [secondarySpans] must refer to the same document as this
+ /// span. Throws an [ArgumentError] if any secondary span has a different
+ /// source URL than this span.
+ ///
+ /// Note that while this will work with plain [SourceSpan]s, it will produce
+ /// much more useful output with [SourceSpanWithContext]s (including
+ /// [FileSpan]s).
+ String highlightMultiple(String label, Map<SourceSpan, String> secondarySpans,
+ {bool color = false, String primaryColor, String secondaryColor}) =>
+ Highlighter.multiple(this, label, secondarySpans,
+ color: color,
+ primaryColor: primaryColor,
+ secondaryColor: secondaryColor)
+ .highlight();
+}
diff --git a/lib/src/span_exception.dart b/lib/src/span_exception.dart
index 02c8974..5b33d06 100644
--- a/lib/src/span_exception.dart
+++ b/lib/src/span_exception.dart
@@ -37,7 +37,6 @@
/// A [SourceSpanException] that's also a [FormatException].
class SourceSpanFormatException extends SourceSpanException
implements FormatException {
- // This is a getter so that subclasses can override it.
@override
final dynamic source;
@@ -47,3 +46,71 @@
SourceSpanFormatException(String message, SourceSpan span, [this.source])
: super(message, span);
}
+
+/// A [SourceSpanException] that also highlights some secondary spans to provide
+/// the user with extra context.
+///
+/// Each span has a label ([primaryLabel] for the primary, and the values of the
+/// [secondarySpans] map for the secondary spans) that's used to indicate to the
+/// user what that particular span represents.
+class MultiSourceSpanException extends SourceSpanException {
+ /// A label to attach to [span] that provides additional information and helps
+ /// distinguish it from [secondarySpans].
+ final String primaryLabel;
+
+ /// A map whose keys are secondary spans that should be highlighted.
+ ///
+ /// Each span's value is a label to attach to that span that provides
+ /// additional information and helps distinguish it from [secondarySpans].
+ final Map<SourceSpan, String> secondarySpans;
+
+ MultiSourceSpanException(String message, SourceSpan span, this.primaryLabel,
+ Map<SourceSpan, String> secondarySpans)
+ : secondarySpans = Map.unmodifiable(secondarySpans),
+ super(message, 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 ANSI terminal color escape that should be used to
+ /// highlight the primary 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.
+ ///
+ /// If [color] is `true` or a string, [secondaryColor] is used to highlight
+ /// [secondarySpans].
+ @override
+ String toString({color, String secondaryColor}) {
+ if (span == null) return message;
+
+ var useColor = false;
+ String primaryColor;
+ if (color is String) {
+ useColor = true;
+ primaryColor = color;
+ } else if (color == true) {
+ useColor = true;
+ }
+
+ return "Error on " +
+ span.messageMultiple(message, primaryLabel, secondarySpans,
+ color: useColor,
+ primaryColor: primaryColor,
+ secondaryColor: secondaryColor);
+ }
+}
+
+/// A [MultiSourceSpanException] that's also a [FormatException].
+class MultiSourceSpanFormatException extends MultiSourceSpanException
+ implements FormatException {
+ @override
+ final dynamic source;
+
+ @override
+ int get offset => span?.start?.offset;
+
+ MultiSourceSpanFormatException(String message, SourceSpan span,
+ String primaryLabel, Map<SourceSpan, String> secondarySpans,
+ [this.source])
+ : super(message, span, primaryLabel, secondarySpans);
+}
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 63ff01c..12b15b3 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -2,6 +2,8 @@
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
+import 'span.dart';
+
/// Returns the minimum of [obj1] and [obj2] according to
/// [Comparable.compareTo].
T min<T extends Comparable>(T obj1, T obj2) =>
@@ -12,6 +14,42 @@
T max<T extends Comparable>(T obj1, T obj2) =>
obj1.compareTo(obj2) > 0 ? obj1 : obj2;
+/// Returns whether all elements of [iter] are the same value, according to
+/// `==`.
+///
+/// Assumes [iter] doesn't contain any `null` values.
+bool isAllTheSame(Iterable<Object> iter) {
+ Object lastValue;
+ for (var value in iter) {
+ if (lastValue == null) {
+ lastValue = value;
+ } else if (value != lastValue) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/// Returns whether [span] covers multiple lines.
+bool isMultiline(SourceSpan span) => span.start.line != span.end.line;
+
+/// Sets the first `null` element of [list] to [element].
+void replaceFirstNull<E>(List<E> list, E element) {
+ var index = list.indexOf(null);
+ if (index < 0) throw ArgumentError("$list contains no null elements.");
+ list[index] = element;
+}
+
+/// Sets the element of [list] that currently contains [element] to `null`.
+void replaceWithNull<E>(List<E> list, E element) {
+ var index = list.indexOf(element);
+ if (index < 0) {
+ throw ArgumentError("$list contains no elements matching $element.");
+ }
+
+ list[index] = null;
+}
+
/// Returns the number of instances of [codeUnit] in [string].
int countCodeUnits(String string, int codeUnit) {
var count = 0;
diff --git a/pubspec.yaml b/pubspec.yaml
index 71e10f6..2054423 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,15 +1,17 @@
name: source_span
-version: 1.5.6-dev
+version: 1.6.0
description: A library for identifying source spans and locations.
author: Dart Team <misc@dartlang.org>
homepage: https://github.com/dart-lang/source_span
environment:
- sdk: '>=2.1.0 <3.0.0'
+ sdk: '>=2.6.0 <3.0.0'
dependencies:
charcode: ^1.0.0
+ collection: ^1.8.0
+ meta: '>=0.9.0 <2.0.0'
path: '>=1.2.0 <2.0.0'
term_glyph: ^1.0.0
diff --git a/test/highlight_test.dart b/test/highlight_test.dart
index 9d95b21..6313108 100644
--- a/test/highlight_test.dart
+++ b/test/highlight_test.dart
@@ -140,6 +140,14 @@
'"""));
});
+ test('highlights text including a trailing newline', () {
+ expect(file.span(8, 12).highlight(), equals("""
+ ,
+1 | foo bar baz
+ | ^^^
+ '"""));
+ });
+
test('highlights a single empty line', () {
expect(
SourceFile.fromString('foo\n\nbar').span(4, 5).highlight(), equals("""
@@ -149,6 +157,14 @@
'"""));
});
+ test('highlights a trailing newline', () {
+ expect(file.span(11, 12).highlight(), equals("""
+ ,
+1 | foo bar baz
+ | ^
+ '"""));
+ });
+
group('with a multiline span', () {
test('highlights the middle of the first and last lines', () {
expect(file.span(4, 34).highlight(), equals("""
@@ -383,6 +399,21 @@
'"""));
});
+ test('at the beginning of the first highlighted line', () {
+ final span = SourceFile.fromString('''
+foo bar\tbaz
+whiz bang boom
+''').span(7, 21);
+
+ expect(span.highlight(), equals("""
+ ,
+1 | foo bar baz
+ | ,--------^
+2 | | whiz bang boom
+ | '---------^
+ '"""));
+ });
+
test('within a middle highlighted line', () {
final span = SourceFile.fromString('''
foo bar baz
@@ -415,6 +446,21 @@
'"""));
});
+ test('at the end of the last highlighted line', () {
+ final span = SourceFile.fromString('''
+foo bar baz
+whiz\tbang boom
+''').span(4, 17);
+
+ expect(span.highlight(), equals("""
+ ,
+1 | foo bar baz
+ | ,-----^
+2 | | whiz bang boom
+ | '--------^
+ '"""));
+ });
+
test('after the highlighted section', () {
final span = SourceFile.fromString('''
foo bar baz
@@ -498,7 +544,7 @@
expect(file.span(4, 7).highlight(color: true), equals('''
${colors.blue} ,${colors.none}
${colors.blue}1 |${colors.none} foo ${colors.red}bar${colors.none} baz
-${colors.blue} |${colors.none} ${colors.red}^^^${colors.none}
+${colors.blue} |${colors.none} ${colors.red} ^^^${colors.none}
${colors.blue} '${colors.none}'''));
});
@@ -506,7 +552,7 @@
expect(file.span(4, 7).highlight(color: colors.yellow), equals('''
${colors.blue} ,${colors.none}
${colors.blue}1 |${colors.none} foo ${colors.yellow}bar${colors.none} baz
-${colors.blue} |${colors.none} ${colors.yellow}^^^${colors.none}
+${colors.blue} |${colors.none} ${colors.yellow} ^^^${colors.none}
${colors.blue} '${colors.none}'''));
});
@@ -514,19 +560,19 @@
expect(file.span(4, 34).highlight(color: true), equals('''
${colors.blue} ,${colors.none}
${colors.blue}1 |${colors.none} foo ${colors.red}bar baz${colors.none}
-${colors.blue} |${colors.none} ${colors.red},-----^${colors.none}
-${colors.blue}2 |${colors.none} ${colors.red}| whiz bang boom${colors.none}
-${colors.blue}3 |${colors.none} ${colors.red}| zip zap${colors.none} zop
-${colors.blue} |${colors.none} ${colors.red}'-------^${colors.none}
+${colors.blue} |${colors.none} ${colors.red},${colors.none}${colors.red}-----^${colors.none}
+${colors.blue}2 |${colors.none} ${colors.red}|${colors.none} ${colors.red}whiz bang boom${colors.none}
+${colors.blue}3 |${colors.none} ${colors.red}|${colors.none} ${colors.red}zip zap${colors.none} zop
+${colors.blue} |${colors.none} ${colors.red}'${colors.none}${colors.red}-------^${colors.none}
${colors.blue} '${colors.none}'''));
});
test('colorizes a multiline span that highlights full lines', () {
expect(file.span(0, 39).highlight(color: true), equals('''
${colors.blue} ,${colors.none}
-${colors.blue}1 |${colors.none} ${colors.red}/ foo bar baz${colors.none}
-${colors.blue}2 |${colors.none} ${colors.red}| whiz bang boom${colors.none}
-${colors.blue}3 |${colors.none} ${colors.red}\\ zip zap zop${colors.none}
+${colors.blue}1 |${colors.none} ${colors.red}/${colors.none} ${colors.red}foo bar baz${colors.none}
+${colors.blue}2 |${colors.none} ${colors.red}|${colors.none} ${colors.red}whiz bang boom${colors.none}
+${colors.blue}3 |${colors.none} ${colors.red}\\${colors.none} ${colors.red}zip zap zop${colors.none}
${colors.blue} '${colors.none}'''));
});
});
diff --git a/test/multiple_highlight_test.dart b/test/multiple_highlight_test.dart
new file mode 100644
index 0000000..f0cceed
--- /dev/null
+++ b/test/multiple_highlight_test.dart
@@ -0,0 +1,281 @@
+// Copyright (c) 2019, 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:source_span/source_span.dart';
+import 'package:term_glyph/term_glyph.dart' as glyph;
+import 'package:test/test.dart';
+
+void main() {
+ bool oldAscii;
+ setUpAll(() {
+ oldAscii = glyph.ascii;
+ glyph.ascii = true;
+ });
+
+ tearDownAll(() {
+ glyph.ascii = oldAscii;
+ });
+
+ SourceFile file;
+ setUp(() {
+ file = SourceFile.fromString('''
+foo bar baz
+whiz bang boom
+zip zap zop
+fwee fwoo fwip
+argle bargle boo
+gibble bibble bop
+''', url: "file1.txt");
+ });
+
+ test("highlights spans on separate lines", () {
+ expect(
+ file.span(17, 21).highlightMultiple(
+ "one", {file.span(31, 34): "two", file.span(4, 7): "three"}),
+ equals("""
+ ,
+1 | foo bar baz
+ | === three
+2 | whiz bang boom
+ | ^^^^ one
+3 | zip zap zop
+ | === two
+ '"""));
+ });
+
+ test("highlights spans on the same line", () {
+ expect(
+ file.span(17, 21).highlightMultiple(
+ "one", {file.span(22, 26): "two", file.span(12, 16): "three"}),
+ equals("""
+ ,
+2 | whiz bang boom
+ | ^^^^ one
+ | ==== three
+ | ==== two
+ '"""));
+ });
+
+ test("highlights overlapping spans on the same line", () {
+ expect(
+ file.span(17, 21).highlightMultiple(
+ "one", {file.span(20, 26): "two", file.span(12, 18): "three"}),
+ equals("""
+ ,
+2 | whiz bang boom
+ | ^^^^ one
+ | ====== three
+ | ====== two
+ '"""));
+ });
+
+ test("highlights multiple multiline spans", () {
+ expect(
+ file.span(27, 54).highlightMultiple(
+ "one", {file.span(54, 89): "two", file.span(0, 27): "three"}),
+ equals("""
+ ,
+1 | / foo bar baz
+2 | | whiz bang boom
+ | '--- three
+3 | / zip zap zop
+4 | | fwee fwoo fwip
+ | '--- one
+5 | / argle bargle boo
+6 | | gibble bibble bop
+ | '--- two
+ '"""));
+ });
+
+ test("highlights multiple overlapping multiline spans", () {
+ expect(
+ file.span(12, 70).highlightMultiple(
+ "one", {file.span(54, 89): "two", file.span(0, 27): "three"}),
+ equals("""
+ ,
+1 | /- foo bar baz
+2 | |/ whiz bang boom
+ | '+--- three
+3 | | zip zap zop
+4 | | fwee fwoo fwip
+5 | /+ argle bargle boo
+ | |'--- one
+6 | | gibble bibble bop
+ | '---- two
+ '"""));
+ });
+
+ test("highlights many layers of overlaps", () {
+ expect(
+ file.span(0, 54).highlightMultiple("one", {
+ file.span(12, 77): "two",
+ file.span(27, 84): "three",
+ file.span(39, 88): "four"
+ }),
+ equals("""
+ ,
+1 | /--- foo bar baz
+2 | |/-- whiz bang boom
+3 | ||/- zip zap zop
+4 | |||/ fwee fwoo fwip
+ | '+++--- one
+5 | ||| argle bargle boo
+6 | ||| gibble bibble bop
+ | '++------^ two
+ | '+-------------^ three
+ | '--- four
+ '"""));
+ });
+
+ group("highlights a multiline span that's a subset", () {
+ test("with no first or last line overlap", () {
+ expect(
+ file
+ .span(27, 53)
+ .highlightMultiple("inner", {file.span(12, 70): "outer"}),
+ equals("""
+ ,
+2 | /- whiz bang boom
+3 | |/ zip zap zop
+4 | || fwee fwoo fwip
+ | |'--- inner
+5 | | argle bargle boo
+ | '---- outer
+ '"""));
+ });
+
+ test("overlapping the whole first line", () {
+ expect(
+ file
+ .span(12, 53)
+ .highlightMultiple("inner", {file.span(12, 70): "outer"}),
+ equals("""
+ ,
+2 | // whiz bang boom
+3 | || zip zap zop
+4 | || fwee fwoo fwip
+ | |'--- inner
+5 | | argle bargle boo
+ | '---- outer
+ '"""));
+ });
+
+ test("overlapping part of first line", () {
+ expect(
+ file
+ .span(17, 53)
+ .highlightMultiple("inner", {file.span(12, 70): "outer"}),
+ equals("""
+ ,
+2 | /- whiz bang boom
+ | |,------^
+3 | || zip zap zop
+4 | || fwee fwoo fwip
+ | |'--- inner
+5 | | argle bargle boo
+ | '---- outer
+ '"""));
+ });
+
+ test("overlapping the whole last line", () {
+ expect(
+ file
+ .span(27, 70)
+ .highlightMultiple("inner", {file.span(12, 70): "outer"}),
+ equals("""
+ ,
+2 | /- whiz bang boom
+3 | |/ zip zap zop
+4 | || fwee fwoo fwip
+5 | || argle bargle boo
+ | |'--- inner
+ | '---- outer
+ '"""));
+ });
+
+ test("overlapping part of the last line", () {
+ expect(
+ file
+ .span(27, 66)
+ .highlightMultiple("inner", {file.span(12, 70): "outer"}),
+ equals("""
+ ,
+2 | /- whiz bang boom
+3 | |/ zip zap zop
+4 | || fwee fwoo fwip
+5 | || argle bargle boo
+ | |'------------^ inner
+ | '---- outer
+ '"""));
+ });
+ });
+
+ group("a single-line span in a multiline span", () {
+ test("on the first line", () {
+ expect(
+ file
+ .span(17, 21)
+ .highlightMultiple("inner", {file.span(12, 70): "outer"}),
+ equals("""
+ ,
+2 | / whiz bang boom
+ | | ^^^^ inner
+3 | | zip zap zop
+4 | | fwee fwoo fwip
+5 | | argle bargle boo
+ | '--- outer
+ '"""));
+ });
+
+ test("in the middle", () {
+ expect(
+ file
+ .span(31, 34)
+ .highlightMultiple("inner", {file.span(12, 70): "outer"}),
+ equals("""
+ ,
+2 | / whiz bang boom
+3 | | zip zap zop
+ | | ^^^ inner
+4 | | fwee fwoo fwip
+5 | | argle bargle boo
+ | '--- outer
+ '"""));
+ });
+
+ test("on the last line", () {
+ expect(
+ file
+ .span(60, 66)
+ .highlightMultiple("inner", {file.span(12, 70): "outer"}),
+ equals("""
+ ,
+2 | / whiz bang boom
+3 | | zip zap zop
+4 | | fwee fwoo fwip
+5 | | argle bargle boo
+ | | ^^^^^^ inner
+ | '--- outer
+ '"""));
+ });
+ });
+
+ test("highlights multiple files with their URLs", () {
+ var file2 = SourceFile.fromString('''
+quibble bibble boop
+''', url: "file2.txt");
+
+ expect(
+ file.span(31, 34).highlightMultiple("one", {file2.span(8, 14): "two"}),
+ equals("""
+ ,--> file1.txt
+3 | zip zap zop
+ | ^^^ one
+ '
+ ,--> file2.txt
+1 | quibble bibble boop
+ | ====== two
+ '"""));
+ });
+}
diff --git a/test/span_test.dart b/test/span_test.dart
index 1ac9d35..f44b02f 100644
--- a/test/span_test.dart
+++ b/test/span_test.dart
@@ -246,7 +246,7 @@
line 1, column 6 of foo.dart: oh no
${colors.blue} ,${colors.none}
${colors.blue}1 |${colors.none} -----${colors.yellow}foo bar${colors.none}-----
-${colors.blue} |${colors.none} ${colors.yellow}^^^^^^^${colors.none}
+${colors.blue} |${colors.none} ${colors.yellow} ^^^^^^^${colors.none}
${colors.blue} '${colors.none}"""));
});
});