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

Rely on GetUsedAssemblyReferences #20

Merged
merged 25 commits into from
Jan 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisMode>Recommended</AnalysisMode>

<!-- See https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005 -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>

<!-- Enable implicit usings -->
<ImplicitUsings>Enable</ImplicitUsings>

Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ Easily identify which dependencies can be removed from an MSBuild project.
## How to use
Simply add a `PackageReference` to the [ReferenceTrimmer](https://www.nuget.org/packages/ReferenceTrimmer) package in your projects. You can add the package reference to your `Directory.Build.props` or `Directory.Build.targets` instead to apply to the entire repo.

The package contains build logic to emit warnings when unused dependencies are detected.
The package contains build logic to emit warnings when unused dependencies are detected. The logic relies on [`GetUsedAssemblyReferences`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.compilation.getusedassemblyreferences) analyzer API which is available starting with Roslyn compiler that shipped with Visual Studio 2019 version 16.10, .NET 5. (see https://github.com/dotnet/roslyn/blob/main/docs/wiki/NuGet-packages.md#versioning).

Note: to get better effects, enable [`IDE0005`](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005) unnecessary code rule. See also the note for why IDE0005 code analysis rule requires `<GenerateDocumentationFile>` property to be enabled. Documentation generation is also required for accuracy of used references detection (based on https://github.com/dotnet/roslyn/issues/66188).

## Configuration
`$(EnableReferenceTrimmer)` - Controls whether the build logic should run for a given project. Defaults to `true`.

## Future development

The outcome of https://github.com/dotnet/sdk/issues/10414 may be of use for `ReferenceTrimmer` future updates.
11 changes: 10 additions & 1 deletion ReferenceTrimmer.sln
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@


Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.2.32317.152
Expand All @@ -23,6 +23,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{16768D5E-9
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D49A052F-6C94-4B71-81D4-4D1871858F6D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "analyzer", "analyzer", "{20D61165-FED7-496A-9F83-AAD2CA32CFA5}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReferenceTrimmerAnalyzer", "analyzer\ReferenceTrimmerAnalyzer.csproj", "{2C295A5A-5889-494B-8893-66381B000102}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -37,13 +41,18 @@ Global
{9F80F05C-91EA-4D15-B96C-128E7E5E93CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9F80F05C-91EA-4D15-B96C-128E7E5E93CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9F80F05C-91EA-4D15-B96C-128E7E5E93CB}.Release|Any CPU.Build.0 = Release|Any CPU
{2C295A5A-5889-494B-8893-66381B000102}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2C295A5A-5889-494B-8893-66381B000102}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2C295A5A-5889-494B-8893-66381B000102}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2C295A5A-5889-494B-8893-66381B000102}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{D0410117-6DF9-4E70-91BD-312FEDD51299} = {D49A052F-6C94-4B71-81D4-4D1871858F6D}
{9F80F05C-91EA-4D15-B96C-128E7E5E93CB} = {16768D5E-9929-45B8-9222-A4A83C40B005}
{2C295A5A-5889-494B-8893-66381B000102} = {20D61165-FED7-496A-9F83-AAD2CA32CFA5}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {BD73DFDB-4A6B-49B2-B544-7F071454A46A}
Expand Down
10 changes: 10 additions & 0 deletions analyzer/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
; Shipped analyzer releases
; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md

## Release 3.1.0

### New Rules

Rule ID | Category | Severity | Notes
--------|----------|----------|--------------------
DOC001 | Documentation | Warning | See https://github.com/dotnet/roslyn/issues/66188
2 changes: 2 additions & 0 deletions analyzer/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
; Unshipped analyzer release
; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
16 changes: 16 additions & 0 deletions analyzer/ReferenceTrimmerAnalyzer.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- 3.10 is the lowest version the GetUsedAssemblyReferences API is available in.
Upgrading this package automatically upgrades the minimum compiler version this analyzer will work with, so upgrade with care and update README.md accordingly -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" Version="3.10" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="AnalyzerReleases.Shipped.md" />
<AdditionalFiles Include="AnalyzerReleases.Unshipped.md" />
</ItemGroup>
</Project>
48 changes: 48 additions & 0 deletions analyzer/UsedAssemblyReferencesDumper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

namespace ReferenceTrimmer
{
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic, LanguageNames.FSharp)]
public class UsedAssemblyReferencesDumper : DiagnosticAnalyzer
{
private static readonly string Title = "Enable documentation generation for accuracy of used references detection";
private static readonly string Message = "Enable /doc parameter or in MSBuild set <GenerateDocumentationFile>true</GenerateDocumentationFile> for accuracy of used references detection";
private static DiagnosticDescriptor Rule = new DiagnosticDescriptor("DOC001", Title, Message, "Documentation", DiagnosticSeverity.Warning, true);

/// <summary>
/// The supported diagnosticts.
/// </summary>
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);

