Merge pull request #348 from dart-lang/fncov

Function coverage
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 947de22..cd6880b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,20 @@
+## 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 `--function-coverage` (abbr `-f`) to collect_coverage that collects
+  function coverage information.
+* 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). `collect` also has a new
+  optional bool flag controlling whether function coverage is collected.
+
 ## 1.0.4 - 2021-06-08
 
 * Ensure `createHitmap` returns a sorted hitmap. This fixes a potential issue with
@@ -9,7 +26,7 @@
 
 ## 1.0.2 - 2021-03-15
 
-* Fix an issue where the `--packages` argument wasn't passed to `format_coverage`. 
+* Fix an issue where the `--packages` argument wasn't passed to `format_coverage`.
 
 ## 1.0.1 - 2021-02-25
 
diff --git a/bin/collect_coverage.dart b/bin/collect_coverage.dart
index ccead74..50cf424 100644
--- a/bin/collect_coverage.dart
+++ b/bin/collect_coverage.dart
@@ -21,7 +21,7 @@
   await Chain.capture(() async {
     final coverage = await collect(options.serviceUri, options.resume,
         options.waitPaused, options.includeDart, options.scopedOutput,
-        timeout: options.timeout);
+        timeout: options.timeout, functionCoverage: options.functionCoverage);
     options.out.write(json.encode(coverage));
     await options.out.close();
   }, onError: (dynamic error, Chain chain) {
@@ -35,7 +35,7 @@
 
 class Options {
   Options(this.serviceUri, this.out, this.timeout, this.waitPaused, this.resume,
-      this.includeDart, this.scopedOutput);
+      this.includeDart, this.functionCoverage, this.scopedOutput);
 
   final Uri serviceUri;
   final IOSink out;
@@ -43,6 +43,7 @@
   final bool waitPaused;
   final bool resume;
   final bool includeDart;
+  final bool functionCoverage;
   final Set<String> scopedOutput;
 }
 
@@ -72,6 +73,8 @@
         abbr: 'r', defaultsTo: false, help: 'resume all isolates on exit')
     ..addFlag('include-dart',
         abbr: 'd', defaultsTo: false, help: 'include "dart:" libraries')
+    ..addFlag('function-coverage',
+        abbr: 'f', defaultsTo: false, help: 'Collect function coverage info')
     ..addFlag('help', abbr: 'h', negatable: false, help: 'show this help');
 
   final args = parser.parse(arguments);
@@ -123,6 +126,7 @@
     args['wait-paused'] as bool,
     args['resume-isolates'] as bool,
     args['include-dart'] as bool,
+    args['function-coverage'] as bool,
     scopedOutput.toSet(),
   );
 }
diff --git a/bin/format_coverage.dart b/bin/format_coverage.dart
index 2f32875..0ca57fb 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;
@@ -73,10 +75,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,
@@ -131,7 +133,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,
@@ -212,12 +218,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 {
@@ -238,6 +246,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..7c481b0 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);
 
@@ -29,6 +30,9 @@
 /// If [includeDart] is true, code coverage for core `dart:*` libraries will be
 /// collected.
 ///
+/// If [functionCoverage] is true, function coverage information will be
+/// collected.
+///
 /// If [scopedOutput] is non-empty, coverage will be restricted so that only
 /// scripts that start with any of the provided paths are considered.
 ///
