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
30 changes: 23 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,11 @@ Use `#if ROSLYN_X_Y_OR_GREATER` guards for APIs that only exist in newer Roslyn
```text
src/Zomp.SyncMethodGenerator/ Generator (netstandard2.0)
SyncMethodSourceGenerator.cs Entry point — IIncrementalGenerator
AsyncToSyncRewriter.cs Core transformation engine (CSharpSyntaxRewriter)
SourceGenerationHelper.cs Output file structure and attribute definitions
AsyncToSyncRewriter.cs Async to sync rules, on top of CloningRewriter
SourceGenerationHelper.cs Attribute definitions
Extensions.cs Type-checking extensions on INamedTypeSymbol
DiagnosticMessages.cs ZSMGEN001-004 diagnostic descriptors
DiagnosticMessages.cs ZSMGEN001-006 diagnostic descriptors
Cloning/ Copying a method into a file of its own
Models/ Data records for the pipeline
Helpers/ EquatableArray<T>, DirectiveStack, etc.
Properties/ Assembly attributes
Expand All @@ -121,11 +122,26 @@ tests/Generator.Tests/ Unit tests (xUnit + Verify snapshot testi
tests/GenerationSandbox.Tests/ Integration tests (real-world patterns)
```

## Cloning layer

`Cloning/` holds everything about emitting a copy of a method that has nothing
to do with async: finding the marked methods (`CloneTarget`), collecting the
namespaces, usings and containing types around them (`MethodLocation`), fully
qualifying the names a method uses (`CloningRewriter`), detecting colliding
signatures and naming the files (`ClonedMethodOutput`), and writing the file
(`ClonedMethodSource`).

Keep async knowledge out of it. A transformation plugs in by deriving from
`CloningRewriter`, overriding the visitors it needs, and using the `MapSymbol`
and `MapTypeName` hooks to substitute types. Nothing under `Cloning/` should
know about tasks or `SYNC_ONLY`; a grep for `Task|Async|SYNC_ONLY` there should
come back empty.

## Transformation Pipeline

1. **Find candidates** — `ForAttributeWithMetadataName` locates `[CreateSyncVersion]` on methods or types
2. **Extract metadata** — parent class hierarchy, namespaces, configuration flags
3. **Rewrite** — `AsyncToSyncRewriter` (a `CSharpSyntaxRewriter`) traverses the syntax tree:
1. **Find candidates** — `CloneTarget` uses `ForAttributeWithMetadataName` to locate `[CreateSyncVersion]` on methods or types
2. **Extract metadata** — `MethodLocation` collects the parent class hierarchy, namespaces and usings; the generator reads the configuration flags
3. **Rewrite** — `AsyncToSyncRewriter` (a `CloningRewriter`, which fully qualifies names) traverses the syntax tree:
- Strips `async` modifier and `await` expressions
- Transforms return types: `Task`/`ValueTask` to `void`, `Task<T>`/`ValueTask<T>` to `T`
- Transforms collection types: `IAsyncEnumerable<T>` to `IEnumerable<T>`
Expand All @@ -134,7 +150,7 @@ tests/GenerationSandbox.Tests/ Integration tests (real-world patterns)
- Renames method calls: strips `Async` suffix
- Handles special methods: `Task.FromResult(x)` to `x`, `Task.Delay()` to `Thread.Sleep()`
- Processes `#if SYNC_ONLY` / `#if !SYNC_ONLY` directives
4. **Emit** — `SourceGenerationHelper` wraps the rewritten method in namespace/class structure
4. **Emit** — `ClonedMethodSource` wraps the rewritten method in namespace/class structure, and `ClonedMethodOutput` names the file and reports colliding signatures

## Testing Conventions

Expand Down
434 changes: 80 additions & 354 deletions src/Zomp.SyncMethodGenerator/AsyncToSyncRewriter.cs

Large diffs are not rendered by default.

61 changes: 61 additions & 0 deletions src/Zomp.SyncMethodGenerator/Cloning/CloneTarget.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
namespace Zomp.SyncMethodGenerator.Cloning;

/// <summary>
/// A method marked for cloning, either by an attribute of its own or by one on its containing type.
/// </summary>
/// <param name="Context">Context of the attribute which marked the method.</param>
/// <param name="Syntax">Declaration of the method.</param>
internal sealed record CloneTarget(GeneratorAttributeSyntaxContext Context, MethodDeclarationSyntax Syntax)
{
/// <summary>
/// Finds the methods an attribute marks. One on a type marks every method the type declares.
/// </summary>
/// <param name="syntaxProvider">Syntax provider of the generator.</param>
/// <param name="fullyQualifiedMetadataName">Name of the attribute.</param>
/// <returns>A method for each method marked.</returns>
public static IncrementalValuesProvider<CloneTarget> ForAttribute(SyntaxValueProvider syntaxProvider, string fullyQualifiedMetadataName)
=> syntaxProvider
.ForAttributeWithMetadataName(
fullyQualifiedMetadataName,
predicate: static (s, _) => IsCandidate(s),
transform: static (ctx, ct) => Expand(ctx, ct))
.SelectMany((list, ct) => list);

private static bool IsCandidate(SyntaxNode node) => node switch
{
MethodDeclarationSyntax { AttributeLists.Count: > 0 } => true,
ClassDeclarationSyntax { AttributeLists.Count: > 0 } => true,
StructDeclarationSyntax { AttributeLists.Count: > 0 } => true,
InterfaceDeclarationSyntax { AttributeLists.Count: > 0 } => true,
RecordDeclarationSyntax { AttributeLists.Count: > 0 } => true,
_ => false,
};

private static ImmutableArray<CloneTarget> Expand(GeneratorAttributeSyntaxContext ctx, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();

if (ctx.TargetNode is TypeDeclarationSyntax typeDecl)
{
#if ROSLYN_4_12_OR_GREATER
return [.. typeDecl.Members.OfType<MethodDeclarationSyntax>().Select(s => new CloneTarget(ctx, s))];
#else
return ImmutableArray.CreateRange(typeDecl.Members.OfType<MethodDeclarationSyntax>().Select(s => new CloneTarget(ctx, s)));
#endif
}
else if (ctx.TargetNode is MethodDeclarationSyntax methodDecl)
{
#if ROSLYN_4_12_OR_GREATER
return [new CloneTarget(ctx, methodDecl)];
#else
return ImmutableArray.Create(new CloneTarget(ctx, methodDecl));
#endif
}

#if ROSLYN_4_12_OR_GREATER
return [];
#else
return ImmutableArray<CloneTarget>.Empty;
#endif
}
}
53 changes: 53 additions & 0 deletions src/Zomp.SyncMethodGenerator/Cloning/ClonedMethod.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace Zomp.SyncMethodGenerator.Cloning;

/// <summary>
/// A method rewritten by a transformation, ready to be emitted into a file of its own.
/// </summary>
/// <param name="Location">Where the original method sits.</param>
/// <param name="MethodName">Name of the original method.</param>
/// <param name="Implementation">The rewritten method.</param>
/// <param name="DisableNullable">True to leave the <c>#nullable enable</c> directive out of the file.</param>
/// <param name="Diagnostics">Diagnostics the transformation reported.</param>
/// <param name="HasErrors">True if there are errors in <see cref="Diagnostics"/>, in which case nothing is emitted.</param>
/// <param name="Signature">Signature the rewritten method will be emitted with, used to detect colliding overloads.</param>
internal sealed record ClonedMethod(
MethodLocation Location,
string MethodName,
string Implementation,
bool DisableNullable,
EquatableArray<ReportedDiagnostic> Diagnostics,
bool HasErrors,
MethodSignature? Signature)
{
/// <summary>
/// Creates a cloned method from the result of rewriting it.
/// </summary>
/// <param name="location">Where the original method sits.</param>
/// <param name="original">Declaration of the original method.</param>
/// <param name="rewritten">Result of rewriting the method, or the extension block which holds it.</param>
/// <param name="disableNullable">True to leave the <c>#nullable enable</c> directive out of the file.</param>
/// <param name="diagnostics">Diagnostics the transformation reported.</param>
/// <returns>A new <see cref="ClonedMethod"/>.</returns>
public static ClonedMethod Create(
MethodLocation location,
MethodDeclarationSyntax original,
SyntaxNode rewritten,
bool disableNullable,
ImmutableArray<ReportedDiagnostic> diagnostics)
{
var hasErrors = false;
foreach (var diagnostic in diagnostics)
{
hasErrors |= diagnostic.Descriptor.DefaultSeverity == DiagnosticSeverity.Error;
}

return new(
location,
original.Identifier.ValueText,
rewritten.ToFullString(),
disableNullable,
diagnostics,
hasErrors,
MethodSignature.Create(rewritten, location, original));
}
}
138 changes: 138 additions & 0 deletions src/Zomp.SyncMethodGenerator/Cloning/ClonedMethodOutput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
namespace Zomp.SyncMethodGenerator.Cloning;

