diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..20c0459 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -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" + } + ] +} diff --git a/README.md b/README.md index fad1f1b..8063fad 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/plugins/sync-method-generator/plugin.json b/plugins/sync-method-generator/plugin.json new file mode 100644 index 0000000..7c4de7e --- /dev/null +++ b/plugins/sync-method-generator/plugin.json @@ -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" +} diff --git a/plugins/sync-method-generator/skills/sync-from-async/SKILL.md b/plugins/sync-method-generator/skills/sync-from-async/SKILL.md new file mode 100644 index 0000000..4cbd7f8 --- /dev/null +++ b/plugins/sync-method-generator/skills/sync-from-async/SKILL.md @@ -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` unwrapped to `T`, `IAsyncEnumerable` to `IEnumerable`, +`ReadOnlyMemory` to `ReadOnlySpan`, `CancellationToken` and `IProgress` parameters +dropped, `ConfigureAwait` calls removed, and `FooAsync()` invocations rewritten to `Foo()`. + +```cs +[Zomp.SyncMethodGenerator.CreateSyncVersion] +static async Task WriteAsync(ReadOnlyMemory buffer, Stream stream, CancellationToken ct) + => await stream.WriteAsync(buffer, ct).ConfigureAwait(false); + +// generated: +static void Write(ReadOnlySpan 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 + +``` + +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 + +``` + +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 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` 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 `` 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 +true +``` + +Build, then find the file under +`obj///generated/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.SyncMethodSourceGenerator/`. +It is named `...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. diff --git a/plugins/sync-method-generator/skills/sync-from-async/references/migrating-existing-twins.md b/plugins/sync-method-generator/skills/sync-from-async/references/migrating-existing-twins.md new file mode 100644 index 0000000..d7882ba --- /dev/null +++ b/plugins/sync-method-generator/skills/sync-from-async/references/migrating-existing-twins.md @@ -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:` 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 + true + ``` + Output goes to + `obj///generated/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.SyncMethodSourceGenerator/...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)`. Generating it from `ReadAsync(Memory)` did +not deduplicate anything - `Stream` already provides a `Read(Span)` 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. diff --git a/plugins/sync-method-generator/skills/sync-from-async/references/transformations.md b/plugins/sync-method-generator/skills/sync-from-async/references/transformations.md new file mode 100644 index 0000000..95203fa --- /dev/null +++ b/plugins/sync-method-generator/skills/sync-from-async/references/transformations.md @@ -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`, `ValueTask` | `T` | +| `Func` | `Action` | +| `Func>` | `Func` | +| `IAsyncEnumerable` | `IEnumerable` | +| `IAsyncEnumerator` | `IEnumerator` | +| `ConfiguredCancelableAsyncEnumerable.Enumerator` | `IEnumerator` | +| `ConfiguredCancelableAsyncEnumerable.GetAsyncEnumerator` | `IEnumerable.GetEnumerator` | +| `Memory` | `Span` | +| `ReadOnlyMemory` | `ReadOnlySpan` | + +`Memory` and `ReadOnlyMemory` 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` 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.Report(T)` calls are removed unless `PreserveProgress` is set. +- `Memory.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` becomes `GetEnumerator()`. + +## Documentation + +XML documentation comments are carried over. A `` 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.