From 5fd54674f16f4f9dcf8b850e3ecd58991cc2fef7 Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Fri, 13 Mar 2026 12:36:20 +0100 Subject: [PATCH 1/9] feat(MappingConfiguration): add ATCMCF014 diagnostic for unresolved types --- CLAUDE.md | 1 + .../AnalyzerReleases.Unshipped.md | 1 + .../MappingConfigurationGenerator.cs | 68 +++++++++++++++ .../RuleIdentifierConstants.cs | 5 ++ ...MappingConfigurationGeneratorErrorTests.cs | 85 +++++++++++++++++++ 5 files changed, 160 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index b702bac..887d527 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1081,6 +1081,7 @@ When mapping properties with different enum types (e.g., `ContactType` → `Cust - `ATCMCF010` - Assembly MapTypes source type not found (Error) - `ATCMCF011` - Map() requires exactly two type arguments (Error) - `ATCMCF013` - AddMappings() requires a lambda expression argument (Error) +- `ATCMCF014` - Type could not be resolved, possibly from sibling source generator (Warning) - `ATCMAP005` - Duplicate mapping: attribute-based [MapTo] takes precedence over configuration (Warning) - `ATCMAP006` - Duplicate configuration mapping for same source→target pair (Warning) diff --git a/src/Atc.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/Atc.SourceGenerators/AnalyzerReleases.Unshipped.md index 046137e..1b1dc98 100644 --- a/src/Atc.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/Atc.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -19,3 +19,4 @@ ATCMCF011 | MappingConfiguration | Error | Map() requires exactly two type argum ATCMCF013 | MappingConfiguration | Error | AddMappings() requires a lambda expression argument ATCENUM006 | EnumMapping | Warning | Flag enum mapping may produce incorrect results for combined values ATCMAP011 | ObjectMapping | Warning | Collection element type has no mapping method +ATCMCF014 | MappingConfiguration | Warning | Type could not be resolved (possibly from sibling generator) diff --git a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs index 4611129..77f4c77 100644 --- a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs @@ -84,6 +84,14 @@ public class MappingConfigurationGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor TypeNotResolvedDescriptor = new( + id: RuleIdentifierConstants.MappingConfiguration.TypeNotResolved, + title: "Type not resolved", + messageFormat: "Type '{0}' could not be resolved in method '{1}'. If this type is produced by another source generator, note that source generators cannot reference types from sibling generators in the same compilation. Consider moving the type to a referenced project or pre-compiling the other generator's output.", + category: RuleCategoryConstants.MappingConfiguration, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + private static readonly DiagnosticDescriptor DuplicateAttributeAndConfigurationDescriptor = new( id: RuleIdentifierConstants.ObjectMappingExtended.DuplicateAttributeAndConfiguration, title: "Duplicate attribute and configuration mapping", @@ -457,6 +465,18 @@ private static void Execute( return null; } + // Check for unresolved return type (possibly from sibling source generator) + if (!method.ReturnsVoid && method.ReturnType.TypeKind == TypeKind.Error) + { + context.ReportDiagnostic( + Diagnostic.Create( + TypeNotResolvedDescriptor, + method.Locations.FirstOrDefault() ?? Location.None, + method.ReturnType.Name, + method.Name)); + return null; + } + // Validate return type if (method.ReturnsVoid || method.ReturnType is not INamedTypeSymbol targetType || @@ -478,6 +498,18 @@ method.ReturnType is not INamedTypeSymbol targetType || return null; } + // Check for unresolved source type (possibly from sibling source generator) + if (sourceType.TypeKind == TypeKind.Error) + { + context.ReportDiagnostic( + Diagnostic.Create( + TypeNotResolvedDescriptor, + method.Locations.FirstOrDefault() ?? Location.None, + sourceType.Name, + method.Name)); + return null; + } + // Extract attributes var ignoredProperties = new List(); var propertyRenames = new List<(string Source, string Target)>(); @@ -717,6 +749,19 @@ attr.ConstructorArguments[0].Value is string sourcePropName && return null; } + // Check for unresolved types (possibly from sibling source generator) + if (sourceType.TypeKind == TypeKind.Error || targetType.TypeKind == TypeKind.Error) + { + var unresolvedType = sourceType.TypeKind == TypeKind.Error ? sourceType : targetType; + context.ReportDiagnostic( + Diagnostic.Create( + TypeNotResolvedDescriptor, + mapCall.GetLocation(), + unresolvedType.Name, + "Map")); + return null; + } + // Validate target type if (targetType.TypeKind != TypeKind.Class && targetType.TypeKind != TypeKind.Struct) { @@ -877,6 +922,29 @@ exprElement.Expression is LiteralExpressionSyntax literal && return null; } + // Check for unresolved types (possibly from sibling source generator) + if (sourceType.TypeKind == TypeKind.Error) + { + context.ReportDiagnostic( + Diagnostic.Create( + TypeNotResolvedDescriptor, + attr.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None, + sourceType.Name, + "MapTypes")); + return null; + } + + if (targetType.TypeKind == TypeKind.Error) + { + context.ReportDiagnostic( + Diagnostic.Create( + TypeNotResolvedDescriptor, + attr.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None, + targetType.Name, + "MapTypes")); + return null; + } + // Validate target type if (targetType.TypeKind != TypeKind.Class && targetType.TypeKind != TypeKind.Struct) { diff --git a/src/Atc.SourceGenerators/RuleIdentifierConstants.cs b/src/Atc.SourceGenerators/RuleIdentifierConstants.cs index 14578cc..7efb67a 100644 --- a/src/Atc.SourceGenerators/RuleIdentifierConstants.cs +++ b/src/Atc.SourceGenerators/RuleIdentifierConstants.cs @@ -294,6 +294,11 @@ internal static class MappingConfiguration /// ATCMCF013: AddMappings() requires a lambda expression argument. /// internal const string AddMappingsLambdaRequired = "ATCMCF013"; + + /// + /// ATCMCF014: Type could not be resolved (possibly produced by another source generator). + /// + internal const string TypeNotResolved = "ATCMCF014"; } /// diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorErrorTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorErrorTests.cs index dd26c27..563e939 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorErrorTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorErrorTests.cs @@ -317,4 +317,89 @@ public static partial class Mappings var extensionErrors = diagnostics.Where(d => d.Id == "ATCMCF004").ToList(); Assert.True(extensionErrors.Count >= 2, $"Expected at least 2 ATCMCF004 errors but found {extensionErrors.Count}"); } + + [Fact] + public void Config_Should_Warn_ATCMCF014_When_Return_Type_Is_Unresolved() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.UnresolvedTarget MapToTarget(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF014"); + var diagnostic = diagnostics.First(d => d.Id == "ATCMCF014"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + Assert.DoesNotContain(diagnostics, d => d.Id == "ATCMCF010"); + } + + [Fact] + public void Config_Should_Warn_ATCMCF014_When_Source_Type_Is_Unresolved() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace MyApp + { + public class Target { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.Target MapToTarget(this ExternalLib.UnresolvedSource source); + } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF014"); + var diagnostic = diagnostics.First(d => d.Id == "ATCMCF014"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + } + + [Fact] + public void Config_Should_Still_Error_ATCMCF010_When_Return_Type_Is_Void() + { + // Ensure existing ATCMCF010 behavior is preserved for void/primitive returns + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class Source { public int Id { get; set; } } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial void MapToNothing(this ExternalLib.Source source); + } + } + """; + + var (diagnostics, _) = GetGeneratedOutput(source, "ATCMCF"); + + Assert.Contains(diagnostics, d => d.Id == "ATCMCF010"); + Assert.DoesNotContain(diagnostics, d => d.Id == "ATCMCF014"); + } } \ No newline at end of file From ea561c5331a5deda5cda68510b6a4416a6201e5b Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Fri, 13 Mar 2026 13:19:42 +0100 Subject: [PATCH 2/9] fix(MappingConfiguration): handle positional records and collection enum mapping --- .../Internal/MappingTypeAnalyzer.cs | 29 ++++ .../MappingConfigurationGenerator.cs | 78 ++++++++-- ...pingConfigurationGeneratorAdvancedTests.cs | 139 ++++++++++++++++++ 3 files changed, 236 insertions(+), 10 deletions(-) diff --git a/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs b/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs index 2c5b01b..a56966a 100644 --- a/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs +++ b/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs @@ -189,4 +189,33 @@ internal static bool HasMapIgnoreAttribute(IPropertySymbol property) return attributes.Any(attr => IsAttributeMatch(attr.AttributeClass, "MapIgnoreAttribute")); } + + internal static bool IsImplicitlyConvertible( + ITypeSymbol sourceType, + ITypeSymbol targetType) + { + if (targetType.TypeKind == TypeKind.Interface) + { + return sourceType.AllInterfaces.Any(i => + SymbolEqualityComparer.Default.Equals(i, targetType)); + } + + var current = sourceType.BaseType; + while (current is not null) + { + if (SymbolEqualityComparer.Default.Equals(current, targetType)) + { + return true; + } + + current = current.BaseType; + } + + if (targetType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } namedTarget) + { + return SymbolEqualityComparer.Default.Equals(sourceType, namedTarget.TypeArguments[0]); + } + + return false; + } } \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs index 77f4c77..52dd870 100644 --- a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs @@ -1158,12 +1158,27 @@ private static List GetPropertyMappingsWithConfig( if (isSourceCollection && isTargetCollection && sourceElementType is not null && targetElementType is not null) { + // Auto-detect enum mapping for collection element types + var hasCollectionEnumMapping = false; + if (MappingTypeAnalyzer.IsEnumConversion(sourceElementType, targetElementType)) + { + hasCollectionEnumMapping = HasEnumMappingAttribute(sourceElementType, targetElementType); + + if (!hasCollectionEnumMapping && + sourceElementType is INamedTypeSymbol sourceElementEnumType && + targetElementType is INamedTypeSymbol targetElementEnumType) + { + hasCollectionEnumMapping = TryAutoDetectEnumMapping( + sourceElementEnumType, targetElementEnumType, autoDetectedEnums, context); + } + } + mappings.Add(new PropertyMapping( SourceProperty: sourceProp, TargetProperty: targetProp, RequiresConversion: false, IsNested: false, - HasEnumMapping: false, + HasEnumMapping: hasCollectionEnumMapping, IsCollection: true, CollectionElementType: targetElementType, CollectionTargetType: MappingTypeAnalyzer.GetCollectionTargetType(targetProp.Type), @@ -1467,11 +1482,7 @@ private static (IMethodSymbol? Constructor, List ParameterNames) FindBes return (null, []); } - var sourceProperties = sourceType - .GetMembers() - .OfType() - .Where(p => p.GetMethod is not null) - .ToList(); + var sourceProperties = GetAllProperties(sourceType, includePrivateMembers: false); foreach (var constructor in constructors.OrderByDescending(c => c.Parameters.Length)) { @@ -1489,6 +1500,19 @@ private static (IMethodSymbol? Constructor, List ParameterNames) FindBes break; } + // Verify type compatibility: reject clear mismatches (e.g., string vs int) + // Allow custom type mismatches since mapping methods may bridge the gap + // (e.g., Address → AddressDto, StatusEnum → StatusEnumDto) + var sourcePropertyType = matchingSourceProperty.Type; + var parameterType = parameter.Type; + if (!SymbolEqualityComparer.Default.Equals(sourcePropertyType, parameterType) && + !MappingTypeAnalyzer.IsImplicitlyConvertible(sourcePropertyType, parameterType) && + !AreBothCustomOrEnumTypes(sourcePropertyType, parameterType)) + { + allParametersMatch = false; + break; + } + parameterNames.Add(parameter.Name); } @@ -1501,6 +1525,33 @@ private static (IMethodSymbol? Constructor, List ParameterNames) FindBes return (null, []); } + private static bool AreBothCustomOrEnumTypes( + ITypeSymbol sourceType, + ITypeSymbol targetType) + { + if (sourceType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } ns) + { + sourceType = ns.TypeArguments[0]; + } + + if (targetType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nt) + { + targetType = nt.TypeArguments[0]; + } + + if (sourceType.TypeKind == TypeKind.Enum && targetType.TypeKind == TypeKind.Enum) + { + return true; + } + + var isSourceCustom = sourceType.TypeKind is TypeKind.Class or TypeKind.Struct && + sourceType.SpecialType == SpecialType.None; + var isTargetCustom = targetType.TypeKind is TypeKind.Class or TypeKind.Struct && + targetType.SpecialType == SpecialType.None; + + return isSourceCustom && isTargetCustom; + } + private static void DetectDuplicatesWithAttributeMappings( Compilation compilation, List configuredMappings, @@ -1911,12 +1962,19 @@ private static void GenerateAutoDetectedEnumMethods( } // Check if any mapping in this scope references this enum pair + // (either as a direct property conversion or as a collection element type) var isReferenced = mappings.Any(m => m.PropertyMappings.Any(p => - p.RequiresConversion && - p.HasEnumMapping && - SymbolEqualityComparer.Default.Equals(p.SourceProperty.Type, enumMapping.SourceEnum) && - SymbolEqualityComparer.Default.Equals(p.TargetProperty.Type, enumMapping.TargetEnum))); + (p.RequiresConversion && + p.HasEnumMapping && + SymbolEqualityComparer.Default.Equals(p.SourceProperty.Type, enumMapping.SourceEnum) && + SymbolEqualityComparer.Default.Equals(p.TargetProperty.Type, enumMapping.TargetEnum)) || + (p.IsCollection && + p.HasEnumMapping && + MappingTypeAnalyzer.IsCollectionType(p.SourceProperty.Type, out var srcElem) && + srcElem is not null && + SymbolEqualityComparer.Default.Equals(srcElem, enumMapping.SourceEnum) && + SymbolEqualityComparer.Default.Equals(p.CollectionElementType, enumMapping.TargetEnum)))); if (!isReferenced) { diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs index b9fec06..a3ca9f7 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs @@ -237,4 +237,143 @@ public static partial class Mappings Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); Assert.Contains("IsActive = source.IsActive", output, StringComparison.Ordinal); } + + [Fact] + public void Config_Should_Use_Constructor_For_Positional_Record_With_Different_Types() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public enum SourceStatus { None, Active, Inactive } + + public class SourceItem + { + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public SourceStatus Status { get; set; } + } + } + + namespace MyApp + { + public enum TargetStatus { Unknown, Active, Inactive } + + public sealed record TargetItem(string Id, string Name, TargetStatus Status); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetItem MapToTargetItem(this ExternalLib.SourceItem source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new MyApp.TargetItem(", output, StringComparison.Ordinal); + Assert.DoesNotContain("new MyApp.TargetItem\n", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Use_Constructor_For_Positional_Record_With_Nested_Types() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class SourceAddress + { + public string Street { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + } + + public class SourcePerson + { + public string Name { get; set; } = string.Empty; + public SourceAddress Address { get; set; } = new(); + } + } + + namespace MyApp + { + public class TargetAddress + { + public string Street { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + } + + public sealed record TargetPerson(string Name, TargetAddress Address); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetAddress MapToTargetAddress(this ExternalLib.SourceAddress source); + public static partial MyApp.TargetPerson MapToTargetPerson(this ExternalLib.SourcePerson source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new MyApp.TargetPerson(", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Generate_Enum_Mapper_For_Collection_Element_Types() + { + const string source = """ + using System.Collections.Generic; + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public enum FeatureType { None, Basic, Premium, Enterprise } + + public class SourceProduct + { + public string Name { get; set; } = string.Empty; + public List Features { get; set; } = new(); + } + } + + namespace MyApp + { + public enum FeatureType { Unknown, Basic, Premium, Enterprise } + + public class TargetProduct + { + public string Name { get; set; } = string.Empty; + public List Features { get; set; } = new(); + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetProduct MapToTargetProduct(this ExternalLib.SourceProduct source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("x.MapToFeatureType()", output, StringComparison.Ordinal); + Assert.Contains("MapToFeatureType(", output, StringComparison.Ordinal); + Assert.Contains("this ExternalLib.FeatureType source)", output, StringComparison.Ordinal); + Assert.Contains("=> source switch", output, StringComparison.Ordinal); + } } \ No newline at end of file From 2b5a5f6aee032b776acbfd02f18fa0d37a742cec Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Fri, 13 Mar 2026 13:43:56 +0100 Subject: [PATCH 3/9] feat(MappingConfiguration): support partial constructor matching for positional records --- .../Internal/ConfiguredMappingInfo.cs | 1 + .../Internal/ConstructorDefaultParameter.cs | 10 ++ .../MappingConfigurationGenerator.cs | 129 +++++++++++++++--- ...pingConfigurationGeneratorAdvancedTests.cs | 88 ++++++++++++ 4 files changed, 208 insertions(+), 20 deletions(-) create mode 100644 src/Atc.SourceGenerators/Generators/Internal/ConstructorDefaultParameter.cs diff --git a/src/Atc.SourceGenerators/Generators/Internal/ConfiguredMappingInfo.cs b/src/Atc.SourceGenerators/Generators/Internal/ConfiguredMappingInfo.cs index 68455a3..4ec6ce5 100644 --- a/src/Atc.SourceGenerators/Generators/Internal/ConfiguredMappingInfo.cs +++ b/src/Atc.SourceGenerators/Generators/Internal/ConfiguredMappingInfo.cs @@ -10,6 +10,7 @@ internal sealed record ConfiguredMappingInfo( bool EnableFlattening, IMethodSymbol? Constructor, List ConstructorParameterNames, + List ConstructorDefaultParameters, PropertyNameStrategy PropertyNameStrategy, List IgnoredProperties, List<(string Source, string Target)> PropertyRenames); \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/Internal/ConstructorDefaultParameter.cs b/src/Atc.SourceGenerators/Generators/Internal/ConstructorDefaultParameter.cs new file mode 100644 index 0000000..ebdb7eb --- /dev/null +++ b/src/Atc.SourceGenerators/Generators/Internal/ConstructorDefaultParameter.cs @@ -0,0 +1,10 @@ +namespace Atc.SourceGenerators.Generators.Internal; + +/// +/// Represents a constructor parameter that has no matching source property +/// and should be filled with a default value. +/// +internal sealed record ConstructorDefaultParameter( + string Name, + ITypeSymbol Type, + string DefaultExpression); \ No newline at end of file diff --git a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs index 52dd870..0e487d6 100644 --- a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs @@ -609,7 +609,7 @@ attr.ConstructorArguments[0].Value is string sourcePropName && context); // Find best matching constructor - var (constructor, constructorParameterNames) = FindBestConstructor(sourceType, targetType); + var (constructor, constructorParameterNames, constructorDefaultParameters) = FindBestConstructor(sourceType, targetType); return new ConfiguredMappingInfo( SourceType: sourceType, @@ -621,6 +621,7 @@ attr.ConstructorArguments[0].Value is string sourcePropName && EnableFlattening: enableFlattening, Constructor: constructor, ConstructorParameterNames: constructorParameterNames, + ConstructorDefaultParameters: constructorDefaultParameters, PropertyNameStrategy: propertyNameStrategy, IgnoredProperties: ignoredProperties, PropertyRenames: propertyRenames); @@ -830,7 +831,7 @@ attr.ConstructorArguments[0].Value is string sourcePropName && context); // Find best matching constructor - var (constructor, constructorParameterNames) = FindBestConstructor(sourceType, targetType); + var (constructor, constructorParameterNames, constructorDefaultParameters) = FindBestConstructor(sourceType, targetType); return new ConfiguredMappingInfo( SourceType: sourceType, @@ -842,6 +843,7 @@ attr.ConstructorArguments[0].Value is string sourcePropName && EnableFlattening: false, Constructor: constructor, ConstructorParameterNames: constructorParameterNames, + ConstructorDefaultParameters: constructorDefaultParameters, PropertyNameStrategy: propertyNameStrategy, IgnoredProperties: ignoredProperties, PropertyRenames: propertyRenames); @@ -1011,7 +1013,7 @@ exprElement.Expression is LiteralExpressionSyntax literal && context); // Find best matching constructor - var (constructor, constructorParameterNames) = FindBestConstructor(sourceType, targetType); + var (constructor, constructorParameterNames, constructorDefaultParameters) = FindBestConstructor(sourceType, targetType); return new ConfiguredMappingInfo( SourceType: sourceType, @@ -1023,6 +1025,7 @@ exprElement.Expression is LiteralExpressionSyntax literal && EnableFlattening: false, Constructor: constructor, ConstructorParameterNames: constructorParameterNames, + ConstructorDefaultParameters: constructorDefaultParameters, PropertyNameStrategy: propertyNameStrategy, IgnoredProperties: ignoredProperties, PropertyRenames: propertyRenames); @@ -1468,7 +1471,7 @@ private static bool HasEnumMappingAttribute( return false; } - private static (IMethodSymbol? Constructor, List ParameterNames) FindBestConstructor( + private static (IMethodSymbol? Constructor, List ParameterNames, List DefaultParameters) FindBestConstructor( INamedTypeSymbol sourceType, INamedTypeSymbol targetType) { @@ -1479,11 +1482,12 @@ private static (IMethodSymbol? Constructor, List ParameterNames) FindBes if (constructors.Count == 0) { - return (null, []); + return (null, [], []); } var sourceProperties = GetAllProperties(sourceType, includePrivateMembers: false); + // First pass: try exact match (all parameters have matching source properties) foreach (var constructor in constructors.OrderByDescending(c => c.Parameters.Length)) { var parameterNames = new List(); @@ -1518,11 +1522,82 @@ private static (IMethodSymbol? Constructor, List ParameterNames) FindBes if (allParametersMatch && constructor.Parameters.Length > 0) { - return (constructor, parameterNames); + return (constructor, parameterNames, []); } } - return (null, []); + // Second pass: partial match for types without parameterless constructor + // (e.g., positional records). Fill unmatched parameters with defaults. + var hasParameterlessConstructor = constructors.Any(c => c.Parameters.Length == 0); + if (!hasParameterlessConstructor) + { + foreach (var constructor in constructors.OrderByDescending(c => c.Parameters.Length)) + { + var matchedNames = new List(); + var defaultParams = new List(); + var hasIncompatibleParam = false; + + foreach (var parameter in constructor.Parameters) + { + var matchingSourceProperty = sourceProperties.FirstOrDefault(p => + string.Equals(p.Name, parameter.Name, StringComparison.OrdinalIgnoreCase)); + + if (matchingSourceProperty is null) + { + // Unmatched parameter: will use default value + defaultParams.Add(new ConstructorDefaultParameter( + parameter.Name, + parameter.Type, + GetDefaultExpression(parameter.Type))); + continue; + } + + var sourcePropertyType = matchingSourceProperty.Type; + var parameterType = parameter.Type; + if (!SymbolEqualityComparer.Default.Equals(sourcePropertyType, parameterType) && + !MappingTypeAnalyzer.IsImplicitlyConvertible(sourcePropertyType, parameterType) && + !AreBothCustomOrEnumTypes(sourcePropertyType, parameterType)) + { + hasIncompatibleParam = true; + break; + } + + matchedNames.Add(parameter.Name); + } + + // Accept if at least one parameter matched and no type incompatibilities + if (!hasIncompatibleParam && matchedNames.Count > 0) + { + return (constructor, matchedNames, defaultParams); + } + } + } + + return (null, [], []); + } + + private static string GetDefaultExpression(ITypeSymbol type) + { + // Nullable reference type (string?, object?, etc.) + if (type.NullableAnnotation == NullableAnnotation.Annotated) + { + return "null"; + } + + // Nullable value type (int?, DateTime?, etc.) + if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }) + { + return "null"; + } + + // Value types (int, bool, enums, structs) + if (type.IsValueType) + { + return "default"; + } + + // Non-nullable reference types + return "default!"; } private static bool AreBothCustomOrEnumTypes( @@ -1827,6 +1902,8 @@ private static void GenerateMethodContent( if (useConstructor) { var constructorParamSet = new HashSet(mapping.ConstructorParameterNames, StringComparer.OrdinalIgnoreCase); + var defaultParamSet = new HashSet( + mapping.ConstructorDefaultParameters.Select(d => d.Name), StringComparer.OrdinalIgnoreCase); var constructorProps = new List(); var initializerProps = new List(); @@ -1836,33 +1913,44 @@ private static void GenerateMethodContent( { constructorProps.Add(prop); } - else + else if (!defaultParamSet.Contains(prop.TargetProperty.Name)) { initializerProps.Add(prop); } } - // Order constructor props by parameter order - var orderedConstructorProps = new List(); - foreach (var paramName in mapping.ConstructorParameterNames) + // Build ordered list of all constructor arguments (matched + defaults) + // by iterating the actual constructor parameters in order + var constructorArguments = new List<(string Value, bool IsDefault)>(); + foreach (var param in mapping.Constructor!.Parameters) { - var prop = constructorProps.FirstOrDefault(p => - string.Equals(p.TargetProperty.Name, paramName, StringComparison.OrdinalIgnoreCase)); - if (prop is not null) + var matchedProp = constructorProps.FirstOrDefault(p => + string.Equals(p.TargetProperty.Name, param.Name, StringComparison.OrdinalIgnoreCase)); + + if (matchedProp is not null) { - orderedConstructorProps.Add(prop); + constructorArguments.Add((GeneratePropertyMappingValue(matchedProp, "source"), false)); + } + else + { + var defaultParam = mapping.ConstructorDefaultParameters.FirstOrDefault(d => + string.Equals(d.Name, param.Name, StringComparison.OrdinalIgnoreCase)); + + if (defaultParam is not null) + { + constructorArguments.Add((defaultParam.DefaultExpression, true)); + } } } sb.AppendLineLf($" return new {targetTypeName}("); - for (var i = 0; i < orderedConstructorProps.Count; i++) + for (var i = 0; i < constructorArguments.Count; i++) { - var prop = orderedConstructorProps[i]; - var isLast = i == orderedConstructorProps.Count - 1; + var (value, _) = constructorArguments[i]; + var isLast = i == constructorArguments.Count - 1; var comma = isLast && initializerProps.Count == 0 ? string.Empty : ","; - var value = GeneratePropertyMappingValue(prop, "source"); sb.AppendLineLf($" {value}{comma}"); } @@ -1907,7 +1995,7 @@ private static void GenerateReverseMappingMethod( autoDetectedEnums, context); - var (reverseConstructor, reverseConstructorParams) = FindBestConstructor(mapping.TargetType, mapping.SourceType); + var (reverseConstructor, reverseConstructorParams, reverseConstructorDefaults) = FindBestConstructor(mapping.TargetType, mapping.SourceType); sb.AppendLineLf(" /// "); sb.AppendLineLf($" /// Maps to ."); @@ -1926,6 +2014,7 @@ private static void GenerateReverseMappingMethod( EnableFlattening: mapping.EnableFlattening, Constructor: reverseConstructor, ConstructorParameterNames: reverseConstructorParams, + ConstructorDefaultParameters: reverseConstructorDefaults, PropertyNameStrategy: mapping.PropertyNameStrategy, IgnoredProperties: [], PropertyRenames: []); diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs index a3ca9f7..697e8ca 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs @@ -376,4 +376,92 @@ public static partial class Mappings Assert.Contains("this ExternalLib.FeatureType source)", output, StringComparison.Ordinal); Assert.Contains("=> source switch", output, StringComparison.Ordinal); } + + [Fact] + public void Config_Should_Use_Partial_Constructor_Match_For_Positional_Record() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class SourceProduct + { + public string Name { get; set; } = string.Empty; + public decimal Price { get; set; } + public string? Description { get; set; } + } + } + + namespace MyApp + { + public sealed record TargetProduct( + string Name, + decimal Price, + string? Description, + string? ImageUrl, + int StockCount); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetProduct MapToTargetProduct(this ExternalLib.SourceProduct source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new MyApp.TargetProduct(", output, StringComparison.Ordinal); + Assert.Contains("source.Name", output, StringComparison.Ordinal); + Assert.Contains("source.Price", output, StringComparison.Ordinal); + Assert.Contains("source.Description", output, StringComparison.Ordinal); + Assert.Contains("null", output, StringComparison.Ordinal); + Assert.Contains("default", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Not_Use_Partial_Match_When_Parameterless_Constructor_Exists() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class SourceItem + { + public string Name { get; set; } = string.Empty; + } + } + + namespace MyApp + { + public class TargetItem + { + public string Name { get; set; } = string.Empty; + public string Extra { get; set; } = string.Empty; + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetItem MapToTargetItem(this ExternalLib.SourceItem source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + // Should use object initializer, not constructor (class has parameterless constructor) + Assert.Contains("new MyApp.TargetItem", output, StringComparison.Ordinal); + Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); + } } \ No newline at end of file From 413125418ff6da856122403dd69a02d86f6cdf95 Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Fri, 13 Mar 2026 15:35:30 +0100 Subject: [PATCH 4/9] fix(MappingConfiguration): support numeric and collection type conversions in constructor matching --- .../Internal/MappingTypeAnalyzer.cs | 9 + .../MappingConfigurationGenerator.cs | 38 ++- ...pingConfigurationGeneratorAdvancedTests.cs | 218 ++++++++++++++++++ 3 files changed, 261 insertions(+), 4 deletions(-) diff --git a/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs b/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs index a56966a..6db9281 100644 --- a/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs +++ b/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs @@ -108,6 +108,15 @@ internal static bool IsNumericType(string typeName) "uint" or "ulong" or "ushort" or "decimal" or "double" or "float"; + internal static bool IsNumericTypeConversion( + ITypeSymbol sourceType, + ITypeSymbol targetType) + { + var sourceName = sourceType.ToDisplayString(); + var targetName = targetType.ToDisplayString(); + return sourceName != targetName && IsNumericType(sourceName) && IsNumericType(targetName); + } + internal static bool IsCollectionType( ITypeSymbol type, out ITypeSymbol? elementType) diff --git a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs index 0e487d6..e6643a0 100644 --- a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs @@ -1196,6 +1196,7 @@ sourceElementType is INamedTypeSymbol sourceElementEnumType && var requiresConversion = MappingTypeAnalyzer.IsEnumConversion(sourceProp.Type, targetProp.Type); var isNested = MappingTypeAnalyzer.IsNestedMapping(sourceProp.Type, targetProp.Type); var isBuiltInTypeConversion = MappingTypeAnalyzer.IsBuiltInTypeConversion(sourceProp.Type, targetProp.Type); + var isNumericConversion = MappingTypeAnalyzer.IsNumericTypeConversion(sourceProp.Type, targetProp.Type); // Auto-detect enum mapping var hasEnumMapping = false; @@ -1214,12 +1215,12 @@ sourceProp.Type is INamedTypeSymbol sourceEnumType && } } - if (requiresConversion || isNested || isBuiltInTypeConversion) + if (requiresConversion || isNested || isBuiltInTypeConversion || isNumericConversion) { mappings.Add(new PropertyMapping( SourceProperty: sourceProp, TargetProperty: targetProp, - RequiresConversion: requiresConversion, + RequiresConversion: requiresConversion || isNumericConversion, IsNested: isNested, HasEnumMapping: hasEnumMapping, IsCollection: false, @@ -1511,7 +1512,9 @@ private static (IMethodSymbol? Constructor, List ParameterNames, List ParameterNames, List configuredMappings, diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs index 697e8ca..ef765d4 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs @@ -464,4 +464,222 @@ public static partial class Mappings Assert.Contains("new MyApp.TargetItem", output, StringComparison.Ordinal); Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); } + + [Fact] + public void Config_Should_Handle_Numeric_Type_Width_Conversion_In_Constructor() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class SourceProject + { + public string Name { get; set; } = string.Empty; + public uint Version { get; set; } + } + } + + namespace MyApp + { + public sealed record TargetProject(string Name, int Version); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetProject MapToTargetProject(this ExternalLib.SourceProject source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new MyApp.TargetProject(", output, StringComparison.Ordinal); + Assert.Contains("(int)source.Version", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Handle_Multiple_Numeric_Conversions_In_Constructor() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class SourceDevice + { + public string Id { get; set; } = string.Empty; + public ushort Address { get; set; } + public uint Port { get; set; } + public long Timestamp { get; set; } + } + } + + namespace MyApp + { + public sealed record TargetDevice(string Id, int Address, int Port, int Timestamp); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetDevice MapToTargetDevice(this ExternalLib.SourceDevice source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new MyApp.TargetDevice(", output, StringComparison.Ordinal); + Assert.Contains("(int)source.Address", output, StringComparison.Ordinal); + Assert.Contains("(int)source.Port", output, StringComparison.Ordinal); + Assert.Contains("(int)source.Timestamp", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Handle_Numeric_Conversion_With_Object_Initializer() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class SourceData + { + public string Name { get; set; } = string.Empty; + public uint Count { get; set; } + } + } + + namespace MyApp + { + public class TargetData + { + public string Name { get; set; } = string.Empty; + public int Count { get; set; } + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetData MapToTargetData(this ExternalLib.SourceData source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("Count = (int)source.Count", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Use_Constructor_For_Record_With_Collection_Of_Different_Element_Types() + { + const string source = """ + using System.Collections.Generic; + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class SourceHeader + { + public string Title { get; set; } = string.Empty; + } + + public class SourceProject + { + public string Name { get; set; } = string.Empty; + public IList Headers { get; set; } = new List(); + } + } + + namespace MyApp + { + public class TargetHeader + { + public string Title { get; set; } = string.Empty; + } + + public sealed record TargetProject(string Name, List Headers); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetHeader MapToTargetHeader(this ExternalLib.SourceHeader source); + public static partial MyApp.TargetProject MapToTargetProject(this ExternalLib.SourceProject source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new MyApp.TargetProject(", output, StringComparison.Ordinal); + Assert.Contains("MapToTargetHeader", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Use_Constructor_For_Record_With_Mixed_Collections_And_Numeric_Conversions() + { + const string source = """ + using System.Collections.Generic; + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class SourceItem + { + public string Id { get; set; } = string.Empty; + } + + public class SourceContainer + { + public string Name { get; set; } = string.Empty; + public uint Version { get; set; } + public IList Items { get; set; } = new List(); + } + } + + namespace MyApp + { + public class TargetItem + { + public string Id { get; set; } = string.Empty; + } + + public sealed record TargetContainer(string Name, int Version, List Items); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetItem MapToTargetItem(this ExternalLib.SourceItem source); + public static partial MyApp.TargetContainer MapToTargetContainer(this ExternalLib.SourceContainer source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new MyApp.TargetContainer(", output, StringComparison.Ordinal); + Assert.Contains("(int)source.Version", output, StringComparison.Ordinal); + Assert.Contains("MapToTargetItem", output, StringComparison.Ordinal); + } } \ No newline at end of file From f553f1e30ed25280aab7e7098bfba2b3ab61eac5 Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Fri, 13 Mar 2026 15:48:59 +0100 Subject: [PATCH 5/9] fix(Mapping): omit null-conditional operator on value type nested mappings --- .../MappingConfigurationGenerator.cs | 5 + .../Generators/ObjectMappingGenerator.cs | 5 + ...pingConfigurationGeneratorAdvancedTests.cs | 116 ++++++++++++++++++ 3 files changed, 126 insertions(+) diff --git a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs index e6643a0..d949b48 100644 --- a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs @@ -2199,6 +2199,11 @@ private static string GeneratePropertyMappingValue( if (prop.IsNested) { var nestedMethodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(prop.TargetProperty.Type.Name)}"; + if (prop.SourceProperty.Type.IsValueType) + { + return $"{sourceVariable}.{prop.SourceProperty.Name}.{nestedMethodName}()"; + } + return $"{sourceVariable}.{prop.SourceProperty.Name}?.{nestedMethodName}()!"; } diff --git a/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs b/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs index cf66896..a3df90c 100644 --- a/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs @@ -1825,6 +1825,11 @@ private static string GeneratePropertyMappingValue( return $"{propertyAccess} is {{ }} __nested ? __nested.{nestedMethodName}() : default!"; } + if (prop.SourceProperty.Type.IsValueType) + { + return $"{propertyAccess}.{nestedMethodName}()"; + } + return $"{propertyAccess}?.{nestedMethodName}()!"; } diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs index ef765d4..4f95257 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs @@ -682,4 +682,120 @@ public static partial class Mappings Assert.Contains("(int)source.Version", output, StringComparison.Ordinal); Assert.Contains("MapToTargetItem", output, StringComparison.Ordinal); } + + [Fact] + public void Config_Should_Emit_All_Constructor_Args_When_Source_Has_Default_Values() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public class SourceGridSettings + { + public bool Show { get; set; } + public double DefaultThickness { get; set; } + } + + public sealed record SourceDesignerSettings( + SourceGridSettings GridLines, + int SizeHorizontal = 300, + int SizeVertical = 300, + int GridCountHorizontal = 6, + int GridCountVertical = 6, + string BackgroundColorInHex = "#FF333333"); + } + + namespace MyApp + { + public class TargetGridSettings + { + public bool Show { get; set; } + public double DefaultThickness { get; set; } + } + + public record TargetDesignerSettings( + TargetGridSettings GridLines, + int SizeHorizontal, + int SizeVertical, + int GridCountHorizontal, + int GridCountVertical, + string BackgroundColorInHex); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetGridSettings MapToTargetGridSettings(this ExternalLib.SourceGridSettings source); + public static partial MyApp.TargetDesignerSettings MapToTargetDesignerSettings(this ExternalLib.SourceDesignerSettings source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new MyApp.TargetDesignerSettings(", output, StringComparison.Ordinal); + Assert.Contains("source.SizeHorizontal", output, StringComparison.Ordinal); + Assert.Contains("source.SizeVertical", output, StringComparison.Ordinal); + Assert.Contains("source.GridCountHorizontal", output, StringComparison.Ordinal); + Assert.Contains("source.GridCountVertical", output, StringComparison.Ordinal); + Assert.Contains("source.BackgroundColorInHex", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Not_Use_Null_Conditional_On_Value_Type_Nested_Mapping() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace ExternalLib + { + public struct SourcePoint + { + public double X { get; set; } + public double Y { get; set; } + } + + public class SourceShape + { + public string Name { get; set; } = string.Empty; + public SourcePoint Location { get; set; } + } + } + + namespace MyApp + { + public struct TargetPoint + { + public double X { get; set; } + public double Y { get; set; } + } + + public class TargetShape + { + public string Name { get; set; } = string.Empty; + public TargetPoint Location { get; set; } + } + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial MyApp.TargetPoint MapToTargetPoint(this ExternalLib.SourcePoint source); + public static partial MyApp.TargetShape MapToTargetShape(this ExternalLib.SourceShape source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("source.Location.MapToTargetPoint()", output, StringComparison.Ordinal); + Assert.DoesNotContain("source.Location?.MapToTargetPoint()", output, StringComparison.Ordinal); + } } \ No newline at end of file From 7eb176e4525279c984c957a6205dca825759673a Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Fri, 13 Mar 2026 16:18:41 +0100 Subject: [PATCH 6/9] fix(Mapping): handle struct sources, ref-to-struct nested mapping, and renamed constructor params --- .../MappingConfigurationGenerator.cs | 65 +++++-- .../Generators/ObjectMappingGenerator.cs | 5 + ...pingConfigurationGeneratorAdvancedTests.cs | 172 ++++++++++++++++++ 3 files changed, 229 insertions(+), 13 deletions(-) diff --git a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs index d949b48..2522612 100644 --- a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs @@ -609,7 +609,7 @@ attr.ConstructorArguments[0].Value is string sourcePropName && context); // Find best matching constructor - var (constructor, constructorParameterNames, constructorDefaultParameters) = FindBestConstructor(sourceType, targetType); + var (constructor, constructorParameterNames, constructorDefaultParameters) = FindBestConstructor(sourceType, targetType, propertyRenames); return new ConfiguredMappingInfo( SourceType: sourceType, @@ -831,7 +831,7 @@ attr.ConstructorArguments[0].Value is string sourcePropName && context); // Find best matching constructor - var (constructor, constructorParameterNames, constructorDefaultParameters) = FindBestConstructor(sourceType, targetType); + var (constructor, constructorParameterNames, constructorDefaultParameters) = FindBestConstructor(sourceType, targetType, propertyRenames); return new ConfiguredMappingInfo( SourceType: sourceType, @@ -1013,7 +1013,7 @@ exprElement.Expression is LiteralExpressionSyntax literal && context); // Find best matching constructor - var (constructor, constructorParameterNames, constructorDefaultParameters) = FindBestConstructor(sourceType, targetType); + var (constructor, constructorParameterNames, constructorDefaultParameters) = FindBestConstructor(sourceType, targetType, propertyRenames); return new ConfiguredMappingInfo( SourceType: sourceType, @@ -1474,7 +1474,8 @@ private static bool HasEnumMappingAttribute( private static (IMethodSymbol? Constructor, List ParameterNames, List DefaultParameters) FindBestConstructor( INamedTypeSymbol sourceType, - INamedTypeSymbol targetType) + INamedTypeSymbol targetType, + List<(string Source, string Target)>? propertyRenames = null) { var constructors = targetType .Constructors @@ -1496,8 +1497,8 @@ private static (IMethodSymbol? Constructor, List ParameterNames, List - string.Equals(p.Name, parameter.Name, StringComparison.OrdinalIgnoreCase)); + var matchingSourceProperty = FindSourcePropertyForParameter( + sourceProperties, parameter.Name, propertyRenames); if (matchingSourceProperty is null) { @@ -1542,8 +1543,8 @@ private static (IMethodSymbol? Constructor, List ParameterNames, List - string.Equals(p.Name, parameter.Name, StringComparison.OrdinalIgnoreCase)); + var matchingSourceProperty = FindSourcePropertyForParameter( + sourceProperties, parameter.Name, propertyRenames); if (matchingSourceProperty is null) { @@ -1605,6 +1606,36 @@ private static string GetDefaultExpression(ITypeSymbol type) return "default!"; } + private static IPropertySymbol? FindSourcePropertyForParameter( + List sourceProperties, + string parameterName, + List<(string Source, string Target)>? propertyRenames) + { + // Direct name match + var match = sourceProperties.FirstOrDefault(p => + string.Equals(p.Name, parameterName, StringComparison.OrdinalIgnoreCase)); + + if (match is not null) + { + return match; + } + + // Check property renames: parameterName matches a rename Target → find source by rename Source + if (propertyRenames is not null) + { + var rename = propertyRenames.FirstOrDefault(r => + string.Equals(r.Target, parameterName, StringComparison.OrdinalIgnoreCase)); + + if (rename != default) + { + return sourceProperties.FirstOrDefault(p => + string.Equals(p.Name, rename.Source, StringComparison.OrdinalIgnoreCase)); + } + } + + return null; + } + private static bool AreBothCustomOrEnumTypes( ITypeSymbol sourceType, ITypeSymbol targetType) @@ -1921,11 +1952,14 @@ private static void GenerateMethodContent( { var targetTypeName = mapping.TargetType.ToDisplayString(); - sb.AppendLineLf(" if (source is null)"); - sb.AppendLineLf(" {"); - sb.AppendLineLf(" return default!;"); - sb.AppendLineLf(" }"); - sb.AppendLineLf(); + if (!mapping.SourceType.IsValueType) + { + sb.AppendLineLf(" if (source is null)"); + sb.AppendLineLf(" {"); + sb.AppendLineLf(" return default!;"); + sb.AppendLineLf(" }"); + sb.AppendLineLf(); + } var useConstructor = mapping.Constructor is not null && mapping.ConstructorParameterNames.Count > 0; @@ -2204,6 +2238,11 @@ private static string GeneratePropertyMappingValue( return $"{sourceVariable}.{prop.SourceProperty.Name}.{nestedMethodName}()"; } + if (prop.TargetProperty.Type.IsValueType) + { + return $"{sourceVariable}.{prop.SourceProperty.Name} is not null ? {sourceVariable}.{prop.SourceProperty.Name}.{nestedMethodName}() : default"; + } + return $"{sourceVariable}.{prop.SourceProperty.Name}?.{nestedMethodName}()!"; } diff --git a/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs b/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs index a3df90c..2c77e24 100644 --- a/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs @@ -1830,6 +1830,11 @@ private static string GeneratePropertyMappingValue( return $"{propertyAccess}.{nestedMethodName}()"; } + if (prop.TargetProperty.Type.IsValueType) + { + return $"{propertyAccess} is not null ? {propertyAccess}.{nestedMethodName}() : default"; + } + return $"{propertyAccess}?.{nestedMethodName}()!"; } diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs index 4f95257..3e0247d 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs @@ -798,4 +798,176 @@ public static partial class Mappings Assert.Contains("source.Location.MapToTargetPoint()", output, StringComparison.Ordinal); Assert.DoesNotContain("source.Location?.MapToTargetPoint()", output, StringComparison.Ordinal); } + + [Fact] + public void Config_Bug6_Source_With_Defaults_To_Target_Without_Defaults() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace Source + { + public sealed record SettingsWithDefaults( + string Name, + int Width = 100, + int Height = 200, + string Color = "#FFF"); + } + + namespace Target + { + public record SettingsNoDefaults( + string Name, + int Width, + int Height, + string Color); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial Target.SettingsNoDefaults MapToSettingsNoDefaults( + this Source.SettingsWithDefaults source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new Target.SettingsNoDefaults(", output, StringComparison.Ordinal); + Assert.Contains("source.Name", output, StringComparison.Ordinal); + Assert.Contains("source.Width", output, StringComparison.Ordinal); + Assert.Contains("source.Height", output, StringComparison.Ordinal); + Assert.Contains("source.Color", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Bug8a_Ref_To_Value_Type_Nested_Mapping() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace Source + { + public sealed record PositionDto(double X, double Y); + public sealed record ItemDto(string Name, PositionDto Location); + } + + namespace Target + { + public readonly struct Position + { + public Position(double x, double y) { X = x; Y = y; } + public double X { get; } + public double Y { get; } + } + + public record Item(string Name, Position Location); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial Target.Position MapToPosition(this Source.PositionDto source); + public static partial Target.Item MapToItem(this Source.ItemDto source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + // Should NOT use ?. when target type is value type + Assert.DoesNotContain("?.MapToPosition()!", output, StringComparison.Ordinal); + // Should use ternary or direct call pattern + Assert.Contains("MapToPosition()", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Bug8b_No_Null_Guard_For_Struct_Source() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace Source + { + public readonly struct Coordinate + { + public Coordinate(int x, int y) { X = x; Y = y; } + public int X { get; } + public int Y { get; } + } + } + + namespace Target + { + public sealed record CoordinateDto(int X, int Y); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial Target.CoordinateDto MapToCoordinateDto( + this Source.Coordinate source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new Target.CoordinateDto(", output, StringComparison.Ordinal); + // Should NOT have null guard for struct input + Assert.DoesNotContain("if (source is null)", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Bug8c_MapConfigProperty_With_Constructor_Uses_Named_Args() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace Source + { + public readonly struct GridPosition + { + public GridPosition(int x, int y) { X = x; Y = y; } + public int X { get; } + public int Y { get; } + } + } + + namespace Target + { + public sealed record GridPositionDto(int Row, int Column); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + [MapConfigProperty("X", "Column")] + [MapConfigProperty("Y", "Row")] + public static partial Target.GridPositionDto MapToGridPositionDto( + this Source.GridPosition source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + // Should use constructor syntax, not object initializer + Assert.Contains("new Target.GridPositionDto(", output, StringComparison.Ordinal); + // Should NOT use object initializer + Assert.DoesNotContain("new Target.GridPositionDto\n", output, StringComparison.Ordinal); + } } \ No newline at end of file From 34837ad31ab76715cc19eb0ae822aec405cc05b1 Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Fri, 13 Mar 2026 16:32:15 +0100 Subject: [PATCH 7/9] test: add reproduction tests for int-default-params-skipped issue --- ...pingConfigurationGeneratorAdvancedTests.cs | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs index 3e0247d..ad9f0dd 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs @@ -844,6 +844,116 @@ public static partial Target.SettingsNoDefaults MapToSettingsNoDefaults( Assert.Contains("source.Color", output, StringComparison.Ordinal); } + [Fact] + public void Config_Bug6_Mixed_Defaults_Int_Params_Not_Skipped() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace Source + { + public sealed record ConfigWithIntDefaults( + string Name, + int Width = 100, + int Height = 200, + double Scale = 1.5, + string Label = "default"); + } + + namespace Target + { + public record ConfigAllRequired( + string Name, + int Width, + int Height, + double Scale, + string Label); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial Target.ConfigAllRequired MapToConfigAllRequired( + this Source.ConfigWithIntDefaults source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new Target.ConfigAllRequired(", output, StringComparison.Ordinal); + Assert.Contains("source.Name,", output, StringComparison.Ordinal); + Assert.Contains("source.Width,", output, StringComparison.Ordinal); + Assert.Contains("source.Height,", output, StringComparison.Ordinal); + Assert.Contains("source.Scale,", output, StringComparison.Ordinal); + Assert.Contains("source.Label", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Bug6_Same_Named_Records_With_Mixed_Defaults() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace Generated.Models + { + public class GridSettings + { + public bool Show { get; set; } + } + + public sealed record DesignerSettings( + GridSettings GridLines, + int SizeHorizontal = 300, + int SizeVertical = 300, + int GridCountHorizontal = 6, + int GridCountVertical = 6, + string BackgroundColorInHex = "#FF333333"); + } + + namespace Domain.Models + { + public class GridSettings + { + public bool Show { get; set; } + } + + public record DesignerSettings( + GridSettings GridLines, + int SizeHorizontal, + int SizeVertical, + int GridCountHorizontal, + int GridCountVertical, + string BackgroundColorInHex); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial Domain.Models.GridSettings MapToDomainGridSettings( + this Generated.Models.GridSettings source); + public static partial Domain.Models.DesignerSettings MapToDomainDesignerSettings( + this Generated.Models.DesignerSettings source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new Domain.Models.DesignerSettings(", output, StringComparison.Ordinal); + Assert.Contains("source.SizeHorizontal,", output, StringComparison.Ordinal); + Assert.Contains("source.SizeVertical,", output, StringComparison.Ordinal); + Assert.Contains("source.GridCountHorizontal,", output, StringComparison.Ordinal); + Assert.Contains("source.GridCountVertical,", output, StringComparison.Ordinal); + Assert.Contains("source.BackgroundColorInHex", output, StringComparison.Ordinal); + } + [Fact] public void Config_Bug8a_Ref_To_Value_Type_Nested_Mapping() { From 36f75bd0b97cc6bf6706490288ecba7a2449acfc Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Fri, 13 Mar 2026 17:38:44 +0100 Subject: [PATCH 8/9] fix(Mapping): include private-setter properties from metadata types --- .../MappingConfigurationGenerator.cs | 4 +- .../Generators/ObjectMappingGenerator.cs | 2 +- ...pingConfigurationGeneratorAdvancedTests.cs | 125 ++++++++++++++++++ .../MappingConfigurationGeneratorTests.cs | 91 +++++++++++++ 4 files changed, 219 insertions(+), 3 deletions(-) diff --git a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs index 2522612..1ac33fa 100644 --- a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs @@ -573,7 +573,7 @@ attr.ConstructorArguments[0].Value is string sourcePropName && } // Validate renamed properties exist - var targetProperties = GetAllProperties(targetType, includePrivateMembers: false, requireSetter: true); + var targetProperties = GetAllProperties(targetType, includePrivateMembers: false); foreach (var rename in propertyRenames) { if (!sourceProperties.Any(p => string.Equals(p.Name, rename.Source, StringComparison.OrdinalIgnoreCase))) @@ -1106,7 +1106,7 @@ private static List GetPropertyMappingsWithConfig( var mappings = new List(); var sourceProperties = GetAllProperties(sourceType, includePrivateMembers: false); - var targetProperties = GetAllProperties(targetType, includePrivateMembers: false, requireSetter: true); + var targetProperties = GetAllProperties(targetType, includePrivateMembers: false); foreach (var sourceProp in sourceProperties) { diff --git a/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs b/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs index 2c77e24..975366e 100644 --- a/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs @@ -360,7 +360,7 @@ private static List GetPropertyMappings( var sourceProperties = GetAllProperties(sourceType, includePrivateMembers); // Collect properties from the target type including inherited properties - var targetProperties = GetAllProperties(targetType, includePrivateMembers, requireSetter: true); + var targetProperties = GetAllProperties(targetType, includePrivateMembers); foreach (var sourceProp in sourceProperties) { diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs index ad9f0dd..cff34b4 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs @@ -1080,4 +1080,129 @@ public static partial Target.GridPositionDto MapToGridPositionDto( // Should NOT use object initializer Assert.DoesNotContain("new Target.GridPositionDto\n", output, StringComparison.Ordinal); } + + [Fact] + public void Config_PrivateSetter_Properties_Included_When_Target_From_Assembly_Reference() + { + // Step 1: Compile target type with { get; private set; } to a DLL + var targetDll = CompileToAssembly(""" + namespace TestDomain + { + public record Settings( + string Name, + int Width = 100, + int Height = 200, + string Color = "#FFF") + { + public int Width { get; private set; } = Width; + public int Height { get; private set; } = Height; + } + } + """); + + // Step 2: Source code referencing the DLL type as target + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace TestGen + { + public sealed record Settings( + string Name, + int Width = 100, + int Height = 200, + string Color = "#FFF"); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial TestDomain.Settings MapToDomainSettings( + this TestGen.Settings source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutputWithAssemblyReference(source, targetDll); + + Assert.Empty(diagnostics); + Assert.Contains("new TestDomain.Settings(", output, StringComparison.Ordinal); + // All 4 constructor args must be emitted, including Width and Height (which have private setters) + Assert.Contains("source.Name", output, StringComparison.Ordinal); + Assert.Contains("source.Width", output, StringComparison.Ordinal); + Assert.Contains("source.Height", output, StringComparison.Ordinal); + Assert.Contains("source.Color", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_PrivateSetter_DesignerSettings_From_Assembly_Reference() + { + // Real-world reproduction: DesignerSettings with 4 int properties having private setters + var targetDll = CompileToAssembly(""" + namespace Linksoft.Data.Models + { + public class GridSettings + { + public bool Show { get; set; } + } + + public record DesignerSettings( + GridSettings GridLines, + int SizeHorizontal = 300, + int SizeVertical = 300, + int GridCountHorizontal = 6, + int GridCountVertical = 6, + string BackgroundColorInHex = "#FF333333") + { + public int SizeHorizontal { get; private set; } = SizeHorizontal; + public int SizeVertical { get; private set; } = SizeVertical; + public int GridCountHorizontal { get; private set; } = GridCountHorizontal; + public int GridCountVertical { get; private set; } = GridCountVertical; + } + } + """); + + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace Generated.Models + { + public class GridSettings + { + public bool Show { get; set; } + } + + public sealed record DesignerSettings( + GridSettings GridLines, + int SizeHorizontal = 300, + int SizeVertical = 300, + int GridCountHorizontal = 6, + int GridCountVertical = 6, + string BackgroundColorInHex = "#FF333333"); + } + + namespace MyApp.Mappings + { + [MappingConfiguration] + public static partial class Mappings + { + public static partial Linksoft.Data.Models.GridSettings MapToDomainGridSettings( + this Generated.Models.GridSettings source); + public static partial Linksoft.Data.Models.DesignerSettings MapToDomainDesignerSettings( + this Generated.Models.DesignerSettings source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutputWithAssemblyReference(source, targetDll); + + Assert.Empty(diagnostics); + Assert.Contains("new Linksoft.Data.Models.DesignerSettings(", output, StringComparison.Ordinal); + Assert.Contains("source.SizeHorizontal", output, StringComparison.Ordinal); + Assert.Contains("source.SizeVertical", output, StringComparison.Ordinal); + Assert.Contains("source.GridCountHorizontal", output, StringComparison.Ordinal); + Assert.Contains("source.GridCountVertical", output, StringComparison.Ordinal); + Assert.Contains("source.BackgroundColorInHex", output, StringComparison.Ordinal); + } } \ No newline at end of file diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorTests.cs index 7400a8c..53b1ed8 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorTests.cs @@ -51,4 +51,95 @@ private static (ImmutableArray Diagnostics, string Output) GetGenera return (allDiagnostics, output); } + + /// + /// Compiles source code to an in-memory assembly (DLL bytes) for use as a metadata reference. + /// This simulates types coming from a compiled assembly reference where Roslyn hides private members. + /// + private static byte[] CompileToAssembly(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var references = AppDomain + .CurrentDomain + .GetAssemblies() + .Where(assembly => !assembly.IsDynamic && + !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Cast(); + + var compilation = CSharpCompilation.Create( + "ExternalAssembly", + [syntaxTree], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + using var ms = new System.IO.MemoryStream(); + var result = compilation.Emit(ms); + if (!result.Success) + { + var errors = string.Join("\n", result.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.ToString())); + throw new InvalidOperationException($"Failed to compile assembly:\n{errors}"); + } + + return ms.ToArray(); + } + + /// + /// Runs the generator with an additional assembly reference (compiled DLL bytes). + /// This allows testing scenarios where target types come from metadata (not source), + /// which affects visibility of private setters. + /// + [SuppressMessage("", "S1854:Remove this useless assignment to local variable 'driver'", Justification = "OK")] + private static (ImmutableArray Diagnostics, string Output) GetGeneratedOutputWithAssemblyReference( + string source, + byte[] assemblyBytes, + string? diagnosticPrefix = null) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var references = AppDomain + .CurrentDomain + .GetAssemblies() + .Where(assembly => !assembly.IsDynamic && + !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Cast() + .ToList(); + + // Add the external assembly as a metadata reference + references.Add(MetadataReference.CreateFromImage(assemblyBytes)); + + var compilation = CSharpCompilation.Create( + "TestAssembly", + [syntaxTree], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var mappingConfigGenerator = new MappingConfigurationGenerator(); + var objectMappingGenerator = new ObjectMappingGenerator(); + var enumMappingGenerator = new EnumMappingGenerator(); + var driver = CSharpGeneratorDriver.Create(mappingConfigGenerator, objectMappingGenerator, enumMappingGenerator); + driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var generatorDiagnostics); + + var prefix = diagnosticPrefix ?? "ATCM"; + var allDiagnostics = outputCompilation + .GetDiagnostics() + .Concat(generatorDiagnostics) + .Where(d => d.Severity >= DiagnosticSeverity.Info && + d.Id.StartsWith(prefix, StringComparison.Ordinal)) + .ToImmutableArray(); + + var output = string.Join( + Constants.LineFeed, + outputCompilation + .SyntaxTrees + .Skip(1) + .Select(tree => tree.ToString())); + + return (allDiagnostics, output); + } } \ No newline at end of file From 91d8312942e9c7cfeb81f41ae4747235a7311c7b Mon Sep 17 00:00:00 2001 From: davidkallesen Date: Fri, 13 Mar 2026 23:40:26 +0100 Subject: [PATCH 9/9] feat(Mapping): support nullable types, Uri/enum conversions, and nested sub-method generation --- .../Internal/MappingTypeAnalyzer.cs | 99 ++- .../Generators/Internal/PropertyMapping.cs | 1 + .../MappingConfigurationGenerator.cs | 296 +++++++- .../Generators/ObjectMappingGenerator.cs | 4 + .../Helpers/EnumMappingHelper.cs | 13 +- ...pingConfigurationGeneratorAdvancedTests.cs | 7 + ...gurationGeneratorBuiltInConversionTests.cs | 647 ++++++++++++++++++ 7 files changed, 1023 insertions(+), 44 deletions(-) create mode 100644 test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorBuiltInConversionTests.cs diff --git a/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs b/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs index 6db9281..6400c94 100644 --- a/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs +++ b/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs @@ -24,21 +24,40 @@ internal static bool IsAttributeMatch( attributeClass.ContainingNamespace?.ToDisplayString() == expectedNamespace; } + internal static ITypeSymbol UnwrapNullable(ITypeSymbol type) + { + if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable) + { + return nullable.TypeArguments[0]; + } + + return type; + } + + internal static bool IsNullableValueType(ITypeSymbol type) + => type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }; + internal static bool IsEnumConversion( ITypeSymbol sourceType, ITypeSymbol targetType) - => sourceType.TypeKind == TypeKind.Enum && - targetType.TypeKind == TypeKind.Enum; + { + var unwrappedSource = UnwrapNullable(sourceType); + var unwrappedTarget = UnwrapNullable(targetType); + return unwrappedSource.TypeKind == TypeKind.Enum && + unwrappedTarget.TypeKind == TypeKind.Enum; + } internal static bool IsNestedMapping( ITypeSymbol sourceType, ITypeSymbol targetType) { - var st = sourceType.SpecialType.ToString(); - var tt = targetType.SpecialType.ToString(); + var unwrappedSource = UnwrapNullable(sourceType); + var unwrappedTarget = UnwrapNullable(targetType); + var st = unwrappedSource.SpecialType.ToString(); + var tt = unwrappedTarget.SpecialType.ToString(); - return sourceType.TypeKind is TypeKind.Class or TypeKind.Struct && - targetType.TypeKind is TypeKind.Class or TypeKind.Struct && + return unwrappedSource.TypeKind is TypeKind.Class or TypeKind.Struct && + unwrappedTarget.TypeKind is TypeKind.Class or TypeKind.Struct && !st.StartsWith("System", StringComparison.Ordinal) && !tt.StartsWith("System", StringComparison.Ordinal); } @@ -47,55 +66,81 @@ internal static bool IsBuiltInTypeConversion( ITypeSymbol sourceType, ITypeSymbol targetType) { - var sourceTypeName = sourceType.ToDisplayString(); - var targetTypeName = targetType.ToDisplayString(); + var sourceTypeName = UnwrapNullable(sourceType).ToDisplayString(); + var targetTypeName = UnwrapNullable(targetType).ToDisplayString(); - // DateTime/DateTimeOffset -> string + return IsStringToFromSystemTypeConversion(sourceTypeName, targetTypeName) || + IsStringToFromUriConversion(sourceTypeName, targetTypeName) || + IsStringToFromEnumConversion(sourceType, targetType, sourceTypeName, targetTypeName); + } + + private static bool IsStringToFromSystemTypeConversion( + string sourceTypeName, + string targetTypeName) + { + // DateTime/DateTimeOffset <-> string if ((sourceTypeName is "System.DateTime" or "System.DateTimeOffset") && targetTypeName == "string") { return true; } - // string -> DateTime/DateTimeOffset if (sourceTypeName == "string" && (targetTypeName is "System.DateTime" or "System.DateTimeOffset")) { return true; } - // Guid -> string - if (sourceTypeName == "System.Guid" && targetTypeName == "string") + // Guid <-> string + if ((sourceTypeName == "System.Guid" && targetTypeName == "string") || + (sourceTypeName == "string" && targetTypeName == "System.Guid")) { return true; } - // string -> Guid - if (sourceTypeName == "string" && targetTypeName == "System.Guid") + // Numeric types <-> string + if ((IsNumericType(sourceTypeName) && targetTypeName == "string") || + (sourceTypeName == "string" && IsNumericType(targetTypeName))) { return true; } - // Numeric types -> string - if (IsNumericType(sourceTypeName) && targetTypeName == "string") + // bool <-> string + if ((sourceTypeName == "bool" && targetTypeName == "string") || + (sourceTypeName == "string" && targetTypeName == "bool")) { return true; } - // string -> Numeric types - if (sourceTypeName == "string" && IsNumericType(targetTypeName)) - { - return true; - } + return false; + } + + private static bool IsStringToFromUriConversion( + string sourceTypeName, + string targetTypeName) + { + var normalizedSource = sourceTypeName.TrimEnd('?'); + var normalizedTarget = targetTypeName.TrimEnd('?'); + + return (normalizedSource == "string" && normalizedTarget == "System.Uri") || + (normalizedSource == "System.Uri" && normalizedTarget == "string"); + } + + private static bool IsStringToFromEnumConversion( + ITypeSymbol sourceType, + ITypeSymbol targetType, + string sourceTypeName, + string targetTypeName) + { + var normalizedSource = sourceTypeName.TrimEnd('?'); + var normalizedTarget = targetTypeName.TrimEnd('?'); - // bool -> string - if (sourceTypeName == "bool" && targetTypeName == "string") + if (normalizedSource == "string" && UnwrapNullable(targetType).TypeKind == TypeKind.Enum) { return true; } - // string -> bool - if (sourceTypeName == "string" && targetTypeName == "bool") + if (UnwrapNullable(sourceType).TypeKind == TypeKind.Enum && normalizedTarget == "string") { return true; } @@ -112,8 +157,8 @@ internal static bool IsNumericTypeConversion( ITypeSymbol sourceType, ITypeSymbol targetType) { - var sourceName = sourceType.ToDisplayString(); - var targetName = targetType.ToDisplayString(); + var sourceName = UnwrapNullable(sourceType).ToDisplayString(); + var targetName = UnwrapNullable(targetType).ToDisplayString(); return sourceName != targetName && IsNumericType(sourceName) && IsNumericType(targetName); } diff --git a/src/Atc.SourceGenerators/Generators/Internal/PropertyMapping.cs b/src/Atc.SourceGenerators/Generators/Internal/PropertyMapping.cs index f6113d8..0fed00c 100644 --- a/src/Atc.SourceGenerators/Generators/Internal/PropertyMapping.cs +++ b/src/Atc.SourceGenerators/Generators/Internal/PropertyMapping.cs @@ -8,6 +8,7 @@ internal sealed record PropertyMapping( bool HasEnumMapping, bool IsCollection, ITypeSymbol? CollectionElementType, + ITypeSymbol? SourceCollectionElementType, string? CollectionTargetType, bool IsFlattened, IPropertySymbol? FlattenedNestedProperty, diff --git a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs index 1ac33fa..545a113 100644 --- a/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/MappingConfigurationGenerator.cs @@ -1140,6 +1140,7 @@ private static List GetPropertyMappingsWithConfig( HasEnumMapping: false, IsCollection: false, CollectionElementType: null, + SourceCollectionElementType: null, CollectionTargetType: null, IsFlattened: false, FlattenedNestedProperty: null, @@ -1184,6 +1185,7 @@ sourceElementType is INamedTypeSymbol sourceElementEnumType && HasEnumMapping: hasCollectionEnumMapping, IsCollection: true, CollectionElementType: targetElementType, + SourceCollectionElementType: sourceElementType, CollectionTargetType: MappingTypeAnalyzer.GetCollectionTargetType(targetProp.Type), IsFlattened: false, FlattenedNestedProperty: null, @@ -1198,16 +1200,18 @@ sourceElementType is INamedTypeSymbol sourceElementEnumType && var isBuiltInTypeConversion = MappingTypeAnalyzer.IsBuiltInTypeConversion(sourceProp.Type, targetProp.Type); var isNumericConversion = MappingTypeAnalyzer.IsNumericTypeConversion(sourceProp.Type, targetProp.Type); - // Auto-detect enum mapping + // Auto-detect enum mapping (unwrap nullable for enum checks) var hasEnumMapping = false; if (requiresConversion) { - hasEnumMapping = HasEnumMappingAttribute(sourceProp.Type, targetProp.Type); + var unwrappedSourceType = MappingTypeAnalyzer.UnwrapNullable(sourceProp.Type); + var unwrappedTargetType = MappingTypeAnalyzer.UnwrapNullable(targetProp.Type); + hasEnumMapping = HasEnumMappingAttribute(unwrappedSourceType, unwrappedTargetType); // If no explicit mapping, try auto-detection if (!hasEnumMapping && - sourceProp.Type is INamedTypeSymbol sourceEnumType && - targetProp.Type is INamedTypeSymbol targetEnumType) + unwrappedSourceType is INamedTypeSymbol sourceEnumType && + unwrappedTargetType is INamedTypeSymbol targetEnumType) { var autoMapping = TryAutoDetectEnumMapping( sourceEnumType, targetEnumType, autoDetectedEnums, context); @@ -1215,6 +1219,12 @@ sourceProp.Type is INamedTypeSymbol sourceEnumType && } } + // Built-in type conversions take precedence over nested mapping detection + if (isBuiltInTypeConversion) + { + isNested = false; + } + if (requiresConversion || isNested || isBuiltInTypeConversion || isNumericConversion) { mappings.Add(new PropertyMapping( @@ -1225,6 +1235,7 @@ sourceProp.Type is INamedTypeSymbol sourceEnumType && HasEnumMapping: hasEnumMapping, IsCollection: false, CollectionElementType: null, + SourceCollectionElementType: null, CollectionTargetType: null, IsFlattened: false, FlattenedNestedProperty: null, @@ -1283,6 +1294,7 @@ sourceProp.Type is INamedTypeSymbol sourceEnumType && HasEnumMapping: false, IsCollection: false, CollectionElementType: null, + SourceCollectionElementType: null, CollectionTargetType: null, IsFlattened: true, FlattenedNestedProperty: nestedProp, @@ -1515,6 +1527,7 @@ private static (IMethodSymbol? Constructor, List ParameterNames, List properties var isReferenced = mappings.Any(m => m.PropertyMappings.Any(p => (p.RequiresConversion && p.HasEnumMapping && - SymbolEqualityComparer.Default.Equals(p.SourceProperty.Type, enumMapping.SourceEnum) && - SymbolEqualityComparer.Default.Equals(p.TargetProperty.Type, enumMapping.TargetEnum)) || + SymbolEqualityComparer.Default.Equals(MappingTypeAnalyzer.UnwrapNullable(p.SourceProperty.Type), enumMapping.SourceEnum) && + SymbolEqualityComparer.Default.Equals(MappingTypeAnalyzer.UnwrapNullable(p.TargetProperty.Type), enumMapping.TargetEnum)) || (p.IsCollection && p.HasEnumMapping && MappingTypeAnalyzer.IsCollectionType(p.SourceProperty.Type, out var srcElem) && @@ -2158,6 +2178,137 @@ srcElem is not null && } } + private static void GenerateCollectionElementTypeMappingMethods( + StringBuilder sb, + List mappings, + List autoDetectedEnums, + SourceProductionContext context) + { + var generated = new HashSet(StringComparer.Ordinal); + + // Collect all top-level mapping pairs (source -> target) so we don't duplicate them + var topLevelMappingPairs = new HashSet(StringComparer.Ordinal); + foreach (var mapping in mappings) + { + topLevelMappingPairs.Add($"{mapping.SourceType.ToDisplayString()}->{mapping.TargetType.ToDisplayString()}"); + } + + // Use a queue for recursive processing of nested type pairs + var queue = new Queue<(INamedTypeSymbol Source, INamedTypeSymbol Target)>(); + CollectNestedTypePairs(mappings, queue); + + // Track sub-mappings for enum method generation + var subMappings = new List(); + + while (queue.Count > 0) + { + var (sourceElementType, targetElementType) = queue.Dequeue(); + + // Strip nullable annotation from display strings to match top-level pairs correctly + var sourceDisplay = sourceElementType.ToDisplayString().TrimEnd('?'); + var targetDisplay = targetElementType.ToDisplayString().TrimEnd('?'); + var key = $"{sourceDisplay}->{targetDisplay}"; + + // Skip if already generated or if there's already a top-level mapping for this pair + if (!generated.Add(key) || topLevelMappingPairs.Contains(key)) + { + continue; + } + + // Generate a nested mapping method for this type pair + var methodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(targetElementType.Name)}"; + var elementPropertyMappings = GetPropertyMappingsWithConfig( + sourceElementType, + targetElementType, + enableFlattening: false, + PropertyNameStrategy.PascalCase, + [], + [], + autoDetectedEnums, + context); + + var (constructor, constructorParameterNames, constructorDefaultParameters) = FindBestConstructor(sourceElementType, targetElementType); + + var elementMapping = new ConfiguredMappingInfo( + SourceType: sourceElementType, + TargetType: targetElementType, + Method: null, + ContainingClass: null, + PropertyMappings: elementPropertyMappings, + Bidirectional: false, + EnableFlattening: false, + Constructor: constructor, + ConstructorParameterNames: constructorParameterNames, + ConstructorDefaultParameters: constructorDefaultParameters, + PropertyNameStrategy: PropertyNameStrategy.PascalCase, + IgnoredProperties: [], + PropertyRenames: []); + + sb.AppendLineLf(" /// "); + sb.AppendLineLf($" /// Maps to ."); + sb.AppendLineLf(" /// "); + sb.AppendLineLf($" private static {targetElementType.ToDisplayString()} {methodName}("); + sb.AppendLineLf($" this {sourceElementType.ToDisplayString()} source)"); + sb.AppendLineLf(" {"); + + GenerateMethodContent(sb, elementMapping); + + sb.AppendLineLf(" }"); + sb.AppendLineLf(); + + subMappings.Add(elementMapping); + + // Recursively discover nested types within this sub-method's properties + CollectNestedTypePairs([elementMapping], queue); + } + + // Generate enum mapping methods discovered during sub-method generation + if (subMappings.Count > 0) + { + GenerateAutoDetectedEnumMethods(sb, autoDetectedEnums, subMappings); + } + } + + private static void CollectNestedTypePairs( + List mappings, + Queue<(INamedTypeSymbol Source, INamedTypeSymbol Target)> queue) + { + foreach (var mapping in mappings) + { + foreach (var prop in mapping.PropertyMappings) + { + // Collection element type pairs + if (prop.IsCollection && + prop.SourceCollectionElementType is not null && + prop.CollectionElementType is not null && + !SymbolEqualityComparer.Default.Equals(prop.SourceCollectionElementType, prop.CollectionElementType) && + !prop.HasEnumMapping && + prop.SourceCollectionElementType is INamedTypeSymbol sourceElem && + prop.CollectionElementType is INamedTypeSymbol targetElem && + sourceElem.SpecialType == SpecialType.None && + targetElem.SpecialType == SpecialType.None) + { + queue.Enqueue((sourceElem, targetElem)); + } + + // Non-collection nested property type pairs + if (prop.IsNested) + { + var unwrappedSource = MappingTypeAnalyzer.UnwrapNullable(prop.SourceProperty.Type) as INamedTypeSymbol; + var unwrappedTarget = MappingTypeAnalyzer.UnwrapNullable(prop.TargetProperty.Type) as INamedTypeSymbol; + + if (unwrappedSource is not null && + unwrappedTarget is not null && + unwrappedSource.SpecialType == SpecialType.None && + unwrappedTarget.SpecialType == SpecialType.None) + { + queue.Enqueue((unwrappedSource, unwrappedTarget)); + } + } + } + } + } + private static void GeneratePropertyInitializers( StringBuilder sb, List properties) @@ -2197,48 +2348,103 @@ private static string GeneratePropertyMappingValue( if (prop.IsCollection) { + var collectionType = prop.CollectionTargetType!; + var sourceAccess = $"{sourceVariable}.{prop.SourceProperty.Name}"; + + // Check if source and target element types are the same - skip Select if so + var sameElementType = prop.SourceCollectionElementType is not null && + prop.CollectionElementType is not null && + SymbolEqualityComparer.Default.Equals(prop.SourceCollectionElementType, prop.CollectionElementType); + + if (sameElementType) + { + // Same element type - just convert the collection type + if (collectionType == "Array") + { + return $"{sourceAccess}?.ToArray()!"; + } + + if (collectionType == "Collection") + { + return $"new global::System.Collections.ObjectModel.Collection<{prop.CollectionElementType!.ToDisplayString()}>({sourceAccess}?.ToList()!)"; + } + + if (collectionType == "ReadOnlyCollection") + { + return $"new global::System.Collections.ObjectModel.ReadOnlyCollection<{prop.CollectionElementType!.ToDisplayString()}>({sourceAccess}?.ToList()!)"; + } + + return $"{sourceAccess}?.ToList()!"; + } + var elementTypeName = prop.CollectionElementType!.Name; var mappingMethodName = $"MapTo{elementTypeName}"; - var collectionType = prop.CollectionTargetType!; if (collectionType == "Array") { - return $"{sourceVariable}.{prop.SourceProperty.Name}?.Select(x => x.{mappingMethodName}()).ToArray()!"; + return $"{sourceAccess}?.Select(x => x.{mappingMethodName}()).ToArray()!"; } if (collectionType == "Collection") { - return $"new global::System.Collections.ObjectModel.Collection<{prop.CollectionElementType.ToDisplayString()}>({sourceVariable}.{prop.SourceProperty.Name}?.Select(x => x.{mappingMethodName}()).ToList()!)"; + return $"new global::System.Collections.ObjectModel.Collection<{prop.CollectionElementType.ToDisplayString()}>({sourceAccess}?.Select(x => x.{mappingMethodName}()).ToList()!)"; } if (collectionType == "ReadOnlyCollection") { - return $"new global::System.Collections.ObjectModel.ReadOnlyCollection<{prop.CollectionElementType.ToDisplayString()}>({sourceVariable}.{prop.SourceProperty.Name}?.Select(x => x.{mappingMethodName}()).ToList()!)"; + return $"new global::System.Collections.ObjectModel.ReadOnlyCollection<{prop.CollectionElementType.ToDisplayString()}>({sourceAccess}?.Select(x => x.{mappingMethodName}()).ToList()!)"; } - return $"{sourceVariable}.{prop.SourceProperty.Name}?.Select(x => x.{mappingMethodName}()).ToList()!"; + return $"{sourceAccess}?.Select(x => x.{mappingMethodName}()).ToList()!"; } if (prop.RequiresConversion) { if (prop.HasEnumMapping) { - var enumMappingMethodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(prop.TargetProperty.Type.Name)}"; + var sourceIsNullable = MappingTypeAnalyzer.IsNullableValueType(prop.SourceProperty.Type); + var targetIsNullable = MappingTypeAnalyzer.IsNullableValueType(prop.TargetProperty.Type); + var unwrappedTarget = MappingTypeAnalyzer.UnwrapNullable(prop.TargetProperty.Type); + var enumMappingMethodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(unwrappedTarget.Name)}"; + + if (sourceIsNullable && targetIsNullable) + { + var varName = $"__{char.ToLowerInvariant(prop.SourceProperty.Name[0])}{prop.SourceProperty.Name.Substring(1)}"; + return $"{sourceVariable}.{prop.SourceProperty.Name} is {{ }} {varName} ? {varName}.{enumMappingMethodName}() : null"; + } + + if (sourceIsNullable) + { + return $"{sourceVariable}.{prop.SourceProperty.Name}!.Value.{enumMappingMethodName}()"; + } + return $"{sourceVariable}.{prop.SourceProperty.Name}.{enumMappingMethodName}()"; } + // Numeric conversion - handle nullable wrapping return $"({prop.TargetProperty.Type.ToDisplayString()}){sourceVariable}.{prop.SourceProperty.Name}"; } if (prop.IsNested) { - var nestedMethodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(prop.TargetProperty.Type.Name)}"; - if (prop.SourceProperty.Type.IsValueType) + var unwrappedSourceType = MappingTypeAnalyzer.UnwrapNullable(prop.SourceProperty.Type); + var unwrappedTargetType = MappingTypeAnalyzer.UnwrapNullable(prop.TargetProperty.Type); + var nestedMethodName = $"MapTo{TypeNameSanitizer.SanitizeForIdentifier(unwrappedTargetType.Name)}"; + var sourceIsNullable = MappingTypeAnalyzer.IsNullableValueType(prop.SourceProperty.Type); + var targetIsNullable = MappingTypeAnalyzer.IsNullableValueType(prop.TargetProperty.Type); + + if (sourceIsNullable && targetIsNullable) + { + var varName = $"__{char.ToLowerInvariant(prop.SourceProperty.Name[0])}{prop.SourceProperty.Name.Substring(1)}"; + return $"{sourceVariable}.{prop.SourceProperty.Name} is {{ }} {varName} ? {varName}.{nestedMethodName}() : null"; + } + + if (prop.SourceProperty.Type.IsValueType && !sourceIsNullable) { return $"{sourceVariable}.{prop.SourceProperty.Name}.{nestedMethodName}()"; } - if (prop.TargetProperty.Type.IsValueType) + if (prop.TargetProperty.Type.IsValueType && !targetIsNullable) { return $"{sourceVariable}.{prop.SourceProperty.Name} is not null ? {sourceVariable}.{prop.SourceProperty.Name}.{nestedMethodName}() : default"; } @@ -2257,6 +2463,14 @@ private static string GenerateBuiltInTypeConversion( var targetTypeName = prop.TargetProperty.Type.ToDisplayString(); var sourcePropertyAccess = $"{sourceVariable}.{prop.SourceProperty.Name}"; + // Normalize type names by stripping nullable annotation for matching + var normalizedSource = sourceTypeName.TrimEnd('?'); + var normalizedTarget = targetTypeName.TrimEnd('?'); + var sourceIsNullableRef = sourceTypeName.EndsWith("?", StringComparison.Ordinal) && + !prop.SourceProperty.Type.IsValueType; + var targetIsNullableRef = targetTypeName.EndsWith("?", StringComparison.Ordinal) && + !prop.TargetProperty.Type.IsValueType; + if (sourceTypeName is "System.DateTime" or "System.DateTimeOffset" && targetTypeName == "string") { return $"{sourcePropertyAccess}.ToString(\"O\", global::System.Globalization.CultureInfo.InvariantCulture)"; @@ -2304,6 +2518,58 @@ private static string GenerateBuiltInTypeConversion( return $"bool.Parse({sourcePropertyAccess})"; } + // string <-> Uri conversions + if (normalizedSource == "string" && normalizedTarget == "System.Uri") + { + if (sourceIsNullableRef || targetIsNullableRef) + { + return $"{sourcePropertyAccess} is not null ? new global::System.Uri({sourcePropertyAccess}) : null"; + } + + return $"new global::System.Uri({sourcePropertyAccess})"; + } + + if (normalizedSource == "System.Uri" && normalizedTarget == "string") + { + if (sourceIsNullableRef) + { + return $"{sourcePropertyAccess}?.ToString()"; + } + + return $"{sourcePropertyAccess}.ToString()"; + } + + // string <-> enum conversions + var unwrappedSourceType = MappingTypeAnalyzer.UnwrapNullable(prop.SourceProperty.Type); + var unwrappedTargetType = MappingTypeAnalyzer.UnwrapNullable(prop.TargetProperty.Type); + + if (normalizedSource == "string" && unwrappedTargetType.TypeKind == TypeKind.Enum) + { + var enumType = unwrappedTargetType.ToDisplayString(); + var sourceIsNullable = sourceIsNullableRef || + prop.SourceProperty.Type.NullableAnnotation == NullableAnnotation.Annotated; + var targetIsNullableValue = MappingTypeAnalyzer.IsNullableValueType(prop.TargetProperty.Type); + + if (sourceIsNullable && targetIsNullableValue) + { + var varName = $"__{char.ToLowerInvariant(prop.SourceProperty.Name[0])}{prop.SourceProperty.Name.Substring(1)}"; + return $"{sourcePropertyAccess} is {{ }} {varName} ? (global::System.Enum.TryParse({varName}, out var __parsed) ? __parsed : default({enumType}?)) : null"; + } + + return $"global::System.Enum.TryParse({sourcePropertyAccess}, out var __parsed) ? __parsed : default(global::{enumType})"; + } + + if (unwrappedSourceType.TypeKind == TypeKind.Enum && normalizedTarget == "string") + { + var sourceIsNullableValue = MappingTypeAnalyzer.IsNullableValueType(prop.SourceProperty.Type); + if (sourceIsNullableValue) + { + return $"{sourcePropertyAccess}?.ToString()"; + } + + return $"{sourcePropertyAccess}.ToString()"; + } + return sourcePropertyAccess; } diff --git a/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs b/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs index 975366e..6f0d8d4 100644 --- a/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs +++ b/src/Atc.SourceGenerators/Generators/ObjectMappingGenerator.cs @@ -405,6 +405,7 @@ private static List GetPropertyMappings( HasEnumMapping: false, IsCollection: false, CollectionElementType: null, + SourceCollectionElementType: null, CollectionTargetType: null, IsFlattened: false, FlattenedNestedProperty: null, @@ -436,6 +437,7 @@ private static List GetPropertyMappings( HasEnumMapping: false, IsCollection: true, CollectionElementType: targetElementType, + SourceCollectionElementType: sourceElementType, CollectionTargetType: GetCollectionTargetType(targetProp.Type), IsFlattened: false, FlattenedNestedProperty: null, @@ -473,6 +475,7 @@ private static List GetPropertyMappings( HasEnumMapping: hasEnumMapping, IsCollection: false, CollectionElementType: null, + SourceCollectionElementType: null, CollectionTargetType: null, IsFlattened: false, FlattenedNestedProperty: null, @@ -536,6 +539,7 @@ private static List GetPropertyMappings( HasEnumMapping: false, IsCollection: false, CollectionElementType: null, + SourceCollectionElementType: null, CollectionTargetType: null, IsFlattened: true, FlattenedNestedProperty: nestedProp, diff --git a/src/Atc.SourceGenerators/Helpers/EnumMappingHelper.cs b/src/Atc.SourceGenerators/Helpers/EnumMappingHelper.cs index 73bbb98..fe25e65 100644 --- a/src/Atc.SourceGenerators/Helpers/EnumMappingHelper.cs +++ b/src/Atc.SourceGenerators/Helpers/EnumMappingHelper.cs @@ -102,7 +102,16 @@ private static List GetEnumValues(INamedTypeSymbol enumSymbol) return caseInsensitiveMatch; } - // 3. Try special case mappings + // 3. Try match after removing underscores (e.g., Block_DE_L2 -> BlockDEL2 matches BlockDeL2) + var normalizedSource = sourceValue.Replace("_", string.Empty); + var underscoreMatch = targetValues.FirstOrDefault(t => + t.Replace("_", string.Empty).Equals(normalizedSource, StringComparison.OrdinalIgnoreCase)); + if (underscoreMatch is not null) + { + return underscoreMatch; + } + + // 4. Try special case mappings if (SpecialCaseMappings.TryGetValue(sourceValue, out var specialCases)) { foreach (var specialCase in specialCases) @@ -116,7 +125,7 @@ private static List GetEnumValues(INamedTypeSymbol enumSymbol) } } - // 4. No match found + // 5. No match found return null; } diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs index cff34b4..79b8924 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs @@ -460,6 +460,7 @@ public static partial class Mappings var (diagnostics, output) = GetGeneratedOutput(source); Assert.Empty(diagnostics); + // Should use object initializer, not constructor (class has parameterless constructor) Assert.Contains("new MyApp.TargetItem", output, StringComparison.Ordinal); Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); @@ -992,8 +993,10 @@ public static partial class Mappings var (diagnostics, output) = GetGeneratedOutput(source); Assert.Empty(diagnostics); + // Should NOT use ?. when target type is value type Assert.DoesNotContain("?.MapToPosition()!", output, StringComparison.Ordinal); + // Should use ternary or direct call pattern Assert.Contains("MapToPosition()", output, StringComparison.Ordinal); } @@ -1034,6 +1037,7 @@ public static partial Target.CoordinateDto MapToCoordinateDto( Assert.Empty(diagnostics); Assert.Contains("new Target.CoordinateDto(", output, StringComparison.Ordinal); + // Should NOT have null guard for struct input Assert.DoesNotContain("if (source is null)", output, StringComparison.Ordinal); } @@ -1075,8 +1079,10 @@ public static partial Target.GridPositionDto MapToGridPositionDto( var (diagnostics, output) = GetGeneratedOutput(source); Assert.Empty(diagnostics); + // Should use constructor syntax, not object initializer Assert.Contains("new Target.GridPositionDto(", output, StringComparison.Ordinal); + // Should NOT use object initializer Assert.DoesNotContain("new Target.GridPositionDto\n", output, StringComparison.Ordinal); } @@ -1128,6 +1134,7 @@ public static partial TestDomain.Settings MapToDomainSettings( Assert.Empty(diagnostics); Assert.Contains("new TestDomain.Settings(", output, StringComparison.Ordinal); + // All 4 constructor args must be emitted, including Width and Height (which have private setters) Assert.Contains("source.Name", output, StringComparison.Ordinal); Assert.Contains("source.Width", output, StringComparison.Ordinal); diff --git a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorBuiltInConversionTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorBuiltInConversionTests.cs new file mode 100644 index 0000000..6ce32b7 --- /dev/null +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorBuiltInConversionTests.cs @@ -0,0 +1,647 @@ +// ReSharper disable StringLiteralTypo +namespace Atc.SourceGenerators.Tests.Generators.MappingConfiguration; + +public partial class MappingConfigurationGeneratorTests +{ + [Fact] + public void Config_Should_Map_String_To_Uri() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace SourceNs + { + public class Item + { + public string Link { get; set; } = string.Empty; + } + } + + namespace TargetNs + { + public class Item + { + public Uri Link { get; set; } + } + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class ItemMappings + { + public static partial TargetNs.Item MapToItem(this SourceNs.Item source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("new global::System.Uri(source.Link)", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Map_Uri_To_String() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + namespace SourceNs + { + public class Item + { + public Uri Link { get; set; } + } + } + + namespace TargetNs + { + public class Item + { + public string Link { get; set; } = string.Empty; + } + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class ItemMappings + { + public static partial TargetNs.Item MapToItem(this SourceNs.Item source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("source.Link.ToString()", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Map_Nullable_String_To_Nullable_Uri() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + #nullable enable + + namespace SourceNs + { + public class Item + { + public string? Link { get; set; } + } + } + + namespace TargetNs + { + public class Item + { + public Uri? Link { get; set; } + } + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class ItemMappings + { + public static partial TargetNs.Item MapToItem(this SourceNs.Item source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("is not null ? new global::System.Uri(source.Link) : null", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Map_Nullable_Uri_To_Nullable_String() + { + const string source = """ + using System; + using Atc.SourceGenerators.Annotations; + + #nullable enable + + namespace SourceNs + { + public class Item + { + public Uri? Link { get; set; } + } + } + + namespace TargetNs + { + public class Item + { + public string? Link { get; set; } + } + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class ItemMappings + { + public static partial TargetNs.Item MapToItem(this SourceNs.Item source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("source.Link?.ToString()", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Map_String_To_Enum() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace SourceNs + { + public class Item + { + public string Status { get; set; } = string.Empty; + } + } + + namespace TargetNs + { + public enum StatusType + { + None, + Active, + Inactive, + } + + public class Item + { + public StatusType Status { get; set; } + } + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class ItemMappings + { + public static partial TargetNs.Item MapToItem(this SourceNs.Item source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("Enum.TryParse(source.Status, out var __parsed)", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Map_Enum_To_String() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace SourceNs + { + public enum StatusType + { + None, + Active, + Inactive, + } + + public class Item + { + public StatusType Status { get; set; } + } + } + + namespace TargetNs + { + public class Item + { + public string Status { get; set; } = string.Empty; + } + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class ItemMappings + { + public static partial TargetNs.Item MapToItem(this SourceNs.Item source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("source.Status.ToString()", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Map_Nullable_Enum_To_String() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace SourceNs + { + public enum StatusType + { + None, + Active, + Inactive, + } + + public class Item + { + public StatusType? Status { get; set; } + } + } + + namespace TargetNs + { + public class Item + { + public string Status { get; set; } = string.Empty; + } + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class ItemMappings + { + public static partial TargetNs.Item MapToItem(this SourceNs.Item source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("source.Status?.ToString()", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Map_Decimal_To_Double() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace SourceNs + { + public class Item + { + public decimal Value { get; set; } + } + } + + namespace TargetNs + { + public class Item + { + public double Value { get; set; } + } + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class ItemMappings + { + public static partial TargetNs.Item MapToItem(this SourceNs.Item source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("(double)source.Value", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Map_Nullable_Decimal_To_Nullable_Double() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace SourceNs + { + public class Item + { + public decimal? Value { get; set; } + } + } + + namespace TargetNs + { + public class Item + { + public double? Value { get; set; } + } + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class ItemMappings + { + public static partial TargetNs.Item MapToItem(this SourceNs.Item source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("(double?)source.Value", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Map_SameShape_DifferentNamespace_Nested() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace NS1 + { + public class KeyValue + { + public string Key { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + } + + public class Container + { + public KeyValue Item { get; set; } + } + } + + namespace NS2 + { + public class KeyValue + { + public string Key { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + } + + public class Container + { + public KeyValue Item { get; set; } + } + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class ContainerMappings + { + public static partial NS2.Container MapToContainer(this NS1.Container source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains("MapToKeyValue", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Map_SameShape_Collection() + { + const string source = """ + using System.Collections.Generic; + using Atc.SourceGenerators.Annotations; + + namespace NS1 + { + public class Item + { + public string Name { get; set; } = string.Empty; + } + + public class Container + { + public List Items { get; set; } = new(); + } + } + + namespace NS2 + { + public class Item + { + public string Name { get; set; } = string.Empty; + } + + public class Container + { + public List Items { get; set; } = new(); + } + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class ContainerMappings + { + public static partial NS2.Container MapToContainer(this NS1.Container source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + Assert.Contains(".Select(x => x.MapToItem()).ToList()", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Generate_Nested_Property_SubMethod() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace SourceNs + { + public class Inner + { + public string Name { get; set; } = string.Empty; + public int Count { get; set; } + } + + public class Outer + { + public string Title { get; set; } = string.Empty; + public Inner Detail { get; set; } + } + } + + namespace TargetNs + { + public class Inner + { + public string Name { get; set; } = string.Empty; + public int Count { get; set; } + } + + public class Outer + { + public string Title { get; set; } = string.Empty; + public Inner Detail { get; set; } + } + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class OuterMappings + { + public static partial TargetNs.Outer MapToOuter(this SourceNs.Outer source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Should generate the top-level mapping + Assert.Contains("MapToOuter", output, StringComparison.Ordinal); + + // Should generate a sub-method for the nested type + Assert.Contains("MapToInner", output, StringComparison.Ordinal); + Assert.Contains("Name = source.Name", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Generate_DeeplyNested_SubMethods() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace SourceNs + { + public class Price + { + public decimal Amount { get; set; } + public string Currency { get; set; } = string.Empty; + } + + public class PurchaseInfo + { + public string Vendor { get; set; } = string.Empty; + public Price Cost { get; set; } + } + + public class Order + { + public string Name { get; set; } = string.Empty; + public PurchaseInfo Purchase { get; set; } + } + } + + namespace TargetNs + { + public class Price + { + public decimal Amount { get; set; } + public string Currency { get; set; } = string.Empty; + } + + public class PurchaseInfo + { + public string Vendor { get; set; } = string.Empty; + public Price Cost { get; set; } + } + + public class Order + { + public string Name { get; set; } = string.Empty; + public PurchaseInfo Purchase { get; set; } + } + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class OrderMappings + { + public static partial TargetNs.Order MapToOrder(this SourceNs.Order source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Should generate sub-methods for all levels + Assert.Contains("MapToOrder", output, StringComparison.Ordinal); + Assert.Contains("MapToPurchaseInfo", output, StringComparison.Ordinal); + Assert.Contains("MapToPrice", output, StringComparison.Ordinal); + } + + [Fact] + public void Config_Should_Map_String_To_Enum_In_RecordConstructor() + { + const string source = """ + using Atc.SourceGenerators.Annotations; + + namespace SourceNs + { + public class Item + { + public string Status { get; set; } = string.Empty; + public decimal Value { get; set; } + } + } + + namespace TargetNs + { + public enum StatusType + { + None, + Active, + Inactive, + } + + public record Item(StatusType Status, double Value); + } + + namespace Mappings + { + [MappingConfiguration] + public static partial class ItemMappings + { + public static partial TargetNs.Item MapToItem(this SourceNs.Item source); + } + } + """; + + var (diagnostics, output) = GetGeneratedOutput(source); + + Assert.Empty(diagnostics); + + // Should use constructor invocation with TryParse for enum and cast for decimal->double + Assert.Contains("Enum.TryParse(source.Status, out var __parsed)", output, StringComparison.Ordinal); + Assert.Contains("(double)source.Value", output, StringComparison.Ordinal); + } +} \ No newline at end of file