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
15 changes: 15 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "zomp",
"description": "Zomp's Claude Code plugins. Currently skills for Zomp.SyncMethodGenerator, the .NET source generator which writes the sync half of a sync/async method pair",
"owner": {
"name": "Zomp"
},
"plugins": [
{
"name": "sync-method-generator",
"source": "./plugins/sync-method-generator",
"description": "Generate the sync half of a C# sync/async method pair with Zomp.SyncMethodGenerator instead of hand-maintaining both",
"version": "0.1.0"
}
]
}
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,17 @@ To add the library use:
dotnet add package Zomp.SyncMethodGenerator
```

### Claude Code plugin

This repository is also a [Claude Code](https://claude.com/claude-code) marketplace. Installing the plugin teaches the agent to reach for this generator when it finds itself writing or maintaining both halves of a sync/async pair, rather than duplicating the method by hand.

```sh
claude plugin marketplace add zompinc/sync-method-generator
claude plugin install sync-method-generator@zomp
```

The plugin ships a single skill covering setup, the attribute options, the transformation table, and how to verify that a generated method is the hand-written one it replaces.

## Development

### Related projects
Expand Down
12 changes: 12 additions & 0 deletions plugins/sync-method-generator/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "sync-method-generator",
"version": "0.1.0",
"description": "Generate the sync half of a C# sync/async method pair with Zomp.SyncMethodGenerator instead of hand-maintaining both",
"author": {
"name": "Zomp"
},
"homepage": "https://github.com/zompinc/sync-method-generator",
"license": "MIT",
"keywords": ["dotnet", "csharp", "async", "source-generator", "roslyn"],
"skills": "./skills"
}
152 changes: 152 additions & 0 deletions plugins/sync-method-generator/skills/sync-from-async/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
---
name: sync-from-async
description: >
Generate the synchronous half of a C# method pair from the async half with the
Zomp.SyncMethodGenerator source generator, instead of hand-writing and maintaining both.
Use whenever a .NET type needs sync and async versions of the same method: adding a sync
overload beside an async one, editing a file where `Foo` and `FooAsync` share a body, or
removing the duplication between hand-written twins. Also use when asked to "write the sync
version of this method", "keep sync and async in step", or "stop duplicating sync and
async". DO NOT USE FOR: turning sync code async, calling async code from a sync context
(`GetAwaiter().GetResult()`), or languages other than C#.
---

# Sync methods generated from async ones

`Zomp.SyncMethodGenerator` is a Roslyn source generator. You write the async method, attribute
it, and the generator emits the sync twin into a partial of the same type: `await` and `async`
removed, `Task<T>` unwrapped to `T`, `IAsyncEnumerable<T>` to `IEnumerable<T>`,
`ReadOnlyMemory<T>` to `ReadOnlySpan<T>`, `CancellationToken` and `IProgress<T>` parameters
dropped, `ConfigureAwait` calls removed, and `FooAsync()` invocations rewritten to `Foo()`.

```cs
[Zomp.SyncMethodGenerator.CreateSyncVersion]
static async Task WriteAsync(ReadOnlyMemory<byte> buffer, Stream stream, CancellationToken ct)
=> await stream.WriteAsync(buffer, ct).ConfigureAwait(false);

