Change function coverage to be backwards compatible (#356)

There were 5 functions that were changed in a backwards incompatible way in #348: `createHitmap`, `mergeHitmaps`, `parseCoverage`, `toScriptCoverageJson`, and `Formatter.format`.

This change makes #348 backwards compatible by renaming those newer functions, and adding versions of all those functions with the same signature as before. Most of these legacy functions simply wrap the newer versions and convert the `HitMap` back to a `Map<int, int>`.

| Legacy | New |
| --- | --- |
| createHitmap | HitMap.parseJson |
| mergeHitmaps | FileHitMaps.merge |
| parseCoverage | HitMap.parseFiles |
| toScriptCoverageJson | deprecated, no replacement |
| Formatter | deprecated, no replacement |
| PrettyPrintFormatter | FileHitMapsFormatter.prettyPrint |
| LcovFormatter | FileHitMapsFormatter.formatLcov |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94688de..5c984bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,11 +9,15 @@
   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.
+* Add an optional bool flag to `collect` that controls whether function coverage
+  is collected.
+* Added `HitMap.parseJson`, `FileHitMaps.merge`, `HitMap.parseFiles`,
+  `HitMap.toJson`, `FileHitMapsFormatter.formatLcov`, and
+  `FileHitMapsFormatter.prettyPrint` that switch from using `Map<int, int>` to
+  represent line coverage to using `HitMap` (which contains both line and
+  function coverage). Document the old versions of these functions as
+  deprecated. We will delete the old functions when we update to coverage
+  version 2.0.0.
 * Ensure `createHitmap` returns a sorted hitmap. This fixes a potential issue with
   ignore line annotations.
 * Use the `reportLines` flag in `vm_service`'s `getSourceReport` RPC. This
diff --git a/bin/format_coverage.dart b/bin/format_coverage.dart
index 0efaee1..27d2e71 100644
--- a/bin/format_coverage.dart
+++ b/bin/format_coverage.dart
@@ -58,9 +58,8 @@
   }
 
   final clock = Stopwatch()..start();
-  final hitmap = await parseCoverage(
+  final hitmap = await HitMap.parseFiles(
     files,
-    env.workers,
     checkIgnoredLines: env.checkIgnore,
     packagesPath: env.packagesPath,
   );
@@ -76,14 +75,12 @@
       : Resolver(packagesPath: env.packagesPath, sdkRoot: env.sdkRoot);
   final loader = Loader();
   if (env.prettyPrint || env.prettyPrintFunc) {
-    output = await PrettyPrintFormatter(resolver, loader,
-            reportOn: env.reportOn, reportFuncs: env.prettyPrintFunc)
-        .format(hitmap);
+    output = await hitmap.prettyPrint(resolver, loader,
+        reportOn: env.reportOn, reportFuncs: env.prettyPrintFunc);
   } else {
     assert(env.lcov);
-    output = await LcovFormatter(resolver,
-            reportOn: env.reportOn, basePath: env.baseDirectory)
-        .format(hitmap);
+    output = hitmap.formatLcov(resolver,
+        reportOn: env.reportOn, basePath: env.baseDirectory);
   }
 
   env.output.write(output);
diff --git a/lib/coverage.dart b/lib/coverage.dart
index 9ebeaa5..9cc81ab 100644
--- a/lib/coverage.dart
+++ b/lib/coverage.dart
@@ -5,6 +5,6 @@
 export 'src/chrome.dart';
 export 'src/collect.dart';
 export 'src/formatter.dart';
-export 'src/hitmap.dart';
+export 'src/hitmap.dart' hide hitmapToJson;
 export 'src/resolver.dart';
 export 'src/run_and_collect.dart';
diff --git a/lib/src/chrome.dart b/lib/src/chrome.dart
index 770e287..8d32fa9 100644
--- a/lib/src/chrome.dart
+++ b/lib/src/chrome.dart
@@ -81,7 +81,7 @@
 
   final allCoverage = <Map<String, dynamic>>[];
   coverageHitMaps.forEach((uri, hitMap) {
-    allCoverage.add(toScriptCoverageJson(uri, hitMap));
+    allCoverage.add(hitmapToJson(hitMap, uri));
   });
   return <String, dynamic>{'type': 'CodeCoverage', 'coverage': allCoverage};
 }
diff --git a/lib/src/collect.dart b/lib/src/collect.dart
index 0c26822..5cd6695 100644
--- a/lib/src/collect.dart
+++ b/lib/src/collect.dart
@@ -333,7 +333,7 @@
   // Output JSON
   final coverage = <Map<String, dynamic>>[];
   hitMaps.forEach((uri, hits) {
-    coverage.add(toScriptCoverageJson(uri, hits));
+    coverage.add(hitmapToJson(hits, uri));
   });
   return coverage;
 }
diff --git a/lib/src/formatter.dart b/lib/src/formatter.dart
index 50e0f67..b8d0be0 100644
--- a/lib/src/formatter.dart
+++ b/lib/src/formatter.dart
@@ -7,9 +7,10 @@
 import 'resolver.dart';
 import 'hitmap.dart';
 
+@Deprecated('Migrate to FileHitMapsFormatter')
 abstract class Formatter {
   /// Returns the formatted coverage data.
-  Future<String> format(Map<String, HitMap> hitmap);
+  Future<String> format(Map<String, Map<int, int>> hitmap);
 }
 
 /// Converts the given hitmap to lcov format and appends the result to
@@ -17,6 +18,7 @@
 ///
 /// Returns a [Future] that completes as soon as all map entries have been
 /// emitted.
+@Deprecated('Migrate to FileHitMapsFormatter.formatLcov')
 class LcovFormatter implements Formatter {
   /// Creates a LCOV formatter.
   ///
@@ -30,49 +32,10 @@
   final List<String>? reportOn;
 
   @override
-  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;
-      }
-
-      if (!pathFilter(source)) {
-        continue;
-      }
-
-      if (basePath != null) {
-        source = p.relative(source, from: basePath);
-      }
-
-      buf.write('SF:$source\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');
-      }
-      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');
-    }
-
-    return buf.toString();
+  Future<String> format(Map<String, Map<int, int>> hitmap) {
+    return Future.value(hitmap
+        .map((key, value) => MapEntry(key, HitMap(value)))
+        .formatLcov(resolver, basePath: basePath, reportOn: reportOn));
   }
 }
 
@@ -81,6 +44,7 @@
 ///
 /// Returns a [Future] that completes as soon as all map entries have been
 /// emitted.
