Skip to content

Commit

Permalink
feat: support enum naming strategies for DescriptionAttribute and Enu…
Browse files Browse the repository at this point in the history
…mMemberAttribute (#1507)
  • Loading branch information
latonz authored Sep 29, 2024
1 parent 5932a4f commit 35fbd7c
Show file tree
Hide file tree
Showing 17 changed files with 523 additions and 161 deletions.
20 changes: 11 additions & 9 deletions docs/docs/configuration/enum.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,17 @@ Enum from/to strings mappings can be customized by setting the enum naming strat
You can specify the naming strategy using `NamingStrategy` in `MapEnumAttribute` or `EnumNamingStrategy` in `MapperAttribute` and `MapperDefaultsAttribute`.
Available naming strategies:

| Name | Description |
| -------------- | ----------------------------------------------- |
| MemberName | Matches enum values using their name. (default) |
| CamelCase | Matches enum values using camelCase. |
| PascalCase | Matches enum values using PascalCase. |
| SnakeCase | Matches enum values using snake_case. |
| UpperSnakeCase | Matches enum values using UPPER_SNAKE_CASE. |
| KebabCase | Matches enum values using kebab-case. |
| UpperKebabCase | Matches enum values using UPPER-KEBAB-CASE. |
| Name | Description |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MemberName | Matches enum values using their name. (default) |
| CamelCase | Matches enum values using camelCase. |
| PascalCase | Matches enum values using PascalCase. |
| SnakeCase | Matches enum values using snake_case. |
| UpperSnakeCase | Matches enum values using UPPER_SNAKE_CASE. |
| KebabCase | Matches enum values using kebab-case. |
| UpperKebabCase | Matches enum values using UPPER-KEBAB-CASE. |
| ComponentModelDescriptionAttribute | Matches enum values using the `Description` property of the `System.ComponentModel.DescriptionAttribute`. If the attribute is not present or the property is null, the member name is used. |
| SerializationEnumMemberAttribute | Matches enum values using the `Value` property of the `System.Runtime.Serialization.EnumMemberAttribute`. If the attribute is not present or the property is null, the member name is used. |

Note that explicit enum mappings (`MapEnumValue`) and fallback values (`FallbackValue` in `MapEnum`)
are not affected by naming strategies.
Expand Down
15 changes: 15 additions & 0 deletions src/Riok.Mapperly.Abstractions/EnumNamingStrategy.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using System.ComponentModel;
using System.Runtime.Serialization;

namespace Riok.Mapperly.Abstractions;

/// <summary>
Expand Down Expand Up @@ -39,4 +42,16 @@ public enum EnumNamingStrategy
/// Matches enum values using UPPER-KEBAB-CASE.
/// </summary>
UpperKebabCase,

/// <summary>
/// Matches enum values using <see cref="DescriptionAttribute.Description"/>
/// or <see cref="MemberName"/> if the attribute is not present on the enum member.
/// </summary>
ComponentModelDescriptionAttribute,

/// <summary>
/// Matches enum values using <see cref="EnumMemberAttribute.Value"/>
/// or <see cref="MemberName"/> if the attribute is not present on the enum member.
/// </summary>
SerializationEnumMemberAttribute,
}
2 changes: 1 addition & 1 deletion src/Riok.Mapperly.Abstractions/MapperAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ public class MapperAttribute : Attribute
public bool AutoUserMappings { get; set; } = true;

