Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
<Using Include="Zomp.SyncMethodGenerator.DiagnosticMessages" Static="true" />
</ItemGroup>

<ItemGroup>
<!-- The tests clone methods through the Cloning layer directly -->
<InternalsVisibleTo Include="Generator.Tests" />
</ItemGroup>

<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers" />
</ItemGroup>
Expand Down
68 changes: 68 additions & 0 deletions tests/Generator.Tests/IdentityCloneGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using Zomp.SyncMethodGenerator;
using Zomp.SyncMethodGenerator.Cloning;

namespace Generator.Tests;

/// <summary>
/// Clones every method the sync generator would synchronize, through <see cref="IdentityRewriter"/>,
/// 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.
/// </summary>
internal sealed class IdentityCloneGenerator : IIncrementalGenerator
{
/// <inheritdoc/>
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, []);
}
}
35 changes: 35 additions & 0 deletions tests/Generator.Tests/IdentityRewriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Zomp.SyncMethodGenerator.Cloning;

namespace Generator.Tests;

/// <summary>
/// A transformation which changes nothing but the name of the method, leaving everything else
/// to <see cref="CloningRewriter"/>.
/// </summary>
/// <param name="semanticModel">The semantic model.</param>
/// <param name="targetMethod">The method declaration to clone.</param>
internal sealed class IdentityRewriter(SemanticModel semanticModel, MethodDeclarationSyntax targetMethod)
: CloningRewriter(semanticModel, targetMethod)
{
/// <summary>
/// Appended to the name of each method, so that the clone does not collide with the original.
/// </summary>
internal const string Suffix = "Clone";

/// <inheritdoc/>
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()));
}
}
47 changes: 47 additions & 0 deletions tests/Generator.Tests/TestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -177,6 +179,51 @@ internal static string ChangeIndentation(string source, Func<string, string> fun
return linesWithIndentation;
}

/// <summary>
/// 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.
/// </summary>
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();
Expand Down
Loading