Skip to content

Commit

Permalink
Support multi-targeting for Roslyn components
Browse files Browse the repository at this point in the history
Allows for Roslyn components (analyzers, source generators) to target mulltiple Roslyn API versions in a single package. The highest compatible asset is selected.

Fix dotnet#20355
  • Loading branch information
eerhardt committed Sep 7, 2021
1 parent 0768104 commit 98e5b09
Show file tree
Hide file tree
Showing 10 changed files with 329 additions and 6 deletions.
9 changes: 9 additions & 0 deletions src/Assets/TestPackages/Library.ContainsAnalyzer/Class1.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Library.ContainsAnalyzer
{
public class Class1
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/roslyn3.9/cs" Visible="false" />
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/roslyn4.0/cs" Visible="false" />
</ItemGroup>
</Project>
9 changes: 9 additions & 0 deletions src/Assets/TestPackages/Library.ContainsAnalyzer2/Class1.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Library.ContainsAnalyzer2
{
public class Class2
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/roslyn3.7/cs" Visible="false" />
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/roslyn3.8/cs" Visible="false" />
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/roslyn3.10/cs" Visible="false" />
</ItemGroup>

</Project>
164 changes: 162 additions & 2 deletions src/Tasks/Microsoft.NET.Build.Tasks/NuGetUtils.NuGet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,167 @@ public static NuGetFramework ParseFrameworkName(string frameworkName)
return frameworkName == null ? null : NuGetFramework.Parse(frameworkName);
}

public static bool IsApplicableAnalyzer(string file, string projectLanguage)
/// <summary>
/// Gets a collection of 'analyzer' assets that should be excluded based on the
/// <paramref name="compilerApiVersion"/>.
/// </summary>
/// <remarks>
/// This allows packages to ship multiple analyzers that target different versions
/// of the compiler. For example, a package may include:
///
/// "analyzers/dotnet/roslyn3.7/analyzer.dll"
/// "analyzers/dotnet/roslyn3.8/analyzer.dll"
/// "analyzers/dotnet/roslyn4.0/analyzer.dll"
///
/// When the <paramref name="compilerApiVersion"/> is 'roslyn3.9', only the assets
/// in the folder with the highest applicable compiler version are picked.
/// In this case,
///
/// "analyzers/dotnet/roslyn3.8/analyzer.dll"
///
/// will be picked, and the other analyzer assets will be excluded.
///
/// "analyzers/dotnet/roslyn3.7/analyzer.dll"
/// "analyzers/dotnet/roslyn4.0/analyzer.dll"
///
/// will be returned, since they should be excluded.
/// </remarks>
public static HashSet<string>? GetExcludedAnalyzers(LockFile lockFile, string projectLanguage, string compilerApiVersion)
{
if (!ParseCompilerApiVersion(compilerApiVersion, out ReadOnlyMemory<char> compilerName, out Version compilerVersion))
{
return null;
}

HashSet<string> excludedAnalyzers = null;

// gather all the potential analyzers contained in a folder for the current compiler
Version maxApplicableVersion = null;
List<(string, Version)> potentialAnalyzers = null;

#if NETFRAMEWORK
string compilerSearchString = "/" + compilerName;
#else
string compilerSearchString = string.Concat("/".AsSpan(), compilerName.Span);
#endif
foreach (var library in lockFile.Libraries)
{
if (!library.IsPackage())
{
continue;
}

foreach (var file in library.Files)
{
if (IsApplicableAnalyzer(file, projectLanguage, excludedAnalyzers: null))
{
int compilerNameStart = file.IndexOf(compilerSearchString);
if (compilerNameStart == -1)
{
continue;
}

int compilerVersionStart = compilerNameStart + compilerSearchString.Length;
int compilerVersionStop = file.IndexOf('/', compilerVersionStart);
if (compilerVersionStop == -1)
{
continue;
}

if (!TryParseVersion(file, compilerVersionStart, compilerVersionStop - compilerVersionStart, out Version fileCompilerVersion))
{
continue;
}

// version is too high - add to exclude list
if (fileCompilerVersion > compilerVersion)
{
excludedAnalyzers ??= new HashSet<string>();
excludedAnalyzers.Add(file);
}
else
{
potentialAnalyzers ??= new List<(string, Version)>();
potentialAnalyzers.Add((file, fileCompilerVersion));

if (maxApplicableVersion == null || fileCompilerVersion > maxApplicableVersion)
{
maxApplicableVersion = fileCompilerVersion;
}
}
}
}

if (maxApplicableVersion != null && potentialAnalyzers?.Count > 0)
{
foreach (var (file, version) in potentialAnalyzers)
{
if (version != maxApplicableVersion)
{
excludedAnalyzers ??= new HashSet<string>();
excludedAnalyzers.Add(file);
}
}
}

maxApplicableVersion = null;
potentialAnalyzers?.Clear();
}

return excludedAnalyzers;
}

/// <summary>
/// Parses the <paramref name="compilerApiVersion"/> string into its component parts:
/// compilerName:, e.g. "roslyn"
/// compilerVersion: e.g. 3.9
/// </summary>
private static bool ParseCompilerApiVersion(string compilerApiVersion, out ReadOnlyMemory<char> compilerName, out Version compilerVersion)
{
compilerName = default;
compilerVersion = default;

if (string.IsNullOrEmpty(compilerApiVersion))
{
return false;
}

int compilerVersionStart = -1;
for (int i = 0; i < compilerApiVersion.Length; i++)
{
if (char.IsDigit(compilerApiVersion[i]))
{
compilerVersionStart = i;
break;
}
}

if (compilerVersionStart > 0)
{
if (TryParseVersion(compilerApiVersion, compilerVersionStart, out compilerVersion))
{
compilerName = compilerApiVersion.AsMemory(0, compilerVersionStart);
return true;
}
}

// didn't find a compiler name or version
return false;
}

private static bool TryParseVersion(string value, int startIndex, out Version version) =>
TryParseVersion(value, startIndex, value.Length - startIndex, out version);

private static bool TryParseVersion(string value, int startIndex, int length, out Version version)
{
#if NETFRAMEWORK
return Version.TryParse(value.Substring(startIndex, length), out version);
#else
return Version.TryParse(value.AsSpan(startIndex, length), out version);
#endif
}

public static bool IsApplicableAnalyzer(string file, string projectLanguage, HashSet<string>? excludedAnalyzers)
{
// This logic is preserved from previous implementations.
// See https://github.com/NuGet/Home/issues/6279#issuecomment-353696160 for possible issues with it.
Expand Down Expand Up @@ -77,7 +237,7 @@ bool FileMatchesProjectLanguage()
}
}

