Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions .bench-harness-current/BenchHarness.csproj

This file was deleted.

34 changes: 0 additions & 34 deletions .bench-harness-current/Program.cs

This file was deleted.

2 changes: 1 addition & 1 deletion .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ on:
default: true

env:
DOTNET_VERSION: "10.0.103"
DOTNET_VERSION: "10.0.x"
BASELINE_BRANCH: "next"

permissions:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
branches: [ "next" ]

env:
DOTNET_VERSION: '10.0.103'
DOTNET_VERSION: '10.0.x'
NODE_VERSION: '24'

permissions:
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ FastCloner is a zero-dependency deep cloning library for .NET, from <code>.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:
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/FastCloner.SourceGenerator/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("FastCloner.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100bd882e7dcc8a5cfdd6e2193c66bb144ab67e83964b825035b4b05ccdc40a5a7218a497799f98f98e628c38492fa227ad1579c7d701934ea2e30459a4dabfcfa498fc9f4dc6d3e3f118e4df6615aced3da480ea45d30832ddbfd56acebc957ee6345944d2e9b82705a725276146baadf08cf7a8612fd4f3ceb8b8ec9529b975c2")]
22 changes: 22 additions & 0 deletions src/FastCloner.SourceGenerator/BridgeContract.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace FastCloner.SourceGenerator;

internal sealed record BridgeMethodSpec(
string Name,
string ReturnTypeFqn,
EquatableArray<string> ParameterTypeFqns)
{
public bool IsVoid => ReturnTypeFqn == "void";
}

internal sealed record BridgeContract(
string BridgeTypeMetadataName,
string ProxyTypeFullName,
EquatableArray<BridgeMethodSpec> Methods,
bool IsAvailable)
{
public static BridgeContract Empty { get; } = new BridgeContract(
BridgeTypeMetadataName: string.Empty,
ProxyTypeFullName: string.Empty,
Methods: EquatableArray<BridgeMethodSpec>.Empty,
IsAvailable: false);
}
64 changes: 64 additions & 0 deletions src/FastCloner.SourceGenerator/BridgeContractCollector.cs
Original file line number Diff line number Diff line change
@@ -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<BridgeMethodSpec> 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<string>(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<BridgeMethodSpec>(sortedMethods),
IsAvailable: true);
}
}
158 changes: 158 additions & 0 deletions src/FastCloner.SourceGenerator/BridgeProxyEmitter.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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<string> 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();
}
}
29 changes: 17 additions & 12 deletions src/FastCloner.SourceGenerator/ClassCloneBodyGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,18 @@ public static void WriteClassCloneBody(
List<string> 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;

Comment on lines +71 to +78
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}");
}
}

Expand All @@ -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
Expand Down
Loading
Loading