public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.RegisterCompilationAction(DumpUsedReferences);
}

private static void DumpUsedReferences(CompilationAnalysisContext context)
{
Compilation compilation = context.Compilation;
if (compilation.SyntaxTrees.FirstOrDefault()?.Options.DocumentationMode == DocumentationMode.None)
{
string? nameOrPath = compilation.Options.ModuleName;
Location location = Location.None;
context.ReportDiagnostic(Diagnostic.Create(Rule, location, nameOrPath));
}

if (compilation.Options.Errors.IsEmpty)
{
AdditionalText? analyzerOutputFile = context.Options.AdditionalFiles.FirstOrDefault(file => file.Path.EndsWith("_ReferenceTrimmer_GetUsedAssemblyReferences.txt", StringComparison.OrdinalIgnoreCase));
if (analyzerOutputFile != null)
{
IEnumerable<MetadataReference> usedReferences = compilation.GetUsedAssemblyReferences();
Directory.CreateDirectory(Path.GetDirectoryName(analyzerOutputFile.Path));
File.WriteAllLines(analyzerOutputFile.Path, usedReferences.Select(reference => reference.Display));
}
}
}
}
}
18 changes: 16 additions & 2 deletions src/ReferenceTrimmer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
<TargetFramework>netstandard2.0</TargetFramework>
<BuildOutputTargetFolder>build\</BuildOutputTargetFolder>
<DevelopmentDependency>true</DevelopmentDependency>
<!-- This package contains MSBuild tasks only, so avoid dependencies. -->
<!-- This package contains MSBuild task and an analyzer, so no need for extra dependencies. -->
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);_AddAnalyzersToOutput</TargetsForTfmSpecificContentInPackage>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Build.Utilities.Core" Version="17.3.1" />
<PackageReference Include="NuGet.ProjectModel" Version="6.3.0" />
<PackageReference Include="System.Reflection.Metadata" Version="6.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\analyzer\ReferenceTrimmerAnalyzer.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<None Include="build\*">
Expand All @@ -22,8 +25,19 @@
<Pack>true</Pack>
<PackagePath>buildMultiTargeting\</PackagePath>
</None>
<None Update="tools\*.ps1">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Pack>true</Pack>
<PackagePath>tools\</PackagePath>
</None>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ReferenceTrimmer.Tests" />
</ItemGroup>

<Target Name="_AddAnalyzersToOutput">
<ItemGroup>
<TfmSpecificPackageFile Include="..\analyzer\$(IntermediateOutputPath)ReferenceTrimmerAnalyzer.dll" PackagePath="analyzers/dotnet" />
</ItemGroup>
</Target>
</Project>
71 changes: 25 additions & 46 deletions src/ReferenceTrimmerTask.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.Reflection;
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using System.Reflection;
using System.Xml.Linq;
using Microsoft.Build.Framework;
using NuGet.Common;
Expand All @@ -16,7 +14,7 @@ public sealed class ReferenceTrimmerTask : MSBuildTask
{
// Direct dependency
"NuGet.ProjectModel",

// Indirect dependencies
"NuGet.Common",
"NuGet.Frameworks",
Expand All @@ -25,9 +23,9 @@ public sealed class ReferenceTrimmerTask : MSBuildTask
};