return IsAnalyzer() && FileMatchesProjectLanguage();
return IsAnalyzer() && FileMatchesProjectLanguage() && excludedAnalyzers?.Contains(file) != true;
}

public static string GetBestMatchingRid(RuntimeGraph runtimeGraph, string runtimeIdentifier,
Expand Down
11 changes: 10 additions & 1 deletion src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ public sealed class ResolvePackageAssets : TaskBase
/// </summary>
public string ProjectLanguage { get; set; }

/// <summary>
/// Optional version of the compiler API (E.g. 'roslyn3.9', 'roslyn4.0')
/// Impacts applicability of analyzer assets.
/// </summary>
public string CompilerApiVersion { get; set; }

/// <summary>
/// Check that there is at least one package dependency in the RID graph that is not in the RID-agnostic graph.
/// Used as a heuristic to detect invalid RIDs.
Expand Down Expand Up @@ -425,6 +431,7 @@ internal byte[] HashSettings()
}
}
writer.Write(ProjectLanguage ?? "");
writer.Write(CompilerApiVersion ?? "");
writer.Write(ProjectPath);
writer.Write(RuntimeIdentifier ?? "");
if (ShimRuntimeIdentifiers != null)
Expand Down Expand Up @@ -848,6 +855,8 @@ public int GetHashCode((string, NuGetVersion) library)

