From 02e530156fb057b0e6728faacd1ebcfe4e0d633f Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Wed, 30 Jun 2021 19:16:28 +0100 Subject: [PATCH] Add support for c# records. --- Directory.Build.props | 2 +- src/MapTo/ClassMappingContext.cs | 27 + .../CompilerServices/NullableAttributes.cs | 178 +++++++ src/MapTo/DiagnosticsFactory.cs | 7 +- src/MapTo/Extensions/RoslynExtensions.cs | 41 +- src/MapTo/MapToGenerator.cs | 22 +- src/MapTo/MappingContext.cs | 477 +++++++++--------- src/MapTo/RecordMappingContext.cs | 28 + src/MapTo/Sources/MapClassSource.cs | 2 +- .../Sources/MapPropertyAttributeSource.cs | 2 +- src/MapTo/Sources/MapRecordSource.cs | 180 +++++++ .../MapTypeConverterAttributeSource.cs | 2 +- src/MapTo/Sources/SourceBuilder.cs | 18 + .../TestAnalyzerConfigOptions.cs | 2 +- test/MapTo.Tests/MapPropertyTests.cs | 2 +- test/MapTo.Tests/MapTypeConverterTests.cs | 4 +- test/MapTo.Tests/MappedClassesTests.cs | 280 +++++++--- 17 files changed, 955 insertions(+), 319 deletions(-) create mode 100644 src/MapTo/ClassMappingContext.cs create mode 100644 src/MapTo/CompilerServices/NullableAttributes.cs create mode 100644 src/MapTo/RecordMappingContext.cs create mode 100644 src/MapTo/Sources/MapRecordSource.cs diff --git a/Directory.Build.props b/Directory.Build.props index c8314ba..b3e9068 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ - 3.4.216 + 3.4.22 all diff --git a/src/MapTo/ClassMappingContext.cs b/src/MapTo/ClassMappingContext.cs new file mode 100644 index 0000000..b248402 --- /dev/null +++ b/src/MapTo/ClassMappingContext.cs @@ -0,0 +1,27 @@ +using System.Collections.Immutable; +using System.Linq; +using MapTo.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace MapTo +{ + internal class ClassMappingContext : MappingContext + { + internal ClassMappingContext(Compilation compilation, SourceGenerationOptions sourceGenerationOptions, TypeDeclarationSyntax typeSyntax) + : base(compilation, sourceGenerationOptions, typeSyntax) { } + + protected override ImmutableArray GetMappedProperties(ITypeSymbol typeSymbol, ITypeSymbol sourceTypeSymbol, bool isInheritFromMappedBaseClass) + { + var sourceProperties = sourceTypeSymbol.GetAllMembers().OfType().ToArray(); + + return typeSymbol + .GetAllMembers(!isInheritFromMappedBaseClass) + .OfType() + .Where(p => !p.HasAttribute(IgnorePropertyAttributeTypeSymbol)) + .Select(property => MapProperty(sourceTypeSymbol, sourceProperties, property)) + .Where(mappedProperty => mappedProperty is not null) + .ToImmutableArray()!; + } + } +} \ No newline at end of file diff --git a/src/MapTo/CompilerServices/NullableAttributes.cs b/src/MapTo/CompilerServices/NullableAttributes.cs new file mode 100644 index 0000000..6886ade --- /dev/null +++ b/src/MapTo/CompilerServices/NullableAttributes.cs @@ -0,0 +1,178 @@ +// ReSharper disable CheckNamespace +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NETSTANDARD2_0 +namespace System.Diagnostics.CodeAnalysis +{ +// These attributes already shipped with .NET Core 3.1 in System.Runtime +#if !NETCOREAPP3_0 && !NETCOREAPP3_1 && !NETSTANDARD2_1 + /// Specifies that null is allowed as an input even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property)] + internal sealed class AllowNullAttribute : Attribute { } + + /// Specifies that null is disallowed as an input even if the corresponding type allows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property)] + internal sealed class DisallowNullAttribute : Attribute { } + + /// Specifies that an output may be null even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue)] + internal sealed class MaybeNullAttribute : Attribute { } + + /// + /// Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input + /// argument was not null when the call returns. + /// + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue)] + internal sealed class NotNullAttribute : Attribute { } + + /// + /// Specifies that when a method returns , the parameter may be null even if the + /// corresponding type disallows it. + /// + [AttributeUsage(AttributeTargets.Parameter)] + internal sealed class MaybeNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter may be null. + /// + public MaybeNullWhenAttribute(bool returnValue) + { + ReturnValue = returnValue; + } + + /// Gets the return value condition. + public bool ReturnValue { get; } + } + + /// + /// Specifies that when a method returns , the parameter will not be null even if the + /// corresponding type allows it. + /// + [AttributeUsage(AttributeTargets.Parameter)] + internal sealed class NotNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + public NotNullWhenAttribute(bool returnValue) + { + ReturnValue = returnValue; + } + + /// Gets the return value condition. + public bool ReturnValue { get; } + } + + /// Specifies that the output will be non-null if the named parameter is non-null. + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true)] + internal sealed class NotNullIfNotNullAttribute : Attribute + { + /// Initializes the attribute with the associated parameter name. + /// + /// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null. + /// + public NotNullIfNotNullAttribute(string parameterName) + { + ParameterName = parameterName; + } + + /// Gets the associated parameter name. + public string ParameterName { get; } + } + + /// Applied to a method that will never return under any circumstance. + [AttributeUsage(AttributeTargets.Method, Inherited = false)] + internal sealed class DoesNotReturnAttribute : Attribute { } + + /// Specifies that the method will not return if the associated Boolean parameter is passed the specified value. + [AttributeUsage(AttributeTargets.Parameter)] + internal sealed class DoesNotReturnIfAttribute : Attribute + { + /// Initializes the attribute with the specified parameter value. + /// + /// The condition parameter value. Code after the method will be considered unreachable by diagnostics if the argument to + /// the associated parameter matches this value. + /// + public DoesNotReturnIfAttribute(bool parameterValue) + { + ParameterValue = parameterValue; + } + + /// Gets the condition parameter value. + public bool ParameterValue { get; } + } +#endif + + /// + /// Specifies that the method or property will ensure that the listed field and property members have not-null + /// values. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] + internal sealed class MemberNotNullAttribute : Attribute + { + /// Initializes the attribute with a field or property member. + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullAttribute(string member) + { + Members = new[] { member }; + } + + /// Initializes the attribute with the list of field and property members. + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullAttribute(params string[] members) + { + Members = members; + } + + /// Gets field or property member names. + public string[] Members { get; } + } + + /// + /// Specifies that the method or property will ensure that the listed field and property members have not-null + /// values when returning with the specified return value condition. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] + internal sealed class MemberNotNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition and a field or property member. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, string member) + { + ReturnValue = returnValue; + Members = new[] { member }; + } + + /// Initializes the attribute with the specified return value condition and list of field and property members. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, params string[] members) + { + ReturnValue = returnValue; + Members = members; + } + + /// Gets field or property member names. + public string[] Members { get; } + + /// Gets the return value condition. + public bool ReturnValue { get; } + } +} +#endif \ No newline at end of file diff --git a/src/MapTo/DiagnosticsFactory.cs b/src/MapTo/DiagnosticsFactory.cs index d5f73a3..3b29a8a 100644 --- a/src/MapTo/DiagnosticsFactory.cs +++ b/src/MapTo/DiagnosticsFactory.cs @@ -1,4 +1,5 @@ using System.Linq; +using MapTo.Extensions; using MapTo.Sources; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -23,11 +24,11 @@ namespace MapTo internal static Diagnostic NoMatchingPropertyFoundError(Location location, INamedTypeSymbol classType, INamedTypeSymbol sourceType) => Create($"{ErrorId}030", location, $"No matching properties found between '{classType.ToDisplayString()}' and '{sourceType.ToDisplayString()}' types."); - internal static Diagnostic NoMatchingPropertyTypeFoundError(IPropertySymbol property) => + internal static Diagnostic NoMatchingPropertyTypeFoundError(ISymbol property) => Create($"{ErrorId}031", property.Locations.FirstOrDefault(), $"Cannot create a map for '{property.ToDisplayString()}' property because source and destination types are not implicitly convertible. Consider using '{MapTypeConverterAttributeSource.FullyQualifiedName}' to provide a type converter or ignore the property using '{IgnorePropertyAttributeSource.FullyQualifiedName}'."); - internal static Diagnostic InvalidTypeConverterGenericTypesError(IPropertySymbol property, IPropertySymbol sourceProperty) => - Create($"{ErrorId}032", property.Locations.FirstOrDefault(), $"Cannot map '{property.ToDisplayString()}' property because the annotated converter does not implement '{RootNamespace}.{ITypeConverterSource.InterfaceName}<{sourceProperty.Type.ToDisplayString()}, {property.Type.ToDisplayString()}>'."); + internal static Diagnostic InvalidTypeConverterGenericTypesError(ISymbol property, IPropertySymbol sourceProperty) => + Create($"{ErrorId}032", property.Locations.FirstOrDefault(), $"Cannot map '{property.ToDisplayString()}' property because the annotated converter does not implement '{RootNamespace}.{ITypeConverterSource.InterfaceName}<{sourceProperty.Type.ToDisplayString()}, {property.GetTypeSymbol()?.ToDisplayString()}>'."); internal static Diagnostic ConfigurationParseError(string error) => Create($"{ErrorId}040", Location.None, error); diff --git a/src/MapTo/Extensions/RoslynExtensions.cs b/src/MapTo/Extensions/RoslynExtensions.cs index 2c5a847..234b60d 100644 --- a/src/MapTo/Extensions/RoslynExtensions.cs +++ b/src/MapTo/Extensions/RoslynExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -48,15 +49,36 @@ namespace MapTo.Extensions public static AttributeData? GetAttribute(this ISymbol symbol, ITypeSymbol attributeSymbol) => symbol.GetAttributes(attributeSymbol).FirstOrDefault(); - public static string? GetNamespace(this TypeDeclarationSyntax typeDeclarationSyntax) => - typeDeclarationSyntax.Ancestors() - .OfType() - .FirstOrDefault() - ?.Name - .ToString(); + public static string? GetNamespace(this TypeDeclarationSyntax typeDeclarationSyntax) => typeDeclarationSyntax + .Ancestors() + .OfType() + .FirstOrDefault() + ?.Name + .ToString(); - public static bool HasCompatibleTypes(this Compilation compilation, IPropertySymbol sourceProperty, IPropertySymbol destinationProperty) => - SymbolEqualityComparer.Default.Equals(destinationProperty.Type, sourceProperty.Type) || compilation.HasImplicitConversion(sourceProperty.Type, destinationProperty.Type); + public static bool HasCompatibleTypes(this Compilation compilation, ISymbol source, ISymbol destination) => + source.TryGetTypeSymbol(out var sourceType) && destination.TryGetTypeSymbol(out var destinationType) && + (SymbolEqualityComparer.Default.Equals(destinationType, sourceType) || compilation.HasImplicitConversion(sourceType, destinationType)); + + public static bool TryGetTypeSymbol(this ISymbol symbol, [NotNullWhen(true)] out ITypeSymbol? typeSymbol) + { + switch (symbol) + { + case IPropertySymbol propertySymbol: + typeSymbol = propertySymbol.Type; + return true; + + case IParameterSymbol parameterSymbol: + typeSymbol = parameterSymbol.Type; + return true; + + default: + typeSymbol = null; + return false; + } + } + + public static ITypeSymbol? GetTypeSymbol(this ISymbol symbol) => symbol.TryGetTypeSymbol(out var typeSymbol) ? typeSymbol : null; public static IPropertySymbol? FindProperty(this IEnumerable properties, IPropertySymbol targetProperty) { @@ -91,5 +113,8 @@ namespace MapTo.Extensions SpecialType.System_Double or SpecialType.System_Char or SpecialType.System_Object; + + public static SyntaxNode? GetSyntaxNode(this ISymbol symbol) => + symbol.Locations.FirstOrDefault() is { } location ? location.SourceTree?.GetRoot().FindNode(location.SourceSpan) : null; } } \ No newline at end of file diff --git a/src/MapTo/MapToGenerator.cs b/src/MapTo/MapToGenerator.cs index 6a39f62..1e5f581 100644 --- a/src/MapTo/MapToGenerator.cs +++ b/src/MapTo/MapToGenerator.cs @@ -47,18 +47,26 @@ namespace MapTo } } - private static void AddGeneratedMappingsClasses(GeneratorExecutionContext context, Compilation compilation, IEnumerable candidateClasses, SourceGenerationOptions options) + private static void AddGeneratedMappingsClasses(GeneratorExecutionContext context, Compilation compilation, IEnumerable candidateTypes, SourceGenerationOptions options) { - foreach (var classSyntax in candidateClasses) + foreach (var typeDeclarationSyntax in candidateTypes) { - var mappingContext = new MappingContext(compilation, options, classSyntax); + var mappingContext = MappingContext.Create(compilation, options, typeDeclarationSyntax); mappingContext.Diagnostics.ForEach(context.ReportDiagnostic); - - if (mappingContext.Model is not null) + + if (mappingContext.Model is null) { - var (source, hintName) = MapClassSource.Generate(mappingContext.Model); - context.AddSource(hintName, source); + continue; } + + var (source, hintName) = typeDeclarationSyntax switch + { + ClassDeclarationSyntax => MapClassSource.Generate(mappingContext.Model), + RecordDeclarationSyntax => MapRecordSource.Generate(mappingContext.Model), + _ => throw new ArgumentOutOfRangeException() + }; + + context.AddSource(hintName, source); } } } diff --git a/src/MapTo/MappingContext.cs b/src/MapTo/MappingContext.cs index ebd64ea..f5b193d 100644 --- a/src/MapTo/MappingContext.cs +++ b/src/MapTo/MappingContext.cs @@ -1,101 +1,301 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using MapTo.Extensions; using MapTo.Sources; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace MapTo { - internal class MappingContext + internal abstract class MappingContext { - private readonly TypeDeclarationSyntax _typeSyntax; - private readonly Compilation _compilation; - private readonly List _diagnostics; - private readonly INamedTypeSymbol _ignorePropertyAttributeTypeSymbol; - private readonly INamedTypeSymbol _mapFromAttributeTypeSymbol; - private readonly INamedTypeSymbol _mapPropertyAttributeTypeSymbol; - private readonly INamedTypeSymbol _mapTypeConverterAttributeTypeSymbol; - private readonly INamedTypeSymbol _mappingContextTypeSymbol; - private readonly SourceGenerationOptions _sourceGenerationOptions; - private readonly INamedTypeSymbol _typeConverterInterfaceTypeSymbol; - private readonly List _usings; - - internal MappingContext(Compilation compilation, SourceGenerationOptions sourceGenerationOptions, TypeDeclarationSyntax typeSyntax) + protected MappingContext(Compilation compilation, SourceGenerationOptions sourceGenerationOptions, TypeDeclarationSyntax typeSyntax) { - _diagnostics = new List(); - _usings = new List { "System", Constants.RootNamespace }; - _sourceGenerationOptions = sourceGenerationOptions; - _typeSyntax = typeSyntax; - _compilation = compilation; + Diagnostics = ImmutableArray.Empty; + Usings = ImmutableArray.Create("System", Constants.RootNamespace); + SourceGenerationOptions = sourceGenerationOptions; + TypeSyntax = typeSyntax; + Compilation = compilation; - _ignorePropertyAttributeTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(IgnorePropertyAttributeSource.FullyQualifiedName); - _mapTypeConverterAttributeTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(MapTypeConverterAttributeSource.FullyQualifiedName); - _typeConverterInterfaceTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(ITypeConverterSource.FullyQualifiedName); - _mapPropertyAttributeTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(MapPropertyAttributeSource.FullyQualifiedName); - _mapFromAttributeTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(MapFromAttributeSource.FullyQualifiedName); - _mappingContextTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(MappingContextSource.FullyQualifiedName); + IgnorePropertyAttributeTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(IgnorePropertyAttributeSource.FullyQualifiedName); + MapTypeConverterAttributeTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(MapTypeConverterAttributeSource.FullyQualifiedName); + TypeConverterInterfaceTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(ITypeConverterSource.FullyQualifiedName); + MapPropertyAttributeTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(MapPropertyAttributeSource.FullyQualifiedName); + MapFromAttributeTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(MapFromAttributeSource.FullyQualifiedName); + MappingContextTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(MappingContextSource.FullyQualifiedName); AddUsingIfRequired(sourceGenerationOptions.SupportNullableStaticAnalysis, "System.Diagnostics.CodeAnalysis"); - - Initialize(); } + public ImmutableArray Diagnostics { get; private set; } + public MappingModel? Model { get; private set; } - public IEnumerable Diagnostics => _diagnostics; + protected Compilation Compilation { get; } - private void Initialize() + protected INamedTypeSymbol IgnorePropertyAttributeTypeSymbol { get; } + + protected INamedTypeSymbol MapFromAttributeTypeSymbol { get; } + + protected INamedTypeSymbol MappingContextTypeSymbol { get; } + + protected INamedTypeSymbol MapPropertyAttributeTypeSymbol { get; } + + protected INamedTypeSymbol MapTypeConverterAttributeTypeSymbol { get; } + + protected SourceGenerationOptions SourceGenerationOptions { get; } + + protected INamedTypeSymbol TypeConverterInterfaceTypeSymbol { get; } + + protected TypeDeclarationSyntax TypeSyntax { get; } + + protected ImmutableArray Usings { get; private set; } + + public static MappingContext Create(Compilation compilation, SourceGenerationOptions sourceGenerationOptions, TypeDeclarationSyntax typeSyntax) { - var semanticModel = _compilation.GetSemanticModel(_typeSyntax.SyntaxTree); - if (ModelExtensions.GetDeclaredSymbol(semanticModel, _typeSyntax) is not INamedTypeSymbol classTypeSymbol) + MappingContext context = typeSyntax switch { - _diagnostics.Add(DiagnosticsFactory.TypeNotFoundError(_typeSyntax.GetLocation(), _typeSyntax.Identifier.ValueText)); - return; + ClassDeclarationSyntax => new ClassMappingContext(compilation, sourceGenerationOptions, typeSyntax), + RecordDeclarationSyntax => new RecordMappingContext(compilation, sourceGenerationOptions, typeSyntax), + _ => throw new ArgumentOutOfRangeException() + }; + + context.Model = context.CreateMappingModel(); + + return context; + } + + protected void AddDiagnostic(Diagnostic diagnostic) + { + Diagnostics = Diagnostics.Add(diagnostic); + } + + protected void AddUsingIfRequired(ISymbol? namedTypeSymbol) => + AddUsingIfRequired(namedTypeSymbol?.ContainingNamespace.IsGlobalNamespace == false, namedTypeSymbol?.ContainingNamespace.ToDisplayString()); + + protected void AddUsingIfRequired(bool condition, string? ns) + { + if (condition && ns is not null && ns != TypeSyntax.GetNamespace() && !Usings.Contains(ns)) + { + Usings = Usings.Add(ns); + } + } + + protected IPropertySymbol? FindSourceProperty(IEnumerable sourceProperties, ISymbol property) + { + var propertyName = property + .GetAttribute(MapPropertyAttributeTypeSymbol) + ?.NamedArguments + .SingleOrDefault(a => a.Key == MapPropertyAttributeSource.SourcePropertyNamePropertyName) + .Value.Value as string ?? property.Name; + + return sourceProperties.SingleOrDefault(p => p.Name == propertyName); + } + + protected abstract ImmutableArray GetMappedProperties(ITypeSymbol typeSymbol, ITypeSymbol sourceTypeSymbol, bool isInheritFromMappedBaseClass); + + protected INamedTypeSymbol? GetSourceTypeSymbol(TypeDeclarationSyntax typeDeclarationSyntax, SemanticModel? semanticModel = null) => + GetSourceTypeSymbol(typeDeclarationSyntax.GetAttribute(MapFromAttributeSource.AttributeName), semanticModel); + + protected INamedTypeSymbol? GetSourceTypeSymbol(SyntaxNode? attributeSyntax, SemanticModel? semanticModel = null) + { + if (attributeSyntax is null) + { + return null; } - var sourceTypeSymbol = GetSourceTypeSymbol(_typeSyntax, semanticModel); + semanticModel ??= Compilation.GetSemanticModel(attributeSyntax.SyntaxTree); + var sourceTypeExpressionSyntax = attributeSyntax + .DescendantNodes() + .OfType() + .SingleOrDefault(); + + return sourceTypeExpressionSyntax is not null ? semanticModel.GetTypeInfo(sourceTypeExpressionSyntax.Type).Type as INamedTypeSymbol : null; + } + + protected bool IsTypeInheritFromMappedBaseClass(SemanticModel semanticModel) + { + return TypeSyntax.BaseList is not null && TypeSyntax.BaseList.Types + .Select(t => semanticModel.GetTypeInfo(t.Type).Type) + .Any(t => t?.GetAttribute(MapFromAttributeTypeSymbol) != null); + } + + protected virtual MappedProperty? MapProperty(ISymbol sourceTypeSymbol, IReadOnlyCollection sourceProperties, ISymbol property) + { + var sourceProperty = FindSourceProperty(sourceProperties, property); + if (sourceProperty is null || !property.TryGetTypeSymbol(out var propertyType)) + { + return null; + } + + string? converterFullyQualifiedName = null; + var converterParameters = ImmutableArray.Empty; + ITypeSymbol? mappedSourcePropertyType = null; + ITypeSymbol? enumerableTypeArgumentType = null; + + if (!Compilation.HasCompatibleTypes(sourceProperty, property)) + { + if (!TryGetMapTypeConverter(property, sourceProperty, out converterFullyQualifiedName, out converterParameters) && + !TryGetNestedObjectMappings(property, out mappedSourcePropertyType, out enumerableTypeArgumentType)) + { + return null; + } + } + + AddUsingIfRequired(propertyType); + AddUsingIfRequired(sourceTypeSymbol); + AddUsingIfRequired(enumerableTypeArgumentType); + AddUsingIfRequired(mappedSourcePropertyType); + + return new MappedProperty( + property.Name, + propertyType.Name, + converterFullyQualifiedName, + converterParameters.ToImmutableArray(), + sourceProperty.Name, + mappedSourcePropertyType?.Name, + enumerableTypeArgumentType?.Name); + } + + protected bool TryGetMapTypeConverter(ISymbol property, IPropertySymbol sourceProperty, out string? converterFullyQualifiedName, + out ImmutableArray converterParameters) + { + converterFullyQualifiedName = null; + converterParameters = ImmutableArray.Empty; + + if (!Diagnostics.IsEmpty()) + { + return false; + } + + var typeConverterAttribute = property.GetAttribute(MapTypeConverterAttributeTypeSymbol); + if (!(typeConverterAttribute?.ConstructorArguments.First().Value is INamedTypeSymbol converterTypeSymbol)) + { + return false; + } + + var baseInterface = GetTypeConverterBaseInterface(converterTypeSymbol, property, sourceProperty); + if (baseInterface is null) + { + AddDiagnostic(DiagnosticsFactory.InvalidTypeConverterGenericTypesError(property, sourceProperty)); + return false; + } + + converterFullyQualifiedName = converterTypeSymbol.ToDisplayString(); + converterParameters = GetTypeConverterParameters(typeConverterAttribute); + return true; + } + + protected bool TryGetNestedObjectMappings(ISymbol property, out ITypeSymbol? mappedSourcePropertyType, out ITypeSymbol? enumerableTypeArgument) + { + mappedSourcePropertyType = null; + enumerableTypeArgument = null; + + if (!Diagnostics.IsEmpty()) + { + return false; + } + + if (!property.TryGetTypeSymbol(out var propertyType)) + { + AddDiagnostic(DiagnosticsFactory.NoMatchingPropertyTypeFoundError(property)); + return false; + } + + var mapFromAttribute = propertyType.GetAttribute(MapFromAttributeTypeSymbol); + if (mapFromAttribute is null && + propertyType is INamedTypeSymbol namedTypeSymbol && + !propertyType.IsPrimitiveType() && + (Compilation.IsGenericEnumerable(propertyType) || propertyType.AllInterfaces.Any(i => Compilation.IsGenericEnumerable(i)))) + { + enumerableTypeArgument = namedTypeSymbol.TypeArguments.First(); + mapFromAttribute = enumerableTypeArgument.GetAttribute(MapFromAttributeTypeSymbol); + } + + mappedSourcePropertyType = mapFromAttribute?.ConstructorArguments.First().Value as INamedTypeSymbol; + + if (mappedSourcePropertyType is null && enumerableTypeArgument is null) + { + AddDiagnostic(DiagnosticsFactory.NoMatchingPropertyTypeFoundError(property)); + } + + return Diagnostics.IsEmpty(); + } + + private static ImmutableArray GetTypeConverterParameters(AttributeData typeConverterAttribute) + { + var converterParameter = typeConverterAttribute.ConstructorArguments.Skip(1).FirstOrDefault(); + return converterParameter.IsNull + ? ImmutableArray.Empty + : converterParameter.Values.Where(v => v.Value is not null).Select(v => v.Value!.ToSourceCodeString()).ToImmutableArray(); + } + + private MappingModel? CreateMappingModel() + { + var semanticModel = Compilation.GetSemanticModel(TypeSyntax.SyntaxTree); + if (semanticModel.GetDeclaredSymbol(TypeSyntax) is not INamedTypeSymbol typeSymbol) + { + AddDiagnostic(DiagnosticsFactory.TypeNotFoundError(TypeSyntax.GetLocation(), TypeSyntax.Identifier.ValueText)); + return null; + } + + var sourceTypeSymbol = GetSourceTypeSymbol(TypeSyntax, semanticModel); if (sourceTypeSymbol is null) { - _diagnostics.Add(DiagnosticsFactory.MapFromAttributeNotFoundError(_typeSyntax.GetLocation())); - return; + AddDiagnostic(DiagnosticsFactory.MapFromAttributeNotFoundError(TypeSyntax.GetLocation())); + return null; } - var typeIdentifierName = _typeSyntax.GetIdentifierName(); + var typeIdentifierName = TypeSyntax.GetIdentifierName(); var sourceTypeIdentifierName = sourceTypeSymbol.Name; var isTypeInheritFromMappedBaseClass = IsTypeInheritFromMappedBaseClass(semanticModel); var shouldGenerateSecondaryConstructor = ShouldGenerateSecondaryConstructor(semanticModel, sourceTypeSymbol); - var mappedProperties = GetMappedProperties(classTypeSymbol, sourceTypeSymbol, isTypeInheritFromMappedBaseClass); + var mappedProperties = GetMappedProperties(typeSymbol, sourceTypeSymbol, isTypeInheritFromMappedBaseClass); if (!mappedProperties.Any()) { - _diagnostics.Add(DiagnosticsFactory.NoMatchingPropertyFoundError(_typeSyntax.GetLocation(), classTypeSymbol, sourceTypeSymbol)); - return; + AddDiagnostic(DiagnosticsFactory.NoMatchingPropertyFoundError(TypeSyntax.GetLocation(), typeSymbol, sourceTypeSymbol)); + return null; } AddUsingIfRequired(sourceTypeSymbol); AddUsingIfRequired(mappedProperties.Any(p => p.IsEnumerable), "System.Linq"); - Model = new MappingModel( - _sourceGenerationOptions, - _typeSyntax.GetNamespace(), - _typeSyntax.Modifiers, - _typeSyntax.Keyword.Text, + return new MappingModel( + SourceGenerationOptions, + TypeSyntax.GetNamespace(), + TypeSyntax.Modifiers, + TypeSyntax.Keyword.Text, typeIdentifierName, sourceTypeSymbol.ContainingNamespace.ToString(), sourceTypeIdentifierName, sourceTypeSymbol.ToString(), - mappedProperties.ToImmutableArray(), + mappedProperties, isTypeInheritFromMappedBaseClass, - _usings.ToImmutableArray(), + Usings, shouldGenerateSecondaryConstructor); } + private INamedTypeSymbol? GetTypeConverterBaseInterface(ITypeSymbol converterTypeSymbol, ISymbol property, IPropertySymbol sourceProperty) + { + if (!property.TryGetTypeSymbol(out var propertyType)) + { + return null; + } + + return converterTypeSymbol.AllInterfaces + .SingleOrDefault(i => + i.TypeArguments.Length == 2 && + SymbolEqualityComparer.Default.Equals(i.ConstructedFrom, TypeConverterInterfaceTypeSymbol) && + SymbolEqualityComparer.Default.Equals(sourceProperty.Type, i.TypeArguments[0]) && + SymbolEqualityComparer.Default.Equals(propertyType, i.TypeArguments[1])); + } + private bool ShouldGenerateSecondaryConstructor(SemanticModel semanticModel, ISymbol sourceTypeSymbol) { - var constructorSyntax = _typeSyntax.DescendantNodes() + var constructorSyntax = TypeSyntax.DescendantNodes() .OfType() .SingleOrDefault(c => c.ParameterList.Parameters.Count == 1 && @@ -108,186 +308,13 @@ namespace MapTo } if (constructorSyntax.Initializer?.ArgumentList.Arguments is not { Count: 2 } arguments || - !SymbolEqualityComparer.Default.Equals(semanticModel.GetTypeInfo(arguments[0].Expression).ConvertedType, _mappingContextTypeSymbol) || + !SymbolEqualityComparer.Default.Equals(semanticModel.GetTypeInfo(arguments[0].Expression).ConvertedType, MappingContextTypeSymbol) || !SymbolEqualityComparer.Default.Equals(semanticModel.GetTypeInfo(arguments[1].Expression).ConvertedType, sourceTypeSymbol)) { - _diagnostics.Add(DiagnosticsFactory.MissingConstructorArgument(constructorSyntax)); + AddDiagnostic(DiagnosticsFactory.MissingConstructorArgument(constructorSyntax)); } return false; } - - private bool IsTypeInheritFromMappedBaseClass(SemanticModel semanticModel) - { - return _typeSyntax.BaseList is not null && _typeSyntax.BaseList.Types - .Select(t => ModelExtensions.GetTypeInfo(semanticModel, t.Type).Type) - .Any(t => t?.GetAttribute(_mapFromAttributeTypeSymbol) != null); - } - - private ImmutableArray GetMappedProperties(ITypeSymbol typeSymbol, ITypeSymbol sourceTypeSymbol, bool isClassInheritFromMappedBaseClass) - { - var mappedProperties = new List(); - var sourceProperties = sourceTypeSymbol.GetAllMembers().OfType().ToArray(); - var classProperties = typeSymbol.GetAllMembers(!isClassInheritFromMappedBaseClass) - .OfType() - .Where(p => !p.HasAttribute(_ignorePropertyAttributeTypeSymbol)); - - foreach (var property in classProperties) - { - var sourceProperty = FindSourceProperty(sourceProperties, property); - if (sourceProperty is null) - { - continue; - } - - string? converterFullyQualifiedName = null; - var converterParameters = ImmutableArray.Empty; - ITypeSymbol? mappedSourcePropertyType = null; - ITypeSymbol? enumerableTypeArgumentType = null; - - if (!_compilation.HasCompatibleTypes(sourceProperty, property)) - { - if (!TryGetMapTypeConverter(property, sourceProperty, out converterFullyQualifiedName, out converterParameters) && - !TryGetNestedObjectMappings(property, out mappedSourcePropertyType, out enumerableTypeArgumentType)) - { - continue; - } - } - - AddUsingIfRequired(property.Type); - AddUsingIfRequired(sourceTypeSymbol); - AddUsingIfRequired(enumerableTypeArgumentType); - AddUsingIfRequired(mappedSourcePropertyType); - - mappedProperties.Add( - new MappedProperty( - property.Name, - property.Type.Name, - converterFullyQualifiedName, - converterParameters.ToImmutableArray(), - sourceProperty.Name, - mappedSourcePropertyType?.Name, - enumerableTypeArgumentType?.Name)); - } - - return mappedProperties.ToImmutableArray(); - } - - private bool TryGetNestedObjectMappings(IPropertySymbol property, out ITypeSymbol? mappedSourcePropertyType, out ITypeSymbol? enumerableTypeArgument) - { - mappedSourcePropertyType = null; - enumerableTypeArgument = null; - - if (!_diagnostics.IsEmpty()) - { - return false; - } - - var mapFromAttribute = property.Type.GetAttribute(_mapFromAttributeTypeSymbol); - if (mapFromAttribute is null && - property.Type is INamedTypeSymbol namedTypeSymbol && - !property.Type.IsPrimitiveType() && - (_compilation.IsGenericEnumerable(property.Type) || property.Type.AllInterfaces.Any(i => _compilation.IsGenericEnumerable(i)))) - { - enumerableTypeArgument = namedTypeSymbol.TypeArguments.First(); - mapFromAttribute = enumerableTypeArgument.GetAttribute(_mapFromAttributeTypeSymbol); - } - - mappedSourcePropertyType = mapFromAttribute?.ConstructorArguments.First().Value as INamedTypeSymbol; - - if (mappedSourcePropertyType is null && enumerableTypeArgument is null) - { - _diagnostics.Add(DiagnosticsFactory.NoMatchingPropertyTypeFoundError(property)); - } - - return _diagnostics.IsEmpty(); - } - - private void AddUsingIfRequired(ISymbol? namedTypeSymbol) => - AddUsingIfRequired(namedTypeSymbol?.ContainingNamespace.IsGlobalNamespace == false, namedTypeSymbol?.ContainingNamespace.ToDisplayString()); - - private void AddUsingIfRequired(bool condition, string? ns) - { - if (condition && ns is not null && ns != _typeSyntax.GetNamespace() && !_usings.Contains(ns)) - { - _usings.Add(ns); - } - } - - private bool TryGetMapTypeConverter(IPropertySymbol property, IPropertySymbol sourceProperty, out string? converterFullyQualifiedName, out ImmutableArray converterParameters) - { - converterFullyQualifiedName = null; - converterParameters = ImmutableArray.Empty; - - if (!_diagnostics.IsEmpty()) - { - return false; - } - - var typeConverterAttribute = property.GetAttribute(_mapTypeConverterAttributeTypeSymbol); - if (!(typeConverterAttribute?.ConstructorArguments.First().Value is INamedTypeSymbol converterTypeSymbol)) - { - return false; - } - - var baseInterface = GetTypeConverterBaseInterface(converterTypeSymbol, property, sourceProperty); - if (baseInterface is null) - { - _diagnostics.Add(DiagnosticsFactory.InvalidTypeConverterGenericTypesError(property, sourceProperty)); - return false; - } - - converterFullyQualifiedName = converterTypeSymbol.ToDisplayString(); - converterParameters = GetTypeConverterParameters(typeConverterAttribute); - return true; - } - - private IPropertySymbol? FindSourceProperty(IEnumerable sourceProperties, IPropertySymbol property) - { - var propertyName = property - .GetAttribute(_mapPropertyAttributeTypeSymbol) - ?.NamedArguments - .SingleOrDefault(a => a.Key == MapPropertyAttributeSource.SourcePropertyNamePropertyName) - .Value.Value as string ?? property.Name; - - return sourceProperties.SingleOrDefault(p => p.Name == propertyName); - } - - private INamedTypeSymbol? GetTypeConverterBaseInterface(ITypeSymbol converterTypeSymbol, IPropertySymbol property, IPropertySymbol sourceProperty) - { - return converterTypeSymbol.AllInterfaces - .SingleOrDefault(i => - i.TypeArguments.Length == 2 && - SymbolEqualityComparer.Default.Equals(i.ConstructedFrom, _typeConverterInterfaceTypeSymbol) && - SymbolEqualityComparer.Default.Equals(sourceProperty.Type, i.TypeArguments[0]) && - SymbolEqualityComparer.Default.Equals(property.Type, i.TypeArguments[1])); - } - - private static ImmutableArray GetTypeConverterParameters(AttributeData typeConverterAttribute) - { - var converterParameter = typeConverterAttribute.ConstructorArguments.Skip(1).FirstOrDefault(); - return converterParameter.IsNull - ? ImmutableArray.Empty - : converterParameter.Values.Where(v => v.Value is not null).Select(v => v.Value!.ToSourceCodeString()).ToImmutableArray(); - } - - private INamedTypeSymbol? GetSourceTypeSymbol(TypeDeclarationSyntax typeDeclarationSyntax, SemanticModel? semanticModel = null) => - GetSourceTypeSymbol(typeDeclarationSyntax.GetAttribute(MapFromAttributeSource.AttributeName), semanticModel); - - private INamedTypeSymbol? GetSourceTypeSymbol(AttributeSyntax? attributeSyntax, SemanticModel? semanticModel = null) - { - if (attributeSyntax is null) - { - return null; - } - - semanticModel ??= _compilation.GetSemanticModel(attributeSyntax.SyntaxTree); - var sourceTypeExpressionSyntax = attributeSyntax - .DescendantNodes() - .OfType() - .SingleOrDefault(); - - return sourceTypeExpressionSyntax is not null ? ModelExtensions.GetTypeInfo(semanticModel, sourceTypeExpressionSyntax.Type).Type as INamedTypeSymbol : null; - } } } \ No newline at end of file diff --git a/src/MapTo/RecordMappingContext.cs b/src/MapTo/RecordMappingContext.cs new file mode 100644 index 0000000..ba87b2d --- /dev/null +++ b/src/MapTo/RecordMappingContext.cs @@ -0,0 +1,28 @@ +using System.Collections.Immutable; +using System.Linq; +using MapTo.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace MapTo +{ + internal class RecordMappingContext : MappingContext + { + internal RecordMappingContext(Compilation compilation, SourceGenerationOptions sourceGenerationOptions, TypeDeclarationSyntax typeSyntax) + : base(compilation, sourceGenerationOptions, typeSyntax) { } + + protected override ImmutableArray GetMappedProperties(ITypeSymbol typeSymbol, ITypeSymbol sourceTypeSymbol, bool isInheritFromMappedBaseClass) + { + var sourceProperties = sourceTypeSymbol.GetAllMembers().OfType().ToArray(); + return typeSymbol.GetMembers() + .OfType() + .OrderByDescending(s => s.Parameters.Length) + .First(s => s.Name == ".ctor") + .Parameters + .Where(p => !p.HasAttribute(IgnorePropertyAttributeTypeSymbol)) + .Select(property => MapProperty(sourceTypeSymbol, sourceProperties, property)) + .Where(mappedProperty => mappedProperty is not null) + .ToImmutableArray()!; + } + } +} \ No newline at end of file diff --git a/src/MapTo/Sources/MapClassSource.cs b/src/MapTo/Sources/MapClassSource.cs index 86fa6a5..e3ff4df 100644 --- a/src/MapTo/Sources/MapClassSource.cs +++ b/src/MapTo/Sources/MapClassSource.cs @@ -44,7 +44,7 @@ namespace MapTo.Sources // End namespace declaration .WriteClosingBracket(); - return new(builder.ToString(), $"{model.TypeIdentifierName}.g.cs"); + return new(builder.ToString(), $"{model.Namespace}.{model.TypeIdentifierName}.g.cs"); } private static SourceBuilder GenerateSecondaryConstructor(this SourceBuilder builder, MappingModel model) diff --git a/src/MapTo/Sources/MapPropertyAttributeSource.cs b/src/MapTo/Sources/MapPropertyAttributeSource.cs index 3012223..7574204 100644 --- a/src/MapTo/Sources/MapPropertyAttributeSource.cs +++ b/src/MapTo/Sources/MapPropertyAttributeSource.cs @@ -36,7 +36,7 @@ namespace MapTo.Sources } builder - .WriteLine("[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]") + .WriteLine("[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)]") .WriteLine($"public sealed class {AttributeClassName} : Attribute") .WriteOpeningBracket(); diff --git a/src/MapTo/Sources/MapRecordSource.cs b/src/MapTo/Sources/MapRecordSource.cs new file mode 100644 index 0000000..6bfe929 --- /dev/null +++ b/src/MapTo/Sources/MapRecordSource.cs @@ -0,0 +1,180 @@ +using System; +using MapTo.Extensions; +using static MapTo.Sources.Constants; + +namespace MapTo.Sources +{ + internal static class MapRecordSource + { + internal static SourceCode Generate(MappingModel model) + { + using var builder = new SourceBuilder() + .WriteLine(GeneratedFilesHeader) + .WriteNullableContextOptionIf(model.Options.SupportNullableReferenceTypes) + + // Namespace declaration + .WriteLine($"namespace {model.Namespace}") + .WriteOpeningBracket() + .WriteUsings(model.Usings) + .WriteLine() + + // Class declaration + .WriteLine($"partial record {model.TypeIdentifierName}") + .WriteOpeningBracket(); + + // Class body + if (model.GenerateSecondaryConstructor) + { + builder + .GenerateSecondaryConstructor(model) + .WriteLine(); + } + + builder + .GeneratePrivateConstructor(model) + .WriteLine() + .GenerateFactoryMethod(model) + + // End class declaration + .WriteClosingBracket() + .WriteLine() + + // Extension class declaration + .GenerateSourceTypeExtensionClass(model) + + // End namespace declaration + .WriteClosingBracket(); + + return new(builder.ToString(), $"{model.Namespace}.{model.TypeIdentifierName}.g.cs"); + } + + private static SourceBuilder GenerateSecondaryConstructor(this SourceBuilder builder, MappingModel model) + { + var sourceClassParameterName = model.SourceTypeIdentifierName.ToCamelCase(); + + if (model.Options.GenerateXmlDocument) + { + builder + .WriteLine("/// ") + .WriteLine($"/// Initializes a new instance of the class") + .WriteLine($"/// using the property values from the specified .") + .WriteLine("/// ") + .WriteLine($"/// {sourceClassParameterName} is null"); + } + + return builder + .WriteLine($"{model.Options.ConstructorAccessModifier.ToLowercaseString()} {model.TypeIdentifierName}({model.SourceTypeIdentifierName} {sourceClassParameterName})") + .WriteLine($" : this(new {MappingContextSource.ClassName}(), {sourceClassParameterName}) {{ }}"); + } + + private static SourceBuilder GeneratePrivateConstructor(this SourceBuilder builder, MappingModel model) + { + var sourceClassParameterName = model.SourceTypeIdentifierName.ToCamelCase(); + const string mappingContextParameterName = "context"; + + builder + .WriteLine($"private protected {model.TypeIdentifierName}({MappingContextSource.ClassName} {mappingContextParameterName}, {model.SourceTypeIdentifierName} {sourceClassParameterName})") + .Indent() + .Write(": this("); + + for (var i = 0; i < model.MappedProperties.Length; i++) + { + var property = model.MappedProperties[i]; + if (property.TypeConverter is null) + { + if (property.IsEnumerable) + { + builder.Write( + $"{property.Name}: {sourceClassParameterName}.{property.SourcePropertyName}.Select({mappingContextParameterName}.{MappingContextSource.MapMethodName}<{property.MappedSourcePropertyTypeName}, {property.EnumerableTypeArgument}>).ToList()"); + } + else + { + builder.Write(property.MappedSourcePropertyTypeName is null + ? $"{property.Name}: {sourceClassParameterName}.{property.SourcePropertyName}" + : $"{property.Name}: {mappingContextParameterName}.{MappingContextSource.MapMethodName}<{property.MappedSourcePropertyTypeName}, {property.Type}>({sourceClassParameterName}.{property.SourcePropertyName})"); + } + } + else + { + var parameters = property.TypeConverterParameters.IsEmpty + ? "null" + : $"new object[] {{ {string.Join(", ", property.TypeConverterParameters)} }}"; + + builder.Write($"{property.Name}: new {property.TypeConverter}().Convert({sourceClassParameterName}.{property.SourcePropertyName}, {parameters})"); + } + + if (i < model.MappedProperties.Length - 1) + { + builder.Write(", "); + } + } + + builder.WriteLine(")") + .Unindent() + .WriteOpeningBracket() + .WriteLine($"if ({mappingContextParameterName} == null) throw new ArgumentNullException(nameof({mappingContextParameterName}));") + .WriteLine($"if ({sourceClassParameterName} == null) throw new ArgumentNullException(nameof({sourceClassParameterName}));") + .WriteLine() + .WriteLine($"{mappingContextParameterName}.{MappingContextSource.RegisterMethodName}({sourceClassParameterName}, this);"); + + // End constructor declaration + return builder.WriteClosingBracket(); + } + + private static SourceBuilder GenerateFactoryMethod(this SourceBuilder builder, MappingModel model) + { + var sourceClassParameterName = model.SourceTypeIdentifierName.ToCamelCase(); + + return builder + .GenerateConvertorMethodsXmlDocs(model, sourceClassParameterName) + .WriteLineIf(model.Options.SupportNullableStaticAnalysis, $"[return: NotNullIfNotNull(\"{sourceClassParameterName}\")]") + .WriteLine( + $"{model.Options.GeneratedMethodsAccessModifier.ToLowercaseString()} static {model.TypeIdentifierName}{model.Options.NullableReferenceSyntax} From({model.SourceTypeIdentifierName}{model.Options.NullableReferenceSyntax} {sourceClassParameterName})") + .WriteOpeningBracket() + .WriteLine( + $"return {sourceClassParameterName} == null ? null : {MappingContextSource.ClassName}.{MappingContextSource.FactoryMethodName}<{model.SourceTypeIdentifierName}, {model.TypeIdentifierName}>({sourceClassParameterName});") + .WriteClosingBracket(); + } + + private static SourceBuilder GenerateConvertorMethodsXmlDocs(this SourceBuilder builder, MappingModel model, string sourceClassParameterName) + { + if (!model.Options.GenerateXmlDocument) + { + return builder; + } + + return builder + .WriteLine("/// ") + .WriteLine($"/// Creates a new instance of and sets its participating properties") + .WriteLine($"/// using the property values from .") + .WriteLine("/// ") + .WriteLine($"/// The instance of to use as source.") + .WriteLine( + $"/// A new instance of -or- null if is null."); + } + + private static SourceBuilder GenerateSourceTypeExtensionClass(this SourceBuilder builder, MappingModel model) + { + return builder + .WriteLine( + $"{model.Options.GeneratedMethodsAccessModifier.ToLowercaseString()} static partial class {model.SourceTypeIdentifierName}To{model.TypeIdentifierName}Extensions") + .WriteOpeningBracket() + .GenerateSourceTypeExtensionMethod(model) + .WriteClosingBracket(); + } + + private static SourceBuilder GenerateSourceTypeExtensionMethod(this SourceBuilder builder, MappingModel model) + { + var sourceClassParameterName = model.SourceTypeIdentifierName.ToCamelCase(); + + return builder + .GenerateConvertorMethodsXmlDocs(model, sourceClassParameterName) + .WriteLineIf(model.Options.SupportNullableStaticAnalysis, $"[return: NotNullIfNotNull(\"{sourceClassParameterName}\")]") + .WriteLine( + $"{model.Options.GeneratedMethodsAccessModifier.ToLowercaseString()} static {model.TypeIdentifierName}{model.Options.NullableReferenceSyntax} To{model.TypeIdentifierName}(this {model.SourceTypeIdentifierName}{model.Options.NullableReferenceSyntax} {sourceClassParameterName})") + .WriteOpeningBracket() + .WriteLine($"return {sourceClassParameterName} == null ? null : new {model.TypeIdentifierName}({sourceClassParameterName});") + .WriteClosingBracket(); + } + } +} \ No newline at end of file diff --git a/src/MapTo/Sources/MapTypeConverterAttributeSource.cs b/src/MapTo/Sources/MapTypeConverterAttributeSource.cs index 6852549..f9efa26 100644 --- a/src/MapTo/Sources/MapTypeConverterAttributeSource.cs +++ b/src/MapTo/Sources/MapTypeConverterAttributeSource.cs @@ -31,7 +31,7 @@ namespace MapTo.Sources } builder - .WriteLine("[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]") + .WriteLine("[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false)]") .WriteLine($"public sealed class {AttributeClassName} : Attribute") .WriteOpeningBracket(); diff --git a/src/MapTo/Sources/SourceBuilder.cs b/src/MapTo/Sources/SourceBuilder.cs index c15320b..369fe9e 100644 --- a/src/MapTo/Sources/SourceBuilder.cs +++ b/src/MapTo/Sources/SourceBuilder.cs @@ -38,6 +38,12 @@ namespace MapTo.Sources return this; } + public SourceBuilder Write(string? value = null) + { + _indentedWriter.Write(value); + return this; + } + public SourceBuilder WriteLineIf(bool condition, string? value) { if (condition) @@ -83,6 +89,18 @@ namespace MapTo.Sources return this; } + public SourceBuilder Indent() + { + _indentedWriter.Indent++; + return this; + } + + public SourceBuilder Unindent() + { + _indentedWriter.Indent--; + return this; + } + /// public override string ToString() => _writer.ToString(); } diff --git a/test/MapTo.Tests/Infrastructure/TestAnalyzerConfigOptions.cs b/test/MapTo.Tests/Infrastructure/TestAnalyzerConfigOptions.cs index dd13340..462d71d 100644 --- a/test/MapTo.Tests/Infrastructure/TestAnalyzerConfigOptions.cs +++ b/test/MapTo.Tests/Infrastructure/TestAnalyzerConfigOptions.cs @@ -14,6 +14,6 @@ namespace MapTo.Tests.Infrastructure _backing = properties?.ToImmutableDictionary(KeyComparer) ?? ImmutableDictionary.Create(KeyComparer); } - public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) => _backing.TryGetValue(key, out value); + public override bool TryGetValue(string key, out string? value) => _backing.TryGetValue(key, out value); } } \ No newline at end of file diff --git a/test/MapTo.Tests/MapPropertyTests.cs b/test/MapTo.Tests/MapPropertyTests.cs index 48904f6..9bf8ca5 100644 --- a/test/MapTo.Tests/MapPropertyTests.cs +++ b/test/MapTo.Tests/MapPropertyTests.cs @@ -28,7 +28,7 @@ using System; namespace MapTo {{ - [AttributeUsage(AttributeTargets.Property, AllowMultiple = true)] + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)] public sealed class MapPropertyAttribute : Attribute {{ public string{nullableSyntax} SourcePropertyName {{ get; set; }} diff --git a/test/MapTo.Tests/MapTypeConverterTests.cs b/test/MapTo.Tests/MapTypeConverterTests.cs index 011cf09..c7a4720 100644 --- a/test/MapTo.Tests/MapTypeConverterTests.cs +++ b/test/MapTo.Tests/MapTypeConverterTests.cs @@ -23,7 +23,7 @@ using System; namespace MapTo {{ - [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false)] public sealed class MapTypeConverterAttribute : Attribute {{ public MapTypeConverterAttribute(Type converter, object[] converterParameters = null) @@ -60,7 +60,7 @@ using System; namespace MapTo {{ - [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false)] public sealed class MapTypeConverterAttribute : Attribute {{ public MapTypeConverterAttribute(Type converter, object[]? converterParameters = null) diff --git a/test/MapTo.Tests/MappedClassesTests.cs b/test/MapTo.Tests/MappedClassesTests.cs index d120da2..08b2400 100644 --- a/test/MapTo.Tests/MappedClassesTests.cs +++ b/test/MapTo.Tests/MappedClassesTests.cs @@ -1,6 +1,8 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using MapTo.Tests.Extensions; using MapTo.Tests.Infrastructure; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Shouldly; using Xunit; @@ -18,40 +20,15 @@ namespace MapTo.Tests _output = output; } - [Fact] - public void VerifyMappedClassSource() + [Theory] + [MemberData(nameof(SecondaryConstructorCheckData))] + public void When_SecondaryConstructorExists_Should_NotGenerateOne(string source, LanguageVersion languageVersion) { // Arrange - var sources = new[] { MainSourceClass, NestedSourceClass, MainDestinationClass, NestedDestinationClass }; + source = source.Trim(); // Act - var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(sources, analyzerConfigOptions: DefaultAnalyzerOptions); - - // Assert - diagnostics.ShouldBeSuccessful(); - _output.WriteLine(compilation.PrintSyntaxTree()); - } - - [Fact] - public void When_SecondaryConstructorExists_Should_NotGenerateOne() - { - // Arrange - var source = @" -using MapTo; -namespace Test.Data.Models -{ - public class SourceClass { public string Prop1 { get; set; } } - - [MapFrom(typeof(SourceClass))] - public partial class DestinationClass - { - public DestinationClass(SourceClass source) : this(new MappingContext(), source) { } - public string Prop1 { get; set; } - } -} -".Trim(); - // Act - var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); + var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions, languageVersion: languageVersion); // Assert diagnostics.ShouldBeSuccessful(); @@ -65,11 +42,71 @@ namespace Test.Data.Models .ShouldBe(1); } - [Fact] - public void When_SecondaryConstructorExistsButDoNotReferencePrivateConstructor_Should_ReportError() + public static IEnumerable SecondaryConstructorCheckData => new List + { + new object[] + { + @" +using MapTo; +namespace Test.Data.Models +{ + public class SourceClass { public string Prop1 { get; set; } } + + [MapFrom(typeof(SourceClass))] + public partial class DestinationClass + { + public DestinationClass(SourceClass source) : this(new MappingContext(), source) { } + public string Prop1 { get; set; } + } +} +", + LanguageVersion.CSharp7_3 + }, + new object[] + { + @" +using MapTo; +namespace Test.Data.Models +{ + public record SourceClass(string Prop1); + + [MapFrom(typeof(SourceClass))] + public partial record DestinationClass(string Prop1) + { + public DestinationClass(SourceClass source) : this(new MappingContext(), source) { } + } +} +", + LanguageVersion.CSharp9 + } + }; + + [Theory] + [MemberData(nameof(SecondaryCtorWithoutPrivateCtorData))] + public void When_SecondaryConstructorExistsButDoNotReferencePrivateConstructor_Should_ReportError(string source, LanguageVersion languageVersion) { // Arrange - var source = @" + source = source.Trim(); + + // Act + var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions, languageVersion: languageVersion); + + // Assert + var constructorSyntax = compilation.SyntaxTrees + .First() + .GetRoot() + .DescendantNodes() + .OfType() + .Single(); + + diagnostics.ShouldNotBeSuccessful(DiagnosticsFactory.MissingConstructorArgument(constructorSyntax)); + } + + public static IEnumerable SecondaryCtorWithoutPrivateCtorData => new List + { + new object[] + { + @" using MapTo; namespace Test.Data.Models { @@ -82,20 +119,27 @@ namespace Test.Data.Models public string Prop1 { get; set; } } } -".Trim(); - // Act - var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); +", + LanguageVersion.CSharp7_3 + }, + new object[] + { + @" +using MapTo; +namespace Test.Data.Models +{ + public record SourceClass(string Prop1); - // Assert - var constructorSyntax = compilation.SyntaxTrees - .First() - .GetRoot() - .DescendantNodes() - .OfType() - .Single(); - - diagnostics.ShouldNotBeSuccessful(DiagnosticsFactory.MissingConstructorArgument(constructorSyntax)); - } + [MapFrom(typeof(SourceClass))] + public partial record DestinationClass(string Prop1) + { + public DestinationClass(SourceClass source) : this(""invalid"") { } + } +} +", + LanguageVersion.CSharp9 + } + }; [Fact] public void When_PropertyNameIsTheSameAsClassName_Should_MapAccordingly() @@ -122,26 +166,67 @@ namespace SaleModel ".Trim(); // Act - var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); + var (_, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions); // Assert diagnostics.ShouldBeSuccessful(); } - private static string NestedSourceClass => @" -namespace Test.Data.Models + [Theory] + [MemberData(nameof(VerifyMappedTypesData))] + public void VerifyMappedTypes(string[] sources, LanguageVersion languageVersion) + { + // Arrange + // Act + var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(sources, analyzerConfigOptions: DefaultAnalyzerOptions, languageVersion: languageVersion); + + // Assert + diagnostics.ShouldBeSuccessful(); + _output.WriteLine(compilation.PrintSyntaxTree()); + } + + public static IEnumerable VerifyMappedTypesData => new List + { + new object[] { new[] { MainSourceClass, NestedSourceClass, MainDestinationClass, NestedDestinationClass }, LanguageVersion.CSharp7_3 }, + new object[] { new[] { MainSourceRecord, NestedSourceRecord, MainDestinationRecord, NestedDestinationRecord }, LanguageVersion.CSharp9 } + }; + + [Fact] + public void VerifySelfReferencingRecords() + { + // Arrange + var source = @" +namespace Tests.Data.Models { - public class Profile - { - public string FirstName { get; set; } + using System.Collections.Generic; + + public record Employee(int Id, string EmployeeCode, Manager Manager); - public string LastName { get; set; } + public record Manager(int Id, string EmployeeCode, Manager Manager, int Level, List Employees) : Employee(Id, EmployeeCode, Manager); +} - public string FullName => $""{FirstName} {LastName}""; - } +namespace Tests.Data.ViewModels +{ + using System.Collections.Generic; + using Tests.Data.Models; + using MapTo; + + [MapFrom(typeof(Employee))] + public partial record EmployeeViewModel(int Id, string EmployeeCode, ManagerViewModel Manager); + + [MapFrom(typeof(Manager))] + public partial record ManagerViewModel(int Id, string EmployeeCode, ManagerViewModel Manager, int Level, List Employees) : EmployeeViewModel(Id, EmployeeCode, Manager); } ".Trim(); + // Act + var (compilation, diagnostics) = CSharpGenerator.GetOutputCompilation(source, analyzerConfigOptions: DefaultAnalyzerOptions, languageVersion: LanguageVersion.CSharp9); + + // Assert + diagnostics.ShouldBeSuccessful(); + _output.WriteLine(compilation.PrintSyntaxTree()); + } + private static string MainSourceClass => @" using System; @@ -157,24 +242,21 @@ namespace Test.Data.Models } } ".Trim(); - - private static string NestedDestinationClass => @" -using MapTo; -using Test.Data.Models; - -namespace Test.ViewModels + + private static string NestedSourceClass => @" +namespace Test.Data.Models { - [MapFrom(typeof(Profile))] - public partial class ProfileViewModel + public class Profile { - public string FirstName { get; } + public string FirstName { get; set; } - public string LastName { get; } + public string LastName { get; set; } + + public string FullName => $""{FirstName} {LastName}""; } } ".Trim(); - - + private static string MainDestinationClass => @" using System; using MapTo; @@ -201,5 +283,67 @@ namespace Test.ViewModels } } ".Trim(); + + private static string NestedDestinationClass => @" +using MapTo; +using Test.Data.Models; + +namespace Test.ViewModels +{ + [MapFrom(typeof(Profile))] + public partial class ProfileViewModel + { + public string FirstName { get; } + + public string LastName { get; } + } +} +".Trim(); + + private static string MainSourceRecord => BuildSourceRecord("public record User(int Id, DateTimeOffset RegisteredAt, Profile Profile);"); + + private static string MainDestinationRecord => BuildDestinationRecord(@" +[MapFrom(typeof(User))] +public partial record UserViewModel( + [MapProperty(SourcePropertyName = nameof(User.Id))] + [MapTypeConverter(typeof(UserViewModel.IdConverter))] + string Key, + DateTimeOffset RegisteredAt) +{ + private class IdConverter : ITypeConverter + { + public string Convert(int source, object[] converterParameters) => $""{source:X}""; + } +}"); + + private static string NestedSourceRecord => BuildSourceRecord("public record Profile(string FirstName, string LastName) { public string FullName => $\"{FirstName} {LastName}\"; }"); + + private static string NestedDestinationRecord => BuildDestinationRecord("[MapFrom(typeof(Profile))] public partial record ProfileViewModel(string FirstName, string LastName);"); + + private static string BuildSourceRecord(string record) + { + return $@" +using System; + +namespace RecordTest.Data.Models +{{ + {record} +}} +".Trim(); + } + + private static string BuildDestinationRecord(string record) + { + return $@" +using System; +using MapTo; +using RecordTest.Data.Models; + +namespace RecordTest.ViewModels +{{ + {record} +}} +".Trim(); + } } } \ No newline at end of file