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
2 changes: 1 addition & 1 deletion docs/generators/adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ public static partial class Adapters

### 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.
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.

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

Expand Down
62 changes: 43 additions & 19 deletions src/PatternKit.Generators/Adapter/AdapterGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,22 +193,28 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
transform: static (ctx, _) => ctx
);

// Generate for each host
context.RegisterSourceOutput(adapterHosts, (spc, typeContext) =>
{
if (typeContext.TargetSymbol is not INamedTypeSymbol hostSymbol)
return;

var node = typeContext.TargetNode;
// Collect all hosts so we can detect conflicts across the entire compilation
var collectedAdapterHosts = adapterHosts.Collect();

// Generate for all hosts, tracking generated adapter type names globally
context.RegisterSourceOutput(collectedAdapterHosts, (spc, collectedTypeContexts) =>
{
// Track generated adapter type names to detect conflicts (namespace -> type name -> location)
var generatedAdapters = new Dictionary<string, Dictionary<string, Location>>();

// Process each [GenerateAdapter] attribute on the host
foreach (var attr in typeContext.Attributes.Where(a =>
a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Adapter.GenerateAdapterAttribute"))
foreach (var typeContext in collectedTypeContexts)
{
GenerateAdapterForAttribute(spc, hostSymbol, attr, node, typeContext.SemanticModel, generatedAdapters);
if (typeContext.TargetSymbol is not INamedTypeSymbol hostSymbol)
continue;

var node = typeContext.TargetNode;

// Process each [GenerateAdapter] attribute on the host
foreach (var attr in typeContext.Attributes.Where(a =>
a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Adapter.GenerateAdapterAttribute"))
{
GenerateAdapterForAttribute(spc, hostSymbol, attr, node, typeContext.SemanticModel, generatedAdapters);
}
}
});
}
Expand Down Expand Up @@ -497,7 +503,7 @@ private static bool HasTypeNameConflict(Compilation compilation, string ns, stri
return compilation.GetTypeByMetadataName(fullName) is not null;
}

private List<Diagnostic> ValidateTargetMembers(INamedTypeSymbol targetType, Location location)
private List<Diagnostic> ValidateTargetMembers(INamedTypeSymbol targetType, Location fallbackLocation)
{
var diagnostics = new List<Diagnostic>();
var isAbstractClass = targetType.TypeKind == TypeKind.Class && targetType.IsAbstract;
Expand All @@ -522,6 +528,9 @@ private List<Diagnostic> ValidateTargetMembers(INamedTypeSymbol targetType, Loca

foreach (var member in membersToCheck)
{
// Use member location if available, otherwise fall back to host location
var location = member.Locations.FirstOrDefault() ?? fallbackLocation;

// Check for static members (not supported)
if (member.IsStatic)
{
Expand Down Expand Up @@ -620,7 +629,7 @@ private List<Diagnostic> ValidateTargetMembers(INamedTypeSymbol targetType, Loca
{
diagnostics.Add(Diagnostic.Create(
OverloadedMethodsNotSupportedDescriptor,
location,
fallbackLocation,
targetType.Name,
kvp.Key));
}
Expand Down Expand Up @@ -755,12 +764,27 @@ private static List<ISymbol> GetTargetMembers(INamedTypeSymbol targetType)
}
}

// 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();
// Order by declaration order when available, falling back to stable sort for metadata-only symbols
// This provides both readable (contract-ordered) output and deterministic ordering
return members.OrderBy(m =>
{
// Try to get syntax declaration order (file path + line number)
var syntaxRef = m.DeclaringSyntaxReferences.FirstOrDefault();
if (syntaxRef != null)
{
var location = syntaxRef.GetSyntax().GetLocation();
var lineSpan = location.GetLineSpan();
// Return a tuple of (file path, line number) for natural ordering
return (lineSpan.Path, lineSpan.StartLinePosition.Line, 0);
}
// For metadata-only symbols without source, use a fallback ordering
// Use the symbol's metadata token which is stable across compilations
return (string.Empty, int.MaxValue, m.MetadataToken);
})
.ThenBy(m => m.Kind)
.ThenBy(m => m.Name)
.ThenBy(m => m.ToDisplayString(FullyQualifiedFormat))
.ToList();
}

private static string GetMemberSignature(IMethodSymbol method)
Expand Down
75 changes: 75 additions & 0 deletions test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1202,4 +1202,79 @@ public static partial class Adapters { }
var diags = result.Results.SelectMany(r => r.Diagnostics);
Assert.Contains(diags, d => d.Id == "PKADP017");
}

[Fact]
public void ErrorWhenTargetHasIndexer()
{
const string source = """
using PatternKit.Generators.Adapter;

namespace TestNamespace;

public interface IIndexable
{
string this[int index] { get; } // Indexer
}

public class LegacyService { }

[GenerateAdapter(Target = typeof(IIndexable), Adaptee = typeof(LegacyService))]
public static partial class Adapters { }
""";

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

// PKADP018 diagnostic is reported
var diags = result.Results.SelectMany(r => r.Diagnostics);
Assert.Contains(diags, d => d.Id == "PKADP018");
}

[Fact]
public void ErrorWhenTwoHostsGenerateSameAdapterTypeName()
{
const string source = """
using PatternKit.Generators.Adapter;

namespace TestNamespace;

public interface IClock
{
System.DateTimeOffset Now { get; }
}

public interface ITimer
{
long Ticks { get; }
}

public class LegacyClock { }
public class LegacyTimer { }

// First host generates LegacyClockAdapter
[GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock), AdapterTypeName = "SharedAdapter")]
public static partial class ClockAdapters
{
[AdapterMap(TargetMember = nameof(IClock.Now))]
public static System.DateTimeOffset MapNow(LegacyClock adaptee) => default;
}

// Second host attempts to generate the same adapter type name
[GenerateAdapter(Target = typeof(ITimer), Adaptee = typeof(LegacyTimer), AdapterTypeName = "SharedAdapter")]
public static partial class TimerAdapters
{
[AdapterMap(TargetMember = nameof(ITimer.Ticks))]
public static long MapTicks(LegacyTimer adaptee) => default;
}
""";

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

// PKADP006 diagnostic is reported (at least once, possibly twice)
var diags = result.Results.SelectMany(r => r.Diagnostics);
Assert.Contains(diags, d => d.Id == "PKADP006");
}
}