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 @@
+[](https://github.com/dart-lang/source_span/actions/workflows/test-package.yml)
+[](https://pub.dev/packages/source_span)
+[](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);
+ });
+ });
+}