diff --git a/docs/generators/singleton.md b/docs/generators/singleton.md new file mode 100644 index 0000000..44b11b8 --- /dev/null +++ b/docs/generators/singleton.md @@ -0,0 +1,443 @@ +# Singleton Generator + +## Overview + +The **Singleton Generator** creates thread-safe singleton implementations with explicit initialization and threading semantics. It eliminates common singleton footguns like incorrect lazy initialization and double-checked locking bugs. + +## When to Use + +Use the Singleton generator when you need: + +- **A single instance** of a class throughout the application lifecycle +- **Explicit initialization timing**: Control whether the instance is created eagerly or lazily +- **Thread-safety guarantees**: Configurable threading model for your use case +- **Compile-time safety**: The generator validates your singleton at build time + +## Installation + +The generator is included in the `PatternKit.Generators` package: + +```bash +dotnet add package PatternKit.Generators +``` + +## Quick Start + +```csharp +using PatternKit.Generators.Singleton; + +[Singleton] +public partial class AppClock +{ + public DateTime Now => DateTime.UtcNow; + + private AppClock() { } +} +``` + +Generated: +```csharp +public partial class AppClock +{ + private static readonly AppClock __PatternKit_Instance = new AppClock(); + + /// Gets the singleton instance of this type. + public static AppClock Instance => __PatternKit_Instance; +} +``` + +Usage: +```csharp +var now = AppClock.Instance.Now; +``` + +## Initialization Modes + +### Eager Initialization (Default) + +The instance is created when the type is first accessed. This is the simplest and safest approach: + +```csharp +[Singleton] // Mode defaults to SingletonMode.Eager +public partial class Configuration +{ + public string ConnectionString { get; } + + private Configuration() + { + ConnectionString = Environment.GetEnvironmentVariable("CONNECTION_STRING") ?? ""; + } +} +``` + +Generated: +```csharp +private static readonly Configuration __PatternKit_Instance = new Configuration(); +public static Configuration Instance => __PatternKit_Instance; +``` + +**Pros:** +- Simple and thread-safe by CLR guarantee +- No runtime overhead on access +- Deterministic initialization + +**Cons:** +- Instance created even if never accessed +- Initialization order depends on type access order + +### Lazy Initialization + +The instance is created on first access to the `Instance` property: + +```csharp +[Singleton(Mode = SingletonMode.Lazy)] +public partial class ExpensiveService +{ + private ExpensiveService() + { + // Expensive initialization here + } +} +``` + +Generated (thread-safe): +```csharp +private static readonly Lazy __PatternKit_LazyInstance = + new Lazy(() => new ExpensiveService()); + +public static ExpensiveService Instance => __PatternKit_LazyInstance.Value; +``` + +**Pros:** +- Instance only created when actually needed +- Can reduce startup time for rarely-used services + +**Cons:** +- Slight runtime overhead on first access +- Less predictable initialization timing + +## Threading Options + +When using `SingletonMode.Lazy`, you can configure the threading model: + +### ThreadSafe (Default) + +Uses `Lazy` which is thread-safe by default: + +```csharp +[Singleton(Mode = SingletonMode.Lazy, Threading = SingletonThreading.ThreadSafe)] +public partial class SafeCache { } +``` + +### SingleThreadedFast + +For scenarios where you guarantee single-threaded access: + +```csharp +[Singleton(Mode = SingletonMode.Lazy, Threading = SingletonThreading.SingleThreadedFast)] +public partial class UiService +{ + // Only accessed from UI thread + private UiService() { } +} +``` + +Generated: +```csharp +private static UiService? __PatternKit_Instance; + +/// +/// Gets the singleton instance of this type. +/// WARNING: This implementation is not thread-safe. +/// +public static UiService Instance => __PatternKit_Instance ??= new UiService(); +``` + +⚠️ **Warning:** Only use `SingleThreadedFast` when you can guarantee single-threaded access. Multi-threaded access may result in multiple instances being created. + +## Custom Factory Methods + +When your singleton needs custom initialization logic, use `[SingletonFactory]`: + +```csharp +[Singleton(Mode = SingletonMode.Lazy)] +public partial class ConfigManager +{ + public string ConfigPath { get; } + + private ConfigManager(string path) + { + ConfigPath = path; + } + + [SingletonFactory] + private static ConfigManager Create() + { + var path = Environment.GetEnvironmentVariable("CONFIG_PATH") ?? "config.json"; + return new ConfigManager(path); + } +} +``` + +Factory method requirements: +- Must be `static` +- Must be parameterless +- Must return the containing type +- Only one factory method per type + +## Attributes + +### `[Singleton]` + +Marks a partial class for singleton generation. + +| Property | Type | Default | Description | +|---|---|---|---| +| `Mode` | `SingletonMode` | `Eager` | When the instance is created | +| `Threading` | `SingletonThreading` | `ThreadSafe` | Threading model (for Lazy mode) | +| `InstancePropertyName` | `string` | `"Instance"` | Name of the generated property | + +### `[SingletonFactory]` + +Marks a static method as the factory for creating the singleton instance. + +```csharp +[SingletonFactory] +private static MyService Create() => new MyService(); +``` + +## Supported Types + +The generator supports: + +| Type | Supported | +|---|---| +| `partial class` | ✅ | +| `partial record class` | ✅ | +| `struct` | ❌ (singleton value semantics are odd) | +| `interface` | ❌ | + +## Diagnostics + +| ID | Severity | Description | +|---|---|---| +| **PKSNG001** | Error | Type marked with `[Singleton]` must be `partial` | +| **PKSNG002** | Error | Singleton type must be a class (not struct/interface) | +| **PKSNG003** | Error | No usable constructor or `[SingletonFactory]` found | +| **PKSNG004** | Error | Multiple `[SingletonFactory]` methods found | +| **PKSNG005** | Warning | Public constructor detected; singleton can be bypassed | +| **PKSNG006** | Error | Instance property name conflicts with existing member | +| **PKSNG007** | Error | Generic types are not supported for singleton generation | +| **PKSNG008** | Error | Nested types are not supported for singleton generation | +| **PKSNG009** | Error | Invalid instance property name (not a valid C# identifier) | +| **PKSNG010** | Error | Abstract types not supported (unless `[SingletonFactory]` provided) | + +## Best Practices + +### 1. Make Constructors Private + +Always make your constructor private to prevent bypassing the singleton: + +```csharp +// ✅ Good: Private constructor +[Singleton] +public partial class Logger +{ + private Logger() { } +} + +// ⚠️ Bad: Public constructor (generates PKSNG005 warning) +[Singleton] +public partial class Logger +{ + public Logger() { } // Anyone can create instances! +} +``` + +### 2. Prefer Eager Initialization + +Unless you have a specific reason for lazy initialization, prefer eager mode: + +```csharp +// ✅ Preferred: Simple, predictable, no overhead +[Singleton] +public partial class AppConfig { } + +// Only use lazy when needed +[Singleton(Mode = SingletonMode.Lazy)] +public partial class ExpensiveToCreate { } +``` + +### 3. Avoid Mutable State + +Singletons with mutable state can cause subtle bugs: + +```csharp +// ⚠️ Caution: Mutable singleton state +[Singleton] +public partial class Counter +{ + public int Value { get; set; } // Shared mutable state +} + +// ✅ Better: Immutable or thread-safe state +[Singleton] +public partial class Counter +{ + private int _value; + public int Value => _value; + public int Increment() => Interlocked.Increment(ref _value); +} +``` + +### 4. Use Custom Property Names When Needed + +Avoid conflicts with existing members: + +```csharp +[Singleton(InstancePropertyName = "Default")] +public partial class Settings +{ + // Can't use "Instance" if it already exists + public int Instance { get; set; } +} +``` + +## Examples + +### Configuration Manager + +```csharp +using PatternKit.Generators.Singleton; + +[Singleton(Mode = SingletonMode.Lazy)] +public partial class ConfigManager +{ + public string AppName { get; } + public string Environment { get; } + public string ConnectionString { get; } + + private ConfigManager(string appName, string env, string connStr) + { + AppName = appName; + Environment = env; + ConnectionString = connStr; + } + + [SingletonFactory] + private static ConfigManager Create() + { + // Load from environment or config file + return new ConfigManager( + Environment.GetEnvironmentVariable("APP_NAME") ?? "MyApp", + Environment.GetEnvironmentVariable("ENVIRONMENT") ?? "Development", + Environment.GetEnvironmentVariable("CONNECTION_STRING") ?? ""); + } +} + +// Usage +var config = ConfigManager.Instance; +Console.WriteLine($"Running {config.AppName} in {config.Environment}"); +``` + +### Service Locator + +```csharp +[Singleton] +public partial class ServiceLocator +{ + private readonly Dictionary _services = new(); + + private ServiceLocator() { } + + public void Register(T service) where T : class + { + _services[typeof(T)] = service; + } + + public T Resolve() where T : class + { + return (T)_services[typeof(T)]; + } +} + +// Usage +ServiceLocator.Instance.Register(new ConsoleLogger()); +var logger = ServiceLocator.Instance.Resolve(); +``` + +### Application Clock + +```csharp +[Singleton] +public partial class AppClock +{ + public DateTime UtcNow => DateTime.UtcNow; + public DateTimeOffset Now => DateTimeOffset.Now; + + private AppClock() { } +} + +// Usage - consistent time source throughout app +var timestamp = AppClock.Instance.UtcNow; +``` + +## Troubleshooting + +### PKSNG001: Type must be partial + +**Cause:** Missing `partial` keyword. + +**Fix:** +```csharp +// ❌ Wrong +[Singleton] +public class MySingleton { } + +// ✅ Correct +[Singleton] +public partial class MySingleton { } +``` + +### PKSNG003: No usable constructor or factory + +**Cause:** Type has no parameterless constructor and no factory method. + +**Fix:** Add a parameterless constructor or factory: +```csharp +// Option 1: Parameterless constructor +[Singleton] +public partial class MySingleton +{ + private MySingleton() { } +} + +// Option 2: Factory method +[Singleton] +public partial class MySingleton +{ + private MySingleton(string config) { } + + [SingletonFactory] + private static MySingleton Create() => new("default.json"); +} +``` + +### PKSNG005: Public constructor warning + +**Cause:** Public constructor allows bypassing singleton. + +**Fix:** Make constructor private: +```csharp +// ❌ Generates warning +public MySingleton() { } + +// ✅ No warning +private MySingleton() { } +``` + +## See Also + +- [Memento Generator](memento.md) — For saving/restoring singleton state +- [Prototype Generator](prototype.md) — For cloning patterns +- [GoF: Singleton](https://en.wikipedia.org/wiki/Singleton_pattern) diff --git a/src/PatternKit.Examples/SingletonGeneratorDemo/AppClock.cs b/src/PatternKit.Examples/SingletonGeneratorDemo/AppClock.cs new file mode 100644 index 0000000..0ae8774 --- /dev/null +++ b/src/PatternKit.Examples/SingletonGeneratorDemo/AppClock.cs @@ -0,0 +1,26 @@ +using PatternKit.Generators.Singleton; + +namespace PatternKit.Examples.SingletonGeneratorDemo; + +/// +/// Application clock singleton using eager initialization. +/// Provides a consistent time source throughout the application, +/// which is especially useful for testing (can be mocked). +/// +[Singleton] // Defaults to eager initialization +public partial class AppClock +{ + /// Gets the current UTC time. + public DateTime UtcNow => DateTime.UtcNow; + + /// Gets the current local time. + public DateTimeOffset Now => DateTimeOffset.Now; + + /// Gets the current date (UTC). + public DateOnly Today => DateOnly.FromDateTime(DateTime.UtcNow); + + /// Gets the Unix timestamp in seconds. + public long UnixTimestamp => DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + private AppClock() { } +} diff --git a/src/PatternKit.Examples/SingletonGeneratorDemo/ConfigManager.cs b/src/PatternKit.Examples/SingletonGeneratorDemo/ConfigManager.cs new file mode 100644 index 0000000..8385db6 --- /dev/null +++ b/src/PatternKit.Examples/SingletonGeneratorDemo/ConfigManager.cs @@ -0,0 +1,57 @@ +using PatternKit.Generators.Singleton; + +namespace PatternKit.Examples.SingletonGeneratorDemo; + +/// +/// Configuration manager singleton using lazy initialization with custom factory. +/// Demonstrates real-world pattern for managing application configuration. +/// +[Singleton(Mode = SingletonMode.Lazy)] +public partial class ConfigManager +{ + /// Gets the application name. + public string AppName { get; } + + /// Gets the current environment (Development, Staging, Production). + public string Environment { get; } + + /// Gets the database connection string. + public string ConnectionString { get; } + + /// Gets whether debug logging is enabled. + public bool DebugLogging { get; } + + /// Gets the configuration load timestamp. + public DateTime LoadedAt { get; } + + private ConfigManager(string appName, string environment, string connectionString, bool debugLogging) + { + AppName = appName; + Environment = environment; + ConnectionString = connectionString; + DebugLogging = debugLogging; + LoadedAt = DateTime.UtcNow; + } + + /// + /// Factory method that loads configuration from environment variables. + /// In a real application, this might read from a config file or service. + /// + [SingletonFactory] + private static ConfigManager Create() + { + // In a real app, this would read from appsettings.json, environment, etc. + var appName = System.Environment.GetEnvironmentVariable("APP_NAME") ?? "PatternKitDemo"; + var environment = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; + var connectionString = System.Environment.GetEnvironmentVariable("CONNECTION_STRING") ?? "Server=localhost;Database=Demo"; + var debugLogging = System.Environment.GetEnvironmentVariable("DEBUG_LOGGING") == "true"; + + return new ConfigManager(appName, environment, connectionString, debugLogging); + } + + /// + /// Returns a string representation of the current configuration. + /// + public override string ToString() => + $"ConfigManager[App={AppName}, Env={Environment}, Debug={DebugLogging}, LoadedAt={LoadedAt:HH:mm:ss}]"; +} diff --git a/src/PatternKit.Examples/SingletonGeneratorDemo/ServiceRegistry.cs b/src/PatternKit.Examples/SingletonGeneratorDemo/ServiceRegistry.cs new file mode 100644 index 0000000..bd504c0 --- /dev/null +++ b/src/PatternKit.Examples/SingletonGeneratorDemo/ServiceRegistry.cs @@ -0,0 +1,83 @@ +using PatternKit.Generators.Singleton; +using System.Collections.Concurrent; + +namespace PatternKit.Examples.SingletonGeneratorDemo; + +/// +/// Simple service registry singleton demonstrating thread-safe lazy initialization. +/// In production, consider using a proper DI container like Microsoft.Extensions.DependencyInjection. +/// +[Singleton(Mode = SingletonMode.Lazy, Threading = SingletonThreading.ThreadSafe)] +public partial class ServiceRegistry +{ + // Use Lazy for thread-safe single-call factory semantics + private readonly ConcurrentDictionary> _services = new(); + + private ServiceRegistry() { } + + /// + /// Registers a service instance. + /// + public void Register(TService service) where TService : class + { + ArgumentNullException.ThrowIfNull(service); + _services[typeof(TService)] = new Lazy(() => service); + } + + /// + /// Registers a factory for lazy service creation. + /// The factory is guaranteed to be called at most once, even under concurrent access. + /// + public void RegisterFactory(Func factory) where TService : class + { + ArgumentNullException.ThrowIfNull(factory); + _services[typeof(TService)] = new Lazy(() => factory()); + } + + /// + /// Resolves a registered service. + /// + /// Service not registered. + public TService Resolve() where TService : class + { + var type = typeof(TService); + + if (_services.TryGetValue(type, out var lazy)) + { + return (TService)lazy.Value; + } + + throw new InvalidOperationException($"Service {type.Name} is not registered."); + } + + /// + /// Tries to resolve a service, returning null if not found. + /// + public TService? TryResolve() where TService : class + { + var type = typeof(TService); + + if (_services.TryGetValue(type, out var lazy)) + { + return (TService)lazy.Value; + } + + return null; + } + + /// + /// Checks if a service is registered. + /// + public bool IsRegistered() where TService : class + { + return _services.ContainsKey(typeof(TService)); + } + + /// + /// Clears all registrations. Useful for testing. + /// + public void Clear() + { + _services.Clear(); + } +} diff --git a/src/PatternKit.Generators.Abstractions/Singleton/SingletonAttributes.cs b/src/PatternKit.Generators.Abstractions/Singleton/SingletonAttributes.cs new file mode 100644 index 0000000..e6d8c55 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Singleton/SingletonAttributes.cs @@ -0,0 +1,98 @@ +namespace PatternKit.Generators.Singleton; + +/// +/// Marks a partial class or record class for Singleton pattern code generation. +/// Generates a thread-safe singleton instance accessor with configurable initialization mode. +/// +/// +/// The generator supports two initialization modes: +/// +/// Eager: Instance is created when the type is first accessed (static field initializer) +/// Lazy: Instance is created on first access to the Instance property +/// +/// For Lazy mode, thread-safety is configurable via the property. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class SingletonAttribute : Attribute +{ + /// + /// Gets or sets the singleton initialization mode. + /// Default is . + /// + public SingletonMode Mode { get; set; } = SingletonMode.Eager; + + /// + /// Gets or sets the threading model for singleton access. + /// Default is . + /// Only applies when is . + /// + public SingletonThreading Threading { get; set; } = SingletonThreading.ThreadSafe; + + /// + /// Gets or sets the name of the generated singleton instance property. + /// Default is "Instance". + /// + public string InstancePropertyName { get; set; } = "Instance"; +} + +/// +/// Specifies when the singleton instance is created. +/// +public enum SingletonMode +{ + /// + /// Instance is created when the type is first accessed. + /// Uses a static field initializer for simple, thread-safe initialization. + /// + Eager = 0, + + /// + /// Instance is created on first access to the Instance property. + /// Uses for thread-safe lazy initialization. + /// + Lazy = 1 +} + +/// +/// Specifies the threading model for singleton instance access. +/// +public enum SingletonThreading +{ + /// + /// Thread-safe singleton access using locks or Lazy<T>. + /// Recommended for most scenarios. + /// + ThreadSafe = 0, + + /// + /// No thread synchronization. Faster but only safe in single-threaded scenarios. + /// WARNING: May result in multiple instance creation if accessed from multiple threads. + /// + SingleThreadedFast = 1 +} + +/// +/// Marks a static method as the factory for creating the singleton instance. +/// The method must be static, parameterless, and return the containing type. +/// +/// +/// Use this when the singleton requires custom initialization logic +/// beyond what a parameterless constructor can provide. +/// Only one method in a type may be marked with this attribute. +/// +/// +/// +/// [Singleton(Mode = SingletonMode.Lazy)] +/// public partial class ConfigManager +/// { +/// private ConfigManager(string path) { } +/// +/// [SingletonFactory] +/// private static ConfigManager Create() => new ConfigManager("config.json"); +/// } +/// +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class SingletonFactoryAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 52049f2..a9215a7 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -88,4 +88,13 @@ PKPRO007 | PatternKit.Generators.Prototype | Error | DeepCopy strategy not yet i PKPRO008 | PatternKit.Generators.Prototype | Error | Generic types not supported for Prototype pattern PKPRO009 | PatternKit.Generators.Prototype | Error | Nested types not supported for Prototype pattern PKPRO010 | PatternKit.Generators.Prototype | Error | Abstract types not supported for Prototype pattern - +PKSNG001 | PatternKit.Generators.Singleton | Error | Type marked with [Singleton] must be partial +PKSNG002 | PatternKit.Generators.Singleton | Error | Singleton type must be a class +PKSNG003 | PatternKit.Generators.Singleton | Error | No usable constructor or factory method found +PKSNG004 | PatternKit.Generators.Singleton | Error | Multiple [SingletonFactory] methods found +PKSNG005 | PatternKit.Generators.Singleton | Warning | Public constructor detected +PKSNG006 | PatternKit.Generators.Singleton | Error | Instance property name conflicts with existing member +PKSNG007 | PatternKit.Generators.Singleton | Error | Generic types are not supported +PKSNG008 | PatternKit.Generators.Singleton | Error | Nested types are not supported +PKSNG009 | PatternKit.Generators.Singleton | Error | Invalid instance property name +PKSNG010 | PatternKit.Generators.Singleton | Error | Abstract types not supported for Singleton pattern diff --git a/src/PatternKit.Generators/Singleton/SingletonGenerator.cs b/src/PatternKit.Generators/Singleton/SingletonGenerator.cs new file mode 100644 index 0000000..3da296f --- /dev/null +++ b/src/PatternKit.Generators/Singleton/SingletonGenerator.cs @@ -0,0 +1,494 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Text; + +namespace PatternKit.Generators.Singleton; + +/// +/// Source generator for the Singleton pattern. +/// Generates thread-safe singleton instance accessors with configurable initialization modes. +/// +[Generator] +public sealed class SingletonGenerator : IIncrementalGenerator +{ + // Diagnostic IDs + private const string DiagIdTypeNotPartial = "PKSNG001"; + private const string DiagIdNotClass = "PKSNG002"; + private const string DiagIdNoConstructorOrFactory = "PKSNG003"; + private const string DiagIdMultipleFactories = "PKSNG004"; + private const string DiagIdPublicConstructor = "PKSNG005"; + private const string DiagIdNameConflict = "PKSNG006"; + private const string DiagIdGenericType = "PKSNG007"; + private const string DiagIdNestedType = "PKSNG008"; + private const string DiagIdInvalidPropertyName = "PKSNG009"; + private const string DiagIdAbstractType = "PKSNG010"; + + private static readonly DiagnosticDescriptor TypeNotPartialDescriptor = new( + id: DiagIdTypeNotPartial, + title: "Type marked with [Singleton] must be partial", + messageFormat: "Type '{0}' is marked with [Singleton] but is not declared as partial. Add the 'partial' keyword to the type declaration.", + category: "PatternKit.Generators.Singleton", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NotClassDescriptor = new( + id: DiagIdNotClass, + title: "Singleton type must be a class", + messageFormat: "Type '{0}' is marked with [Singleton] but is not a class. Only classes and record classes are supported for singleton generation.", + category: "PatternKit.Generators.Singleton", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NoConstructorOrFactoryDescriptor = new( + id: DiagIdNoConstructorOrFactory, + title: "No usable constructor or factory method found", + messageFormat: "Type '{0}' has no accessible parameterless constructor and no method marked with [SingletonFactory]. Add a parameterless constructor or mark a static factory method with [SingletonFactory].", + category: "PatternKit.Generators.Singleton", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor MultipleFactoriesDescriptor = new( + id: DiagIdMultipleFactories, + title: "Multiple [SingletonFactory] methods found", + messageFormat: "Type '{0}' has multiple methods marked with [SingletonFactory]. Only one factory method is allowed per singleton type.", + category: "PatternKit.Generators.Singleton", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor PublicConstructorDescriptor = new( + id: DiagIdPublicConstructor, + title: "Public constructor detected", + messageFormat: "Type '{0}' has a public constructor. The singleton pattern can be bypassed by direct instantiation. Consider making the constructor private.", + category: "PatternKit.Generators.Singleton", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NameConflictDescriptor = new( + id: DiagIdNameConflict, + title: "Instance property name conflicts with existing member", + messageFormat: "The instance property name '{0}' conflicts with an existing member in type '{1}'. Use InstancePropertyName to specify a different name.", + category: "PatternKit.Generators.Singleton", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor GenericTypeDescriptor = new( + id: DiagIdGenericType, + title: "Generic types are not supported", + messageFormat: "Type '{0}' is a generic type. Generic types are not supported for singleton generation.", + category: "PatternKit.Generators.Singleton", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NestedTypeDescriptor = new( + id: DiagIdNestedType, + title: "Nested types are not supported", + messageFormat: "Type '{0}' is a nested type. Nested types are not supported for singleton generation.", + category: "PatternKit.Generators.Singleton", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor InvalidPropertyNameDescriptor = new( + id: DiagIdInvalidPropertyName, + title: "Invalid instance property name", + messageFormat: "The instance property name '{0}' is not a valid C# identifier.", + category: "PatternKit.Generators.Singleton", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor AbstractTypeDescriptor = new( + id: DiagIdAbstractType, + title: "Abstract types not supported for Singleton pattern", + messageFormat: "Type '{0}' is abstract and cannot be directly instantiated. Either provide a [SingletonFactory] method or use a concrete type.", + category: "PatternKit.Generators.Singleton", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Find all type declarations with [Singleton] attribute + var singletonTypes = context.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "PatternKit.Generators.Singleton.SingletonAttribute", + predicate: static (node, _) => node is TypeDeclarationSyntax, + transform: static (ctx, _) => ctx + ); + + // Generate for each type + context.RegisterSourceOutput(singletonTypes, (spc, typeContext) => + { + if (typeContext.TargetSymbol is not INamedTypeSymbol typeSymbol) + return; + + var attr = typeContext.Attributes.FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Singleton.SingletonAttribute"); + if (attr is null) + return; + + GenerateSingletonForType(spc, typeSymbol, attr, typeContext.TargetNode); + }); + } + + private void GenerateSingletonForType( + SourceProductionContext context, + INamedTypeSymbol typeSymbol, + AttributeData attribute, + SyntaxNode node) + { + // Check if type is partial + if (!IsPartialType(node)) + { + context.ReportDiagnostic(Diagnostic.Create( + TypeNotPartialDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + // Check if type is a class (not struct or interface) + if (typeSymbol.TypeKind != TypeKind.Class) + { + context.ReportDiagnostic(Diagnostic.Create( + NotClassDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + // Check for unsupported generic types + if (typeSymbol.IsGenericType) + { + context.ReportDiagnostic(Diagnostic.Create( + GenericTypeDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + // Check for unsupported nested types + if (typeSymbol.ContainingType != null) + { + context.ReportDiagnostic(Diagnostic.Create( + NestedTypeDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + // Find factory method early to check if abstract types have one + var factoryMethods = FindFactoryMethods(typeSymbol); + if (factoryMethods.Count > 1) + { + context.ReportDiagnostic(Diagnostic.Create( + MultipleFactoriesDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + var factoryMethod = factoryMethods.FirstOrDefault(); + + // Check for unsupported abstract types (unless they have a factory method) + if (typeSymbol.IsAbstract && factoryMethod is null) + { + context.ReportDiagnostic(Diagnostic.Create( + AbstractTypeDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + // Parse attribute arguments + var config = ParseSingletonConfig(attribute); + + // Validate InstancePropertyName + if (!IsValidCSharpIdentifier(config.InstancePropertyName)) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidPropertyNameDescriptor, + node.GetLocation(), + config.InstancePropertyName ?? "(null)")); + return; + } + + // Check for name conflicts with existing members + if (HasNameConflict(typeSymbol, config.InstancePropertyName)) + { + context.ReportDiagnostic(Diagnostic.Create( + NameConflictDescriptor, + node.GetLocation(), + config.InstancePropertyName, + typeSymbol.Name)); + return; + } + + // Check for usable constructor or factory + var hasParameterlessConstructor = HasAccessibleParameterlessConstructor(typeSymbol); + if (!hasParameterlessConstructor && factoryMethod is null) + { + context.ReportDiagnostic(Diagnostic.Create( + NoConstructorOrFactoryDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + // Warn about public constructors + if (HasPublicConstructor(typeSymbol)) + { + context.ReportDiagnostic(Diagnostic.Create( + PublicConstructorDescriptor, + node.GetLocation(), + typeSymbol.Name)); + } + + // Create type info + var typeInfo = new SingletonTypeInfo + { + TypeSymbol = typeSymbol, + TypeName = typeSymbol.Name, + Namespace = typeSymbol.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : typeSymbol.ContainingNamespace.ToDisplayString(), + IsRecordClass = typeSymbol.IsRecord, + FactoryMethodName = factoryMethod?.Name + }; + + // Generate singleton code + var source = GenerateSingletonCode(typeInfo, config); + if (!string.IsNullOrEmpty(source)) + { + // Use full type name (namespace + type) to avoid collisions when types share the same name + var hintName = typeSymbol.ContainingNamespace.IsGlobalNamespace + ? $"{typeSymbol.Name}.Singleton.g.cs" + : $"{typeSymbol.ContainingNamespace.ToDisplayString()}.{typeSymbol.Name}.Singleton.g.cs"; + context.AddSource(hintName, source); + } + } + + private static bool IsPartialType(SyntaxNode node) + { + return node switch + { + ClassDeclarationSyntax classDecl => classDecl.Modifiers.Any(SyntaxKind.PartialKeyword), + RecordDeclarationSyntax recordDecl => recordDecl.Modifiers.Any(SyntaxKind.PartialKeyword), + StructDeclarationSyntax structDecl => structDecl.Modifiers.Any(SyntaxKind.PartialKeyword), + _ => false + }; + } + + private static SingletonConfig ParseSingletonConfig(AttributeData attribute) + { + var config = new SingletonConfig(); + + foreach (var named in attribute.NamedArguments) + { + switch (named.Key) + { + case "Mode": + var modeValue = (int)named.Value.Value!; + if (Enum.IsDefined(typeof(SingletonModeValue), modeValue)) + { + config.Mode = (SingletonModeValue)modeValue; + } + // Invalid values default to Eager (the default) + break; + case "Threading": + var threadingValue = (int)named.Value.Value!; + if (Enum.IsDefined(typeof(SingletonThreadingValue), threadingValue)) + { + config.Threading = (SingletonThreadingValue)threadingValue; + } + // Invalid values default to ThreadSafe (the default) + break; + case "InstancePropertyName": + config.InstancePropertyName = (string)named.Value.Value!; + break; + } + } + + return config; + } + + private static bool HasNameConflict(INamedTypeSymbol typeSymbol, string propertyName) + { + // Normalize verbatim identifiers by stripping leading @ + var normalizedName = propertyName.StartsWith("@") ? propertyName.Substring(1) : propertyName; + + // Check for existing members with the same name in declared members + if (typeSymbol.GetMembers(normalizedName).Length > 0) + return true; + + // Also check inherited members by walking base types + var baseType = typeSymbol.BaseType; + while (baseType != null && baseType.SpecialType != SpecialType.System_Object) + { + if (baseType.GetMembers(normalizedName).Any(m => m.DeclaredAccessibility != Accessibility.Private)) + return true; + baseType = baseType.BaseType; + } + + return false; + } + + private static List FindFactoryMethods(INamedTypeSymbol typeSymbol) + { + return typeSymbol.GetMembers() + .OfType() + .Where(m => m.IsStatic && + m.Parameters.Length == 0 && + m.TypeParameters.Length == 0 && // Exclude generic methods + HasAttribute(m, "PatternKit.Generators.Singleton.SingletonFactoryAttribute") && + SymbolEqualityComparer.Default.Equals(m.ReturnType, typeSymbol)) + .ToList(); + } + + private static bool HasAttribute(ISymbol symbol, string attributeFullName) + { + return symbol.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == attributeFullName); + } + + private static bool HasAccessibleParameterlessConstructor(INamedTypeSymbol typeSymbol) + { + return typeSymbol.InstanceConstructors.Any(c => c.Parameters.Length == 0); + } + + private static bool IsValidCSharpIdentifier(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return false; + + // Use Roslyn's parser for accurate C# identifier validation + // name is guaranteed non-null here due to the check above + var token = SyntaxFactory.ParseToken(name!); + + // Must be a valid identifier token with no trailing trivia/errors + // and must consume the entire input + return token.IsKind(SyntaxKind.IdentifierToken) && + token.Text == name && + !token.IsMissing; + } + + private static bool HasPublicConstructor(INamedTypeSymbol typeSymbol) + { + // Check for explicit public constructors + var hasExplicitPublicCtor = typeSymbol.InstanceConstructors.Any(c => + c.DeclaredAccessibility == Accessibility.Public && + !c.IsImplicitlyDeclared); + + if (hasExplicitPublicCtor) + return true; + + // Check for implicit public constructor (class with no declared constructors) + // A class with no constructors has an implicit public parameterless ctor + var hasOnlyImplicitCtor = typeSymbol.InstanceConstructors.All(c => c.IsImplicitlyDeclared); + if (hasOnlyImplicitCtor && typeSymbol.InstanceConstructors.Any(c => + c.DeclaredAccessibility == Accessibility.Public)) + { + return true; + } + + return false; + } + + private string GenerateSingletonCode(SingletonTypeInfo typeInfo, SingletonConfig config) + { + var sb = new StringBuilder(); + + // File header + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + // Namespace + if (!string.IsNullOrEmpty(typeInfo.Namespace)) + { + sb.AppendLine($"namespace {typeInfo.Namespace};"); + sb.AppendLine(); + } + + // Type declaration + var typeKind = typeInfo.IsRecordClass ? "record class" : "class"; + sb.AppendLine($"partial {typeKind} {typeInfo.TypeName}"); + sb.AppendLine("{"); + + // Generate based on mode + var isLazy = config.Mode == SingletonModeValue.Lazy; + var isThreadSafe = config.Threading == SingletonThreadingValue.ThreadSafe; + + var instanceCreation = typeInfo.FactoryMethodName != null + ? $"{typeInfo.FactoryMethodName}()" + : $"new {typeInfo.TypeName}()"; + + if (isLazy) + { + if (isThreadSafe) + { + // Lazy with thread-safety + sb.AppendLine($" private static readonly global::System.Lazy<{typeInfo.TypeName}> __PatternKit_LazyInstance ="); + sb.AppendLine($" new global::System.Lazy<{typeInfo.TypeName}>(() => {instanceCreation});"); + sb.AppendLine(); + sb.AppendLine(" /// Gets the singleton instance of this type."); + sb.AppendLine($" public static {typeInfo.TypeName} {config.InstancePropertyName} => __PatternKit_LazyInstance.Value;"); + } + else + { + // Non-thread-safe lazy initialization + sb.AppendLine($" private static {typeInfo.TypeName}? __PatternKit_Instance;"); + sb.AppendLine(); + sb.AppendLine(" /// "); + sb.AppendLine(" /// Gets the singleton instance of this type."); + sb.AppendLine(" /// WARNING: This implementation is not thread-safe."); + sb.AppendLine(" /// "); + sb.AppendLine($" public static {typeInfo.TypeName} {config.InstancePropertyName} => __PatternKit_Instance ??= {instanceCreation};"); + } + } + else + { + // Eager initialization + sb.AppendLine($" private static readonly {typeInfo.TypeName} __PatternKit_Instance = {instanceCreation};"); + sb.AppendLine(); + sb.AppendLine(" /// Gets the singleton instance of this type."); + sb.AppendLine($" public static {typeInfo.TypeName} {config.InstancePropertyName} => __PatternKit_Instance;"); + } + + sb.AppendLine("}"); + + return sb.ToString(); + } + + // Helper classes and enums + + /// + /// Internal enum mirroring SingletonMode from abstractions. + /// + private enum SingletonModeValue + { + Eager = 0, + Lazy = 1 + } + + /// + /// Internal enum mirroring SingletonThreading from abstractions. + /// + private enum SingletonThreadingValue + { + ThreadSafe = 0, + SingleThreadedFast = 1 + } + + private class SingletonConfig + { + public SingletonModeValue Mode { get; set; } = SingletonModeValue.Eager; + public SingletonThreadingValue Threading { get; set; } = SingletonThreadingValue.ThreadSafe; + public string InstancePropertyName { get; set; } = "Instance"; + } + + private class SingletonTypeInfo + { + public INamedTypeSymbol TypeSymbol { get; set; } = null!; + public string TypeName { get; set; } = ""; + public string Namespace { get; set; } = ""; + public bool IsRecordClass { get; set; } + public string? FactoryMethodName { get; set; } + } +} diff --git a/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs b/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs new file mode 100644 index 0000000..f3b81bb --- /dev/null +++ b/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs @@ -0,0 +1,210 @@ +using PatternKit.Examples.SingletonGeneratorDemo; + +namespace PatternKit.Examples.Tests.SingletonGeneratorDemo; + +public class SingletonGeneratorDemoTests +{ + [Fact] + public void AppClock_ReturnsSameInstance() + { + // Act + var clock1 = AppClock.Instance; + var clock2 = AppClock.Instance; + + // Assert + Assert.Same(clock1, clock2); + } + + [Fact] + public void AppClock_ProvidesCurrentTime() + { + // Act + var before = DateTime.UtcNow; + var clockTime = AppClock.Instance.UtcNow; + var after = DateTime.UtcNow; + + // Assert + Assert.InRange(clockTime, before, after); + } + + [Fact] + public void AppClock_ProvidesUnixTimestamp() + { + // Arrange + var beforeTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + // Act + var timestamp = AppClock.Instance.UnixTimestamp; + var afterTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + // Assert + Assert.True(timestamp > 0); + Assert.InRange(timestamp, beforeTimestamp, afterTimestamp); + } + + [Fact] + public void ConfigManager_ReturnsSameInstance() + { + // Act + var config1 = ConfigManager.Instance; + var config2 = ConfigManager.Instance; + + // Assert + Assert.Same(config1, config2); + } + + [Fact] + public void ConfigManager_HasDefaultValues() + { + // Act + var config = ConfigManager.Instance; + + // Assert + Assert.NotNull(config.AppName); + Assert.NotNull(config.Environment); + Assert.NotNull(config.ConnectionString); + } + + [Fact] + public void ConfigManager_LoadedAtIsInPast() + { + // Act + var config = ConfigManager.Instance; + + // Assert + Assert.True(config.LoadedAt <= DateTime.UtcNow); + } + + [Fact] + public void ServiceRegistry_ReturnsSameInstance() + { + // Act + var registry1 = ServiceRegistry.Instance; + var registry2 = ServiceRegistry.Instance; + + // Assert + Assert.Same(registry1, registry2); + } + + [Fact] + public void ServiceRegistry_RegisterAndResolve() + { + // Arrange + var registry = ServiceRegistry.Instance; + registry.Clear(); + var service = new TestService("test"); + + // Act + registry.Register(service); + var resolved = registry.Resolve(); + + // Assert + Assert.Same(service, resolved); + } + + [Fact] + public void ServiceRegistry_RegisterFactory() + { + // Arrange + var registry = ServiceRegistry.Instance; + registry.Clear(); + var callCount = 0; + + // Act + registry.RegisterFactory(() => + { + callCount++; + return new TestService($"created-{callCount}"); + }); + + var first = registry.Resolve(); + var second = registry.Resolve(); + + // Assert + Assert.Same(first, second); // Factory result is cached + Assert.Equal(1, callCount); // Factory only called once + } + + [Fact] + public void ServiceRegistry_TryResolve_ReturnsNullWhenNotFound() + { + // Arrange + var registry = ServiceRegistry.Instance; + registry.Clear(); + + // Act + var result = registry.TryResolve(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ServiceRegistry_Resolve_ThrowsWhenNotFound() + { + // Arrange + var registry = ServiceRegistry.Instance; + registry.Clear(); + + // Act & Assert + Assert.Throws(() => + registry.Resolve()); + } + + [Fact] + public void ServiceRegistry_IsRegistered() + { + // Arrange + var registry = ServiceRegistry.Instance; + registry.Clear(); + registry.Register(new TestService("test")); + + // Assert + Assert.True(registry.IsRegistered()); + Assert.False(registry.IsRegistered()); + } + + [Fact] + public void ServiceRegistry_ThreadSafe_ParallelAccess() + { + // Arrange + var registry = ServiceRegistry.Instance; + registry.Clear(); + var creationCount = 0; + + registry.RegisterFactory(() => + { + Interlocked.Increment(ref creationCount); + Thread.Sleep(10); // Simulate slow creation + return new TestService($"thread-{Environment.CurrentManagedThreadId}"); + }); + + // Act - access from multiple threads simultaneously + var results = new ITestService[10]; + Parallel.For(0, 10, i => + { + results[i] = registry.Resolve(); + }); + + // Assert - all should get the same instance + var first = results[0]; + Assert.All(results, r => Assert.Same(first, r)); + // Factory is invoked exactly once due to Lazy ensuring single execution + Assert.Equal(1, creationCount); + } + + // Test service interface + public interface ITestService + { + string Name { get; } + } + + public interface IUnregisteredService { } + + // Test service implementation + public class TestService : ITestService + { + public string Name { get; } + public TestService(string name) => Name = name; + } +} diff --git a/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs b/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs new file mode 100644 index 0000000..68f126c --- /dev/null +++ b/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs @@ -0,0 +1,780 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Generators.Singleton; + +namespace PatternKit.Generators.Tests; + +public class SingletonGeneratorTests +{ + [Fact] + public void GenerateEagerSingleton() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton] + public partial class AppClock + { + private AppClock() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateEagerSingleton)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Singleton file is generated + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("TestNamespace.AppClock.Singleton.g.cs", names); + + // Generated code contains expected shape + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace.AppClock.Singleton.g.cs") + .SourceText.ToString(); + + Assert.Contains("private static readonly AppClock __PatternKit_Instance = new AppClock();", generatedSource); + Assert.Contains("public static AppClock Instance => __PatternKit_Instance;", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + /// + /// Validates that lazy thread-safe singletons generate the correct Lazy<T> pattern. + /// Note: Parallel access behavior is tested at runtime in the demo project tests, + /// which validate that multiple threads accessing Instance concurrently receive + /// the same instance. This test focuses on correct code generation. + /// + [Fact] + public void GenerateLazyThreadSafeSingleton() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton(Mode = SingletonMode.Lazy, Threading = SingletonThreading.ThreadSafe)] + public partial class ConfigManager + { + private ConfigManager() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateLazyThreadSafeSingleton)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Singleton file is generated + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("TestNamespace.ConfigManager.Singleton.g.cs", names); + + // Generated code contains Lazy pattern + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace.ConfigManager.Singleton.g.cs") + .SourceText.ToString(); + + Assert.Contains("System.Lazy", generatedSource); + Assert.Contains("__PatternKit_LazyInstance.Value", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateLazySingleThreadedSingleton() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton(Mode = SingletonMode.Lazy, Threading = SingletonThreading.SingleThreadedFast)] + public partial class FastCache + { + private FastCache() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateLazySingleThreadedSingleton)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Generated code contains non-thread-safe pattern + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace.FastCache.Singleton.g.cs") + .SourceText.ToString(); + + Assert.Contains("__PatternKit_Instance ??=", generatedSource); + Assert.Contains("not thread-safe", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateSingletonWithCustomFactory() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton(Mode = SingletonMode.Lazy)] + public partial class ServiceLocator + { + private ServiceLocator(string config) { } + + [SingletonFactory] + private static ServiceLocator Create() => new ServiceLocator("default.config"); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateSingletonWithCustomFactory)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Generated code uses factory method + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace.ServiceLocator.Singleton.g.cs") + .SourceText.ToString(); + + Assert.Contains("Create()", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateSingletonForRecordClass() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton] + public partial record class AppSettings + { + private AppSettings() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateSingletonForRecordClass)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Singleton file is generated + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("TestNamespace.AppSettings.Singleton.g.cs", names); + + // Uses record class keyword + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace.AppSettings.Singleton.g.cs") + .SourceText.ToString(); + + Assert.Contains("partial record class AppSettings", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateSingletonWithCustomPropertyName() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton(InstancePropertyName = "Default")] + public partial class Logger + { + private Logger() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateSingletonWithCustomPropertyName)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Generated code uses custom property name + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace.Logger.Singleton.g.cs") + .SourceText.ToString(); + + Assert.Contains("public static Logger Default =>", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void ErrorWhenNotPartial() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton] + public class NotPartialSingleton + { + private NotPartialSingleton() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenNotPartial)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKSNG001 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKSNG001"); + } + + [Fact] + public void ErrorWhenStruct() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton] + public partial struct StructSingleton + { + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenStruct)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKSNG002 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKSNG002"); + } + + [Fact] + public void ErrorWhenNoConstructorOrFactory() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton] + public partial class NoWayToConstruct + { + private NoWayToConstruct(string required) { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenNoConstructorOrFactory)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKSNG003 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKSNG003"); + } + + [Fact] + public void ErrorWhenMultipleFactories() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton] + public partial class TwoFactories + { + private TwoFactories() { } + + [SingletonFactory] + private static TwoFactories Create1() => new TwoFactories(); + + [SingletonFactory] + private static TwoFactories Create2() => new TwoFactories(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenMultipleFactories)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKSNG004 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKSNG004"); + } + + [Fact] + public void WarnWhenPublicConstructor() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton] + public partial class PublicCtorSingleton + { + public PublicCtorSingleton() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(WarnWhenPublicConstructor)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // PKSNG005 diagnostic is reported (warning) + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKSNG005" && d.Severity == DiagnosticSeverity.Warning); + + // Still generates code despite warning + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("TestNamespace.PublicCtorSingleton.Singleton.g.cs", names); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void ErrorWhenNameConflict() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton] + public partial class HasExistingInstance + { + private HasExistingInstance() { } + + public static int Instance => 42; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenNameConflict)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKSNG006 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKSNG006"); + } + + [Fact] + public void GenerateSingletonInGlobalNamespace() + { + const string source = """ + using PatternKit.Generators.Singleton; + + [Singleton] + public partial class GlobalSingleton + { + private GlobalSingleton() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateSingletonInGlobalNamespace)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Generated code has no namespace + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "GlobalSingleton.Singleton.g.cs") + .SourceText.ToString(); + + Assert.DoesNotContain("namespace", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void EagerSingleton_Compiles() + { + const string source = """ + using PatternKit.Generators.Singleton; + using System; + + namespace TestNamespace; + + [Singleton] + public partial class Counter + { + public int Value { get; set; } + private Counter() { Value = 42; } + } + + public static class TestRunner + { + public static bool Test() + { + var a = Counter.Instance; + var b = Counter.Instance; + return object.ReferenceEquals(a, b) && a.Value == 42; + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(EagerSingleton_Compiles)); + var gen = new SingletonGenerator(); + _ = 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 LazySingleton_Compiles() + { + const string source = """ + using PatternKit.Generators.Singleton; + using System; + + namespace TestNamespace; + + [Singleton(Mode = SingletonMode.Lazy)] + public partial class LazyCounter + { + public int Value { get; set; } + private LazyCounter() { Value = 99; } + } + + public static class TestRunner + { + public static bool Test() + { + var a = LazyCounter.Instance; + var b = LazyCounter.Instance; + return object.ReferenceEquals(a, b) && a.Value == 99; + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(LazySingleton_Compiles)); + var gen = new SingletonGenerator(); + _ = 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 ErrorWhenGenericType() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton] + public partial class GenericSingleton + { + private GenericSingleton() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenGenericType)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKSNG007 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKSNG007"); + } + + [Fact] + public void ErrorWhenNestedType() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + public class Outer + { + [Singleton] + public partial class NestedSingleton + { + private NestedSingleton() { } + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenNestedType)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKSNG008 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKSNG008"); + } + + [Fact] + public void ErrorWhenAbstractType() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton] + public abstract partial class AbstractSingleton + { + protected AbstractSingleton() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenAbstractType)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKSNG010 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKSNG010"); + } + + [Fact] + public void AllowAbstractTypeWithFactory() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton] + public abstract partial class AbstractWithFactory + { + protected AbstractWithFactory() { } + + [SingletonFactory] + private static AbstractWithFactory Create() => new ConcreteImpl(); + } + + public class ConcreteImpl : AbstractWithFactory { } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(AllowAbstractTypeWithFactory)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No PKSNG010 - abstract type is allowed with factory + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.DoesNotContain(diags, d => d.Id == "PKSNG010"); + + // Generated code uses factory method + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace.AbstractWithFactory.Singleton.g.cs") + .SourceText.ToString(); + + Assert.Contains("Create()", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void ErrorWhenReservedKeywordPropertyName() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton(InstancePropertyName = "class")] + public partial class KeywordSingleton + { + private KeywordSingleton() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenReservedKeywordPropertyName)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKSNG009 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKSNG009"); + } + + [Fact] + public void AllowVerbatimKeywordPropertyName() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton(InstancePropertyName = "@class")] + public partial class VerbatimSingleton + { + private VerbatimSingleton() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(AllowVerbatimKeywordPropertyName)); + var gen = new SingletonGenerator(); + _ = 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 ErrorWhenInheritedMemberConflict() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + public class BaseClass + { + public static int Instance => 42; + } + + [Singleton] + public partial class DerivedSingleton : BaseClass + { + private DerivedSingleton() { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenInheritedMemberConflict)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKSNG006 diagnostic is reported for inherited member conflict + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKSNG006"); + } + + [Fact] + public void IgnoreGenericFactoryMethods() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton] + public partial class GenericFactorySingleton + { + private GenericFactorySingleton() { } + + // This generic method should NOT be picked up as a factory + [SingletonFactory] + private static T Create() where T : new() => new T(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(IgnoreGenericFactoryMethods)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should still generate using the parameterless constructor + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("TestNamespace.GenericFactorySingleton.Singleton.g.cs", names); + + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "TestNamespace.GenericFactorySingleton.Singleton.g.cs") + .SourceText.ToString(); + + // Should use constructor, not factory + Assert.Contains("new GenericFactorySingleton()", generatedSource); + Assert.DoesNotContain("Create()", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void WarnWhenImplicitPublicConstructor() + { + const string source = """ + using PatternKit.Generators.Singleton; + + namespace TestNamespace; + + [Singleton] + public partial class ImplicitCtorSingleton + { + // No explicit constructor - compiler generates public parameterless ctor + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(WarnWhenImplicitPublicConstructor)); + var gen = new SingletonGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // PKSNG005 diagnostic is reported (warning) for implicit public ctor + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKSNG005" && d.Severity == DiagnosticSeverity.Warning); + + // Still generates code despite warning + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("TestNamespace.ImplicitCtorSingleton.Singleton.g.cs", names); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } +}