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