Skip to content

Commit 43b8a65

Browse files
virzakclaude
andcommitted
Add multi-Roslyn-version build architecture for .NET 8/9/10 SDK support
Ship multiple analyzer DLLs targeting Roslyn 4.8 (.NET 8), 4.12 (.NET 9), and 5.0 (.NET 10) so the same NuGet package works across all SDK versions. NuGet automatically picks the highest compatible variant. - Add Directory.Build.targets with RoslynVersion-based package switching - Add Zomp.SyncMethodGenerator.Pack project to assemble variant DLLs - Move pack concerns out of generator csproj (now IsPackable=false) - Add #if ROSLYN_X_Y_OR_GREATER guards for version-specific APIs - Add Pack.ps1 for local multi-variant builds - Update CI to build variants before packing - Add AGENTS.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ab77e1a commit 43b8a65

8 files changed

Lines changed: 259 additions & 27 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,16 @@ jobs:
111111
name: coverage_${{ matrix.os }}
112112
path: reports/coverage
113113
if: steps.codecov.outcome == 'success'
114+
- name: Build Roslyn variants
115+
if: ${{ runner.os != 'Windows' }}
116+
run: |
117+
gen="src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj"
118+
for rv in roslyn4.8 roslyn4.12 roslyn5.0; do
119+
dotnet build "$gen" -c Release -p:RoslynVersion=$rv -p:BaseIntermediateOutputPath=obj/$rv/
120+
done
114121
- name: Pack
115122
if: ${{ runner.os != 'Windows' }}
116-
run: dotnet pack --configuration Release -o packages --no-build
123+
run: dotnet pack src/Zomp.SyncMethodGenerator.Pack/Zomp.SyncMethodGenerator.Pack.csproj --configuration Release -o packages --no-build
117124
- name: Upload artifacts (.nupkg)
118125
if: ${{ runner.os != 'Windows' }}
119126
uses: actions/upload-artifact@v4