[Required]
public string OutputAssembly { get; set; }
public string MSBuildProjectFile { get; set; }

public bool NeedsTransitiveAssemblyReferences { get; set; }
public ITaskItem[] UsedReferences { get; set; }

public ITaskItem[] References { get; set; }

Expand Down Expand Up @@ -102,7 +100,7 @@ public override bool Execute()

if (!assemblyReferences.Contains(referenceAssemblyName))
{
Log.LogWarning($"Reference {referenceSpec} can be removed");
LogWarning("Reference {0} can be removed", referenceSpec);
}
}
}
Expand All @@ -115,7 +113,7 @@ public override bool Execute()
if (!assemblyReferences.Contains(projectReferenceAssemblyName.Name))
{
string referenceProjectFile = projectReference.GetMetadata("OriginalProjectReferenceItemSpec");
Log.LogWarning($"ProjectReference {referenceProjectFile} can be removed");
LogWarning("ProjectReference {0} can be removed", referenceProjectFile);
}
}
}
Expand All @@ -132,45 +130,22 @@ public override bool Execute()

if (!packageAssemblies.Any(packageAssembly => assemblyReferences.Contains(packageAssembly)))
{
Log.LogWarning($"PackageReference {packageReference} can be removed");
LogWarning("PackageReference {0} can be removed", packageReference);
}
}
}

return !Log.HasLoggedErrors;
}
finally
{
AppDomain.CurrentDomain.AssemblyResolve -= ResolveAssembly;
}
}

private HashSet<string> GetAssemblyReferences()
{
var assemblyReferences = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var stream = File.OpenRead(OutputAssembly))
using (var peReader = new PEReader(stream))
{
var metadata = peReader.GetMetadataReader(MetadataReaderOptions.ApplyWindowsRuntimeProjections);
if (!metadata.IsAssembly)
{
Log.LogError($"{OutputAssembly} is not an assembly");
return null;
}
return !Log.HasLoggedErrors;
}

foreach (var assemblyReferenceHandle in metadata.AssemblyReferences)
{
AssemblyReference reference = metadata.GetAssemblyReference(assemblyReferenceHandle);
string name = metadata.GetString(reference.Name);
if (!string.IsNullOrEmpty(name))
{
assemblyReferences.Add(name);
}
}
}
private void LogWarning(string message, params object[] messageArgs) => Log.LogWarning(null, null, null, MSBuildProjectFile, 0, 0, 0, 0, message, messageArgs);

return assemblyReferences;
}
private HashSet<string> GetAssemblyReferences() => new(UsedReferences.Select(usedReference => AssemblyName.GetAssemblyName(usedReference.ItemSpec).Name), StringComparer.OrdinalIgnoreCase);

private Dictionary<string, List<string>> GetPackageAssemblies()
{
Expand Down Expand Up @@ -218,6 +193,12 @@ private Dictionary<string, List<string>> GetPackageAssemblies()
})
.ToList();

// Add this package's assemblies, if there are any
stan-sz marked this conversation as resolved.
Show resolved Hide resolved
if (nugetLibraryAssemblies.Count == 0)
{
continue;
}

// Walk up to add assemblies to all packages which directly or indirectly depend on this one.
var seenDependants = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var queue = new Queue<string>();
Expand All @@ -226,18 +207,14 @@ private Dictionary<string, List<string>> GetPackageAssemblies()
{
var packageId = queue.Dequeue();

// Add this package's assemblies, if there are any
if (nugetLibraryAssemblies.Count > 0)
if (!packageAssemblies.TryGetValue(packageId, out var assemblies))
{
if (!packageAssemblies.TryGetValue(packageId, out var assemblies))
{
assemblies = new List<string>();
packageAssemblies.Add(packageId, assemblies);
}

assemblies.AddRange(nugetLibraryAssemblies);
assemblies = new List<string>();
packageAssemblies.Add(packageId, assemblies);
}

assemblies.AddRange(nugetLibraryAssemblies);

// Recurse though dependants
if (nugetDependants.TryGetValue(packageId, out var dependants))
{
Expand Down Expand Up @@ -285,14 +262,16 @@ internal HashSet<string> GetTargetFrameworkAssemblyNames()
{
targetFrameworkAssemblyNames.Add(assemblyName);
}

}
}
}

