From 51ea7930008cb581d9bf0d1b356eb8434ccc7c0c Mon Sep 17 00:00:00 2001
From: Sam Harwell <sam@tunnelvisionlabs.com>
Date: Thu, 17 Jan 2019 16:52:51 -0600
Subject: [PATCH] Add a "single hit" collection mode

Fixes #306
---
 src/coverlet.console/Program.cs               |  3 ++-
 src/coverlet.core/Coverage.cs                 |  6 +++--
 .../Instrumentation/Instrumenter.cs           | 26 ++++++++++++++++---
 .../InstrumentationTask.cs                    |  9 ++++++-
 src/coverlet.msbuild/coverlet.msbuild.props   |  1 +
 src/coverlet.msbuild/coverlet.msbuild.targets |  2 ++
 .../ModuleTrackerTemplate.cs                  | 25 +++++++++++++++++-
 test/coverlet.core.tests/CoverageTests.cs     |  2 +-
 .../Instrumentation/InstrumenterTests.cs      |  4 +--
 9 files changed, 66 insertions(+), 12 deletions(-)

diff --git a/src/coverlet.console/Program.cs b/src/coverlet.console/Program.cs
index be50c97c1..1028bc831 100644
--- a/src/coverlet.console/Program.cs
+++ b/src/coverlet.console/Program.cs
@@ -37,6 +37,7 @@ static int Main(string[] args)
             CommandOption excludedSourceFiles = app.Option("--exclude-by-file", "Glob patterns specifying source files to exclude.", CommandOptionType.MultipleValue);
             CommandOption includeDirectories = app.Option("--include-directory", "Include directories containing additional assemblies to be instrumented.", CommandOptionType.MultipleValue);
             CommandOption excludeAttributes = app.Option("--exclude-by-attribute", "Attributes to exclude from code coverage.", CommandOptionType.MultipleValue);
+            CommandOption singleHit = app.Option("--single-hit", "Specifies whether to limit code coverage hit reporting to a single hit for each location", CommandOptionType.NoValue);
             CommandOption mergeWith = app.Option("--merge-with", "Path to existing coverage result to merge.", CommandOptionType.SingleValue);
             CommandOption useSourceLink = app.Option("--use-source-link", "Specifies whether to use SourceLink URIs in place of file system paths.", CommandOptionType.NoValue);
 
@@ -48,7 +49,7 @@ static int Main(string[] args)
                 if (!target.HasValue())
                     throw new CommandParsingException(app, "Target must be specified.");
 
-                Coverage coverage = new Coverage(module.Value, includeFilters.Values.ToArray(), includeDirectories.Values.ToArray(), excludeFilters.Values.ToArray(), excludedSourceFiles.Values.ToArray(), excludeAttributes.Values.ToArray(), mergeWith.Value(), useSourceLink.HasValue());
+                Coverage coverage = new Coverage(module.Value, includeFilters.Values.ToArray(), includeDirectories.Values.ToArray(), excludeFilters.Values.ToArray(), excludedSourceFiles.Values.ToArray(), excludeAttributes.Values.ToArray(), singleHit.HasValue(), mergeWith.Value(), useSourceLink.HasValue());
                 coverage.PrepareModules();
 
                 Process process = new Process();
diff --git a/src/coverlet.core/Coverage.cs b/src/coverlet.core/Coverage.cs
index cd1cf6af1..e72a63992 100644
--- a/src/coverlet.core/Coverage.cs
+++ b/src/coverlet.core/Coverage.cs
@@ -22,6 +22,7 @@ public class Coverage
         private string[] _excludeFilters;
         private string[] _excludedSourceFiles;
         private string[] _excludeAttributes;
+        private bool _singleHit;
         private string _mergeWith;
         private bool _useSourceLink;
         private List<InstrumenterResult> _results;
@@ -31,7 +32,7 @@ public string Identifier
             get { return _identifier; }
         }
 