‎AGENTS.md‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Agents Guide
2+
3+
See README.md for project overview and consumer API.
4+
5+
## Build & Test
6+
7+
```bash
8+
# Build
9+
dotnet build
10+
11+
# Test
12+
dotnet test
13+
14+
# Pack (multi-Roslyn-version NuGet package)
15+
./Pack.ps1
16+
```
17+
18+
The project uses a `.slnx` solution file: `Zomp.SyncMethodGenerator.slnx`.
19+
20+
## Multi-Roslyn-Version Architecture
21+
22+
The generator ships multiple analyzer DLLs targeting different Roslyn versions so the same NuGet package works across .NET 8, 9, and 10 SDKs.
23+
24+
- `Directory.Build.targets` — switches `Microsoft.CodeAnalysis.CSharp` version via `RoslynVersion` MSBuild property and defines `ROSLYN_X_Y_OR_GREATER` constants
25+
- `src/Zomp.SyncMethodGenerator/` — the generator project; `BaseOutputPath` is set per-variant so builds don't collide
26+
- `src/Zomp.SyncMethodGenerator.Pack/` — packing-only project that gathers pre-built variant DLLs into versioned `analyzers/dotnet/roslyn4.X/cs/` NuGet paths
27+
- `Pack.ps1` — builds all variants in parallel, then packs
28+
29+
Roslyn variants: `roslyn4.8` (4.8.0, .NET 8), `roslyn4.12` (4.12.0, .NET 9), `roslyn5.0` (5.0.0, .NET 10).
30+
31+
Use `#if ROSLYN_X_Y_OR_GREATER` guards for APIs that only exist in newer Roslyn versions (e.g., `ExtensionBlockDeclarationSyntax` is `ROSLYN_5_0_OR_GREATER` only).
32+
33+
## Project Structure
34+
35+
```text
36+
src/Zomp.SyncMethodGenerator/ Generator (netstandard2.0)
37+
SyncMethodSourceGenerator.cs Entry point — IIncrementalGenerator
38+
AsyncToSyncRewriter.cs Core transformation engine (CSharpSyntaxRewriter)
39+
SourceGenerationHelper.cs Output file structure and attribute definitions
40+
Extensions.cs Type-checking extensions on INamedTypeSymbol
41+
DiagnosticMessages.cs ZSMGEN001-003 diagnostic descriptors
42+
Models/ Data records for the pipeline
43+
Helpers/ EquatableArray<T>, DirectiveStack, etc.
44+
Properties/ Assembly attributes
45+
tools/ MSBuild props/targets shipped in the NuGet package
46+
src/Zomp.SyncMethodGenerator.Pack/ Packing-only project (no code)
47+
tests/Generator.Tests/ Unit tests (xUnit + Verify snapshot testing)
48+
tests/GenerationSandbox.Tests/ Integration tests (real-world patterns)
49+
```
50+
51+
## Transformation Pipeline
52+
53+
1. **Find candidates** — `ForAttributeWithMetadataName` locates `[CreateSyncVersion]` on methods or types
54+
2. **Extract metadata** — parent class hierarchy, namespaces, configuration flags
55+
3. **Rewrite** — `AsyncToSyncRewriter` (a `CSharpSyntaxRewriter`) traverses the syntax tree:
56+
- Strips `async` modifier and `await` expressions
57+
- Transforms return types: `Task`/`ValueTask` to `void`, `Task<T>`/`ValueTask<T>` to `T`
58+
- Transforms collection types: `IAsyncEnumerable<T>` to `IEnumerable<T>`
59+
- Transforms memory types: `Memory<T>` to `Span<T>` (except in arrays)
60+
- Removes `CancellationToken` and `IProgress<T>` parameters (configurable)
61+
- Renames method calls: strips `Async` suffix
62+
- Handles special methods: `Task.FromResult(x)` to `x`, `Task.Delay()` to `Thread.Sleep()`
63+
- Processes `#if SYNC_ONLY` / `#if !SYNC_ONLY` directives
64+
4. **Emit** — `SourceGenerationHelper` wraps the rewritten method in namespace/class structure
65+
66+
## Testing Conventions
67+
68+
- **Snapshot testing** with Verify.SourceGenerators — test inputs are inline C# strings, outputs are `.verified.cs` files in `Snapshots/`
69+
- Test pattern: `[Fact] public Task TestName() => "source code".Verify();`
70+
- Snapshot files: `{TestClass}.{TestName}[.Platform].g.verified.cs`
71+
- Tests compile against real framework assemblies via `TestHelper`
72+
73+
## Key Conventions
74+
75+
- Central package management (`Directory.Packages.props`) — never put versions in csproj files
76+
- `TreatWarningsAsErrors` is enabled globally
77+
- StyleCop + NetAnalyzers enforced; `.editorconfig` defines style rules
78+
- File-scoped namespaces required
79+
- Nerdbank.GitVersioning for version management (from git tags/height)
80+
- The generator targets `netstandard2.0` for maximum host compatibility
81+
- `EquatableArray<T>` wraps `ImmutableArray<T>` for value equality in the incremental pipeline
82+
83+
## Diagnostics
84+
85+
| ID | Description |
86+
|----|-------------|
87+
| ZSMGEN001 | Invalid nesting of `SYNC_ONLY` directive |
88+
| ZSMGEN002 | `SYNC_ONLY` mixed with other symbols in `#if` condition |
89+
| ZSMGEN003 | `SYNC_ONLY` used with `#elif` |

‎Directory.Build.targets‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<Project>
2+
3+
<PropertyGroup>
4+
<RoslynVersion></RoslynVersion>
5+
</PropertyGroup>
6+
7+
<Choose>
8+
<When Condition="$(RoslynVersion) == 'roslyn4.8'">
9+
<ItemGroup>
10+
<PackageReference Update="Microsoft.CodeAnalysis.CSharp" VersionOverride="4.8.0" />
11+
</ItemGroup>
12+
<PropertyGroup>
13+
<DefineConstants>$(DefineConstants);ROSLYN_4_8;ROSLYN_4_8_OR_GREATER</DefineConstants>
14+
</PropertyGroup>
15+
</When>
16+
17+
<When Condition="$(RoslynVersion) == 'roslyn4.12'">
18+
<ItemGroup>
19+
<PackageReference Update="Microsoft.CodeAnalysis.CSharp" VersionOverride="4.12.0" />
20+
</ItemGroup>
21+
<PropertyGroup>
22+
<DefineConstants>$(DefineConstants);ROSLYN_4_12;ROSLYN_4_8_OR_GREATER;ROSLYN_4_12_OR_GREATER</DefineConstants>
23+
</PropertyGroup>
24+
</When>
25+
26+
<Otherwise>
27+
<PropertyGroup>
28+
<DefineConstants>$(DefineConstants);ROSLYN_5_0;ROSLYN_4_8_OR_GREATER;ROSLYN_4_12_OR_GREATER;ROSLYN_5_0_OR_GREATER</DefineConstants>
29+
</PropertyGroup>
30+
</Otherwise>
31+
</Choose>
32+
33+
</Project>

