From bc7f6885934c8f0e955babb01ab63e07aef6dbf3 Mon Sep 17 00:00:00 2001 From: Kataane Date: Fri, 2 Jan 2026 16:43:57 +0700 Subject: [PATCH 1/2] feat: implement multi-enum to enum target mapping support --- .../Enumerables/CollectionInfoBuilder.cs | 4 +- .../EnumToEnumMappingBuilder.cs | 598 ++++++++++++--- .../Mappings/Enums/EnumMultiSourceMapping.cs | 91 +++ .../Mappings/Enums/EnumSourceMapping.cs | 11 + .../Emit/Syntax/SyntaxFactoryHelper.Return.cs | 12 + .../Mapping/EnumAdditionalParametersTest.cs | 706 ++++++++++++++++++ 6 files changed, 1307 insertions(+), 115 deletions(-) create mode 100644 src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumMultiSourceMapping.cs create mode 100644 src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumSourceMapping.cs create mode 100644 test/Riok.Mapperly.Tests/Mapping/EnumAdditionalParametersTest.cs diff --git a/src/Riok.Mapperly/Descriptors/Enumerables/CollectionInfoBuilder.cs b/src/Riok.Mapperly/Descriptors/Enumerables/CollectionInfoBuilder.cs index a7acfd8783..167f9e54e9 100644 --- a/src/Riok.Mapperly/Descriptors/Enumerables/CollectionInfoBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/Enumerables/CollectionInfoBuilder.cs @@ -168,7 +168,7 @@ params ITypeSymbol[] typeArguments ctx.Types.Get(genericType).Construct(typeArguments).WithNullableAnnotation(NullableAnnotation.NotAnnotated); } - private static CollectionInfo BuildCollectionInfo( + public static CollectionInfo BuildCollectionInfo( WellKnownTypes wellKnownTypes, SymbolAccessor symbolAccessor, ITypeSymbol type, @@ -190,7 +190,7 @@ ITypeSymbol enumeratedType ); } - private static ITypeSymbol? GetEnumeratedType(WellKnownTypes types, ITypeSymbol type) + public static ITypeSymbol? GetEnumeratedType(WellKnownTypes types, ITypeSymbol type) { // if type is array return element type // otherwise using the IEnumerable element type can erase the null annotation for external types diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/EnumToEnumMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/EnumToEnumMappingBuilder.cs index 92e6002e6e..03be346893 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/EnumToEnumMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/EnumToEnumMappingBuilder.cs @@ -1,12 +1,16 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Riok.Mapperly.Abstractions; +using Riok.Mapperly.Configuration; +using Riok.Mapperly.Descriptors.Enumerables; 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 Riok.Mapperly.Symbols; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; using static Riok.Mapperly.Emit.Syntax.SyntaxFactoryHelper; @@ -16,39 +20,277 @@ public static class EnumToEnumMappingBuilder { public static INewInstanceMapping? TryBuildMapping(MappingBuilderContext ctx) { + var (isEnumerableTarget, actualTargetEnumType) = DetermineEnumerableTarget(ctx); + var sourceIsEnum = ctx.Source.TryGetEnumUnderlyingType(out var sourceEnumType); - var targetIsEnum = ctx.Target.TryGetEnumUnderlyingType(out var targetEnumType); + var targetIsEnum = actualTargetEnumType.IsEnum(); // none is an enum if (!sourceIsEnum && !targetIsEnum) return null; - // one is an enum, other may be an underlying type (eg. int) + // one is an enum, other may be an underlying type (e.g. int) if (!sourceIsEnum || !targetIsEnum) { - return - ctx.IsConversionEnabled(MappingConversionType.EnumUnderlyingType) - && ctx.FindOrBuildMapping(sourceEnumType ?? ctx.Source, targetEnumType ?? ctx.Target) is { } delegateMapping - ? new CastMapping(ctx.Source, ctx.Target, delegateMapping) - : null; + return TryBuildUnderlyingTypeMapping(ctx, sourceEnumType, actualTargetEnumType); } if (!ctx.IsConversionEnabled(MappingConversionType.EnumToEnum)) return null; - // map enums by strategy + // Check for multiple enum parameters + return TryGetMultipleEnumParameters(ctx, out var enumParams) + ? BuildMultiSourceEnumMapping(ctx, enumParams, actualTargetEnumType, isEnumerableTarget) + : BuildSingleSourceMapping(ctx); + } + + private static (bool isEnumerableTarget, ITypeSymbol actualTargetEnumType) DetermineEnumerableTarget(MappingBuilderContext ctx) + { + var isEnumerableTarget = false; + var actualTargetEnumType = ctx.Target; + + var enumeratedType = CollectionInfoBuilder.GetEnumeratedType(ctx.Types, ctx.Target); + if (enumeratedType is null) + { + return (isEnumerableTarget, actualTargetEnumType); + } + + var targetInfo = CollectionInfoBuilder.BuildCollectionInfo(ctx.Types, ctx.SymbolAccessor, ctx.Target, enumeratedType); + + if (!targetInfo.ImplementsIEnumerable) + { + return (isEnumerableTarget, actualTargetEnumType); + } + + isEnumerableTarget = true; + actualTargetEnumType = enumeratedType; + + return (isEnumerableTarget, actualTargetEnumType); + } + + private static INewInstanceMapping? TryBuildUnderlyingTypeMapping( + MappingBuilderContext ctx, + ITypeSymbol? sourceEnumType, + ITypeSymbol targetType + ) + { + // If enum underlying type conversion is disabled, don't block other conversions + if (!ctx.IsConversionEnabled(MappingConversionType.EnumUnderlyingType)) + return null; + + // Get the underlying type if target is an enum + targetType.TryGetEnumUnderlyingType(out var targetUnderlyingType); + + var delegateMapping = ctx.FindOrBuildMapping(sourceEnumType ?? ctx.Source, targetUnderlyingType ?? ctx.Target); + + return delegateMapping is not null ? new CastMapping(ctx.Source, ctx.Target, delegateMapping) : null; + } + + private static bool TryGetMultipleEnumParameters(MappingBuilderContext ctx, out IReadOnlyList enumParams) + { + enumParams = []; + + if (ctx.UserMapping?.Method.Parameters is not { Length: > 1 } parameters) + return false; + + var paramList = new List(parameters.Length); + foreach (var param in parameters) + { + if (param.Type.TryGetEnumUnderlyingType(out _)) + { + paramList.Add(ctx.SymbolAccessor.WrapMethodParameter(param)); + } + } + + if (paramList.Count <= 1) + return false; + + enumParams = paramList; + return true; + } + + private static INewInstanceMapping BuildSingleSourceMapping(MappingBuilderContext ctx) + { return ctx.Configuration.Enum.Strategy switch { EnumMappingStrategy.ByName when ctx.IsExpression => BuildCastMappingAndDiagnostic(ctx), + EnumMappingStrategy.ByValue when ctx is { IsExpression: true, Configuration.Enum.HasExplicitConfigurations: true } => BuildCastMappingAndDiagnostic(ctx), + EnumMappingStrategy.ByValueCheckDefined when ctx.IsExpression => BuildCastMappingAndDiagnostic(ctx), + EnumMappingStrategy.ByName => BuildNameMapping(ctx), + EnumMappingStrategy.ByValueCheckDefined => BuildEnumToEnumCastMapping(ctx, checkTargetDefined: true), + _ => BuildEnumToEnumCastMapping(ctx), }; } + #region Multi-Source Enum Mapping + + private static INewInstanceMapping BuildMultiSourceEnumMapping( + MappingBuilderContext ctx, + IReadOnlyList allEnumParameters, + ITypeSymbol targetEnumType, + bool useYieldReturn + ) + { + var mappedTargetMembers = new HashSet(SymbolEqualityComparer.Default); + var sourceMappings = new List(allEnumParameters.Count); + + foreach (var param in allEnumParameters) + { + var memberMappings = BuildEnumMemberMappingsForParameter(ctx, param, targetEnumType, mappedTargetMembers); + sourceMappings.Add(new EnumSourceMapping(param, memberMappings)); + } + + var fallbackMapping = BuildFallbackMapping(ctx, targetEnumType); + return new EnumMultiSourceMapping(allEnumParameters[0].Type, ctx.Target, sourceMappings, fallbackMapping, useYieldReturn); + } + + private static IReadOnlyDictionary BuildEnumMemberMappingsForParameter( + MappingBuilderContext ctx, + MethodParameter parameter, + ITypeSymbol targetEnumType, + HashSet alreadyMappedTargets + ) + { + var ignoredSourceSet = ctx.Configuration.Enum.IgnoredSourceMembers.ToHashSet(SymbolTypeEqualityComparer.FieldDefault); + var ignoredTargetSet = ctx.Configuration.Enum.IgnoredTargetMembers.ToHashSet(SymbolTypeEqualityComparer.FieldDefault); + + var sourceMembers = ctx.SymbolAccessor.GetFieldsExcept(parameter.Type, ignoredSourceSet); + var targetMembers = ctx.SymbolAccessor.GetFieldsExcept(targetEnumType, ignoredTargetSet); + + var explicitMappings = BuildExplicitValueMappingsForSource(ctx, parameter.Type, targetEnumType); + var mappings = new Dictionary(SymbolEqualityComparer.Default); + + foreach (var sourceMember in sourceMembers) + { + var targetMember = FindTargetMemberForSource( + ctx, + sourceMember, + targetMembers, + explicitMappings, + targetEnumType, + alreadyMappedTargets + ); + + if (targetMember is not null && alreadyMappedTargets.Add(targetMember)) + { + mappings.Add(sourceMember, targetMember); + } + } + + return mappings; + } + + private static IFieldSymbol? FindTargetMemberForSource( + MappingBuilderContext ctx, + IFieldSymbol sourceMember, + IReadOnlyCollection targetMembers, + IReadOnlyDictionary explicitMappings, + ITypeSymbol targetEnumType, + HashSet alreadyMappedTargets + ) + { + // Check explicit mappings first + if (explicitMappings.TryGetValue(sourceMember, out var targetMember)) + return targetMember; + + // Apply strategy-based matching + return ctx.Configuration.Enum.Strategy switch + { + EnumMappingStrategy.ByValue or EnumMappingStrategy.ByValueCheckDefined => FindByValue( + sourceMember, + targetMembers, + targetEnumType, + alreadyMappedTargets + ), + + EnumMappingStrategy.ByName => FindByName(ctx, sourceMember, targetMembers, alreadyMappedTargets), + + _ => FindByValue(sourceMember, targetMembers, targetEnumType, alreadyMappedTargets), + }; + } + + private static IFieldSymbol? FindByValue( + IFieldSymbol sourceMember, + IReadOnlyCollection targetMembers, + ITypeSymbol targetEnumType, + HashSet alreadyMappedTargets + ) + { + var sourceValue = sourceMember.ConstantValue!; + return targetMembers.FirstOrDefault(t => + !alreadyMappedTargets.Contains(t) + && SymbolEqualityComparer.Default.Equals(t.ContainingType, targetEnumType) + && Equals(t.ConstantValue, sourceValue) + ); + } + + private static IFieldSymbol? FindByName( + MappingBuilderContext ctx, + IFieldSymbol sourceMember, + IReadOnlyCollection targetMembers, + HashSet alreadyMappedTargets + ) + { + var comparer = ctx.Configuration.Enum.IgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + var targetMembersByName = targetMembers + .Where(t => !alreadyMappedTargets.Contains(t)) + .DistinctBy(t => t.Name, comparer) + .ToDictionary(t => t.Name, t => t, comparer); + + return targetMembersByName.TryGetValue(sourceMember.Name, out var target) ? target : null; + } + + private static IReadOnlyDictionary BuildExplicitValueMappingsForSource( + MappingBuilderContext ctx, + ITypeSymbol sourceType, + ITypeSymbol targetEnumType + ) + { + var explicitMappings = new Dictionary(SymbolEqualityComparer.Default); + var sourceFields = ctx.SymbolAccessor.GetEnumFieldsByValue(sourceType); + var targetFields = ctx.SymbolAccessor.GetEnumFieldsByValue(targetEnumType); + + foreach (var (source, target) in ctx.Configuration.Enum.ExplicitMappings) + { + // Only process explicit mappings that match this source type + if (!SymbolEqualityComparer.Default.Equals(source.ConstantValue.Type, sourceType)) + continue; + + if (!ValidateExplicitMappingTypes(ctx, source, target, sourceType, targetEnumType)) + continue; + + if ( + !TryGetMappingFields( + sourceFields, + targetFields, + source.ConstantValue, + target.ConstantValue, + out var sourceField, + out var targetField + ) + ) + continue; + + if (!explicitMappings.TryAdd(sourceField, targetField)) + { + ctx.ReportDiagnostic(DiagnosticDescriptors.EnumSourceValueDuplicated, sourceField, sourceType, targetEnumType); + } + } + + return explicitMappings; + } + + #endregion + + #region Single-Source Enum Mapping + private static INewInstanceMapping BuildCastMappingAndDiagnostic(MappingBuilderContext ctx) { ctx.ReportDiagnostic( @@ -56,7 +298,8 @@ private static INewInstanceMapping BuildCastMappingAndDiagnostic(MappingBuilderC ctx.Source.ToDisplayString(), ctx.Target.ToDisplayString() ); - return BuildEnumToEnumCastMapping(ctx, true); + + return BuildEnumToEnumCastMapping(ctx, ignoreExplicitAndIgnoredMappings: true); } private static INewInstanceMapping BuildEnumToEnumCastMapping( @@ -71,19 +314,16 @@ private static INewInstanceMapping BuildEnumToEnumCastMapping( static x => x.ConstantValue!, EqualityComparer.Default ); + var fallbackMapping = BuildFallbackMapping(ctx); + if (fallbackMapping.FallbackExpression is not null && !checkTargetDefined) { ctx.ReportDiagnostic(DiagnosticDescriptors.EnumFallbackValueRequiresByValueCheckDefinedStrategy); checkTargetDefined = true; } - var checkDefinedMode = checkTargetDefined switch - { - false => EnumCastMapping.CheckDefinedMode.NoCheck, - _ when ctx.SymbolAccessor.HasAttribute(ctx.Target) => EnumCastMapping.CheckDefinedMode.Flags, - _ => EnumCastMapping.CheckDefinedMode.Value, - }; + var checkDefinedMode = DetermineCheckDefinedMode(ctx, checkTargetDefined); var castFallbackMapping = new EnumCastMapping( ctx.Source, @@ -92,27 +332,45 @@ _ when ctx.SymbolAccessor.HasAttribute(ctx.Target) => EnumCastMa enumMemberMappings.TargetMembers, fallbackMapping ); - var differentValueExplicitEnumMappings = enumMemberMappings + + var differentValueMappings = enumMemberMappings .ExplicitMemberMappings.Where(x => x.Key.ConstantValue?.Equals(x.Value.ConstantValue) != true) .ToDictionary(x => x.Key, x => x.Value, SymbolTypeEqualityComparer.FieldDefault); - if (differentValueExplicitEnumMappings.Count == 0) + if (differentValueMappings.Count == 0) return castFallbackMapping; return new EnumNameMapping( ctx.Source, ctx.Target, - differentValueExplicitEnumMappings, + differentValueMappings, new EnumFallbackValueMapping(ctx.Source, ctx.Target, castFallbackMapping) ); } + private static EnumCastMapping.CheckDefinedMode DetermineCheckDefinedMode(MappingBuilderContext ctx, bool checkTargetDefined) + { + return checkTargetDefined switch + { + false => EnumCastMapping.CheckDefinedMode.NoCheck, + _ when ctx.SymbolAccessor.HasAttribute(ctx.Target) => EnumCastMapping.CheckDefinedMode.Flags, + _ => EnumCastMapping.CheckDefinedMode.Value, + }; + } + private static EnumNameMapping BuildNameMapping(MappingBuilderContext ctx) { var fallbackMapping = BuildFallbackMapping(ctx); + var enumMemberMappings = ctx.Configuration.Enum.IgnoreCase - ? BuildEnumMemberMappings(ctx, false, static x => x.Name, StringComparer.Ordinal, StringComparer.OrdinalIgnoreCase) - : BuildEnumMemberMappings(ctx, false, static x => x.Name, StringComparer.Ordinal); + ? BuildEnumMemberMappings( + ctx, + ignoreExplicitAndIgnoredMappings: false, + static x => x.Name, + StringComparer.Ordinal, + StringComparer.OrdinalIgnoreCase + ) + : BuildEnumMemberMappings(ctx, ignoreExplicitAndIgnoredMappings: false, static x => x.Name, StringComparer.Ordinal); if (enumMemberMappings.MemberMappings.Count == 0) { @@ -130,24 +388,76 @@ params IEqualityComparer[] propertyComparer ) where T : notnull { - var ignoredSourceMembers = ignoreExplicitAndIgnoredMappings - ? new HashSet(SymbolEqualityComparer.Default) - : ctx.Configuration.Enum.IgnoredSourceMembers.ToHashSet(SymbolTypeEqualityComparer.FieldDefault); - var ignoredTargetMembers = ignoreExplicitAndIgnoredMappings - ? new HashSet(SymbolEqualityComparer.Default) - : ctx.Configuration.Enum.IgnoredTargetMembers.ToHashSet(SymbolTypeEqualityComparer.FieldDefault); - var explicitMappings = ignoreExplicitAndIgnoredMappings - ? new Dictionary(SymbolEqualityComparer.Default) - : BuildExplicitValueMappings(ctx); + var (ignoredSourceMembers, ignoredTargetMembers, explicitMappings) = GetMappingConfiguration(ctx, ignoreExplicitAndIgnoredMappings); + var sourceMembers = ctx.SymbolAccessor.GetFieldsExcept(ctx.Source, ignoredSourceMembers); var targetMembers = ctx.SymbolAccessor.GetFieldsExcept(ctx.Target, ignoredTargetMembers); - var targetMembersByProperty = propertyComparer - .Select(pc => targetMembers.DistinctBy(propertySelector, pc).ToDictionary(propertySelector, x => x, pc)) - .ToList(); + var targetMembersByProperty = BuildTargetMemberLookups(targetMembers, propertySelector, propertyComparer); + + var (mappings, mappedTargetMembers) = BuildMemberMappingsDictionary( + sourceMembers, + targetMembersByProperty, + explicitMappings, + propertySelector + ); + + ReportMappingDiagnostics( + ctx, + mappings, + mappedTargetMembers, + sourceMembers, + targetMembers, + ignoredSourceMembers, + ignoredTargetMembers + ); + + return new EnumMemberMappings(mappings, explicitMappings, targetMembers); + } + + private static ( + HashSet ignoredSource, + HashSet ignoredTarget, + Dictionary explicitMappings + ) GetMappingConfiguration(MappingBuilderContext ctx, bool ignoreAll) + { + if (ignoreAll) + { + return ( + new HashSet(SymbolEqualityComparer.Default), + new HashSet(SymbolEqualityComparer.Default), + new Dictionary(SymbolEqualityComparer.Default) + ); + } + return ( + ctx.Configuration.Enum.IgnoredSourceMembers.ToHashSet(SymbolTypeEqualityComparer.FieldDefault), + ctx.Configuration.Enum.IgnoredTargetMembers.ToHashSet(SymbolTypeEqualityComparer.FieldDefault), + BuildExplicitValueMappings(ctx) + ); + } + + private static List> BuildTargetMemberLookups( + IReadOnlyCollection targetMembers, + Func propertySelector, + IEqualityComparer[] comparers + ) + where T : notnull + { + return comparers.Select(pc => targetMembers.DistinctBy(propertySelector, pc).ToDictionary(propertySelector, x => x, pc)).ToList(); + } + + private static (Dictionary mappings, HashSet mappedTargets) BuildMemberMappingsDictionary( + IReadOnlyCollection sourceMembers, + List> targetMembersByProperty, + Dictionary explicitMappings, + Func propertySelector + ) + where T : notnull + { var mappedTargetMembers = new HashSet(SymbolEqualityComparer.Default); var mappings = new Dictionary(SymbolEqualityComparer.Default); + foreach (var sourceMember in sourceMembers) { if (!explicitMappings.TryGetValue(sourceMember, out var targetMember)) @@ -159,7 +469,7 @@ params IEqualityComparer[] propertyComparer break; } - if (targetMember == null) + if (targetMember is null) continue; } @@ -167,120 +477,182 @@ params IEqualityComparer[] propertyComparer mappedTargetMembers.Add(targetMember); } + return (mappings, mappedTargetMembers); + } + + private static void ReportMappingDiagnostics( + MappingBuilderContext ctx, + Dictionary mappings, + HashSet mappedTargetMembers, + IReadOnlyCollection sourceMembers, + IReadOnlyCollection targetMembers, + HashSet ignoredSourceMembers, + HashSet ignoredTargetMembers + ) + { EnumMappingDiagnosticReporter.AddUnmappedSourceMembersDiagnostics(ctx, mappings.Keys.ToHashSet(), sourceMembers); EnumMappingDiagnosticReporter.AddUnmappedTargetMembersDiagnostics(ctx, mappedTargetMembers, targetMembers); EnumMappingDiagnosticReporter.AddUnmatchedSourceIgnoredMembers(ctx, ignoredSourceMembers); EnumMappingDiagnosticReporter.AddUnmatchedTargetIgnoredMembers(ctx, ignoredTargetMembers); - return new EnumMemberMappings(mappings, explicitMappings, targetMembers); } - private static EnumFallbackValueMapping BuildFallbackMapping(MappingBuilderContext ctx) + #endregion + + #region Explicit Mappings and Fallback + + private static Dictionary BuildExplicitValueMappings(MappingBuilderContext ctx) + { + var explicitMappings = new Dictionary(SymbolEqualityComparer.Default); + var sourceFields = ctx.SymbolAccessor.GetEnumFieldsByValue(ctx.Source); + var targetFields = ctx.SymbolAccessor.GetEnumFieldsByValue(ctx.Target); + + foreach (var (source, target) in ctx.Configuration.Enum.ExplicitMappings) + { + if (!ValidateExplicitMappingTypes(ctx, source, target, ctx.Source, ctx.Target)) + continue; + + if ( + !TryGetMappingFields( + sourceFields, + targetFields, + source.ConstantValue, + target.ConstantValue, + out var sourceField, + out var targetField + ) + ) + continue; + + if (!explicitMappings.TryAdd(sourceField, targetField)) + { + ctx.ReportDiagnostic(DiagnosticDescriptors.EnumSourceValueDuplicated, sourceField, ctx.Source, ctx.Target); + } + } + + return explicitMappings; + } + + private static bool ValidateExplicitMappingTypes( + MappingBuilderContext ctx, + AttributeValue source, + AttributeValue target, + ITypeSymbol sourceType, + ITypeSymbol targetType + ) + { + if (source.ConstantValue.Kind is not TypedConstantKind.Enum) + { + ctx.ReportDiagnostic( + DiagnosticDescriptors.MapValueTypeMismatch, + source.Expression.ToFullString(), + source.ConstantValue.Type?.ToDisplayString() ?? "unknown", + sourceType + ); + return false; + } + + if (target.ConstantValue.Kind is not TypedConstantKind.Enum) + { + ctx.ReportDiagnostic( + DiagnosticDescriptors.MapValueTypeMismatch, + target.Expression.ToFullString(), + target.ConstantValue.Type?.ToDisplayString() ?? "unknown", + targetType + ); + return false; + } + + if (!SymbolEqualityComparer.Default.Equals(source.ConstantValue.Type, sourceType)) + { + ctx.ReportDiagnostic( + DiagnosticDescriptors.SourceEnumValueDoesNotMatchSourceEnumType, + target.Expression.ToFullString(), + target.ConstantValue.Value ?? 0, + target.ConstantValue.Type?.ToDisplayString() ?? "unknown", + sourceType + ); + return false; + } + + if (SymbolEqualityComparer.Default.Equals(target.ConstantValue.Type, targetType)) + { + return true; + } + + ctx.ReportDiagnostic( + DiagnosticDescriptors.TargetEnumValueDoesNotMatchTargetEnumType, + source.Expression.ToFullString(), + source.ConstantValue.Value ?? 0, + source.ConstantValue.Type?.ToDisplayString() ?? "unknown", + targetType + ); + + return false; + } + + private static bool TryGetMappingFields( + IReadOnlyDictionary sourceFields, + IReadOnlyDictionary targetFields, + TypedConstant sourceConstant, + TypedConstant targetConstant, + [MaybeNullWhen(false)] out IFieldSymbol sourceField, + [MaybeNullWhen(false)] out IFieldSymbol targetField + ) + { + sourceField = null; + targetField = null; + + if (sourceConstant.Value is null || targetConstant.Value is null) + { + return false; + } + + return sourceFields.TryGetValue(sourceConstant.Value, out sourceField) + && targetFields.TryGetValue(targetConstant.Value, out targetField); + } + + private static EnumFallbackValueMapping BuildFallbackMapping(MappingBuilderContext ctx) => BuildFallbackMapping(ctx, ctx.Target); + + private static EnumFallbackValueMapping BuildFallbackMapping(MappingBuilderContext ctx, ITypeSymbol targetEnumType) { var fallbackValue = ctx.Configuration.Enum.FallbackValue; if (fallbackValue is null) { - return new EnumFallbackValueMapping(ctx.Source, ctx.Target); + return new EnumFallbackValueMapping(ctx.Source, targetEnumType); } if (fallbackValue is not { Expression: MemberAccessExpressionSyntax memberAccessExpression }) { ctx.ReportDiagnostic(DiagnosticDescriptors.InvalidEnumMappingFallbackValue, fallbackValue.Value.Expression.ToFullString()); - return new EnumFallbackValueMapping(ctx.Source, ctx.Target); + return new EnumFallbackValueMapping(ctx.Source, targetEnumType); } - if (!SymbolEqualityComparer.Default.Equals(ctx.Target, fallbackValue.Value.ConstantValue.Type)) + if (!SymbolEqualityComparer.Default.Equals(targetEnumType, fallbackValue.Value.ConstantValue.Type)) { ctx.ReportDiagnostic( DiagnosticDescriptors.EnumFallbackValueTypeDoesNotMatchTargetEnumType, fallbackValue, fallbackValue.Value.ConstantValue.Value ?? 0, fallbackValue.Value.ConstantValue.Type?.Name ?? "unknown", - ctx.Target + targetEnumType ); - return new EnumFallbackValueMapping(ctx.Source, ctx.Target); + return new EnumFallbackValueMapping(ctx.Source, targetEnumType); } var fallbackExpression = MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, - FullyQualifiedIdentifier(ctx.Target), + FullyQualifiedIdentifier(targetEnumType), memberAccessExpression.Name ); - return new EnumFallbackValueMapping(ctx.Source, ctx.Target, fallbackExpression: fallbackExpression); - } - - private static IReadOnlyDictionary BuildExplicitValueMappings(MappingBuilderContext ctx) - { - var explicitMappings = new Dictionary(SymbolEqualityComparer.Default); - 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) - { - ctx.ReportDiagnostic( - DiagnosticDescriptors.MapValueTypeMismatch, - source.Expression.ToFullString(), - source.ConstantValue.Type?.ToDisplayString() ?? "unknown", - ctx.Source - ); - continue; - } - if (target.ConstantValue.Kind is not TypedConstantKind.Enum) - { - ctx.ReportDiagnostic( - DiagnosticDescriptors.MapValueTypeMismatch, - target.Expression.ToFullString(), - target.ConstantValue.Type?.ToDisplayString() ?? "unknown", - ctx.Target - ); - continue; - } - - if (!SymbolEqualityComparer.Default.Equals(source.ConstantValue.Type, ctx.Source)) - { - ctx.ReportDiagnostic( - DiagnosticDescriptors.SourceEnumValueDoesNotMatchSourceEnumType, - target.Expression.ToFullString(), - target.ConstantValue.Value ?? 0, - target.ConstantValue.Type?.ToDisplayString() ?? "unknown", - ctx.Source - ); - continue; - } - - if (!SymbolEqualityComparer.Default.Equals(target.ConstantValue.Type, ctx.Target)) - { - ctx.ReportDiagnostic( - DiagnosticDescriptors.TargetEnumValueDoesNotMatchTargetEnumType, - source.Expression.ToFullString(), - source.ConstantValue.Value ?? 0, - source.ConstantValue.Type?.ToDisplayString() ?? "unknown", - ctx.Target - ); - continue; - } - - if ( - !sourceFields.TryGetValue(source.ConstantValue.Value!, out var sourceField) - || !targetFields.TryGetValue(target.ConstantValue.Value!, out var targetField) - ) - { - continue; - } - - if (!explicitMappings.TryAdd(sourceField, targetField)) - { - ctx.ReportDiagnostic(DiagnosticDescriptors.EnumSourceValueDuplicated, sourceField, ctx.Source, ctx.Target); - } - } - - return explicitMappings; + return new EnumFallbackValueMapping(ctx.Source, targetEnumType, fallbackExpression: fallbackExpression); } - private record EnumMemberMappings( + #endregion + + private sealed record EnumMemberMappings( IReadOnlyDictionary MemberMappings, - IReadOnlyDictionary ExplicitMemberMappings, + Dictionary ExplicitMemberMappings, IReadOnlyCollection TargetMembers ); } diff --git a/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumMultiSourceMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumMultiSourceMapping.cs new file mode 100644 index 0000000000..4215585106 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumMultiSourceMapping.cs @@ -0,0 +1,91 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; +using static Riok.Mapperly.Emit.Syntax.SyntaxFactoryHelper; + +namespace Riok.Mapperly.Descriptors.Mappings.Enums; + +/// +/// Represents a mapping from multiple enum sources to a single target enum. +/// When useYieldReturn is true, generates multiple yield return statements (one for each source). +/// Otherwise, uses a cascading switch approach where the first source has highest priority. +/// +public class EnumMultiSourceMapping( + ITypeSymbol source, + ITypeSymbol target, + IReadOnlyList sourceMappings, + EnumFallbackValueMapping fallback, + bool useYieldReturn = false +) : NewInstanceMethodMapping(source, target) +{ + public override IEnumerable BuildBody(TypeMappingBuildContext ctx) + { + if (useYieldReturn) + { + // Generate multiple yield return statements - one for each source enum + foreach (var sourceMapping in sourceMappings) + { + var switchExpression = BuildSingleSourceSwitch(ctx, sourceMapping); + yield return ctx.SyntaxFactory.YieldReturn(switchExpression); + } + } + else + { + // Build cascading switch expression starting with the first source + var switchExpression = BuildCascadingSwitch(ctx, sourceMappings, 0); + yield return ctx.SyntaxFactory.Return(switchExpression); + } + } + + private static ExpressionSyntax BuildSingleSourceSwitch(TypeMappingBuildContext ctx, EnumSourceMapping sourceMapping) + { + var parameterAccess = IdentifierName(sourceMapping.Parameter.Name); + + // Build switch arms for the source + var arms = sourceMapping.MemberMappings.Select(x => BuildArm(x.Key, x.Value)).ToList(); + + // Add fallback arm that throws for this specific source parameter + var sourceExpression = IdentifierName(sourceMapping.Parameter.Name); + var fallbackArm = SwitchArm( + DiscardPattern(), + ThrowArgumentOutOfRangeException(sourceExpression, $"The value of enum {sourceMapping.Parameter.Type.Name} is not supported") + ); + arms.Add(fallbackArm); + + return ctx.SyntaxFactory.Switch(parameterAccess, arms); + } + + private ExpressionSyntax BuildCascadingSwitch(TypeMappingBuildContext ctx, IReadOnlyList sources, int index) + { + var currentSource = sources[index]; + var parameterAccess = IdentifierName(currentSource.Parameter.Name); + + // Build switch arms for current source + var arms = currentSource.MemberMappings.Select(x => BuildArm(x.Key, x.Value)).ToList(); + + // Add fallback for current source + if (index == sources.Count - 1) + { + // Last source uses the configured fallback + arms.Add(fallback.BuildDiscardArm(ctx)); + } + else + { + // Non-last source cascades to next source + var nextSwitch = BuildCascadingSwitch(ctx, sources, index + 1); + arms.Add(SwitchArm(DiscardPattern(), nextSwitch)); + } + + return ctx.SyntaxFactory.Switch(parameterAccess, arms); + } + + private static SwitchExpressionArmSyntax BuildArm(IFieldSymbol sourceMemberField, IFieldSymbol targetMemberField) + { + var sourceMember = MemberAccess(FullyQualifiedIdentifier(sourceMemberField.ContainingType), sourceMemberField.Name); + // Use the targetMemberField's ContainingType (the actual enum type) instead of TargetType + // which could be IEnumerable when yield return is used + var targetMember = MemberAccess(FullyQualifiedIdentifier(targetMemberField.ContainingType), targetMemberField.Name); + var pattern = ConstantPattern(sourceMember); + return SwitchArm(pattern, targetMember); + } +} diff --git a/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumSourceMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumSourceMapping.cs new file mode 100644 index 0000000000..2d364ec128 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumSourceMapping.cs @@ -0,0 +1,11 @@ +using Microsoft.CodeAnalysis; +using Riok.Mapperly.Symbols; + +namespace Riok.Mapperly.Descriptors.Mappings.Enums; + +/// +/// Represents the mapping configuration for a single enum source parameter in a multi-source enum mapping. +/// +/// The method parameter representing this enum source +/// Dictionary mapping source enum fields to target enum fields +public record EnumSourceMapping(MethodParameter Parameter, IReadOnlyDictionary MemberMappings); diff --git a/src/Riok.Mapperly/Emit/Syntax/SyntaxFactoryHelper.Return.cs b/src/Riok.Mapperly/Emit/Syntax/SyntaxFactoryHelper.Return.cs index fb5fd6b8c4..96cdb92845 100644 --- a/src/Riok.Mapperly/Emit/Syntax/SyntaxFactoryHelper.Return.cs +++ b/src/Riok.Mapperly/Emit/Syntax/SyntaxFactoryHelper.Return.cs @@ -18,5 +18,17 @@ public ReturnStatementSyntax Return(ExpressionSyntax? expression = default) ); } + public YieldStatementSyntax YieldReturn(ExpressionSyntax expression) + { + return YieldStatement( + SyntaxKind.YieldReturnStatement, + default, + LeadingLineFeedTrailingSpaceToken(SyntaxKind.YieldKeyword), + TrailingSpacedToken(SyntaxKind.ReturnKeyword), + expression, + Token(SyntaxKind.SemicolonToken) + ); + } + public StatementSyntax ReturnVariable(string identifierName) => Return(IdentifierName(identifierName)); } diff --git a/test/Riok.Mapperly.Tests/Mapping/EnumAdditionalParametersTest.cs b/test/Riok.Mapperly.Tests/Mapping/EnumAdditionalParametersTest.cs new file mode 100644 index 0000000000..99875d01cd --- /dev/null +++ b/test/Riok.Mapperly.Tests/Mapping/EnumAdditionalParametersTest.cs @@ -0,0 +1,706 @@ +using Riok.Mapperly.Abstractions; + +namespace Riok.Mapperly.Tests.Mapping; + +public class EnumAdditionalParametersTest +{ + [Fact] + public void TwoEnumSourcesByValueShouldMapToIEnumerableEnumTarget() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByValue)] partial IEnumerable Combine(Source1 s1, Source2 s2);", + "enum Source1 { B = 2 }", + "enum Source2 { C = 3 }", + "enum Target { A = 1, B = 2, C = 3 }" + ); + + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + yield return s1 switch + { + global::Source1.B => global::Target.B, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(s1), s1, "The value of enum Source1 is not supported"), + }; + yield return s2 switch + { + global::Source2.C => global::Target.C, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(s2), s2, "The value of enum Source2 is not supported"), + }; + """ + ); + } + + [Fact] + public void TwoEnumSourcesByValueShouldMapWithCascadingSwitch() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByValue)] partial Target Combine(Source1 source1, Source2 source2);", + "enum Source1 { One = 1, Two = 2, Four = 4 }", + "enum Source2 { Two = 2, Three = 3 }", + "enum Target { One = 1, Two = 2, Three = 3, Four = 4 }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.One => global::Target.One, + global::Source1.Two => global::Target.Two, + global::Source1.Four => global::Target.Four, + _ => source2 switch + { + global::Source2.Three => global::Target.Three, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void TwoEnumSourcesByNameShouldMapWithCascadingSwitch() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName)] partial Target Combine(Source1 source1, Source2 source2);", + "enum Source1 { A, B }", + "enum Source2 { B, C }", + "enum Target { A, B, C }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.A => global::Target.A, + global::Source1.B => global::Target.B, + _ => source2 switch + { + global::Source2.C => global::Target.C, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void ThreeEnumSourcesShouldMapWithThreeLevelCascadingSwitch() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByValue)] partial Target Combine(Source1 s1, Source2 s2, Source3 s3);", + "enum Source1 { A = 1 }", + "enum Source2 { B = 2 }", + "enum Source3 { C = 3 }", + "enum Target { A = 1, B = 2, C = 3 }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return s1 switch + { + global::Source1.A => global::Target.A, + _ => s2 switch + { + global::Source2.B => global::Target.B, + _ => s3 switch + { + global::Source3.C => global::Target.C, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(s1), s1, "The value of enum Source1 is not supported"), + }, + }, + }; + """ + ); + } + + [Fact] + public void OverlappingValuesShouldPrioritizeFirstSource() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByValue)] partial Target Combine(Source1 source1, Source2 source2);", + "enum Source1 { Shared = 1 }", + "enum Source2 { Shared = 1, Extra = 2 }", + "enum Target { Shared = 1, Extra = 2 }" + ); + + // Source1.Shared maps to Target.Shared + // Source2.Shared is NOT mapped because Target.Shared is already covered + // Source2.Extra maps to Target.Extra + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.Shared => global::Target.Shared, + _ => source2 switch + { + global::Source2.Extra => global::Target.Extra, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void AdditionalParameterCoversAllMissingShouldNotReportDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByValue)] partial Target Combine(Source1 source1, Source2 source2);", + "enum Source1 { A = 1 }", + "enum Source2 { B = 2 }", // Contributes B mapping + "enum Target { A = 1, B = 2 }" + ); + + // No diagnostics expected - Source2 contributes B mapping + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.A => global::Target.A, + _ => source2 switch + { + global::Source2.B => global::Target.B, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void MixedEnumAndNonEnumParametersShouldOnlyUseEnumParams() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByValue)] partial Target Map(Source1 source1, int ignored, Source2 source2);", + "enum Source1 { A = 1 }", + "enum Source2 { B = 2 }", + "enum Target { A = 1, B = 2 }" + ); + + // The int parameter should be ignored for enum mapping + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.A => global::Target.A, + _ => source2 switch + { + global::Source2.B => global::Target.B, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void SingleEnumParameterShouldFallbackToStandardMapping() + { + // When there's only one enum parameter, standard enum mapping should be used + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByValue)] partial Target Map(Source source);", + "enum Source { A, B, C }", + "enum Target { A, B, C }" + ); + + TestHelper.GenerateMapper(source).Should().HaveSingleMethodBody("return (global::Target)source;"); + } + + [Fact] + public void DisjointEnumsShouldMapAllValues() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByValue)] partial Target Combine(Source1 source1, Source2 source2);", + "enum Source1 { A = 1, B = 2 }", + "enum Source2 { C = 3, D = 4 }", + "enum Target { A = 1, B = 2, C = 3, D = 4 }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.A => global::Target.A, + global::Source1.B => global::Target.B, + _ => source2 switch + { + global::Source2.C => global::Target.C, + global::Source2.D => global::Target.D, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void GlobalConfigByValueShouldGenerateCascading() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial Target Combine(Source1 source1, Source2 source2);", + new TestSourceBuilderOptions { EnumMappingStrategy = EnumMappingStrategy.ByValue }, + "enum Source1 { X = 10 }", + "enum Source2 { Y = 20 }", + "enum Target { X = 10, Y = 20 }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.X => global::Target.X, + _ => source2 switch + { + global::Source2.Y => global::Target.Y, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void ByValueCheckDefinedStrategyShouldWork() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByValueCheckDefined)] partial Target Combine(Source1 source1, Source2 source2);", + "enum Source1 { A = 1, B = 2 }", + "enum Source2 { C = 3 }", + "enum Target { A = 1, B = 2, C = 3 }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.A => global::Target.A, + global::Source1.B => global::Target.B, + _ => source2 switch + { + global::Source2.C => global::Target.C, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void ByNameWithIgnoreCaseShouldWork() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapEnum(EnumMappingStrategy.ByName, IgnoreCase = true)] + partial Target Combine(Source1 source1, Source2 source2); + """, + "enum Source1 { apple, BANANA }", + "enum Source2 { cherry }", + "enum Target { Apple, Banana, Cherry }" + ); + + // Should match case-insensitively: apple -> Apple, BANANA -> Banana, cherry -> Cherry + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.apple => global::Target.Apple, + global::Source1.BANANA => global::Target.Banana, + _ => source2 switch + { + global::Source2.cherry => global::Target.Cherry, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void WithFallbackValueShouldUseFallback() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapEnum(EnumMappingStrategy.ByName, FallbackValue = Target.Unknown)] + partial Target Combine(Source1 source1, Source2 source2); + """, + "enum Source1 { A, B }", + "enum Source2 { C }", + "enum Target { A, B, C, Unknown }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.A => global::Target.A, + global::Source1.B => global::Target.B, + _ => source2 switch + { + global::Source2.C => global::Target.C, + _ => global::Target.Unknown, + }, + }; + """ + ); + } + + [Fact] + public void WithExplicitMappingShouldUseMapEnumValue() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapEnum(EnumMappingStrategy.ByValue)] + [MapEnumValue(Source1.X, Target.MappedX)] + partial Target Combine(Source1 source1, Source2 source2); + """, + "enum Source1 { X = 1, Y = 2 }", + "enum Source2 { Z = 3 }", + "enum Target { MappedX = 10, Y = 2, Z = 3 }" + ); + + // X should be explicitly mapped to MappedX, Y by value, Z from source2 by value + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.X => global::Target.MappedX, + global::Source1.Y => global::Target.Y, + _ => source2 switch + { + global::Source2.Z => global::Target.Z, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void WithIgnoredSourceValueShouldSkipMember() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapEnum(EnumMappingStrategy.ByValue)] + [MapperIgnoreSourceValue(Source1.Ignored)] + partial Target Combine(Source1 source1, Source2 source2); + """, + "enum Source1 { A = 1, Ignored = 2, C = 3 }", + "enum Source2 { D = 4 }", + "enum Target { A = 1, B = 2, C = 3, D = 4 }" + ); + + // Source1.Ignored should be skipped, so Target.B won't be mapped from Source1 + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.A => global::Target.A, + global::Source1.C => global::Target.C, + _ => source2 switch + { + global::Source2.D => global::Target.D, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void WithIgnoredTargetValueShouldSkipTargetMember() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapEnum(EnumMappingStrategy.ByValue)] + [MapperIgnoreTargetValue(Target.IgnoredTarget)] + partial Target Combine(Source1 source1, Source2 source2); + """, + "enum Source1 { A = 1, B = 2 }", + "enum Source2 { C = 3 }", + "enum Target { A = 1, IgnoredTarget = 2, C = 3 }" + ); + + // Target.IgnoredTarget (value=2) should not be mapped, so Source1.B won't have a matching target + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.A => global::Target.A, + _ => source2 switch + { + global::Source2.C => global::Target.C, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void WithDifferentValuesButSameNamesByNameStrategy() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[MapEnum(EnumMappingStrategy.ByName)] partial Target Combine(Source1 source1, Source2 source2);", + "enum Source1 { Alpha = 100, Beta = 200 }", + "enum Source2 { Gamma = 300 }", + "enum Target { Alpha = 1, Beta = 2, Gamma = 3 }" + ); + + // Should match by name regardless of underlying values + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.Alpha => global::Target.Alpha, + global::Source1.Beta => global::Target.Beta, + _ => source2 switch + { + global::Source2.Gamma => global::Target.Gamma, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void GlobalMapperConfigWithByNameIgnoreCase() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "partial Target Combine(Source1 source1, Source2 source2);", + new TestSourceBuilderOptions { EnumMappingStrategy = EnumMappingStrategy.ByName, EnumMappingIgnoreCase = true }, + "enum Source1 { ALPHA }", + "enum Source2 { beta }", + "enum Target { Alpha, Beta }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.ALPHA => global::Target.Alpha, + _ => source2 switch + { + global::Source2.beta => global::Target.Beta, + _ => throw new global::System.ArgumentOutOfRangeException(nameof(source1), source1, "The value of enum Source1 is not supported"), + }, + }; + """ + ); + } + + [Fact] + public void FallbackValueWithByValueStrategyShouldUseFallback() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapEnum(EnumMappingStrategy.ByValue, FallbackValue = Target.Default)] + partial Target Map(Source1 source1, Source2 source2); + """, + "enum Source1 { A = 1, B = 2 }", + "enum Source2 { C = 3 }", + "enum Target { A = 1, B = 2, C = 3, Default = 99 }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.A => global::Target.A, + global::Source1.B => global::Target.B, + _ => source2 switch + { + global::Source2.C => global::Target.C, + _ => global::Target.Default, + }, + }; + """ + ); + } + + [Fact] + public void FallbackValueWithByValueCheckDefinedStrategyShouldUseFallback() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapEnum(EnumMappingStrategy.ByValueCheckDefined, FallbackValue = Target.Undefined)] + partial Target Map(Source1 source1, Source2 source2); + """, + "enum Source1 { Known = 1 }", + "enum Source2 { AnotherKnown = 2 }", + "enum Target { Known = 1, AnotherKnown = 2, Undefined = 0 }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.Known => global::Target.Known, + _ => source2 switch + { + global::Source2.AnotherKnown => global::Target.AnotherKnown, + _ => global::Target.Undefined, + }, + }; + """ + ); + } + + [Fact] + public void FallbackValueNotAffectedByEnumNamingStrategies() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapEnum(EnumMappingStrategy.ByName, FallbackValue = Target.DefaultValue)] + partial Target Map(Source1 source1, Source2 source2); + """, + "enum Source1 { value_one }", + "enum Source2 { value_two }", + "enum Target { value_one, value_two, DefaultValue }" + ); + + // FallbackValue should be used as-is, not affected by naming strategy + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.value_one => global::Target.value_one, + _ => source2 switch + { + global::Source2.value_two => global::Target.value_two, + _ => global::Target.DefaultValue, + }, + }; + """ + ); + } + + [Fact] + public void FallbackValueWithExplicitMappingShouldUseFallback() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapEnum(EnumMappingStrategy.ByValue, FallbackValue = Target.Default)] + [MapEnumValue(Source1.Special, Target.MappedSpecial)] + partial Target Map(Source1 source1, Source2 source2); + """, + "enum Source1 { Special = 1, Regular = 2 }", + "enum Source2 { Extra = 3 }", + "enum Target { MappedSpecial = 10, Regular = 2, Extra = 3, Default = 0 }" + ); + + // Should use explicit mapping for Special, regular value mapping for Regular, + // source2 mapping for Extra, and fallback for unknown values + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.Special => global::Target.MappedSpecial, + global::Source1.Regular => global::Target.Regular, + _ => source2 switch + { + global::Source2.Extra => global::Target.Extra, + _ => global::Target.Default, + }, + }; + """ + ); + } + + [Fact] + public void FallbackValueWithIgnoredValuesShouldUseFallback() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapEnum(EnumMappingStrategy.ByValue, FallbackValue = Target.Default)] + [MapperIgnoreSourceValue(Source1.Ignored)] + partial Target Map(Source1 source1, Source2 source2); + """, + "enum Source1 { A = 1, Ignored = 2, B = 3 }", + "enum Source2 { C = 4 }", + "enum Target { A = 1, B = 3, C = 4, Default = 0 }" + ); + + // Ignored value should not be mapped, fallback should be used for unknown values + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + return source1 switch + { + global::Source1.A => global::Target.A, + global::Source1.B => global::Target.B, + _ => source2 switch + { + global::Source2.C => global::Target.C, + _ => global::Target.Default, + }, + }; + """ + ); + } +} From 32eb07c1b11b5d7aeb7d7fac2c63d57a8aa0eefb Mon Sep 17 00:00:00 2001 From: Kataane Date: Fri, 2 Jan 2026 17:19:41 +0700 Subject: [PATCH 2/2] remove recursion --- .../Mappings/Enums/EnumMultiSourceMapping.cs | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumMultiSourceMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumMultiSourceMapping.cs index 4215585106..16f31d8188 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumMultiSourceMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/Enums/EnumMultiSourceMapping.cs @@ -22,7 +22,7 @@ public override IEnumerable BuildBody(TypeMappingBuildContext c { if (useYieldReturn) { - // Generate multiple yield return statements - one for each source enum + // Generate yield return statements - one for each source mapping. foreach (var sourceMapping in sourceMappings) { var switchExpression = BuildSingleSourceSwitch(ctx, sourceMapping); @@ -31,8 +31,8 @@ public override IEnumerable BuildBody(TypeMappingBuildContext c } else { - // Build cascading switch expression starting with the first source - var switchExpression = BuildCascadingSwitch(ctx, sourceMappings, 0); + // Build a single switch expression that cascades through sources using an iterative approach. + var switchExpression = BuildCascadingSwitch(ctx, sourceMappings); yield return ctx.SyntaxFactory.Return(switchExpression); } } @@ -41,10 +41,14 @@ private static ExpressionSyntax BuildSingleSourceSwitch(TypeMappingBuildContext { var parameterAccess = IdentifierName(sourceMapping.Parameter.Name); - // Build switch arms for the source - var arms = sourceMapping.MemberMappings.Select(x => BuildArm(x.Key, x.Value)).ToList(); + // Build switch arms for the source mappings. + var arms = new List(sourceMapping.MemberMappings.Count + 1); + foreach (var (sourceMember, targetMember) in sourceMapping.MemberMappings) + { + arms.Add(BuildArm(sourceMember, targetMember)); + } - // Add fallback arm that throws for this specific source parameter + // Add fallback arm that throws for unsupported values. var sourceExpression = IdentifierName(sourceMapping.Parameter.Name); var fallbackArm = SwitchArm( DiscardPattern(), @@ -55,28 +59,30 @@ private static ExpressionSyntax BuildSingleSourceSwitch(TypeMappingBuildContext return ctx.SyntaxFactory.Switch(parameterAccess, arms); } - private ExpressionSyntax BuildCascadingSwitch(TypeMappingBuildContext ctx, IReadOnlyList sources, int index) + private ExpressionSyntax BuildCascadingSwitch(TypeMappingBuildContext ctx, IReadOnlyList sources) { - var currentSource = sources[index]; - var parameterAccess = IdentifierName(currentSource.Parameter.Name); - - // Build switch arms for current source - var arms = currentSource.MemberMappings.Select(x => BuildArm(x.Key, x.Value)).ToList(); + var innerExpression = fallback.BuildDiscardArm(ctx).Expression; - // Add fallback for current source - if (index == sources.Count - 1) - { - // Last source uses the configured fallback - arms.Add(fallback.BuildDiscardArm(ctx)); - } - else + for (var i = sources.Count - 1; i >= 0; i--) { - // Non-last source cascades to next source - var nextSwitch = BuildCascadingSwitch(ctx, sources, index + 1); - arms.Add(SwitchArm(DiscardPattern(), nextSwitch)); + var currentSource = sources[i]; + var parameterAccess = IdentifierName(currentSource.Parameter.Name); + + // Collect arms for current source. + var arms = new List(currentSource.MemberMappings.Count + 1); + foreach (var (sourceMember, targetMember) in currentSource.MemberMappings) + { + arms.Add(BuildArm(sourceMember, targetMember)); + } + + // Add discard arm that cascades to the inner expression (next source or fallback). + arms.Add(SwitchArm(DiscardPattern(), innerExpression)); + + // Update inner expression for the next (outer) source. + innerExpression = ctx.SyntaxFactory.Switch(parameterAccess, arms); } - return ctx.SyntaxFactory.Switch(parameterAccess, arms); + return innerExpression; } private static SwitchExpressionArmSyntax BuildArm(IFieldSymbol sourceMemberField, IFieldSymbol targetMemberField)