Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Verification coverage report #4625

Merged
merged 23 commits into from
Oct 12, 2023
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
10786bf
Move CoverageReport code for use with verification
atomb Oct 3, 2023
94e5829
WIP
atomb Oct 4, 2023
e208197
Merge remote-tracking branch 'upstream/master' into verification-cove…
atomb Oct 4, 2023
efb99e4
Merge branch 'master' into verification-coverage-report
atomb Oct 4, 2023
0873a7f
Merge remote-tracking branch 'upstream/master' into verification-cove…
atomb Oct 6, 2023
b51fc59
Remove debug printing
atomb Oct 6, 2023
ed001d7
Fix off-by-one error
atomb Oct 6, 2023
6efb54a
Update comment
atomb Oct 6, 2023
18bc776
Undo temporary change
atomb Oct 6, 2023
c9c5e16
Deal with overlapping ranges in reporter
atomb Oct 6, 2023
1ce654f
Merge remote-tracking branch 'upstream/master' into verification-cove…
atomb Oct 6, 2023
1aa5258
Merge remote-tracking branch 'upstream/master' into verification-cove…
atomb Oct 10, 2023
ae43ffe
Rearrange how highlighting is done overall
atomb Oct 10, 2023
76d0938
Fix line number to pos conversion for test generation reports with mu…
Oct 11, 2023
43c2377
Merge pull request #5 from Dargones/coverage
atomb Oct 11, 2023
702e954
Fill characterLabels array
atomb Oct 11, 2023
60cfa5b
Make SerializeVerificationCoverageReport non-static
atomb Oct 11, 2023
5e699c8
Add new feature text
atomb Oct 11, 2023
652acb6
Add test for `dafny verify --coverage-report`
atomb Oct 11, 2023
6fd0765
Merge remote-tracking branch 'upstream/master' into verification-cove…
atomb Oct 11, 2023
4ba4328
Fix whitespace
atomb Oct 11, 2023
d6e8aa6
Merge remote-tracking branch 'upstream/master' into verification-cove…
atomb Oct 12, 2023
b48afa4
Fix check-uniformity
atomb Oct 12, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
namespace Microsoft.Dafny;

public enum CoverageLabel {
None,
FullyCovered,
NotCovered,
PartiallyCovered
PartiallyCovered,
}

