diff --git a/docs/docs/configuration/enum.mdx b/docs/docs/configuration/enum.mdx index f8cb6a3b7c..20db9a82c3 100644 --- a/docs/docs/configuration/enum.mdx +++ b/docs/docs/configuration/enum.mdx @@ -56,11 +56,68 @@ The `IgnoreCase` property allows to opt in for case insensitive mappings (defaul +## Enum from/to string naming strategies + +Enum from/to strings mappings can be customized by setting the enum naming strategy to be used. +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. | + +Note that explicit enum mappings (`MapEnumValue`) and fallback values (`FallbackValue` in `MapEnum`) +are not affected by naming strategies. + + + + + Applied to all enums mapped inside this mapper. + + ```csharp + // highlight-start + [Mapper(EnumNamingStrategy = EnumNamingStrategy.SnakeCase)] + // highlight-end + public partial class CarMapper + { + ... + } + ``` + + + + + Applied to the specific enum mapped by this method. + Attribute is only valid on mapping method with enums as parameters. + + ```csharp + [Mapper] + public partial class CarMapper + { + // highlight-start + [MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.SnakeCase)] + // highlight-end + public partial CarMakeDto MapMake(CarMake make); + } + ``` + + + + + ## Manually mapped enum values To explicitly map enum values the `MapEnumValueAttibute` can be used. Attribute is only valid on enum-to-enum, enum-to-string and string-to-enum mappings. +Explicit enum mappings are not affected by enum naming strategies. + ```csharp [Mapper] public partial class CarMapper @@ -106,6 +163,7 @@ To map to a fallback value instead of throwing when encountering an unknown valu the `FallbackValue` property on the `MapEnum` attribute can be used. `FallbackValue` is supported by `ByName` and `ByValueCheckDefined`. +`FallbackValue` is not affected by enum naming strategies. ```csharp [Mapper] diff --git a/src/Riok.Mapperly.Abstractions/EnumNamingStrategy.cs b/src/Riok.Mapperly.Abstractions/EnumNamingStrategy.cs new file mode 100644 index 0000000000..ecbc9711b1 --- /dev/null +++ b/src/Riok.Mapperly.Abstractions/EnumNamingStrategy.cs @@ -0,0 +1,42 @@ +namespace Riok.Mapperly.Abstractions; + +/// +/// Defines the strategy to use when mapping an enum from/to string. +/// +public enum EnumNamingStrategy +{ + /// + /// Matches enum values using their name. + /// + MemberName, + + /// + /// Matches enum values using camelCase. + /// + CamelCase, + + /// + /// Matches enum values using PascalCase. + /// + PascalCase, + + /// + /// Matches enum values using snake_case. + /// + SnakeCase, + + /// + /// Matches enum values using UPPER_SNAKE_CASE. + /// + UpperSnakeCase, + + /// + /// Matches enum values using kebab-case. + /// + KebabCase, + + /// + /// Matches enum values using UPPER-KEBAB-CASE. + /// + UpperKebabCase, +} diff --git a/src/Riok.Mapperly.Abstractions/MapEnumAttribute.cs b/src/Riok.Mapperly.Abstractions/MapEnumAttribute.cs index c7ad02bb77..0dd4f96dfd 100644 --- a/src/Riok.Mapperly.Abstractions/MapEnumAttribute.cs +++ b/src/Riok.Mapperly.Abstractions/MapEnumAttribute.cs @@ -19,7 +19,7 @@ public MapEnumAttribute(EnumMappingStrategy strategy) } /// - /// The strategy to be used to map enums. + /// The strategy to be used to map enums to enums. /// public EnumMappingStrategy Strategy { get; } @@ -32,4 +32,9 @@ public MapEnumAttribute(EnumMappingStrategy strategy) /// The fallback value if an enum cannot be mapped, used instead of throwing. /// public object? FallbackValue { get; set; } + + /// + /// The strategy to be used to map enums from/to strings. + /// + public EnumNamingStrategy NamingStrategy { get; set; } } diff --git a/src/Riok.Mapperly.Abstractions/MapperAttribute.cs b/src/Riok.Mapperly.Abstractions/MapperAttribute.cs index 84a269ee33..664bcc5227 100644 --- a/src/Riok.Mapperly.Abstractions/MapperAttribute.cs +++ b/src/Riok.Mapperly.Abstractions/MapperAttribute.cs @@ -125,4 +125,10 @@ public class MapperAttribute : Attribute /// partial methods are discovered. /// public bool AutoUserMappings { get; set; } = true; + + /// + /// The default enum naming strategy. + /// Can be overwritten on specific enums via mapping method configurations. + /// + public EnumNamingStrategy EnumNamingStrategy { get; set; } = EnumNamingStrategy.MemberName; } diff --git a/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt b/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt index f40e11a59d..01a2c66689 100644 --- a/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt +++ b/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt @@ -188,3 +188,15 @@ Riok.Mapperly.Abstractions.MapperAttribute.IncludedConstructors.get -> Riok.Mapp Riok.Mapperly.Abstractions.MapperAttribute.IncludedConstructors.set -> void Riok.Mapperly.Abstractions.MapEnumValueAttribute.Source.get -> object! Riok.Mapperly.Abstractions.MapEnumValueAttribute.Target.get -> object! +Riok.Mapperly.Abstractions.EnumNamingStrategy +Riok.Mapperly.Abstractions.EnumNamingStrategy.MemberName = 0 -> Riok.Mapperly.Abstractions.EnumNamingStrategy +Riok.Mapperly.Abstractions.EnumNamingStrategy.CamelCase = 1 -> Riok.Mapperly.Abstractions.EnumNamingStrategy +Riok.Mapperly.Abstractions.EnumNamingStrategy.PascalCase = 2 -> Riok.Mapperly.Abstractions.EnumNamingStrategy +Riok.Mapperly.Abstractions.EnumNamingStrategy.SnakeCase = 3 -> Riok.Mapperly.Abstractions.EnumNamingStrategy +Riok.Mapperly.Abstractions.EnumNamingStrategy.UpperSnakeCase = 4 -> Riok.Mapperly.Abstractions.EnumNamingStrategy +Riok.Mapperly.Abstractions.EnumNamingStrategy.KebabCase = 5 -> Riok.Mapperly.Abstractions.EnumNamingStrategy +Riok.Mapperly.Abstractions.EnumNamingStrategy.UpperKebabCase = 6 -> Riok.Mapperly.Abstractions.EnumNamingStrategy +Riok.Mapperly.Abstractions.MapEnumAttribute.NamingStrategy.get -> Riok.Mapperly.Abstractions.EnumNamingStrategy +Riok.Mapperly.Abstractions.MapEnumAttribute.NamingStrategy.set -> void +Riok.Mapperly.Abstractions.MapperAttribute.EnumNamingStrategy.get -> Riok.Mapperly.Abstractions.EnumNamingStrategy +Riok.Mapperly.Abstractions.MapperAttribute.EnumNamingStrategy.set -> void diff --git a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md index cdcd8920d0..dd7ae0cd63 100644 --- a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md +++ b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md @@ -187,6 +187,7 @@ 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 ### Removed Rules diff --git a/src/Riok.Mapperly/Configuration/EnumConfiguration.cs b/src/Riok.Mapperly/Configuration/EnumConfiguration.cs index 8ea51ae569..212c140432 100644 --- a/src/Riok.Mapperly/Configuration/EnumConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/EnumConfiguration.cs @@ -1,4 +1,3 @@ -using Microsoft.CodeAnalysis; using Riok.Mapperly.Abstractions; namespace Riok.Mapperly.Configuration; @@ -10,7 +9,7 @@ namespace Riok.Mapperly.Configuration; public class EnumConfiguration(EnumMappingStrategy strategy) { /// - /// The strategy to be used to map enums. + /// The strategy to be used to map enums to enums. /// public EnumMappingStrategy Strategy { get; } = strategy; @@ -22,5 +21,10 @@ public class EnumConfiguration(EnumMappingStrategy strategy) /// /// The fallback value if an enum cannot be mapped, used instead of throwing. /// - public IFieldSymbol? FallbackValue { get; set; } + public AttributeValue? FallbackValue { get; set; } + + /// + /// The strategy to be used to map enums from/to strings. + /// + public EnumNamingStrategy NamingStrategy { get; set; } } diff --git a/src/Riok.Mapperly/Configuration/EnumMappingConfiguration.cs b/src/Riok.Mapperly/Configuration/EnumMappingConfiguration.cs index ac2f31fce5..b2af7e1b35 100644 --- a/src/Riok.Mapperly/Configuration/EnumMappingConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/EnumMappingConfiguration.cs @@ -6,11 +6,12 @@ namespace Riok.Mapperly.Configuration; public record EnumMappingConfiguration( EnumMappingStrategy Strategy, bool IgnoreCase, - IFieldSymbol? FallbackValue, + AttributeValue? FallbackValue, IReadOnlyCollection IgnoredSourceMembers, IReadOnlyCollection IgnoredTargetMembers, IReadOnlyCollection ExplicitMappings, - RequiredMappingStrategy RequiredMappingStrategy + RequiredMappingStrategy RequiredMappingStrategy, + EnumNamingStrategy NamingStrategy ) { public bool HasExplicitConfigurations => ExplicitMappings.Count > 0 || IgnoredSourceMembers.Count > 0 || IgnoredTargetMembers.Count > 0; diff --git a/src/Riok.Mapperly/Configuration/MapperConfiguration.cs b/src/Riok.Mapperly/Configuration/MapperConfiguration.cs index f2b24c7ec8..2bee7dc7ce 100644 --- a/src/Riok.Mapperly/Configuration/MapperConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/MapperConfiguration.cs @@ -118,4 +118,10 @@ public record MapperConfiguration /// Whether to consider non-partial methods in a mapper as user implemented mapping methods. /// public bool? AutoUserMappings { get; init; } + + /// + /// The default enum naming strategy. + /// Can be overwritten on specific enums via mapping method configurations. + /// + public EnumNamingStrategy? EnumNamingStrategy { get; init; } } diff --git a/src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs b/src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs index ee10b1db41..fa0e603345 100644 --- a/src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs +++ b/src/Riok.Mapperly/Configuration/MapperConfigurationMerger.cs @@ -65,6 +65,9 @@ public static MapperAttribute Merge(MapperConfiguration mapperConfiguration, Map mapper.AutoUserMappings = mapperConfiguration.AutoUserMappings ?? defaultMapperConfiguration.AutoUserMappings ?? mapper.AutoUserMappings; + mapper.EnumNamingStrategy = + mapperConfiguration.EnumNamingStrategy ?? defaultMapperConfiguration.EnumNamingStrategy ?? mapper.EnumNamingStrategy; + return mapper; } } diff --git a/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs b/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs index b99bd14d9d..86a353ea67 100644 --- a/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs +++ b/src/Riok.Mapperly/Configuration/MapperConfigurationReader.cs @@ -33,7 +33,8 @@ MapperConfiguration defaultMapperConfiguration [], [], [], - mapper.RequiredMappingStrategy + mapper.RequiredMappingStrategy, + mapper.EnumNamingStrategy ), new MembersMappingConfiguration([], [], [], [], [], mapper.IgnoreObsoleteMembersStrategy, mapper.RequiredMappingStrategy), [] @@ -162,7 +163,8 @@ private EnumMappingConfiguration BuildEnumConfig(MappingConfigurationReference c ignoredSources, ignoredTargets, explicitMappings, - requiredMapping ?? MapperConfiguration.Enum.RequiredMappingStrategy + requiredMapping ?? MapperConfiguration.Enum.RequiredMappingStrategy, + configData?.NamingStrategy ?? MapperConfiguration.Enum.NamingStrategy ); } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/EnumMappingDiagnosticReporter.cs b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/EnumMappingDiagnosticReporter.cs index c4a31174df..4eae55fd78 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/EnumMappingDiagnosticReporter.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/EnumMappingDiagnosticReporter.cs @@ -49,8 +49,9 @@ IEnumerable targetMembers if (!ctx.Configuration.Enum.RequiredMappingStrategy.HasFlag(RequiredMappingStrategy.Target)) return; + var fallbackValue = ctx.Configuration.Enum.FallbackValue?.ConstantValue.Value; var missingTargetMembers = targetMembers.Where(field => - !mappings.Contains(field) && ctx.Configuration.Enum.FallbackValue?.ConstantValue?.Equals(field.ConstantValue) != true + !mappings.Contains(field) && fallbackValue?.Equals(field.ConstantValue) is not true ); foreach (var member in missingTargetMembers) { diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/EnumToEnumMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/EnumToEnumMappingBuilder.cs index ae8f1e0a80..a532739fe4 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/EnumToEnumMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/EnumToEnumMappingBuilder.cs @@ -1,10 +1,14 @@ using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Riok.Mapperly.Abstractions; using Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext; using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Descriptors.Mappings.Enums; using Riok.Mapperly.Diagnostics; using Riok.Mapperly.Helpers; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; +using static Riok.Mapperly.Emit.Syntax.SyntaxFactoryHelper; namespace Riok.Mapperly.Descriptors.MappingBuilders; @@ -68,7 +72,7 @@ private static INewInstanceMapping BuildEnumToEnumCastMapping( EqualityComparer.Default ); var fallbackMapping = BuildFallbackMapping(ctx); - if (fallbackMapping.FallbackMember != null && !checkTargetDefined) + if (fallbackMapping.FallbackExpression is not null && !checkTargetDefined) { ctx.ReportDiagnostic(DiagnosticDescriptors.EnumFallbackValueRequiresByValueCheckDefinedStrategy); checkTargetDefined = true; @@ -173,17 +177,31 @@ params IEqualityComparer[] propertyComparer private static EnumFallbackValueMapping BuildFallbackMapping(MappingBuilderContext ctx) { var fallbackValue = ctx.Configuration.Enum.FallbackValue; - if (fallbackValue == null) + if (fallbackValue is null) + { + return new EnumFallbackValueMapping(ctx.Source, ctx.Target); + } + + if (fallbackValue is not { Expression: MemberAccessExpressionSyntax memberAccessExpression }) + { + ctx.ReportDiagnostic(DiagnosticDescriptors.InvalidFallbackValue, fallbackValue.Value.Expression.ToFullString()); return new EnumFallbackValueMapping(ctx.Source, ctx.Target); + } + + var fallbackExpression = MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + FullyQualifiedIdentifier(ctx.Target), + memberAccessExpression.Name + ); - if (SymbolEqualityComparer.Default.Equals(ctx.Target, fallbackValue.Type)) - return new EnumFallbackValueMapping(ctx.Source, ctx.Target, fallbackMember: fallbackValue); + 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.ConstantValue ?? 0, - fallbackValue.Type, + fallbackValue.Value.ConstantValue.Value ?? 0, + fallbackValue.Value.ConstantValue.Type?.Name ?? "unknown", ctx.Target ); return new EnumFallbackValueMapping(ctx.Source, ctx.Target); diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/EnumToStringMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/EnumToStringMappingBuilder.cs index 17822cec4d..21940abaf2 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/EnumToStringMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/EnumToStringMappingBuilder.cs @@ -6,6 +6,7 @@ using Riok.Mapperly.Descriptors.Mappings.Enums; using Riok.Mapperly.Diagnostics; using Riok.Mapperly.Helpers; +using static Riok.Mapperly.Emit.Syntax.SyntaxFactoryHelper; namespace Riok.Mapperly.Descriptors.MappingBuilders; @@ -26,11 +27,55 @@ public static class EnumToStringMappingBuilder private static EnumToStringMapping BuildEnumToStringMapping(MappingBuilderContext ctx) { - var ignoredSourceMembers = ctx.Configuration.Enum.IgnoredSourceMembers.ToHashSet(SymbolTypeEqualityComparer.FieldDefault); - var explicitMappings = BuildExplicitValueMappings(ctx); + 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 BuildEnumMemberMappings(MappingBuilderContext ctx, string? fallbackStringValue) + { + var namingStrategy = ctx.Configuration.Enum.NamingStrategy; + + var ignoredSourceMembers = ctx.Configuration.Enum.IgnoredSourceMembers.ToHashSet(SymbolTypeEqualityComparer.FieldDefault); EnumMappingDiagnosticReporter.AddUnmatchedSourceIgnoredMembers(ctx, ignoredSourceMembers); - return new EnumToStringMapping(ctx.Source, ctx.Target, ctx.SymbolAccessor.GetAllFields(ctx.Source), explicitMappings); + + var sourceFields = ctx.SymbolAccessor.GetFieldsExcept(ctx.Source, ignoredSourceMembers); + var explicitValueMappings = BuildExplicitValueMappings(ctx); + + foreach (var sourceField in sourceFields) + { + // 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)) + { + // "explicit_value1" + yield return new EnumMemberMapping(sourceSyntax, explicitMapping); + continue; + } + + if (namingStrategy is not EnumNamingStrategy.MemberName) + { + // "value1" + yield return new EnumMemberMapping(sourceSyntax, StringLiteral(name)); + continue; + } + + // nameof(source.Value1) + yield return new EnumMemberMapping(sourceSyntax, NameOf(sourceSyntax)); + } } private static IReadOnlyDictionary BuildExplicitValueMappings(MappingBuilderContext ctx) @@ -64,4 +109,23 @@ private static IReadOnlyDictionary BuildExplicit return explicitMappings; } + + private static EnumFallbackValueMapping BuildFallbackMapping(MappingBuilderContext ctx, out string? fallbackStringValue) + { + fallbackStringValue = null; + var fallbackValue = ctx.Configuration.Enum.FallbackValue; + if (fallbackValue is null) + { + return new EnumFallbackValueMapping(ctx.Source, ctx.Target, new ToStringMapping(ctx.Source, ctx.Target)); + } + + if (fallbackValue.Value.ConstantValue.Value is not string fallbackString) + { + ctx.ReportDiagnostic(DiagnosticDescriptors.InvalidFallbackValue, fallbackValue.Value.Expression.ToFullString()); + return new EnumFallbackValueMapping(ctx.Source, ctx.Target, new ToStringMapping(ctx.Source, ctx.Target)); + } + + fallbackStringValue = fallbackString; + return new EnumFallbackValueMapping(ctx.Source, ctx.Target, fallbackExpression: StringLiteral(fallbackString)); + } } diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/StringToEnumMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/StringToEnumMappingBuilder.cs index 5c3c0b99ec..6956ebd5b8 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/StringToEnumMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/StringToEnumMappingBuilder.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Riok.Mapperly.Abstractions; using Riok.Mapperly.Descriptors.MappingBodyBuilders.BuilderContext; @@ -6,6 +7,8 @@ using Riok.Mapperly.Descriptors.Mappings.Enums; using Riok.Mapperly.Diagnostics; using Riok.Mapperly.Helpers; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; +using static Riok.Mapperly.Emit.Syntax.SyntaxFactoryHelper; namespace Riok.Mapperly.Descriptors.MappingBuilders; @@ -43,35 +46,79 @@ private static EnumFromStringSwitchMapping BuildEnumFromStringSwitchMapping( bool genericEnumParseMethodSupported ) { - var ignoredTargetMembers = ctx.Configuration.Enum.IgnoredTargetMembers.ToHashSet(SymbolTypeEqualityComparer.FieldDefault); - var explicitMappings = BuildExplicitValueMappings(ctx); - + var fallbackMapping = BuildFallbackParseMapping(ctx, genericEnumParseMethodSupported, out var fallbackMember); + var enumMemberMappings = BuildEnumMemberMappings(ctx, fallbackMember); // from string => use an optimized method of Enum.Parse which would use slow reflection // however we currently don't support all features of Enum.Parse yet (ex. flags) // therefore we use Enum.Parse as fallback. - var fallbackMapping = BuildFallbackParseMapping(ctx, genericEnumParseMethodSupported); - var members = ctx.SymbolAccessor.GetAllFields(ctx.Target); - if (fallbackMapping.FallbackMember != null) + if (fallbackMember is not null) { // no need to explicitly map fallback value - members = members.Where(x => fallbackMapping.FallbackMember.ConstantValue?.Equals(x.ConstantValue) != true); + enumMemberMappings = enumMemberMappings.Where(m => + !m.TargetSyntax.ToString().Equals(fallbackMember.Name, StringComparison.Ordinal) + ); } - EnumMappingDiagnosticReporter.AddUnmatchedTargetIgnoredMembers(ctx, ignoredTargetMembers); return new EnumFromStringSwitchMapping( ctx.Source, ctx.Target, - members, ctx.Configuration.Enum.IgnoreCase, - fallbackMapping, - explicitMappings + enumMemberMappings, + fallbackMapping ); } - private static EnumFallbackValueMapping BuildFallbackParseMapping(MappingBuilderContext ctx, bool genericEnumParseMethodSupported) + private static IEnumerable BuildEnumMemberMappings(MappingBuilderContext ctx, IFieldSymbol? fallbackMember) { + var namingStrategy = ctx.Configuration.Enum.NamingStrategy; + + var ignoredTargetMembers = ctx.Configuration.Enum.IgnoredTargetMembers.ToHashSet(SymbolTypeEqualityComparer.FieldDefault); + EnumMappingDiagnosticReporter.AddUnmatchedTargetIgnoredMembers(ctx, ignoredTargetMembers); + if (fallbackMember is not null) + { + ignoredTargetMembers.Add(fallbackMember); + } + + var targetFields = ctx.SymbolAccessor.GetFieldsExcept(ctx.Target, ignoredTargetMembers); + var explicitValueMappings = BuildExplicitValueMappings(ctx); + + foreach (var targetField in targetFields) + { + // source.Value1 + var targetSyntax = MemberAccess(FullyQualifiedIdentifier(ctx.Target), targetField.Name); + + if (explicitValueMappings.TryGetValue(targetField, out var explicitMappings)) + { + foreach (var explicitMapping in explicitMappings) + { + // "explicit_value1" => source.Value1 + yield return new EnumMemberMapping(explicitMapping, targetSyntax); + } + continue; + } + + if (namingStrategy is EnumNamingStrategy.MemberName) + { + // nameof(source.Value1) + yield return new EnumMemberMapping(NameOf(targetSyntax), targetSyntax); + continue; + } + + // "value1" + var name = targetField.GetName(namingStrategy); + yield return new EnumMemberMapping(StringLiteral(name), targetSyntax); + } + } + + private static EnumFallbackValueMapping BuildFallbackParseMapping( + MappingBuilderContext ctx, + bool genericEnumParseMethodSupported, + out IFieldSymbol? fallbackMember + ) + { + fallbackMember = null; var fallbackValue = ctx.Configuration.Enum.FallbackValue; - if (fallbackValue == null) + if (fallbackValue is null) { return new EnumFallbackValueMapping( ctx.Source, @@ -80,14 +127,33 @@ private static EnumFallbackValueMapping BuildFallbackParseMapping(MappingBuilder ); } - if (SymbolEqualityComparer.Default.Equals(ctx.Target, fallbackValue.Type)) - return new EnumFallbackValueMapping(ctx.Source, ctx.Target, fallbackMember: fallbackValue); + if (fallbackValue is not { Expression: MemberAccessExpressionSyntax memberAccessExpression }) + { + ctx.ReportDiagnostic(DiagnosticDescriptors.InvalidFallbackValue, fallbackValue.Value.Expression.ToFullString()); + return new EnumFallbackValueMapping( + ctx.Source, + ctx.Target, + new EnumFromStringParseMapping(ctx.Source, ctx.Target, genericEnumParseMethodSupported, ctx.Configuration.Enum.IgnoreCase) + ); + } + + var fallbackExpression = MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + FullyQualifiedIdentifier(ctx.Target), + memberAccessExpression.Name + ); + + if (SymbolEqualityComparer.Default.Equals(ctx.Target, fallbackValue.Value.ConstantValue.Type)) + { + fallbackMember = ctx.SymbolAccessor.GetField(ctx.Target, memberAccessExpression.Name.Identifier.Text); + return new EnumFallbackValueMapping(ctx.Source, ctx.Target, fallbackExpression: fallbackExpression); + } ctx.ReportDiagnostic( DiagnosticDescriptors.EnumFallbackValueTypeDoesNotMatchTargetEnumType, fallbackValue, - fallbackValue.ConstantValue ?? 0, - fallbackValue.Type, + fallbackValue.Value.ConstantValue.Value ?? 0, + fallbackValue.Value.ConstantValue.Type?.Name ?? "unknown", ctx.Target ); return new EnumFallbackValueMapping( diff --git a/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumFallbackValueMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumFallbackValueMapping.cs index 3d95e85d55..659a1cf654 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumFallbackValueMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumFallbackValueMapping.cs @@ -13,21 +13,21 @@ public class EnumFallbackValueMapping( ITypeSymbol source, ITypeSymbol target, INewInstanceMapping? fallbackMapping = null, - IFieldSymbol? fallbackMember = null + ExpressionSyntax? fallbackExpression = null ) : NewInstanceMapping(source, target) { - public IFieldSymbol? FallbackMember { get; } = fallbackMember; + public ExpressionSyntax? FallbackExpression { get; } = fallbackExpression; public SwitchExpressionArmSyntax BuildDiscardArm(TypeMappingBuildContext ctx) => SwitchArm(DiscardPattern(), Build(ctx)); public override ExpressionSyntax Build(TypeMappingBuildContext ctx) { - if (fallbackMapping != null) + if (fallbackMapping is not null) return fallbackMapping.Build(ctx); - if (FallbackMember == null) + if (FallbackExpression is null) return ThrowArgumentOutOfRangeException(ctx.Source, $"The value of enum {SourceType.Name} is not supported"); - return MemberAccess(FullyQualifiedIdentifier(TargetType), FallbackMember.Name); + return FallbackExpression; } } diff --git a/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumFromStringSwitchMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumFromStringSwitchMapping.cs index f049ff52a7..0f3ab3186c 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumFromStringSwitchMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumFromStringSwitchMapping.cs @@ -1,7 +1,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Riok.Mapperly.Emit.Syntax; -using Riok.Mapperly.Helpers; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.Syntax.SyntaxFactoryHelper; @@ -15,10 +14,9 @@ namespace Riok.Mapperly.Descriptors.Mappings.Enums; public class EnumFromStringSwitchMapping( ITypeSymbol sourceType, ITypeSymbol targetType, - IEnumerable enumMembers, bool ignoreCase, - EnumFallbackValueMapping fallbackMapping, - IReadOnlyDictionary> explicitMappings + IEnumerable enumMemberMappings, + EnumFallbackValueMapping fallbackMapping ) : NewInstanceMethodMapping(sourceType, targetType) { private const string IgnoreCaseSwitchDesignatedVariableName = "s"; @@ -38,30 +36,10 @@ public override IEnumerable BuildBody(TypeMappingBuildContext c private IEnumerable BuildArmsIgnoreCase(TypeMappingBuildContext ctx) { var ignoreCaseSwitchDesignatedVariableName = ctx.NameBuilder.New(IgnoreCaseSwitchDesignatedVariableName); - foreach (var field in enumMembers) - { - // source.Value1 - var typeMemberAccess = MemberAccess(field.ContainingType.NonNullable().FullyQualifiedIdentifierName(), field.Name); - if (explicitMappings.TryGetValue(field, out var sourceExpressions)) - { - // add switch arm for each source string - foreach (var sourceExpression in sourceExpressions) - { - yield return BuildArmIgnoreCase(ignoreCaseSwitchDesignatedVariableName, typeMemberAccess, sourceExpression); - } - } - else - { - yield return BuildArmIgnoreCase(ignoreCaseSwitchDesignatedVariableName, typeMemberAccess, NameOf(typeMemberAccess)); - } - } + return enumMemberMappings.Select(m => BuildArmIgnoreCase(ignoreCaseSwitchDesignatedVariableName, m)); } - private static SwitchExpressionArmSyntax BuildArmIgnoreCase( - string ignoreCaseSwitchDesignatedVariableName, - MemberAccessExpressionSyntax typeMemberAccess, - ExpressionSyntax stringExpression - ) + private static SwitchExpressionArmSyntax BuildArmIgnoreCase(string ignoreCaseSwitchDesignatedVariableName, EnumMemberMapping mapping) { // { } s var pattern = RecursivePattern() @@ -74,36 +52,14 @@ ExpressionSyntax stringExpression var whenClause = SwitchWhen( InvocationWithoutIndention( MemberAccess(ignoreCaseSwitchDesignatedVariableName, StringEqualsMethodName), - stringExpression, + mapping.SourceSyntax, IdentifierName(StringComparisonFullName) ) ); // { } s when s.Equals(nameof(source.Value1), StringComparison.OrdinalIgnoreCase) => source.Value1; - return SwitchArm(pattern, typeMemberAccess).WithWhenClause(whenClause); + return SwitchArm(pattern, mapping.TargetSyntax).WithWhenClause(whenClause); } - private IEnumerable BuildArms() - { - foreach (var field in enumMembers) - { - // source.Value1 - var typeMemberAccess = MemberAccess(field.ContainingType.NonNullable().FullyQualifiedIdentifierName(), field.Name); - if (explicitMappings.TryGetValue(field, out var sourceExpressions)) - { - // add switch arm for each source string - foreach (var sourceExpression in sourceExpressions) - { - yield return BuildArm(typeMemberAccess, sourceExpression); - } - } - else - { - yield return BuildArm(typeMemberAccess, NameOf(typeMemberAccess)); - } - } - } - - private static SwitchExpressionArmSyntax BuildArm(MemberAccessExpressionSyntax typeMemberAccess, ExpressionSyntax patternSyntax) => - SwitchArm(ConstantPattern(patternSyntax), typeMemberAccess); + private IEnumerable BuildArms() => enumMemberMappings.Select(m => m.BuildSwitchArm()); } diff --git a/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumMemberMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumMemberMapping.cs new file mode 100644 index 0000000000..eb21e883ac --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumMemberMapping.cs @@ -0,0 +1,10 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; +using static Riok.Mapperly.Emit.Syntax.SyntaxFactoryHelper; + +namespace Riok.Mapperly.Descriptors.Mappings.Enums; + +public sealed record EnumMemberMapping(ExpressionSyntax SourceSyntax, ExpressionSyntax TargetSyntax) +{ + public SwitchExpressionArmSyntax BuildSwitchArm() => SwitchArm(ConstantPattern(SourceSyntax), TargetSyntax); +} diff --git a/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumToStringMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumToStringMapping.cs index 99dac5e532..770fcbb861 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumToStringMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumToStringMapping.cs @@ -1,8 +1,5 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -using Riok.Mapperly.Helpers; -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; -using static Riok.Mapperly.Emit.Syntax.SyntaxFactoryHelper; namespace Riok.Mapperly.Descriptors.Mappings.Enums; @@ -14,39 +11,21 @@ namespace Riok.Mapperly.Descriptors.Mappings.Enums; public class EnumToStringMapping( ITypeSymbol sourceType, ITypeSymbol targetType, - IEnumerable enumMembers, - IReadOnlyDictionary explicitMappings + IEnumerable enumMemberMappings, + EnumFallbackValueMapping fallbackMapping ) : NewInstanceMethodMapping(sourceType, targetType) { - private const string ToStringMethodName = nameof(Enum.ToString); - public override IEnumerable BuildBody(TypeMappingBuildContext ctx) { // fallback switch arm: _ => source.ToString() - var fallbackArm = SwitchArm(DiscardPattern(), ctx.SyntaxFactory.Invocation(MemberAccess(ctx.Source, ToStringMethodName))); + // or: _ => "fallback_value" (if fallback mapping exists) + var fallbackArm = fallbackMapping.BuildDiscardArm(ctx); // switch for each name to the enum value // eg: Enum1.Value1 => "Value1" // or: Enum1.Value1 => "value_1" (if explicit mapping exists) - var arms = enumMembers.Select(BuildArm).Append(fallbackArm); + var arms = enumMemberMappings.Select(m => m.BuildSwitchArm()).Append(fallbackArm); var switchExpr = ctx.SyntaxFactory.Switch(ctx.Source, arms); yield return ctx.SyntaxFactory.Return(switchExpr); } - - private SwitchExpressionArmSyntax BuildArm(IFieldSymbol field) - { - // source.Value1 - var typeMemberAccess = MemberAccess(FullyQualifiedIdentifier(field.ContainingType.NonNullable()), field.Name); - var pattern = ConstantPattern(typeMemberAccess); - if (explicitMappings.TryGetValue(field, out var expression)) - { - // source.Value1 => "VALUE-1" - return SwitchArm(pattern, expression); - } - - // nameof(source.Value1) - var nameOf = NameOf(typeMemberAccess); - // source.Value1 => nameof(source.Value1) - return SwitchArm(pattern, nameOf); - } } diff --git a/src/Riok.Mapperly/Descriptors/SymbolAccessor.cs b/src/Riok.Mapperly/Descriptors/SymbolAccessor.cs index 0ce5014b1b..8a00d6c352 100644 --- a/src/Riok.Mapperly/Descriptors/SymbolAccessor.cs +++ b/src/Riok.Mapperly/Descriptors/SymbolAccessor.cs @@ -227,6 +227,9 @@ internal IEnumerable GetAllMethods(ITypeSymbol symbol, string nam internal IEnumerable GetAllFields(ITypeSymbol symbol) => GetAllMembers(symbol).OfType(); + internal IFieldSymbol? GetField(ITypeSymbol symbol, string name) => + GetAllFields(symbol).FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.Ordinal)); + internal Dictionary GetEnumFields(ITypeSymbol symbol) => GetAllFields(symbol).ToDictionary(f => f.ConstantValue!, f => f); diff --git a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs index 1143680e95..d3c5251ae3 100644 --- a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs +++ b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs @@ -787,6 +787,16 @@ public static class DiagnosticDescriptors true ); + public static readonly DiagnosticDescriptor InvalidFallbackValue = + new( + "RMG085", + "Invalid usage of Fallback value", + "Fallback value '{0}' is invalid in the this mapping", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Error, + true + ); + private static string BuildHelpUri(string id) { #if ENV_NEXT diff --git a/src/Riok.Mapperly/Helpers/EnumNamingStrategyHelper.cs b/src/Riok.Mapperly/Helpers/EnumNamingStrategyHelper.cs new file mode 100644 index 0000000000..b4fd5aa4ff --- /dev/null +++ b/src/Riok.Mapperly/Helpers/EnumNamingStrategyHelper.cs @@ -0,0 +1,20 @@ +using Microsoft.CodeAnalysis; +using Riok.Mapperly.Abstractions; + +namespace Riok.Mapperly.Helpers; + +public static class EnumNamingStrategyHelper +{ + public static string GetName(this IFieldSymbol field, EnumNamingStrategy namingStrategy) => + 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(), + _ => throw new ArgumentOutOfRangeException($"{nameof(namingStrategy)} has an unknown value {namingStrategy}"), + }; +} diff --git a/src/Riok.Mapperly/Helpers/MemberNamingUtil.cs b/src/Riok.Mapperly/Helpers/MemberNamingUtil.cs new file mode 100644 index 0000000000..301696018e --- /dev/null +++ b/src/Riok.Mapperly/Helpers/MemberNamingUtil.cs @@ -0,0 +1,199 @@ +using System.Globalization; +using System.Text; + +namespace Riok.Mapperly.Helpers; + +public static class MemberNamingUtil +{ + private static readonly Processor _camelCaseProcessor = new(string.Empty, LetterCase.Lower, LetterCase.Upper, LetterCase.Lower); + private static readonly Processor _pascalCaseProcessor = new(string.Empty, LetterCase.Upper, LetterCase.Upper, LetterCase.Lower); + private static readonly Processor _snakeCaseProcessor = new("_", LetterCase.Lower); + private static readonly Processor _upperSnakeCaseProcessor = new("_", LetterCase.Upper); + private static readonly Processor _kebabCaseProcessor = new("-", LetterCase.Lower); + private static readonly Processor _upperKebabCaseProcessor = new("-", LetterCase.Upper); + + public static string ToCamelCase(this string str) => _camelCaseProcessor.Process(str); + + public static string ToPascalCase(this string str) => _pascalCaseProcessor.Process(str); + + public static string ToSnakeCase(this string str) => _snakeCaseProcessor.Process(str); + + public static string ToUpperSnakeCase(this string str) => _upperSnakeCaseProcessor.Process(str); + + public static string ToKebabCase(this string str) => _kebabCaseProcessor.Process(str); + + public static string ToUpperKebabCase(this string str) => _upperKebabCaseProcessor.Process(str); + + private readonly struct Processor( + string separator, + LetterCase startLetterCase, + LetterCase wordStartLetterCase, + LetterCase wordLetterCase + ) + { + public string Separator { get; } = separator; + public Func ModifyStartLetter { get; } = GetCharModifier(startLetterCase); + public Func ModifyWordStartLetter { get; } = GetCharModifier(wordStartLetterCase); + public Func ModifyLetter { get; } = GetCharModifier(wordLetterCase); + + public Processor(string separator, LetterCase letterCase) + : this(separator, letterCase, letterCase, letterCase) { } + + public string Process(string str) + { + if (str.Length == 0) + return str; + + var capacity = str.Length; + if (Separator.Length > 0) + { + // average word length is around 5 chars + // add 20% capacity to account for the separator + capacity = (int)(capacity * 1.2); + } + + var state = new ProcessorState(capacity, this); + foreach (var c in str) + { + switch (char.GetUnicodeCategory(c)) + { + case UnicodeCategory.UppercaseLetter: + state.AppendUpper(c); + break; + case UnicodeCategory.LowercaseLetter: + state.AppendLower(c); + break; + case UnicodeCategory.DecimalDigitNumber: + state.AppendDigit(c); + break; + case UnicodeCategory.ConnectorPunctuation when !state.IsAtStart: + state.AppendSeparator(); + break; + } + } + + return state.Done(); + } + + private static Func GetCharModifier(LetterCase letterCase) => + letterCase switch + { + LetterCase.Lower => char.ToLowerInvariant, + LetterCase.Upper => char.ToUpperInvariant, + _ => throw new ArgumentOutOfRangeException(nameof(letterCase), letterCase, "Unknown letter case"), + }; + } + + private struct ProcessorState(int capacity, Processor processor) + { + private readonly StringBuilder _sb = new(capacity); + private State _state = State.Start; + private State _prevState = State.Start; + + public readonly bool IsAtStart => _state == State.Start; + + public readonly string Done() => _sb.ToString(); + + public void AppendSeparator() => UpdateState(State.Separator); + + public void AppendUpper(char c) + { + switch (_state) + { + case State.Start: + AppendStart(c, State.UppercaseLetter); + break; + case State.LowercaseLetterOrDigit: + case State.Separator: + AppendNewWord(c, State.UppercaseLetter); + break; + case State.UppercaseLetter: + Append(c, State.UppercaseLetter); + break; + } + } + + public void AppendLower(char c) + { + switch (_state) + { + case State.Start: + AppendStart(c, State.LowercaseLetterOrDigit); + break; + case State.Separator: + AppendNewWord(c, State.LowercaseLetterOrDigit); + break; + case State.LowercaseLetterOrDigit: + Append(c, State.LowercaseLetterOrDigit); + break; + case State.UppercaseLetter: + if (_prevState == State.UppercaseLetter) + { + var prevC = _sb[^1]; + _sb.Remove(_sb.Length - 1, 1); + _sb.Append(processor.Separator); + _sb.Append(processor.ModifyWordStartLetter(prevC)); + } + + Append(c, State.LowercaseLetterOrDigit); + break; + } + } + + public void AppendDigit(char c) + { + switch (_state) + { + case State.Start: + AppendStart(c, State.LowercaseLetterOrDigit); + break; + case State.Separator: + AppendNewWord(c, State.LowercaseLetterOrDigit); + break; + case State.LowercaseLetterOrDigit: + case State.UppercaseLetter: + Append(c, State.LowercaseLetterOrDigit); + break; + } + } + + private void AppendNewWord(char c, State newState) + { + _sb.Append(processor.Separator); + _sb.Append(processor.ModifyWordStartLetter(c)); + UpdateState(newState); + } + + private void AppendStart(char c, State newState) + { + _sb.Append(processor.ModifyStartLetter(c)); + UpdateState(newState); + } + + private void Append(char c, State newState) + { + _sb.Append(processor.ModifyLetter(c)); + UpdateState(newState); + } + + private void UpdateState(State newState) + { + _prevState = _state; + _state = newState; + } + + private enum State + { + Start, + UppercaseLetter, + LowercaseLetterOrDigit, + Separator, + } + } + + private enum LetterCase + { + Lower, + Upper, + } +} diff --git a/test/Riok.Mapperly.Tests/Helpers/MemberNamingUtilTest.cs b/test/Riok.Mapperly.Tests/Helpers/MemberNamingUtilTest.cs new file mode 100644 index 0000000000..38bccb4cef --- /dev/null +++ b/test/Riok.Mapperly.Tests/Helpers/MemberNamingUtilTest.cs @@ -0,0 +1,66 @@ +using Riok.Mapperly.Helpers; + +namespace Riok.Mapperly.Tests.Helpers; + +public class MemberNamingUtilTest +{ + [Theory] + [InlineData("OneTwoThree", "oneTwoThree")] + [InlineData("oneTwoThree", "oneTwoThree")] + [InlineData("one_two_three", "oneTwoThree")] + [InlineData("ONE_TWO_THREE", "oneTwoThree")] + [InlineData("OneTWOThree", "oneTwoThree")] + [InlineData("One1Two12Three123", "one1Two12Three123")] + [InlineData("One1TWO12Three123", "one1Two12Three123")] + public void ToCamelCaseTest(string input, string expected) => input.ToCamelCase().Should().Be(expected); + + [Theory] + [InlineData("OneTwoThree", "OneTwoThree")] + [InlineData("oneTwoThree", "OneTwoThree")] + [InlineData("one_two_three", "OneTwoThree")] + [InlineData("ONE_TWO_THREE", "OneTwoThree")] + [InlineData("OneTWOThree", "OneTwoThree")] + [InlineData("One1Two12Three123", "One1Two12Three123")] + [InlineData("One1TWO12Three123", "One1Two12Three123")] + public void ToPascalCaseTest(string input, string expected) => input.ToPascalCase().Should().Be(expected); + + [Theory] + [InlineData("OneTwoThree", "one_two_three")] + [InlineData("oneTwoThree", "one_two_three")] + [InlineData("one_two_three", "one_two_three")] + [InlineData("ONE_TWO_THREE", "one_two_three")] + [InlineData("OneTWOThree", "one_two_three")] + [InlineData("One1Two12Three123", "one1_two12_three123")] + [InlineData("One1TWO12Three123", "one1_two12_three123")] + public void ToSnakeCaseTest(string input, string expected) => input.ToSnakeCase().Should().Be(expected); + + [Theory] + [InlineData("OneTwoThree", "ONE_TWO_THREE")] + [InlineData("oneTwoThree", "ONE_TWO_THREE")] + [InlineData("one_two_three", "ONE_TWO_THREE")] + [InlineData("ONE_TWO_THREE", "ONE_TWO_THREE")] + [InlineData("OneTWOThree", "ONE_TWO_THREE")] + [InlineData("One1Two12Three123", "ONE1_TWO12_THREE123")] + [InlineData("One1TWO12Three123", "ONE1_TWO12_THREE123")] + public void ToUpperSnakeCaseTest(string input, string expected) => input.ToUpperSnakeCase().Should().Be(expected); + + [Theory] + [InlineData("OneTwoThree", "one-two-three")] + [InlineData("oneTwoThree", "one-two-three")] + [InlineData("one_two_three", "one-two-three")] + [InlineData("ONE_TWO_THREE", "one-two-three")] + [InlineData("OneTWOThree", "one-two-three")] + [InlineData("One1Two12Three123", "one1-two12-three123")] + [InlineData("One1TWO12Three123", "one1-two12-three123")] + public void ToKebabCaseTest(string input, string expected) => input.ToKebabCase().Should().Be(expected); + + [Theory] + [InlineData("OneTwoThree", "ONE-TWO-THREE")] + [InlineData("oneTwoThree", "ONE-TWO-THREE")] + [InlineData("one_two_three", "ONE-TWO-THREE")] + [InlineData("ONE_TWO_THREE", "ONE-TWO-THREE")] + [InlineData("OneTWOThree", "ONE-TWO-THREE")] + [InlineData("One1Two12Three123", "ONE1-TWO12-THREE123")] + [InlineData("One1TWO12Three123", "ONE1-TWO12-THREE123")] + public void ToUpperKebabCaseTest(string input, string expected) => input.ToUpperKebabCase().Should().Be(expected); +} diff --git a/test/Riok.Mapperly.Tests/Mapping/EnumFallbackValueTest.cs b/test/Riok.Mapperly.Tests/Mapping/EnumFallbackValueTest.cs index a58d343c91..8a6c4577a6 100644 --- a/test/Riok.Mapperly.Tests/Mapping/EnumFallbackValueTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/EnumFallbackValueTest.cs @@ -131,4 +131,51 @@ public void StringToEnumShouldSwitch() """ ); } + + [Fact] + public void EnumToStringFallbackValueShouldSwitch() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, FallbackValue = \"_unknown\")] partial string ToE1(E1 source);", + "enum E1 {Unknown = -1, A, B, C}" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + global::E1.Unknown => nameof(global::E1.Unknown), + global::E1.A => nameof(global::E1.A), + global::E1.B => nameof(global::E1.B), + global::E1.C => nameof(global::E1.C), + _ => "_unknown", + }; + """ + ); + } + + [Fact] + public void EnumToStringWithNamingStrategyFallbackValueShouldSwitch() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.CamelCase, FallbackValue = \"unknown\")] partial string ToE1(E1 source);", + "enum E1 {Unknown = -1, A, B, C}" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + global::E1.A => "a", + global::E1.B => "b", + global::E1.C => "c", + _ => "unknown", + }; + """ + ); + } } diff --git a/test/Riok.Mapperly.Tests/Mapping/EnumNamingStrategyTest.cs b/test/Riok.Mapperly.Tests/Mapping/EnumNamingStrategyTest.cs new file mode 100644 index 0000000000..b47f4dd99d --- /dev/null +++ b/test/Riok.Mapperly.Tests/Mapping/EnumNamingStrategyTest.cs @@ -0,0 +1,451 @@ +namespace Riok.Mapperly.Tests.Mapping; + +public class EnumNamingStrategyTest +{ + [Fact] + public void EnumToStringWithMemberNameNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.MemberName)] public partial string ToStr(E source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + global::E.Abc => nameof(global::E.Abc), + global::E.BcD => nameof(global::E.BcD), + global::E.C1D => nameof(global::E.C1D), + global::E.DEf => nameof(global::E.DEf), + global::E.EFG => nameof(global::E.EFG), + global::E.FG1 => nameof(global::E.FG1), + global::E.Gh1 => nameof(global::E.Gh1), + global::E.Hi_J => nameof(global::E.Hi_J), + _ => source.ToString(), + }; + """ + ); + } + + [Fact] + public void EnumToStringWithCamelCaseNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.CamelCase)] public partial string ToStr(E source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + global::E.Abc => "abc", + global::E.BcD => "bcD", + global::E.C1D => "c1D", + global::E.DEf => "dEf", + global::E.EFG => "efg", + global::E.FG1 => "fg1", + global::E.Gh1 => "gh1", + global::E.Hi_J => "hiJ", + _ => source.ToString(), + }; + """ + ); + } + + [Fact] + public void EnumToStringWithPascalCaseNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.PascalCase)] public partial string ToStr(E source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + global::E.Abc => "Abc", + global::E.BcD => "BcD", + global::E.C1D => "C1D", + global::E.DEf => "DEf", + global::E.EFG => "Efg", + global::E.FG1 => "Fg1", + global::E.Gh1 => "Gh1", + global::E.Hi_J => "HiJ", + _ => source.ToString(), + }; + """ + ); + } + + [Fact] + public void EnumToStringWithSnakeCaseNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.SnakeCase)] public partial string ToStr(E source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + global::E.Abc => "abc", + global::E.BcD => "bc_d", + global::E.C1D => "c1_d", + global::E.DEf => "d_ef", + global::E.EFG => "efg", + global::E.FG1 => "fg1", + global::E.Gh1 => "gh1", + global::E.Hi_J => "hi_j", + _ => source.ToString(), + }; + """ + ); + } + + [Fact] + public void EnumToStringWithUpperSnakeCaseNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.UpperSnakeCase)] public partial string ToStr(E source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + global::E.Abc => "ABC", + global::E.BcD => "BC_D", + global::E.C1D => "C1_D", + global::E.DEf => "D_EF", + global::E.EFG => "EFG", + global::E.FG1 => "FG1", + global::E.Gh1 => "GH1", + global::E.Hi_J => "HI_J", + _ => source.ToString(), + }; + """ + ); + } + + [Fact] + public void EnumToStringWithKebabCaseNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.KebabCase)] public partial string ToStr(E source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + global::E.Abc => "abc", + global::E.BcD => "bc-d", + global::E.C1D => "c1-d", + global::E.DEf => "d-ef", + global::E.EFG => "efg", + global::E.FG1 => "fg1", + global::E.Gh1 => "gh1", + global::E.Hi_J => "hi-j", + _ => source.ToString(), + }; + """ + ); + } + + [Fact] + public void EnumToStringWithUpperKebabCaseNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.UpperKebabCase)] public partial string ToStr(E source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + global::E.Abc => "ABC", + global::E.BcD => "BC-D", + global::E.C1D => "C1-D", + global::E.DEf => "D-EF", + global::E.EFG => "EFG", + global::E.FG1 => "FG1", + global::E.Gh1 => "GH1", + global::E.Hi_J => "HI-J", + _ => source.ToString(), + }; + """ + ); + } + + [Fact] + public void EnumFromStringWithMemberNameNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.MemberName)] public partial E ToEnum(string source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + nameof(global::E.Abc) => global::E.Abc, + nameof(global::E.BcD) => global::E.BcD, + nameof(global::E.C1D) => global::E.C1D, + nameof(global::E.DEf) => global::E.DEf, + nameof(global::E.EFG) => global::E.EFG, + nameof(global::E.FG1) => global::E.FG1, + nameof(global::E.Gh1) => global::E.Gh1, + nameof(global::E.Hi_J) => global::E.Hi_J, + _ => System.Enum.Parse(source, false), + }; + """ + ); + } + + [Fact] + public void EnumFromStringWithCamelCaseNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.CamelCase)] public partial E ToEnum(string source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + "abc" => global::E.Abc, + "bcD" => global::E.BcD, + "c1D" => global::E.C1D, + "dEf" => global::E.DEf, + "efg" => global::E.EFG, + "fg1" => global::E.FG1, + "gh1" => global::E.Gh1, + "hiJ" => global::E.Hi_J, + _ => System.Enum.Parse(source, false), + }; + """ + ); + } + + [Fact] + public void EnumFromStringWithPascalCaseNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.PascalCase)] public partial E ToEnum(string source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + "Abc" => global::E.Abc, + "BcD" => global::E.BcD, + "C1D" => global::E.C1D, + "DEf" => global::E.DEf, + "Efg" => global::E.EFG, + "Fg1" => global::E.FG1, + "Gh1" => global::E.Gh1, + "HiJ" => global::E.Hi_J, + _ => System.Enum.Parse(source, false), + }; + """ + ); + } + + [Fact] + public void EnumFromStringWithSnakeCaseNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.SnakeCase)] public partial E ToEnum(string source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + "abc" => global::E.Abc, + "bc_d" => global::E.BcD, + "c1_d" => global::E.C1D, + "d_ef" => global::E.DEf, + "efg" => global::E.EFG, + "fg1" => global::E.FG1, + "gh1" => global::E.Gh1, + "hi_j" => global::E.Hi_J, + _ => System.Enum.Parse(source, false), + }; + """ + ); + } + + [Fact] + public void EnumFromStringWithUpperSnakeCaseNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.UpperSnakeCase)] public partial E ToEnum(string source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + "ABC" => global::E.Abc, + "BC_D" => global::E.BcD, + "C1_D" => global::E.C1D, + "D_EF" => global::E.DEf, + "EFG" => global::E.EFG, + "FG1" => global::E.FG1, + "GH1" => global::E.Gh1, + "HI_J" => global::E.Hi_J, + _ => System.Enum.Parse(source, false), + }; + """ + ); + } + + [Fact] + public void EnumFromStringWithKebabCaseNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.KebabCase)] public partial E ToEnum(string source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + "abc" => global::E.Abc, + "bc-d" => global::E.BcD, + "c1-d" => global::E.C1D, + "d-ef" => global::E.DEf, + "efg" => global::E.EFG, + "fg1" => global::E.FG1, + "gh1" => global::E.Gh1, + "hi-j" => global::E.Hi_J, + _ => System.Enum.Parse(source, false), + }; + """ + ); + } + + [Fact] + public void EnumFromStringWithUpperKebabCaseNamingStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.UpperKebabCase)] public partial E ToEnum(string source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + "ABC" => global::E.Abc, + "BC-D" => global::E.BcD, + "C1-D" => global::E.C1D, + "D-EF" => global::E.DEf, + "EFG" => global::E.EFG, + "FG1" => global::E.FG1, + "GH1" => global::E.Gh1, + "HI-J" => global::E.Hi_J, + _ => System.Enum.Parse(source, false), + }; + """ + ); + } + + [Fact] + public void EnumToStringWithFallbackValue() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.SnakeCase, FallbackValue = \"default\")] public partial string ToStr(E source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + global::E.Abc => "abc", + global::E.BcD => "bc_d", + global::E.C1D => "c1_d", + global::E.DEf => "d_ef", + global::E.EFG => "efg", + global::E.FG1 => "fg1", + global::E.Gh1 => "gh1", + global::E.Hi_J => "hi_j", + _ => "default", + }; + """ + ); + } + + [Fact] + public void EnumFromStringWithFallbackValue() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName, NamingStrategy = EnumNamingStrategy.SnakeCase, FallbackValue = E.DEf)] public partial E ToEnum(string source);", + "public enum E {Abc, BcD, C1D, DEf, EFG, FG1, Gh1, Hi_J}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source switch + { + "abc" => global::E.Abc, + "bc_d" => global::E.BcD, + "c1_d" => global::E.C1D, + "efg" => global::E.EFG, + "fg1" => global::E.FG1, + "gh1" => global::E.Gh1, + "hi_j" => global::E.Hi_J, + _ => global::E.DEf, + }; + """ + ); + } +}