diff --git a/.gitignore b/.gitignore index dc0470a..e043c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -203,6 +203,11 @@ PublishScripts/ **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ +# and except a Unity project's Packages/, which is source: Unity's package manifest and its +# resolved lock file are both meant to be committed, and a NuGet restore folder never contains +# a file by either name. +!**/[Pp]ackages/manifest.json +!**/[Pp]ackages/packages-lock.json # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files @@ -651,3 +656,16 @@ Temporary Items # ImGui.ini files imgui.ini + +# Game engine projects +# +# Godot: the import cache, and the mono/temp bin+obj a C# build writes. +.godot/ + +# Unity: .meta files are source, not the Visual Studio C++ build artifact that the `*.meta` rule +# further up targets. Unity generates one per asset and it carries the GUID that scenes, prefabs +# and serialized references point at, so ignoring them gives every clone fresh GUIDs and silently +# breaks those references - including for a plug-in whose .dll is itself a build output. This +# negation has to come after that rule to win, and is scoped to the asset tree so the Visual +# Studio artifact stays ignored everywhere else. +!**/[Aa]ssets/**/*.meta diff --git a/DeepClone.Test/DictionaryComparerTests.cs b/DeepClone.Test/DictionaryComparerTests.cs new file mode 100644 index 0000000..0c1f2f1 --- /dev/null +++ b/DeepClone.Test/DictionaryComparerTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.DeepClone.Test; + +using System.Collections.Concurrent; +using System.Collections.Immutable; + +/// +/// Tests that cloning a dictionary through or +/// keeps the source's key comparer for every +/// dictionary type that exposes one (ktsu-dev/DeepClone#83). +/// +[TestClass] +public class DictionaryComparerTests +{ + private static readonly string[] DescendingKeys = ["d", "c", "b", "a"]; + + /// + /// Tests that a concurrent dictionary cloned through IDictionary keeps a case-insensitive comparer + /// and stays concurrent. + /// + [TestMethod] + public void ConcurrentDictionary_ThroughIDictionary_KeepsComparer() + { + ConcurrentDictionary original = new(StringComparer.OrdinalIgnoreCase); + original["Key"] = 1; + + IDictionary clone = ((IDictionary)original).DeepClone(); + + Assert.IsInstanceOfType>(clone); + Assert.IsTrue(clone.ContainsKey("key")); + } + + /// + /// Tests that a concurrent dictionary using reference equality, holding two equal but distinct keys, + /// clones without throwing and keeps both entries. + /// + [TestMethod] + public void ConcurrentDictionary_WithReferenceEquality_ClonesEqualKeys() + { + ConcurrentDictionary original = new(ReferenceEqualityComparer.Instance); + original[new string('k', 1)] = 1; + original[new string('k', 1)] = 2; + + IDictionary clone = ((IDictionary)original).DeepClone(); + + Assert.HasCount(2, clone); + } + + /// + /// Tests that a sorted list cloned through IDictionary stays a sorted list with the source's comparer, + /// so it keeps its order on later inserts. + /// + [TestMethod] + public void SortedList_ThroughIDictionary_KeepsComparerAndOrder() + { + Comparer descending = Comparer.Create((x, y) => string.CompareOrdinal(y, x)); + SortedList original = new(descending) { ["a"] = 1, ["c"] = 3, ["b"] = 2 }; + + IDictionary clone = ((IDictionary)original).DeepClone(); + clone["d"] = 4; + + SortedList sortedClone = Assert.IsInstanceOfType>(clone); + Assert.AreSame(descending, sortedClone.Comparer); + CollectionAssert.AreEqual(DescendingKeys, sortedClone.Keys.ToArray()); + } + + /// + /// Tests that a sorted list cloned through IReadOnlyDictionary keeps a case-insensitive comparer. + /// + [TestMethod] + public void SortedList_ThroughIReadOnlyDictionary_KeepsComparer() + { + SortedList original = new(StringComparer.OrdinalIgnoreCase) { ["Key"] = 1 }; + + IReadOnlyDictionary clone = ((IReadOnlyDictionary)original).DeepClone(); + + Assert.IsTrue(clone.ContainsKey("key")); + } + + /// + /// Tests that an immutable dictionary cloned through IDictionary keeps its case-insensitive key comparer. + /// + [TestMethod] + public void ImmutableDictionary_ThroughIDictionary_KeepsComparer() + { + ImmutableDictionary original = ImmutableDictionary.Create(StringComparer.OrdinalIgnoreCase).Add("Key", 1); + + IDictionary clone = ((IDictionary)original).DeepClone(); + + Assert.IsTrue(clone.ContainsKey("key")); + } + + /// + /// Tests that an immutable dictionary using reference equality, holding two equal but distinct keys, + /// clones without throwing and keeps both entries. + /// + [TestMethod] + public void ImmutableDictionary_WithReferenceEquality_ClonesEqualKeys() + { + ImmutableDictionary original = ImmutableDictionary.Create(ReferenceEqualityComparer.Instance) + .Add(new string('k', 1), 1) + .Add(new string('k', 1), 2); + + IReadOnlyDictionary clone = ((IReadOnlyDictionary)original).DeepClone(); + + Assert.HasCount(2, clone); + } + + /// + /// Tests that an immutable sorted dictionary cloned through IDictionary stays sorted by the source's comparer. + /// + [TestMethod] + public void ImmutableSortedDictionary_ThroughIDictionary_KeepsComparerAndOrder() + { + Comparer descending = Comparer.Create((x, y) => string.CompareOrdinal(y, x)); + ImmutableSortedDictionary original = ImmutableSortedDictionary.Create(descending) + .Add("a", 1).Add("c", 3).Add("b", 2); + + IDictionary clone = ((IDictionary)original).DeepClone(); + clone["d"] = 4; + + CollectionAssert.AreEqual(DescendingKeys, clone.Keys.ToArray()); + } +} diff --git a/DeepClone/DeepCloneContainerExtensions.cs b/DeepClone/DeepCloneContainerExtensions.cs index 03688f5..1f677e8 100644 --- a/DeepClone/DeepCloneContainerExtensions.cs +++ b/DeepClone/DeepCloneContainerExtensions.cs @@ -4,6 +4,9 @@ namespace ktsu.DeepClone; using System.Collections.Concurrent; using System.Collections.ObjectModel; +#if NET +using System.Collections.Immutable; +#endif /// /// Extension methods for deep cloning collections of objects. @@ -188,9 +191,12 @@ public static IEnumerable DeepClone(this IEnumerable source) /// otherwise containing the original keys and values. /// /// This method returns a new dictionary with cloned key-value pairs. Both keys and values - /// are deep cloned if they implement IDeepCloneable. A - /// is cloned as a sorted dictionary, and the source's key comparer is kept when the runtime type - /// exposes one ( or ). + /// are deep cloned if they implement IDeepCloneable. The source's key comparer is kept when the runtime + /// type exposes one: , , + /// , (.NET 6 and later), + /// and, on .NET, ImmutableDictionary and ImmutableSortedDictionary. A sorted source stays sorted, + /// a sorted list or concurrent dictionary is cloned as the same type, and an immutable source is cloned as the + /// mutable dictionary of the same kind. /// /// Example usage: /// @@ -309,8 +315,11 @@ public static ReadOnlyDictionary DeepClone(this Read /// otherwise containing the original keys and values. /// /// This method returns a new read-only dictionary with cloned key-value pairs. As with the - /// overload, a sorted dictionary stays sorted and the source's - /// key comparer is kept when the runtime type exposes one. + /// overload, a sorted source stays sorted and the source's key comparer + /// is kept when the runtime type exposes one: , + /// , , + /// (.NET 6 and later), and, on .NET, ImmutableDictionary + /// and ImmutableSortedDictionary. /// /// Example usage: /// @@ -334,8 +343,9 @@ public static IReadOnlyDictionary DeepClone(this IRe /// The type of values in the dictionary. /// The dictionary being cloned, inspected for its runtime type and comparer. /// The key-value pairs of the dictionary being cloned. - /// A for a sorted source, otherwise a . - /// Both implement . + /// A or for a source of + /// that type, a for any other sorted source, otherwise a + /// . All of them implement . private static IDictionary CloneDictionary(object source, IEnumerable> pairs) where TKey : notnull { @@ -343,6 +353,15 @@ private static IDictionary CloneDictionary(object so { SortedDictionary sorted => new SortedDictionary(sorted.Comparer), Dictionary dictionary => new Dictionary(dictionary.Count, dictionary.Comparer), + SortedList sortedList => new SortedList(sortedList.Count, sortedList.Comparer), +#if NET6_0_OR_GREATER + ConcurrentDictionary concurrent => new ConcurrentDictionary(concurrent.Comparer), +#endif +#if NET + // The immutable types cannot be filled in place, so the clone is the mutable type with the same lookup. + ImmutableSortedDictionary immutableSorted => new SortedDictionary(immutableSorted.KeyComparer), + ImmutableDictionary immutable => new Dictionary(immutable.Count, immutable.KeyComparer), +#endif _ => new Dictionary(), }; AddClonedPairs(clone, pairs); diff --git a/README.md b/README.md index fae1190..9feccba 100644 --- a/README.md +++ b/README.md @@ -601,8 +601,8 @@ Extension methods for deep cloning various container types. | Name | Container Type | Description | |------|---------------|-------------| | `DeepClone` | `IEnumerable?` | Creates a deep clone of an enumerable collection | -| `DeepClone` | `IDictionary?` | Creates a deep clone of a dictionary | -| `DeepClone` | `IReadOnlyDictionary?` | Creates a deep clone of a read-only dictionary | +| `DeepClone` | `IDictionary?` | Creates a deep clone of a dictionary, preserving the key comparer where the runtime type exposes one | +| `DeepClone` | `IReadOnlyDictionary?` | Creates a deep clone of a read-only dictionary, preserving the key comparer where the runtime type exposes one | | `DeepClone` | `HashSet?` | Creates a deep clone of a hash set, preserving the comparer | | `DeepClone` | `SortedSet?` | Creates a deep clone of a sorted set, preserving the comparer | | `DeepClone` | `Stack?` | Creates a deep clone of a stack, preserving the element order |