/// <summary>
/// Emits cloned methods, each into a file of its own, and reports those which cannot be emitted.
/// </summary>
internal static class ClonedMethodOutput
{
/// <summary>
/// Longest generated file name before the scope is shortened. Emitted files sit under
/// {project}\obj\{configuration}\{framework}\generated\{generator assembly}\{generator},
/// which alone is around a hundred characters before the project path is counted.
/// </summary>
private const int MaxFileNameLength = 100;

/// <summary>
/// Adds the output of the generator: a file for each method, and the diagnostics of each.
/// </summary>
/// <param name="context">Context of the generator.</param>
/// <param name="methods">Methods to emit.</param>
/// <param name="collision">Reported against each method whose signature another method also produces.</param>
public static void Register(
IncrementalGeneratorInitializationContext context,
IncrementalValuesProvider<ClonedMethod> methods,
DiagnosticDescriptor collision)
{
var sourceTexts = methods
.Select(static (m, _) => GenerateSource(m))
.WithTrackingName("GenerateSource");

// Signatures produced more than once. Emitting them all would declare the same member
// twice, so the methods behind them are reported instead of generated.
var collidingSignatures = methods
.Select(static (m, _) => m.Signature?.Key)
.Collect()
.Select(static (keys, _) => MethodSignature.FindCollisions(keys));

context.RegisterSourceOutput(
sourceTexts.Combine(collidingSignatures),
(spc, pair) =>
{
var (source, colliding) = pair;

foreach (var diagnostic in source.Method.Diagnostics)
{
spc.ReportDiagnostic(diagnostic);
}

if (source.Method.Signature is { } signature
&& colliding.AsImmutableArray().Contains(signature.Key, StringComparer.Ordinal))
{
spc.ReportDiagnostic(signature.ToDiagnostic(collision));
return;
}

if (!source.Method.HasErrors)
{
spc.AddSource(source.Path, SourceText.From(source.Content, Encoding.UTF8));
}
});
}

private static (ClonedMethod Method, string Path, string Content) GenerateSource(ClonedMethod m)
{
var location = m.Location;

var scope = $"{string.Join(".", location.Namespaces)}" +
$".{string.Join(".", location.Parents.Select(static p => p.ScopeName))}" +
(location.IsCSharp14Extension ? ".ext" : string.Empty);

var method = m.MethodName + (location.Index == 1 ? string.Empty : "_" + location.Index);

var sourcePath = BuildFileName(scope, method);

var source = ClonedMethodSource.Generate(m);

return (m, sourcePath, source);
}

/// <summary>
/// Builds the file name a generated method is emitted under, shortening it when the
/// namespace and containing type chain make it long enough to be a problem. Emitted files
/// live several directories deep inside the intermediate output path, so a long name here
/// can carry the full path past the limit the file system accepts.
/// </summary>
/// <remarks>
/// The scope is what gets shortened, since the method name is what someone reads the file
/// name for. A hash of the untruncated name keeps distinct methods in distinct files.
/// </remarks>
private static string BuildFileName(string scope, string method)
{
const string extension = ".g.cs";

var fileName = $"{scope}.{method}{extension}";

if (fileName.Length <= MaxFileNameLength)
{
return fileName;
}

var hash = Hash(fileName);

// separator, hash, dot, extension
var fixedLength = 1 + hash.Length + 1 + extension.Length;

var forScope = MaxFileNameLength - fixedLength - method.Length;

if (forScope > 0)
{
return $"{scope[..Math.Min(scope.Length, forScope)]}_{hash}.{method}{extension}";
}

// The method name alone fills the budget, so it has to be shortened as well.
return $"_{hash}.{method[..(MaxFileNameLength - fixedLength)]}{extension}";
}

/// <summary>
/// FNV-1a. Short, dependency free, and stable across processes and runtimes, which
/// <see cref="string.GetHashCode()"/> is not.
/// </summary>
private static string Hash(string value)
{
const uint offsetBasis = 2166136261;
const uint prime = 16777619;

var hash = offsetBasis;

unchecked
{
foreach (var c in value)
{
hash ^= c;
hash *= prime;
}
}

return hash.ToString("x8", System.Globalization.CultureInfo.InvariantCulture);
}
}
Loading
Loading