From 936e46822f83e675fd0c5aaee5129d2475eba841 Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Sat, 7 Feb 2026 00:09:44 -0600 Subject: [PATCH 01/12] 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 --- docs/generators/adapter.md | 344 +++++++++ .../AdapterGeneratorDemo/ClockAdapter.cs | 69 ++ .../AdapterGeneratorDemo/LoggerAdapter.cs | 92 +++ .../AdapterGeneratorDemo/PaymentAdapter.cs | 240 +++++++ .../Adapter/AdapterAttributes.cs | 126 ++++ .../Adapter/AdapterGenerator.cs | 659 ++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 8 + .../AdapterGeneratorDemoTests.cs | 305 ++++++++ .../AdapterGeneratorTests.cs | 574 +++++++++++++++ 9 files changed, 2417 insertions(+) create mode 100644 docs/generators/adapter.md create mode 100644 src/PatternKit.Examples/AdapterGeneratorDemo/ClockAdapter.cs create mode 100644 src/PatternKit.Examples/AdapterGeneratorDemo/LoggerAdapter.cs create mode 100644 src/PatternKit.Examples/AdapterGeneratorDemo/PaymentAdapter.cs create mode 100644 src/PatternKit.Generators.Abstractions/Adapter/AdapterAttributes.cs create mode 100644 src/PatternKit.Generators/Adapter/AdapterGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/AdapterGeneratorDemo/AdapterGeneratorDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs diff --git a/docs/generators/adapter.md b/docs/generators/adapter.md new file mode 100644 index 0000000..d4432d7 --- /dev/null +++ b/docs/generators/adapter.md @@ -0,0 +1,344 @@ +# Adapter Generator + +## Overview + +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. + +## When to Use + +Use the Adapter generator when you need to: + +- **Integrate legacy code**: Wrap older implementations to work with modern interfaces +- **Abstract third-party libraries**: Create a clean boundary around external dependencies +- **Support multiple implementations**: Adapt different backends (payment gateways, loggers, etc.) to a unified interface +- **Compile-time safety**: Ensure all contract members are properly mapped + +## Installation + +The generator is included in the `PatternKit.Generators` package: + +```bash +dotnet add package PatternKit.Generators +``` + +## Quick Start + +```csharp +using PatternKit.Generators.Adapter; + +// Target interface your app uses +public interface IClock +{ + DateTimeOffset UtcNow { get; } +} + +// Legacy class with different API +public class LegacyClock +{ + public DateTime GetCurrentTimeUtc() => DateTime.UtcNow; +} + +// Define mappings in a static partial class +[GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] +public static partial class ClockAdapters +{ + [AdapterMap(TargetMember = nameof(IClock.UtcNow))] + public static DateTimeOffset MapUtcNow(LegacyClock adaptee) + => new(adaptee.GetCurrentTimeUtc(), TimeSpan.Zero); +} +``` + +Generated: +```csharp +public sealed partial class LegacyClockToIClockAdapter : IClock +{ + private readonly LegacyClock _adaptee; + + public LegacyClockToIClockAdapter(LegacyClock adaptee) + { + _adaptee = adaptee ?? throw new ArgumentNullException(nameof(adaptee)); + } + + public DateTimeOffset UtcNow + { + get => ClockAdapters.MapUtcNow(_adaptee); + } +} +``` + +Usage: +```csharp +// Create the adapter +IClock clock = new LegacyClockToIClockAdapter(new LegacyClock()); + +// Use through the clean interface +var now = clock.UtcNow; +``` + +## Mapping Methods + +Each target contract member needs a mapping method marked with `[AdapterMap]`. + +### Property Mappings + +For properties, the mapping method takes only the adaptee and returns the property type: + +```csharp +public interface IService +{ + string Name { get; } +} + +[AdapterMap(TargetMember = nameof(IService.Name))] +public static string MapName(LegacyService adaptee) => adaptee.ServiceName; +``` + +### Method Mappings + +For methods, the mapping method takes the adaptee as the first parameter, followed by all method parameters: + +```csharp +public interface ICalculator +{ + int Add(int a, int b); +} + +[AdapterMap(TargetMember = nameof(ICalculator.Add))] +public static int MapAdd(OldCalculator adaptee, int a, int b) + => adaptee.Sum(a, b); +``` + +### Async Method Mappings + +Async methods work the same way - just match the return type: + +```csharp +public interface IPaymentGateway +{ + Task ChargeAsync(string token, decimal amount, CancellationToken ct); +} + +[AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))] +public static async Task MapChargeAsync( + LegacyPaymentClient adaptee, + string token, + decimal amount, + CancellationToken ct) +{ + var response = await adaptee.ProcessPaymentAsync(token, (int)(amount * 100), ct); + return new PaymentResult(response.Success, response.Id); +} +``` + +## Attributes + +### `[GenerateAdapter]` + +Marks a static partial class as an adapter mapping host. + +| Property | Type | Default | Description | +|---|---|---|---| +| `Target` | `Type` | Required | The interface or abstract class to implement | +| `Adaptee` | `Type` | Required | The class to adapt | +| `AdapterTypeName` | `string` | `{Adaptee}To{Target}Adapter` | Custom name for the generated adapter class | +| `MissingMap` | `AdapterMissingMapPolicy` | `Error` | How to handle unmapped members | +| `Sealed` | `bool` | `true` | Whether the adapter class is sealed | +| `Namespace` | `string` | Host namespace | Custom namespace for the adapter | + +### `[AdapterMap]` + +Marks a method as a mapping for a target member. + +| Property | Type | Default | Description | +|---|---|---|---| +| `TargetMember` | `string` | Required | Name of the target member (use `nameof()`) | + +## Missing Map Policies + +Control what happens when a target member has no `[AdapterMap]`: + +### Error (Default) + +Emits a compiler error. Recommended for production code: + +```csharp +[GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] +// MissingMap = AdapterMissingMapPolicy.Error is the default +``` + +### ThrowingStub + +Generates a stub that throws `NotImplementedException`. Useful during incremental development: + +```csharp +[GenerateAdapter( + Target = typeof(IClock), + Adaptee = typeof(LegacyClock), + MissingMap = AdapterMissingMapPolicy.ThrowingStub)] +``` + +### Ignore + +Silently ignores unmapped members. May cause compilation errors if the target is an interface (missing implementations): + +```csharp +[GenerateAdapter( + Target = typeof(IPartialService), + Adaptee = typeof(Legacy), + MissingMap = AdapterMissingMapPolicy.Ignore)] +``` + +## Multiple Adapters + +You can define multiple adapters in the same host class: + +```csharp +[GenerateAdapter(Target = typeof(IPaymentGateway), Adaptee = typeof(StripeClient))] +[GenerateAdapter(Target = typeof(IPaymentGateway), Adaptee = typeof(PayPalClient))] +public static partial class PaymentAdapters +{ + [AdapterMap(TargetMember = nameof(IPaymentGateway.Charge))] + public static PaymentResult MapStripeCharge(StripeClient adaptee, ...) { ... } + + [AdapterMap(TargetMember = nameof(IPaymentGateway.Charge))] + public static PaymentResult MapPayPalCharge(PayPalClient adaptee, ...) { ... } +} +``` + +The generator matches mapping methods to adapters by the first parameter type (adaptee). + +## Abstract Class Targets + +The generator supports abstract classes as targets: + +```csharp +public abstract class ClockBase +{ + public abstract DateTimeOffset Now { get; } + public virtual string TimeZone => "UTC"; // Inherited, not in contract +} + +[GenerateAdapter(Target = typeof(ClockBase), Adaptee = typeof(LegacyClock))] +public static partial class Adapters +{ + [AdapterMap(TargetMember = nameof(ClockBase.Now))] + public static DateTimeOffset MapNow(LegacyClock adaptee) => ...; + // Only abstract members need mapping +} +``` + +## Diagnostics + +| ID | Severity | Description | +|---|---|---| +| **PKADP001** | Error | Adapter host must be `static partial` | +| **PKADP002** | Error | Target must be interface or abstract class | +| **PKADP003** | Error | Missing `[AdapterMap]` for target member | +| **PKADP004** | Error | Multiple `[AdapterMap]` methods for same target member | +| **PKADP005** | Error | Mapping method signature doesn't match target member | +| **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 | + +## Best Practices + +### 1. Use `nameof()` for Type Safety + +```csharp +// ✅ Good: Compile-time checked +[AdapterMap(TargetMember = nameof(IClock.Now))] + +// ❌ Bad: String literals can drift +[AdapterMap(TargetMember = "Now")] +``` + +### 2. Keep Mapping Methods Simple + +Mapping methods should be thin wrappers, not business logic: + +```csharp +// ✅ Good: Simple delegation with conversion +[AdapterMap(TargetMember = nameof(IService.DoWork))] +public static void MapDoWork(Legacy adaptee, string input) + => adaptee.PerformTask(input); + +// ❌ Bad: Business logic in mapping +[AdapterMap(TargetMember = nameof(IService.DoWork))] +public static void MapDoWork(Legacy adaptee, string input) +{ + if (string.IsNullOrEmpty(input)) throw new ArgumentException(); + var processed = input.ToUpper().Trim(); + adaptee.PerformTask(processed); + // This logic should be elsewhere +} +``` + +### 3. Separate Mapping Hosts by Domain + +```csharp +// ✅ Good: Organized by domain +public static partial class PaymentAdapters { ... } +public static partial class LoggingAdapters { ... } + +// ❌ Bad: Everything in one place +public static partial class AllAdapters { ... } +``` + +### 4. Document Complex Mappings + +```csharp +/// +/// Maps the legacy millisecond-based delay to TimeSpan. +/// Note: Precision is limited to milliseconds. +/// +[AdapterMap(TargetMember = nameof(IClock.DelayAsync))] +public static ValueTask MapDelayAsync(LegacyClock adaptee, TimeSpan duration, CancellationToken ct) + => new(adaptee.Sleep((int)duration.TotalMilliseconds, ct)); +``` + +## Real-World Example: Payment Gateway Abstraction + +```csharp +// Unified interface for your application +public interface IPaymentGateway +{ + Task ChargeAsync(string token, decimal amount, string currency, CancellationToken ct); + Task RefundAsync(string transactionId, decimal amount, CancellationToken ct); + string GatewayName { get; } +} + +// Stripe adapter +[GenerateAdapter(Target = typeof(IPaymentGateway), Adaptee = typeof(StripeClient), AdapterTypeName = "StripePaymentAdapter")] +public static partial class StripeAdapters +{ + [AdapterMap(TargetMember = nameof(IPaymentGateway.GatewayName))] + public static string MapGatewayName(StripeClient adaptee) => "Stripe"; + + [AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))] + public static async Task MapChargeAsync( + StripeClient adaptee, string token, decimal amount, string currency, CancellationToken ct) + { + var request = new StripeChargeRequest { Source = token, Amount = (long)(amount * 100), Currency = currency }; + var response = await adaptee.CreateChargeAsync(request, ct); + return new PaymentResult(response.Succeeded, response.ChargeId, response.Error); + } + + [AdapterMap(TargetMember = nameof(IPaymentGateway.RefundAsync))] + public static async Task MapRefundAsync( + StripeClient adaptee, string transactionId, decimal amount, CancellationToken ct) + { + var response = await adaptee.CreateRefundAsync(transactionId, (long)(amount * 100), ct); + return new RefundResult(response.Succeeded, response.RefundId, response.Error); + } +} + +// Usage with DI +services.AddSingleton(); +services.AddSingleton(sp => new StripePaymentAdapter(sp.GetRequiredService())); +``` + +## See Also + +- [Facade Generator](facade.md) - For simplifying complex subsystems +- [Decorator Generator](decorator.md) - For adding behavior to objects +- [Proxy Generator](proxy.md) - For controlling access to objects diff --git a/src/PatternKit.Examples/AdapterGeneratorDemo/ClockAdapter.cs b/src/PatternKit.Examples/AdapterGeneratorDemo/ClockAdapter.cs new file mode 100644 index 0000000..1001fc4 --- /dev/null +++ b/src/PatternKit.Examples/AdapterGeneratorDemo/ClockAdapter.cs @@ -0,0 +1,69 @@ +using PatternKit.Generators.Adapter; + +namespace PatternKit.Examples.AdapterGeneratorDemo; + +// ============================================================================= +// Scenario: Adapting a legacy time service to a modern interface +// ============================================================================= + +/// +/// Modern clock interface used throughout the application. +/// Provides a clean, testable abstraction for time operations. +/// +public interface IClock +{ + /// Gets the current UTC time. + DateTimeOffset UtcNow { get; } + + /// Gets the current local time. + DateTimeOffset LocalNow { get; } + + /// Gets the Unix timestamp in seconds. + long UnixTimestamp { get; } + + /// Delays for the specified duration. + ValueTask DelayAsync(TimeSpan duration, CancellationToken ct = default); +} + +/// +/// A legacy clock implementation from an older library. +/// Has different method names and signatures that don't match IClock. +/// +public sealed class LegacySystemClock +{ + public DateTime GetCurrentTimeUtc() => DateTime.UtcNow; + + public DateTime GetCurrentTimeLocal() => DateTime.Now; + + public int GetUnixTime() => (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + public Task Sleep(int milliseconds, CancellationToken cancellation) + => Task.Delay(milliseconds, cancellation); +} + +/// +/// Adapter mappings that bridge LegacySystemClock to IClock. +/// The generator creates a LegacySystemClockToIClockAdapter class. +/// +[GenerateAdapter( + Target = typeof(IClock), + Adaptee = typeof(LegacySystemClock), + AdapterTypeName = "SystemClockAdapter")] +public static partial class ClockAdapterMappings +{ + [AdapterMap(TargetMember = nameof(IClock.UtcNow))] + public static DateTimeOffset MapUtcNow(LegacySystemClock adaptee) + => new(adaptee.GetCurrentTimeUtc(), TimeSpan.Zero); + + [AdapterMap(TargetMember = nameof(IClock.LocalNow))] + public static DateTimeOffset MapLocalNow(LegacySystemClock adaptee) + => new(adaptee.GetCurrentTimeLocal(), DateTimeOffset.Now.Offset); + + [AdapterMap(TargetMember = nameof(IClock.UnixTimestamp))] + public static long MapUnixTimestamp(LegacySystemClock adaptee) + => adaptee.GetUnixTime(); + + [AdapterMap(TargetMember = nameof(IClock.DelayAsync))] + public static ValueTask MapDelayAsync(LegacySystemClock adaptee, TimeSpan duration, CancellationToken ct) + => new(adaptee.Sleep((int)duration.TotalMilliseconds, ct)); +} diff --git a/src/PatternKit.Examples/AdapterGeneratorDemo/LoggerAdapter.cs b/src/PatternKit.Examples/AdapterGeneratorDemo/LoggerAdapter.cs new file mode 100644 index 0000000..126c4cc --- /dev/null +++ b/src/PatternKit.Examples/AdapterGeneratorDemo/LoggerAdapter.cs @@ -0,0 +1,92 @@ +using PatternKit.Generators.Adapter; + +namespace PatternKit.Examples.AdapterGeneratorDemo; + +// ============================================================================= +// Scenario: Adapting a legacy logging library to a modern interface +// ============================================================================= + +/// +/// Modern structured logging interface. +/// +public interface IStructuredLogger +{ + void LogDebug(string message); + void LogInfo(string message); + void LogWarning(string message); + void LogError(string message, Exception? exception = null); + + bool IsEnabled(LogLevel level); +} + +/// Log severity levels. +public enum LogLevel +{ + Debug = 0, + Info = 1, + Warning = 2, + Error = 3 +} + +/// +/// A legacy console logger with a different API. +/// +public sealed class LegacyConsoleLogger +{ + private readonly string _prefix; + private readonly int _minimumLevel; + + public LegacyConsoleLogger(string prefix = "LOG", int minimumLevel = 0) + { + _prefix = prefix; + _minimumLevel = minimumLevel; + } + + public int MinimumLevel => _minimumLevel; + + public void WriteDebug(string msg) => Write(0, "DBG", msg); + public void WriteInfo(string msg) => Write(1, "INF", msg); + public void WriteWarning(string msg) => Write(2, "WRN", msg); + public void WriteError(string msg, Exception? ex = null) + { + Write(3, "ERR", msg); + if (ex != null) + Console.Error.WriteLine($"[{_prefix}] Exception: {ex.Message}"); + } + + private void Write(int level, string tag, string msg) + { + if (level >= _minimumLevel) + Console.WriteLine($"[{_prefix}:{tag}] {DateTime.UtcNow:HH:mm:ss.fff} {msg}"); + } +} + +/// +/// Adapter mappings that bridge LegacyConsoleLogger to IStructuredLogger. +/// +[GenerateAdapter( + Target = typeof(IStructuredLogger), + Adaptee = typeof(LegacyConsoleLogger), + AdapterTypeName = "ConsoleLoggerAdapter")] +public static partial class LoggerAdapterMappings +{ + [AdapterMap(TargetMember = nameof(IStructuredLogger.LogDebug))] + public static void MapLogDebug(LegacyConsoleLogger adaptee, string message) + => adaptee.WriteDebug(message); + + [AdapterMap(TargetMember = nameof(IStructuredLogger.LogInfo))] + public static void MapLogInfo(LegacyConsoleLogger adaptee, string message) + => adaptee.WriteInfo(message); + + [AdapterMap(TargetMember = nameof(IStructuredLogger.LogWarning))] + public static void MapLogWarning(LegacyConsoleLogger adaptee, string message) + => adaptee.WriteWarning(message); + + [AdapterMap(TargetMember = nameof(IStructuredLogger.LogError))] + public static void MapLogError(LegacyConsoleLogger adaptee, string message, Exception? exception) + => adaptee.WriteError(message, exception); + + [AdapterMap(TargetMember = nameof(IStructuredLogger.IsEnabled))] + public static bool MapIsEnabled(LegacyConsoleLogger adaptee, LogLevel level) + => (int)level >= adaptee.MinimumLevel; +} diff --git a/src/PatternKit.Examples/AdapterGeneratorDemo/PaymentAdapter.cs b/src/PatternKit.Examples/AdapterGeneratorDemo/PaymentAdapter.cs new file mode 100644 index 0000000..3023797 --- /dev/null +++ b/src/PatternKit.Examples/AdapterGeneratorDemo/PaymentAdapter.cs @@ -0,0 +1,240 @@ +using PatternKit.Generators.Adapter; + +namespace PatternKit.Examples.AdapterGeneratorDemo; + +// ============================================================================= +// Scenario: Adapting multiple payment gateways to a unified interface +// ============================================================================= + +/// +/// Unified payment gateway interface for the application. +/// Allows easy swapping of payment providers. +/// +public interface IPaymentGateway +{ + /// Charges a credit card. + Task ChargeAsync(string cardToken, decimal amount, string currency, CancellationToken ct = default); + + /// Refunds a previous charge. + Task RefundAsync(string transactionId, decimal amount, CancellationToken ct = default); + + /// Gets the gateway name for logging. + string GatewayName { get; } +} + +/// Result of a payment charge operation. +public record PaymentResult(bool Success, string TransactionId, string? ErrorMessage = null); + +/// Result of a refund operation. +public record RefundResult(bool Success, string RefundId, string? ErrorMessage = null); + +// ----------------------------------------------------------------------------- +// Legacy Stripe-like SDK with different API shape +// ----------------------------------------------------------------------------- + +/// +/// Simulates a third-party Stripe-like payment SDK. +/// +public sealed class StripePaymentClient +{ + public string ProviderName => "Stripe"; + + public async Task CreateChargeAsync( + StripeChargeRequest request, + CancellationToken cancellation = default) + { + await Task.Delay(10, cancellation); // Simulate network call + return new StripeChargeResponse + { + Succeeded = true, + ChargeId = $"ch_{Guid.NewGuid():N}", + Error = null + }; + } + + public async Task CreateRefundAsync( + string chargeId, + long amountInCents, + CancellationToken cancellation = default) + { + await Task.Delay(10, cancellation); + return new StripeRefundResponse + { + Succeeded = true, + RefundId = $"re_{Guid.NewGuid():N}", + Error = null + }; + } +} + +public class StripeChargeRequest +{ + public string Source { get; set; } = ""; + public long AmountInCents { get; set; } + public string Currency { get; set; } = "usd"; +} + +public class StripeChargeResponse +{ + public bool Succeeded { get; set; } + public string ChargeId { get; set; } = ""; + public string? Error { get; set; } +} + +public class StripeRefundResponse +{ + public bool Succeeded { get; set; } + public string RefundId { get; set; } = ""; + public string? Error { get; set; } +} + +/// +/// Adapter mappings that bridge StripePaymentClient to IPaymentGateway. +/// +[GenerateAdapter( + Target = typeof(IPaymentGateway), + Adaptee = typeof(StripePaymentClient), + AdapterTypeName = "StripePaymentAdapter")] +public static partial class StripeAdapterMappings +{ + [AdapterMap(TargetMember = nameof(IPaymentGateway.GatewayName))] + public static string MapGatewayName(StripePaymentClient adaptee) + => adaptee.ProviderName; + + [AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))] + public static async Task MapChargeAsync( + StripePaymentClient adaptee, + string cardToken, + decimal amount, + string currency, + CancellationToken ct) + { + var request = new StripeChargeRequest + { + Source = cardToken, + AmountInCents = (long)(amount * 100), + Currency = currency.ToLowerInvariant() + }; + + var response = await adaptee.CreateChargeAsync(request, ct); + return new PaymentResult(response.Succeeded, response.ChargeId, response.Error); + } + + [AdapterMap(TargetMember = nameof(IPaymentGateway.RefundAsync))] + public static async Task MapRefundAsync( + StripePaymentClient adaptee, + string transactionId, + decimal amount, + CancellationToken ct) + { + var response = await adaptee.CreateRefundAsync(transactionId, (long)(amount * 100), ct); + return new RefundResult(response.Succeeded, response.RefundId, response.Error); + } +} + +// ----------------------------------------------------------------------------- +// Legacy PayPal-like SDK with yet another API shape +// ----------------------------------------------------------------------------- + +/// +/// Simulates a third-party PayPal-like payment SDK. +/// +public sealed class PayPalPaymentService +{ + public string ServiceIdentifier => "PayPal"; + + public async Task ExecutePaymentAsync( + string tokenId, + PayPalAmount paymentAmount, + CancellationToken token = default) + { + await Task.Delay(15, token); + return new PayPalTransaction + { + State = "approved", + Id = $"PAY-{Guid.NewGuid():N}" + }; + } + + public async Task ProcessRefundAsync( + string paymentId, + PayPalAmount refundAmount, + CancellationToken token = default) + { + await Task.Delay(15, token); + return new PayPalRefund + { + State = "completed", + Id = $"REF-{Guid.NewGuid():N}" + }; + } +} + +public class PayPalAmount +{ + public string Total { get; set; } = "0.00"; + public string Currency { get; set; } = "USD"; +} + +public class PayPalTransaction +{ + public string State { get; set; } = ""; + public string Id { get; set; } = ""; +} + +public class PayPalRefund +{ + public string State { get; set; } = ""; + public string Id { get; set; } = ""; +} + +/// +/// Adapter mappings that bridge PayPalPaymentService to IPaymentGateway. +/// +[GenerateAdapter( + Target = typeof(IPaymentGateway), + Adaptee = typeof(PayPalPaymentService), + AdapterTypeName = "PayPalPaymentAdapter")] +public static partial class PayPalAdapterMappings +{ + [AdapterMap(TargetMember = nameof(IPaymentGateway.GatewayName))] + public static string MapGatewayName(PayPalPaymentService adaptee) + => adaptee.ServiceIdentifier; + + [AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))] + public static async Task MapChargeAsync( + PayPalPaymentService adaptee, + string cardToken, + decimal amount, + string currency, + CancellationToken ct) + { + var paypalAmount = new PayPalAmount + { + Total = amount.ToString("F2"), + Currency = currency.ToUpperInvariant() + }; + + var transaction = await adaptee.ExecutePaymentAsync(cardToken, paypalAmount, ct); + var success = transaction.State == "approved"; + return new PaymentResult(success, transaction.Id, success ? null : transaction.State); + } + + [AdapterMap(TargetMember = nameof(IPaymentGateway.RefundAsync))] + public static async Task MapRefundAsync( + PayPalPaymentService adaptee, + string transactionId, + decimal amount, + CancellationToken ct) + { + var refundAmount = new PayPalAmount + { + Total = amount.ToString("F2"), + Currency = "USD" + }; + + var refund = await adaptee.ProcessRefundAsync(transactionId, refundAmount, ct); + var success = refund.State == "completed"; + return new RefundResult(success, refund.Id, success ? null : refund.State); + } +} diff --git a/src/PatternKit.Generators.Abstractions/Adapter/AdapterAttributes.cs b/src/PatternKit.Generators.Abstractions/Adapter/AdapterAttributes.cs new file mode 100644 index 0000000..f75afb7 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Adapter/AdapterAttributes.cs @@ -0,0 +1,126 @@ +namespace PatternKit.Generators.Adapter; + +/// +/// Marks a static partial class as an adapter mapping host for generating an object adapter. +/// The host class contains mapping methods that define how target contract members +/// delegate to the adaptee type. +/// +/// +/// +/// The Adapter pattern allows incompatible interfaces to work together by wrapping +/// an object (the adaptee) in an adapter that implements the target interface. +/// +/// +/// This generator creates Object Adapters (composition-based), where the adapter +/// holds a reference to the adaptee and delegates calls through mapping methods. +/// +/// +/// +/// +/// [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] +/// public static partial class ClockAdapters +/// { +/// [AdapterMap(TargetMember = nameof(IClock.Now))] +/// public static DateTimeOffset MapNow(LegacyClock adaptee) => adaptee.GetNow(); +/// } +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +public sealed class GenerateAdapterAttribute : Attribute +{ + /// + /// Gets or sets the target contract type (interface or abstract class) that the adapter will implement. + /// Required. + /// + public Type Target { get; set; } = null!; + + /// + /// Gets or sets the adaptee type that provides the actual implementation. + /// Required. + /// + public Type Adaptee { get; set; } = null!; + + /// + /// Gets or sets the name of the generated adapter type. + /// Default: "{AdapteeName}To{TargetName}Adapter" (e.g., "LegacyClockToIClockAdapter"). + /// + public string? AdapterTypeName { get; set; } + + /// + /// Gets or sets how to handle target members without explicit mappings. + /// Default: (emit diagnostic). + /// + public AdapterMissingMapPolicy MissingMap { get; set; } = AdapterMissingMapPolicy.Error; + + /// + /// Gets or sets whether the generated adapter class should be sealed. + /// Default: true. + /// + public bool Sealed { get; set; } = true; + + /// + /// Gets or sets the namespace for the generated adapter. + /// Default: same namespace as the mapping host class. + /// + public string? Namespace { get; set; } +} + +/// +/// Marks a method as a mapping for a specific target contract member. +/// The mapping method defines how the target member delegates to the adaptee. +/// +/// +/// +/// The mapping method signature must match the target member: +/// +/// +/// First parameter must be the adaptee type +/// Remaining parameters must match the target member's parameters +/// Return type must be compatible with the target member's return type +/// +/// +/// +/// +/// // Target: DateTimeOffset IClock.Now { get; } +/// [AdapterMap(TargetMember = nameof(IClock.Now))] +/// public static DateTimeOffset MapNow(LegacyClock adaptee) => adaptee.GetNow(); +/// +/// // Target: ValueTask IClock.Delay(TimeSpan duration, CancellationToken ct) +/// [AdapterMap(TargetMember = nameof(IClock.Delay))] +/// public static ValueTask MapDelay(LegacyClock adaptee, TimeSpan duration, CancellationToken ct) +/// => new(adaptee.SleepAsync((int)duration.TotalMilliseconds, ct)); +/// +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class AdapterMapAttribute : Attribute +{ + /// + /// Gets or sets the name of the target contract member this method maps to. + /// Required. Use nameof(ITarget.Member) for compile-time safety. + /// + public string TargetMember { get; set; } = null!; +} + +/// +/// Specifies how to handle target contract members without explicit [AdapterMap] mappings. +/// +public enum AdapterMissingMapPolicy +{ + /// + /// Emit a compiler error diagnostic for unmapped members. + /// This is the recommended default to ensure all contract members are explicitly handled. + /// + Error = 0, + + /// + /// Generate a throwing stub that throws . + /// Useful during incremental development. + /// + ThrowingStub = 1, + + /// + /// Silently ignore unmapped members (compilation will fail if target is interface/abstract). + /// Discouraged: may lead to incomplete implementations. + /// + Ignore = 2 +} diff --git a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs new file mode 100644 index 0000000..2a9dcbf --- /dev/null +++ b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs @@ -0,0 +1,659 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.Text; + +namespace PatternKit.Generators.Adapter; + +/// +/// Source generator for the Adapter pattern. +/// Generates object adapters that implement a target contract by delegating to an adaptee through mapping methods. +/// +[Generator] +public sealed class AdapterGenerator : IIncrementalGenerator +{ + // Diagnostic IDs + private const string DiagIdHostNotStaticPartial = "PKADP001"; + private const string DiagIdTargetNotInterfaceOrAbstract = "PKADP002"; + private const string DiagIdMissingMapping = "PKADP003"; + private const string DiagIdDuplicateMapping = "PKADP004"; + private const string DiagIdSignatureMismatch = "PKADP005"; + private const string DiagIdTypeNameConflict = "PKADP006"; + private const string DiagIdInvalidAdapteType = "PKADP007"; + private const string DiagIdMapMethodNotStatic = "PKADP008"; + + private static readonly DiagnosticDescriptor HostNotStaticPartialDescriptor = new( + id: DiagIdHostNotStaticPartial, + title: "Adapter host must be static partial", + messageFormat: "Type '{0}' is marked with [GenerateAdapter] but is not declared as 'static partial'", + category: "PatternKit.Generators.Adapter", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor TargetNotInterfaceOrAbstractDescriptor = new( + id: DiagIdTargetNotInterfaceOrAbstract, + title: "Target must be interface or abstract class", + messageFormat: "Target type '{0}' must be an interface or abstract class", + category: "PatternKit.Generators.Adapter", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor MissingMappingDescriptor = new( + id: DiagIdMissingMapping, + title: "Missing mapping for target member", + messageFormat: "No [AdapterMap] method found for target member '{0}.{1}'", + category: "PatternKit.Generators.Adapter", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateMappingDescriptor = new( + id: DiagIdDuplicateMapping, + title: "Duplicate mapping for target member", + messageFormat: "Multiple [AdapterMap] methods found for target member '{0}'", + category: "PatternKit.Generators.Adapter", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor SignatureMismatchDescriptor = new( + id: DiagIdSignatureMismatch, + title: "Mapping method signature mismatch", + messageFormat: "Mapping method '{0}' signature does not match target member '{1}': {2}", + category: "PatternKit.Generators.Adapter", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor TypeNameConflictDescriptor = new( + id: DiagIdTypeNameConflict, + title: "Adapter type name conflicts with existing type", + messageFormat: "Adapter type name '{0}' conflicts with an existing type in namespace '{1}'", + category: "PatternKit.Generators.Adapter", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor InvalidAdapteeTypeDescriptor = new( + id: DiagIdInvalidAdapteType, + title: "Invalid adaptee type", + messageFormat: "Adaptee type '{0}' must be a concrete class or struct", + category: "PatternKit.Generators.Adapter", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor MapMethodNotStaticDescriptor = new( + id: DiagIdMapMethodNotStatic, + title: "Mapping method must be static", + messageFormat: "Mapping method '{0}' must be declared as static", + category: "PatternKit.Generators.Adapter", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Find all class declarations with [GenerateAdapter] attribute + var adapterHosts = context.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "PatternKit.Generators.Adapter.GenerateAdapterAttribute", + predicate: static (node, _) => node is ClassDeclarationSyntax, + 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; + + // 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); + } + }); + } + + private void GenerateAdapterForAttribute( + SourceProductionContext context, + INamedTypeSymbol hostSymbol, + AttributeData attribute, + SyntaxNode node, + SemanticModel semanticModel) + { + // Validate host is static partial + if (!IsStaticPartial(node)) + { + context.ReportDiagnostic(Diagnostic.Create( + HostNotStaticPartialDescriptor, + node.GetLocation(), + hostSymbol.Name)); + return; + } + + // Parse attribute arguments + var config = ParseAdapterConfig(attribute); + if (config.TargetType is null || config.AdapteeType is null) + return; // Attribute error, let compiler handle + + // Validate target is interface or abstract class + if (!IsValidTargetType(config.TargetType)) + { + context.ReportDiagnostic(Diagnostic.Create( + TargetNotInterfaceOrAbstractDescriptor, + node.GetLocation(), + config.TargetType.ToDisplayString())); + return; + } + + // Validate adaptee is concrete type + if (!IsValidAdapteeType(config.AdapteeType)) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidAdapteeTypeDescriptor, + node.GetLocation(), + config.AdapteeType.ToDisplayString())); + return; + } + + // Get all mapping methods from host + var mappingMethods = GetMappingMethods(hostSymbol, config.AdapteeType); + + // Validate mapping methods are static + foreach (var (method, _) in mappingMethods) + { + if (!method.IsStatic) + { + context.ReportDiagnostic(Diagnostic.Create( + MapMethodNotStaticDescriptor, + method.Locations.FirstOrDefault() ?? node.GetLocation(), + method.Name)); + return; + } + } + + // Get target members that need mapping + var targetMembers = GetTargetMembers(config.TargetType); + + // Build mapping dictionary and validate + var memberMappings = new Dictionary(SymbolEqualityComparer.Default); + var hasErrors = false; + + foreach (var targetMember in targetMembers) + { + var memberName = targetMember.Name; + var matchingMaps = mappingMethods.Where(m => m.TargetMember == memberName).ToList(); + + if (matchingMaps.Count == 0) + { + if (config.MissingMapPolicy == AdapterMissingMapPolicyValue.Error) + { + context.ReportDiagnostic(Diagnostic.Create( + MissingMappingDescriptor, + node.GetLocation(), + config.TargetType.Name, + memberName)); + hasErrors = true; + } + // For ThrowingStub, we'll generate the stub later + } + else if (matchingMaps.Count > 1) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateMappingDescriptor, + matchingMaps[1].Method.Locations.FirstOrDefault() ?? node.GetLocation(), + memberName)); + hasErrors = true; + } + else + { + var mapping = matchingMaps[0]; + var signatureError = ValidateSignature(targetMember, mapping.Method, config.AdapteeType); + if (signatureError is not null) + { + context.ReportDiagnostic(Diagnostic.Create( + SignatureMismatchDescriptor, + mapping.Method.Locations.FirstOrDefault() ?? node.GetLocation(), + mapping.Method.Name, + memberName, + signatureError)); + hasErrors = true; + } + else + { + memberMappings[targetMember] = mapping.Method; + } + } + } + + if (hasErrors) + return; + + // Determine adapter type name + var adapterTypeName = config.AdapterTypeName + ?? $"{config.AdapteeType.Name}To{config.TargetType.Name}Adapter"; + + // Determine namespace + var ns = config.Namespace + ?? (hostSymbol.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : hostSymbol.ContainingNamespace.ToDisplayString()); + + // Generate adapter + var source = GenerateAdapterCode( + adapterTypeName, + ns, + config.TargetType, + config.AdapteeType, + hostSymbol, + targetMembers, + memberMappings, + config.MissingMapPolicy, + config.Sealed); + + var hintName = string.IsNullOrEmpty(ns) + ? $"{adapterTypeName}.Adapter.g.cs" + : $"{ns}.{adapterTypeName}.Adapter.g.cs"; + + context.AddSource(hintName, source); + } + + private static bool IsStaticPartial(SyntaxNode node) + { + if (node is not ClassDeclarationSyntax classDecl) + return false; + + var hasStatic = classDecl.Modifiers.Any(SyntaxKind.StaticKeyword); + var hasPartial = classDecl.Modifiers.Any(SyntaxKind.PartialKeyword); + return hasStatic && hasPartial; + } + + private static bool IsValidTargetType(INamedTypeSymbol type) + { + return type.TypeKind == TypeKind.Interface || + (type.TypeKind == TypeKind.Class && type.IsAbstract); + } + + private static bool IsValidAdapteeType(INamedTypeSymbol type) + { + return (type.TypeKind == TypeKind.Class || type.TypeKind == TypeKind.Struct) && + !type.IsAbstract; + } + + private static AdapterConfig ParseAdapterConfig(AttributeData attribute) + { + var config = new AdapterConfig(); + + foreach (var named in attribute.NamedArguments) + { + switch (named.Key) + { + case "Target": + config.TargetType = named.Value.Value as INamedTypeSymbol; + break; + case "Adaptee": + config.AdapteeType = named.Value.Value as INamedTypeSymbol; + break; + case "AdapterTypeName": + config.AdapterTypeName = named.Value.Value as string; + break; + case "MissingMap": + if (named.Value.Value is int missingMapValue) + config.MissingMapPolicy = (AdapterMissingMapPolicyValue)missingMapValue; + break; + case "Sealed": + if (named.Value.Value is bool sealedValue) + config.Sealed = sealedValue; + break; + case "Namespace": + config.Namespace = named.Value.Value as string; + break; + } + } + + return config; + } + + private static List<(IMethodSymbol Method, string TargetMember)> GetMappingMethods( + INamedTypeSymbol hostSymbol, + INamedTypeSymbol adapteeType) + { + var mappings = new List<(IMethodSymbol, string)>(); + + foreach (var member in hostSymbol.GetMembers().OfType()) + { + var mapAttr = member.GetAttributes().FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Adapter.AdapterMapAttribute"); + + if (mapAttr is null) + continue; + + var targetMember = mapAttr.NamedArguments + .FirstOrDefault(na => na.Key == "TargetMember") + .Value.Value as string; + + if (targetMember is not null) + { + mappings.Add((member, targetMember)); + } + } + + return mappings; + } + + private static List GetTargetMembers(INamedTypeSymbol targetType) + { + var members = new List(); + + // Get members from this type and all base interfaces/classes + var typesToProcess = new Queue(); + typesToProcess.Enqueue(targetType); + + var processed = new HashSet(SymbolEqualityComparer.Default); + + while (typesToProcess.Count > 0) + { + var type = typesToProcess.Dequeue(); + if (!processed.Add(type)) + continue; + + foreach (var member in type.GetMembers()) + { + // Include methods (not constructors), properties, and events + if (member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary) + { + members.Add(member); + } + else if (member is IPropertySymbol prop && !prop.IsIndexer) + { + members.Add(member); + } + else if (member is IEventSymbol) + { + members.Add(member); + } + } + + // Add base interfaces + foreach (var iface in type.Interfaces) + { + typesToProcess.Enqueue(iface); + } + + // Add base class (for abstract classes) + if (type.BaseType is not null && type.BaseType.IsAbstract) + { + typesToProcess.Enqueue(type.BaseType); + } + } + + // Sort by name for deterministic output + return members.OrderBy(m => m.Name).ThenBy(m => m.ToDisplayString()).ToList(); + } + + private static string? ValidateSignature(ISymbol targetMember, IMethodSymbol mapMethod, INamedTypeSymbol adapteeType) + { + // First parameter must be the adaptee type + if (mapMethod.Parameters.Length == 0) + return $"First parameter must be of type '{adapteeType.ToDisplayString()}'."; + + var firstParam = mapMethod.Parameters[0]; + if (!SymbolEqualityComparer.Default.Equals(firstParam.Type, adapteeType)) + return $"First parameter must be of type '{adapteeType.ToDisplayString()}', but was '{firstParam.Type.ToDisplayString()}'."; + + if (targetMember is IMethodSymbol targetMethod) + { + // Check return type + if (!SymbolEqualityComparer.Default.Equals(mapMethod.ReturnType, targetMethod.ReturnType)) + return $"Return type must be '{targetMethod.ReturnType.ToDisplayString()}', but was '{mapMethod.ReturnType.ToDisplayString()}'."; + + // Check remaining parameters (after adaptee) + var mapParams = mapMethod.Parameters.Skip(1).ToList(); + var targetParams = targetMethod.Parameters.ToList(); + + if (mapParams.Count != targetParams.Count) + return $"Expected {targetParams.Count} parameters (after adaptee), but found {mapParams.Count}."; + + for (int i = 0; i < targetParams.Count; i++) + { + if (!SymbolEqualityComparer.Default.Equals(mapParams[i].Type, targetParams[i].Type)) + return $"Parameter '{targetParams[i].Name}' type mismatch: expected '{targetParams[i].Type.ToDisplayString()}', but was '{mapParams[i].Type.ToDisplayString()}'."; + } + } + else if (targetMember is IPropertySymbol targetProp) + { + // For property getters, no additional parameters + if (mapMethod.Parameters.Length != 1) + return $"Property getter mapping must have exactly one parameter (the adaptee)."; + + // Check return type + if (!SymbolEqualityComparer.Default.Equals(mapMethod.ReturnType, targetProp.Type)) + return $"Return type must be '{targetProp.Type.ToDisplayString()}', but was '{mapMethod.ReturnType.ToDisplayString()}'."; + } + + return null; // Valid + } + + private static string GenerateAdapterCode( + string adapterTypeName, + string ns, + INamedTypeSymbol targetType, + INamedTypeSymbol adapteeType, + INamedTypeSymbol hostSymbol, + List targetMembers, + Dictionary memberMappings, + AdapterMissingMapPolicyValue missingMapPolicy, + bool isSealed) + { + var sb = new StringBuilder(); + + // File header + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + // Namespace + if (!string.IsNullOrEmpty(ns)) + { + sb.AppendLine($"namespace {ns};"); + sb.AppendLine(); + } + + // Class declaration + var sealedModifier = isSealed ? "sealed " : ""; + var targetTypeName = targetType.ToDisplayString(); + var adapteeTypeName = adapteeType.ToDisplayString(); + var hostTypeName = hostSymbol.ToDisplayString(); + + sb.AppendLine("/// "); + sb.AppendLine($"/// Adapter that implements by delegating to ."); + sb.AppendLine("/// "); + sb.AppendLine($"public {sealedModifier}partial class {adapterTypeName} : {targetTypeName}"); + sb.AppendLine("{"); + + // Field + sb.AppendLine($" private readonly {adapteeTypeName} _adaptee;"); + sb.AppendLine(); + + // Constructor + sb.AppendLine(" /// "); + sb.AppendLine($" /// Initializes a new instance of the class."); + sb.AppendLine(" /// "); + sb.AppendLine($" /// The adaptee instance to delegate to."); + sb.AppendLine($" /// Thrown when is null."); + sb.AppendLine($" public {adapterTypeName}({adapteeTypeName} adaptee)"); + sb.AppendLine(" {"); + sb.AppendLine(" _adaptee = adaptee ?? throw new global::System.ArgumentNullException(nameof(adaptee));"); + sb.AppendLine(" }"); + sb.AppendLine(); + + // Generate members + var isAbstractClassTarget = targetType.TypeKind == TypeKind.Class && targetType.IsAbstract; + foreach (var member in targetMembers) + { + if (memberMappings.TryGetValue(member, out var mapMethod)) + { + GenerateMappedMember(sb, member, mapMethod, hostTypeName, isAbstractClassTarget); + } + else if (missingMapPolicy == AdapterMissingMapPolicyValue.ThrowingStub) + { + GenerateThrowingStub(sb, member, isAbstractClassTarget); + } + // Ignore policy: don't generate anything (will cause compile error if interface) + } + + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static void GenerateMappedMember(StringBuilder sb, ISymbol member, IMethodSymbol mapMethod, string hostTypeName, bool isAbstractClassTarget) + { + // Determine if we need 'override' keyword (for abstract class members) + var overrideKeyword = isAbstractClassTarget && member.IsAbstract ? "override " : ""; + + if (member is IMethodSymbol targetMethod) + { + // Generate method + var returnType = targetMethod.ReturnType.ToDisplayString(); + var methodName = targetMethod.Name; + var parameters = string.Join(", ", targetMethod.Parameters.Select(p => + $"{GetParameterModifiers(p)}{p.Type.ToDisplayString()} {p.Name}{GetDefaultValue(p)}")); + var parameterNames = string.Join(", ", targetMethod.Parameters.Select(p => + $"{GetArgumentModifier(p)}{p.Name}")); + + var isVoid = targetMethod.ReturnsVoid; + var callExpression = $"{hostTypeName}.{mapMethod.Name}(_adaptee{(string.IsNullOrEmpty(parameterNames) ? "" : ", " + parameterNames)})"; + + sb.AppendLine($" /// "); + sb.AppendLine($" public {overrideKeyword}{returnType} {methodName}({parameters})"); + sb.AppendLine(" {"); + if (isVoid) + { + sb.AppendLine($" {callExpression};"); + } + else + { + sb.AppendLine($" return {callExpression};"); + } + sb.AppendLine(" }"); + sb.AppendLine(); + } + else if (member is IPropertySymbol targetProp) + { + // Generate property + var propType = targetProp.Type.ToDisplayString(); + var propName = targetProp.Name; + + sb.AppendLine($" /// "); + sb.AppendLine($" public {overrideKeyword}{propType} {propName}"); + sb.AppendLine(" {"); + if (targetProp.GetMethod is not null) + { + sb.AppendLine($" get => {hostTypeName}.{mapMethod.Name}(_adaptee);"); + } + sb.AppendLine(" }"); + sb.AppendLine(); + } + } + + private static void GenerateThrowingStub(StringBuilder sb, ISymbol member, bool isAbstractClassTarget) + { + var overrideKeyword = isAbstractClassTarget && member.IsAbstract ? "override " : ""; + + if (member is IMethodSymbol targetMethod) + { + var returnType = targetMethod.ReturnType.ToDisplayString(); + var methodName = targetMethod.Name; + var parameters = string.Join(", ", targetMethod.Parameters.Select(p => + $"{GetParameterModifiers(p)}{p.Type.ToDisplayString()} {p.Name}{GetDefaultValue(p)}")); + + sb.AppendLine($" /// "); + sb.AppendLine($" /// This member is not mapped and will throw ."); + sb.AppendLine($" public {overrideKeyword}{returnType} {methodName}({parameters})"); + sb.AppendLine(" {"); + sb.AppendLine($" throw new global::System.NotImplementedException(\"No [AdapterMap] provided for '{methodName}'.\");"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + else if (member is IPropertySymbol targetProp) + { + var propType = targetProp.Type.ToDisplayString(); + var propName = targetProp.Name; + + sb.AppendLine($" /// "); + sb.AppendLine($" /// This property is not mapped and will throw ."); + sb.AppendLine($" public {overrideKeyword}{propType} {propName}"); + sb.AppendLine(" {"); + if (targetProp.GetMethod is not null) + { + sb.AppendLine($" get => throw new global::System.NotImplementedException(\"No [AdapterMap] provided for '{propName}'.\");"); + } + if (targetProp.SetMethod is not null) + { + sb.AppendLine($" set => throw new global::System.NotImplementedException(\"No [AdapterMap] provided for '{propName}'.\");"); + } + sb.AppendLine(" }"); + sb.AppendLine(); + } + } + + private static string GetParameterModifiers(IParameterSymbol param) + { + return param.RefKind switch + { + RefKind.Ref => "ref ", + RefKind.Out => "out ", + RefKind.In => "in ", + RefKind.RefReadOnlyParameter => "ref readonly ", + _ => "" + }; + } + + private static string GetArgumentModifier(IParameterSymbol param) + { + return param.RefKind switch + { + RefKind.Ref => "ref ", + RefKind.Out => "out ", + RefKind.In => "in ", + RefKind.RefReadOnlyParameter => "in ", + _ => "" + }; + } + + private static string GetDefaultValue(IParameterSymbol param) + { + if (!param.HasExplicitDefaultValue) + return ""; + + if (param.ExplicitDefaultValue is null) + return " = default"; + + if (param.ExplicitDefaultValue is string s) + return $" = \"{s}\""; + + if (param.ExplicitDefaultValue is bool b) + return b ? " = true" : " = false"; + + return $" = {param.ExplicitDefaultValue}"; + } + + // Helper types + + private enum AdapterMissingMapPolicyValue + { + Error = 0, + ThrowingStub = 1, + Ignore = 2 + } + + private class AdapterConfig + { + public INamedTypeSymbol? TargetType { get; set; } + public INamedTypeSymbol? AdapteeType { get; set; } + public string? AdapterTypeName { get; set; } + public AdapterMissingMapPolicyValue MissingMapPolicy { get; set; } = AdapterMissingMapPolicyValue.Error; + public bool Sealed { get; set; } = true; + public string? Namespace { get; set; } + } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index a9215a7..a722360 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -98,3 +98,11 @@ PKSNG007 | PatternKit.Generators.Singleton | Error | Generic types are not suppo PKSNG008 | PatternKit.Generators.Singleton | Error | Nested types are not supported PKSNG009 | PatternKit.Generators.Singleton | Error | Invalid instance property name PKSNG010 | PatternKit.Generators.Singleton | Error | Abstract types not supported for Singleton pattern +PKADP001 | PatternKit.Generators.Adapter | Error | Adapter host must be static partial +PKADP002 | PatternKit.Generators.Adapter | Error | Target must be interface or abstract class +PKADP003 | PatternKit.Generators.Adapter | Error | Missing mapping for target member +PKADP004 | PatternKit.Generators.Adapter | Error | Multiple mappings found for target member +PKADP005 | PatternKit.Generators.Adapter | Error | Mapping method signature mismatch +PKADP006 | PatternKit.Generators.Adapter | Error | Adapter type name conflicts with existing type +PKADP007 | PatternKit.Generators.Adapter | Error | Invalid adaptee type (must be concrete) +PKADP008 | PatternKit.Generators.Adapter | Error | Mapping method must be static diff --git a/test/PatternKit.Examples.Tests/AdapterGeneratorDemo/AdapterGeneratorDemoTests.cs b/test/PatternKit.Examples.Tests/AdapterGeneratorDemo/AdapterGeneratorDemoTests.cs new file mode 100644 index 0000000..99e7e75 --- /dev/null +++ b/test/PatternKit.Examples.Tests/AdapterGeneratorDemo/AdapterGeneratorDemoTests.cs @@ -0,0 +1,305 @@ +using PatternKit.Examples.AdapterGeneratorDemo; + +namespace PatternKit.Examples.Tests.AdapterGeneratorDemo; + +public class AdapterGeneratorDemoTests +{ + // ========================================================================= + // Clock Adapter Tests + // ========================================================================= + + [Fact] + public void ClockAdapter_ImplementsIClock() + { + // Arrange + var legacyClock = new LegacySystemClock(); + + // Act + IClock clock = new SystemClockAdapter(legacyClock); + + // Assert - the adapter implements the interface + Assert.NotNull(clock); + } + + [Fact] + public void ClockAdapter_UtcNow_DelegatesToLegacyClock() + { + // Arrange + var legacyClock = new LegacySystemClock(); + IClock clock = new SystemClockAdapter(legacyClock); + + // Act + var before = DateTimeOffset.UtcNow; + var result = clock.UtcNow; + var after = DateTimeOffset.UtcNow; + + // Assert + Assert.InRange(result, before, after); + } + + [Fact] + public void ClockAdapter_LocalNow_DelegatesToLegacyClock() + { + // Arrange + var legacyClock = new LegacySystemClock(); + IClock clock = new SystemClockAdapter(legacyClock); + + // Act + var before = DateTimeOffset.Now; + var result = clock.LocalNow; + var after = DateTimeOffset.Now; + + // Assert + Assert.InRange(result, before, after); + } + + [Fact] + public void ClockAdapter_UnixTimestamp_ReturnsValidTimestamp() + { + // Arrange + var legacyClock = new LegacySystemClock(); + IClock clock = new SystemClockAdapter(legacyClock); + + // Act + var before = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var result = clock.UnixTimestamp; + var after = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + // Assert + Assert.InRange(result, before, after); + } + + [Fact] + public async Task ClockAdapter_DelayAsync_Delays() + { + // Arrange + var legacyClock = new LegacySystemClock(); + IClock clock = new SystemClockAdapter(legacyClock); + + // Act + var sw = System.Diagnostics.Stopwatch.StartNew(); + await clock.DelayAsync(TimeSpan.FromMilliseconds(50)); + sw.Stop(); + + // Assert - should have delayed at least 40ms (some tolerance for timing) + Assert.True(sw.ElapsedMilliseconds >= 40); + } + + [Fact] + public void ClockAdapter_ThrowsOnNullAdaptee() + { + // Act & Assert + Assert.Throws(() => new SystemClockAdapter(null!)); + } + + // ========================================================================= + // Payment Adapter Tests + // ========================================================================= + + [Fact] + public void StripeAdapter_ImplementsIPaymentGateway() + { + // Arrange + var stripeClient = new StripePaymentClient(); + + // Act + IPaymentGateway gateway = new StripePaymentAdapter(stripeClient); + + // Assert + Assert.NotNull(gateway); + } + + [Fact] + public void StripeAdapter_GatewayName_ReturnsStripe() + { + // Arrange + var stripeClient = new StripePaymentClient(); + IPaymentGateway gateway = new StripePaymentAdapter(stripeClient); + + // Act + var name = gateway.GatewayName; + + // Assert + Assert.Equal("Stripe", name); + } + + [Fact] + public async Task StripeAdapter_ChargeAsync_ReturnsSuccessfulResult() + { + // Arrange + var stripeClient = new StripePaymentClient(); + IPaymentGateway gateway = new StripePaymentAdapter(stripeClient); + + // Act + var result = await gateway.ChargeAsync("tok_visa", 99.99m, "USD"); + + // Assert + Assert.True(result.Success); + Assert.StartsWith("ch_", result.TransactionId); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public async Task StripeAdapter_RefundAsync_ReturnsSuccessfulResult() + { + // Arrange + var stripeClient = new StripePaymentClient(); + IPaymentGateway gateway = new StripePaymentAdapter(stripeClient); + + // Act + var result = await gateway.RefundAsync("ch_123", 50.00m); + + // Assert + Assert.True(result.Success); + Assert.StartsWith("re_", result.RefundId); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public void PayPalAdapter_ImplementsIPaymentGateway() + { + // Arrange + var paypalService = new PayPalPaymentService(); + + // Act + IPaymentGateway gateway = new PayPalPaymentAdapter(paypalService); + + // Assert + Assert.NotNull(gateway); + } + + [Fact] + public void PayPalAdapter_GatewayName_ReturnsPayPal() + { + // Arrange + var paypalService = new PayPalPaymentService(); + IPaymentGateway gateway = new PayPalPaymentAdapter(paypalService); + + // Act + var name = gateway.GatewayName; + + // Assert + Assert.Equal("PayPal", name); + } + + [Fact] + public async Task PayPalAdapter_ChargeAsync_ReturnsSuccessfulResult() + { + // Arrange + var paypalService = new PayPalPaymentService(); + IPaymentGateway gateway = new PayPalPaymentAdapter(paypalService); + + // Act + var result = await gateway.ChargeAsync("token_123", 149.99m, "USD"); + + // Assert + Assert.True(result.Success); + Assert.StartsWith("PAY-", result.TransactionId); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public async Task PayPalAdapter_RefundAsync_ReturnsSuccessfulResult() + { + // Arrange + var paypalService = new PayPalPaymentService(); + IPaymentGateway gateway = new PayPalPaymentAdapter(paypalService); + + // Act + var result = await gateway.RefundAsync("PAY-123", 75.00m); + + // Assert + Assert.True(result.Success); + Assert.StartsWith("REF-", result.RefundId); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public async Task MultiplePaymentAdapters_AreInterchangeable() + { + // Arrange - different implementations behind the same interface + IPaymentGateway stripeGateway = new StripePaymentAdapter(new StripePaymentClient()); + IPaymentGateway paypalGateway = new PayPalPaymentAdapter(new PayPalPaymentService()); + + // Act - use them interchangeably + var stripeResult = await stripeGateway.ChargeAsync("tok_1", 100m, "USD"); + var paypalResult = await paypalGateway.ChargeAsync("tok_2", 100m, "USD"); + + // Assert - both work through the unified interface + Assert.True(stripeResult.Success); + Assert.True(paypalResult.Success); + Assert.NotEqual(stripeResult.TransactionId, paypalResult.TransactionId); + } + + // ========================================================================= + // Logger Adapter Tests + // ========================================================================= + + [Fact] + public void LoggerAdapter_ImplementsIStructuredLogger() + { + // Arrange + var legacyLogger = new LegacyConsoleLogger("TEST"); + + // Act + IStructuredLogger logger = new ConsoleLoggerAdapter(legacyLogger); + + // Assert + Assert.NotNull(logger); + } + + [Fact] + public void LoggerAdapter_IsEnabled_RespectsMinimumLevel() + { + // Arrange - logger with Warning minimum level + var legacyLogger = new LegacyConsoleLogger("TEST", minimumLevel: 2); + IStructuredLogger logger = new ConsoleLoggerAdapter(legacyLogger); + + // Act & Assert + Assert.False(logger.IsEnabled(LogLevel.Debug)); + Assert.False(logger.IsEnabled(LogLevel.Info)); + Assert.True(logger.IsEnabled(LogLevel.Warning)); + Assert.True(logger.IsEnabled(LogLevel.Error)); + } + + [Fact] + public void LoggerAdapter_IsEnabled_AllLevelsWhenMinimumIsDebug() + { + // Arrange - logger with Debug minimum level (default) + var legacyLogger = new LegacyConsoleLogger("TEST", minimumLevel: 0); + IStructuredLogger logger = new ConsoleLoggerAdapter(legacyLogger); + + // Act & Assert + Assert.True(logger.IsEnabled(LogLevel.Debug)); + Assert.True(logger.IsEnabled(LogLevel.Info)); + Assert.True(logger.IsEnabled(LogLevel.Warning)); + Assert.True(logger.IsEnabled(LogLevel.Error)); + } + + [Fact] + public void LoggerAdapter_LogMethods_DoNotThrow() + { + // Arrange + var legacyLogger = new LegacyConsoleLogger("TEST"); + IStructuredLogger logger = new ConsoleLoggerAdapter(legacyLogger); + + // Act & Assert - none should throw + var ex = Record.Exception(() => + { + logger.LogDebug("Debug message"); + logger.LogInfo("Info message"); + logger.LogWarning("Warning message"); + logger.LogError("Error message"); + logger.LogError("Error with exception", new InvalidOperationException("Test")); + }); + + Assert.Null(ex); + } + + [Fact] + public void LoggerAdapter_ThrowsOnNullAdaptee() + { + // Act & Assert + Assert.Throws(() => new ConsoleLoggerAdapter(null!)); + } +} diff --git a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs new file mode 100644 index 0000000..a57051e --- /dev/null +++ b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs @@ -0,0 +1,574 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Generators.Adapter; + +namespace PatternKit.Generators.Tests; + +public class AdapterGeneratorTests +{ + [Fact] + public void GenerateSimpleAdapter() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public class LegacyClock + { + public System.DateTimeOffset GetNow() => System.DateTimeOffset.UtcNow; + } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class ClockAdapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow(LegacyClock adaptee) => adaptee.GetNow(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateSimpleAdapter)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Adapter file is generated + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("TestNamespace.LegacyClockToIClockAdapter.Adapter.g.cs", names); + + // Generated code contains expected shape + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace.LegacyClockToIClockAdapter.Adapter.g.cs") + .SourceText.ToString(); + + Assert.Contains("public sealed partial class LegacyClockToIClockAdapter : TestNamespace.IClock", generatedSource); + Assert.Contains("private readonly TestNamespace.LegacyClock _adaptee;", generatedSource); + Assert.Contains("public LegacyClockToIClockAdapter(TestNamespace.LegacyClock adaptee)", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateAdapterWithMethods() + { + const string source = """ + using PatternKit.Generators.Adapter; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace TestNamespace; + + public interface IClock + { + DateTimeOffset Now { get; } + ValueTask DelayAsync(TimeSpan duration, CancellationToken ct = default); + } + + public class LegacyClock + { + public DateTimeOffset GetNow() => DateTimeOffset.UtcNow; + public Task SleepAsync(int milliseconds, CancellationToken ct) => Task.Delay(milliseconds, ct); + } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class ClockAdapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static DateTimeOffset MapNow(LegacyClock adaptee) => adaptee.GetNow(); + + [AdapterMap(TargetMember = nameof(IClock.DelayAsync))] + public static ValueTask MapDelayAsync(LegacyClock adaptee, TimeSpan duration, CancellationToken ct) + => new(adaptee.SleepAsync((int)duration.TotalMilliseconds, ct)); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateAdapterWithMethods)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Generated code contains method + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("LegacyClockToIClockAdapter")) + .SourceText.ToString(); + + Assert.Contains("public System.Threading.Tasks.ValueTask DelayAsync", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateAdapterWithCustomName() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public class LegacyClock + { + public System.DateTimeOffset GetNow() => System.DateTimeOffset.UtcNow; + } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock), AdapterTypeName = "ClockAdapter")] + public static partial class ClockAdapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow(LegacyClock adaptee) => adaptee.GetNow(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateAdapterWithCustomName)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Custom named adapter file is generated + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("TestNamespace.ClockAdapter.Adapter.g.cs", names); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void ErrorWhenHostNotStaticPartial() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public class LegacyClock { } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public class NotStaticPartial + { + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenHostNotStaticPartial)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP001 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP001"); + } + + [Fact] + public void ErrorWhenTargetNotInterface() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public class NotInterface + { + public System.DateTimeOffset Now { get; } + } + + public class LegacyClock { } + + [GenerateAdapter(Target = typeof(NotInterface), Adaptee = typeof(LegacyClock))] + public static partial class Adapters { } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenTargetNotInterface)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP002 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP002"); + } + + [Fact] + public void ErrorWhenMissingMapping() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + void Tick(); + } + + public class LegacyClock { } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow(LegacyClock adaptee) => default; + // Missing mapping for Tick() + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenMissingMapping)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP003 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP003"); + } + + [Fact] + public void ErrorWhenDuplicateMapping() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public class LegacyClock { } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow1(LegacyClock adaptee) => default; + + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow2(LegacyClock adaptee) => default; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenDuplicateMapping)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP004 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP004"); + } + + [Fact] + public void ErrorWhenSignatureMismatch() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public class LegacyClock { } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static string MapNow(LegacyClock adaptee) => "wrong type"; // Should return DateTimeOffset + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenSignatureMismatch)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP005 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP005"); + } + + [Fact] + public void GenerateThrowingStubWhenPolicySet() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + void Tick(); + } + + public class LegacyClock { } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock), MissingMap = AdapterMissingMapPolicy.ThrowingStub)] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow(LegacyClock adaptee) => default; + // No mapping for Tick() - should generate throwing stub + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateThrowingStubWhenPolicySet)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics (ThrowingStub policy allows missing maps) + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Generated code contains throwing stub + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("Adapter")) + .SourceText.ToString(); + + Assert.Contains("throw new global::System.NotImplementedException", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateAdapterWithAbstractClassTarget() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public abstract class ClockBase + { + public abstract System.DateTimeOffset Now { get; } + } + + public class LegacyClock + { + public System.DateTimeOffset GetNow() => System.DateTimeOffset.UtcNow; + } + + [GenerateAdapter(Target = typeof(ClockBase), Adaptee = typeof(LegacyClock))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(ClockBase.Now))] + public static System.DateTimeOffset MapNow(LegacyClock adaptee) => adaptee.GetNow(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateAdapterWithAbstractClassTarget)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Adapter inherits from abstract class + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("Adapter")) + .SourceText.ToString(); + + Assert.Contains(": TestNamespace.ClockBase", generatedSource); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateMultipleAdaptersFromSameHost() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock { System.DateTimeOffset Now { get; } } + public interface ITimer { void Start(); } + + public class LegacyClock + { + public System.DateTimeOffset GetNow() => System.DateTimeOffset.UtcNow; + } + + public class LegacyTimer + { + public void Begin() { } + } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + [GenerateAdapter(Target = typeof(ITimer), Adaptee = typeof(LegacyTimer))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow(LegacyClock adaptee) => adaptee.GetNow(); + + [AdapterMap(TargetMember = nameof(ITimer.Start))] + public static void MapStart(LegacyTimer adaptee) => adaptee.Begin(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateMultipleAdaptersFromSameHost)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Both adapters are generated + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains(names, n => n.Contains("LegacyClockToIClockAdapter")); + Assert.Contains(names, n => n.Contains("LegacyTimerToITimerAdapter")); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateAdapterInGlobalNamespace() + { + const string source = """ + using PatternKit.Generators.Adapter; + + public interface IClock { System.DateTimeOffset Now { get; } } + public class LegacyClock { public System.DateTimeOffset GetNow() => default; } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow(LegacyClock adaptee) => adaptee.GetNow(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateAdapterInGlobalNamespace)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Adapter is generated without namespace + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("Adapter")) + .SourceText.ToString(); + + Assert.DoesNotContain("namespace", generatedSource); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateNonSealedAdapter() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock { System.DateTimeOffset Now { get; } } + public class LegacyClock { public System.DateTimeOffset GetNow() => default; } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock), Sealed = false)] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow(LegacyClock adaptee) => adaptee.GetNow(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateNonSealedAdapter)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("Adapter")) + .SourceText.ToString(); + + Assert.DoesNotContain("sealed", generatedSource); + Assert.Contains("public partial class", generatedSource); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateAdapterWithMethodParameters() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface ICalculator + { + int Add(int a, int b); + int Multiply(int x, int y); + } + + public class OldCalculator + { + public int Sum(int first, int second) => first + second; + public int Product(int m, int n) => m * n; + } + + [GenerateAdapter(Target = typeof(ICalculator), Adaptee = typeof(OldCalculator))] + public static partial class CalculatorAdapters + { + [AdapterMap(TargetMember = nameof(ICalculator.Add))] + public static int MapAdd(OldCalculator adaptee, int a, int b) => adaptee.Sum(a, b); + + [AdapterMap(TargetMember = nameof(ICalculator.Multiply))] + public static int MapMultiply(OldCalculator adaptee, int x, int y) => adaptee.Product(x, y); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateAdapterWithMethodParameters)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("Adapter")) + .SourceText.ToString(); + + Assert.Contains("public int Add(int a, int b)", generatedSource); + Assert.Contains("public int Multiply(int x, int y)", generatedSource); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } +} From 73758e7c7790f8fa63e4086f7f974a2e25db5095 Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Sat, 7 Feb 2026 08:53:13 -0600 Subject: [PATCH 02/12] 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. --- .../Adapter/AdapterGenerator.cs | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs index 2a9dcbf..246376a 100644 --- a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs +++ b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs @@ -327,6 +327,14 @@ private static AdapterConfig ParseAdapterConfig(AttributeData attribute) if (mapAttr is null) continue; + // Filter by first parameter type matching the adaptee type + if (member.Parameters.Length == 0) + continue; + + var firstParamType = member.Parameters[0].Type; + if (!SymbolEqualityComparer.Default.Equals(firstParamType, adapteeType)) + continue; + var targetMember = mapAttr.NamedArguments .FirstOrDefault(na => na.Key == "TargetMember") .Value.Value as string; @@ -343,6 +351,7 @@ private static AdapterConfig ParseAdapterConfig(AttributeData attribute) private static List GetTargetMembers(INamedTypeSymbol targetType) { var members = new List(); + var isAbstractClass = targetType.TypeKind == TypeKind.Class && targetType.IsAbstract; // Get members from this type and all base interfaces/classes var typesToProcess = new Queue(); @@ -358,7 +367,11 @@ private static List GetTargetMembers(INamedTypeSymbol targetType) foreach (var member in type.GetMembers()) { - // Include methods (not constructors), properties, and events + // For abstract classes, only include abstract members (must be overridden) + if (isAbstractClass && !member.IsAbstract) + continue; + + // Include methods (not constructors), properties (not events - not supported) if (member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary) { members.Add(member); @@ -367,10 +380,7 @@ private static List GetTargetMembers(INamedTypeSymbol targetType) { members.Add(member); } - else if (member is IEventSymbol) - { - members.Add(member); - } + // Events are intentionally excluded - not supported by this generator } // Add base interfaces @@ -475,14 +485,25 @@ private static string GenerateAdapterCode( sb.AppendLine(); // Constructor + var isValueTypeAdaptee = adapteeType.IsValueType; sb.AppendLine(" /// "); sb.AppendLine($" /// Initializes a new instance of the class."); sb.AppendLine(" /// "); sb.AppendLine($" /// The adaptee instance to delegate to."); - sb.AppendLine($" /// Thrown when is null."); + if (!isValueTypeAdaptee) + { + sb.AppendLine($" /// Thrown when is null."); + } sb.AppendLine($" public {adapterTypeName}({adapteeTypeName} adaptee)"); sb.AppendLine(" {"); - sb.AppendLine(" _adaptee = adaptee ?? throw new global::System.ArgumentNullException(nameof(adaptee));"); + if (isValueTypeAdaptee) + { + sb.AppendLine(" _adaptee = adaptee;"); + } + else + { + sb.AppendLine(" _adaptee = adaptee ?? throw new global::System.ArgumentNullException(nameof(adaptee));"); + } sb.AppendLine(" }"); sb.AppendLine(); From 054d406988d13da1879180555c778b6fdc4f44ea Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Sat, 7 Feb 2026 10:27:32 -0600 Subject: [PATCH 03/12] 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 --- docs/generators/adapter.md | 8 +- .../Adapter/AdapterGenerator.cs | 49 +++-- .../AdapterGeneratorTests.cs | 184 ++++++++++++++++++ 3 files changed, 226 insertions(+), 15 deletions(-) diff --git a/docs/generators/adapter.md b/docs/generators/adapter.md index d4432d7..9b68ebe 100644 --- a/docs/generators/adapter.md +++ b/docs/generators/adapter.md @@ -197,11 +197,11 @@ You can define multiple adapters in the same host class: [GenerateAdapter(Target = typeof(IPaymentGateway), Adaptee = typeof(PayPalClient))] public static partial class PaymentAdapters { - [AdapterMap(TargetMember = nameof(IPaymentGateway.Charge))] - public static PaymentResult MapStripeCharge(StripeClient adaptee, ...) { ... } + [AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))] + public static Task MapStripeChargeAsync(StripeClient adaptee, ...) { ... } - [AdapterMap(TargetMember = nameof(IPaymentGateway.Charge))] - public static PaymentResult MapPayPalCharge(PayPalClient adaptee, ...) { ... } + [AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))] + public static Task MapPayPalChargeAsync(PayPalClient adaptee, ...) { ... } } ``` diff --git a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs index 246376a..018cb09 100644 --- a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs +++ b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs @@ -351,6 +351,7 @@ private static AdapterConfig ParseAdapterConfig(AttributeData attribute) private static List GetTargetMembers(INamedTypeSymbol targetType) { var members = new List(); + var seenSignatures = new HashSet(); var isAbstractClass = targetType.TypeKind == TypeKind.Class && targetType.IsAbstract; // Get members from this type and all base interfaces/classes @@ -374,11 +375,17 @@ private static List GetTargetMembers(INamedTypeSymbol targetType) // Include methods (not constructors), properties (not events - not supported) if (member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary) { - members.Add(member); + // De-duplicate by signature for interface diamonds + var sig = GetMemberSignature(method); + if (seenSignatures.Add(sig)) + members.Add(member); } else if (member is IPropertySymbol prop && !prop.IsIndexer) { - members.Add(member); + // De-duplicate by name+type for properties + var sig = $"P:{prop.Name}:{prop.Type.ToDisplayString()}"; + if (seenSignatures.Add(sig)) + members.Add(member); } // Events are intentionally excluded - not supported by this generator } @@ -400,6 +407,13 @@ private static List GetTargetMembers(INamedTypeSymbol targetType) return members.OrderBy(m => m.Name).ThenBy(m => m.ToDisplayString()).ToList(); } + private static string GetMemberSignature(IMethodSymbol method) + { + var paramSig = string.Join(",", method.Parameters.Select(p => + $"{p.RefKind}:{p.Type.ToDisplayString()}")); + return $"M:{method.Name}({paramSig}):{method.ReturnType.ToDisplayString()}"; + } + private static string? ValidateSignature(ISymbol targetMember, IMethodSymbol mapMethod, INamedTypeSymbol adapteeType) { // First parameter must be the adaptee type @@ -425,8 +439,14 @@ private static List GetTargetMembers(INamedTypeSymbol targetType) for (int i = 0; i < targetParams.Count; i++) { - if (!SymbolEqualityComparer.Default.Equals(mapParams[i].Type, targetParams[i].Type)) - return $"Parameter '{targetParams[i].Name}' type mismatch: expected '{targetParams[i].Type.ToDisplayString()}', but was '{mapParams[i].Type.ToDisplayString()}'."; + var mapParam = mapParams[i]; + var targetParam = targetParams[i]; + + if (!SymbolEqualityComparer.Default.Equals(mapParam.Type, targetParam.Type)) + return $"Parameter '{targetParam.Name}' type mismatch: expected '{targetParam.Type.ToDisplayString()}', but was '{mapParam.Type.ToDisplayString()}'."; + + if (mapParam.RefKind != targetParam.RefKind) + return $"Parameter '{targetParam.Name}' ref kind mismatch: expected '{targetParam.RefKind}', but was '{mapParam.RefKind}'."; } } else if (targetMember is IPropertySymbol targetProp) @@ -572,6 +592,10 @@ private static void GenerateMappedMember(StringBuilder sb, ISymbol member, IMeth { sb.AppendLine($" get => {hostTypeName}.{mapMethod.Name}(_adaptee);"); } + if (targetProp.SetMethod is not null) + { + sb.AppendLine($" set => throw new global::System.NotSupportedException(\"Property setter mapping is not supported for '{propName}'.\");"); + } sb.AppendLine(" }"); sb.AppendLine(); } @@ -647,16 +671,19 @@ private static string GetDefaultValue(IParameterSymbol param) if (!param.HasExplicitDefaultValue) return ""; - if (param.ExplicitDefaultValue is null) - return " = default"; + var value = param.ExplicitDefaultValue; - if (param.ExplicitDefaultValue is string s) - return $" = \"{s}\""; + if (value is null) + { + // For reference types or nullable value types, emit 'null'; otherwise, use 'default' + if (param.Type.IsReferenceType || param.NullableAnnotation == NullableAnnotation.Annotated) + return " = null"; - if (param.ExplicitDefaultValue is bool b) - return b ? " = true" : " = false"; + return " = default"; + } - return $" = {param.ExplicitDefaultValue}"; + var literal = SymbolDisplay.FormatPrimitive(value, quoteStrings: true, useHexadecimalNumbers: false); + return " = " + literal; } // Helper types diff --git a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs index a57051e..874bc8b 100644 --- a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs @@ -571,4 +571,188 @@ public static partial class CalculatorAdapters var emit = updated.Emit(Stream.Null); Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); } + + [Fact] + public void ErrorWhenAdapteeIsAbstract() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public abstract class AbstractClock { } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(AbstractClock))] + public static partial class Adapters { } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenAdapteeIsAbstract)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP007 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP007"); + } + + [Fact] + public void ErrorWhenMappingMethodNotStatic() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public class LegacyClock { } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public System.DateTimeOffset MapNow(LegacyClock adaptee) => default; // Missing static + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenMappingMethodNotStatic)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP008 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP008"); + } + + [Fact] + public void MultipleAdaptersWithOverlappingMemberNames() + { + // Both IClock and ITimer have a Name property - should not cause false duplicate errors + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock { string Name { get; } } + public interface ITimer { string Name { get; } } + + public class LegacyClock { public string ClockName => "Clock"; } + public class LegacyTimer { public string TimerName => "Timer"; } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + [GenerateAdapter(Target = typeof(ITimer), Adaptee = typeof(LegacyTimer))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Name))] + public static string MapClockName(LegacyClock adaptee) => adaptee.ClockName; + + [AdapterMap(TargetMember = nameof(ITimer.Name))] + public static string MapTimerName(LegacyTimer adaptee) => adaptee.TimerName; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(MultipleAdaptersWithOverlappingMemberNames)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics (mappings are distinguished by adaptee type) + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Both adapters generated + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains(names, n => n.Contains("LegacyClockToIClockAdapter")); + Assert.Contains(names, n => n.Contains("LegacyTimerToITimerAdapter")); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void InterfaceDiamondDeduplication() + { + // IChild inherits from both IBase1 and IBase2 which both have DoWork() + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IBase { void DoWork(); } + public interface IChild : IBase { void DoExtra(); } + + public class Legacy + { + public void Work() { } + public void Extra() { } + } + + [GenerateAdapter(Target = typeof(IChild), Adaptee = typeof(Legacy))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IChild.DoWork))] + public static void MapDoWork(Legacy adaptee) => adaptee.Work(); + + [AdapterMap(TargetMember = nameof(IChild.DoExtra))] + public static void MapDoExtra(Legacy adaptee) => adaptee.Extra(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(InterfaceDiamondDeduplication)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Should only have one DoWork method in generated code, not duplicates + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("Adapter")) + .SourceText.ToString(); + + var doWorkCount = generatedSource.Split("public void DoWork()").Length - 1; + Assert.Equal(1, doWorkCount); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void RefParameterValidation() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IProcessor + { + void Process(ref int value); + } + + public class Legacy { } + + [GenerateAdapter(Target = typeof(IProcessor), Adaptee = typeof(Legacy))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IProcessor.Process))] + public static void MapProcess(Legacy adaptee, int value) { } // Missing ref + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(RefParameterValidation)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP005 diagnostic is reported for ref kind mismatch + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP005" && d.GetMessage().Contains("ref kind")); + } } From b56855198376a3ac40c3f2884ba7e77ade8458ec Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Sat, 7 Feb 2026 10:37:32 -0600 Subject: [PATCH 04/12] 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 --- .../Adapter/AdapterGenerator.cs | 157 ++++++++++++++ .../AnalyzerReleases.Unshipped.md | 4 + .../AdapterGeneratorTests.cs | 200 ++++++++++++++++++ 3 files changed, 361 insertions(+) diff --git a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs index 018cb09..1a2d245 100644 --- a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs +++ b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs @@ -22,6 +22,10 @@ public sealed class AdapterGenerator : IIncrementalGenerator private const string DiagIdTypeNameConflict = "PKADP006"; private const string DiagIdInvalidAdapteType = "PKADP007"; private const string DiagIdMapMethodNotStatic = "PKADP008"; + private const string DiagIdEventsNotSupported = "PKADP009"; + private const string DiagIdGenericMethodsNotSupported = "PKADP010"; + private const string DiagIdOverloadedMethodsNotSupported = "PKADP011"; + private const string DiagIdAbstractClassNoParameterlessCtor = "PKADP012"; private static readonly DiagnosticDescriptor HostNotStaticPartialDescriptor = new( id: DiagIdHostNotStaticPartial, @@ -87,6 +91,38 @@ public sealed class AdapterGenerator : IIncrementalGenerator defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor EventsNotSupportedDescriptor = new( + id: DiagIdEventsNotSupported, + title: "Events are not supported", + messageFormat: "Target type '{0}' contains event '{1}' which is not supported by the adapter generator", + category: "PatternKit.Generators.Adapter", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor GenericMethodsNotSupportedDescriptor = new( + id: DiagIdGenericMethodsNotSupported, + title: "Generic methods are not supported", + messageFormat: "Target type '{0}' contains generic method '{1}' which is not supported by the adapter generator", + category: "PatternKit.Generators.Adapter", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor OverloadedMethodsNotSupportedDescriptor = new( + id: DiagIdOverloadedMethodsNotSupported, + title: "Overloaded methods are not supported", + messageFormat: "Target type '{0}' contains overloaded method '{1}' which is not supported by the adapter generator", + category: "PatternKit.Generators.Adapter", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor AbstractClassNoParameterlessCtorDescriptor = new( + id: DiagIdAbstractClassNoParameterlessCtor, + title: "Abstract class target requires accessible parameterless constructor", + messageFormat: "Abstract class '{0}' does not have an accessible parameterless constructor", + category: "PatternKit.Generators.Adapter", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + public void Initialize(IncrementalGeneratorInitializationContext context) { // Find all class declarations with [GenerateAdapter] attribute @@ -155,6 +191,33 @@ private void GenerateAdapterForAttribute( return; } + // For abstract class targets, validate accessible parameterless constructor exists + if (config.TargetType.TypeKind == TypeKind.Class && config.TargetType.IsAbstract) + { + var hasAccessibleParameterlessCtor = config.TargetType.InstanceConstructors + .Any(c => c.Parameters.Length == 0 && + (c.DeclaredAccessibility == Accessibility.Public || + c.DeclaredAccessibility == Accessibility.Protected)); + + if (!hasAccessibleParameterlessCtor) + { + context.ReportDiagnostic(Diagnostic.Create( + AbstractClassNoParameterlessCtorDescriptor, + node.GetLocation(), + config.TargetType.ToDisplayString())); + return; + } + } + + // Check for unsupported members (events, generic methods, overloads) + var unsupportedMemberErrors = ValidateTargetMembers(config.TargetType, node.GetLocation()); + foreach (var diagnostic in unsupportedMemberErrors) + { + context.ReportDiagnostic(diagnostic); + } + if (unsupportedMemberErrors.Any()) + return; + // Get all mapping methods from host var mappingMethods = GetMappingMethods(hostSymbol, config.AdapteeType); @@ -238,6 +301,17 @@ private void GenerateAdapterForAttribute( ? string.Empty : hostSymbol.ContainingNamespace.ToDisplayString()); + // Check for type name conflict (PKADP006) + if (HasTypeNameConflict(semanticModel.Compilation, ns, adapterTypeName)) + { + context.ReportDiagnostic(Diagnostic.Create( + TypeNameConflictDescriptor, + node.GetLocation(), + adapterTypeName, + string.IsNullOrEmpty(ns) ? "" : ns)); + return; + } + // Generate adapter var source = GenerateAdapterCode( adapterTypeName, @@ -279,6 +353,89 @@ private static bool IsValidAdapteeType(INamedTypeSymbol type) !type.IsAbstract; } + private static bool HasTypeNameConflict(Compilation compilation, string ns, string typeName) + { + var fullName = string.IsNullOrEmpty(ns) ? typeName : $"{ns}.{typeName}"; + return compilation.GetTypeByMetadataName(fullName) is not null; + } + + private List ValidateTargetMembers(INamedTypeSymbol targetType, Location location) + { + var diagnostics = new List(); + var isAbstractClass = targetType.TypeKind == TypeKind.Class && targetType.IsAbstract; + var methodNames = new Dictionary(); + + // Collect all members from the type hierarchy + var typesToProcess = new Queue(); + typesToProcess.Enqueue(targetType); + var processed = new HashSet(SymbolEqualityComparer.Default); + + while (typesToProcess.Count > 0) + { + var type = typesToProcess.Dequeue(); + if (!processed.Add(type)) + continue; + + foreach (var member in type.GetMembers()) + { + // For abstract classes, only check abstract members + if (isAbstractClass && !member.IsAbstract) + continue; + + // Check for events (not supported) + if (member is IEventSymbol evt) + { + diagnostics.Add(Diagnostic.Create( + EventsNotSupportedDescriptor, + location, + targetType.Name, + evt.Name)); + } + + // Check for generic methods (not supported) + if (member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary) + { + if (method.IsGenericMethod) + { + diagnostics.Add(Diagnostic.Create( + GenericMethodsNotSupportedDescriptor, + location, + targetType.Name, + method.Name)); + } + + // Track method names for overload detection + if (!methodNames.TryGetValue(method.Name, out var count)) + count = 0; + methodNames[method.Name] = count + 1; + } + } + + // Add base interfaces + foreach (var iface in type.Interfaces) + typesToProcess.Enqueue(iface); + + // Add base class (for abstract classes) + if (type.BaseType is not null && type.BaseType.IsAbstract) + typesToProcess.Enqueue(type.BaseType); + } + + // Check for overloaded methods + foreach (var kvp in methodNames) + { + if (kvp.Value > 1) + { + diagnostics.Add(Diagnostic.Create( + OverloadedMethodsNotSupportedDescriptor, + location, + targetType.Name, + kvp.Key)); + } + } + + return diagnostics; + } + private static AdapterConfig ParseAdapterConfig(AttributeData attribute) { var config = new AdapterConfig(); diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index a722360..355ca57 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -106,3 +106,7 @@ PKADP005 | PatternKit.Generators.Adapter | Error | Mapping method signature mism PKADP006 | PatternKit.Generators.Adapter | Error | Adapter type name conflicts with existing type PKADP007 | PatternKit.Generators.Adapter | Error | Invalid adaptee type (must be concrete) PKADP008 | PatternKit.Generators.Adapter | Error | Mapping method must be static +PKADP009 | PatternKit.Generators.Adapter | Error | Events are not supported +PKADP010 | PatternKit.Generators.Adapter | Error | Generic methods are not supported +PKADP011 | PatternKit.Generators.Adapter | Error | Overloaded methods are not supported +PKADP012 | PatternKit.Generators.Adapter | Error | Abstract class target requires accessible parameterless constructor diff --git a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs index 874bc8b..d134772 100644 --- a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs @@ -755,4 +755,204 @@ public static void MapProcess(Legacy adaptee, int value) { } // Missing ref var diags = result.Results.SelectMany(r => r.Diagnostics); Assert.Contains(diags, d => d.Id == "PKADP005" && d.GetMessage().Contains("ref kind")); } + + [Fact] + public void ErrorWhenTargetHasEvents() + { + const string source = """ + using PatternKit.Generators.Adapter; + using System; + + namespace TestNamespace; + + public interface INotifier + { + event EventHandler Changed; + } + + public class Legacy { } + + [GenerateAdapter(Target = typeof(INotifier), Adaptee = typeof(Legacy))] + public static partial class Adapters { } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenTargetHasEvents)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP009 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP009"); + } + + [Fact] + public void ErrorWhenTargetHasGenericMethods() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface ISerializer + { + T Deserialize(string data); + } + + public class Legacy { } + + [GenerateAdapter(Target = typeof(ISerializer), Adaptee = typeof(Legacy))] + public static partial class Adapters { } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenTargetHasGenericMethods)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP010 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP010"); + } + + [Fact] + public void ErrorWhenTargetHasOverloadedMethods() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface ICalculator + { + int Add(int a, int b); + int Add(int a, int b, int c); + } + + public class Legacy { } + + [GenerateAdapter(Target = typeof(ICalculator), Adaptee = typeof(Legacy))] + public static partial class Adapters { } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenTargetHasOverloadedMethods)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP011 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP011"); + } + + [Fact] + public void ErrorWhenAbstractClassHasNoParameterlessCtor() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public abstract class ServiceBase + { + protected ServiceBase(string name) { } + public abstract void DoWork(); + } + + public class Legacy { } + + [GenerateAdapter(Target = typeof(ServiceBase), Adaptee = typeof(Legacy))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(ServiceBase.DoWork))] + public static void MapDoWork(Legacy adaptee) { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenAbstractClassHasNoParameterlessCtor)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP012 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP012"); + } + + [Fact] + public void ErrorWhenAdapterTypeNameConflicts() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public class LegacyClock { } + + // This type already exists with the same name the generator would use + public class LegacyClockToIClockAdapter { } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow(LegacyClock adaptee) => default; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenAdapterTypeNameConflicts)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP006 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP006"); + } + + [Fact] + public void StructAdapteeNoNullCheck() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public struct StructClock + { + public System.DateTimeOffset GetNow() => System.DateTimeOffset.UtcNow; + } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(StructClock))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow(StructClock adaptee) => adaptee.GetNow(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(StructAdapteeNoNullCheck)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Generated code should NOT have null check for struct + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("Adapter")) + .SourceText.ToString(); + + Assert.DoesNotContain("throw new global::System.ArgumentNullException", generatedSource); + Assert.Contains("_adaptee = adaptee;", generatedSource); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } } From fa1d1215a228274faeea731c7e7b4c0d1fd40043 Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Sat, 7 Feb 2026 21:23:06 -0600 Subject: [PATCH 05/12] 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 --- .../Adapter/AdapterGenerator.cs | 83 +++++++++++++------ .../AnalyzerReleases.Unshipped.md | 1 + .../AdapterGeneratorTests.cs | 83 +++++++++++++++++-- 3 files changed, 133 insertions(+), 34 deletions(-) diff --git a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs index 1a2d245..6f7fc05 100644 --- a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs +++ b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs @@ -13,6 +13,14 @@ namespace PatternKit.Generators.Adapter; [Generator] public sealed class AdapterGenerator : IIncrementalGenerator { + // Symbol display format for generated code (fully qualified with global::, but use keywords for special types) + private static readonly SymbolDisplayFormat FullyQualifiedFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | + SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + // Diagnostic IDs private const string DiagIdHostNotStaticPartial = "PKADP001"; private const string DiagIdTargetNotInterfaceOrAbstract = "PKADP002"; @@ -26,6 +34,7 @@ public sealed class AdapterGenerator : IIncrementalGenerator private const string DiagIdGenericMethodsNotSupported = "PKADP010"; private const string DiagIdOverloadedMethodsNotSupported = "PKADP011"; private const string DiagIdAbstractClassNoParameterlessCtor = "PKADP012"; + private const string DiagIdSettablePropertiesNotSupported = "PKADP013"; private static readonly DiagnosticDescriptor HostNotStaticPartialDescriptor = new( id: DiagIdHostNotStaticPartial, @@ -123,6 +132,14 @@ public sealed class AdapterGenerator : IIncrementalGenerator defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor SettablePropertiesNotSupportedDescriptor = new( + id: DiagIdSettablePropertiesNotSupported, + title: "Settable properties are not supported", + messageFormat: "Target type '{0}' contains settable property '{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 @@ -197,7 +214,9 @@ private void GenerateAdapterForAttribute( var hasAccessibleParameterlessCtor = config.TargetType.InstanceConstructors .Any(c => c.Parameters.Length == 0 && (c.DeclaredAccessibility == Accessibility.Public || - c.DeclaredAccessibility == Accessibility.Protected)); + c.DeclaredAccessibility == Accessibility.Protected || + c.DeclaredAccessibility == Accessibility.ProtectedOrInternal || + c.DeclaredAccessibility == Accessibility.Internal)); if (!hasAccessibleParameterlessCtor) { @@ -363,7 +382,10 @@ private List ValidateTargetMembers(INamedTypeSymbol targetType, Loca { var diagnostics = new List(); var isAbstractClass = targetType.TypeKind == TypeKind.Class && targetType.IsAbstract; - var methodNames = new Dictionary(); + + // Track method signatures to detect true overloads vs diamond inheritance + // Key: method name, Value: set of full signatures for that name + var methodSignatures = new Dictionary>(); // Collect all members from the type hierarchy var typesToProcess = new Queue(); @@ -392,6 +414,16 @@ private List ValidateTargetMembers(INamedTypeSymbol targetType, Loca evt.Name)); } + // Check for settable properties (not supported) + if (member is IPropertySymbol prop && !prop.IsIndexer && prop.SetMethod is not null) + { + diagnostics.Add(Diagnostic.Create( + SettablePropertiesNotSupportedDescriptor, + location, + targetType.Name, + prop.Name)); + } + // Check for generic methods (not supported) if (member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary) { @@ -404,10 +436,14 @@ private List ValidateTargetMembers(INamedTypeSymbol targetType, Loca method.Name)); } - // Track method names for overload detection - if (!methodNames.TryGetValue(method.Name, out var count)) - count = 0; - methodNames[method.Name] = count + 1; + // Track full method signature for overload detection + var sig = GetMemberSignature(method); + if (!methodSignatures.TryGetValue(method.Name, out var sigs)) + { + sigs = new HashSet(); + methodSignatures[method.Name] = sigs; + } + sigs.Add(sig); } } @@ -420,10 +456,11 @@ private List ValidateTargetMembers(INamedTypeSymbol targetType, Loca typesToProcess.Enqueue(type.BaseType); } - // Check for overloaded methods - foreach (var kvp in methodNames) + // Check for true overloaded methods (same name, different signatures) + // Diamond inheritance (same signature from multiple paths) is OK + foreach (var kvp in methodSignatures) { - if (kvp.Value > 1) + if (kvp.Value.Count > 1) { diagnostics.Add(Diagnostic.Create( OverloadedMethodsNotSupportedDescriptor, @@ -647,9 +684,9 @@ private static string GenerateAdapterCode( // Class declaration var sealedModifier = isSealed ? "sealed " : ""; - var targetTypeName = targetType.ToDisplayString(); - var adapteeTypeName = adapteeType.ToDisplayString(); - var hostTypeName = hostSymbol.ToDisplayString(); + var targetTypeName = targetType.ToDisplayString(FullyQualifiedFormat); + var adapteeTypeName = adapteeType.ToDisplayString(FullyQualifiedFormat); + var hostTypeName = hostSymbol.ToDisplayString(FullyQualifiedFormat); sb.AppendLine("/// "); sb.AppendLine($"/// Adapter that implements by delegating to ."); @@ -712,10 +749,10 @@ private static void GenerateMappedMember(StringBuilder sb, ISymbol member, IMeth if (member is IMethodSymbol targetMethod) { // Generate method - var returnType = targetMethod.ReturnType.ToDisplayString(); + var returnType = targetMethod.ReturnType.ToDisplayString(FullyQualifiedFormat); var methodName = targetMethod.Name; var parameters = string.Join(", ", targetMethod.Parameters.Select(p => - $"{GetParameterModifiers(p)}{p.Type.ToDisplayString()} {p.Name}{GetDefaultValue(p)}")); + $"{GetParameterModifiers(p)}{p.Type.ToDisplayString(FullyQualifiedFormat)} {p.Name}{GetDefaultValue(p)}")); var parameterNames = string.Join(", ", targetMethod.Parameters.Select(p => $"{GetArgumentModifier(p)}{p.Name}")); @@ -739,20 +776,17 @@ private static void GenerateMappedMember(StringBuilder sb, ISymbol member, IMeth else if (member is IPropertySymbol targetProp) { // Generate property - var propType = targetProp.Type.ToDisplayString(); + var propType = targetProp.Type.ToDisplayString(FullyQualifiedFormat); var propName = targetProp.Name; sb.AppendLine($" /// "); sb.AppendLine($" public {overrideKeyword}{propType} {propName}"); sb.AppendLine(" {"); + // Only read-only properties are supported (setters are caught by PKADP013) if (targetProp.GetMethod is not null) { sb.AppendLine($" get => {hostTypeName}.{mapMethod.Name}(_adaptee);"); } - if (targetProp.SetMethod is not null) - { - sb.AppendLine($" set => throw new global::System.NotSupportedException(\"Property setter mapping is not supported for '{propName}'.\");"); - } sb.AppendLine(" }"); sb.AppendLine(); } @@ -764,10 +798,10 @@ private static void GenerateThrowingStub(StringBuilder sb, ISymbol member, bool if (member is IMethodSymbol targetMethod) { - var returnType = targetMethod.ReturnType.ToDisplayString(); + var returnType = targetMethod.ReturnType.ToDisplayString(FullyQualifiedFormat); var methodName = targetMethod.Name; var parameters = string.Join(", ", targetMethod.Parameters.Select(p => - $"{GetParameterModifiers(p)}{p.Type.ToDisplayString()} {p.Name}{GetDefaultValue(p)}")); + $"{GetParameterModifiers(p)}{p.Type.ToDisplayString(FullyQualifiedFormat)} {p.Name}{GetDefaultValue(p)}")); sb.AppendLine($" /// "); sb.AppendLine($" /// This member is not mapped and will throw ."); @@ -779,21 +813,18 @@ private static void GenerateThrowingStub(StringBuilder sb, ISymbol member, bool } else if (member is IPropertySymbol targetProp) { - var propType = targetProp.Type.ToDisplayString(); + var propType = targetProp.Type.ToDisplayString(FullyQualifiedFormat); var propName = targetProp.Name; sb.AppendLine($" /// "); sb.AppendLine($" /// This property is not mapped and will throw ."); sb.AppendLine($" public {overrideKeyword}{propType} {propName}"); sb.AppendLine(" {"); + // Only read-only properties are supported (setters are caught by PKADP013) if (targetProp.GetMethod is not null) { sb.AppendLine($" get => throw new global::System.NotImplementedException(\"No [AdapterMap] provided for '{propName}'.\");"); } - if (targetProp.SetMethod is not null) - { - sb.AppendLine($" set => throw new global::System.NotImplementedException(\"No [AdapterMap] provided for '{propName}'.\");"); - } sb.AppendLine(" }"); sb.AppendLine(); } diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 355ca57..558eed1 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -110,3 +110,4 @@ PKADP009 | PatternKit.Generators.Adapter | Error | Events are not supported PKADP010 | PatternKit.Generators.Adapter | Error | Generic methods are not supported 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 diff --git a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs index d134772..31a45ae 100644 --- a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs @@ -48,9 +48,9 @@ public static partial class ClockAdapters .First(gs => gs.HintName == "TestNamespace.LegacyClockToIClockAdapter.Adapter.g.cs") .SourceText.ToString(); - Assert.Contains("public sealed partial class LegacyClockToIClockAdapter : TestNamespace.IClock", generatedSource); - Assert.Contains("private readonly TestNamespace.LegacyClock _adaptee;", generatedSource); - Assert.Contains("public LegacyClockToIClockAdapter(TestNamespace.LegacyClock adaptee)", generatedSource); + Assert.Contains("public sealed partial class LegacyClockToIClockAdapter : global::TestNamespace.IClock", generatedSource); + Assert.Contains("private readonly global::TestNamespace.LegacyClock _adaptee;", generatedSource); + Assert.Contains("public LegacyClockToIClockAdapter(global::TestNamespace.LegacyClock adaptee)", generatedSource); // Compilation succeeds var emit = updated.Emit(Stream.Null); @@ -105,7 +105,7 @@ public static ValueTask MapDelayAsync(LegacyClock adaptee, TimeSpan duration, Ca .First(gs => gs.HintName.Contains("LegacyClockToIClockAdapter")) .SourceText.ToString(); - Assert.Contains("public System.Threading.Tasks.ValueTask DelayAsync", generatedSource); + Assert.Contains("public global::System.Threading.Tasks.ValueTask DelayAsync", generatedSource); // Compilation succeeds var emit = updated.Emit(Stream.Null); @@ -396,7 +396,7 @@ public static partial class Adapters .First(gs => gs.HintName.Contains("Adapter")) .SourceText.ToString(); - Assert.Contains(": TestNamespace.ClockBase", generatedSource); + Assert.Contains(": global::TestNamespace.ClockBase", generatedSource); var emit = updated.Emit(Stream.Null); Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); @@ -678,14 +678,16 @@ public static partial class Adapters [Fact] public void InterfaceDiamondDeduplication() { - // IChild inherits from both IBase1 and IBase2 which both have DoWork() + // True diamond: IBase1 and IBase2 both declare DoWork(), IChild inherits from both + // This tests that we de-duplicate by signature and don't emit DoWork twice const string source = """ using PatternKit.Generators.Adapter; namespace TestNamespace; - public interface IBase { void DoWork(); } - public interface IChild : IBase { void DoExtra(); } + public interface IBase1 { void DoWork(); } + public interface IBase2 { void DoWork(); } + public interface IChild : IBase1, IBase2 { void DoExtra(); } public class Legacy { @@ -955,4 +957,69 @@ public static partial class Adapters var emit = updated.Emit(Stream.Null); Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); } + + [Fact] + public void ErrorWhenTargetHasSettableProperty() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface ISettings + { + string Name { get; set; } + } + + public class Legacy { } + + [GenerateAdapter(Target = typeof(ISettings), Adaptee = typeof(Legacy))] + public static partial class Adapters { } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenTargetHasSettableProperty)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP013 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP013"); + } + + [Fact] + public void ReadOnlyPropertyIsSupported() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public class LegacyClock + { + public System.DateTimeOffset GetNow() => System.DateTimeOffset.UtcNow; + } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow(LegacyClock adaptee) => adaptee.GetNow(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ReadOnlyPropertyIsSupported)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics (read-only properties are fine) + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } } From 27cfb8220287091054cc6ed81d50ba0f19517f23 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:00:05 -0600 Subject: [PATCH 06/12] 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> --- .../Adapter/AdapterAttributes.cs | 2 +- .../Adapter/AdapterGenerator.cs | 47 +++++++++---------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/PatternKit.Generators.Abstractions/Adapter/AdapterAttributes.cs b/src/PatternKit.Generators.Abstractions/Adapter/AdapterAttributes.cs index f75afb7..72d6da4 100644 --- a/src/PatternKit.Generators.Abstractions/Adapter/AdapterAttributes.cs +++ b/src/PatternKit.Generators.Abstractions/Adapter/AdapterAttributes.cs @@ -76,7 +76,7 @@ public sealed class GenerateAdapterAttribute : Attribute /// /// First parameter must be the adaptee type /// Remaining parameters must match the target member's parameters -/// Return type must be compatible with the target member's return type +/// Return type must match the target member's return type exactly /// /// /// diff --git a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs index 6f7fc05..97bd340 100644 --- a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs +++ b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs @@ -28,7 +28,7 @@ public sealed class AdapterGenerator : IIncrementalGenerator private const string DiagIdDuplicateMapping = "PKADP004"; private const string DiagIdSignatureMismatch = "PKADP005"; private const string DiagIdTypeNameConflict = "PKADP006"; - private const string DiagIdInvalidAdapteType = "PKADP007"; + private const string DiagIdInvalidAdapteeType = "PKADP007"; private const string DiagIdMapMethodNotStatic = "PKADP008"; private const string DiagIdEventsNotSupported = "PKADP009"; private const string DiagIdGenericMethodsNotSupported = "PKADP010"; @@ -85,7 +85,7 @@ public sealed class AdapterGenerator : IIncrementalGenerator isEnabledByDefault: true); private static readonly DiagnosticDescriptor InvalidAdapteeTypeDescriptor = new( - id: DiagIdInvalidAdapteType, + id: DiagIdInvalidAdapteeType, title: "Invalid adaptee type", messageFormat: "Adaptee type '{0}' must be a concrete class or struct", category: "PatternKit.Generators.Adapter", @@ -398,12 +398,11 @@ private List ValidateTargetMembers(INamedTypeSymbol targetType, Loca if (!processed.Add(type)) continue; - foreach (var member in type.GetMembers()) - { - // For abstract classes, only check abstract members - if (isAbstractClass && !member.IsAbstract) - continue; + var membersToCheck = type.GetMembers() + .Where(m => !isAbstractClass || m.IsAbstract); + foreach (var member in membersToCheck) + { // Check for events (not supported) if (member is IEventSymbol evt) { @@ -458,16 +457,13 @@ private List ValidateTargetMembers(INamedTypeSymbol targetType, Loca // Check for true overloaded methods (same name, different signatures) // Diamond inheritance (same signature from multiple paths) is OK - foreach (var kvp in methodSignatures) + foreach (var kvp in methodSignatures.Where(kvp => kvp.Value.Count > 1)) { - if (kvp.Value.Count > 1) - { - diagnostics.Add(Diagnostic.Create( - OverloadedMethodsNotSupportedDescriptor, - location, - targetType.Name, - kvp.Key)); - } + diagnostics.Add(Diagnostic.Create( + OverloadedMethodsNotSupportedDescriptor, + location, + targetType.Name, + kvp.Key)); } return diagnostics; @@ -560,12 +556,11 @@ private static List GetTargetMembers(INamedTypeSymbol targetType) if (!processed.Add(type)) continue; - foreach (var member in type.GetMembers()) - { - // For abstract classes, only include abstract members (must be overridden) - if (isAbstractClass && !member.IsAbstract) - continue; + var membersToProcess = type.GetMembers() + .Where(m => !isAbstractClass || m.IsAbstract); + foreach (var member in membersToProcess) + { // Include methods (not constructors), properties (not events - not supported) if (member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary) { @@ -577,7 +572,7 @@ private static List GetTargetMembers(INamedTypeSymbol targetType) else if (member is IPropertySymbol prop && !prop.IsIndexer) { // De-duplicate by name+type for properties - var sig = $"P:{prop.Name}:{prop.Type.ToDisplayString()}"; + var sig = $"P:{prop.Name}:{prop.Type.ToDisplayString(FullyQualifiedFormat)}"; if (seenSignatures.Add(sig)) members.Add(member); } @@ -597,15 +592,15 @@ private static List GetTargetMembers(INamedTypeSymbol targetType) } } - // Sort by name for deterministic output - return members.OrderBy(m => m.Name).ThenBy(m => m.ToDisplayString()).ToList(); + // Return in declaration order (members already added in traversal order) + return members; } private static string GetMemberSignature(IMethodSymbol method) { var paramSig = string.Join(",", method.Parameters.Select(p => - $"{p.RefKind}:{p.Type.ToDisplayString()}")); - return $"M:{method.Name}({paramSig}):{method.ReturnType.ToDisplayString()}"; + $"{p.RefKind}:{p.Type.ToDisplayString(FullyQualifiedFormat)}")); + return $"M:{method.Name}({paramSig}):{method.ReturnType.ToDisplayString(FullyQualifiedFormat)}"; } private static string? ValidateSignature(ISymbol targetMember, IMethodSymbol mapMethod, INamedTypeSymbol adapteeType) From 55646f907eeffb1dd11d1d0d6bba6dfc972126ab Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:30:12 -0600 Subject: [PATCH 07/12] 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> --- docs/generators/adapter.md | 41 ++++++++ .../Adapter/AdapterGenerator.cs | 99 ++++++++++++++++++- .../AnalyzerReleases.Unshipped.md | 4 + .../AdapterGeneratorTests.cs | 4 +- 4 files changed, 142 insertions(+), 6 deletions(-) diff --git a/docs/generators/adapter.md b/docs/generators/adapter.md index 9b68ebe..7b5980a 100644 --- a/docs/generators/adapter.md +++ b/docs/generators/adapter.md @@ -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 diff --git a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs index 97bd340..6cf28b1 100644 --- a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs +++ b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs @@ -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; @@ -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, @@ -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 @@ -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) @@ -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) @@ -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 @@ -403,6 +459,16 @@ private List 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) { @@ -423,6 +489,16 @@ private List 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) { @@ -435,6 +511,16 @@ private List 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)) @@ -557,6 +643,7 @@ private static List 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) @@ -592,8 +679,12 @@ private static List 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) diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 558eed1..947541b 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -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 diff --git a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs index 31a45ae..c8b0aa9 100644 --- a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs @@ -184,7 +184,7 @@ public class NotStaticPartial } [Fact] - public void ErrorWhenTargetNotInterface() + public void ErrorWhenTargetNotInterfaceOrAbstract() { const string source = """ using PatternKit.Generators.Adapter; @@ -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 _); From 4a4b68a7cdf3107516122aa30c6ebf5660242a08 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:51:22 -0600 Subject: [PATCH 08/12] 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> --- .../Adapter/AdapterGenerator.cs | 46 ++++++++++++++++--- .../AdapterGeneratorDemoTests.cs | 6 +-- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs index 6cf28b1..de3668b 100644 --- a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs +++ b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs @@ -233,6 +233,26 @@ private void GenerateAdapterForAttribute( if (config.TargetType is null || config.AdapteeType is null) return; // Attribute error, let compiler handle + // Reject unbound/open generic target types (e.g., typeof(IFoo<>)) + if (config.TargetType is INamedTypeSymbol targetNamed && targetNamed.IsUnboundGenericType) + { + context.ReportDiagnostic(Diagnostic.Create( + TargetNotInterfaceOrAbstractDescriptor, + node.GetLocation(), + config.TargetType.ToDisplayString())); + return; + } + + // Reject unbound/open generic adaptee types (e.g., typeof(IFoo<>)) + if (config.AdapteeType is INamedTypeSymbol adapteeNamed && adapteeNamed.IsUnboundGenericType) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidAdapteeTypeDescriptor, + node.GetLocation(), + config.AdapteeType.ToDisplayString())); + return; + } + // Validate target is interface or abstract class if (!IsValidTargetType(config.TargetType)) { @@ -573,8 +593,11 @@ private static AdapterConfig ParseAdapterConfig(AttributeData attribute) config.AdapterTypeName = named.Value.Value as string; break; case "MissingMap": - if (named.Value.Value is int missingMapValue) + if (named.Value.Value is int missingMapValue && + global::System.Enum.IsDefined(typeof(AdapterMissingMapPolicyValue), missingMapValue)) + { config.MissingMapPolicy = (AdapterMissingMapPolicyValue)missingMapValue; + } break; case "Sealed": if (named.Value.Value is bool sealedValue) @@ -704,10 +727,21 @@ private static string GetMemberSignature(IMethodSymbol method) if (!SymbolEqualityComparer.Default.Equals(firstParam.Type, adapteeType)) return $"First parameter must be of type '{adapteeType.ToDisplayString()}', but was '{firstParam.Type.ToDisplayString()}'."; + // Adaptee parameter must be passed by value (no ref/in/out) and cannot be a 'this' or 'scoped' parameter, + // because the generated call site always passes `_adaptee` without any modifier. + if (firstParam.RefKind != RefKind.None) + return "Adaptee parameter must not have a ref, in, or out modifier."; + + if (firstParam.IsThis) + return "Adaptee parameter cannot be declared with the 'this' modifier."; + + if (firstParam.ScopedKind != ScopedKind.None) + return "Adaptee parameter cannot be declared with the 'scoped' modifier."; + if (targetMember is IMethodSymbol targetMethod) { - // Check return type - if (!SymbolEqualityComparer.Default.Equals(mapMethod.ReturnType, targetMethod.ReturnType)) + // Check return type with nullability + if (!SymbolEqualityComparer.IncludeNullability.Equals(mapMethod.ReturnType, targetMethod.ReturnType)) return $"Return type must be '{targetMethod.ReturnType.ToDisplayString()}', but was '{mapMethod.ReturnType.ToDisplayString()}'."; // Check remaining parameters (after adaptee) @@ -722,7 +756,7 @@ private static string GetMemberSignature(IMethodSymbol method) var mapParam = mapParams[i]; var targetParam = targetParams[i]; - if (!SymbolEqualityComparer.Default.Equals(mapParam.Type, targetParam.Type)) + if (!SymbolEqualityComparer.IncludeNullability.Equals(mapParam.Type, targetParam.Type)) return $"Parameter '{targetParam.Name}' type mismatch: expected '{targetParam.Type.ToDisplayString()}', but was '{mapParam.Type.ToDisplayString()}'."; if (mapParam.RefKind != targetParam.RefKind) @@ -735,8 +769,8 @@ private static string GetMemberSignature(IMethodSymbol method) if (mapMethod.Parameters.Length != 1) return $"Property getter mapping must have exactly one parameter (the adaptee)."; - // Check return type - if (!SymbolEqualityComparer.Default.Equals(mapMethod.ReturnType, targetProp.Type)) + // Check return type with nullability + if (!SymbolEqualityComparer.IncludeNullability.Equals(mapMethod.ReturnType, targetProp.Type)) return $"Return type must be '{targetProp.Type.ToDisplayString()}', but was '{mapMethod.ReturnType.ToDisplayString()}'."; } diff --git a/test/PatternKit.Examples.Tests/AdapterGeneratorDemo/AdapterGeneratorDemoTests.cs b/test/PatternKit.Examples.Tests/AdapterGeneratorDemo/AdapterGeneratorDemoTests.cs index 99e7e75..d517d01 100644 --- a/test/PatternKit.Examples.Tests/AdapterGeneratorDemo/AdapterGeneratorDemoTests.cs +++ b/test/PatternKit.Examples.Tests/AdapterGeneratorDemo/AdapterGeneratorDemoTests.cs @@ -78,11 +78,11 @@ public async Task ClockAdapter_DelayAsync_Delays() // Act var sw = System.Diagnostics.Stopwatch.StartNew(); - await clock.DelayAsync(TimeSpan.FromMilliseconds(50)); + await clock.DelayAsync(TimeSpan.FromMilliseconds(100)); sw.Stop(); - // Assert - should have delayed at least 40ms (some tolerance for timing) - Assert.True(sw.ElapsedMilliseconds >= 40); + // Assert - should have delayed at least 50ms (allowing for scheduler jitter on loaded CI) + Assert.True(sw.ElapsedMilliseconds >= 50); } [Fact] From 2d80388fb782812745d4f8a77372bd29a10d6220 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:30:39 -0600 Subject: [PATCH 09/12] 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> --- .../Adapter/AdapterGenerator.cs | 4 +- .../AdapterGeneratorTests.cs | 180 ++++++++++++++++++ 2 files changed, 182 insertions(+), 2 deletions(-) diff --git a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs index de3668b..e2565a3 100644 --- a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs +++ b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs @@ -234,7 +234,7 @@ private void GenerateAdapterForAttribute( return; // Attribute error, let compiler handle // Reject unbound/open generic target types (e.g., typeof(IFoo<>)) - if (config.TargetType is INamedTypeSymbol targetNamed && targetNamed.IsUnboundGenericType) + if (config.TargetType.IsUnboundGenericType) { context.ReportDiagnostic(Diagnostic.Create( TargetNotInterfaceOrAbstractDescriptor, @@ -244,7 +244,7 @@ private void GenerateAdapterForAttribute( } // Reject unbound/open generic adaptee types (e.g., typeof(IFoo<>)) - if (config.AdapteeType is INamedTypeSymbol adapteeNamed && adapteeNamed.IsUnboundGenericType) + if (config.AdapteeType.IsUnboundGenericType) { context.ReportDiagnostic(Diagnostic.Create( InvalidAdapteeTypeDescriptor, diff --git a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs index c8b0aa9..80ca415 100644 --- a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs @@ -1022,4 +1022,184 @@ public static partial class Adapters var emit = updated.Emit(Stream.Null); Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); } + + [Fact] + public void ErrorWhenHostIsNested() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public class LegacyClock { } + + public class OuterClass + { + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class Adapters { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenHostIsNested)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP014 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP014"); + } + + [Fact] + public void ErrorWhenHostIsGeneric() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public class LegacyClock { } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class Adapters { } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenHostIsGeneric)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP014 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP014"); + } + + [Fact] + public void ErrorWhenMappingMethodNotAccessible() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public class LegacyClock { } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + private static System.DateTimeOffset MapNow(LegacyClock adaptee) => default; // Private instead of public/internal + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenMappingMethodNotAccessible)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP015 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP015"); + } + + [Fact] + public void ErrorWhenTargetHasStaticMembers() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + static abstract void StaticMethod(); // Static abstract member (C# 11+) + } + + public class LegacyClock { } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class Adapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow(LegacyClock adaptee) => default; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenTargetHasStaticMembers)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP016 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP016"); + } + + [Fact] + public void ErrorWhenTargetHasRefReturnProperty() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + ref int RefProperty { get; } // Ref-return property + } + + public class LegacyClock { } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class Adapters { } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenTargetHasRefReturnProperty)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP017 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP017"); + } + + [Fact] + public void ErrorWhenTargetHasRefReturnMethod() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + ref int GetRefValue(); // Ref-return method + } + + public class LegacyClock { } + + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))] + public static partial class Adapters { } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenTargetHasRefReturnMethod)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP017 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP017"); + } } From 1819ae01f1ffa3cb05769e92307bc376e0131b6f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:50:16 -0600 Subject: [PATCH 10/12] 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> --- docs/generators/adapter.md | 1 + .../Adapter/AdapterGenerator.cs | 71 ++++++++++++++++--- .../AnalyzerReleases.Unshipped.md | 1 + 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/docs/generators/adapter.md b/docs/generators/adapter.md index 7b5980a..1ac242d 100644 --- a/docs/generators/adapter.md +++ b/docs/generators/adapter.md @@ -248,6 +248,7 @@ public static partial class Adapters | **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 | +| **PKADP018** | Error | Indexers are not supported | ## Limitations diff --git a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs index e2565a3..85d8f5f 100644 --- a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs +++ b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs @@ -38,6 +38,7 @@ public sealed class AdapterGenerator : IIncrementalGenerator private const string DiagIdMappingMethodNotAccessible = "PKADP015"; private const string DiagIdStaticMembersNotSupported = "PKADP016"; private const string DiagIdRefReturnNotSupported = "PKADP017"; + private const string DiagIdIndexersNotSupported = "PKADP018"; private static readonly DiagnosticDescriptor HostNotStaticPartialDescriptor = new( id: DiagIdHostNotStaticPartial, @@ -175,6 +176,14 @@ public sealed class AdapterGenerator : IIncrementalGenerator defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor IndexersNotSupportedDescriptor = new( + id: DiagIdIndexersNotSupported, + title: "Indexers are not supported", + messageFormat: "Target type '{0}' contains indexer '{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 @@ -192,11 +201,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var node = typeContext.TargetNode; + // Track generated adapter type names to detect conflicts (namespace -> type name -> location) + var generatedAdapters = new Dictionary>(); + // 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); + GenerateAdapterForAttribute(spc, hostSymbol, attr, node, typeContext.SemanticModel, generatedAdapters); } }); } @@ -206,7 +218,8 @@ private void GenerateAdapterForAttribute( INamedTypeSymbol hostSymbol, AttributeData attribute, SyntaxNode node, - SemanticModel semanticModel) + SemanticModel semanticModel, + Dictionary> generatedAdapters) { // Validate host is static partial if (!IsStaticPartial(node)) @@ -277,11 +290,23 @@ private void GenerateAdapterForAttribute( if (config.TargetType.TypeKind == TypeKind.Class && config.TargetType.IsAbstract) { var hasAccessibleParameterlessCtor = config.TargetType.InstanceConstructors - .Any(c => c.Parameters.Length == 0 && - (c.DeclaredAccessibility == Accessibility.Public || - c.DeclaredAccessibility == Accessibility.Protected || - c.DeclaredAccessibility == Accessibility.ProtectedOrInternal || - c.DeclaredAccessibility == Accessibility.Internal)); + .Any(c => + { + if (c.Parameters.Length > 0) + return false; + + var accessibility = c.DeclaredAccessibility; + if (accessibility == Accessibility.Public || + accessibility == Accessibility.Protected || + accessibility == Accessibility.ProtectedOrInternal) + return true; + + // Internal constructors are only accessible if in the same assembly + if (accessibility == Accessibility.Internal) + return semanticModel.Compilation.IsSymbolAccessibleWithin(c, semanticModel.Compilation.Assembly); + + return false; + }); if (!hasAccessibleParameterlessCtor) { @@ -396,7 +421,7 @@ private void GenerateAdapterForAttribute( ? string.Empty : hostSymbol.ContainingNamespace.ToDisplayString()); - // Check for type name conflict (PKADP006) + // Check for type name conflict (PKADP006) - both in existing compilation and in current generator run if (HasTypeNameConflict(semanticModel.Compilation, ns, adapterTypeName)) { context.ReportDiagnostic(Diagnostic.Create( @@ -407,6 +432,24 @@ private void GenerateAdapterForAttribute( return; } + // Check for conflict with adapters being generated in this run + var normalizedNs = string.IsNullOrEmpty(ns) ? "" : ns; + if (!generatedAdapters.ContainsKey(normalizedNs)) + generatedAdapters[normalizedNs] = new Dictionary(); + + if (generatedAdapters[normalizedNs].ContainsKey(adapterTypeName)) + { + context.ReportDiagnostic(Diagnostic.Create( + TypeNameConflictDescriptor, + node.GetLocation(), + adapterTypeName, + string.IsNullOrEmpty(ns) ? "" : ns)); + return; + } + + // Track this adapter type name + generatedAdapters[normalizedNs][adapterTypeName] = node.GetLocation(); + // Generate adapter var source = GenerateAdapterCode( adapterTypeName, @@ -499,6 +542,16 @@ private List ValidateTargetMembers(INamedTypeSymbol targetType, Loca evt.Name)); } + // Check for indexers (not supported) - must be checked before other property checks + if (member is IPropertySymbol propertySymbol && propertySymbol.IsIndexer) + { + diagnostics.Add(Diagnostic.Create( + IndexersNotSupportedDescriptor, + location, + targetType.Name, + propertySymbol.ToDisplayString())); + } + // Check for settable properties (not supported) if (member is IPropertySymbol prop && !prop.IsIndexer && prop.SetMethod is not null) { @@ -510,7 +563,7 @@ private List ValidateTargetMembers(INamedTypeSymbol targetType, Loca } // Check for ref-return properties (not supported) - if (member is IPropertySymbol refProp && refProp.ReturnsByRef) + if (member is IPropertySymbol refProp && !refProp.IsIndexer && refProp.ReturnsByRef) { diagnostics.Add(Diagnostic.Create( RefReturnNotSupportedDescriptor, diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 947541b..ce3dcad 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -115,3 +115,4 @@ PKADP014 | PatternKit.Generators.Adapter | Error | Nested or generic host not su 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 +PKADP018 | PatternKit.Generators.Adapter | Error | Indexers are not supported From 21f3d1ce2f52078c0b45aa31a0e135b782c07578 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:27:30 -0600 Subject: [PATCH 11/12] 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> --- docs/generators/adapter.md | 2 +- .../Adapter/AdapterGenerator.cs | 62 ++++++++++----- .../AdapterGeneratorTests.cs | 75 +++++++++++++++++++ 3 files changed, 119 insertions(+), 20 deletions(-) diff --git a/docs/generators/adapter.md b/docs/generators/adapter.md index 1ac242d..33b2efc 100644 --- a/docs/generators/adapter.md +++ b/docs/generators/adapter.md @@ -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: diff --git a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs index 85d8f5f..4623089 100644 --- a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs +++ b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs @@ -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>(); - // 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); + } } }); } @@ -497,7 +503,7 @@ private static bool HasTypeNameConflict(Compilation compilation, string ns, stri return compilation.GetTypeByMetadataName(fullName) is not null; } - private List ValidateTargetMembers(INamedTypeSymbol targetType, Location location) + private List ValidateTargetMembers(INamedTypeSymbol targetType, Location fallbackLocation) { var diagnostics = new List(); var isAbstractClass = targetType.TypeKind == TypeKind.Class && targetType.IsAbstract; @@ -522,6 +528,9 @@ private List 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) { @@ -620,7 +629,7 @@ private List ValidateTargetMembers(INamedTypeSymbol targetType, Loca { diagnostics.Add(Diagnostic.Create( OverloadedMethodsNotSupportedDescriptor, - location, + fallbackLocation, targetType.Name, kvp.Key)); } @@ -755,12 +764,27 @@ private static List 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) diff --git a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs index 80ca415..08a50cf 100644 --- a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs @@ -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"); + } } From bb51007c749889f648cafd93571f977c0f37729e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:08:26 -0600 Subject: [PATCH 12/12] 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> --- .../Adapter/AdapterGenerator.cs | 62 +++++++++++++++---- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs index 4623089..ebab8f9 100644 --- a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs +++ b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs @@ -210,8 +210,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) 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")) + var generateAdapterAttributes = typeContext.Attributes + .Where(a => a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Adapter.GenerateAdapterAttribute"); + + foreach (var attr in generateAdapterAttributes) { GenerateAdapterForAttribute(spc, hostSymbol, attr, node, typeContext.SemanticModel, generatedAdapters); } @@ -307,8 +309,9 @@ private void GenerateAdapterForAttribute( accessibility == Accessibility.ProtectedOrInternal) return true; - // Internal constructors are only accessible if in the same assembly - if (accessibility == Accessibility.Internal) + // Internal and private protected constructors are only accessible within the same assembly + if (accessibility == Accessibility.Internal || + accessibility == Accessibility.ProtectedAndInternal) return semanticModel.Compilation.IsSymbolAccessibleWithin(c, semanticModel.Compilation.Assembly); return false; @@ -500,7 +503,18 @@ private static bool IsValidAdapteeType(INamedTypeSymbol type) private static bool HasTypeNameConflict(Compilation compilation, string ns, string typeName) { var fullName = string.IsNullOrEmpty(ns) ? typeName : $"{ns}.{typeName}"; - return compilation.GetTypeByMetadataName(fullName) is not null; + var existingType = compilation.GetTypeByMetadataName(fullName); + if (existingType is null) + return false; + + // If the type comes from metadata only, we can't add a partial declaration safely. + if (existingType.DeclaringSyntaxReferences.Length == 0) + return true; + + // Only treat this as a conflict if any declaration is non-partial. + return existingType.DeclaringSyntaxReferences.Any(syntaxRef => + syntaxRef.GetSyntax() is not TypeDeclarationSyntax typeDecl || + !typeDecl.Modifiers.Any(SyntaxKind.PartialKeyword)); } private List ValidateTargetMembers(INamedTypeSymbol targetType, Location fallbackLocation) @@ -729,7 +743,15 @@ private static List GetTargetMembers(INamedTypeSymbol targetType) var membersToProcess = type.GetMembers() .Where(m => !m.IsStatic) // Exclude static members - .Where(m => !isAbstractClass || m.IsAbstract); + .Where(m => + { + // For abstract classes and interfaces, only include abstract members + // (interfaces: exclude default implementations added in C# 8.0+) + if (isAbstractClass || type.TypeKind == TypeKind.Interface) + return m.IsAbstract; + + return true; + }); foreach (var member in membersToProcess) { @@ -768,18 +790,19 @@ private static List GetTargetMembers(INamedTypeSymbol targetType) // This provides both readable (contract-ordered) output and deterministic ordering return members.OrderBy(m => { - // Try to get syntax declaration order (file path + line number) + // Try to get syntax declaration order by 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); + // Use only line number for ordering, not file path (which varies across machines) + // Note: For types split across multiple partial files, this may not preserve + // perfect declaration order, but ThenBy clauses provide stable fallback ordering + return lineSpan.StartLinePosition.Line; } // 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); + return int.MaxValue; }) .ThenBy(m => m.Kind) .ThenBy(m => m.Name) @@ -1067,6 +1090,23 @@ private static string GetDefaultValue(IParameterSymbol param) return " = default"; } + // Handle enum parameters specially to emit proper enum syntax + if (param.Type.TypeKind == TypeKind.Enum && param.Type is INamedTypeSymbol enumType) + { + // Try to find the enum field matching this value + var enumField = enumType.GetMembers() + .OfType() + .FirstOrDefault(f => f.HasConstantValue && Equals(f.ConstantValue, value)); + + if (enumField != null) + { + return $" = {enumType.ToDisplayString(FullyQualifiedFormat)}.{enumField.Name}"; + } + + // Fallback: cast the numeric value + return $" = ({enumType.ToDisplayString(FullyQualifiedFormat)}){value}"; + } + var literal = SymbolDisplay.FormatPrimitive(value, quoteStrings: true, useHexadecimalNumbers: false); return " = " + literal; }