Properly indent multi-line labels for mutli-span highlights (#86)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5722045..4c49079 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,6 @@
-# 1.9.1-dev
+# 1.9.1
+
+* Properly handle multi-line labels for multi-span highlights.
 
 * Populate the pubspec `repository` field.
 
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 002297f..97c0552 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -39,7 +39,6 @@
   - file_names
   - hash_and_equals
   - implementation_imports
-  - invariant_booleans
   - iterable_contains_unrelated_type
   - library_names
   - library_prefixes
diff --git a/lib/src/highlighter.dart b/lib/src/highlighter.dart
index d4e5ebf..53f3d63 100644
--- a/lib/src/highlighter.dart
+++ b/lib/src/highlighter.dart
@@ -367,12 +367,13 @@
       _writeMultilineHighlights(line, highlightsByColumn, current: highlight);
       if (highlightsByColumn.isNotEmpty) _buffer.write(' ');
 
-      _colorize(() {
+      final underlineLength = _colorize(() {
+        final start = _buffer.length;
         _writeUnderline(line, highlight.span,
             highlight.isPrimary ? '^' : glyph.horizontalLineBold);
-        _writeLabel(highlight.label);
+        return _buffer.length - start;
       }, color: color);
-      _buffer.writeln();
+      _writeLabel(highlight, highlightsByColumn, underlineLength);
     } else if (highlight.span.start.line == line.number) {
       if (highlightsByColumn.contains(highlight)) return;
       replaceFirstNull(highlightsByColumn, highlight);
@@ -394,16 +395,17 @@
       _buffer.write(' ');
       _writeMultilineHighlights(line, highlightsByColumn, current: highlight);
 
-      _colorize(() {
+      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);
         }
-        _writeLabel(highlight.label);
+        return _buffer.length - start;
       }, color: color);
-      _buffer.writeln();
+      _writeLabel(highlight, highlightsByColumn, underlineLength);
       replaceWithNull(highlightsByColumn, highlight);
     }
   }
@@ -442,9 +444,45 @@
       ..write('^');
   }
 
-  /// Writes a space followed by [label] if [label] isn't `null`.
-  void _writeLabel(String? label) {
-    if (label != null) _buffer.write(' $label');
+  /// 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
@@ -496,10 +534,11 @@
 
   /// Colors all text written to [_buffer] during [callback], if colorization is
   /// enabled and [color] is not `null`.
-  void _colorize(void Function() callback, {required String? color}) {
+  T _colorize<T>(T Function() callback, {required String? color}) {
     if (_primaryColor != null && color != null) _buffer.write(color);
-    callback();
+    final result = callback();
     if (_primaryColor != null && color != null) _buffer.write(colors.none);
+    return result;
   }
 }
 
@@ -522,14 +561,15 @@
   /// used in the same message.
   final String? label;
 
-  _Highlight(SourceSpan span, {this.label, bool primary = false})
+  _Highlight(SourceSpan span, {String? label, bool primary = false})
       : span = (() {
           var newSpan = _normalizeContext(span);
           newSpan = _normalizeNewlines(newSpan);
           newSpan = _normalizeTrailingNewline(newSpan);
           return _normalizeEndOfLine(newSpan);
         })(),
-        isPrimary = primary;
+        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.
diff --git a/pubspec.yaml b/pubspec.yaml
index 63a64b0..8bde1c2 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: source_span
-version: 1.9.1-dev
+version: 1.9.1
 description: A library for identifying source spans and locations.
 repository: https://github.com/dart-lang/source_span
 
diff --git a/test/multiple_highlight_test.dart b/test/multiple_highlight_test.dart
index a9f5fda..ba4d686 100644
--- a/test/multiple_highlight_test.dart
+++ b/test/multiple_highlight_test.dart
@@ -328,4 +328,100 @@
   | === two
   '"""));
   });
+
+  group('indents mutli-line labels', () {
+    test('for the primary label', () {
+      expect(file.span(17, 21).highlightMultiple('line 1\nline 2\nline 3', {}),
+          equals("""
+  ,
+2 | whiz bang boom
+  |      ^^^^ line 1
+  |           line 2
+  |           line 3
+  '"""));
+    });
+
+    group('for a secondary label', () {
+      test('on the same line', () {
+        expect(
+            file.span(17, 21).highlightMultiple(
+                'primary', {file.span(22, 26): 'line 1\nline 2\nline 3'}),
+            equals("""
+  ,
+2 | whiz bang boom
+  |      ^^^^ primary
+  |           ==== line 1
+  |                line 2
+  |                line 3
+  '"""));
+      });
+
+      test('on a different line', () {
+        expect(
+            file.span(17, 21).highlightMultiple(
+                'primary', {file.span(31, 34): 'line 1\nline 2\nline 3'}),
+            equals("""
+  ,
+2 | whiz bang boom
+  |      ^^^^ primary
+3 | zip zap zop
+  |     === line 1
+  |         line 2
+  |         line 3
+  '"""));
+      });
+    });
+
+    group('for a multiline span', () {
+      test('that covers the whole last line', () {
+        expect(
+            file.span(12, 70).highlightMultiple('line 1\nline 2\nline 3', {}),
+            equals("""
+  ,
+2 | / whiz bang boom
+3 | | zip zap zop
+4 | | fwee fwoo fwip
+5 | | argle bargle boo
+  | '--- line 1
+  |      line 2
+  |      line 3
+  '"""));
+      });
+
+      test('that covers part of the last line', () {
+        expect(
+            file.span(12, 66).highlightMultiple('line 1\nline 2\nline 3', {}),
+            equals("""
+  ,
+2 | / whiz bang boom
+3 | | zip zap zop
+4 | | fwee fwoo fwip
+5 | | argle bargle boo
+  | '------------^ line 1
+  |                line 2
+  |                line 3
+  '"""));
+      });
+    });
+
+    test('with an overlapping span', () {
+      expect(
+          file.span(12, 70).highlightMultiple('line 1\nline 2\nline 3',
+              {file.span(54, 89): 'two', file.span(0, 27): 'three'}),
+          equals("""
+  ,
+1 | /- foo bar baz
+2 | |/ whiz bang boom
+  | '+--- three
+3 |  | zip zap zop
+4 |  | fwee fwoo fwip
+5 | /+ argle bargle boo
+  | |'--- line 1
+  | |     line 2
+  | |     line 3
+6 | |  gibble bibble bop
+  | '---- two
+  '"""));
+    });
+  });
 }