diff --git a/AGENTS.md b/AGENTS.md index 83743a8..d49e158 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,6 +160,10 @@ come back empty. - Tests compile against real framework assemblies via `TestHelper` - `TestHelper` fails a test whose generated code does not compile, before any snapshot is compared, and reports the compiler errors +- `TestHelper` also clones every synchronized method through + `IdentityCloneGenerator`, which changes nothing but the method's name, and + fails the test with "Identity clone does not compile" when a clone does not. + That failure is in the `Cloning/` layer, not in the async to sync rules ### Choosing how the source is wrapped diff --git a/src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj b/src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj index 38aa6d0..a8c2e83 100644 --- a/src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj +++ b/src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj @@ -54,6 +54,11 @@ + + + + + diff --git a/tests/Generator.Tests/IdentityCloneGenerator.cs b/tests/Generator.Tests/IdentityCloneGenerator.cs new file mode 100644 index 0000000..c9f1039 --- /dev/null +++ b/tests/Generator.Tests/IdentityCloneGenerator.cs @@ -0,0 +1,68 @@ +using Zomp.SyncMethodGenerator; +using Zomp.SyncMethodGenerator.Cloning; + +namespace Generator.Tests; + +/// +/// Clones every method the sync generator would synchronize, through , +/// which changes nothing but its name. A clone which does not compile points at the cloning layer +/// rather than at the async to sync rules. +/// +internal sealed class IdentityCloneGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var disableNullable = context.CompilationProvider.Select(static (c, _) => + c.Options.NullableContextOptions == NullableContextOptions.Disable + || c is CSharpCompilation { LanguageVersion: < LanguageVersion.CSharp8 }); + + var methods = CloneTarget.ForAttribute(context.SyntaxProvider, SyncMethodSourceGenerator.QualifiedCreateSyncVersionAttribute) + .Combine(disableNullable) + .Select(static (data, ct) => Clone(data.Left, data.Right, ct)!) + .Where(static m => m is not null); + + ClonedMethodOutput.Register(context, methods, Zomp.SyncMethodGenerator.DiagnosticMessages.CollidingOverloads); + } + + private static ClonedMethod? Clone(CloneTarget target, bool disableNullable, CancellationToken ct) + { + var context = target.Context; + var isTargetTypeSymbol = context.TargetSymbol is ITypeSymbol; + + var symbol = isTargetTypeSymbol + ? context.SemanticModel.GetDeclaredSymbol(target.Syntax, ct) + : context.TargetSymbol as IMethodSymbol; + + // The same selection the sync generator makes, so that only the methods it copies are cloned. + if (symbol is null + || (!symbol.IsAsync + && (symbol.ReturnType is not INamedTypeSymbol named || !AsyncToSyncRewriter.IsTypeOfInterest(named)))) + { + return null; + } + + foreach (var attribute in symbol.GetAttributes()) + { + var name = attribute.AttributeClass?.ToDisplayString(); + + if (name == SyncMethodSourceGenerator.QualifiedSkipSyncVersionAttribute + || (isTargetTypeSymbol && name == SyncMethodSourceGenerator.QualifiedCreateSyncVersionAttribute)) + { + return null; + } + } + + if (!MethodLocation.TryCreate(target.Syntax, symbol, out var location, out var root)) + { + return null; + } + + disableNullable |= context.Attributes[0].NamedArguments + .Any(static a => a is { Key: SyncMethodSourceGenerator.OmitNullableDirective, Value.Value: true }); + + var rewritten = new IdentityRewriter(context.SemanticModel, target.Syntax).Visit(root); + + return ClonedMethod.Create(location, target.Syntax, rewritten, disableNullable, []); + } +} diff --git a/tests/Generator.Tests/IdentityRewriter.cs b/tests/Generator.Tests/IdentityRewriter.cs new file mode 100644 index 0000000..459bab3 --- /dev/null +++ b/tests/Generator.Tests/IdentityRewriter.cs @@ -0,0 +1,35 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Zomp.SyncMethodGenerator.Cloning; + +namespace Generator.Tests; + +/// +/// A transformation which changes nothing but the name of the method, leaving everything else +/// to . +/// +/// The semantic model. +/// The method declaration to clone. +internal sealed class IdentityRewriter(SemanticModel semanticModel, MethodDeclarationSyntax targetMethod) + : CloningRewriter(semanticModel, targetMethod) +{ + /// + /// Appended to the name of each method, so that the clone does not collide with the original. + /// + internal const string Suffix = "Clone"; + + /// + public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node) + { + if (base.VisitMethodDeclaration(node) is not MethodDeclarationSyntax clone) + { + return null; + } + + var name = SyntaxFactory.Identifier(node.Identifier.ValueText + Suffix).WithTriviaFrom(clone.Identifier); + + // Directives leading the method belong to the file it was written in. + return clone + .WithIdentifier(name) + .WithLeadingTrivia(RemovePreprocessorDirectives(clone.GetLeadingTrivia())); + } +} diff --git a/tests/Generator.Tests/TestHelper.cs b/tests/Generator.Tests/TestHelper.cs index 900cc9d..f5be7f4 100644 --- a/tests/Generator.Tests/TestHelper.cs +++ b/tests/Generator.Tests/TestHelper.cs @@ -134,6 +134,8 @@ namespace Test; "Compilation errors:\n" + string.Join("\n", diagnostics.Select(d => d.ToString()))); } + EnsureIdentityClonesCompile(compilation, parseOptions); + var target = new RunResultWithIgnoreList { Result = driver.GetRunResult(), @@ -177,6 +179,51 @@ internal static string ChangeIndentation(string source, Func fun return linesWithIndentation; } + /// + /// Clones every method the test synchronizes, changing nothing but its name, and fails the + /// test when a clone does not compile. Such a failure is in the cloning layer, whatever the + /// test itself is about. + /// + private static void EnsureIdentityClonesCompile(CSharpCompilation compilation, CSharpParseOptions parseOptions) + { + // The sync generator runs as well, since it declares the attributes the source uses. + var result = CSharpGeneratorDriver.Create(new SyncMethodSourceGenerator(), new IdentityCloneGenerator()) + .WithUpdatedParseOptions(parseOptions) + .RunGenerators(compilation) + .GetRunResult(); + + var identity = result.Results.Single(r => r.Generator.GetGeneratorType() == typeof(IdentityCloneGenerator)); + + if (identity.Exception is { } exception) + { + throw new InvalidOperationException("Identity clone threw", exception); + } + + // Every method which is synchronized is cloned as well, so that a pass which cloned + // nothing cannot pass for one whose clones compile. + var synchronized = result.Results + .Single(r => r.Generator.GetGeneratorType() == typeof(SyncMethodSourceGenerator)) + .GeneratedSources + .Count(s => s.HintName is not ($"{SyncMethodSourceGenerator.CreateSyncVersionAttribute}.g.cs" or $"{SyncMethodSourceGenerator.SkipSyncVersionAttribute}.g.cs")); + + if (identity.GeneratedSources.Length < synchronized) + { + throw new InvalidOperationException( + $"Identity clone produced {identity.GeneratedSources.Length} files for {synchronized} synchronized methods"); + } + + var errors = identity.Diagnostics + .Concat(compilation.AddSyntaxTrees(result.GeneratedTrees).GetDiagnostics()) + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToArray(); + + if (errors.Length > 0) + { + throw new InvalidOperationException( + "Identity clone does not compile:\n" + string.Join("\n", errors.Select(d => d.ToString()))); + } + } + #if NET8_0_OR_GREATER [GeneratedRegex("\r\n")] private static partial Regex NewLineRegex();