From ab8d7072fbac1761232ed9201a0a3a9bc656fdee Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 14 Jul 2026 23:37:13 +0200 Subject: [PATCH 1/3] docs: add AI plan 158 for Collection Count Equality Signed-off-by: Kenny Pflug --- ai-plans/0158-collection-count-equality.md | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 ai-plans/0158-collection-count-equality.md diff --git a/ai-plans/0158-collection-count-equality.md b/ai-plans/0158-collection-count-equality.md new file mode 100644 index 0000000..5f15712 --- /dev/null +++ b/ai-plans/0158-collection-count-equality.md @@ -0,0 +1,49 @@ +# Collection Count Equality + +## Rationale + +Parallel collections such as key/value arrays and correlated batch inputs must often contain the same number of items. Callers currently have to obtain and compare both counts manually, losing Light.GuardClauses' standard null handling, exception contract, caller-expression capture, and fluent return value. Add `MustHaveSameCountAs` as the cross-collection counterpart to `MustHaveCount`. + +The guard must support collections with different element and concrete types, preserve the receiver's concrete type, reuse the existing optimized count infrastructure, and remain safe for lazy or single-use enumerables. + +## Acceptance Criteria + +- [ ] `MustHaveSameCountAs` is available with identical semantics on .NET Standard 2.0, .NET Standard 2.1, and .NET 10 for any two reference collections implementing `IEnumerable`, including collections with different element types. +- [ ] The guard accepts equal counts, including two empty collections, and returns the original receiver in its concrete type without comparing collection contents. +- [ ] A null receiver or comparison collection throws `ArgumentNullException` by default; unequal counts throw `InvalidCollectionCountException` whose parameter name identifies the receiver and whose default message reports both observed counts. +- [ ] The default overload captures the receiver expression, accepts an optional custom message, and the custom-exception-factory overload receives both original nullable collections and is used for null inputs as well as unequal counts. +- [ ] Count-property fast paths supported by the existing helpers do not enumerate their inputs; each arbitrary enumerable is enumerated at most once per invocation, its enumerator is disposed, and failure-message construction does not enumerate either input again. +- [ ] Automated tests cover equal and unequal counts; empty inputs; different collection and element types; null values in both argument positions; default exception details; custom messages and exception factories; caller argument expressions; fluent concrete-type preservation; count-property fast paths; same-instance inputs; and distinct lazy/single-use enumerables. +- [ ] The source-export whitelist catalog and committed settings contain `MustHaveSameCountAs`, and focused source-export tests cover its count-helper, throw-helper, exception, and custom-factory reachability and trimming. +- [ ] The committed .NET Standard 2.0 single-file distribution is regenerated with the new guard and validates for both supported source-export targets. +- [ ] XML documentation and the assertion overview describe the cross-collection semantics, supported receiver shapes, null and failure behavior, fluent return value, and enumeration characteristics. +- [ ] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK. + +## Technical Details + +Add the assertion as a new `Check.MustHaveSameCountAs.cs` family. Use separate generic parameters for the receiver and comparison collection so a call such as `keys.MustHaveSameCountAs(values)` works when the arrays have different element types while retaining the exact receiver type. The intended public shape is: + +```csharp +public static TCollection MustHaveSameCountAs( + [NotNull, ValidatedNotNull] this TCollection? parameter, + [NotNull, ValidatedNotNull] TOtherCollection? otherCollection, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null +) where TCollection : class, IEnumerable + where TOtherCollection : class, IEnumerable; + +public static TCollection MustHaveSameCountAs( + [NotNull, ValidatedNotNull] this TCollection? parameter, + [NotNull, ValidatedNotNull] TOtherCollection? otherCollection, + Func exceptionFactory +) where TCollection : class, IEnumerable + where TOtherCollection : class, IEnumerable; +``` + +Apply the established aggressive-inlining, nullable-flow, and JetBrains contract annotations. Validate both references before observing either collection; the receiver retains the captured caller expression, while a null comparison collection follows the repository convention of naming the secondary argument itself. The custom factory is invoked once and receives the original values when either reference is null or the counts differ. Comparing a collection with itself is a tautology and should return immediately after null validation, without enumerating it. + +Obtain each count through `FrameworkExtensions.EnumerableExtensions.Count`, not LINQ. This retains the existing O(1), allocation-free paths for non-generic `ICollection` receivers and strings and falls back to enumeration for other `IEnumerable` implementations. Store both counts and pass them to a focused non-returning throw helper that creates `InvalidCollectionCountException`; do not call the existing collection-rendering/counting failure path if it would recount an input. The generated message should state that the receiver must have the same count as the comparison collection and include both precomputed counts without formatting either collection. + +Keep this family scoped to reference `IEnumerable` collections, matching `MustHaveCount`. Span, memory, and `ImmutableArray` values use the library's length terminology and are not part of this request; no item-type equality constraint or content comparison should be introduced. + +Add the assertion to `AssertionWhitelist` and `settings.json`, verify dependency reachability and removal of its factory overload when configured, and regenerate `Light.GuardClauses.SingleFile.cs` as the portable artifact. Add the guard to the collections table in `docs/assertion-overview.md`; detailed complexity and single-pass behavior belong in the public XML remarks. From b23b5b8de750dff9deb2fd01cbff8df2269124d6 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 15 Jul 2026 00:00:00 +0200 Subject: [PATCH 2/3] feat: MustHaveSameCountAs Signed-off-by: Kenny Pflug --- Light.GuardClauses.SingleFile.cs | 98 +++++++++- ai-plans/0158-collection-count-equality.md | 20 +- docs/assertion-overview.md | 3 + .../Check.MustHaveSameCountAs.cs | 106 ++++++++++ .../Throw.CollectionCountsNotEqual.cs | 26 +++ .../SourceFileMergerWhitelistTests.cs | 49 +++++ .../MustHaveSameCountAsTests.cs | 183 ++++++++++++++++++ .../AssertionWhitelist.cs | 2 + .../settings.json | 1 + 9 files changed, 472 insertions(+), 16 deletions(-) create mode 100644 src/Light.GuardClauses/Check.MustHaveSameCountAs.cs create mode 100644 src/Light.GuardClauses/ExceptionFactory/Throw.CollectionCountsNotEqual.cs create mode 100644 tests/Light.GuardClauses.Tests/CollectionAssertions/MustHaveSameCountAsTests.cs diff --git a/Light.GuardClauses.SingleFile.cs b/Light.GuardClauses.SingleFile.cs index 9bded8e..d9f0761 100644 --- a/Light.GuardClauses.SingleFile.cs +++ b/Light.GuardClauses.SingleFile.cs @@ -4440,7 +4440,7 @@ public static TStream MustBeReadable([NotNull, ValidatedNotNull] this T Throw.ArgumentNull(parameterName, message); } - if (parameter.CanRead == false) + if (!parameter.CanRead) { Throw.MustBeReadable(parameterName, message); } @@ -4460,7 +4460,7 @@ public static TStream MustBeReadable([NotNull, ValidatedNotNull] this T public static TStream MustBeReadable([NotNull, ValidatedNotNull] this TStream? parameter, Func exceptionFactory) where TStream : Stream { - if (parameter is null || parameter.CanRead == false) + if (parameter is null || !parameter.CanRead) { Throw.CustomException(exceptionFactory, parameter); } @@ -4525,7 +4525,7 @@ public static TStream MustBeSeekable([NotNull, ValidatedNotNull] this T Throw.ArgumentNull(parameterName, message); } - if (parameter.CanSeek == false) + if (!parameter.CanSeek) { Throw.MustBeSeekable(parameterName, message); } @@ -4545,7 +4545,7 @@ public static TStream MustBeSeekable([NotNull, ValidatedNotNull] this T public static TStream MustBeSeekable([NotNull, ValidatedNotNull] this TStream? parameter, Func exceptionFactory) where TStream : Stream { - if (parameter is null || parameter.CanSeek == false) + if (parameter is null || !parameter.CanSeek) { Throw.CustomException(exceptionFactory, parameter); } @@ -5373,7 +5373,7 @@ public static TStream MustBeWritable([NotNull, ValidatedNotNull] this T Throw.ArgumentNull(parameterName, message); } - if (parameter.CanWrite == false) + if (!parameter.CanWrite) { Throw.MustBeWritable(parameterName, message); } @@ -5393,7 +5393,7 @@ public static TStream MustBeWritable([NotNull, ValidatedNotNull] this T public static TStream MustBeWritable([NotNull, ValidatedNotNull] this TStream? parameter, Func exceptionFactory) where TStream : Stream { - if (parameter is null || parameter.CanWrite == false) + if (parameter is null || !parameter.CanWrite) { Throw.CustomException(exceptionFactory, parameter); } @@ -6765,6 +6765,85 @@ public static Uri MustHaveOneSchemeOf([NotNull][ValidatedNotNull] t return parameter; } + /// + /// Ensures that the collection has the same number of items as , or otherwise + /// throws an . + /// + /// The collection to be checked. + /// The collection whose count is compared with . + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// The original collection. + /// Thrown when the collections have different counts. + /// Thrown when or is null. + /// + /// The collections may have different concrete and element types, and their contents are not compared. Count-property + /// fast paths do not enumerate. Other enumerable implementations are enumerated at most once each, and their + /// enumerators are disposed. Comparing a collection with itself does not enumerate it. The operation uses O(n) time + /// for enumerated inputs and constant additional space. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; otherCollection:null => halt")] + public static TCollection MustHaveSameCountAs([NotNull][ValidatedNotNull] this TCollection? parameter, [NotNull][ValidatedNotNull] TOtherCollection? otherCollection, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) + where TCollection : class, IEnumerable where TOtherCollection : class, IEnumerable + { + var validatedParameter = parameter.MustNotBeNull(parameterName, message); + var validatedOtherCollection = otherCollection.MustNotBeNull(message: message); + if (ReferenceEquals(validatedParameter, validatedOtherCollection)) + { + return validatedParameter; + } + + var count = validatedParameter.Count(); + var otherCount = validatedOtherCollection.Count(); + if (count != otherCount) + { + Throw.CollectionCountsNotEqual(count, otherCount, parameterName, message); + } + + return validatedParameter; + } + + /// + /// Ensures that the collection has the same number of items as , or otherwise + /// throws your custom exception. + /// + /// The collection to be checked. + /// The collection whose count is compared with . + /// The delegate that creates your custom exception. Both original collections are passed to this delegate. + /// The original collection. + /// Your custom exception thrown when either collection is null or the collections have different counts. + /// + /// The collections may have different concrete and element types, and their contents are not compared. Count-property + /// fast paths do not enumerate. Other enumerable implementations are enumerated at most once each, and their + /// enumerators are disposed. Comparing a collection with itself does not enumerate it. The operation uses O(n) time + /// for enumerated inputs and constant additional space. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; otherCollection:null => halt; exceptionFactory:null => halt")] + public static TCollection MustHaveSameCountAs([NotNull][ValidatedNotNull] this TCollection? parameter, [NotNull][ValidatedNotNull] TOtherCollection? otherCollection, Func exceptionFactory) + where TCollection : class, IEnumerable where TOtherCollection : class, IEnumerable + { + if (parameter is null || otherCollection is null) + { + Throw.CustomException(exceptionFactory, parameter, otherCollection); + } + + if (ReferenceEquals(parameter, otherCollection)) + { + return parameter; + } + + var count = parameter.Count(); + var otherCount = otherCollection.Count(); + if (count != otherCount) + { + Throw.CustomException(exceptionFactory, parameter, otherCollection); + } + + return parameter; + } + /// /// Ensures that the has the specified scheme, or otherwise throws an . /// @@ -11314,6 +11393,13 @@ internal static class Throw [DoesNotReturn] public static void CollectionCountNotInRange(IEnumerable parameter, int actualCount, Range range, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) => throw new InvalidCollectionCountException(parameterName, message ?? $"{parameterName ?? "The collection"} must have its count between {range.CreateRangeDescriptionText("and")}, but it actually has count {actualCount}."); /// + /// Throws the default indicating that two collections have different + /// counts, using the already observed counts and optional parameter name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void CollectionCountsNotEqual(int count, int otherCount, string? parameterName = null, string? message = null) => throw new InvalidCollectionCountException(parameterName, message ?? $"{parameterName ?? "The collection"} must have the same count as the comparison collection, but its count is {count} and the comparison collection's count is {otherCount}."); + /// /// Throws the exception that is returned by . /// [ContractAnnotation("=> halt")] diff --git a/ai-plans/0158-collection-count-equality.md b/ai-plans/0158-collection-count-equality.md index 5f15712..3a6d648 100644 --- a/ai-plans/0158-collection-count-equality.md +++ b/ai-plans/0158-collection-count-equality.md @@ -8,16 +8,16 @@ The guard must support collections with different element and concrete types, pr ## Acceptance Criteria -- [ ] `MustHaveSameCountAs` is available with identical semantics on .NET Standard 2.0, .NET Standard 2.1, and .NET 10 for any two reference collections implementing `IEnumerable`, including collections with different element types. -- [ ] The guard accepts equal counts, including two empty collections, and returns the original receiver in its concrete type without comparing collection contents. -- [ ] A null receiver or comparison collection throws `ArgumentNullException` by default; unequal counts throw `InvalidCollectionCountException` whose parameter name identifies the receiver and whose default message reports both observed counts. -- [ ] The default overload captures the receiver expression, accepts an optional custom message, and the custom-exception-factory overload receives both original nullable collections and is used for null inputs as well as unequal counts. -- [ ] Count-property fast paths supported by the existing helpers do not enumerate their inputs; each arbitrary enumerable is enumerated at most once per invocation, its enumerator is disposed, and failure-message construction does not enumerate either input again. -- [ ] Automated tests cover equal and unequal counts; empty inputs; different collection and element types; null values in both argument positions; default exception details; custom messages and exception factories; caller argument expressions; fluent concrete-type preservation; count-property fast paths; same-instance inputs; and distinct lazy/single-use enumerables. -- [ ] The source-export whitelist catalog and committed settings contain `MustHaveSameCountAs`, and focused source-export tests cover its count-helper, throw-helper, exception, and custom-factory reachability and trimming. -- [ ] The committed .NET Standard 2.0 single-file distribution is regenerated with the new guard and validates for both supported source-export targets. -- [ ] XML documentation and the assertion overview describe the cross-collection semantics, supported receiver shapes, null and failure behavior, fluent return value, and enumeration characteristics. -- [ ] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK. +- [x] `MustHaveSameCountAs` is available with identical semantics on .NET Standard 2.0, .NET Standard 2.1, and .NET 10 for any two reference collections implementing `IEnumerable`, including collections with different element types. +- [x] The guard accepts equal counts, including two empty collections, and returns the original receiver in its concrete type without comparing collection contents. +- [x] A null receiver or comparison collection throws `ArgumentNullException` by default; unequal counts throw `InvalidCollectionCountException` whose parameter name identifies the receiver and whose default message reports both observed counts. +- [x] The default overload captures the receiver expression, accepts an optional custom message, and the custom-exception-factory overload receives both original nullable collections and is used for null inputs as well as unequal counts. +- [x] Count-property fast paths supported by the existing helpers do not enumerate their inputs; each arbitrary enumerable is enumerated at most once per invocation, its enumerator is disposed, and failure-message construction does not enumerate either input again. +- [x] Automated tests cover equal and unequal counts; empty inputs; different collection and element types; null values in both argument positions; default exception details; custom messages and exception factories; caller argument expressions; fluent concrete-type preservation; count-property fast paths; same-instance inputs; and distinct lazy/single-use enumerables. +- [x] The source-export whitelist catalog and committed settings contain `MustHaveSameCountAs`, and focused source-export tests cover its count-helper, throw-helper, exception, and custom-factory reachability and trimming. +- [x] The committed .NET Standard 2.0 single-file distribution is regenerated with the new guard and validates for both supported source-export targets. +- [x] XML documentation and the assertion overview describe the cross-collection semantics, supported receiver shapes, null and failure behavior, fluent return value, and enumeration characteristics. +- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK. ## Technical Details diff --git a/docs/assertion-overview.md b/docs/assertion-overview.md index 8d18058..1e6d0e2 100644 --- a/docs/assertion-overview.md +++ b/docs/assertion-overview.md @@ -88,6 +88,7 @@ percentage.MustBeIn(Range.FromExclusive(0).ToInclusive(100)); | `MustNotBeNullOrEmpty` | Requires a non-null, non-empty collection or string | | `MustHaveCount` | Requires an exact collection count | | `MustHaveCountIn` | Requires a collection count within an inclusive/exclusive `Range` | +| `MustHaveSameCountAs` | Requires two reference collections implementing `IEnumerable` to have equal counts, even when their concrete and element types differ | | `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 | @@ -99,6 +100,8 @@ percentage.MustBeIn(Range.FromExclusive(0).ToInclusive(100)); 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. +`MustHaveSameCountAs` validates both collection references before observing either count, returns the original receiver, and compares counts without comparing contents. Null inputs throw `ArgumentNullException`, while differing counts throw `InvalidCollectionCountException`. Existing count-property fast paths avoid enumeration; otherwise, each input is enumerated at most once and its enumerator is disposed. Comparing a collection with itself returns immediately without enumeration. + 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). diff --git a/src/Light.GuardClauses/Check.MustHaveSameCountAs.cs b/src/Light.GuardClauses/Check.MustHaveSameCountAs.cs new file mode 100644 index 0000000..6287686 --- /dev/null +++ b/src/Light.GuardClauses/Check.MustHaveSameCountAs.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; +using Light.GuardClauses.ExceptionFactory; +using Light.GuardClauses.Exceptions; +using Light.GuardClauses.FrameworkExtensions; +using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +namespace Light.GuardClauses; + +public static partial class Check +{ + /// + /// Ensures that the collection has the same number of items as , or otherwise + /// throws an . + /// + /// The collection to be checked. + /// The collection whose count is compared with . + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// The original collection. + /// Thrown when the collections have different counts. + /// Thrown when or is null. + /// + /// The collections may have different concrete and element types, and their contents are not compared. Count-property + /// fast paths do not enumerate. Other enumerable implementations are enumerated at most once each, and their + /// enumerators are disposed. Comparing a collection with itself does not enumerate it. The operation uses O(n) time + /// for enumerated inputs and constant additional space. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; otherCollection:null => halt")] + public static TCollection MustHaveSameCountAs( + [NotNull] [ValidatedNotNull] this TCollection? parameter, + [NotNull] [ValidatedNotNull] TOtherCollection? otherCollection, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null + ) + where TCollection : class, IEnumerable + where TOtherCollection : class, IEnumerable + { + var validatedParameter = parameter.MustNotBeNull(parameterName, message); + var validatedOtherCollection = otherCollection.MustNotBeNull(message: message); + + if (ReferenceEquals(validatedParameter, validatedOtherCollection)) + { + return validatedParameter; + } + + var count = validatedParameter.Count(); + var otherCount = validatedOtherCollection.Count(); + if (count != otherCount) + { + Throw.CollectionCountsNotEqual(count, otherCount, parameterName, message); + } + + return validatedParameter; + } + + /// + /// Ensures that the collection has the same number of items as , or otherwise + /// throws your custom exception. + /// + /// The collection to be checked. + /// The collection whose count is compared with . + /// The delegate that creates your custom exception. Both original collections are passed to this delegate. + /// The original collection. + /// Your custom exception thrown when either collection is null or the collections have different counts. + /// + /// The collections may have different concrete and element types, and their contents are not compared. Count-property + /// fast paths do not enumerate. Other enumerable implementations are enumerated at most once each, and their + /// enumerators are disposed. Comparing a collection with itself does not enumerate it. The operation uses O(n) time + /// for enumerated inputs and constant additional space. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation( + "parameter:null => halt; parameter:notnull => notnull; otherCollection:null => halt; exceptionFactory:null => halt" + )] + public static TCollection MustHaveSameCountAs( + [NotNull] [ValidatedNotNull] this TCollection? parameter, + [NotNull] [ValidatedNotNull] TOtherCollection? otherCollection, + Func exceptionFactory + ) + where TCollection : class, IEnumerable + where TOtherCollection : class, IEnumerable + { + if (parameter is null || otherCollection is null) + { + Throw.CustomException(exceptionFactory, parameter, otherCollection); + } + + if (ReferenceEquals(parameter, otherCollection)) + { + return parameter; + } + + var count = parameter.Count(); + var otherCount = otherCollection.Count(); + if (count != otherCount) + { + Throw.CustomException(exceptionFactory, parameter, otherCollection); + } + + return parameter; + } +} diff --git a/src/Light.GuardClauses/ExceptionFactory/Throw.CollectionCountsNotEqual.cs b/src/Light.GuardClauses/ExceptionFactory/Throw.CollectionCountsNotEqual.cs new file mode 100644 index 0000000..74e37a7 --- /dev/null +++ b/src/Light.GuardClauses/ExceptionFactory/Throw.CollectionCountsNotEqual.cs @@ -0,0 +1,26 @@ +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 two collections have different + /// counts, using the already observed counts and optional parameter name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void CollectionCountsNotEqual( + int count, + int otherCount, + string? parameterName = null, + string? message = null + ) => + throw new InvalidCollectionCountException( + parameterName, + message ?? + $"{parameterName ?? "The collection"} must have the same count as the comparison collection, but its count is {count} and the comparison collection's count is {otherCount}." + ); +} diff --git a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs index 3fcd6ed..db0783d 100644 --- a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs +++ b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs @@ -170,6 +170,55 @@ public static void AdditionalAssertionsRetainPortableSupportClosures() sourceCode.Should().Contain("MustNotBeEmptyOrWhiteSpace("); } + [Fact] + public static void MustHaveSameCountAsWhitelistRetainsCountAndExceptionClosuresOnBothTargets() + { + using var temporaryDirectory = new TemporaryDirectory(); + var portableFile = Path.Combine(temporaryDirectory.DirectoryPath, "SameCountPortable.cs"); + var modernFile = Path.Combine(temporaryDirectory.DirectoryPath, "SameCountModern.cs"); + var whitelist = CreateWhitelist( + includedAssertions: [new ("MustHaveSameCountAs", true)] + ); + + SourceFileMerger.CreateSingleSourceFile(CreateOptions(portableFile, whitelist)); + SourceFileMerger.CreateSingleSourceFile( + CreateOptions(modernFile, whitelist, SourceTargetFramework.Net10_0) + ); + + foreach (var sourceCode in new[] { File.ReadAllText(portableFile), File.ReadAllText(modernFile) }) + { + sourceCode.Should().Contain("MustHaveSameCountAs("); + sourceCode.Should().Contain("public static int Count("); + sourceCode.Should().Contain("public static void CollectionCountsNotEqual("); + sourceCode.Should().Contain("class InvalidCollectionCountException : CollectionException"); + sourceCode.Should() + .Contain("Func exceptionFactory"); + sourceCode.Should().Contain("public static void CustomException("); + sourceCode.Should().NotContain("MustHaveCountIn("); + } + } + + [Fact] + public static void MustHaveSameCountAsWhitelistTrimsItsExceptionFactoryOverload() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "SameCountWithoutFactory.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist(includedAssertions: [new ("MustHaveSameCountAs", false)]) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("MustHaveSameCountAs("); + sourceCode.Should().Contain("public static int Count("); + sourceCode.Should().Contain("public static void CollectionCountsNotEqual("); + sourceCode.Should().Contain("class InvalidCollectionCountException : CollectionException"); + sourceCode.Should().NotContain("Func exceptionFactory"); + } + [Fact] public static void FiniteWhitelistUsesTargetSpecificSurface() { diff --git a/tests/Light.GuardClauses.Tests/CollectionAssertions/MustHaveSameCountAsTests.cs b/tests/Light.GuardClauses.Tests/CollectionAssertions/MustHaveSameCountAsTests.cs new file mode 100644 index 0000000..352ccdc --- /dev/null +++ b/tests/Light.GuardClauses.Tests/CollectionAssertions/MustHaveSameCountAsTests.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Light.GuardClauses.Exceptions; +using Xunit; + +namespace Light.GuardClauses.Tests.CollectionAssertions; + +public static class MustHaveSameCountAsTests +{ + [Fact] + public static void EqualCollectionsWithDifferentConcreteAndElementTypesReturnOriginalReceiver() + { + var keys = new List { 1, 2 }; + var values = new[] { "one", "two" }; + + var result = keys.MustHaveSameCountAs(values); + + result.Should().BeSameAs(keys); + } + + [Fact] + public static void EmptyCollectionsAreAccepted() + { + var collection = Array.Empty(); + + collection.MustHaveSameCountAs(new List()).Should().BeSameAs(collection); + } + + [Fact] + public static void CollectionContentsAreNotCompared() + { + var collection = new[] { 1, 2 }; + + collection.MustHaveSameCountAs(new[] { "different", "items" }).Should().BeSameAs(collection); + } + + [Fact] + public static void UnequalCountsThrowDefaultExceptionWithReceiverDetailsAndBothObservedCounts() + { + var keys = new[] { 1, 2, 3 }; + + Action act = () => keys.MustHaveSameCountAs(new[] { "one" }); + + act.Should().Throw() + .WithParameterName(nameof(keys)) + .WithMessage( + "keys must have the same count as the comparison collection, but its count is 3 and the comparison collection's count is 1.*" + ); + } + + [Fact] + public static void NullReceiverUsesCapturedReceiverExpression() + { + int[] values = null; + + Action act = () => values.MustHaveSameCountAs(Array.Empty()); + + act.Should().Throw().WithParameterName(nameof(values)); + } + + [Fact] + public static void NullComparisonCollectionUsesSecondaryArgumentNameWithoutObservingReceiver() + { + var values = new TrackingEnumerable([1]); + string[] other = null; + + Action act = () => values.MustHaveSameCountAs(other); + + act.Should().Throw().WithParameterName("otherCollection"); + values.EnumeratorCount.Should().Be(0); + } + + [Fact] + public static void CustomMessageIsUsedForUnequalCounts() + { + Action act = () => new[] { 1 }.MustHaveSameCountAs(Array.Empty(), message: "custom message"); + + act.Should().Throw().WithMessage("custom message*"); + } + + [Fact] + public static void CustomMessageIsUsedForNullComparisonCollection() + { + Action act = () => Array.Empty().MustHaveSameCountAs((string[]) null, message: "custom message"); + + act.Should().Throw().WithMessage("custom message*"); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public static void CustomFactoryReceivesOriginalValuesForEveryFailure(bool receiverIsNull, bool otherIsNull) + { + int[] receiver = receiverIsNull ? null : [1, 2]; + string[] other = otherIsNull ? null : ["one"]; + var invocationCount = 0; + var expectedException = new Exception(); + + Action act = () => receiver.MustHaveSameCountAs( + other, + (actualReceiver, actualOther) => + { + ++invocationCount; + actualReceiver.Should().BeSameAs(receiver); + actualOther.Should().BeSameAs(other); + return expectedException; + } + ); + + act.Should().Throw().Which.Should().BeSameAs(expectedException); + invocationCount.Should().Be(1); + } + + [Fact] + public static void CustomFactoryIsNotInvokedForEqualCounts() + { + var collection = new List { 1, 2 }; + + collection.MustHaveSameCountAs( + new[] { "one", "two" }, + (_, _) => throw new InvalidOperationException("The factory must not be invoked.") + ) + .Should().BeSameAs(collection); + } + + [Fact] + public static void NonGenericCollectionCountFastPathDoesNotEnumerateEitherInput() + { + var collection = new IndexableOnlyList([1, 2]); + var other = new IndexableOnlyList(["one", "two"]); + + collection.MustHaveSameCountAs(other).Should().BeSameAs(collection); + } + + [Fact] + public static void StringLengthFastPathSupportsACollectionWithAnotherElementType() + { + const string collection = "text"; + + collection.MustHaveSameCountAs(new[] { 1, 2, 3, 4 }).Should().BeSameAs(collection); + } + + [Fact] + public static void SameInstanceReturnsWithoutEnumeration() + { + var collection = new TrackingEnumerable([1, 2]); + + collection.MustHaveSameCountAs(collection).Should().BeSameAs(collection); + + collection.EnumeratorCount.Should().Be(0); + } + + [Fact] + public static void DistinctLazySingleUseEnumerablesAreEnumeratedOnceAndDisposedOnSuccess() + { + var collection = new TrackingEnumerable([1, 2]); + var other = new TrackingEnumerable(["one", "two"]); + + collection.MustHaveSameCountAs(other).Should().BeSameAs(collection); + + collection.EnumeratorCount.Should().Be(1); + collection.DisposeCount.Should().Be(1); + other.EnumeratorCount.Should().Be(1); + other.DisposeCount.Should().Be(1); + } + + [Fact] + public static void FailureMessageDoesNotReenumerateLazySingleUseEnumerables() + { + var collection = new TrackingEnumerable([1, 2]); + var other = new TrackingEnumerable(["one"]); + + Action act = () => collection.MustHaveSameCountAs(other); + + act.Should().Throw(); + collection.EnumeratorCount.Should().Be(1); + collection.DisposeCount.Should().Be(1); + other.EnumeratorCount.Should().Be(1); + other.DisposeCount.Should().Be(1); + } +} diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs index 2588aac..ce70ed6 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs @@ -197,6 +197,8 @@ public sealed class AssertionWhitelist public AssertionEntry MustHaveCountIn { get; init; } = new(); + public AssertionEntry MustHaveSameCountAs { get; init; } = new(); + public AssertionEntry MustHaveLength { get; init; } = new(); public AssertionEntry MustHaveLengthIn { get; init; } = new(); diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json index 1574048..81c8f17 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json @@ -112,6 +112,7 @@ "MustEndWith": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustHaveCount": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustHaveCountIn": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustHaveSameCountAs": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustHaveLength": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustHaveLengthIn": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustHaveMaximumCount": { "Include": true, "IncludeExceptionFactoryOverload": true }, From 9f69dbacc0af0236559cad89bce42aef026bc4e8 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 15 Jul 2026 00:16:37 +0200 Subject: [PATCH 3/3] chore: remove Light.GuardClauses reference from SourceCodeTransformation project This allows symbols to be resolved properly. Signed-off-by: Kenny Pflug --- .../ArrayTextSink.cs | 9 ++++++--- ...t.GuardClauses.SourceCodeTransformation.csproj | 1 - .../SourceFileMerger.cs | 15 +++++++++------ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/ArrayTextSink.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/ArrayTextSink.cs index 540679b..b2f0249 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/ArrayTextSink.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/ArrayTextSink.cs @@ -9,15 +9,18 @@ internal struct ArrayTextSink public ArrayTextSink(char[] internalArray) { - _internalArray = internalArray.MustNotBeNull(nameof(internalArray)); + ArgumentNullException.ThrowIfNull(internalArray); + _internalArray = internalArray; _currentIndex = 0; } public void Append(ReadOnlySpan text) { for (var i = 0; i < text.Length; ++i) + { _internalArray[_currentIndex++] = text[i]; + } } - public Memory ToMemory() => new Memory(_internalArray, 0, _currentIndex); -} \ No newline at end of file + public Memory ToMemory() => new (_internalArray, 0, _currentIndex); +} diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/Light.GuardClauses.SourceCodeTransformation.csproj b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/Light.GuardClauses.SourceCodeTransformation.csproj index 0b9aba6..167c2f9 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/Light.GuardClauses.SourceCodeTransformation.csproj +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/Light.GuardClauses.SourceCodeTransformation.csproj @@ -7,7 +7,6 @@ - diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs index 124885d..61a771e 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs @@ -3,7 +3,6 @@ using System.IO; using System.Linq; using System.Text; -using Light.GuardClauses.FrameworkExtensions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -15,7 +14,7 @@ public static class SourceFileMerger { public static void CreateSingleSourceFile(SourceFileMergeOptions options) { - options.MustNotBeNull(); + ArgumentNullException.ThrowIfNull(options); // Prepare the target syntax var stringBuilder = new StringBuilder(); @@ -32,9 +31,13 @@ public static void CreateSingleSourceFile(SourceFileMergeOptions options) } Console.WriteLine("Creating default file layout..."); - stringBuilder.AppendLineIf(!options.IncludeVersionComment, "/*") - .AppendLine( - $@"License information for Light.GuardClauses + if (!options.IncludeVersionComment) + { + stringBuilder.AppendLine("/*"); + } + + stringBuilder.AppendLine( + $@"License information for Light.GuardClauses The MIT License (MIT) Copyright (c) 2016, 2025 Kenny Pflug mailto:kenny.pflug@live.de @@ -497,7 +500,7 @@ public CallerArgumentExpressionAttribute(string parameterName) // Else just get the members of the first namespace and add them to the corresponding one var sourceCompilationUnit = (CompilationUnitSyntax) sourceSyntaxTree.GetRoot(); - if (sourceCompilationUnit.Members.IsNullOrEmpty()) + if (sourceCompilationUnit.Members.Count == 0) { continue; }