Skip to content

[coverage] Expose filterIgnored function #2123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pkgs/coverage/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 1.15.0

- Expose `filterIgnored` function, which filters the coverage data according to
`// coverage:ignore-...` comments. Previously this filtering functionality
was only available when loading coverage data from json using `parseJson` etc.

## 1.14.1

- Remove dependency on `package:pubspec_parse`.
Expand Down
106 changes: 39 additions & 67 deletions pkgs/coverage/lib/src/hitmap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,6 @@ class HitMap {
required Map<String, List<List<int>>?> ignoredLinesInFilesCache,
required Resolver resolver,
}) {
final loader = Loader();

// Map of source file to map of line to hit count for that line.
final globalHitMap = <String, HitMap>{};

Expand All @@ -71,66 +69,6 @@ class HitMap {
continue;
}

var ignoredLinesList = <List<int>>[];

if (checkIgnoredLines) {
if (ignoredLinesInFilesCache.containsKey(source)) {
final cacheHit = ignoredLinesInFilesCache[source];
if (cacheHit == null) {
// Null-entry indicates that the whole file was ignored.
continue;
}
ignoredLinesList = cacheHit;
} else {
final path = resolver.resolve(source);
if (path != null) {
final lines = loader.loadSync(path) ?? [];
ignoredLinesList = getIgnoredLines(path, lines);

// Ignore the whole file.
if (ignoredLinesList.length == 1 &&
ignoredLinesList[0][0] == 0 &&
ignoredLinesList[0][1] == lines.length) {
// Null-entry indicates that the whole file was ignored.
ignoredLinesInFilesCache[source] = null;
continue;
}
ignoredLinesInFilesCache[source] = ignoredLinesList;
} else {
// Couldn't resolve source. Allow cache to answer next time
// anyway.
ignoredLinesInFilesCache[source] = ignoredLinesList;
}
}
}

// Move to the first ignore range.
final ignoredLines = ignoredLinesList.iterator;
var hasCurrent = ignoredLines.moveNext();

bool shouldIgnoreLine(Iterator<List<int>> ignoredRanges, int line) {
if (!hasCurrent || ignoredRanges.current.isEmpty) {
return false;
}

if (line < ignoredRanges.current[0]) return false;

while (hasCurrent &&
ignoredRanges.current.isNotEmpty &&
ignoredRanges.current[1] < line) {
hasCurrent = ignoredRanges.moveNext();
}

if (hasCurrent &&
ignoredRanges.current.isNotEmpty &&
ignoredRanges.current[0] <= line &&
line <= ignoredRanges.current[1]) {
return true;
}

return false;
}

void addToMap(Map<int, int> map, int line, int count) {
final oldCount = map.putIfAbsent(line, () => 0);
map[line] = count + oldCount;
Expand All @@ -147,17 +85,13 @@ class HitMap {
final k = hits[i];
if (k is int) {
// Single line.
if (shouldIgnoreLine(ignoredLines, k)) continue;

addToMap(hitMap, k, hits[i + 1] as int);
} else if (k is String) {
// Linerange. We expand line ranges to actual lines at this point.
final splitPos = k.indexOf('-');
final start = int.parse(k.substring(0, splitPos));
final end = int.parse(k.substring(splitPos + 1));
for (var j = start; j <= end; j++) {
if (shouldIgnoreLine(ignoredLines, j)) continue;

addToMap(hitMap, j, hits[i + 1] as int);
}
} else {
Expand Down Expand Up @@ -185,7 +119,12 @@ class HitMap {
fillHitMap(e['branchHits'] as List, sourceHitMap.branchHits!);
}
}
return globalHitMap;
return checkIgnoredLines
? globalHitMap.filterIgnored(
ignoredLinesInFilesCache: ignoredLinesInFilesCache,
resolver: resolver,
)
: globalHitMap;
}

/// Creates a single hitmap from a raw json object.
Expand Down Expand Up @@ -268,6 +207,39 @@ extension FileHitMaps on Map<String, HitMap> {
}
});
}

