diff --git a/Light.GuardClauses.SingleFile.cs b/Light.GuardClauses.SingleFile.cs index fa8760b5..b4d45577 100644 --- a/Light.GuardClauses.SingleFile.cs +++ b/Light.GuardClauses.SingleFile.cs @@ -7608,6 +7608,327 @@ public static Dictionary MustNotContainKey([NotNull] return parameter; } + /// + /// Ensures that the collection does not contain a null item, or otherwise throws an . + /// + /// The collection to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when contains a null item. + /// Thrown when is null. + /// + /// This method inspects the collection once, stops at the first null item, runs in O(n) time, and uses constant + /// additional space. receivers are inspected by index without allocating an enumerator; other + /// receivers are enumerated once. Empty collections succeed. Non-generic access boxes value-type items. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TCollection MustNotContainNull([NotNull][ValidatedNotNull] this TCollection? parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) + where TCollection : class, IEnumerable + { + var position = FindNullItem(parameter.MustNotBeNull(parameterName, message)); + if (position >= 0) + { + Throw.NullItem(position, parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that the collection does not contain a null item, or otherwise throws your custom exception. + /// + /// The collection to be checked. + /// The delegate that creates your custom exception. is passed to this delegate. + /// Your custom exception thrown when is null or contains a null item. + /// + /// This method inspects the collection once, stops at the first null item, runs in O(n) time, and uses constant + /// additional space. receivers are inspected by index without allocating an enumerator; other + /// receivers are enumerated once. Empty collections succeed. Non-generic access boxes value-type items. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; exceptionFactory:null => halt")] + public static TCollection MustNotContainNull([NotNull][ValidatedNotNull] this TCollection? parameter, Func exceptionFactory) + where TCollection : class, IEnumerable + { + if (parameter is null) + { + Throw.CustomException(exceptionFactory, parameter); + } + + if (FindNullItem(parameter) >= 0) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } + + /// + /// Ensures that the does not contain a null item, or otherwise throws an . + /// + /// The immutable array to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when contains a null item. + /// + /// This method inspects an initialized array by index without allocating an enumerator, stops at the first null + /// item, runs in O(n) time, and uses constant additional space. Empty and default immutable arrays succeed. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ImmutableArray MustNotContainNull(this ImmutableArray parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) + { + if (parameter.IsDefault) + { + return parameter; + } + + for (var position = 0; position < parameter.Length; ++position) + { + if (parameter[position] is null) + { + Throw.NullItem(position, parameterName, message); + } + } + + return parameter; + } + + /// + /// Ensures that the does not contain a null item, or otherwise throws your custom exception. + /// + /// The immutable array to be checked. + /// The delegate that creates your custom exception. is passed to this delegate. + /// Your custom exception thrown when contains a null item. + /// + /// This method inspects an initialized array by index without allocating an enumerator, stops at the first null + /// item, runs in O(n) time, and uses constant additional space. Empty and default immutable arrays succeed. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("exceptionFactory:null => halt")] + public static ImmutableArray MustNotContainNull(this ImmutableArray parameter, Func, Exception> exceptionFactory) + { + if (parameter.IsDefault) + { + return parameter; + } + + for (var position = 0; position < parameter.Length; ++position) + { + if (parameter[position] is null) + { + Throw.CustomException(exceptionFactory, parameter); + } + } + + return parameter; + } + + private static int FindNullItem(IEnumerable parameter) + { + if (parameter is IList list) + { + for (var position = 0; position < list.Count; ++position) + { + if (list[position] is null) + { + return position; + } + } + + return -1; + } + + var currentPosition = 0; + foreach (var item in parameter) + { + if (item is null) + { + return currentPosition; + } + + ++currentPosition; + } + + return -1; + } + + /// + /// Ensures that the collection does not contain a null, empty, or white-space-only string, or otherwise throws an . + /// + /// The collection to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when contains a null, empty, or white-space-only string. + /// Thrown when is null. + /// + /// This method inspects the collection once, stops at the first invalid item, runs in O(n) time, and uses constant + /// additional space. and receivers are inspected by index + /// without allocating an enumerator; other receivers are enumerated once. Empty collections succeed. White space + /// is classified with the same Unicode semantics as . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TCollection MustNotContainNullOrWhiteSpace([NotNull][ValidatedNotNull] this TCollection? parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) + where TCollection : class, IEnumerable + { + var position = FindNullOrWhiteSpaceItem(parameter.MustNotBeNull(parameterName, message), out var invalidItem); + if (position >= 0) + { + Throw.NullOrWhiteSpaceItem(invalidItem, position, parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that the collection does not contain a null, empty, or white-space-only string, or otherwise throws your custom exception. + /// + /// The collection to be checked. + /// The delegate that creates your custom exception. is passed to this delegate. + /// Your custom exception thrown when is null or contains a null, empty, or white-space-only string. + /// + /// This method inspects the collection once, stops at the first invalid item, runs in O(n) time, and uses constant + /// additional space. and receivers are inspected by index + /// without allocating an enumerator; other receivers are enumerated once. Empty collections succeed. White space + /// is classified with the same Unicode semantics as . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; exceptionFactory:null => halt")] + public static TCollection MustNotContainNullOrWhiteSpace([NotNull][ValidatedNotNull] this TCollection? parameter, Func exceptionFactory) + where TCollection : class, IEnumerable + { + if (parameter is null) + { + Throw.CustomException(exceptionFactory, parameter); + } + + if (FindNullOrWhiteSpaceItem(parameter, out _) >= 0) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } + + /// + /// Ensures that the immutable array does not contain a null, empty, or white-space-only string, or otherwise throws an . + /// + /// The immutable array to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when contains a null, empty, or white-space-only string. + /// + /// This method inspects an initialized array by index without allocating an enumerator, stops at the first invalid + /// item, runs in O(n) time, and uses constant additional space. Empty and default immutable arrays succeed. White + /// space is classified with the same Unicode semantics as . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ImmutableArray MustNotContainNullOrWhiteSpace(this ImmutableArray parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) + { + if (parameter.IsDefault) + { + return parameter; + } + + for (var position = 0; position < parameter.Length; ++position) + { + var item = parameter[position]; + if (item.IsNullOrWhiteSpace()) + { + Throw.NullOrWhiteSpaceItem(item, position, parameterName, message); + } + } + + return parameter; + } + + /// + /// Ensures that the immutable array does not contain a null, empty, or white-space-only string, or otherwise throws your custom exception. + /// + /// The immutable array to be checked. + /// The delegate that creates your custom exception. is passed to this delegate. + /// Your custom exception thrown when contains a null, empty, or white-space-only string. + /// + /// This method inspects an initialized array by index without allocating an enumerator, stops at the first invalid + /// item, runs in O(n) time, and uses constant additional space. Empty and default immutable arrays succeed. White + /// space is classified with the same Unicode semantics as . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("exceptionFactory:null => halt")] + public static ImmutableArray MustNotContainNullOrWhiteSpace(this ImmutableArray parameter, Func, Exception> exceptionFactory) + { + if (parameter.IsDefault) + { + return parameter; + } + + for (var position = 0; position < parameter.Length; ++position) + { + if (parameter[position].IsNullOrWhiteSpace()) + { + Throw.CustomException(exceptionFactory, parameter); + } + } + + return parameter; + } + + private static int FindNullOrWhiteSpaceItem(IEnumerable parameter, out string? invalidItem) + { + if (parameter is IList list) + { + return FindNullOrWhiteSpaceItem(list, out invalidItem); + } + + if (parameter is IReadOnlyList readOnlyList) + { + for (var position = 0; position < readOnlyList.Count; ++position) + { + var item = readOnlyList[position]; + if (item.IsNullOrWhiteSpace()) + { + invalidItem = item; + return position; + } + } + + invalidItem = null; + return -1; + } + + var currentPosition = 0; + foreach (var item in parameter) + { + if (item.IsNullOrWhiteSpace()) + { + invalidItem = item; + return currentPosition; + } + + ++currentPosition; + } + + invalidItem = null; + return -1; + } + + private static int FindNullOrWhiteSpaceItem(IList list, out string? invalidItem) + { + for (var position = 0; position < list.Count; ++position) + { + var item = list[position]; + if (item.IsNullOrWhiteSpace()) + { + invalidItem = item; + return position; + } + } + + invalidItem = null; + return -1; + } + /// /// Ensures that the string does not end with the specified value, or otherwise throws a . /// @@ -9564,6 +9885,20 @@ public static void MustNotBeLessThanOrEqualTo(T parameter, T boundary, [Calle [DoesNotReturn] public static void NotTrimmedAtStart(string? parameter, string? parameterName, string? message) => throw new StringException(parameterName, message ?? $"{parameterName ?? "The string"} must be trimmed at the start, but it actually is {parameter.ToStringOrNull()}."); /// + /// Throws the default indicating that a collection contains a null item. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void NullItem(int position, string? parameterName = null, string? message = null) => throw new ExistingItemException(parameterName, message ?? $"{parameterName ?? "The collection"} must not contain null items, but a null item was found at position {position}."); + /// + /// Throws the default indicating that a collection contains a null, empty, + /// or white-space-only string. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void NullOrWhiteSpaceItem(string? item, int position, string? parameterName = null, string? message = null) => throw new ExistingItemException(parameterName, message ?? $"{parameterName ?? "The collection"} must not contain null, empty, or white-space-only strings, but {GetFailureCategory(item)} was found at position {position}."); + private static string GetFailureCategory(string? item) => item is null ? "a null string" : item.Length == 0 ? "an empty string" : "a white-space-only string"; + /// /// Throws the default indicating that a has /// no value, using the optional parameter name and message. /// diff --git a/ai-plans/0151-collection-content-guards.md b/ai-plans/0151-collection-content-guards.md new file mode 100644 index 00000000..9174854d --- /dev/null +++ b/ai-plans/0151-collection-content-guards.md @@ -0,0 +1,50 @@ +# Collection Content Guards + +## Rationale + +Collections received from deserialization, binding, and caller-supplied arrays frequently have invariants about their contents rather than only their own nullability or size. Callers currently express these checks with manual loops or LINQ, losing the intent and the library's standard exception, nullability, caller-expression, and fluent-return behavior. Add `MustNotContainNull` and `MustNotContainNullOrWhiteSpace` as named collection guards. + +The additions must work across every package target, enumerate safely and no more than once, preserve collection shapes, remain Native AOT compatible, and participate in the customizable single-file source distribution. + +## Acceptance Criteria + +- [x] `MustNotContainNull` and `MustNotContainNullOrWhiteSpace` are available with identical semantics on .NET Standard 2.0, .NET Standard 2.1, and .NET 10. +- [x] `MustNotContainNull` accepts nullable reference collections implementing `IEnumerable`, rejects any null item (including an empty nullable value type boxed through non-generic enumeration), and accepts empty collections and collections containing only non-null items. +- [x] `MustNotContainNullOrWhiteSpace` accepts nullable reference collections of strings and rejects null, empty, or whitespace-only items using the same Unicode whitespace semantics as `string.IsNullOrWhiteSpace`; empty collections and collections of non-blank strings are accepted. +- [x] Both guards preserve and return the original reference collection shape, and dedicated overloads preserve `ImmutableArray` without boxing. A default immutable array is treated as having no items and therefore succeeds. +- [x] Each invocation obtains at most one enumerator, disposes it normally, and stops at the first invalid item. Failure-message construction does not enumerate the input again, and validation uses constant additional space. +- [x] Common indexable receivers such as arrays and `List` are inspected by index without allocating an enumerator; arbitrary enumerable receivers retain the single-enumerator fallback. +- [x] A null reference collection passed to a default overload throws `ArgumentNullException`, while invalid null or blank content throws `ExistingItemException`. Default messages identify the guarded expression, the failure category, and the zero-based position of the first offending item without rendering the whole collection. +- [x] Every overload returns the successfully validated input, captures the guarded expression for default exceptions, accepts an optional custom message, and has a custom-exception-factory counterpart consistent with the existing API and the receiver shape. +- [x] Automated tests cover empty, valid, and failing inputs; arrays, `List`, lazy/single-use enumerables, nullable reference and value-type items, and default/empty/non-empty immutable arrays; null, empty, ASCII-whitespace, and Unicode-whitespace strings; null receivers; default exceptions and messages; custom messages and factories; caller argument expressions; fluent return types; early termination; single enumeration; and enumerator disposal. +- [x] Microbenchmarks compare both guards' indexable `List` fast paths with equivalent imperative loops and report allocations. +- [x] The source-export whitelist catalog and committed settings contain both assertion families, and focused source-export tests verify their guards, exception and throw-helper reachability, immutable-array overloads, and exception-factory trimming. +- [x] The committed .NET Standard 2.0 single-file distribution is regenerated and validates with the new portable API surface. +- [x] XML documentation and the assertion overview describe the supported receiver shapes, vacuous success for empty/default immutable collections, the guards' O(n), constant-additional-space, single-pass, short-circuiting enumeration behavior, and the boxing incurred when `MustNotContainNull` enumerates value-type items through non-generic `IEnumerable`. +- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK. + +## Technical Details + +Use one `Check..cs` file per family. The null-content guard can preserve any reference receiver through non-generic enumeration because it needs no item type; the string guard can likewise infer the receiver while fixing the item type in its constraint (illustrative signatures): + +```csharp +public static TCollection MustNotContainNull( + [NotNull, ValidatedNotNull] this TCollection? parameter, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null +) where TCollection : class, IEnumerable; + +public static TCollection MustNotContainNullOrWhiteSpace( + [NotNull, ValidatedNotNull] this TCollection? parameter, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null +) where TCollection : class, IEnumerable; +``` + +Add factory counterparts receiving `TCollection?`, with the established `[ContractAnnotation]` and nullable-flow attributes. Add dedicated `ImmutableArray` / `ImmutableArray` overloads and factories so the value-type receiver is neither boxed nor shape-erased. Checking each item with `is null` covers reference types and `Nullable` values through `IEnumerable`; this non-generic enumeration boxes non-null value-type items, so call out that cost in the public XML remarks rather than implying the guard is allocation-free for those collections. The string family should use the existing `IsNullOrWhiteSpace` predicate. Do not compose the string guard from multiple guards, because that would enumerate a lazy input more than once. + +Inspect indexable receivers without obtaining an enumerator: use non-generic `IList` for the null-content guard and `IList` / `IReadOnlyList` for the string guard, and index dedicated immutable-array overloads directly. This covers arrays, `List`, and other common list shapes while retaining the receiver-preserving public signatures. Fall back to a direct `foreach`-style loop for arbitrary enumerable receivers and short-circuit on the first failure. Do not pre-count or call LINQ `Any` or `Count`; a lazy or stateful enumerable must be observed only once. Keep a zero-based position while inspecting and pass the discovered value/category and position to throw helpers; throw helpers must not append collection contents because doing so would re-enumerate a possibly one-shot input. + +Reuse `ExistingItemException` for prohibited null and blank items, with focused throw helpers for the two message forms. Custom messages replace the generated message exactly as in existing guards. A null collection follows `MustNotBeNull`; custom factories are invoked for both a null receiver and invalid contents. A default immutable array is handled before enumeration and succeeds, consistent with `MustNotContain`; callers that also need initialization or non-emptiness can compose `MustNotBeDefaultOrEmpty`. + +Add both names to `AssertionWhitelist` and `settings.json`, cover dependency reachability and factory removal in `SourceFileMergerWhitelistTests`, and regenerate `Light.GuardClauses.SingleFile.cs` for .NET Standard 2.0. Document the new families in the collections section of `docs/assertion-overview.md`, and put the enumeration behavior and complexity in each public method's XML remarks. Boolean predicate counterparts are out of scope: the requested value is the named throwing precondition, while `Any` already covers non-throwing inspection. diff --git a/benchmarks/Light.GuardClauses.Performance/CollectionAssertions/CollectionContentGuardBenchmark.cs b/benchmarks/Light.GuardClauses.Performance/CollectionAssertions/CollectionContentGuardBenchmark.cs new file mode 100644 index 00000000..35449fae --- /dev/null +++ b/benchmarks/Light.GuardClauses.Performance/CollectionAssertions/CollectionContentGuardBenchmark.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; + +namespace Light.GuardClauses.Performance.CollectionAssertions; + +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class CollectionContentGuardBenchmark +{ + public List Collection = ["Alpha", "Beta", "Gamma", "Delta"]; + + [Benchmark(Baseline = true)] + [BenchmarkCategory(nameof(MustNotContainNull))] + public List ImperativeNullCheck() + { + for (var i = 0; i < Collection.Count; ++i) + { + if (Collection[i] is null) + { + throw new InvalidOperationException(); + } + } + + return Collection; + } + + [Benchmark] + [BenchmarkCategory(nameof(MustNotContainNull))] + public List MustNotContainNull() => Collection.MustNotContainNull(); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(nameof(MustNotContainNullOrWhiteSpace))] + public List ImperativeNullOrWhiteSpaceCheck() + { + for (var i = 0; i < Collection.Count; ++i) + { + if (string.IsNullOrWhiteSpace(Collection[i])) + { + throw new InvalidOperationException(); + } + } + + return Collection; + } + + [Benchmark] + [BenchmarkCategory(nameof(MustNotContainNullOrWhiteSpace))] + public List MustNotContainNullOrWhiteSpace() => Collection.MustNotContainNullOrWhiteSpace(); +} diff --git a/docs/assertion-overview.md b/docs/assertion-overview.md index 84b0dd47..3bbf9f01 100644 --- a/docs/assertion-overview.md +++ b/docs/assertion-overview.md @@ -91,12 +91,16 @@ percentage.MustBeIn(Range.FromExclusive(0).ToInclusive(100)); | `MustHaveMinimumCount`, `MustHaveMaximumCount` | Enforce inclusive collection-count bounds | | `IsOneOf`, `MustBeOneOf`, `MustNotBeOneOf` | Test or enforce membership among supplied values | | `MustContain`, `MustNotContain` | Require or reject an item in collections and immutable arrays; string overloads operate on substrings | +| `MustNotContainNull` | Reject null items in reference collections implementing non-generic `IEnumerable`, with a dedicated `ImmutableArray` overload | +| `MustNotContainNullOrWhiteSpace` | Reject null, empty, or Unicode-whitespace-only strings in reference collections implementing `IEnumerable`, with a dedicated immutable-array overload | | `MustContainKey`, `MustNotContainKey` | Require or reject a dictionary key via `ContainsKey`, never enumerating and honoring the dictionary's key comparer; accept `IReadOnlyDictionary` receivers, with dedicated overloads preserving the `Dictionary` shape | | `MustNotBeDefaultOrEmpty` | Requires an initialized, non-empty `ImmutableArray` | | `MustHaveLength`, `MustHaveLengthIn`, `MustHaveMinimumLength`, `MustHaveMaximumLength` | Validate string, span, or immutable-array length as provided by their overloads | Collection overloads preserve and return the original collection shape where possible. Check the XML documentation before using an `IEnumerable` guard in a hot path; annotations identify guards that do not enumerate. +The two collection-content guards treat empty collections and empty or default immutable arrays as valid. Arrays, lists, and other supported indexable receivers are inspected by index without allocating an enumerator; arbitrary enumerable receivers are enumerated at most once and dispose that enumerator normally. Both paths stop at the first invalid item, use O(n) time, and require constant additional space. `MustNotContainNull` uses non-generic item access for reference collections so it can preserve any receiver shape without knowing the item type; consequently, value-type items are boxed by that overload. Its dedicated immutable-array overload uses generic indexed access and avoids that cost. + The dictionary key guards bind to any dictionary type implementing `IReadOnlyDictionary` (such as `ConcurrentDictionary`, `SortedDictionary`, `ReadOnlyDictionary`, `ImmutableDictionary`, and `FrozenDictionary` on .NET 10). A receiver statically typed as `IDictionary` cannot use them; call `dictionary.Keys.MustContain(key)` as a workaround — the key collections of the BCL dictionaries implement `ICollection.Contains` via `ContainsKey`, so this stays O(1). ## Text, character, span, and memory assertions diff --git a/src/Light.GuardClauses/Check.MustNotContainNull.cs b/src/Light.GuardClauses/Check.MustNotContainNull.cs new file mode 100644 index 00000000..77d678cc --- /dev/null +++ b/src/Light.GuardClauses/Check.MustNotContainNull.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections; +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; +using Light.GuardClauses.ExceptionFactory; +using Light.GuardClauses.Exceptions; +using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +namespace Light.GuardClauses; + +public static partial class Check +{ + /// + /// Ensures that the collection does not contain a null item, or otherwise throws an . + /// + /// The collection to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when contains a null item. + /// Thrown when is null. + /// + /// This method inspects the collection once, stops at the first null item, runs in O(n) time, and uses constant + /// additional space. receivers are inspected by index without allocating an enumerator; other + /// receivers are enumerated once. Empty collections succeed. Non-generic access boxes value-type items. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TCollection MustNotContainNull( + [NotNull] [ValidatedNotNull] this TCollection? parameter, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null + ) where TCollection : class, IEnumerable + { + var position = FindNullItem(parameter.MustNotBeNull(parameterName, message)); + if (position >= 0) + { + Throw.NullItem(position, parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that the collection does not contain a null item, or otherwise throws your custom exception. + /// + /// The collection to be checked. + /// The delegate that creates your custom exception. is passed to this delegate. + /// Your custom exception thrown when is null or contains a null item. + /// + /// This method inspects the collection once, stops at the first null item, runs in O(n) time, and uses constant + /// additional space. receivers are inspected by index without allocating an enumerator; other + /// receivers are enumerated once. Empty collections succeed. Non-generic access boxes value-type items. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; exceptionFactory:null => halt")] + public static TCollection MustNotContainNull( + [NotNull] [ValidatedNotNull] this TCollection? parameter, + Func exceptionFactory + ) where TCollection : class, IEnumerable + { + if (parameter is null) + { + Throw.CustomException(exceptionFactory, parameter); + } + + if (FindNullItem(parameter) >= 0) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } + + /// + /// Ensures that the does not contain a null item, or otherwise throws an . + /// + /// The immutable array to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when contains a null item. + /// + /// This method inspects an initialized array by index without allocating an enumerator, stops at the first null + /// item, runs in O(n) time, and uses constant additional space. Empty and default immutable arrays succeed. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ImmutableArray MustNotContainNull( + this ImmutableArray parameter, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null + ) + { + if (parameter.IsDefault) + { + return parameter; + } + + for (var position = 0; position < parameter.Length; ++position) + { + if (parameter[position] is null) + { + Throw.NullItem(position, parameterName, message); + } + } + + return parameter; + } + + /// + /// Ensures that the does not contain a null item, or otherwise throws your custom exception. + /// + /// The immutable array to be checked. + /// The delegate that creates your custom exception. is passed to this delegate. + /// Your custom exception thrown when contains a null item. + /// + /// This method inspects an initialized array by index without allocating an enumerator, stops at the first null + /// item, runs in O(n) time, and uses constant additional space. Empty and default immutable arrays succeed. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("exceptionFactory:null => halt")] + public static ImmutableArray MustNotContainNull( + this ImmutableArray parameter, + Func, Exception> exceptionFactory + ) + { + if (parameter.IsDefault) + { + return parameter; + } + + for (var position = 0; position < parameter.Length; ++position) + { + if (parameter[position] is null) + { + Throw.CustomException(exceptionFactory, parameter); + } + } + + return parameter; + } + + private static int FindNullItem(IEnumerable parameter) + { + if (parameter is IList list) + { + for (var position = 0; position < list.Count; ++position) + { + if (list[position] is null) + { + return position; + } + } + + return -1; + } + + var currentPosition = 0; + foreach (var item in parameter) + { + if (item is null) + { + return currentPosition; + } + + ++currentPosition; + } + + return -1; + } +} diff --git a/src/Light.GuardClauses/Check.MustNotContainNullOrWhiteSpace.cs b/src/Light.GuardClauses/Check.MustNotContainNullOrWhiteSpace.cs new file mode 100644 index 00000000..26e73653 --- /dev/null +++ b/src/Light.GuardClauses/Check.MustNotContainNullOrWhiteSpace.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; +using Light.GuardClauses.ExceptionFactory; +using Light.GuardClauses.Exceptions; +using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +namespace Light.GuardClauses; + +public static partial class Check +{ + /// + /// Ensures that the collection does not contain a null, empty, or white-space-only string, or otherwise throws an . + /// + /// The collection to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when contains a null, empty, or white-space-only string. + /// Thrown when is null. + /// + /// This method inspects the collection once, stops at the first invalid item, runs in O(n) time, and uses constant + /// additional space. and receivers are inspected by index + /// without allocating an enumerator; other receivers are enumerated once. Empty collections succeed. White space + /// is classified with the same Unicode semantics as . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TCollection MustNotContainNullOrWhiteSpace( + [NotNull] [ValidatedNotNull] this TCollection? parameter, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null + ) where TCollection : class, IEnumerable + { + var position = FindNullOrWhiteSpaceItem( + parameter.MustNotBeNull(parameterName, message), + out var invalidItem + ); + if (position >= 0) + { + Throw.NullOrWhiteSpaceItem(invalidItem, position, parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that the collection does not contain a null, empty, or white-space-only string, or otherwise throws your custom exception. + /// + /// The collection to be checked. + /// The delegate that creates your custom exception. is passed to this delegate. + /// Your custom exception thrown when is null or contains a null, empty, or white-space-only string. + /// + /// This method inspects the collection once, stops at the first invalid item, runs in O(n) time, and uses constant + /// additional space. and receivers are inspected by index + /// without allocating an enumerator; other receivers are enumerated once. Empty collections succeed. White space + /// is classified with the same Unicode semantics as . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; exceptionFactory:null => halt")] + public static TCollection MustNotContainNullOrWhiteSpace( + [NotNull] [ValidatedNotNull] this TCollection? parameter, + Func exceptionFactory + ) where TCollection : class, IEnumerable + { + if (parameter is null) + { + Throw.CustomException(exceptionFactory, parameter); + } + + if (FindNullOrWhiteSpaceItem(parameter, out _) >= 0) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } + + /// + /// Ensures that the immutable array does not contain a null, empty, or white-space-only string, or otherwise throws an . + /// + /// The immutable array to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when contains a null, empty, or white-space-only string. + /// + /// This method inspects an initialized array by index without allocating an enumerator, stops at the first invalid + /// item, runs in O(n) time, and uses constant additional space. Empty and default immutable arrays succeed. White + /// space is classified with the same Unicode semantics as . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ImmutableArray MustNotContainNullOrWhiteSpace( + this ImmutableArray parameter, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null + ) + { + if (parameter.IsDefault) + { + return parameter; + } + + for (var position = 0; position < parameter.Length; ++position) + { + var item = parameter[position]; + if (item.IsNullOrWhiteSpace()) + { + Throw.NullOrWhiteSpaceItem(item, position, parameterName, message); + } + } + + return parameter; + } + + /// + /// Ensures that the immutable array does not contain a null, empty, or white-space-only string, or otherwise throws your custom exception. + /// + /// The immutable array to be checked. + /// The delegate that creates your custom exception. is passed to this delegate. + /// Your custom exception thrown when contains a null, empty, or white-space-only string. + /// + /// This method inspects an initialized array by index without allocating an enumerator, stops at the first invalid + /// item, runs in O(n) time, and uses constant additional space. Empty and default immutable arrays succeed. White + /// space is classified with the same Unicode semantics as . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("exceptionFactory:null => halt")] + public static ImmutableArray MustNotContainNullOrWhiteSpace( + this ImmutableArray parameter, + Func, Exception> exceptionFactory + ) + { + if (parameter.IsDefault) + { + return parameter; + } + + for (var position = 0; position < parameter.Length; ++position) + { + if (parameter[position].IsNullOrWhiteSpace()) + { + Throw.CustomException(exceptionFactory, parameter); + } + } + + return parameter; + } + + private static int FindNullOrWhiteSpaceItem(IEnumerable parameter, out string? invalidItem) => + parameter switch + { + IList list => FindNullOrWhiteSpaceItem(list, out invalidItem), + IReadOnlyList readOnlyList => FindNullOrWhiteSpaceItem(readOnlyList, out invalidItem), + _ => FindNullOrWhiteSpaceItemViaForeach(parameter, out invalidItem), + }; + + private static int FindNullOrWhiteSpaceItemViaForeach(IEnumerable parameter, out string? invalidItem) + { + var currentPosition = 0; + foreach (var item in parameter) + { + if (item.IsNullOrWhiteSpace()) + { + invalidItem = item; + return currentPosition; + } + + ++currentPosition; + } + + invalidItem = null; + return -1; + } + + private static int FindNullOrWhiteSpaceItem(IList list, out string? invalidItem) + { + for (var position = 0; position < list.Count; ++position) + { + var item = list[position]; + if (item.IsNullOrWhiteSpace()) + { + invalidItem = item; + return position; + } + } + + invalidItem = null; + return -1; + } + + private static int FindNullOrWhiteSpaceItem(IReadOnlyList list, out string? invalidItem) + { + for (var position = 0; position < list.Count; ++position) + { + var item = list[position]; + if (item.IsNullOrWhiteSpace()) + { + invalidItem = item; + return position; + } + } + + invalidItem = null; + return -1; + } +} diff --git a/src/Light.GuardClauses/ExceptionFactory/Throw.NullItem.cs b/src/Light.GuardClauses/ExceptionFactory/Throw.NullItem.cs new file mode 100644 index 00000000..1e5168f5 --- /dev/null +++ b/src/Light.GuardClauses/ExceptionFactory/Throw.NullItem.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; +using Light.GuardClauses.Exceptions; + +namespace Light.GuardClauses.ExceptionFactory; + +public static partial class Throw +{ + /// + /// Throws the default indicating that a collection contains a null item. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void NullItem(int position, string? parameterName = null, string? message = null) => + throw new ExistingItemException( + parameterName, + message ?? + $"{parameterName ?? "The collection"} must not contain null items, but a null item was found at position {position}." + ); +} diff --git a/src/Light.GuardClauses/ExceptionFactory/Throw.NullOrWhiteSpaceItem.cs b/src/Light.GuardClauses/ExceptionFactory/Throw.NullOrWhiteSpaceItem.cs new file mode 100644 index 00000000..edb09e9c --- /dev/null +++ b/src/Light.GuardClauses/ExceptionFactory/Throw.NullOrWhiteSpaceItem.cs @@ -0,0 +1,30 @@ +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; +using Light.GuardClauses.Exceptions; + +namespace Light.GuardClauses.ExceptionFactory; + +public static partial class Throw +{ + /// + /// Throws the default indicating that a collection contains a null, empty, + /// or white-space-only string. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void NullOrWhiteSpaceItem( + string? item, + int position, + string? parameterName = null, + string? message = null + ) => + throw new ExistingItemException( + parameterName, + message ?? + $"{parameterName ?? "The collection"} must not contain null, empty, or white-space-only strings, but {GetFailureCategory(item)} was found at position {position}." + ); + + private static string GetFailureCategory(string? item) => + item is null ? "a null string" : + item.Length == 0 ? "an empty string" : "a white-space-only string"; +} diff --git a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs index 0c671d91..83ca11f5 100644 --- a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs +++ b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs @@ -286,6 +286,59 @@ public static void MustNotContainKeyWhitelistExportsGuardAndTrimsExceptionFactor sourceCode.Should().NotContain("class MissingKeyException"); } + [Fact] + public static void MustNotContainNullWhitelistExportsDependenciesAndTrimsExceptionFactories() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustNotContainNull.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist( + includedAssertions: [new ("MustNotContainNull", false)] + ) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("MustNotContainNull("); + sourceCode.Should().Contain("where TCollection : class, IEnumerable"); + sourceCode.Should().Contain("ImmutableArray MustNotContainNull("); + sourceCode.Should().Contain("class ExistingItemException : CollectionException"); + sourceCode.Should().Contain("public static void NullItem("); + sourceCode.Should().NotContain("Func exceptionFactory"); + sourceCode.Should().NotContain("Func, Exception> exceptionFactory"); + sourceCode.Should().NotContain("MustNotContainNullOrWhiteSpace"); + } + + [Fact] + public static void MustNotContainNullOrWhiteSpaceWhitelistExportsDependenciesAndTrimsExceptionFactories() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustNotContainNullOrWhiteSpace.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist( + includedAssertions: [new ("MustNotContainNullOrWhiteSpace", false)] + ) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("MustNotContainNullOrWhiteSpace("); + sourceCode.Should().Contain("where TCollection : class, IEnumerable"); + sourceCode.Should().Contain("ImmutableArray MustNotContainNullOrWhiteSpace("); + sourceCode.Should().Contain("class ExistingItemException : CollectionException"); + sourceCode.Should().Contain("public static void NullOrWhiteSpaceItem("); + sourceCode.Should().Contain("public static bool IsNullOrWhiteSpace("); + sourceCode.Should().NotContain("Func exceptionFactory"); + sourceCode.Should().NotContain("Func, Exception> exceptionFactory"); + sourceCode.Should().NotContain("MustNotContainNull"); + } + private static SourceFileMergeOptions CreateOptions( string targetFile, AssertionWhitelist assertionWhitelist = null, diff --git a/tests/Light.GuardClauses.Tests/CollectionAssertions/MustNotContainNullOrWhiteSpaceTests.cs b/tests/Light.GuardClauses.Tests/CollectionAssertions/MustNotContainNullOrWhiteSpaceTests.cs new file mode 100644 index 00000000..5d0f9861 --- /dev/null +++ b/tests/Light.GuardClauses.Tests/CollectionAssertions/MustNotContainNullOrWhiteSpaceTests.cs @@ -0,0 +1,178 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using FluentAssertions; +using Light.GuardClauses.Exceptions; +using Xunit; + +namespace Light.GuardClauses.Tests.CollectionAssertions; + +public static class MustNotContainNullOrWhiteSpaceTests +{ + [Fact] + public static void EmptyArraySucceedsAndPreservesShape() + { + var values = Array.Empty(); + + var result = values.MustNotContainNullOrWhiteSpace(); + + result.Should().BeSameAs(values); + } + + [Fact] + public static void ValidListSucceedsAndPreservesShape() + { + var values = new List { "Alpha", "Beta" }; + + var result = values.MustNotContainNullOrWhiteSpace(); + + result.Should().BeSameAs(values); + } + + [Theory] + [InlineData(null, "a null string")] + [InlineData("", "an empty string")] + [InlineData(" \t\r\n", "a white-space-only string")] + [InlineData("\u2003", "a white-space-only string")] + public static void InvalidStringThrowsWithCategoryAndPosition(string? invalidValue, string category) + { + var values = new[] { "Alpha", invalidValue, "Gamma" }; + + Action act = () => values.MustNotContainNullOrWhiteSpace(); + + var exception = act.Should().Throw().Which; + exception.ParamName.Should().Be(nameof(values)); + exception.Message.Should().Contain(category); + exception.Message.Should().Contain("position 1"); + exception.Message.Should().NotContain("Alpha"); + } + + [Fact] + public static void NullReceiverThrowsArgumentNullExceptionWithCallerExpression() + { + List? values = null; + + Action act = () => values.MustNotContainNullOrWhiteSpace(); + + act.Should().Throw() + .WithParameterName(nameof(values)); + } + + [Fact] + public static void CustomMessageReplacesDefaultMessage() => + Test.CustomMessage( + message => new[] { "" }.MustNotContainNullOrWhiteSpace(message: message) + ); + + [Fact] + public static void NullReceiverCustomMessageIsPassedToArgumentNullException() => + Test.CustomMessage( + message => ((List?) null).MustNotContainNullOrWhiteSpace(message: message) + ); + + [Fact] + public static void CustomFactoryReceivesInvalidCollection() => + Test.CustomException?>( + ["Alpha", " "], + (values, factory) => values.MustNotContainNullOrWhiteSpace(factory) + ); + + [Fact] + public static void CustomFactoryReceivesNullCollection() => + Test.CustomException( + (List?) null, + (values, factory) => values.MustNotContainNullOrWhiteSpace(factory) + ); + + [Fact] + public static void ValidLazyEnumerableIsEnumeratedOnceAndDisposed() + { + var values = new TrackingEnumerable(["Alpha", "Beta"]); + + values.MustNotContainNullOrWhiteSpace().Should().BeSameAs(values); + + values.EnumeratorCount.Should().Be(1); + values.MoveNextCount.Should().Be(3); + values.DisposeCount.Should().Be(1); + } + + [Fact] + public static void InvalidSingleUseEnumerableStopsEarlyAndIsDisposed() + { + var values = new TrackingEnumerable(["Alpha", "\u2003", "unobserved"]); + + Action act = () => values.MustNotContainNullOrWhiteSpace(); + + act.Should().Throw(); + values.EnumeratorCount.Should().Be(1); + values.MoveNextCount.Should().Be(2); + values.DisposeCount.Should().Be(1); + } + + [Fact] + public static void IndexableReceiversDoNotRequestAnEnumerator() + { + var validValues = new IndexableOnlyList(["Alpha", "Beta"]); + var invalidValues = new IndexableOnlyList(["Alpha", " ", "Gamma"]); + var readOnlyValues = new ReadOnlyIndexableOnlyList(["Alpha", "Beta"]); + + validValues.MustNotContainNullOrWhiteSpace().Should().BeSameAs(validValues); + validValues.MustNotContainNullOrWhiteSpace(_ => new InvalidOperationException()).Should().BeSameAs(validValues); + readOnlyValues.MustNotContainNullOrWhiteSpace().Should().BeSameAs(readOnlyValues); + Action act = () => invalidValues.MustNotContainNullOrWhiteSpace(); + + act.Should().Throw() + .WithMessage("*position 1*"); + } + + [Fact] + public static void EmptyImmutableArraySucceeds() + { + var values = ImmutableArray.Empty; + + var result = values.MustNotContainNullOrWhiteSpace(); + + result.Should().Equal(values); + } + + [Fact] + public static void DefaultImmutableArraySucceeds() + { + var values = default(ImmutableArray); + + var result = values.MustNotContainNullOrWhiteSpace(); + + result.IsDefault.Should().BeTrue(); + } + + [Fact] + public static void InvalidImmutableArrayThrowsAtItsPosition() + { + var values = ImmutableArray.Create("Alpha", "", "Gamma"); + + Action act = () => values.MustNotContainNullOrWhiteSpace(); + + act.Should().Throw() + .WithParameterName(nameof(values)) + .WithMessage("*position 1*"); + } + + [Fact] + public static void ImmutableArrayCustomFactoryReceivesOriginalShape() => + Test.CustomException( + ImmutableArray.Create(" "), + (values, factory) => values.MustNotContainNullOrWhiteSpace(factory) + ); + + [Fact] + public static void DefaultImmutableArrayDoesNotInvokeCustomFactory() + { + var values = default(ImmutableArray); + + var result = values.MustNotContainNullOrWhiteSpace(_ => new InvalidOperationException()); + + result.IsDefault.Should().BeTrue(); + } +} diff --git a/tests/Light.GuardClauses.Tests/CollectionAssertions/MustNotContainNullTests.cs b/tests/Light.GuardClauses.Tests/CollectionAssertions/MustNotContainNullTests.cs new file mode 100644 index 00000000..7d355b19 --- /dev/null +++ b/tests/Light.GuardClauses.Tests/CollectionAssertions/MustNotContainNullTests.cs @@ -0,0 +1,191 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using FluentAssertions; +using Light.GuardClauses.Exceptions; +using Xunit; + +namespace Light.GuardClauses.Tests.CollectionAssertions; + +public static class MustNotContainNullTests +{ + [Fact] + public static void EmptyArraySucceedsAndPreservesShape() + { + var values = Array.Empty(); + + var result = values.MustNotContainNull(); + + result.Should().BeSameAs(values); + } + + [Fact] + public static void ValidListSucceedsAndPreservesShape() + { + var values = new List { "Alpha", "Beta" }; + + var result = values.MustNotContainNull(); + + result.Should().BeSameAs(values); + } + + [Fact] + public static void NullReferenceItemThrowsAtItsPositionWithoutRenderingTheCollection() + { + var values = new[] { "Alpha", "Beta", null, "Gamma" }; + + Action act = () => values.MustNotContainNull(); + + var exception = act.Should().Throw().Which; + exception.ParamName.Should().Be(nameof(values)); + exception.Message.Should().Contain("must not contain null items"); + exception.Message.Should().Contain("position 2"); + exception.Message.Should().NotContain("Alpha"); + } + + [Fact] + public static void EmptyNullableValueTypeIsObservedAsNullThroughNonGenericEnumeration() + { + var values = new List { 1, null, 3 }; + + Action act = () => values.MustNotContainNull(); + + act.Should().Throw() + .WithMessage("*position 1*"); + } + + [Fact] + public static void PopulatedNullableValueTypesSucceed() + { + var values = new List { 1, 2, 3 }; + + values.MustNotContainNull().Should().BeSameAs(values); + } + + [Fact] + public static void NullReceiverThrowsArgumentNullExceptionWithCallerExpression() + { + List? values = null; + + Action act = () => values.MustNotContainNull(); + + act.Should().Throw() + .WithParameterName(nameof(values)); + } + + [Fact] + public static void CustomMessageReplacesDefaultMessage() => + Test.CustomMessage( + message => new object?[] { null }.MustNotContainNull(message: message) + ); + + [Fact] + public static void NullReceiverCustomMessageIsPassedToArgumentNullException() => + Test.CustomMessage( + message => ((List?) null).MustNotContainNull(message: message) + ); + + [Fact] + public static void CustomFactoryReceivesInvalidCollection() => + Test.CustomException?>( + ["Alpha", null], + (values, factory) => values.MustNotContainNull(factory) + ); + + [Fact] + public static void CustomFactoryReceivesNullCollection() => + Test.CustomException( + (List?) null, + (values, factory) => values.MustNotContainNull(factory) + ); + + [Fact] + public static void ValidLazyEnumerableIsEnumeratedOnceAndDisposed() + { + var values = new TrackingEnumerable(["Alpha", "Beta"]); + + values.MustNotContainNull().Should().BeSameAs(values); + + values.EnumeratorCount.Should().Be(1); + values.MoveNextCount.Should().Be(3); + values.DisposeCount.Should().Be(1); + } + + [Fact] + public static void InvalidSingleUseEnumerableStopsEarlyAndIsDisposed() + { + var values = new TrackingEnumerable(["Alpha", null, "unobserved"]); + + Action act = () => values.MustNotContainNull(); + + act.Should().Throw(); + values.EnumeratorCount.Should().Be(1); + values.MoveNextCount.Should().Be(2); + values.DisposeCount.Should().Be(1); + } + + [Fact] + public static void IndexableReceiversDoNotRequestAnEnumerator() + { + var validValues = new IndexableOnlyList(["Alpha", "Beta"]); + var invalidValues = new IndexableOnlyList(["Alpha", null, "Gamma"]); + + validValues.MustNotContainNull().Should().BeSameAs(validValues); + validValues.MustNotContainNull(_ => new InvalidOperationException()).Should().BeSameAs(validValues); + Action act = () => invalidValues.MustNotContainNull(); + + act.Should().Throw() + .WithMessage("*position 1*"); + } + + [Fact] + public static void EmptyImmutableArraySucceeds() + { + var values = ImmutableArray.Empty; + + var result = values.MustNotContainNull(); + + result.Should().Equal(values); + } + + [Fact] + public static void DefaultImmutableArraySucceeds() + { + var values = default(ImmutableArray); + + var result = values.MustNotContainNull(); + + result.IsDefault.Should().BeTrue(); + } + + [Fact] + public static void InvalidImmutableArrayThrowsAtItsPosition() + { + var values = ImmutableArray.Create("Alpha", null, "Gamma"); + + Action act = () => values.MustNotContainNull(); + + act.Should().Throw() + .WithParameterName(nameof(values)) + .WithMessage("*position 1*"); + } + + [Fact] + public static void ImmutableArrayCustomFactoryReceivesOriginalShape() => + Test.CustomException( + ImmutableArray.CreateRange(new string?[] { null }), + (values, factory) => values.MustNotContainNull(factory) + ); + + [Fact] + public static void DefaultImmutableArrayDoesNotInvokeCustomFactory() + { + var values = default(ImmutableArray); + + var result = values.MustNotContainNull(_ => new InvalidOperationException()); + + result.IsDefault.Should().BeTrue(); + } +} diff --git a/tests/Light.GuardClauses.Tests/CollectionAssertions/TrackingEnumerable.cs b/tests/Light.GuardClauses.Tests/CollectionAssertions/TrackingEnumerable.cs new file mode 100644 index 00000000..f9f6a711 --- /dev/null +++ b/tests/Light.GuardClauses.Tests/CollectionAssertions/TrackingEnumerable.cs @@ -0,0 +1,141 @@ +#nullable enable + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Light.GuardClauses.Tests.CollectionAssertions; + +internal sealed class TrackingEnumerable : IEnumerable +{ + private readonly IReadOnlyList _items; + private readonly bool _singleUse; + + public TrackingEnumerable(IReadOnlyList items, bool singleUse = true) + { + _items = items; + _singleUse = singleUse; + } + + public int EnumeratorCount { get; private set; } + + public int MoveNextCount { get; private set; } + + public int DisposeCount { get; private set; } + + public IEnumerator GetEnumerator() + { + ++EnumeratorCount; + if (_singleUse && EnumeratorCount > 1) + { + throw new InvalidOperationException("This enumerable can only be observed once."); + } + + return new TrackingEnumerator(this); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private sealed class TrackingEnumerator : IEnumerator + { + private readonly TrackingEnumerable _owner; + private int _position = -1; + + public TrackingEnumerator(TrackingEnumerable owner) => _owner = owner; + + public T Current => _owner._items[_position]; + + object IEnumerator.Current => Current!; + + public bool MoveNext() + { + ++_owner.MoveNextCount; + ++_position; + return _position < _owner._items.Count; + } + + public void Reset() => throw new NotSupportedException(); + + public void Dispose() => ++_owner.DisposeCount; + } +} + +internal sealed class IndexableOnlyList : IList, IList +{ + private readonly IReadOnlyList _items; + + public IndexableOnlyList(IReadOnlyList items) => _items = items; + + object? IList.this[int index] + { + get => _items[index]; + set => throw new NotSupportedException(); + } + + bool IList.IsFixedSize => true; + + bool IList.IsReadOnly => true; + + bool ICollection.IsSynchronized => false; + + object ICollection.SyncRoot => this; + + int IList.Add(object? value) => throw new NotSupportedException(); + + bool IList.Contains(object? value) => throw new NotSupportedException(); + + int IList.IndexOf(object? value) => throw new NotSupportedException(); + + void IList.Insert(int index, object? value) => throw new NotSupportedException(); + + void IList.Remove(object? value) => throw new NotSupportedException(); + + void ICollection.CopyTo(Array array, int index) => throw new NotSupportedException(); + + public T this[int index] + { + get => _items[index]; + set => throw new NotSupportedException(); + } + + public int Count => _items.Count; + + public bool IsReadOnly => true; + + public IEnumerator GetEnumerator() => + throw new InvalidOperationException("The indexable fast path should not request an enumerator."); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public int IndexOf(T item) => throw new NotSupportedException(); + + public void Insert(int index, T item) => throw new NotSupportedException(); + + public void RemoveAt(int index) => throw new NotSupportedException(); + + public void Add(T item) => throw new NotSupportedException(); + + public void Clear() => throw new NotSupportedException(); + + public bool Contains(T item) => throw new NotSupportedException(); + + public void CopyTo(T[] array, int arrayIndex) => throw new NotSupportedException(); + + public bool Remove(T item) => throw new NotSupportedException(); +} + +internal sealed class ReadOnlyIndexableOnlyList : IReadOnlyList +{ + private readonly IReadOnlyList _items; + + public ReadOnlyIndexableOnlyList(IReadOnlyList items) => _items = items; + + public T this[int index] => _items[index]; + + public int Count => _items.Count; + + public IEnumerator GetEnumerator() => + throw new InvalidOperationException("The indexable fast path should not request an enumerator."); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs index d0221e52..eebdb94d 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs @@ -233,6 +233,10 @@ public sealed class AssertionWhitelist public AssertionEntry MustNotContainKey { get; init; } = new(); + public AssertionEntry MustNotContainNull { get; init; } = new(); + + public AssertionEntry MustNotContainNullOrWhiteSpace { get; init; } = new(); + public AssertionEntry MustNotEndWith { get; init; } = new(); public AssertionEntry MustNotStartWith { get; init; } = new(); diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json index af58efdd..41b18a6b 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json @@ -130,6 +130,8 @@ "MustNotBeZero": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustNotContain": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustNotContainKey": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotContainNull": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustNotContainNullOrWhiteSpace": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustNotEndWith": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustNotStartWith": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustStartWith": { "Include": true, "IncludeExceptionFactoryOverload": true }