diff --git a/src/PatternKit.Examples/Decorators/StorageDecoratorExample.cs b/src/PatternKit.Examples/Decorators/StorageDecoratorExample.cs new file mode 100644 index 0000000..7403838 --- /dev/null +++ b/src/PatternKit.Examples/Decorators/StorageDecoratorExample.cs @@ -0,0 +1,231 @@ +using PatternKit.Generators.Decorator; + +namespace PatternKit.Examples.Decorators; + +/// +/// Example demonstrating the Decorator pattern with a storage interface. +/// Shows how to use generated decorator base classes to implement caching, +/// logging, and retry logic in a composable way. +/// +[GenerateDecorator] +public interface IFileStorage +{ + string ReadFile(string path); + void WriteFile(string path, string content); + bool FileExists(string path); + void DeleteFile(string path); +} + +/// +/// Simple in-memory file storage implementation. +/// +public class InMemoryFileStorage : IFileStorage +{ + private readonly Dictionary _files = new(); + + public string ReadFile(string path) + { + if (!_files.TryGetValue(path, out var content)) + throw new FileNotFoundException($"File not found: {path}"); + return content; + } + + public void WriteFile(string path, string content) + { + _files[path] = content; + } + + public bool FileExists(string path) + { + return _files.ContainsKey(path); + } + + public void DeleteFile(string path) + { + _files.Remove(path); + } +} + +/// +/// Caching decorator that caches file reads. +/// +public class CachingFileStorage : FileStorageDecoratorBase +{ + private readonly Dictionary _cache = new(); + + public CachingFileStorage(IFileStorage inner) : base(inner) { } + + public override string ReadFile(string path) + { + if (_cache.TryGetValue(path, out var cached)) + { + Console.WriteLine($"[Cache] Hit for {path}"); + return cached; + } + + Console.WriteLine($"[Cache] Miss for {path}"); + var content = base.ReadFile(path); + _cache[path] = content; + return content; + } + + public override void WriteFile(string path, string content) + { + _cache.Remove(path); // Invalidate cache + base.WriteFile(path, content); + } + + public override void DeleteFile(string path) + { + _cache.Remove(path); // Invalidate cache + base.DeleteFile(path); + } +} + +/// +/// Logging decorator that logs all operations. +/// +public class LoggingFileStorage : FileStorageDecoratorBase +{ + public LoggingFileStorage(IFileStorage inner) : base(inner) { } + + public override string ReadFile(string path) + { + Console.WriteLine($"[Log] Reading file: {path}"); + try + { + var result = base.ReadFile(path); + Console.WriteLine($"[Log] Successfully read {result.Length} characters from {path}"); + return result; + } + catch (Exception ex) + { + Console.WriteLine($"[Log] Error reading {path}: {ex.Message}"); + throw; + } + } + + public override void WriteFile(string path, string content) + { + Console.WriteLine($"[Log] Writing {content.Length} characters to {path}"); + base.WriteFile(path, content); + Console.WriteLine($"[Log] Successfully wrote to {path}"); + } + + public override bool FileExists(string path) + { + Console.WriteLine($"[Log] Checking existence of {path}"); + var exists = base.FileExists(path); + Console.WriteLine($"[Log] File {path} exists: {exists}"); + return exists; + } + + public override void DeleteFile(string path) + { + Console.WriteLine($"[Log] Deleting file: {path}"); + base.DeleteFile(path); + Console.WriteLine($"[Log] Successfully deleted {path}"); + } +} + +/// +/// Retry decorator that retries failed operations. +/// +public class RetryFileStorage : FileStorageDecoratorBase +{ + private readonly int _maxRetries; + private readonly int _retryDelayMs; + + public RetryFileStorage(IFileStorage inner, int maxRetries = 3, int retryDelayMs = 100) + : base(inner) + { + _maxRetries = maxRetries; + _retryDelayMs = retryDelayMs; + } + + public override string ReadFile(string path) + { + return RetryOperation(() => base.ReadFile(path), $"ReadFile({path})"); + } + + public override void WriteFile(string path, string content) + { + RetryOperation(() => { base.WriteFile(path, content); return true; }, $"WriteFile({path})"); + } + + public override void DeleteFile(string path) + { + RetryOperation(() => { base.DeleteFile(path); return true; }, $"DeleteFile({path})"); + } + + private T RetryOperation(Func operation, string operationName) + { + for (int i = 0; i < _maxRetries; i++) + { + try + { + return operation(); + } + catch (Exception ex) when (i < _maxRetries - 1) + { + Console.WriteLine($"[Retry] Attempt {i + 1}/{_maxRetries} failed for {operationName}: {ex.Message}"); + Thread.Sleep(_retryDelayMs); + } + } + // Last attempt - let exception propagate + return operation(); + } +} + +/// +/// Demonstrates using the decorator pattern with the generated base class. +/// +public static class StorageDecoratorDemo +{ + public static void Run() + { + Console.WriteLine("=== Decorator Pattern Demo ===\n"); + + // Base storage + var baseStorage = new InMemoryFileStorage(); + + // Compose decorators: Logging -> Caching -> Retry -> Base + // Order matters: outermost decorator is applied first + var storage = FileStorageDecorators.Compose( + baseStorage, + inner => new LoggingFileStorage(inner), + inner => new CachingFileStorage(inner), + inner => new RetryFileStorage(inner) + ); + + Console.WriteLine("--- Writing a file ---"); + storage.WriteFile("test.txt", "Hello, Decorators!"); + + Console.WriteLine("\n--- Reading the file (cache miss) ---"); + var content1 = storage.ReadFile("test.txt"); + Console.WriteLine($"Content: {content1}"); + + Console.WriteLine("\n--- Reading the file again (cache hit) ---"); + var content2 = storage.ReadFile("test.txt"); + Console.WriteLine($"Content: {content2}"); + + Console.WriteLine("\n--- Checking file existence ---"); + var exists = storage.FileExists("test.txt"); + Console.WriteLine($"Exists: {exists}"); + + Console.WriteLine("\n--- Deleting the file ---"); + storage.DeleteFile("test.txt"); + + Console.WriteLine("\n--- Trying to read deleted file (will fail with retries) ---"); + try + { + storage.ReadFile("test.txt"); + } + catch (FileNotFoundException ex) + { + Console.WriteLine($"Expected error: {ex.Message}"); + } + + Console.WriteLine("\n=== Demo Complete ==="); + } +} diff --git a/src/PatternKit.Generators.Abstractions/Decorator/DecoratorIgnoreAttribute.cs b/src/PatternKit.Generators.Abstractions/Decorator/DecoratorIgnoreAttribute.cs new file mode 100644 index 0000000..77225e7 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Decorator/DecoratorIgnoreAttribute.cs @@ -0,0 +1,13 @@ +namespace PatternKit.Generators.Decorator; + +/// +/// Marks a member that should not participate in decoration. +/// The generator will still emit a forwarding member in the decorator. For concrete +/// members it strips virtual/override so the member is not decorated, +/// but when required to satisfy an abstract or virtual contract it emits a +/// sealed override instead. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] +public sealed class DecoratorIgnoreAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators.Abstractions/Decorator/GenerateDecoratorAttribute.cs b/src/PatternKit.Generators.Abstractions/Decorator/GenerateDecoratorAttribute.cs new file mode 100644 index 0000000..7f53955 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Decorator/GenerateDecoratorAttribute.cs @@ -0,0 +1,65 @@ +namespace PatternKit.Generators.Decorator; + +/// +/// Marks an interface or abstract class for Decorator pattern code generation. +/// Generates a base decorator class that forwards all members to an inner instance, +/// with optional composition helpers for building decorator chains. +/// +[AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class GenerateDecoratorAttribute : Attribute +{ + /// + /// Name of the generated base decorator class. + /// Default is {ContractName}DecoratorBase (e.g., IStorage -> StorageDecoratorBase). + /// + public string? BaseTypeName { get; set; } + + /// + /// Name of the generated helpers/composition class. + /// Default is {ContractName}Decorators (e.g., IStorage -> StorageDecorators). + /// + public string? HelpersTypeName { get; set; } + + /// + /// Determines what composition helpers are generated. + /// Default is HelpersOnly (generates a Compose method for chaining decorators). + /// + public DecoratorCompositionMode Composition { get; set; } = DecoratorCompositionMode.HelpersOnly; + + /// + /// Reserved for future use. + /// Currently ignored by the Decorator generator and has no effect on generated code. + /// Default is false. + /// + public bool GenerateAsync { get; set; } + + /// + /// Reserved for future use. + /// Currently ignored by the Decorator generator and has no effect on generated code. + /// Default is false. + /// + public bool ForceAsync { get; set; } +} + +/// +/// Determines what composition utilities are generated with the decorator. +/// +public enum DecoratorCompositionMode +{ + /// + /// Do not generate any composition helpers. + /// + None = 0, + + /// + /// Generate a static Compose method for chaining decorators in order. + /// Decorators are applied in array order (first element is outermost). + /// + HelpersOnly = 1, + + /// + /// Generate pipeline-style composition with explicit "next" parameter. + /// (Reserved for future use - v2 feature) + /// + PipelineNextStyle = 2 +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index e530359..1f27f05 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -40,3 +40,9 @@ PKMEM006 | PatternKit.Generators.Memento | Info | Init-only or readonly restrict PKVIS001 | PatternKit.Generators.Visitor | Warning | No concrete types found for visitor generation PKVIS002 | PatternKit.Generators.Visitor | Error | Type must be partial for Accept method generation PKVIS004 | PatternKit.Generators.Visitor | Error | Derived type must be partial for Accept method generation +PKDEC001 | PatternKit.Generators.Decorator | Error | Unsupported target type for decorator generation +PKDEC002 | PatternKit.Generators.Decorator | Error | Unsupported member kind for decorator generation +PKDEC003 | PatternKit.Generators.Decorator | Error | Name conflict for generated decorator types +PKDEC004 | PatternKit.Generators.Decorator | Warning | Member is not accessible for decorator generation +PKDEC005 | PatternKit.Generators.Decorator | Error | Generic contracts are not supported for decorator generation +PKDEC006 | PatternKit.Generators.Decorator | Error | Nested types are not supported for decorator generation diff --git a/src/PatternKit.Generators/DecoratorGenerator.cs b/src/PatternKit.Generators/DecoratorGenerator.cs new file mode 100644 index 0000000..fc852a9 --- /dev/null +++ b/src/PatternKit.Generators/DecoratorGenerator.cs @@ -0,0 +1,993 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using PatternKit.Generators.Decorator; +using System.Text; + +namespace PatternKit.Generators; + +/// +/// Source generator for the Decorator pattern. +/// Generates base decorator classes that forward all members to an inner instance, +/// with optional composition helpers for building decorator chains. +/// +[Generator] +public sealed class DecoratorGenerator : IIncrementalGenerator +{ + // Symbol display format that preserves nullable annotations + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + // Diagnostic IDs + private const string DiagIdUnsupportedTargetType = "PKDEC001"; + private const string DiagIdNestedType = "PKDEC006"; + private const string DiagIdUnsupportedMember = "PKDEC002"; + private const string DiagIdNameConflict = "PKDEC003"; + private const string DiagIdInaccessibleMember = "PKDEC004"; + private const string DiagIdGenericContract = "PKDEC005"; + + private static readonly DiagnosticDescriptor UnsupportedTargetTypeDescriptor = new( + id: DiagIdUnsupportedTargetType, + title: "Unsupported target type for decorator generation", + messageFormat: "Type '{0}' cannot be used as a decorator contract. Only interfaces and abstract classes are supported.", + category: "PatternKit.Generators.Decorator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor UnsupportedMemberDescriptor = new( + id: DiagIdUnsupportedMember, + title: "Unsupported member kind for decorator generation", + messageFormat: "Member '{0}' of kind '{1}' is not supported in decorator generation (v1). Only methods and properties are supported.", + category: "PatternKit.Generators.Decorator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NameConflictDescriptor = new( + id: DiagIdNameConflict, + title: "Name conflict for generated decorator types", + messageFormat: "Generated type name '{0}' conflicts with an existing type. Use BaseTypeName or HelpersTypeName to specify a different name.", + category: "PatternKit.Generators.Decorator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor InaccessibleMemberDescriptor = new( + id: DiagIdInaccessibleMember, + title: "Member is not accessible for decorator generation", + messageFormat: "Member '{0}' cannot be forwarded by the generated decorator. Only members accessible from the decorator type (public, internal, or protected internal) can be forwarded; purely protected or private protected members on the inner type are not accessible.", + category: "PatternKit.Generators.Decorator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor GenericContractDescriptor = new( + id: DiagIdGenericContract, + title: "Generic contracts are not supported for decorator generation", + messageFormat: "Generic type '{0}' cannot be used as a decorator contract. Generic contracts are not supported in v1.", + category: "PatternKit.Generators.Decorator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NestedTypeDescriptor = new( + id: DiagIdNestedType, + title: "Nested types are not supported for decorator generation", + messageFormat: "Nested type '{0}' cannot be used as a decorator contract. Only top-level types are supported.", + category: "PatternKit.Generators.Decorator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Find all types (interfaces, abstract classes, or records) marked with [GenerateDecorator] + var decoratorContracts = context.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "PatternKit.Generators.Decorator.GenerateDecoratorAttribute", + predicate: static (node, _) => node is InterfaceDeclarationSyntax or ClassDeclarationSyntax or RecordDeclarationSyntax, + transform: static (ctx, _) => ctx + ); + + // Generate for each contract + context.RegisterSourceOutput(decoratorContracts, (spc, contractContext) => + { + if (contractContext.TargetSymbol is not INamedTypeSymbol contractSymbol) + return; + + var attr = contractContext.Attributes.FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Decorator.GenerateDecoratorAttribute"); + if (attr is null) + return; + + GenerateDecoratorForContract(spc, contractSymbol, attr, contractContext.TargetNode); + }); + } + + private void GenerateDecoratorForContract( + SourceProductionContext context, + INamedTypeSymbol contractSymbol, + AttributeData attribute, + SyntaxNode node) + { + // Check if type is interface or abstract class + if (contractSymbol.TypeKind == TypeKind.Interface) + { + // Interfaces are supported + } + else if (contractSymbol.TypeKind == TypeKind.Class && contractSymbol.IsAbstract) + { + // Abstract classes are supported + } + else + { + // Not an interface or abstract class - unsupported target type + context.ReportDiagnostic(Diagnostic.Create( + UnsupportedTargetTypeDescriptor, + node.GetLocation(), + contractSymbol.Name)); + return; + } + + // Check for nested types (not supported - accessibility issues) + if (contractSymbol.ContainingType != null) + { + context.ReportDiagnostic(Diagnostic.Create( + NestedTypeDescriptor, + node.GetLocation(), + contractSymbol.Name)); + return; + } + + // Check for generic contracts (not supported in v1) + if (contractSymbol.TypeParameters.Length > 0) + { + context.ReportDiagnostic(Diagnostic.Create( + GenericContractDescriptor, + node.GetLocation(), + contractSymbol.Name)); + return; + } + + // Parse attribute arguments + var config = ParseDecoratorConfig(attribute, contractSymbol); + + // Analyze contract and members + var contractInfo = AnalyzeContract(contractSymbol, config, context); + if (contractInfo is null) + return; + + // Check for name conflicts + if (HasNameConflict(contractSymbol, config.BaseTypeName)) + { + context.ReportDiagnostic(Diagnostic.Create( + NameConflictDescriptor, + node.GetLocation(), + config.BaseTypeName)); + return; + } + + // Check for helpers name conflict only if composition helpers will be generated + if ((DecoratorCompositionMode)config.Composition == DecoratorCompositionMode.HelpersOnly && + HasNameConflict(contractSymbol, config.HelpersTypeName)) + { + context.ReportDiagnostic(Diagnostic.Create( + NameConflictDescriptor, + node.GetLocation(), + config.HelpersTypeName)); + return; + } + + // Generate base decorator class + var decoratorSource = GenerateBaseDecorator(contractInfo, config, context); + if (!string.IsNullOrEmpty(decoratorSource)) + { + // Use namespace + simple name to avoid collisions while keeping it readable + var ns = contractSymbol.ContainingNamespace.IsGlobalNamespace + ? "" + : contractSymbol.ContainingNamespace.ToDisplayString().Replace(".", "_") + "_"; + var fileName = $"{ns}{contractSymbol.Name}.Decorator.g.cs"; + context.AddSource(fileName, decoratorSource); + } + } + + private DecoratorConfig ParseDecoratorConfig(AttributeData attribute, INamedTypeSymbol contractSymbol) + { + var config = new DecoratorConfig + { + ContractName = contractSymbol.Name, + Namespace = contractSymbol.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : contractSymbol.ContainingNamespace.ToDisplayString() + }; + + // Determine default base type name + var baseName = contractSymbol.Name; + if (baseName.StartsWith("I") && baseName.Length > 1 && char.IsUpper(baseName[1])) + { + // Interface with I prefix: IStorage -> StorageDecoratorBase + baseName = baseName.Substring(1); + } + config.BaseTypeName = $"{baseName}DecoratorBase"; + config.HelpersTypeName = $"{baseName}Decorators"; + + foreach (var named in attribute.NamedArguments) + { + switch (named.Key) + { + case nameof(GenerateDecoratorAttribute.BaseTypeName): + if (named.Value.Value is string baseTypeName && !string.IsNullOrWhiteSpace(baseTypeName)) + config.BaseTypeName = baseTypeName; + break; + case nameof(GenerateDecoratorAttribute.HelpersTypeName): + if (named.Value.Value is string helpersTypeName && !string.IsNullOrWhiteSpace(helpersTypeName)) + config.HelpersTypeName = helpersTypeName; + break; + case nameof(GenerateDecoratorAttribute.Composition): + config.Composition = (int)named.Value.Value!; + break; + // GenerateAsync and ForceAsync are reserved for future use - not parsed + } + } + + return config; + } + + private ContractInfo? AnalyzeContract( + INamedTypeSymbol contractSymbol, + DecoratorConfig config, + SourceProductionContext context) + { + var contractInfo = new ContractInfo + { + ContractSymbol = contractSymbol, + ContractName = contractSymbol.Name, + Namespace = config.Namespace, + IsInterface = contractSymbol.TypeKind == TypeKind.Interface, + IsAbstractClass = contractSymbol.TypeKind == TypeKind.Class && contractSymbol.IsAbstract, + Members = new List() + }; + + // Collect members based on contract type + var members = GetMembersForDecorator(contractSymbol, contractInfo, context); + contractInfo.Members.AddRange(members); + + if (contractInfo.Members.Count == 0) + { + // No members to forward - skip generation to avoid emitting invalid decorators + // (Could be empty due to hasErrors=true in GetMembersForDecorator, or truly no members) + return null; + } + + // Detect if any async members exist + contractInfo.HasAsyncMembers = contractInfo.Members.Any(m => m.IsAsync); + + return contractInfo; + } + + private List GetMembersForDecorator( + INamedTypeSymbol contractSymbol, + ContractInfo contractInfo, + SourceProductionContext context) + { + var members = new List(); + var hasErrors = false; + + // Get all members from the contract and its base types + var allMembers = GetAllInterfaceMembers(contractSymbol); + + foreach (var member in allMembers) + { + // Check for ignore attribute - these will still be forwarded but marked as non-virtual + var isIgnored = HasAttribute(member, "PatternKit.Generators.Decorator.DecoratorIgnoreAttribute"); + + // Only process methods and properties + if (member is IMethodSymbol method) + { + // Skip special methods (constructors, operators, property accessors, etc.) + if (method.MethodKind != MethodKind.Ordinary) + continue; + + // Skip static methods; decorators only forward instance members + if (method.IsStatic) + continue; + + // Generic methods are not supported in v1 + if (method.TypeParameters.Length > 0) + { + context.ReportDiagnostic(Diagnostic.Create( + UnsupportedMemberDescriptor, + member.Locations.FirstOrDefault() ?? Location.None, + method.Name, + "Generic method")); + hasErrors = true; + continue; + } + + // For abstract classes, only include virtual or abstract methods + if (contractInfo.IsAbstractClass && !method.IsVirtual && !method.IsAbstract) + continue; + + // Skip inaccessible methods + if (!IsAccessibleForDecorator(method.DeclaredAccessibility)) + { + context.ReportDiagnostic(Diagnostic.Create( + InaccessibleMemberDescriptor, + member.Locations.FirstOrDefault() ?? Location.None, + member.Name)); + // PKDEC004 is a warning, not an error - don't set hasErrors + continue; + } + + var isAsync = IsAsyncMethod(method); + var baseReturnType = method.ReturnType.ToDisplayString(TypeFormat); + var returnType = method.ReturnsByRef + ? "ref " + baseReturnType + : method.ReturnsByRefReadonly + ? "ref readonly " + baseReturnType + : baseReturnType; + + members.Add(new MemberInfo + { + Name = method.Name, + MemberType = MemberType.Method, + ReturnType = returnType, + IsAsync = isAsync, + IsVoid = method.ReturnsVoid, + IsIgnored = isIgnored, + Accessibility = method.DeclaredAccessibility, + OriginalSymbol = method, + ReturnsByRef = method.ReturnsByRef, + ReturnsByRefReadonly = method.ReturnsByRefReadonly, + Parameters = method.Parameters.Select(p => new ParameterInfo + { + Name = p.Name, + Type = p.Type.ToDisplayString(TypeFormat), + HasDefaultValue = p.HasExplicitDefaultValue, + DefaultValue = p.HasExplicitDefaultValue ? FormatDefaultValue(p) : null, + RefKind = p.RefKind, + IsParams = p.IsParams, + IsThis = p.IsThis + }).ToList() + }); + } + else if (member is IPropertySymbol property) + { + // Indexer properties are not supported in v1 + if (property.IsIndexer) + { + context.ReportDiagnostic(Diagnostic.Create( + UnsupportedMemberDescriptor, + member.Locations.FirstOrDefault() ?? Location.None, + member.Name, + "Indexer")); + hasErrors = true; + continue; + } + + // Skip static properties; decorators only forward instance members + if (property.IsStatic) + continue; + + // For abstract classes, only include virtual or abstract properties + if (contractInfo.IsAbstractClass && !property.IsVirtual && !property.IsAbstract) + continue; + + // Check accessor-level accessibility + // Property may be public but have protected/private accessors + bool getterAccessible = property.GetMethod == null || IsAccessibleForDecorator(property.GetMethod.DeclaredAccessibility); + bool setterAccessible = property.SetMethod == null || IsAccessibleForDecorator(property.SetMethod.DeclaredAccessibility); + + if (!getterAccessible || !setterAccessible) + { + context.ReportDiagnostic(Diagnostic.Create( + InaccessibleMemberDescriptor, + member.Locations.FirstOrDefault() ?? Location.None, + member.Name)); + // PKDEC004 is a warning, not an error - don't set hasErrors + continue; + } + + // Skip inaccessible properties + if (!IsAccessibleForDecorator(property.DeclaredAccessibility)) + { + context.ReportDiagnostic(Diagnostic.Create( + InaccessibleMemberDescriptor, + member.Locations.FirstOrDefault() ?? Location.None, + member.Name)); + // PKDEC004 is a warning, not an error - don't set hasErrors + continue; + } + + // Properties with init-only setters are not supported + // The decorator pattern is incompatible with init setters because + // you cannot assign to init-only properties after object construction + if (property.SetMethod?.IsInitOnly ?? false) + { + context.ReportDiagnostic(Diagnostic.Create( + UnsupportedMemberDescriptor, + member.Locations.FirstOrDefault() ?? Location.None, + property.Name, + "Init-only property")); + hasErrors = true; + continue; + } + + var propInfo = new MemberInfo + { + Name = property.Name, + MemberType = MemberType.Property, + ReturnType = property.Type.ToDisplayString(TypeFormat), + HasGetter = property.GetMethod is not null, + HasSetter = property.SetMethod is not null, + IsInitOnly = property.SetMethod?.IsInitOnly ?? false, + IsAsync = false, + IsIgnored = isIgnored, + Accessibility = property.DeclaredAccessibility, + GetterAccessibility = property.GetMethod?.DeclaredAccessibility ?? property.DeclaredAccessibility, + SetterAccessibility = property.SetMethod?.DeclaredAccessibility ?? property.DeclaredAccessibility, + OriginalSymbol = property + }; + + members.Add(propInfo); + } + else if (member is IEventSymbol) + { + // Events not supported in v1 + context.ReportDiagnostic(Diagnostic.Create( + UnsupportedMemberDescriptor, + member.Locations.FirstOrDefault() ?? Location.None, + member.Name, + "Event")); + hasErrors = true; + } + else if (member is IFieldSymbol fieldSymbol && IsAccessibleForDecorator(fieldSymbol.DeclaredAccessibility)) + { + // Fields are not supported; only report for forwardable API-surface members + context.ReportDiagnostic(Diagnostic.Create( + UnsupportedMemberDescriptor, + member.Locations.FirstOrDefault() ?? Location.None, + member.Name, + member.Kind.ToString())); + hasErrors = true; + } + else if (member is INamedTypeSymbol typeSymbol && IsAccessibleForDecorator(typeSymbol.DeclaredAccessibility)) + { + // Nested types are not supported; only report for forwardable API-surface members + context.ReportDiagnostic(Diagnostic.Create( + UnsupportedMemberDescriptor, + member.Locations.FirstOrDefault() ?? Location.None, + member.Name, + member.Kind.ToString())); + hasErrors = true; + } + } + + // If any errors were reported, return empty list to skip generation + // This prevents generating incomplete decorator bases that won't compile + if (hasErrors) + { + return new List(); + } + + // Sort members for deterministic ordering by kind, name, and signature + return members.OrderBy(m => GetMemberSortKey(m), StringComparer.Ordinal).ToList(); + } + + private static string GetMemberSortKey(MemberInfo member) + { + // Create a stable sort key: kind + name + parameter signature + var sb = new StringBuilder(); + sb.Append((int)member.MemberType); // 0 for Method, 1 for Property + sb.Append('_'); + sb.Append(member.Name); + + if (member.MemberType == MemberType.Method && member.Parameters.Count > 0) + { + sb.Append('('); + for (int i = 0; i < member.Parameters.Count; i++) + { + if (i > 0) sb.Append(','); + var param = member.Parameters[i]; + sb.Append(param.RefKind switch + { + RefKind.Ref => "ref ", + RefKind.Out => "out ", + RefKind.In => "in ", + _ => "" + }); + sb.Append(param.Type); + } + sb.Append(')'); + } + + return sb.ToString(); + } + + private IEnumerable GetAllInterfaceMembers(INamedTypeSymbol type) + { + var members = new List(); + var seenSignatures = new HashSet(); + + void AddMember(ISymbol symbol) + { + // Create a signature key for deduplication + var signature = GetMemberSignature(symbol); + if (seenSignatures.Add(signature)) + { + members.Add(symbol); + } + } + + if (type.TypeKind == TypeKind.Interface) + { + // For interfaces, collect from this interface and all base interfaces + foreach (var member in type.GetMembers()) + { + AddMember(member); + } + foreach (var baseInterface in type.AllInterfaces) + { + foreach (var member in baseInterface.GetMembers()) + { + AddMember(member); + } + } + } + else if (type.TypeKind == TypeKind.Class && type.IsAbstract) + { + // For abstract classes, collect members from this type and all base types + // (we'll filter virtual/abstract later). We walk the BaseType chain to ensure + // inherited virtual/abstract members are also considered part of the contract. + INamedTypeSymbol? current = type; + while (current != null && current.SpecialType != SpecialType.System_Object) + { + foreach (var member in current.GetMembers()) + { + AddMember(member); + } + current = current.BaseType; + } + } + + return members; + } + + private static string GetMemberSignature(ISymbol symbol) + { + var sb = new StringBuilder(); + sb.Append(symbol.Kind); + sb.Append('_'); + sb.Append(symbol.Name); + + if (symbol is IMethodSymbol method) + { + sb.Append('('); + for (int i = 0; i < method.Parameters.Length; i++) + { + if (i > 0) sb.Append(','); + var param = method.Parameters[i]; + sb.Append(param.RefKind switch + { + RefKind.Ref => "ref ", + RefKind.Out => "out ", + RefKind.In => "in ", + _ => "" + }); + sb.Append(param.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + sb.Append(')'); + sb.Append(':'); + sb.Append(method.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + else if (symbol is IPropertySymbol property) + { + sb.Append(':'); + sb.Append(property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + else if (symbol is IEventSymbol eventSymbol) + { + sb.Append(':'); + sb.Append(eventSymbol.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + + return sb.ToString(); + } + + private static bool IsAsyncMethod(IMethodSymbol method) + { + var returnType = method.ReturnType; + var typeName = returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + return typeName.StartsWith("global::System.Threading.Tasks.Task") || + typeName.StartsWith("global::System.Threading.Tasks.ValueTask"); + } + + private static bool CanTypeAcceptNull(ITypeSymbol type) + { + if (type is null) + return false; + + // All reference types can accept null + if (type.IsReferenceType) + return true; + + // Nullable reference types / annotated types can accept null + if (type.NullableAnnotation == NullableAnnotation.Annotated) + return true; + + // Type parameters without a value type constraint can accept null + if (type is ITypeParameterSymbol typeParam) + return !typeParam.HasValueTypeConstraint; + + // Nullable value types can accept null + if (type is INamedTypeSymbol named && + named.IsGenericType && + named.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T) + { + return true; + } + + return false; + } + + private static string FormatDefaultValue(IParameterSymbol param) + { + if (param.ExplicitDefaultValue is null) + { + // Preserve 'null' for types that can accept null (including Nullable and unconstrained type parameters) + if (CanTypeAcceptNull(param.Type)) + return "null"; + + // For non-nullable value types (structs), use 'default' + if (param.Type.IsValueType) + return "default"; + + // Fallback to 'null' for any remaining cases + return "null"; + } + + if (param.Type.TypeKind == TypeKind.Enum && param.Type is INamedTypeSymbol enumType) + { + // Try to resolve the enum field name corresponding to the default value + var enumField = enumType.GetMembers() + .OfType() + .FirstOrDefault(f => f.HasConstantValue && Equals(f.ConstantValue, param.ExplicitDefaultValue)); + + if (enumField != null) + { + return $"{enumType.ToDisplayString(TypeFormat)}.{enumField.Name}"; + } + + // Fallback: cast the numeric value + return $"({enumType.ToDisplayString(TypeFormat)}){param.ExplicitDefaultValue}"; + } + + // Use Roslyn's culture-invariant literal formatting for all other types + return SymbolDisplay.FormatPrimitive(param.ExplicitDefaultValue, quoteStrings: true, useHexadecimalNumbers: false); + } + + private static bool HasAttribute(ISymbol symbol, string attributeName) + { + return symbol.GetAttributes().Any(a => + a.AttributeClass?.ToDisplayString() == attributeName); + } + + private static bool IsAccessibleForDecorator(Accessibility accessibility) + { + // Allow public/internal/protected internal + // Pure protected can't be forwarded because Inner.Member() isn't accessible through the base type reference + return accessibility == Accessibility.Public || + accessibility == Accessibility.Internal || + accessibility == Accessibility.ProtectedOrInternal; + } + + private static string GetAccessibilityKeyword(Accessibility accessibility) + { + return accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Protected => "protected", + Accessibility.ProtectedOrInternal => "protected internal", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.Private => "private", + _ => "public" + }; + } + + private static bool HasNameConflict(INamedTypeSymbol contractSymbol, string generatedName) + { + // Check if there's already a type with the generated name in the same namespace + var containingNamespace = contractSymbol.ContainingNamespace; + var existingTypes = containingNamespace.GetTypeMembers(generatedName); + return existingTypes.Length > 0; + } + + private string GenerateBaseDecorator(ContractInfo contractInfo, DecoratorConfig config, SourceProductionContext context) + { + var sb = new StringBuilder(); + sb.AppendLine("#nullable enable"); + sb.AppendLine("// "); + sb.AppendLine(); + + // Only add namespace declaration if not in global namespace + if (!string.IsNullOrEmpty(contractInfo.Namespace)) + { + sb.AppendLine($"namespace {contractInfo.Namespace};"); + sb.AppendLine(); + } + + // Generate base decorator class + var contractFullName = contractInfo.ContractSymbol.ToDisplayString(TypeFormat); + var accessibility = GetAccessibilityKeyword(contractInfo.ContractSymbol.DeclaredAccessibility); + sb.AppendLine($"/// Base decorator for {contractInfo.ContractName}. All members forward to Inner."); + sb.AppendLine($"{accessibility} abstract partial class {config.BaseTypeName} : {contractFullName}"); + sb.AppendLine("{"); + + // Constructor + sb.AppendLine($" /// Initializes the decorator with an inner instance."); + sb.AppendLine($" protected {config.BaseTypeName}({contractFullName} inner)"); + sb.AppendLine(" {"); + sb.AppendLine(" Inner = inner ?? throw new System.ArgumentNullException(nameof(inner));"); + sb.AppendLine(" }"); + sb.AppendLine(); + + // Inner property + sb.AppendLine($" /// Gets the inner {contractInfo.ContractName} instance."); + sb.AppendLine($" protected {contractFullName} Inner {{ get; }}"); + sb.AppendLine(); + + // Generate forwarding members + foreach (var member in contractInfo.Members) + { + if (member.MemberType == MemberType.Method) + { + GenerateForwardingMethod(sb, member, contractInfo, config); + } + else if (member.MemberType == MemberType.Property) + { + GenerateForwardingProperty(sb, member, contractInfo, config); + } + } + + sb.AppendLine("}"); + + // Generate composition helpers if requested + if ((DecoratorCompositionMode)config.Composition == DecoratorCompositionMode.HelpersOnly) + { + sb.AppendLine(); + GenerateCompositionHelpers(sb, contractInfo, config); + } + + return sb.ToString(); + } + + private void GenerateForwardingMethod(StringBuilder sb, MemberInfo member, ContractInfo contractInfo, DecoratorConfig config) + { + // For async methods, use direct forwarding (return Inner.X()) instead of async/await + // to avoid unnecessary state machine allocation + + // Determine the modifier keyword + string modifierKeyword = contractInfo.IsAbstractClass + // For abstract class contracts, always override the contract member. + // If the member is ignored, seal the override to prevent further overriding + // while still satisfying the abstract/virtual contract. + ? (member.IsIgnored ? "sealed override " : "override ") + // For non-abstract contracts (e.g., interfaces), only non-ignored members are virtual. + : (member.IsIgnored ? "" : "virtual "); + + // Preserve the original member's accessibility to avoid widening on overrides + var accessibilityKeyword = GetAccessibilityKeyword(member.Accessibility); + + sb.AppendLine($" /// Forwards to Inner.{member.Name}."); + sb.Append($" {accessibilityKeyword} {modifierKeyword}{member.ReturnType} {member.Name}("); + + // Generate parameters + var paramList = string.Join(", ", member.Parameters.Select(p => + { + var refKind = p.RefKind switch + { + RefKind.Ref => "ref ", + RefKind.Out => "out ", + RefKind.In => "in ", + _ => "" + }; + // Preserve additional parameter modifiers (params, this) + var paramsModifier = p.IsParams ? "params " : ""; + var thisModifier = p.IsThis ? "this " : ""; + var defaultVal = p.HasDefaultValue ? $" = {p.DefaultValue}" : ""; + return $"{thisModifier}{paramsModifier}{refKind}{p.Type} {p.Name}{defaultVal}"; + })); + + sb.Append(paramList); + sb.AppendLine(")"); + + // Generate method body - use direct forwarding for all methods including async + if (member.IsVoid) + { + sb.AppendLine(" {"); + sb.Append($" Inner.{member.Name}("); + sb.Append(string.Join(", ", member.Parameters.Select(p => + { + var refKind = p.RefKind switch + { + RefKind.Ref => "ref ", + RefKind.Out => "out ", + RefKind.In => "in ", + _ => "" + }; + return $"{refKind}{p.Name}"; + }))); + sb.AppendLine(");"); + sb.AppendLine(" }"); + } + else + { + // For ref returns, we need to put "ref" before the invocation + var refModifier = member.ReturnsByRef || member.ReturnsByRefReadonly ? "ref " : ""; + sb.Append($" => {refModifier}Inner.{member.Name}("); + sb.Append(string.Join(", ", member.Parameters.Select(p => + { + var refKind = p.RefKind switch + { + RefKind.Ref => "ref ", + RefKind.Out => "out ", + RefKind.In => "in ", + _ => "" + }; + return $"{refKind}{p.Name}"; + }))); + sb.AppendLine(");"); + } + + sb.AppendLine(); + } + + private void GenerateForwardingProperty(StringBuilder sb, MemberInfo member, ContractInfo contractInfo, DecoratorConfig config) + { + // Determine the modifier keyword + string modifierKeyword = contractInfo.IsAbstractClass + ? (member.IsIgnored ? "sealed override " : "override ") + : (member.IsIgnored ? "" : "virtual "); + + // Preserve the original member's accessibility + var accessibilityKeyword = GetAccessibilityKeyword(member.Accessibility); + + sb.AppendLine($" /// Forwards to Inner.{member.Name}."); + sb.Append($" {accessibilityKeyword} {modifierKeyword}{member.ReturnType} {member.Name}"); + + // Determine accessor-level modifiers + string getterModifier = ""; + string setterModifier = ""; + + if (contractInfo.IsAbstractClass) + { + // For abstract classes, apply accessor modifiers when accessibility differs from property + if (member.HasGetter && member.GetterAccessibility != member.Accessibility) + { + getterModifier = GetAccessibilityKeyword(member.GetterAccessibility) + " "; + } + if (member.HasSetter && member.SetterAccessibility != member.Accessibility) + { + setterModifier = GetAccessibilityKeyword(member.SetterAccessibility) + " "; + } + } + + if (member.HasGetter && member.HasSetter) + { + sb.AppendLine(); + sb.AppendLine(" {"); + sb.AppendLine($" {getterModifier}get => Inner.{member.Name};"); + // Note: Init setters cannot be properly forwarded in decorators + // Always use 'set' for forwarding since we can't assign to init-only properties + sb.AppendLine($" {setterModifier}set => Inner.{member.Name} = value;"); + sb.AppendLine(" }"); + } + else if (member.HasGetter) + { + sb.AppendLine($" => Inner.{member.Name};"); + } + else if (member.HasSetter) + { + sb.AppendLine(); + sb.AppendLine(" {"); + // Always use 'set' for forwarding + sb.AppendLine($" {setterModifier}set => Inner.{member.Name} = value;"); + sb.AppendLine(" }"); + } + + sb.AppendLine(); + } + + private void GenerateCompositionHelpers(StringBuilder sb, ContractInfo contractInfo, DecoratorConfig config) + { + var contractFullName = contractInfo.ContractSymbol.ToDisplayString(TypeFormat); + var accessibility = GetAccessibilityKeyword(contractInfo.ContractSymbol.DeclaredAccessibility); + + sb.AppendLine($"/// Composition helpers for {contractInfo.ContractName} decorators."); + sb.AppendLine($"{accessibility} static partial class {config.HelpersTypeName}"); + sb.AppendLine("{"); + sb.AppendLine($" /// "); + sb.AppendLine($" /// Composes multiple decorators in order."); + sb.AppendLine($" /// Decorators are applied in array order: decorators[0] is the outermost decorator,"); + sb.AppendLine($" /// decorators[^1] is the innermost decorator (closest to the inner instance)."); + sb.AppendLine($" /// "); + sb.AppendLine($" /// The inner instance to wrap."); + sb.AppendLine($" /// Factory functions that create decorators."); + sb.AppendLine($" /// The fully decorated instance."); + sb.AppendLine($" public static {contractFullName} Compose("); + sb.AppendLine($" {contractFullName} inner,"); + sb.AppendLine($" params System.Func<{contractFullName}, {contractFullName}>[] decorators)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (inner == null) throw new System.ArgumentNullException(nameof(inner));"); + sb.AppendLine(" if (decorators == null) throw new System.ArgumentNullException(nameof(decorators));"); + sb.AppendLine(); + sb.AppendLine(" var current = inner;"); + sb.AppendLine(" // Apply decorators in reverse order so first decorator is outermost"); + sb.AppendLine(" for (int i = decorators.Length - 1; i >= 0; i--)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (decorators[i] != null)"); + sb.AppendLine(" current = decorators[i](current);"); + sb.AppendLine(" }"); + sb.AppendLine(" return current;"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + } + + // Helper classes + private class DecoratorConfig + { + public string ContractName { get; set; } = ""; + public string Namespace { get; set; } = ""; + public string BaseTypeName { get; set; } = ""; + public string HelpersTypeName { get; set; } = ""; + public int Composition { get; set; } = 1; // HelpersOnly + // GenerateAsync and ForceAsync are reserved for future use - not stored in config + } + + private class ContractInfo + { + public INamedTypeSymbol ContractSymbol { get; set; } = null!; + public string ContractName { get; set; } = ""; + public string Namespace { get; set; } = ""; + public bool IsInterface { get; set; } + public bool IsAbstractClass { get; set; } + public List Members { get; set; } = new(); + public bool HasAsyncMembers { get; set; } + } + + private class MemberInfo + { + public string Name { get; set; } = ""; + public MemberType MemberType { get; set; } + public string ReturnType { get; set; } = ""; + public bool IsAsync { get; set; } + public bool IsVoid { get; set; } + public bool IsIgnored { get; set; } + public List Parameters { get; set; } = new(); + public bool HasGetter { get; set; } + public bool HasSetter { get; set; } + public bool IsInitOnly { get; set; } + public Accessibility Accessibility { get; set; } = Accessibility.Public; + public Accessibility GetterAccessibility { get; set; } = Accessibility.Public; + public Accessibility SetterAccessibility { get; set; } = Accessibility.Public; + public ISymbol? OriginalSymbol { get; set; } + public bool ReturnsByRef { get; set; } + public bool ReturnsByRefReadonly { get; set; } + } + + private class ParameterInfo + { + public string Name { get; set; } = ""; + public string Type { get; set; } = ""; + public bool HasDefaultValue { get; set; } + public string? DefaultValue { get; set; } + public RefKind RefKind { get; set; } + public bool IsParams { get; set; } + public bool IsThis { get; set; } + } + + private enum MemberType + { + Method, + Property + } +} diff --git a/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs b/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs new file mode 100644 index 0000000..a3ed29a --- /dev/null +++ b/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs @@ -0,0 +1,1044 @@ +using Microsoft.CodeAnalysis; + +namespace PatternKit.Generators.Tests; + +public class DecoratorGeneratorTests +{ + [Fact] + public void GenerateDecoratorForInterface_BasicContract() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IStorage + { + string ReadFile(string path); + void WriteFile(string path, string content); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_BasicContract)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Decorator class is generated + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("TestNamespace_IStorage.Decorator.g.cs", names); + + // Verify generated content contains expected elements + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_IStorage.Decorator.g.cs") + .SourceText.ToString(); + + Assert.True(generatedSource.Length > 100, $"Generated source is too short ({generatedSource.Length} chars): {generatedSource}"); + + // The Inner property will use fully qualified names + Assert.Contains("StorageDecoratorBase", generatedSource); + Assert.Contains("protected", generatedSource); + Assert.Contains(" Inner ", generatedSource); + Assert.Contains("public virtual", generatedSource); + Assert.Contains("ReadFile", generatedSource); + Assert.Contains("WriteFile", generatedSource); + Assert.Contains("StorageDecorators", generatedSource); + Assert.Contains("Compose", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateDecoratorForInterface_WithAsyncMethods() + { + const string source = """ + using PatternKit.Generators.Decorator; + using System.Threading; + using System.Threading.Tasks; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IAsyncStorage + { + Task ReadFileAsync(string path, CancellationToken ct = default); + ValueTask WriteFileAsync(string path, string content, CancellationToken ct = default); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_WithAsyncMethods)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify generated content contains async method forwarding (direct forwarding without async/await) + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_IAsyncStorage.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("public", generatedSource); + Assert.Contains("virtual", generatedSource); + Assert.Contains("ReadFileAsync", generatedSource); + Assert.Contains("=> Inner.ReadFileAsync", generatedSource); + Assert.Contains("WriteFileAsync", generatedSource); + Assert.Contains("=> Inner.WriteFileAsync", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateDecoratorForInterface_WithProperties() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IConfiguration + { + string ApiKey { get; set; } + int Timeout { get; } + bool IsEnabled { get; set; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_WithProperties)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify generated content contains properties + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_IConfiguration.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("public virtual string ApiKey", generatedSource); + Assert.Contains("get => Inner.ApiKey", generatedSource); + Assert.Contains("set => Inner.ApiKey = value", generatedSource); + Assert.Contains("public virtual int Timeout => Inner.Timeout", generatedSource); + Assert.Contains("public virtual bool IsEnabled", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateDecoratorForInterface_WithDecoratorIgnore() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IRepository + { + void Save(string data); + + [DecoratorIgnore] + void InternalMethod(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_WithDecoratorIgnore)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify generated content - ignored methods are still forwarded but not virtual + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_IRepository.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("void Save", generatedSource); + Assert.Contains("virtual", generatedSource); // Save should be virtual + Assert.Contains("InternalMethod", generatedSource); // Still present, forwarded to Inner + + // InternalMethod should be present and non-virtual (sealed) + // We can't easily check "public void InternalMethod" vs "public virtual void InternalMethod" + // so we'll just verify it compiles and InternalMethod exists + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateDecoratorForInterface_CustomNames() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator(BaseTypeName = "CustomStorageDecorator", HelpersTypeName = "CustomHelpers")] + public interface IStorage + { + string ReadFile(string path); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_CustomNames)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify custom names are used + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_IStorage.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("class CustomStorageDecorator", generatedSource); + Assert.Contains("class CustomHelpers", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateDecoratorForInterface_NoCompositionHelpers() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator(Composition = DecoratorCompositionMode.None)] + public interface IStorage + { + string ReadFile(string path); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_NoCompositionHelpers)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify composition helpers are NOT generated + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_IStorage.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("class StorageDecoratorBase", generatedSource); + Assert.DoesNotContain("class StorageDecorators", generatedSource); + Assert.DoesNotContain("public static IStorage Compose", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateDecoratorForAbstractClass_VirtualMembersOnly() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public abstract partial class StorageBase + { + public abstract string ReadFile(string path); + public virtual void WriteFile(string path, string content) { } + public void NonVirtualMethod() { } // Should be excluded + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForAbstractClass_VirtualMembersOnly)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify only virtual/abstract members are included + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_StorageBase.Decorator.g.cs") + .SourceText.ToString(); + + // For abstract classes, methods use "override" not "virtual" + Assert.Contains("public override", generatedSource); + Assert.Contains("ReadFile", generatedSource); + Assert.Contains("WriteFile", generatedSource); + Assert.DoesNotContain("NonVirtualMethod", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateDecoratorForInterface_WithDefaultParameters() + { + const string source = """ + using PatternKit.Generators.Decorator; + using System.Threading; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IStorage + { + string ReadFile(string path, int bufferSize = 4096); + void WriteFile(string path, string content, bool append = false); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_WithDefaultParameters)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify default parameters are preserved + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_IStorage.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("int bufferSize = 4096", generatedSource); + Assert.Contains("bool append = false", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateDecoratorForInterface_DeterministicOrdering() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IStorage + { + void Zebra(); + void Apple(); + void Mango(); + void Banana(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_DeterministicOrdering)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify members are ordered alphabetically + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_IStorage.Decorator.g.cs") + .SourceText.ToString(); + + var appleIndex = generatedSource.IndexOf("void Apple"); + var bananaIndex = generatedSource.IndexOf("void Banana"); + var mangoIndex = generatedSource.IndexOf("void Mango"); + var zebraIndex = generatedSource.IndexOf("void Zebra"); + + Assert.True(appleIndex < bananaIndex); + Assert.True(bananaIndex < mangoIndex); + Assert.True(mangoIndex < zebraIndex); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateDecoratorForInterface_WithRefParameters() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface ICalculator + { + void Calculate(ref int value); + void TryParse(string input, out int result); + void Process(in int value); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_WithRefParameters)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify ref/out/in parameters are preserved + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_ICalculator.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("ref int value", generatedSource); + Assert.Contains("out int result", generatedSource); + Assert.Contains("in int value", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateDecoratorForInterface_ComplexExample() + { + const string source = """ + using PatternKit.Generators.Decorator; + using System.IO; + using System.Threading; + using System.Threading.Tasks; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IStorage + { + Stream OpenRead(string path); + ValueTask OpenReadAsync(string path, CancellationToken ct = default); + void Write(string path, byte[] data); + Task WriteAsync(string path, byte[] data, CancellationToken ct = default); + bool Exists(string path); + void Delete(string path); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_ComplexExample)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify all methods are generated correctly + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_IStorage.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("OpenRead", generatedSource); + Assert.Contains("OpenReadAsync", generatedSource); + Assert.Contains("Write", generatedSource); + Assert.Contains("WriteAsync", generatedSource); + Assert.Contains("Exists", generatedSource); + Assert.Contains("Delete", generatedSource); + Assert.Contains("virtual", generatedSource); + // Async methods use direct forwarding (no async/await keywords) + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateDecoratorForInterface_InheritedMembers() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + public interface IReadable + { + string Read(); + } + + [GenerateDecorator] + public interface IStorage : IReadable + { + void Write(string data); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_InheritedMembers)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify inherited members are included + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_IStorage.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("public virtual string Read", generatedSource); + Assert.Contains("public virtual void Write", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void DecoratorComposition_AppliesInCorrectOrder() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IService + { + string Execute(string input); + } + + public class LoggingDecorator : ServiceDecoratorBase + { + public LoggingDecorator(IService inner) : base(inner) { } + + public override string Execute(string input) + { + return "Logging: " + base.Execute(input); + } + } + + public class CachingDecorator : ServiceDecoratorBase + { + public CachingDecorator(IService inner) : base(inner) { } + + public override string Execute(string input) + { + return "Caching: " + base.Execute(input); + } + } + + public class BaseService : IService + { + public string Execute(string input) => "Base: " + input; + } + + public class TestRunner + { + public static string Test() + { + var service = new BaseService(); + var decorated = ServiceDecorators.Compose( + service, + s => new LoggingDecorator(s), + s => new CachingDecorator(s) + ); + return decorated.Execute("test"); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(DecoratorComposition_AppliesInCorrectOrder)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void Diagnostic_PKDEC001_UnsupportedTargetType() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public class ConcreteClass // Not abstract + { + public void Method() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Diagnostic_PKDEC001_UnsupportedTargetType)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have PKDEC001 diagnostic + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKDEC001"); + Assert.Contains(diagnostics, d => d.GetMessage().Contains("ConcreteClass")); + } + + [Fact] + public void Diagnostic_PKDEC002_UnsupportedMemberKind_Event() + { + const string source = """ + using PatternKit.Generators.Decorator; + using System; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IWithEvent + { + event EventHandler Changed; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Diagnostic_PKDEC002_UnsupportedMemberKind_Event)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have PKDEC002 diagnostic for the event + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKDEC002" && d.GetMessage().Contains("Changed")); + + // Generation should be skipped when PKDEC002 (error) is reported + var generatedSources = result.Results.SelectMany(r => r.GeneratedSources).ToArray(); + Assert.Empty(generatedSources); + } + + [Fact] + public void Diagnostic_PKDEC003_NameConflict_BaseType() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IService + { + void Execute(); + } + + // This conflicts with the generated name + public class ServiceDecoratorBase + { + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Diagnostic_PKDEC003_NameConflict_BaseType)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have PKDEC003 diagnostic + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKDEC003" && d.GetMessage().Contains("ServiceDecoratorBase")); + } + + [Fact] + public void Diagnostic_PKDEC003_NameConflict_HelpersType() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IService + { + void Execute(); + } + + // This conflicts with the generated helpers name + public static class ServiceDecorators + { + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Diagnostic_PKDEC003_NameConflict_HelpersType)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have PKDEC003 diagnostic + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKDEC003" && d.GetMessage().Contains("ServiceDecorators")); + } + + [Fact] + public void Diagnostic_PKDEC002_Indexer() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IIndexable + { + string this[int index] { get; set; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Diagnostic_PKDEC002_Indexer)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have PKDEC002 diagnostic for the indexer + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKDEC002" && d.GetMessage().Contains("Indexer")); + + // Generation should be skipped when PKDEC002 (error) is reported + var generatedSources = result.Results.SelectMany(r => r.GeneratedSources).ToArray(); + Assert.Empty(generatedSources); + } + + [Fact] + public void Diagnostic_PKDEC005_GenericContract() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IGenericService + { + T Process(T input); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Diagnostic_PKDEC005_GenericContract)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have PKDEC005 diagnostic for the generic contract + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKDEC005" && d.GetMessage().Contains("IGenericService")); + } + + [Fact] + public void Diagnostic_PKDEC002_InitOnlyProperty() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IConfig + { + string Name { get; init; } + int Value { get; set; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Diagnostic_PKDEC002_InitOnlyProperty)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have PKDEC002 diagnostic for the init-only property + // Init setters are incompatible with the decorator pattern + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKDEC002" && d.GetMessage().Contains("Name")); + + // Generation should be skipped when PKDEC002 (error) is reported + var generatedSources = result.Results.SelectMany(r => r.GeneratedSources).ToArray(); + Assert.Empty(generatedSources); + } + + [Fact] + public void GenerateDecoratorForAbstractClass_WithInternalProtectedMembers() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public abstract class ServiceBase + { + public abstract void PublicMethod(); + protected internal abstract void ProtectedInternalMethod(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForAbstractClass_WithInternalProtectedMembers)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify generated content contains correct accessibility + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_ServiceBase.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("public override", generatedSource); + Assert.Contains("PublicMethod", generatedSource); + Assert.Contains("protected internal override", generatedSource); + Assert.Contains("ProtectedInternalMethod", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void Diagnostic_PKDEC002_GenericMethod() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IGenericService + { + T Process(T input); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Diagnostic_PKDEC002_GenericMethod)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have PKDEC002 diagnostic for the generic method + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKDEC002" && d.GetMessage().Contains("Generic method")); + + // Generation should be skipped when PKDEC002 (error) is reported + var generatedSources = result.Results.SelectMany(r => r.GeneratedSources).ToArray(); + Assert.Empty(generatedSources); + } + + [Fact] + public void GenerateDecoratorForInterface_IgnoresStaticMembers() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IService + { + void InstanceMethod(); + static void StaticMethod() { } + string InstanceProperty { get; set; } + static string StaticProperty { get; set; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_IgnoresStaticMembers)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics for static members + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify generated content contains only instance members + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_IService.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("InstanceMethod", generatedSource); + Assert.Contains("InstanceProperty", generatedSource); + Assert.DoesNotContain("StaticMethod", generatedSource); + Assert.DoesNotContain("StaticProperty", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void Diagnostic_PKDEC004_PropertyWithProtectedSetter() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public abstract class ServiceBase + { + public abstract string Name { get; protected set; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Diagnostic_PKDEC004_PropertyWithProtectedSetter)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have PKDEC004 diagnostic for the property with protected setter + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKDEC004" && d.GetMessage().Contains("Name")); + } + + [Fact] + public void GenerateDecoratorForInterface_SupportsParamsModifier() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IService + { + void ProcessMany(params string[] items); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_SupportsParamsModifier)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify generated content preserves params modifier + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_IService.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("params", generatedSource); + Assert.Contains("params string[] items", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateDecoratorForAbstractClass_InheritsVirtualMembers() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + public abstract class BaseClass + { + public abstract void BaseMethod(); + } + + [GenerateDecorator] + public abstract class DerivedClass : BaseClass + { + public abstract void DerivedMethod(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForAbstractClass_InheritsVirtualMembers)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify generated content includes both base and derived methods + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_DerivedClass.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("BaseMethod", generatedSource); + Assert.Contains("DerivedMethod", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void Diagnostic_PKDEC006_NestedType() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + public class OuterClass + { + [GenerateDecorator] + public interface INestedService + { + void DoWork(); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Diagnostic_PKDEC006_NestedType)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should have PKDEC006 diagnostic for nested type + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKDEC006"); + } + + [Fact] + public void GenerateDecoratorForInterface_SupportsRefReturns() + { + const string source = """ + using PatternKit.Generators.Decorator; + + namespace TestNamespace; + + [GenerateDecorator] + public interface IRefService + { + ref int GetRef(); + ref readonly int GetRefReadonly(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateDecoratorForInterface_SupportsRefReturns)); + var gen = new DecoratorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Verify generated content includes ref/ref readonly modifiers + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace_IRefService.Decorator.g.cs") + .SourceText.ToString(); + + Assert.Contains("ref int GetRef()", generatedSource); + Assert.Contains("ref readonly int GetRefReadonly()", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } +}