Skip to content

Commit

Permalink
Add use-mapping-file to coverlet.console (#1568)
Browse files Browse the repository at this point in the history
Adds the option --use-mapping-file to coverlet.console that allows the
caller to specify a custom source mapping file to use. This is used to
then maps paths located in an assembly's debug symbols to local path
when collecting coverage.

8
  • Loading branch information
jborean93 committed Jan 16, 2024
1 parent 1981476 commit a0bd87d
Show file tree
Hide file tree
Showing 4 changed files with 52 additions and 10 deletions.
13 changes: 13 additions & 0 deletions Documentation/GlobalTool.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Options:
--use-source-link Specifies whether to use SourceLink URIs in place of file system paths.
--does-not-return-attribute Attributes that mark methods that do not return.
--exclude-assemblies-without-sources Specifies behaviour of heuristic to ignore assemblies with missing source documents.
--use-mapping-file Specifies the path to a SourceRootsMappings file.
--version Show version information
-?, -h, --help Show help and usage information
```
Expand Down Expand Up @@ -237,6 +238,18 @@ You can also include coverage of the test assembly itself by specifying the `--i

Coverlet supports [SourceLink](https://github.com/dotnet/sourcelink) custom debug information contained in PDBs. When you specify the `--use-source-link` flag, Coverlet will generate results that contain the URL to the source files in your source control instead of local file paths.

## Path Mappings

Coverlet has the ability to map the paths contained inside the debug sources into a local path where the source is currently located using the option `--source-mapping-file`. This is useful if the source was built using a deterministic build which sets the path to `/_/` or if it was built on a different host where the source is located in a different path.

The value for `--source-mapping-file` should be a file with each line being in the format `|path to map to=path in debug symbol`. For example to map the local checkout of a project `C:\git\coverlet` to project that was built with `<Deterministic>true</Deterministic>` which sets the sources to `/_/*` the following line must be in the mapping file.

```
|C:\git\coverlet\=/_/
```

During coverage collection, Coverlet will translate any path that starts with `/_/` to `C:\git\coverlet\` allowing the collector to find the source file.

## Exit Codes

Coverlet outputs specific exit codes to better support build automation systems for determining the kind of failure so the appropriate action can be taken.
Expand Down
13 changes: 9 additions & 4 deletions src/coverlet.console/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ static int Main(string[] args)
var useSourceLink = new Option<bool>("--use-source-link", "Specifies whether to use SourceLink URIs in place of file system paths.") { Arity = ArgumentArity.Zero };
var doesNotReturnAttributes = new Option<string[]>("--does-not-return-attribute", "Attributes that mark methods that do not return") { Arity = ArgumentArity.ZeroOrMore };
var excludeAssembliesWithoutSources = new Option<string>("--exclude-assemblies-without-sources", "Specifies behaviour of heuristic to ignore assemblies with missing source documents.") { Arity = ArgumentArity.ZeroOrOne };
var sourceMappingFile = new Option<string>("--source-mapping-file", "Specifies the path to a SourceRootsMappings file.") { Arity = ArgumentArity.ZeroOrOne };

RootCommand rootCommand = new()
{
Expand All @@ -71,7 +72,8 @@ static int Main(string[] args)
mergeWith,
useSourceLink,
doesNotReturnAttributes,
excludeAssembliesWithoutSources
excludeAssembliesWithoutSources,
sourceMappingFile
};

rootCommand.Description = "Cross platform .NET Core code coverage tool";
Expand Down Expand Up @@ -99,6 +101,7 @@ static int Main(string[] args)
bool useSourceLinkValue = context.ParseResult.GetValueForOption(useSourceLink);
string[] doesNotReturnAttributesValue = context.ParseResult.GetValueForOption(doesNotReturnAttributes);
string excludeAssembliesWithoutSourcesValue = context.ParseResult.GetValueForOption(excludeAssembliesWithoutSources);
string sourceMappingFileValue = context.ParseResult.GetValueForOption(sourceMappingFile);
if (string.IsNullOrEmpty(moduleOrAppDirectoryValue) || string.IsNullOrWhiteSpace(moduleOrAppDirectoryValue))
throw new ArgumentException("No test assembly or application directory specified.");
Expand All @@ -123,7 +126,8 @@ static int Main(string[] args)
mergeWithValue,
useSourceLinkValue,
doesNotReturnAttributesValue,
excludeAssembliesWithoutSourcesValue);
excludeAssembliesWithoutSourcesValue,
sourceMappingFileValue);
context.ExitCode = taskStatus;
});
Expand All @@ -149,7 +153,8 @@ private static Task<int> HandleCommand(string moduleOrAppDirectory,
string mergeWith,
bool useSourceLink,
string[] doesNotReturnAttributes,
string excludeAssembliesWithoutSources
string excludeAssembliesWithoutSources,
string sourceMappingFile
)
{

Expand All @@ -160,7 +165,7 @@ string excludeAssembliesWithoutSources
serviceCollection.AddTransient<ILogger, ConsoleLogger>();
// We need to keep singleton/static semantics
serviceCollection.AddSingleton<IInstrumentationHelper, InstrumentationHelper>();
serviceCollection.AddSingleton<ISourceRootTranslator, SourceRootTranslator>(provider => new SourceRootTranslator(provider.GetRequiredService<ILogger>(), provider.GetRequiredService<IFileSystem>()));
serviceCollection.AddSingleton<ISourceRootTranslator, SourceRootTranslator>(provider => new SourceRootTranslator(sourceMappingFile, provider.GetRequiredService<ILogger>(), provider.GetRequiredService<IFileSystem>()));
serviceCollection.AddSingleton<ICecilSymbolHelper, CecilSymbolHelper>();

ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider();
Expand Down
17 changes: 11 additions & 6 deletions src/coverlet.core/Helpers/SourceRootTranslator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ internal class SourceRootTranslator : ISourceRootTranslator
private readonly IFileSystem _fileSystem;
private readonly Dictionary<string, List<SourceRootMapping>> _sourceRootMapping;
private readonly Dictionary<string, List<string>> _sourceToDeterministicPathMapping;
private readonly string _mappingFileName;
private Dictionary<string, string> _resolutionCacheFiles;

public SourceRootTranslator(ILogger logger, IFileSystem fileSystem)
Expand All @@ -32,6 +31,13 @@ public SourceRootTranslator(ILogger logger, IFileSystem fileSystem)
_sourceRootMapping = new Dictionary<string, List<SourceRootMapping>>();
}

public SourceRootTranslator(string sourceMappingFile, ILogger logger, IFileSystem fileSystem)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_sourceRootMapping = LoadSourceRootMapping(sourceMappingFile);
}

public SourceRootTranslator(string moduleTestPath, ILogger logger, IFileSystem fileSystem, IAssemblyAdapter assemblyAdapter)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
Expand All @@ -46,11 +52,11 @@ public SourceRootTranslator(string moduleTestPath, ILogger logger, IFileSystem f
}

string assemblyName = assemblyAdapter.GetAssemblyName(moduleTestPath);
_mappingFileName = $"CoverletSourceRootsMapping_{assemblyName}";
string mappingFileName = $"CoverletSourceRootsMapping_{assemblyName}";

_logger.LogInformation($"_mapping file name: '{_mappingFileName}'", true);
_logger.LogInformation($"_mapping file name: '{mappingFileName}'", true);

_sourceRootMapping = LoadSourceRootMapping(Path.GetDirectoryName(moduleTestPath));
_sourceRootMapping = LoadSourceRootMapping(Path.Combine(Path.GetDirectoryName(moduleTestPath), mappingFileName));
_sourceToDeterministicPathMapping = LoadSourceToDeterministicPathMapping(_sourceRootMapping);
}

Expand All @@ -77,11 +83,10 @@ private static Dictionary<string, List<string>> LoadSourceToDeterministicPathMap
return sourceToDeterministicPathMapping;
}

private Dictionary<string, List<SourceRootMapping>> LoadSourceRootMapping(string directory)
private Dictionary<string, List<SourceRootMapping>> LoadSourceRootMapping(string mappingFilePath)
{
var mapping = new Dictionary<string, List<SourceRootMapping>>();

string mappingFilePath = Path.Combine(directory, _mappingFileName);
if (!_fileSystem.Exists(mappingFilePath))
{
return mapping;
Expand Down
19 changes: 19 additions & 0 deletions test/coverlet.core.tests/Helpers/SourceRootTranslatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,25 @@ public void TranslatePathRoot_Success()
Assert.Equal(@"C:\git\coverlet\", translator.ResolvePathRoot("/_/")[0].OriginalPath);
}

[ConditionalFact]
[SkipOnOS(OS.Linux, "Windows path format only")]
[SkipOnOS(OS.MacOS, "Windows path format only")]
public void TranslateWithDirectFile_Success()
{
var logger = new Mock<ILogger>();
var assemblyAdapter = new Mock<IAssemblyAdapter>();
assemblyAdapter.Setup(x => x.GetAssemblyName(It.IsAny<string>())).Returns("testLib");
var fileSystem = new Mock<IFileSystem>();
fileSystem.Setup(f => f.Exists(It.IsAny<string>())).Returns((string p) =>
{
if (p == "testLib.dll" || p == @"C:\git\coverlet\src\coverlet.core\obj\Debug\netstandard2.0\coverlet.core.pdb" || p == "CoverletSourceRootsMapping_testLib") return true;
return false;
});
fileSystem.Setup(f => f.ReadAllLines(It.IsAny<string>())).Returns(File.ReadAllLines(@"TestAssets/CoverletSourceRootsMappingTest"));
var translator = new SourceRootTranslator("CoverletSourceRootsMapping_testLib", logger.Object, fileSystem.Object);
Assert.Equal(@"C:\git\coverlet\", translator.ResolvePathRoot("/_/")[0].OriginalPath);
}

[Fact]
public void Translate_EmptyFile()
{
Expand Down

0 comments on commit a0bd87d

Please sign in to comment.