Skip to content

Commit 58ac680

Browse files
committed
Check that every test input clones without changes
Every test now also runs an identity transformation over its source: IdentityCloneGenerator copies each method the sync generator would synchronize through IdentityRewriter, a CloningRewriter which changes nothing but the method's name, and the test fails if a copy does not compile. A failure there is in the Cloning layer - qualification, usings, containing types, directives - rather than in the async to sync rules, which the snapshot tests cannot tell apart. The pass also fails when it clones fewer methods than were synchronized, so that cloning nothing cannot pass for cloning correctly. The generator exposes its internals to Generator.Tests for this. Generated with Claude Code
1 parent ff8281b commit 58ac680

5 files changed

Lines changed: 159 additions & 0 deletions

File tree

‎AGENTS.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ come back empty.
160160
- Tests compile against real framework assemblies via `TestHelper`
161161
- `TestHelper` fails a test whose generated code does not compile, before any
162162
snapshot is compared, and reports the compiler errors
163+
- `TestHelper` also clones every synchronized method through
164+
`IdentityCloneGenerator`, which changes nothing but the method's name, and
165+
fails the test with "Identity clone does not compile" when a clone does not.
166+
That failure is in the `Cloning/` layer, not in the async to sync rules
163167

164168
### Choosing how the source is wrapped
165169

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@
5454
<Using Include="Zomp.SyncMethodGenerator.DiagnosticMessages" Static="true" />
5555
</ItemGroup>
5656

57+
<ItemGroup>
58+
<!-- The tests clone methods through the Cloning layer directly -->
59+
<InternalsVisibleTo Include="Generator.Tests" />
60+
</ItemGroup>
61+
5762
<ItemGroup>
5863
<PackageReference Update="Microsoft.SourceLink.GitHub" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers" />
5964
</ItemGroup>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using Zomp.SyncMethodGenerator;
2+
using Zomp.SyncMethodGenerator.Cloning;
3+
4+
namespace Generator.Tests;
5+
6+
/// <summary>
7+
/// Clones every method the sync generator would synchronize, through <see cref="IdentityRewriter"/>,
8+
/// which changes nothing but its name. A clone which does not compile points at the cloning layer
9+
/// rather than at the async to sync rules.
10+
/// </summary>
11+
internal sealed class IdentityCloneGenerator : IIncrementalGenerator
12+
{
13+
/// <inheritdoc/>
14+
public void Initialize(IncrementalGeneratorInitializationContext context)
15+
{
16+
var disableNullable = context.CompilationProvider.Select(static (c, _) =>
17+
c.Options.NullableContextOptions == NullableContextOptions.Disable
18+
|| c is CSharpCompilation { LanguageVersion: < LanguageVersion.CSharp8 });
19+
20+
var methods = CloneTarget.ForAttribute(context.SyntaxProvider, SyncMethodSourceGenerator.QualifiedCreateSyncVersionAttribute)
21+
.Combine(disableNullable)
22+
.Select(static (data, ct) => Clone(data.Left, data.Right, ct)!)
23+
.Where(static m => m is not null);
24+
25+
ClonedMethodOutput.Register(context, methods, Zomp.SyncMethodGenerator.DiagnosticMessages.CollidingOverloads);
26+
}
27+
28+
private static ClonedMethod? Clone(CloneTarget target, bool disableNullable, CancellationToken ct)
29+
{
30+
var context = target.Context;
31+
var isTargetTypeSymbol = context.TargetSymbol is ITypeSymbol;
32+
33+
var symbol = isTargetTypeSymbol
34+
? context.SemanticModel.GetDeclaredSymbol(target.Syntax, ct)
35+
: context.TargetSymbol as IMethodSymbol;
36+
37+
// The same selection the sync generator makes, so that only the methods it copies are cloned.
38+
if (symbol is null
39+
|| (!symbol.IsAsync
40+
&& (symbol.ReturnType is not INamedTypeSymbol named || !AsyncToSyncRewriter.IsTypeOfInterest(named))))
41+
{
42+
return null;
43+
}
44+
45+
foreach (var attribute in symbol.GetAttributes())
46+
{
47+
var name = attribute.AttributeClass?.ToDisplayString();
48+
49+
if (name == SyncMethodSourceGenerator.QualifiedSkipSyncVersionAttribute
50+
|| (isTargetTypeSymbol && name == SyncMethodSourceGenerator.QualifiedCreateSyncVersionAttribute))
51+
{
52+
return null;
53+
}
54+
}
55+
56+
if (!MethodLocation.TryCreate(target.Syntax, symbol, out var location, out var root))
57+
{
58+
return null;
59+
}
60+
61+
disableNullable |= context.Attributes[0].NamedArguments
62+
.Any(static a => a is { Key: SyncMethodSourceGenerator.OmitNullableDirective, Value.Value: true });
63+
64+
var rewritten = new IdentityRewriter(context.SemanticModel, target.Syntax).Visit(root);
65+
66+
return ClonedMethod.Create(location, target.Syntax, rewritten, disableNullable, []);
67+
}
68+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using Microsoft.CodeAnalysis.CSharp.Syntax;
2+
using Zomp.SyncMethodGenerator.Cloning;
3+
4+
namespace Generator.Tests;
5+
6+
/// <summary>
7+
/// A transformation which changes nothing but the name of the method, leaving everything else
8+
/// to <see cref="CloningRewriter"/>.
9+
/// </summary>
10+
/// <param name="semanticModel">The semantic model.</param>
11+
/// <param name="targetMethod">The method declaration to clone.</param>
12+
internal sealed class IdentityRewriter(SemanticModel semanticModel, MethodDeclarationSyntax targetMethod)
13+
: CloningRewriter(semanticModel, targetMethod)
14+
{
15+
/// <summary>
16+
/// Appended to the name of each method, so that the clone does not collide with the original.
17+
/// </summary>
18+
internal const string Suffix = "Clone";
19+
20+
/// <inheritdoc/>
21+
public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node)
22+
{
23+
if (base.VisitMethodDeclaration(node) is not MethodDeclarationSyntax clone)
24+
{
25+
return null;
26+
}
27+
28+
var name = SyntaxFactory.Identifier(node.Identifier.ValueText + Suffix).WithTriviaFrom(clone.Identifier);
29+
30+
// Directives leading the method belong to the file it was written in.
31+
return clone
32+
.WithIdentifier(name)
33+
.WithLeadingTrivia(RemovePreprocessorDirectives(clone.GetLeadingTrivia()));
34+
}
35+
}

