Skip to content

Commit 4e11dad

Browse files
feat(generators): Add Adapter pattern generator (#108)
* feat(generators): Add Adapter pattern generator Implements the Adapter pattern generator per issue #33: ## Features - Object adapter generation via [GenerateAdapter] attribute - Explicit member mapping via [AdapterMap] attribute - Support for interface and abstract class targets - Support for properties and methods (including async) - Override keyword for abstract class members - ThrowingStub policy for incremental development - Multiple adapters from single host class ## Diagnostics (PKADP001-008) - PKADP001: Host must be static partial - PKADP002: Target must be interface or abstract class - PKADP003: Missing mapping for target member - PKADP004: Duplicate mapping for target member - PKADP005: Signature mismatch - PKADP006: Type name conflict - PKADP007: Invalid adaptee type - PKADP008: Mapping method must be static ## Tests - 14 generator unit tests - 20 demo integration tests - All pass on net8.0, net9.0, net10.0 ## Documentation - Full docs at docs/generators/adapter.md - Real-world demos: Clock, Payment Gateway, Logger adapters Closes #33 * fix(adapter): Address Copilot review feedback - Filter mapping methods by adaptee type (fixes multiple adapters from same host) - Exclude events from target members (not supported) - For abstract class targets, only collect abstract members - Handle struct adaptee constructor (no null check for value types) Addresses feedback from GitHub Copilot review on PR #108. * fix(adapter): address PR review comments - Use SymbolDisplay.FormatPrimitive for default value formatting - Add setter stub for mapped properties with setters - Validate ref/out/in parameter modifiers in signature check - De-duplicate members from interface diamond inheritance - Add tests for PKADP007 (invalid adaptee), PKADP008 (non-static map) - Add test for overlapping member names across adapters - Add test for interface diamond de-duplication - Add test for ref parameter validation - Fix docs: Charge -> ChargeAsync in example * fix(adapter): address all remaining PR review comments - Add PKADP006 type name conflict check before generating adapter - Add PKADP009 for events not supported in target contract - Add PKADP010 for generic methods not supported - Add PKADP011 for overloaded methods not supported - Add PKADP012 for abstract class without parameterless constructor - Add tests for all new diagnostics (events, generics, overloads, ctor) - Add test for struct adaptee (no null check) - Add test for type name conflict - Update AnalyzerReleases.Unshipped.md with new diagnostic IDs * fix(adapter): address latest PR review feedback - Fix parameterless ctor check to include protected internal and internal - Fix overload detection to compare full signatures (not just name count) - Diamond inheritance (same signature from multiple paths) no longer falsely triggers PKADP011 - Add PKADP013 diagnostic for settable properties (not supported) - Remove setter stub generation (now a compile-time error) - Add SymbolDisplayFormat for consistent type output with global:: prefix - Fix interface diamond test to use actual diamond (IBase1, IBase2) - Add tests for settable property diagnostic - Update test assertions to match global:: prefixed types * Address review feedback: fully-qualified types, member ordering, and diagnostic improvements (#110) * Initial plan * fix(adapter): address review feedback - fully-qualified types, member ordering, and code style Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> * fix(adapter): Add missing diagnostics, validation, and deterministic ordering (#111) * Initial plan * fix(adapter): Address PR review feedback - diagnostics, validation, and docs Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> * fix(adapter): Add parameter validation and nullability-aware type comparison (#112) * Initial plan * fix(adapter): Address review feedback - add parameter validation and improve type comparison Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> * fix(adapter): Only reject unbound generic types, allow closed generics Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> * fix(adapter): Remove redundant type checks and add missing diagnostic tests (#113) * Initial plan * fix(adapter): Remove redundant type checks and add missing diagnostic tests Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> * fix(adapter): Detect duplicate adapter names, reject indexers, validate internal constructor accessibility (#114) * Initial plan * fix(adapter): Address review feedback - duplicate adapter detection, indexers, and internal ctor accessibility Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> * docs(adapter): Add PKADP018 indexer diagnostic to documentation Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> * fix(adapter): Improve variable naming and remove redundant indexer check in ref-return validation Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> * refactor(adapter): Improve code readability in constructor and property validation Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> * refactor(adapter): Use ContainsKey instead of TryGetValue for duplicate adapter check Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> * fix(adapter): Cross-host collision detection, diagnostic locations, and declaration order (#115) * Initial plan * fix(adapter): Add tests and fix cross-host collision detection, member locations, and declaration order Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> * fix(adapter): Address enum defaults, partial types, ordering, and DIM filtering (#116) * Initial plan * fix(adapter): Apply PR review fixes - enum defaults, partial types, ordering, accessibility, interface DIMs Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> * refactor(adapter): Combine internal accessibility checks and clarify ordering comment Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> * refactor(adapter): Simplify conflict detection and filtering logic with LINQ Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com>
1 parent 34e66e7 commit 4e11dad

9 files changed

Lines changed: 3648 additions & 0 deletions

File tree

docs/generators/adapter.md

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
# Adapter Generator
2+
3+
## Overview
4+
5+
The **Adapter Generator** creates object adapters that implement a target contract (interface or abstract class) by delegating to an adaptee through explicit mapping methods. This pattern allows incompatible interfaces to work together without modifying either the target or adaptee.
6+
7+
## When to Use
8+
9+
Use the Adapter generator when you need to:
10+
11+
- **Integrate legacy code**: Wrap older implementations to work with modern interfaces
12+
- **Abstract third-party libraries**: Create a clean boundary around external dependencies
13+
- **Support multiple implementations**: Adapt different backends (payment gateways, loggers, etc.) to a unified interface
14+
- **Compile-time safety**: Ensure all contract members are properly mapped
15+
16+
## Installation
17+
18+
The generator is included in the `PatternKit.Generators` package:
19+
20+
```bash
21+
dotnet add package PatternKit.Generators
22+
```
23+
24+
## Quick Start
25+
26+
```csharp
27+
using PatternKit.Generators.Adapter;
28+
29+
// Target interface your app uses
30+
public interface IClock
31+
{
32+
DateTimeOffset UtcNow { get; }
33+
}
34+
35+
// Legacy class with different API
36+
public class LegacyClock
37+
{
38+
public DateTime GetCurrentTimeUtc() => DateTime.UtcNow;
39+
}
40+
41+
// Define mappings in a static partial class
42+
[GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))]
43+
public static partial class ClockAdapters
44+
{
45+
[AdapterMap(TargetMember = nameof(IClock.UtcNow))]
46+
public static DateTimeOffset MapUtcNow(LegacyClock adaptee)
47+
=> new(adaptee.GetCurrentTimeUtc(), TimeSpan.Zero);
48+
}
49+
```
50+
51+
Generated:
52+
```csharp
53+
public sealed partial class LegacyClockToIClockAdapter : IClock
54+
{
55+
private readonly LegacyClock _adaptee;
56+
57+
public LegacyClockToIClockAdapter(LegacyClock adaptee)
58+
{
59+
_adaptee = adaptee ?? throw new ArgumentNullException(nameof(adaptee));
60+
}
61+
62+
public DateTimeOffset UtcNow
63+
{
64+
get => ClockAdapters.MapUtcNow(_adaptee);
65+
}
66+
}
67+
```
68+
69+
Usage:
70+
```csharp
71+
// Create the adapter
72+
IClock clock = new LegacyClockToIClockAdapter(new LegacyClock());
73+
74+
// Use through the clean interface
75+
var now = clock.UtcNow;
76+
```
77+
78+
## Mapping Methods
79+
80+
Each target contract member needs a mapping method marked with `[AdapterMap]`.
81+
82+
### Property Mappings
83+
84+
For properties, the mapping method takes only the adaptee and returns the property type:
85+
86+
```csharp
87+
public interface IService
88+
{
89+
string Name { get; }
90+
}
91+
92+
[AdapterMap(TargetMember = nameof(IService.Name))]
93+
public static string MapName(LegacyService adaptee) => adaptee.ServiceName;
94+
```
95+
96+
### Method Mappings
97+
98+
For methods, the mapping method takes the adaptee as the first parameter, followed by all method parameters:
99+
100+
```csharp
101+
public interface ICalculator
102+
{
103+
int Add(int a, int b);
104+
}
105+
106+
[AdapterMap(TargetMember = nameof(ICalculator.Add))]
107+
public static int MapAdd(OldCalculator adaptee, int a, int b)
108+
=> adaptee.Sum(a, b);
109+
```
110+
111+
### Async Method Mappings
112+
113+
Async methods work the same way - just match the return type:
114+
115+
```csharp
116+
public interface IPaymentGateway
117+
{
118+
Task<PaymentResult> ChargeAsync(string token, decimal amount, CancellationToken ct);
119+
}
120+
121+
[AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))]
122+
public static async Task<PaymentResult> MapChargeAsync(
123+
LegacyPaymentClient adaptee,
124+
string token,
125+
decimal amount,
126+
CancellationToken ct)
127+
{
128+
var response = await adaptee.ProcessPaymentAsync(token, (int)(amount * 100), ct);
129+
return new PaymentResult(response.Success, response.Id);
130+
}
131+
```
132+
133+
## Attributes
134+
135+
### `[GenerateAdapter]`
136+
137+
Marks a static partial class as an adapter mapping host.
138+
139+
| Property | Type | Default | Description |
140+
|---|---|---|---|
141+
| `Target` | `Type` | Required | The interface or abstract class to implement |
142+
| `Adaptee` | `Type` | Required | The class to adapt |
143+
| `AdapterTypeName` | `string` | `{Adaptee}To{Target}Adapter` | Custom name for the generated adapter class |
144+
| `MissingMap` | `AdapterMissingMapPolicy` | `Error` | How to handle unmapped members |
145+
| `Sealed` | `bool` | `true` | Whether the adapter class is sealed |
146+
| `Namespace` | `string` | Host namespace | Custom namespace for the adapter |
147+
148+
### `[AdapterMap]`
149+
150+
Marks a method as a mapping for a target member.
151+
152+
| Property | Type | Default | Description |
153+
|---|---|---|---|
154+
| `TargetMember` | `string` | Required | Name of the target member (use `nameof()`) |
155+
156+
## Missing Map Policies
157+
158+
Control what happens when a target member has no `[AdapterMap]`:
159+
160+
### Error (Default)
161+
162+
Emits a compiler error. Recommended for production code:
163+
164+
```csharp
165+
[GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))]
166+
// MissingMap = AdapterMissingMapPolicy.Error is the default
167+
```
168+
169+
### ThrowingStub
170+
171+
Generates a stub that throws `NotImplementedException`. Useful during incremental development:
172+
173+
```csharp
174+
[GenerateAdapter(
175+
Target = typeof(IClock),
176+
Adaptee = typeof(LegacyClock),
177+
MissingMap = AdapterMissingMapPolicy.ThrowingStub)]
178+
```
179+
180+
### Ignore
181+
182+
Silently ignores unmapped members. May cause compilation errors if the target is an interface (missing implementations):
183+
184+
```csharp
185+
[GenerateAdapter(
186+
Target = typeof(IPartialService),
187+
Adaptee = typeof(Legacy),
188+
MissingMap = AdapterMissingMapPolicy.Ignore)]
189+
```
190+
191+
## Multiple Adapters
192+
193+
You can define multiple adapters in the same host class:
194+
195+
```csharp
196+
[GenerateAdapter(Target = typeof(IPaymentGateway), Adaptee = typeof(StripeClient))]
197+
[GenerateAdapter(Target = typeof(IPaymentGateway), Adaptee = typeof(PayPalClient))]
198+
public static partial class PaymentAdapters
199+
{
200+
[AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))]
201+
public static Task<PaymentResult> MapStripeChargeAsync(StripeClient adaptee, ...) { ... }
202+
203+
[AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))]
204+
public static Task<PaymentResult> MapPayPalChargeAsync(PayPalClient adaptee, ...) { ... }
205+
}
206+
```
207+
208+
The generator matches mapping methods to adapters by the first parameter type (adaptee).
209+
210+
## Abstract Class Targets
211+
212+
The generator supports abstract classes as targets:
213+
214+
```csharp
215+
public abstract class ClockBase
216+
{
217+
public abstract DateTimeOffset Now { get; }
218+
public virtual string TimeZone => "UTC"; // Inherited, not in contract
219+
}
220+
221+
[GenerateAdapter(Target = typeof(ClockBase), Adaptee = typeof(LegacyClock))]
222+
public static partial class Adapters
223+
{
224+
[AdapterMap(TargetMember = nameof(ClockBase.Now))]
225+
public static DateTimeOffset MapNow(LegacyClock adaptee) => ...;
226+
// Only abstract members need mapping
227+
}
228+
```
229+
230+
## Diagnostics
231+
232+
| ID | Severity | Description |
233+
|---|---|---|
234+
| **PKADP001** | Error | Adapter host must be `static partial` |
235+
| **PKADP002** | Error | Target must be interface or abstract class |
236+
| **PKADP003** | Error | Missing `[AdapterMap]` for target member |
237+
| **PKADP004** | Error | Multiple `[AdapterMap]` methods for same target member |
238+
| **PKADP005** | Error | Mapping method signature doesn't match target member |
239+
| **PKADP006** | Error | Adapter type name conflicts with existing type |
240+
| **PKADP007** | Error | Adaptee must be a concrete class or struct |
241+
| **PKADP008** | Error | Mapping method must be static |
242+
| **PKADP009** | Error | Events are not supported |
243+
| **PKADP010** | Error | Generic methods are not supported |
244+
| **PKADP011** | Error | Overloaded methods are not supported |
245+
| **PKADP012** | Error | Abstract class target requires accessible parameterless constructor |
246+
| **PKADP013** | Error | Settable properties are not supported |
247+
| **PKADP014** | Error | Nested or generic host not supported |
248+
| **PKADP015** | Error | Mapping method must be accessible (public or internal) |
249+
| **PKADP016** | Error | Static members are not supported |
250+
| **PKADP017** | Error | Ref-return members are not supported |
251+
| **PKADP018** | Error | Indexers are not supported |
252+
253+
## Limitations
254+
255+
### Multiple Adapters with Shared Adaptee
256+
257+
When defining multiple `[GenerateAdapter]` attributes within the same host class that share the same adaptee type, mapping ambiguity can occur. The generator matches `[AdapterMap]` methods to adapters solely by adaptee type and then by `TargetMember` name. If two target types have overlapping member names (both use `nameof(...)` resulting in the same string), mappings become inherently ambiguous, and the generator cannot reliably determine which adapter a mapping belongs to. In this case, `PKADP004` duplicate mapping diagnostics are expected given the current API design, rather than being false positives, unless mappings are split into separate hosts or the API is extended to provide additional disambiguation.
258+
259+
**Workaround:** Define separate host classes for each adapter when they share the same adaptee type:
260+
261+
```csharp
262+
// ✅ Good: Separate hosts avoid ambiguity
263+
[GenerateAdapter(Target = typeof(IServiceA), Adaptee = typeof(LegacyService))]
264+
public static partial class ServiceAAdapters
265+
{
266+
[AdapterMap(TargetMember = nameof(IServiceA.DoWork))]
267+
public static void MapDoWork(LegacyService adaptee) => adaptee.Execute();
268+
}
269+
270+
[GenerateAdapter(Target = typeof(IServiceB), Adaptee = typeof(LegacyService))]
271+
public static partial class ServiceBAdapters
272+
{
273+
[AdapterMap(TargetMember = nameof(IServiceB.DoWork))]
274+
public static void MapDoWork(LegacyService adaptee) => adaptee.Run();
275+
}
276+
277+
// ⚠️ Problematic: Multiple adapters with same adaptee in one host
278+
public static partial class AllAdapters
279+
{
280+
// Both IServiceA and IServiceB have DoWork() members
281+
// The generator cannot distinguish which mapping is for which target
282+
}
283+
```
284+
285+
## Best Practices
286+
287+
### 1. Use `nameof()` for Type Safety
288+
289+
```csharp
290+
// ✅ Good: Compile-time checked
291+
[AdapterMap(TargetMember = nameof(IClock.Now))]
292+
293+
// ❌ Bad: String literals can drift
294+
[AdapterMap(TargetMember = "Now")]
295+
```
296+
297+
### 2. Keep Mapping Methods Simple
298+
299+
Mapping methods should be thin wrappers, not business logic:
300+
301+
```csharp
302+
// ✅ Good: Simple delegation with conversion
303+
[AdapterMap(TargetMember = nameof(IService.DoWork))]
304+
public static void MapDoWork(Legacy adaptee, string input)
305+
=> adaptee.PerformTask(input);
306+
307+
// ❌ Bad: Business logic in mapping
308+
[AdapterMap(TargetMember = nameof(IService.DoWork))]
309+
public static void MapDoWork(Legacy adaptee, string input)
310+
{
311+
if (string.IsNullOrEmpty(input)) throw new ArgumentException();
312+
var processed = input.ToUpper().Trim();
313+
adaptee.PerformTask(processed);
314+
// This logic should be elsewhere
315+
}
316+
```
317+
318+
### 3. Separate Mapping Hosts by Domain
319+
320+
```csharp
321+
// ✅ Good: Organized by domain
322+
public static partial class PaymentAdapters { ... }
323+
public static partial class LoggingAdapters { ... }
324+
325+
// ❌ Bad: Everything in one place
326+
public static partial class AllAdapters { ... }
327+
```
328+
329+
### 4. Document Complex Mappings
330+
331+
```csharp
332+
/// <summary>
333+
/// Maps the legacy millisecond-based delay to TimeSpan.
334+
/// Note: Precision is limited to milliseconds.
335+
/// </summary>
336+
[AdapterMap(TargetMember = nameof(IClock.DelayAsync))]
337+
public static ValueTask MapDelayAsync(LegacyClock adaptee, TimeSpan duration, CancellationToken ct)
338+
=> new(adaptee.Sleep((int)duration.TotalMilliseconds, ct));
339+
```
340+
341+
## Real-World Example: Payment Gateway Abstraction
342+
343+
```csharp
344+
// Unified interface for your application
345+
public interface IPaymentGateway
346+
{
347+
Task<PaymentResult> ChargeAsync(string token, decimal amount, string currency, CancellationToken ct);
348+
Task<RefundResult> RefundAsync(string transactionId, decimal amount, CancellationToken ct);
349+
string GatewayName { get; }
350+
}
351+
352+
// Stripe adapter
353+
[GenerateAdapter(Target = typeof(IPaymentGateway), Adaptee = typeof(StripeClient), AdapterTypeName = "StripePaymentAdapter")]
354+
public static partial class StripeAdapters
355+
{
356+
[AdapterMap(TargetMember = nameof(IPaymentGateway.GatewayName))]
357+
public static string MapGatewayName(StripeClient adaptee) => "Stripe";
358+
359+
[AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))]
360+
public static async Task<PaymentResult> MapChargeAsync(
361+
StripeClient adaptee, string token, decimal amount, string currency, CancellationToken ct)
362+
{
363+
var request = new StripeChargeRequest { Source = token, Amount = (long)(amount * 100), Currency = currency };
364+
var response = await adaptee.CreateChargeAsync(request, ct);
365+
return new PaymentResult(response.Succeeded, response.ChargeId, response.Error);
366+
}
367+
368+
[AdapterMap(TargetMember = nameof(IPaymentGateway.RefundAsync))]
369+
public static async Task<RefundResult> MapRefundAsync(
370+
StripeClient adaptee, string transactionId, decimal amount, CancellationToken ct)
371+
{
372+
var response = await adaptee.CreateRefundAsync(transactionId, (long)(amount * 100), ct);
373+
return new RefundResult(response.Succeeded, response.RefundId, response.Error);
374+
}
375+
}
376+
377+
// Usage with DI
378+
services.AddSingleton<StripeClient>();
379+
services.AddSingleton<IPaymentGateway>(sp => new StripePaymentAdapter(sp.GetRequiredService<StripeClient>()));
380+
```
381+
382+
## See Also
383+
384+
- [Facade Generator](facade.md) - For simplifying complex subsystems
385+
- [Decorator Generator](decorator.md) - For adding behavior to objects
386+
- [Proxy Generator](proxy.md) - For controlling access to objects

0 commit comments

Comments
 (0)