+@Deprecated('Migrate to FileHitMapsFormatter.prettyPrint')
 class PrettyPrintFormatter implements Formatter {
   /// Creates a pretty-print formatter.
   ///
@@ -95,18 +59,89 @@
   final bool reportFuncs;
 
   @override
-  Future<String> format(Map<String, HitMap> hitmap) async {
+  Future<String> format(Map<String, Map<int, int>> hitmap) {
+    return hitmap.map((key, value) => MapEntry(key, HitMap(value))).prettyPrint(
+        resolver, loader,
+        reportOn: reportOn, reportFuncs: reportFuncs);
+  }
+}
+
+extension FileHitMapsFormatter on Map<String, HitMap> {
+  /// Converts the given hitmap to lcov format.
+  ///
+  /// If [reportOn] is provided, coverage report output is limited to files
+  /// prefixed with one of the paths included. If [basePath] is provided, paths
+  /// are reported relative to that path.
+  String formatLcov(
+    Resolver resolver, {
+    String? basePath,
+    List<String>? reportOn,
+  }) {
     final pathFilter = _getPathFilter(reportOn);
     final buf = StringBuffer();
-    for (var key in hitmap.keys) {
-      final v = hitmap[key]!;
+    for (final entry in entries) {
+      final v = entry.value;
+      final lineHits = v.lineHits;
+      final funcHits = v.funcHits;
+      final funcNames = v.funcNames;
+      var source = resolver.resolve(entry.key);
+      if (source == null) {
+        continue;
+      }
+
+      if (!pathFilter(source)) {
+        continue;
+      }
+
+      if (basePath != null) {
+        source = p.relative(source, from: basePath);
+      }
+
+      buf.write('SF:$source\n');
+      if (funcHits != null && funcNames != null) {
+        for (final k in funcNames.keys.toList()..sort()) {
+          buf.write('FN:$k,${funcNames[k]}\n');
+        }
+        for (final 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 (final 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');
+    }
+
+    return buf.toString();
+  }
+
+  /// Converts the given hitmap to a pretty-print format.
+  ///
+  /// If [reportOn] is provided, coverage report output is limited to files
+  /// prefixed with one of the paths included.
+  Future<String> prettyPrint(
+    Resolver resolver,
+    Loader loader, {
+    List<String>? reportOn,
+    bool reportFuncs = false,
+  }) async {
+    final pathFilter = _getPathFilter(reportOn);
+    final buf = StringBuffer();
+    for (final entry in entries) {
+      final v = entry.value;
       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);
+      final source = resolver.resolve(entry.key);
       if (source == null) {
         continue;
       }
diff --git a/lib/src/hitmap.dart b/lib/src/hitmap.dart
index 7041e36..c748b2b 100644
--- a/lib/src/hitmap.dart
+++ b/lib/src/hitmap.dart
@@ -10,8 +10,12 @@
 
 /// Contains line and function hit information for a single script.
 class HitMap {
+  /// Constructs a HitMap.
+  HitMap([Map<int, int>? lineHits, this.funcHits, this.funcNames])
+      : lineHits = lineHits ?? {};
+
   /// Map from line to hit count for that line.
-  final lineHits = <int, int>{};
+  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.
@@ -20,6 +24,181 @@
   /// Map from the first line of each function, to the function name. Null if
   /// function coverage info was not gathered.
   Map<int, String>? funcNames;
+
+  /// 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,
+    String? packagesPath,
+  }) async {
+    final resolver = Resolver(packagesPath: packagesPath);
+    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) {
+        final path = resolver.resolve(source);
+        if (path != null) {
+          final lines = await loader.load(path);
+          ignoredLinesList = getIgnoredLines(lines!);
+
+          // Ignore the whole file.
+          if (ignoredLinesList.length == 1 &&
+              ignoredLinesList[0][0] == 0 &&
+              ignoredLinesList[0][1] == lines.length) {
+            continue;
+          }
+        }
+      }
+
+      // 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;
+        }
+      }
+    }
+    return globalHitMap;
+  }
+
+  /// Generates a merged hitmap from a set of coverage JSON files.
+  static Future<Map<String, HitMap>> parseFiles(
+    Iterable<File> files, {
+    bool checkIgnoredLines = false,
+    String? packagesPath,
+  }) 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,
+          packagesPath: packagesPath,
+        ));
+      }
+    }
+    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;
+          });
+        }
+      } 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.
@@ -37,152 +216,38 @@
   final int hitCount;
 }
 
-/// Creates a single hitmap from a raw json object. Throws away all entries that
-/// are not resolvable.
+/// Creates a single hitmap from a raw json object.
 ///
-/// `jsonResult` is expected to be a List<Map<String, dynamic>>.
-Future<Map<String, HitMap>> createHitmap(
+/// 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,
   String? packagesPath,
 }) async {
-  final resolver = Resolver(packagesPath: packagesPath);
-  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) {
-      final path = resolver.resolve(source);
-      if (path != null) {
-        final lines = await loader.load(path);
-        ignoredLinesList = getIgnoredLines(lines!);
-
-        // Ignore the whole file.
-        if (ignoredLinesList.length == 1 &&
-            ignoredLinesList[0][0] == 0 &&
-            ignoredLinesList[0][1] == lines.length) {
-          continue;
-        }
-      }
-    }
-
-    // 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;
-      }
-    }
-  }
-  return globalHitMap;
+  final result = await HitMap.parseJson(
+    jsonResult,
+    checkIgnoredLines: checkIgnoredLines,
+    packagesPath: packagesPath,
+  );
+  return result.map((key, value) => MapEntry(key, value.lineHits));
 }
 
 /// Merges [newMap] into [result].