// generated:
static void Write(ReadOnlySpan<byte> buffer, Stream stream)
=> stream.Write(buffer);
```

The point is that the two halves cannot drift. A bug fixed in the async method is fixed in the
sync one at the next build.

## Check that it fits before proposing it

Run through this first. The generator is a good answer for most sync/async pairs and a bad
answer for a few, and the bad cases are cheaper to spot now than after a migration.

1. **Both halves must live in the same type.** The generated method lands in a partial of the
type declaring the async method. If the hand-written sync version lives in a different class
(a `FooSync` helper, a separate `SyncExtensions`), the generator adds a member rather than
replacing one. Not a fit without moving code first.
1. **The type must be `partial`**, along with every type enclosing it.
1. **The sync half must be a mechanical translation of the async half.** If it uses a different
algorithm, different locking, a sync-only fast path, or different error handling, either
express the difference with `SYNC_ONLY` (see below) or leave the pair alone. Do not flatten a
real behavioural difference into a generated method.
1. **The async method should be named `FooAsync`.** The generated name is the source name with
the `Async` suffix removed; without the suffix the generated method collides with the
original and the build fails.
1. **Watch for a generated method that adds a member instead of removing a duplicate.** If the
sync signature you are about to generate is already satisfied by a base class or interface
shim, generating it changes which member callers bind to. That is a behaviour change, not a
deduplication. See [references/migrating-existing-twins.md](references/migrating-existing-twins.md).

## Set it up

```sh
dotnet add package Zomp.SyncMethodGenerator
```

`PrivateAssets="all"` keeps the generator out of the package your library ships:

```xml
<PackageReference Include="Zomp.SyncMethodGenerator" Version="2.0.42" PrivateAssets="all" />
```

With Central Package Management, the version goes in `Directory.Packages.props`. If most
projects in the solution need it, a single `GlobalPackageReference` is tidier than a
`PackageReference` per project - it carries `PrivateAssets="all"` implicitly:

```xml
<GlobalPackageReference Include="Zomp.SyncMethodGenerator" Version="2.0.42" />
```

The generator targets .NET Standard 2.0 and needs no runtime dependency on the consuming side.

## Write the async method

Attribute a single method, or attribute the type to generate for every async method in it:

```cs
[Zomp.SyncMethodGenerator.CreateSyncVersion]
partial class Reader
{
async Task<int> ReadAsync(...) { ... }

[Zomp.SyncMethodGenerator.SkipSyncVersion]
async Task NoSyncCounterpartAsync(...) { ... }
}
```

Attribute properties, all defaulting to `false`:

| Property | Effect |
| --------------------------- | --------------------------------------------------------------------------- |
| `PreserveCancellationToken` | Keeps `CancellationToken` parameters instead of dropping them |
| `PreserveProgress` | Keeps `IProgress<T>` parameters and `Report` calls instead of dropping them |
| `OmitNullableDirective` | Suppresses the `#nullable enable` the generator emits on C# 8 and above |

XML documentation is carried over and adjusted: a `<returns>` block is dropped when the sync
method returns nothing, and the source file's `using` directives are carried into the generated
file so `cref`s still resolve.

The full transformation table is in
[references/transformations.md](references/transformations.md). Read it when you need to know
whether a particular construct survives the rewrite.

## Code that should only run in one half

Wrap it in a `SYNC_ONLY` conditional. The symbol must never actually be defined anywhere - the
generator reads the directive rather than the compiler:

```cs
[Zomp.SyncMethodGenerator.CreateSyncVersion]
public async Task FlushAsync(CancellationToken ct)
{
#if SYNC_ONLY
Thread.Sleep(BackoffMilliseconds);
#endif
await Task.CompletedTask;
}
```

`#if !SYNC_ONLY` marks async-only code. The block is copied verbatim, so fully qualify anything
that relies on a `using` the generated file might not have. `SYNC_ONLY` cannot be combined with
other symbols in one condition and has no `#elif`.

## Verify what came out

Do not assume the generated method is what you expected - read it. Turn on emission:

```xml
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
```

Build, then find the file under
`obj/<Configuration>/<TargetFramework>/generated/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.SyncMethodSourceGenerator/`.
It is named `<Namespace>.<Type>.<Method>.g.cs`.

When replacing a hand-written sync method, compare the two mechanically rather than by eye.
[references/migrating-existing-twins.md](references/migrating-existing-twins.md) has the
workflow, including how to normalise the generator's `global::` qualification so the comparison
means something.

## When the output is wrong

The rewriter handles a large surface but not an unbounded one. If the generated method does not
compile or does not match the hand-written one for a reason you cannot explain, that is worth an
issue at https://github.com/zompinc/sync-method-generator/issues rather than a workaround -
several of the released fixes came from exactly this, reported by libraries adopting it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Replacing hand-written twins with generated ones

Adding the generator to a new method is low risk: nothing existed before, and the compiler
checks the result. Replacing a sync method somebody wrote and shipped is different. The
generated method has to be the method it replaces, and "looks about right" is not evidence.