/// Filters out lines that are ignored by ignore comments.
Map<String, HitMap> filterIgnored({
required Map<String, List<List<int>>?> ignoredLinesInFilesCache,
required Resolver resolver,
}) {
final loader = Loader();
final newHitMaps = <String, HitMap>{};
for (final MapEntry(key: source, value: hitMap) in entries) {
final ignoredLinesList = ignoredLinesInFilesCache.putIfAbsent(source, () {
final path = resolver.resolve(source);
if (path == null) return <List<int>>[];
return getIgnoredLines(path, loader.loadSync(path));
});
// Null here means that the whole file is ignored.
if (ignoredLinesList == null) continue;

Map<int, int>? filterHits(Map<int, int>? hits) => hits == null
? null
: {
for (final MapEntry(key: line, value: count) in hits.entries)
if (!ignoredLinesList.ignoredContains(line)) line: count,
};

newHitMaps[source] = HitMap(
filterHits(hitMap.lineHits),
filterHits(hitMap.funcHits),
hitMap.funcNames,
filterHits(hitMap.branchHits),
);
}
return newHitMaps;
}
}

/// Class containing information about a coverage hit.
Expand Down
47 changes: 32 additions & 15 deletions pkgs/coverage/lib/src/util.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,16 @@ Uri? extractVMServiceUri(String str) {
return null;
}

final muliLineIgnoreStart = RegExp(r'//\s*coverage:ignore-start[\w\d\s]*$');
final muliLineIgnoreEnd = RegExp(r'//\s*coverage:ignore-end[\w\d\s]*$');
final multiLineIgnoreStart = RegExp(r'//\s*coverage:ignore-start[\w\d\s]*$');
final multiLineIgnoreEnd = RegExp(r'//\s*coverage:ignore-end[\w\d\s]*$');
final singleLineIgnore = RegExp(r'//\s*coverage:ignore-line[\w\d\s]*$');
final ignoreFile = RegExp(r'//\s*coverage:ignore-file[\w\d\s]*$');