private void WriteAnalyzers()
{
HashSet<string>? excludedAnalyzers = NuGetUtils.GetExcludedAnalyzers(_lockFile, _task.ProjectLanguage, _task.CompilerApiVersion);

Dictionary<(string, NuGetVersion), LockFileTargetLibrary> targetLibraries = null;

foreach (var library in _lockFile.Libraries)
Expand All @@ -859,7 +868,7 @@ private void WriteAnalyzers()

foreach (var file in library.Files)
{
if (!NuGetUtils.IsApplicableAnalyzer(file, _task.ProjectLanguage))
if (!NuGetUtils.IsApplicableAnalyzer(file, _task.ProjectLanguage, excludedAnalyzers))
{
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ public string ProjectLanguage
get; set;
}

/// <summary>
/// Optional version of the compiler API (E.g. 'roslyn3.9', 'roslyn4.0')
/// </summary>
public string CompilerApiVersion
{
get; set;
}

/// <summary>
/// Setting this property restores pre-16.7 behaviour of populating <see cref="TargetDefinitions"/>,
/// <see cref="FileDefinitions"/> and <see cref="FileDependencies"/> outputs.
Expand Down Expand Up @@ -176,6 +184,8 @@ private void ReadProjectFileDependencies(string frameworkAlias)
// get library and file definitions
private void GetPackageAndFileDefinitions()
{
HashSet<string>? excludedAnalyzers = NuGetUtils.GetExcludedAnalyzers(LockFile, ProjectLanguage, CompilerApiVersion);

foreach (var package in LockFile.Libraries)
{
var packageName = package.Name;
Expand Down Expand Up @@ -216,7 +226,7 @@ private void GetPackageAndFileDefinitions()
string resolvedPath = ResolveFilePath(file, resolvedPackagePath);
fileItem.SetMetadata(MetadataKeys.ResolvedPath, resolvedPath ?? string.Empty);

if (NuGetUtils.IsApplicableAnalyzer(file, ProjectLanguage))
if (NuGetUtils.IsApplicableAnalyzer(file, ProjectLanguage, excludedAnalyzers))
{
fileItem.SetMetadata(MetadataKeys.Analyzer, "true");
fileItem.SetMetadata(MetadataKeys.Type, "AnalyzerAssembly");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ Copyright (c) .NET Foundation. All rights reserved.
<!-- Setting this property to true restores pre-16.7 behaviour of ResolvePackageDependencies to produce
TargetDefinitions, FileDefinitions and FileDependencies items. -->
<EmitLegacyAssetsFileItems Condition="'$(EmitLegacyAssetsFileItems)' == ''">false</EmitLegacyAssetsFileItems>

<!-- A flag that NuGet packages containing multi-targeted analyzers can check to see if the NuGet package needs to do
its own multi-targeting logic, or if the current SDK targets will pick the assets correctly. -->
<SupportsRoslynComponentVersioning>true</SupportsRoslynComponentVersioning>
</PropertyGroup>

<!-- Target Moniker + RID-->
Expand Down Expand Up @@ -172,12 +176,28 @@ Copyright (c) .NET Foundation. All rights reserved.
<UsingTask TaskName="Microsoft.NET.Build.Tasks.ResolvePackageAssets"
AssemblyFile="$(MicrosoftNETBuildTasksAssembly)" />

<!-- Reads the version of the compiler APIs that are currently being used in order to pick the correct Roslyn components. -->
<Target Name="_ResolveCompilerVersion"
Condition="'$(CompilerApiVersion)' == ''">

<GetAssemblyIdentity AssemblyFiles="$(RoslynTargetsPath)\Microsoft.Build.Tasks.CodeAnalysis.dll">
<Output TaskParameter="Assemblies" ItemName="_CodeAnalysisIdentity" />
</GetAssemblyIdentity>

<PropertyGroup>
<_RoslynApiVersion>$([System.Version]::Parse(%(_CodeAnalysisIdentity.Version)).Major).$([System.Version]::Parse(%(_CodeAnalysisIdentity.Version)).Minor)</_RoslynApiVersion>
<CompilerApiVersion>roslyn$(_RoslynApiVersion)</CompilerApiVersion>
</PropertyGroup>

</Target>

<!-- The condition on this target causes it to be skipped during design-time builds if
the restore operation hasn't run yet. This is to avoid displaying an error in
the Visual Studio error list when a project is created before NuGet restore has
run and created the assets file. -->
<Target Name="RunResolvePackageDependencies"
Condition=" '$(DesignTimeBuild)' != 'true' Or Exists('$(ProjectAssetsFile)')">
Condition=" '$(DesignTimeBuild)' != 'true' Or Exists('$(ProjectAssetsFile)')"
DependsOnTargets="_ResolveCompilerVersion">

<!-- Verify that the assets file has a target for the right framework. Otherwise, if we restored for the
wrong framework, we'd end up finding no references to pass to the compiler, and we'd get a ton of
Expand All @@ -192,6 +212,7 @@ Copyright (c) .NET Foundation. All rights reserved.
ProjectPath="$(MSBuildProjectFullPath)"
ProjectAssetsFile="$(ProjectAssetsFile)"
ProjectLanguage="$(Language)"
CompilerApiVersion="$(CompilerApiVersion)"
EmitLegacyAssetsFileItems="$(EmitLegacyAssetsFileItems)"
TargetFramework="$(TargetFramework)"
ContinueOnError="ErrorAndContinue">
Expand All @@ -209,7 +230,7 @@ Copyright (c) .NET Foundation. All rights reserved.

<Target Name="ResolvePackageAssets"
Condition="('$(DesignTimeBuild)' != 'true' Or Exists('$(ProjectAssetsFile)')) And '$(SkipResolvePackageAssets)' != 'true'"
DependsOnTargets="ProcessFrameworkReferences;_DefaultMicrosoftNETPlatformLibrary;_ComputePackageReferencePublish">
DependsOnTargets="ProcessFrameworkReferences;_DefaultMicrosoftNETPlatformLibrary;_ComputePackageReferencePublish;_ResolveCompilerVersion">

<PropertyGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'
and '$(_TargetFrameworkVersionWithoutV)' >= '3.0'
Expand Down Expand Up @@ -248,6 +269,7 @@ Copyright (c) .NET Foundation. All rights reserved.
ProjectAssetsCacheFile="$(ProjectAssetsCacheFile)"
ProjectPath="$(MSBuildProjectFullPath)"
ProjectLanguage="$(Language)"
CompilerApiVersion="$(CompilerApiVersion)"
EmitAssetsLogMessages="$(EmitAssetsLogMessages)"
TargetFramework="$(TargetFramework)"
RuntimeIdentifier="$(RuntimeIdentifier)"
Expand Down
Loading

0 comments on commit 98e5b09

Please sign in to comment.