Line data Source code
1 : // Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
2 : // for details. All rights reserved. Use of this source code is governed by a
3 : // BSD-style license that can be found in the LICENSE file.
4 :
5 : import 'dart:math' as math;
6 :
7 : import 'package:collection/collection.dart';
8 : import 'package:path/path.dart' as p;
9 : import 'package:term_glyph/term_glyph.dart' as glyph;
10 :
11 : import 'charcode.dart';
12 : import 'colors.dart' as colors;
13 : import 'location.dart';
14 : import 'span.dart';
15 : import 'span_with_context.dart';
16 : import 'utils.dart';
17 :
18 : /// A class for writing a chunk of text with a particular span highlighted.
19 : class Highlighter {
20 : /// The lines to display, including context around the highlighted spans.
21 : final List<_Line> _lines;
22 :
23 : /// The color to highlight the primary [_Highlight] within its context, or
24 : /// `null` if it should not be colored.
25 : final String? _primaryColor;
26 :
27 : /// The color to highlight the secondary [_Highlight]s within their context,
28 : /// or `null` if they should not be colored.
29 : final String? _secondaryColor;
30 :
31 : /// The number of characters before the bar in the sidebar.
32 : final int _paddingBeforeSidebar;
33 :
34 : /// The maximum number of multiline spans that cover any part of a single
35 : /// line in [_lines].
36 : final int _maxMultilineSpans;
37 :
38 : /// Whether [_lines] includes lines from multiple different files.
39 : final bool _multipleFiles;
40 :
41 : /// The buffer to which to write the result.
42 : final _buffer = StringBuffer();
43 :
44 : /// The number of spaces to render for hard tabs that appear in `_span.text`.
45 : ///
46 : /// We don't want to render raw tabs, because they'll mess up our character
47 : /// alignment.
48 : static const _spacesPerTab = 4;
49 :
50 : /// Creates a [Highlighter] that will return a string highlighting [span]
51 : /// within the text of its file when [highlight] is called.
52 : ///
53 : /// [color] may either be a [String], a [bool], or `null`. If it's a string,
54 : /// it indicates an [ANSI terminal color escape][] that should be used to
55 : /// highlight [span]'s text (for example, `"\u001b[31m"` will color red). If
56 : /// it's `true`, it indicates that the text should be highlighted using the
57 : /// default color. If it's `false` or `null`, it indicates that no color
58 : /// should be used.
59 : ///
60 : /// [ANSI terminal color escape]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
61 0 : Highlighter(SourceSpan span, {color})
62 0 : : this._(_collateLines([_Highlight(span, primary: true)]), () {
63 0 : if (color == true) return colors.red;
64 0 : if (color == false) return null;
65 : return color as String?;
66 : }(), null);
67 :
68 : /// Creates a [Highlighter] that will return a string highlighting
69 : /// [primarySpan] as well as all the spans in [secondarySpans] within the text
70 : /// of their file when [highlight] is called.
71 : ///
72 : /// Each span has an associated label that will be written alongside it. For
73 : /// [primarySpan] this message is [primaryLabel], and for [secondarySpans] the
74 : /// labels are the map values.
75 : ///
76 : /// If [color] is `true`, this will use [ANSI terminal color escapes][] to
77 : /// highlight the text. The [primarySpan] will be highlighted with
78 : /// [primaryColor] (which defaults to red), and the [secondarySpans] will be
79 : /// highlighted with [secondaryColor] (which defaults to blue). These
80 : /// arguments are ignored if [color] is `false`.
81 : ///
82 : /// [ANSI terminal color escape]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
83 0 : Highlighter.multiple(SourceSpan primarySpan, String primaryLabel,
84 : Map<SourceSpan, String> secondarySpans,
85 : {bool color = false, String? primaryColor, String? secondaryColor})
86 0 : : this._(
87 0 : _collateLines([
88 0 : _Highlight(primarySpan, label: primaryLabel, primary: true),
89 0 : for (var entry in secondarySpans.entries)
90 0 : _Highlight(entry.key, label: entry.value)
91 : ]),
92 : color ? (primaryColor ?? colors.red) : null,
93 : color ? (secondaryColor ?? colors.blue) : null);
94 :
95 0 : Highlighter._(this._lines, this._primaryColor, this._secondaryColor)
96 0 : : _paddingBeforeSidebar = 1 +
97 0 : math.max<int>(
98 : // In a purely mathematical world, floor(log10(n)) would give the
99 : // number of digits in n, but floating point errors render that
100 : // unreliable in practice.
101 0 : (_lines.last.number + 1).toString().length,
102 : // If [_lines] aren't contiguous, we'll write "..." in place of a
103 : // line number.
104 0 : _contiguous(_lines) ? 0 : 3),
105 : _maxMultilineSpans = _lines
106 0 : .map((line) => line.highlights
107 0 : .where((highlight) => isMultiline(highlight.span))
108 0 : .length)
109 0 : .reduce(math.max),
110 0 : _multipleFiles = !isAllTheSame(_lines.map((line) => line.url));
111 :
112 : /// Returns whether [lines] contains any adjacent lines from the same source
113 : /// file that aren't adjacent in the original file.
114 0 : static bool _contiguous(List<_Line> lines) {
115 0 : for (var i = 0; i < lines.length - 1; i++) {
116 0 : final thisLine = lines[i];
117 0 : final nextLine = lines[i + 1];
118 0 : if (thisLine.number + 1 != nextLine.number &&
119 0 : thisLine.url == nextLine.url) {
120 : return false;
121 : }
122 : }
123 : return true;
124 : }
125 :
126 : /// Collect all the source lines from the contexts of all spans in
127 : /// [highlights], and associates them with the highlights that cover them.
128 0 : static List<_Line> _collateLines(List<_Highlight> highlights) {
129 0 : final highlightsByUrl = groupBy<_Highlight, Uri?>(
130 0 : highlights, (highlight) => highlight.span.sourceUrl);
131 0 : for (var list in highlightsByUrl.values) {
132 0 : list.sort((highlight1, highlight2) =>
133 0 : highlight1.span.compareTo(highlight2.span));
134 : }
135 :
136 0 : return highlightsByUrl.values.expand((highlightsForFile) {
137 : // First, create a list of all the lines in the current file that we have
138 : // context for along with their line numbers.
139 0 : final lines = <_Line>[];
140 0 : for (var highlight in highlightsForFile) {
141 0 : final context = highlight.span.context;
142 : // If [highlight.span.context] contains lines prior to the one
143 : // [highlight.span.text] appears on, write those first.
144 0 : final lineStart = findLineStart(
145 0 : context, highlight.span.text, highlight.span.start.column)!;
146 :
147 : final linesBeforeSpan =
148 0 : '\n'.allMatches(context.substring(0, lineStart)).length;
149 :
150 0 : final url = highlight.span.sourceUrl;
151 0 : var lineNumber = highlight.span.start.line - linesBeforeSpan;
152 0 : for (var line in context.split('\n')) {
153 : // Only add a line if it hasn't already been added for a previous span.
154 0 : if (lines.isEmpty || lineNumber > lines.last.number) {
155 0 : lines.add(_Line(line, lineNumber, url));
156 : }
157 0 : lineNumber++;
158 : }
159 : }
160 :
161 : // Next, associate each line with each highlights that covers it.
162 0 : final activeHighlights = <_Highlight>[];
163 : var highlightIndex = 0;
164 0 : for (var line in lines) {
165 0 : activeHighlights.removeWhere((highlight) =>
166 0 : highlight.span.sourceUrl != line.url ||
167 0 : highlight.span.end.line < line.number);
168 :
169 0 : final oldHighlightLength = activeHighlights.length;
170 0 : for (var highlight in highlightsForFile.skip(highlightIndex)) {
171 0 : if (highlight.span.start.line > line.number) break;
172 0 : if (highlight.span.sourceUrl != line.url) break;
173 0 : activeHighlights.add(highlight);
174 : }
175 0 : highlightIndex += activeHighlights.length - oldHighlightLength;
176 :
177 0 : line.highlights.addAll(activeHighlights);
178 : }
179 :
180 : return lines;
181 0 : }).toList();
182 : }
183 :
184 : /// Returns the highlighted span text.
185 : ///
186 : /// This method should only be called once.
187 0 : String highlight() {
188 0 : _writeFileStart(_lines.first.url);
189 :
190 : // Each index of this list represents a column after the sidebar that could
191 : // contain a line indicating an active highlight. If it's `null`, that
192 : // column is empty; if it contains a highlight, it should be drawn for that column.
193 : final highlightsByColumn =
194 0 : List<_Highlight?>.filled(_maxMultilineSpans, null);
195 :
196 0 : for (var i = 0; i < _lines.length; i++) {
197 0 : final line = _lines[i];
198 0 : if (i > 0) {
199 0 : final lastLine = _lines[i - 1];
200 0 : if (lastLine.url != line.url) {
201 0 : _writeSidebar(end: glyph.upEnd);
202 0 : _buffer.writeln();
203 0 : _writeFileStart(line.url);
204 0 : } else if (lastLine.number + 1 != line.number) {
205 0 : _writeSidebar(text: '...');
206 0 : _buffer.writeln();
207 : }
208 : }
209 :
210 : // If a highlight covers the entire first line other than initial
211 : // whitespace, don't bother pointing out exactly where it begins. Iterate
212 : // in reverse so that longer highlights (which are sorted after shorter
213 : // highlights) appear further out, leading to fewer crossed lines.
214 0 : for (var highlight in line.highlights.reversed) {
215 0 : if (isMultiline(highlight.span) &&
216 0 : highlight.span.start.line == line.number &&
217 0 : _isOnlyWhitespace(
218 0 : line.text.substring(0, highlight.span.start.column))) {
219 0 : replaceFirstNull(highlightsByColumn, highlight);
220 : }
221 : }
222 :
223 0 : _writeSidebar(line: line.number);
224 0 : _buffer.write(' ');
225 0 : _writeMultilineHighlights(line, highlightsByColumn);
226 0 : if (highlightsByColumn.isNotEmpty) _buffer.write(' ');
227 :
228 : final primaryIdx =
229 0 : line.highlights.indexWhere((highlight) => highlight.isPrimary);
230 0 : final primary = primaryIdx == -1 ? null : line.highlights[primaryIdx];
231 :
232 : if (primary != null) {
233 0 : _writeHighlightedText(
234 0 : line.text,
235 0 : primary.span.start.line == line.number
236 0 : ? primary.span.start.column
237 : : 0,
238 0 : primary.span.end.line == line.number
239 0 : ? primary.span.end.column
240 0 : : line.text.length,
241 0 : color: _primaryColor);
242 : } else {
243 0 : _writeText(line.text);
244 : }
245 0 : _buffer.writeln();
246 :
247 : // Always write the primary span's indicator first so that it's right next
248 : // to the highlighted text.
249 0 : if (primary != null) _writeIndicator(line, primary, highlightsByColumn);
250 0 : for (var highlight in line.highlights) {
251 0 : if (highlight.isPrimary) continue;
252 0 : _writeIndicator(line, highlight, highlightsByColumn);
253 : }
254 : }
255 :
256 0 : _writeSidebar(end: glyph.upEnd);
257 0 : return _buffer.toString();
258 : }
259 :
260 : /// Writes the beginning of the file highlight for the file with the given
261 : /// [url].
262 0 : void _writeFileStart(Uri? url) {
263 0 : if (!_multipleFiles || url == null) {
264 0 : _writeSidebar(end: glyph.downEnd);
265 : } else {
266 0 : _writeSidebar(end: glyph.topLeftCorner);
267 0 : _colorize(() => _buffer.write('${glyph.horizontalLine * 2}>'),
268 : color: colors.blue);
269 0 : _buffer.write(' ${p.prettyUri(url)}');
270 : }
271 0 : _buffer.writeln();
272 : }
273 :
274 : /// Writes the post-sidebar highlight bars for [line] according to
275 : /// [highlightsByColumn].
276 : ///
277 : /// If [current] is passed, it's the highlight for which an indicator is being
278 : /// written. If it appears in [highlightsByColumn], a horizontal line is
279 : /// written from its column to the rightmost column.
280 0 : void _writeMultilineHighlights(
281 : _Line line, List<_Highlight?> highlightsByColumn,
282 : {_Highlight? current}) {
283 : // Whether we've written a sidebar indicator for opening a new span on this
284 : // line, and which color should be used for that indicator's rightward line.
285 : var openedOnThisLine = false;
286 : String? openedOnThisLineColor;
287 :
288 : final currentColor = current == null
289 : ? null
290 0 : : current.isPrimary
291 0 : ? _primaryColor
292 0 : : _secondaryColor;
293 : var foundCurrent = false;
294 0 : for (var highlight in highlightsByColumn) {
295 0 : final startLine = highlight?.span.start.line;
296 0 : final endLine = highlight?.span.end.line;
297 0 : if (current != null && highlight == current) {
298 : foundCurrent = true;
299 0 : assert(startLine == line.number || endLine == line.number);
300 0 : _colorize(() {
301 0 : _buffer.write(startLine == line.number
302 0 : ? glyph.topLeftCorner
303 0 : : glyph.bottomLeftCorner);
304 : }, color: currentColor);
305 : } else if (foundCurrent) {
306 0 : _colorize(() {
307 0 : _buffer.write(highlight == null ? glyph.horizontalLine : glyph.cross);
308 : }, color: currentColor);
309 : } else if (highlight == null) {
310 : if (openedOnThisLine) {
311 0 : _colorize(() => _buffer.write(glyph.horizontalLine),
312 : color: openedOnThisLineColor);
313 : } else {
314 0 : _buffer.write(' ');
315 : }
316 : } else {
317 0 : _colorize(() {
318 0 : final vertical = openedOnThisLine ? glyph.cross : glyph.verticalLine;
319 : if (current != null) {
320 0 : _buffer.write(vertical);
321 0 : } else if (startLine == line.number) {
322 0 : _colorize(() {
323 0 : _buffer
324 0 : .write(glyph.glyphOrAscii(openedOnThisLine ? '┬' : '┌', '/'));
325 : }, color: openedOnThisLineColor);
326 : openedOnThisLine = true;
327 : openedOnThisLineColor ??=
328 0 : highlight.isPrimary ? _primaryColor : _secondaryColor;
329 0 : } else if (endLine == line.number &&
330 0 : highlight.span.end.column == line.text.length) {
331 0 : _buffer.write(highlight.label == null
332 0 : ? glyph.glyphOrAscii('└', '\\')
333 : : vertical);
334 : } else {
335 0 : _colorize(() {
336 0 : _buffer.write(vertical);
337 : }, color: openedOnThisLineColor);
338 : }
339 0 : }, color: highlight.isPrimary ? _primaryColor : _secondaryColor);
340 : }
341 : }
342 : }
343 :
344 : // Writes [text], with text between [startColumn] and [endColumn] colorized in
345 : // the same way as [_colorize].
346 0 : void _writeHighlightedText(String text, int startColumn, int endColumn,
347 : {required String? color}) {
348 0 : _writeText(text.substring(0, startColumn));
349 0 : _colorize(() => _writeText(text.substring(startColumn, endColumn)),
350 : color: color);
351 0 : _writeText(text.substring(endColumn, text.length));
352 : }
353 :
354 : /// Writes an indicator for where [highlight] starts, ends, or both below
355 : /// [line].
356 : ///
357 : /// This may either add or remove [highlight] from [highlightsByColumn].
358 0 : void _writeIndicator(
359 : _Line line, _Highlight highlight, List<_Highlight?> highlightsByColumn) {
360 0 : final color = highlight.isPrimary ? _primaryColor : _secondaryColor;
361 0 : if (!isMultiline(highlight.span)) {
362 0 : _writeSidebar();
363 0 : _buffer.write(' ');
364 0 : _writeMultilineHighlights(line, highlightsByColumn, current: highlight);
365 0 : if (highlightsByColumn.isNotEmpty) _buffer.write(' ');
366 :
367 0 : _colorize(() {
368 0 : _writeUnderline(line, highlight.span,
369 0 : highlight.isPrimary ? '^' : glyph.horizontalLineBold);
370 0 : _writeLabel(highlight.label);
371 : }, color: color);
372 0 : _buffer.writeln();
373 0 : } else if (highlight.span.start.line == line.number) {
374 0 : if (highlightsByColumn.contains(highlight)) return;
375 0 : replaceFirstNull(highlightsByColumn, highlight);
376 :
377 0 : _writeSidebar();
378 0 : _buffer.write(' ');
379 0 : _writeMultilineHighlights(line, highlightsByColumn, current: highlight);
380 0 : _colorize(() => _writeArrow(line, highlight.span.start.column),
381 : color: color);
382 0 : _buffer.writeln();
383 0 : } else if (highlight.span.end.line == line.number) {
384 0 : final coversWholeLine = highlight.span.end.column == line.text.length;
385 0 : if (coversWholeLine && highlight.label == null) {
386 0 : replaceWithNull(highlightsByColumn, highlight);
387 : return;
388 : }
389 :
390 0 : _writeSidebar();
391 0 : _buffer.write(' ');
392 0 : _writeMultilineHighlights(line, highlightsByColumn, current: highlight);
393 :
394 0 : _colorize(() {
395 : if (coversWholeLine) {
396 0 : _buffer.write(glyph.horizontalLine * 3);
397 : } else {
398 0 : _writeArrow(line, math.max(highlight.span.end.column - 1, 0),
399 : beginning: false);
400 : }
401 0 : _writeLabel(highlight.label);
402 : }, color: color);
403 0 : _buffer.writeln();
404 0 : replaceWithNull(highlightsByColumn, highlight);
405 : }
406 : }
407 :
408 : /// Underlines the portion of [line] covered by [span] with repeated instances
409 : /// of [character].
410 0 : void _writeUnderline(_Line line, SourceSpan span, String character) {
411 0 : assert(!isMultiline(span));
412 0 : assert(line.text.contains(span.text));
413 :
414 0 : var startColumn = span.start.column;
415 0 : var endColumn = span.end.column;
416 :
417 : // Adjust the start and end columns to account for any tabs that were
418 : // converted to spaces.
419 0 : final tabsBefore = _countTabs(line.text.substring(0, startColumn));
420 0 : final tabsInside = _countTabs(line.text.substring(startColumn, endColumn));
421 0 : startColumn += tabsBefore * (_spacesPerTab - 1);
422 0 : endColumn += (tabsBefore + tabsInside) * (_spacesPerTab - 1);
423 :
424 0 : _buffer
425 0 : ..write(' ' * startColumn)
426 0 : ..write(character * math.max(endColumn - startColumn, 1));
427 : }
428 :
429 : /// Write an arrow pointing to column [column] in [line].
430 : ///
431 : /// If the arrow points to a tab character, this will point to the beginning
432 : /// of the tab if [beginning] is `true` and the end if it's `false`.
433 0 : void _writeArrow(_Line line, int column, {bool beginning = true}) {
434 : final tabs =
435 0 : _countTabs(line.text.substring(0, column + (beginning ? 0 : 1)));
436 0 : _buffer
437 0 : ..write(glyph.horizontalLine * (1 + column + tabs * (_spacesPerTab - 1)))
438 0 : ..write('^');
439 : }
440 :
441 : /// Writes a space followed by [label] if [label] isn't `null`.
442 0 : void _writeLabel(String? label) {
443 0 : if (label != null) _buffer.write(' $label');
444 : }
445 :
446 : /// Writes a snippet from the source text, converting hard tab characters into
447 : /// plain indentation.
448 0 : void _writeText(String text) {
449 0 : for (var char in text.codeUnits) {
450 0 : if (char == $tab) {
451 0 : _buffer.write(' ' * _spacesPerTab);
452 : } else {
453 0 : _buffer.writeCharCode(char);
454 : }
455 : }
456 : }
457 :
458 : // Writes a sidebar to [buffer] that includes [line] as the line number if
459 : // given and writes [end] at the end (defaults to [glyphs.verticalLine]).
460 : //
461 : // If [text] is given, it's used in place of the line number. It can't be
462 : // passed at the same time as [line].
463 0 : void _writeSidebar({int? line, String? text, String? end}) {
464 0 : assert(line == null || text == null);
465 :
466 : // Add 1 to line to convert from computer-friendly 0-indexed line numbers to
467 : // human-friendly 1-indexed line numbers.
468 0 : if (line != null) text = (line + 1).toString();
469 0 : _colorize(() {
470 0 : _buffer
471 0 : ..write((text ?? '').padRight(_paddingBeforeSidebar))
472 0 : ..write(end ?? glyph.verticalLine);
473 : }, color: colors.blue);
474 : }
475 :
476 : /// Returns the number of hard tabs in [text].
477 0 : int _countTabs(String text) {
478 : var count = 0;
479 0 : for (var char in text.codeUnits) {
480 0 : if (char == $tab) count++;
481 : }
482 : return count;
483 : }
484 :
485 : /// Returns whether [text] contains only space or tab characters.
486 0 : bool _isOnlyWhitespace(String text) {
487 0 : for (var char in text.codeUnits) {
488 0 : if (char != $space && char != $tab) return false;
489 : }
490 : return true;
491 : }
492 :
493 : /// Colors all text written to [_buffer] during [callback], if colorization is
494 : /// enabled and [color] is not `null`.
495 0 : void _colorize(void Function() callback, {required String? color}) {
496 0 : if (_primaryColor != null && color != null) _buffer.write(color);
497 : callback();
498 0 : if (_primaryColor != null && color != null) _buffer.write(colors.none);
499 : }
500 : }
501 :
502 : /// Information about how to highlight a single section of a source file.
503 : class _Highlight {
504 : /// The section of the source file to highlight.
505 : ///
506 : /// This is normalized to make it easier for [Highlighter] to work with.
507 : final SourceSpanWithContext span;
508 :
509 : /// Whether this is the primary span in the highlight.
510 : ///
511 : /// The primary span is highlighted with a different character and colored
512 : /// differently than non-primary spans.
513 : final bool isPrimary;
514 :
515 : /// The label to include inline when highlighting [span].
516 : ///
517 : /// This helps distinguish clarify what each highlight means when multiple are
518 : /// used in the same message.
519 : final String? label;
520 :
521 0 : _Highlight(SourceSpan span, {this.label, bool primary = false})
522 0 : : span = (() {
523 0 : var newSpan = _normalizeContext(span);
524 0 : newSpan = _normalizeNewlines(newSpan);
525 0 : newSpan = _normalizeTrailingNewline(newSpan);
526 0 : return _normalizeEndOfLine(newSpan);
527 : })(),
528 : isPrimary = primary;
529 :
530 : /// Normalizes [span] to ensure that it's a [SourceSpanWithContext] whose
531 : /// context actually contains its text at the expected column.
532 : ///
533 : /// If it's not already a [SourceSpanWithContext], adjust the start and end
534 : /// locations' line and column fields so that the highlighter can assume they
535 : /// match up with the context.
536 0 : static SourceSpanWithContext _normalizeContext(SourceSpan span) =>
537 0 : span is SourceSpanWithContext &&
538 0 : findLineStart(span.context, span.text, span.start.column) != null
539 : ? span
540 0 : : SourceSpanWithContext(
541 0 : SourceLocation(span.start.offset,
542 0 : sourceUrl: span.sourceUrl, line: 0, column: 0),
543 0 : SourceLocation(span.end.offset,
544 0 : sourceUrl: span.sourceUrl,
545 0 : line: countCodeUnits(span.text, $lf),
546 0 : column: _lastLineLength(span.text)),
547 0 : span.text,
548 0 : span.text);
549 :
550 : /// Normalizes [span] to replace Windows-style newlines with Unix-style
551 : /// newlines.
552 0 : static SourceSpanWithContext _normalizeNewlines(SourceSpanWithContext span) {
553 0 : final text = span.text;
554 0 : if (!text.contains('\r\n')) return span;
555 :
556 0 : var endOffset = span.end.offset;
557 0 : for (var i = 0; i < text.length - 1; i++) {
558 0 : if (text.codeUnitAt(i) == $cr && text.codeUnitAt(i + 1) == $lf) {
559 0 : endOffset--;
560 : }
561 : }
562 :
563 0 : return SourceSpanWithContext(
564 0 : span.start,
565 0 : SourceLocation(endOffset,
566 0 : sourceUrl: span.sourceUrl,
567 0 : line: span.end.line,
568 0 : column: span.end.column),
569 0 : text.replaceAll('\r\n', '\n'),
570 0 : span.context.replaceAll('\r\n', '\n'));
571 : }
572 :
573 : /// Normalizes [span] to remove a trailing newline from `span.context`.
574 : ///
575 : /// If necessary, also adjust `span.end` so that it doesn't point past where
576 : /// the trailing newline used to be.
577 0 : static SourceSpanWithContext _normalizeTrailingNewline(
578 : SourceSpanWithContext span) {
579 0 : if (!span.context.endsWith('\n')) return span;
580 :
581 : // If there's a full blank line on the end of [span.context], it's probably
582 : // significant, so we shouldn't trim it.
583 0 : if (span.text.endsWith('\n\n')) return span;
584 :
585 0 : final context = span.context.substring(0, span.context.length - 1);
586 0 : var text = span.text;
587 0 : var start = span.start;
588 0 : var end = span.end;
589 0 : if (span.text.endsWith('\n') && _isTextAtEndOfContext(span)) {
590 0 : text = span.text.substring(0, span.text.length - 1);
591 0 : if (text.isEmpty) {
592 : end = start;
593 : } else {
594 0 : end = SourceLocation(span.end.offset - 1,
595 0 : sourceUrl: span.sourceUrl,
596 0 : line: span.end.line - 1,
597 0 : column: _lastLineLength(context));
598 0 : start = span.start.offset == span.end.offset ? end : span.start;
599 : }
600 : }
601 0 : return SourceSpanWithContext(start, end, text, context);
602 : }
603 :
604 : /// Normalizes [span] so that the end location is at the end of a line rather
605 : /// than at the beginning of the next line.
606 0 : static SourceSpanWithContext _normalizeEndOfLine(SourceSpanWithContext span) {
607 0 : if (span.end.column != 0) return span;
608 0 : if (span.end.line == span.start.line) return span;
609 :
610 0 : final text = span.text.substring(0, span.text.length - 1);
611 :
612 0 : return SourceSpanWithContext(
613 0 : span.start,
614 0 : SourceLocation(span.end.offset - 1,
615 0 : sourceUrl: span.sourceUrl,
616 0 : line: span.end.line - 1,
617 0 : column: text.length - text.lastIndexOf('\n') - 1),
618 : text,
619 : // If the context also ends with a newline, it's possible that we don't
620 : // have the full context for that line, so we shouldn't print it at all.
621 0 : span.context.endsWith('\n')
622 0 : ? span.context.substring(0, span.context.length - 1)
623 0 : : span.context);
624 : }
625 :
626 : /// Returns the length of the last line in [text], whether or not it ends in a
627 : /// newline.
628 0 : static int _lastLineLength(String text) {
629 0 : if (text.isEmpty) {
630 : return 0;
631 0 : } else if (text.codeUnitAt(text.length - 1) == $lf) {
632 0 : return text.length == 1
633 : ? 0
634 0 : : text.length - text.lastIndexOf('\n', text.length - 2) - 1;
635 : } else {
636 0 : return text.length - text.lastIndexOf('\n') - 1;
637 : }
638 : }
639 :
640 : /// Returns whether [span]'s text runs all the way to the end of its context.
641 0 : static bool _isTextAtEndOfContext(SourceSpanWithContext span) =>
642 0 : findLineStart(span.context, span.text, span.start.column)! +
643 0 : span.start.column +
644 0 : span.length ==
645 0 : span.context.length;
646 :
647 0 : @override
648 : String toString() {
649 0 : final buffer = StringBuffer();
650 0 : if (isPrimary) buffer.write('primary ');
651 0 : buffer.write('${span.start.line}:${span.start.column}-'
652 0 : '${span.end.line}:${span.end.column}');
653 0 : if (label != null) buffer.write(' ($label)');
654 0 : return buffer.toString();
655 : }
656 : }
657 :
658 : /// A single line of the source file being highlighted.
659 : class _Line {
660 : /// The text of the line, not including the trailing newline.
661 : final String text;
662 :
663 : /// The 0-based line number in the source file.
664 : final int number;
665 :
666 : /// The URL of the source file in which this line appears.
667 : final Uri? url;
668 :
669 : /// All highlights that cover any portion of this line, in source span order.
670 : ///
671 : /// This is populated after the initial line is created.
672 : final highlights = <_Highlight>[];
673 :
674 0 : _Line(this.text, this.number, this.url);
675 :
676 0 : @override
677 0 : String toString() => '$number: "$text" (${highlights.join(', ')})';
678 : }
|