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
41 changes: 41 additions & 0 deletions docs/generators/adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,47 @@ public static partial class Adapters
| **PKADP006** | Error | Adapter type name conflicts with existing type |
| **PKADP007** | Error | Adaptee must be a concrete class or struct |
| **PKADP008** | Error | Mapping method must be static |
| **PKADP009** | Error | Events are not supported |
| **PKADP010** | Error | Generic methods are not supported |
| **PKADP011** | Error | Overloaded methods are not supported |
| **PKADP012** | Error | Abstract class target requires accessible parameterless constructor |
| **PKADP013** | Error | Settable properties are not supported |
| **PKADP014** | Error | Nested or generic host not supported |
| **PKADP015** | Error | Mapping method must be accessible (public or internal) |
| **PKADP016** | Error | Static members are not supported |
| **PKADP017** | Error | Ref-return members are not supported |

## Limitations

### Multiple Adapters with Shared Adaptee

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 ambiguous and may trigger false `PKADP004` duplicate mapping diagnostics.

**Workaround:** Define separate host classes for each adapter when they share the same adaptee type:

```csharp
// ✅ Good: Separate hosts avoid ambiguity
[GenerateAdapter(Target = typeof(IServiceA), Adaptee = typeof(LegacyService))]
public static partial class ServiceAAdapters
{
[AdapterMap(TargetMember = nameof(IServiceA.DoWork))]
public static void MapDoWork(LegacyService adaptee) => adaptee.Execute();
}

[GenerateAdapter(Target = typeof(IServiceB), Adaptee = typeof(LegacyService))]
public static partial class ServiceBAdapters
{
[AdapterMap(TargetMember = nameof(IServiceB.DoWork))]
public static void MapDoWork(LegacyService adaptee) => adaptee.Run();
}

// ⚠️ Problematic: Multiple adapters with same adaptee in one host
public static partial class AllAdapters
{
// Both IServiceA and IServiceB have DoWork() members
// The generator cannot distinguish which mapping is for which target
}
```

## Best Practices

Expand Down
99 changes: 95 additions & 4 deletions src/PatternKit.Generators/Adapter/AdapterGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Text;

namespace PatternKit.Generators.Adapter;
Expand Down Expand Up @@ -35,6 +34,10 @@ public sealed class AdapterGenerator : IIncrementalGenerator
private const string DiagIdOverloadedMethodsNotSupported = "PKADP011";
private const string DiagIdAbstractClassNoParameterlessCtor = "PKADP012";
private const string DiagIdSettablePropertiesNotSupported = "PKADP013";
private const string DiagIdNestedOrGenericHost = "PKADP014";
private const string DiagIdMappingMethodNotAccessible = "PKADP015";
private const string DiagIdStaticMembersNotSupported = "PKADP016";
private const string DiagIdRefReturnNotSupported = "PKADP017";