‎Pack.ps1‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
param(
2+
[string]$configuration = "Release",
3+
[string]$verbosity = "minimal"
4+
)
5+
6+
$artifacts = ".\packages"
7+
8+
function Exec
9+
{
10+
[CmdletBinding()]
11+
param(
12+
[Parameter(Position=0,Mandatory=1)][scriptblock]$cmd,
13+
[Parameter(Position=1,Mandatory=0)][string]$errorMessage = "Error executing command $cmd"
14+
)
15+
& $cmd
16+
if ($lastexitcode -ne 0) {
17+
throw ("Exec: " + $errorMessage)
18+
}
19+
}
20+
21+
if (Test-Path $artifacts) { Remove-Item $artifacts -Force -Recurse }
22+
New-Item -Path $artifacts -ItemType Directory | Out-Null
23+
24+
$gen = "src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj"
25+
$variants = @("roslyn4.8","roslyn4.12","roslyn5.0")
26+
27+
# Restore once, then build all variants with --no-restore
28+
Write-Host "Restoring..." -ForegroundColor Cyan
29+
Exec { & dotnet restore $gen --verbosity $verbosity }
30+
31+
Write-Host "Building Roslyn variants in parallel..." -ForegroundColor Cyan
32+
$jobs = @()
33+
foreach ($rv in $variants) {
34+
$jobs += Start-Process "dotnet" "build $gen -c $configuration -p RoslynVersion=$rv -p BaseIntermediateOutputPath=obj/$rv/ --verbosity $verbosity" -NoNewWindow -PassThru
35+
}
36+
$failed = $false
37+
foreach ($job in $jobs) {
38+
$job.WaitForExit()
39+
if ($job.ExitCode -ne 0) { $failed = $true }
40+
}
41+
if ($failed) { throw "One or more variant builds failed" }
42+
43+
Write-Host "Packing..." -ForegroundColor Cyan
44+
Exec { & dotnet pack src/Zomp.SyncMethodGenerator.Pack/Zomp.SyncMethodGenerator.Pack.csproj -c $configuration -o $artifacts --no-build --verbosity $verbosity }
45+
46+
Write-Host "Done! Package at $artifacts" -ForegroundColor Green

‎Zomp.SyncMethodGenerator.slnx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
<Folder Name="/src/">
2525
<File Path="src/Directory.Build.props" />
2626
<Project Path="src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj" />
27+
<Project Path="src/Zomp.SyncMethodGenerator.Pack/Zomp.SyncMethodGenerator.Pack.csproj" />
2728
</Folder>
2829
<Folder Name="/tests/">
2930
<File Path="tests/coverlet.runsettings" />
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<IncludeBuildOutput>false</IncludeBuildOutput>
6+
<EnableDefaultItems>false</EnableDefaultItems>
7+
<GenerateDependencyFile>false</GenerateDependencyFile>
8+
<NoPackageAnalysis>true</NoPackageAnalysis>
9+
10+
<PackageId>Zomp.SyncMethodGenerator</PackageId>
11+
<Description>Generates synchronized method from async method</Description>
12+
<PackageTags>async sync csharp source generator</PackageTags>
13+
<DevelopmentDependency>true</DevelopmentDependency>
14+
<IsRoslynComponent>true</IsRoslynComponent>
15+
</PropertyGroup>
16+
17+
<ItemGroup>
18+
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
19+
</ItemGroup>
20+
21+
<!-- Microsoft.Bcl.HashCode must be bundled alongside the generator in each variant folder -->
22+
<ItemGroup>
23+
<PackageReference Include="Microsoft.Bcl.HashCode" PrivateAssets="all" GeneratePathProperty="true" />
24+
<PackageReference Include="Nerdbank.GitVersioning" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers" />
25+
</ItemGroup>
26+
27+
<!-- Roslyn 4.8 variant (for .NET 8 SDK) -->
28+
<ItemGroup>
29+
<None Include="$(MSBuildThisFileDirectory)\..\Zomp.SyncMethodGenerator\bin\roslyn4.8\$(Configuration)\Zomp.SyncMethodGenerator.dll"
30+
Pack="true" PackagePath="analyzers/dotnet/roslyn4.8/cs/" Visible="false" />
31+
<None Include="$(PkgMicrosoft_Bcl_HashCode)\lib\netstandard2.0\*.dll"
32+
Pack="true" PackagePath="analyzers/dotnet/roslyn4.8/cs/" Visible="false" />
33+
</ItemGroup>
34+
35+
<!-- Roslyn 4.12 variant (for .NET 9 SDK) -->
36+
<ItemGroup>
37+
<None Include="$(MSBuildThisFileDirectory)\..\Zomp.SyncMethodGenerator\bin\roslyn4.12\$(Configuration)\Zomp.SyncMethodGenerator.dll"
38+
Pack="true" PackagePath="analyzers/dotnet/roslyn4.12/cs/" Visible="false" />
39+
<None Include="$(PkgMicrosoft_Bcl_HashCode)\lib\netstandard2.0\*.dll"
40+
Pack="true" PackagePath="analyzers/dotnet/roslyn4.12/cs/" Visible="false" />
41+
</ItemGroup>
42+
43+
<!-- Roslyn 5.0 variant (for .NET 10 SDK) -->
44+
<ItemGroup>
45+
<None Include="$(MSBuildThisFileDirectory)\..\Zomp.SyncMethodGenerator\bin\roslyn5.0\$(Configuration)\Zomp.SyncMethodGenerator.dll"
46+
Pack="true" PackagePath="analyzers/dotnet/roslyn5.0/cs/" Visible="false" />
47+
<None Include="$(PkgMicrosoft_Bcl_HashCode)\lib\netstandard2.0\*.dll"
48+
Pack="true" PackagePath="analyzers/dotnet/roslyn5.0/cs/" Visible="false" />
49+
</ItemGroup>
50+
51+
</Project>

‎src/Zomp.SyncMethodGenerator/SyncMethodSourceGenerator.cs‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,26 @@ private static ImmutableArray<TransformResult> TransformForGeneration(GeneratorA
9494

9595
if (ctx.TargetNode is TypeDeclarationSyntax typeDecl)
9696
{
97+
#if ROSLYN_4_12_OR_GREATER
9798
return [.. typeDecl.Members.OfType<MethodDeclarationSyntax>().Select(s => new TransformResult(ctx, s))];
99+
#else
100+
return ImmutableArray.CreateRange(typeDecl.Members.OfType<MethodDeclarationSyntax>().Select(s => new TransformResult(ctx, s)));
101+
#endif
98102
}
99103
else if (ctx.TargetNode is MethodDeclarationSyntax methodDecl)
100104
{
105+
#if ROSLYN_4_12_OR_GREATER
101106
return [new TransformResult(ctx, methodDecl)];
107+
#else
108+
return ImmutableArray.Create(new TransformResult(ctx, methodDecl));
109+
#endif
102110
}
103111

112+
#if ROSLYN_4_12_OR_GREATER
104113
return [];
114+
#else
115+
return ImmutableArray<TransformResult>.Empty;
116+
#endif
105117
}
106118

107119
private static (MethodToGenerate MethodToGenerate, string Path, string Content) GenerateSource(MethodToGenerate m)
@@ -201,15 +213,19 @@ static string BuildClassName(MethodParentDeclaration c)
201213

202214
var classes = ImmutableArray.CreateBuilder<MethodParentDeclaration>();
203215
SyntaxNode? node = methodDeclarationSyntax;
216+
#if ROSLYN_5_0_OR_GREATER
204217
ExtensionBlockDeclarationSyntax? extensionParent = null;
218+
#endif
205219
while (node.Parent is not null)
206220
{
207221
node = node.Parent;
222+
#if ROSLYN_5_0_OR_GREATER
208223
if (node is ExtensionBlockDeclarationSyntax eds)
209224
{
210225
extensionParent = eds;
211226
continue;
212227
}
228+
#endif
213229

214230
MethodParentDeclaration? mpd = node switch
215231
{
@@ -240,7 +256,11 @@ static string BuildClassName(MethodParentDeclaration c)
240256
var preserveProgress = syncMethodGeneratorAttributeData.NamedArguments.FirstOrDefault(c => c.Key == PreserveProgress) is { Value.Value: true };
241257
var preserveCancellationToken = syncMethodGeneratorAttributeData.NamedArguments.FirstOrDefault(c => c.Key == PreserveCancellationToken) is { Value.Value: true };
242258

259+
#if ROSLYN_5_0_OR_GREATER
243260
var toVisit = extensionParent ?? (SyntaxNode)methodDeclarationSyntax;
261+
#else
262+
var toVisit = (SyntaxNode)methodDeclarationSyntax;
263+
#endif
244264
var rewriter = new AsyncToSyncRewriter(context.SemanticModel, disableNullable, preserveProgress, preserveCancellationToken, methodDeclarationSyntax);
245265
var sn = rewriter.Visit(toVisit);
246266
var content = sn.ToFullString();
@@ -277,7 +297,12 @@ static string BuildClassName(MethodParentDeclaration c)
277297
}
278298
}
279299

280-
var result = new MethodToGenerate(index, namespaces.ToImmutable(), isNamespaceFileScoped, extensionParent is not null, classes.ToImmutable(), methodDeclarationSyntax.Identifier.ValueText, content, disableNullable, rewriter.Diagnostics, hasErrors);
300+
#if ROSLYN_5_0_OR_GREATER
301+
var isCSharp14Extension = extensionParent is not null;
302+
#else
303+
var isCSharp14Extension = false;
304+
#endif
305+
var result = new MethodToGenerate(index, namespaces.ToImmutable(), isNamespaceFileScoped, isCSharp14Extension, classes.ToImmutable(), methodDeclarationSyntax.Identifier.ValueText, content, disableNullable, rewriter.Diagnostics, hasErrors);
281306

282307
return result;
283308
}

‎src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj‎

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,36 +14,16 @@
1414
<Authors>Victor Irzak</Authors>
1515
<SuppressNETCoreSdkPreviewMessage>true</SuppressNETCoreSdkPreviewMessage>
1616
<NoPackageAnalysis>true</NoPackageAnalysis>
17-
<TargetsForTfmSpecificBuildOutput>$(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage</TargetsForTfmSpecificBuildOutput>
1817
<PackageTags>async sync csharp source generator</PackageTags>
1918
<DevelopmentDependency>true</DevelopmentDependency>
2019
<IsRoslynComponent>true</IsRoslynComponent>
20+
<IsPackable>false</IsPackable>
2121
</PropertyGroup>
2222

23-
<!-- This ensures the library will be packaged as a source generator when we use `dotnet pack` -->
24-
<ItemGroup>
25-
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
26-
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
27-
28-
<!-- Package the Microsoft.Bcl.HashCode alongside the generator assembly -->
29-
<None Include="$(PkgMicrosoft_Bcl_HashCode)\lib\netstandard2.0\*.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
30-
</ItemGroup>
31-
32-
<!-- https://stackoverflow.com/a/59893520/6461844 -->
33-
<Target Name="CopyProjectReferencesToPackage" DependsOnTargets="BuildOnlySettings;ResolveReferences">
34-
<ItemGroup>
35-
<!-- Filter out unnecessary files -->
36-
<_ReferenceCopyLocalPaths Include="@(ReferenceCopyLocalPaths-&gt;WithMetadataValue('ReferenceSourceTarget', 'ProjectReference')-&gt;WithMetadataValue('PrivateAssets', 'All'))" />
37-
</ItemGroup>
38-
39-
<!-- Print batches for debug purposes -->
40-
<Message Text="Batch for .nupkg: ReferenceCopyLocalPaths = @(_ReferenceCopyLocalPaths), ReferenceCopyLocalPaths.DestinationSubDirectory = %(_ReferenceCopyLocalPaths.DestinationSubDirectory) Filename = %(_ReferenceCopyLocalPaths.Filename) Extension = %(_ReferenceCopyLocalPaths.Extension)" Importance="High" Condition="'@(_ReferenceCopyLocalPaths)' != ''" />
41-
42-
<ItemGroup>
43-
<!-- Add file to package with consideration of sub folder. If empty, the root folder is chosen. -->
44-
<BuildOutputInPackage Include="@(_ReferenceCopyLocalPaths)" TargetPath="%(_ReferenceCopyLocalPaths.DestinationSubDirectory)" />
45-
</ItemGroup>
46-
</Target>
23+
<!-- Override output path for Roslyn-variant builds so they don't collide -->
24+
<PropertyGroup Condition="'$(RoslynVersion)' != ''">
25+
<BaseOutputPath>bin\$(RoslynVersion)\</BaseOutputPath>
26+
</PropertyGroup>
4727

4828
<ItemGroup>
4929
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" PrivateAssets="all" Pack="false" />

0 commit comments

Comments
 (0)