/// <summary>
/// The default enum naming strategy.
/// Defines the strategy to use when mapping an enum from/to string.
/// Can be overwritten on specific enums via mapping method configurations.
/// </summary>
public EnumNamingStrategy EnumNamingStrategy { get; set; } = EnumNamingStrategy.MemberName;
Expand Down
2 changes: 2 additions & 0 deletions src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,5 @@ Riok.Mapperly.Abstractions.MapEnumAttribute.NamingStrategy.get -> Riok.Mapperly.
Riok.Mapperly.Abstractions.MapEnumAttribute.NamingStrategy.set -> void
Riok.Mapperly.Abstractions.MapperAttribute.EnumNamingStrategy.get -> Riok.Mapperly.Abstractions.EnumNamingStrategy
Riok.Mapperly.Abstractions.MapperAttribute.EnumNamingStrategy.set -> void
Riok.Mapperly.Abstractions.EnumNamingStrategy.ComponentModelDescriptionAttribute = 7 -> Riok.Mapperly.Abstractions.EnumNamingStrategy
Riok.Mapperly.Abstractions.EnumNamingStrategy.SerializationEnumMemberAttribute = 8 -> Riok.Mapperly.Abstractions.EnumNamingStrategy
5 changes: 4 additions & 1 deletion src/Riok.Mapperly/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,10 @@ RMG081 | Mapper | Error | A mapping method with additional parameters cann
RMG082 | Mapper | Warning | An additional mapping method parameter is not mapped
RMG083 | Mapper | Info | Cannot map to read only type
RMG084 | Mapper | Error | Multiple mappings are configured for the same source string
RMG085 | Mapper | Error | Invalid usage of Fallback value
RMG085 | Mapper | Error | Invalid usage of fallback value
RMG086 | Mapper | Error | The source of the explicit mapping from a string to an enum is not of type string
RMG087 | Mapper | Error | The target of the explicit mapping from an enum to a string is not of type string
RMG088 | Mapper | Info | The attribute to build the name of the enum member is missing

### Removed Rules

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Riok.Mapperly.Configuration;

