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