-void mergeHitmaps(Map<String, HitMap> newMap, Map<String, HitMap> result) {
-  newMap.forEach((String file, HitMap v) {
+@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) {
-      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;
-        });
-      }
+      v.forEach((line, count) {
+        final lineFileResult = fileResult[line];
+        if (lineFileResult == null) {
+          fileResult[line] = count;
+        } else {
+          fileResult[line] = lineFileResult + count;
+        }
+      });
     } else {
       result[file] = v;
     }
@@ -190,43 +255,36 @@
 }
 
 /// Generates a merged hitmap from a set of coverage JSON files.
-Future<Map<String, HitMap>> parseCoverage(
+@Deprecated('Migrate to HitMap.parseFiles')
+Future<Map<String, Map<int, int>>> parseCoverage(
   Iterable<File> files,
   int _, {
   bool checkIgnoredLines = false,
   String? packagesPath,
 }) 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;
-      mergeHitmaps(
-        await createHitmap(
-          jsonResult.cast<Map<String, dynamic>>(),
-          checkIgnoredLines: checkIgnoredLines,
-          packagesPath: packagesPath,
-        ),
-        globalHitmap,
-      );
-    }
-  }
-  return globalHitmap;
+  final result = await HitMap.parseFiles(files,
+      checkIgnoredLines: checkIgnoredLines, packagesPath: packagesPath);
+  return result.map((key, value) => MapEntry(key, value.lineHits));
 }
 
 /// 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;
-  }
+@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) {
+  final json = <String, dynamic>{};
   json['source'] = '$scriptUri';
   json['script'] = {
     'type': '@Script',
@@ -235,12 +293,12 @@
     'uri': '$scriptUri',
     '_kind': 'library',
   };
-  json['hits'] = flattenMap<int>(hits.lineHits);
-  if (hits.funcHits != null) {
-    json['funcHits'] = flattenMap<int>(hits.funcHits!);
+  json['hits'] = _flattenMap<int>(hitmap.lineHits);
+  if (hitmap.funcHits != null) {
+    json['funcHits'] = _flattenMap<int>(hitmap.funcHits!);
   }
-  if (hits.funcNames != null) {
-    json['funcNames'] = flattenMap<dynamic>(hits.funcNames!);
+  if (hitmap.funcNames != null) {
+    json['funcNames'] = _flattenMap<dynamic>(hitmap.funcNames!);
   }
   return json;
 }
diff --git a/test/collect_coverage_test.dart b/test/collect_coverage_test.dart
index 60484ae..72b3c7c 100644
--- a/test/collect_coverage_test.dart
+++ b/test/collect_coverage_test.dart
@@ -73,18 +73,50 @@
         ]
       }
     ];
+    // ignore: deprecated_member_use_from_same_package
     final hitMap = await createHitmap(
       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);
+  });
+
+  test('HitMap.parseJson returns a sorted hitmap', () async {
+    final coverage = [
+      {
+        'source': 'foo',
+        'script': '{type: @Script, fixedId: true, '
+            'id: bar.dart, uri: bar.dart, _kind: library}',
+        'hits': [
+          45,
+          1,
+          46,
+          1,
+          49,
+          0,
+          50,
+          0,
+          15,
+          1,
+          16,
+          2,
+          17,
+          2,
+        ]
+      }
+    ];
+    final hitMap = await HitMap.parseJson(
+      coverage.cast<Map<String, dynamic>>(),
+    );
+    final expectedHits = {15: 1, 16: 2, 17: 2, 45: 1, 46: 1, 49: 0, 50: 0};
     expect(hitMap['foo']?.lineHits, expectedHits);
   });
 
