// 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, {Object? 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 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( // 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) { // Assign spans without URLs opaque Objects as keys. Each such Object will // be different, but they can then be used later on to determine which lines // came from the same span even if they'd all otherwise have `null` URLs. final highlightsByUrl = groupBy<_Highlight, Object>( highlights, (highlight) => highlight.span.sourceUrl ?? Object()); for (var list in highlightsByUrl.values) { list.sort((highlight1, highlight2) => highlight1.span.compareTo(highlight2.span)); } return highlightsByUrl.entries.expand((entry) { final url = entry.key; final highlightsForFile = entry.value; // 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; 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.end.line < line.number); final oldHighlightLength = activeHighlights.length; for (var highlight in highlightsForFile.skip(highlightIndex)) { if (highlight.span.start.line > line.number) 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] (or opaque object if it comes from a span with a null URL). void _writeFileStart(Object url) { if (!_multipleFiles || url is! Uri) { _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('└', r'\') : 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(' '); final underlineLength = _colorize(() { final start = _buffer.length; _writeUnderline(line, highlight.span, highlight.isPrimary ? '^' : glyph.horizontalLineBold); return _buffer.length - start; }, color: color); _writeLabel(highlight, highlightsByColumn, underlineLength); } 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); final underlineLength = _colorize(() { final start = _buffer.length; if (coversWholeLine) { _buffer.write(glyph.horizontalLine * 3); } else { _writeArrow(line, math.max(highlight.span.end.column - 1, 0), beginning: false); } return _buffer.length - start; }, color: color); _writeLabel(highlight, highlightsByColumn, underlineLength); 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), '"${line.text}" should contain "${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 [highlight]'s label. /// /// The `_buffer` is assumed to be written to the point where the first line /// of `highlight.label` can be written after a space, but this takes care of /// writing indentation and highlight columns for later lines. /// /// The [highlightsByColumn] are used to write ongoing highlight lines if the /// label is more than one line long. /// /// The [underlineLength] is the length of the line written between the /// highlights and the beginning of the first label. void _writeLabel(_Highlight highlight, List<_Highlight?> highlightsByColumn, int underlineLength) { final label = highlight.label; if (label == null) { _buffer.writeln(); return; } final lines = label.split('\n'); final color = highlight.isPrimary ? _primaryColor : _secondaryColor; _colorize(() => _buffer.write(' ${lines.first}'), color: color); _buffer.writeln(); for (var text in lines.skip(1)) { _writeSidebar(); _buffer.write(' '); for (var columnHighlight in highlightsByColumn) { if (columnHighlight == null || columnHighlight == highlight) { _buffer.write(' '); } else { _buffer.write(glyph.verticalLine); } } _buffer.write(' ' * underlineLength); _colorize(() => _buffer.write(' $text'), color: color); _buffer.writeln(); } } /// 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`. T _colorize(T Function() callback, {required String? color}) { if (_primaryColor != null && color != null) _buffer.write(color); final result = callback(); if (_primaryColor != null && color != null) _buffer.write(colors.none); return result; } } /// 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, {String? label, bool primary = false}) : span = (() { var newSpan = _normalizeContext(span); newSpan = _normalizeNewlines(newSpan); newSpan = _normalizeTrailingNewline(newSpan); return _normalizeEndOfLine(newSpan); })(), isPrimary = primary, label = label?.replaceAll('\r\n', '\n'); /// 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. /// /// For lines created from spans without an explicit URL, this is an opaque /// object that differs between lines that come from different spans. final Object 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(', ')})'; }