@@ -36,7 +40,9 @@
 /// those VM isolates.
 Future<Map<String, dynamic>> collect(Uri serviceUri, bool resume,
     bool waitPaused, bool includeDart, Set<String>? scopedOutput,
-    {Set<String>? isolateIds, Duration? timeout}) async {
+    {Set<String>? isolateIds,
+    Duration? timeout,
+    bool functionCoverage = false}) async {
   scopedOutput ??= <String>{};
 
   // Create websocket URI. Handle any trailing slashes.
@@ -71,7 +77,7 @@
     }
 
     return await _getAllCoverage(
-        service, includeDart, scopedOutput, isolateIds);
+        service, includeDart, functionCoverage, scopedOutput, isolateIds);
   } finally {
     if (resume) {
       await _resumeIsolates(service);
@@ -85,6 +91,7 @@
 Future<Map<String, dynamic>> _getAllCoverage(
     VmService service,
     bool includeDart,
+    bool functionCoverage,
     Set<String>? scopedOutput,
     Set<String>? isolateIds) async {
   scopedOutput ??= <String>{};
@@ -105,7 +112,7 @@
             isolateRef.id!, <String>[SourceReportKind.kCoverage],
             forceCompile: true, scriptId: script.id);
         final coverage = await _getCoverageJson(
-            service, isolateRef, scriptReport, includeDart);
+            service, isolateRef, scriptReport, includeDart, functionCoverage);
         allCoverage.addAll(coverage);
       }
     } else {
@@ -115,7 +122,7 @@
         forceCompile: true,
       );
       final coverage = await _getCoverageJson(
-          service, isolateRef, isolateReport, includeDart);
+          service, isolateRef, isolateReport, includeDart, functionCoverage);
       allCoverage.addAll(coverage);
     }
   }
@@ -190,12 +197,33 @@
   return null;
 }
 
+Future<void> _processFunction(VmService service, IsolateRef isolateRef,
+    Script script, FuncRef funcRef, HitMap hits) async {
+  final func = await service.getObject(isolateRef.id!, funcRef.id!) as Func;
+  final location = func.location;
+  if (location != null) {
+    final funcName = await _getFuncName(service, isolateRef, func);
+    final tokenPos = location.tokenPos!;
+    final line = _getLineFromTokenPos(script, tokenPos);
+
+    if (line == null) {
+      print('tokenPos $tokenPos has no line mapping for script ${script.uri!}');
+      return;
+    }
+    hits.funcNames![line] = funcName;
+  }
+}
+
 /// Returns a JSON coverage list backward-compatible with pre-1.16.0 SDKs.
-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>>{};
+Future<List<Map<String, dynamic>>> _getCoverageJson(
+    VmService service,
+    IsolateRef isolateRef,
+    SourceReport report,
+    bool includeDart,
+    bool functionCoverage) async {
+  final hitMaps = <Uri, HitMap>{};
   final scripts = <ScriptRef, Script>{};
+  final libraries = <LibraryRef>{};
   for (var range in report.ranges!) {
     final scriptRef = report.scripts![range.scriptIndex!];
     final scriptUri = Uri.parse(report.scripts![range.scriptIndex!].uri!);
@@ -213,8 +241,35 @@
     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 (functionCoverage && libRef != null && !libraries.contains(libRef)) {
+      hits.funcHits ??= <int, int>{};
+      hits.funcNames ??= <int, String>{};
+      libraries.add(libRef);
+      final library =
+          await service.getObject(isolateRef.id!, libRef.id!) as Library;
+      if (library.functions != null) {
+        for (var funcRef in library.functions!) {
+          await _processFunction(service, isolateRef, script, funcRef, hits);
+        }
+      }
+      if (library.classes != null) {
+        for (var classRef in library.classes!) {
+          final clazz =
+              await service.getObject(isolateRef.id!, classRef.id!) as Class;
+          if (clazz.functions != null) {
+            for (var funcRef in clazz.functions!) {
+              await _processFunction(
+                  service, isolateRef, script, funcRef, hits);
+            }
+          }
+        }
+      }
+    }
 
     // Collect hits and misses.
     final coverage = range.coverage;
@@ -227,7 +282,10 @@
         print('tokenPos $tokenPos has no line mapping for script $scriptUri');
         continue;
       }
-      hitMap[line] = hitMap.containsKey(line) ? hitMap[line]! + 1 : 1;
+      _incrementCountForKey(hits.lineHits, line);
+      if (hits.funcNames != null && hits.funcNames!.containsKey(line)) {
+        _incrementCountForKey(hits.funcHits!, line);
+      }
     }
     for (final tokenPos in coverage.misses!) {
       final line = _getLineFromTokenPos(script, tokenPos);
@@ -235,18 +293,38 @@
         print('tokenPos $tokenPos has no line mapping for script $scriptUri');
         continue;
       }
-      hitMap.putIfAbsent(line, () => 0);
+      hits.lineHits.putIfAbsent(line, () => 0);
     }
