diff --git a/docs/examples/abstract-factory-widget-families.md b/docs/examples/abstract-factory-widget-families.md new file mode 100644 index 00000000..55b5591d --- /dev/null +++ b/docs/examples/abstract-factory-widget-families.md @@ -0,0 +1,43 @@ +# Abstract Factory Widget Families + +This example demonstrates a generated Abstract Factory for platform-specific UI widget families. The runtime path is still `AbstractFactory`; the generated path removes repetitive family registration code. + +Source: + +- `src/PatternKit.Examples/AbstractFactoryDemo/AbstractFactoryDemo.cs` +- `test/PatternKit.Examples.Tests/AbstractFactoryDemo/AbstractFactoryDemoTests.cs` + +## Generated Family Matrix + +The example declares the family matrix once: + +```csharp +[GenerateAbstractFactory(typeof(AbstractFactoryDemo.Platform), FactoryMethodName = "Create", ServiceProviderFactoryMethodName = "CreateFromServices")] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.Windows, typeof(AbstractFactoryDemo.IButton), typeof(AbstractFactoryDemo.WindowsButton))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.Windows, typeof(AbstractFactoryDemo.ITextBox), typeof(AbstractFactoryDemo.WindowsTextBox))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.Linux, typeof(AbstractFactoryDemo.IButton), typeof(AbstractFactoryDemo.LinuxButton))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.Linux, typeof(AbstractFactoryDemo.ITextBox), typeof(AbstractFactoryDemo.LinuxTextBox))] +public static partial class GeneratedPlatformWidgetFactory; +``` + +`CreateUIFactory()` calls the generated factory and returns the same runtime abstraction consumers already use: + +```csharp +var factory = AbstractFactoryDemo.CreateUIFactory(); +var windows = factory.GetFamily(AbstractFactoryDemo.Platform.Windows); +var button = windows.Create(); +``` + +## IServiceCollection Import + +The example also exposes an importable DI path: + +```csharp +services.AddAbstractFactoryWidgetExample(); +``` + +The registration uses the generated `CreateFromServices(IServiceProvider)` overload so concrete widget products can evolve toward constructor-injected dependencies without changing client code. + +## Tested Behavior + +The TinyBDD coverage validates that every platform family can create every widget contract, product behavior remains platform-specific, and the DI registration resolves an `AbstractFactory` that importing applications can use directly. diff --git a/docs/generators/abstract-factory.md b/docs/generators/abstract-factory.md new file mode 100644 index 00000000..a055e14d --- /dev/null +++ b/docs/generators/abstract-factory.md @@ -0,0 +1,48 @@ +# Abstract Factory Generator + +`[GenerateAbstractFactory]` emits an `AbstractFactory` from declarative product-family attributes. Use it when the family matrix is known at compile time and should stay reviewable, type-safe, and importable through normal application composition. + +## Quick Start + +```csharp +using PatternKit.Generators.Factories; + +[GenerateAbstractFactory(typeof(Platform), FactoryMethodName = "Create", ServiceProviderFactoryMethodName = "CreateFromServices")] +[AbstractFactoryProduct(Platform.Windows, typeof(IButton), typeof(WindowsButton))] +[AbstractFactoryProduct(Platform.Windows, typeof(ITextBox), typeof(WindowsTextBox))] +[AbstractFactoryProduct(Platform.Linux, typeof(IButton), typeof(LinuxButton))] +[AbstractFactoryProduct(Platform.Linux, typeof(ITextBox), typeof(LinuxTextBox))] +public static partial class PlatformWidgetFactory; +``` + +Generated API: + +```csharp +public static AbstractFactory Create(); +public static AbstractFactory CreateFromServices(IServiceProvider services); +``` + +The normal factory path uses public parameterless constructors. The optional service-provider path uses `ActivatorUtilities.CreateInstance(services)`, so products can add constructor dependencies when the importing app registers them with `IServiceCollection`. + +## Behavior + +- `[GenerateAbstractFactory]` must be placed on a partial class or struct. +- `[AbstractFactoryProduct]` declares one contract/implementation pair for one family key. +- `ImplementationType` must be a concrete class assignable to `ContractType`. +- Each generated factory calls the runtime fluent API: `AbstractFactory.Create().Family(...).Product<...>().Build()`. +- Set `IsDefaultFamily = true` on a product to add it to the default family instead of a keyed family. + +## Diagnostics + +| ID | Meaning | +| --- | --- | +| `PKAF001` | The generated host type is not partial. | +| `PKAF002` | No products were declared. | +| `PKAF003` | A product declaration has an invalid key, contract, implementation, or constructor. | +| `PKAF004` | A family declares the same product contract more than once. | + +## Example Source + +- `src/PatternKit.Examples/AbstractFactoryDemo/AbstractFactoryDemo.cs` +- `test/PatternKit.Examples.Tests/AbstractFactoryDemo/AbstractFactoryDemoTests.cs` +- `test/PatternKit.Generators.Tests/AbstractFactoryGeneratorTests.cs` diff --git a/docs/generators/index.md b/docs/generators/index.md index a6ff1a28..34b421b8 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -26,6 +26,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | Generator | Description | Attribute | |---|---|---| +| [**Abstract Factory**](abstract-factory.md) | Product-family factories with optional IServiceProvider construction | `[GenerateAbstractFactory]` | | [**Builder**](builder.md) | GoF-aligned builders with mutable or state-projection models, sync/async pipelines | `[GenerateBuilder]` | | [**Factory Method**](factory-method.md) | Keyed dispatcher from a static partial class | `[FactoryMethod]` | | [**Factory Class**](factory-class.md) | GoF-style factory mapping keys to products | `[FactoryClass]` | @@ -87,6 +88,12 @@ public partial class Person { public string Name { get; set; } } [GenerateFactory(typeof(INotification), typeof(NotificationKind))] public abstract partial class NotificationFactory { } +// Abstract factory - generated product families +[GenerateAbstractFactory(typeof(Platform))] +[AbstractFactoryProduct(Platform.Windows, typeof(IButton), typeof(WindowsButton))] +[AbstractFactoryProduct(Platform.Linux, typeof(IButton), typeof(LinuxButton))] +public static partial class PlatformWidgets { } + // Prototype - cloning [Prototype] public partial class Document { } diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 02fa05f7..c5bd06c2 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -1,6 +1,9 @@ - name: Generators Overview href: index.md +- name: Abstract Factory + href: abstract-factory.md + - name: Adapter href: adapter.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 84c5a150..a5271296 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -16,7 +16,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Family | Pattern | Fluent path | Source-generated path | | --- | --- | --- | --- | -| Creational | Abstract Factory | `AbstractFactory<,>` | Tracked in [#207](https://github.com/JerrettDavis/PatternKit/issues/207) | +| Creational | Abstract Factory | `AbstractFactory` | Abstract Factory generator | | Creational | Builder | Builder helpers | Builder generator | | Creational | Factory Method | `Factory` | Factory Method generator | | Creational | Prototype | `Prototype` | Prototype generator | diff --git a/docs/patterns/creational/abstract-factory/index.md b/docs/patterns/creational/abstract-factory/index.md index 1b5e54c3..3ff6fe77 100644 --- a/docs/patterns/creational/abstract-factory/index.md +++ b/docs/patterns/creational/abstract-factory/index.md @@ -25,6 +25,15 @@ IButton button = family.Create(); // DarkButton ITextBox textBox = family.Create(); // DarkTextBox ``` +For static family matrices, use the [Abstract Factory Generator](../../../generators/abstract-factory.md) to emit the same `AbstractFactory` runtime object from attributes: + +```csharp +[GenerateAbstractFactory(typeof(Theme))] +[AbstractFactoryProduct(Theme.Light, typeof(IButton), typeof(LightButton))] +[AbstractFactoryProduct(Theme.Dark, typeof(IButton), typeof(DarkButton))] +public static partial class ThemeWidgets; +``` + ## What It Is Abstract Factory provides a way to encapsulate a group of individual factories that have a common theme. It creates families of related or dependent objects without specifying their concrete classes. diff --git a/src/PatternKit.Examples/AbstractFactoryDemo/AbstractFactoryDemo.cs b/src/PatternKit.Examples/AbstractFactoryDemo/AbstractFactoryDemo.cs index 16208cbb..17747d4f 100644 --- a/src/PatternKit.Examples/AbstractFactoryDemo/AbstractFactoryDemo.cs +++ b/src/PatternKit.Examples/AbstractFactoryDemo/AbstractFactoryDemo.cs @@ -1,4 +1,5 @@ using PatternKit.Creational.AbstractFactory; +using PatternKit.Generators.Factories; namespace PatternKit.Examples.AbstractFactoryDemo; @@ -172,31 +173,13 @@ public static Platform DetectPlatform() /// Each platform (Windows, macOS, Linux) is a separate product family. /// public static AbstractFactory CreateUIFactory() - { - return AbstractFactory.Create() - // Windows family - .Family(Platform.Windows) - .Product(() => new WindowsButton()) - .Product(() => new WindowsTextBox()) - .Product(() => new WindowsCheckBox()) - .Product(() => new WindowsDialog()) - - // macOS family - .Family(Platform.MacOS) - .Product(() => new MacButton()) - .Product(() => new MacTextBox()) - .Product(() => new MacCheckBox()) - .Product(() => new MacDialog()) - - // Linux family - .Family(Platform.Linux) - .Product(() => new LinuxButton()) - .Product(() => new LinuxTextBox()) - .Product(() => new LinuxCheckBox()) - .Product(() => new LinuxDialog()) - - .Build(); - } + => GeneratedPlatformWidgetFactory.Create(); + + /// + /// Creates a platform-specific UI factory through so product constructors can use application services. + /// + public static AbstractFactory CreateUIFactory(IServiceProvider services) + => GeneratedPlatformWidgetFactory.CreateFromServices(services); // ───────────────────────────────────────────────────────────────────────── // Client Code - Platform Agnostic @@ -290,3 +273,18 @@ public static void Run() Console.WriteLine("═══════════════════════════════════════════════════════════════"); } } + +[GenerateAbstractFactory(typeof(AbstractFactoryDemo.Platform), FactoryMethodName = "Create", ServiceProviderFactoryMethodName = "CreateFromServices")] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.Windows, typeof(AbstractFactoryDemo.IButton), typeof(AbstractFactoryDemo.WindowsButton))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.Windows, typeof(AbstractFactoryDemo.ITextBox), typeof(AbstractFactoryDemo.WindowsTextBox))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.Windows, typeof(AbstractFactoryDemo.ICheckBox), typeof(AbstractFactoryDemo.WindowsCheckBox))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.Windows, typeof(AbstractFactoryDemo.IDialog), typeof(AbstractFactoryDemo.WindowsDialog))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.MacOS, typeof(AbstractFactoryDemo.IButton), typeof(AbstractFactoryDemo.MacButton))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.MacOS, typeof(AbstractFactoryDemo.ITextBox), typeof(AbstractFactoryDemo.MacTextBox))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.MacOS, typeof(AbstractFactoryDemo.ICheckBox), typeof(AbstractFactoryDemo.MacCheckBox))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.MacOS, typeof(AbstractFactoryDemo.IDialog), typeof(AbstractFactoryDemo.MacDialog))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.Linux, typeof(AbstractFactoryDemo.IButton), typeof(AbstractFactoryDemo.LinuxButton))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.Linux, typeof(AbstractFactoryDemo.ITextBox), typeof(AbstractFactoryDemo.LinuxTextBox))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.Linux, typeof(AbstractFactoryDemo.ICheckBox), typeof(AbstractFactoryDemo.LinuxCheckBox))] +[AbstractFactoryProduct(AbstractFactoryDemo.Platform.Linux, typeof(AbstractFactoryDemo.IDialog), typeof(AbstractFactoryDemo.LinuxDialog))] +public static partial class GeneratedPlatformWidgetFactory; diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 1aad0137..ef4b57e9 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using PatternKit.Behavioral.Chain; using PatternKit.Behavioral.Strategy; using PatternKit.Behavioral.TypeDispatcher; +using PatternKit.Creational.AbstractFactory; using PatternKit.Creational.Prototype; using PatternKit.Creational.Singleton; using PatternKit.Examples.ApiGateway; @@ -42,6 +43,7 @@ using ShowcaseFacade = PatternKit.Examples.PatternShowcase.PatternShowcase.IOrderProcessingFacade; using TransactionPipeline = PatternKit.Examples.Chain.TransactionPipeline; using VisitorTender = PatternKit.Examples.VisitorDemo.Tender; +using WidgetDemo = PatternKit.Examples.AbstractFactoryDemo.AbstractFactoryDemo; namespace PatternKit.Examples.DependencyInjection; @@ -70,6 +72,7 @@ public sealed class CoercerService : ICoercer } public sealed record ProductionReadyExampleIntegrations(IPatternKitExampleCatalog ExampleCatalog, IPatternKitPatternCatalog PatternCatalog); +public sealed record AbstractFactoryWidgetExample(AbstractFactory Factory); public sealed record AuthLoggingChainExample(ActionChain Chain, List Log); public sealed record CoercionExample(ICoercer Integers, ICoercer Booleans, ICoercer Strings); public sealed record ComposedNotificationStrategyExample(AsyncStrategy Strategy); @@ -114,6 +117,7 @@ public static class PatternKitExampleServiceCollectionExtensions public static IServiceCollection AddPatternKitExamples(this IServiceCollection services, IConfiguration? configuration = null) => services .AddProductionReadyExampleIntegrations() + .AddAbstractFactoryWidgetExample() .AddAuthLoggingChainExample() .AddStrategyBasedDataCoercionExample() .AddComposedNotificationStrategyExample() @@ -161,6 +165,13 @@ public static IServiceCollection AddProductionReadyExampleIntegrations(this ISer return services.RegisterExample("Production-Ready Example Integrations", ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore); } + public static IServiceCollection AddAbstractFactoryWidgetExample(this IServiceCollection services) + { + services.AddSingleton(static sp => WidgetDemo.CreateUIFactory(sp)); + services.AddSingleton(sp => new(sp.GetRequiredService>())); + return services.RegisterExample("Abstract Factory Widget Families", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddAuthLoggingChainExample(this IServiceCollection services) { services.AddSingleton(_ => diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index cbfb37cb..52785e57 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -296,6 +296,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.ExternalInfrastructure, ["Facade", "Mailbox", "Outbox", "IdempotentReceiver"], ["host setup", "generated request/reply topology", "generated pub/sub topology", "transport boundary"]), + Descriptor( + "Abstract Factory Widget Families", + "src/PatternKit.Examples/AbstractFactoryDemo/AbstractFactoryDemo.cs", + "test/PatternKit.Examples.Tests/AbstractFactoryDemo/AbstractFactoryDemoTests.cs", + "docs/examples/abstract-factory-widget-families.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["AbstractFactory"], + ["generated family factory", "platform widgets", "DI composition"]), Descriptor( "Prototype Game Character Factory", "src/PatternKit.Examples/PrototypeDemo/PrototypeDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 5d6b10a1..26486f8e 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -68,14 +68,14 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "docs/patterns/creational/abstract-factory/index.md", "src/PatternKit.Core/Creational/AbstractFactory/AbstractFactory.cs", "test/PatternKit.Tests/Creational/AbstractFactoryTests.cs", + "docs/generators/abstract-factory.md", + "src/PatternKit.Generators/Factories/AbstractFactoryGenerator.cs", + "test/PatternKit.Generators.Tests/AbstractFactoryGeneratorTests.cs", null, - null, - null, - "https://github.com/JerrettDavis/PatternKit/issues/207", - "docs/examples/enterprise-order.md", + "docs/examples/abstract-factory-widget-families.md", "src/PatternKit.Examples/AbstractFactoryDemo/AbstractFactoryDemo.cs", "test/PatternKit.Examples.Tests/AbstractFactoryDemo/AbstractFactoryDemoTests.cs", - ["fluent family factory", "dedicated generator tracked", "example importable through AddPatternKitExamples"]), + ["fluent family factory", "generated family factory", "example importable through AddPatternKitExamples"]), Pattern("Builder", PatternFamily.Creational, "docs/patterns/creational/builder/index.md", diff --git a/src/PatternKit.Generators.Abstractions/Factories/FactoriesAttributes.cs b/src/PatternKit.Generators.Abstractions/Factories/FactoriesAttributes.cs index 0d218e7a..0d1a681e 100644 --- a/src/PatternKit.Generators.Abstractions/Factories/FactoriesAttributes.cs +++ b/src/PatternKit.Generators.Abstractions/Factories/FactoriesAttributes.cs @@ -1,6 +1,6 @@ namespace PatternKit.Generators.Factories; -[AttributeUsage(AttributeTargets.Class)] +[AttributeUsage(AttributeTargets.Class, Inherited = false)] public sealed class FactoryMethodAttribute(Type keyType) : Attribute { public Type KeyType { get; } = keyType; @@ -8,18 +8,18 @@ public sealed class FactoryMethodAttribute(Type keyType) : Attribute public bool CaseInsensitiveStrings { get; set; } = true; } -[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] public sealed class FactoryCaseAttribute(object key) : Attribute { public object Key { get; } = key; } -[AttributeUsage(AttributeTargets.Method)] +[AttributeUsage(AttributeTargets.Method, Inherited = false)] public sealed class FactoryDefaultAttribute : Attribute { } -[AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class)] +[AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class, Inherited = false)] public sealed class FactoryClassAttribute(Type keyType) : Attribute { public Type KeyType { get; } = keyType; @@ -28,8 +28,25 @@ public sealed class FactoryClassAttribute(Type keyType) : Attribute public bool GenerateEnumKeys { get; set; } = false; } -[AttributeUsage(AttributeTargets.Class)] +[AttributeUsage(AttributeTargets.Class, Inherited = false)] public sealed class FactoryClassKeyAttribute(object key) : Attribute { public object Key { get; } = key; } + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateAbstractFactoryAttribute(Type keyType) : Attribute +{ + public Type KeyType { get; } = keyType; + public string FactoryMethodName { get; set; } = "Create"; + public string? ServiceProviderFactoryMethodName { get; set; } +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)] +public sealed class AbstractFactoryProductAttribute(object familyKey, Type contractType, Type implementationType) : Attribute +{ + public object FamilyKey { get; } = familyKey; + public Type ContractType { get; } = contractType; + public Type ImplementationType { get; } = implementationType; + public bool IsDefaultFamily { get; set; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 03851073..074b561f 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -189,6 +189,10 @@ PKBT002 | PatternKit.Generators.Messaging | Error | Backplane topology must decl PKBT003 | PatternKit.Generators.Messaging | Error | Backplane request/reply declaration is invalid. PKBT004 | PatternKit.Generators.Messaging | Error | Backplane subscription declaration is invalid. PKBT005 | PatternKit.Generators.Messaging | Error | Backplane request default route is duplicated. +PKAF001 | PatternKit.Generators.Factories | Error | Abstract factory host must be partial. +PKAF002 | PatternKit.Generators.Factories | Error | Abstract factory must declare at least one product. +PKAF003 | PatternKit.Generators.Factories | Error | Abstract factory product declaration is invalid. +PKAF004 | PatternKit.Generators.Factories | Error | Abstract factory product declaration is duplicated. PKRL001 | PatternKit.Generators.Messaging | Error | Recipient list type must be partial. PKRL002 | PatternKit.Generators.Messaging | Error | Recipient list must declare at least one recipient. PKRL003 | PatternKit.Generators.Messaging | Error | Recipient handler or predicate signature is invalid. diff --git a/src/PatternKit.Generators/Factories/AbstractFactoryGenerator.cs b/src/PatternKit.Generators/Factories/AbstractFactoryGenerator.cs new file mode 100644 index 00000000..2b5a13d1 --- /dev/null +++ b/src/PatternKit.Generators/Factories/AbstractFactoryGenerator.cs @@ -0,0 +1,409 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Factories; + +[Generator] +public sealed class AbstractFactoryGenerator : IIncrementalGenerator +{ + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKAF001", + "Abstract factory host must be partial", + "Type '{0}' is marked with [GenerateAbstractFactory] but is not declared as partial", + "PatternKit.Generators.Factories", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingProducts = new( + "PKAF002", + "Abstract factory has no products", + "Type '{0}' is marked with [GenerateAbstractFactory] but does not declare any abstract factory products", + "PatternKit.Generators.Factories", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidProduct = new( + "PKAF003", + "Abstract factory product is invalid", + "Product declaration '{0}' must reference a valid key, contract type, concrete implementation type, and public parameterless constructor", + "PatternKit.Generators.Factories", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor DuplicateProduct = new( + "PKAF004", + "Abstract factory product is duplicated", + "Family '{0}' declares product contract '{1}' more than once", + "PatternKit.Generators.Factories", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Factories.GenerateAbstractFactoryAttribute", + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Factories.GenerateAbstractFactoryAttribute"); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate( + SourceProductionContext context, + INamedTypeSymbol type, + TypeDeclarationSyntax node, + AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var keyType = attribute.ConstructorArguments.Length == 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + if (keyType is null) + return; + + var hasProductAttributes = type.GetAttributes().Any(static attr => + attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Factories.AbstractFactoryProductAttribute"); + var products = GetProducts(type, keyType, context); + if (products.Length == 0) + { + if (!hasProductAttributes) + context.ReportDiagnostic(Diagnostic.Create(MissingProducts, node.Identifier.GetLocation(), type.Name)); + + return; + } + + if (TryFindDuplicate(products, out var duplicate)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateProduct, duplicate.Location, duplicate.FamilyKeyText, duplicate.ContractTypeName)); + return; + } + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + var serviceProviderFactoryMethodName = GetNamedString(attribute, "ServiceProviderFactoryMethodName"); + context.AddSource($"{type.Name}.AbstractFactory.g.cs", SourceText.From( + GenerateSource(type, keyType, products, factoryMethodName, serviceProviderFactoryMethodName), + Encoding.UTF8)); + } + + private static ImmutableArray GetProducts( + INamedTypeSymbol type, + INamedTypeSymbol keyType, + SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var attr in type.GetAttributes().Where(static attr => + attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Factories.AbstractFactoryProductAttribute")) + { + if (!TryGetProduct(keyType, attr, out var product)) + { + var contractName = attr.ConstructorArguments.Length > 1 + ? (attr.ConstructorArguments[1].Value as INamedTypeSymbol)?.Name + : null; + context.ReportDiagnostic(Diagnostic.Create(InvalidProduct, attr.ApplicationSyntaxReference?.GetSyntax().GetLocation(), contractName ?? type.Name)); + continue; + } + + builder.Add(product); + } + + return builder.ToImmutable(); + } + + private static bool TryGetProduct(INamedTypeSymbol keyType, AttributeData attribute, out Product product) + { + product = default; + if (attribute.ConstructorArguments.Length != 3) + return false; + + var key = attribute.ConstructorArguments[0]; + var contractType = attribute.ConstructorArguments[1].Value as INamedTypeSymbol; + var implementationType = attribute.ConstructorArguments[2].Value as INamedTypeSymbol; + if (contractType is null || implementationType is null) + return false; + + if (!IsKeyCompatible(keyType, key) || !TryFormatKey(key, out var keyExpression, out var keyText)) + return false; + + if (implementationType.IsAbstract || implementationType.TypeKind != TypeKind.Class) + return false; + + if (!Implements(implementationType, contractType)) + return false; + + if (!implementationType.Constructors.Any(static ctor => + ctor.DeclaredAccessibility == Accessibility.Public && + ctor.Parameters.Length == 0)) + { + return false; + } + + product = new Product( + keyExpression, + keyText, + contractType.ToDisplayString(TypeFormat), + implementationType.ToDisplayString(TypeFormat), + GetNamedBool(attribute, "IsDefaultFamily"), + attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation()); + return true; + } + + private static bool IsKeyCompatible(INamedTypeSymbol keyType, TypedConstant key) + { + if (key.IsNull || key.Value is null) + return !keyType.IsValueType; + + if (key.Type is null) + return false; + + if (SymbolEqualityComparer.Default.Equals(key.Type, keyType)) + return true; + + return keyType.SpecialType switch + { + SpecialType.System_String => key.Value is string, + SpecialType.System_Boolean => key.Value is bool, + SpecialType.System_Char => key.Value is char, + SpecialType.System_Byte => key.Value is byte, + SpecialType.System_SByte => key.Value is sbyte, + SpecialType.System_Int16 => key.Value is short, + SpecialType.System_UInt16 => key.Value is ushort, + SpecialType.System_Int32 => key.Value is int, + SpecialType.System_UInt32 => key.Value is uint, + SpecialType.System_Int64 => key.Value is long, + SpecialType.System_UInt64 => key.Value is ulong, + _ => keyType.TypeKind == TypeKind.Enum && SymbolEqualityComparer.Default.Equals(key.Type, keyType) + }; + } + + private static bool TryFormatKey(TypedConstant key, out string expression, out string text) + { + if (key.IsNull) + { + expression = "null!"; + text = ""; + return true; + } + + var constantValue = key.Value; + if (constantValue is null) + { + expression = string.Empty; + text = string.Empty; + return false; + } + + if (key.Type is INamedTypeSymbol enumType && enumType.TypeKind == TypeKind.Enum) + { + var enumMember = enumType.GetMembers() + .OfType() + .FirstOrDefault(field => field.HasConstantValue && Equals(field.ConstantValue, constantValue)); + if (enumMember is not null) + { + expression = enumType.ToDisplayString(TypeFormat) + "." + enumMember.Name; + text = enumMember.Name; + return true; + } + + expression = string.Empty; + text = string.Empty; + return false; + } + + expression = key.Value switch + { + string value => "\"" + Escape(value) + "\"", + char value => "'" + EscapeChar(value) + "'", + bool value => value ? "true" : "false", + byte value => value.ToString(CultureInfo.InvariantCulture), + sbyte value => value.ToString(CultureInfo.InvariantCulture), + short value => value.ToString(CultureInfo.InvariantCulture), + ushort value => value.ToString(CultureInfo.InvariantCulture), + int value => value.ToString(CultureInfo.InvariantCulture), + uint value => value.ToString(CultureInfo.InvariantCulture) + "u", + long value => value.ToString(CultureInfo.InvariantCulture) + "L", + ulong value => value.ToString(CultureInfo.InvariantCulture) + "UL", + _ => string.Empty + }; + + text = constantValue.ToString() ?? string.Empty; + return expression.Length > 0; + } + + private static bool Implements(INamedTypeSymbol implementation, INamedTypeSymbol contract) + { + if (SymbolEqualityComparer.Default.Equals(implementation, contract)) + return true; + + if (implementation.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i, contract))) + return true; + + for (var current = implementation.BaseType; current is not null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, contract)) + return true; + } + + return false; + } + + private static bool TryFindDuplicate(IReadOnlyList products, out Product duplicate) + { + var seen = new HashSet(System.StringComparer.Ordinal); + foreach (var product in products) + { + var key = product.IsDefaultFamily + ? ":" + product.ContractTypeName + : product.FamilyKeyText + ":" + product.ContractTypeName; + if (!seen.Add(key)) + { + duplicate = product; + return true; + } + } + + duplicate = default; + return false; + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol keyType, + IReadOnlyList products, + string factoryMethodName, + string? serviceProviderFactoryMethodName) + { + var keyTypeName = keyType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + EmitFactoryMethod(sb, keyTypeName, products, factoryMethodName, useServiceProvider: false); + + if (!string.IsNullOrWhiteSpace(serviceProviderFactoryMethodName)) + { + sb.AppendLine(); + EmitFactoryMethod(sb, keyTypeName, products, serviceProviderFactoryMethodName!, useServiceProvider: true); + } + + sb.AppendLine("}"); + return sb.ToString(); + } + + private static void EmitFactoryMethod( + StringBuilder sb, + string keyTypeName, + IReadOnlyList products, + string methodName, + bool useServiceProvider) + { + sb.Append(" public static global::PatternKit.Creational.AbstractFactory.AbstractFactory<") + .Append(keyTypeName) + .Append("> ") + .Append(methodName) + .Append(useServiceProvider ? "(global::System.IServiceProvider services)" : "()") + .AppendLine(); + sb.AppendLine(" {"); + if (useServiceProvider) + { + sb.AppendLine(" if (services is null)"); + sb.AppendLine(" throw new global::System.ArgumentNullException(nameof(services));"); + sb.AppendLine(); + } + + sb.Append(" var builder = global::PatternKit.Creational.AbstractFactory.AbstractFactory<") + .Append(keyTypeName) + .AppendLine(">.Create();"); + + foreach (var group in products.Where(static p => !p.IsDefaultFamily).GroupBy(static p => p.FamilyKeyExpression).OrderBy(static g => g.Key, System.StringComparer.Ordinal)) + { + sb.Append(" builder.Family(").Append(group.Key).AppendLine(");"); + foreach (var product in group.OrderBy(static p => p.ContractTypeName, System.StringComparer.Ordinal)) + EmitProduct(sb, product, useServiceProvider, defaultFamily: false); + } + + foreach (var product in products.Where(static p => p.IsDefaultFamily).OrderBy(static p => p.ContractTypeName, System.StringComparer.Ordinal)) + EmitProduct(sb, product, useServiceProvider, defaultFamily: true); + + sb.AppendLine(" return builder.Build();"); + sb.AppendLine(" }"); + } + + private static void EmitProduct(StringBuilder sb, Product product, bool useServiceProvider, bool defaultFamily) + { + sb.Append(defaultFamily ? " builder.DefaultProduct<" : " builder.Product<") + .Append(product.ContractTypeName) + .Append(">(() => "); + if (useServiceProvider) + { + sb.Append("global::Microsoft.Extensions.DependencyInjection.ActivatorUtilities.CreateInstance<") + .Append(product.ImplementationTypeName) + .Append(">(services)"); + } + else + { + sb.Append("new ").Append(product.ImplementationTypeName).Append("()"); + } + + sb.AppendLine(");"); + } + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string EscapeChar(char value) + => value switch + { + '\\' => "\\\\", + '\'' => "\\'", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + _ => value.ToString() + }; + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static bool GetNamedBool(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as bool? ?? false; + + private readonly record struct Product( + string FamilyKeyExpression, + string FamilyKeyText, + string ContractTypeName, + string ImplementationTypeName, + bool IsDefaultFamily, + Location? Location); +} diff --git a/src/PatternKit.Generators/Factories/FactoriesAttributes.cs b/src/PatternKit.Generators/Factories/FactoriesAttributes.cs index 0d218e7a..0d1a681e 100644 --- a/src/PatternKit.Generators/Factories/FactoriesAttributes.cs +++ b/src/PatternKit.Generators/Factories/FactoriesAttributes.cs @@ -1,6 +1,6 @@ namespace PatternKit.Generators.Factories; -[AttributeUsage(AttributeTargets.Class)] +[AttributeUsage(AttributeTargets.Class, Inherited = false)] public sealed class FactoryMethodAttribute(Type keyType) : Attribute { public Type KeyType { get; } = keyType; @@ -8,18 +8,18 @@ public sealed class FactoryMethodAttribute(Type keyType) : Attribute public bool CaseInsensitiveStrings { get; set; } = true; } -[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] public sealed class FactoryCaseAttribute(object key) : Attribute { public object Key { get; } = key; } -[AttributeUsage(AttributeTargets.Method)] +[AttributeUsage(AttributeTargets.Method, Inherited = false)] public sealed class FactoryDefaultAttribute : Attribute { } -[AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class)] +[AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class, Inherited = false)] public sealed class FactoryClassAttribute(Type keyType) : Attribute { public Type KeyType { get; } = keyType; @@ -28,8 +28,25 @@ public sealed class FactoryClassAttribute(Type keyType) : Attribute public bool GenerateEnumKeys { get; set; } = false; } -[AttributeUsage(AttributeTargets.Class)] +[AttributeUsage(AttributeTargets.Class, Inherited = false)] public sealed class FactoryClassKeyAttribute(object key) : Attribute { public object Key { get; } = key; } + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateAbstractFactoryAttribute(Type keyType) : Attribute +{ + public Type KeyType { get; } = keyType; + public string FactoryMethodName { get; set; } = "Create"; + public string? ServiceProviderFactoryMethodName { get; set; } +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)] +public sealed class AbstractFactoryProductAttribute(object familyKey, Type contractType, Type implementationType) : Attribute +{ + public object FamilyKey { get; } = familyKey; + public Type ContractType { get; } = contractType; + public Type ImplementationType { get; } = implementationType; + public bool IsDefaultFamily { get; set; } +} diff --git a/test/PatternKit.Examples.Tests/AbstractFactoryDemo/AbstractFactoryDemoTests.cs b/test/PatternKit.Examples.Tests/AbstractFactoryDemo/AbstractFactoryDemoTests.cs index 5886fe81..ef15d28b 100644 --- a/test/PatternKit.Examples.Tests/AbstractFactoryDemo/AbstractFactoryDemoTests.cs +++ b/test/PatternKit.Examples.Tests/AbstractFactoryDemo/AbstractFactoryDemoTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.DependencyInjection; using PatternKit.Examples.AbstractFactoryDemo; using static PatternKit.Examples.AbstractFactoryDemo.AbstractFactoryDemo; using TinyBDD; @@ -21,6 +22,31 @@ public void CreateUIFactory_Creates_All_Platform_Families() ScenarioExpect.NotNull(linuxFamily); } + [Scenario("Generated Factory Creates Platform Widget Families")] + [Fact] + public void Generated_Factory_Creates_Platform_Widget_Families() + { + var factory = GeneratedPlatformWidgetFactory.Create(); + + var windows = factory.GetFamily(Platform.Windows); + var linux = factory.GetFamily(Platform.Linux); + + ScenarioExpect.Contains("Windows Button", windows.Create().Render()); + ScenarioExpect.Contains("GTK Button", linux.Create().Render()); + } + + [Scenario("Generated Factory ServiceProvider Overload Creates Platform Widget Families")] + [Fact] + public void Generated_Factory_ServiceProvider_Overload_Creates_Platform_Widget_Families() + { + var services = new ServiceCollection().BuildServiceProvider(); + var factory = CreateUIFactory(services); + + var mac = factory.GetFamily(Platform.MacOS); + + ScenarioExpect.Contains("macOS Button", mac.Create().Render()); + } + [Scenario("Windows Family Creates All Widget Types")] [Fact] public void Windows_Family_Creates_All_Widget_Types() diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 5f86f70f..46ff37bc 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -6,6 +6,7 @@ using PatternKit.Examples.ProductionReadiness; using PatternKit.Examples.Strategies.Composed; using Showcase = PatternKit.Examples.PatternShowcase.PatternShowcase; +using WidgetDemo = PatternKit.Examples.AbstractFactoryDemo.AbstractFactoryDemo; using TinyBDD; using TinyBDD.Xunit; using Xunit.Abstractions; @@ -64,6 +65,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() private static IReadOnlyList<(string Name, bool Passed)> UseRegisteredExamples(ServiceProvider provider) { var coercion = provider.GetRequiredService(); + var abstractFactory = provider.GetRequiredService(); var notifications = provider.GetRequiredService(); var auth = provider.GetRequiredService(); var router = provider.GetRequiredService(); @@ -119,6 +121,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("integer coercer converts text", coercion.Integers.From("42") == 42), ("boolean coercer converts text", coercion.Booleans.From("true") == true), ("string coercer accepts strings", coercion.Strings.From("patternkit") == "patternkit"), + ("abstract factory creates DI-backed widget families", abstractFactory.Factory.GetFamily(WidgetDemo.Platform.Windows).Create().Render().Contains("Windows", StringComparison.Ordinal)), ("notification strategy sends", send.Success), ("auth chain logs denied admin requests", auth.Log.Contains("deny: missing auth", StringComparer.Ordinal)), ("minimal router returns a successful response", json.StatusCode == 200), diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index d3958dd9..c8c4b64d 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -120,7 +120,6 @@ public Task Each_Pattern_Has_Fluent_Generated_Documented_And_Example_Paths() ScenarioExpect.Equal( [ - "Abstract Factory has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/207", "Interpreter has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/206" ], tracked); }) diff --git a/test/PatternKit.Generators.Tests/AbstractFactoryGeneratorTests.cs b/test/PatternKit.Generators.Tests/AbstractFactoryGeneratorTests.cs new file mode 100644 index 00000000..e8790fd3 --- /dev/null +++ b/test/PatternKit.Generators.Tests/AbstractFactoryGeneratorTests.cs @@ -0,0 +1,435 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Factories; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class AbstractFactoryGeneratorTests +{ + [Scenario("Generates abstract factory families and service provider overload")] + [Fact] + public void GeneratesAbstractFactoryFamiliesAndServiceProviderOverload() + { + var source = """ + using System; + using PatternKit.Generators.Factories; + + namespace Microsoft.Extensions.DependencyInjection + { + public static class ActivatorUtilities + { + public static T CreateInstance(IServiceProvider services) where T : new() => new T(); + } + } + + namespace Demo + { + public enum Platform { Windows, Linux } + public interface IButton { string Render(); } + public interface ITextBox { string Render(); } + public sealed class WindowsButton : IButton { public string Render() => "windows-button"; } + public sealed class WindowsTextBox : ITextBox { public string Render() => "windows-text"; } + public sealed class LinuxButton : IButton { public string Render() => "linux-button"; } + public sealed class LinuxTextBox : ITextBox { public string Render() => "linux-text"; } + + [GenerateAbstractFactory(typeof(Platform), FactoryMethodName = "Build", ServiceProviderFactoryMethodName = "BuildFromServices")] + [AbstractFactoryProduct(Platform.Windows, typeof(IButton), typeof(WindowsButton))] + [AbstractFactoryProduct(Platform.Windows, typeof(ITextBox), typeof(WindowsTextBox))] + [AbstractFactoryProduct(Platform.Linux, typeof(IButton), typeof(LinuxButton))] + [AbstractFactoryProduct(Platform.Linux, typeof(ITextBox), typeof(LinuxTextBox))] + public static partial class WidgetFactory; + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesAbstractFactoryFamiliesAndServiceProviderOverload)); + var gen = new AbstractFactoryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + ScenarioExpect.Equal("WidgetFactory.AbstractFactory.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("Build()", text); + ScenarioExpect.Contains("BuildFromServices(global::System.IServiceProvider services)", text); + ScenarioExpect.Contains("builder.Family(global::Demo.Platform.Windows)", text); + ScenarioExpect.Contains("builder.Product(() => new global::Demo.WindowsButton())", text); + ScenarioExpect.Contains("ActivatorUtilities.CreateInstance(services)", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Reports diagnostic for non-partial abstract factory host")] + [Fact] + public void ReportsDiagnosticForNonPartialAbstractFactoryHost() + { + var source = """ + using PatternKit.Generators.Factories; + + namespace Demo; + + [GenerateAbstractFactory(typeof(string))] + public static class WidgetFactory; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialAbstractFactoryHost)); + var gen = new AbstractFactoryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKAF001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid abstract factory product")] + [Fact] + public void ReportsDiagnosticForInvalidAbstractFactoryProduct() + { + var source = """ + using PatternKit.Generators.Factories; + + namespace Demo; + + public interface IButton { } + public sealed class TextBox { } + + [GenerateAbstractFactory(typeof(string))] + [AbstractFactoryProduct("windows", typeof(IButton), typeof(TextBox))] + public static partial class WidgetFactory; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidAbstractFactoryProduct)); + var gen = new AbstractFactoryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKAF003", diagnostic.Id); + } + + [Scenario("Reports diagnostic for abstract factory without products")] + [Fact] + public void ReportsDiagnosticForAbstractFactoryWithoutProducts() + { + var source = """ + using PatternKit.Generators.Factories; + + namespace Demo; + + [GenerateAbstractFactory(typeof(string))] + public static partial class WidgetFactory; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForAbstractFactoryWithoutProducts)); + + ScenarioExpect.Equal("PKAF002", diagnostic.Id); + } + + [Scenario("Reports diagnostic for duplicate abstract factory products")] + [Fact] + public void ReportsDiagnosticForDuplicateAbstractFactoryProducts() + { + var source = """ + using PatternKit.Generators.Factories; + + namespace Demo; + + public interface IButton { } + public sealed class WindowsButton : IButton { } + public sealed class AlternateWindowsButton : IButton { } + + [GenerateAbstractFactory(typeof(string))] + [AbstractFactoryProduct("windows", typeof(IButton), typeof(WindowsButton))] + [AbstractFactoryProduct("windows", typeof(IButton), typeof(AlternateWindowsButton))] + public static partial class WidgetFactory; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForDuplicateAbstractFactoryProducts)); + + ScenarioExpect.Equal("PKAF004", diagnostic.Id); + } + + [Scenario("Reports diagnostic for incompatible abstract factory key")] + [Fact] + public void ReportsDiagnosticForIncompatibleAbstractFactoryKey() + { + var source = """ + using PatternKit.Generators.Factories; + + namespace Demo; + + public interface IButton { } + public sealed class WindowsButton : IButton { } + + [GenerateAbstractFactory(typeof(int))] + [AbstractFactoryProduct("windows", typeof(IButton), typeof(WindowsButton))] + public static partial class WidgetFactory; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForIncompatibleAbstractFactoryKey)); + + ScenarioExpect.Equal("PKAF003", diagnostic.Id); + } + + [Scenario("Reports diagnostic for abstract factory product without public parameterless constructor")] + [Fact] + public void ReportsDiagnosticForProductWithoutPublicParameterlessConstructor() + { + var source = """ + using PatternKit.Generators.Factories; + + namespace Demo; + + public interface IButton { } + public sealed class WindowsButton : IButton + { + public WindowsButton(string label) { } + } + + [GenerateAbstractFactory(typeof(string))] + [AbstractFactoryProduct("windows", typeof(IButton), typeof(WindowsButton))] + public static partial class WidgetFactory; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForProductWithoutPublicParameterlessConstructor)); + + ScenarioExpect.Equal("PKAF003", diagnostic.Id); + } + + [Scenario("Generates default products and omits service provider overload when not requested")] + [Fact] + public void GeneratesDefaultProductsAndOmitsServiceProviderOverloadWhenNotRequested() + { + var source = """ + using PatternKit.Generators.Factories; + + namespace Demo; + + public interface IButton { } + public class ButtonBase : IButton { } + public sealed class DefaultButton : ButtonBase { } + + [GenerateAbstractFactory(typeof(string), FactoryMethodName = "Build")] + [AbstractFactoryProduct("fallback", typeof(ButtonBase), typeof(DefaultButton), IsDefaultFamily = true)] + public static partial class WidgetFactory; + """; + + var comp = CreateCompilation(source, nameof(GeneratesDefaultProductsAndOmitsServiceProviderOverloadWhenNotRequested)); + var gen = new AbstractFactoryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + var text = generated.SourceText.ToString(); + + ScenarioExpect.Contains("Build()", text); + ScenarioExpect.Contains("builder.DefaultProduct(() => new global::Demo.DefaultButton())", text); + ScenarioExpect.DoesNotContain("IServiceProvider", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Formats escaped literal and null family keys")] + [Fact] + public void FormatsEscapedLiteralAndNullFamilyKeys() + { + var source = """ + using PatternKit.Generators.Factories; + + namespace Demo; + + public interface IButton { } + public sealed class QuoteButton : IButton { } + public sealed class NewLineButton : IButton { } + public sealed class NullButton : IButton { } + + [GenerateAbstractFactory(typeof(string))] + [AbstractFactoryProduct("win\"dows", typeof(IButton), typeof(QuoteButton))] + public static partial class StringLiteralFactory; + + [GenerateAbstractFactory(typeof(char))] + [AbstractFactoryProduct('\n', typeof(IButton), typeof(NewLineButton))] + public static partial class CharLiteralFactory; + + [GenerateAbstractFactory(typeof(object))] + [AbstractFactoryProduct(null, typeof(IButton), typeof(NullButton))] + public static partial class NullFactory; + """; + + var comp = CreateCompilation(source, nameof(FormatsEscapedLiteralAndNullFamilyKeys)); + var gen = new AbstractFactoryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = run.Results.SelectMany(result => result.GeneratedSources).ToArray(); + var stringLiteralFactory = ScenarioExpect.Single(generated.Where(source => source.HintName == "StringLiteralFactory.AbstractFactory.g.cs")); + var charLiteralFactory = ScenarioExpect.Single(generated.Where(source => source.HintName == "CharLiteralFactory.AbstractFactory.g.cs")); + var nullFactory = ScenarioExpect.Single(generated.Where(source => source.HintName == "NullFactory.AbstractFactory.g.cs")); + + ScenarioExpect.Contains("builder.Family(\"win\\\"dows\")", stringLiteralFactory.SourceText.ToString()); + ScenarioExpect.Contains("builder.Family('\\n')", charLiteralFactory.SourceText.ToString()); + ScenarioExpect.Contains("builder.Family(null!)", nullFactory.SourceText.ToString()); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Reports diagnostic for unnamed enum family key")] + [Fact] + public void ReportsDiagnosticForUnnamedEnumFamilyKey() + { + var source = """ + using PatternKit.Generators.Factories; + + namespace Demo; + + public enum Platform { Windows = 1 } + public interface IButton { } + public sealed class WindowsButton : IButton { } + + [GenerateAbstractFactory(typeof(Platform))] + [AbstractFactoryProduct((Platform)2, typeof(IButton), typeof(WindowsButton))] + public static partial class WidgetFactory; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForUnnamedEnumFamilyKey)); + + ScenarioExpect.Equal("PKAF003", diagnostic.Id); + } + + [Scenario("Reports diagnostic for abstract implementation type")] + [Fact] + public void ReportsDiagnosticForAbstractImplementationType() + { + var source = """ + using PatternKit.Generators.Factories; + + namespace Demo; + + public interface IButton { } + public abstract class AbstractButton : IButton { } + + [GenerateAbstractFactory(typeof(string))] + [AbstractFactoryProduct("windows", typeof(IButton), typeof(AbstractButton))] + public static partial class WidgetFactory; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForAbstractImplementationType)); + + ScenarioExpect.Equal("PKAF003", diagnostic.Id); + } + + [Scenario("Generates abstract factory for global struct host")] + [Fact] + public void GeneratesAbstractFactoryForGlobalStructHost() + { + var source = """ + using PatternKit.Generators.Factories; + + public interface IButton { } + public sealed class WindowsButton : IButton { } + + [GenerateAbstractFactory(typeof(uint))] + [AbstractFactoryProduct(7u, typeof(IButton), typeof(WindowsButton))] + public partial struct WidgetFactory; + """; + + var comp = CreateCompilation(source, nameof(GeneratesAbstractFactoryForGlobalStructHost)); + var gen = new AbstractFactoryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + var text = generated.SourceText.ToString(); + + ScenarioExpect.Contains("partial struct WidgetFactory", text); + ScenarioExpect.DoesNotContain("namespace ", text); + ScenarioExpect.Contains("builder.Family(7u)", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Formats primitive abstract factory family keys")] + [Fact] + public void FormatsPrimitiveAbstractFactoryFamilyKeys() + { + var source = """ + using PatternKit.Generators.Factories; + + namespace Demo; + + public class Product { } + + [GenerateAbstractFactory(typeof(bool))] + [AbstractFactoryProduct(true, typeof(Product), typeof(Product))] + public static partial class BoolFactory; + + [GenerateAbstractFactory(typeof(byte))] + [AbstractFactoryProduct((byte)1, typeof(Product), typeof(Product))] + public static partial class ByteFactory; + + [GenerateAbstractFactory(typeof(sbyte))] + [AbstractFactoryProduct((sbyte)-1, typeof(Product), typeof(Product))] + public static partial class SByteFactory; + + [GenerateAbstractFactory(typeof(short))] + [AbstractFactoryProduct((short)-2, typeof(Product), typeof(Product))] + public static partial class ShortFactory; + + [GenerateAbstractFactory(typeof(ushort))] + [AbstractFactoryProduct((ushort)2, typeof(Product), typeof(Product))] + public static partial class UShortFactory; + + [GenerateAbstractFactory(typeof(int))] + [AbstractFactoryProduct(-3, typeof(Product), typeof(Product))] + public static partial class IntFactory; + + [GenerateAbstractFactory(typeof(long))] + [AbstractFactoryProduct(-4L, typeof(Product), typeof(Product))] + public static partial class LongFactory; + + [GenerateAbstractFactory(typeof(ulong))] + [AbstractFactoryProduct(5UL, typeof(Product), typeof(Product))] + public static partial class ULongFactory; + """; + + var comp = CreateCompilation(source, nameof(FormatsPrimitiveAbstractFactoryFamilyKeys)); + var gen = new AbstractFactoryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var text = string.Join("\n", run.Results.SelectMany(result => result.GeneratedSources).Select(source => source.SourceText.ToString())); + + ScenarioExpect.Contains("builder.Family(true)", text); + ScenarioExpect.Contains("builder.Family(1)", text); + ScenarioExpect.Contains("builder.Family(-1)", text); + ScenarioExpect.Contains("builder.Family(-2)", text); + ScenarioExpect.Contains("builder.Family(2)", text); + ScenarioExpect.Contains("builder.Family(-3)", text); + ScenarioExpect.Contains("builder.Family(-4L)", text); + ScenarioExpect.Contains("builder.Family(5UL)", text); + ScenarioExpect.Contains("builder.Product(() => new global::Demo.Product())", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: + [ + MetadataReference.CreateFromFile(typeof(PatternKit.Creational.AbstractFactory.AbstractFactory<>).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IServiceProvider).Assembly.Location) + ]); + + private static Diagnostic RunAndGetSingleDiagnostic(string source, string assemblyName) + { + var comp = CreateCompilation(source, assemblyName); + var gen = new AbstractFactoryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + return ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + } +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index ed2e1902..2b318a14 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -7,6 +7,7 @@ using PatternKit.Generators.Decorator; using PatternKit.Generators.Facade; using PatternKit.Generators.Flyweight; +using PatternKit.Generators.Factories; using PatternKit.Generators.Iterator; using PatternKit.Generators.Messaging; using PatternKit.Generators.Observer; @@ -64,6 +65,13 @@ private enum TestTrigger { typeof(FacadeIgnoreAttribute), AttributeTargets.Method | AttributeTargets.Property, false, false }, { typeof(FlyweightAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(FlyweightFactoryAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateAbstractFactoryAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(AbstractFactoryProductAttribute), AttributeTargets.Class | AttributeTargets.Struct, true, false }, + { typeof(FactoryMethodAttribute), AttributeTargets.Class, false, false }, + { typeof(FactoryCaseAttribute), AttributeTargets.Method, true, false }, + { typeof(FactoryDefaultAttribute), AttributeTargets.Method, false, false }, + { typeof(FactoryClassAttribute), AttributeTargets.Interface | AttributeTargets.Class, false, false }, + { typeof(FactoryClassKeyAttribute), AttributeTargets.Class, false, false }, { typeof(IteratorAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(IteratorStepAttribute), AttributeTargets.Method, false, false }, { typeof(TraversalIteratorAttribute), AttributeTargets.Class, false, false }, @@ -321,6 +329,28 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf Threading = FlyweightThreadingPolicy.Concurrent, GenerateTryGet = false }; + var abstractFactory = new GenerateAbstractFactoryAttribute(typeof(DayOfWeek)) + { + FactoryMethodName = "BuildWidgets", + ServiceProviderFactoryMethodName = "BuildWidgetsFromServices" + }; + var abstractFactoryProduct = new AbstractFactoryProductAttribute(DayOfWeek.Monday, typeof(string), typeof(string)) + { + IsDefaultFamily = true + }; + var factoryMethod = new FactoryMethodAttribute(typeof(string)) + { + CreateMethodName = "Make", + CaseInsensitiveStrings = false + }; + var factoryCase = new FactoryCaseAttribute("email"); + var factoryClass = new FactoryClassAttribute(typeof(int)) + { + FactoryTypeName = "WidgetFactory", + GenerateTryCreate = false, + GenerateEnumKeys = true + }; + var factoryClassKey = new FactoryClassKeyAttribute(7); var iterator = new IteratorAttribute { GenerateEnumerator = false, @@ -407,6 +437,23 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal(FlyweightEviction.Lru, flyweight.Eviction); ScenarioExpect.Equal(FlyweightThreadingPolicy.Concurrent, flyweight.Threading); ScenarioExpect.False(flyweight.GenerateTryGet); + ScenarioExpect.Equal(typeof(DayOfWeek), abstractFactory.KeyType); + ScenarioExpect.Equal("BuildWidgets", abstractFactory.FactoryMethodName); + ScenarioExpect.Equal("BuildWidgetsFromServices", abstractFactory.ServiceProviderFactoryMethodName); + ScenarioExpect.Equal(DayOfWeek.Monday, abstractFactoryProduct.FamilyKey); + ScenarioExpect.Equal(typeof(string), abstractFactoryProduct.ContractType); + ScenarioExpect.Equal(typeof(string), abstractFactoryProduct.ImplementationType); + ScenarioExpect.True(abstractFactoryProduct.IsDefaultFamily); + ScenarioExpect.Equal(typeof(string), factoryMethod.KeyType); + ScenarioExpect.Equal("Make", factoryMethod.CreateMethodName); + ScenarioExpect.False(factoryMethod.CaseInsensitiveStrings); + ScenarioExpect.Equal("email", factoryCase.Key); + ScenarioExpect.IsType(new FactoryDefaultAttribute()); + ScenarioExpect.Equal(typeof(int), factoryClass.KeyType); + ScenarioExpect.Equal("WidgetFactory", factoryClass.FactoryTypeName); + ScenarioExpect.False(factoryClass.GenerateTryCreate); + ScenarioExpect.True(factoryClass.GenerateEnumKeys); + ScenarioExpect.Equal(7, factoryClassKey.Key); ScenarioExpect.False(iterator.GenerateEnumerator); ScenarioExpect.False(iterator.GenerateTryMoveNext); ScenarioExpect.Equal("Demo.Dispatching", dispatcher.Namespace);