blob: 19d8965ee3dc073b3575e6258c59a42c10f16ea3 [file] [log] [blame]
// 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:collection/collection.dart';
import 'package:path/path.dart' as p;
import 'package:term_glyph/term_glyph.dart' as glyph;
import 'charcode.dart';
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 lines to display, including context around the highlighted spans.
final List<_Line> _lines;
/// The color to highlight the primary [_Highlight] within its context, or
/// `null` if it should not be colored.
final String? _primaryColor;
/// 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 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();
/// 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;
/// 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 [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
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);
/// 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);
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++) {
final thisLine = lines[i];
final 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) {
final highlightsByUrl = groupBy<_Highlight, Uri?>(
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.
final lines = <_Line>[];
for (var highlight in highlightsForFile) {
final context = highlight.span.context;
// If [highlight.span.context] contains lines prior to the one
// [highlight.span.text] appears on, write those first.
final lineStart = findLineStart(
context, highlight.span.text, highlight.span.start.column)!;
final linesBeforeSpan =
'\n'.allMatches(context.substring(0, lineStart)).length;
final 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.
final activeHighlights = <_Highlight>[];
var highlightIndex = 0;
for (var line in lines) {
activeHighlights.removeWhere((highlight) =>
highlight.span.sourceUrl != line.url ||
highlight.span.end.line < line.number);
final 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.
final highlightsByColumn =
List<_Highlight?>.filled(_maxMultilineSpans, null);
for (var i = 0; i < _lines.length; i++) {
final line = _lines[i];
if (i > 0) {
final 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(' ');
final primaryIdx =
line.highlights.indexWhere((highlight) => highlight.isPrimary);
final primary = primaryIdx == -1 ? null : line.highlights[primaryIdx];
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;
final currentColor = current == null
? null
: current.isPrimary
? _primaryColor
: _secondaryColor;
var foundCurrent = false;
for (var highlight in highlightsByColumn) {
final startLine = highlight?.span.start.line;
final 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(() {
final 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) {
final 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) {
final 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.
final tabsBefore = _countTabs(line.text.substring(0, startColumn));
final tabsInside = _countTabs(line.text.substring(startColumn, endColumn));
startColumn += tabsBefore * (_spacesPerTab - 1);
endColumn += (tabsBefore + tabsInside) * (_spacesPerTab - 1);
_buffer
..write(' ' * startColumn)
..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}) {
final 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))
..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.
///
/// If it's not already a [SourceSpanWithContext], adjust the start and end
/// locations' line and column fields so that the highlighter can assume they
/// match up with the context.
static SourceSpanWithContext _normalizeContext(SourceSpan span) =>
span is SourceSpanWithContext &&
findLineStart(span.context, span.text, span.start.column) != null
? span
: SourceSpanWithContext(
SourceLocation(span.start.offset,
sourceUrl: span.sourceUrl, line: 0, column: 0),
SourceLocation(span.end.offset,
sourceUrl: span.sourceUrl,
line: countCodeUnits(span.text, $lf),
column: _lastLineLength(span.text)),
span.text,
span.text);
/// Normalizes [span] to replace Windows-style newlines with Unix-style
/// newlines.
static SourceSpanWithContext _normalizeNewlines(SourceSpanWithContext span) {
final text = span.text;
if (!text.contains('\r\n')) return span;
var endOffset = span.end.offset;
for (var i = 0; i < text.length - 1; i++) {
if (text.codeUnitAt(i) == $cr && text.codeUnitAt(i + 1) == $lf) {
endOffset--;
}
}
return SourceSpanWithContext(
span.start,
SourceLocation(endOffset,
sourceUrl: span.sourceUrl,
line: span.end.line,
column: span.end.column),
text.replaceAll('\r\n', '\n'),
span.context.replaceAll('\r\n', '\n'));
}
/// Normalizes [span] to remove a trailing newline from `span.context`.
///
/// If necessary, also adjust `span.end` so that it doesn't point past where
/// the trailing newline used to be.
static SourceSpanWithContext _normalizeTrailingNewline(
SourceSpanWithContext span) {
if (!span.context.endsWith('\n')) return span;
// If there's a full blank line on the end of [span.context], it's probably
// significant, so we shouldn't trim it.
if (span.text.endsWith('\n\n')) return span;
final context = span.context.substring(0, span.context.length - 1);
var text = span.text;
var start = span.start;
var end = span.end;
if (span.text.endsWith('\n') && _isTextAtEndOfContext(span)) {
text = span.text.substring(0, span.text.length - 1);
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);
}
/// Normalizes [span] so that the end location is at the end of a line rather
/// than at the beginning of the next line.
static SourceSpanWithContext _normalizeEndOfLine(SourceSpanWithContext span) {
if (span.end.column != 0) return span;
if (span.end.line == span.start.line) return span;
final text = span.text.substring(0, span.text.length - 1);
return SourceSpanWithContext(
span.start,
SourceLocation(span.end.offset - 1,
sourceUrl: span.sourceUrl,
line: span.end.line - 1,
column: text.length - text.lastIndexOf('\n') - 1),
text,
// 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;
} else if (text.codeUnitAt(text.length - 1) == $lf) {
return text.length == 1
? 0
: text.length - text.lastIndexOf('\n', text.length - 2) - 1;
} else {
return text.length - text.lastIndexOf('\n') - 1;
}
}
/// Returns whether [span]'s text runs all the way to the end of its context.
static bool _isTextAtEndOfContext(SourceSpanWithContext span) =>
findLineStart(span.context, span.text, span.start.column)! +
span.start.column +
span.length ==
span.context.length;
@override
String toString() {
final 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();
}
}
/// 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 is populated after the initial line is created.
final highlights = <_Highlight>[];
_Line(this.text, this.number, this.url);
@override
String toString() => '$number: "$text" (${highlights.join(', ')})';
}