+    hits.funcNames?.forEach((line, funcName) {
+      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 _incrementCountForKey(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}';
+  }
+  final owner = func.owner;
+  if (owner is ClassRef) {
+    final cls = await service.getObject(isolateRef.id!, 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..50e0f67 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,23 @@
       }
 
       buf.write('SF:$source\n');
-      final lines = v.keys.toList()..sort();
-      for (var k in lines) {
-        buf.write('DA:$k,${v[k]}\n');
+      if (funcHits != null && funcNames != null) {
+        for (var k in funcNames.keys.toList()..sort()) {
+          buf.write('FN:$k,${funcNames[k]}\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');
       }
-      buf.write('LF:${lines.length}\n');
-      buf.write('LH:${lines.where((k) => v[k]! > 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 +86,26 @@
   ///
   /// 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]!;
+      if (reportFuncs && v.funcHits == null) {
+        throw 'Function coverage formatting was requested, but the hit map is '
+            'missing function coverage information. Did you run '
+            'collect_coverage with the --function-coverage flag?';
+      }
+      final hits = reportFuncs ? v.funcHits! : v.lineHits;
       final source = resolver.resolve(key);
       if (source == null) {
         continue;
@@ -99,8 +122,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 3cf6a32..7041e36 100644
--- a/lib/src/hitmap.dart
+++ b/lib/src/hitmap.dart
@@ -8,6 +8,20 @@
 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 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;
+}
+
 /// Class containing information about a coverage hit.
 class _HitInfo {
   _HitInfo(this.firstLine, this.hitRange, this.hitCount);
@@ -27,7 +41,7 @@
 /// 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,
@@ -36,12 +50,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?;
@@ -94,33 +103,53 @@
       return false;
     }
 
-    final sourceHitMap = globalHitMap.putIfAbsent(source, () => <int, int>{});
-    var hits = e['hits'] as List;
-    // 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;
+    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) {
+      // 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(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);
+    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;
       }
     }
   }
@@ -128,19 +157,32 @@
 }
 
 /// 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;
-        } else {
-          fileResult[line] = lineFileResult + 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);
+      if (v.funcHits != null) {
+        fileResult.funcHits ??= <int, int>{};
+        mergeHitCounts(v.funcHits!, fileResult.funcHits!);
+      }
+      if (v.funcNames != null) {
+        fileResult.funcNames ??= <int, String>{};
+        v.funcNames?.forEach((int line, String name) {
+          fileResult.funcNames![line] = name;
+        });
+      }
     } else {
       result[file] = v;
     }
@@ -148,13 +190,13 @@
 }
 
 /// 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,
   String? packagesPath,
 }) 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>;
@@ -173,6 +215,36 @@
   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 as T);
+      kvs.add(v as T);
+    });
+    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);
+  if (hits.funcHits != null) {
+    json['funcHits'] = flattenMap<int>(hits.funcHits!);
+  }
+  if (hits.funcNames != null) {
+    json['funcNames'] = flattenMap<dynamic>(hits.funcNames!);
+  }
+  return json;
+}
+
 /// Sorts the hits array based on the line numbers.
 List _sortHits(List hits) {
   final structuredHits = <_HitInfo>[];
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_api_test.dart b/test/collect_coverage_api_test.dart
index 6bb6ce1..5f9d1a3 100644
--- a/test/collect_coverage_api_test.dart
+++ b/test/collect_coverage_api_test.dart
@@ -81,7 +81,7 @@
         _getScriptCoverage(coverage, 'test_app_isolate.dart')!;
     hits = isolateCoverage['hits'] as List<int>;
     _expectHitCount(hits, 11, 1);
-    _expectHitCount(hits, 18, 1);
+    _expectHitCount(hits, 28, 1);
   });
 }
 
@@ -115,7 +115,7 @@
   final isolateIdSet = isolateIds ? {isolateId} : null;
 
   return collect(serviceUri, true, true, false, scopedOutput,
-      timeout: timeout, isolateIds: isolateIdSet);
+      timeout: timeout, isolateIds: isolateIdSet, functionCoverage: true);
 }
 
 // Returns the first coverage hitmap for the script with with the specified
