diff --git a/.bench-harness-current/BenchHarness.csproj b/.bench-harness-current/BenchHarness.csproj deleted file mode 100644 index 851fdc4..0000000 --- a/.bench-harness-current/BenchHarness.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - Exe - net10.0 - enable - enable - - - - - diff --git a/.bench-harness-current/Program.cs b/.bench-harness-current/Program.cs deleted file mode 100644 index 077d33e..0000000 --- a/.bench-harness-current/Program.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Diagnostics; -using FastCloner; - -var results = new List<(string Name, double Ms, double NsPerOp)>(); -Measure("SmallObject x100000", CreateSmallObject(), 2000, 100000, static x => FastCloner.FastCloner.DeepClone((SmallObject)x)!); -Measure("StringArray1000 x20000", CreateStringArray(1000), 500, 20000, static x => FastCloner.FastCloner.DeepClone((string[])x)!); -Measure("Dictionary50 x5000", CreateDictionary(50), 200, 5000, static x => FastCloner.FastCloner.DeepClone((Dictionary)x)!); -foreach (var (name, ms, nsPerOp) in results) - Console.WriteLine($"{name}|{ms:F2}|{nsPerOp:F1}"); - -void Measure(string name, object value, int warmup, int iterations, Func clone) -{ - for (var i = 0; i < warmup; i++) _ = clone(value); - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - var sw = Stopwatch.StartNew(); - for (var i = 0; i < iterations; i++) _ = clone(value); - sw.Stop(); - results.Add((name, sw.Elapsed.TotalMilliseconds, sw.Elapsed.TotalMilliseconds * 1_000_000d / iterations)); -} - -static SmallObject CreateSmallObject() => new() { Id = 123, Name = "small-object-name", CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), IsActive = true, Score = 42.5 }; -static string[] CreateStringArray(int count) { var arr = new string[count]; for (var i = 0; i < count; i++) arr[i] = $"value-{i}"; return arr; } -static Dictionary CreateDictionary(int count) { var dict = new Dictionary(count); for (var i = 0; i < count; i++) dict[$"key-{i}"] = new SmallObject { Id = i, Name = $"item-{i}", CreatedAt = new DateTime(2025, 1, 1).AddMinutes(i), IsActive = (i & 1) == 0, Score = i * 1.25 }; return dict; } - -public sealed class SmallObject -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } - public bool IsActive { get; set; } - public double Score { get; set; } -} diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 56fdee7..b2a0b11 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -21,7 +21,7 @@ on: default: true env: - DOTNET_VERSION: "10.0.103" + DOTNET_VERSION: "10.0.x" BASELINE_BRANCH: "next" permissions: diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 934f88e..2ae4dac 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -7,7 +7,7 @@ on: branches: [ "next" ] env: - DOTNET_VERSION: '10.0.103' + DOTNET_VERSION: '10.0.x' NODE_VERSION: '24' permissions: diff --git a/README.md b/README.md index 5590970..b8b8746 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ FastCloner is a zero-dependency deep cloning library for .NET, from .NET 4 - **Precise control** - Override clone behavior per type or member with `Clone`, `Reference`, `Shallow`, or `Ignore`, at compile time or runtime - **Selective tracking** - FastCloner avoids identity and cycle-tracking overhead by default, but enables it when graph shape or `[FastClonerPreserveIdentity]` requires it - **Easy Integration** - `FastDeepClone()` for AOT cloning, `DeepClone()` for reflection cloning. FastCloner respects standard .NET attributes like `[NonSerialized]`, so you can adopt it without depending on library-specific annotations -- **Production Ready** - Used by projects like [Foundatio](https://github.com/FoundatioFx/Foundatio), [Jobbr](https://jobbr.readthedocs.io/en/latest), [TarkovSP](https://sp-tarkov.com), [SnapX](https://github.com/SnapXL/SnapX), and [WinPaletter](https://github.com/Abdelrhman-AK/WinPaletter), with over [300K downloads on NuGet](https://www.nuget.org/packages/fastCloner#usedby-body-tab) +- **Production Ready** - Used by projects like [Foundatio](https://github.com/FoundatioFx/Foundatio), [Jobbr](https://jobbr.readthedocs.io/en/latest), [TarkovSP](https://sp-tarkov.com), [SnapX](https://github.com/SnapXL/SnapX), and [WinPaletter](https://github.com/Abdelrhman-AK/WinPaletter), with over [500K downloads on NuGet](https://www.nuget.org/packages/fastCloner#usedby-body-tab) ## Getting Started Install the package via NuGet: @@ -239,6 +239,24 @@ if (ctx.TryClone(obj, out var cloned)) } ``` +### Member Visibility + +By default, all members are eligible for cloning regardless of access modifier. Apply `[FastClonerVisibility]` to a type to restrict cloning to a specific subset: + +```csharp +[FastClonerVisibility(FastClonerMemberVisibility.Public | FastClonerMemberVisibility.Internal)] +public class Dto +{ + public int Id { get; set; } // cloned + internal string Tag; // cloned + private string _secret; // skipped +} +``` + +The policy applies to both reflection and source-generated paths; excluded members are left at their default value on the clone. + +The visibility filter runs before the behavior pipeline and is bypassed for any member carrying a member-level behavior attribute (`[FastClonerBehavior]`, `[FastClonerIgnore]`, `[FastClonerShallow]`, `[FastClonerReference]`), so those members are always included with their declared behavior. + ### Nullability Trust The generator can be instructed to fully trust nullability annotations. When `[FastClonerTrustNullability]` attribute is applied, FastCloner will skip null checks for non-nullable reference types (e.g., `string` vs `string?`), assuming the contract is valid. diff --git a/src/FastCloner.SourceGenerator/AssemblyInfo.cs b/src/FastCloner.SourceGenerator/AssemblyInfo.cs new file mode 100644 index 0000000..df64fb7 --- /dev/null +++ b/src/FastCloner.SourceGenerator/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("FastCloner.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100bd882e7dcc8a5cfdd6e2193c66bb144ab67e83964b825035b4b05ccdc40a5a7218a497799f98f98e628c38492fa227ad1579c7d701934ea2e30459a4dabfcfa498fc9f4dc6d3e3f118e4df6615aced3da480ea45d30832ddbfd56acebc957ee6345944d2e9b82705a725276146baadf08cf7a8612fd4f3ceb8b8ec9529b975c2")] diff --git a/src/FastCloner.SourceGenerator/BridgeContract.cs b/src/FastCloner.SourceGenerator/BridgeContract.cs new file mode 100644 index 0000000..2b342fa --- /dev/null +++ b/src/FastCloner.SourceGenerator/BridgeContract.cs @@ -0,0 +1,22 @@ +namespace FastCloner.SourceGenerator; + +internal sealed record BridgeMethodSpec( + string Name, + string ReturnTypeFqn, + EquatableArray ParameterTypeFqns) +{ + public bool IsVoid => ReturnTypeFqn == "void"; +} + +internal sealed record BridgeContract( + string BridgeTypeMetadataName, + string ProxyTypeFullName, + EquatableArray Methods, + bool IsAvailable) +{ + public static BridgeContract Empty { get; } = new BridgeContract( + BridgeTypeMetadataName: string.Empty, + ProxyTypeFullName: string.Empty, + Methods: EquatableArray.Empty, + IsAvailable: false); +} diff --git a/src/FastCloner.SourceGenerator/BridgeContractCollector.cs b/src/FastCloner.SourceGenerator/BridgeContractCollector.cs new file mode 100644 index 0000000..bec1486 --- /dev/null +++ b/src/FastCloner.SourceGenerator/BridgeContractCollector.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace FastCloner.SourceGenerator; + +internal static class BridgeContractCollector +{ + private const string BridgeTypeMetadataName = "FastCloner.FastClonerSourceGeneratorBridge"; + private const string BridgeAttrFqn = "FastCloner.FastClonerSourceGeneratorBridgeAttribute"; + private const string BridgeMemberAttrFqn = "FastCloner.FastClonerSourceGeneratorBridgeMemberAttribute"; + + public static BridgeContract Collect(Compilation compilation) + { + INamedTypeSymbol? bridge = compilation.GetTypeByMetadataName(BridgeTypeMetadataName); + + AttributeData? bridgeAttr = bridge?.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == BridgeAttrFqn); + if (bridgeAttr == null || bridgeAttr.ConstructorArguments.Length == 0 || bridgeAttr.ConstructorArguments[0].Value is not string proxyFqn || string.IsNullOrEmpty(proxyFqn)) + return BridgeContract.Empty; + + List methods = []; + + foreach (ISymbol member in bridge?.GetMembers() ?? []) + { + if (member is not IMethodSymbol method) + continue; + if (method.MethodKind != MethodKind.Ordinary || !method.IsStatic) + continue; + + bool hasMemberAttr = method.GetAttributes() + .Any(a => a.AttributeClass?.ToDisplayString() == BridgeMemberAttrFqn); + if (!hasMemberAttr) + continue; + + string returnTypeFqn = method.ReturnsVoid + ? "void" + : method.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + string[] paramFqns = new string[method.Parameters.Length]; + for (int i = 0; i < method.Parameters.Length; i++) + { + paramFqns[i] = method.Parameters[i].Type + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + methods.Add(new BridgeMethodSpec( + Name: method.Name, + ReturnTypeFqn: returnTypeFqn, + ParameterTypeFqns: new EquatableArray(paramFqns))); + } + + if (methods.Count == 0) + return BridgeContract.Empty; + + BridgeMethodSpec[] sortedMethods = methods.OrderBy(m => m.Name, System.StringComparer.Ordinal).ToArray(); + + return new BridgeContract( + BridgeTypeMetadataName: BridgeTypeMetadataName, + ProxyTypeFullName: proxyFqn!, + Methods: new EquatableArray(sortedMethods), + IsAvailable: true); + } +} diff --git a/src/FastCloner.SourceGenerator/BridgeProxyEmitter.cs b/src/FastCloner.SourceGenerator/BridgeProxyEmitter.cs new file mode 100644 index 0000000..83071fe --- /dev/null +++ b/src/FastCloner.SourceGenerator/BridgeProxyEmitter.cs @@ -0,0 +1,158 @@ +using System.Text; + +namespace FastCloner.SourceGenerator; + +internal static class BridgeProxyEmitter +{ + public const string HintName = "FastCloner_SGBridgeProxy.g.cs"; + + public static bool ShouldEmit(TargetFramework targetFramework, BridgeContract contract) + { + if (targetFramework >= TargetFramework.Net8) + return false; + + return contract is { IsAvailable: true, Methods.Count: > 0 }; + } + + public static string Emit(BridgeContract contract) + { + string proxyFqn = contract.ProxyTypeFullName; + int lastDot = proxyFqn.LastIndexOf('.'); + string proxyNamespace = lastDot >= 0 ? proxyFqn.Substring(0, lastDot) : string.Empty; + string proxyTypeName = lastDot >= 0 ? proxyFqn.Substring(lastDot + 1) : proxyFqn; + + StringBuilder sb = new StringBuilder(2048); + + sb.AppendLine("#nullable disable"); + sb.AppendLine("using System;"); + sb.AppendLine("using System.Reflection;"); + sb.AppendLine(); + + bool hasNamespace = proxyNamespace.Length > 0; + if (hasNamespace) + { + sb.Append("namespace ").Append(proxyNamespace).AppendLine(); + sb.AppendLine("{"); + } + + string indent = hasNamespace ? " " : string.Empty; + + sb.Append(indent).AppendLine("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); + sb.Append(indent).AppendLine("[global::System.Runtime.CompilerServices.CompilerGenerated]"); + sb.Append(indent).Append("internal static class ").AppendLine(proxyTypeName); + sb.Append(indent).AppendLine("{"); + + string body = hasNamespace ? indent + " " : " "; + + sb.Append(body).Append("private const string BridgeTypeName = \"").Append(contract.BridgeTypeMetadataName).AppendLine("\";"); + sb.AppendLine(); + sb.Append(body).AppendLine("private static readonly Type BridgeType = ResolveBridgeType();"); + + foreach (BridgeMethodSpec method in contract.Methods) + { + sb.AppendLine(); + string delegateType = BuildDelegateType(method); + string parameterTypeofList = BuildTypeofList(method.ParameterTypeFqns); + + sb.Append(body).Append("internal static readonly ").Append(delegateType).Append(' ').Append(method.Name).AppendLine(" ="); + sb.Append(body).Append(" (").Append(delegateType).AppendLine(")Delegate.CreateDelegate("); + sb.Append(body).Append(" typeof(").Append(delegateType).AppendLine("),"); + sb.Append(body).Append(" ResolveStaticMethod(\"").Append(method.Name).Append('\"'); + if (parameterTypeofList.Length > 0) + { + sb.Append(", ").Append(parameterTypeofList); + } + sb.AppendLine("));"); + } + + sb.AppendLine(); + sb.Append(body).AppendLine("private static Type ResolveBridgeType()"); + sb.Append(body).AppendLine("{"); + sb.Append(body).AppendLine(" Assembly runtimeAssembly = typeof(global::FastCloner.Code.FastClonerGenerator).Assembly;"); + sb.Append(body).AppendLine(" Type type = runtimeAssembly.GetType(BridgeTypeName, throwOnError: false);"); + sb.Append(body).AppendLine(" if (type == null)"); + sb.Append(body).AppendLine(" {"); + sb.Append(body).AppendLine(" throw new InvalidOperationException("); + sb.Append(body).AppendLine(" \"FastCloner source generator: the runtime bridge type '\" + BridgeTypeName +"); + sb.Append(body).AppendLine(" \"' was not found in assembly '\" + runtimeAssembly.FullName +"); + sb.Append(body).AppendLine(" \"'. The FastCloner runtime version may be incompatible with the source generator. \" +"); + sb.Append(body).AppendLine(" \"Update the FastCloner package to a matching version.\");"); + sb.Append(body).AppendLine(" }"); + sb.Append(body).AppendLine(" return type;"); + sb.Append(body).AppendLine("}"); + + sb.AppendLine(); + sb.Append(body).AppendLine("private static MethodInfo ResolveStaticMethod(string name, params Type[] parameterTypes)"); + sb.Append(body).AppendLine("{"); + sb.Append(body).AppendLine(" const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public;"); + sb.Append(body).AppendLine(" MethodInfo method = BridgeType.GetMethod(name, flags, binder: null, types: parameterTypes, modifiers: null);"); + sb.Append(body).AppendLine(" if (method == null)"); + sb.Append(body).AppendLine(" {"); + sb.Append(body).AppendLine(" throw new InvalidOperationException("); + sb.Append(body).AppendLine(" \"FastCloner source generator: bridge method '\" + name +"); + sb.Append(body).AppendLine(" \"' was not found on '\" + BridgeType.FullName +"); + sb.Append(body).AppendLine(" \"'. The FastCloner runtime version may be incompatible with the source generator.\");"); + sb.Append(body).AppendLine(" }"); + sb.Append(body).AppendLine(" return method;"); + sb.Append(body).AppendLine("}"); + + sb.Append(indent).AppendLine("}"); + + if (hasNamespace) + { + sb.AppendLine("}"); + } + + return sb.ToString(); + } + + private static string BuildDelegateType(BridgeMethodSpec method) + { + if (method.IsVoid) + { + if (method.ParameterTypeFqns.Count == 0) + { + return "global::System.Action"; + } + + return "global::System.Action<" + Join(method.ParameterTypeFqns) + ">"; + } + + if (method.ParameterTypeFqns.Count == 0) + { + return "global::System.Func<" + method.ReturnTypeFqn + ">"; + } + + return "global::System.Func<" + Join(method.ParameterTypeFqns) + ", " + method.ReturnTypeFqn + ">"; + } + + private static string BuildTypeofList(EquatableArray parameterTypeFqns) + { + string[]? items = parameterTypeFqns.GetArray(); + if (items is null || items.Length == 0) + return string.Empty; + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < items.Length; i++) + { + if (i > 0) sb.Append(", "); + sb.Append("typeof(").Append(items[i]).Append(')'); + } + return sb.ToString(); + } + + private static string Join(EquatableArray items) + { + string[]? array = items.GetArray(); + if (array is null || array.Length == 0) + return string.Empty; + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < array.Length; i++) + { + if (i > 0) sb.Append(", "); + sb.Append(array[i]); + } + return sb.ToString(); + } +} diff --git a/src/FastCloner.SourceGenerator/ClassCloneBodyGenerator.cs b/src/FastCloner.SourceGenerator/ClassCloneBodyGenerator.cs index c5adc33..3a8ec28 100644 --- a/src/FastCloner.SourceGenerator/ClassCloneBodyGenerator.cs +++ b/src/FastCloner.SourceGenerator/ClassCloneBodyGenerator.cs @@ -68,13 +68,18 @@ public static void WriteClassCloneBody( List initOnlyMembers = []; foreach (MemberModel member in ctx.Model.Members) { - if (member is { IsProperty: true, IsInitOnly: true } || member.IsRequired) + bool participatesInInitializer = + (member is { IsProperty: true, IsInitOnly: true } || member.IsRequired); + if (!participatesInInitializer) + continue; + + if (member.AccessorStrategy != NonPublicAccessorStrategy.None) + continue; + + string assignment = MemberCloneGenerator.GetMemberAssignment(ctx, member, sourceVarName, stateVar, " "); + if (!string.IsNullOrEmpty(assignment)) { - string assignment = MemberCloneGenerator.GetMemberAssignment(ctx, member, sourceVarName, stateVar, " "); - if (!string.IsNullOrEmpty(assignment)) - { - initOnlyMembers.Add($" {assignment}"); - } + initOnlyMembers.Add($" {assignment}"); } } @@ -92,13 +97,13 @@ public static void WriteClassCloneBody( foreach (MemberModel member in ctx.Model.Members) { - if (member is { IsProperty: true, IsInitOnly: true } || member.IsRequired) + bool participatedInInitializer = + (member is { IsProperty: true, IsInitOnly: true } || member.IsRequired) + && member.AccessorStrategy == NonPublicAccessorStrategy.None; + if (participatedInInitializer) continue; - - if (!member.IsProperty || !member.IsInitOnly) - { - MemberCloneGenerator.WriteMemberCloning(ctx, member, "result", sourceVarName, stateVar); - } + + MemberCloneGenerator.WriteMemberCloning(ctx, member, "result", sourceVarName, stateVar); } } else diff --git a/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs b/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs index 7145cbf..09cee05 100644 --- a/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs +++ b/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs @@ -10,9 +10,9 @@ internal sealed class CloneCodeGenerator private readonly CloneGeneratorContext _context; private readonly EquatableArray _usages; - public CloneCodeGenerator(TypeModel model, EquatableArray usages) + public CloneCodeGenerator(TypeModel model, EquatableArray usages, BridgeContract bridgeContract) { - _context = new CloneGeneratorContext(model); + _context = new CloneGeneratorContext(model, bridgeContract); _usages = usages; } @@ -27,6 +27,8 @@ public string Generate() return _context.Source.ToString(); } + + public IReadOnlyList SkippedNonPublicMembers => _context.SkippedNonPublicMembers; private void PreAnalyzeHelperUsages() { @@ -45,41 +47,47 @@ private void AnalyzeMembers(IEnumerable members) { foreach (MemberModel member in members) { - if (member.TypeKind == MemberTypeKind.Implicit) - { - _context.IncrementHelperUsage(member.TypeFullName); - } - else if (member.TypeKind == MemberTypeKind.Collection || - member.TypeKind == MemberTypeKind.Array || - member.TypeKind == MemberTypeKind.MultiDimArray) + switch (member.TypeKind) { - if (member.ElementTypeName != null) + case MemberTypeKind.Implicit: + _context.IncrementHelperUsage(member.TypeFullName); + break; + case MemberTypeKind.Collection: + case MemberTypeKind.Array: + case MemberTypeKind.MultiDimArray: { - if (_context.TryGetImplicitTypeModel(member.ElementTypeName, out _) || - _context.TryGetMemberModel(member.ElementTypeName, out _)) + if (member.ElementTypeName != null) { - _context.IncrementHelperUsage(member.ElementTypeName); + if (_context.TryGetImplicitTypeModel(member.ElementTypeName, out _) || + _context.TryGetMemberModel(member.ElementTypeName, out _)) + { + _context.IncrementHelperUsage(member.ElementTypeName); + } } + + break; } - } - else if (member.TypeKind == MemberTypeKind.Dictionary) - { - if (member.KeyTypeName != null) + case MemberTypeKind.Dictionary: { - if (_context.TryGetImplicitTypeModel(member.KeyTypeName, out _) || - _context.TryGetMemberModel(member.KeyTypeName, out _)) + if (member.KeyTypeName != null) { - _context.IncrementHelperUsage(member.KeyTypeName); + if (_context.TryGetImplicitTypeModel(member.KeyTypeName, out _) || + _context.TryGetMemberModel(member.KeyTypeName, out _)) + { + _context.IncrementHelperUsage(member.KeyTypeName); + } } - } - if (member.ValueTypeName != null) - { - if (_context.TryGetImplicitTypeModel(member.ValueTypeName, out _) || - _context.TryGetMemberModel(member.ValueTypeName, out _)) + if (member.ValueTypeName != null) { - _context.IncrementHelperUsage(member.ValueTypeName); + if (_context.TryGetImplicitTypeModel(member.ValueTypeName, out _) || + _context.TryGetMemberModel(member.ValueTypeName, out _)) + { + _context.IncrementHelperUsage(member.ValueTypeName); + } } + + break; } } } @@ -138,10 +146,37 @@ private void WriteExtensionClass() WriteClonerClass(); CollectionHelperGenerator.GenerateHelpers(_context); + + EmitNonPublicAccessorBlock(sb); sb.AppendLine(" }"); } + private void EmitNonPublicAccessorBlock(StringBuilder sb) + { + if (_context.NonPublicAccessors.Count == 0) + return; + + bool isGeneric = _context.Model.TypeParameters.Count > 0; + + if (!isGeneric) + { + NonPublicAccessorEmitter.WriteDeclarations(_context, sb, " ", insideNestedShell: false); + return; + } + + string typeParams = $"<{string.Join(", ", _context.Model.TypeParameters)}>"; + string constraints = _context.Model.TypeConstraints.Count == 0 + ? string.Empty + : " " + string.Join(" ", _context.Model.TypeConstraints); + + sb.AppendLine(); + sb.AppendLine($" private static class __FcAccessors{typeParams}{constraints}"); + sb.AppendLine(" {"); + NonPublicAccessorEmitter.WriteDeclarations(_context, sb, " ", insideNestedShell: true); + sb.AppendLine(" }"); + } + private void WritePublicFastDeepCloneMethod(string typeName, string fullTypeName) { StringBuilder sb = _context.Source; diff --git a/src/FastCloner.SourceGenerator/CloneGeneratorContext.cs b/src/FastCloner.SourceGenerator/CloneGeneratorContext.cs index c3bfc9e..1b885af 100644 --- a/src/FastCloner.SourceGenerator/CloneGeneratorContext.cs +++ b/src/FastCloner.SourceGenerator/CloneGeneratorContext.cs @@ -6,15 +6,15 @@ namespace FastCloner.SourceGenerator; internal sealed class CloneGeneratorContext { public TypeModel Model { get; } - public StringBuilder Source { get; } = new(); + public StringBuilder Source { get; } = new StringBuilder(); private readonly Dictionary _typeNameToMethodName; private readonly HashSet _neededHelperMethods; - private readonly Queue _pendingHelperMethods = new(); - private readonly Dictionary _typeNameToMemberModel = new(); - private readonly Dictionary _implicitTypeModels = new(); - private readonly Dictionary _derivedTypeHelpers = new(); - private readonly Dictionary _helperUsageCounts = new(); + private readonly Queue _pendingHelperMethods = new Queue(); + private readonly Dictionary _typeNameToMemberModel = new Dictionary(); + private readonly Dictionary _implicitTypeModels = new Dictionary(); + private readonly Dictionary _derivedTypeHelpers = new Dictionary(); + private readonly Dictionary _helperUsageCounts = new Dictionary(); public bool NeedsStateClass { get; set; } public bool NeedsClonerClass { get; set; } @@ -24,15 +24,19 @@ internal sealed class CloneGeneratorContext public bool NeedsStateTracking { get; set; } public bool IsFastClonerAvailable { get; } public TargetFramework TargetFramework { get; } - + public BridgeContract BridgeContract { get; } + public List NonPublicAccessors { get; } = []; + public List SkippedNonPublicMembers { get; } = []; + private readonly Dictionary _circularReferenceOverrides = new Dictionary(); - public CloneGeneratorContext(TypeModel model, Dictionary? sharedMethodNames = null, HashSet? sharedNeededHelpers = null) + public CloneGeneratorContext(TypeModel model, BridgeContract? bridgeContract = null, Dictionary? sharedMethodNames = null, HashSet? sharedNeededHelpers = null) { Model = model; CanHaveCircularReferences = model.CanHaveCircularReferences; IsFastClonerAvailable = model.IsFastClonerAvailable; TargetFramework = model.TargetFramework; + BridgeContract = bridgeContract ?? BridgeContract.Empty; bool anyMemberNeedsIdentity = false; foreach (MemberModel m in model.Members) @@ -207,9 +211,25 @@ public bool ShouldInline(string typeFullName) return GetHelperUsageCount(typeFullName) == 1; } - private int _variableCounter = 0; - public int GetNextVariableId() => System.Threading.Interlocked.Increment(ref _variableCounter); + private int variableCounter; + public int GetNextVariableId() => System.Threading.Interlocked.Increment(ref variableCounter); + public string GetNonPublicAccessorPrefix() + { + return Model.TypeParameters.Count == 0 ? string.Empty : $"__FcAccessors<{string.Join(", ", Model.TypeParameters)}>."; + } + + public NonPublicAccessor RegisterNonPublicAccessor(NonPublicAccessor accessor) + { + foreach (NonPublicAccessor existing in NonPublicAccessors) + { + if (existing.AccessorMethodName == accessor.AccessorMethodName) + return existing; + } + NonPublicAccessors.Add(accessor); + return accessor; + } + public static string FastClonerDeepCloneCall(string expression) => $"global::FastCloner.FastCloner.DeepClone({expression})"; public static string NotNullIfNotNullAttr(bool isAvailable, string paramName = "source") diff --git a/src/FastCloner.SourceGenerator/ContextCodeGenerator.cs b/src/FastCloner.SourceGenerator/ContextCodeGenerator.cs index 76f582b..63e0b10 100644 --- a/src/FastCloner.SourceGenerator/ContextCodeGenerator.cs +++ b/src/FastCloner.SourceGenerator/ContextCodeGenerator.cs @@ -72,7 +72,7 @@ public string Generate() private void GenerateTypeCloner(TypeModel model) { - CloneGeneratorContext ctx = new CloneGeneratorContext(model, _sharedMethodNames, _sharedNeededHelpers); + CloneGeneratorContext ctx = new CloneGeneratorContext(model, sharedMethodNames: _sharedMethodNames, sharedNeededHelpers: _sharedNeededHelpers); ctx.NeedsStateClass = true; // Use shared state class logic ctx.UseStaticMethods = false; // Use instance methods for context diff --git a/src/FastCloner.SourceGenerator/FastClonerIncrementalGenerator.cs b/src/FastCloner.SourceGenerator/FastClonerIncrementalGenerator.cs index 138cff1..da52616 100644 --- a/src/FastCloner.SourceGenerator/FastClonerIncrementalGenerator.cs +++ b/src/FastCloner.SourceGenerator/FastClonerIncrementalGenerator.cs @@ -21,6 +21,19 @@ public void Initialize(IncrementalGeneratorInitializationContext context) IncrementalValueProvider targetFrameworkProvider = context.AnalyzerConfigOptionsProvider .Select(static (provider, _) => TargetFrameworkDetector.Detect(provider)); + IncrementalValueProvider bridgeContractProvider = context.CompilationProvider + .Select(static (compilation, _) => BridgeContractCollector.Collect(compilation)); + IncrementalValueProvider<(TargetFramework Tfm, BridgeContract Contract)> bridgeProxyConditions = + targetFrameworkProvider.Combine(bridgeContractProvider); + + context.RegisterSourceOutput(bridgeProxyConditions, static (ctx, args) => + { + (TargetFramework tfm, BridgeContract contract) = args; + if (BridgeProxyEmitter.ShouldEmit(tfm, contract)) + { + ctx.AddSource(BridgeProxyEmitter.HintName, SourceText.From(BridgeProxyEmitter.Emit(contract), Encoding.UTF8)); + } + }); IncrementalValuesProvider<(GeneratorAttributeSyntaxContext Ctx, TargetFramework Tfm)> attributeProvider = context.SyntaxProvider.ForAttributeWithMetadataName( @@ -90,9 +103,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return new EquatableArray(list.Distinct().ToArray()); }); - - // Combine type models with collected usages - IncrementalValuesProvider<(Result Left, EquatableArray Right)> combinedPipeline = pipeline.Combine(usagePipeline); + + IncrementalValuesProvider<((Result Left, EquatableArray Right) Data, BridgeContract Contract)> combinedPipeline = + pipeline.Combine(usagePipeline).Combine(bridgeContractProvider); // OPTIMAL PERFORMANCE: No Compilation combine! // All type analysis is pre-computed in TypeModel during the transform step. @@ -100,7 +113,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // not on every keypress. context.RegisterSourceOutput(combinedPipeline, static (ctx, source) => { - (Result? result, EquatableArray usages) = source; + ((Result? result, EquatableArray usages), BridgeContract contract) = source; result.Handle( model => @@ -144,9 +157,27 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } } - CloneCodeGenerator generator = new CloneCodeGenerator(model, usages); + CloneCodeGenerator generator = new CloneCodeGenerator(model, usages, contract); string generatedSource = generator.Generate(); - + + if (generator.SkippedNonPublicMembers.Count > 0) + { + string skippedList = string.Join(", ", generator.SkippedNonPublicMembers); + ctx.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + "FCG010", + "Non-public members skipped by source generator", + "Type '{0}' has non-public members ({1}) that the source generator cannot clone on this target framework. " + + "Either upgrade the consumer to .NET 8+, or install the FastCloner runtime package, " + + "or apply [FastClonerVisibility] / [FastClonerIgnore] to opt out explicitly.", + "FastCloner", + DiagnosticSeverity.Warning, + isEnabledByDefault: true), + Location.None, + model.Name, + skippedList)); + } + // Use FullyQualifiedName to avoid collisions when same class name exists in different namespaces string safeName = model.FullyQualifiedName .Replace("global::", "") diff --git a/src/FastCloner.SourceGenerator/MemberCloneGenerator.cs b/src/FastCloner.SourceGenerator/MemberCloneGenerator.cs index 896a26e..7338f75 100644 --- a/src/FastCloner.SourceGenerator/MemberCloneGenerator.cs +++ b/src/FastCloner.SourceGenerator/MemberCloneGenerator.cs @@ -10,6 +10,9 @@ public static string GetMemberAssignment(CloneGeneratorContext context, MemberMo { string memberName = member.Name; string nf = (!member.IsNullable && !member.IsValueType) ? "!" : ""; + + if (member.AccessorStrategy != NonPublicAccessorStrategy.None) + return string.Empty; switch (member) { @@ -82,6 +85,18 @@ public static void WriteMemberCloning(CloneGeneratorContext context, MemberModel string memberName = member.Name; string nf = (!member.IsNullable && !member.IsValueType) ? "!" : ""; StringBuilder sb = context.Source; + + if (member.AccessorStrategy != NonPublicAccessorStrategy.None) + { + if (!NonPublicAccessorEmitter.CanCloneNonPublicMembers(context)) + { + context.SkippedNonPublicMembers.Add(memberName); + return; + } + + NonPublicAccessorEmitter.WriteCloneCall(context, sb, member, resultVar, sourceVar, stateVar, " "); + return; + } switch (member) { diff --git a/src/FastCloner.SourceGenerator/MemberCollector.cs b/src/FastCloner.SourceGenerator/MemberCollector.cs index fbc06e6..4ff3676 100644 --- a/src/FastCloner.SourceGenerator/MemberCollector.cs +++ b/src/FastCloner.SourceGenerator/MemberCollector.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; namespace FastCloner.SourceGenerator; @@ -26,6 +28,7 @@ public static List GetMembers( { List members = []; HashSet seenNames = []; + FastClonerMemberVisibility visibilityPolicy = GetVisibilityPolicyFromType(symbol); // Get all members from base types too (walking up the inheritance chain) INamedTypeSymbol? currentType = symbol; @@ -44,37 +47,45 @@ public static List GetMembers( { if (property is { GetMethod: not null, IsIndexer: false }) { - // Check if property has an accessible setter - bool hasAccessibleSetter = property.SetMethod != null && - (property.SetMethod.DeclaredAccessibility == Accessibility.Public || - property.SetMethod.DeclaredAccessibility == Accessibility.Internal || - property.SetMethod.DeclaredAccessibility == Accessibility.ProtectedOrInternal); - - // For getter-only properties, we can still clone if: - // 1. It's a collection type that supports population (Add/Clear) - // 2. The property returns a non-null collection instance bool isPopulatableCollection = IsPopulatableCollectionType(property.Type); - - // Include property if it has an accessible setter OR it's a getter-only populatable collection - if (hasAccessibleSetter || isPopulatableCollection) + bool hasSetter = property.SetMethod != null; + + if (hasSetter || isPopulatableCollection) { MemberCloneBehavior behavior = GetMemberBehavior(property, compilation); - if (behavior != MemberCloneBehavior.Ignore) + if (behavior == MemberCloneBehavior.Ignore) + continue; + + if (!HasExplicitMemberBehaviorAttribute(property, compilation)) { - members.Add(new MemberAnalysis(MemberModel.Create(property, nullabilityEnabled, compilation, behavior), property.Type)); + FastClonerMemberVisibility memberMask = PropertyVisibilityMask(property); + if ((visibilityPolicy & memberMask) == 0) + continue; } + + if (IsRedundantNonAutoPropertyClonedThroughField(property, visibilityPolicy, compilation)) + continue; + + members.Add(new MemberAnalysis(MemberModel.Create(property, nullabilityEnabled, compilation, behavior), property.Type)); } } } else if (member is IFieldSymbol field) { if (field.IsConst) continue; // Skip const fields - + MemberCloneBehavior behavior = GetMemberBehavior(field, compilation); - if (behavior != MemberCloneBehavior.Ignore) + if (behavior == MemberCloneBehavior.Ignore) + continue; + + if (!HasExplicitMemberBehaviorAttribute(field, compilation)) { - members.Add(new MemberAnalysis(MemberModel.Create(field, nullabilityEnabled, compilation, behavior), field.Type)); + FastClonerMemberVisibility memberMask = MemberModel.MapAccessibility(field.DeclaredAccessibility); + if ((visibilityPolicy & memberMask) == 0) + continue; } + + members.Add(new MemberAnalysis(MemberModel.Create(field, nullabilityEnabled, compilation, behavior), field.Type)); } } @@ -83,53 +94,116 @@ public static List GetMembers( return members; } + + private static FastClonerMemberVisibility GetVisibilityPolicyFromType(INamedTypeSymbol type) + { + INamedTypeSymbol? current = type; + while (current != null && current.SpecialType != SpecialType.System_Object) + { + foreach (AttributeData attr in current.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString() != "FastCloner.Code.FastClonerVisibilityAttribute") + continue; - /// - /// Checks if a type is a collection that can be populated via Add/Clear methods. - /// This allows getter-only collection properties to be cloned by clearing and repopulating. - /// - private static bool IsPopulatableCollectionType(ITypeSymbol type) + if (attr.ConstructorArguments.Length > 0 && + attr.ConstructorArguments[0].Value is int rawFlags) + { + return (FastClonerMemberVisibility)rawFlags; + } + } + + current = current.BaseType; + } + + return FastClonerMemberVisibility.All; + } + + private static bool HasExplicitMemberBehaviorAttribute(ISymbol member, Compilation compilation) { - // Arrays cannot be populated (fixed size) - if (type is IArrayTypeSymbol) + INamedTypeSymbol? behaviorAttribute = compilation.GetTypeByMetadataName("FastCloner.Code.FastClonerBehaviorAttribute"); + if (behaviorAttribute == null) return false; - - // Check for common populatable collection types - string typeName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - // Check if it's a generic type - if (type is INamedTypeSymbol { IsGenericType: true } namedType) + + foreach (AttributeData attr in member.GetAttributes()) { - string originalDef = namedType.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - // List of populatable collection types (can use Clear + Add pattern) - string[] populatableTypes = - [ - "global::System.Collections.Generic.List", - "global::System.Collections.Generic.HashSet", - "global::System.Collections.Generic.LinkedList", - "global::System.Collections.Generic.Queue", - "global::System.Collections.Generic.Stack", - "global::System.Collections.Generic.SortedSet", - "global::System.Collections.ObjectModel.Collection", - "global::System.Collections.ObjectModel.ObservableCollection", - "global::System.Collections.Concurrent.ConcurrentBag", - "global::System.Collections.Concurrent.ConcurrentQueue", - "global::System.Collections.Concurrent.ConcurrentStack", - // Dictionaries - "global::System.Collections.Generic.Dictionary", - "global::System.Collections.Generic.SortedDictionary", - "global::System.Collections.Generic.SortedList", - "global::System.Collections.Concurrent.ConcurrentDictionary" - ]; - - foreach (string populatable in populatableTypes) + INamedTypeSymbol? attrClass = attr.AttributeClass; + while (attrClass != null) { - if (originalDef == populatable) + if (SymbolEqualityComparer.Default.Equals(attrClass, behaviorAttribute)) return true; + attrClass = attrClass.BaseType; } } - + + return false; + } + + private static FastClonerMemberVisibility PropertyVisibilityMask(IPropertySymbol property) + { + Accessibility get = property.GetMethod?.DeclaredAccessibility ?? Accessibility.NotApplicable; + Accessibility set = property.SetMethod?.DeclaredAccessibility ?? Accessibility.NotApplicable; + Accessibility chosen = MoreAccessible(get, set); + if (chosen == Accessibility.NotApplicable) + chosen = property.DeclaredAccessibility; + return MemberModel.MapAccessibility(chosen); + } + + private static Accessibility MoreAccessible(Accessibility a, Accessibility b) + { + return Score(a) >= Score(b) ? a : b; + static int Score(Accessibility ac) => ac switch + { + Accessibility.Public => 6, + Accessibility.ProtectedOrInternal => 5, + Accessibility.Internal => 4, + Accessibility.Protected => 3, + Accessibility.ProtectedAndInternal => 2, + Accessibility.Private => 1, + _ => 0, + }; + } + + private static bool IsPopulatableCollectionType(ITypeSymbol type) + { + switch (type) + { + case IArrayTypeSymbol: + return false; + case INamedTypeSymbol { IsGenericType: true } namedType: + { + string originalDef = namedType.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // List of populatable collection types (can use Clear + Add pattern) + string[] populatableTypes = + [ + "global::System.Collections.Generic.List", + "global::System.Collections.Generic.HashSet", + "global::System.Collections.Generic.LinkedList", + "global::System.Collections.Generic.Queue", + "global::System.Collections.Generic.Stack", + "global::System.Collections.Generic.SortedSet", + "global::System.Collections.ObjectModel.Collection", + "global::System.Collections.ObjectModel.ObservableCollection", + "global::System.Collections.Concurrent.ConcurrentBag", + "global::System.Collections.Concurrent.ConcurrentQueue", + "global::System.Collections.Concurrent.ConcurrentStack", + // Dictionaries + "global::System.Collections.Generic.Dictionary", + "global::System.Collections.Generic.SortedDictionary", + "global::System.Collections.Generic.SortedList", + "global::System.Collections.Concurrent.ConcurrentDictionary" + ]; + + foreach (string populatable in populatableTypes) + { + if (originalDef == populatable) + return true; + } + + break; + } + } + // Also check if the type implements ICollection and has Add method // This covers custom collection types foreach (INamedTypeSymbol? iface in type.AllInterfaces) @@ -281,5 +355,99 @@ private static MemberCloneBehavior GetMemberBehavior(ISymbol member, Compilation ? GetMemberBehavior(member, memberType, compilation) : MemberCloneBehavior.Clone; } + + private static bool IsRedundantNonAutoPropertyClonedThroughField( + IPropertySymbol property, + FastClonerMemberVisibility visibilityPolicy, + Compilation compilation) + { + if (property.SetMethod == null) + return false; + Accessibility setterAccess = property.SetMethod.DeclaredAccessibility; + bool setterIsAccessible = setterAccess is Accessibility.Public + or Accessibility.Internal + or Accessibility.ProtectedOrInternal; + if (setterIsAccessible) + return false; + if (HasAutoPropertyBackingField(property)) + return false; + + IFieldSymbol? target = TryGetSimpleSetterTargetField(property, compilation); + return target != null && WillFieldBeCollected(target, visibilityPolicy, compilation); + } + + private static IFieldSymbol? TryGetSimpleSetterTargetField(IPropertySymbol property, Compilation compilation) + { + IMethodSymbol? setter = property.SetMethod; + if (setter == null) + return null; + + foreach (SyntaxReference syntaxRef in setter.DeclaringSyntaxReferences) + { + if (syntaxRef.GetSyntax() is not AccessorDeclarationSyntax accessor) + continue; + + ExpressionSyntax? candidate = null; + if (accessor.ExpressionBody != null) + { + candidate = accessor.ExpressionBody.Expression; + } + else if (accessor.Body is { Statements.Count: 1 } body + && body.Statements[0] is ExpressionStatementSyntax exprStmt) + { + candidate = exprStmt.Expression; + } + + if (candidate is not AssignmentExpressionSyntax assign) + continue; + if (!assign.OperatorToken.IsKind(SyntaxKind.EqualsToken)) + continue; + if (assign.Right is not IdentifierNameSyntax rhs || rhs.Identifier.Text != "value") + continue; + + SemanticModel sm = compilation.GetSemanticModel(assign.SyntaxTree); + ISymbol? lhsSymbol = sm.GetSymbolInfo(assign.Left).Symbol; + if (lhsSymbol is IFieldSymbol field + && SymbolEqualityComparer.Default.Equals(field.ContainingType, property.ContainingType)) + { + return field; + } + } + + return null; + } + + private static bool WillFieldBeCollected( + IFieldSymbol field, + FastClonerMemberVisibility visibilityPolicy, + Compilation compilation) + { + if (field.IsConst || field.IsStatic || field.IsImplicitlyDeclared) + return false; + + MemberCloneBehavior behavior = GetMemberBehavior(field, compilation); + if (behavior == MemberCloneBehavior.Ignore) + return false; + + if (HasExplicitMemberBehaviorAttribute(field, compilation)) + return true; + + FastClonerMemberVisibility mask = MemberModel.MapAccessibility(field.DeclaredAccessibility); + return (visibilityPolicy & mask) != 0; + } + + private static bool HasAutoPropertyBackingField(IPropertySymbol property) + { + INamedTypeSymbol? container = property.ContainingType; + if (container == null) + return false; + string backingFieldName = $"<{property.Name}>k__BackingField"; + foreach (ISymbol member in container.GetMembers(backingFieldName)) + { + if (member is IFieldSymbol) + return true; + } + return false; + } } diff --git a/src/FastCloner.SourceGenerator/MemberModel.cs b/src/FastCloner.SourceGenerator/MemberModel.cs index c7d174b..37a29b8 100644 --- a/src/FastCloner.SourceGenerator/MemberModel.cs +++ b/src/FastCloner.SourceGenerator/MemberModel.cs @@ -4,9 +4,18 @@ namespace FastCloner.SourceGenerator; -/// -/// Categorizes member types for optimized clone code generation. -/// +[Flags] +internal enum FastClonerMemberVisibility +{ + None = 0, + Public = 1, + Internal = 2, + Protected = 4, + Private = 8, + NonPublic = Internal | Protected | Private, + All = Public | NonPublic, +} + internal enum MemberTypeKind { Safe, // Primitives, strings - can be shallow copied @@ -20,6 +29,14 @@ internal enum MemberTypeKind Other // Everything else - shallow copy fallback } +internal enum NonPublicAccessorStrategy +{ + None = 0, + Field = 1, + BackingField = 2, + SetterMethod = 3 +} + internal enum CollectionKind { None, @@ -89,7 +106,11 @@ internal readonly record struct MemberModel( bool CollectionHasCount = true, // Whether the source collection type has Count property bool CollectionHasIndexer = true, // Whether the source collection type supports [i] indexing string? ClonableExtensionClass = null, // Precomputed FQN of the extension class for this type (when TypeKind == Clonable) - string? ElementClonableExtensionClass = null // Precomputed FQN of the extension class for the element type (when ElementHasClonableAttr) + string? ElementClonableExtensionClass = null, // Precomputed FQN of the extension class for the element type (when ElementHasClonableAttr) + FastClonerMemberVisibility MemberVisibility = FastClonerMemberVisibility.Public, // Visibility mask of the member, used by the type-level [FastClonerVisibility] policy + NonPublicAccessorStrategy AccessorStrategy = NonPublicAccessorStrategy.None, // How to access the member when it is not publicly accessible + string? DeclaringTypeFullName = null, // FQN of the type that DECLARES this member (may differ from the cloned type for inherited members) + bool GetterIsAccessible = true // Whether the value can be read directly via source.X (false => read via accessor too) ) : IEquatable { /// @@ -122,6 +143,25 @@ public static MemberModel Create(IPropertySymbol property, bool nullabilityEnabl // Check for PreserveIdentity attribute bool? preserveIdentity = GetPreserveIdentityFromAttributes(property.GetAttributes()); + + // Visibility mask: take the most permissive of getter/setter, mirroring the runtime mask helper. + FastClonerMemberVisibility visibility = MapAccessibility(GetEffectivePropertyAccessibility(property)); + bool getterIsAccessible = property.GetMethod == null || IsAccessibleFromExternalClass(property.GetMethod.DeclaredAccessibility); + + // Accessor strategy: only relevant when the SETTER is non-public (we always need to write into the clone target). + NonPublicAccessorStrategy accessorStrategy = NonPublicAccessorStrategy.None; + if (property.SetMethod != null && !setterIsAccessible) + { + accessorStrategy = HasAutoPropertyBackingField(property) + ? NonPublicAccessorStrategy.BackingField + : NonPublicAccessorStrategy.SetterMethod; + } + else if (property.SetMethod == null && property.GetMethod != null && !getterIsAccessible) + { + // Truly read-only & non-public property; we can't clone it via direct assignment. + } + + string? declaringTypeFqn = property.ContainingType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); return new MemberModel( property.Name, @@ -155,7 +195,11 @@ public static MemberModel Create(IPropertySymbol property, bool nullabilityEnabl collHasCount, collHasIndexer, clonableExtClass, - elemClonableExtClass); + elemClonableExtClass, + visibility, + accessorStrategy, + declaringTypeFqn, + getterIsAccessible); } public static MemberModel Create(IFieldSymbol field, bool nullabilityEnabled, Compilation compilation, MemberCloneBehavior memberBehavior = MemberCloneBehavior.Clone) @@ -169,10 +213,17 @@ public static MemberModel Create(IFieldSymbol field, bool nullabilityEnabled, Co // Field accessor capabilities - fields always have getters, setters depend on readonly bool hasGetter = true; bool hasSetter = !field.IsReadOnly; - bool setterIsAccessible = !field.IsReadOnly; // If not readonly, it's accessible (we only collect public fields) + bool fieldIsAccessible = IsAccessibleFromExternalClass(field.DeclaredAccessibility); + bool setterIsAccessible = !field.IsReadOnly && fieldIsAccessible; // Check for PreserveIdentity attribute bool? preserveIdentity = GetPreserveIdentityFromAttributes(field.GetAttributes()); + + FastClonerMemberVisibility visibility = MapAccessibility(field.DeclaredAccessibility); + NonPublicAccessorStrategy accessorStrategy = fieldIsAccessible + ? NonPublicAccessorStrategy.None + : NonPublicAccessorStrategy.Field; + string? declaringTypeFqn = field.ContainingType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); return new MemberModel( field.Name, @@ -206,12 +257,64 @@ public static MemberModel Create(IFieldSymbol field, bool nullabilityEnabled, Co collHasCount, collHasIndexer, clonableExtClass, - elemClonableExtClass); + elemClonableExtClass, + visibility, + accessorStrategy, + declaringTypeFqn, + fieldIsAccessible); + } + + private static bool IsAccessibleFromExternalClass(Accessibility accessibility) => + accessibility is Accessibility.Public + or Accessibility.Internal + or Accessibility.ProtectedOrInternal; + + private static Accessibility GetEffectivePropertyAccessibility(IPropertySymbol property) + { + Accessibility get = property.GetMethod?.DeclaredAccessibility ?? Accessibility.Private; + Accessibility set = property.SetMethod?.DeclaredAccessibility ?? Accessibility.Private; + return MoreAccessible(get, set); + } + + private static Accessibility MoreAccessible(Accessibility a, Accessibility b) + { + return Score(a) >= Score(b) ? a : b; + static int Score(Accessibility ac) => ac switch + { + Accessibility.Public => 6, + Accessibility.ProtectedOrInternal => 5, + Accessibility.Internal => 4, + Accessibility.Protected => 3, + Accessibility.ProtectedAndInternal => 2, + Accessibility.Private => 1, + _ => 0, + }; + } + + public static FastClonerMemberVisibility MapAccessibility(Accessibility accessibility) => + accessibility switch + { + Accessibility.Public => FastClonerMemberVisibility.Public, + Accessibility.Internal => FastClonerMemberVisibility.Internal, + Accessibility.Protected => FastClonerMemberVisibility.Protected, + Accessibility.ProtectedOrInternal => FastClonerMemberVisibility.Protected | FastClonerMemberVisibility.Internal, + Accessibility.ProtectedAndInternal => FastClonerMemberVisibility.Protected | FastClonerMemberVisibility.Internal, + _ => FastClonerMemberVisibility.Private, + }; + + private static bool HasAutoPropertyBackingField(IPropertySymbol property) + { + INamedTypeSymbol? container = property.ContainingType; + if (container == null) return false; + string backingFieldName = $"<{property.Name}>k__BackingField"; + foreach (ISymbol member in container.GetMembers(backingFieldName)) + { + if (member is IFieldSymbol) + return true; + } + return false; } - /// - /// Extracts PreserveIdentity value from attributes if present. - /// private static bool? GetPreserveIdentityFromAttributes(System.Collections.Immutable.ImmutableArray attributes) { foreach (AttributeData attr in attributes) diff --git a/src/FastCloner.SourceGenerator/NonPublicAccessorEmitter.cs b/src/FastCloner.SourceGenerator/NonPublicAccessorEmitter.cs new file mode 100644 index 0000000..e71a570 --- /dev/null +++ b/src/FastCloner.SourceGenerator/NonPublicAccessorEmitter.cs @@ -0,0 +1,285 @@ +using System.Text; + +namespace FastCloner.SourceGenerator; + +internal readonly record struct NonPublicAccessor( + string MemberName, // user-facing C# member name (e.g. "privateField" or "PrivateSetterProperty") + string AccessorMethodName, // generated identifier in the extension class (e.g. "__fc_privateField") + string DeclaringTypeFqn, // FQN of the type that declares the member + string MemberTypeFqn, // FQN of the field/property type + NonPublicAccessorStrategy Strategy, // Field, BackingField, or SetterMethod + bool IsBackingFieldStorage, // True if the runtime storage is a field (true for Field & BackingField, false for SetterMethod) + bool DeclaringTypeIsStruct, // For struct receivers we need 'this' to be passed by ref to mutate it + bool RequiresGetterAccessor // True when the property's getter is also non-public, so we must emit a separate UnsafeAccessor for it +); + +internal static class NonPublicAccessorEmitter +{ + public static string GetFieldStorageName(NonPublicAccessor accessor) => + accessor.Strategy switch + { + NonPublicAccessorStrategy.Field => accessor.MemberName, + NonPublicAccessorStrategy.BackingField => $"<{accessor.MemberName}>k__BackingField", + _ => accessor.MemberName, + }; + + public static string GetSetterMethodName(NonPublicAccessor accessor) => $"set_{accessor.MemberName}"; + + public static string GetGetterMethodName(NonPublicAccessor accessor) => $"get_{accessor.MemberName}"; + + public static string GetGetterAccessorIdentifier(NonPublicAccessor accessor) => $"{accessor.AccessorMethodName}_get"; + + public static NonPublicAccessor BuildFor(MemberModel member, string declaringTypeFqnFallback, bool declaringTypeIsStruct) + { + string declaringFqn = member.DeclaringTypeFullName ?? declaringTypeFqnFallback; + string accessorMethodName = $"__fc_{Sanitize(member.Name)}"; + + bool isBackingFieldStorage = + member.AccessorStrategy is NonPublicAccessorStrategy.Field or NonPublicAccessorStrategy.BackingField; + + bool requiresGetterAccessor = !isBackingFieldStorage && !member.GetterIsAccessible; + + return new NonPublicAccessor( + MemberName: member.Name, + AccessorMethodName: accessorMethodName, + DeclaringTypeFqn: declaringFqn, + MemberTypeFqn: member.TypeFullName, + Strategy: member.AccessorStrategy, + IsBackingFieldStorage: isBackingFieldStorage, + DeclaringTypeIsStruct: declaringTypeIsStruct, + RequiresGetterAccessor: requiresGetterAccessor); + } + + public static void WriteDeclarations(CloneGeneratorContext context, StringBuilder sb, string indent, bool insideNestedShell) + { + if (context.NonPublicAccessors.Count == 0) + return; + + bool useUnsafeAccessor = context.TargetFramework >= TargetFramework.Net8; + bool useRuntimeBridge = !useUnsafeAccessor && context.IsFastClonerAvailable && context.BridgeContract.IsAvailable; + + if (!useUnsafeAccessor && !useRuntimeBridge) + return; + + string accessorMemberAccess = insideNestedShell ? "internal" : "private"; + + sb.AppendLine(); + sb.AppendLine($"{indent}// FastCloner SG: non-public member accessors"); + + foreach (NonPublicAccessor accessor in context.NonPublicAccessors) + { + if (useUnsafeAccessor) + { + WriteUnsafeAccessorDeclaration(sb, accessor, indent, accessorMemberAccess); + } + else + { + WriteRuntimeBridgeCacheDeclaration(context, sb, accessor, indent, accessorMemberAccess); + } + } + } + + private static void WriteUnsafeAccessorDeclaration(StringBuilder sb, NonPublicAccessor accessor, string indent, string memberAccess) + { + string thisParam = accessor.DeclaringTypeIsStruct + ? $"ref {accessor.DeclaringTypeFqn} instance" + : $"{accessor.DeclaringTypeFqn} instance"; + + if (accessor.IsBackingFieldStorage) + { + string fieldName = GetFieldStorageName(accessor); + sb.AppendLine($"{indent}[global::System.Runtime.CompilerServices.UnsafeAccessor(global::System.Runtime.CompilerServices.UnsafeAccessorKind.Field, Name = \"{fieldName}\")]"); + sb.AppendLine($"{indent}{memberAccess} static extern ref {accessor.MemberTypeFqn} {accessor.AccessorMethodName}({thisParam});"); + return; + } + + string setterName = GetSetterMethodName(accessor); + sb.AppendLine($"{indent}[global::System.Runtime.CompilerServices.UnsafeAccessor(global::System.Runtime.CompilerServices.UnsafeAccessorKind.Method, Name = \"{setterName}\")]"); + sb.AppendLine($"{indent}{memberAccess} static extern void {accessor.AccessorMethodName}({thisParam}, {accessor.MemberTypeFqn} value);"); + + if (accessor.RequiresGetterAccessor) + { + string getterName = GetGetterMethodName(accessor); + string getterAccessorId = GetGetterAccessorIdentifier(accessor); + sb.AppendLine($"{indent}[global::System.Runtime.CompilerServices.UnsafeAccessor(global::System.Runtime.CompilerServices.UnsafeAccessorKind.Method, Name = \"{getterName}\")]"); + sb.AppendLine($"{indent}{memberAccess} static extern {accessor.MemberTypeFqn} {getterAccessorId}({thisParam});"); + } + } + + private static void WriteRuntimeBridgeCacheDeclaration(CloneGeneratorContext context, StringBuilder sb, NonPublicAccessor accessor, string indent, string memberAccess) + { + string proxyFqn = "global::" + context.BridgeContract.ProxyTypeFullName; + if (accessor.IsBackingFieldStorage) + { + string fieldName = GetFieldStorageName(accessor); + sb.AppendLine( + $"{indent}{memberAccess} static readonly global::System.Reflection.FieldInfo {accessor.AccessorMethodName}_FI = " + + $"{proxyFqn}.ResolveDeclaredField(typeof({accessor.DeclaringTypeFqn}), \"{fieldName}\");"); + } + else + { + sb.AppendLine( + $"{indent}{memberAccess} static readonly global::System.Reflection.PropertyInfo {accessor.AccessorMethodName}_PI = " + + $"{proxyFqn}.ResolveDeclaredProperty(typeof({accessor.DeclaringTypeFqn}), \"{accessor.MemberName}\");"); + } + } + + public static bool CanCloneNonPublicMembers(CloneGeneratorContext context) + { + if (context.TargetFramework >= TargetFramework.Net8) + return true; + + return context is { IsFastClonerAvailable: true, BridgeContract.IsAvailable: true }; + } + + public static void WriteCloneCall( + CloneGeneratorContext context, + StringBuilder sb, + MemberModel member, + string resultVar, + string sourceVar, + string stateVar, + string indent) + { + string declaringFqnFallback = context.Model.FullyQualifiedName; + bool declaringIsStruct = context.Model.IsStruct; + NonPublicAccessor accessor = BuildFor(member, declaringFqnFallback, declaringIsStruct); + accessor = context.RegisterNonPublicAccessor(accessor); + + if (context.TargetFramework >= TargetFramework.Net8) + { + WriteUnsafeAccessorCall(context, sb, member, accessor, resultVar, sourceVar, stateVar, indent); + } + else + { + WriteRuntimeBridgeCall(context, sb, member, accessor, resultVar, sourceVar, indent); + } + } + + private static void WriteUnsafeAccessorCall( + CloneGeneratorContext context, + StringBuilder sb, + MemberModel member, + NonPublicAccessor accessor, + string resultVar, + string sourceVar, + string stateVar, + string indent) + { + string structRefSource = accessor.DeclaringTypeIsStruct ? "ref " : string.Empty; + string structRefTarget = accessor.DeclaringTypeIsStruct ? "ref " : string.Empty; + string accessorPrefix = context.GetNonPublicAccessorPrefix(); + + string readExpression; + if (member.GetterIsAccessible) + { + readExpression = $"{sourceVar}.{member.Name}"; + } + else if (accessor.IsBackingFieldStorage) + { + readExpression = $"{accessorPrefix}{accessor.AccessorMethodName}({structRefSource}{sourceVar})"; + } + else + { + string getterAccessorId = GetGetterAccessorIdentifier(accessor); + readExpression = $"{accessorPrefix}{getterAccessorId}({structRefSource}{sourceVar})"; + } + + string clonedExpression = ProduceClonedExpression(context, member, readExpression, stateVar); + + if (accessor.IsBackingFieldStorage) + { + sb.AppendLine($"{indent}{accessorPrefix}{accessor.AccessorMethodName}({structRefTarget}{resultVar}) = {clonedExpression};"); + } + else + { + sb.AppendLine($"{indent}{accessorPrefix}{accessor.AccessorMethodName}({structRefTarget}{resultVar}, {clonedExpression});"); + } + } + + private static void WriteRuntimeBridgeCall( + CloneGeneratorContext context, + StringBuilder sb, + MemberModel member, + NonPublicAccessor accessor, + string resultVar, + string sourceVar, + string indent) + { + string accessorPrefix = context.GetNonPublicAccessorPrefix(); + string proxyFqn = "global::" + context.BridgeContract.ProxyTypeFullName; + + if (accessor.DeclaringTypeIsStruct) + { + string boxVar = $"__nptarget_{context.GetNextVariableId()}"; + string structFqn = context.Model.FullyQualifiedName; + sb.AppendLine($"{indent}{{"); + sb.AppendLine($"{indent} object {boxVar} = (object){resultVar};"); + WriteRuntimeBridgeCallStatement(sb, indent + " ", proxyFqn, accessorPrefix, accessor, member, sourceVar, boxVar); + sb.AppendLine($"{indent} {resultVar} = ({structFqn}){boxVar};"); + sb.AppendLine($"{indent}}}"); + } + else + { + WriteRuntimeBridgeCallStatement(sb, indent, proxyFqn, accessorPrefix, accessor, member, sourceVar, resultVar); + } + } + + private static void WriteRuntimeBridgeCallStatement( + StringBuilder sb, + string indent, + string proxyFqn, + string accessorPrefix, + NonPublicAccessor accessor, + MemberModel member, + string sourceVar, + string targetVar) + { + if (accessor.IsBackingFieldStorage) + { + string fiRef = $"{accessorPrefix}{accessor.AccessorMethodName}_FI"; + string method = member.IsShallowClone ? "CopyField" : "DeepCloneField"; + sb.AppendLine($"{indent}{proxyFqn}.{method}({sourceVar}, {targetVar}, {fiRef});"); + } + else + { + string piRef = $"{accessorPrefix}{accessor.AccessorMethodName}_PI"; + sb.AppendLine($"{indent}{proxyFqn}.DeepCloneProperty({sourceVar}, {targetVar}, {piRef});"); + } + } + + private static string ProduceClonedExpression(CloneGeneratorContext context, MemberModel member, string readExpression, string stateVar) + { + if (member.IsShallowClone) + return readExpression; + + switch (member.TypeKind) + { + case MemberTypeKind.Safe: + return readExpression; + + case MemberTypeKind.Clonable: + return $"{member.ClonableExtensionClass}.InternalFastDeepClone({readExpression}, {stateVar})"; + + default: + if (context.IsFastClonerAvailable) + { + return $"({member.TypeFullName})({CloneGeneratorContext.FastClonerDeepCloneCall(readExpression)}!)"; + } + return readExpression; + } + } + + private static string Sanitize(string name) + { + StringBuilder sb = new StringBuilder(); + foreach (char c in name) + { + if (char.IsLetterOrDigit(c) || c == '_') + sb.Append(c); + else + sb.Append('_'); + } + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/FastCloner.SourceGenerator/StateRequirementAnalyzer.cs b/src/FastCloner.SourceGenerator/StateRequirementAnalyzer.cs index 5047432..a6a29f5 100644 --- a/src/FastCloner.SourceGenerator/StateRequirementAnalyzer.cs +++ b/src/FastCloner.SourceGenerator/StateRequirementAnalyzer.cs @@ -22,9 +22,9 @@ public static AnalysisResult Analyze(INamedTypeSymbol rootType, Compilation comp return new AnalysisResult(false, false); } - Dictionary typeOccurrences = new(SymbolEqualityComparer.Default); - HashSet visited = new(SymbolEqualityComparer.Default); - HashSet typesInCollections = new(SymbolEqualityComparer.Default); + Dictionary typeOccurrences = new Dictionary(SymbolEqualityComparer.Default); + HashSet visited = new HashSet(SymbolEqualityComparer.Default); + HashSet typesInCollections = new HashSet(SymbolEqualityComparer.Default); log.Add($" -> Collecting reachable reference types from {rootType.Name} members..."); diff --git a/src/FastCloner.Tests/BridgeProxyEmitterTests.cs b/src/FastCloner.Tests/BridgeProxyEmitterTests.cs new file mode 100644 index 0000000..e49d6f9 --- /dev/null +++ b/src/FastCloner.Tests/BridgeProxyEmitterTests.cs @@ -0,0 +1,120 @@ +using FastCloner.SourceGenerator; + +namespace FastCloner.Tests; + +public class BridgeProxyEmitterTests +{ + private static BridgeContract MakeContract() => new BridgeContract( + BridgeTypeMetadataName: "FastCloner.FastClonerSourceGeneratorBridge", + ProxyTypeFullName: "FastCloner.SourceGenerated.__FastClonerSGBridgeProxy", + Methods: new EquatableArray( + [ + new BridgeMethodSpec( + Name: "DeepCloneField", + ReturnTypeFqn: "void", + ParameterTypeFqns: new EquatableArray(["object", "object", "global::System.Reflection.FieldInfo"])), + new BridgeMethodSpec( + Name: "ResolveDeclaredField", + ReturnTypeFqn: "global::System.Reflection.FieldInfo", + ParameterTypeFqns: new EquatableArray(["global::System.Type", "string"])), + ]), + IsAvailable: true); + + [Test] + public async Task Emits_namespace_and_proxy_type_from_contract_proxy_fqn() + { + BridgeContract contract = MakeContract(); + + string source = BridgeProxyEmitter.Emit(contract); + + await Assert.That(source).Contains("namespace FastCloner.SourceGenerated"); + await Assert.That(source).Contains("internal static class __FastClonerSGBridgeProxy"); + } + + [Test] + public async Task Emits_action_delegate_for_void_returning_method() + { + BridgeContract contract = MakeContract(); + + string source = BridgeProxyEmitter.Emit(contract); + + await Assert.That(source).Contains( + "internal static readonly global::System.Action DeepCloneField ="); + } + + [Test] + public async Task Emits_func_delegate_for_value_returning_method() + { + BridgeContract contract = MakeContract(); + + string source = BridgeProxyEmitter.Emit(contract); + + await Assert.That(source).Contains( + "internal static readonly global::System.Func ResolveDeclaredField ="); + } + + [Test] + public async Task Emits_resolve_static_method_with_parameter_typeof_list() + { + BridgeContract contract = MakeContract(); + + string source = BridgeProxyEmitter.Emit(contract); + + await Assert.That(source).Contains( + "ResolveStaticMethod(\"DeepCloneField\", typeof(object), typeof(object), typeof(global::System.Reflection.FieldInfo))"); + await Assert.That(source).Contains( + "ResolveStaticMethod(\"ResolveDeclaredField\", typeof(global::System.Type), typeof(string))"); + } + + [Test] + public async Task Emits_const_bridge_type_name_from_contract() + { + BridgeContract contract = MakeContract(); + + string source = BridgeProxyEmitter.Emit(contract); + + await Assert.That(source).Contains( + "private const string BridgeTypeName = \"FastCloner.FastClonerSourceGeneratorBridge\";"); + } + + [Test] + public async Task Output_is_byte_stable_for_identical_input() + { + BridgeContract contract = MakeContract(); + + string a = BridgeProxyEmitter.Emit(contract); + string b = BridgeProxyEmitter.Emit(contract); + + await Assert.That(a).IsEqualTo(b); + } + + [Test] + public async Task Should_not_emit_under_net8_or_newer() + { + BridgeContract contract = MakeContract(); + + bool net10 = BridgeProxyEmitter.ShouldEmit(TargetFramework.Net10, contract); + bool net8 = BridgeProxyEmitter.ShouldEmit(TargetFramework.Net8, contract); + + await Assert.That(net10).IsFalse(); + await Assert.That(net8).IsFalse(); + } + + [Test] + public async Task Should_emit_under_older_tfm_when_contract_available() + { + BridgeContract contract = MakeContract(); + + bool ns20 = BridgeProxyEmitter.ShouldEmit(TargetFramework.NetStandard20, contract); + + await Assert.That(ns20).IsTrue(); + } + + [Test] + public async Task Should_not_emit_when_contract_unavailable() + { + bool empty = BridgeProxyEmitter.ShouldEmit(TargetFramework.NetStandard20, BridgeContract.Empty); + + await Assert.That(empty).IsFalse(); + } +} diff --git a/src/FastCloner.Tests/ConcurrentTests.cs b/src/FastCloner.Tests/ConcurrentTests.cs index 3c60dfd..5565d69 100644 --- a/src/FastCloner.Tests/ConcurrentTests.cs +++ b/src/FastCloner.Tests/ConcurrentTests.cs @@ -1,8 +1,9 @@ using System.Collections.Concurrent; using FastCloner.Code; -using System.Threading.Tasks; namespace FastCloner.Tests; + +[NotInParallel] public class ConcurrentTests(int maxRecursionDepth) : BaseTestFixture(maxRecursionDepth) { private class TestClass diff --git a/src/FastCloner.Tests/CopyToObjectTests.cs b/src/FastCloner.Tests/CopyToObjectTests.cs index a0a8ea6..017dbe4 100644 --- a/src/FastCloner.Tests/CopyToObjectTests.cs +++ b/src/FastCloner.Tests/CopyToObjectTests.cs @@ -521,7 +521,7 @@ public async Task Class_With_Subclass_Should_Be_Deep_CLoned() } [Test] - [NotInParallel("FastClonerGlobalState")] + [NotInParallel] public async Task DeepCloneTo_RuntimeMutations_ReflectAndRestoreOnConfiguredRail() { C1 shared = new C1 { A = 12, B = "shared" }; diff --git a/src/FastCloner.Tests/NonPublicAccessorEmitterTests.cs b/src/FastCloner.Tests/NonPublicAccessorEmitterTests.cs new file mode 100644 index 0000000..fe90de1 --- /dev/null +++ b/src/FastCloner.Tests/NonPublicAccessorEmitterTests.cs @@ -0,0 +1,239 @@ +using System.Text; +using FastCloner.SourceGenerator; + +namespace FastCloner.Tests; + +public class NonPublicAccessorEmitterTests +{ + private const string ProxyFqn = "FastCloner.SourceGenerated.__FastClonerSGBridgeProxy"; + private const string GlobalProxyFqn = "global::" + ProxyFqn; + + private static BridgeContract MakeBridgeContract() => new BridgeContract( + BridgeTypeMetadataName: "FastCloner.FastClonerSourceGeneratorBridge", + ProxyTypeFullName: ProxyFqn, + Methods: new EquatableArray( + [ + new BridgeMethodSpec("DeepCloneField", "void", + new EquatableArray(["object", "object", "global::System.Reflection.FieldInfo"])), + new BridgeMethodSpec("CopyField", "void", + new EquatableArray(["object", "object", "global::System.Reflection.FieldInfo"])), + new BridgeMethodSpec("DeepCloneProperty", "void", + new EquatableArray(["object", "object", "global::System.Reflection.PropertyInfo"])), + new BridgeMethodSpec("ResolveDeclaredField", "global::System.Reflection.FieldInfo", + new EquatableArray(["global::System.Type", "string"])), + new BridgeMethodSpec("ResolveDeclaredProperty", "global::System.Reflection.PropertyInfo", + new EquatableArray(["global::System.Type", "string"])), + ]), + IsAvailable: true); + + private static TypeModel MakeTypeModel(bool isStruct, string fullyQualifiedName, EquatableArray? members = null) => + new TypeModel( + Namespace: "Test", + Name: "T", + FullyQualifiedName: fullyQualifiedName, + IsStruct: isStruct, + IsSealed: false, + IsAbstract: false, + IsRecord: false, + HasClonableBaseClass: false, + CanHaveCircularReferences: false, + NeedsStateTracking: false, + IsFastClonerAvailable: true, + Members: members ?? EquatableArray.Empty, + TypeParameters: EquatableArray.Empty, + TypeConstraints: EquatableArray.Empty, + RelatedTypes: EquatableArray.Empty, + NestedTypes: EquatableArray.Empty, + DerivedTypes: EquatableArray.Empty, + NullabilityEnabled: true, + TrustNullability: false, + TargetFramework: TargetFramework.NetStandard20); + + private static MemberModel MakePrivateField(string name = "_privateField", string type = "int", bool isShallow = false) => + new MemberModel( + Name: name, + TypeFullName: type, + IsReadOnly: false, + IsProperty: false, + IsField: true, + TypeKind: MemberTypeKind.Safe, + ElementTypeName: null, + KeyTypeName: null, + ValueTypeName: null, + ElementIsSafe: false, + ElementHasClonableAttr: false, + KeyIsSafe: false, + KeyIsClonable: false, + ValueIsSafe: false, + ValueIsClonable: false, + RequiresFastCloner: false, + CollectionKind: CollectionKind.None, + ConcreteTypeFullName: null, + IsValueType: true, + IsInitOnly: false, + IsRequired: false, + ArrayRank: 0, + IsNullable: false, + HasGetter: true, + HasSetter: true, + SetterIsAccessible: false, + MemberBehavior: isShallow ? MemberCloneBehavior.Shallow : MemberCloneBehavior.Clone, + AccessorStrategy: NonPublicAccessorStrategy.Field, + GetterIsAccessible: false); + + private static MemberModel MakePrivateSetterProperty(string name = "PrivateSetterProp", string type = "int") => + new MemberModel( + Name: name, + TypeFullName: type, + IsReadOnly: false, + IsProperty: true, + IsField: false, + TypeKind: MemberTypeKind.Safe, + ElementTypeName: null, + KeyTypeName: null, + ValueTypeName: null, + ElementIsSafe: false, + ElementHasClonableAttr: false, + KeyIsSafe: false, + KeyIsClonable: false, + ValueIsSafe: false, + ValueIsClonable: false, + RequiresFastCloner: false, + CollectionKind: CollectionKind.None, + ConcreteTypeFullName: null, + IsValueType: true, + IsInitOnly: false, + IsRequired: false, + ArrayRank: 0, + IsNullable: false, + HasGetter: true, + HasSetter: true, + SetterIsAccessible: false, + MemberBehavior: MemberCloneBehavior.Clone, + AccessorStrategy: NonPublicAccessorStrategy.SetterMethod, + GetterIsAccessible: true); + + [Test] + public async Task RuntimeBridge_StructReceiver_FieldStorage_EmitsBoxMutateUnbox() + { + MemberModel member = MakePrivateField(); + TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", new EquatableArray([member])); + CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract()); + StringBuilder sb = new StringBuilder(); + + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " "); + + string emitted = sb.ToString(); + + await Assert.That(emitted).Contains("object __nptarget_"); + await Assert.That(emitted).Contains("= (object)result;"); + await Assert.That(emitted).Contains($"{GlobalProxyFqn}.DeepCloneField(source,"); + await Assert.That(emitted).Contains("result = (global::Test.MyStruct)__nptarget_"); + + // The bridge call must target the box, not the local 'result'. + await Assert.That(emitted).DoesNotContain($"{GlobalProxyFqn}.DeepCloneField(source, result,"); + } + + [Test] + public async Task RuntimeBridge_StructReceiver_ShallowField_EmitsCopyField_ThroughBox() + { + MemberModel member = MakePrivateField(isShallow: true); + TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", new EquatableArray([member])); + CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract()); + StringBuilder sb = new StringBuilder(); + + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " "); + + string emitted = sb.ToString(); + + await Assert.That(emitted).Contains("object __nptarget_"); + await Assert.That(emitted).Contains($"{GlobalProxyFqn}.CopyField(source,"); + await Assert.That(emitted).Contains("result = (global::Test.MyStruct)__nptarget_"); + await Assert.That(emitted).DoesNotContain($"{GlobalProxyFqn}.CopyField(source, result,"); + } + + [Test] + public async Task RuntimeBridge_StructReceiver_PropertySetter_EmitsBoxMutateUnbox() + { + MemberModel member = MakePrivateSetterProperty(); + TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", new EquatableArray([member])); + CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract()); + StringBuilder sb = new StringBuilder(); + + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " "); + + string emitted = sb.ToString(); + + await Assert.That(emitted).Contains("object __nptarget_"); + await Assert.That(emitted).Contains($"{GlobalProxyFqn}.DeepCloneProperty(source,"); + await Assert.That(emitted).Contains("result = (global::Test.MyStruct)__nptarget_"); + await Assert.That(emitted).DoesNotContain($"{GlobalProxyFqn}.DeepCloneProperty(source, result,"); + } + + [Test] + public async Task RuntimeBridge_ClassReceiver_DoesNotBoxOrUnbox() + { + // Reference types should keep using the simple direct call -- boxing is a + // no-op and the generator should not pay for an extra copy. + MemberModel member = MakePrivateField(); + TypeModel model = MakeTypeModel(isStruct: false, "global::Test.MyClass", new EquatableArray([member])); + CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract()); + StringBuilder sb = new StringBuilder(); + + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " "); + + string emitted = sb.ToString(); + + await Assert.That(emitted).Contains($"{GlobalProxyFqn}.DeepCloneField(source, result,"); + await Assert.That(emitted).DoesNotContain("__nptarget_"); + await Assert.That(emitted).DoesNotContain("(object)result"); + } + + [Test] + public async Task RuntimeBridge_StructReceiver_MultipleCalls_UseDistinctBoxLocals() + { + // Two non-public members on the same struct must use distinct local + // names so the emitted method compiles. The generator uses + // GetNextVariableId() for this. + MemberModel a = MakePrivateField("_a"); + MemberModel b = MakePrivateField("_b"); + TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", + new EquatableArray([a, b])); + CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract()); + StringBuilder sb = new StringBuilder(); + + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, a, "result", "source", "state", " "); + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, b, "result", "source", "state", " "); + + string emitted = sb.ToString(); + + // Find both __nptarget_ identifiers and verify they differ. + System.Text.RegularExpressions.MatchCollection matches = + System.Text.RegularExpressions.Regex.Matches(emitted, @"__nptarget_(\d+)"); + HashSet ids = []; + foreach (System.Text.RegularExpressions.Match m in matches) + { + ids.Add(m.Groups[1].Value); + } + + await Assert.That(ids.Count).IsGreaterThanOrEqualTo(2); + } + + [Test] + public async Task TfmAtLeastNet8_DoesNotUseBridge_RegardlessOfStructness() + { + // Sanity: on .NET 8+ the emitter uses UnsafeAccessor and never boxes. + MemberModel member = MakePrivateField(); + TypeModel model = MakeTypeModel(isStruct: true, "global::Test.MyStruct", new EquatableArray([member])) + with { TargetFramework = TargetFramework.Net8 }; + CloneGeneratorContext ctx = new CloneGeneratorContext(model, MakeBridgeContract()); + StringBuilder sb = new StringBuilder(); + + NonPublicAccessorEmitter.WriteCloneCall(ctx, sb, member, "result", "source", "state", " "); + + string emitted = sb.ToString(); + + await Assert.That(emitted).DoesNotContain(GlobalProxyFqn); + await Assert.That(emitted).DoesNotContain("__nptarget_"); + } +} diff --git a/src/FastCloner.Tests/RuntimeBridgeStructBoxingTests.cs b/src/FastCloner.Tests/RuntimeBridgeStructBoxingTests.cs new file mode 100644 index 0000000..98b8ccb --- /dev/null +++ b/src/FastCloner.Tests/RuntimeBridgeStructBoxingTests.cs @@ -0,0 +1,162 @@ +using System.Reflection; + +namespace FastCloner.Tests; + +public class RuntimeBridgeStructBoxingTests +{ + private struct StructWithPrivateField + { + // Used through reflection inside the bridge. +#pragma warning disable CS0649 + private int _privateField; +#pragma warning restore CS0649 + + public int Read() => _privateField; + public void Write(int value) => _privateField = value; + } + + private struct StructWithPrivateInitOnlyField + { +#pragma warning disable CS0649 + private readonly int _privateField; +#pragma warning restore CS0649 + + public int Read() => _privateField; + } + + private struct StructWithPrivateSetterProperty + { + public int Value { get; private set; } + public void Set(int value) => Value = value; + } + + private class ClassWithPrivateField + { +#pragma warning disable CS0649 + private int _privateField; +#pragma warning restore CS0649 + + public int Read() => _privateField; + public void Write(int value) => _privateField = value; + } + + [Test] + public async Task RuntimeBridge_DeepCloneField_MutatesBoxedStruct() + { + StructWithPrivateField source = default; + source.Write(42); + + // The source generator emits: + // object box = (object)result; + // __Bridge.DeepCloneField(source, box, fi); + // result = (T)box; + // This test exercises the middle step: the bridge must mutate `box`. + object boxedTarget = default(StructWithPrivateField); + + FieldInfo fi = typeof(StructWithPrivateField).GetField( + "_privateField", + BindingFlags.NonPublic | BindingFlags.Instance)!; + + FastClonerSourceGeneratorBridge.DeepCloneField(source, boxedTarget, fi); + + StructWithPrivateField unboxed = (StructWithPrivateField)boxedTarget; + await Assert.That(unboxed.Read()).IsEqualTo(42); + } + + [Test] + public async Task RuntimeBridge_CopyField_MutatesBoxedStruct() + { + StructWithPrivateField source = default; + source.Write(42); + + object boxedTarget = default(StructWithPrivateField); + + FieldInfo fi = typeof(StructWithPrivateField).GetField( + "_privateField", + BindingFlags.NonPublic | BindingFlags.Instance)!; + + FastClonerSourceGeneratorBridge.CopyField(source, boxedTarget, fi); + + StructWithPrivateField unboxed = (StructWithPrivateField)boxedTarget; + await Assert.That(unboxed.Read()).IsEqualTo(42); + } + + [Test] + public async Task RuntimeBridge_DeepCloneField_MutatesBoxedStruct_InitOnlyField() + { + // Init-only fields are written through FieldInfo.SetValue, which also + // needs to mutate the boxed payload (not a copy). + StructWithPrivateInitOnlyField source = (StructWithPrivateInitOnlyField) + System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(StructWithPrivateInitOnlyField)); + FieldInfo fi = typeof(StructWithPrivateInitOnlyField).GetField( + "_privateField", + BindingFlags.NonPublic | BindingFlags.Instance)!; + fi.SetValueDirect(__makeref(source), 42); + + object boxedTarget = default(StructWithPrivateInitOnlyField); + + FastClonerSourceGeneratorBridge.DeepCloneField(source, boxedTarget, fi); + + StructWithPrivateInitOnlyField unboxed = (StructWithPrivateInitOnlyField)boxedTarget; + await Assert.That(unboxed.Read()).IsEqualTo(42); + } + + [Test] + public async Task RuntimeBridge_DeepCloneProperty_MutatesBoxedStruct() + { + StructWithPrivateSetterProperty source = default; + source.Set(42); + + object boxedTarget = default(StructWithPrivateSetterProperty); + + PropertyInfo pi = typeof(StructWithPrivateSetterProperty).GetProperty( + nameof(StructWithPrivateSetterProperty.Value), + BindingFlags.Public | BindingFlags.Instance)!; + + FastClonerSourceGeneratorBridge.DeepCloneProperty(source, boxedTarget, pi); + + StructWithPrivateSetterProperty unboxed = (StructWithPrivateSetterProperty)boxedTarget; + await Assert.That(unboxed.Value).IsEqualTo(42); + } + + [Test] + public async Task RuntimeBridge_DeepCloneField_DoesNotMutateUnboxedStructLocal() + { + // Negative pin: passing a struct local directly (not a pre-existing box) + // boxes it at the call site; the bridge mutates that fresh box, and the + // local is unaffected. The emitter must therefore wrap the call in + // box/mutate/unbox -- this test fails if someone simplifies the emitter + // back to a direct call. + StructWithPrivateField source = default; + source.Write(42); + + StructWithPrivateField result = default; + + FieldInfo fi = typeof(StructWithPrivateField).GetField( + "_privateField", + BindingFlags.NonPublic | BindingFlags.Instance)!; + + FastClonerSourceGeneratorBridge.DeepCloneField(source, result, fi); + + await Assert.That(result.Read()).IsEqualTo(0); + } + + [Test] + public async Task RuntimeBridge_DeepCloneField_MutatesClassReceiver() + { + // Sanity: with a reference-type receiver the bridge has always worked, + // because the `object` parameter holds the same reference as the local. + ClassWithPrivateField source = new(); + source.Write(42); + + ClassWithPrivateField result = new(); + + FieldInfo fi = typeof(ClassWithPrivateField).GetField( + "_privateField", + BindingFlags.NonPublic | BindingFlags.Instance)!; + + FastClonerSourceGeneratorBridge.DeepCloneField(source, result, fi); + + await Assert.That(result.Read()).IsEqualTo(42); + } +} diff --git a/src/FastCloner.Tests/SourceGeneratorEdgeCaseTests.cs b/src/FastCloner.Tests/SourceGeneratorEdgeCaseTests.cs index 2519dcf..f68dfdb 100644 --- a/src/FastCloner.Tests/SourceGeneratorEdgeCaseTests.cs +++ b/src/FastCloner.Tests/SourceGeneratorEdgeCaseTests.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using FastCloner.Code; using FastCloner.SourceGenerator.Shared; using System.Threading.Tasks; @@ -218,44 +219,562 @@ public async Task Record_With_Init_Properties_Should_Be_Cloned() #endregion - #region Issue 4: Private Setter Accessibility - - // Note: Properties with private setters should be SKIPPED by the source generator - // because extension classes can't access them. We verify this compiles and works - // for the accessible properties. + #region Issue 4: Private / Protected / Internal member cloning fidelity [FastClonerClonable] public class ClassWithPrivateSetter { public int PublicProperty { get; set; } public int PrivateSetterProperty { get; private set; } - - // Method to set the private property for testing + public void SetPrivate(int value) => PrivateSetterProperty = value; } [Test] [SourceGeneratorCompatible] - public async Task PrivateSetter_Properties_Should_Be_Skipped() + public async Task PrivateSetter_Property_Is_Cloned_Via_Backing_Field_Accessor() { - // Arrange ClassWithPrivateSetter original = new ClassWithPrivateSetter { PublicProperty = 100 }; original.SetPrivate(200); - // Act ClassWithPrivateSetter clone = original.FastDeepClone(); - // Assert - public property should be cloned, private setter property will be default await Assert.That(clone).IsNotNull(); await Assert.That(clone!.PublicProperty).IsEqualTo(100); - // Private setter property is not cloned (can't access from extension class) - // It will have default value - await Assert.That(clone.PrivateSetterProperty).IsEqualTo(0); + await Assert.That(clone.PrivateSetterProperty).IsEqualTo(200); + } + + [FastClonerClonable] + public class ClassWithPrivateField + { + public int PublicProperty { get; set; } + private int privateField; + + public int PrivateFieldValue => privateField; + + public void SetPrivateField(int value) => privateField = value; + } + + [Test] + [SourceGeneratorCompatible] + public async Task Private_Field_Is_Cloned_Via_UnsafeAccessor() + { + ClassWithPrivateField original = new ClassWithPrivateField + { + PublicProperty = 100 + }; + original.SetPrivateField(200); + + ClassWithPrivateField clone = original.FastDeepClone(); + + await Assert.That(clone).IsNotNull(); + await Assert.That(clone!.PublicProperty).IsEqualTo(100); + await Assert.That(clone.PrivateFieldValue).IsEqualTo(200); + } + + [FastClonerClonable] + public class ClassWithProtectedField + { + public int PublicProperty { get; set; } + protected int protectedField; + + public int ProtectedFieldValue => protectedField; + + public void SetProtectedField(int value) => protectedField = value; + } + + [Test] + [SourceGeneratorCompatible] + public async Task Protected_Field_Is_Cloned_Via_UnsafeAccessor() + { + ClassWithProtectedField original = new ClassWithProtectedField + { + PublicProperty = 100 + }; + original.SetProtectedField(300); + + ClassWithProtectedField clone = original.FastDeepClone(); + + await Assert.That(clone).IsNotNull(); + await Assert.That(clone!.PublicProperty).IsEqualTo(100); + await Assert.That(clone.ProtectedFieldValue).IsEqualTo(300); + } + + [FastClonerClonable] + public class ClassWithInternalField + { + public int PublicProperty { get; set; } + internal int internalField; + + public int InternalFieldValue => internalField; + + public void SetInternalField(int value) => internalField = value; + } + + [Test] + [SourceGeneratorCompatible] + public async Task Internal_Field_Is_Cloned() + { + ClassWithInternalField original = new ClassWithInternalField + { + PublicProperty = 100 + }; + original.SetInternalField(400); + + ClassWithInternalField clone = original.FastDeepClone(); + + await Assert.That(clone).IsNotNull(); + await Assert.That(clone!.PublicProperty).IsEqualTo(100); + await Assert.That(clone.InternalFieldValue).IsEqualTo(400); + } + + [FastClonerClonable] + public class ClassWithReferenceTypePrivateField + { + public string? PublicTag { get; set; } + private List? privateList; + + public IReadOnlyList? PrivateListSnapshot => privateList; + + public void SetPrivateList(List? list) => privateList = list; + } + + [Test] + [SourceGeneratorCompatible] + public async Task Private_Reference_Field_Is_Deep_Cloned() + { + List originalList = [1, 2, 3]; + ClassWithReferenceTypePrivateField original = new ClassWithReferenceTypePrivateField { PublicTag = "tag" }; + original.SetPrivateList(originalList); + + ClassWithReferenceTypePrivateField clone = original.FastDeepClone(); + + await Assert.That(clone).IsNotNull(); + await Assert.That(clone!.PublicTag).IsEqualTo("tag"); + await Assert.That(clone.PrivateListSnapshot).IsNotNull(); + // Deep clone, so values match but the instance is independent of the original. + await Assert.That(clone.PrivateListSnapshot!).IsEquivalentTo(originalList); + await Assert.That(ReferenceEquals(clone.PrivateListSnapshot, originalList)).IsFalse(); + } + + public class BaseWithPrivateField + { + private int inheritedPrivate; + public int InheritedPrivateValue => inheritedPrivate; + public void SetInheritedPrivate(int v) => inheritedPrivate = v; + } + + [FastClonerClonable] + public class DerivedWithBasePrivateField : BaseWithPrivateField + { + public int Own { get; set; } + } + + [Test] + [SourceGeneratorCompatible] + public async Task Inherited_Private_Field_Is_Cloned() + { + DerivedWithBasePrivateField original = new DerivedWithBasePrivateField { Own = 7 }; + original.SetInheritedPrivate(42); + + DerivedWithBasePrivateField clone = original.FastDeepClone(); + + await Assert.That(clone).IsNotNull(); + await Assert.That(clone!.Own).IsEqualTo(7); + await Assert.That(clone.InheritedPrivateValue).IsEqualTo(42); + } + + [FastClonerClonable] + public class ClassWithNonAutoPrivateGetterAndSetter + { + private int _backing; + + public string? PublicTag { get; set; } + + private int HiddenValue + { + get => _backing; + set => _backing = value; + } + + public int InspectHiddenValue() => HiddenValue; + public void AssignHiddenValue(int v) => HiddenValue = v; + } + + [Test] + [SourceGeneratorCompatible] + public async Task NonAuto_Property_With_NonPublic_Getter_And_Setter_Is_Cloned() + { + ClassWithNonAutoPrivateGetterAndSetter original = new ClassWithNonAutoPrivateGetterAndSetter + { + PublicTag = "hidden" + }; + original.AssignHiddenValue(123); + + ClassWithNonAutoPrivateGetterAndSetter clone = original.FastDeepClone(); + + await Assert.That(clone).IsNotNull(); + await Assert.That(clone!.PublicTag).IsEqualTo("hidden"); + await Assert.That(clone.InspectHiddenValue()).IsEqualTo(123); + } + + [FastClonerClonable] + public class GenericWithPrivateField + { + public string? Tag { get; set; } + private T? value; + + public T? Value => value; + public void SetValue(T? v) => value = v; + } + + [Test] + [SourceGeneratorCompatible] + public async Task Generic_Private_Field_Is_Cloned_Via_Generic_Accessor_Shell() + { + GenericWithPrivateField original = new GenericWithPrivateField { Tag = "g" }; + original.SetValue(99); + + GenericWithPrivateField clone = original.FastDeepClone(); + + await Assert.That(clone).IsNotNull(); + await Assert.That(clone!.Tag).IsEqualTo("g"); + await Assert.That(clone.Value).IsEqualTo(99); + } + + [FastClonerClonable] + public struct StructWithPrivateField + { + public int Public; + private int hidden; + + public int Hidden => hidden; + public void SetHidden(int v) => hidden = v; + } + + [Test] + [SourceGeneratorCompatible] + public async Task Struct_Private_Field_Is_Cloned() + { + StructWithPrivateField original = new StructWithPrivateField { Public = 5 }; + original.SetHidden(11); + + StructWithPrivateField clone = original.FastDeepClone(); + + await Assert.That(clone.Public).IsEqualTo(5); + await Assert.That(clone.Hidden).IsEqualTo(11); + } + + #endregion + + #region Type-level [FastClonerVisibility] policy + + [FastClonerClonable] + [FastClonerVisibility(FastClonerMemberVisibility.Public)] + public class PublicOnlyDto + { + public int Pub { get; set; } + private int priv; + public int PrivValue => priv; + public void SetPriv(int v) => priv = v; + } + + [Test] + [SourceGeneratorCompatible] + public async Task Visibility_Public_Excludes_Private_Fields() + { + PublicOnlyDto original = new PublicOnlyDto { Pub = 10 }; + original.SetPriv(99); + + PublicOnlyDto clone = original.FastDeepClone(); + + await Assert.That(clone!.Pub).IsEqualTo(10); + await Assert.That(clone.PrivValue).IsEqualTo(0); // explicitly opted out via type policy + } + + [FastClonerClonable] + [FastClonerVisibility(FastClonerMemberVisibility.Public | FastClonerMemberVisibility.Internal)] + public class PublicAndInternalOnly + { + public int Pub; + internal int Inter; + protected int Prot; + private int Priv; + + public int ProtValue => Prot; + public int PrivValue => Priv; + public void SetAll(int prot, int priv) + { + Prot = prot; + Priv = priv; + } + } + + [Test] + [SourceGeneratorCompatible] + public async Task Visibility_Public_And_Internal_Excludes_Protected_And_Private() + { + PublicAndInternalOnly original = new PublicAndInternalOnly { Pub = 1, Inter = 2 }; + original.SetAll(3, 4); + + PublicAndInternalOnly clone = original.FastDeepClone(); + + await Assert.That(clone!.Pub).IsEqualTo(1); + await Assert.That(clone.Inter).IsEqualTo(2); + await Assert.That(clone.ProtValue).IsEqualTo(0); + await Assert.That(clone.PrivValue).IsEqualTo(0); + } + + [FastClonerClonable] + [FastClonerVisibility(FastClonerMemberVisibility.Public)] + public class MemberLevelOverridesPolicy + { + public int Pub; + + // Type policy excludes private, but the explicit member-level FastClonerBehavior + // overrides it - this private field IS cloned. + [global::FastCloner.Code.FastClonerBehavior(global::FastCloner.Code.CloneBehavior.Clone)] + private int includedDespitePolicy; + + // Type policy excludes private, no override -> stays excluded. + private int excludedByPolicy; + + public int IncludedValue => includedDespitePolicy; + public int ExcludedValue => excludedByPolicy; + public void SetBoth(int included, int excluded) + { + includedDespitePolicy = included; + excludedByPolicy = excluded; + } + } + + [Test] + [SourceGeneratorCompatible] + public async Task Member_Level_FastClonerBehavior_Wins_Over_Type_Visibility_Policy() + { + MemberLevelOverridesPolicy original = new MemberLevelOverridesPolicy { Pub = 1 }; + original.SetBoth(included: 42, excluded: 99); + + MemberLevelOverridesPolicy clone = original.FastDeepClone(); + + await Assert.That(clone!.Pub).IsEqualTo(1); + await Assert.That(clone.IncludedValue).IsEqualTo(42); + await Assert.That(clone.ExcludedValue).IsEqualTo(0); + } + + [FastClonerClonable] + [FastClonerVisibility(FastClonerMemberVisibility.All)] + public class IgnoreWinsOverPolicy + { + public int Pub; + [global::FastCloner.Code.FastClonerIgnore] private int alwaysSkipped; + + public int SkippedValue => alwaysSkipped; + public void SetSkipped(int v) => alwaysSkipped = v; + } + + [Test] + [SourceGeneratorCompatible] + public async Task Member_Level_Ignore_Wins_Over_Permissive_Type_Policy() + { + IgnoreWinsOverPolicy original = new IgnoreWinsOverPolicy { Pub = 1 }; + original.SetSkipped(123); + + IgnoreWinsOverPolicy clone = original.FastDeepClone(); + + await Assert.That(clone!.Pub).IsEqualTo(1); + await Assert.That(clone.SkippedValue).IsEqualTo(0); + } + + [FastClonerVisibility(FastClonerMemberVisibility.Public)] + public class SgBaseWithPolicy + { + public int BasePublic; + private int _basePrivate; + public void SetBasePrivate(int v) => _basePrivate = v; + public int GetBasePrivate() => _basePrivate; + } + + [FastClonerClonable] + public class SgDerivedInheritsPolicy : SgBaseWithPolicy + { + public int DerivedPublic { get; set; } + private int _derivedPrivate; + public void SetDerivedPrivate(int v) => _derivedPrivate = v; + public int GetDerivedPrivate() => _derivedPrivate; + } + + [Test] + [SourceGeneratorCompatible] + public async Task SG_Visibility_Policy_Is_Inherited_From_Base_Type() + { + SgDerivedInheritsPolicy original = new SgDerivedInheritsPolicy { BasePublic = 1, DerivedPublic = 3 }; + original.SetBasePrivate(2); + original.SetDerivedPrivate(4); + + SgDerivedInheritsPolicy clone = original.FastDeepClone(); + + await Assert.That(clone).IsNotNull(); + await Assert.That(clone!.BasePublic).IsEqualTo(1); + await Assert.That(clone.GetBasePrivate()).IsEqualTo(0); + await Assert.That(clone.DerivedPublic).IsEqualTo(3); + await Assert.That(clone.GetDerivedPrivate()).IsEqualTo(0); + } + + [FastClonerClonable] + [FastClonerVisibility(FastClonerMemberVisibility.Public)] + public class SgMixedAccessibilityPropertyDto + { + // Property is publicly declared even though the setter is private. Under + // [FastClonerVisibility(Public)] it must be cloned (mask = most-permissive of get/set). + public int PublicGetPrivateSet { get; private set; } + public void Set(int v) => PublicGetPrivateSet = v; + } + + [Test] + [SourceGeneratorCompatible] + public async Task SG_PublicOnly_Policy_Includes_Property_With_Public_Get_And_Private_Set() + { + SgMixedAccessibilityPropertyDto original = new SgMixedAccessibilityPropertyDto(); + original.Set(42); + + SgMixedAccessibilityPropertyDto clone = original.FastDeepClone(); + + await Assert.That(clone).IsNotNull(); + await Assert.That(clone!.PublicGetPrivateSet).IsEqualTo(42); + } + + [FastClonerClonable] + [FastClonerVisibility(FastClonerMemberVisibility.Public)] + public class SgIgnoreFalseOverridesPolicy + { + public int Pub; + + // Type-level Public-only policy excludes private; explicit "don't ignore me" + // member-level attribute should put it back in (parity with runtime). + [global::FastCloner.Code.FastClonerIgnore(false)] + private int forciblyIncluded; + + public int IncludedValue => forciblyIncluded; + public void SetIncluded(int v) => forciblyIncluded = v; + } + + [Test] + [SourceGeneratorCompatible] + public async Task SG_FastClonerIgnore_False_Overrides_Visibility_Policy_Exclusion() + { + SgIgnoreFalseOverridesPolicy original = new SgIgnoreFalseOverridesPolicy { Pub = 1 }; + original.SetIncluded(7); + + SgIgnoreFalseOverridesPolicy clone = original.FastDeepClone(); + + await Assert.That(clone).IsNotNull(); + await Assert.That(clone!.Pub).IsEqualTo(1); + await Assert.That(clone.IncludedValue).IsEqualTo(7); + } + + [FastClonerClonable] + public class SgClassWithPrivateInitProperty + { + // Non-accessible init setter on an auto-property. The SG routes the assignment + // through the auto-property's backing field via [UnsafeAccessor], bypassing the + // init-restriction entirely. + public int PrivateInit { get; private init; } + + public string? Tag { get; set; } + + public SgClassWithPrivateInitProperty() { } + public SgClassWithPrivateInitProperty(int init) { PrivateInit = init; } + } + + [Test] + [SourceGeneratorCompatible] + public async Task SG_PrivateInit_Property_Is_Cloned_Via_Backing_Field() + { + SgClassWithPrivateInitProperty original = new SgClassWithPrivateInitProperty(99) { Tag = "ok" }; + + SgClassWithPrivateInitProperty clone = original.FastDeepClone(); + + await Assert.That(clone).IsNotNull(); + await Assert.That(clone!.Tag).IsEqualTo("ok"); + await Assert.That(clone.PrivateInit).IsEqualTo(99); } + [FastClonerClonable] + public class SgClassWithPrivateInitNonAutoProperty + { + private int _backing; + + // Non-auto property whose init setter is private. There is no auto-property backing + // field, so the SG must emit an [UnsafeAccessor] for set_X. The IsExternalInit modreq + // on the init setter is ignored by name-based UnsafeAccessor binding. + public int CustomInit + { + get => _backing; + private init => _backing = value; + } + + public string? Tag { get; set; } + + public SgClassWithPrivateInitNonAutoProperty() { } + public SgClassWithPrivateInitNonAutoProperty(int init) { CustomInit = init; } + } + + [Test] + [SourceGeneratorCompatible] + public async Task SG_NonAuto_PrivateInit_Property_Is_Cloned_Via_Backing_Field() + { + // The non-auto property's setter is a trivial assignment (`_backing = value`) to a + // field that is also collected, so the SG dedups the property's SetterMethod accessor + // and clones via the field directly. The post-condition is the same: PrivateInit is + // observed equal in the clone. + SgClassWithPrivateInitNonAutoProperty original = new SgClassWithPrivateInitNonAutoProperty(123) { Tag = "ok2" }; + + SgClassWithPrivateInitNonAutoProperty clone = original.FastDeepClone(); + + await Assert.That(clone).IsNotNull(); + await Assert.That(clone!.Tag).IsEqualTo("ok2"); + await Assert.That(clone.CustomInit).IsEqualTo(123); + } + + [FastClonerClonable] + public class SgClassWithComputedPrivateInitNonAuto + { + // Storage and observed value differ: the setter doubles the input. Dedup-via-field + // would lose this transformation, so the SG must keep the SetterMethod UnsafeAccessor + // here (i.e. NOT dedup). Cloning then observes the same `Computed` value because + // we go through the property accessor which inverts the doubling on read. + private int _half; + + public int Computed + { + get => _half * 2; + private init => _half = value / 2; + } + + public string? Tag { get; set; } + + public SgClassWithComputedPrivateInitNonAuto() { } + public SgClassWithComputedPrivateInitNonAuto(int v) { Computed = v; } + } + + [Test] + [SourceGeneratorCompatible] + public async Task SG_NonAuto_PrivateInit_With_Computed_Setter_Keeps_Setter_Accessor() + { + SgClassWithComputedPrivateInitNonAuto original = new SgClassWithComputedPrivateInitNonAuto(20) { Tag = "ok3" }; + + SgClassWithComputedPrivateInitNonAuto clone = original.FastDeepClone(); + + await Assert.That(clone).IsNotNull(); + await Assert.That(clone!.Tag).IsEqualTo("ok3"); + await Assert.That(clone.Computed).IsEqualTo(20); + } + + #endregion #region Issue 5: Delegate and Behavioral Types (Lazy, Func, Task) diff --git a/src/FastCloner.Tests/SpecialCaseTests.cs b/src/FastCloner.Tests/SpecialCaseTests.cs index d887ed4..3fdc979 100644 --- a/src/FastCloner.Tests/SpecialCaseTests.cs +++ b/src/FastCloner.Tests/SpecialCaseTests.cs @@ -1861,7 +1861,7 @@ public async Task Issue27_Clone_Entity_With_EventHandlers_Does_Not_Deep_Clone_De } [Test] - [NotInParallel("FastClonerGlobalState")] + [NotInParallel] public async Task Issue27_Clone_Entity_With_Ignored_EventHandlers_Nulls_Delegates() { // Arrange - user opts to ignore event handler types (OP's preferred workaround) @@ -1896,7 +1896,7 @@ public async Task Issue27_Clone_Entity_With_Ignored_EventHandlers_Nulls_Delegate } [Test] - [NotInParallel("FastClonerGlobalState")] + [NotInParallel] public async Task Issue27_Clone_Entity_With_Ignored_EventHandlers_After_PreWarm_Nulls_Delegates() { MvvmEntity original = new MvvmEntity { Name = "Test", Value = 42 }; @@ -3135,7 +3135,7 @@ public async Task FontCloningTest() [Test] - [NotInParallel("FastClonerGlobalState")] + [NotInParallel] public async Task Lazy_Clone() { LazyClass.Counter = 0; diff --git a/src/FastCloner.Tests/TypeBehaviorTests.cs b/src/FastCloner.Tests/TypeBehaviorTests.cs index 91ee91c..17267a6 100644 --- a/src/FastCloner.Tests/TypeBehaviorTests.cs +++ b/src/FastCloner.Tests/TypeBehaviorTests.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; namespace FastCloner.Tests; -[NotInParallel("FastClonerGlobalState")] +[NotInParallel] public class TypeBehaviorTests(int maxRecursionDepth) : BaseTestFixture(maxRecursionDepth) { public class SimpleClass @@ -825,21 +825,46 @@ public async Task GlobalConfigPublish_BumpsCacheVersion_ForSubsequentReaders() }; long startingVersion = FastClonerCache.GetCacheVersion(); - - FastCloner.MaxRecursionDepth = 999; + + FastCloner.SetTypeBehavior(CloneBehavior.Reference); _ = simple.DeepClone(); _ = dictionary.DeepClone(); long firstMutationVersion = FastClonerCache.GetCacheVersion(); await Assert.That(firstMutationVersion).IsGreaterThan(startingVersion); - FastCloner.MaxRecursionDepth = 998; + FastCloner.ClearTypeBehavior(); _ = simple.DeepClone(); _ = dictionary.DeepClone(); await Assert.That(FastClonerCache.GetCacheVersion()).IsGreaterThan(firstMutationVersion); } + [Test] + public async Task MaxRecursionDepth_Update_Does_Not_Bump_Cache_Version() + { + FastCloner.ClearCache(); + FastCloner.ClearAllTypeBehaviors(); + FastCloner.SetDisableOptionalFeatures(false); + + SimpleClass simple = new SimpleClass { IntValue = 7, StringValue = "Seven" }; + _ = simple.DeepClone(); + + long versionBefore = FastClonerCache.GetCacheVersion(); + + FastCloner.MaxRecursionDepth = 999; + SimpleClass clone1 = simple.DeepClone(); + + FastCloner.MaxRecursionDepth = 998; + SimpleClass clone2 = simple.DeepClone(); + + await Assert.That(FastClonerCache.GetCacheVersion()).IsEqualTo(versionBefore); + await Assert.That(clone1.IntValue).IsEqualTo(7); + await Assert.That(clone2.IntValue).IsEqualTo(7); + + FastCloner.MaxRecursionDepth = 1_000; + } + [Test] public async Task RuntimeConfig_Create_DefaultValues_CanStaySingletonOrBecomeDistinctSnapshot() { diff --git a/src/FastCloner.Tests/VisibilityAttributeRuntimeTests.cs b/src/FastCloner.Tests/VisibilityAttributeRuntimeTests.cs new file mode 100644 index 0000000..543fb71 --- /dev/null +++ b/src/FastCloner.Tests/VisibilityAttributeRuntimeTests.cs @@ -0,0 +1,375 @@ +using FastCloner.Code; + +namespace FastCloner.Tests; + +public class VisibilityAttributeRuntimeTests +{ + #region Test types - public-only policy + + [FastClonerVisibility(FastClonerMemberVisibility.Public)] + public class PublicOnlyDto + { + public int Public; + internal int Internal; + protected int Protected; + private int _private; + + public void Set(int p, int i, int pr, int priv) + { + Public = p; + Internal = i; + Protected = pr; + _private = priv; + } + + public int GetPrivate() => _private; + public int GetProtected() => Protected; + } + + #endregion + + [Test] + public async Task PublicOnly_policy_clones_only_public_members() + { + PublicOnlyDto src = new PublicOnlyDto(); + src.Set(p: 100, i: 200, pr: 300, priv: 400); + + PublicOnlyDto clone = src.DeepClone(); + + await Assert.That(clone.Public).IsEqualTo(100); + await Assert.That(clone.Internal).IsEqualTo(0); + await Assert.That(clone.GetProtected()).IsEqualTo(0); + await Assert.That(clone.GetPrivate()).IsEqualTo(0); + } + + #region Test types - public + internal policy + + [FastClonerVisibility(FastClonerMemberVisibility.Public | FastClonerMemberVisibility.Internal)] + public class PublicAndInternalDto + { + public int Public; + internal int Internal; + protected int Protected; + private int _private; + + public void Set(int p, int i, int pr, int priv) + { + Public = p; + Internal = i; + Protected = pr; + _private = priv; + } + + public int GetPrivate() => _private; + public int GetProtected() => Protected; + } + + #endregion + + [Test] + public async Task PublicAndInternal_policy_includes_internal_excludes_protected_and_private() + { + PublicAndInternalDto src = new PublicAndInternalDto(); + src.Set(p: 1, i: 2, pr: 3, priv: 4); + + PublicAndInternalDto clone = src.DeepClone(); + + await Assert.That(clone.Public).IsEqualTo(1); + await Assert.That(clone.Internal).IsEqualTo(2); + await Assert.That(clone.GetProtected()).IsEqualTo(0); + await Assert.That(clone.GetPrivate()).IsEqualTo(0); + } + + #region Test types - explicit member behavior overrides policy + + [FastClonerVisibility(FastClonerMemberVisibility.Public)] + public class MemberLevelOverridesPolicy + { + public int Public; + + // Type-level policy excludes private; member-level explicit Clone behavior forces it back in. + [FastClonerBehavior(CloneBehavior.Clone)] + private int _private; + + public void Set(int p, int priv) + { + Public = p; + _private = priv; + } + + public int GetPrivate() => _private; + } + + #endregion + + [Test] + public async Task MemberLevel_FastClonerBehavior_Clone_overrides_visibility_policy() + { + MemberLevelOverridesPolicy src = new MemberLevelOverridesPolicy(); + src.Set(p: 10, priv: 99); + + MemberLevelOverridesPolicy clone = src.DeepClone(); + + await Assert.That(clone.Public).IsEqualTo(10); + await Assert.That(clone.GetPrivate()).IsEqualTo(99); + } + + #region Test types - ignore beats All policy + + [FastClonerVisibility(FastClonerMemberVisibility.All)] + public class IgnoreBeatsAllPolicy + { + public int Public; + + [FastClonerIgnore] + private int _ignored; + + public void Set(int p, int ig) + { + Public = p; + _ignored = ig; + } + + public int GetIgnored() => _ignored; + } + + #endregion + + [Test] + public async Task FastClonerIgnore_overrides_All_visibility_policy() + { + IgnoreBeatsAllPolicy src = new IgnoreBeatsAllPolicy(); + src.Set(p: 7, ig: 999); + + IgnoreBeatsAllPolicy clone = src.DeepClone(); + + await Assert.That(clone.Public).IsEqualTo(7); + await Assert.That(clone.GetIgnored()).IsEqualTo(0); + } + + #region Test types - default (no attribute) clones everything + + public class NoVisibilityAttributeDto + { + public int Public; + internal int Internal; + protected int Protected; + private int _private; + + public void Set(int p, int i, int pr, int priv) + { + Public = p; + Internal = i; + Protected = pr; + _private = priv; + } + + public int GetPrivate() => _private; + public int GetProtected() => Protected; + } + + #endregion + + [Test] + public async Task No_attribute_means_All_policy_clones_everything() + { + NoVisibilityAttributeDto src = new NoVisibilityAttributeDto(); + src.Set(p: 1, i: 2, pr: 3, priv: 4); + + NoVisibilityAttributeDto clone = src.DeepClone(); + + await Assert.That(clone.Public).IsEqualTo(1); + await Assert.That(clone.Internal).IsEqualTo(2); + await Assert.That(clone.GetProtected()).IsEqualTo(3); + await Assert.That(clone.GetPrivate()).IsEqualTo(4); + } + + #region Test types - inheritance + policy on derived + + public class BaseWithMembers + { + public int BasePublic; + private int _basePrivate; + + public void SetBase(int p, int priv) + { + BasePublic = p; + _basePrivate = priv; + } + + public int GetBasePrivate() => _basePrivate; + } + + [FastClonerVisibility(FastClonerMemberVisibility.Public)] + public class DerivedWithPublicOnlyPolicy : BaseWithMembers + { + public int DerivedPublic; + private int _derivedPrivate; + + public void SetDerived(int p, int priv) + { + DerivedPublic = p; + _derivedPrivate = priv; + } + + public int GetDerivedPrivate() => _derivedPrivate; + } + + #endregion + + [Test] + public async Task Policy_on_derived_type_filters_inherited_base_members() + { + DerivedWithPublicOnlyPolicy src = new DerivedWithPublicOnlyPolicy(); + src.SetBase(p: 11, priv: 22); + src.SetDerived(p: 33, priv: 44); + + DerivedWithPublicOnlyPolicy clone = src.DeepClone(); + + await Assert.That(clone.BasePublic).IsEqualTo(11); + await Assert.That(clone.GetBasePrivate()).IsEqualTo(0); + await Assert.That(clone.DerivedPublic).IsEqualTo(33); + await Assert.That(clone.GetDerivedPrivate()).IsEqualTo(0); + } + + #region Test types - cache stability + + [FastClonerVisibility(FastClonerMemberVisibility.Public)] + public class CacheStabilityDto + { + public int Public; + private int _private; + + public void Set(int p, int priv) + { + Public = p; + _private = priv; + } + + public int GetPrivate() => _private; + } + + #endregion + + [Test] + public async Task Visibility_policy_is_stable_across_repeated_clones() + { + CacheStabilityDto src = new CacheStabilityDto(); + src.Set(p: 5, priv: 6); + + for (int i = 0; i < 3; i++) + { + CacheStabilityDto clone = src.DeepClone(); + await Assert.That(clone.Public).IsEqualTo(5); + await Assert.That(clone.GetPrivate()).IsEqualTo(0); + } + } + + #region Test types - inherited policy attribute + + [FastClonerVisibility(FastClonerMemberVisibility.Public)] + public class BaseWithVisibilityPolicy + { + public int BasePublic; + private int _basePrivate; + + public void SetBase(int p, int priv) + { + BasePublic = p; + _basePrivate = priv; + } + + public int GetBasePrivate() => _basePrivate; + } + + public class DerivedInheritsPolicy : BaseWithVisibilityPolicy + { + public int DerivedPublic; + private int _derivedPrivate; + + public void SetDerived(int p, int priv) + { + DerivedPublic = p; + _derivedPrivate = priv; + } + + public int GetDerivedPrivate() => _derivedPrivate; + } + + #endregion + + [Test] + public async Task Visibility_policy_is_inherited_from_base_type() + { + DerivedInheritsPolicy src = new DerivedInheritsPolicy(); + src.SetBase(p: 1, priv: 2); + src.SetDerived(p: 3, priv: 4); + + DerivedInheritsPolicy clone = src.DeepClone(); + + await Assert.That(clone.BasePublic).IsEqualTo(1); + await Assert.That(clone.GetBasePrivate()).IsEqualTo(0); + await Assert.That(clone.DerivedPublic).IsEqualTo(3); + await Assert.That(clone.GetDerivedPrivate()).IsEqualTo(0); + } + + #region Test types - mixed-accessibility property + + [FastClonerVisibility(FastClonerMemberVisibility.Public)] + public class MixedAccessibilityPropertyDto + { + // Property is "public" by C# rules even though the setter is private. The visibility + // policy must treat it as Public (most permissive of getter/setter). + public int PublicGetPrivateSet { get; private set; } + + public void Set(int v) => PublicGetPrivateSet = v; + } + + #endregion + + [Test] + public async Task PublicOnly_policy_includes_property_with_public_getter_and_private_setter() + { + MixedAccessibilityPropertyDto src = new MixedAccessibilityPropertyDto(); + src.Set(42); + + MixedAccessibilityPropertyDto clone = src.DeepClone(); + + await Assert.That(clone.PublicGetPrivateSet).IsEqualTo(42); + } + + #region Test types - [FastClonerIgnore(false)] overrides visibility policy + + [FastClonerVisibility(FastClonerMemberVisibility.Public)] + public class IgnoreFalseOverridesPolicy + { + public int Public; + + // Type-level policy excludes private members; an explicit "don't ignore me" + // member-level attribute must put it back in (parity with explicit Clone behavior). + [FastClonerIgnore(false)] + private int _private; + + public void Set(int p, int priv) + { + Public = p; + _private = priv; + } + + public int GetPrivate() => _private; + } + + #endregion + + [Test] + public async Task FastClonerIgnore_false_overrides_visibility_policy_exclusion() + { + IgnoreFalseOverridesPolicy src = new IgnoreFalseOverridesPolicy(); + src.Set(p: 10, priv: 20); + + IgnoreFalseOverridesPolicy clone = src.DeepClone(); + + await Assert.That(clone.Public).IsEqualTo(10); + await Assert.That(clone.GetPrivate()).IsEqualTo(20); + } +} diff --git a/src/FastCloner/Code/FastClonerCache.cs b/src/FastCloner/Code/FastClonerCache.cs index 93b5b28..42b3667 100644 --- a/src/FastCloner/Code/FastClonerCache.cs +++ b/src/FastCloner/Code/FastClonerCache.cs @@ -146,6 +146,7 @@ internal sealed class TypeShape public bool HasReadonlyFields { get; init; } public bool ContainsIgnoredMembers { get; init; } public bool HasDirectSelfReference { get; init; } + public MemberInfo[]? MembersExcludedByVisibility { get; init; } } internal static readonly ConcurrentDictionary TypeBehaviors = []; @@ -206,6 +207,7 @@ private sealed class CacheStore public readonly GenericClrCache FieldCache = new GenericClrCache(); public readonly GenericClrCache MemberBehaviorCache = new GenericClrCache(); public readonly ClrCache AttributedTypeBehaviorCache = new ClrCache(); + public readonly ClrCache AttributedTypeVisibilityCache = new ClrCache(); public readonly ClrCache ImmutableCollectionStatusCache = new ClrCache(); public readonly ClrCache SpecialTypesCache = new ClrCache(); public readonly ClrCache IsTypeSafeHandleCache = new ClrCache(); @@ -229,14 +231,13 @@ private sealed class CacheStore public static object GetOrAddShallowClassTo(Type type, Func valueFactory) => cacheStore.ShallowClassToCache.GetOrAdd(type, valueFactory); public static T GetOrAddConvertor(Type from, Type to, Func valueFactory) { - object? value = cacheStore.TypeConvertCache.GetOrAdd(from, to, (f, t) => valueFactory(f, t)!); - return (T)value!; + object value = cacheStore.TypeConvertCache.GetOrAdd(from, to, (f, t) => valueFactory(f, t)!); + return (T)value; } public static CloneBehavior? GetOrAddMemberBehavior(MemberInfo memberInfo, Func valueFactory) => cacheStore.MemberBehaviorCache.GetOrAdd(memberInfo, valueFactory); - public static CloneBehavior? GetOrAddAttributedTypeBehavior(Type type, Func valueFactory) - => cacheStore.AttributedTypeBehaviorCache.GetOrAdd(type, valueFactory); - public static bool GetOrAddImmutableCollectionStatus(Type type, Func valueFactory) - => cacheStore.ImmutableCollectionStatusCache.GetOrAdd(type, valueFactory); + public static CloneBehavior? GetOrAddAttributedTypeBehavior(Type type, Func valueFactory) => cacheStore.AttributedTypeBehaviorCache.GetOrAdd(type, valueFactory); + public static FastClonerMemberVisibility? GetOrAddAttributedTypeVisibility(Type type, Func valueFactory) => cacheStore.AttributedTypeVisibilityCache.GetOrAdd(type, valueFactory); + public static bool GetOrAddImmutableCollectionStatus(Type type, Func valueFactory) => cacheStore.ImmutableCollectionStatusCache.GetOrAdd(type, valueFactory); public static object GetOrAddSpecialType(Type type, Func valueFactory) => cacheStore.SpecialTypesCache.GetOrAdd(type, valueFactory); public static bool GetOrAddIsTypeSafeHandle(Type type, Func valueFactory) => cacheStore.IsTypeSafeHandleCache.GetOrAdd(type, valueFactory); public static bool GetOrAddAnonymousTypeStatus(Type type, Func valueFactory) => cacheStore.AnonymousTypeStatusCache.GetOrAdd(type, valueFactory); diff --git a/src/FastCloner/Code/FastClonerExprGenerator.cs b/src/FastCloner/Code/FastClonerExprGenerator.cs index d1d2408..e120dd9 100644 --- a/src/FastCloner/Code/FastClonerExprGenerator.cs +++ b/src/FastCloner/Code/FastClonerExprGenerator.cs @@ -84,13 +84,18 @@ internal static bool MemberShouldCopyReference(MemberInfo memberInfo) }); } - /// - /// Gets the clone behavior for a member by checking: - /// 1. Member-level FastClonerBehaviorAttribute (highest priority) - /// 2. [NonSerialized] attribute (treat as Ignore) - /// 3. Backing field's corresponding property - /// 4. Type-level FastClonerBehaviorAttribute on the member's type (lowest priority) - /// + internal static FastClonerMemberVisibility? GetTypeVisibility(Type type) + { + if (FastCloner.DisableOptionalFeatures) + return null; + + return FastClonerCache.GetOrAddAttributedTypeVisibility(type, static t => + { + FastClonerVisibilityAttribute? attr = t.GetCustomAttribute(inherit: true); + return attr?.Visibility; + }); + } + internal static CloneBehavior? GetMemberBehavior(MemberInfo memberInfo) { if (FastCloner.DisableOptionalFeatures) @@ -422,6 +427,50 @@ private static NewExpression CreateNewExpressionWithCtor(ConstructorInfoEx ctorI { return type.IsValueType ? Activator.CreateInstance(type) : null; } + + private static void AddPolicyExcludedMemberResetExpressions( + List expressionList, + ParameterExpression toLocal, + MemberInfo[] excludedMembers, + bool skipReadonly) + { + for (int i = 0; i < excludedMembers.Length; i++) + { + MemberInfo member = excludedMembers[i]; + switch (member) + { + case FieldInfo fieldInfo: + { + bool isReadonly = readonlyFields.GetOrAdd(fieldInfo, f => f.IsInitOnly); + if (isReadonly) + { + if (skipReadonly) + continue; + + expressionList.Add(Expression.Call( + Expression.Constant(fieldInfo), + fieldSetMethod, + Expression.Convert(toLocal, typeof(object)), + Expression.Convert(Expression.Default(fieldInfo.FieldType), typeof(object)))); + } + else + { + expressionList.Add(Expression.Assign( + Expression.Field(toLocal, fieldInfo), + Expression.Default(fieldInfo.FieldType))); + } + break; + } + case PropertyInfo { CanWrite: true } property: + { + expressionList.Add(Expression.Assign( + Expression.MakeMemberAccess(toLocal, property), + Expression.Default(property.PropertyType))); + break; + } + } + } + } private static void AddMemberCloneExpressions( List expressionList, @@ -438,6 +487,17 @@ private static void AddMemberCloneExpressions( MemberInfo[] members = typeShape.Members; Dictionary? ignoredEventDetails = typeShape.IgnoredEventDetails; + // Visibility-policy excluded members run BEFORE the regular member cloning pass: + // the destination has just been seeded with MemberwiseClone (line ~1066), which + // copied the source's reference/value verbatim into the excluded slot. We reset + // those slots to default so the policy actually takes effect at the boundary. + // No-op when the type has no policy (the array is null), so the common path stays + // free of extra work. + if (typeShape.MembersExcludedByVisibility is { Length: > 0 } excludedMembers) + { + AddPolicyExcludedMemberResetExpressions(expressionList, toLocal, excludedMembers, skipReadonly); + } + foreach (MemberInfo member in members) { Type memberType = member switch @@ -757,6 +817,7 @@ private static bool IsCloneable(Type type) private static FastClonerCache.TypeShape BuildTypeShape(Type type) { List members = []; + List? excludedByVisibility = null; Dictionary? ignoredEventDetails = null; List cycleFieldTypes = new List(); bool hasReadonlyFields = false; @@ -764,6 +825,9 @@ private static FastClonerCache.TypeShape BuildTypeShape(Type type) bool hasDirectSelfReference = false; bool includeMemberMetadata = true; Type? currentType = type; + FastClonerMemberVisibility? cachedVisibility = GetTypeVisibility(type); + FastClonerMemberVisibility visibilityPolicy = cachedVisibility ?? FastClonerMemberVisibility.All; + bool hasNonDefaultPolicy = cachedVisibility.HasValue; while (currentType != null && currentType != typeof(object)) { @@ -783,6 +847,24 @@ private static FastClonerCache.TypeShape BuildTypeShape(Type type) continue; } + if (hasNonDefaultPolicy) + { + PropertyInfo? backedProperty = TryGetBackingPropertyForField(field); + bool hasExplicit = HasExplicitMemberBehavior(field) || + (backedProperty is not null && HasExplicitMemberBehavior(backedProperty)); + if (!hasExplicit) + { + FastClonerMemberVisibility memberMask = backedProperty is not null + ? PropertyVisibilityMask(backedProperty) + : FieldVisibilityMask(field); + if ((visibilityPolicy & memberMask) == 0) + { + (excludedByVisibility ??= []).Add(field); + continue; + } + } + } + members.Add(field); if (field.IsInitOnly) { @@ -812,6 +894,16 @@ private static FastClonerCache.TypeShape BuildTypeShape(Type type) continue; } + if (hasNonDefaultPolicy && !HasExplicitMemberBehavior(property)) + { + FastClonerMemberVisibility memberMask = PropertyVisibilityMask(property); + if ((visibilityPolicy & memberMask) == 0) + { + (excludedByVisibility ??= []).Add(property); + continue; + } + } + members.Add(property); if (MemberIsIgnored(property)) { @@ -842,9 +934,107 @@ private static FastClonerCache.TypeShape BuildTypeShape(Type type) CycleFieldTypes = cycleFieldTypes.ToArray(), HasReadonlyFields = hasReadonlyFields, ContainsIgnoredMembers = containsIgnoredMembers, - HasDirectSelfReference = hasDirectSelfReference + HasDirectSelfReference = hasDirectSelfReference, + MembersExcludedByVisibility = excludedByVisibility?.ToArray() }; } + + private static FastClonerMemberVisibility FieldVisibilityMask(FieldInfo field) + { + FieldAttributes access = field.Attributes & FieldAttributes.FieldAccessMask; + switch (access) + { + case FieldAttributes.Public: + return FastClonerMemberVisibility.Public; + case FieldAttributes.Assembly: + return FastClonerMemberVisibility.Internal; + case FieldAttributes.Family: + return FastClonerMemberVisibility.Protected; + case FieldAttributes.FamORAssem: // protected internal + case FieldAttributes.FamANDAssem: // private protected + return FastClonerMemberVisibility.Protected | FastClonerMemberVisibility.Internal; + case FieldAttributes.Private: + case FieldAttributes.PrivateScope: + default: + return FastClonerMemberVisibility.Private; + } + } + + private static FastClonerMemberVisibility PropertyVisibilityMask(PropertyInfo property) + { + MethodInfo? getter = property.GetGetMethod(nonPublic: true); + MethodInfo? setter = property.GetSetMethod(nonPublic: true); + + FastClonerMemberVisibility? getMask = getter is null ? null : MaskFromMethodAttributes(getter.Attributes); + FastClonerMemberVisibility? setMask = setter is null ? null : MaskFromMethodAttributes(setter.Attributes); + + switch (getMask) + { + case null when setMask is null: + return FastClonerMemberVisibility.Private; + case null: + return setMask.Value; + } + + if (setMask is null) + return getMask.Value; + + // Pick the more permissive of the two single-value masks. + return Score(getMask.Value) >= Score(setMask.Value) ? getMask.Value : setMask.Value; + + static int Score(FastClonerMemberVisibility v) => v switch + { + FastClonerMemberVisibility.Public => 6, + // protected internal / private protected (Protected | Internal) + (FastClonerMemberVisibility.Protected | FastClonerMemberVisibility.Internal) => 5, + FastClonerMemberVisibility.Internal => 4, + FastClonerMemberVisibility.Protected => 3, + FastClonerMemberVisibility.Private => 1, + _ => 0, + }; + } + + private static PropertyInfo? TryGetBackingPropertyForField(FieldInfo field) + { + string name = field.Name; + if (name.Length < "<>k__BackingField".Length || name[0] != '<' || !name.EndsWith(">k__BackingField")) + return null; + + int closingBracket = name.IndexOf('>'); + if (closingBracket <= 1) + return null; + + string propertyName = name.Substring(1, closingBracket - 1); + return field.DeclaringType?.GetProperty( + propertyName, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly); + } + + private static FastClonerMemberVisibility MaskFromMethodAttributes(MethodAttributes attributes) + { + MethodAttributes access = attributes & MethodAttributes.MemberAccessMask; + switch (access) + { + case MethodAttributes.Public: + return FastClonerMemberVisibility.Public; + case MethodAttributes.Assembly: + return FastClonerMemberVisibility.Internal; + case MethodAttributes.Family: + return FastClonerMemberVisibility.Protected; + case MethodAttributes.FamORAssem: // protected internal + case MethodAttributes.FamANDAssem: // private protected + return FastClonerMemberVisibility.Protected | FastClonerMemberVisibility.Internal; + case MethodAttributes.Private: + case MethodAttributes.PrivateScope: + default: + return FastClonerMemberVisibility.Private; + } + } + + private static bool HasExplicitMemberBehavior(MemberInfo member) + { + return member.GetCustomAttribute(inherit: false) is not null; + } private static object? CloneIClonable(Type type) { diff --git a/src/FastCloner/Code/FastClonerVisibilityAttribute.cs b/src/FastCloner/Code/FastClonerVisibilityAttribute.cs new file mode 100644 index 0000000..1c35672 --- /dev/null +++ b/src/FastCloner/Code/FastClonerVisibilityAttribute.cs @@ -0,0 +1,71 @@ +namespace FastCloner.Code; + +[Flags] +public enum FastClonerMemberVisibility +{ + /// + /// No members are eligible. + /// + None = 0, + + /// + /// Public members. + /// + Public = 1 << 0, + + /// + /// Pure-internal members. Combines with to also include + /// protected internal and private protected. + /// + Internal = 1 << 1, + + /// + /// Protected members. Combines with to also include + /// protected internal and private protected. + /// + Protected = 1 << 2, + + /// + /// Private members. + /// + Private = 1 << 3, + + /// + /// All non-public visibilities ( | | ). + /// + NonPublic = Internal | Protected | Private, + + /// + /// Shorthand for | . + /// + PublicOrInternal = Public | Internal, + + /// + /// All visibilities. This is the implicit default when no is applied. + /// + All = Public | NonPublic +} + +/// +/// Controls which member visibilities are eligible for cloning on the target type. +///
+/// When this attribute is not present, the policy is : +/// every member is cloned regardless of visibility. +///
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] +public sealed class FastClonerVisibilityAttribute : Attribute +{ + /// + /// The set of visibilities whose members participate in cloning. + /// + public FastClonerMemberVisibility Visibility { get; } + + /// + /// Apply a visibility policy to the target type. + /// + /// The set of member visibilities to include during cloning. + public FastClonerVisibilityAttribute(FastClonerMemberVisibility visibility) + { + Visibility = visibility; + } +} diff --git a/src/FastCloner/Code/FieldAccessorGenerator.cs b/src/FastCloner/Code/FieldAccessorGenerator.cs index 148a1b9..9bfe470 100644 --- a/src/FastCloner/Code/FieldAccessorGenerator.cs +++ b/src/FastCloner/Code/FieldAccessorGenerator.cs @@ -15,20 +15,24 @@ private static Action CreateFieldSetter(FieldInfo field) ParameterExpression targetParam = Expression.Parameter(typeof(object), "target"); ParameterExpression valueParam = Expression.Parameter(typeof(object), "value"); - UnaryExpression targetCast = Expression.Convert(targetParam, field.DeclaringType!); - + Type declaringType = field.DeclaringType!; + Expression body; - + if (field.IsInitOnly) { MethodInfo setValueMethod = typeof(FieldInfo).GetMethod(nameof(FieldInfo.SetValue), [typeof(object), typeof(object)])!; UnaryExpression valueCast = Expression.Convert(valueParam, typeof(object)); - body = Expression.Call(Expression.Constant(field), setValueMethod, targetCast, valueCast); + body = Expression.Call(Expression.Constant(field), setValueMethod, targetParam, valueCast); } else { + Expression target = declaringType.IsValueType + ? Expression.Unbox(targetParam, declaringType) + : Expression.Convert(targetParam, declaringType); + UnaryExpression valueCast = Expression.Convert(valueParam, field.FieldType); - body = Expression.Assign(Expression.Field(targetCast, field), valueCast); + body = Expression.Assign(Expression.Field(target, field), valueCast); } Expression> lambda = Expression.Lambda>( diff --git a/src/FastCloner/FastCloner.cs b/src/FastCloner/FastCloner.cs index 3f6e356..380b75e 100644 --- a/src/FastCloner/FastCloner.cs +++ b/src/FastCloner/FastCloner.cs @@ -45,7 +45,7 @@ public static int MaxRecursionDepth if (maxRecursionDepth == value) return; - PublishEngine(CreatePublishedEngine(value, disableOptionalFeatures, GetTypeBehaviors())); + PublishMaxRecursionDepth(value); } } } @@ -197,6 +197,25 @@ private static void PublishEngine(FastClonerPublishedEngine engine) FastClonerCache.ClearCache(); Volatile.Write(ref publishedEngine, engine); } + + private static void PublishMaxRecursionDepth(int value) + { + FastClonerPublishedEngine current = Volatile.Read(ref publishedEngine); + FastClonerRuntimeConfig oldConfig = current.RuntimeConfig; + FastClonerRuntimeConfig newConfig = FastClonerRuntimeConfig.Create( + value, + oldConfig.DisableOptionalFeatures, + oldConfig.CloneTypeBehaviors(), + oldConfig.CacheKey, + useStartupDefaultSingleton: true); + + FastClonerPublishedEngine newEngine = ReferenceEquals(newConfig, FastClonerRuntimeConfig.Default) + ? FastClonerPublishedEngine.StartupDefault + : FastClonerPublishedEngine.CreateConfigured(newConfig); + + maxRecursionDepth = value; + Volatile.Write(ref publishedEngine, newEngine); + } private static FastClonerPublishedEngine CreatePublishedEngine( int maxRecursionDepth, diff --git a/src/FastCloner/FastClonerSourceGeneratorBridge.cs b/src/FastCloner/FastClonerSourceGeneratorBridge.cs new file mode 100644 index 0000000..8d21880 --- /dev/null +++ b/src/FastCloner/FastClonerSourceGeneratorBridge.cs @@ -0,0 +1,90 @@ +using System.Reflection; +using FastCloner.Code; + +namespace FastCloner; + +// used implicitly +[FastClonerSourceGeneratorBridge("FastCloner.SourceGenerated.__FastClonerSGBridgeProxy")] +internal static class FastClonerSourceGeneratorBridge +{ + [FastClonerSourceGeneratorBridgeMember] + internal static void DeepCloneField(object source, object target, FieldInfo field) + { + if (source is null) throw new ArgumentNullException(nameof(source)); + if (target is null) throw new ArgumentNullException(nameof(target)); + if (field is null) throw new ArgumentNullException(nameof(field)); + + object? value = field.GetValue(source); + object? cloned = FastClonerGenerator.CloneObject(value); + FieldAccessorGenerator.GetFieldSetter(field)(target, cloned!); + } + + [FastClonerSourceGeneratorBridgeMember] + internal static void CopyField(object source, object target, FieldInfo field) + { + if (source is null) throw new ArgumentNullException(nameof(source)); + if (target is null) throw new ArgumentNullException(nameof(target)); + if (field is null) throw new ArgumentNullException(nameof(field)); + + object? value = field.GetValue(source); + FieldAccessorGenerator.GetFieldSetter(field)(target, value!); + } + + [FastClonerSourceGeneratorBridgeMember] + internal static FieldInfo ResolveDeclaredField(Type declaringType, string fieldName) + { + if (declaringType is null) throw new ArgumentNullException(nameof(declaringType)); + if (fieldName is null) throw new ArgumentNullException(nameof(fieldName)); + + FieldInfo? field = declaringType.GetField( + fieldName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + + if (field == null) + { + throw new InvalidOperationException( + $"FastCloner source-generator bridge: field '{fieldName}' was not found on '{declaringType.FullName}'. " + + "The type shape may have changed since the source generator ran."); + } + + return field; + } + + [FastClonerSourceGeneratorBridgeMember] + internal static void DeepCloneProperty(object source, object target, PropertyInfo property) + { + if (source is null) throw new ArgumentNullException(nameof(source)); + if (target is null) throw new ArgumentNullException(nameof(target)); + if (property is null) throw new ArgumentNullException(nameof(property)); + + MethodInfo? setter = property.GetSetMethod(nonPublic: true); + if (setter == null) + { + throw new InvalidOperationException( + $"FastCloner source-generator bridge: property '{property.Name}' on '{property.DeclaringType?.FullName}' has no setter."); + } + + object? value = property.GetValue(source); + object? cloned = FastClonerGenerator.CloneObject(value); + setter.Invoke(target, [cloned]); + } + + [FastClonerSourceGeneratorBridgeMember] + internal static PropertyInfo ResolveDeclaredProperty(Type declaringType, string propertyName) + { + if (declaringType is null) throw new ArgumentNullException(nameof(declaringType)); + if (propertyName is null) throw new ArgumentNullException(nameof(propertyName)); + + PropertyInfo? property = declaringType.GetProperty( + propertyName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + + if (property == null) + { + throw new InvalidOperationException( + $"FastCloner source-generator bridge: property '{propertyName}' was not found on '{declaringType.FullName}'."); + } + + return property; + } +} diff --git a/src/FastCloner/FastClonerSourceGeneratorBridgeContractAttributes.cs b/src/FastCloner/FastClonerSourceGeneratorBridgeContractAttributes.cs new file mode 100644 index 0000000..400561f --- /dev/null +++ b/src/FastCloner/FastClonerSourceGeneratorBridgeContractAttributes.cs @@ -0,0 +1,10 @@ +namespace FastCloner; + +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +internal sealed class FastClonerSourceGeneratorBridgeAttribute(string proxyTypeFullName) : Attribute +{ + public string ProxyTypeFullName { get; } = proxyTypeFullName; +} + +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +internal sealed class FastClonerSourceGeneratorBridgeMemberAttribute : Attribute;