Skip to content

Commit

Permalink
Support multi-targeting for Roslyn components (#20793)
Browse files Browse the repository at this point in the history
* Support multi-targeting for Roslyn components

Allows for Roslyn components (analyzers, source generators) to target mulltiple Roslyn API versions in a single package. The highest compatible asset is selected.

Fix #20355

Undo changes to RunResolvePackageDependencies, since it only affects "legacy" behavior, which shouldn't be necessary to change.

- Add more conditions to when resolving the Roslyn version runs - only for C# and VB projects, and only if the CodeAnalysis.dll file exists

Refactor analyzer resolution logic to happen in one pass, instead of building up an exclusion list.
  • Loading branch information
eerhardt authored Sep 14, 2021
1 parent 4b3dc8d commit 56603c3
Show file tree
Hide file tree
Showing 8 changed files with 351 additions and 12 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>
188 changes: 181 additions & 7 deletions 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,7 +855,7 @@ public int GetHashCode((string, NuGetVersion) library)

private void WriteAnalyzers()
{
Dictionary<(string, NuGetVersion), LockFileTargetLibrary> targetLibraries = null;
AnalyzerResolver resolver = new AnalyzerResolver(this);

foreach (var library in _lockFile.Libraries)
{
Expand All @@ -859,21 +866,188 @@ private void WriteAnalyzers()

foreach (var file in library.Files)
{
if (!NuGetUtils.IsApplicableAnalyzer(file, _task.ProjectLanguage))
resolver.AddFile(file, library);
}

resolver.CompleteLibraryAnalyzers();
}
}

/// <summary>
/// Resolves the correct analyzer assets from a NuGet package.
/// </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.
/// </remarks>
private class AnalyzerResolver
{
private readonly CacheWriter _cacheWriter;
private readonly string? _compilerNameSearchString;
private readonly Version? _compilerVersion;
private Dictionary<(string, NuGetVersion), LockFileTargetLibrary>? _targetLibraries;
private List<(string, LockFileLibrary, Version)>? _potentialAnalyzers;
private Version _maxApplicableVersion;

private Dictionary<(string, NuGetVersion), LockFileTargetLibrary> TargetLibraries =>
_targetLibraries ??=
_cacheWriter._compileTimeTarget.Libraries.ToDictionary(l => (l.Name, l.Version), new LibraryComparer());

public AnalyzerResolver(CacheWriter cacheWriter)
{
_cacheWriter = cacheWriter;

if (ParseCompilerApiVersion(_cacheWriter._task.CompilerApiVersion, out ReadOnlyMemory<char> compilerName, out Version compilerVersion))
{
#if NET
_compilerNameSearchString = string.Concat("/".AsSpan(), compilerName.Span);
#else
_compilerNameSearchString = "/" + compilerName;
#endif
_compilerVersion = compilerVersion;
}
}

public void AddFile(string file, LockFileLibrary library)
{
if (NuGetUtils.IsApplicableAnalyzer(file, _cacheWriter._task.ProjectLanguage))
{
if (IsFileCompilerVersionSpecific(file, out Version fileCompilerVersion))
{
continue;
if (fileCompilerVersion > _compilerVersion)
{
// version is too high - skip this file
return;
}

_potentialAnalyzers ??= new List<(string, LockFileLibrary, Version)>();
_potentialAnalyzers.Add((file, library, fileCompilerVersion));

if (_maxApplicableVersion == null || fileCompilerVersion > _maxApplicableVersion)
{
_maxApplicableVersion = fileCompilerVersion;
}
}
else
{
// if this file isn't specific to a compiler version, just write it directly
WriteAnalyzer(file, library);
}
}
}

private bool IsFileCompilerVersionSpecific(string file, out Version fileCompilerVersion)
{
fileCompilerVersion = null;

if (_compilerNameSearchString == null)
{
// unable to tell if this file is specific to a compiler version
return false;
}

if (targetLibraries == null)
int compilerNameStart = file.IndexOf(_compilerNameSearchString);
if (compilerNameStart == -1)
{
return false;
}

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

return TryParseVersion(file, compilerVersionStart, compilerVersionStop - compilerVersionStart, out fileCompilerVersion);
}

public void CompleteLibraryAnalyzers()
{
if (_maxApplicableVersion != null && _potentialAnalyzers?.Count > 0)
{
foreach (var (file, library, version) in _potentialAnalyzers)
{
targetLibraries = _compileTimeTarget.Libraries.ToDictionary(l => (l.Name, l.Version), new LibraryComparer());
if (version == _maxApplicableVersion)
{
WriteAnalyzer(file, library);
}
}
}

// clear the variables that are scoped per library
_maxApplicableVersion = null;
_potentialAnalyzers?.Clear();
}

private void WriteAnalyzer(string file, LockFileLibrary library)
{
if (TargetLibraries.TryGetValue((library.Name, library.Version), out var targetLibrary))
{
_cacheWriter.WriteItem(_cacheWriter._packageResolver.ResolvePackageAssetPath(targetLibrary, file), targetLibrary);
}
}

if (targetLibraries.TryGetValue((library.Name, library.Version), out var targetLibrary))
/// <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))
{
WriteItem(_packageResolver.ResolvePackageAssetPath(targetLibrary, file), targetLibrary);
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 NET
return Version.TryParse(value.AsSpan(startIndex, length), out version);
#else
return Version.TryParse(value.Substring(startIndex, length), out version);
#endif
}
}

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 @@ -171,7 +175,7 @@ Copyright (c) .NET Foundation. All rights reserved.
AssemblyFile="$(MicrosoftNETBuildTasksAssembly)" />
<UsingTask TaskName="Microsoft.NET.Build.Tasks.ResolvePackageAssets"
AssemblyFile="$(MicrosoftNETBuildTasksAssembly)" />

<!-- 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
Expand Down Expand Up @@ -207,9 +211,26 @@ Copyright (c) .NET Foundation. All rights reserved.

</Target>

<!-- 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)' == '' And
('$(Language)' == 'C#' Or '$(Language)' == 'VB') And
Exists('$(RoslynTargetsPath)\Microsoft.Build.Tasks.CodeAnalysis.dll')">

<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>

<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
16 changes: 16 additions & 0 deletions src/Tests/Microsoft.NET.TestFramework/BuildTestPackages.targets
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@
<Version>1.0.0</Version>
<Clean>True</Clean>
</BaseTestPackageProject>
<BaseTestPackageProject Include="src/Assets/TestPackages/Library.ContainsAnalyzer">
<Name>Library.ContainsAnalyzer</Name>
<ProjectName>Library.ContainsAnalyzer.csproj</ProjectName>
<NuPkgName>Library.ContainsAnalyzer</NuPkgName>
<IsApplicable>True</IsApplicable>
<Version>1.0.0</Version>
<Clean>True</Clean>
</BaseTestPackageProject>
<BaseTestPackageProject Include="src/Assets/TestPackages/Library.ContainsAnalyzer2">
<Name>Library.ContainsAnalyzer2</Name>
<ProjectName>Library.ContainsAnalyzer2.csproj</ProjectName>
<NuPkgName>Library.ContainsAnalyzer2</NuPkgName>
<IsApplicable>True</IsApplicable>
<Version>1.0.0</Version>
<Clean>True</Clean>
</BaseTestPackageProject>

<BaseTestPackageProject>
<NuPkgName Condition=" '%(NuPkgName)' == '' ">%(Name)</NuPkgName>
Expand Down
Loading

0 comments on commit 56603c3

Please sign in to comment.