return targetFrameworkAssemblyNames;
}

/// <summary>
/// Assembly resolution needed for parsing the lock file, needed if the version the task depends on is a different version than MSBuild's
/// </summary>
private Assembly ResolveAssembly(object sender, ResolveEventArgs args)
{
AssemblyName assemblyName = new(args.Name);
Expand Down
29 changes: 23 additions & 6 deletions src/build/ReferenceTrimmer.targets
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,38 @@
<Project>
<UsingTask TaskName="ReferenceTrimmerTask" AssemblyFile="$(ReferenceTrimmerTaskAssembly)" />

<PropertyGroup>
<ReferenceTrimmerGetUsedAssemblyReferencesFile>$(MSBuildProjectDirectory)\$(IntermediateOutputPath)_ReferenceTrimmer_GetUsedAssemblyReferences.txt</ReferenceTrimmerGetUsedAssemblyReferencesFile>
stan-sz marked this conversation as resolved.
Show resolved Hide resolved
<CoreCompileDependsOn>$(CoreCompileDependsOn);DeleteReferenceTrimmerGetUsedAssemblyReferencesFile</CoreCompileDependsOn>
</PropertyGroup>

<ItemGroup Condition="'$(EnableReferenceTrimmer)' != 'false'">
<!-- https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Using%20Additional%20Files.md#in-a-project-file -->
<AdditionalFiles Include="$(ReferenceTrimmerGetUsedAssemblyReferencesFile)" />
<FileWrites Include="$(ReferenceTrimmerGetUsedAssemblyReferencesFile)" />
</ItemGroup>

<Target Name="DeleteReferenceTrimmerGetUsedAssemblyReferencesFile" Condition="'$(EnableReferenceTrimmer)' != 'false'" BeforeTargets="BeforeBuild" >
<Delete Files="$(ReferenceTrimmerGetUsedAssemblyReferencesFile)" Condition="Exists('$(ReferenceTrimmerGetUsedAssemblyReferencesFile)')" />
stan-sz marked this conversation as resolved.
Show resolved Hide resolved
</Target>

<Target Name="VerifyReferences"
Condition="'$(EnableReferenceTrimmer)' != 'false'"
AfterTargets="AfterBuild"
DependsOnTargets="ResolveProjectReferences">
<PropertyGroup>
<!-- Certain project types may require references simply to copy them to the output folder to satisfy transitive dependencies. -->
<_ReferenceTrimmerNeedsTransitiveAssemblyReferences Condition="'$(OutputType)' == 'Exe'">true</_ReferenceTrimmerNeedsTransitiveAssemblyReferences>
</PropertyGroup>
<ItemGroup>
<_ReferenceTrimmerProjectReferences Include="@(ReferencePath)" Condition="'%(ReferencePath.ReferenceSourceTarget)' == 'ProjectReference'" />
</ItemGroup>

<ReadLinesFromFile File="$(ReferenceTrimmerGetUsedAssemblyReferencesFile)">
<Output TaskParameter="Lines" ItemName="UsedReference" />
</ReadLinesFromFile>
<Warning Text="Skipping ReferenceTrimmerTask due to empty file $(ReferenceTrimmerGetUsedAssemblyReferencesFile)" Condition=" '@(UsedReference)' == '' "/>

<ReferenceTrimmerTask
OutputAssembly="$(TargetPath)"
NeedsTransitiveAssemblyReferences="$(_ReferenceTrimmerNeedsTransitiveAssemblyReferences)"
Condition=" '@(UsedReference)' != '' "
MSBuildProjectFile="$(MSBuildProjectFile)"
UsedReferences="@(UsedReference)"
References="@(Reference)"
ProjectReferences="@(_ReferenceTrimmerProjectReferences)"
PackageReferences="@(PackageReference)"
Expand Down
Loading