blob: 56e1ea0a77a1ebcddf0a3fb208e005ce7743e9d8 [file] [log] [blame]
// Copyright (c) 2024, 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:io';
import 'dart:typed_data';
import 'package:_fe_analyzer_shared/src/scanner/characters.dart'
show $SPACE, $CARET;
import 'package:_fe_analyzer_shared/src/scanner/token.dart';
import 'package:front_end/src/fasta/util/parser_ast.dart';
import 'package:front_end/src/fasta/util/parser_ast_helper.dart';
import 'package:kernel/ast.dart';
import 'package:package_config/package_config.dart';
import '../test/coverage_helper.dart';
import 'interval_list.dart';
import 'parser_ast_indexer.dart';
void main(List<String> arguments) {
Uri? coverageUri;
Uri? packagesUri;
for (String argument in arguments) {
const String coverage = "--coverage=";
const String packages = "--packages=";
if (argument.startsWith(coverage)) {
coverageUri =
Uri.base.resolveUri(Uri.file(argument.substring(coverage.length)));
} else if (argument.startsWith(packages)) {
packagesUri =
Uri.base.resolveUri(Uri.file(argument.substring(packages.length)));
} else {
throw "Unsupported argument: $argument";
}
}
if (coverageUri == null) {
throw "Need --coverage=<dir>/ argument";
}
if (packagesUri == null) {
throw "Need --packages=<path/to/package_config.json> argument";
}
Stopwatch stopwatch = new Stopwatch()..start();
mergeFromDirUri(packagesUri, coverageUri, silent: false);
print("Done in ${stopwatch.elapsed}");
}
Map<Uri, CoverageInfo>? mergeFromDirUri(
Uri packagesUri,
Uri coverageUri, {
required bool silent,
}) {
void output(Object? object) {
if (silent) return;
print(object);
}
PackageConfig packageConfig;
try {
packageConfig = PackageConfig.parseBytes(
File.fromUri(packagesUri).readAsBytesSync(), packagesUri);
} catch (e) {
// When will we want to catch this?
output("Error trying to read package config: $e");
return null;
}
// TODO(jensj): We should allow for comments to "excuse" something from not
// being covered. E.g. sometimes we throw after a number of if's saying
// something like "this can probably never happen", and thus we can't expect
// to test that.
// TODO(jensj): We should be able to extract the "coverable" offsets from
// source/dill, thus avoiding asking the VM to do it and basically we would
// then be able to just ignore the uncompiled stuff from the VM. It could
// probably also be used for speeding up doing coverage in flutter. We need to
// be "sharp" about what the VM includes and what the VM doesn't include
// though. I assume it doesn't include everything, but really I don't know.
// TODO(jensj): Things that are not covered by one of our suites should
// probably be marked specifically as we generally want tests "close".
// Merge the data:
// * combine hits, and keep track of where they come from (display name)
// * combine misses, but remove misses that are hits
// * ignore not compiled regions.
Map<Uri, Hit> hits = {};
Map<Uri, Set<int>> misses = {};
for (FileSystemEntity entry
in Directory.fromUri(coverageUri).listSync(recursive: true)) {
if (entry is! File) continue;
try {
Coverage coverage = Coverage.loadFromFile(entry);
output("Loaded $entry as coverage file.");
_mergeCoverageInto(coverage, misses, hits);
} catch (e) {
output("Couldn't load $entry as coverage file.");
}
}
output("");
Set<Uri> knownUris = {};
knownUris.addAll(hits.keys);
knownUris.addAll(misses.keys);
int filesCount = 0;
int allCoveredCount = 0;
int hitsTotal = 0;
int missesTotal = 0;
int errorsCount = 0;
Map<Uri, CoverageInfo> result = {};
for (Uri uri in knownUris.toList()
..sort(((a, b) => a.toString().compareTo(b.toString())))) {
// Don't care about coverage for testing stuff.
if (uri.toString().startsWith("package:front_end/src/testing/")) continue;
Hit? hit = hits[uri];
Set<int>? miss = misses[uri];
List<int> hitsSorted =
hit == null ? const [] : (hit._data.keys.toList()..sort());
CoverageInfo processInfo =
process(packageConfig, uri, miss ?? const {}, hitsSorted);
output(processInfo.visualization);
result[uri] = processInfo;
filesCount++;
if (processInfo.error) {
errorsCount++;
} else {
if (processInfo.allCovered) {
allCoveredCount++;
}
hitsTotal += processInfo.hitCount;
missesTotal += processInfo.missCount;
}
output("");
}
output("Processed $filesCount files with $errorsCount error(s) and "
"$allCoveredCount files being covered 100%.");
int percentHit = (hitsTotal * 100) ~/ (hitsTotal + missesTotal);
output("All-in-all $hitsTotal hits and $missesTotal misses ($percentHit%).");
return result;
}
class CoverageInfo {
final bool error;
final bool allCovered;
final int missCount;
final int hitCount;
final String visualization;
CoverageInfo.error(this.visualization)
: error = true,
allCovered = false,
missCount = 0,
hitCount = 0;
CoverageInfo(
{required this.allCovered,
required this.missCount,
required this.hitCount,
required this.visualization})
: error = false;
}
CoverageInfo process(PackageConfig packageConfig, Uri uri,
Set<int> untrimmedMisses, List<int> hitsSorted) {
Uri? fileUri = packageConfig.resolve(uri);
if (fileUri == null) {
return new CoverageInfo.error("Couldn't find file uri for $uri");
}
File f = new File.fromUri(fileUri);
Uint8List rawBytes;
try {
rawBytes = f.readAsBytesSync();
} catch (e) {
return new CoverageInfo.error("Error reading file $f");
}
List<int> lineStarts = [];
// TODO(jensj): "allowPatterns" for instance should use data from the package
// config to be set correctly.
CompilationUnitEnd ast = getAST(
rawBytes,
includeComments: true,
enableExtensionMethods: true,
enableNonNullable: true,
enableTripleShift: true,
lineStarts: lineStarts,
);
AstIndexerAndIgnoreCollector astIndexer =
AstIndexerAndIgnoreCollector.collect(ast);
// TODO(jensj): Extract all comments and use those as well here.
// TODO(jensj): Should some comment throw/report and error if covered?
// E.g. "we expect this to be dead code, if it isn't we want to know."
StringBuffer visualization = new StringBuffer();
IntervalList ignoredIntervals =
astIndexer.ignoredStartEnd.buildIntervalList();
var (:bool allCovered, :Set<int> trimmedMisses) =
_trimIgnoredAndPrintPercentages(
visualization, ignoredIntervals, untrimmedMisses, hitsSorted, uri);
if (allCovered) {
return new CoverageInfo(
allCovered: allCovered,
missCount: trimmedMisses.length,
hitCount: hitsSorted.length,
visualization: visualization.toString());
}
CompilationUnitBegin unitBegin = ast.children!.first as CompilationUnitBegin;
Token firstToken = unitBegin.token;
Source source = new Source(lineStarts, rawBytes, uri, fileUri);
List<int> sortedMisses = trimmedMisses.toList()..sort();
int lastLine = -1;
int lastOffset = -1;
Uint8List? indentation;
String? line;
Token token = firstToken;
int? latestNodeIndex;
int nextHitIndexToCheck = 0;
void printFinishedLine() {
if (indentation != null) {
String? name = astIndexer.nameOfEntitySpanning(lastOffset);
String pointer = new String.fromCharCodes(indentation!);
if (name != null) {
visualization.writeln("$uri:$lastLine:\nIn '$name':\n$line\n$pointer");
} else {
visualization.writeln("$uri:$lastLine:\n$line\n$pointer");
}
line = null;
indentation = null;
}
}
int nextOffsetIndex = 0;
while (nextOffsetIndex < sortedMisses.length) {
int offset = sortedMisses[nextOffsetIndex];
nextOffsetIndex++;
while (offset > token.charOffset) {
token = token.next!;
if (token.isEof) break;
}
int? thisNodeIndex = astIndexer.findNodeIndexSpanningPosition(offset);
if (thisNodeIndex != null && thisNodeIndex != latestNodeIndex) {
// First miss in this entity: Does it have any hits?
latestNodeIndex = thisNodeIndex;
printFinishedLine();
bool foundHit = false;
int first = astIndexer.moveNodeIndexToFirstMetadataIfAny(thisNodeIndex)!;
int last = astIndexer.moveNodeIndexPastMetadata(
astIndexer.findNodeIndexSpanningPosition(offset)) ??
thisNodeIndex;
int beginOffset = astIndexer.positionStartEndIndex[first * 2 + 0];
int endOffset = astIndexer.positionStartEndIndex[last * 2 + 1];
for (; nextHitIndexToCheck < hitsSorted.length; nextHitIndexToCheck++) {
int hit = hitsSorted[nextHitIndexToCheck];
if (hit >= beginOffset && hit <= endOffset) {
foundHit = true;
break;
} else if (hit > endOffset) {
break;
}
}
if (!foundHit) {
// Don't show a line with only metadata.
offset = astIndexer.positionStartEndIndex[last * 2];
Location location = source.getLocation(uri, offset);
String line = source.getTextLine(location.line)!;
String? name = astIndexer.nameOfEntitySpanning(offset);
if (name != null) {
visualization.writeln(
"$uri:${location.line}: No coverage for '$name'.\n$line\n");
// TODO(jensj): Squiggly line under the identifier of the entity?
} else {
visualization.writeln(
"$uri:${location.line}: No coverage for entity.\n$line\n");
}
// Skip the rest of the miss points inside the entity.
while (nextOffsetIndex < sortedMisses.length) {
offset = sortedMisses[nextOffsetIndex];
if (offset > endOffset) break;
nextOffsetIndex++;
}
continue;
}
}
Location location = source.getLocation(uri, offset);
if (location.line != lastLine) {
printFinishedLine();
lastLine = location.line;
line = source.getTextLine(location.line)!;
indentation = new Uint8List(line!.length)
..fillRange(0, line!.length, $SPACE);
}
try {
if (offset == token.charOffset) {
for (int i = 0; i < token.length; i++) {
indentation![location.column - 1 + i] = $CARET;
}
} else {
indentation![location.column - 1] = $CARET;
}
lastOffset = offset;
} catch (e) {
visualization.writeln("Error on offset $offset --- $location: $e");
visualization.writeln(
"Maybe the coverage data is not up to date with the source?");
return new CoverageInfo.error(visualization.toString());
}
}
printFinishedLine();
return new CoverageInfo(
allCovered: allCovered,
missCount: trimmedMisses.length,
hitCount: hitsSorted.length,
visualization: visualization.toString());
}
({bool allCovered, Set<int> trimmedMisses}) _trimIgnoredAndPrintPercentages(
StringBuffer visualization,
IntervalList ignoredIntervals,
Set<int> untrimmedMisses,
List<int> hitsSorted,
Uri uri) {
int missCount = untrimmedMisses.length;
int hitCount = hitsSorted.length;
Set<int> trimmedMisses;
if (hitCount + missCount == 0) {
visualization.writeln("$uri");
return (allCovered: true, trimmedMisses: untrimmedMisses);
} else {
if (!ignoredIntervals.isEmpty) {
trimmedMisses = {};
for (int position in untrimmedMisses) {
if (ignoredIntervals.contains(position)) {
// Ignored position!
} else {
trimmedMisses.add(position);
}
}
missCount = trimmedMisses.length;
} else {
trimmedMisses = untrimmedMisses;
}
if (missCount > 0) {
visualization.writeln(
"$uri: ${(hitCount / (hitCount + missCount) * 100).round()}% "
"($missCount misses)");
return (allCovered: false, trimmedMisses: trimmedMisses);
} else {
visualization.writeln("$uri: 100% (OK)");
return (allCovered: true, trimmedMisses: trimmedMisses);
}
}
}
void _mergeCoverageInto(
Coverage coverage, Map<Uri, Set<int>> misses, Map<Uri, Hit> hits) {
for (FileCoverage fileCoverage in coverage.getAllFileCoverages()) {
if (fileCoverage.uri.isScheme("package") &&
fileCoverage.uri.pathSegments.first != "front_end") continue;
if (fileCoverage.misses.isNotEmpty) {
Set<int> miss = misses[fileCoverage.uri] ??= {};
miss.addAll(fileCoverage.misses);
}
if (fileCoverage.hits.isNotEmpty) {
Hit hit = hits[fileCoverage.uri] ??= new Hit();
for (int fileHit in fileCoverage.hits) {
hit.addHit(fileHit, coverage.displayName);
}
}
}
// Now remove any misses that are actually hits.
for (MapEntry<Uri, Set<int>> entry in misses.entries) {
Hit? hit = hits[entry.key];
if (hit == null) continue;
entry.value.removeAll(hit._data.keys);
}
}
class Hit {
Map<int, List<String>> _data = {};
void addHit(int offset, String displayName) {
(_data[offset] ??= []).add(displayName);
}
}
class AstIndexerAndIgnoreCollector extends AstIndexer {
final Set<String> topLevelMethodNamesToIgnore = {
"debug",
"debugString",
};
final Set<String> classMethodNamesToIgnore = {
"debug",
"debugString",
"toString",
"debugName",
"writeNullabilityOn",
};
final IntervalListBuilder ignoredStartEnd = new IntervalListBuilder();
late final _AstIndexerAndIgnoreCollectorBody _collectorBody =
new _AstIndexerAndIgnoreCollectorBody(this);
static AstIndexerAndIgnoreCollector collect(ParserAstNode ast) {
AstIndexerAndIgnoreCollector collector =
new AstIndexerAndIgnoreCollector._();
ast.accept(collector);
assert(collector.positionNodeIndex.length ==
collector.positionNodeName.length);
assert(collector.positionNodeIndex.length * 2 ==
collector.positionStartEndIndex.length);
return collector;
}
AstIndexerAndIgnoreCollector._() {}
@override
void visitTopLevelMethodEnd(TopLevelMethodEnd node) {
super.visitTopLevelMethodEnd(node);
String name = node.getNameIdentifier().token.lexeme;
if (topLevelMethodNamesToIgnore.contains(name)) {
// Ignore this method including metadata.
assert(positionNodeIndex.last == node);
assert(positionStartEndIndex.last == node.endToken.charEnd);
int index = positionNodeIndex.length - 1;
int firstIndex = moveNodeIndexToFirstMetadataIfAny(index)!;
ignoredStartEnd.addIntervalIncludingEnd(
positionStartEndIndex[firstIndex * 2 + 0], node.endToken.charEnd);
} else {
node.accept(_collectorBody);
}
}
@override
void containerMethod(BeginAndEndTokenParserAstNode node, String name) {
super.containerMethod(node, name);
if (classMethodNamesToIgnore.contains(name)) {
// Ignore this class method including metadata.
assert(positionNodeIndex.last == node);
assert(positionStartEndIndex.last == node.endToken.charEnd);
int index = positionNodeIndex.length - 1;
int firstIndex = moveNodeIndexToFirstMetadataIfAny(index)!;
ignoredStartEnd.addIntervalIncludingEnd(
positionStartEndIndex[firstIndex * 2 + 0], node.endToken.charEnd);
} else {
node.accept(_collectorBody);
}
}
}
class _AstIndexerAndIgnoreCollectorBody extends RecursiveParserAstVisitor {
final AstIndexerAndIgnoreCollector _collector;
_AstIndexerAndIgnoreCollectorBody(this._collector);
bool _recordIfIsCallToNotExpectedCoverage(
BeginAndEndTokenParserAstNode node) {
List<ParserAstNode>? children = node.children;
if (children != null &&
children.length >= 5 &&
children[1] is IdentifierHandle) {
IdentifierHandle identifier = children[1] as IdentifierHandle;
if ((identifier.token.lexeme == "internalProblem" ||
identifier.token.lexeme == "unimplemented" ||
identifier.token.lexeme == "unhandled" ||
identifier.token.lexeme == "unexpected" ||
identifier.token.lexeme == "unsupported") &&
children[2] is NoTypeArgumentsHandle &&
children[3] is ArgumentsEnd &&
children[4] is SendHandle) {
// This is (probably) a call to `internalProblem`/`unimplemented`/etc
// inside an if block --- we don't expect these to happen
// so we'll ignore them.
_collector.ignoredStartEnd.addIntervalIncludingEnd(
node.beginToken.charOffset, node.endToken.charEnd);
return true;
}
}
return false;
}
@override
void visitReturnStatementEnd(ReturnStatementEnd node) {
if (_recordIfIsCallToNotExpectedCoverage(node)) return;
super.visitReturnStatementEnd(node);
}
@override
void visitBlockEnd(BlockEnd node) {
if (_recordIfIsCallToNotExpectedCoverage(node)) return;
super.visitBlockEnd(node);
}
}