| // 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; |
| |
| /// Creats a [Highlighter] that will return a message associated with [span] |
| /// when [write] is called. |
| /// |
| /// [color] may either be a [String], a [bool], or `null`. If it's a string, |
| /// it indicates an [ANSI terminal color |
| /// escape](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors) 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. |
| factory Highlighter(SourceSpan span, {color}) { |
| if (color == true) color = colors.RED; |
| if (color == false) color = null; |
| |
| var newSpan = _normalizeContext(span); |
| newSpan = _normalizeNewlines(newSpan); |
| newSpan = _normalizeTrailingNewline(newSpan); |
| newSpan = _normalizeEndOfLine(newSpan); |
| |
| return new Highlighter._(newSpan, color); |
| } |
| |
| /// 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 |
| : 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); |
| |
| /// Normalizes [span] to replace Windows-style newlines with Unix-style |
| /// newlines. |
| static SourceSpanWithContext _normalizeNewlines(SourceSpanWithContext span) { |
| var 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 new SourceSpanWithContext( |
| span.start, |
| new 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; |
| |
| var 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); |
| end = new SourceLocation(span.end.offset - 1, |
| sourceUrl: span.sourceUrl, |
| line: span.end.line - 1, |
| column: _lastColumn(text)); |
| start = span.start.offset == span.end.offset ? end : span.start; |
| } |
| return new SourceSpanWithContext(start, end, text, context); |
| } |
| |
| /// Normalizes [span] so that the end location is at the end of a line, rather |
| /// than on 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; |
| |
| assert(span.text.endsWith("\n")); |
| |
| var text = span.text.substring(0, span.text.length - 1); |
| return new SourceSpanWithContext( |
| span.start, |
| new SourceLocation(span.end.offset - 1, |
| sourceUrl: span.sourceUrl, |
| line: span.end.line - 1, |
| column: _lastColumn(text)), |
| text, |
| span.context); |
| } |
| |
| /// 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() { |
| _writeSidebar(end: glyph.downEnd); |
| _buffer.writeln(); |
| |
| // If [_span.context] contains lines prior to the one [_span.text] appears |
| // on, write those first. |
| var lineStart = |
| findLineStart(_span.context, _span.text, _span.start.column); |
| assert(lineStart != null); // enforced by [_normalizeContext] |
| |
| 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); |
| var lastLineIndex = _span.end.line - _span.start.line; |
| 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); |
| 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); |
| } |
| } |