SharpCompress wrote up its migration in
[docs/SYNC_METHOD_GENERATION.md](https://github.com/adamhathcock/sharpcompress/blob/master/docs/SYNC_METHOD_GENERATION.md).
Worth reading before a first migration in an established codebase.

## Order of work

The generated partial and the hand-written method have the same signature, so they cannot
coexist - keeping both is a duplicate member error. That means capturing the original before
deleting it.

1. **Capture the hand-written sync method.** Copy its full text somewhere outside the tree, or
rely on `git show HEAD:<path>` after the deletion.
1. **Delete the hand-written sync method** and attribute the async one.
1. **Build with emission on** so the generated file lands on disk:
```xml
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
```
Output goes to
`obj/<Configuration>/<TargetFramework>/generated/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.SyncMethodSourceGenerator/<Namespace>.<Type>.<Method>.g.cs`.
1. **Compare the generated method against the captured one**, normalised (see below).
1. **Explain every difference before accepting it.** A difference is either a bug in the
generator, a real behavioural difference that was hiding in the pair, or something the
original got wrong. All three are worth knowing about. None of them are noise.

## Normalising the comparison

A direct `diff` is dominated by two differences that do not matter, and hides the ones that do:

- **`global::` qualification.** The generator fully qualifies types. Strip the prefix before
comparing - but strip it precisely. A regex like `s/global::[A-Za-z.]*\.//` is over-greedy and
will eat parts of expressions such as `ArgumentException.ThrowIfNull`, producing a clean diff
that proves nothing.
- **Whitespace and line breaks.** The generator formats from the syntax tree, so wrapping
differs from what a human typed.

Compare token sequences, or normalise whitespace to single spaces and drop the qualification
prefix only where it directly precedes a type name. Then require exact equality. On a real
migration of ten methods, "ten of ten identical after normalisation" is the result worth
reporting; anything less needs a per-method explanation.

## Generating a member instead of removing one

The trap that does not show up as a diff. Before attributing a method, check what the sync
signature would bind to today.

SharpCompress hit this with `Read(Span<byte>)`. Generating it from `ReadAsync(Memory<byte>)` did
not deduplicate anything - `Stream` already provides a `Read(Span<byte>)` shim that routes to
`Read(byte[], int, int)`. The generated override displaced the shim, which is a change in
behaviour for every caller, not a cleanup. It may well be an improvement, but it is a separate
decision from removing duplication and should be made deliberately.

Ask, for each method: is there an existing base class or interface member with this exact
signature? If yes, the generated method is an override, and the change is about behaviour.

## Scope of a first pull request

Maintainers of established libraries are being asked to accept generated code into a build they
own. A first change that converts five to ten methods in one file, with the verification shown,
is easier to accept than one that sweeps a whole project. Save the wide sweep for after the
approach has been agreed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# What the rewriter changes

Reference for deciding whether a given async method translates cleanly. Source of truth is the
project README and the snapshot tests under `tests/Generator.Tests/Snapshots/`.

## Declaration

- The `async` modifier is removed.
- The `Async` suffix is removed from the method name.
- The `CreateSyncVersionAttribute` is removed from the generated method.
- `#nullable enable` is emitted when the language version is 8 or above, unless
`OmitNullableDirective` is set.

## Types

| From | To |
| ----------------------------------------------------------- | ------------------------------ |
| `Task`, `ValueTask` | `void` |
| `Task<T>`, `ValueTask<T>` | `T` |
| `Func<Task>` | `Action` |
| `Func<Task<T>>` | `Func<T>` |
| `IAsyncEnumerable<T>` | `IEnumerable<T>` |
| `IAsyncEnumerator<T>` | `IEnumerator<T>` |
| `ConfiguredCancelableAsyncEnumerable<T>.Enumerator` | `IEnumerator<T>` |
| `ConfiguredCancelableAsyncEnumerable<T>.GetAsyncEnumerator` | `IEnumerable<T>.GetEnumerator` |
| `Memory<T>` | `Span<T>` |
| `ReadOnlyMemory<T>` | `ReadOnlySpan<T>` |

`Memory<T>` and `ReadOnlyMemory<T>` are left alone when they appear as a type argument of a
collection - a `ref struct` cannot be an array element type, so the substitution would not
compile.

## Parameters

- `CancellationToken` parameters are removed, unless `PreserveCancellationToken` is set.
- `IProgress<T>` parameters are removed, unless `PreserveProgress` is set.

## Statements and invocations

- `await` is removed, including from `await foreach`.
- `ConfigureAwait` is removed from tasks and from async enumerations, including standalone
`ConfigureAwait` statements.
- `WaitAsync` and `WithCancellation` calls are removed.
- Invocations ending in `Async` are rewritten to call the sync overload:
`MoveNextAsync()` becomes `MoveNext()`.
- Async invocations without an `Async` suffix are removed.
- `CancellationToken` arguments are dropped from calls.
- `IProgress<T>.Report(T)` calls are removed unless `PreserveProgress` is set.
- `Memory<T>.Span` property accesses are removed, the value already being a span.
- `await Task.FromResult(value)` becomes `value`.
- `await Task.Delay(value)` becomes `Thread.Sleep(value)`.
- Any invocation returning `ConfiguredCancelableAsyncEnumerable<T>` becomes `GetEnumerator()`.

## Documentation

XML documentation comments are carried over. A `<returns>` block is dropped when the sync method
returns nothing. `using` directives from the source file are carried into the generated file, so
`cref`s that relied on them still resolve.

## Where it stops

The rewriter is syntactic and semantic, not a general translator. Constructs it does not know
about are copied through unchanged, which usually surfaces as a compile error in the generated
file rather than as silently wrong code. Read the generated output before trusting it - see the
verification section of the skill.
Loading