diff --git a/Directory.Build.props b/Directory.Build.props index 72560e1..cf82bab 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.1.0 + 1.1.1 MIT https://github.com/layeredcraft/optimized-enums git diff --git a/src/LayeredCraft.OptimizedEnums.Generator/Providers/EnumSyntaxProvider.cs b/src/LayeredCraft.OptimizedEnums.Generator/Providers/EnumSyntaxProvider.cs index d03c275..4a73813 100644 --- a/src/LayeredCraft.OptimizedEnums.Generator/Providers/EnumSyntaxProvider.cs +++ b/src/LayeredCraft.OptimizedEnums.Generator/Providers/EnumSyntaxProvider.cs @@ -62,17 +62,22 @@ internal static bool Predicate(SyntaxNode node, CancellationToken _) => cancellationToken.ThrowIfCancellationRequested(); - // OE0101: Warn about non-private constructors - foreach (var ctor in classSymbol.Constructors) + // OE0101: Warn about non-private constructors. + // Skip for abstract classes: a protected constructor is idiomatic and required + // so that concrete subclasses can invoke base(...). + if (!classSymbol.IsAbstract) { - if (ctor.IsImplicitlyDeclared) - continue; - if (ctor.DeclaredAccessibility != Accessibility.Private) + foreach (var ctor in classSymbol.Constructors) { - diagnostics.Add(new DiagnosticInfo( - DiagnosticDescriptors.NonPrivateConstructor, - ctor.CreateLocationInfo(), - className)); + if (ctor.IsImplicitlyDeclared) + continue; + if (ctor.DeclaredAccessibility != Accessibility.Private) + { + diagnostics.Add(new DiagnosticInfo( + DiagnosticDescriptors.NonPrivateConstructor, + ctor.CreateLocationInfo(), + className)); + } } } @@ -121,12 +126,20 @@ internal static bool Predicate(SyntaxNode node, CancellationToken _) => DetectDuplicateValues(classSymbol, context.SemanticModel, validMembers, diagnostics, className, cancellationToken); // OE0004: no valid members + // Abstract classes with no eligible members and no diagnostics are intermediate base + // classes — skip silently so they produce no output and no noise. + // If diagnostics were collected (e.g. OE0101, OE0102) we still surface them by falling + // through to the EnumInfo return; the generator skips code emission for empty member lists. if (validMembers.Count == 0) { - diagnostics.Add(new DiagnosticInfo( - DiagnosticDescriptors.NoMembersFound, - location, - className)); + if (classSymbol.IsAbstract && diagnostics.Count == 0) + return null; + + if (!classSymbol.IsAbstract) + diagnostics.Add(new DiagnosticInfo( + DiagnosticDescriptors.NoMembersFound, + location, + className)); } return new EnumInfo( diff --git a/tests/LayeredCraft.OptimizedEnums.Generator.Tests/GeneratorVerifyTests.cs b/tests/LayeredCraft.OptimizedEnums.Generator.Tests/GeneratorVerifyTests.cs index 04e0744..7aeb762 100644 --- a/tests/LayeredCraft.OptimizedEnums.Generator.Tests/GeneratorVerifyTests.cs +++ b/tests/LayeredCraft.OptimizedEnums.Generator.Tests/GeneratorVerifyTests.cs @@ -261,6 +261,118 @@ private OrderStatus(int value, string name) : base(value, name) { } }, TestContext.Current.CancellationToken); + [Fact] + public async Task AbstractBase_WithCRTP() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + + namespace MyApp.Domain; + + public abstract partial class DomainEnum : OptimizedEnum + where TSelf : DomainEnum + { + public string Description { get; } + + protected DomainEnum(int value, string name, string description) + : base(value, name) + { + Description = description; + } + } + + public sealed partial class OrderStatus : DomainEnum + { + public static readonly OrderStatus Pending = new(1, nameof(Pending), "Order is pending"); + public static readonly OrderStatus Paid = new(2, nameof(Paid), "Payment received"); + + private OrderStatus(int value, string name, string description) + : base(value, name, description) { } + } + """, + ExpectedTrees = 1, + }, + TestContext.Current.CancellationToken); + + [Fact] + public async Task AbstractBase_Alone_ProducesNoOutput() + { + var options = new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + + namespace MyApp.Domain; + + public abstract partial class DomainEnum : OptimizedEnum + where TSelf : DomainEnum + { + public string Description { get; } + + protected DomainEnum(int value, string name, string description) + : base(value, name) + { + Description = description; + } + } + """, + ExpectedTrees = 0, + }; + + var (driver, _) = GeneratorTestHelpers.GenerateFromSource(options, TestContext.Current.CancellationToken); + + var result = driver.GetRunResult(); + + result.Diagnostics.Should().BeEmpty("abstract intermediate base class should produce no diagnostics"); + result.GeneratedTrees.Length.Should().Be(0, "abstract intermediate base class should produce no generated output"); + } + + [Fact] + public async Task AbstractEnum_WithMembers_StillGenerates() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + + namespace MyApp.Domain; + + public abstract partial class Status : OptimizedEnum + { + public static readonly Status Active = null!; + public static readonly Status Inactive = null!; + + protected Status(int value, string name) : base(value, name) { } + } + """, + ExpectedTrees = 1, + }, + TestContext.Current.CancellationToken); + + [Fact] + public async Task Warning_AbstractBase_WithOnlyInvalidMembers_StillSurfacesDiagnostics() => + await GeneratorTestHelpers.VerifyFailure( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + + namespace MyApp.Domain; + + public abstract partial class DomainEnum : OptimizedEnum + where TSelf : DomainEnum + { + public static DomainEnum BadField = null!; + + protected DomainEnum(int value, string name) : base(value, name) { } + } + """, + ExpectedDiagnosticId = "OE0102", + }, + TestContext.Current.CancellationToken); + [Fact] public async Task Error_OE0005_DuplicateValue_IsEmitted() => await GeneratorTestHelpers.VerifyFailure( diff --git a/tests/LayeredCraft.OptimizedEnums.Generator.Tests/Snapshots/GeneratorVerifyTests.AbstractBase_WithCRTP#MyApp.Domain.OrderStatus.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.Generator.Tests/Snapshots/GeneratorVerifyTests.AbstractBase_WithCRTP#MyApp.Domain.OrderStatus.g.verified.cs new file mode 100644 index 0000000..8c3d17f --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.Generator.Tests/Snapshots/GeneratorVerifyTests.AbstractBase_WithCRTP#MyApp.Domain.OrderStatus.g.verified.cs @@ -0,0 +1,98 @@ +//HintName: MyApp.Domain.OrderStatus.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] +partial class OrderStatus +{ + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_all = + global::System.Array.AsReadOnly(new global::MyApp.Domain.OrderStatus[] + { + Pending, + Paid + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_names = + global::System.Array.AsReadOnly(new string[] + { + Pending.Name, + Paid.Name + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_values = + global::System.Array.AsReadOnly(new int[] + { + Pending.Value, + Paid.Value + }); + + private static readonly global::System.Collections.Generic.Dictionary s_byName = + new global::System.Collections.Generic.Dictionary(2, global::System.StringComparer.Ordinal) + { + [Pending.Name] = Pending, + [Paid.Name] = Paid + }; + + private static readonly global::System.Collections.Generic.Dictionary s_byValue = + new global::System.Collections.Generic.Dictionary(2) + { + [Pending.Value] = Pending, + [Paid.Value] = Paid + }; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList All => s_all; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Names => s_names; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Values => s_values; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public const int Count = 2; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.OrderStatus FromName(string name) + { + if (!s_byName.TryGetValue(name, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{name}' is not a valid name for OrderStatus"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromName(string name, out global::MyApp.Domain.OrderStatus? result) => + s_byName.TryGetValue(name, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.OrderStatus FromValue(int value) + { + if (!s_byValue.TryGetValue(value, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{value}' is not a valid value for OrderStatus"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromValue(int value, out global::MyApp.Domain.OrderStatus? result) => + s_byValue.TryGetValue(value, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsName(string name) => s_byName.ContainsKey(name); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsValue(int value) => s_byValue.ContainsKey(value); +} diff --git a/tests/LayeredCraft.OptimizedEnums.Generator.Tests/Snapshots/GeneratorVerifyTests.AbstractEnum_WithMembers_StillGenerates#MyApp.Domain.Status.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.Generator.Tests/Snapshots/GeneratorVerifyTests.AbstractEnum_WithMembers_StillGenerates#MyApp.Domain.Status.g.verified.cs new file mode 100644 index 0000000..82baaeb --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.Generator.Tests/Snapshots/GeneratorVerifyTests.AbstractEnum_WithMembers_StillGenerates#MyApp.Domain.Status.g.verified.cs @@ -0,0 +1,98 @@ +//HintName: MyApp.Domain.Status.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] +partial class Status +{ + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_all = + global::System.Array.AsReadOnly(new global::MyApp.Domain.Status[] + { + Active, + Inactive + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_names = + global::System.Array.AsReadOnly(new string[] + { + Active.Name, + Inactive.Name + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_values = + global::System.Array.AsReadOnly(new int[] + { + Active.Value, + Inactive.Value + }); + + private static readonly global::System.Collections.Generic.Dictionary s_byName = + new global::System.Collections.Generic.Dictionary(2, global::System.StringComparer.Ordinal) + { + [Active.Name] = Active, + [Inactive.Name] = Inactive + }; + + private static readonly global::System.Collections.Generic.Dictionary s_byValue = + new global::System.Collections.Generic.Dictionary(2) + { + [Active.Value] = Active, + [Inactive.Value] = Inactive + }; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList All => s_all; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Names => s_names; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Values => s_values; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public const int Count = 2; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.Status FromName(string name) + { + if (!s_byName.TryGetValue(name, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{name}' is not a valid name for Status"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromName(string name, out global::MyApp.Domain.Status? result) => + s_byName.TryGetValue(name, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.Status FromValue(int value) + { + if (!s_byValue.TryGetValue(value, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{value}' is not a valid value for Status"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromValue(int value, out global::MyApp.Domain.Status? result) => + s_byValue.TryGetValue(value, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsName(string name) => s_byName.ContainsKey(name); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsValue(int value) => s_byValue.ContainsKey(value); +} diff --git a/tests/LayeredCraft.OptimizedEnums.Generator.Tests/Snapshots/GeneratorVerifyTests.Warning_AbstractBase_WithOnlyInvalidMembers_StillSurfacesDiagnostics.verified.txt b/tests/LayeredCraft.OptimizedEnums.Generator.Tests/Snapshots/GeneratorVerifyTests.Warning_AbstractBase_WithOnlyInvalidMembers_StillSurfacesDiagnostics.verified.txt new file mode 100644 index 0000000..1d13667 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.Generator.Tests/Snapshots/GeneratorVerifyTests.Warning_AbstractBase_WithOnlyInvalidMembers_StillSurfacesDiagnostics.verified.txt @@ -0,0 +1,18 @@ +{ + Diagnostics: [ + { + Location: Program.cs: (7,36)-(7,44), + Message: The field 'BadField' in class 'DomainEnum' is a public static field of the enum type but is not readonly, + Severity: Warning, + WarningLevel: 1, + Descriptor: { + Id: OE0102, + Title: OptimizedEnum static field should be readonly, + MessageFormat: The field '{0}' in class '{1}' is a public static field of the enum type but is not readonly, + Category: OptimizedEnums.Usage, + DefaultSeverity: Warning, + IsEnabledByDefault: true + } + } + ] +} \ No newline at end of file