/// Return list containing inclusive range of lines to be ignored by coverage.
/// Return list containing inclusive ranges of lines to be ignored by coverage.
/// If there is a error in balancing the statements it will throw a
/// [FormatException],
/// unless `coverage:ignore-file` is found.
/// Return [0, lines.length] if the whole file is ignored.
/// Return null if the whole file is ignored.
///
/// ```
/// 1. final str = ''; // coverage:ignore-line
Expand All @@ -86,42 +86,39 @@ final ignoreFile = RegExp(r'//\s*coverage:ignore-file[\w\d\s]*$');
/// ]
/// ```
///
List<List<int>> getIgnoredLines(String filePath, List<String>? lines) {
/// The returned ranges are in sorted order, and never overlap.
List<List<int>>? getIgnoredLines(String filePath, List<String>? lines) {
final ignoredLines = <List<int>>[];
if (lines == null) return ignoredLines;

final allLines = [
[0, lines.length]
];

FormatException? err;
var i = 0;
while (i < lines.length) {
if (lines[i].contains(ignoreFile)) return allLines;
if (lines[i].contains(ignoreFile)) return null;

if (lines[i].contains(muliLineIgnoreEnd)) {
if (lines[i].contains(multiLineIgnoreEnd)) {
err ??= FormatException(
'unmatched coverage:ignore-end found at $filePath:${i + 1}',
);
}

if (lines[i].contains(singleLineIgnore)) ignoredLines.add([i + 1, i + 1]);

if (lines[i].contains(muliLineIgnoreStart)) {
if (lines[i].contains(multiLineIgnoreStart)) {
final start = i;
var isUnmatched = true;
++i;
while (i < lines.length) {
if (lines[i].contains(ignoreFile)) return allLines;
if (lines[i].contains(muliLineIgnoreStart)) {
if (lines[i].contains(ignoreFile)) return null;
if (lines[i].contains(multiLineIgnoreStart)) {
err ??= FormatException(
'coverage:ignore-start found at $filePath:${i + 1}'
' before previous coverage:ignore-start ended',
);
break;
}

if (lines[i].contains(muliLineIgnoreEnd)) {
if (lines[i].contains(multiLineIgnoreEnd)) {
ignoredLines.add([start + 1, i + 1]);
isUnmatched = false;
break;
Expand All @@ -146,6 +143,26 @@ List<List<int>> getIgnoredLines(String filePath, List<String>? lines) {
throw err;
}

extension IgnoredLinesContains on List<List<int>> {
/// Returns whether this list of line ranges contains the given line.
bool ignoredContains(int line) {
if (length == 0 || this[0][0] > line) return false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any chance that this[0].isEmpty?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, every inner list has exactly 2 elements: the start and end of a range. It's a poorly designed API. I wanted to define a little Range class and change this to a List<Range>, but unfortunately the List<List<int>> type is exposed in the public API in parseJsonSync's Map<String, List<List<int>>?> ignoredLinesInFilesCache, so that would be a breaking change. I've added it to my list of tech debt to clean up in v2.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying! But yeah, that's not great. I wonder why we didn't use a class in the first place... It'd be good to switch to a record type when we do plan for a v2 release.


// Binary search for the range with the largest start value that is <= line.
var lo = 0;
var hi = length;
while (lo < hi - 1) {
final mid = lo + (hi - lo) ~/ 2;
if (this[mid][0] <= line) {
lo = mid;
} else {
hi = mid;
}
}
return this[lo][1] >= line;
}
}

extension StandardOutExtension on Stream<List<int>> {
Stream<String> lines() =>
transform(const SystemEncoding().decoder).transform(const LineSplitter());
Expand Down
2 changes: 1 addition & 1 deletion pkgs/coverage/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: coverage
version: 1.14.1
version: 1.15.0
description: Coverage data manipulation and formatting
repository: https://github.com/dart-lang/tools/tree/main/pkgs/coverage
issue_tracker: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Acoverage
Expand Down
103 changes: 103 additions & 0 deletions pkgs/coverage/test/filter_ignored_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) 2025, 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:coverage/coverage.dart' show Resolver;
import 'package:coverage/src/hitmap.dart';
import 'package:test/test.dart';

String fileUri(String relativePath) =>
Uri.file(File(relativePath).absolute.path).toString();

void main() {
test('filter ignored', () async {
// The ignored lines come from the comments in the test dart files. But the
// hitmaps are fake, and don't have to correspond to real coverage data.
final hitmaps = {
fileUri('nonexistent_file.dart'): HitMap(
{1: 1, 2: 2, 3: 3},
{1: 1, 2: 2, 3: 3},
{1: 'abc', 2: 'def'},
{1: 1, 2: 2, 3: 3},
),
fileUri('another_nonexistent_file.dart'): HitMap(
{1: 1, 2: 2, 3: 3},
),
fileUri('test/test_files/test_app.dart'): HitMap(
{1: 1, 2: 2, 3: 3},
{1: 1, 2: 2, 3: 3},
{1: 'abc', 2: 'def'},
{1: 1, 2: 2, 3: 3},
),
fileUri('test/test_files/test_app_isolate.dart'): HitMap(
{for (var i = 50; i < 100; ++i) i: i},
{for (var i = 50; i < 100; ++i) i: i},
{for (var i = 50; i < 100; ++i) i: '$i'},
{for (var i = 50; i < 100; ++i) i: i},
),
};

// Lines ignored in test/test_files/test_app_isolate.dart.
const ignores = [
52,
54,
55,
56,
57,
58,
63,
64,
65,
66,
67,
68,
69,
70,
71,
72,
73,
];

final expected = {
fileUri('nonexistent_file.dart'): HitMap(
{1: 1, 2: 2, 3: 3},
{1: 1, 2: 2, 3: 3},
{1: 'abc', 2: 'def'},
{1: 1, 2: 2, 3: 3},
),
fileUri('another_nonexistent_file.dart'): HitMap(
{1: 1, 2: 2, 3: 3},
),
fileUri('test/test_files/test_app_isolate.dart'): HitMap(
{
for (var i = 50; i < 100; ++i)
if (!ignores.contains(i)) i: i
},
{
for (var i = 50; i < 100; ++i)
if (!ignores.contains(i)) i: i
},
{for (var i = 50; i < 100; ++i) i: '$i'},
{
for (var i = 50; i < 100; ++i)
if (!ignores.contains(i)) i: i
},
),
};

final resolver = await Resolver.create(packagePath: '.');

final actual =
hitmaps.filterIgnored(ignoredLinesInFilesCache: {}, resolver: resolver);

expect(actual.keys.toList(), expected.keys.toList());
for (final source in expected.keys) {
expect(actual[source]!.lineHits, expected[source]!.lineHits);
expect(actual[source]!.funcHits, expected[source]!.funcHits);
expect(actual[source]!.funcNames, expected[source]!.funcNames);
expect(actual[source]!.branchHits, expected[source]!.branchHits);
}
});
}
Loading