diff --git a/.editorconfig b/.editorconfig index efb12e903c..a7cd612838 100644 --- a/.editorconfig +++ b/.editorconfig @@ -210,7 +210,7 @@ indent_size = 2 # markdown [*.{md,mdx}] -indent_size = 2 +indent_size = unset trim_trailing_whitespace = false # Verify settings diff --git a/docs/docs/configuration/mapper.mdx b/docs/docs/configuration/mapper.mdx index 7fac0bfb6f..a03ada032b 100644 --- a/docs/docs/configuration/mapper.mdx +++ b/docs/docs/configuration/mapper.mdx @@ -229,7 +229,7 @@ To enforce strict enum mappings set `RMG037` and `RMG038` to error, see [strict ### String format -The string format passed to `ToString` calls when converting to a string can be customized +The string format passed to `ToString` calls when converting to a string (using `IFormattable`) can be customized by using the `StringFormat` property of the `MapPropertyAttribute`. ```csharp @@ -243,6 +243,43 @@ public partial class CarMapper } ``` +### String format provider & culture + +To customize the format provider / culture to be used by Mapperly when calling `ToString` (using `IFormattable`) +format providers can be used. +A format provider can be provided to Mapperly by simply annotating a field or property within the Mapper with the `FormatProviderAttribute`. +The field/property need to return a type implementing `System.IFormatProvider`. +Format providers can be referenced by the name of the property / field in `MapPropertyAttribute.FormatProvider`. +A format provider can be marked as default (set the default property of the `FormatProviderAttribute` to true). +A default format provider is used for all `ToString` conversions when the source implements `System.IFormattable`. +In a mapper only one format provider can be marked as default. + +```csharp +[Mapper] +public partial class CarMapper +{ + // highlight-start + [FormatProvider(Default = true)] + private IFormatProvider CurrentCulture => CultureInfo.CurrentCulture; + // highlight-end + + // highlight-start + [FormatProvider] + private readonly IFormatProvider _enCulture = CultureInfo.GetCultureInfo("en-US"); + // highlight-end + + // highlight-start + [MapProperty(nameof(Car.LocalPrice), nameof(CarDto.LocalPrice), StringFormat = "C")] + [MapProperty(nameof(Car.ListPrice), nameof(CarDto.ListPrice), StringFormat = "C", FormatProvider = nameof(_enCulture)] + // highlight-end + public partial CarDto MapCar(Car car); + + // generates + target.LocalPrice = source.LocalPrice.ToString("C", CurrentCulture); + target.ListPrice = source.ListPrice.ToString("C", _enCulture); +} +``` + ## Default Mapper configuration The `MapperDefaultsAttribute` allows to set default configurations applied to all mappers on the assembly level. diff --git a/docs/docs/contributing/architecture.md b/docs/docs/contributing/architecture.md index 8416f61781..44f23bcf25 100644 --- a/docs/docs/contributing/architecture.md +++ b/docs/docs/contributing/architecture.md @@ -33,8 +33,9 @@ The `DescriptorBuilder` does this by following this process: 2. Extracting user implemented and user defined mapping methods. It instantiates a `User*Mapping` (eg. `UserDefinedNewInstanceMethodMapping`) for each discovered mapping method and adds it to the queue of mappings to work on. 3. Extracting user implemented object factories -4. Extracting external mappings -5. For each mapping in the queue the `DescriptorBuilder` tries to build its implementation bodies. +4. Extracting user implemented format providers +5. Extracting external mappings +6. For each mapping in the queue the `DescriptorBuilder` tries to build its implementation bodies. This is done by a so called `*MappingBodyBuilder`. A mapping body builder tries to map each property from the source to the target. To do this, it asks the `DescriptorBuilder` to create mappings for the according types. @@ -42,7 +43,7 @@ The `DescriptorBuilder` does this by following this process: Each of the mapping builders try to create a mapping (an `ITypeMapping` implementation) for the asked type mapping by using one approach on how to map types (eg. an explicit cast is implemented by the `ExplicitCastMappingBuilder`). These mappings are queued in the queue of mappings which need the body to be built (currently body builders are only used for object to object (property-based) mappings). -6. The `SourceEmitter` emits the code described by the `MapperDescriptor` and all its mappings. +7. The `SourceEmitter` emits the code described by the `MapperDescriptor` and all its mappings. The syntax objects are created by using `SyntaxFactory` and `SyntaxFactoryHelper`. The `SyntaxFactoryHelper` tries to simplify creating formatted syntax trees. If indentation is needed, diff --git a/src/Riok.Mapperly.Abstractions/FormatProviderAttribute.cs b/src/Riok.Mapperly.Abstractions/FormatProviderAttribute.cs new file mode 100644 index 0000000000..a6816ef07c --- /dev/null +++ b/src/Riok.Mapperly.Abstractions/FormatProviderAttribute.cs @@ -0,0 +1,16 @@ +namespace Riok.Mapperly.Abstractions; + +/// +/// Marks a property or field as a format provider. +/// A format provider needs to be of a type which implements and needs to have a getter. +/// +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] +public sealed class FormatProviderAttribute : Attribute +{ + /// + /// If set to true, this format provider acts as a default format provider + /// and is used for all conversions without an explicit set. + /// Only one in a Mapper can be set to true. + /// + public bool Default { get; set; } +} diff --git a/src/Riok.Mapperly.Abstractions/MapPropertyAttribute.cs b/src/Riok.Mapperly.Abstractions/MapPropertyAttribute.cs index 2887b8228f..11878e0507 100644 --- a/src/Riok.Mapperly.Abstractions/MapPropertyAttribute.cs +++ b/src/Riok.Mapperly.Abstractions/MapPropertyAttribute.cs @@ -44,10 +44,17 @@ public MapPropertyAttribute(string[] source, string[] target) public IReadOnlyCollection Target { get; } /// - /// Gets or sets the format of the ToString conversion. + /// Gets or sets the format of the ToString conversion (implementing ). /// public string? StringFormat { get; set; } + /// + /// Gets or sets the name of a format provider field or property to be used for conversions accepting a format provider (implementing ). + /// If null the default format provider (annotated with and true) + /// or none (if no default format provider is provided) is used. + /// + public string? FormatProvider { get; set; } + /// /// Gets the full name of the target property path. /// diff --git a/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt b/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt index cb33fe160d..737d71121e 100644 --- a/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt +++ b/src/Riok.Mapperly.Abstractions/PublicAPI.Shipped.txt @@ -131,3 +131,9 @@ Riok.Mapperly.Abstractions.MemberVisibility.Internal = 4 -> Riok.Mapperly.Abstra Riok.Mapperly.Abstractions.MemberVisibility.Private = 16 -> Riok.Mapperly.Abstractions.MemberVisibility Riok.Mapperly.Abstractions.MemberVisibility.Protected = 8 -> Riok.Mapperly.Abstractions.MemberVisibility Riok.Mapperly.Abstractions.MemberVisibility.Public = 2 -> Riok.Mapperly.Abstractions.MemberVisibility +Riok.Mapperly.Abstractions.FormatProviderAttribute +Riok.Mapperly.Abstractions.FormatProviderAttribute.Default.get -> bool +Riok.Mapperly.Abstractions.FormatProviderAttribute.Default.set -> void +Riok.Mapperly.Abstractions.FormatProviderAttribute.FormatProviderAttribute() -> void +Riok.Mapperly.Abstractions.MapPropertyAttribute.FormatProvider.get -> string? +Riok.Mapperly.Abstractions.MapPropertyAttribute.FormatProvider.set -> void diff --git a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md index 04042b272b..c82bd0df87 100644 --- a/src/Riok.Mapperly/AnalyzerReleases.Shipped.md +++ b/src/Riok.Mapperly/AnalyzerReleases.Shipped.md @@ -127,7 +127,10 @@ RMG051 | Mapper | Warning | Invalid ignore source member found, nested ignor RMG052 | Mapper | Warning | Invalid ignore target member found, nested ignores are not supported RMG053 | Mapper | Error | The flag MemberVisibility.Accessible cannot be disabled, this feature requires .NET 8.0 or greater RMG054 | Mapper | Error | Mapper class containing 'static partial' method must not have any instance methods -RMG055 | Mapper | Error | The source type does not implement IFormattable, string format cannot be applied +RMG055 | Mapper | Error | The source type does not implement IFormattable, string format and format provider cannot be applied +RMG056 | Mapper | Error | Invalid format provider signature +RMG057 | Mapper | Error | Format provider not found +RMG058 | Mapper | Error | Multiple default format providers found, only one is allowed ### Removed Rules Rule ID | Category | Severity | Notes diff --git a/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs b/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs index afcfd9064a..8fe4a1a538 100644 --- a/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs +++ b/src/Riok.Mapperly/Configuration/AttributeDataAccessor.cs @@ -13,6 +13,9 @@ public class AttributeDataAccessor(SymbolAccessor symbolAccessor) private const string NameOfOperatorName = "nameof"; private const char FullNameOfPrefix = '@'; + public TAttribute AccessSingle(ISymbol symbol) + where TAttribute : Attribute => AccessSingle(symbol); + public TData AccessSingle(ISymbol symbol) where TAttribute : Attribute where TData : notnull => Access(symbol).Single(); diff --git a/src/Riok.Mapperly/Configuration/PropertyMappingConfiguration.cs b/src/Riok.Mapperly/Configuration/PropertyMappingConfiguration.cs index 5c8c802146..9341a08da4 100644 --- a/src/Riok.Mapperly/Configuration/PropertyMappingConfiguration.cs +++ b/src/Riok.Mapperly/Configuration/PropertyMappingConfiguration.cs @@ -6,5 +6,7 @@ public record PropertyMappingConfiguration(StringMemberPath Source, StringMember { public string? StringFormat { get; set; } - public TypeMappingConfiguration ToTypeMappingConfiguration() => new(StringFormat); + public string? FormatProvider { get; set; } + + public TypeMappingConfiguration ToTypeMappingConfiguration() => new(StringFormat, FormatProvider); } diff --git a/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs b/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs index 3ae11aee56..357c9a7eb4 100644 --- a/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/DescriptorBuilder.cs @@ -3,6 +3,7 @@ using Riok.Mapperly.Abstractions.ReferenceHandling; using Riok.Mapperly.Configuration; using Riok.Mapperly.Descriptors.ExternalMappings; +using Riok.Mapperly.Descriptors.FormatProviders; using Riok.Mapperly.Descriptors.MappingBodyBuilders; using Riok.Mapperly.Descriptors.MappingBuilders; using Riok.Mapperly.Descriptors.ObjectFactories; @@ -67,7 +68,8 @@ MapperConfiguration defaultMapperConfiguration // ExtractObjectFactories needs to be called after ExtractUserMappings due to configuring mapperDescriptor.Static var objectFactories = ExtractObjectFactories(); - EnqueueUserMappings(objectFactories); + var formatProviders = ExtractFormatProviders(); + EnqueueUserMappings(objectFactories, formatProviders); ExtractExternalMappings(); _mappingBodyBuilder.BuildMappingBodies(cancellationToken); BuildMappingMethodNames(); @@ -144,13 +146,14 @@ private ObjectFactoryCollection ExtractObjectFactories() return ObjectFactoryBuilder.ExtractObjectFactories(_builderContext, _mapperDescriptor.Symbol); } - private void EnqueueUserMappings(ObjectFactoryCollection objectFactories) + private void EnqueueUserMappings(ObjectFactoryCollection objectFactories, FormatProviderCollection formatProviders) { foreach (var userMapping in _mappings.UserMappings) { var ctx = new MappingBuilderContext( _builderContext, objectFactories, + formatProviders, userMapping.Method, new TypeMappingKey(userMapping.SourceType, userMapping.TargetType) ); @@ -167,6 +170,11 @@ private void ExtractExternalMappings() } } + private FormatProviderCollection ExtractFormatProviders() + { + return FormatProviderBuilder.ExtractFormatProviders(_builderContext, _mapperDescriptor.Symbol); + } + private void BuildMappingMethodNames() { foreach (var methodMapping in _mappings.MethodMappings) diff --git a/src/Riok.Mapperly/Descriptors/FormatProviders/FormatProvider.cs b/src/Riok.Mapperly/Descriptors/FormatProviders/FormatProvider.cs new file mode 100644 index 0000000000..91a9d0c7bb --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/FormatProviders/FormatProvider.cs @@ -0,0 +1,5 @@ +using Microsoft.CodeAnalysis; + +namespace Riok.Mapperly.Descriptors.FormatProviders; + +public record FormatProvider(string Name, bool Default, ISymbol Symbol); diff --git a/src/Riok.Mapperly/Descriptors/FormatProviders/FormatProviderBuilder.cs b/src/Riok.Mapperly/Descriptors/FormatProviders/FormatProviderBuilder.cs new file mode 100644 index 0000000000..fcdebe50e4 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/FormatProviders/FormatProviderBuilder.cs @@ -0,0 +1,45 @@ +using Microsoft.CodeAnalysis; +using Riok.Mapperly.Abstractions; +using Riok.Mapperly.Diagnostics; +using Riok.Mapperly.Helpers; +using Riok.Mapperly.Symbols; + +namespace Riok.Mapperly.Descriptors.FormatProviders; + +public static class FormatProviderBuilder +{ + public static FormatProviderCollection ExtractFormatProviders(SimpleMappingBuilderContext ctx, ITypeSymbol mapperSymbol) + { + var formatProviders = mapperSymbol + .GetMembers() + .Where(x => ctx.SymbolAccessor.HasAttribute(x)) + .Select(x => BuildFormatProvider(ctx, x)) + .WhereNotNull() + .ToList(); + + var defaultFormatProviderCandidates = formatProviders.Where(x => x.Default).Take(2).ToList(); + if (defaultFormatProviderCandidates.Count > 1) + { + ctx.ReportDiagnostic(DiagnosticDescriptors.MultipleDefaultFormatProviders, defaultFormatProviderCandidates[1].Symbol); + } + + var formatProvidersByName = formatProviders.GroupBy(x => x.Name).ToDictionary(x => x.Key, x => x.Single()); + return new FormatProviderCollection(formatProvidersByName, defaultFormatProviderCandidates.FirstOrDefault()); + } + + private static FormatProvider? BuildFormatProvider(SimpleMappingBuilderContext ctx, ISymbol symbol) + { + var memberSymbol = MappableMember.Create(ctx.SymbolAccessor, symbol); + if (memberSymbol == null) + return null; + + if (!memberSymbol.CanGet || symbol.IsStatic != ctx.Static || !memberSymbol.Type.Implements(ctx.Types.Get())) + { + ctx.ReportDiagnostic(DiagnosticDescriptors.InvalidFormatProviderSignature, symbol, symbol.Name); + return null; + } + + var attribute = ctx.AttributeAccessor.AccessSingle(symbol); + return new FormatProvider(symbol.Name, attribute.Default, symbol); + } +} diff --git a/src/Riok.Mapperly/Descriptors/FormatProviders/FormatProviderCollection.cs b/src/Riok.Mapperly/Descriptors/FormatProviders/FormatProviderCollection.cs new file mode 100644 index 0000000000..851ef66657 --- /dev/null +++ b/src/Riok.Mapperly/Descriptors/FormatProviders/FormatProviderCollection.cs @@ -0,0 +1,12 @@ +namespace Riok.Mapperly.Descriptors.FormatProviders; + +public class FormatProviderCollection( + IReadOnlyDictionary formatProvidersByName, + FormatProvider? defaultFormatProvider +) +{ + public FormatProvider? Get(string? reference) + { + return reference == null ? defaultFormatProvider : formatProvidersByName.GetValueOrDefault(reference); + } +} diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs index 63eefd7d0d..8b842ad05f 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilderContext.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis; using Riok.Mapperly.Configuration; using Riok.Mapperly.Descriptors.Enumerables; +using Riok.Mapperly.Descriptors.FormatProviders; using Riok.Mapperly.Descriptors.Mappings; using Riok.Mapperly.Descriptors.Mappings.ExistingTarget; using Riok.Mapperly.Descriptors.Mappings.UserMappings; @@ -19,19 +20,21 @@ public class MappingBuilderContext : SimpleMappingBuilderContext public MappingBuilderContext( SimpleMappingBuilderContext parentCtx, ObjectFactoryCollection objectFactories, + FormatProviderCollection formatProviders, IMethodSymbol? userSymbol, TypeMappingKey mappingKey ) : base(parentCtx) { ObjectFactories = objectFactories; + FormatProviders = formatProviders; UserSymbol = userSymbol; MappingKey = mappingKey; Configuration = ReadConfiguration(new MappingConfigurationReference(UserSymbol, mappingKey.Source, mappingKey.Target)); } protected MappingBuilderContext(MappingBuilderContext ctx, IMethodSymbol? userSymbol, TypeMappingKey mappingKey, bool clearDerivedTypes) - : this(ctx, ctx.ObjectFactories, userSymbol, mappingKey) + : this(ctx, ctx.ObjectFactories, ctx.FormatProviders, userSymbol, mappingKey) { if (clearDerivedTypes) { @@ -57,6 +60,7 @@ protected MappingBuilderContext(MappingBuilderContext ctx, IMethodSymbol? userSy public virtual bool IsExpression => false; public ObjectFactoryCollection ObjectFactories { get; } + public FormatProviderCollection FormatProviders { get; } /// public IReadOnlyCollection UserMappings => MappingBuilder.UserMappings; @@ -212,6 +216,17 @@ public void ReportDiagnostic(DiagnosticDescriptor descriptor, params object[] me public NullFallbackValue GetNullFallbackValue(ITypeSymbol? targetType = null) => GetNullFallbackValue(targetType ?? Target, MapperConfiguration.ThrowOnMappingNullMismatch); + public FormatProvider? GetFormatProvider(string? formatProviderName) + { + var formatProvider = FormatProviders.Get(formatProviderName); + if (formatProviderName != null && formatProvider == null) + { + ReportDiagnostic(DiagnosticDescriptors.FormatProviderNotFound, formatProviderName); + } + + return formatProvider; + } + protected virtual NullFallbackValue GetNullFallbackValue(ITypeSymbol targetType, bool throwOnMappingNullMismatch) { if (targetType.IsNullable()) diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/ToStringMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/ToStringMappingBuilder.cs index cd8afa0b4a..ce37611fcb 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBuilders/ToStringMappingBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/ToStringMappingBuilder.cs @@ -16,7 +16,8 @@ public static class ToStringMappingBuilder if (ctx.Target.SpecialType != SpecialType.System_String) return null; - if (ctx.MappingKey.Configuration.StringFormat == null) + var formatProvider = ctx.GetFormatProvider(ctx.MappingKey.Configuration.FormatProviderName); + if (ctx.MappingKey.Configuration.StringFormat == null && formatProvider == null) return new ToStringMapping(ctx.Source, ctx.Target); if (!ctx.Source.Implements(ctx.Types.Get())) @@ -25,6 +26,6 @@ public static class ToStringMappingBuilder return new ToStringMapping(ctx.Source, ctx.Target); } - return new ToStringMapping(ctx.Source, ctx.Target, ctx.MappingKey.Configuration.StringFormat); + return new ToStringMapping(ctx.Source, ctx.Target, ctx.MappingKey.Configuration.StringFormat, formatProvider?.Name); } } diff --git a/src/Riok.Mapperly/Descriptors/Mappings/ToStringMapping.cs b/src/Riok.Mapperly/Descriptors/Mappings/ToStringMapping.cs index fe413383d0..0402d1baab 100644 --- a/src/Riok.Mapperly/Descriptors/Mappings/ToStringMapping.cs +++ b/src/Riok.Mapperly/Descriptors/Mappings/ToStringMapping.cs @@ -1,5 +1,6 @@ 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; @@ -11,15 +12,15 @@ namespace Riok.Mapperly.Descriptors.Mappings; /// target = source.ToString(); /// /// -public class ToStringMapping(ITypeSymbol sourceType, ITypeSymbol targetType, string? stringFormat = null) +public class ToStringMapping(ITypeSymbol sourceType, ITypeSymbol targetType, string? stringFormat = null, string? formatProviderName = null) : SourceObjectMethodMapping(sourceType, targetType, nameof(ToString)) { protected override IEnumerable BuildArguments(TypeMappingBuildContext ctx) { - if (stringFormat == null) + if (stringFormat == null && formatProviderName == null) yield break; - yield return StringLiteral(stringFormat); - yield return NullLiteral(); + yield return stringFormat == null ? NullLiteral() : StringLiteral(stringFormat); + yield return formatProviderName == null ? NullLiteral() : IdentifierName(formatProviderName); } } diff --git a/src/Riok.Mapperly/Descriptors/TypeMappingConfiguration.cs b/src/Riok.Mapperly/Descriptors/TypeMappingConfiguration.cs index 5aaeced986..e9bcebde73 100644 --- a/src/Riok.Mapperly/Descriptors/TypeMappingConfiguration.cs +++ b/src/Riok.Mapperly/Descriptors/TypeMappingConfiguration.cs @@ -4,8 +4,9 @@ namespace Riok.Mapperly.Descriptors; /// Configuration for a type mapping. /// Eg. the format to apply to `ToString` calls. /// -/// The format to apply to `ToString` calls. -public record TypeMappingConfiguration(string? StringFormat = null) +/// The format to apply to . +/// The name of the format provider to apply to . +public record TypeMappingConfiguration(string? StringFormat = null, string? FormatProviderName = null) { public static readonly TypeMappingConfiguration Default = new(); } diff --git a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs index 02e01b609d..1796bb9210 100644 --- a/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs +++ b/src/Riok.Mapperly/Diagnostics/DiagnosticDescriptors.cs @@ -476,8 +476,35 @@ public static class DiagnosticDescriptors public static readonly DiagnosticDescriptor SourceDoesNotImplementIFormattable = new DiagnosticDescriptor( "RMG055", - $"The source type does not implement {nameof(IFormattable)}, string format cannot be applied", - $"The source type {{0}} does not implement {nameof(IFormattable)}, string format cannot be applied", + $"The source type does not implement {nameof(IFormattable)}, string format and format provider cannot be applied", + $"The source type {{0}} does not implement {nameof(IFormattable)}, string format and format provider cannot be applied", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor InvalidFormatProviderSignature = new DiagnosticDescriptor( + "RMG056", + "Invalid format provider signature", + "The format provider {0} has an invalid signature", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor FormatProviderNotFound = new DiagnosticDescriptor( + "RMG057", + "Format provider not found", + $"The format provider {{0}} could not be found, make sure it is annotated with {nameof(FormatProviderAttribute)}", + DiagnosticCategories.Mapper, + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor MultipleDefaultFormatProviders = new DiagnosticDescriptor( + "RMG058", + "Multiple default format providers found, only one is allowed", + "Multiple default format providers found, only one is allowed", DiagnosticCategories.Mapper, DiagnosticSeverity.Error, true diff --git a/src/Riok.Mapperly/Symbols/FieldMember.cs b/src/Riok.Mapperly/Symbols/FieldMember.cs index 538995c880..f136b36199 100644 --- a/src/Riok.Mapperly/Symbols/FieldMember.cs +++ b/src/Riok.Mapperly/Symbols/FieldMember.cs @@ -14,8 +14,8 @@ public class FieldMember(IFieldSymbol fieldSymbol) : IMappableMember public ISymbol MemberSymbol => _fieldSymbol; public bool IsNullable => _fieldSymbol.NullableAnnotation == NullableAnnotation.Annotated || Type.IsNullable(); public bool IsIndexer => false; - public bool CanGet => !_fieldSymbol.IsReadOnly; - public bool CanSet => true; + public bool CanGet => true; + public bool CanSet => !_fieldSymbol.IsReadOnly; public bool CanSetDirectly => true; public bool IsInitOnly => false; diff --git a/test/Riok.Mapperly.IntegrationTests/BaseMapperTest.cs b/test/Riok.Mapperly.IntegrationTests/BaseMapperTest.cs index e371b3124a..96ddb174ce 100644 --- a/test/Riok.Mapperly.IntegrationTests/BaseMapperTest.cs +++ b/test/Riok.Mapperly.IntegrationTests/BaseMapperTest.cs @@ -15,8 +15,8 @@ namespace Riok.Mapperly.IntegrationTests { public abstract class BaseMapperTest { - private static string SolutionDirectory = GetSolutionDirectory(); - private static string ProjectDirectory = GetProjectDirectory(); + private static readonly string _solutionDirectory = GetSolutionDirectory(); + private static readonly string _projectDirectory = GetProjectDirectory(); static BaseMapperTest() { @@ -33,7 +33,7 @@ static BaseMapperTest() Verifier.DerivePathInfo( (_, _, type, method) => new PathInfo( - Path.Combine(ProjectDirectory, "_snapshots"), + Path.Combine(_projectDirectory, "_snapshots"), type.Name, method.Name + GetSnapshotVersionSuffix(type, method) ) @@ -43,7 +43,7 @@ static BaseMapperTest() protected string GetGeneratedMapperFilePath(string name) { return Path.Combine( - SolutionDirectory, + _solutionDirectory, "artifacts", "obj", "Riok.Mapperly.IntegrationTests", @@ -70,6 +70,7 @@ public static TestObject NewTestObj() SubObject = new InheritanceSubObject { BaseIntValue = 1, SubIntValue = 2, }, EnumRawValue = TestEnum.Value20, EnumStringValue = TestEnum.Value30, + DateTimeValue = new DateTime(2020, 1, 3, 15, 10, 5, DateTimeKind.Utc), DateTimeValueTargetDateOnly = new DateTime(2020, 1, 3, 15, 10, 5, DateTimeKind.Utc), DateTimeValueTargetTimeOnly = new DateTime(2020, 1, 3, 15, 10, 5, DateTimeKind.Utc), IgnoredStringValue = "ignored", diff --git a/test/Riok.Mapperly.IntegrationTests/Dto/TestObjectDto.cs b/test/Riok.Mapperly.IntegrationTests/Dto/TestObjectDto.cs index b6e60d56a3..7a391aa5fc 100644 --- a/test/Riok.Mapperly.IntegrationTests/Dto/TestObjectDto.cs +++ b/test/Riok.Mapperly.IntegrationTests/Dto/TestObjectDto.cs @@ -123,6 +123,10 @@ public TestObjectDto(int ctorValue, int unknownValue = 10, int ctorValue2 = 100) public TimeOnly DateTimeValueTargetTimeOnly { get; set; } + public string FormattedIntValue { get; set; } = string.Empty; + + public string FormattedDateValue { get; set; } = string.Empty; + public int ExposePrivateValue => PrivateValue; private int PrivateValue { get; set; } diff --git a/test/Riok.Mapperly.IntegrationTests/Mapper/TestMapper.cs b/test/Riok.Mapperly.IntegrationTests/Mapper/TestMapper.cs index 9c3a3f2361..c860a9c05d 100644 --- a/test/Riok.Mapperly.IntegrationTests/Mapper/TestMapper.cs +++ b/test/Riok.Mapperly.IntegrationTests/Mapper/TestMapper.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using Riok.Mapperly.Abstractions; using Riok.Mapperly.IntegrationTests.Dto; using Riok.Mapperly.IntegrationTests.Models; @@ -17,6 +18,12 @@ namespace Riok.Mapperly.IntegrationTests.Mapper #endif public partial class TestMapper { + [FormatProvider(Default = true)] + private readonly CultureInfo _formatDeCh = CultureInfo.GetCultureInfo("de-CH"); + + [FormatProvider] + private readonly CultureInfo _formatEnUs = CultureInfo.GetCultureInfo("en-US"); + public partial int DirectInt(int value); public partial long ImplicitCastInt(int value); @@ -43,6 +50,13 @@ public TestObjectDto MapToDto(TestObject src) [MapperIgnoreTarget(nameof(TestObjectDto.IgnoredStringValue))] [MapperIgnoreTarget(nameof(TestObjectDto.IgnoredIntValue))] [MapperIgnoreSource(nameof(TestObject.IgnoredIntValue))] + [MapProperty(nameof(TestObject.IntValue), nameof(TestObjectDto.FormattedIntValue), StringFormat = "C")] + [MapProperty( + nameof(TestObject.DateTimeValue), + nameof(TestObjectDto.FormattedDateValue), + StringFormat = "D", + FormatProvider = nameof(_formatEnUs) + )] [MapProperty(nameof(TestObject.RenamedStringValue), nameof(TestObjectDto.RenamedStringValue2))] [MapProperty( new[] { nameof(TestObject.UnflatteningIdValue) }, diff --git a/test/Riok.Mapperly.IntegrationTests/Models/TestObject.cs b/test/Riok.Mapperly.IntegrationTests/Models/TestObject.cs index bd797aa3c6..0c0af0ea83 100644 --- a/test/Riok.Mapperly.IntegrationTests/Models/TestObject.cs +++ b/test/Riok.Mapperly.IntegrationTests/Models/TestObject.cs @@ -116,6 +116,8 @@ public TestObject(int ctorValue, int unknownValue = 10, int ctorValue2 = 100) public int IgnoredIntValue { get; set; } + public DateTime DateTimeValue { get; set; } + public DateTime DateTimeValueTargetDateOnly { get; set; } public DateTime DateTimeValueTargetTimeOnly { get; set; } diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.RunMappingShouldWork_NET6_0.verified.txt b/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.RunMappingShouldWork_NET6_0.verified.txt index 4aa16dc034..d3bf52de8e 100644 --- a/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.RunMappingShouldWork_NET6_0.verified.txt +++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.RunMappingShouldWork_NET6_0.verified.txt @@ -168,6 +168,7 @@ SubIntValue: 2, BaseIntValue: 1 }, + DateTimeValue: 2020-01-03 15:10:05 Utc, DateTimeValueTargetDateOnly: 2020-01-03 15:10:05 Utc, DateTimeValueTargetTimeOnly: 2020-01-03 15:10:05 Utc, ExposePrivateValue: 18 diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.SnapshotGeneratedSource_NET6_0.verified.cs b/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.SnapshotGeneratedSource_NET6_0.verified.cs index 18bc0c942b..c23f6c8f9f 100644 --- a/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.SnapshotGeneratedSource_NET6_0.verified.cs +++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/DeepCloningMapperTest.SnapshotGeneratedSource_NET6_0.verified.cs @@ -95,6 +95,7 @@ public static partial class DeepCloningMapper target.EnumRawValue = src.EnumRawValue; target.EnumStringValue = src.EnumStringValue; target.EnumReverseStringValue = src.EnumReverseStringValue; + target.DateTimeValue = src.DateTimeValue; target.DateTimeValueTargetDateOnly = src.DateTimeValueTargetDateOnly; target.DateTimeValueTargetTimeOnly = src.DateTimeValueTargetTimeOnly; return target; diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.RunMappingShouldWork_NET6_0.verified.txt b/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.RunMappingShouldWork_NET6_0.verified.txt index 93274d630b..1adfb8a39b 100644 --- a/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.RunMappingShouldWork_NET6_0.verified.txt +++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.RunMappingShouldWork_NET6_0.verified.txt @@ -48,6 +48,8 @@ EnumName: Value30, EnumStringValue: 0, EnumReverseStringValue: DtoValue3, + FormattedIntValue: CHF 0.00, + FormattedDateValue: Monday, January 1, 0001, ExposePrivateValue: 16 }, SourceTargetSameObjectType: { @@ -184,5 +186,7 @@ }, DateTimeValueTargetDateOnly: 2020-01-03, DateTimeValueTargetTimeOnly: 3:10 PM, + FormattedIntValue: CHF 10.00, + FormattedDateValue: Friday, January 3, 2020, ExposePrivateValue: 18 } \ No newline at end of file diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.SnapshotGeneratedSource_NET8_0.verified.cs b/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.SnapshotGeneratedSource_NET8_0.verified.cs index 1d345352d6..c56f5b81db 100644 --- a/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.SnapshotGeneratedSource_NET8_0.verified.cs +++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/MapperTest.SnapshotGeneratedSource_NET8_0.verified.cs @@ -1,4 +1,4 @@ -// +// #nullable enable namespace Riok.Mapperly.IntegrationTests.Mapper { @@ -137,6 +137,8 @@ public partial int ParseableInt(string value) target.EnumReverseStringValue = MapToTestEnumDtoByValue(testObject.EnumReverseStringValue); target.DateTimeValueTargetDateOnly = global::System.DateOnly.FromDateTime(testObject.DateTimeValueTargetDateOnly); target.DateTimeValueTargetTimeOnly = global::System.TimeOnly.FromDateTime(testObject.DateTimeValueTargetTimeOnly); + target.FormattedIntValue = testObject.IntValue.ToString("C", _formatDeCh); + target.FormattedDateValue = testObject.DateTimeValue.ToString("D", _formatEnUs); target.SetPrivateValue(DirectInt(testObject.GetPrivateValue())); return target; } @@ -179,38 +181,38 @@ public partial int ParseableInt(string value) target.StringNullableTargetNotNullable = dto.StringNullableTargetNotNullable; target.SourceTargetSameObjectType = dto.SourceTargetSameObjectType; target.MemoryValue = MapToStringArray(dto.MemoryValue.Span); - target.StackValue = new global::System.Collections.Generic.Stack(global::System.Linq.Enumerable.Select(dto.StackValue, x => x.ToString())); - target.QueueValue = new global::System.Collections.Generic.Queue(global::System.Linq.Enumerable.Select(dto.QueueValue, x => x.ToString())); - target.ImmutableArrayValue = global::System.Collections.Immutable.ImmutableArray.ToImmutableArray(global::System.Linq.Enumerable.Select(dto.ImmutableArrayValue, x => x.ToString())); - target.ImmutableListValue = global::System.Collections.Immutable.ImmutableList.ToImmutableList(global::System.Linq.Enumerable.Select(dto.ImmutableListValue, x => x.ToString())); - target.ImmutableHashSetValue = global::System.Collections.Immutable.ImmutableHashSet.ToImmutableHashSet(global::System.Linq.Enumerable.Select(dto.ImmutableHashSetValue, x => x.ToString())); - target.ImmutableQueueValue = global::System.Collections.Immutable.ImmutableQueue.CreateRange(global::System.Linq.Enumerable.Select(dto.ImmutableQueueValue, x => x.ToString())); - target.ImmutableStackValue = global::System.Collections.Immutable.ImmutableStack.CreateRange(global::System.Linq.Enumerable.Select(dto.ImmutableStackValue, x => x.ToString())); - target.ImmutableSortedSetValue = global::System.Collections.Immutable.ImmutableSortedSet.ToImmutableSortedSet(global::System.Linq.Enumerable.Select(dto.ImmutableSortedSetValue, x => x.ToString())); - target.ImmutableDictionaryValue = global::System.Collections.Immutable.ImmutableDictionary.ToImmutableDictionary(dto.ImmutableDictionaryValue, x => x.Key.ToString(), x => x.Value.ToString()); - target.ImmutableSortedDictionaryValue = global::System.Collections.Immutable.ImmutableSortedDictionary.ToImmutableSortedDictionary(dto.ImmutableSortedDictionaryValue, x => x.Key.ToString(), x => x.Value.ToString()); + target.StackValue = new global::System.Collections.Generic.Stack(global::System.Linq.Enumerable.Select(dto.StackValue, x => x.ToString(null, _formatDeCh))); + target.QueueValue = new global::System.Collections.Generic.Queue(global::System.Linq.Enumerable.Select(dto.QueueValue, x => x.ToString(null, _formatDeCh))); + target.ImmutableArrayValue = global::System.Collections.Immutable.ImmutableArray.ToImmutableArray(global::System.Linq.Enumerable.Select(dto.ImmutableArrayValue, x => x.ToString(null, _formatDeCh))); + target.ImmutableListValue = global::System.Collections.Immutable.ImmutableList.ToImmutableList(global::System.Linq.Enumerable.Select(dto.ImmutableListValue, x => x.ToString(null, _formatDeCh))); + target.ImmutableHashSetValue = global::System.Collections.Immutable.ImmutableHashSet.ToImmutableHashSet(global::System.Linq.Enumerable.Select(dto.ImmutableHashSetValue, x => x.ToString(null, _formatDeCh))); + target.ImmutableQueueValue = global::System.Collections.Immutable.ImmutableQueue.CreateRange(global::System.Linq.Enumerable.Select(dto.ImmutableQueueValue, x => x.ToString(null, _formatDeCh))); + target.ImmutableStackValue = global::System.Collections.Immutable.ImmutableStack.CreateRange(global::System.Linq.Enumerable.Select(dto.ImmutableStackValue, x => x.ToString(null, _formatDeCh))); + target.ImmutableSortedSetValue = global::System.Collections.Immutable.ImmutableSortedSet.ToImmutableSortedSet(global::System.Linq.Enumerable.Select(dto.ImmutableSortedSetValue, x => x.ToString(null, _formatDeCh))); + target.ImmutableDictionaryValue = global::System.Collections.Immutable.ImmutableDictionary.ToImmutableDictionary(dto.ImmutableDictionaryValue, x => x.Key.ToString(null, _formatDeCh), x => x.Value.ToString(null, _formatDeCh)); + target.ImmutableSortedDictionaryValue = global::System.Collections.Immutable.ImmutableSortedDictionary.ToImmutableSortedDictionary(dto.ImmutableSortedDictionaryValue, x => x.Key.ToString(null, _formatDeCh), x => x.Value.ToString(null, _formatDeCh)); foreach (var item in dto.ExistingISet) { - target.ExistingISet.Add(item.ToString()); + target.ExistingISet.Add(item.ToString(null, _formatDeCh)); } target.ExistingHashSet.EnsureCapacity(dto.ExistingHashSet.Count + target.ExistingHashSet.Count); foreach (var item1 in dto.ExistingHashSet) { - target.ExistingHashSet.Add(item1.ToString()); + target.ExistingHashSet.Add(item1.ToString(null, _formatDeCh)); } foreach (var item2 in dto.ExistingSortedSet) { - target.ExistingSortedSet.Add(item2.ToString()); + target.ExistingSortedSet.Add(item2.ToString(null, _formatDeCh)); } target.ExistingList.EnsureCapacity(dto.ExistingList.Count + target.ExistingList.Count); foreach (var item3 in dto.ExistingList) { - target.ExistingList.Add(item3.ToString()); + target.ExistingList.Add(item3.ToString(null, _formatDeCh)); } - target.ISet = global::System.Linq.Enumerable.ToHashSet(global::System.Linq.Enumerable.Select(dto.ISet, x => x.ToString())); - target.IReadOnlySet = global::System.Linq.Enumerable.ToHashSet(global::System.Linq.Enumerable.Select(dto.IReadOnlySet, x => x.ToString())); - target.HashSet = global::System.Linq.Enumerable.ToHashSet(global::System.Linq.Enumerable.Select(dto.HashSet, x => x.ToString())); - target.SortedSet = new global::System.Collections.Generic.SortedSet(global::System.Linq.Enumerable.Select(dto.SortedSet, x => x.ToString())); + target.ISet = global::System.Linq.Enumerable.ToHashSet(global::System.Linq.Enumerable.Select(dto.ISet, x => x.ToString(null, _formatDeCh))); + target.IReadOnlySet = global::System.Linq.Enumerable.ToHashSet(global::System.Linq.Enumerable.Select(dto.IReadOnlySet, x => x.ToString(null, _formatDeCh))); + target.HashSet = global::System.Linq.Enumerable.ToHashSet(global::System.Linq.Enumerable.Select(dto.HashSet, x => x.ToString(null, _formatDeCh))); + target.SortedSet = new global::System.Collections.Generic.SortedSet(global::System.Linq.Enumerable.Select(dto.SortedSet, x => x.ToString(null, _formatDeCh))); target.EnumValue = (global::Riok.Mapperly.IntegrationTests.Models.TestEnum)dto.EnumValue; target.FlagsEnumValue = (global::Riok.Mapperly.IntegrationTests.Models.TestFlagsEnum)dto.FlagsEnumValue; target.EnumName = (global::Riok.Mapperly.IntegrationTests.Models.TestEnum)dto.EnumName; @@ -325,7 +327,7 @@ private partial int PrivateDirectInt(int value) public partial (string X, string Y) MapAliasedTuple((int X, int Y) source) { - var target = (X: source.X.ToString(), Y: source.Y.ToString()); + var target = (X: source.X.ToString(null, _formatDeCh), Y: source.Y.ToString(null, _formatDeCh)); return target; } @@ -413,7 +415,7 @@ private string MapToString(global::Riok.Mapperly.IntegrationTests.Models.TestEnu private (string A, string) MapToValueTuple1((int A, int) source) { - var target = (A: source.A.ToString(), source.Item2.ToString()); + var target = (A: source.A.ToString(null, _formatDeCh), source.Item2.ToString(null, _formatDeCh)); return target; } @@ -432,7 +434,7 @@ private string[] MapToStringArray(global::System.ReadOnlySpan source) var target = new string[source.Length]; for (var i = 0; i < source.Length; i++) { - target[i] = source[i].ToString(); + target[i] = source[i].ToString(null, _formatDeCh); } return target; } diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunExtensionMappingShouldWork_NET6_0.verified.txt b/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunExtensionMappingShouldWork_NET6_0.verified.txt index 9e396839a6..17153c34e0 100644 --- a/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunExtensionMappingShouldWork_NET6_0.verified.txt +++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunExtensionMappingShouldWork_NET6_0.verified.txt @@ -43,6 +43,8 @@ EnumName: Value30, EnumStringValue: 0, EnumReverseStringValue: DtoValue3, + FormattedIntValue: , + FormattedDateValue: , ExposePrivateValue: 26 }, SourceTargetSameObjectType: { @@ -179,5 +181,7 @@ }, DateTimeValueTargetDateOnly: 2020-01-03, DateTimeValueTargetTimeOnly: 3:10 PM, + FormattedIntValue: , + FormattedDateValue: , ExposePrivateValue: 28 } \ No newline at end of file diff --git a/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunMappingShouldWork_NET6_0.verified.txt b/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunMappingShouldWork_NET6_0.verified.txt index 0e81ca4261..4b1ab3647a 100644 --- a/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunMappingShouldWork_NET6_0.verified.txt +++ b/test/Riok.Mapperly.IntegrationTests/_snapshots/StaticMapperTest.RunMappingShouldWork_NET6_0.verified.txt @@ -48,6 +48,8 @@ EnumName: Value30, EnumStringValue: 0, EnumReverseStringValue: DtoValue3, + FormattedIntValue: , + FormattedDateValue: , ExposePrivateValue: 26 }, SourceTargetSameObjectType: { @@ -184,5 +186,7 @@ }, DateTimeValueTargetDateOnly: 2020-01-03, DateTimeValueTargetTimeOnly: 3:10 PM, + FormattedIntValue: , + FormattedDateValue: , ExposePrivateValue: 28 } \ No newline at end of file diff --git a/test/Riok.Mapperly.Tests/Mapping/ToStringFormattedTest.cs b/test/Riok.Mapperly.Tests/Mapping/ToStringFormattedTest.cs index fe791fba1f..f6bc712845 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ToStringFormattedTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ToStringFormattedTest.cs @@ -54,7 +54,31 @@ public void RecordMultiplePropertiesToStringWithDifferentFormats() } [Fact] - public void ClassToStringWithoutFormatParameterShouldDiagnostic() + public void ClassToStringWithIFormattable() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [MapProperty("Value", "Value", StringFormat = "C")] + partial B Map(A source);", + """, + "class A { public C Value { get; set; } }", + "class B { public string Value { get; set; } }", + "class C : IFormattable { public string ToString(string? format, IFormatProvider? formatProvider) => format; }" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = source.Value.ToString("C", null); + return target; + """ + ); + } + + [Fact] + public void ClassToStringWithoutIFormattableShouldDiagnostic() { var source = TestSourceBuilder.MapperWithBodyAndTypes( """ @@ -70,7 +94,7 @@ public void ClassToStringWithoutFormatParameterShouldDiagnostic() .Should() .HaveDiagnostic( DiagnosticDescriptors.SourceDoesNotImplementIFormattable, - "The source type C does not implement IFormattable, string format cannot be applied" + "The source type C does not implement IFormattable, string format and format provider cannot be applied" ) .HaveAssertedAllDiagnostics() .HaveSingleMethodBody( @@ -81,4 +105,344 @@ public void ClassToStringWithoutFormatParameterShouldDiagnostic() """ ); } + + [Fact] + public void ClassToStringWithFormatProviderWithoutIFormattableShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [FormatProvider] + private readonly IFormatProvider _formatter = CultureInfo.GetCultureInfo("de-CH"); + + [MapProperty("Value", "Value", FormatProvider = "_formatter")] + partial B Map(A source);", + """, + "class A { public C Value { get; set; } }", + "class B { public string Value { get; set; } }", + "class C {}" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic( + DiagnosticDescriptors.SourceDoesNotImplementIFormattable, + "The source type C does not implement IFormattable, string format and format provider cannot be applied" + ) + .HaveAssertedAllDiagnostics() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = source.Value.ToString(); + return target; + """ + ); + } + + [Fact] + public void ClassWithDefaultFormatProvider() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [FormatProvider(Default = true)] + private readonly IFormatProvider _formatter = CultureInfo.GetCultureInfo("de-CH"); + + partial B Map(A source);", + """, + "class A { public DateTime Value { get; set; } }", + "class B { public string Value { get; set; } }" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = source.Value.ToString(null, _formatter); + return target; + """ + ); + } + + [Fact] + public void ClassWithDefaultFormatProviderAndStringFormat() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [FormatProvider(Default = true)] + private readonly IFormatProvider _formatter = CultureInfo.GetCultureInfo("de-CH"); + + [MapProperty("Value", "Value", StringFormat = "dd.MM.yyyy")] + partial B Map(A source);", + """, + "class A { public DateTime Value { get; set; } }", + "class B { public string Value { get; set; } }" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = source.Value.ToString("dd.MM.yyyy", _formatter); + return target; + """ + ); + } + + [Fact] + public void ClassWithDefaultAndExplicitFormatProviderAndStringFormat() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [FormatProvider(Default = true)] + private readonly IFormatProvider _formatter = CultureInfo.GetCultureInfo("de-CH"); + + [FormatProvider] + private readonly IFormatProvider _formatterEN = CultureInfo.GetCultureInfo("en-US"); + + [MapProperty("Value", "Value", StringFormat = "yyyy-MM-dd", FormatProvider = nameof(_formatterEN))] + partial B Map(A source);", + """, + "class A { public DateTime Value { get; set; } }", + "class B { public string Value { get; set; } }" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = source.Value.ToString("yyyy-MM-dd", _formatterEN); + return target; + """ + ); + } + + [Fact] + public void ClassWithExplicitFormatProvider() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [FormatProvider] + private readonly IFormatProvider _formatterEN = CultureInfo.GetCultureInfo("en-US"); + + [MapProperty("Value", "Value", StringFormat = "yyyy-MM-dd", FormatProvider = nameof(_formatterEN))] + partial B Map(A source);", + """, + "class A { public DateTime Value { get; set; } public DateTime Value2 { get; set; } }", + "class B { public string Value { get; set; } public string Value2 { get; set; } }" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = source.Value.ToString("yyyy-MM-dd", _formatterEN); + target.Value2 = source.Value2.ToString(); + return target; + """ + ); + } + + [Fact] + public void ClassWithDefaultFormatProviderDisabledNullable() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [FormatProvider(Default = true)] + private readonly IFormatProvider _formatter = CultureInfo.GetCultureInfo("de-CH"); + + partial B Map(A source);", + """, + "class A { public DateTime Value { get; set; } }", + "class B { public string Value { get; set; } }" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.DisabledNullable) + .Should() + .HaveSingleMethodBody( + """ + if (source == null) + return default; + var target = new global::B(); + target.Value = source.Value.ToString(null, _formatter); + return target; + """ + ); + } + + [Fact] + public void NullableFormatProvider() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [FormatProvider(Default = true)] + private readonly IFormatProvider? _formatter = CultureInfo.GetCultureInfo("de-CH"); + + [MapProperty("Value", "Value", StringFormat = "dd.MM.yyyy")] + partial B Map(A source);", + """, + "class A { public DateTime Value { get; set; } }", + "class B { public string Value { get; set; } }" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = source.Value.ToString("dd.MM.yyyy", _formatter); + return target; + """ + ); + } + + [Fact] + public void FormatProviderStaticInStaticMapper() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [FormatProvider(Default = true)] + private static readonly IFormatProvider _formatter = CultureInfo.GetCultureInfo("de-CH"); + static partial B Map(A source); + """, + TestSourceBuilderOptions.AsStatic, + "class A { public DateTime Value { get; set; } }", + "class B { public string Value { get; set; } }" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = source.Value.ToString(null, _formatter); + return target; + """ + ); + } + + [Fact] + public void FormatProviderStaticInStaticMethodsMapper() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [FormatProvider(Default = true)] + private static readonly IFormatProvider _formatter = CultureInfo.GetCultureInfo("de-CH"); + static partial B Map(A source); + """, + "class A { public DateTime Value { get; set; } }", + "class B { public string Value { get; set; } }" + ); + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = source.Value.ToString(null, _formatter); + return target; + """ + ); + } + + [Fact] + public void FormatProviderStaticInMapperShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [FormatProvider(Default = true)] + private static readonly IFormatProvider _formatter = CultureInfo.GetCultureInfo("de-CH"); + partial B Map(A source); + """, + "class A { public DateTime Value { get; set; } }", + "class B { public string Value { get; set; } }" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic(DiagnosticDescriptors.InvalidFormatProviderSignature, "The format provider _formatter has an invalid signature") + .HaveAssertedAllDiagnostics(); + } + + [Fact] + public void FormatProviderStaticShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [FormatProvider(Default = true)] + private static readonly IFormatProvider _formatter = CultureInfo.GetCultureInfo("de-CH"); + + [MapProperty("Value", "Value", StringFormat = "dd.MM.yyyy")] + partial B Map(A source);", + """, + "class A { public DateTime Value { get; set; } }", + "class B { public string Value { get; set; } }" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic(DiagnosticDescriptors.InvalidFormatProviderSignature, "The format provider _formatter has an invalid signature") + .HaveAssertedAllDiagnostics() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value = source.Value.ToString("dd.MM.yyyy", null); + return target; + """ + ); + } + + [Fact] + public void UnknownFormatProviderShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [FormatProvider(Default = true)] + private readonly IFormatProvider _formatter = CultureInfo.GetCultureInfo("de-CH"); + + [MapProperty("Value", "Value", FormatProvider = "fooBar")] + partial B Map(A source);", + """, + "class A { public DateTime Value { get; set; } }", + "class B { public string Value { get; set; } }" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic( + DiagnosticDescriptors.FormatProviderNotFound, + "The format provider fooBar could not be found, make sure it is annotated with FormatProviderAttribute" + ) + .HaveAssertedAllDiagnostics(); + } + + [Fact] + public void DuplicatedDefaultFormatProviderShouldDiagnostic() + { + var source = TestSourceBuilder.MapperWithBodyAndTypes( + """ + [FormatProvider(Default = true)] + private readonly IFormatProvider _formatter = CultureInfo.GetCultureInfo("de-CH"); + + [FormatProvider(Default = true)] + private readonly IFormatProvider _formatterDe = CultureInfo.GetCultureInfo("de-DE"); + + partial B Map(A source);", + """, + "class A { public DateTime Value { get; set; } }", + "class B { public string Value { get; set; } }" + ); + TestHelper + .GenerateMapper(source, TestHelperOptions.AllowDiagnostics) + .Should() + .HaveDiagnostic( + DiagnosticDescriptors.MultipleDefaultFormatProviders, + "Multiple default format providers found, only one is allowed" + ) + .HaveAssertedAllDiagnostics(); + } + + // TODO test diagnostics } diff --git a/test/Riok.Mapperly.Tests/TestSourceBuilder.cs b/test/Riok.Mapperly.Tests/TestSourceBuilder.cs index 62579524b3..26e715f305 100644 --- a/test/Riok.Mapperly.Tests/TestSourceBuilder.cs +++ b/test/Riok.Mapperly.Tests/TestSourceBuilder.cs @@ -48,7 +48,7 @@ public static string MapperWithBody([StringSyntax(StringSyntax.CSharp)] string b {{(options.Namespace != null ? $"namespace {options.Namespace};" : string.Empty)}} {{BuildAttribute(options)}} - public partial class {{options.MapperClassName}} + public {{(options.Static ? "static " : "")}}partial class {{options.MapperClassName}} { {{body}} } diff --git a/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs b/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs index 0060d5008d..737294f945 100644 --- a/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs +++ b/test/Riok.Mapperly.Tests/TestSourceBuilderOptions.cs @@ -16,12 +16,14 @@ public record TestSourceBuilderOptions( bool? EnumMappingIgnoreCase = null, IgnoreObsoleteMembersStrategy? IgnoreObsoleteMembersStrategy = null, RequiredMappingStrategy? RequiredMappingStrategy = null, - MemberVisibility? IncludedMembers = null + MemberVisibility? IncludedMembers = null, + bool Static = false ) { public const string DefaultMapperClassName = "Mapper"; public static readonly TestSourceBuilderOptions Default = new(); + public static readonly TestSourceBuilderOptions AsStatic = new(Static: true); public static readonly TestSourceBuilderOptions WithDeepCloning = new(UseDeepCloning: true); public static readonly TestSourceBuilderOptions WithReferenceHandling = new(UseReferenceHandling: true);