-  test('createHitmap', () async {
+  test('HitMap.parseJson', () async {
     final resultString = await _collectCoverage(true);
     final jsonResult = json.decode(resultString) as Map<String, dynamic>;
     final coverage = jsonResult['coverage'] as List;
-    final hitMap = await createHitmap(
+    final hitMap = await HitMap.parseJson(
       coverage.cast<Map<String, dynamic>>(),
     );
     expect(hitMap, contains(_sampleAppFileUri));
@@ -158,6 +190,7 @@
       final coverageResults = await _getCoverageResult();
       await outputFile.writeAsString(coverageResults, flush: true);
 
+      // ignore: deprecated_member_use_from_same_package
       final parsedResult = await parseCoverage([outputFile], 1);
 
       expect(parsedResult, contains(_sampleAppFileUri));
@@ -167,7 +200,7 @@
     }
   });
 
-  test('parseCoverage with packagesPath and checkIgnoredLines', () async {
+  test('HitMap.parseFiles', () async {
     final tempDir = await Directory.systemTemp.createTemp('coverage.test.');
 
     try {
@@ -176,7 +209,25 @@
       final coverageResults = await _getCoverageResult();
       await outputFile.writeAsString(coverageResults, flush: true);
 
-      final parsedResult = await parseCoverage([outputFile], 1,
+      final parsedResult = await HitMap.parseFiles([outputFile]);
+
+      expect(parsedResult, contains(_sampleAppFileUri));
+      expect(parsedResult, contains(_isolateLibFileUri));
+    } finally {
+      await tempDir.delete(recursive: true);
+    }
+  });
+
+  test('HitMap.parseFiles with packagesPath and checkIgnoredLines', () async {
+    final tempDir = await Directory.systemTemp.createTemp('coverage.test.');
+
+    try {
+      final outputFile = File(p.join(tempDir.path, 'coverage.json'));
+
+      final coverageResults = await _getCoverageResult();
+      await outputFile.writeAsString(coverageResults, flush: true);
+
+      final parsedResult = await HitMap.parseFiles([outputFile],
           packagesPath: '.packages', checkIgnoredLines: true);
 
       // This file has ignore:coverage-file.
@@ -186,6 +237,53 @@
       await tempDir.delete(recursive: true);
     }
   });
+
+  test('mergeHitmaps', () {
+    final resultMap = <String, Map<int, int>>{
+      "foo.dart": {10: 2, 20: 0},
+      "bar.dart": {10: 3, 20: 1, 30: 0},
+    };
+    final newMap = <String, Map<int, int>>{
+      "bar.dart": {10: 2, 20: 0, 40: 3},
+      "baz.dart": {10: 1, 20: 0, 30: 1},
+    };
+    // ignore: deprecated_member_use_from_same_package
+    mergeHitmaps(newMap, resultMap);
+    expect(resultMap, <String, Map<int, int>>{
+      "foo.dart": {10: 2, 20: 0},
+      "bar.dart": {10: 5, 20: 1, 30: 0, 40: 3},
+      "baz.dart": {10: 1, 20: 0, 30: 1},
+    });
+  });
+
+  test('FileHitMaps.merge', () {
+    final resultMap = <String, HitMap>{
+      "foo.dart":
+          HitMap({10: 2, 20: 0}, {15: 0, 25: 1}, {15: "bobble", 25: "cobble"}),
+      "bar.dart": HitMap(
+          {10: 3, 20: 1, 30: 0}, {15: 5, 25: 0}, {15: "gobble", 25: "wobble"}),
+    };
+    final newMap = <String, HitMap>{
+      "bar.dart": HitMap(
+          {10: 2, 20: 0, 40: 3}, {15: 1, 35: 4}, {15: "gobble", 35: "dobble"}),
+      "baz.dart": HitMap(
+          {10: 1, 20: 0, 30: 1}, {15: 0, 25: 2}, {15: "lobble", 25: "zobble"}),
+    };
+    resultMap.merge(newMap);
+    expect(resultMap["foo.dart"]?.lineHits, <int, int>{10: 2, 20: 0});
+    expect(resultMap["foo.dart"]?.funcHits, <int, int>{15: 0, 25: 1});
+    expect(resultMap["foo.dart"]?.funcNames,
+        <int, String>{15: "bobble", 25: "cobble"});
+    expect(resultMap["bar.dart"]?.lineHits,
+        <int, int>{10: 5, 20: 1, 30: 0, 40: 3});
+    expect(resultMap["bar.dart"]?.funcHits, <int, int>{15: 6, 25: 0, 35: 4});
+    expect(resultMap["bar.dart"]?.funcNames,
+        <int, String>{15: "gobble", 25: "wobble", 35: "dobble"});
+    expect(resultMap["baz.dart"]?.lineHits, <int, int>{10: 1, 20: 0, 30: 1});
+    expect(resultMap["baz.dart"]?.funcHits, <int, int>{15: 0, 25: 2});
+    expect(resultMap["baz.dart"]?.funcNames,
+        <int, String>{15: "lobble", 25: "zobble"});
+  });
 }
 
 String? _coverageData;
diff --git a/test/lcov_test.dart b/test/lcov_test.dart
index d77c23d..7ce7334 100644
--- a/test/lcov_test.dart
+++ b/test/lcov_test.dart
@@ -49,48 +49,55 @@
       final hitmap = await _getHitMap();
 
       final resolver = Resolver(packagesPath: '.packages');
+      // ignore: deprecated_member_use_from_same_package
       final formatter = LcovFormatter(resolver);
 
-      final res = await formatter.format(hitmap);
+      final res = await formatter
+          .format(hitmap.map((key, value) => MapEntry(key, value.lineHits)));
 
       expect(res, contains(p.absolute(_sampleAppPath)));
       expect(res, contains(p.absolute(_isolateLibPath)));
       expect(res, contains(p.absolute(p.join('lib', 'src', 'util.dart'))));
     });
 
-    test('format() includes files in reportOn list', () async {
+    test('formatLcov()', () async {
       final hitmap = await _getHitMap();
 
       final resolver = Resolver(packagesPath: '.packages');
-      final formatter = LcovFormatter(resolver, reportOn: ['lib/', 'test/']);
-
-      final res = await formatter.format(hitmap);
+      final res = hitmap.formatLcov(resolver);
 
       expect(res, contains(p.absolute(_sampleAppPath)));
       expect(res, contains(p.absolute(_isolateLibPath)));
       expect(res, contains(p.absolute(p.join('lib', 'src', 'util.dart'))));
     });
 
-    test('format() excludes files not in reportOn list', () async {
+    test('formatLcov() includes files in reportOn list', () async {
       final hitmap = await _getHitMap();
 
       final resolver = Resolver(packagesPath: '.packages');
-      final formatter = LcovFormatter(resolver, reportOn: ['lib/']);
+      final res = hitmap.formatLcov(resolver, reportOn: ['lib/', 'test/']);
 
-      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'))));
+    });
+
+    test('formatLcov() excludes files not in reportOn list', () async {
+      final hitmap = await _getHitMap();
+
+      final resolver = Resolver(packagesPath: '.packages');
+      final res = hitmap.formatLcov(resolver, reportOn: ['lib/']);
 
       expect(res, isNot(contains(p.absolute(_sampleAppPath))));
       expect(res, isNot(contains(p.absolute(_isolateLibPath))));
       expect(res, contains(p.absolute(p.join('lib', 'src', 'util.dart'))));
     });
 
-    test('format() uses paths relative to basePath', () async {
+    test('formatLcov() uses paths relative to basePath', () async {
       final hitmap = await _getHitMap();
 
       final resolver = Resolver(packagesPath: '.packages');
-      final formatter = LcovFormatter(resolver, basePath: p.absolute('lib'));
-
-      final res = await formatter.format(hitmap);
+      final res = hitmap.formatLcov(resolver, basePath: p.absolute('lib'));
 
       expect(
           res, isNot(contains(p.absolute(p.join('lib', 'src', 'util.dart')))));
@@ -103,9 +110,11 @@
       final hitmap = await _getHitMap();
 
       final resolver = Resolver(packagesPath: '.packages');
+      // ignore: deprecated_member_use_from_same_package
       final formatter = PrettyPrintFormatter(resolver, Loader());
 
-      final res = await formatter.format(hitmap);
+      final res = await formatter
+          .format(hitmap.map((key, value) => MapEntry(key, value.lineHits)));
 
       expect(res, contains(p.absolute(_sampleAppPath)));
       expect(res, contains(p.absolute(_isolateLibPath)));
@@ -124,42 +133,59 @@
       expect(hitCount, greaterThanOrEqualTo(1));
     });
 
-    test('format() includes files in reportOn list', () async {
+    test('prettyPrint()', () async {
       final hitmap = await _getHitMap();
 
       final resolver = Resolver(packagesPath: '.packages');
-      final formatter =
-          PrettyPrintFormatter(resolver, Loader(), reportOn: ['lib/', 'test/']);
+      final res = await hitmap.prettyPrint(resolver, Loader());
 
-      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('      0|  return a - b;'));
+
+      expect(res, contains('|  return _withTimeout(() async {'),
+          reason: 'be careful if you change lib/src/util.dart');
+
+      final hitLineRegexp = RegExp(r'\s+(\d+)\|  return a \+ b;');
+      final match = hitLineRegexp.allMatches(res).single;
+
+      final hitCount = int.parse(match[1]!);
+      expect(hitCount, greaterThanOrEqualTo(1));
+    });
+
+    test('prettyPrint() includes files in reportOn list', () async {
+      final hitmap = await _getHitMap();
+
+      final resolver = Resolver(packagesPath: '.packages');
+      final res = await hitmap
+          .prettyPrint(resolver, Loader(), reportOn: ['lib/', 'test/']);
 
       expect(res, contains(p.absolute(_sampleAppPath)));
       expect(res, contains(p.absolute(_isolateLibPath)));
       expect(res, contains(p.absolute(p.join('lib', 'src', 'util.dart'))));
     });
 
-    test('format() excludes files not in reportOn list', () async {
+    test('prettyPrint() excludes files not in reportOn list', () async {
       final hitmap = await _getHitMap();
 
       final resolver = Resolver(packagesPath: '.packages');
-      final formatter =
-          PrettyPrintFormatter(resolver, Loader(), reportOn: ['lib/']);
-
-      final res = await formatter.format(hitmap);
+      final res =
+          await hitmap.prettyPrint(resolver, Loader(), reportOn: ['lib/']);
 
       expect(res, isNot(contains(p.absolute(_sampleAppPath))));
       expect(res, isNot(contains(p.absolute(_isolateLibPath))));
       expect(res, contains(p.absolute(p.join('lib', 'src', 'util.dart'))));
     });
 
-    test('format() functions', () async {
+    test('prettyPrint() functions', () async {
       final hitmap = await _getHitMap();
 
       final resolver = Resolver(packagesPath: '.packages');
-      final formatter =
-          PrettyPrintFormatter(resolver, Loader(), reportFuncs: true);
-
-      final res = await formatter.format(hitmap);
+      final res =
+          await hitmap.prettyPrint(resolver, Loader(), reportFuncs: true);
 
       expect(res, contains(p.absolute(_sampleAppPath)));
       expect(res, contains(p.absolute(_isolateLibPath)));
@@ -206,7 +232,7 @@
   // collect hit map.
   final coverageJson = (await collect(serviceUri, true, true, false, <String>{},
       functionCoverage: true))['coverage'] as List<Map<String, dynamic>>;
-  final hitMap = createHitmap(coverageJson);
+  final hitMap = HitMap.parseJson(coverageJson);
 
   // wait for sample app to terminate.
   final exitCode = await sampleProcess.exitCode;
diff --git a/test/run_and_collect_test.dart b/test/run_and_collect_test.dart
index 4095a13..388921e 100644
--- a/test/run_and_collect_test.dart
+++ b/test/run_and_collect_test.dart
@@ -38,7 +38,7 @@
       expect(sampleCoverageData['hits'], isNotEmpty);
     }
 
-    final hitMap = await createHitmap(coverage, checkIgnoredLines: true);
+    final hitMap = await HitMap.parseJson(coverage, checkIgnoredLines: true);
     expect(hitMap, isNot(contains(_sampleAppFileUri)));
 
     final actualHitMap = hitMap[_isolateLibFileUri];