diff --git a/Containers.Test/NullKeyParamNameTests.cs b/Containers.Test/NullKeyParamNameTests.cs new file mode 100644 index 0000000..06f0292 --- /dev/null +++ b/Containers.Test/NullKeyParamNameTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Containers.Tests; + +using System.Collections; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// A null key reports the parameter's own name, as does, +/// not the text of the cast that checks it (ktsu-dev/Containers#146). +/// +[TestClass] +public class NullKeyParamNameTests +{ + private static void AssertParamName(string expected, Action action) + { + ArgumentNullException exception = Assert.ThrowsExactly(action); + Assert.AreEqual(expected, exception.ParamName); + } + + private static void AssertParamName(string expected, Func func) => + AssertParamName(expected, () => { _ = func(); }); + + private static bool KeysContain(ICollection keys) => keys.Contains(null!); + + [TestMethod] + public void ContiguousMap_NullKey_ReportsParameterName() + { + ContiguousMap map = new() { ["a"] = 1 }; + + AssertParamName("key", () => map[null!]); + AssertParamName("key", () => map[null!] = 1); + AssertParamName("key", () => map.Add(null!, 1)); + AssertParamName("key", () => map.Remove(null!)); + AssertParamName("key", () => map.ContainsKey(null!)); + AssertParamName("key", () => map.TryGetValue(null!, out _)); + AssertParamName("item", () => KeysContain(map.Keys)); + } + + [TestMethod] + public void InsertionOrderMap_NullKey_ReportsParameterName() + { + InsertionOrderMap map = new() { ["a"] = 1 }; + + AssertParamName("key", () => map[null!]); + AssertParamName("key", () => map[null!] = 1); + AssertParamName("key", () => map.Add(null!, 1)); + AssertParamName("key", () => map.Remove(null!)); + AssertParamName("key", () => map.ContainsKey(null!)); + AssertParamName("key", () => map.TryGetValue(null!, out _)); + AssertParamName("item", () => KeysContain(map.Keys)); + } + + [TestMethod] + public void OrderedMap_NullKey_ReportsParameterName() + { + OrderedMap map = new() { ["a"] = 1 }; + + AssertParamName("key", () => map[null!]); + AssertParamName("key", () => map[null!] = 1); + AssertParamName("key", () => map.Add(null!, 1)); + AssertParamName("key", () => map.Remove(null!)); + AssertParamName("key", () => map.ContainsKey(null!)); + AssertParamName("key", () => map.TryGetValue(null!, out _)); + AssertParamName("item", () => map.Contains(new KeyValuePair(null!, 1))); + AssertParamName("item", () => map.Remove(new KeyValuePair(null!, 1))); + AssertParamName("item", () => KeysContain(map.Keys)); + } + + [TestMethod] + public void OrderedMap_DictionaryConstructorWithNullKey_ReportsDictionary() + { + NullKeyDictionary source = new(); + + AssertParamName("dictionary", () => new OrderedMap(source)); + AssertParamName("dictionary", () => new OrderedMap(source, StringComparer.Ordinal)); + } + + /// + /// A dictionary that enumerates one pair with a null key, which can't hold. + /// + private sealed class NullKeyDictionary : IDictionary + { + private readonly List> pairs = [new(null!, 1)]; + + public int this[string key] { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public ICollection Keys => throw new NotSupportedException(); + public ICollection Values => throw new NotSupportedException(); + public int Count => pairs.Count; + public bool IsReadOnly => true; + public void Add(string key, int value) => throw new NotSupportedException(); + public void Add(KeyValuePair item) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public bool Contains(KeyValuePair item) => throw new NotSupportedException(); + public bool ContainsKey(string key) => throw new NotSupportedException(); + public void CopyTo(KeyValuePair[] array, int arrayIndex) => pairs.CopyTo(array, arrayIndex); + public IEnumerator> GetEnumerator() => pairs.GetEnumerator(); + public bool Remove(string key) => throw new NotSupportedException(); + public bool Remove(KeyValuePair item) => throw new NotSupportedException(); + public bool TryGetValue(string key, out int value) => throw new NotSupportedException(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/Containers/ContiguousMap.cs b/Containers/ContiguousMap.cs index be2cb67..8b8bf69 100644 --- a/Containers/ContiguousMap.cs +++ b/Containers/ContiguousMap.cs @@ -181,7 +181,7 @@ public TValue this[TKey key] { get { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); return !keyToIndex.TryGetValue(key, out int index) ? throw new KeyNotFoundException($"The key '{key}' was not found in the map.") @@ -189,7 +189,7 @@ public TValue this[TKey key] } set { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); if (keyToIndex.TryGetValue(key, out int index)) { @@ -344,7 +344,7 @@ public ContiguousMap(IDictionary dictionary, IEqualityComparer public void Add(TKey key, TValue value) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); if (keyToIndex.ContainsKey(key)) { @@ -378,7 +378,7 @@ public void Add(TKey key, TValue value) /// public bool Remove(TKey key) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); if (!keyToIndex.TryGetValue(key, out int index)) { @@ -421,7 +421,7 @@ public bool Remove(TKey key) /// public bool ContainsKey(TKey key) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); return keyToIndex.ContainsKey(key); } @@ -437,7 +437,7 @@ public bool ContainsKey(TKey key) /// public bool TryGetValue(TKey key, out TValue value) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); if (keyToIndex.TryGetValue(key, out int index)) { @@ -602,7 +602,7 @@ public void TrimExcess() /// public ref TValue GetValueRefOrNullRef(TKey key) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); if (keyToIndex.TryGetValue(key, out int index)) { @@ -706,7 +706,7 @@ public bool Remove(TKey item) => public bool Contains(TKey item) { - Ensure.NotNull((object?)item); + NullKey.ThrowIfNullItem(item); return map.ContainsKey(item); } diff --git a/Containers/InsertionOrderMap.cs b/Containers/InsertionOrderMap.cs index 76a6d23..5e843d5 100644 --- a/Containers/InsertionOrderMap.cs +++ b/Containers/InsertionOrderMap.cs @@ -91,7 +91,7 @@ public TValue this[TKey key] { get { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); return !keyToIndex.TryGetValue(key, out int index) ? throw new KeyNotFoundException($"The key '{key}' was not found in the map.") @@ -99,7 +99,7 @@ public TValue this[TKey key] } set { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); if (keyToIndex.TryGetValue(key, out int index)) { @@ -242,7 +242,7 @@ public InsertionOrderMap(IDictionary dictionary, IEqualityComparer /// public void Add(TKey key, TValue value) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); if (keyToIndex.ContainsKey(key)) { @@ -269,7 +269,7 @@ public void Add(TKey key, TValue value) /// public bool Remove(TKey key) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); if (!keyToIndex.TryGetValue(key, out int index)) { @@ -302,7 +302,7 @@ public bool Remove(TKey key) /// public bool ContainsKey(TKey key) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); return keyToIndex.ContainsKey(key); } @@ -322,7 +322,7 @@ public bool TryGetValue(TKey key, #endif out TValue value) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); if (keyToIndex.TryGetValue(key, out int index)) { @@ -434,7 +434,7 @@ public bool Remove(TKey item) => public bool Contains(TKey item) { - Ensure.NotNull((object?)item); + NullKey.ThrowIfNullItem(item); return map.ContainsKey(item); } diff --git a/Containers/NullKey.cs b/Containers/NullKey.cs new file mode 100644 index 0000000..48197bd --- /dev/null +++ b/Containers/NullKey.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Containers; + +/// +/// Rejects a null key for a map whose key type is unconstrained, naming the parameter the caller passed it as. +/// +/// +/// takes its name from [CallerArgumentExpression], which records +/// the whole argument text. At a call site, the (object?) cast an unconstrained key needs would end up in +/// . Each check here takes the key through a parameter named for what +/// reports, so the recorded expression is exactly that name. +/// +internal static class NullKey +{ + /// + /// Throws when a key passed as key is null. + /// + /// The key to check. + /// Thrown, with a ParamName of key, when is null. + internal static void ThrowIfNull(object? key) => Ensure.NotNull(key); + + /// + /// Throws when a key passed as item, as to a key collection's Contains, is null. + /// + /// The key to check. + /// Thrown, with a ParamName of item, when is null. + internal static void ThrowIfNullItem(object? item) => Ensure.NotNull(item); + + /// + /// Throws when a key read from a source dictionary passed as dictionary is null. + /// + /// The key to check. + /// Thrown, with a ParamName of dictionary, when is null. + internal static void ThrowIfNullIn(object? dictionary) => Ensure.NotNull(dictionary); +} diff --git a/Containers/OrderedMap.cs b/Containers/OrderedMap.cs index 45be767..233b81d 100644 --- a/Containers/OrderedMap.cs +++ b/Containers/OrderedMap.cs @@ -100,7 +100,7 @@ public TValue this[TKey key] { get { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); int index = BinarySearchByKey(key); return index < 0 @@ -109,7 +109,7 @@ public TValue this[TKey key] } set { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); int index = BinarySearchByKey(key); if (index >= 0) @@ -246,7 +246,7 @@ private static List BuildSorted(IDictionary dictionary, ICo List list = new(dictionary.Count); foreach (KeyValuePair pair in dictionary) { - Ensure.NotNull((object?)pair.Key); + NullKey.ThrowIfNullIn(pair.Key); list.Add(new Entry(pair.Key, pair.Value)); } @@ -275,7 +275,7 @@ private static List BuildSorted(IDictionary dictionary, ICo /// Thrown when key already exists in the map. public void Add(TKey key, TValue value) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); int index = BinarySearchByKey(key); if (index >= 0) @@ -296,7 +296,7 @@ public void Add(TKey key, TValue value) /// Thrown when key is null. public bool Remove(TKey key) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); int index = BinarySearchByKey(key); if (index < 0) @@ -317,7 +317,7 @@ public bool Remove(TKey key) /// Thrown when key is null. public bool ContainsKey(TKey key) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); return BinarySearchByKey(key) >= 0; } @@ -335,7 +335,7 @@ public bool TryGetValue(TKey key, #endif out TValue value) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); int index = BinarySearchByKey(key); if (index >= 0) @@ -376,7 +376,7 @@ public void Clear() /// True if the key-value pair is found; otherwise, false. public bool Contains(KeyValuePair item) { - Ensure.NotNull((object?)item.Key); + NullKey.ThrowIfNullItem(item.Key); int index = BinarySearchByKey(item.Key); return (index >= 0) @@ -419,7 +419,7 @@ public void CopyTo(KeyValuePair[] array, int arrayIndex) /// True if the key-value pair was successfully removed; otherwise, false. public bool Remove(KeyValuePair item) { - Ensure.NotNull((object?)item.Key); + NullKey.ThrowIfNullItem(item.Key); int index = BinarySearchByKey(item.Key); if (index < 0) @@ -464,7 +464,7 @@ public bool Remove(KeyValuePair item) /// Thrown when key is null. private int BinarySearchByKey(TKey key) { - Ensure.NotNull((object?)key); + NullKey.ThrowIfNull(key); int left = 0; int right = items.Count - 1; @@ -511,7 +511,7 @@ public bool Remove(TKey item) => public bool Contains(TKey item) { - Ensure.NotNull((object?)item); + NullKey.ThrowIfNullItem(item); return map.ContainsKey(item); }