-        public Coverage(string module, string[] includeFilters, string[] includeDirectories, string[] excludeFilters, string[] excludedSourceFiles, string[] excludeAttributes, string mergeWith, bool useSourceLink)
+        public Coverage(string module, string[] includeFilters, string[] includeDirectories, string[] excludeFilters, string[] excludedSourceFiles, string[] excludeAttributes, bool singleHit, string mergeWith, bool useSourceLink)
         {
             _module = module;
             _includeFilters = includeFilters;
@@ -39,6 +40,7 @@ public Coverage(string module, string[] includeFilters, string[] includeDirector
             _excludeFilters = excludeFilters;
             _excludedSourceFiles = excludedSourceFiles;
             _excludeAttributes = excludeAttributes;
+            _singleHit = singleHit;
             _mergeWith = mergeWith;
             _useSourceLink = useSourceLink;
 
@@ -59,7 +61,7 @@ public void PrepareModules()
                     !InstrumentationHelper.IsModuleIncluded(module, _includeFilters))
                     continue;
 
-                var instrumenter = new Instrumenter(module, _identifier, _excludeFilters, _includeFilters, excludes, _excludeAttributes);
+                var instrumenter = new Instrumenter(module, _identifier, _excludeFilters, _includeFilters, excludes, _excludeAttributes, _singleHit);
                 if (instrumenter.CanInstrument())
                 {
                     InstrumentationHelper.BackupOriginalModule(module, _identifier);
diff --git a/src/coverlet.core/Instrumentation/Instrumenter.cs b/src/coverlet.core/Instrumentation/Instrumenter.cs
index 58fc5f9f9..2bece3356 100644
--- a/src/coverlet.core/Instrumentation/Instrumenter.cs
+++ b/src/coverlet.core/Instrumentation/Instrumenter.cs
@@ -23,17 +23,19 @@ internal class Instrumenter
         private readonly string[] _includeFilters;
         private readonly string[] _excludedFiles;
         private readonly string[] _excludedAttributes;
+        private readonly bool _singleHit;
         private readonly bool _isCoreLibrary;
         private InstrumenterResult _result;
         private FieldDefinition _customTrackerHitsArray;
         private FieldDefinition _customTrackerHitsFilePath;
+        private FieldDefinition _customTrackerSingleHit;
         private ILProcessor _customTrackerClassConstructorIl;
         private TypeDefinition _customTrackerTypeDef;
         private MethodReference _customTrackerRegisterUnloadEventsMethod;
         private MethodReference _customTrackerRecordHitMethod;
         private List<string> _asyncMachineStateMethod;
 
-        public Instrumenter(string module, string identifier, string[] excludeFilters, string[] includeFilters, string[] excludedFiles, string[] excludedAttributes)
+        public Instrumenter(string module, string identifier, string[] excludeFilters, string[] includeFilters, string[] excludedFiles, string[] excludedAttributes, bool singleHit)
         {
             _module = module;
             _identifier = identifier;
@@ -41,6 +43,7 @@ public Instrumenter(string module, string identifier, string[] excludeFilters, s
             _includeFilters = includeFilters;
             _excludedFiles = excludedFiles ?? Array.Empty<string>();
             _excludedAttributes = excludedAttributes;
+            _singleHit = singleHit;
 
             _isCoreLibrary = Path.GetFileNameWithoutExtension(_module) == "System.Private.CoreLib";
         }
@@ -125,6 +128,8 @@ private void InstrumentModule()
                     _customTrackerClassConstructorIl.InsertBefore(lastInstr, Instruction.Create(OpCodes.Stsfld, _customTrackerHitsArray));
                     _customTrackerClassConstructorIl.InsertBefore(lastInstr, Instruction.Create(OpCodes.Ldstr, _result.HitsFilePath));
                     _customTrackerClassConstructorIl.InsertBefore(lastInstr, Instruction.Create(OpCodes.Stsfld, _customTrackerHitsFilePath));
+                    _customTrackerClassConstructorIl.InsertBefore(lastInstr, Instruction.Create(_singleHit ? OpCodes.Ldc_I4_1 : OpCodes.Ldc_I4_0));
+                    _customTrackerClassConstructorIl.InsertBefore(lastInstr, Instruction.Create(OpCodes.Stsfld, _customTrackerSingleHit));
 
                     if (containsAppContext)
                     {
@@ -174,6 +179,8 @@ private void AddCustomModuleTrackerToModule(ModuleDefinition module)
                         _customTrackerHitsArray = fieldClone;
                     else if (fieldClone.Name == nameof(ModuleTrackerTemplate.HitsFilePath))
                         _customTrackerHitsFilePath = fieldClone;
+                    else if (fieldClone.Name == nameof(ModuleTrackerTemplate.SingleHit))
+                        _customTrackerSingleHit = fieldClone;
                 }
 
                 foreach (MethodDefinition methodDef in moduleTrackerTemplate.Methods)
@@ -426,9 +433,20 @@ private Instruction AddInstrumentationInstructions(MethodDefinition method, ILPr
         {
             if (_customTrackerRecordHitMethod == null)
             {
-                var recordHitMethodName = _isCoreLibrary
-                    ? nameof(ModuleTrackerTemplate.RecordHitInCoreLibrary)
-                    : nameof(ModuleTrackerTemplate.RecordHit);
+                string recordHitMethodName;
+                if (_singleHit)
+                {
+                    recordHitMethodName = _isCoreLibrary
+                        ? nameof(ModuleTrackerTemplate.RecordSingleHitInCoreLibrary)
+                        : nameof(ModuleTrackerTemplate.RecordSingleHit);
+                }
+                else
+                {
+                    recordHitMethodName = _isCoreLibrary
+                        ? nameof(ModuleTrackerTemplate.RecordHitInCoreLibrary)
+                        : nameof(ModuleTrackerTemplate.RecordHit);
+                }
+
                 _customTrackerRecordHitMethod = new MethodReference(
                     recordHitMethodName, method.Module.TypeSystem.Void, _customTrackerTypeDef);
                 _customTrackerRecordHitMethod.Parameters.Add(new ParameterDefinition("hitLocationIndex", ParameterAttributes.None, method.Module.TypeSystem.Int32));
diff --git a/src/coverlet.msbuild.tasks/InstrumentationTask.cs b/src/coverlet.msbuild.tasks/InstrumentationTask.cs
index 542f98c1f..4883586bf 100644
--- a/src/coverlet.msbuild.tasks/InstrumentationTask.cs
+++ b/src/coverlet.msbuild.tasks/InstrumentationTask.cs
@@ -14,6 +14,7 @@ public class InstrumentationTask : Task
         private string _exclude;
         private string _excludeByFile;
         private string _excludeByAttribute;
+        private bool _singleHit;
         private string _mergeWith;
         private bool _useSourceLink;
 
@@ -59,6 +60,12 @@ public string ExcludeByAttribute
             set { _excludeByAttribute = value; }
         }
 
+        public bool SingleHit
+        {
+            get { return _singleHit; }
+            set { _singleHit = value; }
+        }
+
         public string MergeWith
         {
             get { return _mergeWith; }
@@ -81,7 +88,7 @@ public override bool Execute()
                 var excludedSourceFiles = _excludeByFile?.Split(',');
                 var excludeAttributes = _excludeByAttribute?.Split(',');
 
-                _coverage = new Coverage(_path, includeFilters, includeDirectories, excludeFilters, excludedSourceFiles, excludeAttributes, _mergeWith, _useSourceLink);
+                _coverage = new Coverage(_path, includeFilters, includeDirectories, excludeFilters, excludedSourceFiles, excludeAttributes, _singleHit, _mergeWith, _useSourceLink);
                 _coverage.PrepareModules();
             }
             catch (Exception ex)
diff --git a/src/coverlet.msbuild/coverlet.msbuild.props b/src/coverlet.msbuild/coverlet.msbuild.props
index 4be3b8011..9910db827 100644
--- a/src/coverlet.msbuild/coverlet.msbuild.props
+++ b/src/coverlet.msbuild/coverlet.msbuild.props
@@ -6,6 +6,7 @@
     <Exclude Condition="$(Exclude) == ''"></Exclude>
     <ExcludeByFile Condition="$(ExcludeByFile) == ''"></ExcludeByFile>
     <ExcludeByAttribute Condition="$(ExcludeByAttribute) == ''"></ExcludeByAttribute>
+    <CoverletSingleHit Condition="'$(CoverletSingleHit)' == ''">false</CoverletSingleHit>
     <MergeWith Condition="$(MergeWith) == ''"></MergeWith>
     <UseSourceLink Condition="$(UseSourceLink) == ''">false</UseSourceLink>
     <CoverletOutputFormat Condition="$(CoverletOutputFormat) == ''">json</CoverletOutputFormat>
diff --git a/src/coverlet.msbuild/coverlet.msbuild.targets b/src/coverlet.msbuild/coverlet.msbuild.targets
index ee5cd1c82..c673586a1 100644
--- a/src/coverlet.msbuild/coverlet.msbuild.targets
+++ b/src/coverlet.msbuild/coverlet.msbuild.targets
@@ -12,6 +12,7 @@
       Exclude="$(Exclude)"
       ExcludeByFile="$(ExcludeByFile)"
       ExcludeByAttribute="$(ExcludeByAttribute)"
+      SingleHit="$(CoverletSingleHit)"
       MergeWith="$(MergeWith)"
       UseSourceLink="$(UseSourceLink)" />
   </Target>
@@ -25,6 +26,7 @@
       Exclude="$(Exclude)"
       ExcludeByFile="$(ExcludeByFile)"
       ExcludeByAttribute="$(ExcludeByAttribute)"
+      SingleHit="$(CoverletSingleHit)"
       MergeWith="$(MergeWith)"
       UseSourceLink="$(UseSourceLink)" />
   </Target>
diff --git a/src/coverlet.template/ModuleTrackerTemplate.cs b/src/coverlet.template/ModuleTrackerTemplate.cs
index 2bb959971..4689a56f3 100644
--- a/src/coverlet.template/ModuleTrackerTemplate.cs
+++ b/src/coverlet.template/ModuleTrackerTemplate.cs
@@ -18,6 +18,7 @@ public static class ModuleTrackerTemplate
     {
         public static string HitsFilePath;
         public static int[] HitsArray;
+        public static bool SingleHit;
 
         static ModuleTrackerTemplate()
         {
@@ -50,6 +51,25 @@ public static void RecordHit(int hitLocationIndex)
             Interlocked.Increment(ref HitsArray[hitLocationIndex]);
         }
 
+        public static void RecordSingleHitInCoreLibrary(int hitLocationIndex)
+        {
+            // Make sure to avoid recording if this is a call to RecordHit within the AppDomain setup code in an
+            // instrumented build of System.Private.CoreLib.
+            if (HitsArray is null)
+                return;
+
+            ref int location = ref HitsArray[hitLocationIndex];
+            if (location == 0)
+                location = 1;
+        }
+
+        public static void RecordSingleHit(int hitLocationIndex)
+        {
+            ref int location = ref HitsArray[hitLocationIndex];
+            if (location == 0)
+                location = 1;
+        }
+
         public static void UnloadModule(object sender, EventArgs e)
         {
             // Claim the current hits array and reset it to prevent double-counting scenarios.
@@ -99,7 +119,10 @@ public static void UnloadModule(object sender, EventArgs e)
                         {
                             int oldHitCount = br.ReadInt32();
                             bw.Seek(-sizeof(int), SeekOrigin.Current);
-                            bw.Write(hitsArray[i] + oldHitCount);
+                            if (SingleHit)
+                                bw.Write(hitsArray[i] + oldHitCount > 0 ? 1 : 0);
+                            else
+                                bw.Write(hitsArray[i] + oldHitCount);
                         }
                     }
                 }
diff --git a/test/coverlet.core.tests/CoverageTests.cs b/test/coverlet.core.tests/CoverageTests.cs
index b757fe664..f02d7fed2 100644
--- a/test/coverlet.core.tests/CoverageTests.cs
+++ b/test/coverlet.core.tests/CoverageTests.cs
@@ -24,7 +24,7 @@ public void TestCoverage()
 
             // TODO: Find a way to mimick hits
 
-            var coverage = new Coverage(Path.Combine(directory.FullName, Path.GetFileName(module)), Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), string.Empty, false);
+            var coverage = new Coverage(Path.Combine(directory.FullName, Path.GetFileName(module)), Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), false, string.Empty, false);
             coverage.PrepareModules();
 
             var result = coverage.GetCoverageResult();
diff --git a/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs b/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs
index 721a0edf3..e67bc2084 100644
--- a/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs
+++ b/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs
@@ -27,7 +27,7 @@ public void TestCoreLibInstrumentation()
             foreach (var file in files)
                 File.Copy(Path.Combine(OriginalFilesDir, file), Path.Combine(TestFilesDir, file), overwrite: true);
 
-            Instrumenter instrumenter = new Instrumenter(Path.Combine(TestFilesDir, files[0]), "_coverlet_instrumented", Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>());
+            Instrumenter instrumenter = new Instrumenter(Path.Combine(TestFilesDir, files[0]), "_coverlet_instrumented", Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), false);
             Assert.True(instrumenter.CanInstrument());
             var result = instrumenter.Instrument();
             Assert.NotNull(result);
@@ -119,7 +119,7 @@ private InstrumenterTest CreateInstrumentor(bool fakeCoreLibModule = false, stri
             File.Copy(pdb, Path.Combine(directory.FullName, destPdb), true);
 
             module = Path.Combine(directory.FullName, destModule);
-            Instrumenter instrumenter = new Instrumenter(module, identifier, Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), attributesToIgnore);
+            Instrumenter instrumenter = new Instrumenter(module, identifier, Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), attributesToIgnore, false);
             return new InstrumenterTest
             {
                 Instrumenter = instrumenter,