Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/Sharprompt.SourceGenerator/EnumMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ private static EnumMemberMetadata AnalyzeMember(INamedTypeSymbol enumSymbol, IFi
int? order = null;

var displayAttribute = field.GetAttributes()
.FirstOrDefault(attribute => attribute.AttributeClass?.ToDisplayString() == "System.ComponentModel.DataAnnotations.DisplayAttribute");
.FirstOrDefault(attribute => IsDisplayAttribute(attribute.AttributeClass));

if (displayAttribute != null)
{
Expand All @@ -128,6 +128,21 @@ private static EnumMemberMetadata AnalyzeMember(INamedTypeSymbol enumSymbol, IFi
return new EnumMemberMetadata(field.Name, displayName, order, index);
}

private static bool IsDisplayAttribute(ITypeSymbol? type)
{
while (type != null)
{
if (type.ToDisplayString() == "System.ComponentModel.DataAnnotations.DisplayAttribute")
{
return true;
}

type = type.BaseType;
}

return false;
}

private static string Quote(string text)
{
return "\"" + text.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r") + "\"";
Expand Down
12 changes: 3 additions & 9 deletions src/Sharprompt/EnumMetadataRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,11 @@ internal static bool TryGet<TEnum>(out IReadOnlyList<TEnum> values, out Func<TEn
[UnconditionalSuppressMessage("Trimming", "IL2090", Justification = "This method is only invoked for enum types, and the trimmer always preserves the fields of enum types it keeps.")]
internal static EnumMetadata<TEnum> CreateFallbackMetadata<TEnum>() where TEnum : notnull
{
// GetFields does not guarantee ordering and MetadataToken is unavailable on
// Native AOT, so sort by the underlying constant value for a deterministic
// order, matching Enum.GetValues semantics. Aliased members sharing the
// same constant value keep a single representative, chosen by ordinal name
// order so the result does not depend on the GetFields ordering either.
// Preserve declaration order for deterministic alias deduplication and secondary
// tie-breaking, matching the semantics of EnumMetadataGenerator.
var members = typeof(TEnum).GetFields(BindingFlags.Public | BindingFlags.Static)
.Select(field => (value: (TEnum)field.GetValue(null)!, field.Name, displayAttribute: field.GetCustomAttribute<DisplayAttribute>()))
.OrderBy(x => x.value)
.ThenBy(x => x.Name, StringComparer.Ordinal)
.Select((field, index) => (value: (TEnum)field.GetValue(null)!, field.Name, index, displayAttribute: field.GetCustomAttribute<DisplayAttribute>()))
.DistinctBy(x => x.value)
.Select((x, index) => (x.value, x.Name, index, x.displayAttribute))
.ToArray();

var values = members.OrderBy(x => x.displayAttribute?.GetOrder() ?? int.MaxValue)
Expand Down
114 changes: 103 additions & 11 deletions tests/Sharprompt.Tests/Binding/EnumMetadataRegistryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,37 +74,102 @@ public void CreateFallbackMetadata_AliasedValues_KeepsSingleDeterministicReprese
{
var metadata = EnumMetadataRegistry.CreateFallbackMetadata<AliasedEnum>();

// Aliases collapse to one entry; the representative is the alias whose
// name sorts first ordinally ("Default" < "None").
// Aliases collapse to one entry, keeping the first declared representative ("None").
Assert.Equal(new[] { AliasedEnum.None, AliasedEnum.Value }, metadata.Values);
Assert.Equal("Default", metadata.TextSelector(AliasedEnum.None));
Assert.Equal("Default", metadata.TextSelector(AliasedEnum.Default));
Assert.Equal("None", metadata.TextSelector(AliasedEnum.None));
Assert.Equal("None", metadata.TextSelector(AliasedEnum.Default));
}

[Fact]
public void Generated_And_Fallback_Metadata_Are_Identical_For_AnnotatedEnum()
{
AssertMetadataParity<AnnotatedEnum>();
}

[Fact]
public void Generated_And_Fallback_Metadata_Are_Identical_For_AliasedEnum()
{
AssertMetadataParity<AliasedEnum>();
}

[Fact]
public void Generated_And_Fallback_Metadata_Are_Identical_For_AliasedWithDisplayEnum()
{
AssertMetadataParity<AliasedWithDisplayEnum>();

var fallback = EnumMetadataRegistry.CreateFallbackMetadata<AliasedWithDisplayEnum>();
Assert.Equal(new[] { AliasedWithDisplayEnum.Zero, AliasedWithDisplayEnum.One }, fallback.Values);
Assert.Equal("Zero (First)", fallback.TextSelector(AliasedWithDisplayEnum.Zero));
Assert.Equal("Zero (First)", fallback.TextSelector(AliasedWithDisplayEnum.Nothing));
Assert.Equal("One", fallback.TextSelector(AliasedWithDisplayEnum.One));
}

[Fact]
public void Generated_And_Fallback_Metadata_Are_Identical_For_ExplicitOrderAndUnannotated()
{
AssertMetadataParity<AnnotatedOrderEnum>();

var fallback = EnumMetadataRegistry.CreateFallbackMetadata<AnnotatedOrderEnum>();
Assert.Equal(
new[]
{
AnnotatedOrderEnum.FirstOrder,
AnnotatedOrderEnum.SecondOrder,
AnnotatedOrderEnum.SecondOrderB,
AnnotatedOrderEnum.UnannotatedA,
AnnotatedOrderEnum.UnannotatedB
},
fallback.Values);
}

[Fact]
public void Generated_And_Fallback_Metadata_Are_Identical_For_OutOfOrderEnum()
{
AssertMetadataParity<OutOfOrderEnum>();

var fallback = EnumMetadataRegistry.CreateFallbackMetadata<OutOfOrderEnum>();
Assert.Equal(new[] { OutOfOrderEnum.Charlie, OutOfOrderEnum.Alpha, OutOfOrderEnum.Bravo }, fallback.Values);
}

[Fact]
public void SelectOptions_UnregisteredEnum_HasItems()
{
var options = new SelectOptions<FallbackEnum>
var options = new SelectOptions<ConsoleColor>
{
Message = "message"
};

options.EnsureOptions();

Assert.Equal(new[] { FallbackEnum.Foo, FallbackEnum.Bar }, options.Items);
Assert.Equal(Enum.GetValues<ConsoleColor>(), options.Items);
}

[Fact]
public void MultiSelectOptions_UnregisteredEnum_HasItems()
{
var options = new MultiSelectOptions<FallbackEnum>
var options = new MultiSelectOptions<ConsoleColor>
{
Message = "message"
};

options.EnsureOptions();

Assert.Equal(new[] { FallbackEnum.Foo, FallbackEnum.Bar }, options.Items);
Assert.Equal(Enum.GetValues<ConsoleColor>(), options.Items);
}

private static void AssertMetadataParity<TEnum>() where TEnum : struct, Enum
{
var found = EnumMetadataRegistry.TryGet<TEnum>(out var generatedValues, out var generatedSelector);
Assert.True(found, $"Source generator should have registered metadata for {typeof(TEnum).Name}.");

var fallback = EnumMetadataRegistry.CreateFallbackMetadata<TEnum>();

Assert.Equal(generatedValues, fallback.Values);

foreach (var value in Enum.GetValues<TEnum>())
{
Assert.Equal(generatedSelector(value), fallback.TextSelector(value));
}
}

public sealed record CustomItem(string Value);
Expand All @@ -131,9 +196,36 @@ public enum AliasedEnum
Value = 1
}

public enum FallbackEnum
public enum AliasedWithDisplayEnum
{
[Display(Name = "Zero (First)")]
Zero = 0,
[Display(Name = "Zero (Second)")]
Nothing = 0,
[Display(Name = "One")]
One = 1
}

public enum AnnotatedOrderEnum
{
[Display(Name = "Order 2", Order = 2)]
SecondOrder = 10,

UnannotatedA = 20,

[Display(Name = "Order 1", Order = 1)]
FirstOrder = 30,

UnannotatedB = 40,

[Display(Name = "Order 2 (Second)", Order = 2)]
SecondOrderB = 50
}

public enum OutOfOrderEnum
{
Foo,
Bar
Charlie = 3,
Alpha = 1,
Bravo = 2
}
}
1 change: 1 addition & 0 deletions tests/Sharprompt.Tests/Sharprompt.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

<ItemGroup>
<ProjectReference Include="..\..\src\Sharprompt\Sharprompt.csproj" />
<ProjectReference Include="..\..\src\Sharprompt.SourceGenerator\Sharprompt.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

</Project>