‎tests/Generator.Tests/TestHelper.cs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ namespace Test;
134134
"Compilation errors:\n" + string.Join("\n", diagnostics.Select(d => d.ToString())));
135135
}
136136

137+
EnsureIdentityClonesCompile(compilation, parseOptions);
138+
137139
var target = new RunResultWithIgnoreList
138140
{
139141
Result = driver.GetRunResult(),
@@ -177,6 +179,51 @@ internal static string ChangeIndentation(string source, Func<string, string> fun
177179
return linesWithIndentation;
178180
}
179181

182+
/// <summary>
183+
/// Clones every method the test synchronizes, changing nothing but its name, and fails the
184+
/// test when a clone does not compile. Such a failure is in the cloning layer, whatever the
185+
/// test itself is about.
186+
/// </summary>
187+
private static void EnsureIdentityClonesCompile(CSharpCompilation compilation, CSharpParseOptions parseOptions)
188+
{
189+
// The sync generator runs as well, since it declares the attributes the source uses.
190+
var result = CSharpGeneratorDriver.Create(new SyncMethodSourceGenerator(), new IdentityCloneGenerator())
191+
.WithUpdatedParseOptions(parseOptions)
192+
.RunGenerators(compilation)
193+
.GetRunResult();
194+
195+
var identity = result.Results.Single(r => r.Generator.GetGeneratorType() == typeof(IdentityCloneGenerator));
196+
197+
if (identity.Exception is { } exception)
198+
{
199+
throw new InvalidOperationException("Identity clone threw", exception);
200+
}
201+
202+
// Every method which is synchronized is cloned as well, so that a pass which cloned
203+
// nothing cannot pass for one whose clones compile.
204+
var synchronized = result.Results
205+
.Single(r => r.Generator.GetGeneratorType() == typeof(SyncMethodSourceGenerator))
206+
.GeneratedSources
207+
.Count(s => s.HintName is not ($"{SyncMethodSourceGenerator.CreateSyncVersionAttribute}.g.cs" or $"{SyncMethodSourceGenerator.SkipSyncVersionAttribute}.g.cs"));
208+
209+
if (identity.GeneratedSources.Length < synchronized)
210+
{
211+
throw new InvalidOperationException(
212+
$"Identity clone produced {identity.GeneratedSources.Length} files for {synchronized} synchronized methods");
213+
}
214+
215+
var errors = identity.Diagnostics
216+
.Concat(compilation.AddSyntaxTrees(result.GeneratedTrees).GetDiagnostics())
217+
.Where(d => d.Severity == DiagnosticSeverity.Error)
218+
.ToArray();
219+
220+
if (errors.Length > 0)
221+
{
222+
throw new InvalidOperationException(
223+
"Identity clone does not compile:\n" + string.Join("\n", errors.Select(d => d.ToString())));
224+
}
225+
}
226+
180227
#if NET8_0_OR_GREATER
181228
[GeneratedRegex("\r\n")]
182229
private static partial Regex NewLineRegex();

0 commit comments

Comments
 (0)