| // 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<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) { | |
| // 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>(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(', ')})'; | |
| } |