private static readonly DiagnosticDescriptor HostNotStaticPartialDescriptor = new(
id: DiagIdHostNotStaticPartial,
Expand Down Expand Up @@ -140,6 +143,38 @@ public sealed class AdapterGenerator : IIncrementalGenerator
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

private static readonly DiagnosticDescriptor NestedOrGenericHostDescriptor = new(
id: DiagIdNestedOrGenericHost,
title: "Nested or generic host not supported",
messageFormat: "Adapter host '{0}' cannot be nested or generic",
category: "PatternKit.Generators.Adapter",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

private static readonly DiagnosticDescriptor MappingMethodNotAccessibleDescriptor = new(
id: DiagIdMappingMethodNotAccessible,
title: "Mapping method must be accessible",
messageFormat: "Mapping method '{0}' must be public or internal to be accessible from generated adapter",
category: "PatternKit.Generators.Adapter",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

private static readonly DiagnosticDescriptor StaticMembersNotSupportedDescriptor = new(
id: DiagIdStaticMembersNotSupported,
title: "Static members are not supported",
messageFormat: "Target type '{0}' contains static member '{1}' which is not supported by the adapter generator",
category: "PatternKit.Generators.Adapter",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

private static readonly DiagnosticDescriptor RefReturnNotSupportedDescriptor = new(
id: DiagIdRefReturnNotSupported,
title: "Ref-return members are not supported",
messageFormat: "Target type '{0}' contains ref-return member '{1}' which is not supported by the adapter generator",
category: "PatternKit.Generators.Adapter",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);

public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Find all class declarations with [GenerateAdapter] attribute
Expand Down Expand Up @@ -183,6 +218,16 @@ private void GenerateAdapterForAttribute(
return;
}

// Validate host is not nested or generic
if (hostSymbol.ContainingType is not null || hostSymbol.TypeParameters.Length > 0)
{
context.ReportDiagnostic(Diagnostic.Create(
NestedOrGenericHostDescriptor,
node.GetLocation(),
hostSymbol.Name));
return;
}

// Parse attribute arguments
var config = ParseAdapterConfig(attribute);
if (config.TargetType is null || config.AdapteeType is null)
Expand Down Expand Up @@ -240,7 +285,7 @@ private void GenerateAdapterForAttribute(
// Get all mapping methods from host
var mappingMethods = GetMappingMethods(hostSymbol, config.AdapteeType);

// Validate mapping methods are static
// Validate mapping methods are static and accessible
foreach (var (method, _) in mappingMethods)
{
if (!method.IsStatic)
Expand All @@ -251,6 +296,17 @@ private void GenerateAdapterForAttribute(
method.Name));
return;
}

// Validate method is accessible (public or internal)
if (method.DeclaredAccessibility != Accessibility.Public &&
method.DeclaredAccessibility != Accessibility.Internal)
{
context.ReportDiagnostic(Diagnostic.Create(
MappingMethodNotAccessibleDescriptor,
method.Locations.FirstOrDefault() ?? node.GetLocation(),
method.Name));
return;
}
}

// Get target members that need mapping
Expand Down Expand Up @@ -403,6 +459,16 @@ private List<Diagnostic> ValidateTargetMembers(INamedTypeSymbol targetType, Loca

foreach (var member in membersToCheck)
{
// Check for static members (not supported)
if (member.IsStatic)
{
diagnostics.Add(Diagnostic.Create(
StaticMembersNotSupportedDescriptor,
location,
targetType.Name,
member.Name));
}

// Check for events (not supported)
if (member is IEventSymbol evt)
{
Expand All @@ -423,6 +489,16 @@ private List<Diagnostic> ValidateTargetMembers(INamedTypeSymbol targetType, Loca
prop.Name));
}

// Check for ref-return properties (not supported)
if (member is IPropertySymbol refProp && refProp.ReturnsByRef)
{
diagnostics.Add(Diagnostic.Create(
RefReturnNotSupportedDescriptor,
location,
targetType.Name,
refProp.Name));
}

// Check for generic methods (not supported)
if (member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary)
{
Expand All @@ -435,6 +511,16 @@ private List<Diagnostic> ValidateTargetMembers(INamedTypeSymbol targetType, Loca
method.Name));
}

// Check for ref-return methods (not supported)
if (method.ReturnsByRef || method.ReturnsByRefReadonly)
{
diagnostics.Add(Diagnostic.Create(
RefReturnNotSupportedDescriptor,
location,
targetType.Name,
method.Name));
}

// Track full method signature for overload detection
var sig = GetMemberSignature(method);
if (!methodSignatures.TryGetValue(method.Name, out var sigs))
Expand Down Expand Up @@ -557,6 +643,7 @@ private static List<ISymbol> GetTargetMembers(INamedTypeSymbol targetType)
continue;

var membersToProcess = type.GetMembers()
.Where(m => !m.IsStatic) // Exclude static members
.Where(m => !isAbstractClass || m.IsAbstract);

foreach (var member in membersToProcess)
Expand Down Expand Up @@ -592,8 +679,12 @@ private static List<ISymbol> GetTargetMembers(INamedTypeSymbol targetType)
}
}

// Return in declaration order (members already added in traversal order)
return members;
// Ensure stable, deterministic ordering by kind+name+signature
// This provides a predictable output order even if member traversal is non-deterministic
return members.OrderBy(m => m.Kind)
.ThenBy(m => m.Name)
.ThenBy(m => m.ToDisplayString(FullyQualifiedFormat))
.ToList();
}

private static string GetMemberSignature(IMethodSymbol method)
Expand Down
4 changes: 4 additions & 0 deletions src/PatternKit.Generators/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,7 @@ PKADP010 | PatternKit.Generators.Adapter | Error | Generic methods are not suppo
PKADP011 | PatternKit.Generators.Adapter | Error | Overloaded methods are not supported
PKADP012 | PatternKit.Generators.Adapter | Error | Abstract class target requires accessible parameterless constructor
PKADP013 | PatternKit.Generators.Adapter | Error | Settable properties are not supported
PKADP014 | PatternKit.Generators.Adapter | Error | Nested or generic host not supported
PKADP015 | PatternKit.Generators.Adapter | Error | Mapping method must be accessible
PKADP016 | PatternKit.Generators.Adapter | Error | Static members are not supported
PKADP017 | PatternKit.Generators.Adapter | Error | Ref-return members are not supported
4 changes: 2 additions & 2 deletions test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ public class NotStaticPartial
}

[Fact]
public void ErrorWhenTargetNotInterface()
public void ErrorWhenTargetNotInterfaceOrAbstract()
{
const string source = """
using PatternKit.Generators.Adapter;
Expand All @@ -202,7 +202,7 @@ public class LegacyClock { }
public static partial class Adapters { }
""";

var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenTargetNotInterface));
var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenTargetNotInterfaceOrAbstract));
var gen = new AdapterGenerator();
_ = RoslynTestHelpers.Run(comp, gen, out var result, out _);

Expand Down