From ca2ea8a6cb6f9c67859fb929c2551357e42fa986 Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Fri, 6 Feb 2026 12:41:59 -0600 Subject: [PATCH 1/7] feat(generators): Add Singleton pattern generator - Add [Singleton] attribute with Mode (Eager/Lazy) and Threading options - Add [SingletonFactory] for custom factory methods - Implement PKSNG001-006 diagnostics per spec - Support partial class and partial record class --- .../Singleton/SingletonAttributes.cs | 98 +++++ .../AnalyzerReleases.Unshipped.md | 7 +- .../Singleton/SingletonGenerator.cs | 342 ++++++++++++++++++ 3 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 src/PatternKit.Generators.Abstractions/Singleton/SingletonAttributes.cs create mode 100644 src/PatternKit.Generators/Singleton/SingletonGenerator.cs 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..7e1976f 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -88,4 +88,9 @@ 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 diff --git a/src/PatternKit.Generators/Singleton/SingletonGenerator.cs b/src/PatternKit.Generators/Singleton/SingletonGenerator.cs new file mode 100644 index 0000000..c12467e --- /dev/null +++ b/src/PatternKit.Generators/Singleton/SingletonGenerator.cs @@ -0,0 +1,342 @@ +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 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); + + 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; + } + + // Parse attribute arguments + var config = ParseSingletonConfig(attribute); + + // Check for name conflicts with existing members + if (HasNameConflict(typeSymbol, config.InstancePropertyName)) + { + context.ReportDiagnostic(Diagnostic.Create( + NameConflictDescriptor, + node.GetLocation(), + config.InstancePropertyName, + typeSymbol.Name)); + return; + } + + // Find factory method if any + var factoryMethods = FindFactoryMethods(typeSymbol); + if (factoryMethods.Count > 1) + { + context.ReportDiagnostic(Diagnostic.Create( + MultipleFactoriesDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + var factoryMethod = factoryMethods.FirstOrDefault(); + + // 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)) + { + var fileName = $"{typeSymbol.Name}.Singleton.g.cs"; + context.AddSource(fileName, 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 SingletonConfig ParseSingletonConfig(AttributeData attribute) + { + var config = new SingletonConfig(); + + foreach (var named in attribute.NamedArguments) + { + switch (named.Key) + { + case nameof(SingletonAttribute.Mode): + config.Mode = (int)named.Value.Value!; + break; + case nameof(SingletonAttribute.Threading): + config.Threading = (int)named.Value.Value!; + break; + case nameof(SingletonAttribute.InstancePropertyName): + config.InstancePropertyName = (string)named.Value.Value!; + break; + } + } + + return config; + } + + private static bool HasNameConflict(INamedTypeSymbol typeSymbol, string propertyName) + { + // Check for existing members with the same name (including inherited) + return typeSymbol.GetMembers(propertyName).Length > 0; + } + + private static List FindFactoryMethods(INamedTypeSymbol typeSymbol) + { + return typeSymbol.GetMembers() + .OfType() + .Where(m => m.IsStatic && + m.Parameters.Length == 0 && + 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 && + c.DeclaredAccessibility != Accessibility.Public || // private, protected, internal are all accessible from generated code + c.DeclaredAccessibility == Accessibility.Public); + } + + private static bool HasPublicConstructor(INamedTypeSymbol typeSymbol) + { + return typeSymbol.InstanceConstructors.Any(c => + c.DeclaredAccessibility == Accessibility.Public && + !c.IsImplicitlyDeclared); // Don't warn about compiler-generated constructors + } + + 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 == 1; // SingletonMode.Lazy + var isThreadSafe = config.Threading == 0; // SingletonThreading.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}> _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} => _lazyInstance.Value;"); + } + else + { + // Non-thread-safe lazy initialization + sb.AppendLine($" private static {typeInfo.TypeName}? _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} => _instance ??= {instanceCreation};"); + } + } + else + { + // Eager initialization + sb.AppendLine($" private static readonly {typeInfo.TypeName} _instance = {instanceCreation};"); + sb.AppendLine(); + sb.AppendLine(" /// Gets the singleton instance of this type."); + sb.AppendLine($" public static {typeInfo.TypeName} {config.InstancePropertyName} => _instance;"); + } + + sb.AppendLine("}"); + + return sb.ToString(); + } + + // Helper classes + private class SingletonConfig + { + public int Mode { get; set; } // 0 = Eager, 1 = Lazy + public int Threading { get; set; } // 0 = ThreadSafe, 1 = SingleThreadedFast + 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; } + } +} From 6b6b7557ca28bbcafbbe95e49a41c6d3346f6881 Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Fri, 6 Feb 2026 12:42:06 -0600 Subject: [PATCH 2/7] test(singleton): Add generator tests - Eager singleton returns same instance - Lazy thread-safe singleton using Lazy - Custom factory method support - All diagnostic scenarios (PKSNG001-006) --- .../SingletonGeneratorTests.cs | 505 ++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs diff --git a/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs b/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs new file mode 100644 index 0000000..6c55719 --- /dev/null +++ b/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs @@ -0,0 +1,505 @@ +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("AppClock.Singleton.g.cs", names); + + // Generated code contains expected shape + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "AppClock.Singleton.g.cs") + .SourceText.ToString(); + + Assert.Contains("private static readonly AppClock _instance = new AppClock();", generatedSource); + Assert.Contains("public static AppClock Instance => _instance;", generatedSource); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [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("ConfigManager.Singleton.g.cs", names); + + // Generated code contains Lazy pattern + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "ConfigManager.Singleton.g.cs") + .SourceText.ToString(); + + Assert.Contains("System.Lazy", generatedSource); + Assert.Contains("_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 == "FastCache.Singleton.g.cs") + .SourceText.ToString(); + + Assert.Contains("_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 == "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("AppSettings.Singleton.g.cs", names); + + // Uses record class keyword + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "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 == "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("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_ReturnsSameInstance() + { + 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_ReturnsSameInstance)); + 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_ReturnsSameInstance() + { + 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_ReturnsSameInstance)); + 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)); + } +} From e74e6406998be7cf3ace0896fc263b99c0f78d8b Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Fri, 6 Feb 2026 12:42:13 -0600 Subject: [PATCH 3/7] docs(singleton): Add generator documentation - Overview, attributes, diagnostics - Examples for eager/lazy/factory patterns - Best practices and troubleshooting --- docs/generators/singleton.md | 439 +++++++++++++++++++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 docs/generators/singleton.md diff --git a/docs/generators/singleton.md b/docs/generators/singleton.md new file mode 100644 index 0000000..34086b6 --- /dev/null +++ b/docs/generators/singleton.md @@ -0,0 +1,439 @@ +# 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 _instance = new AppClock(); + + /// Gets the singleton instance of this type. + public static AppClock Instance => _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 _instance = new Configuration(); +public static Configuration Instance => _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 _lazyInstance = + new Lazy(() => new ExpensiveService()); + +public static ExpensiveService Instance => _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? _instance; + +/// +/// Gets the singleton instance of this type. +/// WARNING: This implementation is not thread-safe. +/// +public static UiService Instance => _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 | + +## 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) From 606093acc4dd29d5111fae950044cf33cd23d00c Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Fri, 6 Feb 2026 12:42:20 -0600 Subject: [PATCH 4/7] feat(examples): Add SingletonGeneratorDemo - AppClock: eager singleton for consistent time source - ConfigManager: lazy singleton with custom factory - ServiceRegistry: thread-safe service locator pattern - Comprehensive tests for all examples --- .../SingletonGeneratorDemo/AppClock.cs | 26 +++ .../SingletonGeneratorDemo/ConfigManager.cs | 57 +++++ .../SingletonGeneratorDemo/ServiceRegistry.cs | 83 +++++++ .../SingletonGeneratorDemoTests.cs | 205 ++++++++++++++++++ 4 files changed, 371 insertions(+) create mode 100644 src/PatternKit.Examples/SingletonGeneratorDemo/AppClock.cs create mode 100644 src/PatternKit.Examples/SingletonGeneratorDemo/ConfigManager.cs create mode 100644 src/PatternKit.Examples/SingletonGeneratorDemo/ServiceRegistry.cs create mode 100644 test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs 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/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs b/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs new file mode 100644 index 0000000..5e5ea6e --- /dev/null +++ b/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs @@ -0,0 +1,205 @@ +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() + { + // Act + var timestamp = AppClock.Instance.UnixTimestamp; + + // Assert + Assert.True(timestamp > 0); + Assert.True(timestamp > 1700000000); // After 2023 + } + + [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)); + // Note: ConcurrentDictionary may call factory multiple times but TryAdd ensures one wins + } + + // 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; + } +} From ed58e4bad5bda4b4668389e9d0b99ed9b5603986 Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Fri, 6 Feb 2026 13:24:30 -0600 Subject: [PATCH 5/7] fix(singleton): Address PR review comments --- .../Singleton/SingletonGenerator.cs | 94 +++++++++++++++++-- .../SingletonGeneratorDemoTests.cs | 3 +- .../SingletonGeneratorTests.cs | 28 +++--- 3 files changed, 104 insertions(+), 21 deletions(-) diff --git a/src/PatternKit.Generators/Singleton/SingletonGenerator.cs b/src/PatternKit.Generators/Singleton/SingletonGenerator.cs index c12467e..98132ba 100644 --- a/src/PatternKit.Generators/Singleton/SingletonGenerator.cs +++ b/src/PatternKit.Generators/Singleton/SingletonGenerator.cs @@ -19,6 +19,9 @@ public sealed class SingletonGenerator : IIncrementalGenerator 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 static readonly DiagnosticDescriptor TypeNotPartialDescriptor = new( id: DiagIdTypeNotPartial, @@ -68,6 +71,30 @@ public sealed class SingletonGenerator : IIncrementalGenerator 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); + public void Initialize(IncrementalGeneratorInitializationContext context) { // Find all type declarations with [Singleton] attribute @@ -118,9 +145,39 @@ private void GenerateSingletonForType( 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; + } + // 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)) { @@ -181,8 +238,11 @@ private void GenerateSingletonForType( var source = GenerateSingletonCode(typeInfo, config); if (!string.IsNullOrEmpty(source)) { - var fileName = $"{typeSymbol.Name}.Singleton.g.cs"; - context.AddSource(fileName, 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); } } @@ -244,10 +304,32 @@ private static bool HasAttribute(ISymbol symbol, string attributeFullName) private static bool HasAccessibleParameterlessConstructor(INamedTypeSymbol typeSymbol) { - return typeSymbol.InstanceConstructors.Any(c => - c.Parameters.Length == 0 && - c.DeclaredAccessibility != Accessibility.Public || // private, protected, internal are all accessible from generated code - c.DeclaredAccessibility == Accessibility.Public); + return typeSymbol.InstanceConstructors.Any(c => c.Parameters.Length == 0); + } + + private static bool IsValidCSharpIdentifier(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return false; + + // Handle verbatim identifiers (@keyword) + var identifier = name.StartsWith("@") ? name.Substring(1) : name; + + if (identifier.Length == 0) + return false; + + // First character must be letter or underscore + if (!char.IsLetter(identifier[0]) && identifier[0] != '_') + return false; + + // Remaining characters must be letters, digits, or underscores + for (int i = 1; i < identifier.Length; i++) + { + if (!char.IsLetterOrDigit(identifier[i]) && identifier[i] != '_') + return false; + } + + return true; } private static bool HasPublicConstructor(INamedTypeSymbol typeSymbol) diff --git a/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs b/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs index 5e5ea6e..82f836c 100644 --- a/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs +++ b/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs @@ -185,7 +185,8 @@ public void ServiceRegistry_ThreadSafe_ParallelAccess() // Assert - all should get the same instance var first = results[0]; Assert.All(results, r => Assert.Same(first, r)); - // Note: ConcurrentDictionary may call factory multiple times but TryAdd ensures one wins + // Factory is invoked exactly once due to Lazy ensuring single execution + Assert.Equal(1, creationCount); } // Test service interface diff --git a/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs b/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs index 6c55719..7421c5e 100644 --- a/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs @@ -29,12 +29,12 @@ private AppClock() { } // Singleton file is generated var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); - Assert.Contains("AppClock.Singleton.g.cs", names); + 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 == "AppClock.Singleton.g.cs") + .First(gs => gs.HintName == "TestNamespace.AppClock.Singleton.g.cs") .SourceText.ToString(); Assert.Contains("private static readonly AppClock _instance = new AppClock();", generatedSource); @@ -69,12 +69,12 @@ private ConfigManager() { } // Singleton file is generated var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); - Assert.Contains("ConfigManager.Singleton.g.cs", names); + 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 == "ConfigManager.Singleton.g.cs") + .First(gs => gs.HintName == "TestNamespace.ConfigManager.Singleton.g.cs") .SourceText.ToString(); Assert.Contains("System.Lazy", generatedSource); @@ -110,7 +110,7 @@ private FastCache() { } // Generated code contains non-thread-safe pattern var generatedSource = result.Results .SelectMany(r => r.GeneratedSources) - .First(gs => gs.HintName == "FastCache.Singleton.g.cs") + .First(gs => gs.HintName == "TestNamespace.FastCache.Singleton.g.cs") .SourceText.ToString(); Assert.Contains("_instance ??=", generatedSource); @@ -149,7 +149,7 @@ private ServiceLocator(string config) { } // Generated code uses factory method var generatedSource = result.Results .SelectMany(r => r.GeneratedSources) - .First(gs => gs.HintName == "ServiceLocator.Singleton.g.cs") + .First(gs => gs.HintName == "TestNamespace.ServiceLocator.Singleton.g.cs") .SourceText.ToString(); Assert.Contains("Create()", generatedSource); @@ -183,12 +183,12 @@ private AppSettings() { } // Singleton file is generated var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); - Assert.Contains("AppSettings.Singleton.g.cs", names); + Assert.Contains("TestNamespace.AppSettings.Singleton.g.cs", names); // Uses record class keyword var generatedSource = result.Results .SelectMany(r => r.GeneratedSources) - .First(gs => gs.HintName == "AppSettings.Singleton.g.cs") + .First(gs => gs.HintName == "TestNamespace.AppSettings.Singleton.g.cs") .SourceText.ToString(); Assert.Contains("partial record class AppSettings", generatedSource); @@ -223,7 +223,7 @@ private Logger() { } // Generated code uses custom property name var generatedSource = result.Results .SelectMany(r => r.GeneratedSources) - .First(gs => gs.HintName == "Logger.Singleton.g.cs") + .First(gs => gs.HintName == "TestNamespace.Logger.Singleton.g.cs") .SourceText.ToString(); Assert.Contains("public static Logger Default =>", generatedSource); @@ -359,7 +359,7 @@ public PublicCtorSingleton() { } // Still generates code despite warning var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); - Assert.Contains("PublicCtorSingleton.Singleton.g.cs", names); + Assert.Contains("TestNamespace.PublicCtorSingleton.Singleton.g.cs", names); // Compilation succeeds var emit = updated.Emit(Stream.Null); @@ -426,7 +426,7 @@ private GlobalSingleton() { } } [Fact] - public void EagerSingleton_ReturnsSameInstance() + public void EagerSingleton_Compiles() { const string source = """ using PatternKit.Generators.Singleton; @@ -452,7 +452,7 @@ public static bool Test() } """; - var comp = RoslynTestHelpers.CreateCompilation(source, nameof(EagerSingleton_ReturnsSameInstance)); + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(EagerSingleton_Compiles)); var gen = new SingletonGenerator(); _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); @@ -465,7 +465,7 @@ public static bool Test() } [Fact] - public void LazySingleton_ReturnsSameInstance() + public void LazySingleton_Compiles() { const string source = """ using PatternKit.Generators.Singleton; @@ -491,7 +491,7 @@ public static bool Test() } """; - var comp = RoslynTestHelpers.CreateCompilation(source, nameof(LazySingleton_ReturnsSameInstance)); + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(LazySingleton_Compiles)); var gen = new SingletonGenerator(); _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); From 6a60103beb6a8559c3831e1b1bc8ae63c2404cdc Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Fri, 6 Feb 2026 14:10:53 -0600 Subject: [PATCH 6/7] fix(singleton): Address second round of PR review comments - Add PKSNG007-009 diagnostics to AnalyzerReleases.Unshipped.md - Add PKSNG007-009 to singleton.md documentation diagnostics table - IsValidCSharpIdentifier: Reject reserved keywords (unless @-prefixed) - HasNameConflict: Check inherited members by walking base types - FindFactoryMethods: Exclude generic methods (TypeParameters.Length == 0) - HasPublicConstructor: Detect implicit public constructors - Add tests for all new validation logic - Add XML doc to thread-safe test explaining runtime tests are in demo --- docs/generators/singleton.md | 3 + .../AnalyzerReleases.Unshipped.md | 3 + .../Singleton/SingletonGenerator.cs | 49 +++- .../SingletonGeneratorTests.cs | 210 ++++++++++++++++++ 4 files changed, 259 insertions(+), 6 deletions(-) diff --git a/docs/generators/singleton.md b/docs/generators/singleton.md index 34086b6..ac12848 100644 --- a/docs/generators/singleton.md +++ b/docs/generators/singleton.md @@ -227,6 +227,9 @@ The generator supports: | **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) | ## Best Practices diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 7e1976f..c6a95e3 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -94,3 +94,6 @@ PKSNG003 | PatternKit.Generators.Singleton | Error | No usable constructor or fa 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 diff --git a/src/PatternKit.Generators/Singleton/SingletonGenerator.cs b/src/PatternKit.Generators/Singleton/SingletonGenerator.cs index 98132ba..a05d011 100644 --- a/src/PatternKit.Generators/Singleton/SingletonGenerator.cs +++ b/src/PatternKit.Generators/Singleton/SingletonGenerator.cs @@ -282,8 +282,20 @@ private SingletonConfig ParseSingletonConfig(AttributeData attribute) private static bool HasNameConflict(INamedTypeSymbol typeSymbol, string propertyName) { - // Check for existing members with the same name (including inherited) - return typeSymbol.GetMembers(propertyName).Length > 0; + // Check for existing members with the same name in declared members + if (typeSymbol.GetMembers(propertyName).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(propertyName).Any(m => m.DeclaredAccessibility != Accessibility.Private)) + return true; + baseType = baseType.BaseType; + } + + return false; } private static List FindFactoryMethods(INamedTypeSymbol typeSymbol) @@ -292,6 +304,7 @@ private static List FindFactoryMethods(INamedTypeSymbol typeSymbo .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(); @@ -312,8 +325,9 @@ private static bool IsValidCSharpIdentifier(string? name) if (string.IsNullOrWhiteSpace(name)) return false; - // Handle verbatim identifiers (@keyword) - var identifier = name.StartsWith("@") ? name.Substring(1) : name; + // Handle verbatim identifiers (@keyword) - these are allowed + var isVerbatim = name.StartsWith("@"); + var identifier = isVerbatim ? name.Substring(1) : name; if (identifier.Length == 0) return false; @@ -329,14 +343,37 @@ private static bool IsValidCSharpIdentifier(string? name) return false; } + // Check for reserved keywords (unless verbatim identifier) + if (!isVerbatim) + { + var keywordKind = SyntaxFacts.GetKeywordKind(identifier); + if (keywordKind != SyntaxKind.None) + return false; + } + return true; } private static bool HasPublicConstructor(INamedTypeSymbol typeSymbol) { - return typeSymbol.InstanceConstructors.Any(c => + // Check for explicit public constructors + var hasExplicitPublicCtor = typeSymbol.InstanceConstructors.Any(c => c.DeclaredAccessibility == Accessibility.Public && - !c.IsImplicitlyDeclared); // Don't warn about compiler-generated constructors + !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) diff --git a/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs b/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs index 7421c5e..aabc262 100644 --- a/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs @@ -45,6 +45,12 @@ private AppClock() { } 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() { @@ -502,4 +508,208 @@ public static bool Test() 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 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)); + } } From ccb7756c79f904cb84b0fe5342a36f4f208079e2 Mon Sep 17 00:00:00 2001 From: "Jerrett D. Davis" Date: Fri, 6 Feb 2026 14:50:18 -0600 Subject: [PATCH 7/7] fix(singleton): Address third round of PR review comments --- docs/generators/singleton.md | 17 +- .../AnalyzerReleases.Unshipped.md | 1 + .../Singleton/SingletonGenerator.cs | 149 +++++++++++------- .../SingletonGeneratorDemoTests.cs | 6 +- .../SingletonGeneratorTests.cs | 73 ++++++++- 5 files changed, 175 insertions(+), 71 deletions(-) diff --git a/docs/generators/singleton.md b/docs/generators/singleton.md index ac12848..44b11b8 100644 --- a/docs/generators/singleton.md +++ b/docs/generators/singleton.md @@ -39,10 +39,10 @@ Generated: ```csharp public partial class AppClock { - private static readonly AppClock _instance = new AppClock(); + private static readonly AppClock __PatternKit_Instance = new AppClock(); /// Gets the singleton instance of this type. - public static AppClock Instance => _instance; + public static AppClock Instance => __PatternKit_Instance; } ``` @@ -72,8 +72,8 @@ public partial class Configuration Generated: ```csharp -private static readonly Configuration _instance = new Configuration(); -public static Configuration Instance => _instance; +private static readonly Configuration __PatternKit_Instance = new Configuration(); +public static Configuration Instance => __PatternKit_Instance; ``` **Pros:** @@ -102,10 +102,10 @@ public partial class ExpensiveService Generated (thread-safe): ```csharp -private static readonly Lazy _lazyInstance = +private static readonly Lazy __PatternKit_LazyInstance = new Lazy(() => new ExpensiveService()); -public static ExpensiveService Instance => _lazyInstance.Value; +public static ExpensiveService Instance => __PatternKit_LazyInstance.Value; ``` **Pros:** @@ -144,13 +144,13 @@ public partial class UiService Generated: ```csharp -private static UiService? _instance; +private static UiService? __PatternKit_Instance; /// /// Gets the singleton instance of this type. /// WARNING: This implementation is not thread-safe. /// -public static UiService Instance => _instance ??= new UiService(); +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. @@ -230,6 +230,7 @@ The generator supports: | **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 diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index c6a95e3..a9215a7 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -97,3 +97,4 @@ PKSNG006 | PatternKit.Generators.Singleton | Error | Instance property name conf 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 index a05d011..3da296f 100644 --- a/src/PatternKit.Generators/Singleton/SingletonGenerator.cs +++ b/src/PatternKit.Generators/Singleton/SingletonGenerator.cs @@ -22,6 +22,7 @@ public sealed class SingletonGenerator : IIncrementalGenerator 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, @@ -95,6 +96,14 @@ public sealed class SingletonGenerator : IIncrementalGenerator 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 @@ -165,6 +174,29 @@ private void GenerateSingletonForType( 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); @@ -189,19 +221,6 @@ private void GenerateSingletonForType( return; } - // Find factory method if any - var factoryMethods = FindFactoryMethods(typeSymbol); - if (factoryMethods.Count > 1) - { - context.ReportDiagnostic(Diagnostic.Create( - MultipleFactoriesDescriptor, - node.GetLocation(), - typeSymbol.Name)); - return; - } - - var factoryMethod = factoryMethods.FirstOrDefault(); - // Check for usable constructor or factory var hasParameterlessConstructor = HasAccessibleParameterlessConstructor(typeSymbol); if (!hasParameterlessConstructor && factoryMethod is null) @@ -257,7 +276,7 @@ private static bool IsPartialType(SyntaxNode node) }; } - private SingletonConfig ParseSingletonConfig(AttributeData attribute) + private static SingletonConfig ParseSingletonConfig(AttributeData attribute) { var config = new SingletonConfig(); @@ -265,13 +284,23 @@ private SingletonConfig ParseSingletonConfig(AttributeData attribute) { switch (named.Key) { - case nameof(SingletonAttribute.Mode): - config.Mode = (int)named.Value.Value!; + 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 nameof(SingletonAttribute.Threading): - config.Threading = (int)named.Value.Value!; + 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 nameof(SingletonAttribute.InstancePropertyName): + case "InstancePropertyName": config.InstancePropertyName = (string)named.Value.Value!; break; } @@ -282,15 +311,18 @@ private SingletonConfig ParseSingletonConfig(AttributeData attribute) 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(propertyName).Length > 0) + 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(propertyName).Any(m => m.DeclaredAccessibility != Accessibility.Private)) + if (baseType.GetMembers(normalizedName).Any(m => m.DeclaredAccessibility != Accessibility.Private)) return true; baseType = baseType.BaseType; } @@ -325,33 +357,15 @@ private static bool IsValidCSharpIdentifier(string? name) if (string.IsNullOrWhiteSpace(name)) return false; - // Handle verbatim identifiers (@keyword) - these are allowed - var isVerbatim = name.StartsWith("@"); - var identifier = isVerbatim ? name.Substring(1) : name; - - if (identifier.Length == 0) - return false; - - // First character must be letter or underscore - if (!char.IsLetter(identifier[0]) && identifier[0] != '_') - return false; - - // Remaining characters must be letters, digits, or underscores - for (int i = 1; i < identifier.Length; i++) - { - if (!char.IsLetterOrDigit(identifier[i]) && identifier[i] != '_') - 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!); - // Check for reserved keywords (unless verbatim identifier) - if (!isVerbatim) - { - var keywordKind = SyntaxFacts.GetKeywordKind(identifier); - if (keywordKind != SyntaxKind.None) - return false; - } - - return true; + // 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) @@ -398,8 +412,8 @@ private string GenerateSingletonCode(SingletonTypeInfo typeInfo, SingletonConfig sb.AppendLine("{"); // Generate based on mode - var isLazy = config.Mode == 1; // SingletonMode.Lazy - var isThreadSafe = config.Threading == 0; // SingletonThreading.ThreadSafe + var isLazy = config.Mode == SingletonModeValue.Lazy; + var isThreadSafe = config.Threading == SingletonThreadingValue.ThreadSafe; var instanceCreation = typeInfo.FactoryMethodName != null ? $"{typeInfo.FactoryMethodName}()" @@ -410,31 +424,31 @@ private string GenerateSingletonCode(SingletonTypeInfo typeInfo, SingletonConfig if (isThreadSafe) { // Lazy with thread-safety - sb.AppendLine($" private static readonly global::System.Lazy<{typeInfo.TypeName}> _lazyInstance ="); + 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} => _lazyInstance.Value;"); + sb.AppendLine($" public static {typeInfo.TypeName} {config.InstancePropertyName} => __PatternKit_LazyInstance.Value;"); } else { // Non-thread-safe lazy initialization - sb.AppendLine($" private static {typeInfo.TypeName}? _instance;"); + 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} => _instance ??= {instanceCreation};"); + sb.AppendLine($" public static {typeInfo.TypeName} {config.InstancePropertyName} => __PatternKit_Instance ??= {instanceCreation};"); } } else { // Eager initialization - sb.AppendLine($" private static readonly {typeInfo.TypeName} _instance = {instanceCreation};"); + 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} => _instance;"); + sb.AppendLine($" public static {typeInfo.TypeName} {config.InstancePropertyName} => __PatternKit_Instance;"); } sb.AppendLine("}"); @@ -442,11 +456,30 @@ private string GenerateSingletonCode(SingletonTypeInfo typeInfo, SingletonConfig return sb.ToString(); } - // Helper classes + // 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 int Mode { get; set; } // 0 = Eager, 1 = Lazy - public int Threading { get; set; } // 0 = ThreadSafe, 1 = SingleThreadedFast + public SingletonModeValue Mode { get; set; } = SingletonModeValue.Eager; + public SingletonThreadingValue Threading { get; set; } = SingletonThreadingValue.ThreadSafe; public string InstancePropertyName { get; set; } = "Instance"; } diff --git a/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs b/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs index 82f836c..f3b81bb 100644 --- a/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs +++ b/test/PatternKit.Examples.Tests/SingletonGeneratorDemo/SingletonGeneratorDemoTests.cs @@ -30,12 +30,16 @@ public void AppClock_ProvidesCurrentTime() [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.True(timestamp > 1700000000); // After 2023 + Assert.InRange(timestamp, beforeTimestamp, afterTimestamp); } [Fact] diff --git a/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs b/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs index aabc262..68f126c 100644 --- a/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/SingletonGeneratorTests.cs @@ -37,8 +37,8 @@ private AppClock() { } .First(gs => gs.HintName == "TestNamespace.AppClock.Singleton.g.cs") .SourceText.ToString(); - Assert.Contains("private static readonly AppClock _instance = new AppClock();", generatedSource); - Assert.Contains("public static AppClock Instance => _instance;", generatedSource); + 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); @@ -84,7 +84,7 @@ private ConfigManager() { } .SourceText.ToString(); Assert.Contains("System.Lazy", generatedSource); - Assert.Contains("_lazyInstance.Value", generatedSource); + Assert.Contains("__PatternKit_LazyInstance.Value", generatedSource); // Compilation succeeds var emit = updated.Emit(Stream.Null); @@ -119,7 +119,7 @@ private FastCache() { } .First(gs => gs.HintName == "TestNamespace.FastCache.Singleton.g.cs") .SourceText.ToString(); - Assert.Contains("_instance ??=", generatedSource); + Assert.Contains("__PatternKit_Instance ??=", generatedSource); Assert.Contains("not thread-safe", generatedSource); // Compilation succeeds @@ -560,6 +560,71 @@ private NestedSingleton() { } 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() {