blog post image
Andrew Lock avatar

Andrew Lock

~8 min read

Supporting multiple .NET SDK versions in analyzers and source generators

Share on:

In this post I describe why source generators might need to work with multiple versions of the .NET SDK. This may be necessary if you want to support features that are only available in newer versions of the .NET SDK, while simultaneously supporting users building with older versions of the .NET SDK in a more limited fashion. I then show how support was added in .NET 6 for this scenario, and how NuGet package layouts have changed. In the next post I show how you can add multi-targeting support to your own packages.

Why does the version of the .NET SDK matter for source generators?

When you create a source generator, you reference a specific version of the Microsoft.CodeAnalysis.CSharp NuGet package. The version of the package you choose defines the minimum version of Visual Studio, MSBuild, and the .NET SDK that your source generator will work with.

The following table is compiled (well, inferred) from the links above, primarily for recent versions of the package:

Roslyn package versionSDK versionMSBuild/Visual Studio version
4.12.09.0.1xx17.12
4.11.08.0.4xx17.11
4.10.08.0.3xx17.10
4.9.28.0.2xx17.9
4.8.08.0.1xx17.8
4.7.07.0.4xx17.7
4.6.07.0.3xx17.6
4.5.07.0.2xx17.5
4.4.07.0.1xx17.4
4.3.16.0.4xx17.3
4.2.06.0.3xx17.2
4.1.06.0.2xx17.1
4.0.16.0.1xx17.0
3.8.05.0.1xx16.8
3.4.03.1.1xx16.4
2.10.02.1.5xx15.9

As you can see, each new minor release of the .NET SDK and Visual Studio version is associated with a new Roslyn package (Microsoft.CodeAnalysis.CSharp) version. Each new version of the package provides additional APIs for you to use.

For example, when I first created my NetEscapades.EnumGenerators project I used version 4.0.1 of Microsoft.CodeAnalysis.CSharp, which gave access to the new incremental generator API IIncrementalGenerator introduced in .NET 6.

When I wanted to use the recommended ForAttributeWithMetadataName API introduced in .NET 7, I had to update the Microsoft.CodeAnalysis.CSharp version used by the source generator to 4.4.0. However, by updating that dependency, the source generator was no longer compatible with the .NET 6 SDK. You could still install the package, but the generator would not work, and you would get a warning at compile time:

CSC : warning CS8032: An instance of analyzer NetEscapades.EnumGenerators.EnumGenerator
cannot be created from C:\Users\Sock\.nuget\packages\enumgenerators.enumgenerator\1.0.0-beta09\analyzers\dotnet\
cs\NetEscapades.EnumGenerators.dll : 
Could not load file or assembly 'Microsoft.CodeAnalysis, Version=4.4.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35'. 
The system cannot find the file specified.

So in general, if you want to have the broadest support, you should reference the lowest version of Microsoft.CodeAnalysis.CSharp that you can. On the other hand, if you want access to the latest SDK features, you need to reference the highest version of the package. It's the same classic trade-off that library maintainers have to make when it comes to dependencies, and what's the right choice for you will vary.

Supporting multiple SDK versions and lighting-up features

One traditional approach to solving the "broader support vs. features" trade-off is to kind of do both! You target the lowest version of the API you can get away with, and that serves as your lower bound. You then "light up" with additional features when you detect that a newer version of the API is available.

A classic example of where this makes sense for a source generator is with the interceptor support I recently added to the NetEscapades.EnumGenerators source generator. The source generator requires version 4.4.0 as a minimum (.NET 7 SDK, for ForAttributeWithMetadataName support), but if you're using a .NET SDK version of 8.0.400 or greater, you can optionally enable the interceptor support.

I could have made 8.0.400 the minimum required SDK version to get any functionality, but by making the interceptor support an optional extra, people stuck on older versions of the SDK can continue to use the other features of the library.

There are also examples of needing to take this approach in the runtime itself. For example, the original System.Text.Json source generator was written to support the non-incremental version of source generators. However, this API was notorious for causing performance problems in the IDE. The only solution was to rewrite the generator to use IIncrementalGenerator.

Unfortunately, simply updating the JsonSourceGenerator package to use a newer version of Microsoft.CodeAnalysis.CSharp (to access the IIncrementalGenerator API) would have broken the generator for anyone using the (still supported at the time) .NET 5 SDK. Instead, the team created two versions of the generator—one that used the .NET 6 SDK and the IIncrementalGenerator API, and one that used the .NET 5 SDK and the non-incremental generator.

Sounds easy enough, right? After all, shipping different code for multiple target frameworks in a single NuGet package has been possible in .NET for a long time. Well…not so fast. We're not talking about shipping different dlls per framework. We're talking about shipping different dlls per .NET SDK

Creating NuGet packages that support multiple Roslyn/SDK versions

The fundamental problem of needing to ship different versions of source generators (and analyzers more generally) was realised as part of the .NET 6 preview releases, when the performance issues with the System.Text.Json generator appeared. At that point, there was essentially no solution—as a source generator author you had to choose either broad support or new APIs. And if you went for the latter, then customers using your package with old versions of the SDK would get warnings in their build.

The solution was to update the logic in the .NET SDK that loads analyzers/source generators from NuGet packages to be "SDK version aware". This logic is available as of .NET 6.

Prior to .NET 6, the .NET SDK only recognizes the following folder folder structure in NuGet packages for loading analyzers:

analyzers[\dotnet][\cs|vb]\my_generator.dll

In .NET 6 this pattern is still recognized, but if you have specific Roslyn API version requirements, you can encode those in the folder structure:

analyzers[\dotnet][\roslyn{version}][\cs|vb]\my_generator.dll

Note the additional roslyn{version} folder in the analyzers\dotnet folder. The {version} is a {major}.{minor} number corresponding to the Roslyn API version (the version of the Microsoft.CodeAnalysis.CSharp package referenced in your source generator).

By splitting by Roslyn API version, you can now ship multiple dlls in the same NuGet package, each of which uses a different version of the Microsoft.CodeAnalysis.CSharp package, and hence each of which can use features available in the specific version of the .NET SDK that's available.

We can see this in practice by taking a look at how the layout of the System.Text.Json NuGet package changed between the .NET 6 preview 5 builds (when the non-incremental source generator was introduced) and the first GA .NET 6 build.

The 6.0.0-preview.5.21301.5 version of the package uses the default "simple" layout, placing the System.Text.Json.SourceGeneration.dll file in the analyzers/dotnet/cs folder:

The layout of the 6.0.0-preview.5.21301.5 version of the System.Text.Json NuGet

In contrast, if we look at the 6.0.0 version of the package, you can see there are intermediate roslyn3.11 and roslyn4.0 folders. The 3.11 version includes a version of the generator that is compatible with the .NET 5 SDK, while the 4.0 version requires the .NET 6 SDK, and uses IIncrementalGenerator:

The layout of the 6.0.0 version of the System.Text.Json NuGet

Note that I simplified the layout in the package above to remove the localization dlls, so if you inspect the version from nuget.org, you'll also see the additional satellite resource dlls.

The latest version of the package, 9.0.0 includes a third differentiation, supporting the .NET 7 SDK (version 4.4 of the roslyn API):

The layout of the 9.0.0 version of the System.Text.Json NuGet

There's a slight wrinkle in this otherwise-elegant solution when a NuGet package is loaded in a version of the .NET SDK that doesn't understand the new analyzer layout. By default, the earlier SDK would load all of the versions of the generator, which is obviously problematic.

To work around that, the .NET 6 SDK introduces a new MSBuild property, $(SupportsRoslynComponentVersioning). You can then include a .targets folder in your NuGet package that detects the lack of this variable to do the analyzer selection manually yourself.

As an example, the System.Text.Json 6.0.0 package includes the following .targets file, which is added to the build of a project that references the System.Text.Json NuGet package. The file is a little hard to read (obviously, it's MSBuild) but it's essentially just checking for $(SupportsRoslynComponentVersioning) != 'true', and removing the rolsyn4.x analyzers if so, leaving only the roslyn3.x package:

<Project>
  <Target Name="_System_Text_JsonGatherAnalyzers">

    <ItemGroup>
      <_System_Text_JsonAnalyzer Include="@(Analyzer)" Condition="'%(Analyzer.NuGetPackageId)' == 'System.Text.Json'" />
    </ItemGroup>
  </Target>

  <Target Name="_System_Text_JsonAnalyzerMultiTargeting" 
          Condition="'$(SupportsRoslynComponentVersioning)' != 'true'" 
          AfterTargets="ResolvePackageDependenciesForBuild;ResolveNuGetPackageAssets"
          DependsOnTargets="_System_Text_JsonGatherAnalyzers">

    <ItemGroup>
      <!-- Remove our analyzers targeting roslyn4.x -->
      <Analyzer Remove="@(_System_Text_JsonAnalyzer)"
                Condition="$([System.String]::Copy('%(_System_Text_JsonAnalyzer.Identity)').IndexOf('roslyn4')) &gt;= 0"/>
    </ItemGroup>
  </Target>

  <Target Name="_System_Text_JsonRemoveAnalyzers" 
          Condition="'$(DisableSystemTextJsonSourceGenerator)' == 'true'"
          AfterTargets="ResolvePackageDependenciesForBuild;ResolveNuGetPackageAssets"
          DependsOnTargets="_System_Text_JsonGatherAnalyzers">

    <!-- Remove all our analyzers -->
    <ItemGroup>
      <Analyzer Remove="@(_System_Text_JsonAnalyzer)" />
    </ItemGroup>
  </Target>
</Project>

This ensures that the multi-targeting works even in early versions of the .NET SDK that don't support the new folder layout.

I was somewhat interested to see that this code still exists, even in the latest versions of the package, when .NET 5 SDK has been out of support for 2.5 years. I assume the issue is that some versions of Visual Studio are still supported, and fundamentally you can't stop people using it with old SDKs.

I've covered all the details of why you might want to multi-target multiple roslyn versions in a NuGet package in this post. In the next post I'll show how you can support this in your own packages, by showing how I added multi-targeting support to my NetEscapades.EnumGenerators source generator.

Summary

In this post I described how the version of the Microsoft.CodeAnalysis.CSharp NuGet package used by a source generator is tied to a Roslyn API and .NET SDK version. I discussed how the fact you need to choose a single API version means you have to trade off between broadness of support and access to features. .NET 6 introduced a new feature for NuGet packages that allows you to multi-target your analyzers/source generators for multiple .NET SDK versions.

In the next post I show a case study of how I updated my NetEscapades.EnumGenerators source generator to use this feature and multi-target against multiple .NET SDK versions.

  • Buy Me A Coffee
  • Donate with PayPal
Andrew Lock | .Net Escapades
Want an email when
there's new posts?