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.