public static class CoverageLabelExtension {
Expand All @@ -14,6 +15,12 @@ public static class CoverageLabelExtension {
/// Combine coverage labels. E.g. FullyCovered + NotCovered = PartiallyCovered
/// </summary>
public static CoverageLabel Combine(CoverageLabel one, CoverageLabel two) {
if (one == CoverageLabel.None) {
return two;
}
if (two == CoverageLabel.None) {
return one;
}
if (one == CoverageLabel.PartiallyCovered || two == CoverageLabel.PartiallyCovered || one != two) {
return CoverageLabel.PartiallyCovered;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ public class CoverageReport {

private static int nextUniqueId = 0;

// INVARIANT: CoverageSpans are sorted within each list by the position of the StartToken
private readonly Dictionary<string, List<CoverageSpan>> labelsByFile;
public readonly string Name; // the name to assign to this coverage report
public readonly string Units; // the units of coverage (plural). This will be written in the coverage report table.
Expand Down Expand Up @@ -39,37 +38,12 @@ public CoverageReport(string name, string units, string suffix, Program program)

/// <summary>
/// Assign a coverage label to the code indicated by the <param name="span"></param> range token.
/// If the span intersects with existing coverage information, it will be merged according to
/// CoverageLabelExtension.Combine.
/// </summary>
public void LabelCode(RangeToken span, CoverageLabel label) {
Contract.Assert(labelsByFile.ContainsKey(span.ActualFilename));
if (span.StartToken.CompareTo(span.EndToken) == 0) {
return;
}
var labeledFile = labelsByFile[span.ActualFilename];
var coverageSpan = new CoverageSpan(span, label);
int index = labeledFile.BinarySearch(0, labeledFile.Count, coverageSpan, null);
if (index < 0) {
labeledFile.Insert(~index, coverageSpan);
return;
}
var overlappingSpan = labeledFile[index];
labeledFile.RemoveAt(index);
var combinedLabel = CoverageLabelExtension.Combine(label, overlappingSpan.Label);
switch (overlappingSpan.Span.EndToken.CompareTo(span.EndToken)) {
case 0:
labeledFile.Insert(index, new CoverageSpan(span, combinedLabel));
break;
case > 0:
labeledFile.Insert(index, new CoverageSpan(span, combinedLabel));
labeledFile.Insert(index + 1, new CoverageSpan(new RangeToken(span.EndToken, overlappingSpan.Span.EndToken), overlappingSpan.Label));
break;
default:
labeledFile.Insert(index, new CoverageSpan(overlappingSpan.Span, combinedLabel));
LabelCode(new RangeToken(overlappingSpan.Span.EndToken, span.EndToken), label);
break;
}
labeledFile.Add(coverageSpan);
}

public IEnumerable<CoverageSpan> CoverageSpansForFile(string fileName) {
Expand All @@ -92,8 +66,12 @@ private void RegisterFiles(Node astNode) {
if (astNode.StartToken.ActualFilename != null) {
labelsByFile[astNode.StartToken.ActualFilename] = new();
}

foreach (var declaration in astNode.Children.OfType<LiteralModuleDecl>()) {
RegisterFiles(declaration);
}
foreach (var declaration in astNode.Children.OfType<Declaration>()) {
RegisterFiles(declaration);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ public CoverageSpan(RangeToken span, CoverageLabel label) {
Contract.Assert(span.Uri != null);
Contract.Assert(span.StartToken != null);
Contract.Assert(span.EndToken != null);
Contract.Assert(span.StartToken.CompareTo(span.EndToken) != 0);
Span = span;
Label = label;
}
Expand Down
5 changes: 5 additions & 0 deletions Source/DafnyCore/Options/CommonOptionBag.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ May slow down verification slightly.
May produce spurious warnings.") {
IsHidden = true
};
public static readonly Option<string> VerificationCoverageReport = new("--coverage-report",
"Emit verification coverage report to a given directory, in the same format as a test coverage report.") {
ArgumentHelpName = "directory"
};

public static readonly Option<bool> IncludeRuntimeOption = new("--include-runtime",
"Include the Dafny runtime as source in the target language.");
Expand Down Expand Up @@ -470,6 +474,7 @@ void ParsePrintMode(Option<PrintModes> option, Boogie.CommandLineParseState ps,
IncludeRuntimeOption,
WarnContradictoryAssumptions,
WarnRedundantAssumptions,
VerificationCoverageReport,
DefaultFunctionOpacity
);
}
Expand Down
3 changes: 2 additions & 1 deletion Source/DafnyCore/Options/DafnyCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ static DafnyCommands() {
BoogieOptionBag.VerificationErrorLimit,
CommonOptionBag.DefaultFunctionOpacity,
CommonOptionBag.WarnContradictoryAssumptions,
CommonOptionBag.WarnRedundantAssumptions
CommonOptionBag.WarnRedundantAssumptions,
CommonOptionBag.VerificationCoverageReport
}.ToList();

public static IReadOnlyList<Option> TranslationOptions = new Option[] {
Expand Down
5 changes: 5 additions & 0 deletions Source/DafnyCore/Verifier/ProofDependencyManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//-----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using Dafny;
using Bpl = Microsoft.Boogie;
using BplParser = Microsoft.Boogie.Parser;
Expand Down Expand Up @@ -42,6 +43,10 @@ public IEnumerable<ProofDependency> GetPotentialDependenciesForDefinition(string
return idsByMemberName[defName];
}

public IEnumerable<ProofDependency> GetAllPotentialDependencies() {
return idsByMemberName.Values.SelectMany(deps => deps);
}

// The "id" attribute on a Boogie AST node is used by Boogie to label
// that AST node in the SMT-Lib formula when constructing a verification
// condition.
Expand Down
3 changes: 3 additions & 0 deletions Source/DafnyDriver/Commands/VerifyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ public static Command Create() {
result.AddOption(option);
}
DafnyCli.SetHandlerUsingDafnyOptionsContinuation(result, (options, _) => {
if (options.Get(CommonOptionBag.VerificationCoverageReport) != null) {
options.TrackVerificationCoverage = true;
}
options.Compile = false;
return CompilerDriver.RunCompiler(options);
});
Expand Down
7 changes: 7 additions & 0 deletions Source/DafnyDriver/CompilerDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@ private async Task<ExitValue> ProcessFilesAsync(IReadOnlyList<DafnyFile/*!*/>/*!

if (options.TrackVerificationCoverage) {
ProofDependencyWarnings.WarnAboutSuspiciousDependencies(options, dafnyProgram.Reporter, depManager);
var coverageReportDir = options.Get(CommonOptionBag.VerificationCoverageReport);
if (coverageReportDir != null) {
new CoverageReporter(options).SerializeVerificationCoverageReport(
depManager, dafnyProgram,
boogiePrograms.SelectMany(tp => tp.Item2.AllCoveredElements),
coverageReportDir);
}
}

bool compiled;
Expand Down
99 changes: 63 additions & 36 deletions Source/DafnyDriver/CoverageReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using DafnyCore.Verifier;
using Microsoft.Boogie;
using Microsoft.Extensions.Primitives;

namespace Microsoft.Dafny;

Expand All @@ -22,7 +25,7 @@ public class CoverageReporter {
private static readonly Regex TableFooterRegex = new(@"\{\{TABLE_FOOTER\}\}");
private static readonly Regex TableBodyRegex = new(@"\{\{TABLE_BODY\}\}");
private static readonly Regex IndexFileNameRegex = new(@"index(.*)\.html");
private static readonly Regex SpanRegexInverse = new("class=\"([a-z]+)\" id=\"line([0-9]+)col([0-9]+)-line([0-9]+)col([0-9]+)\"");
private static readonly Regex PosRegexInverse = new("class=\"([a-z]+)\" id=\"pos([0-9]+)\">");
private const string CoverageReportTemplatePath = "coverage_report_template.html";
private const string CoverageReportIndexTemplatePath = "coverage_report_index_template.html";
private const string CoverageReportSupportingFilesPath = ".resources";
Expand All @@ -39,6 +42,28 @@ public CoverageReporter(DafnyOptions options) {
this.options = options;
}


public void SerializeVerificationCoverageReport(ProofDependencyManager depManager, Program dafnyProgram, IEnumerable<TrackedNodeComponent> usedComponents, string coverageReportDir) {
var usedDependencies =
usedComponents.Select(depManager.GetFullIdDependency).ToHashSet();
var allDependencies =
depManager
.GetAllPotentialDependencies()
.OrderBy(dep => dep.Range.StartToken);
var coverageReport = new CoverageReport("Verification coverage", "Lines", "_verification", dafnyProgram);
foreach (var dep in allDependencies) {
if (dep is FunctionDefinitionDependency) {
continue;
}
coverageReport.LabelCode(dep.Range,
usedDependencies.Contains(dep)
? CoverageLabel.FullyCovered
: CoverageLabel.NotCovered);
}

SerializeCoverageReports(coverageReport, coverageReportDir);
}

public void Merge(List<string> coverageReportsToMerge, string coverageReportOutDir) {
// assume only one report in directory for now
List<CoverageReport> reports = new();
Expand Down Expand Up @@ -80,16 +105,17 @@ private static CoverageReport ParseCoverageReport(string reportDir, string name,
continue;
}
var uri = new Uri(uriMatch.Groups[1].Value);
var lastEndToken = new Token(1, 1);
report.RegisterFile(uri);
foreach (var span in SpanRegexInverse.Matches(source).Where(match => match.Success)) {
if (int.TryParse(span.Groups[2].Value, out var startLine) &&
int.TryParse(span.Groups[3].Value, out var startCol) &&
int.TryParse(span.Groups[4].Value, out var endLine) &&
int.TryParse(span.Groups[5].Value, out var endCol)) {
var startToken = new Token(startLine, startCol);
startToken.Uri = uri;
var endToken = new Token(endLine, endCol);
foreach (var span in PosRegexInverse.Matches(source).Where(match => match.Success)) {
if (int.TryParse(span.Groups[2].Value, out var pos)) {
var startToken = new Token(1, 1);
startToken.Uri = uri;
startToken.pos = pos;
lastEndToken.pos = pos;
var endToken = new Token(1, 1);
endToken.Uri = uri;
lastEndToken = endToken;
var rangeToken = new RangeToken(startToken, endToken);
rangeToken.Uri = uri;
report.LabelCode(rangeToken, FromHtmlClass(span.Groups[1].Value));
Expand Down Expand Up @@ -125,7 +151,7 @@ private void SerializeCoverageReports(List<CoverageReport> reports, string repor
.TakeWhile((c, i) => allFiles.All(s => s[i] == c)).ToArray()).Length;
Dictionary<string, string> sourceFileToCoverageReport = new Dictionary<string, string>();
foreach (var fileName in allFiles) {
var directoryForFile = Path.Combine(sessionDirectory, Path.GetDirectoryName(fileName)?[prefixLength..] ?? "");
var directoryForFile = Path.Combine(sessionDirectory, Path.GetDirectoryName(fileName)?[prefixLength..].TrimStart('/') ?? "");
var pathToRoot = Path.GetRelativePath(directoryForFile, sessionDirectory);
Directory.CreateDirectory(directoryForFile);
for (int i = 0; i < reports.Count; i++) {
Expand Down Expand Up @@ -239,16 +265,31 @@ private void CopyStyleFiles(string baseDirectory) {
private string HtmlReportForFile(CoverageReport report, string pathToSourceFile, string baseDirectory, string linksToOtherReports) {
var source = new StreamReader(pathToSourceFile).ReadToEnd();
var lines = source.Split("\n");
IToken lastToken = new Token(0, 0);
var characterLabels = new CoverageLabel[source.Length];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth explicitly initializing values with None so that the order of labels in the CoverageLabel enum doesn't affect the behavior?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good catch. I'll change that.

Array.Fill(characterLabels, CoverageLabel.None);
IToken lastToken = new Token(1, 1);
var labeledCodeBuilder = new StringBuilder(source.Length);
foreach (var span in report.CoverageSpansForFile(pathToSourceFile)) {
AppendCodeBetweenTokens(labeledCodeBuilder, lines, lastToken, span.Span.StartToken);
labeledCodeBuilder.Append(OpenHtmlTag(span));
AppendCodeBetweenTokens(labeledCodeBuilder, lines, span.Span.StartToken, span.Span.EndToken);
labeledCodeBuilder.Append(CloseHtmlTag(span));
lastToken = span.Span.EndToken;
for (var pos = span.Span.StartToken.pos; pos <= span.Span.EndToken.pos; pos++) {
characterLabels[pos] = CoverageLabelExtension.Combine(characterLabels[pos], span.Label);
}
Comment on lines +273 to +275
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have just realized that this loop replaces about 30 lines of some binary search recursion madness I wrote...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally started thinking about interval trees when I realized that I needed to deal with arbitrary types and degrees of overlap. And then I realized that just tracking the labels on a per-character basis was simpler and probably even more efficient.

}
AppendCodeBetweenTokens(labeledCodeBuilder, lines, lastToken, null);

CoverageLabel lastLabel = CoverageLabel.None;
labeledCodeBuilder.Append(OpenHtmlTag(0, CoverageLabel.None));
for (var pos = 0; pos < source.Length; pos++) {
var thisLabel = characterLabels[pos];
if (thisLabel != lastLabel) {
labeledCodeBuilder.Append(CloseHtmlTag());
labeledCodeBuilder.Append(OpenHtmlTag(pos, thisLabel));
}

labeledCodeBuilder.Append(source[pos]);

lastLabel = thisLabel;
}
labeledCodeBuilder.Append(CloseHtmlTag());

var assembly = System.Reflection.Assembly.GetCallingAssembly();
var templateStream = assembly.GetManifestResourceStream(CoverageReportTemplatePath);
var labeledCode = labeledCodeBuilder.ToString();
Expand All @@ -266,21 +307,6 @@ private string HtmlReportForFile(CoverageReport report, string pathToSourceFile,
return LabeledCodeRegex.Replace(templateText, labeledCode);
}

/// <summary>
/// Append code from <param name="lines"></param> that lies between <param name="start"></param> and
/// <param name="end"></param> tokens to the <param name="stringBuilder"></param>
/// </summary>
private static void AppendCodeBetweenTokens(StringBuilder stringBuilder, string[] lines, IToken start, IToken end) {
var currToken = new Token(start.line, start.col);
while (currToken.line < lines.Length && (end == null || currToken.line < end.line)) {
stringBuilder.Append(lines[currToken.line][currToken.col..] + "\n");
currToken.line += 1;
}
if (end != null && currToken.line < lines.Length) {
stringBuilder.Append(lines[currToken.line][currToken.col..end.col]);
}
}

/// <summary>
/// Return HTML class used to highlight lines in different colors depending on the coverage.
/// Look at assets/.resources/coverage.css for the styles corresponding to these classes
Expand All @@ -290,6 +316,7 @@ private static string ToHtmlClass(CoverageLabel label) {
CoverageLabel.FullyCovered => "fc",
CoverageLabel.NotCovered => "nc",
CoverageLabel.PartiallyCovered => "pc",
CoverageLabel.None => "none",
_ => ""
};
}
Expand All @@ -304,13 +331,13 @@ private static CoverageLabel FromHtmlClass(string htmlClass) {
return CoverageLabel.NotCovered; // this is a fallback in case the HTML has invalid classes
}

private string OpenHtmlTag(CoverageSpan span) {
var id = $"id=\"line{span.Span.StartToken.line}col{span.Span.StartToken.col}-line{span.Span.EndToken.line}col{span.Span.EndToken.col}\"";
var classLabel = ToHtmlClass(span.Label);
private string OpenHtmlTag(int pos, CoverageLabel label) {
var id = $"id=\"pos{pos}\"";
var classLabel = ToHtmlClass(label);
return $"<span class=\"{classLabel}\" {id}>";
}

private string CloseHtmlTag(CoverageSpan span) {
private string CloseHtmlTag() {
return "</span>";
}
}
9 changes: 7 additions & 2 deletions Source/DafnyTestGeneration/Main.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ private static void PopulateCoverageReport(CoverageReport coverageReport, Progra
if (coverageReport == null) {
return;
}

var lineNumToPosCache = new Dictionary<Uri, int[]>();
var lineRegex = new Regex("^(.*)\\(([0-9]+),[0-9]+\\)");
HashSet<string> coveredStates = new(); // set of program states that are expected to be covered by tests
foreach (var modification in cache.Values) {
Expand All @@ -133,7 +135,6 @@ private static void PopulateCoverageReport(CoverageReport coverageReport, Progra
if (!int.TryParse(match.Groups[2].Value, out var lineNumber) || lineNumber == 0) {
continue;
}
lineNumber -= 1; // to zero-based
Uri uri;
try {
uri = new Uri(
Expand All @@ -143,8 +144,12 @@ private static void PopulateCoverageReport(CoverageReport coverageReport, Progra
} catch (ArgumentException) {
continue;
}
var rangeToken = new RangeToken(new Token(lineNumber, 0), new Token(lineNumber + 1, 0));
var linePos = Utils.GetPosFromLine(uri, lineNumber, lineNumToPosCache);
var lineLength = Utils.GetPosFromLine(uri, lineNumber + 1, lineNumToPosCache) - linePos - 1;
var rangeToken = new RangeToken(new Token(lineNumber, 1), new Token(lineNumber, lineLength));
rangeToken.Uri = uri;
rangeToken.StartToken.pos = linePos;
rangeToken.EndToken.pos = linePos + lineLength;
coverageReport.LabelCode(rangeToken,
coveredStates.Contains(state)
? CoverageLabel.FullyCovered
Expand Down
22 changes: 22 additions & 0 deletions Source/DafnyTestGeneration/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,28 @@ public static string GetBlockId(Block block, DafnyOptions options) {
return state == null ? null : Regex.Replace(state, @"\s+", "") + uniqueId;
}

/// <summary>
/// Given a file URI and a one-based line number, return the position of the character at the start of the line.
/// Use a cache to prevent reading the same file twice.
/// </summary>
public static int GetPosFromLine(Uri fileUri, int lineNum, Dictionary<Uri, int[]> cache) {
if (!cache.ContainsKey(fileUri)) {
var source = new StreamReader(fileUri.LocalPath).ReadToEnd();
var lines = source.Split("\n");
var pos = 0;
var line = 0;
var linePositions = new int[lines.Length + 1];
while (pos < source.Length) {
linePositions[line] = pos;
pos += lines[line].Length + 1;
line++;
}
linePositions[^1] = pos;
cache[fileUri] = linePositions;
}
return cache[fileUri][lineNum - 1]; // subtract one because lineNum is one-based
}

public static IList<object> GetAttributeValue(Implementation implementation, string attribute) {
var attributes = implementation.Attributes;
while (attributes != null) {
Expand Down
Loading
Loading