blob: 9a8cca66d2bbc6017b965ec2fcdc853dfe885cef [file] [log] [blame]
// 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:convert' show json;
import 'dart:io';
import 'package:coverage/src/resolver.dart';
import 'package:coverage/src/util.dart';
/// Contains line and function hit information for a single script.
class HitMap {
/// Constructs a HitMap.
HitMap([
Map<int, int>? lineHits,
this.funcHits,
this.funcNames,
this.branchHits,
]) : lineHits = lineHits ?? {};
/// Map from line to hit count for that line.
final Map<int, int> lineHits;
/// Map from the first line of each function, to the hit count for that
/// function. Null if function coverage info was not gathered.
Map<int, int>? funcHits;
/// Map from the first line of each function, to the function name. Null if
/// function coverage info was not gathered.
Map<int, String>? funcNames;
/// Map from branch line, to the hit count for that branch. Null if branch
/// coverage info was not gathered.
Map<int, int>? branchHits;
/// Creates a single hitmap from a raw json object.
///
/// Note that when [checkIgnoredLines] is `true` all files will be
/// read to get ignore comments. This will add some overhead.
/// To combat this when calling this function multiple times from the
/// same source (e.g. test runs of different files) a cache is taken
/// via [ignoredLinesInFilesCache]. If this cache contains the parsed
/// data for the specific file already, the file will not be read and
/// parsed again.
///
/// Throws away all entries that are not resolvable.
static Map<String, HitMap> parseJsonSync(
List<Map<String, dynamic>> jsonResult, {
required bool checkIgnoredLines,
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>{};
for (var e in jsonResult) {
final source = e['source'] as String?;
if (source == null) {
// Couldn't resolve import, so skip this entry.
continue;
}
var ignoredLinesList = <List<int>>[];
if (checkIgnoredLines) {
if (ignoredLinesInFilesCache.containsKey(source)) {
final List<List<int>>? 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;
}
void fillHitMap(List hits, Map<int, int> hitMap) {
// Ignore line annotations require hits to be sorted.
hits = _sortHits(hits);
// hits is a flat array of the following format:
// [ <line|linerange>, <hitcount>,...]
// line: number.
// linerange: '<line>-<line>'.
for (var i = 0; i < hits.length; i += 2) {
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 {
throw StateError('Expected value of type int or String');
}
}
}
final sourceHitMap = globalHitMap.putIfAbsent(source, () => HitMap());
fillHitMap(e['hits'] as List, sourceHitMap.lineHits);
if (e.containsKey('funcHits')) {
sourceHitMap.funcHits ??= <int, int>{};
fillHitMap(e['funcHits'] as List, sourceHitMap.funcHits!);
}
if (e.containsKey('funcNames')) {
sourceHitMap.funcNames ??= <int, String>{};
final funcNames = e['funcNames'] as List;
for (var i = 0; i < funcNames.length; i += 2) {
sourceHitMap.funcNames![funcNames[i] as int] =
funcNames[i + 1] as String;
}
}
if (e.containsKey('branchHits')) {
sourceHitMap.branchHits ??= <int, int>{};
fillHitMap(e['branchHits'] as List, sourceHitMap.branchHits!);
}
}
return globalHitMap;
}
/// Creates a single hitmap from a raw json object.
///
/// Throws away all entries that are not resolvable.
static Future<Map<String, HitMap>> parseJson(
List<Map<String, dynamic>> jsonResult, {
bool checkIgnoredLines = false,
@Deprecated('Use packagePath') String? packagesPath,
String? packagePath,
}) async {
final Resolver resolver = await Resolver.create(
packagesPath: packagesPath, packagePath: packagePath);
return parseJsonSync(jsonResult,
checkIgnoredLines: checkIgnoredLines,
ignoredLinesInFilesCache: {},
resolver: resolver);
}
/// Generates a merged hitmap from a set of coverage JSON files.
static Future<Map<String, HitMap>> parseFiles(
Iterable<File> files, {
bool checkIgnoredLines = false,
@Deprecated('Use packagePath') String? packagesPath,
String? packagePath,
}) async {
final globalHitmap = <String, HitMap>{};
for (var file in files) {
final contents = file.readAsStringSync();
final jsonMap = json.decode(contents) as Map<String, dynamic>;
if (jsonMap.containsKey('coverage')) {
final jsonResult = jsonMap['coverage'] as List;
globalHitmap.merge(await HitMap.parseJson(
jsonResult.cast<Map<String, dynamic>>(),
checkIgnoredLines: checkIgnoredLines,
// ignore: deprecated_member_use_from_same_package
packagesPath: packagesPath,
packagePath: packagePath,
));
}
}
return globalHitmap;
}
}
extension FileHitMaps on Map<String, HitMap> {
/// Merges [newMap] into this one.
void merge(Map<String, HitMap> newMap) {
newMap.forEach((file, v) {
final fileResult = this[file];
if (fileResult != null) {
_mergeHitCounts(v.lineHits, fileResult.lineHits);
if (v.funcHits != null) {
fileResult.funcHits ??= <int, int>{};
_mergeHitCounts(v.funcHits!, fileResult.funcHits!);
}
if (v.funcNames != null) {
fileResult.funcNames ??= <int, String>{};
v.funcNames?.forEach((line, name) {
fileResult.funcNames![line] = name;
});
}
if (v.branchHits != null) {
fileResult.branchHits ??= <int, int>{};
_mergeHitCounts(v.branchHits!, fileResult.branchHits!);
}
} else {
this[file] = v;
}
});
}
static void _mergeHitCounts(Map<int, int> src, Map<int, int> dest) {
src.forEach((line, count) {
final lineFileResult = dest[line];
if (lineFileResult == null) {
dest[line] = count;
} else {
dest[line] = lineFileResult + count;
}
});
}
}
/// Class containing information about a coverage hit.
class _HitInfo {
_HitInfo(this.firstLine, this.hitRange, this.hitCount);
/// The line number of the first line of this hit range.
final int firstLine;
/// A hit range is either a number (1 line) or a String of the form
/// "start-end" (multi-line range).
final dynamic hitRange;
/// How many times this hit range was executed.
final int hitCount;
}
/// Creates a single hitmap from a raw json object.
///
/// Throws away all entries that are not resolvable.
@Deprecated('Migrate to HitMap.parseJson')
Future<Map<String, Map<int, int>>> createHitmap(
List<Map<String, dynamic>> jsonResult, {
bool checkIgnoredLines = false,
@Deprecated('Use packagePath') String? packagesPath,
String? packagePath,
}) async {
final result = await HitMap.parseJson(
jsonResult,
checkIgnoredLines: checkIgnoredLines,
packagesPath: packagesPath,
packagePath: packagePath,
);
return result.map((key, value) => MapEntry(key, value.lineHits));
}
/// Merges [newMap] into [result].
@Deprecated('Migrate to FileHitMaps.merge')
void mergeHitmaps(
Map<String, Map<int, int>> newMap, Map<String, Map<int, int>> result) {
newMap.forEach((file, v) {
final fileResult = result[file];
if (fileResult != null) {
v.forEach((line, count) {
final lineFileResult = fileResult[line];
if (lineFileResult == null) {
fileResult[line] = count;
} else {
fileResult[line] = lineFileResult + count;
}
});
} else {
result[file] = v;
}
});
}
/// Generates a merged hitmap from a set of coverage JSON files.
@Deprecated('Migrate to HitMap.parseFiles')
Future<Map<String, Map<int, int>>> parseCoverage(
Iterable<File> files,
int _, {
bool checkIgnoredLines = false,
@Deprecated('Use packagePath') String? packagesPath,
String? packagePath,
}) async {
final result = await HitMap.parseFiles(files,
checkIgnoredLines: checkIgnoredLines,
packagesPath: packagesPath,
packagePath: packagePath);
return result.map((key, value) => MapEntry(key, value.lineHits));
}
/// Returns a JSON hit map backward-compatible with pre-1.16.0 SDKs.
@Deprecated('Will be removed in 2.0.0')
Map<String, dynamic> toScriptCoverageJson(Uri scriptUri, Map<int, int> hitMap) {
return hitmapToJson(HitMap(hitMap), scriptUri);
}
List<T> _flattenMap<T>(Map map) {
final kvs = <T>[];
map.forEach((k, v) {
kvs.add(k as T);
kvs.add(v as T);
});
return kvs;
}
/// Returns a JSON hit map backward-compatible with pre-1.16.0 SDKs.
Map<String, dynamic> hitmapToJson(HitMap hitmap, Uri scriptUri) =>
<String, dynamic>{
'source': '$scriptUri',
'script': {
'type': '@Script',
'fixedId': true,
'id':
'libraries/1/scripts/${Uri.encodeComponent(scriptUri.toString())}',
'uri': '$scriptUri',
'_kind': 'library',
},
'hits': _flattenMap<int>(hitmap.lineHits),
if (hitmap.funcHits != null)
'funcHits': _flattenMap<int>(hitmap.funcHits!),
if (hitmap.funcNames != null)
'funcNames': _flattenMap<dynamic>(hitmap.funcNames!),
if (hitmap.branchHits != null)
'branchHits': _flattenMap<int>(hitmap.branchHits!),
};
/// Sorts the hits array based on the line numbers.
List _sortHits(List hits) {
final structuredHits = <_HitInfo>[];
for (var i = 0; i < hits.length - 1; i += 2) {
final lineOrLineRange = hits[i];
final firstLineInRange = lineOrLineRange is int
? lineOrLineRange
: int.parse(lineOrLineRange.split('-')[0] as String);
structuredHits.add(_HitInfo(firstLineInRange, hits[i], hits[i + 1] as int));
}
structuredHits.sort((a, b) => a.firstLine.compareTo(b.firstLine));
return structuredHits
.map((item) => [item.hitRange, item.hitCount])
.expand((item) => item)
.toList();
}