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/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/Internal/MappingTypeAnalyzer.cs b/src/Atc.SourceGenerators/Generators/Internal/MappingTypeAnalyzer.cs index 2c5b01b..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; } @@ -108,6 +153,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 = UnwrapNullable(sourceType).ToDisplayString(); + var targetName = UnwrapNullable(targetType).ToDisplayString(); + return sourceName != targetName && IsNumericType(sourceName) && IsNumericType(targetName); + } + internal static bool IsCollectionType( ITypeSymbol type, out ITypeSymbol? elementType) @@ -189,4 +243,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/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 4611129..545a113 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)>(); @@ -541,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))) @@ -577,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, propertyRenames); return new ConfiguredMappingInfo( SourceType: sourceType, @@ -589,6 +621,7 @@ attr.ConstructorArguments[0].Value is string sourcePropName && EnableFlattening: enableFlattening, Constructor: constructor, ConstructorParameterNames: constructorParameterNames, + ConstructorDefaultParameters: constructorDefaultParameters, PropertyNameStrategy: propertyNameStrategy, IgnoredProperties: ignoredProperties, PropertyRenames: propertyRenames); @@ -717,6 +750,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) { @@ -785,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, propertyRenames); return new ConfiguredMappingInfo( SourceType: sourceType, @@ -797,6 +843,7 @@ attr.ConstructorArguments[0].Value is string sourcePropName && EnableFlattening: false, Constructor: constructor, ConstructorParameterNames: constructorParameterNames, + ConstructorDefaultParameters: constructorDefaultParameters, PropertyNameStrategy: propertyNameStrategy, IgnoredProperties: ignoredProperties, PropertyRenames: propertyRenames); @@ -877,6 +924,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) { @@ -943,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, propertyRenames); return new ConfiguredMappingInfo( SourceType: sourceType, @@ -955,6 +1025,7 @@ exprElement.Expression is LiteralExpressionSyntax literal && EnableFlattening: false, Constructor: constructor, ConstructorParameterNames: constructorParameterNames, + ConstructorDefaultParameters: constructorDefaultParameters, PropertyNameStrategy: propertyNameStrategy, IgnoredProperties: ignoredProperties, PropertyRenames: propertyRenames); @@ -1035,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) { @@ -1069,6 +1140,7 @@ private static List GetPropertyMappingsWithConfig( HasEnumMapping: false, IsCollection: false, CollectionElementType: null, + SourceCollectionElementType: null, CollectionTargetType: null, IsFlattened: false, FlattenedNestedProperty: null, @@ -1090,14 +1162,30 @@ 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, + SourceCollectionElementType: sourceElementType, CollectionTargetType: MappingTypeAnalyzer.GetCollectionTargetType(targetProp.Type), IsFlattened: false, FlattenedNestedProperty: null, @@ -1110,17 +1198,20 @@ private static List GetPropertyMappingsWithConfig( 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 + // 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); @@ -1128,16 +1219,23 @@ sourceProp.Type is INamedTypeSymbol sourceEnumType && } } - if (requiresConversion || isNested || isBuiltInTypeConversion) + // Built-in type conversions take precedence over nested mapping detection + if (isBuiltInTypeConversion) + { + isNested = false; + } + + if (requiresConversion || isNested || isBuiltInTypeConversion || isNumericConversion) { mappings.Add(new PropertyMapping( SourceProperty: sourceProp, TargetProperty: targetProp, - RequiresConversion: requiresConversion, + RequiresConversion: requiresConversion || isNumericConversion, IsNested: isNested, HasEnumMapping: hasEnumMapping, IsCollection: false, CollectionElementType: null, + SourceCollectionElementType: null, CollectionTargetType: null, IsFlattened: false, FlattenedNestedProperty: null, @@ -1196,6 +1294,7 @@ sourceProp.Type is INamedTypeSymbol sourceEnumType && HasEnumMapping: false, IsCollection: false, CollectionElementType: null, + SourceCollectionElementType: null, CollectionTargetType: null, IsFlattened: true, FlattenedNestedProperty: nestedProp, @@ -1385,9 +1484,10 @@ 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) + INamedTypeSymbol targetType, + List<(string Source, string Target)>? propertyRenames = null) { var constructors = targetType .Constructors @@ -1396,15 +1496,12 @@ private static (IMethodSymbol? Constructor, List ParameterNames) FindBes if (constructors.Count == 0) { - return (null, []); + return (null, [], []); } - var sourceProperties = sourceType - .GetMembers() - .OfType() - .Where(p => p.GetMethod is not null) - .ToList(); + 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(); @@ -1412,8 +1509,8 @@ private static (IMethodSymbol? Constructor, List ParameterNames) FindBes foreach (var parameter in constructor.Parameters) { - var matchingSourceProperty = sourceProperties.FirstOrDefault(p => - string.Equals(p.Name, parameter.Name, StringComparison.OrdinalIgnoreCase)); + var matchingSourceProperty = FindSourcePropertyForParameter( + sourceProperties, parameter.Name, propertyRenames); if (matchingSourceProperty is null) { @@ -1421,16 +1518,187 @@ 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) && + !MappingTypeAnalyzer.IsNumericTypeConversion(sourcePropertyType, parameterType) && + !MappingTypeAnalyzer.IsBuiltInTypeConversion(sourcePropertyType, parameterType) && + !AreBothCollectionTypesWithCompatibleElements(sourcePropertyType, parameterType)) + { + allParametersMatch = false; + break; + } + parameterNames.Add(parameter.Name); } if (allParametersMatch && constructor.Parameters.Length > 0) { - return (constructor, parameterNames); + return (constructor, parameterNames, []); + } + } + + // 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 = FindSourcePropertyForParameter( + sourceProperties, parameter.Name, propertyRenames); + + 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) && + !MappingTypeAnalyzer.IsNumericTypeConversion(sourcePropertyType, parameterType) && + !AreBothCollectionTypesWithCompatibleElements(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 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, []); + 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 bool AreBothCollectionTypesWithCompatibleElements( + ITypeSymbol sourceType, + ITypeSymbol targetType) + { + if (!MappingTypeAnalyzer.IsCollectionType(sourceType, out var sourceElement) || + !MappingTypeAnalyzer.IsCollectionType(targetType, out var targetElement)) + { + return false; + } + + if (sourceElement is null || targetElement is null) + { + return false; + } + + // Same element type + if (SymbolEqualityComparer.Default.Equals(sourceElement, targetElement)) + { + return true; + } + + // Compatible element types (custom/enum types that can be mapped) + return AreBothCustomOrEnumTypes(sourceElement, targetElement); } private static void DetectDuplicatesWithAttributeMappings( @@ -1590,6 +1858,9 @@ private static void GenerateMapperClassCode( // Generate auto-detected enum mapping methods GenerateAutoDetectedEnumMethods(sb, autoDetectedEnums, classMappings); + // Generate nested type mapping methods for collection element types + GenerateCollectionElementTypeMappingMethods(sb, classMappings, autoDetectedEnums, context); + sb.AppendLineLf("}"); var fileName = $"{containingClass.Name}.g.cs"; @@ -1644,6 +1915,9 @@ private static void GenerateAssemblyLevelCode( // Generate auto-detected enum mapping methods GenerateAutoDetectedEnumMethods(sb, autoDetectedEnums, mappings); + // Generate nested type mapping methods for collection element types + GenerateCollectionElementTypeMappingMethods(sb, mappings, autoDetectedEnums, context); + sb.AppendLineLf("}"); context.AddSource("ConfiguredMappingExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); @@ -1697,17 +1971,22 @@ 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; 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(); @@ -1717,33 +1996,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) + { + constructorArguments.Add((GeneratePropertyMappingValue(matchedProp, "source"), false)); + } + else { - orderedConstructorProps.Add(prop); + 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}"); } @@ -1788,7 +2078,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 ."); @@ -1807,6 +2097,7 @@ private static void GenerateReverseMappingMethod( EnableFlattening: mapping.EnableFlattening, Constructor: reverseConstructor, ConstructorParameterNames: reverseConstructorParams, + ConstructorDefaultParameters: reverseConstructorDefaults, PropertyNameStrategy: mapping.PropertyNameStrategy, IgnoredProperties: [], PropertyRenames: []); @@ -1843,12 +2134,20 @@ 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) + // Unwrap nullable types when comparing to handle Nullable 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))); + (p.RequiresConversion && + p.HasEnumMapping && + 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) && + srcElem is not null && + SymbolEqualityComparer.Default.Equals(srcElem, enumMapping.SourceEnum) && + SymbolEqualityComparer.Default.Equals(p.CollectionElementType, enumMapping.TargetEnum)))); if (!isReferenced) { @@ -1879,6 +2178,137 @@ private static void GenerateAutoDetectedEnumMethods( } } + 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) @@ -1918,42 +2348,107 @@ 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)}"; + 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 && !targetIsNullable) + { + return $"{sourceVariable}.{prop.SourceProperty.Name} is not null ? {sourceVariable}.{prop.SourceProperty.Name}.{nestedMethodName}() : default"; + } + return $"{sourceVariable}.{prop.SourceProperty.Name}?.{nestedMethodName}()!"; } @@ -1968,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)"; @@ -2015,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 cf66896..6f0d8d4 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) { @@ -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, @@ -1825,6 +1829,16 @@ private static string GeneratePropertyMappingValue( return $"{propertyAccess} is {{ }} __nested ? __nested.{nestedMethodName}() : default!"; } + if (prop.SourceProperty.Type.IsValueType) + { + return $"{propertyAccess}.{nestedMethodName}()"; + } + + if (prop.TargetProperty.Type.IsValueType) + { + return $"{propertyAccess} is not null ? {propertyAccess}.{nestedMethodName}() : default"; + } + return $"{propertyAccess}?.{nestedMethodName}()!"; } 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/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/MappingConfigurationGeneratorAdvancedTests.cs b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs index b9fec06..79b8924 100644 --- a/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs +++ b/test/Atc.SourceGenerators.Tests/Generators/MappingConfiguration/MappingConfigurationGeneratorAdvancedTests.cs @@ -237,4 +237,979 @@ 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); + } + + [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); + } + + [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); + } + + [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); + } + + [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_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() + { + 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); + } + + [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/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 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 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