Merge package:source_span into the tools monorepo
diff --git a/pkgs/source_span/.github/dependabot.yml b/pkgs/source_span/.github/dependabot.yml
new file mode 100644
index 0000000..eeaa9d9
--- /dev/null
+++ b/pkgs/source_span/.github/dependabot.yml
@@ -0,0 +1,16 @@
+# Set update schedule for GitHub Actions
+# See https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
+
+version: 2
+updates:
+
+- package-ecosystem: github-actions
+  directory: /
+  schedule:
+    interval: monthly
+  labels:
+    - autosubmit
+  groups:
+    github-actions:
+      patterns:
+        - "*"
diff --git a/pkgs/source_span/.github/workflows/publish.yaml b/pkgs/source_span/.github/workflows/publish.yaml
new file mode 100644
index 0000000..2239b63
--- /dev/null
+++ b/pkgs/source_span/.github/workflows/publish.yaml
@@ -0,0 +1,14 @@
+# A CI configuration to auto-publish pub packages.
+
+name: Publish
+
+on:
+  pull_request:
+    branches: [ master ]
+  push:
+    tags: [ 'v[0-9]+.[0-9]+.[0-9]+' ]
+
+jobs:
+  publish:
+    if: ${{ github.repository_owner == 'dart-lang' }}
+    uses: dart-lang/ecosystem/.github/workflows/publish.yaml@main
diff --git a/pkgs/source_span/.github/workflows/test-package.yml b/pkgs/source_span/.github/workflows/test-package.yml
new file mode 100644
index 0000000..c8f1f52
--- /dev/null
+++ b/pkgs/source_span/.github/workflows/test-package.yml
@@ -0,0 +1,64 @@
+name: Dart CI
+
+on:
+  # Run on PRs and pushes to the default branch.
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+  schedule:
+    - cron: "0 0 * * 0"
+
+env:
+  PUB_ENVIRONMENT: bot.github
+
+jobs:
+  # Check code formatting and static analysis on a single OS (linux)
+  # against Dart dev.
+  analyze:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        sdk: [dev]
+    steps:
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94
+        with:
+          sdk: ${{ matrix.sdk }}
+      - id: install
+        name: Install dependencies
+        run: dart pub get
+      - name: Check formatting
+        run: dart format --output=none --set-exit-if-changed .
+        if: always() && steps.install.outcome == 'success'
+      - name: Analyze code
+        run: dart analyze --fatal-infos
+        if: always() && steps.install.outcome == 'success'
+
+  # Run tests on a matrix consisting of two dimensions:
+  # 1. OS: ubuntu-latest, (macos-latest, windows-latest)
+  # 2. release channel: dev
+  test:
+    needs: analyze
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        # Add macos-latest and/or windows-latest if relevant for this package.
+        os: [ubuntu-latest]
+        sdk: [3.1.0, dev]
+    steps:
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94
+        with:
+          sdk: ${{ matrix.sdk }}
+      - id: install
+        name: Install dependencies
+        run: dart pub get
+      - name: Run VM tests
+        run: dart test --platform vm
+        if: always() && steps.install.outcome == 'success'
+      - name: Run Chrome tests
+        run: dart test --platform chrome
+        if: always() && steps.install.outcome == 'success'
diff --git a/pkgs/source_span/.gitignore b/pkgs/source_span/.gitignore
new file mode 100644
index 0000000..ab3cb76
--- /dev/null
+++ b/pkgs/source_span/.gitignore
@@ -0,0 +1,16 @@
+# Don’t commit the following directories created by pub.
+.buildlog
+.dart_tool/
+.pub/
+build/
+packages
+.packages
+
+# Or the files created by dart2js.
+*.dart.js
+*.js_
+*.js.deps
+*.js.map
+
+# Include when developing application packages.
+pubspec.lock
diff --git a/pkgs/source_span/CHANGELOG.md b/pkgs/source_span/CHANGELOG.md
new file mode 100644
index 0000000..f46c161
--- /dev/null
+++ b/pkgs/source_span/CHANGELOG.md
@@ -0,0 +1,239 @@
+## 1.10.1-wip
+
+* Require Dart 3.1
+
+## 1.10.0
+
+* Add a `SourceFile.codeUnits` property.
+* Require Dart 2.18
+* Add an API usage example in `example/`.
+
+## 1.9.1
+
+* Properly handle multi-line labels for multi-span highlights.
+
+* Populate the pubspec `repository` field.
+
+## 1.9.0
+
+* Add `SourceSpanWithContextExtension.subspan` that returns a
+  `SourceSpanWithContext` rather than a plain `SourceSpan`.
+
+## 1.8.2
+
+* Fix a bug where highlighting multiple spans with `null` URLs could cause an
+  assertion error. Now when multiple spans are passed with `null` URLs, they're
+  highlighted as though they all come from different source files.
+
+## 1.8.1
+
+* Fix a bug where the URL header for the highlights with multiple files would
+  get omitted only one span has a non-null URI.
+
+## 1.8.0
+
+* Stable release for null safety.
+
+## 1.7.0
+
+* Add a `SourceSpan.subspan()` extension method which returns a slice of an
+  existing source span.
+
+## 1.6.0
+
+* Add support for highlighting multiple source spans at once, providing more
+  context for span-based messages. This is exposed through the new APIs
+  `SourceSpan.highlightMultiple()` and `SourceSpan.messageMultiple()` (both
+  extension methods), `MultiSourceSpanException`, and
+  `MultiSourceSpanFormatException`.
+
+## 1.5.6
+
+* Fix padding around line numbers that are powers of 10 in
+  `FileSpan.highlight()`.
+
+## 1.5.5
+
+* Fix a bug where `FileSpan.highlight()` would crash for spans that covered a
+  trailing newline and a single additional empty line.
+
+## 1.5.4
+
+* `FileSpan.highlight()` now properly highlights point spans at the beginning of
+  lines.
+
+## 1.5.3
+
+* Fix an edge case where `FileSpan.highlight()` would put the highlight
+  indicator in the wrong position when highlighting a point span after the end
+  of a file.
+
+## 1.5.2
+
+* `SourceFile.span()` now goes to the end of the file by default, rather than
+  ending one character before the end of the file. This matches the documented
+  behavior.
+
+* `FileSpan.context` now includes the full line on which the span appears for
+  empty spans at the beginning and end of lines.
+
+* Fix an edge case where `FileSpan.highlight()` could crash when highlighting a
+  span that ended with an empty line.
+
+## 1.5.1
+
+* Produce better source span highlights for multi-line spans that cover the
+  entire last line of the span, including the newline.
+
+* Produce better source span highlights for spans that contain Windows-style
+  newlines.
+
+## 1.5.0
+
+* Improve the output of `SourceSpan.highlight()` and `SourceSpan.message()`:
+
+  * They now include line numbers.
+  * They will now print every line of a multiline span.
+  * They will now use Unicode box-drawing characters by default (this can be
+    controlled using [`term_glyph.ascii`][]).
+
+[`term_glyph.ascii`]: https://pub.dartlang.org/documentation/term_glyph/latest/term_glyph/ascii.html
+
+## 1.4.1
+
+* Set max SDK version to `<3.0.0`, and adjust other dependencies.
+
+## 1.4.0
+
+* The `new SourceFile()` constructor is deprecated. This constructed a source
+  file from a string's runes, rather than its code units, which runs counter to
+  the way Dart handles strings otherwise. The `new StringFile.fromString()`
+  constructor (see below) should be used instead.
+
+* The `new SourceFile.fromString()` constructor was added. This works like `new
+  SourceFile()`, except it uses code units rather than runes.
+
+* The current behavior when characters larger than `0xFFFF` are passed to `new
+  SourceFile.decoded()` is now considered deprecated.
+
+## 1.3.1
+
+* Properly highlight spans for lines that include tabs with
+  `SourceSpan.highlight()` and `SourceSpan.message()`.
+
+## 1.3.0
+
+* Add `SourceSpan.highlight()`, which returns just the highlighted text that
+  would be included in `SourceSpan.message()`.
+
+## 1.2.4
+
+* Fix a new strong mode error.
+
+## 1.2.3
+
+* Fix a bug where a point span at the end of a file without a trailing newline
+  would be printed incorrectly.
+
+## 1.2.2
+
+* Allow `SourceSpanException.message`, `SourceSpanFormatException.source`, and
+  `SourceSpanWithContext.context` to be overridden in strong mode.
+
+## 1.2.1
+
+* Fix the declared type of `FileSpan.start` and `FileSpan.end`. In 1.2.0 these
+  were mistakenly changed from `FileLocation` to `SourceLocation`.
+
+## 1.2.0
+
+* **Deprecated:** Extending `SourceLocation` directly is deprecated. Instead,
+  extend the new `SourceLocationBase` class or mix in the new
+  `SourceLocationMixin` mixin.
+
+* Dramatically improve the performance of `FileLocation`.
+
+## 1.1.6
+
+* Optimize `getLine()` in `SourceFile` when repeatedly called.
+
+## 1.1.5
+
+* Fixed another case in which `FileSpan.union` could throw an exception for
+  external implementations of `FileSpan`.
+
+## 1.1.4
+
+* Eliminated dart2js warning about overriding `==`, but not `hashCode`.
+
+## 1.1.3
+
+* `FileSpan.compareTo`, `FileSpan.==`, `FileSpan.union`, and `FileSpan.expand`
+  no longer throw exceptions for external implementations of `FileSpan`.
+
+* `FileSpan.hashCode` now fully agrees with `FileSpan.==`.
+
+## 1.1.2
+
+* Fixed validation in `SourceSpanWithContext` to allow multiple occurrences of
+  `text` within `context`.
+
+## 1.1.1
+
+* Fixed `FileSpan`'s context to include the full span text, not just the first
+  line of it.
+
+## 1.1.0
+
+* Added `SourceSpanWithContext`: a span that also includes the full line of text
+  that contains the span.
+
+## 1.0.3
+
+* Cleanup equality operator to accept any Object rather than just a
+  `SourceLocation`.
+
+## 1.0.2
+
+* Avoid unintentionally allocating extra objects for internal `FileSpan`
+  operations.
+
+* Ensure that `SourceSpan.operator==` works on arbitrary `Object`s.
+
+## 1.0.1
+
+* Use a more compact internal representation for `FileSpan`.
+
+## 1.0.0
+
+This package was extracted from the
+[`source_maps`](https://pub.dev/packages/source_maps) package, but the
+API has many differences. Among them:
+
+* `Span` has been renamed to `SourceSpan` and `Location` has been renamed to
+  `SourceLocation` to clarify their purpose and maintain consistency with the
+  package name. Likewise, `SpanException` is now `SourceSpanException` and
+  `SpanFormatException` is not `SourceSpanFormatException`.
+
+* `FixedSpan` and `FixedLocation` have been rolled into the `Span` and
+  `Location` classes, respectively.
+
+* `SourceFile` is more aggressive about validating its arguments. Out-of-bounds
+  lines, columns, and offsets will now throw errors rather than be silently
+  clamped.
+
+* `SourceSpan.sourceUrl`, `SourceLocation.sourceUrl`, and `SourceFile.url` now
+  return `Uri` objects rather than `String`s. The constructors allow either
+  `String`s or `Uri`s.
+
+* `Span.getLocationMessage` and `SourceFile.getLocationMessage` are now
+  `SourceSpan.message` and `SourceFile.message`, respectively. Rather than
+  taking both a `useColor` and a `color` parameter, they now take a single
+  `color` parameter that controls both whether and which color is used.
+
+* `Span.isIdentifier` has been removed. This property doesn't make sense outside
+  of a source map context.
+
+* `SourceFileSegment` has been removed. This class wasn't widely used and was
+  inconsistent in its choice of which parameters were considered relative and
+  which absolute.
diff --git a/pkgs/source_span/LICENSE b/pkgs/source_span/LICENSE
new file mode 100644
index 0000000..000cd7b
--- /dev/null
+++ b/pkgs/source_span/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2014, the Dart project authors. 
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+    * Neither the name of Google LLC nor the names of its
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/pkgs/source_span/README.md b/pkgs/source_span/README.md
new file mode 100644
index 0000000..0faf0cb
--- /dev/null
+++ b/pkgs/source_span/README.md
@@ -0,0 +1,21 @@
+[![Dart CI](https://github.com/dart-lang/source_span/actions/workflows/test-package.yml/badge.svg)](https://github.com/dart-lang/source_span/actions/workflows/test-package.yml)
+[![pub package](https://img.shields.io/pub/v/source_span.svg)](https://pub.dev/packages/source_span)
+[![package publisher](https://img.shields.io/pub/publisher/source_span.svg)](https://pub.dev/packages/source_span/publisher)
+
+## About this package
+
+`source_span` is a library for tracking locations in source code. It's designed
+to provide a standard representation for source code locations and spans so that
+disparate packages can easily pass them among one another, and to make it easy
+to generate human-friendly messages associated with a given piece of code.
+
+The most commonly-used class is the package's namesake, `SourceSpan`. It
+represents a span of characters in some source file, and is often attached to an
+object that has been parsed to indicate where it was parsed from. It provides
+access to the text of the span via `SourceSpan.text` and can be used to produce
+human-friendly messages using `SourceSpan.message()`.
+
+When parsing code from a file, `SourceFile` is useful. Not only does it provide
+an efficient means of computing line and column numbers, `SourceFile.span()`
+returns special `FileSpan`s that are able to provide more context for their
+error messages.
diff --git a/pkgs/source_span/analysis_options.yaml b/pkgs/source_span/analysis_options.yaml
new file mode 100644
index 0000000..d2ebdbf
--- /dev/null
+++ b/pkgs/source_span/analysis_options.yaml
@@ -0,0 +1,32 @@
+# https://dart.dev/guides/language/analysis-options
+include: package:dart_flutter_team_lints/analysis_options.yaml
+
+analyzer:
+  language:
+    strict-casts: true
+    strict-inference: true
+    strict-raw-types: true
+
+linter:
+  rules:
+  - avoid_bool_literals_in_conditional_expressions
+  - avoid_classes_with_only_static_members
+  - avoid_private_typedef_functions
+  - avoid_redundant_argument_values
+  - avoid_returning_this
+  - avoid_unused_constructor_parameters
+  - avoid_void_async
+  - cancel_subscriptions
+  - cascade_invocations
+  - join_return_with_assignment
+  - literal_only_boolean_expressions
+  - missing_whitespace_between_adjacent_strings
+  - no_adjacent_strings_in_list
+  - prefer_const_declarations
+  - prefer_expression_function_bodies
+  - prefer_final_locals
+  - unnecessary_await_in_return
+  - unnecessary_raw_strings
+  - use_if_null_to_convert_nulls_to_bools
+  - use_raw_strings
+  - use_string_buffers
diff --git a/pkgs/source_span/example/main.dart b/pkgs/source_span/example/main.dart
new file mode 100644
index 0000000..e296765
--- /dev/null
+++ b/pkgs/source_span/example/main.dart
@@ -0,0 +1,51 @@
+// Copyright (c) 2023, 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:io';
+
+import 'package:source_span/source_span.dart';
+
+void main(List<String> args) {
+  final file = File('README.md');
+  final contents = file.readAsStringSync();
+
+  final sourceFile = SourceFile.fromString(contents, url: file.uri);
+  final spans = _parseFile(contents, sourceFile);
+
+  for (var span in spans.take(30)) {
+    print('[${span.start.line + 1}:${span.start.column + 1}] ${span.text}');
+  }
+}
+
+Iterable<SourceSpan> _parseFile(String contents, SourceFile sourceFile) sync* {
+  var wordStart = 0;
+  var inWhiteSpace = true;
+
+  for (var i = 0; i < contents.length; i++) {
+    final codeUnit = contents.codeUnitAt(i);
+
+    if (codeUnit == _eol || codeUnit == _space) {
+      if (!inWhiteSpace) {
+        inWhiteSpace = true;
+
+        // emit a word
+        yield sourceFile.span(wordStart, i);
+      }
+    } else {
+      if (inWhiteSpace) {
+        inWhiteSpace = false;
+
+        wordStart = i;
+      }
+    }
+  }
+
+  if (!inWhiteSpace) {
+    // emit a word
+    yield sourceFile.span(wordStart, contents.length);
+  }
+}
+
+const int _eol = 10;
+const int _space = 32;
diff --git a/pkgs/source_span/lib/source_span.dart b/pkgs/source_span/lib/source_span.dart
new file mode 100644
index 0000000..534a3a7
--- /dev/null
+++ b/pkgs/source_span/lib/source_span.dart
@@ -0,0 +1,11 @@
+// Copyright (c) 2014, 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.
+
+export 'src/file.dart';
+export 'src/location.dart';
+export 'src/location_mixin.dart';
+export 'src/span.dart';
+export 'src/span_exception.dart';
+export 'src/span_mixin.dart';
+export 'src/span_with_context.dart';
diff --git a/pkgs/source_span/lib/src/charcode.dart b/pkgs/source_span/lib/src/charcode.dart
new file mode 100644
index 0000000..5182638
--- /dev/null
+++ b/pkgs/source_span/lib/src/charcode.dart
@@ -0,0 +1,15 @@
+// Copyright (c) 2020, 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.
+
+/// "Carriage return" control character.
+const int $cr = 0x0D;
+
+/// "Line feed" control character.
+const int $lf = 0x0A;
+
+/// Space character.
+const int $space = 0x20;
+
+/// "Horizontal Tab" control character, common name.
+const int $tab = 0x09;
diff --git a/pkgs/source_span/lib/src/colors.dart b/pkgs/source_span/lib/src/colors.dart
new file mode 100644
index 0000000..b48d468
--- /dev/null
+++ b/pkgs/source_span/lib/src/colors.dart
@@ -0,0 +1,12 @@
+// Copyright (c) 2014, 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.
+
+// Color constants used for generating messages.
+const String red = '\u001b[31m';
+
+const String yellow = '\u001b[33m';
+
+const String blue = '\u001b[34m';
+
+const String none = '\u001b[0m';
diff --git a/pkgs/source_span/lib/src/file.dart b/pkgs/source_span/lib/src/file.dart
new file mode 100644
index 0000000..74c9234
--- /dev/null
+++ b/pkgs/source_span/lib/src/file.dart
@@ -0,0 +1,454 @@
+// Copyright (c) 2014, 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 'dart:typed_data';
+
+import 'location.dart';
+import 'location_mixin.dart';
+import 'span.dart';
+import 'span_mixin.dart';
+import 'span_with_context.dart';
+
+// Constants to determine end-of-lines.
+const int _lf = 10;
+const int _cr = 13;
+
+/// A class representing a source file.
+///
+/// This doesn't necessarily have to correspond to a file on disk, just a chunk
+/// of text usually with a URL associated with it.
+class SourceFile {
+  /// The URL where the source file is located.
+  ///
+  /// This may be null, indicating that the URL is unknown or unavailable.
+  final Uri? url;
+
+  /// An array of offsets for each line beginning in the file.
+  ///
+  /// Each offset refers to the first character *after* the newline. If the
+  /// source file has a trailing newline, the final offset won't actually be in
+  /// the file.
+  final _lineStarts = <int>[0];
+
+  /// The code units of the characters in the file.
+  ///
+  /// If this was constructed with the deprecated `SourceFile()` constructor,
+  /// this will instead contain the code _points_ of the characters in the file
+  /// (so characters above 2^16 are represented as individual integers rather
+  /// than surrogate pairs).
+  List<int> get codeUnits => _decodedChars;
+
+  /// The code units of the characters in this file.
+  final Uint32List _decodedChars;
+
+  /// The length of the file in characters.
+  int get length => _decodedChars.length;
+
+  /// The number of lines in the file.
+  int get lines => _lineStarts.length;
+
+  /// The line that the offset fell on the last time [getLine] was called.
+  ///
+  /// In many cases, sequential calls to getLine() are for nearby, usually
+  /// increasing offsets. In that case, we can find the line for an offset
+  /// quickly by first checking to see if the offset is on the same line as the
+  /// previous result.
+  int? _cachedLine;
+
+  /// This constructor is deprecated.
+  ///
+  /// Use [SourceFile.fromString] instead.
+  @Deprecated('Will be removed in 2.0.0')
+  SourceFile(String text, {Object? url}) : this.decoded(text.runes, url: url);
+
+  /// Creates a new source file from [text].
+  ///
+  /// [url] may be either a [String], a [Uri], or `null`.
+  SourceFile.fromString(String text, {Object? url})
+      : this.decoded(text.codeUnits, url: url);
+
+  /// Creates a new source file from a list of decoded code units.
+  ///
+  /// [url] may be either a [String], a [Uri], or `null`.
+  ///
+  /// Currently, if [decodedChars] contains characters larger than `0xFFFF`,
+  /// they'll be treated as single characters rather than being split into
+  /// surrogate pairs. **This behavior is deprecated**. For
+  /// forwards-compatibility, callers should only pass in characters less than
+  /// or equal to `0xFFFF`.
+  SourceFile.decoded(Iterable<int> decodedChars, {Object? url})
+      : url = url is String ? Uri.parse(url) : url as Uri?,
+        _decodedChars = Uint32List.fromList(decodedChars.toList()) {
+    for (var i = 0; i < _decodedChars.length; i++) {
+      var c = _decodedChars[i];
+      if (c == _cr) {
+        // Return not followed by newline is treated as a newline
+        final j = i + 1;
+        if (j >= _decodedChars.length || _decodedChars[j] != _lf) c = _lf;
+      }
+      if (c == _lf) _lineStarts.add(i + 1);
+    }
+  }
+
+  /// Returns a span from [start] to [end] (exclusive).
+  ///
+  /// If [end] isn't passed, it defaults to the end of the file.
+  FileSpan span(int start, [int? end]) {
+    end ??= length;
+    return _FileSpan(this, start, end);
+  }
+
+  /// Returns a location at [offset].
+  FileLocation location(int offset) => FileLocation._(this, offset);
+
+  /// Gets the 0-based line corresponding to [offset].
+  int getLine(int offset) {
+    if (offset < 0) {
+      throw RangeError('Offset may not be negative, was $offset.');
+    } else if (offset > length) {
+      throw RangeError('Offset $offset must not be greater than the number '
+          'of characters in the file, $length.');
+    }
+
+    if (offset < _lineStarts.first) return -1;
+    if (offset >= _lineStarts.last) return _lineStarts.length - 1;
+
+    if (_isNearCachedLine(offset)) return _cachedLine!;
+
+    _cachedLine = _binarySearch(offset) - 1;
+    return _cachedLine!;
+  }
+
+  /// Returns `true` if [offset] is near [_cachedLine].
+  ///
+  /// Checks on [_cachedLine] and the next line. If it's on the next line, it
+  /// updates [_cachedLine] to point to that.
+  bool _isNearCachedLine(int offset) {
+    if (_cachedLine == null) return false;
+    final cachedLine = _cachedLine!;
+
+    // See if it's before the cached line.
+    if (offset < _lineStarts[cachedLine]) return false;
+
+    // See if it's on the cached line.
+    if (cachedLine >= _lineStarts.length - 1 ||
+        offset < _lineStarts[cachedLine + 1]) {
+      return true;
+    }
+
+    // See if it's on the next line.
+    if (cachedLine >= _lineStarts.length - 2 ||
+        offset < _lineStarts[cachedLine + 2]) {
+      _cachedLine = cachedLine + 1;
+      return true;
+    }
+
+    return false;
+  }
+
+  /// Binary search through [_lineStarts] to find the line containing [offset].
+  ///
+  /// Returns the index of the line in [_lineStarts].
+  int _binarySearch(int offset) {
+    var min = 0;
+    var max = _lineStarts.length - 1;
+    while (min < max) {
+      final half = min + ((max - min) ~/ 2);
+      if (_lineStarts[half] > offset) {
+        max = half;
+      } else {
+        min = half + 1;
+      }
+    }
+
+    return max;
+  }
+
+  /// Gets the 0-based column corresponding to [offset].
+  ///
+  /// If [line] is passed, it's assumed to be the line containing [offset] and
+  /// is used to more efficiently compute the column.
+  int getColumn(int offset, {int? line}) {
+    if (offset < 0) {
+      throw RangeError('Offset may not be negative, was $offset.');
+    } else if (offset > length) {
+      throw RangeError('Offset $offset must be not be greater than the '
+          'number of characters in the file, $length.');
+    }
+
+    if (line == null) {
+      line = getLine(offset);
+    } else if (line < 0) {
+      throw RangeError('Line may not be negative, was $line.');
+    } else if (line >= lines) {
+      throw RangeError('Line $line must be less than the number of '
+          'lines in the file, $lines.');
+    }
+
+    final lineStart = _lineStarts[line];
+    if (lineStart > offset) {
+      throw RangeError('Line $line comes after offset $offset.');
+    }
+
+    return offset - lineStart;
+  }
+
+  /// Gets the offset for a [line] and [column].
+  ///
+  /// [column] defaults to 0.
+  int getOffset(int line, [int? column]) {
+    column ??= 0;
+
+    if (line < 0) {
+      throw RangeError('Line may not be negative, was $line.');
+    } else if (line >= lines) {
+      throw RangeError('Line $line must be less than the number of '
+          'lines in the file, $lines.');
+    } else if (column < 0) {
+      throw RangeError('Column may not be negative, was $column.');
+    }
+
+    final result = _lineStarts[line] + column;
+    if (result > length ||
+        (line + 1 < lines && result >= _lineStarts[line + 1])) {
+      throw RangeError("Line $line doesn't have $column columns.");
+    }
+
+    return result;
+  }
+
+  /// Returns the text of the file from [start] to [end] (exclusive).
+  ///
+  /// If [end] isn't passed, it defaults to the end of the file.
+  String getText(int start, [int? end]) =>
+      String.fromCharCodes(_decodedChars.sublist(start, end));
+}
+
+/// A [SourceLocation] within a [SourceFile].
+///
+/// Unlike the base [SourceLocation], [FileLocation] lazily computes its line
+/// and column values based on its offset and the contents of [file].
+///
+/// A [FileLocation] can be created using [SourceFile.location].
+class FileLocation extends SourceLocationMixin implements SourceLocation {
+  /// The [file] that `this` belongs to.
+  final SourceFile file;
+
+  @override
+  final int offset;
+
+  @override
+  Uri? get sourceUrl => file.url;
+
+  @override
+  int get line => file.getLine(offset);
+
+  @override
+  int get column => file.getColumn(offset);
+
+  FileLocation._(this.file, this.offset) {
+    if (offset < 0) {
+      throw RangeError('Offset may not be negative, was $offset.');
+    } else if (offset > file.length) {
+      throw RangeError('Offset $offset must not be greater than the number '
+          'of characters in the file, ${file.length}.');
+    }
+  }
+
+  @override
+  FileSpan pointSpan() => _FileSpan(file, offset, offset);
+}
+
+/// A [SourceSpan] within a [SourceFile].
+///
+/// Unlike the base [SourceSpan], [FileSpan] lazily computes its line and column
+/// values based on its offset and the contents of [file]. [SourceSpan.message]
+/// is also able to provide more context then [SourceSpan.message], and
+/// [SourceSpan.union] will return a [FileSpan] if possible.
+///
+/// A [FileSpan] can be created using [SourceFile.span].
+abstract class FileSpan implements SourceSpanWithContext {
+  /// The [file] that `this` belongs to.
+  SourceFile get file;
+
+  @override
+  FileLocation get start;
+
+  @override
+  FileLocation get end;
+
+  /// Returns a new span that covers both `this` and [other].
+  ///
+  /// Unlike [union], [other] may be disjoint from `this`. If it is, the text
+  /// between the two will be covered by the returned span.
+  FileSpan expand(FileSpan other);
+}
+
+/// The implementation of [FileSpan].
+///
+/// This is split into a separate class so that `is _FileSpan` checks can be run
+/// to make certain operations more efficient. If we used `is FileSpan`, that
+/// would break if external classes implemented the interface.
+class _FileSpan extends SourceSpanMixin implements FileSpan {
+  @override
+  final SourceFile file;
+
+  /// The offset of the beginning of the span.
+  ///
+  /// [start] is lazily generated from this to avoid allocating unnecessary
+  /// objects.
+  final int _start;
+
+  /// The offset of the end of the span.
+  ///
+  /// [end] is lazily generated from this to avoid allocating unnecessary
+  /// objects.
+  final int _end;
+
+  @override
+  Uri? get sourceUrl => file.url;
+
+  @override
+  int get length => _end - _start;
+
+  @override
+  FileLocation get start => FileLocation._(file, _start);
+
+  @override
+  FileLocation get end => FileLocation._(file, _end);
+
+  @override
+  String get text => file.getText(_start, _end);
+
+  @override
+  String get context {
+    final endLine = file.getLine(_end);
+    final endColumn = file.getColumn(_end);
+
+    int? endOffset;
+    if (endColumn == 0 && endLine != 0) {
+      // If [end] is at the very beginning of the line, the span covers the
+      // previous newline, so we only want to include the previous line in the
+      // context...
+
+      if (length == 0) {
+        // ...unless this is a point span, in which case we want to include the
+        // next line (or the empty string if this is the end of the file).
+        return endLine == file.lines - 1
+            ? ''
+            : file.getText(
+                file.getOffset(endLine), file.getOffset(endLine + 1));
+      }
+
+      endOffset = _end;
+    } else if (endLine == file.lines - 1) {
+      // If the span covers the last line of the file, the context should go all
+      // the way to the end of the file.
+      endOffset = file.length;
+    } else {
+      // Otherwise, the context should cover the full line on which [end]
+      // appears.
+      endOffset = file.getOffset(endLine + 1);
+    }
+
+    return file.getText(file.getOffset(file.getLine(_start)), endOffset);
+  }
+
+  _FileSpan(this.file, this._start, this._end) {
+    if (_end < _start) {
+      throw ArgumentError('End $_end must come after start $_start.');
+    } else if (_end > file.length) {
+      throw RangeError('End $_end must not be greater than the number '
+          'of characters in the file, ${file.length}.');
+    } else if (_start < 0) {
+      throw RangeError('Start may not be negative, was $_start.');
+    }
+  }
+
+  @override
+  int compareTo(SourceSpan other) {
+    if (other is! _FileSpan) return super.compareTo(other);
+
+    final result = _start.compareTo(other._start);
+    return result == 0 ? _end.compareTo(other._end) : result;
+  }
+
+  @override
+  SourceSpan union(SourceSpan other) {
+    if (other is! FileSpan) return super.union(other);
+
+    final span = expand(other);
+
+    if (other is _FileSpan) {
+      if (_start > other._end || other._start > _end) {
+        throw ArgumentError('Spans $this and $other are disjoint.');
+      }
+    } else {
+      if (_start > other.end.offset || other.start.offset > _end) {
+        throw ArgumentError('Spans $this and $other are disjoint.');
+      }
+    }
+
+    return span;
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! FileSpan) return super == other;
+    if (other is! _FileSpan) {
+      return super == other && sourceUrl == other.sourceUrl;
+    }
+
+    return _start == other._start &&
+        _end == other._end &&
+        sourceUrl == other.sourceUrl;
+  }
+
+  @override
+  int get hashCode => Object.hash(_start, _end, sourceUrl);
+
+  /// Returns a new span that covers both `this` and [other].
+  ///
+  /// Unlike [union], [other] may be disjoint from `this`. If it is, the text
+  /// between the two will be covered by the returned span.
+  @override
+  FileSpan expand(FileSpan other) {
+    if (sourceUrl != other.sourceUrl) {
+      throw ArgumentError('Source URLs "$sourceUrl" and '
+          " \"${other.sourceUrl}\" don't match.");
+    }
+
+    if (other is _FileSpan) {
+      final start = math.min(_start, other._start);
+      final end = math.max(_end, other._end);
+      return _FileSpan(file, start, end);
+    } else {
+      final start = math.min(_start, other.start.offset);
+      final end = math.max(_end, other.end.offset);
+      return _FileSpan(file, start, end);
+    }
+  }
+
+  /// See `SourceSpanExtension.subspan`.
+  FileSpan subspan(int start, [int? end]) {
+    RangeError.checkValidRange(start, end, length);
+    if (start == 0 && (end == null || end == length)) return this;
+    return file.span(_start + start, end == null ? _end : _start + end);
+  }
+}
+
+// TODO(#52): Move these to instance methods in the next breaking release.
+/// Extension methods on the [FileSpan] API.
+extension FileSpanExtension on FileSpan {
+  /// See `SourceSpanExtension.subspan`.
+  FileSpan subspan(int start, [int? end]) {
+    RangeError.checkValidRange(start, end, length);
+    if (start == 0 && (end == null || end == length)) return this;
+
+    final startOffset = this.start.offset;
+    return file.span(
+        startOffset + start, end == null ? this.end.offset : startOffset + end);
+  }
+}
diff --git a/pkgs/source_span/lib/src/highlighter.dart b/pkgs/source_span/lib/src/highlighter.dart
new file mode 100644
index 0000000..19e04d0
--- /dev/null
+++ b/pkgs/source_span/lib/src/highlighter.dart
@@ -0,0 +1,727 @@
+// 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(', ')})';
+}
diff --git a/pkgs/source_span/lib/src/location.dart b/pkgs/source_span/lib/src/location.dart
new file mode 100644
index 0000000..8f22d7b
--- /dev/null
+++ b/pkgs/source_span/lib/src/location.dart
@@ -0,0 +1,102 @@
+// Copyright (c) 2014, 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 'span.dart';
+
+// TODO(nweiz): Use SourceLocationMixin once we decide to cut a release with
+// breaking changes. See SourceLocationMixin for details.
+
+/// A class that describes a single location within a source file.
+///
+/// This class should not be extended. Instead, [SourceLocationBase] should be
+/// extended instead.
+class SourceLocation implements Comparable<SourceLocation> {
+  /// URL of the source containing this location.
+  ///
+  /// This may be null, indicating that the source URL is unknown or
+  /// unavailable.
+  final Uri? sourceUrl;
+
+  /// The 0-based offset of this location in the source.
+  final int offset;
+
+  /// The 0-based line of this location in the source.
+  final int line;
+
+  /// The 0-based column of this location in the source
+  final int column;
+
+  /// Returns a representation of this location in the `source:line:column`
+  /// format used by text editors.
+  ///
+  /// This prints 1-based lines and columns.
+  String get toolString {
+    final source = sourceUrl ?? 'unknown source';
+    return '$source:${line + 1}:${column + 1}';
+  }
+
+  /// Creates a new location indicating [offset] within [sourceUrl].
+  ///
+  /// [line] and [column] default to assuming the source is a single line. This
+  /// means that [line] defaults to 0 and [column] defaults to [offset].
+  ///
+  /// [sourceUrl] may be either a [String], a [Uri], or `null`.
+  SourceLocation(this.offset, {Object? sourceUrl, int? line, int? column})
+      : sourceUrl =
+            sourceUrl is String ? Uri.parse(sourceUrl) : sourceUrl as Uri?,
+        line = line ?? 0,
+        column = column ?? offset {
+    if (offset < 0) {
+      throw RangeError('Offset may not be negative, was $offset.');
+    } else if (line != null && line < 0) {
+      throw RangeError('Line may not be negative, was $line.');
+    } else if (column != null && column < 0) {
+      throw RangeError('Column may not be negative, was $column.');
+    }
+  }
+
+  /// Returns the distance in characters between `this` and [other].
+  ///
+  /// This always returns a non-negative value.
+  int distance(SourceLocation other) {
+    if (sourceUrl != other.sourceUrl) {
+      throw ArgumentError('Source URLs "$sourceUrl" and '
+          "\"${other.sourceUrl}\" don't match.");
+    }
+    return (offset - other.offset).abs();
+  }
+
+  /// Returns a span that covers only a single point: this location.
+  SourceSpan pointSpan() => SourceSpan(this, this, '');
+
+  /// Compares two locations.
+  ///
+  /// [other] must have the same source URL as `this`.
+  @override
+  int compareTo(SourceLocation other) {
+    if (sourceUrl != other.sourceUrl) {
+      throw ArgumentError('Source URLs "$sourceUrl" and '
+          "\"${other.sourceUrl}\" don't match.");
+    }
+    return offset - other.offset;
+  }
+
+  @override
+  bool operator ==(Object other) =>
+      other is SourceLocation &&
+      sourceUrl == other.sourceUrl &&
+      offset == other.offset;
+
+  @override
+  int get hashCode => (sourceUrl?.hashCode ?? 0) + offset;
+
+  @override
+  String toString() => '<$runtimeType: $offset $toolString>';
+}
+
+/// A base class for source locations with [offset], [line], and [column] known
+/// at construction time.
+class SourceLocationBase extends SourceLocation {
+  SourceLocationBase(super.offset, {super.sourceUrl, super.line, super.column});
+}
diff --git a/pkgs/source_span/lib/src/location_mixin.dart b/pkgs/source_span/lib/src/location_mixin.dart
new file mode 100644
index 0000000..a44f5e2
--- /dev/null
+++ b/pkgs/source_span/lib/src/location_mixin.dart
@@ -0,0 +1,55 @@
+// Copyright (c) 2015, 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 'location.dart';
+import 'span.dart';
+
+// Note: this class duplicates a lot of functionality of [SourceLocation]. This
+// is because in order for SourceLocation to use SourceLocationMixin,
+// SourceLocationMixin couldn't implement SourceLocation. In SourceSpan we
+// handle this by making the class itself non-extensible, but that would be a
+// breaking change for SourceLocation. So until we want to endure the pain of
+// cutting a release with breaking changes, we duplicate the code here.
+
+/// A mixin for easily implementing [SourceLocation].
+abstract class SourceLocationMixin implements SourceLocation {
+  @override
+  String get toolString {
+    final source = sourceUrl ?? 'unknown source';
+    return '$source:${line + 1}:${column + 1}';
+  }
+
+  @override
+  int distance(SourceLocation other) {
+    if (sourceUrl != other.sourceUrl) {
+      throw ArgumentError('Source URLs "$sourceUrl" and '
+          "\"${other.sourceUrl}\" don't match.");
+    }
+    return (offset - other.offset).abs();
+  }
+
+  @override
+  SourceSpan pointSpan() => SourceSpan(this, this, '');
+
+  @override
+  int compareTo(SourceLocation other) {
+    if (sourceUrl != other.sourceUrl) {
+      throw ArgumentError('Source URLs "$sourceUrl" and '
+          "\"${other.sourceUrl}\" don't match.");
+    }
+    return offset - other.offset;
+  }
+
+  @override
+  bool operator ==(Object other) =>
+      other is SourceLocation &&
+      sourceUrl == other.sourceUrl &&
+      offset == other.offset;
+
+  @override
+  int get hashCode => (sourceUrl?.hashCode ?? 0) + offset;
+
+  @override
+  String toString() => '<$runtimeType: $offset $toolString>';
+}
diff --git a/pkgs/source_span/lib/src/span.dart b/pkgs/source_span/lib/src/span.dart
new file mode 100644
index 0000000..941dedc
--- /dev/null
+++ b/pkgs/source_span/lib/src/span.dart
@@ -0,0 +1,193 @@
+// Copyright (c) 2014, 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 'package:path/path.dart' as p;
+import 'package:term_glyph/term_glyph.dart' as glyph;
+
+import 'file.dart';
+import 'highlighter.dart';
+import 'location.dart';
+import 'span_mixin.dart';
+import 'span_with_context.dart';
+import 'utils.dart';
+
+/// A class that describes a segment of source text.
+abstract class SourceSpan implements Comparable<SourceSpan> {
+  /// The start location of this span.
+  SourceLocation get start;
+
+  /// The end location of this span, exclusive.
+  SourceLocation get end;
+
+  /// The source text for this span.
+  String get text;
+
+  /// The URL of the source (typically a file) of this span.
+  ///
+  /// This may be null, indicating that the source URL is unknown or
+  /// unavailable.
+  Uri? get sourceUrl;
+
+  /// The length of this span, in characters.
+  int get length;
+
+  /// Creates a new span from [start] to [end] (exclusive) containing [text].
+  ///
+  /// [start] and [end] must have the same source URL and [start] must come
+  /// before [end]. [text] must have a number of characters equal to the
+  /// distance between [start] and [end].
+  factory SourceSpan(SourceLocation start, SourceLocation end, String text) =>
+      SourceSpanBase(start, end, text);
+
+  /// Creates a new span that's the union of `this` and [other].
+  ///
+  /// The two spans must have the same source URL and may not be disjoint.
+  /// [text] is computed by combining `this.text` and `other.text`.
+  SourceSpan union(SourceSpan other);
+
+  /// Compares two spans.
+  ///
+  /// [other] must have the same source URL as `this`. This orders spans by
+  /// [start] then [length].
+  @override
+  int compareTo(SourceSpan other);
+
+  /// Formats [message] in a human-friendly way associated with this span.
+  ///
+  /// [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 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.
+  ///
+  /// This uses the full range of Unicode characters to highlight the source
+  /// span if [glyph.ascii] is `false` (the default), but only uses ASCII
+  /// characters if it's `true`.
+  ///
+  /// [ANSI terminal color escape]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
+  String message(String message, {Object? color});
+
+  /// Prints the text associated with this span in a user-friendly way.
+  ///
+  /// This is identical to [message], except that it doesn't print the file
+  /// name, line number, column number, or message. If [length] is 0 and this
+  /// isn't a [SourceSpanWithContext], returns an empty string.
+  ///
+  /// [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 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.
+  ///
+  /// This uses the full range of Unicode characters to highlight the source
+  /// span if [glyph.ascii] is `false` (the default), but only uses ASCII
+  /// characters if it's `true`.
+  ///
+  /// [ANSI terminal color escape]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
+  String highlight({Object? color});
+}
+
+/// A base class for source spans with [start], [end], and [text] known at
+/// construction time.
+class SourceSpanBase extends SourceSpanMixin {
+  @override
+  final SourceLocation start;
+  @override
+  final SourceLocation end;
+  @override
+  final String text;
+
+  SourceSpanBase(this.start, this.end, this.text) {
+    if (end.sourceUrl != start.sourceUrl) {
+      throw ArgumentError('Source URLs "${start.sourceUrl}" and '
+          " \"${end.sourceUrl}\" don't match.");
+    } else if (end.offset < start.offset) {
+      throw ArgumentError('End $end must come after start $start.');
+    } else if (text.length != start.distance(end)) {
+      throw ArgumentError('Text "$text" must be ${start.distance(end)} '
+          'characters long.');
+    }
+  }
+}
+
+// TODO(#52): Move these to instance methods in the next breaking release.
+/// Extension methods on the base [SourceSpan] API.
+extension SourceSpanExtension on SourceSpan {
+  /// Like [SourceSpan.message], but also highlights [secondarySpans] to provide
+  /// the user with additional context.
+  ///
+  /// Each span takes a label ([label] for this span, and the values of the
+  /// [secondarySpans] map for the secondary spans) that's used to indicate to
+  /// the user what that particular span represents.
+  ///
+  /// If [color] is `true`, [ANSI terminal color escapes][] are used to color
+  /// the resulting string. By default this span is colored red and the
+  /// secondary spans are colored blue, but that can be customized by passing
+  /// ANSI escape strings to [primaryColor] or [secondaryColor].
+  ///
+  /// [ANSI terminal color escapes]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
+  ///
+  /// Each span in [secondarySpans] must refer to the same document as this
+  /// span. Throws an [ArgumentError] if any secondary span has a different
+  /// source URL than this span.
+  ///
+  /// Note that while this will work with plain [SourceSpan]s, it will produce
+  /// much more useful output with [SourceSpanWithContext]s (including
+  /// [FileSpan]s).
+  String messageMultiple(
+      String message, String label, Map<SourceSpan, String> secondarySpans,
+      {bool color = false, String? primaryColor, String? secondaryColor}) {
+    final buffer = StringBuffer()
+      ..write('line ${start.line + 1}, column ${start.column + 1}');
+    if (sourceUrl != null) buffer.write(' of ${p.prettyUri(sourceUrl)}');
+    buffer
+      ..writeln(': $message')
+      ..write(highlightMultiple(label, secondarySpans,
+          color: color,
+          primaryColor: primaryColor,
+          secondaryColor: secondaryColor));
+    return buffer.toString();
+  }
+
+  /// Like [SourceSpan.highlight], but also highlights [secondarySpans] to
+  /// provide the user with additional context.
+  ///
+  /// Each span takes a label ([label] for this span, and the values of the
+  /// [secondarySpans] map for the secondary spans) that's used to indicate to
+  /// the user what that particular span represents.
+  ///
+  /// If [color] is `true`, [ANSI terminal color escapes][] are used to color
+  /// the resulting string. By default this span is colored red and the
+  /// secondary spans are colored blue, but that can be customized by passing
+  /// ANSI escape strings to [primaryColor] or [secondaryColor].
+  ///
+  /// [ANSI terminal color escapes]: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
+  ///
+  /// Each span in [secondarySpans] must refer to the same document as this
+  /// span. Throws an [ArgumentError] if any secondary span has a different
+  /// source URL than this span.
+  ///
+  /// Note that while this will work with plain [SourceSpan]s, it will produce
+  /// much more useful output with [SourceSpanWithContext]s (including
+  /// [FileSpan]s).
+  String highlightMultiple(String label, Map<SourceSpan, String> secondarySpans,
+          {bool color = false, String? primaryColor, String? secondaryColor}) =>
+      Highlighter.multiple(this, label, secondarySpans,
+              color: color,
+              primaryColor: primaryColor,
+              secondaryColor: secondaryColor)
+          .highlight();
+
+  /// Returns a span from [start] code units (inclusive) to [end] code units
+  /// (exclusive) after the beginning of this span.
+  SourceSpan subspan(int start, [int? end]) {
+    RangeError.checkValidRange(start, end, length);
+    if (start == 0 && (end == null || end == length)) return this;
+
+    final locations = subspanLocations(this, start, end);
+    return SourceSpan(locations[0], locations[1], text.substring(start, end));
+  }
+}
diff --git a/pkgs/source_span/lib/src/span_exception.dart b/pkgs/source_span/lib/src/span_exception.dart
new file mode 100644
index 0000000..90ad690
--- /dev/null
+++ b/pkgs/source_span/lib/src/span_exception.dart
@@ -0,0 +1,114 @@
+// Copyright (c) 2014, 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 'span.dart';
+
+/// A class for exceptions that have source span information attached.
+class SourceSpanException implements Exception {
+  // This is a getter so that subclasses can override it.
+  /// A message describing the exception.
+  String get message => _message;
+  final String _message;
+
+  // This is a getter so that subclasses can override it.
+  /// The span associated with this exception.
+  ///
+  /// This may be `null` if the source location can't be determined.
+  SourceSpan? get span => _span;
+  final SourceSpan? _span;
+
+  SourceSpanException(this._message, this._span);
+
+  /// Returns a string representation of `this`.
+  ///
+  /// [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 the span's text. 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.
+  @override
+  String toString({Object? color}) {
+    if (span == null) return message;
+    return 'Error on ${span!.message(message, color: color)}';
+  }
+}
+
+/// A [SourceSpanException] that's also a [FormatException].
+class SourceSpanFormatException extends SourceSpanException
+    implements FormatException {
+  @override
+  final dynamic source;
+
+  @override
+  int? get offset => span?.start.offset;
+
+  SourceSpanFormatException(super.message, super.span, [this.source]);
+}
+
+/// A [SourceSpanException] that also highlights some secondary spans to provide
+/// the user with extra context.
+///
+/// Each span has a label ([primaryLabel] for the primary, and the values of the
+/// [secondarySpans] map for the secondary spans) that's used to indicate to the
+/// user what that particular span represents.
+class MultiSourceSpanException extends SourceSpanException {
+  /// A label to attach to [span] that provides additional information and helps
+  /// distinguish it from [secondarySpans].
+  final String primaryLabel;
+
+  /// A map whose keys are secondary spans that should be highlighted.
+  ///
+  /// Each span's value is a label to attach to that span that provides
+  /// additional information and helps distinguish it from [secondarySpans].
+  final Map<SourceSpan, String> secondarySpans;
+
+  MultiSourceSpanException(super.message, super.span, this.primaryLabel,
+      Map<SourceSpan, String> secondarySpans)
+      : secondarySpans = Map.unmodifiable(secondarySpans);
+
+  /// Returns a string representation of `this`.
+  ///
+  /// [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 the primary span's text. 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.
+  ///
+  /// If [color] is `true` or a string, [secondaryColor] is used to highlight
+  /// [secondarySpans].
+  @override
+  String toString({Object? color, String? secondaryColor}) {
+    if (span == null) return message;
+
+    var useColor = false;
+    String? primaryColor;
+    if (color is String) {
+      useColor = true;
+      primaryColor = color;
+    } else if (color == true) {
+      useColor = true;
+    }
+
+    final formatted = span!.messageMultiple(
+        message, primaryLabel, secondarySpans,
+        color: useColor,
+        primaryColor: primaryColor,
+        secondaryColor: secondaryColor);
+    return 'Error on $formatted';
+  }
+}
+
+/// A [MultiSourceSpanException] that's also a [FormatException].
+class MultiSourceSpanFormatException extends MultiSourceSpanException
+    implements FormatException {
+  @override
+  final dynamic source;
+
+  @override
+  int? get offset => span?.start.offset;
+
+  MultiSourceSpanFormatException(
+      super.message, super.span, super.primaryLabel, super.secondarySpans,
+      [this.source]);
+}
diff --git a/pkgs/source_span/lib/src/span_mixin.dart b/pkgs/source_span/lib/src/span_mixin.dart
new file mode 100644
index 0000000..29b6119
--- /dev/null
+++ b/pkgs/source_span/lib/src/span_mixin.dart
@@ -0,0 +1,84 @@
+// Copyright (c) 2014, 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 'package:path/path.dart' as p;
+
+import 'highlighter.dart';
+import 'span.dart';
+import 'span_with_context.dart';
+import 'utils.dart';
+
+/// A mixin for easily implementing [SourceSpan].
+///
+/// This implements the [SourceSpan] methods in terms of [start], [end], and
+/// [text]. This assumes that [start] and [end] have the same source URL, that
+/// [start] comes before [end], and that [text] has a number of characters equal
+/// to the distance between [start] and [end].
+abstract class SourceSpanMixin implements SourceSpan {
+  @override
+  Uri? get sourceUrl => start.sourceUrl;
+
+  @override
+  int get length => end.offset - start.offset;
+
+  @override
+  int compareTo(SourceSpan other) {
+    final result = start.compareTo(other.start);
+    return result == 0 ? end.compareTo(other.end) : result;
+  }
+
+  @override
+  SourceSpan union(SourceSpan other) {
+    if (sourceUrl != other.sourceUrl) {
+      throw ArgumentError('Source URLs "$sourceUrl" and '
+          " \"${other.sourceUrl}\" don't match.");
+    }
+
+    final start = min(this.start, other.start);
+    final end = max(this.end, other.end);
+    final beginSpan = start == this.start ? this : other;
+    final endSpan = end == this.end ? this : other;
+
+    if (beginSpan.end.compareTo(endSpan.start) < 0) {
+      throw ArgumentError('Spans $this and $other are disjoint.');
+    }
+
+    final text = beginSpan.text +
+        endSpan.text.substring(beginSpan.end.distance(endSpan.start));
+    return SourceSpan(start, end, text);
+  }
+
+  @override
+  String message(String message, {Object? color}) {
+    final buffer = StringBuffer()
+      ..write('line ${start.line + 1}, column ${start.column + 1}');
+    if (sourceUrl != null) buffer.write(' of ${p.prettyUri(sourceUrl)}');
+    buffer.write(': $message');
+
+    final highlight = this.highlight(color: color);
+    if (highlight.isNotEmpty) {
+      buffer
+        ..writeln()
+        ..write(highlight);
+    }
+
+    return buffer.toString();
+  }
+
+  @override
+  String highlight({Object? color}) {
+    if (this is! SourceSpanWithContext && length == 0) return '';
+    return Highlighter(this, color: color).highlight();
+  }
+
+  @override
+  bool operator ==(Object other) =>
+      other is SourceSpan && start == other.start && end == other.end;
+
+  @override
+  int get hashCode => Object.hash(start, end);
+
+  @override
+  String toString() => '<$runtimeType: from $start to $end "$text">';
+}
diff --git a/pkgs/source_span/lib/src/span_with_context.dart b/pkgs/source_span/lib/src/span_with_context.dart
new file mode 100644
index 0000000..776c789
--- /dev/null
+++ b/pkgs/source_span/lib/src/span_with_context.dart
@@ -0,0 +1,51 @@
+// Copyright (c) 2014, 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 'location.dart';
+import 'span.dart';
+import 'utils.dart';
+
+/// A class that describes a segment of source text with additional context.
+class SourceSpanWithContext extends SourceSpanBase {
+  // This is a getter so that subclasses can override it.
+  /// Text around the span, which includes the line containing this span.
+  String get context => _context;
+  final String _context;
+
+  /// Creates a new span from [start] to [end] (exclusive) containing [text], in
+  /// the given [context].
+  ///
+  /// [start] and [end] must have the same source URL and [start] must come
+  /// before [end]. [text] must have a number of characters equal to the
+  /// distance between [start] and [end]. [context] must contain [text], and
+  /// [text] should start at `start.column` from the beginning of a line in
+  /// [context].
+  SourceSpanWithContext(
+      SourceLocation start, SourceLocation end, String text, this._context)
+      : super(start, end, text) {
+    if (!context.contains(text)) {
+      throw ArgumentError('The context line "$context" must contain "$text".');
+    }
+
+    if (findLineStart(context, text, start.column) == null) {
+      throw ArgumentError('The span text "$text" must start at '
+          'column ${start.column + 1} in a line within "$context".');
+    }
+  }
+}
+
+// TODO(#52): Move these to instance methods in the next breaking release.
+/// Extension methods on the base [SourceSpan] API.
+extension SourceSpanWithContextExtension on SourceSpanWithContext {
+  /// Returns a span from [start] code units (inclusive) to [end] code units
+  /// (exclusive) after the beginning of this span.
+  SourceSpanWithContext subspan(int start, [int? end]) {
+    RangeError.checkValidRange(start, end, length);
+    if (start == 0 && (end == null || end == length)) return this;
+
+    final locations = subspanLocations(this, start, end);
+    return SourceSpanWithContext(
+        locations[0], locations[1], text.substring(start, end), context);
+  }
+}
diff --git a/pkgs/source_span/lib/src/utils.dart b/pkgs/source_span/lib/src/utils.dart
new file mode 100644
index 0000000..aba14ec
--- /dev/null
+++ b/pkgs/source_span/lib/src/utils.dart
@@ -0,0 +1,145 @@
+// Copyright (c) 2014, 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 'charcode.dart';
+import 'location.dart';
+import 'span.dart';
+import 'span_with_context.dart';
+
+/// Returns the minimum of [obj1] and [obj2] according to
+/// [Comparable.compareTo].
+T min<T extends Comparable<T>>(T obj1, T obj2) =>
+    obj1.compareTo(obj2) > 0 ? obj2 : obj1;
+
+/// Returns the maximum of [obj1] and [obj2] according to
+/// [Comparable.compareTo].
+T max<T extends Comparable<T>>(T obj1, T obj2) =>
+    obj1.compareTo(obj2) > 0 ? obj1 : obj2;
+
+/// Returns whether all elements of [iter] are the same value, according to
+/// `==`.
+bool isAllTheSame(Iterable<Object?> iter) {
+  if (iter.isEmpty) return true;
+  final firstValue = iter.first;
+  for (var value in iter.skip(1)) {
+    if (value != firstValue) {
+      return false;
+    }
+  }
+  return true;
+}
+
+/// Returns whether [span] covers multiple lines.
+bool isMultiline(SourceSpan span) => span.start.line != span.end.line;
+
+/// Sets the first `null` element of [list] to [element].
+void replaceFirstNull<E>(List<E?> list, E element) {
+  final index = list.indexOf(null);
+  if (index < 0) throw ArgumentError('$list contains no null elements.');
+  list[index] = element;
+}
+
+/// Sets the element of [list] that currently contains [element] to `null`.
+void replaceWithNull<E>(List<E?> list, E element) {
+  final index = list.indexOf(element);
+  if (index < 0) {
+    throw ArgumentError('$list contains no elements matching $element.');
+  }
+
+  list[index] = null;
+}
+
+/// Returns the number of instances of [codeUnit] in [string].
+int countCodeUnits(String string, int codeUnit) {
+  var count = 0;
+  for (var codeUnitToCheck in string.codeUnits) {
+    if (codeUnitToCheck == codeUnit) count++;
+  }
+  return count;
+}
+
+/// Finds a line in [context] containing [text] at the specified [column].
+///
+/// Returns the index in [context] where that line begins, or null if none
+/// exists.
+int? findLineStart(String context, String text, int column) {
+  // If the text is empty, we just want to find the first line that has at least
+  // [column] characters.
+  if (text.isEmpty) {
+    var beginningOfLine = 0;
+    while (true) {
+      final index = context.indexOf('\n', beginningOfLine);
+      if (index == -1) {
+        return context.length - beginningOfLine >= column
+            ? beginningOfLine
+            : null;
+      }
+
+      if (index - beginningOfLine >= column) return beginningOfLine;
+      beginningOfLine = index + 1;
+    }
+  }
+
+  var index = context.indexOf(text);
+  while (index != -1) {
+    // Start looking before [index] in case [text] starts with a newline.
+    final lineStart = index == 0 ? 0 : context.lastIndexOf('\n', index - 1) + 1;
+    final textColumn = index - lineStart;
+    if (column == textColumn) return lineStart;
+    index = context.indexOf(text, index + 1);
+  }
+  // ignore: avoid_returning_null
+  return null;
+}
+
+/// Returns a two-element list containing the start and end locations of the
+/// span from [start] code units (inclusive) to [end] code units (exclusive)
+/// after the beginning of [span].
+///
+/// This is factored out so it can be shared between
+/// [SourceSpanExtension.subspan] and [SourceSpanWithContextExtension.subspan].
+List<SourceLocation> subspanLocations(SourceSpan span, int start, [int? end]) {
+  final text = span.text;
+  final startLocation = span.start;
+  var line = startLocation.line;
+  var column = startLocation.column;
+
+  // Adjust [line] and [column] as necessary if the character at [i] in [text]
+  // is a newline.
+  void consumeCodePoint(int i) {
+    final codeUnit = text.codeUnitAt(i);
+    if (codeUnit == $lf ||
+        // A carriage return counts as a newline, but only if it's not
+        // followed by a line feed.
+        (codeUnit == $cr &&
+            (i + 1 == text.length || text.codeUnitAt(i + 1) != $lf))) {
+      line += 1;
+      column = 0;
+    } else {
+      column += 1;
+    }
+  }
+
+  for (var i = 0; i < start; i++) {
+    consumeCodePoint(i);
+  }
+
+  final newStartLocation = SourceLocation(startLocation.offset + start,
+      sourceUrl: span.sourceUrl, line: line, column: column);
+
+  SourceLocation newEndLocation;
+  if (end == null || end == span.length) {
+    newEndLocation = span.end;
+  } else if (end == start) {
+    newEndLocation = newStartLocation;
+  } else {
+    for (var i = start; i < end; i++) {
+      consumeCodePoint(i);
+    }
+    newEndLocation = SourceLocation(startLocation.offset + end,
+        sourceUrl: span.sourceUrl, line: line, column: column);
+  }
+
+  return [newStartLocation, newEndLocation];
+}
diff --git a/pkgs/source_span/pubspec.yaml b/pkgs/source_span/pubspec.yaml
new file mode 100644
index 0000000..3a7624b
--- /dev/null
+++ b/pkgs/source_span/pubspec.yaml
@@ -0,0 +1,17 @@
+name: source_span
+version: 1.10.1-wip
+description: >-
+  Provides a standard representation for source code locations and spans.
+repository: https://github.com/dart-lang/source_span
+
+environment:
+  sdk: ^3.1.0
+
+dependencies:
+  collection: ^1.15.0
+  path: ^1.8.0
+  term_glyph: ^1.2.0
+
+dev_dependencies:
+  dart_flutter_team_lints: ^3.0.0
+  test: ^1.16.0
diff --git a/pkgs/source_span/test/file_test.dart b/pkgs/source_span/test/file_test.dart
new file mode 100644
index 0000000..dff51ee
--- /dev/null
+++ b/pkgs/source_span/test/file_test.dart
@@ -0,0 +1,530 @@
+// Copyright (c) 2014, 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 'package:source_span/source_span.dart';
+import 'package:test/test.dart';
+
+void main() {
+  late SourceFile file;
+  setUp(() {
+    file = SourceFile.fromString('''
+foo bar baz
+whiz bang boom
+zip zap zop''', url: 'foo.dart');
+  });
+
+  group('errors', () {
+    group('for span()', () {
+      test('end must come after start', () {
+        expect(() => file.span(10, 5), throwsArgumentError);
+      });
+
+      test('start may not be negative', () {
+        expect(() => file.span(-1, 5), throwsRangeError);
+      });
+
+      test('end may not be outside the file', () {
+        expect(() => file.span(10, 100), throwsRangeError);
+      });
+    });
+
+    group('for location()', () {
+      test('offset may not be negative', () {
+        expect(() => file.location(-1), throwsRangeError);
+      });
+
+      test('offset may not be outside the file', () {
+        expect(() => file.location(100), throwsRangeError);
+      });
+    });
+
+    group('for getLine()', () {
+      test('offset may not be negative', () {
+        expect(() => file.getLine(-1), throwsRangeError);
+      });
+
+      test('offset may not be outside the file', () {
+        expect(() => file.getLine(100), throwsRangeError);
+      });
+    });
+
+    group('for getColumn()', () {
+      test('offset may not be negative', () {
+        expect(() => file.getColumn(-1), throwsRangeError);
+      });
+
+      test('offset may not be outside the file', () {
+        expect(() => file.getColumn(100), throwsRangeError);
+      });
+
+      test('line may not be negative', () {
+        expect(() => file.getColumn(1, line: -1), throwsRangeError);
+      });
+
+      test('line may not be outside the file', () {
+        expect(() => file.getColumn(1, line: 100), throwsRangeError);
+      });
+
+      test('line must be accurate', () {
+        expect(() => file.getColumn(1, line: 1), throwsRangeError);
+      });
+    });
+
+    group('getOffset()', () {
+      test('line may not be negative', () {
+        expect(() => file.getOffset(-1), throwsRangeError);
+      });
+
+      test('column may not be negative', () {
+        expect(() => file.getOffset(1, -1), throwsRangeError);
+      });
+
+      test('line may not be outside the file', () {
+        expect(() => file.getOffset(100), throwsRangeError);
+      });
+
+      test('column may not be outside the file', () {
+        expect(() => file.getOffset(2, 100), throwsRangeError);
+      });
+
+      test('column may not be outside the line', () {
+        expect(() => file.getOffset(1, 20), throwsRangeError);
+      });
+    });
+
+    group('for getText()', () {
+      test('end must come after start', () {
+        expect(() => file.getText(10, 5), throwsArgumentError);
+      });
+
+      test('start may not be negative', () {
+        expect(() => file.getText(-1, 5), throwsRangeError);
+      });
+
+      test('end may not be outside the file', () {
+        expect(() => file.getText(10, 100), throwsRangeError);
+      });
+    });
+
+    group('for span().union()', () {
+      test('source URLs must match', () {
+        final other = SourceSpan(SourceLocation(10), SourceLocation(11), '_');
+
+        expect(() => file.span(9, 10).union(other), throwsArgumentError);
+      });
+
+      test('spans may not be disjoint', () {
+        expect(() => file.span(9, 10).union(file.span(11, 12)),
+            throwsArgumentError);
+      });
+    });
+
+    test('for span().expand() source URLs must match', () {
+      final other = SourceFile.fromString('''
+foo bar baz
+whiz bang boom
+zip zap zop''', url: 'bar.dart').span(10, 11);
+
+      expect(() => file.span(9, 10).expand(other), throwsArgumentError);
+    });
+  });
+
+  test('fields work correctly', () {
+    expect(file.url, equals(Uri.parse('foo.dart')));
+    expect(file.lines, equals(3));
+    expect(file.length, equals(38));
+  });
+
+  group('new SourceFile()', () {
+    test('handles CRLF correctly', () {
+      expect(SourceFile.fromString('foo\r\nbar').getLine(6), equals(1));
+    });
+
+    test('handles a lone CR correctly', () {
+      expect(SourceFile.fromString('foo\rbar').getLine(5), equals(1));
+    });
+  });
+
+  group('span()', () {
+    test('returns a span between the given offsets', () {
+      final span = file.span(5, 10);
+      expect(span.start, equals(file.location(5)));
+      expect(span.end, equals(file.location(10)));
+    });
+
+    test('end defaults to the end of the file', () {
+      final span = file.span(5);
+      expect(span.start, equals(file.location(5)));
+      expect(span.end, equals(file.location(file.length)));
+    });
+  });
+
+  group('getLine()', () {
+    test('works for a middle character on the line', () {
+      expect(file.getLine(15), equals(1));
+    });
+
+    test('works for the first character of a line', () {
+      expect(file.getLine(12), equals(1));
+    });
+
+    test('works for a newline character', () {
+      expect(file.getLine(11), equals(0));
+    });
+
+    test('works for the last offset', () {
+      expect(file.getLine(file.length), equals(2));
+    });
+  });
+
+  group('getColumn()', () {
+    test('works for a middle character on the line', () {
+      expect(file.getColumn(15), equals(3));
+    });
+
+    test('works for the first character of a line', () {
+      expect(file.getColumn(12), equals(0));
+    });
+
+    test('works for a newline character', () {
+      expect(file.getColumn(11), equals(11));
+    });
+
+    test('works when line is passed as well', () {
+      expect(file.getColumn(12, line: 1), equals(0));
+    });
+
+    test('works for the last offset', () {
+      expect(file.getColumn(file.length), equals(11));
+    });
+  });
+
+  group('getOffset()', () {
+    test('works for a middle character on the line', () {
+      expect(file.getOffset(1, 3), equals(15));
+    });
+
+    test('works for the first character of a line', () {
+      expect(file.getOffset(1), equals(12));
+    });
+
+    test('works for a newline character', () {
+      expect(file.getOffset(0, 11), equals(11));
+    });
+
+    test('works for the last offset', () {
+      expect(file.getOffset(2, 11), equals(file.length));
+    });
+  });
+
+  group('getText()', () {
+    test('returns a substring of the source', () {
+      expect(file.getText(8, 15), equals('baz\nwhi'));
+    });
+
+    test('end defaults to the end of the file', () {
+      expect(file.getText(20), equals('g boom\nzip zap zop'));
+    });
+  });
+
+  group('FileLocation', () {
+    test('reports the correct line number', () {
+      expect(file.location(15).line, equals(1));
+    });
+
+    test('reports the correct column number', () {
+      expect(file.location(15).column, equals(3));
+    });
+
+    test('pointSpan() returns a FileSpan', () {
+      final location = file.location(15);
+      final span = location.pointSpan();
+      expect(span, isA<FileSpan>());
+      expect(span.start, equals(location));
+      expect(span.end, equals(location));
+      expect(span.text, isEmpty);
+    });
+  });
+
+  group('FileSpan', () {
+    test('text returns a substring of the source', () {
+      expect(file.span(8, 15).text, equals('baz\nwhi'));
+    });
+
+    test('text includes the last char when end is defaulted to EOF', () {
+      expect(file.span(29).text, equals('p zap zop'));
+    });
+
+    group('context', () {
+      test("contains the span's text", () {
+        final span = file.span(8, 15);
+        expect(span.context.contains(span.text), isTrue);
+        expect(span.context, equals('foo bar baz\nwhiz bang boom\n'));
+      });
+
+      test('contains the previous line for a point span at the end of a line',
+          () {
+        final span = file.span(25, 25);
+        expect(span.context, equals('whiz bang boom\n'));
+      });
+
+      test('contains the next line for a point span at the beginning of a line',
+          () {
+        final span = file.span(12, 12);
+        expect(span.context, equals('whiz bang boom\n'));
+      });
+
+      group('for a point span at the end of a file', () {
+        test('without a newline, contains the last line', () {
+          final span = file.span(file.length, file.length);
+          expect(span.context, equals('zip zap zop'));
+        });
+
+        test('with a newline, contains an empty line', () {
+          file = SourceFile.fromString('''
+foo bar baz
+whiz bang boom
+zip zap zop
+''', url: 'foo.dart');
+
+          final span = file.span(file.length, file.length);
+          expect(span.context, isEmpty);
+        });
+      });
+    });
+
+    group('union()', () {
+      late FileSpan span;
+      setUp(() {
+        span = file.span(5, 12);
+      });
+
+      test('works with a preceding adjacent span', () {
+        final other = file.span(0, 5);
+        final result = span.union(other);
+        expect(result.start, equals(other.start));
+        expect(result.end, equals(span.end));
+        expect(result.text, equals('foo bar baz\n'));
+      });
+
+      test('works with a preceding overlapping span', () {
+        final other = file.span(0, 8);
+        final result = span.union(other);
+        expect(result.start, equals(other.start));
+        expect(result.end, equals(span.end));
+        expect(result.text, equals('foo bar baz\n'));
+      });
+
+      test('works with a following adjacent span', () {
+        final other = file.span(12, 16);
+        final result = span.union(other);
+        expect(result.start, equals(span.start));
+        expect(result.end, equals(other.end));
+        expect(result.text, equals('ar baz\nwhiz'));
+      });
+
+      test('works with a following overlapping span', () {
+        final other = file.span(9, 16);
+        final result = span.union(other);
+        expect(result.start, equals(span.start));
+        expect(result.end, equals(other.end));
+        expect(result.text, equals('ar baz\nwhiz'));
+      });
+
+      test('works with an internal overlapping span', () {
+        final other = file.span(7, 10);
+        expect(span.union(other), equals(span));
+      });
+
+      test('works with an external overlapping span', () {
+        final other = file.span(0, 16);
+        expect(span.union(other), equals(other));
+      });
+
+      test('returns a FileSpan for a FileSpan input', () {
+        expect(span.union(file.span(0, 5)), isA<FileSpan>());
+      });
+
+      test('returns a base SourceSpan for a SourceSpan input', () {
+        final other = SourceSpan(SourceLocation(0, sourceUrl: 'foo.dart'),
+            SourceLocation(5, sourceUrl: 'foo.dart'), 'hey, ');
+        final result = span.union(other);
+        expect(result, isNot(isA<FileSpan>()));
+        expect(result.start, equals(other.start));
+        expect(result.end, equals(span.end));
+        expect(result.text, equals('hey, ar baz\n'));
+      });
+    });
+
+    group('expand()', () {
+      late FileSpan span;
+      setUp(() {
+        span = file.span(5, 12);
+      });
+
+      test('works with a preceding nonadjacent span', () {
+        final other = file.span(0, 3);
+        final result = span.expand(other);
+        expect(result.start, equals(other.start));
+        expect(result.end, equals(span.end));
+        expect(result.text, equals('foo bar baz\n'));
+      });
+
+      test('works with a preceding overlapping span', () {
+        final other = file.span(0, 8);
+        final result = span.expand(other);
+        expect(result.start, equals(other.start));
+        expect(result.end, equals(span.end));
+        expect(result.text, equals('foo bar baz\n'));
+      });
+
+      test('works with a following nonadjacent span', () {
+        final other = file.span(14, 16);
+        final result = span.expand(other);
+        expect(result.start, equals(span.start));
+        expect(result.end, equals(other.end));
+        expect(result.text, equals('ar baz\nwhiz'));
+      });
+
+      test('works with a following overlapping span', () {
+        final other = file.span(9, 16);
+        final result = span.expand(other);
+        expect(result.start, equals(span.start));
+        expect(result.end, equals(other.end));
+        expect(result.text, equals('ar baz\nwhiz'));
+      });
+
+      test('works with an internal overlapping span', () {
+        final other = file.span(7, 10);
+        expect(span.expand(other), equals(span));
+      });
+
+      test('works with an external overlapping span', () {
+        final other = file.span(0, 16);
+        expect(span.expand(other), equals(other));
+      });
+    });
+
+    group('subspan()', () {
+      late FileSpan span;
+      setUp(() {
+        span = file.span(5, 11); // "ar baz"
+      });
+
+      group('errors', () {
+        test('start must be greater than zero', () {
+          expect(() => span.subspan(-1), throwsRangeError);
+        });
+
+        test('start must be less than or equal to length', () {
+          expect(() => span.subspan(span.length + 1), throwsRangeError);
+        });
+
+        test('end must be greater than start', () {
+          expect(() => span.subspan(2, 1), throwsRangeError);
+        });
+
+        test('end must be less than or equal to length', () {
+          expect(() => span.subspan(0, span.length + 1), throwsRangeError);
+        });
+      });
+
+      test('preserves the source URL', () {
+        final result = span.subspan(1, 2);
+        expect(result.start.sourceUrl, equals(span.sourceUrl));
+        expect(result.end.sourceUrl, equals(span.sourceUrl));
+      });
+
+      group('returns the original span', () {
+        test('with an implicit end',
+            () => expect(span.subspan(0), equals(span)));
+
+        test('with an explicit end',
+            () => expect(span.subspan(0, span.length), equals(span)));
+      });
+
+      group('within a single line', () {
+        test('returns a strict substring of the original span', () {
+          final result = span.subspan(1, 5);
+          expect(result.text, equals('r ba'));
+          expect(result.start.offset, equals(6));
+          expect(result.start.line, equals(0));
+          expect(result.start.column, equals(6));
+          expect(result.end.offset, equals(10));
+          expect(result.end.line, equals(0));
+          expect(result.end.column, equals(10));
+        });
+
+        test('an implicit end goes to the end of the original span', () {
+          final result = span.subspan(1);
+          expect(result.text, equals('r baz'));
+          expect(result.start.offset, equals(6));
+          expect(result.start.line, equals(0));
+          expect(result.start.column, equals(6));
+          expect(result.end.offset, equals(11));
+          expect(result.end.line, equals(0));
+          expect(result.end.column, equals(11));
+        });
+
+        test('can return an empty span', () {
+          final result = span.subspan(3, 3);
+          expect(result.text, isEmpty);
+          expect(result.start.offset, equals(8));
+          expect(result.start.line, equals(0));
+          expect(result.start.column, equals(8));
+          expect(result.end, equals(result.start));
+        });
+      });
+
+      group('across multiple lines', () {
+        setUp(() {
+          span = file.span(22, 30); // "boom\nzip"
+        });
+
+        test('with start and end in the middle of a line', () {
+          final result = span.subspan(3, 6);
+          expect(result.text, equals('m\nz'));
+          expect(result.start.offset, equals(25));
+          expect(result.start.line, equals(1));
+          expect(result.start.column, equals(13));
+          expect(result.end.offset, equals(28));
+          expect(result.end.line, equals(2));
+          expect(result.end.column, equals(1));
+        });
+
+        test('with start at the end of a line', () {
+          final result = span.subspan(4, 6);
+          expect(result.text, equals('\nz'));
+          expect(result.start.offset, equals(26));
+          expect(result.start.line, equals(1));
+          expect(result.start.column, equals(14));
+        });
+
+        test('with start at the beginning of a line', () {
+          final result = span.subspan(5, 6);
+          expect(result.text, equals('z'));
+          expect(result.start.offset, equals(27));
+          expect(result.start.line, equals(2));
+          expect(result.start.column, equals(0));
+        });
+
+        test('with end at the end of a line', () {
+          final result = span.subspan(3, 4);
+          expect(result.text, equals('m'));
+          expect(result.end.offset, equals(26));
+          expect(result.end.line, equals(1));
+          expect(result.end.column, equals(14));
+        });
+
+        test('with end at the beginning of a line', () {
+          final result = span.subspan(3, 5);
+          expect(result.text, equals('m\n'));
+          expect(result.end.offset, equals(27));
+          expect(result.end.line, equals(2));
+          expect(result.end.column, equals(0));
+        });
+      });
+    });
+  });
+}
diff --git a/pkgs/source_span/test/highlight_test.dart b/pkgs/source_span/test/highlight_test.dart
new file mode 100644
index 0000000..93c42db
--- /dev/null
+++ b/pkgs/source_span/test/highlight_test.dart
@@ -0,0 +1,605 @@
+// Copyright (c) 2014, 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.
+
+// ignore_for_file: prefer_interpolation_to_compose_strings
+
+import 'package:source_span/source_span.dart';
+import 'package:source_span/src/colors.dart' as colors;
+import 'package:term_glyph/term_glyph.dart' as glyph;
+import 'package:test/test.dart';
+
+void main() {
+  late bool oldAscii;
+  setUpAll(() {
+    oldAscii = glyph.ascii;
+    glyph.ascii = true;
+  });
+
+  tearDownAll(() {
+    glyph.ascii = oldAscii;
+  });
+
+  late SourceFile file;
+  setUp(() {
+    file = SourceFile.fromString('''
+foo bar baz
+whiz bang boom
+zip zap zop
+''');
+  });
+
+  test('points to the span in the source', () {
+    expect(file.span(4, 7).highlight(), equals("""
+  ,
+1 | foo bar baz
+  |     ^^^
+  '"""));
+  });
+
+  test('gracefully handles a missing source URL', () {
+    final span = SourceFile.fromString('foo bar baz').span(4, 7);
+    expect(span.highlight(), equals("""
+  ,
+1 | foo bar baz
+  |     ^^^
+  '"""));
+  });
+
+  group('highlights a point span', () {
+    test('in the middle of a line', () {
+      expect(file.location(4).pointSpan().highlight(), equals("""
+  ,
+1 | foo bar baz
+  |     ^
+  '"""));
+    });
+
+    test('at the beginning of the file', () {
+      expect(file.location(0).pointSpan().highlight(), equals("""
+  ,
+1 | foo bar baz
+  | ^
+  '"""));
+    });
+
+    test('at the beginning of a line', () {
+      expect(file.location(12).pointSpan().highlight(), equals("""
+  ,
+2 | whiz bang boom
+  | ^
+  '"""));
+    });
+
+    test('at the end of a line', () {
+      expect(file.location(11).pointSpan().highlight(), equals("""
+  ,
+1 | foo bar baz
+  |            ^
+  '"""));
+    });
+
+    test('at the end of the file', () {
+      expect(file.location(38).pointSpan().highlight(), equals("""
+  ,
+3 | zip zap zop
+  |            ^
+  '"""));
+    });
+
+    test('after the end of the file', () {
+      expect(file.location(39).pointSpan().highlight(), equals("""
+  ,
+4 | 
+  | ^
+  '"""));
+    });
+
+    test('at the end of the file with no trailing newline', () {
+      file = SourceFile.fromString('zip zap zop');
+      expect(file.location(10).pointSpan().highlight(), equals("""
+  ,
+1 | zip zap zop
+  |           ^
+  '"""));
+    });
+
+    test('after the end of the file with no trailing newline', () {
+      file = SourceFile.fromString('zip zap zop');
+      expect(file.location(11).pointSpan().highlight(), equals("""
+  ,
+1 | zip zap zop
+  |            ^
+  '"""));
+    });
+
+    test('in an empty file', () {
+      expect(SourceFile.fromString('').location(0).pointSpan().highlight(),
+          equals("""
+  ,
+1 | 
+  | ^
+  '"""));
+    });
+
+    test('on an empty line', () {
+      final file = SourceFile.fromString('foo\n\nbar');
+      expect(file.location(4).pointSpan().highlight(), equals("""
+  ,
+2 | 
+  | ^
+  '"""));
+    });
+  });
+
+  test('highlights a single-line file without a newline', () {
+    expect(SourceFile.fromString('foo bar').span(0, 7).highlight(), equals("""
+  ,
+1 | foo bar
+  | ^^^^^^^
+  '"""));
+  });
+
+  test('highlights text including a trailing newline', () {
+    expect(file.span(8, 12).highlight(), equals("""
+  ,
+1 | foo bar baz
+  |         ^^^
+  '"""));
+  });
+
+  test('highlights a single empty line', () {
+    expect(
+        SourceFile.fromString('foo\n\nbar').span(4, 5).highlight(), equals("""
+  ,
+2 | 
+  | ^
+  '"""));
+  });
+
+  test('highlights a trailing newline', () {
+    expect(file.span(11, 12).highlight(), equals("""
+  ,
+1 | foo bar baz
+  |            ^
+  '"""));
+  });
+
+  group('with a multiline span', () {
+    test('highlights the middle of the first and last lines', () {
+      expect(file.span(4, 34).highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz bang boom
+3 | | zip zap zop
+  | '-------^
+  '"""));
+    });
+
+    test('works when it begins at the end of a line', () {
+      expect(file.span(11, 34).highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,------------^
+2 | | whiz bang boom
+3 | | zip zap zop
+  | '-------^
+  '"""));
+    });
+
+    test('works when it ends at the beginning of a line', () {
+      expect(file.span(4, 28).highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz bang boom
+3 | | zip zap zop
+  | '-^
+  '"""));
+    });
+
+    test('highlights the full first line', () {
+      expect(file.span(0, 34).highlight(), equals("""
+  ,
+1 | / foo bar baz
+2 | | whiz bang boom
+3 | | zip zap zop
+  | '-------^
+  '"""));
+    });
+
+    test("highlights the full first line even if it's indented", () {
+      final file = SourceFile.fromString('''
+  foo bar baz
+  whiz bang boom
+  zip zap zop
+''');
+
+      expect(file.span(2, 38).highlight(), equals("""
+  ,
+1 | /   foo bar baz
+2 | |   whiz bang boom
+3 | |   zip zap zop
+  | '-------^
+  '"""));
+    });
+
+    test("highlights the full first line if it's empty", () {
+      final file = SourceFile.fromString('''
+foo
+
+bar
+''');
+
+      expect(file.span(4, 9).highlight(), equals(r"""
+  ,
+2 | / 
+3 | \ bar
+  '"""));
+    });
+
+    test('highlights the full last line', () {
+      expect(file.span(4, 27).highlight(), equals(r"""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | \ whiz bang boom
+  '"""));
+    });
+
+    test('highlights the full last line with no trailing newline', () {
+      expect(file.span(4, 26).highlight(), equals(r"""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | \ whiz bang boom
+  '"""));
+    });
+
+    test('highlights the full last line with a trailing Windows newline', () {
+      final file = SourceFile.fromString('''
+foo bar baz\r
+whiz bang boom\r
+zip zap zop\r
+''');
+
+      expect(file.span(4, 29).highlight(), equals(r"""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | \ whiz bang boom
+  '"""));
+    });
+
+    test('highlights the full last line at the end of the file', () {
+      expect(file.span(4, 39).highlight(), equals(r"""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz bang boom
+3 | \ zip zap zop
+  '"""));
+    });
+
+    test(
+        'highlights the full last line at the end of the file with no trailing '
+        'newline', () {
+      final file = SourceFile.fromString('''
+foo bar baz
+whiz bang boom
+zip zap zop''');
+
+      expect(file.span(4, 38).highlight(), equals(r"""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz bang boom
+3 | \ zip zap zop
+  '"""));
+    });
+
+    test("highlights the full last line if it's empty", () {
+      final file = SourceFile.fromString('''
+foo
+
+bar
+''');
+
+      expect(file.span(0, 5).highlight(), equals(r"""
+  ,
+1 | / foo
+2 | \ 
+  '"""));
+    });
+
+    test('highlights multiple empty lines', () {
+      final file = SourceFile.fromString('foo\n\n\n\nbar');
+      expect(file.span(4, 7).highlight(), equals(r"""
+  ,
+2 | / 
+3 | | 
+4 | \ 
+  '"""));
+    });
+
+    // Regression test for #32
+    test('highlights the end of a line and an empty line', () {
+      final file = SourceFile.fromString('foo\n\n');
+      expect(file.span(3, 5).highlight(), equals(r"""
+  ,
+1 |   foo
+  | ,----^
+2 | \ 
+  '"""));
+    });
+  });
+
+  group('prints tabs as spaces', () {
+    group('in a single-line span', () {
+      test('before the highlighted section', () {
+        final span = SourceFile.fromString('foo\tbar baz').span(4, 7);
+
+        expect(span.highlight(), equals("""
+  ,
+1 | foo    bar baz
+  |        ^^^
+  '"""));
+      });
+
+      test('within the highlighted section', () {
+        final span = SourceFile.fromString('foo bar\tbaz bang').span(4, 11);
+
+        expect(span.highlight(), equals("""
+  ,
+1 | foo bar    baz bang
+  |     ^^^^^^^^^^
+  '"""));
+      });
+
+      test('after the highlighted section', () {
+        final span = SourceFile.fromString('foo bar\tbaz').span(4, 7);
+
+        expect(span.highlight(), equals("""
+  ,
+1 | foo bar    baz
+  |     ^^^
+  '"""));
+      });
+    });
+
+    group('in a multi-line span', () {
+      test('before the highlighted section', () {
+        final span = SourceFile.fromString('''
+foo\tbar baz
+whiz bang boom
+''').span(4, 21);
+
+        expect(span.highlight(), equals("""
+  ,
+1 |   foo    bar baz
+  | ,--------^
+2 | | whiz bang boom
+  | '---------^
+  '"""));
+      });
+
+      test('within the first highlighted line', () {
+        final span = SourceFile.fromString('''
+foo bar\tbaz
+whiz bang boom
+''').span(4, 21);
+
+        expect(span.highlight(), equals("""
+  ,
+1 |   foo bar    baz
+  | ,-----^
+2 | | whiz bang boom
+  | '---------^
+  '"""));
+      });
+
+      test('at the beginning of the first highlighted line', () {
+        final span = SourceFile.fromString('''
+foo bar\tbaz
+whiz bang boom
+''').span(7, 21);
+
+        expect(span.highlight(), equals("""
+  ,
+1 |   foo bar    baz
+  | ,--------^
+2 | | whiz bang boom
+  | '---------^
+  '"""));
+      });
+
+      test('within a middle highlighted line', () {
+        final span = SourceFile.fromString('''
+foo bar baz
+whiz\tbang boom
+zip zap zop
+''').span(4, 34);
+
+        expect(span.highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz    bang boom
+3 | | zip zap zop
+  | '-------^
+  '"""));
+      });
+
+      test('within the last highlighted line', () {
+        final span = SourceFile.fromString('''
+foo bar baz
+whiz\tbang boom
+''').span(4, 21);
+
+        expect(span.highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz    bang boom
+  | '------------^
+  '"""));
+      });
+
+      test('at the end of the last highlighted line', () {
+        final span = SourceFile.fromString('''
+foo bar baz
+whiz\tbang boom
+''').span(4, 17);
+
+        expect(span.highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz    bang boom
+  | '--------^
+  '"""));
+      });
+
+      test('after the highlighted section', () {
+        final span = SourceFile.fromString('''
+foo bar baz
+whiz bang\tboom
+''').span(4, 21);
+
+        expect(span.highlight(), equals("""
+  ,
+1 |   foo bar baz
+  | ,-----^
+2 | | whiz bang    boom
+  | '---------^
+  '"""));
+      });
+    });
+  });
+
+  group('supports lines of preceding and following context for a span', () {
+    test('within a single line', () {
+      final span = SourceSpanWithContext(
+          SourceLocation(20, line: 2, column: 5, sourceUrl: 'foo.dart'),
+          SourceLocation(27, line: 2, column: 12, sourceUrl: 'foo.dart'),
+          'foo bar',
+          'previous\nlines\n-----foo bar-----\nfollowing line\n');
+
+      expect(span.highlight(), equals("""
+  ,
+1 | previous
+2 | lines
+3 | -----foo bar-----
+  |      ^^^^^^^
+4 | following line
+  '"""));
+    });
+
+    test('covering a full line', () {
+      final span = SourceSpanWithContext(
+          SourceLocation(15, line: 2, column: 0, sourceUrl: 'foo.dart'),
+          SourceLocation(33, line: 3, column: 0, sourceUrl: 'foo.dart'),
+          '-----foo bar-----\n',
+          'previous\nlines\n-----foo bar-----\nfollowing line\n');
+
+      expect(span.highlight(), equals("""
+  ,
+1 | previous
+2 | lines
+3 | -----foo bar-----
+  | ^^^^^^^^^^^^^^^^^
+4 | following line
+  '"""));
+    });
+
+    test('covering multiple full lines', () {
+      final span = SourceSpanWithContext(
+          SourceLocation(15, line: 2, column: 0, sourceUrl: 'foo.dart'),
+          SourceLocation(23, line: 4, column: 0, sourceUrl: 'foo.dart'),
+          'foo\nbar\n',
+          'previous\nlines\nfoo\nbar\nfollowing line\n');
+
+      expect(span.highlight(), equals(r"""
+  ,
+1 |   previous
+2 |   lines
+3 | / foo
+4 | \ bar
+5 |   following line
+  '"""));
+    });
+  });
+
+  group('colors', () {
+    test("doesn't colorize if color is false", () {
+      expect(file.span(4, 7).highlight(color: false), equals("""
+  ,
+1 | foo bar baz
+  |     ^^^
+  '"""));
+    });
+
+    test('colorizes if color is true', () {
+      expect(file.span(4, 7).highlight(color: true), equals('''
+${colors.blue}  ,${colors.none}
+${colors.blue}1 |${colors.none} foo ${colors.red}bar${colors.none} baz
+${colors.blue}  |${colors.none} ${colors.red}    ^^^${colors.none}
+${colors.blue}  '${colors.none}'''));
+    });
+
+    test("uses the given color if it's passed", () {
+      expect(file.span(4, 7).highlight(color: colors.yellow), equals('''
+${colors.blue}  ,${colors.none}
+${colors.blue}1 |${colors.none} foo ${colors.yellow}bar${colors.none} baz
+${colors.blue}  |${colors.none} ${colors.yellow}    ^^^${colors.none}
+${colors.blue}  '${colors.none}'''));
+    });
+
+    test('colorizes a multiline span', () {
+      expect(file.span(4, 34).highlight(color: true), equals('''
+${colors.blue}  ,${colors.none}
+${colors.blue}1 |${colors.none}   foo ${colors.red}bar baz${colors.none}
+${colors.blue}  |${colors.none} ${colors.red},${colors.none}${colors.red}-----^${colors.none}
+${colors.blue}2 |${colors.none} ${colors.red}|${colors.none} ${colors.red}whiz bang boom${colors.none}
+${colors.blue}3 |${colors.none} ${colors.red}|${colors.none} ${colors.red}zip zap${colors.none} zop
+${colors.blue}  |${colors.none} ${colors.red}'${colors.none}${colors.red}-------^${colors.none}
+${colors.blue}  '${colors.none}'''));
+    });
+
+    test('colorizes a multiline span that highlights full lines', () {
+      expect(file.span(0, 39).highlight(color: true), equals('''
+${colors.blue}  ,${colors.none}
+${colors.blue}1 |${colors.none} ${colors.red}/${colors.none} ${colors.red}foo bar baz${colors.none}
+${colors.blue}2 |${colors.none} ${colors.red}|${colors.none} ${colors.red}whiz bang boom${colors.none}
+${colors.blue}3 |${colors.none} ${colors.red}\\${colors.none} ${colors.red}zip zap zop${colors.none}
+${colors.blue}  '${colors.none}'''));
+    });
+  });
+
+  group('line numbers have appropriate padding', () {
+    test('with line number 9', () {
+      expect(
+          SourceFile.fromString('\n' * 8 + 'foo bar baz\n')
+              .span(8, 11)
+              .highlight(),
+          equals("""
+  ,
+9 | foo bar baz
+  | ^^^
+  '"""));
+    });
+
+    test('with line number 10', () {
+      expect(
+          SourceFile.fromString('\n' * 9 + 'foo bar baz\n')
+              .span(9, 12)
+              .highlight(),
+          equals("""
+   ,
+10 | foo bar baz
+   | ^^^
+   '"""));
+    });
+  });
+}
diff --git a/pkgs/source_span/test/location_test.dart b/pkgs/source_span/test/location_test.dart
new file mode 100644
index 0000000..bbe259b
--- /dev/null
+++ b/pkgs/source_span/test/location_test.dart
@@ -0,0 +1,97 @@
+// Copyright (c) 2014, 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 'package:source_span/source_span.dart';
+import 'package:test/test.dart';
+
+void main() {
+  late SourceLocation location;
+  setUp(() {
+    location = SourceLocation(15, line: 2, column: 6, sourceUrl: 'foo.dart');
+  });
+
+  group('errors', () {
+    group('for new SourceLocation()', () {
+      test('offset may not be negative', () {
+        expect(() => SourceLocation(-1), throwsRangeError);
+      });
+
+      test('line may not be negative', () {
+        expect(() => SourceLocation(0, line: -1), throwsRangeError);
+      });
+
+      test('column may not be negative', () {
+        expect(() => SourceLocation(0, column: -1), throwsRangeError);
+      });
+    });
+
+    test('for distance() source URLs must match', () {
+      expect(() => location.distance(SourceLocation(0)), throwsArgumentError);
+    });
+
+    test('for compareTo() source URLs must match', () {
+      expect(() => location.compareTo(SourceLocation(0)), throwsArgumentError);
+    });
+  });
+
+  test('fields work correctly', () {
+    expect(location.sourceUrl, equals(Uri.parse('foo.dart')));
+    expect(location.offset, equals(15));
+    expect(location.line, equals(2));
+    expect(location.column, equals(6));
+  });
+
+  group('toolString', () {
+    test('returns a computer-readable representation', () {
+      expect(location.toolString, equals('foo.dart:3:7'));
+    });
+
+    test('gracefully handles a missing source URL', () {
+      final location = SourceLocation(15, line: 2, column: 6);
+      expect(location.toolString, equals('unknown source:3:7'));
+    });
+  });
+
+  test('distance returns the absolute distance between locations', () {
+    final other = SourceLocation(10, sourceUrl: 'foo.dart');
+    expect(location.distance(other), equals(5));
+    expect(other.distance(location), equals(5));
+  });
+
+  test('pointSpan returns an empty span at location', () {
+    final span = location.pointSpan();
+    expect(span.start, equals(location));
+    expect(span.end, equals(location));
+    expect(span.text, isEmpty);
+  });
+
+  group('compareTo()', () {
+    test('sorts by offset', () {
+      final other = SourceLocation(20, sourceUrl: 'foo.dart');
+      expect(location.compareTo(other), lessThan(0));
+      expect(other.compareTo(location), greaterThan(0));
+    });
+
+    test('considers equal locations equal', () {
+      expect(location.compareTo(location), equals(0));
+    });
+  });
+
+  group('equality', () {
+    test('two locations with the same offset and source are equal', () {
+      final other = SourceLocation(15, sourceUrl: 'foo.dart');
+      expect(location, equals(other));
+    });
+
+    test("a different offset isn't equal", () {
+      final other = SourceLocation(10, sourceUrl: 'foo.dart');
+      expect(location, isNot(equals(other)));
+    });
+
+    test("a different source isn't equal", () {
+      final other = SourceLocation(15, sourceUrl: 'bar.dart');
+      expect(location, isNot(equals(other)));
+    });
+  });
+}
diff --git a/pkgs/source_span/test/multiple_highlight_test.dart b/pkgs/source_span/test/multiple_highlight_test.dart
new file mode 100644
index 0000000..139d53c
--- /dev/null
+++ b/pkgs/source_span/test/multiple_highlight_test.dart
@@ -0,0 +1,423 @@
+// Copyright (c) 2019, 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 'package:source_span/source_span.dart';
+import 'package:term_glyph/term_glyph.dart' as glyph;
+import 'package:test/test.dart';
+
+void main() {
+  late bool oldAscii;
+  setUpAll(() {
+    oldAscii = glyph.ascii;
+    glyph.ascii = true;
+  });
+
+  tearDownAll(() {
+    glyph.ascii = oldAscii;
+  });
+
+  late SourceFile file;
+  setUp(() {
+    file = SourceFile.fromString('''
+foo bar baz
+whiz bang boom
+zip zap zop
+fwee fwoo fwip
+argle bargle boo
+gibble bibble bop
+''', url: 'file1.txt');
+  });
+
+  test('highlights spans on separate lines', () {
+    expect(
+        file.span(17, 21).highlightMultiple(
+            'one', {file.span(31, 34): 'two', file.span(4, 7): 'three'}),
+        equals("""
+  ,
+1 | foo bar baz
+  |     === three
+2 | whiz bang boom
+  |      ^^^^ one
+3 | zip zap zop
+  |     === two
+  '"""));
+  });
+
+  test('highlights spans on the same line', () {
+    expect(
+        file.span(17, 21).highlightMultiple(
+            'one', {file.span(22, 26): 'two', file.span(12, 16): 'three'}),
+        equals("""
+  ,
+2 | whiz bang boom
+  |      ^^^^ one
+  | ==== three
+  |           ==== two
+  '"""));
+  });
+
+  test('highlights overlapping spans on the same line', () {
+    expect(
+        file.span(17, 21).highlightMultiple(
+            'one', {file.span(20, 26): 'two', file.span(12, 18): 'three'}),
+        equals("""
+  ,
+2 | whiz bang boom
+  |      ^^^^ one
+  | ====== three
+  |         ====== two
+  '"""));
+  });
+
+  test('highlights multiple multiline spans', () {
+    expect(
+        file.span(27, 54).highlightMultiple(
+            'one', {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
+  | '--- one
+5 | / argle bargle boo
+6 | | gibble bibble bop
+  | '--- two
+  '"""));
+  });
+
+  test('highlights multiple overlapping multiline spans', () {
+    expect(
+        file.span(12, 70).highlightMultiple(
+            'one', {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
+  | |'--- one
+6 | |  gibble bibble bop
+  | '---- two
+  '"""));
+  });
+
+  test('highlights many layers of overlaps', () {
+    expect(
+        file.span(0, 54).highlightMultiple('one', {
+          file.span(12, 77): 'two',
+          file.span(27, 84): 'three',
+          file.span(39, 88): 'four'
+        }),
+        equals("""
+  ,
+1 | /--- foo bar baz
+2 | |/-- whiz bang boom
+3 | ||/- zip zap zop
+4 | |||/ fwee fwoo fwip
+  | '+++--- one
+5 |  ||| argle bargle boo
+6 |  ||| gibble bibble bop
+  |  '++------^ two
+  |   '+-------------^ three
+  |    '--- four
+  '"""));
+  });
+
+  group("highlights a multiline span that's a subset", () {
+    test('with no first or last line overlap', () {
+      expect(
+          file
+              .span(27, 53)
+              .highlightMultiple('inner', {file.span(12, 70): 'outer'}),
+          equals("""
+  ,
+2 | /- whiz bang boom
+3 | |/ zip zap zop
+4 | || fwee fwoo fwip
+  | |'--- inner
+5 | |  argle bargle boo
+  | '---- outer
+  '"""));
+    });
+
+    test('overlapping the whole first line', () {
+      expect(
+          file
+              .span(12, 53)
+              .highlightMultiple('inner', {file.span(12, 70): 'outer'}),
+          equals("""
+  ,
+2 | // whiz bang boom
+3 | || zip zap zop
+4 | || fwee fwoo fwip
+  | |'--- inner
+5 | |  argle bargle boo
+  | '---- outer
+  '"""));
+    });
+
+    test('overlapping part of first line', () {
+      expect(
+          file
+              .span(17, 53)
+              .highlightMultiple('inner', {file.span(12, 70): 'outer'}),
+          equals("""
+  ,
+2 | /- whiz bang boom
+  | |,------^
+3 | || zip zap zop
+4 | || fwee fwoo fwip
+  | |'--- inner
+5 | |  argle bargle boo
+  | '---- outer
+  '"""));
+    });
+
+    test('overlapping the whole last line', () {
+      expect(
+          file
+              .span(27, 70)
+              .highlightMultiple('inner', {file.span(12, 70): 'outer'}),
+          equals("""
+  ,
+2 | /- whiz bang boom
+3 | |/ zip zap zop
+4 | || fwee fwoo fwip
+5 | || argle bargle boo
+  | |'--- inner
+  | '---- outer
+  '"""));
+    });
+
+    test('overlapping part of the last line', () {
+      expect(
+          file
+              .span(27, 66)
+              .highlightMultiple('inner', {file.span(12, 70): 'outer'}),
+          equals("""
+  ,
+2 | /- whiz bang boom
+3 | |/ zip zap zop
+4 | || fwee fwoo fwip
+5 | || argle bargle boo
+  | |'------------^ inner
+  | '---- outer
+  '"""));
+    });
+  });
+
+  group('a single-line span in a multiline span', () {
+    test('on the first line', () {
+      expect(
+          file
+              .span(17, 21)
+              .highlightMultiple('inner', {file.span(12, 70): 'outer'}),
+          equals("""
+  ,
+2 | / whiz bang boom
+  | |      ^^^^ inner
+3 | | zip zap zop
+4 | | fwee fwoo fwip
+5 | | argle bargle boo
+  | '--- outer
+  '"""));
+    });
+
+    test('in the middle', () {
+      expect(
+          file
+              .span(31, 34)
+              .highlightMultiple('inner', {file.span(12, 70): 'outer'}),
+          equals("""
+  ,
+2 | / whiz bang boom
+3 | | zip zap zop
+  | |     ^^^ inner
+4 | | fwee fwoo fwip
+5 | | argle bargle boo
+  | '--- outer
+  '"""));
+    });
+
+    test('on the last line', () {
+      expect(
+          file
+              .span(60, 66)
+              .highlightMultiple('inner', {file.span(12, 70): 'outer'}),
+          equals("""
+  ,
+2 | / whiz bang boom
+3 | | zip zap zop
+4 | | fwee fwoo fwip
+5 | | argle bargle boo
+  | |       ^^^^^^ inner
+  | '--- outer
+  '"""));
+    });
+  });
+
+  group('writes headers when highlighting multiple files', () {
+    test('writes all file URLs', () {
+      final span2 = SourceFile.fromString('''
+quibble bibble boop
+''', url: 'file2.txt').span(8, 14);
+
+      expect(
+          file.span(31, 34).highlightMultiple('one', {span2: 'two'}), equals("""
+  ,--> file1.txt
+3 | zip zap zop
+  |     ^^^ one
+  '
+  ,--> file2.txt
+1 | quibble bibble boop
+  |         ====== two
+  '"""));
+    });
+
+    test('allows secondary spans to have null URL', () {
+      final span2 = SourceSpan(SourceLocation(1), SourceLocation(4), 'foo');
+
+      expect(
+          file.span(31, 34).highlightMultiple('one', {span2: 'two'}), equals("""
+  ,--> file1.txt
+3 | zip zap zop
+  |     ^^^ one
+  '
+  ,
+1 | foo
+  | === two
+  '"""));
+    });
+
+    test('allows primary span to have null URL', () {
+      final span1 = SourceSpan(SourceLocation(1), SourceLocation(4), 'foo');
+
+      expect(
+          span1.highlightMultiple('one', {file.span(31, 34): 'two'}), equals("""
+  ,
+1 | foo
+  | ^^^ one
+  '
+  ,--> file1.txt
+3 | zip zap zop
+  |     === two
+  '"""));
+    });
+  });
+
+  test('highlights multiple null URLs as separate files', () {
+    final span1 = SourceSpan(SourceLocation(1), SourceLocation(4), 'foo');
+    final span2 = SourceSpan(SourceLocation(1), SourceLocation(4), 'bar');
+
+    expect(span1.highlightMultiple('one', {span2: 'two'}), equals("""
+  ,
+1 | foo
+  | ^^^ one
+  '
+  ,
+1 | bar
+  | === 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
+  '"""));
+    });
+  });
+}
diff --git a/pkgs/source_span/test/span_test.dart b/pkgs/source_span/test/span_test.dart
new file mode 100644
index 0000000..22c498e
--- /dev/null
+++ b/pkgs/source_span/test/span_test.dart
@@ -0,0 +1,432 @@
+// Copyright (c) 2014, 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 'package:source_span/source_span.dart';
+import 'package:source_span/src/colors.dart' as colors;
+import 'package:term_glyph/term_glyph.dart' as glyph;
+import 'package:test/test.dart';
+
+void main() {
+  late bool oldAscii;
+
+  setUpAll(() {
+    oldAscii = glyph.ascii;
+    glyph.ascii = true;
+  });
+
+  tearDownAll(() {
+    glyph.ascii = oldAscii;
+  });
+
+  late SourceSpan span;
+  setUp(() {
+    span = SourceSpan(SourceLocation(5, sourceUrl: 'foo.dart'),
+        SourceLocation(12, sourceUrl: 'foo.dart'), 'foo bar');
+  });
+
+  group('errors', () {
+    group('for new SourceSpan()', () {
+      test('source URLs must match', () {
+        final start = SourceLocation(0, sourceUrl: 'foo.dart');
+        final end = SourceLocation(1, sourceUrl: 'bar.dart');
+        expect(() => SourceSpan(start, end, '_'), throwsArgumentError);
+      });
+
+      test('end must come after start', () {
+        final start = SourceLocation(1);
+        final end = SourceLocation(0);
+        expect(() => SourceSpan(start, end, '_'), throwsArgumentError);
+      });
+
+      test('text must be the right length', () {
+        final start = SourceLocation(0);
+        final end = SourceLocation(1);
+        expect(() => SourceSpan(start, end, 'abc'), throwsArgumentError);
+      });
+    });
+
+    group('for new SourceSpanWithContext()', () {
+      test('context must contain text', () {
+        final start = SourceLocation(2);
+        final end = SourceLocation(5);
+        expect(() => SourceSpanWithContext(start, end, 'abc', '--axc--'),
+            throwsArgumentError);
+      });
+
+      test('text starts at start.column in context', () {
+        final start = SourceLocation(3);
+        final end = SourceLocation(5);
+        expect(() => SourceSpanWithContext(start, end, 'abc', '--abc--'),
+            throwsArgumentError);
+      });
+
+      test('text starts at start.column of line in multi-line context', () {
+        final start = SourceLocation(4, line: 55, column: 3);
+        final end = SourceLocation(7, line: 55, column: 6);
+        expect(() => SourceSpanWithContext(start, end, 'abc', '\n--abc--'),
+            throwsArgumentError);
+        expect(() => SourceSpanWithContext(start, end, 'abc', '\n----abc--'),
+            throwsArgumentError);
+        expect(() => SourceSpanWithContext(start, end, 'abc', '\n\n--abc--'),
+            throwsArgumentError);
+
+        // However, these are valid:
+        SourceSpanWithContext(start, end, 'abc', '\n---abc--');
+        SourceSpanWithContext(start, end, 'abc', '\n\n---abc--');
+      });
+
+      test('text can occur multiple times in context', () {
+        final start1 = SourceLocation(4, line: 55, column: 2);
+        final end1 = SourceLocation(7, line: 55, column: 5);
+        final start2 = SourceLocation(4, line: 55, column: 8);
+        final end2 = SourceLocation(7, line: 55, column: 11);
+        SourceSpanWithContext(start1, end1, 'abc', '--abc---abc--\n');
+        SourceSpanWithContext(start1, end1, 'abc', '--abc--abc--\n');
+        SourceSpanWithContext(start2, end2, 'abc', '--abc---abc--\n');
+        SourceSpanWithContext(start2, end2, 'abc', '---abc--abc--\n');
+        expect(
+            () => SourceSpanWithContext(start1, end1, 'abc', '---abc--abc--\n'),
+            throwsArgumentError);
+        expect(
+            () => SourceSpanWithContext(start2, end2, 'abc', '--abc--abc--\n'),
+            throwsArgumentError);
+      });
+    });
+
+    group('for union()', () {
+      test('source URLs must match', () {
+        final other = SourceSpan(SourceLocation(12, sourceUrl: 'bar.dart'),
+            SourceLocation(13, sourceUrl: 'bar.dart'), '_');
+
+        expect(() => span.union(other), throwsArgumentError);
+      });
+
+      test('spans may not be disjoint', () {
+        final other = SourceSpan(SourceLocation(13, sourceUrl: 'foo.dart'),
+            SourceLocation(14, sourceUrl: 'foo.dart'), '_');
+
+        expect(() => span.union(other), throwsArgumentError);
+      });
+    });
+
+    test('for compareTo() source URLs must match', () {
+      final other = SourceSpan(SourceLocation(12, sourceUrl: 'bar.dart'),
+          SourceLocation(13, sourceUrl: 'bar.dart'), '_');
+
+      expect(() => span.compareTo(other), throwsArgumentError);
+    });
+  });
+
+  test('fields work correctly', () {
+    expect(span.start, equals(SourceLocation(5, sourceUrl: 'foo.dart')));
+    expect(span.end, equals(SourceLocation(12, sourceUrl: 'foo.dart')));
+    expect(span.sourceUrl, equals(Uri.parse('foo.dart')));
+    expect(span.length, equals(7));
+  });
+
+  group('union()', () {
+    test('works with a preceding adjacent span', () {
+      final other = SourceSpan(SourceLocation(0, sourceUrl: 'foo.dart'),
+          SourceLocation(5, sourceUrl: 'foo.dart'), 'hey, ');
+
+      final result = span.union(other);
+      expect(result.start, equals(other.start));
+      expect(result.end, equals(span.end));
+      expect(result.text, equals('hey, foo bar'));
+    });
+
+    test('works with a preceding overlapping span', () {
+      final other = SourceSpan(SourceLocation(0, sourceUrl: 'foo.dart'),
+          SourceLocation(8, sourceUrl: 'foo.dart'), 'hey, foo');
+
+      final result = span.union(other);
+      expect(result.start, equals(other.start));
+      expect(result.end, equals(span.end));
+      expect(result.text, equals('hey, foo bar'));
+    });
+
+    test('works with a following adjacent span', () {
+      final other = SourceSpan(SourceLocation(12, sourceUrl: 'foo.dart'),
+          SourceLocation(16, sourceUrl: 'foo.dart'), ' baz');
+
+      final result = span.union(other);
+      expect(result.start, equals(span.start));
+      expect(result.end, equals(other.end));
+      expect(result.text, equals('foo bar baz'));
+    });
+
+    test('works with a following overlapping span', () {
+      final other = SourceSpan(SourceLocation(9, sourceUrl: 'foo.dart'),
+          SourceLocation(16, sourceUrl: 'foo.dart'), 'bar baz');
+
+      final result = span.union(other);
+      expect(result.start, equals(span.start));
+      expect(result.end, equals(other.end));
+      expect(result.text, equals('foo bar baz'));
+    });
+
+    test('works with an internal overlapping span', () {
+      final other = SourceSpan(SourceLocation(7, sourceUrl: 'foo.dart'),
+          SourceLocation(10, sourceUrl: 'foo.dart'), 'o b');
+
+      expect(span.union(other), equals(span));
+    });
+
+    test('works with an external overlapping span', () {
+      final other = SourceSpan(SourceLocation(0, sourceUrl: 'foo.dart'),
+          SourceLocation(16, sourceUrl: 'foo.dart'), 'hey, foo bar baz');
+
+      expect(span.union(other), equals(other));
+    });
+  });
+
+  group('subspan()', () {
+    group('errors', () {
+      test('start must be greater than zero', () {
+        expect(() => span.subspan(-1), throwsRangeError);
+      });
+
+      test('start must be less than or equal to length', () {
+        expect(() => span.subspan(span.length + 1), throwsRangeError);
+      });
+
+      test('end must be greater than start', () {
+        expect(() => span.subspan(2, 1), throwsRangeError);
+      });
+
+      test('end must be less than or equal to length', () {
+        expect(() => span.subspan(0, span.length + 1), throwsRangeError);
+      });
+    });
+
+    test('preserves the source URL', () {
+      final result = span.subspan(1, 2);
+      expect(result.start.sourceUrl, equals(span.sourceUrl));
+      expect(result.end.sourceUrl, equals(span.sourceUrl));
+    });
+
+    test('preserves the context', () {
+      final start = SourceLocation(2);
+      final end = SourceLocation(5);
+      final span = SourceSpanWithContext(start, end, 'abc', '--abc--');
+      expect(span.subspan(1, 2).context, equals('--abc--'));
+    });
+
+    group('returns the original span', () {
+      test('with an implicit end', () => expect(span.subspan(0), equals(span)));
+
+      test('with an explicit end',
+          () => expect(span.subspan(0, span.length), equals(span)));
+    });
+
+    group('within a single line', () {
+      test('returns a strict substring of the original span', () {
+        final result = span.subspan(1, 5);
+        expect(result.text, equals('oo b'));
+        expect(result.start.offset, equals(6));
+        expect(result.start.line, equals(0));
+        expect(result.start.column, equals(6));
+        expect(result.end.offset, equals(10));
+        expect(result.end.line, equals(0));
+        expect(result.end.column, equals(10));
+      });
+
+      test('an implicit end goes to the end of the original span', () {
+        final result = span.subspan(1);
+        expect(result.text, equals('oo bar'));
+        expect(result.start.offset, equals(6));
+        expect(result.start.line, equals(0));
+        expect(result.start.column, equals(6));
+        expect(result.end.offset, equals(12));
+        expect(result.end.line, equals(0));
+        expect(result.end.column, equals(12));
+      });
+
+      test('can return an empty span', () {
+        final result = span.subspan(3, 3);
+        expect(result.text, isEmpty);
+        expect(result.start.offset, equals(8));
+        expect(result.start.line, equals(0));
+        expect(result.start.column, equals(8));
+        expect(result.end, equals(result.start));
+      });
+    });
+
+    group('across multiple lines', () {
+      setUp(() {
+        span = SourceSpan(
+            SourceLocation(5, line: 2, column: 0),
+            SourceLocation(16, line: 4, column: 3),
+            'foo\n'
+            'bar\n'
+            'baz');
+      });
+
+      test('with start and end in the middle of a line', () {
+        final result = span.subspan(2, 5);
+        expect(result.text, equals('o\nb'));
+        expect(result.start.offset, equals(7));
+        expect(result.start.line, equals(2));
+        expect(result.start.column, equals(2));
+        expect(result.end.offset, equals(10));
+        expect(result.end.line, equals(3));
+        expect(result.end.column, equals(1));
+      });
+
+      test('with start at the end of a line', () {
+        final result = span.subspan(3, 5);
+        expect(result.text, equals('\nb'));
+        expect(result.start.offset, equals(8));
+        expect(result.start.line, equals(2));
+        expect(result.start.column, equals(3));
+      });
+
+      test('with start at the beginning of a line', () {
+        final result = span.subspan(4, 5);
+        expect(result.text, equals('b'));
+        expect(result.start.offset, equals(9));
+        expect(result.start.line, equals(3));
+        expect(result.start.column, equals(0));
+      });
+
+      test('with end at the end of a line', () {
+        final result = span.subspan(2, 3);
+        expect(result.text, equals('o'));
+        expect(result.end.offset, equals(8));
+        expect(result.end.line, equals(2));
+        expect(result.end.column, equals(3));
+      });
+
+      test('with end at the beginning of a line', () {
+        final result = span.subspan(2, 4);
+        expect(result.text, equals('o\n'));
+        expect(result.end.offset, equals(9));
+        expect(result.end.line, equals(3));
+        expect(result.end.column, equals(0));
+      });
+    });
+  });
+
+  group('message()', () {
+    test('prints the text being described', () {
+      expect(span.message('oh no'), equals("""
+line 1, column 6 of foo.dart: oh no
+  ,
+1 | foo bar
+  | ^^^^^^^
+  '"""));
+    });
+
+    test('gracefully handles a missing source URL', () {
+      final span = SourceSpan(SourceLocation(5), SourceLocation(12), 'foo bar');
+
+      expect(span.message('oh no'), equalsIgnoringWhitespace("""
+line 1, column 6: oh no
+  ,
+1 | foo bar
+  | ^^^^^^^
+  '"""));
+    });
+
+    test('gracefully handles empty text', () {
+      final span = SourceSpan(SourceLocation(5), SourceLocation(5), '');
+
+      expect(span.message('oh no'), equals('line 1, column 6: oh no'));
+    });
+
+    test("doesn't colorize if color is false", () {
+      expect(span.message('oh no', color: false), equals("""
+line 1, column 6 of foo.dart: oh no
+  ,
+1 | foo bar
+  | ^^^^^^^
+  '"""));
+    });
+
+    test('colorizes if color is true', () {
+      expect(span.message('oh no', color: true), equals("""
+line 1, column 6 of foo.dart: oh no
+${colors.blue}  ,${colors.none}
+${colors.blue}1 |${colors.none} ${colors.red}foo bar${colors.none}
+${colors.blue}  |${colors.none} ${colors.red}^^^^^^^${colors.none}
+${colors.blue}  '${colors.none}"""));
+    });
+
+    test("uses the given color if it's passed", () {
+      expect(span.message('oh no', color: colors.yellow), equals("""
+line 1, column 6 of foo.dart: oh no
+${colors.blue}  ,${colors.none}
+${colors.blue}1 |${colors.none} ${colors.yellow}foo bar${colors.none}
+${colors.blue}  |${colors.none} ${colors.yellow}^^^^^^^${colors.none}
+${colors.blue}  '${colors.none}"""));
+    });
+
+    test('with context, underlines the right column', () {
+      final spanWithContext = SourceSpanWithContext(
+          SourceLocation(5, sourceUrl: 'foo.dart'),
+          SourceLocation(12, sourceUrl: 'foo.dart'),
+          'foo bar',
+          '-----foo bar-----');
+
+      expect(spanWithContext.message('oh no', color: colors.yellow), equals("""
+line 1, column 6 of foo.dart: oh no
+${colors.blue}  ,${colors.none}
+${colors.blue}1 |${colors.none} -----${colors.yellow}foo bar${colors.none}-----
+${colors.blue}  |${colors.none} ${colors.yellow}     ^^^^^^^${colors.none}
+${colors.blue}  '${colors.none}"""));
+    });
+  });
+
+  group('compareTo()', () {
+    test('sorts by start location first', () {
+      final other = SourceSpan(SourceLocation(6, sourceUrl: 'foo.dart'),
+          SourceLocation(14, sourceUrl: 'foo.dart'), 'oo bar b');
+
+      expect(span.compareTo(other), lessThan(0));
+      expect(other.compareTo(span), greaterThan(0));
+    });
+
+    test('sorts by length second', () {
+      final other = SourceSpan(SourceLocation(5, sourceUrl: 'foo.dart'),
+          SourceLocation(14, sourceUrl: 'foo.dart'), 'foo bar b');
+
+      expect(span.compareTo(other), lessThan(0));
+      expect(other.compareTo(span), greaterThan(0));
+    });
+
+    test('considers equal spans equal', () {
+      expect(span.compareTo(span), equals(0));
+    });
+  });
+
+  group('equality', () {
+    test('two spans with the same locations are equal', () {
+      final other = SourceSpan(SourceLocation(5, sourceUrl: 'foo.dart'),
+          SourceLocation(12, sourceUrl: 'foo.dart'), 'foo bar');
+
+      expect(span, equals(other));
+    });
+
+    test("a different start isn't equal", () {
+      final other = SourceSpan(SourceLocation(0, sourceUrl: 'foo.dart'),
+          SourceLocation(12, sourceUrl: 'foo.dart'), 'hey, foo bar');
+
+      expect(span, isNot(equals(other)));
+    });
+
+    test("a different end isn't equal", () {
+      final other = SourceSpan(SourceLocation(5, sourceUrl: 'foo.dart'),
+          SourceLocation(16, sourceUrl: 'foo.dart'), 'foo bar baz');
+
+      expect(span, isNot(equals(other)));
+    });
+
+    test("a different source URL isn't equal", () {
+      final other = SourceSpan(SourceLocation(5, sourceUrl: 'bar.dart'),
+          SourceLocation(12, sourceUrl: 'bar.dart'), 'foo bar');
+
+      expect(span, isNot(equals(other)));
+    });
+  });
+}
diff --git a/pkgs/source_span/test/utils_test.dart b/pkgs/source_span/test/utils_test.dart
new file mode 100644
index 0000000..91397c0
--- /dev/null
+++ b/pkgs/source_span/test/utils_test.dart
@@ -0,0 +1,58 @@
+// Copyright (c) 2013, 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 'package:source_span/src/utils.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('find line start', () {
+    test('skip entries in wrong column', () {
+      const context = '0_bb\n1_bbb\n2b____\n3bbb\n';
+      final index = findLineStart(context, 'b', 1)!;
+      expect(index, 11);
+      expect(context.substring(index - 1, index + 3), '\n2b_');
+    });
+
+    test('end of line column for empty text', () {
+      const context = '0123\n56789\nabcdefgh\n';
+      final index = findLineStart(context, '', 5)!;
+      expect(index, 5);
+      expect(context[index], '5');
+    });
+
+    test('column at the end of the file for empty text', () {
+      var context = '0\n2\n45\n';
+      var index = findLineStart(context, '', 2)!;
+      expect(index, 4);
+      expect(context[index], '4');
+
+      context = '0\n2\n45';
+      index = findLineStart(context, '', 2)!;
+      expect(index, 4);
+    });
+
+    test('empty text in empty context', () {
+      final index = findLineStart('', '', 0);
+      expect(index, 0);
+    });
+
+    test('found on the first line', () {
+      const context = '0\n2\n45\n';
+      final index = findLineStart(context, '0', 0);
+      expect(index, 0);
+    });
+
+    test('finds text that starts with a newline', () {
+      const context = '0\n2\n45\n';
+      final index = findLineStart(context, '\n2', 1);
+      expect(index, 0);
+    });
+
+    test('not found', () {
+      const context = '0\n2\n45\n';
+      final index = findLineStart(context, '0', 1);
+      expect(index, isNull);
+    });
+  });
+}