WIP: Mostly done with function coverage, but need to update tests.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 519f484..1256f4b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,17 @@
+## 1.1.0
+
+* Support function level coverage information, when running tests in the Dart
+ VM. This is not supported for web tests yet.
+* Add flag `--pretty-print-func` (abbr `-f`) to format_coverage that works
+ similarly to pretty print, but outputs function level coverage, rather than
+ line level.
+* Update `--lcov` (abbr `-l`) in format_coverage to output function level
+ coverage, in addition to line level.
+* BREAKING CHANGE: The signatures of `createHitmap`, `mergeHitmaps`,
+ `parseCoverage`, `toScriptCoverageJson`, and `Formatter.format` have changed
+ from using `Map<int, int>` to represent line coverage to using `HitMap`
+ (which contains both line and function coverage).
+
## 1.0.1 - 2021-02-25
* Allow the chrome `sourceUriProvider` to return `null`.
diff --git a/bin/format_coverage.dart b/bin/format_coverage.dart
index ab643ed..49c6a65 100644
--- a/bin/format_coverage.dart
+++ b/bin/format_coverage.dart
@@ -20,6 +20,7 @@
required this.output,
required this.packagesPath,
required this.prettyPrint,
+ required this.prettyPrintFunc,
required this.reportOn,
required this.sdkRoot,
required this.verbose,
@@ -35,6 +36,7 @@
IOSink output;
String? packagesPath;
bool prettyPrint;
+ bool prettyPrintFunc;
List<String>? reportOn;
String? sdkRoot;
bool verbose;
@@ -72,10 +74,10 @@
? BazelResolver(workspacePath: env.bazelWorkspace)
: Resolver(packagesPath: env.packagesPath, sdkRoot: env.sdkRoot);
final loader = Loader();
- if (env.prettyPrint) {
- output =
- await PrettyPrintFormatter(resolver, loader, reportOn: env.reportOn)
- .format(hitmap);
+ if (env.prettyPrint || env.prettyPrintFunc) {
+ output = await PrettyPrintFormatter(resolver, loader,
+ reportOn: env.reportOn, reportFuncs: env.prettyPrintFunc)
+ .format(hitmap);
} else {
assert(env.lcov);
output = await LcovFormatter(resolver,
@@ -130,7 +132,11 @@
parser.addFlag('pretty-print',
abbr: 'r',
negatable: false,
- help: 'convert coverage data to pretty print format');
+ help: 'convert line coverage data to pretty print format');
+ parser.addFlag('pretty-print-func',
+ abbr: 'f',
+ negatable: false,
+ help: 'convert function coverage data to pretty print format');
parser.addFlag('lcov',
abbr: 'l',
negatable: false,
@@ -211,12 +217,14 @@
}
final lcov = args['lcov'] as bool;
- if (args['pretty-print'] as bool && lcov == true) {
- fail('Choose one of pretty-print or lcov output');
+ var prettyPrint = args['pretty-print'] as bool;
+ final prettyPrintFunc = args['pretty-print-func'] as bool;
+ if ((prettyPrint ? 1 : 0) + (prettyPrintFunc ? 1 : 0) + (lcov ? 1 : 0) > 1) {
+ fail('Choose one of the pretty-print modes or lcov output');
}
// Use pretty-print either explicitly or by default.
- final prettyPrint = !lcov;
+ if (!lcov && !prettyPrintFunc) prettyPrint = true;
int workers;
try {
@@ -237,6 +245,7 @@
output: output,
packagesPath: packagesPath,
prettyPrint: prettyPrint,
+ prettyPrintFunc: prettyPrintFunc,
reportOn: reportOn,
sdkRoot: sdkRoot,
verbose: verbose,
diff --git a/lib/src/chrome.dart b/lib/src/chrome.dart
index 0063d3b..ca429ba 100644
--- a/lib/src/chrome.dart
+++ b/lib/src/chrome.dart
@@ -2,6 +2,7 @@
// 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:coverage/src/hitmap.dart';
import 'package:coverage/src/util.dart';
import 'package:source_maps/parser.dart';
@@ -70,11 +71,11 @@
}
}
- final coverageHitMaps = <Uri, Map<int, int>>{};
+ final coverageHitMaps = <Uri, HitMap>{};
coverageReport.forEach((uri, coverage) {
- final hitMap = <int, int>{};
+ final hitMap = HitMap();
for (var line in coverage.keys.toList()..sort()) {
- hitMap[line] = coverage[line]! ? 1 : 0;
+ hitMap.lineHits[line] = coverage[line]! ? 1 : 0;
}
coverageHitMaps[uri] = hitMap;
});
diff --git a/lib/src/collect.dart b/lib/src/collect.dart
index 29be493..59760f8 100644
--- a/lib/src/collect.dart
+++ b/lib/src/collect.dart
@@ -8,6 +8,7 @@
import 'package:vm_service/vm_service.dart';
import 'util.dart';
+import 'hitmap.dart';
const _retryInterval = Duration(milliseconds: 200);
@@ -194,8 +195,9 @@
Future<List<Map<String, dynamic>>> _getCoverageJson(VmService service,
IsolateRef isolateRef, SourceReport report, bool includeDart) async {
// script uri -> { line -> hit count }
- final hitMaps = <Uri, Map<int, int>>{};
+ final hitMaps = <Uri, HitMap>{};
final scripts = <ScriptRef, Script>{};
+ final functions = <LibraryRef, Map<ScriptRef, Set<int>>>{};
for (var range in report.ranges!) {
final scriptRef = report.scripts![range.scriptIndex!];
final scriptUri = Uri.parse(report.scripts![range.scriptIndex!].uri!);
@@ -213,8 +215,45 @@
final script = scripts[scriptRef];
if (script == null) continue;
- // Look up the hit map for this script (shared across isolates).
- final hitMap = hitMaps.putIfAbsent(scriptUri, () => <int, int>{});
+ // Look up the hit maps for this script (shared across isolates).
+ final hits = hitMaps.putIfAbsent(scriptUri, () => HitMap());
+
+ // If the script's library isn't loaded, load it then look up all its funcs.
+ final libRef = script.library;
+ if (libRef != null && !functions.containsKey(libRef)) {
+ final library =
+ await service.getObject(isolateRef.id!, libRef.id!) as Library;
+ final Map<ScriptRef, Set<int>> libFuncs = {};
+ if (library.functions != null) {
+ for (var funcRef in library.functions!) {
+ final func =
+ await service.getObject(isolateRef.id!, funcRef.id!) as Func;
+ final location = func.location;
+ if (location != null) {
+ final scriptFuncs =
+ libFuncs.putIfAbsent(location.script!, () => <int>{});
+ final funcName = await _getFuncName(service, isolateRef, func);
+ final tokenPos = location.tokenPos!;
+ scriptFuncs.add(tokenPos);
+
+ final line = _getLineFromTokenPos(script, tokenPos);
+ if (line == null) {
+ print(
+ 'tokenPos $tokenPos has no line mapping for script $scriptUri');
+ continue;
+ }
+ if (hits.funcNames.containsKey(line)) {
+ print(
+ 'Multiple functions defined on line $line of script $scriptUri');
+ continue;
+ }
+ hits.funcNames[line] = funcName;
+ }
+ }
+ }
+ functions[libRef] = libFuncs;
+ }
+ final scriptFuncs = functions[libRef]![scriptRef]!;
// Collect hits and misses.
final coverage = range.coverage;
@@ -227,7 +266,8 @@
print('tokenPos $tokenPos has no line mapping for script $scriptUri');
continue;
}
- hitMap[line] = hitMap.containsKey(line) ? hitMap[line]! + 1 : 1;
+ _increment(hits.lineHits, line);
+ if (scriptFuncs.contains(tokenPos)) _increment(hits.funcHits, line);
}
for (final tokenPos in coverage.misses!) {
final line = _getLineFromTokenPos(script, tokenPos);
@@ -235,18 +275,43 @@
print('tokenPos $tokenPos has no line mapping for script $scriptUri');
continue;
}
- hitMap.putIfAbsent(line, () => 0);
+ hits.lineHits.putIfAbsent(line, () => 0);
}
+ scriptFuncs.forEach((tokenPos) {
+ final line = _getLineFromTokenPos(script, tokenPos);
+ if (line == null) {
+ print('tokenPos $tokenPos has no line mapping for script $scriptUri');
+ return;
+ }
+ hits.funcHits.putIfAbsent(line, () => 0);
+ });
}
// Output JSON
final coverage = <Map<String, dynamic>>[];
- hitMaps.forEach((uri, hitMap) {
- coverage.add(toScriptCoverageJson(uri, hitMap));
+ hitMaps.forEach((uri, hits) {
+ coverage.add(toScriptCoverageJson(uri, hits));
});
return coverage;
}
+void _increment(Map<int, int> counter, int key) {
+ counter[key] = counter.containsKey(key) ? counter[key]! + 1 : 1;
+}
+
+Future<String> _getFuncName(
+ VmService service, IsolateRef isolateRef, Func func) async {
+ if (func.name == null) {
+ return "${func.type}:${func.location!.tokenPos}";
+ }
+ if (func.owner is ClassRef) {
+ final cls =
+ await service.getObject(isolateRef.id!, func.owner.id!) as Class;
+ if (cls.name != null) return "${cls.name}.${func.name}";
+ }
+ return func.name!;
+}
+
class StdoutLog extends Log {
@override
void warning(String message) => print(message);
diff --git a/lib/src/formatter.dart b/lib/src/formatter.dart
index 679b8f3..f2e54bc 100644
--- a/lib/src/formatter.dart
+++ b/lib/src/formatter.dart
@@ -5,10 +5,11 @@
import 'package:path/path.dart' as p;
import 'resolver.dart';
+import 'hitmap.dart';
abstract class Formatter {
/// Returns the formatted coverage data.
- Future<String> format(Map<String, Map<int, int>> hitmap);
+ Future<String> format(Map<String, HitMap> hitmap);
}
/// Converts the given hitmap to lcov format and appends the result to
@@ -29,11 +30,14 @@
final List<String>? reportOn;
@override
- Future<String> format(Map<String, Map<int, int>> hitmap) async {
+ Future<String> format(Map<String, HitMap> hitmap) async {
final pathFilter = _getPathFilter(reportOn);
final buf = StringBuffer();
for (var key in hitmap.keys) {
final v = hitmap[key]!;
+ final lineHits = v.lineHits;
+ final funcHits = v.funcHits;
+ final funcNames = v.funcNames;
var source = resolver.resolve(key);
if (source == null) {
continue;
@@ -48,12 +52,21 @@
}
buf.write('SF:$source\n');
- final lines = v.keys.toList()..sort();
- for (var k in lines) {
- buf.write('DA:$k,${v[k]}\n');
+ for (var k in funcNames.keys.toList()..sort()) {
+ buf.write('FN:$k,${funcNames[k]}\n');
}
- buf.write('LF:${lines.length}\n');
- buf.write('LH:${lines.where((k) => v[k]! > 0).length}\n');
+ for (var k in funcHits.keys.toList()..sort()) {
+ if (funcHits[k]! != 0) {
+ buf.write('FNDA:${funcHits[k]},${funcNames[k]}\n');
+ }
+ }
+ buf.write('FNF:${funcNames.length}\n');
+ buf.write('FNH:${funcHits.values.where((v) => v > 0).length}\n');
+ for (var k in lineHits.keys.toList()..sort()) {
+ buf.write('DA:$k,${lineHits[k]}\n');
+ }
+ buf.write('LF:${lineHits.length}\n');
+ buf.write('LH:${lineHits.values.where((v) => v > 0).length}\n');
buf.write('end_of_record\n');
}
@@ -71,18 +84,21 @@
///
/// If [reportOn] is provided, coverage report output is limited to files
/// prefixed with one of the paths included.
- PrettyPrintFormatter(this.resolver, this.loader, {this.reportOn});
+ PrettyPrintFormatter(this.resolver, this.loader,
+ {this.reportOn, this.reportFuncs = false});
final Resolver resolver;
final Loader loader;
final List<String>? reportOn;
+ final bool reportFuncs;
@override
- Future<String> format(Map<String, dynamic> hitmap) async {
+ Future<String> format(Map<String, HitMap> hitmap) async {
final pathFilter = _getPathFilter(reportOn);
final buf = StringBuffer();
for (var key in hitmap.keys) {
- final v = hitmap[key] as Map<int, int>;
+ final v = hitmap[key]!;
+ final hits = reportFuncs ? v.funcHits : v.lineHits;
final source = resolver.resolve(key);
if (source == null) {
continue;
@@ -99,8 +115,8 @@
buf.writeln(source);
for (var line = 1; line <= lines.length; line++) {
var prefix = _prefix;
- if (v.containsKey(line)) {
- prefix = v[line].toString().padLeft(_prefix.length);
+ if (hits.containsKey(line)) {
+ prefix = hits[line].toString().padLeft(_prefix.length);
}
buf.writeln('$prefix|${lines[line - 1]}');
}
diff --git a/lib/src/hitmap.dart b/lib/src/hitmap.dart
index fa32a27..2b048ed 100644
--- a/lib/src/hitmap.dart
+++ b/lib/src/hitmap.dart
@@ -8,11 +8,23 @@
import 'package:coverage/src/resolver.dart';
import 'package:coverage/src/util.dart';
+/// Contains line and function hit information for a single script.
+class HitMap {
+ /// Map from line to hit count for that line.
+ final lineHits = <int, int>{};
+
+ /// Map from function definition line to hit count for that function.
+ final funcHits = <int, int>{};
+
+ /// Map from function definition line to function name.
+ final funcNames = <int, String>{};
+}
+
/// Creates a single hitmap from a raw json object. Throws away all entries that
/// are not resolvable.
///
/// `jsonResult` is expected to be a List<Map<String, dynamic>>.
-Future<Map<String, Map<int, int>>> createHitmap(
+Future<Map<String, HitMap>> createHitmap(
List<Map<String, dynamic>> jsonResult, {
bool checkIgnoredLines = false,
String? packagesPath,
@@ -21,12 +33,7 @@
final loader = Loader();
// Map of source file to map of line to hit count for that line.
- final globalHitMap = <String, Map<int, int>>{};
-
- void addToMap(Map<int, int> map, int line, int count) {
- final oldCount = map.putIfAbsent(line, () => 0);
- map[line] = count + oldCount;
- }
+ final globalHitMap = <String, HitMap>{};
for (var e in jsonResult) {
final source = e['source'] as String?;
@@ -79,49 +86,74 @@
return false;
}
- final sourceHitMap = globalHitMap.putIfAbsent(source, () => <int, int>{});
- final hits = e['hits'] as List;
- // 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;
+ void addToMap(Map<int, int> map, int line, int count) {
+ final oldCount = map.putIfAbsent(line, () => 0);
+ map[line] = count + oldCount;
+ }
- addToMap(sourceHitMap, 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;
+ void fillHitMap(List hits, Map<int, int> hitMap) {
+ // 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(sourceHitMap, j, hits[i + 1] as int);
+ 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');
}
- } else {
- throw StateError('Expected value of type int or String');
}
}
+
+ final sourceHitMap = globalHitMap.putIfAbsent(source, () => HitMap());
+ fillHitMap(e['hits'] as List, sourceHitMap.lineHits);
+ fillHitMap(e['funcHits'] as List, sourceHitMap.funcHits);
+ final funcNames = e['funcNames'] as List;
+ for (var i = 0; i < funcNames.length; i += 2) {
+ sourceHitMap.funcNames[funcNames[i]] = funcNames[i + 1];
+ }
}
return globalHitMap;
}
/// Merges [newMap] into [result].
-void mergeHitmaps(
- Map<String, Map<int, int>> newMap, Map<String, Map<int, int>> result) {
- newMap.forEach((String file, Map<int, int> v) {
+void mergeHitmaps(Map<String, HitMap> newMap, Map<String, HitMap> result) {
+ newMap.forEach((String file, HitMap v) {
final fileResult = result[file];
if (fileResult != null) {
- v.forEach((int line, int cnt) {
- final lineFileResult = fileResult[line];
- if (lineFileResult == null) {
- fileResult[line] = cnt;
+ void mergeHitCounts(Map<int, int> src, Map<int, int> dest) {
+ src.forEach((int line, int cnt) {
+ final lineFileResult = dest[line];
+ if (lineFileResult == null) {
+ dest[line] = cnt;
+ } else {
+ dest[line] = lineFileResult + cnt;
+ }
+ });
+ }
+
+ mergeHitCounts(v.lineHits, fileResult.lineHits);
+ mergeHitCounts(v.funcHits, fileResult.funcHits);
+ final destFuncNames = fileResult.funcNames;
+ v.funcNames.forEach((int line, String name) {
+ if (destFuncNames.containsKey(line) && destFuncNames[line] != name) {
+ print('Multiple functions defined on line $line of script $file');
} else {
- fileResult[line] = lineFileResult + cnt;
+ destFuncNames[line] = name;
}
});
} else {
@@ -131,12 +163,12 @@
}
/// Generates a merged hitmap from a set of coverage JSON files.
-Future<Map<String, Map<int, int>>> parseCoverage(
+Future<Map<String, HitMap>> parseCoverage(
Iterable<File> files,
int _, {
bool checkIgnoredLines = false,
}) async {
- final globalHitmap = <String, Map<int, int>>{};
+ final globalHitmap = <String, HitMap>{};
for (var file in files) {
final contents = file.readAsStringSync();
final jsonMap = json.decode(contents) as Map<String, dynamic>;
@@ -153,3 +185,30 @@
}
return globalHitmap;
}
+
+/// Returns a JSON hit map backward-compatible with pre-1.16.0 SDKs.
+Map<String, dynamic> toScriptCoverageJson(Uri scriptUri, HitMap hits) {
+ final json = <String, dynamic>{};
+ List<T> flattenMap<T>(Map map) {
+ final kvs = <T>[];
+ map.forEach((k, v) {
+ kvs.add(k);
+ kvs.add(v);
+ });
+ return kvs;
+ }
+
+ ;
+ json['source'] = '$scriptUri';
+ json['script'] = {
+ 'type': '@Script',
+ 'fixedId': true,
+ 'id': 'libraries/1/scripts/${Uri.encodeComponent(scriptUri.toString())}',
+ 'uri': '$scriptUri',
+ '_kind': 'library',
+ };
+ json['hits'] = flattenMap<int>(hits.lineHits);
+ json['funcHits'] = flattenMap<int>(hits.funcHits);
+ json['funcNames'] = flattenMap<dynamic>(hits.funcNames);
+ return json;
+}
diff --git a/lib/src/util.dart b/lib/src/util.dart
index 45b2b9c..14ba7d5 100644
--- a/lib/src/util.dart
+++ b/lib/src/util.dart
@@ -73,26 +73,6 @@
}
}
-/// Returns a JSON hit map backward-compatible with pre-1.16.0 SDKs.
-Map<String, dynamic> toScriptCoverageJson(Uri scriptUri, Map<int, int> hitMap) {
- final json = <String, dynamic>{};
- final hits = <int>[];
- hitMap.forEach((line, hitCount) {
- hits.add(line);
- hits.add(hitCount);
- });
- json['source'] = '$scriptUri';
- json['script'] = {
- 'type': '@Script',
- 'fixedId': true,
- 'id': 'libraries/1/scripts/${Uri.encodeComponent(scriptUri.toString())}',
- 'uri': '$scriptUri',
- '_kind': 'library',
- };
- json['hits'] = hits;
- return json;
-}
-
/// Generates a hash code for two objects.
int hash2(dynamic a, dynamic b) =>
_finish(_combine(_combine(0, a.hashCode), b.hashCode));
diff --git a/test/collect_coverage_test.dart b/test/collect_coverage_test.dart
index 0407389..6dd8ea1 100644
--- a/test/collect_coverage_test.dart
+++ b/test/collect_coverage_test.dart
@@ -60,7 +60,7 @@
expect(hitMap, contains(_sampleAppFileUri));
final isolateFile = hitMap[_isolateLibFileUri];
- final expectedHits = {
+ final expectedLineHits = {
12: 1,
13: 1,
15: 0,
@@ -80,16 +80,17 @@
// Dart VMs prior to 2.0.0-dev.5.0 contain a bug that emits coverage on the
// closing brace of async function blocks.
// See: https://github.com/dart-lang/coverage/issues/196
- expectedHits[23] = 0;
+ expectedLineHits[23] = 0;
} else {
// Dart VMs version 2.0.0-dev.6.0 mark the opening brace of a function as
// coverable.
- expectedHits[11] = 1;
- expectedHits[18] = 1;
- expectedHits[28] = 1;
- expectedHits[32] = 3;
+ expectedLineHits[11] = 1;
+ expectedLineHits[18] = 1;
+ expectedLineHits[28] = 1;
+ expectedLineHits[32] = 3;
}
- expect(isolateFile, expectedHits);
+ expect(isolateFile?.lineHits, expectedLineHits);
+ expect(isolateFile?.funcHits, {11: 1, 18: 1, 28: 1, 32: 1});
});
test('parseCoverage', () async {
diff --git a/test/lcov_test.dart b/test/lcov_test.dart
index 1399820..c9ace53 100644
--- a/test/lcov_test.dart
+++ b/test/lcov_test.dart
@@ -145,7 +145,7 @@
});
}
-Future<Map<String, Map<int, int>>> _getHitMap() async {
+Future<Map<String, HitMap>> _getHitMap() async {
expect(FileSystemEntity.isFileSync(_sampleAppPath), isTrue);
// select service port.