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
2 changes: 1 addition & 1 deletion src/FastCloner.SourceGenerator/ContextCodeGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ private void GenerateDispatcher()

private void GenerateIsHandled()
{
_sb.AppendLine(" public override bool IsHandled(Type type)");
_sb.AppendLine(" public override bool IsHandled(global::System.Type type)");
_sb.AppendLine(" {");
foreach (TypeModel? type in _model.RegisteredTypes)
{
Expand Down
149 changes: 149 additions & 0 deletions src/FastCloner.Tests/CollectionTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,41 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Threading.Tasks;

namespace FastCloner.Tests;

/// <summary>
/// A non-generic class that implements ISet&lt;string&gt; directly.
/// Exercises the code path where type.GetGenericArguments() returns an empty array
/// but IsSetType() matches via the interface.
/// </summary>
public class StringSet : ISet<string>
{
private readonly HashSet<string> _inner = new();

public int Count => _inner.Count;
public bool IsReadOnly => false;

public bool Add(string item) => _inner.Add(item);
public void Clear() => _inner.Clear();
public bool Contains(string item) => _inner.Contains(item);
public void CopyTo(string[] array, int arrayIndex) => _inner.CopyTo(array, arrayIndex);
public void ExceptWith(IEnumerable<string> other) => _inner.ExceptWith(other);
public IEnumerator<string> GetEnumerator() => _inner.GetEnumerator();
public void IntersectWith(IEnumerable<string> other) => _inner.IntersectWith(other);
public bool IsProperSubsetOf(IEnumerable<string> other) => _inner.IsProperSubsetOf(other);
public bool IsProperSupersetOf(IEnumerable<string> other) => _inner.IsProperSupersetOf(other);
public bool IsSubsetOf(IEnumerable<string> other) => _inner.IsSubsetOf(other);
public bool IsSupersetOf(IEnumerable<string> other) => _inner.IsSupersetOf(other);
public bool Overlaps(IEnumerable<string> other) => _inner.Overlaps(other);
public bool Remove(string item) => _inner.Remove(item);
public bool SetEquals(IEnumerable<string> other) => _inner.SetEquals(other);
public void SymmetricExceptWith(IEnumerable<string> other) => _inner.SymmetricExceptWith(other);
public void UnionWith(IEnumerable<string> other) => _inner.UnionWith(other);
void ICollection<string>.Add(string item) => _inner.Add(item);
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

public class CollectionTests
{
[Test]
Expand Down Expand Up @@ -176,4 +210,119 @@ public async Task LinkedList_Should_Be_Deep_Cloned_Correctly()
// Original should remain untouched
await Assert.That(original.Count).IsEqualTo(3);
}

[Test]
public async Task NonGenericSetImplementingISet_Should_Be_Deep_Cloned_Correctly()
{
StringSet original = new StringSet();
original.Add("alpha");
original.Add("beta");
original.Add("gamma");

StringSet clone = original.DeepClone();

await Assert.That(clone).IsNotSameReferenceAs(original);
await Assert.That(clone.Count).IsEqualTo(3);
await Assert.That(clone.Contains("alpha")).IsTrue();
await Assert.That(clone.Contains("beta")).IsTrue();
await Assert.That(clone.Contains("gamma")).IsTrue();

// Mutating clone should not affect original
clone.Add("delta");
await Assert.That(clone.Count).IsEqualTo(4);
await Assert.That(original.Count).IsEqualTo(3);
}

[Test]
public async Task NonGenericSetInsideObject_Should_Be_Deep_Cloned_Correctly()
{
ObjectWithNonGenericSet original = new ObjectWithNonGenericSet
{
Name = "test",
Tags = new StringSet()
};
original.Tags.Add("a");
original.Tags.Add("b");

ObjectWithNonGenericSet clone = original.DeepClone();

await Assert.That(clone).IsNotSameReferenceAs(original);
await Assert.That(clone.Tags).IsNotSameReferenceAs(original.Tags);
await Assert.That(clone.Tags.Count).IsEqualTo(2);
await Assert.That(clone.Tags.Contains("a")).IsTrue();
await Assert.That(clone.Tags.Contains("b")).IsTrue();

clone.Tags.Add("c");
await Assert.That(clone.Tags.Count).IsEqualTo(3);
await Assert.That(original.Tags.Count).IsEqualTo(2);
}

[Test]
public async Task GenericSetWhereElementIsNotFirstTypeArg_Should_Be_Deep_Cloned_Correctly()
{
// TTag=List<int> (mutable, no stable hash semantics) forces the iterate-and-clone
// path rather than the memberwise fast path, exposing the wrong element type.
Comment on lines +263 to +264

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment claims TTag=List<int> forces the iterate-and-clone path by affecting stable hash semantics, but the set fast/slow path decision is based on the ISet element type (here string, which has stable hash semantics). Consider rewording to reflect the actual regression being tested: ensuring the ISet element type is derived from the implemented interface (not type.GetGenericArguments()[0]).

Suggested change
// TTag=List<int> (mutable, no stable hash semantics) forces the iterate-and-clone
// path rather than the memberwise fast path, exposing the wrong element type.
// Regression test: generic type where the ISet<T> element type (string) is not
// the first generic argument (TTag=List<int>). Ensures the element type is
// derived from the implemented ISet<T> interface, not type.GetGenericArguments()[0].

Copilot uses AI. Check for mistakes.
TaggedSet<List<int>, string> original = new TaggedSet<List<int>, string>
{
Tag = new List<int> { 1, 2, 3 }
};
original.Add("one");
original.Add("two");
original.Add("three");

TaggedSet<List<int>, string> clone = original.DeepClone();

await Assert.That(clone).IsNotSameReferenceAs(original);
await Assert.That(clone.Tag).IsNotSameReferenceAs(original.Tag);
await Assert.That(clone.Tag).IsEquivalentTo(original.Tag);
await Assert.That(clone.Count).IsEqualTo(3);
await Assert.That(clone.Contains("one")).IsTrue();
await Assert.That(clone.Contains("two")).IsTrue();
await Assert.That(clone.Contains("three")).IsTrue();

// Mutating clone should not affect original
clone.Add("four");
clone.Tag = new List<int> { 99 };
await Assert.That(clone.Count).IsEqualTo(4);
await Assert.That(original.Count).IsEqualTo(3);
await Assert.That(original.Tag).IsEquivalentTo(new List<int> { 1, 2, 3 });
}
}

public class ObjectWithNonGenericSet
{
public string Name { get; set; } = "";
public StringSet Tags { get; set; } = new();
}

/// <summary>
/// A generic set where the ISet element type is NOT the first generic parameter.
/// Exercises the case where type.GetGenericArguments()[0] != the ISet&lt;T&gt; element type.
/// </summary>
public class TaggedSet<TTag, TElement> : ISet<TElement>
{
private readonly HashSet<TElement> _inner = new();
public TTag? Tag { get; set; }

public int Count => _inner.Count;
public bool IsReadOnly => false;

public bool Add(TElement item) => _inner.Add(item);
public void Clear() => _inner.Clear();
public bool Contains(TElement item) => _inner.Contains(item);
public void CopyTo(TElement[] array, int arrayIndex) => _inner.CopyTo(array, arrayIndex);
public void ExceptWith(IEnumerable<TElement> other) => _inner.ExceptWith(other);
public IEnumerator<TElement> GetEnumerator() => _inner.GetEnumerator();
public void IntersectWith(IEnumerable<TElement> other) => _inner.IntersectWith(other);
public bool IsProperSubsetOf(IEnumerable<TElement> other) => _inner.IsProperSubsetOf(other);
public bool IsProperSupersetOf(IEnumerable<TElement> other) => _inner.IsProperSupersetOf(other);
public bool IsSubsetOf(IEnumerable<TElement> other) => _inner.IsSubsetOf(other);
public bool IsSupersetOf(IEnumerable<TElement> other) => _inner.IsSupersetOf(other);
public bool Overlaps(IEnumerable<TElement> other) => _inner.Overlaps(other);
public bool Remove(TElement item) => _inner.Remove(item);
public bool SetEquals(IEnumerable<TElement> other) => _inner.SetEquals(other);
public void SymmetricExceptWith(IEnumerable<TElement> other) => _inner.SymmetricExceptWith(other);
public void UnionWith(IEnumerable<TElement> other) => _inner.UnionWith(other);
void ICollection<TElement>.Add(TElement item) => _inner.Add(item);
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
2 changes: 1 addition & 1 deletion src/FastCloner.Tests/FastCloner.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<PackageReference Include="System.Drawing.Common" Version="10.0.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.3" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.3" />
<PackageReference Include="TUnit" Version="1.19.16" />
<PackageReference Include="TUnit" Version="1.22.6" />
</ItemGroup>

<PropertyGroup>
Expand Down
65 changes: 65 additions & 0 deletions src/FastCloner.Tests/TypeNameConflictTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using FastCloner.SourceGenerator.Shared;
using System.Threading.Tasks;

namespace FastCloner.Tests.TypeNameConflict;

/// <summary>
/// Regression test for https://github.com/AnyMindGroup/FastCloner/issues/37
/// A user-defined "Type" in the same namespace as a FastClonerContext must not
/// shadow System.Type in generated code. The source generator must emit
/// global::System.Type instead of unqualified Type.
/// If the generator emits unqualified "Type", this file will fail to compile.
/// </summary>
public enum Type
{
A,
B,
C
}

public class Widget
{
public string Name { get; set; } = "";
public Type Kind { get; set; }
}

[FastClonerRegister(typeof(Widget))]
public partial class WidgetCloningContext : FastClonerContext { }

public class TypeNameConflictTests
{
[Test]
public async Task Context_IsHandled_Should_Work_When_Type_Name_Is_Shadowed()
{
FastClonerContext ctx = new WidgetCloningContext();

await Assert.That(ctx.IsHandled(typeof(Widget))).IsTrue();
await Assert.That(ctx.IsHandled(typeof(string))).IsFalse();
}

[Test]
public async Task Context_TryClone_Should_Work_When_Type_Name_Is_Shadowed()
{
FastClonerContext ctx = new WidgetCloningContext();
Widget original = new Widget { Name = "gear", Kind = Type.B };

await Assert.That(ctx.TryClone(original, out object? clone)).IsTrue();
Widget cloned = (Widget)clone!;
await Assert.That(cloned).IsNotSameReferenceAs(original);
await Assert.That(cloned.Name).IsEqualTo("gear");
await Assert.That(cloned.Kind).IsEqualTo(Type.B);
}

[Test]
public async Task Context_Clone_Should_Work_When_Type_Name_Is_Shadowed()
{
WidgetCloningContext ctx = new WidgetCloningContext();
Widget original = new Widget { Name = "spring", Kind = Type.C };

Widget? cloned = ctx.Clone(original);
await Assert.That(cloned).IsNotNull();
await Assert.That(cloned).IsNotSameReferenceAs(original);
await Assert.That(cloned!.Name).IsEqualTo("spring");
await Assert.That(cloned.Kind).IsEqualTo(Type.C);
}
}
14 changes: 9 additions & 5 deletions src/FastCloner/Code/FastClonerExprGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,10 @@ private static bool ShouldDeepCloneStructReadonlyFields(Type type)

internal static object? GenerateProcessMethod(Type realType, bool asObject) => GenerateProcessMethod(realType, asObject && realType.IsValueType(), new ExpressionPosition(0, 0));
public static bool IsListType(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>);
public static bool IsSetType(Type type) => type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ISet<>));
public static bool IsSetType(Type type) => GetSetInterface(type) is not null;

private static Type? GetSetInterface(Type type) =>
type.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ISet<>));

public static bool IsConcurrentBagOrQueue(Type type)
{
Expand Down Expand Up @@ -921,9 +924,10 @@ private static FastClonerCache.TypeShape BuildTypeShape(Type type)
return GenerateProcessConcurrentBagOrQueueMethod(type, position);
}

if (IsSetType(type))
Type? setInterface = GetSetInterface(type);
if (setInterface is not null)
{
return GenerateProcessSetMethod(type, position);
return GenerateProcessSetMethod(type, setInterface, position);
}

if (type.IsArray)
Expand Down Expand Up @@ -1853,7 +1857,7 @@ private static object GenerateProcessConcurrentBagOrQueueMethod(Type type, Expre
).Compile();
}

private static object GenerateProcessSetMethod(Type type, ExpressionPosition position)
private static object GenerateProcessSetMethod(Type type, Type setInterface, ExpressionPosition position)
{
if (FastClonerCache.IsTypeIgnored(type))
{
Expand All @@ -1862,7 +1866,7 @@ private static object GenerateProcessSetMethod(Type type, ExpressionPosition pos
return Expression.Lambda<Func<object, FastCloneState, object>>(pFrom, pFrom, pState).Compile();
}

Type elementType = type.GenericArguments()[0];
Type elementType = setInterface.GetGenericArguments()[0];

// Fast path check first - avoid creating expressions if we don't need them
bool isImmutable = IsImmutableCollection(type);
Expand Down
Loading