/// <summary>
/// Configuration class to represent <see cref="System.ComponentModel.DescriptionAttribute"/>
/// </summary>
public record ComponentModelDescriptionAttributeConfiguration(string? Description)
{
public ComponentModelDescriptionAttributeConfiguration()
: this((string?)null) { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System.ComponentModel;
using System.Runtime.Serialization;
using Microsoft.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Configuration;
using Riok.Mapperly.Diagnostics;
using Riok.Mapperly.Helpers;

namespace Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext;

public static class EnumMappingBuilder
{
internal static string GetMemberName(MappingBuilderContext ctx, IFieldSymbol field) =>
GetMemberName(ctx, field, ctx.Configuration.Enum.NamingStrategy);

private static string GetMemberName(MappingBuilderContext ctx, IFieldSymbol field, EnumNamingStrategy namingStrategy)
{
return namingStrategy switch
{
EnumNamingStrategy.MemberName => field.Name,
EnumNamingStrategy.CamelCase => field.Name.ToCamelCase(),
EnumNamingStrategy.PascalCase => field.Name.ToPascalCase(),
EnumNamingStrategy.SnakeCase => field.Name.ToSnakeCase(),
EnumNamingStrategy.UpperSnakeCase => field.Name.ToUpperSnakeCase(),
EnumNamingStrategy.KebabCase => field.Name.ToKebabCase(),
EnumNamingStrategy.UpperKebabCase => field.Name.ToUpperKebabCase(),
EnumNamingStrategy.ComponentModelDescriptionAttribute => GetComponentModelDescription(ctx, field),
EnumNamingStrategy.SerializationEnumMemberAttribute => GetEnumMemberValue(ctx, field),
_ => throw new ArgumentOutOfRangeException($"{nameof(namingStrategy)} has an unknown value {namingStrategy}"),
};
}

private static string GetEnumMemberValue(MappingBuilderContext ctx, IFieldSymbol field)
{
var name = ctx.AttributeAccessor.AccessFirstOrDefault<EnumMemberAttribute>(field)?.Value;
if (name != null)
return name;

ctx.ReportDiagnostic(
DiagnosticDescriptors.EnumNamingAttributeMissing,
nameof(EnumMemberAttribute),
field.Name,
field.ConstantValue ?? "<unknown>"
);
return GetMemberName(ctx, field, EnumNamingStrategy.MemberName);
}

private static string GetComponentModelDescription(MappingBuilderContext ctx, IFieldSymbol field)
{
var name = ctx
.AttributeAccessor.AccessFirstOrDefault<DescriptionAttribute, ComponentModelDescriptionAttributeConfiguration>(field)
?.Description;
if (name != null)
return name;

ctx.ReportDiagnostic(
DiagnosticDescriptors.EnumNamingAttributeMissing,
nameof(DescriptionAttribute),
field.Name,
field.ConstantValue ?? "<unknown>"
);
return GetMemberName(ctx, field, EnumNamingStrategy.MemberName);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,19 @@ private static EnumFallbackValueMapping BuildFallbackMapping(MappingBuilderConte

if (fallbackValue is not { Expression: MemberAccessExpressionSyntax memberAccessExpression })
{
ctx.ReportDiagnostic(DiagnosticDescriptors.InvalidFallbackValue, fallbackValue.Value.Expression.ToFullString());
ctx.ReportDiagnostic(DiagnosticDescriptors.InvalidEnumMappingFallbackValue, fallbackValue.Value.Expression.ToFullString());
return new EnumFallbackValueMapping(ctx.Source, ctx.Target);
}

if (!SymbolEqualityComparer.Default.Equals(ctx.Target, fallbackValue.Value.ConstantValue.Type))
{
ctx.ReportDiagnostic(
DiagnosticDescriptors.EnumFallbackValueTypeDoesNotMatchTargetEnumType,
fallbackValue,
fallbackValue.Value.ConstantValue.Value ?? 0,
fallbackValue.Value.ConstantValue.Type?.Name ?? "unknown",
ctx.Target
);
return new EnumFallbackValueMapping(ctx.Source, ctx.Target);
}

Expand All @@ -193,25 +205,14 @@ private static EnumFallbackValueMapping BuildFallbackMapping(MappingBuilderConte
FullyQualifiedIdentifier(ctx.Target),
memberAccessExpression.Name
);

if (SymbolEqualityComparer.Default.Equals(ctx.Target, fallbackValue.Value.ConstantValue.Type))
return new EnumFallbackValueMapping(ctx.Source, ctx.Target, fallbackExpression: fallbackExpression);

ctx.ReportDiagnostic(
DiagnosticDescriptors.EnumFallbackValueTypeDoesNotMatchTargetEnumType,
fallbackValue,
fallbackValue.Value.ConstantValue.Value ?? 0,
fallbackValue.Value.ConstantValue.Type?.Name ?? "unknown",
ctx.Target
);
return new EnumFallbackValueMapping(ctx.Source, ctx.Target);
return new EnumFallbackValueMapping(ctx.Source, ctx.Target, fallbackExpression: fallbackExpression);
}

private static IReadOnlyDictionary<IFieldSymbol, IFieldSymbol> BuildExplicitValueMappings(MappingBuilderContext ctx)
{
var explicitMappings = new Dictionary<IFieldSymbol, IFieldSymbol>(SymbolEqualityComparer.Default);
var sourceFields = ctx.SymbolAccessor.GetEnumFields(ctx.Source);
var targetFields = ctx.SymbolAccessor.GetEnumFields(ctx.Target);
var sourceFields = ctx.SymbolAccessor.GetEnumFieldsByValue(ctx.Source);
var targetFields = ctx.SymbolAccessor.GetEnumFieldsByValue(ctx.Target);
foreach (var (source, target) in ctx.Configuration.Enum.ExplicitMappings)
{
if (source.ConstantValue.Kind is not TypedConstantKind.Enum)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Riok.Mapperly.Abstractions;
using Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext;
using Riok.Mapperly.Descriptors.Mappings;
Expand Down Expand Up @@ -29,21 +28,11 @@ private static EnumToStringMapping BuildEnumToStringMapping(MappingBuilderContex
{
var fallbackMapping = BuildFallbackMapping(ctx, out var fallbackStringValue);
var enumMemberMappings = BuildEnumMemberMappings(ctx, fallbackStringValue);

if (fallbackStringValue is not null)
{
enumMemberMappings = enumMemberMappings.Where(m =>
!m.TargetSyntax.ToString().Equals(fallbackStringValue, StringComparison.Ordinal)
);
}

return new EnumToStringMapping(ctx.Source, ctx.Target, enumMemberMappings, fallbackMapping);
}

private static IEnumerable<EnumMemberMapping> BuildEnumMemberMappings(MappingBuilderContext ctx, string? fallbackStringValue)
{
var namingStrategy = ctx.Configuration.Enum.NamingStrategy;

var ignoredSourceMembers = ctx.Configuration.Enum.IgnoredSourceMembers.ToHashSet(SymbolTypeEqualityComparer.FieldDefault);
EnumMappingDiagnosticReporter.AddUnmatchedSourceIgnoredMembers(ctx, ignoredSourceMembers);

Expand All @@ -54,37 +43,45 @@ private static IEnumerable<EnumMemberMapping> BuildEnumMemberMappings(MappingBui
{
// source.Value1
var sourceSyntax = MemberAccess(FullyQualifiedIdentifier(ctx.Source), sourceField.Name);

var name = sourceField.GetName(namingStrategy);
if (string.Equals(name, fallbackStringValue, StringComparison.Ordinal))
continue;

if (explicitValueMappings.TryGetValue(sourceField, out var explicitMapping))
if (explicitValueMappings.TryGetValue(sourceField, out var memberName))
{
if (string.Equals(fallbackStringValue, memberName, StringComparison.Ordinal))
continue;

// "explicit_value1"
yield return new EnumMemberMapping(sourceSyntax, explicitMapping);
yield return new EnumMemberMapping(sourceSyntax, StringLiteral(memberName));
continue;
}

if (namingStrategy is not EnumNamingStrategy.MemberName)
var name = EnumMappingBuilder.GetMemberName(ctx, sourceField);
if (string.Equals(name, fallbackStringValue, StringComparison.Ordinal))
continue;

if (ctx.Configuration.Enum.NamingStrategy == EnumNamingStrategy.MemberName)
{
// "value1"
yield return new EnumMemberMapping(sourceSyntax, StringLiteral(name));
// nameof(source.Value1)
yield return new EnumMemberMapping(sourceSyntax, NameOf(sourceSyntax));
continue;
}

// nameof(source.Value1)
yield return new EnumMemberMapping(sourceSyntax, NameOf(sourceSyntax));
// "value1"
yield return new EnumMemberMapping(sourceSyntax, StringLiteral(name));
}
}

private static IReadOnlyDictionary<IFieldSymbol, ExpressionSyntax> BuildExplicitValueMappings(MappingBuilderContext ctx)
private static IReadOnlyDictionary<IFieldSymbol, string> BuildExplicitValueMappings(MappingBuilderContext ctx)
{
var explicitMappings = new Dictionary<IFieldSymbol, ExpressionSyntax>(SymbolEqualityComparer.Default);
var sourceFields = ctx.SymbolAccessor.GetEnumFields(ctx.Source);
var explicitMappings = new Dictionary<IFieldSymbol, string>(SymbolEqualityComparer.Default);
if (!ctx.Configuration.Enum.HasExplicitConfigurations)
return explicitMappings;

var sourceFields = ctx.SymbolAccessor.GetEnumFieldsByValue(ctx.Source);
foreach (var (source, target) in ctx.Configuration.Enum.ExplicitMappings)
{
if (!SymbolEqualityComparer.Default.Equals(source.ConstantValue.Type, ctx.Source))
if (
!SymbolEqualityComparer.Default.Equals(source.ConstantValue.Type, ctx.Source)
|| !sourceFields.TryGetValue(source.ConstantValue.Value!, out var sourceField)
)
{
ctx.ReportDiagnostic(
DiagnosticDescriptors.SourceEnumValueDoesNotMatchSourceEnumType,
Expand All @@ -96,12 +93,13 @@ private static IReadOnlyDictionary<IFieldSymbol, ExpressionSyntax> BuildExplicit
continue;
}

if (!sourceFields.TryGetValue(source.ConstantValue.Value!, out var sourceField))
if (target.ConstantValue.Value is not string targetStringValue)
{
ctx.ReportDiagnostic(DiagnosticDescriptors.EnumExplicitMappingTargetNotString);
continue;
}

if (!explicitMappings.TryAdd(sourceField, target.Expression))
if (!explicitMappings.TryAdd(sourceField, targetStringValue))
{
ctx.ReportDiagnostic(DiagnosticDescriptors.EnumSourceValueDuplicated, sourceField, ctx.Source, ctx.Target);
}
Expand All @@ -121,7 +119,7 @@ private static EnumFallbackValueMapping BuildFallbackMapping(MappingBuilderConte

if (fallbackValue.Value.ConstantValue.Value is not string fallbackString)
{
ctx.ReportDiagnostic(DiagnosticDescriptors.InvalidFallbackValue, fallbackValue.Value.Expression.ToFullString());
ctx.ReportDiagnostic(DiagnosticDescriptors.InvalidEnumMappingFallbackValue, fallbackValue.Value.Expression.ToFullString());
return new EnumFallbackValueMapping(ctx.Source, ctx.Target, new ToStringMapping(ctx.Source, ctx.Target));
}

Expand Down
Loading

0 comments on commit 35fbd7c

Please sign in to comment.