diff --git a/test/collect_coverage_test.dart b/test/collect_coverage_test.dart
index b3a26a0..bc39bef 100644
--- a/test/collect_coverage_test.dart
+++ b/test/collect_coverage_test.dart
@@ -77,7 +77,7 @@
       coverage.cast<Map<String, dynamic>>(),
     );
     final expectedHits = {15: 1, 16: 2, 17: 2, 45: 1, 46: 1, 49: 0, 50: 0};
-    expect(hitMap['foo'], expectedHits);
+    expect(hitMap['foo']?.lineHits, expectedHits);
   });
 
   test('createHitmap', () async {
@@ -95,29 +95,34 @@
       12: 1,
       13: 1,
       15: 0,
+      19: 1,
+      23: 1,
+      24: 2,
       28: 1,
       29: 1,
-      31: 1,
-      32: 3,
+      30: 1,
+      32: 0,
       38: 1,
+      39: 1,
       41: 1,
-      42: 1,
+      42: 3,
       43: 1,
-      46: 1,
-      47: 1,
+      44: 3,
+      45: 1,
+      48: 1,
       49: 1,
-      50: 1,
       51: 1,
-      53: 1,
       54: 1,
       55: 1,
-      18: 1,
-      19: 1,
-      20: 1,
-      22: 0,
-      33: 1,
-      34: 3,
-      35: 1
+      56: 1,
+      59: 1,
+      60: 1,
+      62: 1,
+      63: 1,
+      64: 1,
+      66: 1,
+      67: 1,
+      68: 1
     };
     if (Platform.version.startsWith('1.')) {
       // Dart VMs prior to 2.0.0-dev.5.0 contain a bug that emits coverage on the
@@ -128,11 +133,20 @@
       // 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;
+      expectedHits[38] = 1;
+      expectedHits[42] = 3;
     }
-    expect(isolateFile, expectedHits);
+    expect(isolateFile?.lineHits, expectedHits);
+    expect(isolateFile?.funcHits, {11: 1, 19: 1, 21: 0, 23: 1, 28: 1, 38: 1});
+    expect(isolateFile?.funcNames, {
+      11: 'fooSync',
+      19: 'BarClass.BarClass',
+      21: 'BarClass.x=',
+      23: 'BarClass.baz',
+      28: 'fooAsync',
+      38: 'isolateTask'
+    });
   });
 
   test('parseCoverage', () async {
@@ -206,6 +220,7 @@
   // TODO: need to get all of this functionality in the lib
   final toolResult = await Process.run('dart', [
     _collectAppPath,
+    '--function-coverage',
     '--uri',
     '$serviceUri',
     '--resume-isolates',
diff --git a/test/lcov_test.dart b/test/lcov_test.dart
index 6d28aac..1da9bfd 100644
--- a/test/lcov_test.dart
+++ b/test/lcov_test.dart
@@ -26,12 +26,21 @@
     expect(hitmap, contains('package:coverage/src/util.dart'));
 
     final sampleAppHitMap = hitmap[_sampleAppFileUri];
+    final sampleAppHitLines = sampleAppHitMap?.lineHits;
+    final sampleAppHitFuncs = sampleAppHitMap?.funcHits;
+    final sampleAppFuncNames = sampleAppHitMap?.funcNames;
 
-    expect(sampleAppHitMap, containsPair(46, greaterThanOrEqualTo(1)),
+    expect(sampleAppHitLines, containsPair(46, greaterThanOrEqualTo(1)),
         reason: 'be careful if you modify the test file');
-    expect(sampleAppHitMap, containsPair(50, 0),
+    expect(sampleAppHitLines, containsPair(50, 0),
         reason: 'be careful if you modify the test file');
-    expect(sampleAppHitMap, isNot(contains(32)),
+    expect(sampleAppHitLines, isNot(contains(32)),
+        reason: 'be careful if you modify the test file');
+    expect(sampleAppHitFuncs, containsPair(45, 1),
+        reason: 'be careful if you modify the test file');
+    expect(sampleAppHitFuncs, containsPair(49, 0),
+        reason: 'be careful if you modify the test file');
+    expect(sampleAppFuncNames, containsPair(45, 'usedMethod'),
         reason: 'be careful if you modify the test file');
   });
 
@@ -142,10 +151,30 @@
       expect(res, isNot(contains(p.absolute(_isolateLibPath))));
       expect(res, contains(p.absolute(p.join('lib', 'src', 'util.dart'))));
     });
+
+    test('format() functions', () async {
+      final hitmap = await _getHitMap();
+
+      final resolver = Resolver(packagesPath: '.packages');
+      final formatter =
+          PrettyPrintFormatter(resolver, Loader(), reportFuncs: true);
+
+      final res = await formatter.format(hitmap);
+
+      expect(res, contains(p.absolute(_sampleAppPath)));
+      expect(res, contains(p.absolute(_isolateLibPath)));
+      expect(res, contains(p.absolute(p.join('lib', 'src', 'util.dart'))));
+
+      // be very careful if you change the test file
+      expect(res, contains('      1|Future<Null> main() async {'));
+      expect(res, contains('      1|int usedMethod(int a, int b) {'));
+      expect(res, contains('      0|int unusedMethod(int a, int b) {'));
+      expect(res, contains('       |  return a + b;'));
+    });
   });
 }
 
-Future<Map<String, Map<int, int>>> _getHitMap() async {
+Future<Map<String, HitMap>> _getHitMap() async {
   expect(FileSystemEntity.isFileSync(_sampleAppPath), isTrue);
 
   // select service port.
@@ -175,9 +204,8 @@
   final serviceUri = await serviceUriCompleter.future;
 
   // collect hit map.
-  final coverageJson =
-      (await collect(serviceUri, true, true, false, <String>{}))['coverage']
-          as List<Map<String, dynamic>>;
+  final coverageJson = (await collect(serviceUri, true, true, false, <String>{},
+      functionCoverage: true))['coverage'] as List<Map<String, dynamic>>;
   final hitMap = createHitmap(coverageJson);
 
   // wait for sample app to terminate.
diff --git a/test/run_and_collect_test.dart b/test/run_and_collect_test.dart
index 99b0e92..4095a13 100644
--- a/test/run_and_collect_test.dart
+++ b/test/run_and_collect_test.dart
@@ -41,27 +41,35 @@
     final hitMap = await createHitmap(coverage, checkIgnoredLines: true);
     expect(hitMap, isNot(contains(_sampleAppFileUri)));
 
-    final actualHits = hitMap[_isolateLibFileUri];
-    final expectedHits = {
+    final actualHitMap = hitMap[_isolateLibFileUri];
+    final actualLineHits = actualHitMap?.lineHits;
+    final expectedLineHits = {
       11: 1,
       12: 1,
       13: 1,
       15: 0,
+      19: 1,
+      23: 1,
+      24: 2,
       28: 1,
       29: 1,
-      31: 1,
-      32: 3,
-      46: 1,
-      47: 1,
-      18: 1,
-      19: 1,
-      20: 1,
-      22: 0,
-      33: 1,
-      34: 3,
-      35: 1
+      30: 1,
+      32: 0,
+      38: 1,
+      39: 1,
+      41: 1,
+      42: 3,
+      43: 1,
+      44: 3,
+      45: 1,
+      48: 1,
+      49: 1,
+      59: 1,
+      60: 1
     };
 
-    expect(actualHits, expectedHits);
+    expect(actualLineHits, expectedLineHits);
+    expect(actualHitMap?.funcHits, isNull);
+    expect(actualHitMap?.funcNames, isNull);
   });
 }
diff --git a/test/test_files/test_app_isolate.dart b/test/test_files/test_app_isolate.dart
index eac9c2d..fc8d723 100644
--- a/test/test_files/test_app_isolate.dart
+++ b/test/test_files/test_app_isolate.dart
@@ -15,6 +15,16 @@
   return List.generate(x, (_) => 'xyzzy').join(' ');
 }
 
+class BarClass {
+  BarClass(this.x);
+
+  int x;
+
+  void baz() {
+    print(x);
+  }
+}
+
 Future<String> fooAsync(int x) async {
   if (x == answer) {
     return '*' * x;
@@ -35,6 +45,9 @@
     port.send(sum);
   });
 
+  final bar = BarClass(123);
+  bar.baz();
+
   print('678'); // coverage:ignore-line
 
   // coverage:ignore-start