diff --git a/src/FastCloner.SourceGenerator/ContextCodeGenerator.cs b/src/FastCloner.SourceGenerator/ContextCodeGenerator.cs index f01cd1f..76f582b 100644 --- a/src/FastCloner.SourceGenerator/ContextCodeGenerator.cs +++ b/src/FastCloner.SourceGenerator/ContextCodeGenerator.cs @@ -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) { diff --git a/src/FastCloner.Tests/CollectionTests.cs b/src/FastCloner.Tests/CollectionTests.cs index e46be1b..f2585fe 100644 --- a/src/FastCloner.Tests/CollectionTests.cs +++ b/src/FastCloner.Tests/CollectionTests.cs @@ -1,7 +1,41 @@ +using System.Collections; using System.Collections.Concurrent; using System.Threading.Tasks; namespace FastCloner.Tests; + +/// +/// A non-generic class that implements ISet<string> directly. +/// Exercises the code path where type.GetGenericArguments() returns an empty array +/// but IsSetType() matches via the interface. +/// +public class StringSet : ISet +{ + private readonly HashSet _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 other) => _inner.ExceptWith(other); + public IEnumerator GetEnumerator() => _inner.GetEnumerator(); + public void IntersectWith(IEnumerable other) => _inner.IntersectWith(other); + public bool IsProperSubsetOf(IEnumerable other) => _inner.IsProperSubsetOf(other); + public bool IsProperSupersetOf(IEnumerable other) => _inner.IsProperSupersetOf(other); + public bool IsSubsetOf(IEnumerable other) => _inner.IsSubsetOf(other); + public bool IsSupersetOf(IEnumerable other) => _inner.IsSupersetOf(other); + public bool Overlaps(IEnumerable other) => _inner.Overlaps(other); + public bool Remove(string item) => _inner.Remove(item); + public bool SetEquals(IEnumerable other) => _inner.SetEquals(other); + public void SymmetricExceptWith(IEnumerable other) => _inner.SymmetricExceptWith(other); + public void UnionWith(IEnumerable other) => _inner.UnionWith(other); + void ICollection.Add(string item) => _inner.Add(item); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + public class CollectionTests { [Test] @@ -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 (mutable, no stable hash semantics) forces the iterate-and-clone + // path rather than the memberwise fast path, exposing the wrong element type. + TaggedSet, string> original = new TaggedSet, string> + { + Tag = new List { 1, 2, 3 } + }; + original.Add("one"); + original.Add("two"); + original.Add("three"); + + TaggedSet, 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 { 99 }; + await Assert.That(clone.Count).IsEqualTo(4); + await Assert.That(original.Count).IsEqualTo(3); + await Assert.That(original.Tag).IsEquivalentTo(new List { 1, 2, 3 }); + } +} + +public class ObjectWithNonGenericSet +{ + public string Name { get; set; } = ""; + public StringSet Tags { get; set; } = new(); +} + +/// +/// A generic set where the ISet element type is NOT the first generic parameter. +/// Exercises the case where type.GetGenericArguments()[0] != the ISet<T> element type. +/// +public class TaggedSet : ISet +{ + private readonly HashSet _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 other) => _inner.ExceptWith(other); + public IEnumerator GetEnumerator() => _inner.GetEnumerator(); + public void IntersectWith(IEnumerable other) => _inner.IntersectWith(other); + public bool IsProperSubsetOf(IEnumerable other) => _inner.IsProperSubsetOf(other); + public bool IsProperSupersetOf(IEnumerable other) => _inner.IsProperSupersetOf(other); + public bool IsSubsetOf(IEnumerable other) => _inner.IsSubsetOf(other); + public bool IsSupersetOf(IEnumerable other) => _inner.IsSupersetOf(other); + public bool Overlaps(IEnumerable other) => _inner.Overlaps(other); + public bool Remove(TElement item) => _inner.Remove(item); + public bool SetEquals(IEnumerable other) => _inner.SetEquals(other); + public void SymmetricExceptWith(IEnumerable other) => _inner.SymmetricExceptWith(other); + public void UnionWith(IEnumerable other) => _inner.UnionWith(other); + void ICollection.Add(TElement item) => _inner.Add(item); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } diff --git a/src/FastCloner.Tests/FastCloner.Tests.csproj b/src/FastCloner.Tests/FastCloner.Tests.csproj index 74b196c..e5696b0 100644 --- a/src/FastCloner.Tests/FastCloner.Tests.csproj +++ b/src/FastCloner.Tests/FastCloner.Tests.csproj @@ -23,7 +23,7 @@ - + diff --git a/src/FastCloner.Tests/TypeNameConflictTests.cs b/src/FastCloner.Tests/TypeNameConflictTests.cs new file mode 100644 index 0000000..e351b4c --- /dev/null +++ b/src/FastCloner.Tests/TypeNameConflictTests.cs @@ -0,0 +1,65 @@ +using FastCloner.SourceGenerator.Shared; +using System.Threading.Tasks; + +namespace FastCloner.Tests.TypeNameConflict; + +/// +/// 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. +/// +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); + } +} diff --git a/src/FastCloner/Code/FastClonerExprGenerator.cs b/src/FastCloner/Code/FastClonerExprGenerator.cs index 58f68e6..de8beba 100644 --- a/src/FastCloner/Code/FastClonerExprGenerator.cs +++ b/src/FastCloner/Code/FastClonerExprGenerator.cs @@ -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) { @@ -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) @@ -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)) { @@ -1862,7 +1866,7 @@ private static object GenerateProcessSetMethod(Type type, ExpressionPosition pos return Expression.Lambda>(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);