From b9776641e9bef724696f391b2e172772b41a61ec Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 16:39:22 -0400 Subject: [PATCH 01/21] feat(enumerable): add HasAny, OrEmpty, None --- .../EnumerableExtensionTest.cs | 40 +++++++++++++++++++ .../EnumerableExtensions.cs | 35 ++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs index 8a88f33..57424c8 100644 --- a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using CSharpHelperExtensions.Enumerable; using Shouldly; using Xunit; @@ -175,5 +176,44 @@ public void Reduce_Add4Numbers_WithInitialValue_Decimal_ReturnExpected() actual.ShouldBe(expected); actual.GetType().ShouldBe(expected.GetType()); } + + [Fact] + public void HasAny_ReturnsTrue_WhenSequenceHasElements() + { + new[] { 1, 2, 3 }.HasAny().ShouldBeTrue(); + new[] { (string)null }.HasAny().ShouldBeTrue(); + } + + [Fact] + public void HasAny_ReturnsFalse_WhenNullOrEmpty() + { + ((IEnumerable)null).HasAny().ShouldBeFalse(); + System.Linq.Enumerable.Empty().HasAny().ShouldBeFalse(); + } + + [Fact] + public void OrEmpty_ReturnsOriginal_WhenNotNull() + { + new[] { 1, 2 }.OrEmpty().ShouldBe(new[] { 1, 2 }); + } + + [Fact] + public void OrEmpty_ReturnsEmpty_WhenNull() + { + ((IEnumerable)null).OrEmpty().ShouldBeEmpty(); + } + + [Fact] + public void None_ReturnsTrue_WhenNullOrEmpty() + { + ((IEnumerable)null).None().ShouldBeTrue(); + System.Linq.Enumerable.Empty().None().ShouldBeTrue(); + } + + [Fact] + public void None_ReturnsFalse_WhenSequenceHasElements() + { + new[] { 1, 2 }.None().ShouldBeFalse(); + } } } diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index 27d24b4..8004870 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -372,4 +372,39 @@ public static TOut Reduce( return result; } + + /// + /// Returns if the sequence is non-null and contains at least one element. + /// + /// The element type. + /// The sequence to check. + /// + /// if is not and has at least one element; + /// otherwise . + /// + public static bool HasAny(this IEnumerable source) + => source != null && source.Any(); + + /// + /// Returns the sequence unchanged if non-null, or an empty sequence if the source is . + /// + /// The element type. + /// The sequence to evaluate. + /// + /// if it is not ; otherwise . + /// + public static IEnumerable OrEmpty(this IEnumerable source) + => source ?? System.Linq.Enumerable.Empty(); + + /// + /// Returns if the sequence is or contains no elements. + /// + /// The element type. + /// The sequence to check. + /// + /// if is or empty; + /// otherwise . + /// + public static bool None(this IEnumerable source) + => source is null || !source.Any(); } From f74d4c53ed7433b2850a4de54bf007a7d1edfa26 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 16:40:56 -0400 Subject: [PATCH 02/21] style: fix test indentation for HasAny, OrEmpty, None tests --- .../EnumerableExtensionTest.cs | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs index 57424c8..094ce17 100644 --- a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs @@ -175,45 +175,45 @@ public void Reduce_Add4Numbers_WithInitialValue_Decimal_ReturnExpected() 1.5m); actual.ShouldBe(expected); actual.GetType().ShouldBe(expected.GetType()); - } + } - [Fact] - public void HasAny_ReturnsTrue_WhenSequenceHasElements() - { - new[] { 1, 2, 3 }.HasAny().ShouldBeTrue(); - new[] { (string)null }.HasAny().ShouldBeTrue(); - } + [Fact] + public void HasAny_ReturnsTrue_WhenSequenceHasElements() + { + new[] { 1, 2, 3 }.HasAny().ShouldBeTrue(); + new[] { (string)null }.HasAny().ShouldBeTrue(); + } - [Fact] - public void HasAny_ReturnsFalse_WhenNullOrEmpty() - { - ((IEnumerable)null).HasAny().ShouldBeFalse(); - System.Linq.Enumerable.Empty().HasAny().ShouldBeFalse(); - } + [Fact] + public void HasAny_ReturnsFalse_WhenNullOrEmpty() + { + ((IEnumerable)null).HasAny().ShouldBeFalse(); + System.Linq.Enumerable.Empty().HasAny().ShouldBeFalse(); + } - [Fact] - public void OrEmpty_ReturnsOriginal_WhenNotNull() - { - new[] { 1, 2 }.OrEmpty().ShouldBe(new[] { 1, 2 }); - } + [Fact] + public void OrEmpty_ReturnsOriginal_WhenNotNull() + { + new[] { 1, 2 }.OrEmpty().ShouldBe(new[] { 1, 2 }); + } - [Fact] - public void OrEmpty_ReturnsEmpty_WhenNull() - { - ((IEnumerable)null).OrEmpty().ShouldBeEmpty(); - } + [Fact] + public void OrEmpty_ReturnsEmpty_WhenNull() + { + ((IEnumerable)null).OrEmpty().ShouldBeEmpty(); + } - [Fact] - public void None_ReturnsTrue_WhenNullOrEmpty() - { - ((IEnumerable)null).None().ShouldBeTrue(); - System.Linq.Enumerable.Empty().None().ShouldBeTrue(); - } + [Fact] + public void None_ReturnsTrue_WhenNullOrEmpty() + { + ((IEnumerable)null).None().ShouldBeTrue(); + System.Linq.Enumerable.Empty().None().ShouldBeTrue(); + } - [Fact] - public void None_ReturnsFalse_WhenSequenceHasElements() - { - new[] { 1, 2 }.None().ShouldBeFalse(); - } + [Fact] + public void None_ReturnsFalse_WhenSequenceHasElements() + { + new[] { 1, 2 }.None().ShouldBeFalse(); + } } } From 28d8219d814afbd9b75b812adfca56bdb0d57712 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 16:42:42 -0400 Subject: [PATCH 03/21] feat(enumerable): add WhereNotNull, AsReadOnlyList, ToHashSetSafe --- .../EnumerableExtensionTest.cs | 40 +++++++++++++++++++ .../EnumerableExtensions.cs | 32 +++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs index 094ce17..2ad95b7 100644 --- a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs @@ -215,5 +215,45 @@ public void None_ReturnsFalse_WhenSequenceHasElements() { new[] { 1, 2 }.None().ShouldBeFalse(); } + + [Fact] + public void WhereNotNull_FiltersNullsFromReferenceSequence() + { + var result = new[] { "a", null, "b", null, "c" }.WhereNotNull().ToList(); + result.ShouldBe(new[] { "a", "b", "c" }); + } + + [Fact] + public void WhereNotNull_OnNullSource_ReturnsEmpty() + { + ((IEnumerable)null).WhereNotNull().ShouldBeEmpty(); + } + + [Fact] + public void AsReadOnlyList_MaterializesSequenceInOrder() + { + IReadOnlyList result = new[] { 3, 1, 2 }.AsReadOnlyList(); + result.ShouldBe(new[] { 3, 1, 2 }); + } + + [Fact] + public void AsReadOnlyList_OnNullSource_ReturnsEmpty() + { + IReadOnlyList result = ((IEnumerable)null).AsReadOnlyList(); + result.ShouldBeEmpty(); + } + + [Fact] + public void ToHashSetSafe_DeduplicatesElements() + { + var result = new[] { 1, 2, 2, 3 }.ToHashSetSafe(); + result.ShouldBe(new HashSet { 1, 2, 3 }); + } + + [Fact] + public void ToHashSetSafe_OnNullSource_ReturnsEmpty() + { + ((IEnumerable)null).ToHashSetSafe().ShouldBeEmpty(); + } } } diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index 8004870..e0bea81 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -407,4 +407,36 @@ public static IEnumerable OrEmpty(this IEnumerable source) /// public static bool None(this IEnumerable source) => source is null || !source.Any(); + + /// + /// Filters out elements from a sequence of reference types. + /// Returns an empty sequence if the source is . + /// + /// The element type (must be a reference type). + /// The sequence to filter. + /// A sequence containing only non-null elements. +#nullable enable + public static IEnumerable WhereNotNull(this IEnumerable source) where T : class + => source is null ? System.Linq.Enumerable.Empty() : source.Where(x => x is not null).Cast(); +#nullable restore + + /// + /// Materializes the sequence into an , preserving order. + /// Returns an empty list if the source is . + /// + /// The element type. + /// The sequence to materialize. + /// An containing all elements in order. + public static IReadOnlyList AsReadOnlyList(this IEnumerable source) + => (source ?? System.Linq.Enumerable.Empty()).ToList(); + + /// + /// Converts the sequence to a , deduplicating elements. + /// Returns an empty set if the source is . + /// + /// The element type. + /// The sequence to convert. + /// A containing the distinct elements. + public static HashSet ToHashSetSafe(this IEnumerable source) + => source is null ? new HashSet() : source.ToHashSet(); } From 40ee94d2113cce2ef4858d2477b653d2276954cd Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 16:44:58 -0400 Subject: [PATCH 04/21] feat(enumerable): add Yield, JoinAsString, WithIndex --- .../EnumerableExtensionTest.cs | 45 +++++++++++++++++++ .../EnumerableExtensions.cs | 32 +++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs index 2ad95b7..5e6fd48 100644 --- a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs @@ -255,5 +255,50 @@ public void ToHashSetSafe_OnNullSource_ReturnsEmpty() { ((IEnumerable)null).ToHashSetSafe().ShouldBeEmpty(); } + + [Fact] + public void Yield_WrapsValueTypeAsSingleItemSequence() + { + 42.Yield().ToList().ShouldBe(new[] { 42 }); + } + + [Fact] + public void Yield_WrapsReferenceTypeAsSingleItemSequence() + { + "hello".Yield().Single().ShouldBe("hello"); + } + + [Fact] + public void JoinAsString_JoinsWithSeparator() + { + new[] { "a", "b", "c" }.JoinAsString(", ").ShouldBe("a, b, c"); + } + + [Fact] + public void JoinAsString_WorksForNonStringTypes() + { + new[] { 1, 2, 3 }.JoinAsString("-").ShouldBe("1-2-3"); + } + + [Fact] + public void JoinAsString_OnNullSource_ReturnsEmptyString() + { + ((IEnumerable)null).JoinAsString(",").ShouldBe(string.Empty); + } + + [Fact] + public void WithIndex_ProjectsZeroBasedIndexAndItem() + { + var result = new[] { "a", "b", "c" }.WithIndex().ToList(); + result[0].ShouldBe((0, "a")); + result[1].ShouldBe((1, "b")); + result[2].ShouldBe((2, "c")); + } + + [Fact] + public void WithIndex_OnNullSource_ReturnsEmpty() + { + ((IEnumerable)null).WithIndex().ShouldBeEmpty(); + } } } diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index e0bea81..d0e0bfc 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -439,4 +439,36 @@ public static IReadOnlyList AsReadOnlyList(this IEnumerable source) /// A containing the distinct elements. public static HashSet ToHashSetSafe(this IEnumerable source) => source is null ? new HashSet() : source.ToHashSet(); + + /// + /// Wraps a single value in an containing only that item. + /// + /// The type of the value. + /// The value to wrap. + /// A sequence containing exactly one element: . + public static IEnumerable Yield(this T item) + { + yield return item; + } + + /// + /// Concatenates the elements of a sequence into a single string using the specified separator. + /// Returns if the source is . + /// + /// The element type. + /// The sequence whose elements to join. + /// The string to use as a separator between elements. + /// A string of all elements joined by , or if source is . + public static string JoinAsString(this IEnumerable source, string separator) + => string.Join(separator, source ?? System.Linq.Enumerable.Empty()); + + /// + /// Projects each element of a sequence into a tuple of its zero-based index and the element itself. + /// Returns an empty sequence if the source is . + /// + /// The element type. + /// The sequence to index. + /// A sequence of (Index, Item) tuples. + public static IEnumerable<(int Index, T Item)> WithIndex(this IEnumerable source) + => (source ?? System.Linq.Enumerable.Empty()).Select((item, i) => (i, item)); } From 326d26fe8b7a4564a605cf9a31c2853cacc49dfb Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 16:46:33 -0400 Subject: [PATCH 05/21] feat(enumerable): add ToDictionarySafe --- .../EnumerableExtensionTest.cs | 25 +++++++++++ .../EnumerableExtensions.cs | 41 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs index 5e6fd48..f3c35bc 100644 --- a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs @@ -300,5 +300,30 @@ public void WithIndex_OnNullSource_ReturnsEmpty() { ((IEnumerable)null).WithIndex().ShouldBeEmpty(); } + + [Fact] + public void ToDictionarySafe_CreatesDictionaryFromSequence() + { + var result = new[] { ("a", 1), ("b", 2) } + .ToDictionarySafe(x => x.Item1, x => x.Item2); + result["a"].ShouldBe(1); + result["b"].ShouldBe(2); + } + + [Fact] + public void ToDictionarySafe_KeepsLastValue_OnDuplicateKey() + { + var result = new[] { ("a", 1), ("a", 99) } + .ToDictionarySafe(x => x.Item1, x => x.Item2); + result["a"].ShouldBe(99); + } + + [Fact] + public void ToDictionarySafe_OnNullSource_ReturnsEmptyDictionary() + { + var result = ((IEnumerable<(string, int)>)null) + .ToDictionarySafe(x => x.Item1, x => x.Item2); + result.ShouldBeEmpty(); + } } } diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index d0e0bfc..1524a7c 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -471,4 +471,45 @@ public static string JoinAsString(this IEnumerable source, string separato /// A sequence of (Index, Item) tuples. public static IEnumerable<(int Index, T Item)> WithIndex(this IEnumerable source) => (source ?? System.Linq.Enumerable.Empty()).Select((item, i) => (i, item)); + + /// + /// Converts a sequence to a using the specified key and value selectors. + /// Returns an empty dictionary if the source is . + /// When duplicate keys are encountered, the last value for that key is retained. + /// + /// The element type of the input sequence. + /// The type of the dictionary keys. + /// The type of the dictionary values. + /// The sequence to convert to a dictionary. + /// A function to extract the key from each element. + /// A function to extract the value from each element. + /// + /// A containing the projected key-value pairs, + /// or an empty dictionary if source is . + /// + /// + /// Unlike , + /// this method does not throw an on duplicate keys. + /// Instead, the last occurrence of a duplicate key overwrites previous values. + /// This is similar to dictionary initialization with repeated keys. + /// + /// + /// + /// var pairs = new[] { ("a", 1), ("b", 2), ("a", 99) }; + /// var dict = pairs.ToDictionarySafe(x => x.Item1, x => x.Item2); + /// // dict["a"] == 99 (last value wins) + /// // dict["b"] == 2 + /// + /// + public static Dictionary ToDictionarySafe( + this IEnumerable source, + Func keySelector, + Func valueSelector) + where TKey : notnull + { + var dict = new Dictionary(); + foreach (var item in source ?? System.Linq.Enumerable.Empty()) + dict[keySelector(item)] = valueSelector(item); + return dict; + } } From 5baff6b209dd0a364840513ba4b5f20e894b7b7e Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 17:06:35 -0400 Subject: [PATCH 06/21] feat(enumerable): add AddIf, AddRangeIf --- .../EnumerableExtensionTest.cs | 48 +++++++++++++++++++ .../EnumerableExtensions.cs | 45 +++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs index f3c35bc..7235f60 100644 --- a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs @@ -325,5 +325,53 @@ public void ToDictionarySafe_OnNullSource_ReturnsEmptyDictionary() .ToDictionarySafe(x => x.Item1, x => x.Item2); result.ShouldBeEmpty(); } + + [Fact] + public void AddIf_AddsItem_WhenConditionIsTrue() + { + var list = new List { 1, 2 }; + list.AddIf(true, 3); + list.ShouldBe(new[] { 1, 2, 3 }); + } + + [Fact] + public void AddIf_DoesNotAdd_WhenConditionIsFalse() + { + var list = new List { 1, 2 }; + list.AddIf(false, 3); + list.ShouldBe(new[] { 1, 2 }); + } + + [Fact] + public void AddIf_ReturnsSameListInstance() + { + var list = new List(); + var returned = list.AddIf(true, 1); + ReferenceEquals(list, returned).ShouldBeTrue(); + } + + [Fact] + public void AddRangeIf_AddsItems_WhenConditionIsTrue() + { + var list = new List { 1 }; + list.AddRangeIf(true, new[] { 2, 3 }); + list.ShouldBe(new[] { 1, 2, 3 }); + } + + [Fact] + public void AddRangeIf_DoesNotAdd_WhenConditionIsFalse() + { + var list = new List { 1 }; + list.AddRangeIf(false, new[] { 2, 3 }); + list.ShouldBe(new[] { 1 }); + } + + [Fact] + public void AddRangeIf_ReturnsSameListInstance() + { + var list = new List(); + var returned = list.AddRangeIf(true, new[] { 1, 2 }); + ReferenceEquals(list, returned).ShouldBeTrue(); + } } } diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index 1524a7c..014295f 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -512,4 +512,49 @@ public static Dictionary ToDictionarySafe( dict[keySelector(item)] = valueSelector(item); return dict; } + + /// + /// Conditionally adds an item to a list and returns the same list instance for chaining. + /// + /// The element type. + /// The list to modify. + /// If , the item is added; otherwise the list is unchanged. + /// The item to add if the condition is . + /// The same instance for method chaining. + /// + /// + /// var list = new List<int> { 1, 2 }; + /// list.AddIf(true, 3); // list is now [1, 2, 3] + /// list.AddIf(false, 4); // list is still [1, 2, 3] + /// + /// + public static IList AddIf(this IList list, bool condition, T item) + { + if (condition) list.Add(item); + return list; + } + + /// + /// Conditionally adds a range of items to a list and returns the same list instance for chaining. + /// + /// The element type. + /// The list to modify. + /// If , the items are added; otherwise the list is unchanged. + /// The items to add if the condition is . + /// If , treated as empty and no items are added. + /// The same instance for method chaining. + /// + /// + /// var list = new List<int> { 1 }; + /// list.AddRangeIf(true, new[] { 2, 3 }); // list is now [1, 2, 3] + /// list.AddRangeIf(false, new[] { 4, 5 }); // list is still [1, 2, 3] + /// + /// + public static IList AddRangeIf(this IList list, bool condition, IEnumerable items) + { + if (condition) + foreach (var item in items ?? System.Linq.Enumerable.Empty()) + list.Add(item); + return list; + } } From 9eef11ae1ab2e674dbb108266acd4e0081ad7e83 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 17:23:01 -0400 Subject: [PATCH 07/21] feat(enumerable): add ConcatIf --- .../EnumerableExtensionTest.cs | 24 +++++++++++++++ .../EnumerableExtensions.cs | 29 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs index 7235f60..6a27278 100644 --- a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs @@ -373,5 +373,29 @@ public void AddRangeIf_ReturnsSameListInstance() var returned = list.AddRangeIf(true, new[] { 1, 2 }); ReferenceEquals(list, returned).ShouldBeTrue(); } + + [Fact] + public void ConcatIf_ConcatenatesOther_WhenConditionIsTrue() + { + new[] { 1, 2 }.ConcatIf(true, new[] { 3, 4 }).ShouldBe(new[] { 1, 2, 3, 4 }); + } + + [Fact] + public void ConcatIf_ReturnsSource_WhenConditionIsFalse() + { + new[] { 1, 2 }.ConcatIf(false, new[] { 3, 4 }).ShouldBe(new[] { 1, 2 }); + } + + [Fact] + public void ConcatIf_OnNullSource_ReturnsOther_WhenConditionIsTrue() + { + ((IEnumerable)null).ConcatIf(true, new[] { 1, 2 }).ShouldBe(new[] { 1, 2 }); + } + + [Fact] + public void ConcatIf_OnNullSource_ReturnsEmpty_WhenConditionIsFalse() + { + ((IEnumerable)null).ConcatIf(false, new[] { 1, 2 }).ShouldBeEmpty(); + } } } diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index 014295f..d9c9519 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -557,4 +557,33 @@ public static IList AddRangeIf(this IList list, bool condition, IEnumer list.Add(item); return list; } + + /// + /// Concatenates another sequence to the source sequence if a condition is , + /// otherwise returns the source sequence unchanged. + /// If the source sequence is , an empty sequence is used. + /// If the other sequence is , an empty sequence is used. + /// + /// The element type. + /// The source sequence. If , treated as empty. + /// If , is concatenated; otherwise only is returned. + /// The sequence to concatenate if the condition is . If , treated as empty. + /// + /// If is , returns concatenated with . + /// Otherwise, returns . + /// + /// + /// + /// new[] { 1, 2 }.ConcatIf(true, new[] { 3, 4 }) // [1, 2, 3, 4] + /// new[] { 1, 2 }.ConcatIf(false, new[] { 3, 4 }) // [1, 2] + /// ((IEnumerable<int>)null).ConcatIf(true, new[] { 1, 2 }) // [1, 2] + /// ((IEnumerable<int>)null).ConcatIf(false, new[] { 1, 2 }) // empty + /// + /// + public static IEnumerable ConcatIf( + this IEnumerable source, bool condition, IEnumerable other) + { + var first = source ?? System.Linq.Enumerable.Empty(); + return condition ? first.Concat(other ?? System.Linq.Enumerable.Empty()) : first; + } } From e7a96e6430be145ae7f0cc10dc8959b7ba10d657 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 17:27:12 -0400 Subject: [PATCH 08/21] feat(enumerable): add None(predicate), IsSingle, IndexOf --- .../EnumerableExtensionTest.cs | 84 +++++++++++++++++++ .../EnumerableExtensions.cs | 67 +++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs index 6a27278..e731180 100644 --- a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs @@ -397,5 +397,89 @@ public void ConcatIf_OnNullSource_ReturnsEmpty_WhenConditionIsFalse() { ((IEnumerable)null).ConcatIf(false, new[] { 1, 2 }).ShouldBeEmpty(); } + + [Fact] + public void None_WithPredicate_ReturnsTrue_WhenNoElementMatches() + { + new[] { 1, 2, 3 }.None(x => x > 10).ShouldBeTrue(); + } + + [Fact] + public void None_WithPredicate_ReturnsFalse_WhenAnyElementMatches() + { + new[] { 1, 2, 3 }.None(x => x > 2).ShouldBeFalse(); + } + + [Fact] + public void None_WithPredicate_ReturnsTrue_WhenSourceIsNull() + { + ((IEnumerable)null).None(x => x > 0).ShouldBeTrue(); + } + + [Fact] + public void IsSingle_ReturnsTrue_WhenExactlyOneElement() + { + new[] { 42 }.IsSingle().ShouldBeTrue(); + } + + [Fact] + public void IsSingle_ReturnsFalse_WhenEmpty() + { + System.Linq.Enumerable.Empty().IsSingle().ShouldBeFalse(); + } + + [Fact] + public void IsSingle_ReturnsFalse_WhenMoreThanOneElement() + { + new[] { 1, 2 }.IsSingle().ShouldBeFalse(); + } + + [Fact] + public void IsSingle_ReturnsFalse_WhenNull() + { + ((IEnumerable)null).IsSingle().ShouldBeFalse(); + } + + [Fact] + public void IsSingle_WithPredicate_ReturnsTrue_WhenExactlyOneMatches() + { + new[] { 1, 2, 3 }.IsSingle(x => x > 2).ShouldBeTrue(); + } + + [Fact] + public void IsSingle_WithPredicate_ReturnsFalse_WhenZeroMatch() + { + new[] { 1, 2, 3 }.IsSingle(x => x > 10).ShouldBeFalse(); + } + + [Fact] + public void IsSingle_WithPredicate_ReturnsFalse_WhenMoreThanOneMatch() + { + new[] { 1, 2, 3 }.IsSingle(x => x > 1).ShouldBeFalse(); + } + + [Fact] + public void IndexOf_ReturnsFirstMatchingIndex() + { + new[] { "a", "b", "c" }.IndexOf(x => x == "b").ShouldBe(1); + } + + [Fact] + public void IndexOf_ReturnsZero_WhenFirstElementMatches() + { + new[] { "a", "b", "c" }.IndexOf(x => x == "a").ShouldBe(0); + } + + [Fact] + public void IndexOf_ReturnsMinusOne_WhenNoMatch() + { + new[] { "a", "b", "c" }.IndexOf(x => x == "z").ShouldBe(-1); + } + + [Fact] + public void IndexOf_ReturnsMinusOne_WhenSourceIsNull() + { + ((IEnumerable)null).IndexOf(x => x == "a").ShouldBe(-1); + } } } diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index d9c9519..931c0fd 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -408,6 +408,73 @@ public static IEnumerable OrEmpty(this IEnumerable source) public static bool None(this IEnumerable source) => source is null || !source.Any(); + /// + /// Returns if no element in the sequence satisfies the predicate, + /// or if the sequence is . + /// + /// The element type. + /// The sequence to check. + /// A function to test each element. + /// + /// if is or no element + /// matches ; otherwise . + /// + public static bool None(this IEnumerable source, Func predicate) + => source is null || !source.Any(predicate); + + /// + /// Returns if the sequence contains exactly one element. + /// Returns if the source is . + /// + /// The element type. + /// The sequence to check. + /// + /// if has exactly one element; + /// otherwise . + /// + public static bool IsSingle(this IEnumerable source) + { + if (source is null) return false; + using var e = source.GetEnumerator(); + return e.MoveNext() && !e.MoveNext(); + } + + /// + /// Returns if exactly one element in the sequence satisfies the predicate. + /// Returns if the source is . + /// + /// The element type. + /// The sequence to check. + /// A function to test each element. + /// + /// if exactly one element matches ; + /// otherwise . + /// + public static bool IsSingle(this IEnumerable source, Func predicate) + => source?.Count(predicate) == 1; + + /// + /// Returns the zero-based index of the first element in the sequence that satisfies the predicate, + /// or -1 if no element matches or the source is . + /// + /// The element type. + /// The sequence to search. + /// A function to test each element. + /// + /// The index of the first matching element, or -1 if none is found or source is . + /// + public static int IndexOf(this IEnumerable source, Func predicate) + { + if (source is null) return -1; + int index = 0; + foreach (var item in source) + { + if (predicate(item)) return index; + index++; + } + return -1; + } + /// /// Filters out elements from a sequence of reference types. /// Returns an empty sequence if the source is . From 65767031b9995c6c1531b9d970b83e792ede84f0 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 17:29:35 -0400 Subject: [PATCH 09/21] feat(enumerable): add Partition, Batch --- .../EnumerableExtensionTest.cs | 56 +++++++++++++++++++ .../EnumerableExtensions.cs | 37 ++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs index e731180..6efae68 100644 --- a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs @@ -481,5 +481,61 @@ public void IndexOf_ReturnsMinusOne_WhenSourceIsNull() { ((IEnumerable)null).IndexOf(x => x == "a").ShouldBe(-1); } + + [Fact] + public void Partition_SplitsSequenceIntoMatchedAndRest() + { + var (matched, rest) = new[] { 1, 2, 3, 4, 5 }.Partition(x => x % 2 == 0); + matched.ShouldBe(new[] { 2, 4 }); + rest.ShouldBe(new[] { 1, 3, 5 }); + } + + [Fact] + public void Partition_AllMatch_ReturnsEmptyRest() + { + var (matched, rest) = new[] { 2, 4, 6 }.Partition(x => x % 2 == 0); + matched.ShouldBe(new[] { 2, 4, 6 }); + rest.ShouldBeEmpty(); + } + + [Fact] + public void Partition_NoneMatch_ReturnsEmptyMatched() + { + var (matched, rest) = new[] { 1, 3, 5 }.Partition(x => x % 2 == 0); + matched.ShouldBeEmpty(); + rest.ShouldBe(new[] { 1, 3, 5 }); + } + + [Fact] + public void Partition_OnNullSource_ReturnsTwoEmptyLists() + { + var (matched, rest) = ((IEnumerable)null).Partition(x => x > 0); + matched.ShouldBeEmpty(); + rest.ShouldBeEmpty(); + } + + [Fact] + public void Batch_SplitsSequenceIntoChunksOfGivenSize() + { + var result = new[] { 1, 2, 3, 4, 5 }.Batch(2).ToList(); + result.Count.ShouldBe(3); + result[0].ShouldBe(new[] { 1, 2 }); + result[1].ShouldBe(new[] { 3, 4 }); + result[2].ShouldBe(new[] { 5 }); + } + + [Fact] + public void Batch_OnNullSource_ReturnsEmpty() + { + ((IEnumerable)null).Batch(3).ShouldBeEmpty(); + } + + [Fact] + public void Batch_WhenSizeLargerThanSequence_ReturnsSingleChunk() + { + var result = new[] { 1, 2 }.Batch(10).ToList(); + result.Count.ShouldBe(1); + result[0].ShouldBe(new[] { 1, 2 }); + } } } diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index 931c0fd..bc6e7fb 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -653,4 +653,41 @@ public static IEnumerable ConcatIf( var first = source ?? System.Linq.Enumerable.Empty(); return condition ? first.Concat(other ?? System.Linq.Enumerable.Empty()) : first; } + + /// + /// Splits a sequence into two lists based on a predicate: elements that match go into + /// Matched, all others go into Rest. + /// Returns two empty lists if the source is . + /// + /// The element type. + /// The sequence to partition. If , treated as empty. + /// A function that returns for elements to include in Matched. + /// + /// A tuple of two read-only lists: Matched containing elements that satisfy the predicate, + /// and Rest containing all other elements, both in original order. + /// + public static (IReadOnlyList Matched, IReadOnlyList Remaining) Partition( + this IEnumerable source, Func predicate) + { + var matched = new List(); + var rest = new List(); + foreach (var item in source ?? System.Linq.Enumerable.Empty()) + { + if (predicate(item)) matched.Add(item); + else rest.Add(item); + } + return (matched, rest); + } + + /// + /// Splits a sequence into chunks of at most elements each. + /// The last chunk may contain fewer elements if the sequence length is not evenly divisible. + /// Returns an empty sequence if the source is . + /// + /// The element type. + /// The sequence to batch. If , treated as empty. + /// The maximum number of elements per chunk. + /// A sequence of read-only lists, each containing at most elements. + public static IEnumerable> Batch(this IEnumerable source, int size) + => (source ?? System.Linq.Enumerable.Empty()).Chunk(size).Select(c => (IReadOnlyList)c); } From a75ead5c5dbcfb9d5e6d19d0cfe6e7d89ff5a8d0 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 17:54:52 -0400 Subject: [PATCH 10/21] feat(enumerable): add MinByOrDefault, MaxByOrDefault --- .../EnumerableExtensionTest.cs | 36 ++++++++++++++++ .../EnumerableExtensions.cs | 41 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs index 6efae68..febeda5 100644 --- a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs @@ -537,5 +537,41 @@ public void Batch_WhenSizeLargerThanSequence_ReturnsSingleChunk() result.Count.ShouldBe(1); result[0].ShouldBe(new[] { 1, 2 }); } + + [Fact] + public void MinByOrDefault_ReturnsElementWithSmallestKey() + { + new[] { 3, 1, 2 }.MinByOrDefault(x => x).ShouldBe(1); + } + + [Fact] + public void MinByOrDefault_ReturnsDefault_WhenSourceIsNull() + { + ((IEnumerable)null).MinByOrDefault(x => x).ShouldBe(0); + } + + [Fact] + public void MinByOrDefault_ReturnsNull_WhenSourceIsEmpty_ReferenceType() + { + System.Linq.Enumerable.Empty().MinByOrDefault(x => x).ShouldBeNull(); + } + + [Fact] + public void MaxByOrDefault_ReturnsElementWithLargestKey() + { + new[] { 3, 1, 2 }.MaxByOrDefault(x => x).ShouldBe(3); + } + + [Fact] + public void MaxByOrDefault_ReturnsDefault_WhenSourceIsNull() + { + ((IEnumerable)null).MaxByOrDefault(x => x).ShouldBe(0); + } + + [Fact] + public void MaxByOrDefault_ReturnsNull_WhenSourceIsEmpty_ReferenceType() + { + System.Linq.Enumerable.Empty().MaxByOrDefault(x => x).ShouldBeNull(); + } } } diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index bc6e7fb..fe22a6f 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using System.Threading.Tasks; +#nullable enable namespace CSharpHelperExtensions.Enumerable; /// @@ -690,4 +691,44 @@ public static (IReadOnlyList Matched, IReadOnlyList Remaining) PartitionA sequence of read-only lists, each containing at most elements. public static IEnumerable> Batch(this IEnumerable source, int size) => (source ?? System.Linq.Enumerable.Empty()).Chunk(size).Select(c => (IReadOnlyList)c); + + /// + /// Returns the element with the smallest key value, or () + /// if the source is . + /// For empty sequences, returns (). + /// + /// The element type. + /// The key type used for comparison. + /// The sequence to search. If , () is returned. + /// A function to extract the comparison key from each element. + /// The element with the smallest key, or () if source is or empty. + /// + /// + /// new[] { 3, 1, 2 }.MinByOrDefault(x => x) // 1 + /// ((IEnumerable<int>)null).MinByOrDefault(x => x) // 0 (default for int) + /// System.Linq.Enumerable.Empty<string>().MinByOrDefault(x => x) // null + /// + /// + public static T? MinByOrDefault(this IEnumerable source, Func keySelector) + => source is null ? default : source.MinBy(keySelector); + + /// + /// Returns the element with the largest key value, or () + /// if the source is . + /// For empty sequences, returns (). + /// + /// The element type. + /// The key type used for comparison. + /// The sequence to search. If , () is returned. + /// A function to extract the comparison key from each element. + /// The element with the largest key, or () if source is or empty. + /// + /// + /// new[] { 3, 1, 2 }.MaxByOrDefault(x => x) // 3 + /// ((IEnumerable<int>)null).MaxByOrDefault(x => x) // 0 (default for int) + /// System.Linq.Enumerable.Empty<string>().MaxByOrDefault(x => x) // null + /// + /// + public static T? MaxByOrDefault(this IEnumerable source, Func keySelector) + => source is null ? default : source.MaxBy(keySelector); } From 5630a3eb92753283d0ceda83a027fc602b8a06af Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 17:56:53 -0400 Subject: [PATCH 11/21] feat(enumerable): add SelectAsync, WhenAllList --- .../EnumerableExtensionTest.cs | 56 +++++++++++++++++++ .../EnumerableExtensions.cs | 50 +++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs index febeda5..71fae9a 100644 --- a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using CSharpHelperExtensions.Enumerable; using Shouldly; using Xunit; @@ -573,5 +575,59 @@ public void MaxByOrDefault_ReturnsNull_WhenSourceIsEmpty_ReferenceType() { System.Linq.Enumerable.Empty().MaxByOrDefault(x => x).ShouldBeNull(); } + + [Fact] + public async Task SelectAsync_ProjectsEachElementConcurrently() + { + var result = await new[] { 1, 2, 3 } + .SelectAsync(async x => { await Task.Yield(); return x * 2; }); + result.ShouldBe(new[] { 2, 4, 6 }); + } + + [Fact] + public async Task SelectAsync_OnNullSource_ReturnsEmpty() + { + var result = await ((IEnumerable)null) + .SelectAsync(async x => x * 2); + result.ShouldBeEmpty(); + } + + [Fact] + public async Task SelectAsync_WithMaxParallel_CapsConcurrency() + { + int concurrent = 0; + int maxSeen = 0; + + await System.Linq.Enumerable.Range(1, 10).ToList().SelectAsync(async x => + { + var c = Interlocked.Increment(ref concurrent); + Interlocked.Exchange(ref maxSeen, Math.Max(maxSeen, c)); + await Task.Delay(20); + Interlocked.Decrement(ref concurrent); + return x; + }, maxParallel: 3); + + maxSeen.ShouldBeLessThanOrEqualTo(3); + } + + [Fact] + public async Task WhenAllList_ReturnsAllTaskResults_AsReadOnlyList() + { + IEnumerable> tasks = new[] + { + Task.FromResult(1), + Task.FromResult(2), + Task.FromResult(3) + }; + IReadOnlyList result = await tasks.WhenAllList(); + result.ShouldBe(new[] { 1, 2, 3 }); + } + + [Fact] + public async Task WhenAllList_OnNullSource_ReturnsEmpty() + { + var result = await ((IEnumerable>)null).WhenAllList(); + result.ShouldBeEmpty(); + } } } diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index fe22a6f..ef9da6e 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; #nullable enable @@ -731,4 +732,53 @@ public static IEnumerable> Batch(this IEnumerable source, /// public static T? MaxByOrDefault(this IEnumerable source, Func keySelector) => source is null ? default : source.MaxBy(keySelector); + + /// + /// Projects each element of a sequence to a using an async selector, + /// running projections concurrently and collecting all results into a read-only list. + /// Returns an empty list if the source is . + /// + /// The element type of the input sequence. + /// The type of the projected result. + /// The sequence to project. If , returns an empty list. + /// An async function to apply to each element. + /// + /// Optional cap on the number of concurrent async operations. + /// When (the default), all projections are started concurrently. + /// + /// + /// A that completes with an containing + /// all projected results in source order. + /// + public static async Task> SelectAsync( + this IEnumerable source, + Func> selector, + int? maxParallel = null) + { + if (source is null) return Array.Empty(); + + if (maxParallel is null) + return await Task.WhenAll(source.Select(selector)); + + using var semaphore = new SemaphoreSlim(maxParallel.Value); + var tasks = source.Select(async item => + { + await semaphore.WaitAsync(); + try { return await selector(item); } + finally { semaphore.Release(); } + }); + return await Task.WhenAll(tasks); + } + + /// + /// Awaits all tasks in the sequence and returns the results as an . + /// Returns an empty list if the source is . + /// + /// The result type of each task. + /// The sequence of tasks to await. If , returns an empty list. + /// + /// A that completes with an containing all task results. + /// + public static async Task> WhenAllList(this IEnumerable> tasks) + => await Task.WhenAll(tasks ?? System.Linq.Enumerable.Empty>()); } From 6bb43d2e1188a831b39c2ecee469008cf0f4fe48 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 17:57:31 -0400 Subject: [PATCH 12/21] style: remove unnecessary using, simplify collection expressions in SelectAsync/WhenAllList --- src/CSharpHelperExtensions/EnumerableExtensions.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index ef9da6e..0ac9d94 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -755,7 +754,7 @@ public static async Task> SelectAsync( Func> selector, int? maxParallel = null) { - if (source is null) return Array.Empty(); + if (source is null) return []; if (maxParallel is null) return await Task.WhenAll(source.Select(selector)); @@ -780,5 +779,5 @@ public static async Task> SelectAsync( /// A that completes with an containing all task results. /// public static async Task> WhenAllList(this IEnumerable> tasks) - => await Task.WhenAll(tasks ?? System.Linq.Enumerable.Empty>()); + => await Task.WhenAll(tasks ?? []); } From 7cfd86be6fe8a3e44cb95f3666b626253f817aff Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 18:01:33 -0400 Subject: [PATCH 13/21] feat(enumerable): add enumerable-extension.ipynb sample notebook; linter cleanup --- sample/enumerable-extension.ipynb | 734 ++++++++++++++++++ .../EnumerableExtensions.cs | 2 +- 2 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 sample/enumerable-extension.ipynb diff --git a/sample/enumerable-extension.ipynb b/sample/enumerable-extension.ipynb new file mode 100644 index 0000000..311bb04 --- /dev/null +++ b/sample/enumerable-extension.ipynb @@ -0,0 +1,734 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Enumerable Extensions\n", + "\n", + "Runnable samples for every method in `CSharpHelperExtensions.Enumerable`. \n", + "Run the **Setup** cell first, then any section independently.\n", + "\n", + "| Section | Methods |\n", + "|---|---|\n", + "| [1. Collection Presence Shortcuts](#1-collection-presence-shortcuts) | `HasAny` · `OrEmpty` · `None` |\n", + "| [2. Materialization Helpers](#2-materialization-helpers) | `WhereNotNull` · `AsReadOnlyList` · `ToHashSetSafe` |\n", + "| [3. Sequence Transforms](#3-sequence-transforms) | `Yield` · `JoinAsString` · `WithIndex` |\n", + "| [4. Dictionary](#4-dictionary) | `ToDictionarySafe` |\n", + "| [5. Conditional Mutation](#5-conditional-mutation) | `AddIf` · `AddRangeIf` |\n", + "| [6. Conditional Concatenation](#6-conditional-concatenation) | `ConcatIf` |\n", + "| [7. Predicate Queries](#7-predicate-queries) | `None(predicate)` · `IsSingle` · `IsSingle(predicate)` · `IndexOf` |\n", + "| [8. Splitting and Chunking](#8-splitting-and-chunking) | `Partition` · `Batch` |\n", + "| [9. Min/Max Defaults](#9-minmax-defaults) | `MinByOrDefault` · `MaxByOrDefault` |\n", + "| [10. Async Projection](#10-async-projection) | `SelectAsync` · `WhenAllList` |\n", + "| [11. Existing Methods](#11-existing-methods) | `IsNullOrEmpty` · `CleanNullOrEmptyItems` · `ContainsOnly` · `AreEqual` · `ForEach` · `Reduce` |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "> **Run this cell first.** It loads the compiled library and imports the required namespaces.\n", + ">\n", + "> Build first if the DLL is missing: `dotnet build` from the repo root." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "#r \"../src/CSharpHelperExtensions/bin/Debug/net10.0/CSharpHelperExtensions.dll\"\n", + "using System.Collections.Generic;\n", + "using System.Linq;\n", + "using CSharpHelperExtensions; // IsNullOrEmpty (string), In, IsBetween, ToJson\n", + "using CSharpHelperExtensions.Enumerable; // all EnumerableExtensions" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 1. Collection Presence Shortcuts\n", + "\n", + "| Method | Signature | Returns |\n", + "|---|---|---|\n", + "| `HasAny` | `IEnumerable → bool` | `true` when non-null and has at least one element |\n", + "| `OrEmpty` | `IEnumerable → IEnumerable` | original sequence, or `Empty()` when null |\n", + "| `None` | `IEnumerable → bool` | `true` when null or empty |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// HasAny — true only when non-null and has at least one element\n", + "display(new[] { 1, 2, 3 }.HasAny()); // True\n", + "display(new int[0].HasAny()); // False (empty)\n", + "display(((IEnumerable)null).HasAny()); // False (null)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// OrEmpty — safe null-to-empty coalesce; non-null sequences pass through unchanged\n", + "display(((IEnumerable)null).OrEmpty().Count()); // 0 (null → empty)\n", + "display(new[] { 1, 2, 3 }.OrEmpty().Count()); // 3 (unchanged)\n", + "display(new int[0].OrEmpty().Count()); // 0 (empty stays empty)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// None — true when null or empty (opposite of HasAny)\n", + "display(((IEnumerable)null).None()); // True (null)\n", + "display(new string[0].None()); // True (empty)\n", + "display(new[] { \"a\", \"b\" }.None()); // False (has elements)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 2. Materialization Helpers\n", + "\n", + "| Method | Signature | Returns |\n", + "|---|---|---|\n", + "| `WhereNotNull` | `IEnumerable → IEnumerable` | filters out null reference elements |\n", + "| `AsReadOnlyList` | `IEnumerable → IReadOnlyList` | materializes to a read-only list |\n", + "| `ToHashSetSafe` | `IEnumerable → HashSet` | null-safe dedup to HashSet |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// WhereNotNull — strips null reference elements; returns empty sequence for null source\n", + "var withNulls = new string?[] { \"apple\", null, \"banana\", null, \"cherry\" };\n", + "display(withNulls.WhereNotNull().ToList()); // [\"apple\", \"banana\", \"cherry\"]\n", + "\n", + "display(((IEnumerable)null).WhereNotNull().ToList()); // [] (null source → empty)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// AsReadOnlyList — materializes a lazy sequence into an IReadOnlyList\n", + "IEnumerable lazy = Enumerable.Range(1, 5);\n", + "IReadOnlyList list = lazy.AsReadOnlyList();\n", + "display(list); // [1, 2, 3, 4, 5]\n", + "display(list.Count); // 5\n", + "\n", + "display(((IEnumerable)null).AsReadOnlyList().Count); // 0 (null → empty list)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// ToHashSetSafe — deduplicates; returns empty HashSet for null source (no exception)\n", + "var dupes = new[] { 1, 2, 2, 3, 3, 3 };\n", + "display(dupes.ToHashSetSafe()); // {1, 2, 3}\n", + "display(dupes.ToHashSetSafe().Count); // 3\n", + "\n", + "display(((IEnumerable)null).ToHashSetSafe().Count); // 0 (null → empty set)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 3. Sequence Transforms\n", + "\n", + "| Method | Signature | Returns |\n", + "|---|---|---|\n", + "| `Yield` | `T → IEnumerable` | wraps a single value into a one-element sequence |\n", + "| `JoinAsString` | `IEnumerable, string → string` | joins elements to a string with a separator |\n", + "| `WithIndex` | `IEnumerable → IEnumerable<(int Index, T Item)>` | pairs each element with its zero-based index |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// Yield — wraps a single value into an IEnumerable\n", + "display(42.Yield().ToList()); // [42]\n", + "display(\"hello\".Yield().ToList()); // [\"hello\"]\n", + "\n", + "// Useful for concatenating a single item onto an existing sequence\n", + "var existing = new[] { 1, 2, 3 };\n", + "display(existing.Concat(99.Yield()).ToList()); // [1, 2, 3, 99]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// JoinAsString — fluent string.Join with separator as argument\n", + "display(new[] { \"one\", \"two\", \"three\" }.JoinAsString(\", \")); // \"one, two, three\"\n", + "display(new[] { 1, 2, 3 }.JoinAsString(\" | \")); // \"1 | 2 | 3\"\n", + "display(new[] { \"a\", \"b\" }.JoinAsString(\"\")); // \"ab\" (no separator)\n", + "display(((IEnumerable)null).JoinAsString(\", \")); // \"\" (null-safe)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// WithIndex — projects (Index, Item) tuples; useful for numbered loops without a counter variable\n", + "var fruits = new[] { \"apple\", \"banana\", \"cherry\" };\n", + "foreach (var (index, item) in fruits.WithIndex())\n", + " display($\"{index}: {item}\"); // \"0: apple\", \"1: banana\", \"2: cherry\"\n", + "\n", + "display(((IEnumerable)null).WithIndex().ToList()); // [] (null-safe)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 4. Dictionary\n", + "\n", + "| Method | Signature | Notes |\n", + "|---|---|---|\n", + "| `ToDictionarySafe` | `IEnumerable, keySelector, valueSelector → Dictionary` | null-safe; last value wins on duplicate keys |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// ToDictionarySafe — null-safe conversion to Dictionary; duplicate keys don't throw\n", + "var pairs = new[] { (\"a\", 1), (\"b\", 2), (\"c\", 3) };\n", + "var dict = pairs.ToDictionarySafe(x => x.Item1, x => x.Item2);\n", + "display(dict); // {a: 1, b: 2, c: 3}\n", + "\n", + "// Last-value-wins on duplicate keys (unlike standard ToDictionary which throws)\n", + "var withDupes = new[] { (\"a\", 1), (\"b\", 2), (\"a\", 99) };\n", + "var dictDupes = withDupes.ToDictionarySafe(x => x.Item1, x => x.Item2);\n", + "display(dictDupes[\"a\"]); // 99 (last value wins)\n", + "display(dictDupes[\"b\"]); // 2\n", + "\n", + "// Null source returns empty dictionary (no NullReferenceException)\n", + "IEnumerable<(string, int)> nullSource = null;\n", + "display(nullSource.ToDictionarySafe(x => x.Item1, x => x.Item2).Count); // 0" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 5. Conditional Mutation\n", + "\n", + "Both methods operate on `IList` and return the **same list instance** for fluent chaining.\n", + "\n", + "| Method | Signature | Notes |\n", + "|---|---|---|\n", + "| `AddIf` | `IList, bool, T → IList` | adds single item when condition is true |\n", + "| `AddRangeIf` | `IList, bool, IEnumerable → IList` | adds multiple items when condition is true |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// AddIf — adds item only when condition is true; returns same list for chaining\n", + "var list = new List { 1, 2 };\n", + "list.AddIf(true, 3); // adds 3\n", + "list.AddIf(false, 4); // skipped\n", + "display(list); // [1, 2, 3]\n", + "\n", + "// Fluent chaining\n", + "bool includeBonus = true;\n", + "bool includeExtra = false;\n", + "var scores = new List { 10, 20 }\n", + " .AddIf(includeBonus, 5)\n", + " .AddIf(includeExtra, 100);\n", + "display(scores); // [10, 20, 5]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// AddRangeIf — adds a range of items only when condition is true; returns same list for chaining\n", + "var items = new List { \"base\" };\n", + "items.AddRangeIf(true, new[] { \"extra1\", \"extra2\" }); // adds both\n", + "items.AddRangeIf(false, new[] { \"skip1\", \"skip2\" }); // skipped\n", + "display(items); // [\"base\", \"extra1\", \"extra2\"]\n", + "\n", + "// null items parameter is treated as empty (no exception)\n", + "var safe = new List { 1 };\n", + "safe.AddRangeIf(true, (IEnumerable)null);\n", + "display(safe); // [1] (unchanged)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 6. Conditional Concatenation\n", + "\n", + "| Method | Signature | Notes |\n", + "|---|---|---|\n", + "| `ConcatIf` | `IEnumerable, bool, IEnumerable → IEnumerable` | concatenates second sequence only when condition is true |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// ConcatIf — returns source + other when condition is true, otherwise just source\n", + "display(new[] { 1, 2 }.ConcatIf(true, new[] { 3, 4 }).ToList()); // [1, 2, 3, 4]\n", + "display(new[] { 1, 2 }.ConcatIf(false, new[] { 3, 4 }).ToList()); // [1, 2] (not concatenated)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// Null-safe on both sides\n", + "display(((IEnumerable)null).ConcatIf(true, new[] { 1, 2 }).ToList()); // [1, 2] (null source → empty)\n", + "display(((IEnumerable)null).ConcatIf(false, new[] { 1, 2 }).ToList()); // [] (condition false)\n", + "display(new[] { 1, 2 }.ConcatIf(true, (IEnumerable)null).ToList()); // [1, 2] (null other → empty)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// Practical use: build a filter list conditionally\n", + "bool includeArchived = false;\n", + "bool includeDeleted = true;\n", + "var statuses = new[] { \"active\", \"pending\" }\n", + " .ConcatIf(includeArchived, new[] { \"archived\" })\n", + " .ConcatIf(includeDeleted, new[] { \"deleted\" })\n", + " .ToList();\n", + "display(statuses); // [\"active\", \"pending\", \"deleted\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 7. Predicate Queries\n", + "\n", + "| Method | Signature | Returns |\n", + "|---|---|---|\n", + "| `None(predicate)` | `IEnumerable, Func → bool` | `true` when no element matches, or source is null |\n", + "| `IsSingle` | `IEnumerable → bool` | `true` when exactly one element exists |\n", + "| `IsSingle(predicate)` | `IEnumerable, Func → bool` | `true` when exactly one element matches |\n", + "| `IndexOf` | `IEnumerable, Func → int` | first matching index, or `-1` |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// None(predicate) — true when no element satisfies the predicate\n", + "display(new[] { 1, 2, 3 }.None(x => x > 10)); // True (none above 10)\n", + "display(new[] { 1, 2, 3 }.None(x => x > 2)); // False (3 is above 2)\n", + "display(((IEnumerable)null).None(x => x > 0)); // True (null source)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// IsSingle — true when the sequence contains exactly one element\n", + "display(new[] { 42 }.IsSingle()); // True (one element)\n", + "display(new[] { 1, 2 }.IsSingle()); // False (two elements)\n", + "display(new int[0].IsSingle()); // False (empty)\n", + "display(((IEnumerable)null).IsSingle()); // False (null)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// IsSingle(predicate) — true when exactly one element matches the predicate\n", + "display(new[] { 1, 5, 2 }.IsSingle(x => x > 3)); // True (only 5 > 3)\n", + "display(new[] { 1, 5, 6 }.IsSingle(x => x > 3)); // False (both 5 and 6 > 3)\n", + "display(new[] { 1, 2, 3 }.IsSingle(x => x > 10)); // False (none matches)\n", + "display(((IEnumerable)null).IsSingle(x => x > 0)); // False (null source)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// IndexOf — returns zero-based index of the first matching element, or -1 if not found\n", + "var names = new[] { \"Alice\", \"Bob\", \"Charlie\", \"Dave\" };\n", + "display(names.IndexOf(n => n.StartsWith(\"C\"))); // 2 (\"Charlie\")\n", + "display(names.IndexOf(n => n.StartsWith(\"Z\"))); // -1 (no match)\n", + "display(names.IndexOf(n => n.Length > 3)); // 0 (\"Alice\" is first match)\n", + "\n", + "display(((IEnumerable)null).IndexOf(n => true)); // -1 (null source)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 8. Splitting and Chunking\n", + "\n", + "| Method | Signature | Returns |\n", + "|---|---|---|\n", + "| `Partition` | `IEnumerable, Func → (Matched, Remaining)` | splits into two lists by predicate |\n", + "| `Batch` | `IEnumerable, int → IEnumerable>` | splits into chunks of at most `size` elements |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// Partition — splits sequence into (Matched, Remaining) based on a predicate\n", + "var numbers = new[] { 1, 2, 3, 4, 5, 6 };\n", + "var (evens, odds) = numbers.Partition(x => x % 2 == 0);\n", + "display(evens); // [2, 4, 6]\n", + "display(odds); // [1, 3, 5]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// Partition — practical use: separate valid from invalid items\n", + "var inputs = new[] { \"alice@example.com\", \"not-an-email\", \"bob@test.org\", \"\" };\n", + "var (valid, invalid) = inputs.Partition(s => s.Contains('@'));\n", + "display(valid); // [\"alice@example.com\", \"bob@test.org\"]\n", + "display(invalid); // [\"not-an-email\", \"\"]\n", + "\n", + "// Null source returns two empty lists\n", + "var (m, r) = ((IEnumerable)null).Partition(x => x > 0);\n", + "display(m.Count); // 0\n", + "display(r.Count); // 0" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// Batch — splits sequence into chunks of a given size; last chunk may be smaller\n", + "var items = Enumerable.Range(1, 10).ToList();\n", + "var batches = items.Batch(3).ToList();\n", + "display(batches.Count); // 4 batches\n", + "display(batches[0]); // [1, 2, 3]\n", + "display(batches[1]); // [4, 5, 6]\n", + "display(batches[2]); // [7, 8, 9]\n", + "display(batches[3]); // [10] (last chunk smaller)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// Batch — null source returns empty sequence\n", + "display(((IEnumerable)null).Batch(5).ToList().Count); // 0\n", + "\n", + "// Batch size larger than sequence — single chunk\n", + "display(new[] { 1, 2 }.Batch(10).ToList().Count); // 1 (one chunk with both elements)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 9. Min/Max Defaults\n", + "\n", + "Null-safe wrappers around LINQ's `MinBy`/`MaxBy` that return `default(T)` instead of throwing on null or empty sequences.\n", + "\n", + "| Method | Signature | Notes |\n", + "|---|---|---|\n", + "| `MinByOrDefault` | `IEnumerable, Func → T?` | smallest by key, or `default` when null/empty |\n", + "| `MaxByOrDefault` | `IEnumerable, Func → T?` | largest by key, or `default` when null/empty |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// MinByOrDefault — returns element with smallest key value\n", + "display(new[] { 3, 1, 4, 1, 5, 9 }.MinByOrDefault(x => x)); // 1\n", + "display(((IEnumerable)null).MinByOrDefault(x => x)); // 0 (default for int)\n", + "display(Enumerable.Empty().MinByOrDefault(x => x)); // null (default for string)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// MaxByOrDefault — returns element with largest key value\n", + "display(new[] { 3, 1, 4, 1, 5, 9 }.MaxByOrDefault(x => x)); // 9\n", + "display(((IEnumerable)null).MaxByOrDefault(x => x)); // 0 (default for int)\n", + "display(Enumerable.Empty().MaxByOrDefault(x => x)); // null (default for string)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// Min/Max on objects by property — common real-world use\n", + "var people = new[]\n", + "{\n", + " (Name: \"Alice\", Age: 30),\n", + " (Name: \"Bob\", Age: 25),\n", + " (Name: \"Carol\", Age: 35),\n", + "};\n", + "display(people.MinByOrDefault(p => p.Age)); // (Bob, 25)\n", + "display(people.MaxByOrDefault(p => p.Age)); // (Carol, 35)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 10. Async Projection\n", + "\n", + "| Method | Signature | Notes |\n", + "|---|---|---|\n", + "| `SelectAsync` | `IEnumerable, Func>, int? → Task>` | async map; optional concurrency cap via `maxParallel` |\n", + "| `WhenAllList` | `IEnumerable> → Task>` | awaits all tasks, returns results as read-only list |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// SelectAsync — async projection of each element; all run concurrently by default\n", + "async Task FetchLabel(int id)\n", + "{\n", + " await Task.Delay(1); // simulate async work\n", + " return $\"item-{id}\";\n", + "}\n", + "\n", + "var results = await new[] { 1, 2, 3, 4, 5 }.SelectAsync(FetchLabel);\n", + "display(results); // [\"item-1\", \"item-2\", \"item-3\", \"item-4\", \"item-5\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// SelectAsync with maxParallel — limits concurrency (useful for rate-limited APIs)\n", + "var throttled = await new[] { 1, 2, 3, 4, 5 }.SelectAsync(FetchLabel, maxParallel: 2);\n", + "display(throttled); // [\"item-1\", \"item-2\", \"item-3\", \"item-4\", \"item-5\"]\n", + "\n", + "// Null source returns empty list without throwing\n", + "var empty = await ((IEnumerable)null).SelectAsync(FetchLabel);\n", + "display(empty.Count); // 0" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// WhenAllList — awaits a sequence of tasks and collects results into IReadOnlyList\n", + "var tasks = new[] { 10, 20, 30 }.Select(async n =>\n", + "{\n", + " await Task.Delay(1);\n", + " return n * 2;\n", + "});\n", + "\n", + "var doubled = await tasks.WhenAllList();\n", + "display(doubled); // [20, 40, 60]\n", + "\n", + "// Null source returns empty list\n", + "var nullResult = await ((IEnumerable>)null).WhenAllList();\n", + "display(nullResult.Count); // 0" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 11. Existing Methods\n", + "\n", + "These methods were present before the new additions. Shown here for completeness.\n", + "\n", + "| Method | Notes |\n", + "|---|---|\n", + "| `IsNullOrEmpty` | `true` when null, empty, or all-null elements |\n", + "| `CleanNullOrEmptyItems` | removes null (and for strings: empty/whitespace) elements |\n", + "| `ContainsOnly` | `true` when sequence contains exactly the specified items (any order) |\n", + "| `AreEqual` | order-sensitive or order-insensitive sequence equality |\n", + "| `ForEach` | side-effectful iteration; returns original sequence |\n", + "| `Reduce` | fold/accumulate to a single value |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// IsNullOrEmpty — true for null, empty, or all-null sequences\n", + "display(((IEnumerable)null).IsNullOrEmpty()); // True\n", + "display(new List().IsNullOrEmpty()); // True\n", + "display(new string[] { null, null }.IsNullOrEmpty()); // True (all-null elements)\n", + "display(new[] { 1, 2, 3 }.IsNullOrEmpty()); // False" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// CleanNullOrEmptyItems — removes nulls; for string sequences also removes empty/whitespace\n", + "display(new[] { \"hello\", null, \"\", \" \", \"world\" }.CleanNullOrEmptyItems().ToList());\n", + "// [\"hello\", \"world\"]\n", + "\n", + "display(new int?[] { 1, null, 2, null, 3 }.CleanNullOrEmptyItems().ToList());\n", + "// [1, 2, 3]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// ContainsOnly — true when sequence contains exactly these items (order-insensitive)\n", + "display(new[] { 1, 2, 3 }.ContainsOnly(3, 1, 2)); // True (same items, different order)\n", + "display(new[] { 1, 2, 3 }.ContainsOnly(1, 2)); // False (extra element in source)\n", + "display(new[] { 1, 2 }.ContainsOnly(1, 2, 3)); // False (missing element)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// AreEqual — sequence equality with optional order sensitivity\n", + "display(new[] { 1, 2, 3 }.AreEqual(new[] { 3, 1, 2 })); // True (NoOrder default)\n", + "display(new[] { 1, 2, 3 }.AreEqual(new[] { 3, 1, 2 }, Compare.InOrder)); // False (order differs)\n", + "display(new[] { 1, 2, 3 }.AreEqual(new[] { 1, 2, 3 }, Compare.InOrder)); // True\n", + "display(((IEnumerable)null).AreEqual(null)); // True (both null)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// ForEach — side-effectful iteration; returns original sequence for chaining\n", + "var log = new List();\n", + "new[] { \"a\", \"b\", \"c\" }\n", + " .ForEach(item => log.Add(item.ToUpper()))\n", + " .ForEach(item => display(item)); // prints \"a\", \"b\", \"c\"\n", + "display(log); // [\"A\", \"B\", \"C\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "// Reduce — fold to single accumulated value\n", + "// Sum integers\n", + "display(new[] { 1, 2, 3, 4 }.Reduce((item, acc) => acc + item, initialValue: 0)); // 10\n", + "\n", + "// Build comma-separated string\n", + "display(new[] { \"a\", \"b\", \"c\" }.Reduce(\n", + " (item, acc) => acc == \"\" ? item : acc + \", \" + item, \"\")); // \"a, b, c\"\n", + "\n", + "// Reduce with index — build numbered list\n", + "display(new[] { \"apple\", \"banana\", \"cherry\" }.Reduce(\n", + " (item, acc, index) => acc + $\"{index}: {item}\\n\", \"\"));\n", + "// \"0: apple\\n1: banana\\n2: cherry\\n\"" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".NET (C#)", + "language": "C#", + "name": ".net-csharp" + }, + "language_info": { + "name": "C#", + "version": "13.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index 0ac9d94..b179c69 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -169,7 +169,7 @@ public static IEnumerable CleanNullOrEmptyItems(this IEnumerable value) var list = value?.ToList(); if (list is null || !list.Any()) { - return null; + return []; } return list.Where(item => From b84baaa29626e63cc931cf6bac1b2361cf22d967 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 18:09:46 -0400 Subject: [PATCH 14/21] docs(samples): add chaining examples section to both notebooks --- sample/enumerable-extension.ipynb | 686 ++++++++++++++++++++++++++++-- sample/string-extensions.ipynb | 228 +++++++++- 2 files changed, 873 insertions(+), 41 deletions(-) diff --git a/sample/enumerable-extension.ipynb b/sample/enumerable-extension.ipynb index 311bb04..d2c5624 100644 --- a/sample/enumerable-extension.ipynb +++ b/sample/enumerable-extension.ipynb @@ -2,6 +2,7 @@ "cells": [ { "cell_type": "markdown", + "id": "ecc668f8", "metadata": {}, "source": [ "# Enumerable Extensions\n", @@ -21,11 +22,14 @@ "| [8. Splitting and Chunking](#8-splitting-and-chunking) | `Partition` · `Batch` |\n", "| [9. Min/Max Defaults](#9-minmax-defaults) | `MinByOrDefault` · `MaxByOrDefault` |\n", "| [10. Async Projection](#10-async-projection) | `SelectAsync` · `WhenAllList` |\n", - "| [11. Existing Methods](#11-existing-methods) | `IsNullOrEmpty` · `CleanNullOrEmptyItems` · `ContainsOnly` · `AreEqual` · `ForEach` · `Reduce` |" + "| [11. Existing Methods](#11-existing-methods) | `IsNullOrEmpty` · `CleanNullOrEmptyItems` · `ContainsOnly` · `AreEqual` · `ForEach` · `Reduce` |\n", + "\n", + "| [12. Chaining Examples](#12-chaining-examples) | Composing multiple extensions into pipelines |" ] }, { "cell_type": "markdown", + "id": "4358ebee", "metadata": {}, "source": [ "## Setup\n", @@ -38,7 +42,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "2a22ea16", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "#r \"../src/CSharpHelperExtensions/bin/Debug/net10.0/CSharpHelperExtensions.dll\"\n", @@ -50,6 +59,7 @@ }, { "cell_type": "markdown", + "id": "8058b417", "metadata": {}, "source": [ "---\n", @@ -65,7 +75,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "dc1aecc2", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// HasAny — true only when non-null and has at least one element\n", @@ -76,9 +91,149 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 1, + "id": "e4d5ada6", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\r\n", + "
\r\n", + " \r\n", + " \r\n", + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n", + "(2,34): error CS1061: 'IEnumerable' does not contain a definition for 'OrEmpty' and no accessible extension method 'OrEmpty' accepting a first argument of type 'IEnumerable' could be found (are you missing a using directive or an assembly reference?)\n", + "\n", + "(3,27): error CS1061: 'int[]' does not contain a definition for 'OrEmpty' and no accessible extension method 'OrEmpty' accepting a first argument of type 'int[]' could be found (are you missing a using directive or an assembly reference?)\n", + "\n", + "(4,20): error CS1061: 'int[]' does not contain a definition for 'OrEmpty' and no accessible extension method 'OrEmpty' accepting a first argument of type 'int[]' could be found (are you missing a using directive or an assembly reference?)\n", + "\n" + ] + }, + { + "ename": "Error", + "evalue": "compilation error", + "output_type": "error", + "traceback": [] + } + ], "source": [ "// OrEmpty — safe null-to-empty coalesce; non-null sequences pass through unchanged\n", "display(((IEnumerable)null).OrEmpty().Count()); // 0 (null → empty)\n", @@ -89,7 +244,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "255332dc", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// None — true when null or empty (opposite of HasAny)\n", @@ -100,6 +260,7 @@ }, { "cell_type": "markdown", + "id": "4e3862c3", "metadata": {}, "source": [ "---\n", @@ -115,7 +276,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "aac4c3a5", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// WhereNotNull — strips null reference elements; returns empty sequence for null source\n", @@ -128,7 +294,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "ced3e752", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// AsReadOnlyList — materializes a lazy sequence into an IReadOnlyList\n", @@ -143,7 +314,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "7ba7ca89", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// ToHashSetSafe — deduplicates; returns empty HashSet for null source (no exception)\n", @@ -156,6 +332,7 @@ }, { "cell_type": "markdown", + "id": "6cb76f10", "metadata": {}, "source": [ "---\n", @@ -171,7 +348,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "a5133163", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// Yield — wraps a single value into an IEnumerable\n", @@ -186,7 +368,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "1adf5527", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// JoinAsString — fluent string.Join with separator as argument\n", @@ -199,7 +386,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "fc43af2c", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// WithIndex — projects (Index, Item) tuples; useful for numbered loops without a counter variable\n", @@ -212,6 +404,7 @@ }, { "cell_type": "markdown", + "id": "9fd62b7f", "metadata": {}, "source": [ "---\n", @@ -225,7 +418,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "5679cade", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// ToDictionarySafe — null-safe conversion to Dictionary; duplicate keys don't throw\n", @@ -246,6 +444,7 @@ }, { "cell_type": "markdown", + "id": "1d7d07d0", "metadata": {}, "source": [ "---\n", @@ -262,7 +461,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "b9371ca6", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// AddIf — adds item only when condition is true; returns same list for chaining\n", @@ -283,7 +487,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "8fcab453", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// AddRangeIf — adds a range of items only when condition is true; returns same list for chaining\n", @@ -300,6 +509,7 @@ }, { "cell_type": "markdown", + "id": "3f137015", "metadata": {}, "source": [ "---\n", @@ -313,7 +523,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "6da80bb9", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// ConcatIf — returns source + other when condition is true, otherwise just source\n", @@ -324,7 +539,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "658b80b3", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// Null-safe on both sides\n", @@ -336,7 +556,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "a2e745fe", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// Practical use: build a filter list conditionally\n", @@ -351,6 +576,7 @@ }, { "cell_type": "markdown", + "id": "2aecc2bf", "metadata": {}, "source": [ "---\n", @@ -367,7 +593,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "a954abfc", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// None(predicate) — true when no element satisfies the predicate\n", @@ -379,7 +610,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "644e29d5", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// IsSingle — true when the sequence contains exactly one element\n", @@ -392,7 +628,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "cd0af8d8", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// IsSingle(predicate) — true when exactly one element matches the predicate\n", @@ -405,7 +646,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "ead2788c", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// IndexOf — returns zero-based index of the first matching element, or -1 if not found\n", @@ -419,6 +665,7 @@ }, { "cell_type": "markdown", + "id": "3c2a7f27", "metadata": {}, "source": [ "---\n", @@ -433,7 +680,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "9ba17fa3", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// Partition — splits sequence into (Matched, Remaining) based on a predicate\n", @@ -446,7 +698,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "95cc5bff", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// Partition — practical use: separate valid from invalid items\n", @@ -464,7 +721,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "d554fff6", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// Batch — splits sequence into chunks of a given size; last chunk may be smaller\n", @@ -480,7 +742,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "dbb983f6", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// Batch — null source returns empty sequence\n", @@ -492,6 +759,7 @@ }, { "cell_type": "markdown", + "id": "f0a2c1d4", "metadata": {}, "source": [ "---\n", @@ -508,7 +776,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "d038da52", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// MinByOrDefault — returns element with smallest key value\n", @@ -520,7 +793,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "ede991cb", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// MaxByOrDefault — returns element with largest key value\n", @@ -532,7 +810,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "09895039", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// Min/Max on objects by property — common real-world use\n", @@ -548,6 +831,7 @@ }, { "cell_type": "markdown", + "id": "5f46a2c1", "metadata": {}, "source": [ "---\n", @@ -562,7 +846,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "94da3458", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// SelectAsync — async projection of each element; all run concurrently by default\n", @@ -579,7 +868,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "2e3539f8", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// SelectAsync with maxParallel — limits concurrency (useful for rate-limited APIs)\n", @@ -594,7 +888,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "20290a27", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// WhenAllList — awaits a sequence of tasks and collects results into IReadOnlyList\n", @@ -614,6 +913,7 @@ }, { "cell_type": "markdown", + "id": "fcca5b6b", "metadata": {}, "source": [ "---\n", @@ -634,7 +934,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "a93c398f", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// IsNullOrEmpty — true for null, empty, or all-null sequences\n", @@ -647,7 +952,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "b4e9522f", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// CleanNullOrEmptyItems — removes nulls; for string sequences also removes empty/whitespace\n", @@ -661,7 +971,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "b61ed91e", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// ContainsOnly — true when sequence contains exactly these items (order-insensitive)\n", @@ -673,7 +988,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "594f7e4b", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// AreEqual — sequence equality with optional order sensitivity\n", @@ -686,7 +1006,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "b824c753", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// ForEach — side-effectful iteration; returns original sequence for chaining\n", @@ -700,7 +1025,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "id": "d53a3501", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// Reduce — fold to single accumulated value\n", @@ -716,6 +1046,281 @@ " (item, acc, index) => acc + $\"{index}: {item}\\n\", \"\"));\n", "// \"0: apple\\n1: banana\\n2: cherry\\n\"" ] + }, + { + "cell_type": "markdown", + "id": "c1794c82f51e49da", + "metadata": {}, + "source": [ + "---\n", + "## 12. Chaining Examples\n", + "\n", + "Most extensions are designed to compose. The examples below show realistic pipelines that chain\n", + "several methods together.\n" + ] + }, + { + "cell_type": "markdown", + "id": "a70d966263594134", + "metadata": {}, + "source": [ + "### Defensive null-safe pipeline\n", + "`OrEmpty → WhereNotNull → CleanNullOrEmptyItems → AsReadOnlyList`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d3162f163cff4fe6", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Raw data that may arrive null or contain nulls/blanks\n", + "IEnumerable raw = null;\n", + "\n", + "var clean = raw\n", + " .OrEmpty() // null → empty sequence\n", + " .WhereNotNull() // drop null elements\n", + " .CleanNullOrEmptyItems() // drop empty/whitespace strings\n", + " .AsReadOnlyList(); // materialise into IReadOnlyList\n", + "\n", + "display(clean); // []\n", + "\n", + "IEnumerable messy = new string?[] { \"Alice\", null, \"\", \" \", \"Bob\" };\n", + "display(messy.OrEmpty().WhereNotNull().CleanNullOrEmptyItems().AsReadOnlyList());\n", + "// [\"Alice\", \"Bob\"]" + ] + }, + { + "cell_type": "markdown", + "id": "71985548965f44c3", + "metadata": {}, + "source": [ + "### Presence check after cleanup\n", + "`OrEmpty → WhereNotNull → HasAny / None`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "733f523eac5844c7", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "IEnumerable input = new string?[] { null, \"\", null };\n", + "\n", + "bool hasRealValues = input.OrEmpty().WhereNotNull()\n", + " .CleanNullOrEmptyItems().HasAny();\n", + "display(hasRealValues); // False (all cleaned away)\n", + "\n", + "IEnumerable mixed = new string?[] { null, \"hello\" };\n", + "display(mixed.OrEmpty().WhereNotNull().HasAny()); // True" + ] + }, + { + "cell_type": "markdown", + "id": "ef94f9bbffa649af", + "metadata": {}, + "source": [ + "### Partition then summarise each half\n", + "`Partition → JoinAsString`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3516e12b99374c94", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "var scores = new[] { 85, 42, 91, 37, 78, 55, 60 };\n", + "var (passed, failed) = scores.Partition(s => s >= 60);\n", + "\n", + "display($\"Passed ({passed.Count}): {passed.JoinAsString(\", \")}\");\n", + "// \"Passed (4): 85, 91, 78, 60\"\n", + "display($\"Failed ({failed.Count}): {failed.JoinAsString(\", \")}\");\n", + "// \"Failed (3): 42, 37, 55\"" + ] + }, + { + "cell_type": "markdown", + "id": "55a8056e67ac47c5", + "metadata": {}, + "source": [ + "### Batch + async processing with concurrency cap\n", + "`Batch → SelectAsync(maxParallel)`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f29d67cb631045d7", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "async Task ProcessBatch(IReadOnlyList batch)\n", + "{\n", + " await Task.Delay(1); // simulate I/O per batch\n", + " return $\"batch[{batch.JoinAsString(\",\")}]\";\n", + "}\n", + "\n", + "var ids = Enumerable.Range(1, 9).ToList();\n", + "var summaries = await ids\n", + " .Batch(3) // chunk into groups of 3\n", + " .SelectAsync(ProcessBatch, maxParallel: 2); // process ≤2 batches at once\n", + "\n", + "display(summaries); // [\"batch[1,2,3]\", \"batch[4,5,6]\", \"batch[7,8,9]\"]" + ] + }, + { + "cell_type": "markdown", + "id": "bd09252a78624074", + "metadata": {}, + "source": [ + "### Build a lookup dictionary from messy input\n", + "`OrEmpty → WhereNotNull → ToDictionarySafe`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9cc743ba7770402a", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Input may be null or contain null entries\n", + "IEnumerable<(string? key, int value)?> rawPairs = new (string?, int)?[]\n", + "{\n", + " (\"alpha\", 1), null, (\"beta\", 2), (\"alpha\", 99), null\n", + "};\n", + "\n", + "var lookup = rawPairs\n", + " .OrEmpty()\n", + " .WhereNotNull()\n", + " .ToDictionarySafe(p => p.key ?? \"\", p => p.value);\n", + "\n", + "display(lookup[\"alpha\"]); // 99 (last-value-wins)\n", + "display(lookup[\"beta\"]); // 2" + ] + }, + { + "cell_type": "markdown", + "id": "3a3d34441e554de3", + "metadata": {}, + "source": [ + "### Conditional feature flags → JoinAsString label\n", + "`AddIf → AddRangeIf → JoinAsString`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cbc52072d97b4ba0", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "bool isPremium = true;\n", + "bool isBeta = false;\n", + "bool isAdmin = true;\n", + "\n", + "var tags = new List()\n", + " .AddIf(isPremium, \"premium\")\n", + " .AddIf(isBeta, \"beta\")\n", + " .AddIf(isAdmin, \"admin\")\n", + " .AddRangeIf(isPremium && isAdmin, new[] { \"billing\", \"audit\" });\n", + "\n", + "display(((IEnumerable)tags).JoinAsString(\" | \"));\n", + "// \"premium | admin | billing | audit\"" + ] + }, + { + "cell_type": "markdown", + "id": "4c12fa890b024b88", + "metadata": {}, + "source": [ + "### Conditional concat → deduplicate → sort\n", + "`ConcatIf → ToHashSetSafe → MinByOrDefault / MaxByOrDefault`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9b50d7435eb74d79", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "var baseItems = new[] { 3, 1, 4, 1, 5 };\n", + "var extraItems = new[] { 9, 2, 6, 5, 3 };\n", + "bool includeExtra = true;\n", + "\n", + "var unique = baseItems\n", + " .ConcatIf(includeExtra, extraItems)\n", + " .ToHashSetSafe(); // dedup: {3, 1, 4, 5, 9, 2, 6}\n", + "\n", + "display(unique.MinByOrDefault(x => x)); // 1\n", + "display(unique.MaxByOrDefault(x => x)); // 9\n", + "display(unique.None(x => x < 0)); // True (no negatives)" + ] + }, + { + "cell_type": "markdown", + "id": "1968bb4296b24953", + "metadata": {}, + "source": [ + "### Indexed rendering pipeline\n", + "`OrEmpty → WhereNotNull → WithIndex → ForEach`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0ea467666830481c", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "IEnumerable names = new string?[] { \"Alice\", null, \"Bob\", \"Carol\" };\n", + "\n", + "names\n", + " .OrEmpty()\n", + " .WhereNotNull()\n", + " .WithIndex()\n", + " .ForEach(t => display($\"{t.Index + 1}. {t.Item}\"));\n", + "// \"1. Alice\"\n", + "// \"2. Bob\"\n", + "// \"3. Carol\"" + ] } ], "metadata": { @@ -725,7 +1330,10 @@ "name": ".net-csharp" }, "language_info": { + "file_extension": ".cs", + "mimetype": "text/x-csharp", "name": "C#", + "pygments_lexer": "csharp", "version": "13.0" } }, diff --git a/sample/string-extensions.ipynb b/sample/string-extensions.ipynb index e722724..a785935 100644 --- a/sample/string-extensions.ipynb +++ b/sample/string-extensions.ipynb @@ -20,7 +20,8 @@ "| [7. Splitting & Joining](#7-splitting--joining) | `SplitNonEmpty` · `JoinWith` · `ReplaceMany` |\n", "| [8. Character Validation](#8-character-validation) | `IsNumeric` · `IsAlpha` · `IsAlphaNumeric` |\n", "| [9. Encoding & Bytes](#9-encoding--bytes) | `Base64Encode` · `Base64Decode` · `ToBase64Url` · `FromBase64Url` · `ToUtf8Bytes` · `ToUtf8Stream` |\n", - "| [10. Internationalisation](#10-internationalisation) | `RemoveDiacritics` |" + "| [10. Internationalisation](#10-internationalisation) | `RemoveDiacritics` |", + "\n| [11. Chaining Examples](#11-chaining-examples) | Composing multiple string extensions into pipelines |" ] }, { @@ -588,6 +589,229 @@ "var value = \"café\";\n", "display(value.RemoveDiacritics().EqualsIgnoreCase(search)); // True" ] + }, + { + "cell_type": "markdown", + "id": "1ffc976154984861", + "metadata": {}, + "source": [ + "---\n", + "## 11. Chaining Examples\n", + "\n", + "String extensions are designed to compose. These pipelines show how to chain methods\n", + "for common real-world scenarios.\n" + ] + }, + { + "cell_type": "markdown", + "id": "bbb104c1168a4a3d", + "metadata": {}, + "source": [ + "### Defensive input normalisation\n", + "`OrDefault → CollapseWhitespace → TrimToLower`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e46348f227ec491f", + "metadata": {}, + "outputs": [], + "source": [ + "// Normalise untrusted user input before storing or comparing\n", + "string? userInput = \" Hello WORLD \";\n", + "\n", + "var normalised = userInput\n", + " .OrDefault(\"unknown\") // replace null/whitespace with fallback\n", + " .CollapseWhitespace() // \"Hello WORLD\"\n", + " .TrimToLower(); // \"hello world\"\n", + "\n", + "display(normalised); // \"hello world\"\n", + "\n", + "// Works safely when input is null or blank\n", + "display(((string)null).OrDefault(\"unknown\").CollapseWhitespace().TrimToLower());\n", + "// \"unknown\"" + ] + }, + { + "cell_type": "markdown", + "id": "f66099cc196e434f", + "metadata": {}, + "source": [ + "### URL slug generation from raw titles\n", + "`OrEmpty → CollapseWhitespace → RemoveDiacritics → ToSlug → EnsurePrefix`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e96361db75e480d", + "metadata": {}, + "outputs": [], + "source": [ + "string? title = \" Ångström & Café: The Story \";\n", + "\n", + "var slug = title\n", + " .OrEmpty() // null → \"\"\n", + " .CollapseWhitespace() // \"Ångström & Café: The Story\"\n", + " .RemoveDiacritics() // \"Angstrom & Cafe: The Story\"\n", + " .ToSlug() // \"angstrom-cafe-the-story\"\n", + " .EnsurePrefix(\"/\"); // \"/angstrom-cafe-the-story\"\n", + "\n", + "display(slug); // \"/angstrom-cafe-the-story\"" + ] + }, + { + "cell_type": "markdown", + "id": "895badb931414fd6", + "metadata": {}, + "source": [ + "### Safe display name with truncation\n", + "`OrDefault → ToTitleCase → Truncate → EnsureSuffix`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0d5d0a3462e848ab", + "metadata": {}, + "outputs": [], + "source": [ + "string? rawName = \" alice smith \";\n", + "\n", + "var displayName = rawName\n", + " .OrDefault(\"Anonymous\") // fallback for null/blank\n", + " .ToTitleCase() // \"Alice Smith\"\n", + " .Truncate(10) // \"Alice Smit\" (if too long)\n", + " .EnsureSuffix(\".\"); // \"Alice Smit.\"\n", + "\n", + "display(displayName); // \"Alice Smit.\"\n", + "\n", + "display(((string)null).OrDefault(\"Anonymous\").ToTitleCase().Truncate(10));\n", + "// \"Anonymous\"" + ] + }, + { + "cell_type": "markdown", + "id": "4057d4e9d46d4ef8", + "metadata": {}, + "source": [ + "### Credential masking pipeline\n", + "`OrEmpty → TrimToLower → MaskStart`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35231e96151e4f06", + "metadata": {}, + "outputs": [], + "source": [ + "// Display an API key or card number safely\n", + "string? apiKey = \" sk-ABCDEF1234567890 \";\n", + "\n", + "var safe = apiKey\n", + " .OrEmpty()\n", + " .TrimToLower() // \"sk-abcdef1234567890\"\n", + " .MaskStart(4); // \"**************7890\"\n", + "\n", + "display(safe); // \"**************7890\"\n", + "display(((string)null).OrEmpty().MaskStart(4)); // \"\" (null-safe)" + ] + }, + { + "cell_type": "markdown", + "id": "f4dfdf053a614fef", + "metadata": {}, + "source": [ + "### CSV tag parsing and normalisation\n", + "`OrEmpty → SplitNonEmpty → Select(TrimToLower) → JoinWith`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "50d0883e7a944dcf", + "metadata": {}, + "outputs": [], + "source": [ + "using CSharpHelperExtensions.Strings;\n", + "\n", + "string? csv = \" Tech , SCIENCE , , art , Tech \";\n", + "\n", + "var tags = csv\n", + " .OrEmpty()\n", + " .SplitNonEmpty(',') // [\" Tech \", \" SCIENCE \", \" art \", \" Tech \"]\n", + " .Select(t => t.TrimToLower()) // [\"tech\", \"science\", \"art\", \"tech\"]\n", + " .Distinct() // [\"tech\", \"science\", \"art\"]\n", + " .OrderBy(t => t) // [\"art\", \"science\", \"tech\"]\n", + " .ToList();\n", + "\n", + "display(\", \".JoinWith(tags)); // \"art, science, tech\"" + ] + }, + { + "cell_type": "markdown", + "id": "deb39156d1e1439d", + "metadata": {}, + "source": [ + "### Search normalisation for diacritic-insensitive lookup\n", + "`RemoveDiacritics → TrimToLower → ContainsIgnoreCase`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e246b1cca03e4363", + "metadata": {}, + "outputs": [], + "source": [ + "// Search a name list where entries may have accents\n", + "var names = new[] { \"José García\", \"André Martin\", \"John Smith\", \"Ångström Lab\" };\n", + "string? query = \" garcia \";\n", + "\n", + "var normQuery = query\n", + " .OrEmpty()\n", + " .RemoveDiacritics()\n", + " .TrimToLower(); // \"garcia\"\n", + "\n", + "var matches = names\n", + " .Where(n => n.RemoveDiacritics().ContainsIgnoreCase(normQuery))\n", + " .ToList();\n", + "\n", + "display(matches); // [\"José García\"]" + ] + }, + { + "cell_type": "markdown", + "id": "4913e2f5e0004b1c", + "metadata": {}, + "source": [ + "### Dynamic base-URL construction\n", + "`OrDefault → TrimSuffix → EnsureSuffix → EnsurePrefix`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eb6de6aaaafe4922", + "metadata": {}, + "outputs": [], + "source": [ + "string? baseUrl = \" https://api.example.com/ \";\n", + "string? path = \"v2/users\";\n", + "\n", + "var url = baseUrl\n", + " .OrDefault(\"https://localhost\")\n", + " .CollapseWhitespace() // strip internal whitespace too\n", + " .TrimSuffix(\"/\") // remove trailing slash\n", + " + \"/\" +\n", + " path\n", + " .OrEmpty()\n", + " .TrimPrefix(\"/\"); // remove leading slash\n", + "\n", + "display(url); // \"https://api.example.com/v2/users\"" + ] } ], "metadata": { @@ -606,4 +830,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file From a8d8073b1da1572af59d27e5fc8087cb991d4492 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 18:40:05 -0400 Subject: [PATCH 15/21] reviewing and fixing things up --- CLAUDE.md | 10 +- sample/enumerable-extension.ipynb | 3323 +++++++++++++++-- sample/string-extensions.ipynb | 2940 ++++++++++++++- .../StringExtensionTest.cs | 36 + .../EnumerableExtensions.cs | 145 +- .../GenericExtensions.cs | 33 +- .../StringExtensions.cs | 100 +- 7 files changed, 6103 insertions(+), 484 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7435b19..307ee74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,8 +19,8 @@ dotnet test --filter "FullyQualifiedName~" # Example: dotnet test --filter "FullyQualifiedName~Verify_In_Exists" -# Run all tests in a specific class -dotnet test --filter "ClassName~EnumerableExtensionTest" +# Run all tests in a specific class (ClassName~ filter does NOT work; use FullyQualifiedName~) +dotnet test --filter "FullyQualifiedName~EnumerableExtensionTest" # Build only the library project dotnet build src/CSharpHelperExtensions/CSharpHelperExtensions.csproj @@ -43,7 +43,7 @@ dotnet test CSharpHelperExtensions.slnx This is a two-project solution: - **`src/CSharpHelperExtensions/`** — `net10.0` class library. The publishable NuGet package (`CSharpHelperExtensions` v2.0.0). Depends only on `Newtonsoft.Json`. -- **`src/CSharpHelperExtensions.Test/`** — xUnit test project (`net10.0`) using FluentAssertions. +- **`src/CSharpHelperExtensions.Test/`** — xUnit test project (`net10.0`) using Shouldly for assertions. Root-level config files: `CSharpHelperExtensions.slnx` (solution entry point), `global.json` (pins SDK to 10.0.x), `.editorconfig` (C# code style + formatting rules), `.config/dotnet-tools.json` (CSharpier formatter). @@ -54,8 +54,8 @@ The library splits extensions across three namespaces — callers must import th | File | Namespace | Key types | |------|-----------|-----------| | `GenericExtensions.cs` | `CSharpHelperExtensions` | `In`, `IsNullOrEmpty` (string), `IsBetween`, `ToJson` | -| `EnumerableExtensions.cs` | `CSharpHelperExtensions.Enumerable` | `IsNullOrEmpty`, `CleanNullOrEmptyItems`, `ContainsOnly`, `AreEqual`, `ForEach`, `Reduce` | -| `StringExtensions.cs` | `CSharpHelperExtensions.Strings` | `ToNullable` | +| `EnumerableExtensions.cs` | `CSharpHelperExtensions.Enumerable` | `IsNullOrEmpty`, `HasAny`, `None`, `CleanNullOrEmptyItems`, `WhereNotNull`, `ContainsOnly`, `AreEqual`, `ForEach`, `Reduce`, `SelectAsync`, `WhenAllList`, `Partition`, `Batch`, `MinByOrDefault`, `MaxByOrDefault`, `ToDictionarySafe`, `AddIf`, `AddRangeIf`, `ConcatIf`, `IsSingle`, `IndexOf`, `Yield`, `WithIndex`, `JoinAsString`, `AsReadOnlyList`, `ToHashSetSafe`, `OrEmpty` | +| `StringExtensions.cs` | `CSharpHelperExtensions.Strings` | `IsNullOrEmpty`, `HasValue`, `OrEmpty`, `OrDefault`, `Truncate`, `Reverse`, `TrimToLower`, `TrimToUpper`, `ToTitleCase`, `ToSlug`, `MaskStart`, `EqualsIgnoreCase`, `ContainsIgnoreCase`, `StartsWithIgnoreCase`, `EndsWithIgnoreCase`, `EnsurePrefix`, `EnsureSuffix`, `TrimPrefix`, `TrimSuffix`, `SplitNonEmpty`, `JoinWith`, `ReplaceMany`, `RemoveWhitespace`, `CollapseWhitespace`, `RemoveDiacritics`, `IsNumeric`, `IsAlpha`, `IsAlphaNumeric`, `ToNullable`, `ToIntOrNull`, `ToDecimalOrNull`, `ToDateTimeOrNull`, `ToGuidOrNull`, `ToBoolOrNull`, `Base64Encode`, `Base64Decode`, `ToBase64Url`, `FromBase64Url`, `ToUtf8Bytes`, `ToUtf8Stream` | `IsNullOrEmpty` exists in **both** `GenericExtensions` (for `string`) and `EnumerableExtensions` (for `IEnumerable`). Be careful about which namespace is imported. diff --git a/sample/enumerable-extension.ipynb b/sample/enumerable-extension.ipynb index d2c5624..bb8e83b 100644 --- a/sample/enumerable-extension.ipynb +++ b/sample/enumerable-extension.ipynb @@ -23,7 +23,6 @@ "| [9. Min/Max Defaults](#9-minmax-defaults) | `MinByOrDefault` · `MaxByOrDefault` |\n", "| [10. Async Projection](#10-async-projection) | `SelectAsync` · `WhenAllList` |\n", "| [11. Existing Methods](#11-existing-methods) | `IsNullOrEmpty` · `CleanNullOrEmptyItems` · `ContainsOnly` · `AreEqual` · `ForEach` · `Reduce` |\n", - "\n", "| [12. Chaining Examples](#12-chaining-examples) | Composing multiple extensions into pipelines |" ] }, @@ -41,7 +40,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 32, "id": "2a22ea16", "metadata": { "vscode": { @@ -74,14 +73,135 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 33, "id": "dc1aecc2", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// HasAny — true only when non-null and has at least one element\n", "display(new[] { 1, 2, 3 }.HasAny()); // True\n", @@ -91,7 +211,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 34, "id": "e4d5ada6", "metadata": { "vscode": { @@ -102,136 +222,122 @@ { "data": { "text/html": [ - "\r\n", - "
\r\n", - " \r\n", - " \r\n", - "
" + "
0
" ] }, "metadata": {}, "output_type": "display_data" }, { - "name": "stderr", - "output_type": "stream", - "text": [ - "\n", - "(2,34): error CS1061: 'IEnumerable' does not contain a definition for 'OrEmpty' and no accessible extension method 'OrEmpty' accepting a first argument of type 'IEnumerable' could be found (are you missing a using directive or an assembly reference?)\n", - "\n", - "(3,27): error CS1061: 'int[]' does not contain a definition for 'OrEmpty' and no accessible extension method 'OrEmpty' accepting a first argument of type 'int[]' could be found (are you missing a using directive or an assembly reference?)\n", - "\n", - "(4,20): error CS1061: 'int[]' does not contain a definition for 'OrEmpty' and no accessible extension method 'OrEmpty' accepting a first argument of type 'int[]' could be found (are you missing a using directive or an assembly reference?)\n", - "\n" - ] + "data": { + "text/html": [ + "
3
" + ] + }, + "metadata": {}, + "output_type": "display_data" }, { - "ename": "Error", - "evalue": "compilation error", - "output_type": "error", - "traceback": [] + "data": { + "text/html": [ + "
0
" + ] + }, + "metadata": {}, + "output_type": "display_data" } ], "source": [ @@ -243,14 +349,135 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 35, "id": "255332dc", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// None — true when null or empty (opposite of HasAny)\n", "display(((IEnumerable)null).None()); // True (null)\n", @@ -275,14 +502,106 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 36, "id": "aac4c3a5", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
[ apple, banana, cherry ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[  ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n", + "(2,27): warning CS8632: The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.\n", + "\n", + "(5,29): warning CS8632: The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.\n", + "\n" + ] + } + ], "source": [ "// WhereNotNull — strips null reference elements; returns empty sequence for null source\n", "var withNulls = new string?[] { \"apple\", null, \"banana\", null, \"cherry\" };\n", @@ -293,14 +612,135 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 37, "id": "ced3e752", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
[ 1, 2, 3, 4, 5 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
5
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
0
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// AsReadOnlyList — materializes a lazy sequence into an IReadOnlyList\n", "IEnumerable lazy = Enumerable.Range(1, 5);\n", @@ -313,31 +753,152 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 38, "id": "7ba7ca89", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], - "source": [ - "// ToHashSetSafe — deduplicates; returns empty HashSet for null source (no exception)\n", - "var dupes = new[] { 1, 2, 2, 3, 3, 3 };\n", - "display(dupes.ToHashSetSafe()); // {1, 2, 3}\n", - "display(dupes.ToHashSetSafe().Count); // 3\n", - "\n", - "display(((IEnumerable)null).ToHashSetSafe().Count); // 0 (null → empty set)" - ] - }, - { - "cell_type": "markdown", - "id": "6cb76f10", - "metadata": {}, - "source": [ - "---\n", - "## 3. Sequence Transforms\n", - "\n", + "outputs": [ + { + "data": { + "text/html": [ + "
[ 1, 2, 3 ]
Count
3
Capacity
7
Comparer
System.Collections.Generic.GenericEqualityComparer`1[System.Int32]
(values)
[ 1, 2, 3 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
3
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
0
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "// ToHashSetSafe — deduplicates; returns empty HashSet for null source (no exception)\n", + "var dupes = new[] { 1, 2, 2, 3, 3, 3 };\n", + "display(dupes.ToHashSetSafe()); // {1, 2, 3}\n", + "display(dupes.ToHashSetSafe().Count); // 3\n", + "\n", + "display(((IEnumerable)null).ToHashSetSafe().Count); // 0 (null → empty set)" + ] + }, + { + "cell_type": "markdown", + "id": "6cb76f10", + "metadata": {}, + "source": [ + "---\n", + "## 3. Sequence Transforms\n", + "\n", "| Method | Signature | Returns |\n", "|---|---|---|\n", "| `Yield` | `T → IEnumerable` | wraps a single value into a one-element sequence |\n", @@ -347,14 +908,135 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 39, "id": "a5133163", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
[ 42 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ hello ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ 1, 2, 3, 99 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// Yield — wraps a single value into an IEnumerable\n", "display(42.Yield().ToList()); // [42]\n", @@ -367,14 +1049,49 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 40, "id": "1adf5527", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "one, two, three" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "1 | 2 | 3" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "ab" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// JoinAsString — fluent string.Join with separator as argument\n", "display(new[] { \"one\", \"two\", \"three\" }.JoinAsString(\", \")); // \"one, two, three\"\n", @@ -385,14 +1102,82 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 41, "id": "fc43af2c", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "0: apple" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "1: banana" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "2: cherry" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "(empty)" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// WithIndex — projects (Index, Item) tuples; useful for numbered loops without a counter variable\n", "var fruits = new[] { \"apple\", \"banana\", \"cherry\" };\n", @@ -417,14 +1202,175 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 42, "id": "5679cade", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
keyvalue
a
1
b
2
c
3
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
99
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
2
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
0
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// ToDictionarySafe — null-safe conversion to Dictionary; duplicate keys don't throw\n", "var pairs = new[] { (\"a\", 1), (\"b\", 2), (\"c\", 3) };\n", @@ -460,20 +1406,101 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 43, "id": "b9371ca6", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], - "source": [ - "// AddIf — adds item only when condition is true; returns same list for chaining\n", - "var list = new List { 1, 2 };\n", - "list.AddIf(true, 3); // adds 3\n", - "list.AddIf(false, 4); // skipped\n", - "display(list); // [1, 2, 3]\n", + "outputs": [ + { + "data": { + "text/html": [ + "
[ 1, 2, 3 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ 10, 20, 5 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "// AddIf — adds item only when condition is true; returns same list for chaining\n", + "var list = new List { 1, 2 };\n", + "list.AddIf(true, 3); // adds 3\n", + "list.AddIf(false, 4); // skipped\n", + "display(list); // [1, 2, 3]\n", "\n", "// Fluent chaining\n", "bool includeBonus = true;\n", @@ -486,14 +1513,95 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 44, "id": "8fcab453", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
[ base, extra1, extra2 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ 1 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// AddRangeIf — adds a range of items only when condition is true; returns same list for chaining\n", "var items = new List { \"base\" };\n", @@ -522,14 +1630,95 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 45, "id": "6da80bb9", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
[ 1, 2, 3, 4 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ 1, 2 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// ConcatIf — returns source + other when condition is true, otherwise just source\n", "display(new[] { 1, 2 }.ConcatIf(true, new[] { 3, 4 }).ToList()); // [1, 2, 3, 4]\n", @@ -538,14 +1727,135 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 46, "id": "658b80b3", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
[ 1, 2 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[  ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ 1, 2 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// Null-safe on both sides\n", "display(((IEnumerable)null).ConcatIf(true, new[] { 1, 2 }).ToList()); // [1, 2] (null source → empty)\n", @@ -555,14 +1865,55 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 47, "id": "a2e745fe", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
[ active, pending, deleted ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// Practical use: build a filter list conditionally\n", "bool includeArchived = false;\n", @@ -592,14 +1943,135 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 48, "id": "a954abfc", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// None(predicate) — true when no element satisfies the predicate\n", "display(new[] { 1, 2, 3 }.None(x => x > 10)); // True (none above 10)\n", @@ -609,50 +2081,533 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 49, "id": "644e29d5", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], - "source": [ - "// IsSingle — true when the sequence contains exactly one element\n", - "display(new[] { 42 }.IsSingle()); // True (one element)\n", - "display(new[] { 1, 2 }.IsSingle()); // False (two elements)\n", - "display(new int[0].IsSingle()); // False (empty)\n", - "display(((IEnumerable)null).IsSingle()); // False (null)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cd0af8d8", - "metadata": { - "vscode": { - "languageId": "csharp" - } - }, - "outputs": [], - "source": [ - "// IsSingle(predicate) — true when exactly one element matches the predicate\n", - "display(new[] { 1, 5, 2 }.IsSingle(x => x > 3)); // True (only 5 > 3)\n", - "display(new[] { 1, 5, 6 }.IsSingle(x => x > 3)); // False (both 5 and 6 > 3)\n", - "display(new[] { 1, 2, 3 }.IsSingle(x => x > 10)); // False (none matches)\n", - "display(((IEnumerable)null).IsSingle(x => x > 0)); // False (null source)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ead2788c", - "metadata": { + "outputs": [ + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "// IsSingle — true when the sequence contains exactly one element\n", + "display(new[] { 42 }.IsSingle()); // True (one element)\n", + "display(new[] { 1, 2 }.IsSingle()); // False (two elements)\n", + "display(new int[0].IsSingle()); // False (empty)\n", + "display(((IEnumerable)null).IsSingle()); // False (null)" + ] + }, + { + "cell_type": "code", + "execution_count": 50, + "id": "cd0af8d8", + "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "// IsSingle(predicate) — true when exactly one element matches the predicate\n", + "display(new[] { 1, 5, 2 }.IsSingle(x => x > 3)); // True (only 5 > 3)\n", + "display(new[] { 1, 5, 6 }.IsSingle(x => x > 3)); // False (both 5 and 6 > 3)\n", + "display(new[] { 1, 2, 3 }.IsSingle(x => x > 10)); // False (none matches)\n", + "display(((IEnumerable)null).IsSingle(x => x > 0)); // False (null source)" + ] + }, + { + "cell_type": "code", + "execution_count": 51, + "id": "ead2788c", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
2
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
-1
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
0
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
-1
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// IndexOf — returns zero-based index of the first matching element, or -1 if not found\n", "var names = new[] { \"Alice\", \"Bob\", \"Charlie\", \"Dave\" };\n", @@ -679,14 +2634,95 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 52, "id": "9ba17fa3", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
[ 2, 4, 6 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ 1, 3, 5 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// Partition — splits sequence into (Matched, Remaining) based on a predicate\n", "var numbers = new[] { 1, 2, 3, 4, 5, 6 };\n", @@ -697,14 +2733,175 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 53, "id": "95cc5bff", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
[ alice@example.com, bob@test.org ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ not-an-email,  ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
0
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
0
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// Partition — practical use: separate valid from invalid items\n", "var inputs = new[] { \"alice@example.com\", \"not-an-email\", \"bob@test.org\", \"\" };\n", @@ -720,14 +2917,215 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 54, "id": "d554fff6", "metadata": { "vscode": { "languageId": "csharp" } - }, - "outputs": [], + }, + "outputs": [ + { + "data": { + "text/html": [ + "
4
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ 1, 2, 3 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ 4, 5, 6 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ 7, 8, 9 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ 10 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// Batch — splits sequence into chunks of a given size; last chunk may be smaller\n", "var items = Enumerable.Range(1, 10).ToList();\n", @@ -741,14 +3139,95 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 55, "id": "dbb983f6", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
0
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
1
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// Batch — null source returns empty sequence\n", "display(((IEnumerable)null).Batch(5).ToList().Count); // 0\n", @@ -775,14 +3254,135 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 56, "id": "d038da52", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
1
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
0
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
<null>
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// MinByOrDefault — returns element with smallest key value\n", "display(new[] { 3, 1, 4, 1, 5, 9 }.MinByOrDefault(x => x)); // 1\n", @@ -792,14 +3392,135 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 57, "id": "ede991cb", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
9
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
0
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
<null>
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// MaxByOrDefault — returns element with largest key value\n", "display(new[] { 3, 1, 4, 1, 5, 9 }.MaxByOrDefault(x => x)); // 9\n", @@ -809,14 +3530,95 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 58, "id": "09895039", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
(Bob, 25)
Item1
Bob
Item2
25
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
(Carol, 35)
Item1
Carol
Item2
35
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// Min/Max on objects by property — common real-world use\n", "var people = new[]\n", @@ -845,14 +3647,55 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 59, "id": "94da3458", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
[ item-1, item-2, item-3, item-4, item-5 ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// SelectAsync — async projection of each element; all run concurrently by default\n", "async Task FetchLabel(int id)\n", @@ -867,14 +3710,32 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 60, "id": "2e3539f8", "metadata": { "vscode": { "languageId": "csharp" } }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n", + "(6,13): error CS0103: The name 'await' does not exist in the current context\n", + "\n", + "(7,15): error CS1061: 'Task>' does not contain a definition for 'Count' and no accessible extension method 'Count' accepting a first argument of type 'Task>' could be found (are you missing a using directive or an assembly reference?)\n", + "\n" + ] + }, + { + "ename": "Error", + "evalue": "compilation error", + "output_type": "error", + "traceback": [] + } + ], "source": [ "// SelectAsync with maxParallel — limits concurrency (useful for rate-limited APIs)\n", "var throttled = await new[] { 1, 2, 3, 4, 5 }.SelectAsync(FetchLabel, maxParallel: 2);\n", diff --git a/sample/string-extensions.ipynb b/sample/string-extensions.ipynb index a785935..ea0e0c4 100644 --- a/sample/string-extensions.ipynb +++ b/sample/string-extensions.ipynb @@ -11,7 +11,7 @@ "\n", "| Section | Methods |\n", "|---|---|\n", - "| [1. Null-Safety & Coalescing](#1-null-safety--coalescing) | `HasValue` · `OrEmpty` · `OrDefault` |\n", + "| [1. Null-Safety & Coalescing](#1-null-safety--coalescing) | `IsNullOrEmpty` · `HasValue` · `OrEmpty` · `OrDefault` |\n", "| [2. Parsing](#2-parsing) | `ToNullable` · `ToIntOrNull` · `ToDecimalOrNull` · `ToDateTimeOrNull` · `ToGuidOrNull` · `ToBoolOrNull` |\n", "| [3. Text Transformation](#3-text-transformation) | `TrimToLower` · `TrimToUpper` · `ToTitleCase` · `ToSlug` · `Reverse` · `Truncate` · `MaskStart` |\n", "| [4. Whitespace](#4-whitespace) | `RemoveWhitespace` · `CollapseWhitespace` |\n", @@ -20,8 +20,8 @@ "| [7. Splitting & Joining](#7-splitting--joining) | `SplitNonEmpty` · `JoinWith` · `ReplaceMany` |\n", "| [8. Character Validation](#8-character-validation) | `IsNumeric` · `IsAlpha` · `IsAlphaNumeric` |\n", "| [9. Encoding & Bytes](#9-encoding--bytes) | `Base64Encode` · `Base64Decode` · `ToBase64Url` · `FromBase64Url` · `ToUtf8Bytes` · `ToUtf8Stream` |\n", - "| [10. Internationalisation](#10-internationalisation) | `RemoveDiacritics` |", - "\n| [11. Chaining Examples](#11-chaining-examples) | Composing multiple string extensions into pipelines |" + "| [10. Internationalisation](#10-internationalisation) | `RemoveDiacritics` |\n", + "| [11. Chaining Examples](#11-chaining-examples) | Composing multiple string extensions into pipelines |" ] }, { @@ -38,7 +38,11 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "#r \"../src/CSharpHelperExtensions/bin/Debug/net10.0/CSharpHelperExtensions.dll\"\n", @@ -55,6 +59,7 @@ "\n", "| Method | Signature | Returns |\n", "|---|---|---|\n", + "| `IsNullOrEmpty` | `string → bool` | `true` when null, empty, or whitespace-only |\n", "| `HasValue` | `string → bool` | `true` when not null/whitespace |\n", "| `OrEmpty` | `string → string` | `\"\"` when null; passes whitespace through |\n", "| `OrDefault` | `string, string → string` | fallback when null **or** whitespace |" @@ -62,9 +67,353 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 4, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "// IsNullOrEmpty — true for null, empty, or whitespace-only strings\n", + "display(((string)null).IsNullOrEmpty()); // True\n", + "display(\"\".IsNullOrEmpty()); // True\n", + "display(\" \".IsNullOrEmpty()); // True (whitespace counts as empty)\n", + "display(\"hello\".IsNullOrEmpty()); // False\n", + "display(\" x \".IsNullOrEmpty()); // False (has real content)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// HasValue — true only when there is real content\n", "display(\"hello\".HasValue()); // True\n", @@ -74,9 +423,39 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 6, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + " " + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "hello" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// OrEmpty — safe null-to-empty coalesce; whitespace strings pass through unchanged\n", "display(((string)null).OrEmpty()); // \"\"\n", @@ -86,9 +465,41 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 7, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "N/A" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "N/A" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "hello" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// OrDefault — replaces null AND whitespace with a fallback value\n", "display(((string)null).OrDefault(\"N/A\")); // \"N/A\"\n", @@ -112,9 +523,183 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 8, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
42
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
<null>
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
<null>
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "2024-06-15 00:00:00Z" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
3fa85f64-5717-4562-b3fc-2c963f66afa6
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// ToNullable — generic converter; throws FormatException on invalid input\n", "display(\"42\".ToNullable()); // 42\n", @@ -126,9 +711,254 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 9, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
99
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
<null>
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
<null>
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
3.14
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
1000.50
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
<null>
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// ToIntOrNull / ToDecimalOrNull — invariant-culture, no exceptions\n", "display(\"99\".ToIntOrNull()); // 99\n", @@ -142,9 +972,263 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 10, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "2024-01-15 00:00:00Z" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
<null>
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
3fa85f64-5717-4562-b3fc-2c963f66afa6
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
<null>
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
<null>
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// ToDateTimeOrNull / ToGuidOrNull / ToBoolOrNull\n", "display(\"2024-01-15\".ToDateTimeOrNull()); // DateTime\n", @@ -178,9 +1262,39 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 11, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "hello world" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "HELLO WORLD" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// TrimToLower / TrimToUpper\n", "display(\" Hello World \".TrimToLower()); // \"hello world\"\n", @@ -190,9 +1304,48 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 12, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "Hello World" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "Hello World" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "The Quick Brown Fox" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// ToTitleCase — collapses whitespace, then capitalizes each word\n", "display(\"hello world\".ToTitleCase()); // \"Hello World\"\n", @@ -203,9 +1356,50 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 13, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "hello-world" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "c-helper-extensions" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "cafe-au-lait" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "leading-dashes" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// ToSlug — URL-safe: lowercase, diacritics removed, non-alphanumeric → single dash\n", "display(\"Hello World!\".ToSlug()); // \"hello-world\"\n", @@ -216,9 +1410,46 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 14, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "olleh" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "edcba" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// Reverse\n", "display(\"hello\".Reverse()); // \"olleh\"\n", @@ -229,9 +1460,48 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 15, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "Hello" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "Hi" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "Hello" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// Truncate — keep first maxLength characters\n", "display(\"Hello, World!\".Truncate(5)); // \"Hello\"\n", @@ -242,9 +1512,48 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 16, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "************1234" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "############1234" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "AB" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// MaskStart — hide all but the last N characters (useful for credentials/card numbers)\n", "display(\"4111111111111234\".MaskStart(4)); // \"************1234\"\n", @@ -268,9 +1577,48 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 17, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "helloworld" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "hello" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "abc" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// RemoveWhitespace — removes every whitespace character (spaces, tabs, newlines)\n", "display(\"hello world\".RemoveWhitespace()); // \"helloworld\"\n", @@ -281,9 +1629,48 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 18, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "hello world" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "one two three" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "already clean" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// CollapseWhitespace — trim + collapse internal runs to a single space\n", "display(\" hello world \".CollapseWhitespace()); // \"hello world\"\n", @@ -304,9 +1691,134 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 19, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// EqualsIgnoreCase\n", "display(\"Hello\".EqualsIgnoreCase(\"hello\")); // True\n", @@ -316,9 +1828,174 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 20, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// ContainsIgnoreCase\n", "display(\"Hello, World!\".ContainsIgnoreCase(\"world\")); // True\n", @@ -329,9 +2006,174 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 21, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// StartsWithIgnoreCase / EndsWithIgnoreCase\n", "display(\"Hello, World!\".StartsWithIgnoreCase(\"hello\")); // True\n", @@ -358,9 +2200,41 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 22, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "/api/users" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "/api/users" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "/" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// EnsurePrefix — idempotent: won't double-add\n", "display(\"/api/users\".EnsurePrefix(\"/\")); // \"/api/users\" (already has it)\n", @@ -370,9 +2244,41 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 23, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "https://example.com/" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "https://example.com/" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "/" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// EnsureSuffix — idempotent: won't double-add\n", "display(\"https://example.com/\".EnsureSuffix(\"/\")); // \"https://example.com/\" (already has it)\n", @@ -382,9 +2288,50 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 24, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "api/users" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "api/users" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "report" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "report.txt" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// TrimPrefix / TrimSuffix — removes exactly one occurrence if present\n", "display(\"/api/users\".TrimPrefix(\"/\")); // \"api/users\"\n", @@ -410,9 +2357,174 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 25, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
[ a, b, c ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ one, two, three ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[ a, b, c ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
[  ]
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// SplitNonEmpty — splits on one or more separators, drops empty entries\n", "display(\"a,b,,c,\".SplitNonEmpty(',')); // [\"a\", \"b\", \"c\"]\n", @@ -423,9 +2535,41 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 26, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "one, two, three" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "one | two | three" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "onetwothree" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// JoinWith — separator is the receiver (reads naturally in pipeline style)\n", "var words = new[] { \"one\", \"two\", \"three\" };\n", @@ -437,9 +2581,32 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 27, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "bar and qux" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "<b>Hello & world</b>" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// ReplaceMany — ordered replacements applied sequentially\n", "var replacements = new (string, string)[] { (\"foo\", \"bar\"), (\"baz\", \"qux\") };\n", @@ -462,9 +2629,174 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 28, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// IsNumeric — all characters are digits 0–9\n", "display(\"12345\".IsNumeric()); // True\n", @@ -475,9 +2807,134 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 29, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// IsAlpha — all characters are Unicode letters\n", "display(\"Hello\".IsAlpha()); // True\n", @@ -487,9 +2944,134 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 30, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
True
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
False
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// IsAlphaNumeric — all characters are letters or digits\n", "display(\"Hello123\".IsAlphaNumeric()); // True\n", @@ -514,9 +3096,72 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 31, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "SGVsbG8sIFdvcmxkIQ==" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "Hello, World!" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
<null>
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// Standard Base64 — roundtrip\n", "var encoded = \"Hello, World!\".Base64Encode();\n", @@ -528,9 +3173,50 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 32, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "SGVsbG8sIFdvcmxkIQ" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "Hello, World!" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "aGVsbG8rd29ybGQvdGVzdD0=" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "aGVsbG8rd29ybGQvdGVzdD0" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "// URL-safe Base64 — no +, /, or = characters; safe for query strings / JWT tokens\n", "var urlToken = \"Hello, World!\".ToBase64Url();\n", @@ -545,9 +3231,29 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 33, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n", + "(7,11): error CS1002: ; expected\n", + "\n" + ] + }, + { + "ename": "Error", + "evalue": "compilation error", + "output_type": "error", + "traceback": [] + } + ], "source": [ "// ToUtf8Bytes — get raw UTF-8 bytes\n", "var bytes = \"hello\".ToUtf8Bytes();\n", @@ -574,7 +3280,11 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// RemoveDiacritics\n", @@ -615,7 +3325,11 @@ "cell_type": "code", "execution_count": null, "id": "e46348f227ec491f", - "metadata": {}, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// Normalise untrusted user input before storing or comparing\n", @@ -646,7 +3360,11 @@ "cell_type": "code", "execution_count": null, "id": "8e96361db75e480d", - "metadata": {}, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "string? title = \" Ångström & Café: The Story \";\n", @@ -674,7 +3392,11 @@ "cell_type": "code", "execution_count": null, "id": "0d5d0a3462e848ab", - "metadata": {}, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "string? rawName = \" alice smith \";\n", @@ -704,7 +3426,11 @@ "cell_type": "code", "execution_count": null, "id": "35231e96151e4f06", - "metadata": {}, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// Display an API key or card number safely\n", @@ -732,7 +3458,11 @@ "cell_type": "code", "execution_count": null, "id": "50d0883e7a944dcf", - "metadata": {}, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "using CSharpHelperExtensions.Strings;\n", @@ -763,7 +3493,11 @@ "cell_type": "code", "execution_count": null, "id": "e246b1cca03e4363", - "metadata": {}, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "// Search a name list where entries may have accents\n", @@ -795,7 +3529,11 @@ "cell_type": "code", "execution_count": null, "id": "eb6de6aaaafe4922", - "metadata": {}, + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, "outputs": [], "source": [ "string? baseUrl = \" https://api.example.com/ \";\n", @@ -825,9 +3563,9 @@ "mimetype": "text/x-csharp", "name": "C#", "pygments_lexer": "csharp", - "version": "10.0" + "version": "13.0" } }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +} diff --git a/src/CSharpHelperExtensions.Test/StringExtensionTest.cs b/src/CSharpHelperExtensions.Test/StringExtensionTest.cs index 9a2734b..4952649 100644 --- a/src/CSharpHelperExtensions.Test/StringExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/StringExtensionTest.cs @@ -19,6 +19,14 @@ public void Verify_StringIsEmpty_CheckForWhitespace() " ".IsNullOrEmpty().ShouldBeTrue(); } + [Fact] + public void Verify_IsNullOrEmpty_ReturnsFalse_WhenHasContent() + { + "hello".IsNullOrEmpty().ShouldBeFalse(); + " x ".IsNullOrEmpty().ShouldBeFalse(); + "0".IsNullOrEmpty().ShouldBeFalse(); + } + [Fact] public void Verify_HasValue_ReturnsTrue_WhenNotNullOrWhitespace() { @@ -140,6 +148,22 @@ public void Verify_MaskStart_UsesCustomChar() "123456".MaskStart(2, '#').ShouldBe("####56"); } + [Fact] + public void Verify_ToNullable_ParsesValidInput() + { + "42".ToNullable().ShouldBe(42); + "-7".ToNullable().ShouldBe(-7); + "".ToNullable().ShouldBeNull(); + " ".ToNullable().ShouldBeNull(); + ((string)null).ToNullable().ShouldBeNull(); + } + + [Fact] + public void Verify_ToNullable_ThrowsOnInvalidFormat() + { + Should.Throw(() => "not-a-number".ToNullable()); + } + [Fact] public void Verify_ToIntOrNull_ParsesValidInt() { @@ -379,12 +403,24 @@ public void Verify_EnsurePrefix_ThrowsOnNullPrefix() Should.Throw(() => "hello".EnsurePrefix(null)); } + [Fact] + public void Verify_EnsureSuffix_ThrowsOnNullSuffix() + { + Should.Throw(() => "hello".EnsureSuffix(null)); + } + [Fact] public void Verify_TrimPrefix_ThrowsOnNullPrefix() { Should.Throw(() => "hello".TrimPrefix(null)); } + [Fact] + public void Verify_TrimSuffix_ThrowsOnNullSuffix() + { + Should.Throw(() => "hello".TrimSuffix(null)); + } + [Fact] public void Verify_ToTitleCase_CapitalizesFirstLetterOfEachWord() { diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index b179c69..245d6ab 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -53,14 +53,12 @@ public static class EnumerableExtensions public static bool ContainsOnly(this IEnumerable enumerable, params T[] value) { if (value.IsNullOrEmpty() || enumerable.IsNullOrEmpty()) - { return false; - } - if (enumerable.Count() != value.Count()) - { + var list = enumerable.ToList(); + if (list.Count != value.Length) return false; - } - return value.All(item => enumerable.Contains(item)); + var set = new HashSet(list); + return value.All(set.Contains); } /// @@ -106,37 +104,27 @@ public static bool AreEqual( { return true; } - values ??= new List(); - enumerable ??= new List(); + if (ReferenceEquals(enumerable, values)) { return true; } - if (values.Count() != enumerable.Count()) + + var left = enumerable?.ToList() ?? []; + var right = values?.ToList() ?? []; + + if (left.Count != right.Count) { return false; } - return comparison switch - { - Compare.InOrder => CompareItemsInOrder(enumerable, values), - Compare.NoOrder => enumerable.All(item => values.Contains(item)), - _ => false, - }; - } - private static bool CompareItemsInOrder(IEnumerable list1, IEnumerable list2) - { - var firstList = list1.ToList(); - var secondList = list2.ToList(); - for (int index = 0; index <= firstList.Count - 1; index++) + if (comparison == Compare.InOrder) { - var areEqual = firstList[index].Equals(secondList[index]); - if (!areEqual) - { - return false; - } + return left.SequenceEqual(right); } - return true; + + var rightSet = new HashSet(right); + return left.All(rightSet.Contains); } /// @@ -166,21 +154,10 @@ private static bool CompareItemsInOrder(IEnumerable list1, IEnumerable /// public static IEnumerable CleanNullOrEmptyItems(this IEnumerable value) { - var list = value?.ToList(); - if (list is null || !list.Any()) - { + if (value is null) return []; - } - - return list.Where(item => - { - if (item is string itemStr) - { - return !string.IsNullOrWhiteSpace(itemStr); - } - - return item is not null; - }) + return value + .Where(item => item is string s ? !string.IsNullOrWhiteSpace(s) : item is not null) .ToList(); } @@ -203,10 +180,7 @@ public static IEnumerable CleanNullOrEmptyItems(this IEnumerable value) /// /// public static bool IsNullOrEmpty(this IEnumerable values) - { - var enumerable = values?.ToArray(); - return enumerable == null || !enumerable.Any() || enumerable.All(item => item is null); - } + => values is null || !values.Any(item => item is not null); /// /// Executes an action on each element of the sequence and returns the original sequence unchanged. @@ -250,10 +224,7 @@ public static IEnumerable ForEach(this IEnumerable values, Action ex /// A that completes when all async actions have finished. /// public static Task ForEach(this IEnumerable values, Func execute) - { - var collection = values?.ToList() ?? new List(); - return Task.WhenAll(collection.Select(item => execute(item))); - } + => Task.WhenAll(values.OrEmpty().Select(execute)); /// /// Asynchronously projects each element of a sequence using the given async transform @@ -268,15 +239,11 @@ public static Task ForEach(this IEnumerable values, Func execute) /// as each async operation completes. /// public static async IAsyncEnumerable ForEach( - this IEnumerable values, - Func> execute -) + this IEnumerable values, + Func> execute) { - var collection = values?.ToList() ?? new List(); - foreach (var item in collection) - { + foreach (var item in values.OrEmpty()) yield return await execute(item); - } } /// @@ -285,8 +252,7 @@ Func> execute /// /// /// The sequence to reduce. - /// If or empty, is ignored and - /// () is returned. + /// If or empty, returns unchanged. /// /// /// The reducer function. Receives the current element and the current accumulated value, @@ -312,19 +278,13 @@ Func> execute public static TOut Reduce( this IEnumerable values, Func execute, - TOut initialValue = default + TOut initialValue = default! ) { - var collection = values?.ToList() ?? new List(); - var result = default(TOut); - var temp = initialValue; - foreach (var item in collection) - { - result = execute(item, temp); - temp = result; - } - - return result; + var acc = initialValue; + foreach (var item in values.OrEmpty()) + acc = execute(item, acc); + return acc; } /// @@ -333,8 +293,7 @@ public static TOut Reduce( /// /// /// The sequence to reduce. - /// If or empty, is ignored and - /// () is returned. + /// If or empty, returns unchanged. /// /// /// The reducer function. Receives the current element, the current accumulated value, @@ -358,20 +317,14 @@ public static TOut Reduce( public static TOut Reduce( this IEnumerable values, Func execute, - TOut initialValue = default + TOut initialValue = default! ) { - var collection = values?.ToList() ?? new List(); - var result = default(TOut); - var temp = initialValue; - for (int counter = 0; counter <= collection.Count - 1; counter++) - { - var item = collection[counter]; - result = execute(item, temp, counter); - temp = result; - } - - return result; + var collection = values?.ToList() ?? []; + var acc = initialValue; + for (int i = 0; i < collection.Count; i++) + acc = execute(collection[i], acc, i); + return acc; } /// @@ -435,7 +388,8 @@ public static bool None(this IEnumerable source, Func predicate) /// public static bool IsSingle(this IEnumerable source) { - if (source is null) return false; + if (source is null) + return false; using var e = source.GetEnumerator(); return e.MoveNext() && !e.MoveNext(); } @@ -466,11 +420,13 @@ public static bool IsSingle(this IEnumerable source, Func predica /// public static int IndexOf(this IEnumerable source, Func predicate) { - if (source is null) return -1; + if (source is null) + return -1; int index = 0; foreach (var item in source) { - if (predicate(item)) return index; + if (predicate(item)) + return index; index++; } return -1; @@ -483,10 +439,8 @@ public static int IndexOf(this IEnumerable source, Func predicate /// The element type (must be a reference type). /// The sequence to filter. /// A sequence containing only non-null elements. -#nullable enable public static IEnumerable WhereNotNull(this IEnumerable source) where T : class => source is null ? System.Linq.Enumerable.Empty() : source.Where(x => x is not null).Cast(); -#nullable restore /// /// Materializes the sequence into an , preserving order. @@ -598,7 +552,8 @@ public static Dictionary ToDictionarySafe( /// public static IList AddIf(this IList list, bool condition, T item) { - if (condition) list.Add(item); + if (condition) + list.Add(item); return list; } @@ -674,8 +629,10 @@ public static (IReadOnlyList Matched, IReadOnlyList Remaining) Partition(); foreach (var item in source ?? System.Linq.Enumerable.Empty()) { - if (predicate(item)) matched.Add(item); - else rest.Add(item); + if (predicate(item)) + matched.Add(item); + else + rest.Add(item); } return (matched, rest); } @@ -754,7 +711,8 @@ public static async Task> SelectAsync( Func> selector, int? maxParallel = null) { - if (source is null) return []; + if (source is null) + return []; if (maxParallel is null) return await Task.WhenAll(source.Select(selector)); @@ -763,7 +721,8 @@ public static async Task> SelectAsync( var tasks = source.Select(async item => { await semaphore.WaitAsync(); - try { return await selector(item); } + try + { return await selector(item); } finally { semaphore.Release(); } }); return await Task.WhenAll(tasks); diff --git a/src/CSharpHelperExtensions/GenericExtensions.cs b/src/CSharpHelperExtensions/GenericExtensions.cs index 25ae732..b9e3f2f 100644 --- a/src/CSharpHelperExtensions/GenericExtensions.cs +++ b/src/CSharpHelperExtensions/GenericExtensions.cs @@ -86,37 +86,6 @@ public static bool In(this T value, params T[] input) return input is { } && input.Contains(value); } - /// - /// Returns if the string is , empty, - /// or consists only of whitespace characters. - /// Delegates to . - /// - /// - /// Despite the name, whitespace-only strings are treated as empty (uses internally, - /// not ). - /// This overload operates on . - /// For collections, use - /// - /// (requires the CSharpHelperExtensions.Enumerable namespace). - /// - /// The string to check. - /// - /// if is , empty, or whitespace-only; - /// otherwise . - /// - /// - /// - /// ((string)null).IsNullOrEmpty() // true - /// "".IsNullOrEmpty() // true - /// " ".IsNullOrEmpty() // true (whitespace only) - /// "hello".IsNullOrEmpty() // false - /// - /// - public static bool IsNullOrEmpty(this string value) - { - return string.IsNullOrWhiteSpace(value); - } - /// /// Serializes an object to its JSON string representation using Newtonsoft.Json. /// @@ -152,4 +121,4 @@ public static string ToJson(this T value, bool indentation = false) where T : var formatting = indentation ? Formatting.Indented : Formatting.None; return value == null ? null : JsonConvert.SerializeObject(value, formatting); } -} \ No newline at end of file +} diff --git a/src/CSharpHelperExtensions/StringExtensions.cs b/src/CSharpHelperExtensions/StringExtensions.cs index ebeaefa..804b4c1 100644 --- a/src/CSharpHelperExtensions/StringExtensions.cs +++ b/src/CSharpHelperExtensions/StringExtensions.cs @@ -13,6 +13,37 @@ public static class StringExtensions { private static readonly Regex CollapseRegex = new(@"\s+", RegexOptions.Compiled); + /// + /// Returns if the string is , empty, + /// or consists only of whitespace characters. + /// Delegates to . + /// + /// + /// Despite the name, whitespace-only strings are treated as empty (uses internally, + /// not ). + /// This overload operates on . + /// For collections, use + /// + /// (requires the CSharpHelperExtensions.Enumerable namespace). + /// + /// The string to check. + /// + /// if is , empty, or whitespace-only; + /// otherwise . + /// + /// + /// + /// ((string)null).IsNullOrEmpty() // true + /// "".IsNullOrEmpty() // true + /// " ".IsNullOrEmpty() // true (whitespace only) + /// "hello".IsNullOrEmpty() // false + /// + /// + public static bool IsNullOrEmpty(this string value) + { + return string.IsNullOrWhiteSpace(value); + } + /// /// Converts a string to the specified nullable value type using its registered . /// Returns when the input is , empty, or whitespace. @@ -45,7 +76,10 @@ public static class StringExtensions public static T? ToNullable(this string input) where T : struct { if (input.IsNullOrEmpty()) + { return null; + } + var converter = TypeDescriptor.GetConverter(typeof(T)); return (T?)converter.ConvertFrom(input); } @@ -90,7 +124,8 @@ public static string OrDefault(this string input, string fallback) public static string Truncate(this string input, int maxLength) { ArgumentOutOfRangeException.ThrowIfNegative(maxLength); - if (input == null) return string.Empty; + if (input == null) + return string.Empty; return input.Length <= maxLength ? input : input[..maxLength]; } @@ -102,7 +137,8 @@ public static string Truncate(this string input, int maxLength) /// The reversed string. public static string Reverse(this string input) { - if (input.IsNullOrEmpty()) return string.Empty; + if (input.IsNullOrEmpty()) + return string.Empty; var chars = input.ToCharArray(); Array.Reverse(chars); return new string(chars); @@ -131,7 +167,8 @@ public static bool EqualsIgnoreCase(this string input, string other) /// The substring to search for. public static bool ContainsIgnoreCase(this string input, string value) { - if (input == null || value == null) return false; + if (input == null || value == null) + return false; return input.Contains(value, StringComparison.OrdinalIgnoreCase); } @@ -140,7 +177,8 @@ public static bool ContainsIgnoreCase(this string input, string value) /// The prefix to look for. public static bool StartsWithIgnoreCase(this string input, string value) { - if (input == null || value == null) return false; + if (input == null || value == null) + return false; return input.StartsWith(value, StringComparison.OrdinalIgnoreCase); } @@ -149,7 +187,8 @@ public static bool StartsWithIgnoreCase(this string input, string value) /// The suffix to look for. public static bool EndsWithIgnoreCase(this string input, string value) { - if (input == null || value == null) return false; + if (input == null || value == null) + return false; return input.EndsWith(value, StringComparison.OrdinalIgnoreCase); } @@ -169,8 +208,10 @@ public static bool EndsWithIgnoreCase(this string input, string value) /// public static string MaskStart(this string input, int visibleCount, char maskChar = '*') { - if (input.IsNullOrEmpty()) return string.Empty; - if (visibleCount >= input.Length) return input; + if (input.IsNullOrEmpty()) + return string.Empty; + if (visibleCount >= input.Length) + return input; var maskLength = input.Length - visibleCount; return new string(maskChar, maskLength) + input[maskLength..]; } @@ -208,7 +249,8 @@ public static string MaskStart(this string input, int visibleCount, char maskCha /// The Base64-encoded string, or if is . public static string Base64Encode(this string input) { - if (input == null) return null; + if (input == null) + return null; return Convert.ToBase64String(Encoding.UTF8.GetBytes(input)); } @@ -217,7 +259,8 @@ public static string Base64Encode(this string input) /// The decoded string, or if is . public static string Base64Decode(this string input) { - if (input == null) return null; + if (input == null) + return null; return Encoding.UTF8.GetString(Convert.FromBase64String(input)); } @@ -229,7 +272,8 @@ public static string Base64Decode(this string input) /// The URL-safe Base64 string, or if is . public static string ToBase64Url(this string input) { - if (input == null) return null; + if (input == null) + return null; return Convert.ToBase64String(Encoding.UTF8.GetBytes(input)) .Replace('+', '-') .Replace('/', '_') @@ -243,7 +287,8 @@ public static string ToBase64Url(this string input) /// The decoded string, or if is . public static string FromBase64Url(this string input) { - if (input == null) return null; + if (input == null) + return null; var padded = input.Replace('-', '+').Replace('_', '/'); padded = (padded.Length % 4) switch { @@ -285,7 +330,8 @@ public static string JoinWith(this string separator, IEnumerable values) /// Non-empty segments after splitting. public static string[] SplitNonEmpty(this string input, params char[] separators) { - if (input.IsNullOrEmpty()) return []; + if (input.IsNullOrEmpty()) + return []; return input.Split(separators, StringSplitOptions.RemoveEmptyEntries); } @@ -294,7 +340,8 @@ public static string[] SplitNonEmpty(this string input, params char[] separators /// The string with all whitespace removed. public static string RemoveWhitespace(this string input) { - if (input == null) return string.Empty; + if (input == null) + return string.Empty; return string.Concat(input.Where(c => !char.IsWhiteSpace(c))); } @@ -305,7 +352,8 @@ public static string RemoveWhitespace(this string input) /// The collapsed string. public static string CollapseWhitespace(this string input) { - if (input.IsNullOrEmpty()) return string.Empty; + if (input.IsNullOrEmpty()) + return string.Empty; return CollapseRegex.Replace(input.Trim(), " "); } @@ -317,7 +365,8 @@ public static string CollapseWhitespace(this string input) /// The resulting string after all replacements. public static string ReplaceMany(this string input, IEnumerable<(string OldValue, string NewValue)> pairs) { - if (input == null) return string.Empty; + if (input == null) + return string.Empty; var result = input; foreach (var (oldValue, newValue) in pairs ?? []) result = result.Replace(oldValue, newValue); @@ -332,7 +381,8 @@ public static string ReplaceMany(this string input, IEnumerable<(string OldValue /// The string with diacritics removed. public static string RemoveDiacritics(this string input) { - if (input.IsNullOrEmpty()) return string.Empty; + if (input.IsNullOrEmpty()) + return string.Empty; var normalized = input.Normalize(NormalizationForm.FormD); var sb = new StringBuilder(normalized.Length); foreach (var c in normalized) @@ -365,7 +415,8 @@ public static bool IsAlphaNumeric(this string input) public static string EnsurePrefix(this string input, string prefix) { ArgumentNullException.ThrowIfNull(prefix); - if (input == null) return prefix; + if (input == null) + return prefix; return input.StartsWith(prefix, StringComparison.Ordinal) ? input : prefix + input; } @@ -376,7 +427,8 @@ public static string EnsurePrefix(this string input, string prefix) public static string EnsureSuffix(this string input, string suffix) { ArgumentNullException.ThrowIfNull(suffix); - if (input == null) return suffix; + if (input == null) + return suffix; return input.EndsWith(suffix, StringComparison.Ordinal) ? input : input + suffix; } @@ -387,7 +439,8 @@ public static string EnsureSuffix(this string input, string suffix) public static string TrimPrefix(this string input, string prefix) { ArgumentNullException.ThrowIfNull(prefix); - if (input == null) return string.Empty; + if (input == null) + return string.Empty; return input.StartsWith(prefix, StringComparison.Ordinal) ? input[prefix.Length..] : input; @@ -400,7 +453,8 @@ public static string TrimPrefix(this string input, string prefix) public static string TrimSuffix(this string input, string suffix) { ArgumentNullException.ThrowIfNull(suffix); - if (input == null) return string.Empty; + if (input == null) + return string.Empty; return input.EndsWith(suffix, StringComparison.Ordinal) ? input[..^suffix.Length] : input; @@ -414,7 +468,8 @@ public static string TrimSuffix(this string input, string suffix) /// The slug string, e.g. "Hello World!""hello-world". public static string ToSlug(this string input) { - if (input.IsNullOrEmpty()) return string.Empty; + if (input.IsNullOrEmpty()) + return string.Empty; var clean = input.RemoveDiacritics().ToLowerInvariant(); var sb = new StringBuilder(clean.Length); var lastWasDash = false; @@ -444,7 +499,8 @@ public static string ToSlug(this string input) /// The title-cased string, e.g. "hello world""Hello World". public static string ToTitleCase(this string input) { - if (input.IsNullOrEmpty()) return string.Empty; + if (input.IsNullOrEmpty()) + return string.Empty; var words = input.ToLowerInvariant().CollapseWhitespace().Split(' '); for (var i = 0; i < words.Length; i++) words[i] = char.ToUpperInvariant(words[i][0]) + words[i][1..]; From 13fa54dc00a9106d0ba41feb6baaabac62318733 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 18:55:14 -0400 Subject: [PATCH 16/21] fixing the AddBraces warning --- .../plans/2026-05-28-enumerable-extensions.md | 1083 +++++++++++++++++ .../EnumerableExtensions.cs | 136 ++- .../StringExtensions.cs | 137 ++- 3 files changed, 1275 insertions(+), 81 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-28-enumerable-extensions.md diff --git a/docs/superpowers/plans/2026-05-28-enumerable-extensions.md b/docs/superpowers/plans/2026-05-28-enumerable-extensions.md new file mode 100644 index 0000000..45637b9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-enumerable-extensions.md @@ -0,0 +1,1083 @@ +# EnumerableExtensions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add 20 new extension methods to `EnumerableExtensions` covering presence checks, materialization, transforms, conditional mutation, predicate queries, splitting/chunking, min/max defaults, and async projection. + +**Architecture:** All new methods are added to the existing `EnumerableExtensions` static class in `src/CSharpHelperExtensions/EnumerableExtensions.cs`, keeping the `CSharpHelperExtensions.Enumerable` namespace. Tests extend the existing `EnumerableExtensionTest.cs`. Every method is null-safe on the source sequence. `AddIf`/`AddRangeIf` target `IList` (not `IEnumerable`) because they mutate the collection. + +**Tech Stack:** .NET 10, C# 13, xUnit, Shouldly. `Chunk` (BCL .NET 6+) used for `Batch`. `MinBy`/`MaxBy` (BCL .NET 6+) used for min/max helpers. `SemaphoreSlim` used for `SelectAsync` concurrency cap. + +--- + +## File Map + +| Action | Path | Responsibility | +|--------|------|----------------| +| Modify | `src/CSharpHelperExtensions/EnumerableExtensions.cs` | All 20 new methods, plus `using System.Threading;` | +| Modify | `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` | All new tests, plus `using System.Linq;`, `using System.Threading;`, `using System.Threading.Tasks;` | + +--- + +### Task 1: `HasAny`, `OrEmpty`, `None()` — Collection presence shortcuts + +**Files:** +- Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `EnumerableExtensionTest.cs` (inside the `EnumerableExtensionTest` class): + +```csharp +[Fact] +public void HasAny_ReturnsTrue_WhenSequenceHasElements() +{ + new[] { 1, 2, 3 }.HasAny().ShouldBeTrue(); + new[] { (string)null }.HasAny().ShouldBeTrue(); +} + +[Fact] +public void HasAny_ReturnsFalse_WhenNullOrEmpty() +{ + ((IEnumerable)null).HasAny().ShouldBeFalse(); + Enumerable.Empty().HasAny().ShouldBeFalse(); +} + +[Fact] +public void OrEmpty_ReturnsOriginal_WhenNotNull() +{ + new[] { 1, 2 }.OrEmpty().ShouldBe(new[] { 1, 2 }); +} + +[Fact] +public void OrEmpty_ReturnsEmpty_WhenNull() +{ + ((IEnumerable)null).OrEmpty().ShouldBeEmpty(); +} + +[Fact] +public void None_ReturnsTrue_WhenNullOrEmpty() +{ + ((IEnumerable)null).None().ShouldBeTrue(); + Enumerable.Empty().None().ShouldBeTrue(); +} + +[Fact] +public void None_ReturnsFalse_WhenSequenceHasElements() +{ + new[] { 1, 2 }.None().ShouldBeFalse(); +} +``` + +Add `using System.Linq;` to the top of `EnumerableExtensionTest.cs` if not present. + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: compile error — `HasAny`, `OrEmpty`, `None` not defined. + +- [ ] **Step 3: Implement the methods** + +Add to the `EnumerableExtensions` static class in `EnumerableExtensions.cs`: + +```csharp +public static bool HasAny(this IEnumerable source) + => source != null && source.Any(); + +public static IEnumerable OrEmpty(this IEnumerable source) + => source ?? Enumerable.Empty(); + +public static bool None(this IEnumerable source) + => source is null || !source.Any(); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: all tests PASS including `HasAny_*`, `OrEmpty_*`, `None_Returns*`. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/EnumerableExtensions.cs src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +git commit -m "feat(enumerable): add HasAny, OrEmpty, None" +``` + +--- + +### Task 2: `WhereNotNull`, `AsReadOnlyList`, `ToHashSetSafe` — Materialization helpers + +**Files:** +- Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `EnumerableExtensionTest.cs`: + +```csharp +[Fact] +public void WhereNotNull_FiltersNullsFromReferenceSequence() +{ + var result = new[] { "a", null, "b", null, "c" }.WhereNotNull().ToList(); + result.ShouldBe(new[] { "a", "b", "c" }); +} + +[Fact] +public void WhereNotNull_OnNullSource_ReturnsEmpty() +{ + ((IEnumerable)null).WhereNotNull().ShouldBeEmpty(); +} + +[Fact] +public void AsReadOnlyList_MaterializesSequenceInOrder() +{ + IReadOnlyList result = new[] { 3, 1, 2 }.AsReadOnlyList(); + result.ShouldBe(new[] { 3, 1, 2 }); +} + +[Fact] +public void AsReadOnlyList_OnNullSource_ReturnsEmpty() +{ + IReadOnlyList result = ((IEnumerable)null).AsReadOnlyList(); + result.ShouldBeEmpty(); +} + +[Fact] +public void ToHashSetSafe_DeduplicatesElements() +{ + var result = new[] { 1, 2, 2, 3 }.ToHashSetSafe(); + result.ShouldBe(new HashSet { 1, 2, 3 }); +} + +[Fact] +public void ToHashSetSafe_OnNullSource_ReturnsEmpty() +{ + ((IEnumerable)null).ToHashSetSafe().ShouldBeEmpty(); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: compile error — `WhereNotNull`, `AsReadOnlyList`, `ToHashSetSafe` not defined. + +- [ ] **Step 3: Implement the methods** + +Add to `EnumerableExtensions.cs`: + +```csharp +public static IEnumerable WhereNotNull(this IEnumerable source) where T : class + => source is null ? Enumerable.Empty() : source.Where(x => x is not null)!; + +public static IReadOnlyList AsReadOnlyList(this IEnumerable source) + => (source ?? Enumerable.Empty()).ToList(); + +public static HashSet ToHashSetSafe(this IEnumerable source) + => source is null ? new HashSet() : source.ToHashSet(); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/EnumerableExtensions.cs src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +git commit -m "feat(enumerable): add WhereNotNull, AsReadOnlyList, ToHashSetSafe" +``` + +--- + +### Task 3: `Yield`, `JoinAsString`, `WithIndex` — Sequence transforms + +**Files:** +- Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `EnumerableExtensionTest.cs`: + +```csharp +[Fact] +public void Yield_WrapsValueTypeAsSingleItemSequence() +{ + 42.Yield().ToList().ShouldBe(new[] { 42 }); +} + +[Fact] +public void Yield_WrapsReferenceTypeAsSingleItemSequence() +{ + "hello".Yield().Single().ShouldBe("hello"); +} + +[Fact] +public void JoinAsString_JoinsWithSeparator() +{ + new[] { "a", "b", "c" }.JoinAsString(", ").ShouldBe("a, b, c"); +} + +[Fact] +public void JoinAsString_WorksForNonStringTypes() +{ + new[] { 1, 2, 3 }.JoinAsString("-").ShouldBe("1-2-3"); +} + +[Fact] +public void JoinAsString_OnNullSource_ReturnsEmptyString() +{ + ((IEnumerable)null).JoinAsString(",").ShouldBe(string.Empty); +} + +[Fact] +public void WithIndex_ProjectsZeroBasedIndexAndItem() +{ + var result = new[] { "a", "b", "c" }.WithIndex().ToList(); + result[0].ShouldBe((0, "a")); + result[1].ShouldBe((1, "b")); + result[2].ShouldBe((2, "c")); +} + +[Fact] +public void WithIndex_OnNullSource_ReturnsEmpty() +{ + ((IEnumerable)null).WithIndex().ShouldBeEmpty(); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: compile error — `Yield`, `JoinAsString`, `WithIndex` not defined. + +- [ ] **Step 3: Implement the methods** + +Add to `EnumerableExtensions.cs`: + +```csharp +public static IEnumerable Yield(this T item) +{ + yield return item; +} + +public static string JoinAsString(this IEnumerable source, string separator) + => string.Join(separator, source ?? Enumerable.Empty()); + +public static IEnumerable<(int Index, T Item)> WithIndex(this IEnumerable source) + => (source ?? Enumerable.Empty()).Select((item, i) => (i, item)); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/EnumerableExtensions.cs src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +git commit -m "feat(enumerable): add Yield, JoinAsString, WithIndex" +``` + +--- + +### Task 4: `ToDictionarySafe` — Duplicate-key-safe dictionary + +**Files:** +- Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `EnumerableExtensionTest.cs`: + +```csharp +[Fact] +public void ToDictionarySafe_CreatesDictionaryFromSequence() +{ + var result = new[] { ("a", 1), ("b", 2) } + .ToDictionarySafe(x => x.Item1, x => x.Item2); + result["a"].ShouldBe(1); + result["b"].ShouldBe(2); +} + +[Fact] +public void ToDictionarySafe_KeepsLastValue_OnDuplicateKey() +{ + var result = new[] { ("a", 1), ("a", 99) } + .ToDictionarySafe(x => x.Item1, x => x.Item2); + result["a"].ShouldBe(99); +} + +[Fact] +public void ToDictionarySafe_OnNullSource_ReturnsEmptyDictionary() +{ + var result = ((IEnumerable<(string, int)>)null) + .ToDictionarySafe(x => x.Item1, x => x.Item2); + result.ShouldBeEmpty(); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: compile error — `ToDictionarySafe` not defined. + +- [ ] **Step 3: Implement the method** + +Add to `EnumerableExtensions.cs`: + +```csharp +public static Dictionary ToDictionarySafe( + this IEnumerable source, + Func keySelector, + Func valueSelector) + where TKey : notnull +{ + var dict = new Dictionary(); + foreach (var item in source ?? Enumerable.Empty()) + dict[keySelector(item)] = valueSelector(item); + return dict; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: all tests PASS including `ToDictionarySafe_*`. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/EnumerableExtensions.cs src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +git commit -m "feat(enumerable): add ToDictionarySafe" +``` + +--- + +### Task 5: `AddIf`, `AddRangeIf` — Conditional list mutation + +**Files:** +- Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `EnumerableExtensionTest.cs`: + +```csharp +[Fact] +public void AddIf_AddsItem_WhenConditionIsTrue() +{ + var list = new List { 1, 2 }; + list.AddIf(true, 3); + list.ShouldBe(new[] { 1, 2, 3 }); +} + +[Fact] +public void AddIf_DoesNotAdd_WhenConditionIsFalse() +{ + var list = new List { 1, 2 }; + list.AddIf(false, 3); + list.ShouldBe(new[] { 1, 2 }); +} + +[Fact] +public void AddIf_ReturnsSameListInstance() +{ + var list = new List(); + var returned = list.AddIf(true, 1); + ReferenceEquals(list, returned).ShouldBeTrue(); +} + +[Fact] +public void AddRangeIf_AddsItems_WhenConditionIsTrue() +{ + var list = new List { 1 }; + list.AddRangeIf(true, new[] { 2, 3 }); + list.ShouldBe(new[] { 1, 2, 3 }); +} + +[Fact] +public void AddRangeIf_DoesNotAdd_WhenConditionIsFalse() +{ + var list = new List { 1 }; + list.AddRangeIf(false, new[] { 2, 3 }); + list.ShouldBe(new[] { 1 }); +} + +[Fact] +public void AddRangeIf_ReturnsSameListInstance() +{ + var list = new List(); + var returned = list.AddRangeIf(true, new[] { 1, 2 }); + ReferenceEquals(list, returned).ShouldBeTrue(); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: compile error — `AddIf`, `AddRangeIf` not defined. + +- [ ] **Step 3: Implement the methods** + +Add to `EnumerableExtensions.cs`: + +```csharp +public static IList AddIf(this IList list, bool condition, T item) +{ + if (condition) list.Add(item); + return list; +} + +public static IList AddRangeIf(this IList list, bool condition, IEnumerable items) +{ + if (condition) + foreach (var item in items ?? Enumerable.Empty()) + list.Add(item); + return list; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: all tests PASS including `AddIf_*`, `AddRangeIf_*`. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/EnumerableExtensions.cs src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +git commit -m "feat(enumerable): add AddIf, AddRangeIf" +``` + +--- + +### Task 6: `ConcatIf` — Conditional concatenation + +**Files:** +- Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `EnumerableExtensionTest.cs`: + +```csharp +[Fact] +public void ConcatIf_ConcatenatesOther_WhenConditionIsTrue() +{ + new[] { 1, 2 }.ConcatIf(true, new[] { 3, 4 }).ShouldBe(new[] { 1, 2, 3, 4 }); +} + +[Fact] +public void ConcatIf_ReturnsSource_WhenConditionIsFalse() +{ + new[] { 1, 2 }.ConcatIf(false, new[] { 3, 4 }).ShouldBe(new[] { 1, 2 }); +} + +[Fact] +public void ConcatIf_OnNullSource_ReturnsOther_WhenConditionIsTrue() +{ + ((IEnumerable)null).ConcatIf(true, new[] { 1, 2 }).ShouldBe(new[] { 1, 2 }); +} + +[Fact] +public void ConcatIf_OnNullSource_ReturnsEmpty_WhenConditionIsFalse() +{ + ((IEnumerable)null).ConcatIf(false, new[] { 1, 2 }).ShouldBeEmpty(); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: compile error — `ConcatIf` not defined. + +- [ ] **Step 3: Implement the method** + +Add to `EnumerableExtensions.cs`: + +```csharp +public static IEnumerable ConcatIf( + this IEnumerable source, bool condition, IEnumerable other) +{ + var first = source ?? Enumerable.Empty(); + return condition ? first.Concat(other ?? Enumerable.Empty()) : first; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: all tests PASS including `ConcatIf_*`. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/EnumerableExtensions.cs src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +git commit -m "feat(enumerable): add ConcatIf" +``` + +--- + +### Task 7: `None(predicate)`, `IsSingle`, `IsSingle(predicate)`, `IndexOf` — Predicate queries + +**Files:** +- Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `EnumerableExtensionTest.cs`: + +```csharp +[Fact] +public void None_WithPredicate_ReturnsTrue_WhenNoElementMatches() +{ + new[] { 1, 2, 3 }.None(x => x > 10).ShouldBeTrue(); +} + +[Fact] +public void None_WithPredicate_ReturnsFalse_WhenAnyElementMatches() +{ + new[] { 1, 2, 3 }.None(x => x > 2).ShouldBeFalse(); +} + +[Fact] +public void None_WithPredicate_ReturnsTrue_WhenSourceIsNull() +{ + ((IEnumerable)null).None(x => x > 0).ShouldBeTrue(); +} + +[Fact] +public void IsSingle_ReturnsTrue_WhenExactlyOneElement() +{ + new[] { 42 }.IsSingle().ShouldBeTrue(); +} + +[Fact] +public void IsSingle_ReturnsFalse_WhenEmpty() +{ + Enumerable.Empty().IsSingle().ShouldBeFalse(); +} + +[Fact] +public void IsSingle_ReturnsFalse_WhenMoreThanOneElement() +{ + new[] { 1, 2 }.IsSingle().ShouldBeFalse(); +} + +[Fact] +public void IsSingle_ReturnsFalse_WhenNull() +{ + ((IEnumerable)null).IsSingle().ShouldBeFalse(); +} + +[Fact] +public void IsSingle_WithPredicate_ReturnsTrue_WhenExactlyOneMatches() +{ + new[] { 1, 2, 3 }.IsSingle(x => x > 2).ShouldBeTrue(); +} + +[Fact] +public void IsSingle_WithPredicate_ReturnsFalse_WhenZeroMatch() +{ + new[] { 1, 2, 3 }.IsSingle(x => x > 10).ShouldBeFalse(); +} + +[Fact] +public void IsSingle_WithPredicate_ReturnsFalse_WhenMoreThanOneMatch() +{ + new[] { 1, 2, 3 }.IsSingle(x => x > 1).ShouldBeFalse(); +} + +[Fact] +public void IndexOf_ReturnsFirstMatchingIndex() +{ + new[] { "a", "b", "c" }.IndexOf(x => x == "b").ShouldBe(1); +} + +[Fact] +public void IndexOf_ReturnsZero_WhenFirstElementMatches() +{ + new[] { "a", "b", "c" }.IndexOf(x => x == "a").ShouldBe(0); +} + +[Fact] +public void IndexOf_ReturnsMinusOne_WhenNoMatch() +{ + new[] { "a", "b", "c" }.IndexOf(x => x == "z").ShouldBe(-1); +} + +[Fact] +public void IndexOf_ReturnsMinusOne_WhenSourceIsNull() +{ + ((IEnumerable)null).IndexOf(x => x == "a").ShouldBe(-1); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: compile error — `None` (predicate overload), `IsSingle`, `IndexOf` not defined. + +- [ ] **Step 3: Implement the methods** + +Add to `EnumerableExtensions.cs`: + +```csharp +public static bool None(this IEnumerable source, Func predicate) + => source is null || !source.Any(predicate); + +public static bool IsSingle(this IEnumerable source) +{ + if (source is null) return false; + using var e = source.GetEnumerator(); + return e.MoveNext() && !e.MoveNext(); +} + +public static bool IsSingle(this IEnumerable source, Func predicate) + => source?.Count(predicate) == 1; + +public static int IndexOf(this IEnumerable source, Func predicate) +{ + if (source is null) return -1; + int index = 0; + foreach (var item in source) + { + if (predicate(item)) return index; + index++; + } + return -1; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: all tests PASS including `None_WithPredicate_*`, `IsSingle_*`, `IndexOf_*`. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/EnumerableExtensions.cs src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +git commit -m "feat(enumerable): add None(predicate), IsSingle, IndexOf" +``` + +--- + +### Task 8: `Partition`, `Batch` — Splitting and chunking + +**Files:** +- Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `EnumerableExtensionTest.cs`: + +```csharp +[Fact] +public void Partition_SplitsSequenceIntoMatchedAndRest() +{ + var (matched, rest) = new[] { 1, 2, 3, 4, 5 }.Partition(x => x % 2 == 0); + matched.ShouldBe(new[] { 2, 4 }); + rest.ShouldBe(new[] { 1, 3, 5 }); +} + +[Fact] +public void Partition_AllMatch_ReturnsEmptyRest() +{ + var (matched, rest) = new[] { 2, 4, 6 }.Partition(x => x % 2 == 0); + matched.ShouldBe(new[] { 2, 4, 6 }); + rest.ShouldBeEmpty(); +} + +[Fact] +public void Partition_NoneMatch_ReturnsEmptyMatched() +{ + var (matched, rest) = new[] { 1, 3, 5 }.Partition(x => x % 2 == 0); + matched.ShouldBeEmpty(); + rest.ShouldBe(new[] { 1, 3, 5 }); +} + +[Fact] +public void Partition_OnNullSource_ReturnsTwoEmptyLists() +{ + var (matched, rest) = ((IEnumerable)null).Partition(x => x > 0); + matched.ShouldBeEmpty(); + rest.ShouldBeEmpty(); +} + +[Fact] +public void Batch_SplitsSequenceIntoChunksOfGivenSize() +{ + var result = new[] { 1, 2, 3, 4, 5 }.Batch(2).ToList(); + result.Count.ShouldBe(3); + result[0].ShouldBe(new[] { 1, 2 }); + result[1].ShouldBe(new[] { 3, 4 }); + result[2].ShouldBe(new[] { 5 }); +} + +[Fact] +public void Batch_OnNullSource_ReturnsEmpty() +{ + ((IEnumerable)null).Batch(3).ShouldBeEmpty(); +} + +[Fact] +public void Batch_WhenSizeLargerThanSequence_ReturnsSingleChunk() +{ + var result = new[] { 1, 2 }.Batch(10).ToList(); + result.Count.ShouldBe(1); + result[0].ShouldBe(new[] { 1, 2 }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: compile error — `Partition`, `Batch` not defined. + +- [ ] **Step 3: Implement the methods** + +Add to `EnumerableExtensions.cs`: + +```csharp +public static (IReadOnlyList Matched, IReadOnlyList Rest) Partition( + this IEnumerable source, Func predicate) +{ + var matched = new List(); + var rest = new List(); + foreach (var item in source ?? Enumerable.Empty()) + { + if (predicate(item)) matched.Add(item); + else rest.Add(item); + } + return (matched, rest); +} + +public static IEnumerable> Batch(this IEnumerable source, int size) + => (source ?? Enumerable.Empty()).Chunk(size).Select(c => (IReadOnlyList)c); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: all tests PASS including `Partition_*`, `Batch_*`. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/EnumerableExtensions.cs src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +git commit -m "feat(enumerable): add Partition, Batch" +``` + +--- + +### Task 9: `MinByOrDefault`, `MaxByOrDefault` — Min/Max with null-safe default + +**Files:** +- Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `EnumerableExtensionTest.cs`: + +```csharp +[Fact] +public void MinByOrDefault_ReturnsElementWithSmallestKey() +{ + new[] { 3, 1, 2 }.MinByOrDefault(x => x).ShouldBe(1); +} + +[Fact] +public void MinByOrDefault_ReturnsDefault_WhenSourceIsNull() +{ + ((IEnumerable)null).MinByOrDefault(x => x).ShouldBe(0); +} + +[Fact] +public void MinByOrDefault_ReturnsNull_WhenSourceIsEmpty_ReferenceType() +{ + Enumerable.Empty().MinByOrDefault(x => x).ShouldBeNull(); +} + +[Fact] +public void MaxByOrDefault_ReturnsElementWithLargestKey() +{ + new[] { 3, 1, 2 }.MaxByOrDefault(x => x).ShouldBe(3); +} + +[Fact] +public void MaxByOrDefault_ReturnsDefault_WhenSourceIsNull() +{ + ((IEnumerable)null).MaxByOrDefault(x => x).ShouldBe(0); +} + +[Fact] +public void MaxByOrDefault_ReturnsNull_WhenSourceIsEmpty_ReferenceType() +{ + Enumerable.Empty().MaxByOrDefault(x => x).ShouldBeNull(); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: compile error — `MinByOrDefault`, `MaxByOrDefault` not defined. + +- [ ] **Step 3: Implement the methods** + +Add to `EnumerableExtensions.cs`. Note: BCL `MinBy`/`MaxBy` already return `default` on empty sequences, so null-source handling is all we need to add. + +```csharp +public static T? MinByOrDefault(this IEnumerable source, Func keySelector) + => source is null ? default : source.MinBy(keySelector); + +public static T? MaxByOrDefault(this IEnumerable source, Func keySelector) + => source is null ? default : source.MaxBy(keySelector); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: all tests PASS including `MinByOrDefault_*`, `MaxByOrDefault_*`. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/EnumerableExtensions.cs src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +git commit -m "feat(enumerable): add MinByOrDefault, MaxByOrDefault" +``` + +--- + +### Task 10: `SelectAsync`, `WhenAllList` — Async projection + +**Files:** +- Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` (add `using System.Threading;`) +- Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` (add `using System.Threading;`, `using System.Threading.Tasks;`) + +- [ ] **Step 1: Write the failing tests** + +Add these usings at the top of `EnumerableExtensionTest.cs` if not present: +```csharp +using System.Threading; +using System.Threading.Tasks; +``` + +Add to `EnumerableExtensionTest.cs`: + +```csharp +[Fact] +public async Task SelectAsync_ProjectsEachElementConcurrently() +{ + var result = await new[] { 1, 2, 3 } + .SelectAsync(async x => { await Task.Yield(); return x * 2; }); + result.ShouldBe(new[] { 2, 4, 6 }); +} + +[Fact] +public async Task SelectAsync_OnNullSource_ReturnsEmpty() +{ + var result = await ((IEnumerable)null) + .SelectAsync(async x => x * 2); + result.ShouldBeEmpty(); +} + +[Fact] +public async Task SelectAsync_WithMaxParallel_CapsConcurrency() +{ + int concurrent = 0; + int maxSeen = 0; + + await Enumerable.Range(1, 10).ToList().SelectAsync(async x => + { + var c = Interlocked.Increment(ref concurrent); + Interlocked.Exchange(ref maxSeen, Math.Max(maxSeen, c)); + await Task.Delay(20); + Interlocked.Decrement(ref concurrent); + return x; + }, maxParallel: 3); + + maxSeen.ShouldBeLessThanOrEqualTo(3); +} + +[Fact] +public async Task WhenAllList_ReturnsAllTaskResults_AsReadOnlyList() +{ + IEnumerable> tasks = new[] + { + Task.FromResult(1), + Task.FromResult(2), + Task.FromResult(3) + }; + IReadOnlyList result = await tasks.WhenAllList(); + result.ShouldBe(new[] { 1, 2, 3 }); +} + +[Fact] +public async Task WhenAllList_OnNullSource_ReturnsEmpty() +{ + var result = await ((IEnumerable>)null).WhenAllList(); + result.ShouldBeEmpty(); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: compile error — `SelectAsync`, `WhenAllList` not defined. + +- [ ] **Step 3: Add the using directive** + +Add `using System.Threading;` to `EnumerableExtensions.cs` (after `using System.Threading.Tasks;`). + +- [ ] **Step 4: Implement the methods** + +Add to `EnumerableExtensions.cs`: + +```csharp +public static async Task> SelectAsync( + this IEnumerable source, + Func> selector, + int? maxParallel = null) +{ + if (source is null) return Array.Empty(); + + if (maxParallel is null) + return await Task.WhenAll(source.Select(selector)); + + using var semaphore = new SemaphoreSlim(maxParallel.Value); + var tasks = source.Select(async item => + { + await semaphore.WaitAsync(); + try { return await selector(item); } + finally { semaphore.Release(); } + }); + return await Task.WhenAll(tasks); +} + +public static async Task> WhenAllList(this IEnumerable> tasks) + => await Task.WhenAll(tasks ?? Enumerable.Empty>()); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +dotnet test --filter "ClassName~EnumerableExtensionTest" --verbosity normal +``` + +Expected: all tests PASS including `SelectAsync_*`, `WhenAllList_*`. + +- [ ] **Step 6: Run the full test suite to check for regressions** + +```bash +dotnet test --verbosity normal +``` + +Expected: all tests PASS with no failures. + +- [ ] **Step 7: Commit** + +```bash +git add src/CSharpHelperExtensions/EnumerableExtensions.cs src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +git commit -m "feat(enumerable): add SelectAsync, WhenAllList" +``` + +--- + +## Self-Review + +### Spec Coverage + +| Spec member | Task | +|-------------|------| +| `HasAny` | Task 1 | +| `OrEmpty` | Task 1 | +| `None(predicate?)` | Task 1 (no-arg), Task 7 (predicate) | +| `WhereNotNull` | Task 2 | +| `AsReadOnlyList` | Task 2 | +| `ToHashSetSafe` | Task 2 | +| `Yield` | Task 3 | +| `JoinAsString(separator)` | Task 3 | +| `WithIndex` | Task 3 | +| `ToDictionarySafe(key, value)` | Task 4 | +| `AddIf(condition, item)` | Task 5 | +| `AddRangeIf(condition, items)` | Task 5 | +| `ConcatIf(condition, other)` | Task 6 | +| `IsSingle` / `IsSingle(predicate)` | Task 7 | +| `IndexOf(predicate)` | Task 7 | +| `Partition(predicate)` | Task 8 | +| `Batch(size)` | Task 8 | +| `MinByOrDefault(keySelector)` | Task 9 | +| `MaxByOrDefault(keySelector)` | Task 9 | +| `SelectAsync(selector, maxParallel?)` | Task 10 | +| `WhenAllList` | Task 10 | + +All 21 spec members (counting `None` overloads separately) are covered. No gaps. diff --git a/src/CSharpHelperExtensions/EnumerableExtensions.cs b/src/CSharpHelperExtensions/EnumerableExtensions.cs index 245d6ab..900e936 100644 --- a/src/CSharpHelperExtensions/EnumerableExtensions.cs +++ b/src/CSharpHelperExtensions/EnumerableExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -53,10 +53,16 @@ public static class EnumerableExtensions public static bool ContainsOnly(this IEnumerable enumerable, params T[] value) { if (value.IsNullOrEmpty() || enumerable.IsNullOrEmpty()) + { return false; + } + var list = enumerable.ToList(); if (list.Count != value.Length) + { return false; + } + var set = new HashSet(list); return value.All(set.Contains); } @@ -155,7 +161,9 @@ public static bool AreEqual( public static IEnumerable CleanNullOrEmptyItems(this IEnumerable value) { if (value is null) + { return []; + } return value .Where(item => item is string s ? !string.IsNullOrWhiteSpace(s) : item is not null) .ToList(); @@ -179,12 +187,12 @@ public static IEnumerable CleanNullOrEmptyItems(this IEnumerable value) /// new[] { 1, 2, 3 }.IsNullOrEmpty() // false /// /// - public static bool IsNullOrEmpty(this IEnumerable values) - => values is null || !values.Any(item => item is not null); + public static bool IsNullOrEmpty(this IEnumerable values) => + values is null || !values.Any(item => item is not null); /// /// Executes an action on each element of the sequence and returns the original sequence unchanged. - /// Useful for chaining side-effectful operations in a fluent pipeline. + /// Useful for chaining side-effect operations in a fluent pipeline. /// /// /// The sequence to iterate. @@ -223,8 +231,8 @@ public static IEnumerable ForEach(this IEnumerable values, Action ex /// /// A that completes when all async actions have finished. /// - public static Task ForEach(this IEnumerable values, Func execute) - => Task.WhenAll(values.OrEmpty().Select(execute)); + public static Task ForEach(this IEnumerable values, Func execute) => + Task.WhenAll(values.OrEmpty().Select(execute)); /// /// Asynchronously projects each element of a sequence using the given async transform @@ -240,10 +248,13 @@ public static Task ForEach(this IEnumerable values, Func execute) /// public static async IAsyncEnumerable ForEach( this IEnumerable values, - Func> execute) + Func> execute + ) { foreach (var item in values.OrEmpty()) + { yield return await execute(item); + } } /// @@ -283,7 +294,9 @@ public static TOut Reduce( { var acc = initialValue; foreach (var item in values.OrEmpty()) + { acc = execute(item, acc); + } return acc; } @@ -323,7 +336,9 @@ public static TOut Reduce( var collection = values?.ToList() ?? []; var acc = initialValue; for (int i = 0; i < collection.Count; i++) + { acc = execute(collection[i], acc, i); + } return acc; } @@ -336,8 +351,7 @@ public static TOut Reduce( /// if is not and has at least one element; /// otherwise . /// - public static bool HasAny(this IEnumerable source) - => source != null && source.Any(); + public static bool HasAny(this IEnumerable source) => source != null && source.Any(); /// /// Returns the sequence unchanged if non-null, or an empty sequence if the source is . @@ -347,8 +361,8 @@ public static bool HasAny(this IEnumerable source) /// /// if it is not ; otherwise . /// - public static IEnumerable OrEmpty(this IEnumerable source) - => source ?? System.Linq.Enumerable.Empty(); + public static IEnumerable OrEmpty(this IEnumerable source) => + source ?? System.Linq.Enumerable.Empty(); /// /// Returns if the sequence is or contains no elements. @@ -359,8 +373,7 @@ public static IEnumerable OrEmpty(this IEnumerable source) /// if is or empty; /// otherwise . /// - public static bool None(this IEnumerable source) - => source is null || !source.Any(); + public static bool None(this IEnumerable source) => source is null || !source.Any(); /// /// Returns if no element in the sequence satisfies the predicate, @@ -373,8 +386,8 @@ public static bool None(this IEnumerable source) /// if is or no element /// matches ; otherwise . /// - public static bool None(this IEnumerable source, Func predicate) - => source is null || !source.Any(predicate); + public static bool None(this IEnumerable source, Func predicate) => + source is null || !source.Any(predicate); /// /// Returns if the sequence contains exactly one element. @@ -389,7 +402,9 @@ public static bool None(this IEnumerable source, Func predicate) public static bool IsSingle(this IEnumerable source) { if (source is null) + { return false; + } using var e = source.GetEnumerator(); return e.MoveNext() && !e.MoveNext(); } @@ -405,8 +420,8 @@ public static bool IsSingle(this IEnumerable source) /// if exactly one element matches ; /// otherwise . /// - public static bool IsSingle(this IEnumerable source, Func predicate) - => source?.Count(predicate) == 1; + public static bool IsSingle(this IEnumerable source, Func predicate) => + source?.Count(predicate) == 1; /// /// Returns the zero-based index of the first element in the sequence that satisfies the predicate, @@ -421,12 +436,16 @@ public static bool IsSingle(this IEnumerable source, Func predica public static int IndexOf(this IEnumerable source, Func predicate) { if (source is null) + { return -1; + } int index = 0; foreach (var item in source) { if (predicate(item)) + { return index; + } index++; } return -1; @@ -439,8 +458,11 @@ public static int IndexOf(this IEnumerable source, Func predicate /// The element type (must be a reference type). /// The sequence to filter. /// A sequence containing only non-null elements. - public static IEnumerable WhereNotNull(this IEnumerable source) where T : class - => source is null ? System.Linq.Enumerable.Empty() : source.Where(x => x is not null).Cast(); + public static IEnumerable WhereNotNull(this IEnumerable source) + where T : class => + source is null + ? System.Linq.Enumerable.Empty() + : source.Where(x => x is not null).Cast(); /// /// Materializes the sequence into an , preserving order. @@ -449,8 +471,8 @@ public static IEnumerable WhereNotNull(this IEnumerable source) where /// The element type. /// The sequence to materialize. /// An containing all elements in order. - public static IReadOnlyList AsReadOnlyList(this IEnumerable source) - => (source ?? System.Linq.Enumerable.Empty()).ToList(); + public static IReadOnlyList AsReadOnlyList(this IEnumerable source) => + (source ?? System.Linq.Enumerable.Empty()).ToList(); /// /// Converts the sequence to a , deduplicating elements. @@ -459,8 +481,8 @@ public static IReadOnlyList AsReadOnlyList(this IEnumerable source) /// The element type. /// The sequence to convert. /// A containing the distinct elements. - public static HashSet ToHashSetSafe(this IEnumerable source) - => source is null ? new HashSet() : source.ToHashSet(); + public static HashSet ToHashSetSafe(this IEnumerable source) => + source is null ? new HashSet() : source.ToHashSet(); /// /// Wraps a single value in an containing only that item. @@ -481,8 +503,8 @@ public static IEnumerable Yield(this T item) /// The sequence whose elements to join. /// The string to use as a separator between elements. /// A string of all elements joined by , or if source is . - public static string JoinAsString(this IEnumerable source, string separator) - => string.Join(separator, source ?? System.Linq.Enumerable.Empty()); + public static string JoinAsString(this IEnumerable source, string separator) => + string.Join(separator, source ?? System.Linq.Enumerable.Empty()); /// /// Projects each element of a sequence into a tuple of its zero-based index and the element itself. @@ -491,8 +513,8 @@ public static string JoinAsString(this IEnumerable source, string separato /// The element type. /// The sequence to index. /// A sequence of (Index, Item) tuples. - public static IEnumerable<(int Index, T Item)> WithIndex(this IEnumerable source) - => (source ?? System.Linq.Enumerable.Empty()).Select((item, i) => (i, item)); + public static IEnumerable<(int Index, T Item)> WithIndex(this IEnumerable source) => + (source ?? System.Linq.Enumerable.Empty()).Select((item, i) => (i, item)); /// /// Converts a sequence to a using the specified key and value selectors. @@ -526,12 +548,15 @@ public static string JoinAsString(this IEnumerable source, string separato public static Dictionary ToDictionarySafe( this IEnumerable source, Func keySelector, - Func valueSelector) + Func valueSelector + ) where TKey : notnull { var dict = new Dictionary(); foreach (var item in source ?? System.Linq.Enumerable.Empty()) + { dict[keySelector(item)] = valueSelector(item); + } return dict; } @@ -553,7 +578,9 @@ public static Dictionary ToDictionarySafe( public static IList AddIf(this IList list, bool condition, T item) { if (condition) + { list.Add(item); + } return list; } @@ -576,8 +603,12 @@ public static IList AddIf(this IList list, bool condition, T item) public static IList AddRangeIf(this IList list, bool condition, IEnumerable items) { if (condition) + { foreach (var item in items ?? System.Linq.Enumerable.Empty()) + { list.Add(item); + } + } return list; } @@ -604,7 +635,10 @@ public static IList AddRangeIf(this IList list, bool condition, IEnumer /// /// public static IEnumerable ConcatIf( - this IEnumerable source, bool condition, IEnumerable other) + this IEnumerable source, + bool condition, + IEnumerable other + ) { var first = source ?? System.Linq.Enumerable.Empty(); return condition ? first.Concat(other ?? System.Linq.Enumerable.Empty()) : first; @@ -623,16 +657,22 @@ public static IEnumerable ConcatIf( /// and Rest containing all other elements, both in original order. /// public static (IReadOnlyList Matched, IReadOnlyList Remaining) Partition( - this IEnumerable source, Func predicate) + this IEnumerable source, + Func predicate + ) { var matched = new List(); var rest = new List(); foreach (var item in source ?? System.Linq.Enumerable.Empty()) { if (predicate(item)) + { matched.Add(item); + } else + { rest.Add(item); + } } return (matched, rest); } @@ -646,8 +686,8 @@ public static (IReadOnlyList Matched, IReadOnlyList Remaining) PartitionThe sequence to batch. If , treated as empty. /// The maximum number of elements per chunk. /// A sequence of read-only lists, each containing at most elements. - public static IEnumerable> Batch(this IEnumerable source, int size) - => (source ?? System.Linq.Enumerable.Empty()).Chunk(size).Select(c => (IReadOnlyList)c); + public static IEnumerable> Batch(this IEnumerable source, int size) => + (source ?? System.Linq.Enumerable.Empty()).Chunk(size).Select(c => (IReadOnlyList)c); /// /// Returns the element with the smallest key value, or () @@ -666,8 +706,10 @@ public static IEnumerable> Batch(this IEnumerable source, /// System.Linq.Enumerable.Empty<string>().MinByOrDefault(x => x) // null /// /// - public static T? MinByOrDefault(this IEnumerable source, Func keySelector) - => source is null ? default : source.MinBy(keySelector); + public static T? MinByOrDefault( + this IEnumerable source, + Func keySelector + ) => source is null ? default : source.MinBy(keySelector); /// /// Returns the element with the largest key value, or () @@ -686,8 +728,10 @@ public static IEnumerable> Batch(this IEnumerable source, /// System.Linq.Enumerable.Empty<string>().MaxByOrDefault(x => x) // null /// /// - public static T? MaxByOrDefault(this IEnumerable source, Func keySelector) - => source is null ? default : source.MaxBy(keySelector); + public static T? MaxByOrDefault( + this IEnumerable source, + Func keySelector + ) => source is null ? default : source.MaxBy(keySelector); /// /// Projects each element of a sequence to a using an async selector, @@ -709,21 +753,31 @@ public static IEnumerable> Batch(this IEnumerable source, public static async Task> SelectAsync( this IEnumerable source, Func> selector, - int? maxParallel = null) + int? maxParallel = null + ) { if (source is null) + { return []; + } if (maxParallel is null) + { return await Task.WhenAll(source.Select(selector)); + } using var semaphore = new SemaphoreSlim(maxParallel.Value); var tasks = source.Select(async item => { await semaphore.WaitAsync(); try - { return await selector(item); } - finally { semaphore.Release(); } + { + return await selector(item); + } + finally + { + semaphore.Release(); + } }); return await Task.WhenAll(tasks); } @@ -737,6 +791,6 @@ public static async Task> SelectAsync( /// /// A that completes with an containing all task results. /// - public static async Task> WhenAllList(this IEnumerable> tasks) - => await Task.WhenAll(tasks ?? []); + public static async Task> WhenAllList(this IEnumerable> tasks) => + await Task.WhenAll(tasks ?? []); } diff --git a/src/CSharpHelperExtensions/StringExtensions.cs b/src/CSharpHelperExtensions/StringExtensions.cs index 804b4c1..4d950cd 100644 --- a/src/CSharpHelperExtensions/StringExtensions.cs +++ b/src/CSharpHelperExtensions/StringExtensions.cs @@ -73,7 +73,8 @@ public static bool IsNullOrEmpty(this string value) /// "not-a-number".ToNullable<int>() // throws FormatException /// /// - public static T? ToNullable(this string input) where T : struct + public static T? ToNullable(this string input) + where T : struct { if (input.IsNullOrEmpty()) { @@ -109,8 +110,8 @@ public static bool IsNullOrEmpty(this string value) /// The string to test. /// The value to return when is absent. /// if it has content; otherwise . - public static string OrDefault(this string input, string fallback) - => string.IsNullOrWhiteSpace(input) ? fallback : input; + public static string OrDefault(this string input, string fallback) => + string.IsNullOrWhiteSpace(input) ? fallback : input; /// /// Returns the first characters of . @@ -125,7 +126,9 @@ public static string Truncate(this string input, int maxLength) { ArgumentOutOfRangeException.ThrowIfNegative(maxLength); if (input == null) + { return string.Empty; + } return input.Length <= maxLength ? input : input[..maxLength]; } @@ -138,7 +141,9 @@ public static string Truncate(this string input, int maxLength) public static string Reverse(this string input) { if (input.IsNullOrEmpty()) + { return string.Empty; + } var chars = input.ToCharArray(); Array.Reverse(chars); return new string(chars); @@ -147,20 +152,20 @@ public static string Reverse(this string input) /// Trims whitespace then converts to lowercase. /// The string to transform. Accepts . /// The trimmed, lowercased string, or if is . - public static string TrimToLower(this string input) - => input?.Trim().ToLowerInvariant() ?? string.Empty; + public static string TrimToLower(this string input) => + input?.Trim().ToLowerInvariant() ?? string.Empty; /// Trims whitespace then converts to uppercase. /// The string to transform. Accepts . /// The trimmed, upper-cased string, or if is . - public static string TrimToUpper(this string input) - => input?.Trim().ToUpperInvariant() ?? string.Empty; + public static string TrimToUpper(this string input) => + input?.Trim().ToUpperInvariant() ?? string.Empty; /// Returns if both strings are equal using ordinal case-insensitive comparison. /// The source string. /// The string to compare to. - public static bool EqualsIgnoreCase(this string input, string other) - => string.Equals(input, other, StringComparison.OrdinalIgnoreCase); + public static bool EqualsIgnoreCase(this string input, string other) => + string.Equals(input, other, StringComparison.OrdinalIgnoreCase); /// Returns if contains using ordinal case-insensitive comparison. /// The string to search in. @@ -168,7 +173,9 @@ public static bool EqualsIgnoreCase(this string input, string other) public static bool ContainsIgnoreCase(this string input, string value) { if (input == null || value == null) + { return false; + } return input.Contains(value, StringComparison.OrdinalIgnoreCase); } @@ -178,7 +185,9 @@ public static bool ContainsIgnoreCase(this string input, string value) public static bool StartsWithIgnoreCase(this string input, string value) { if (input == null || value == null) + { return false; + } return input.StartsWith(value, StringComparison.OrdinalIgnoreCase); } @@ -188,7 +197,9 @@ public static bool StartsWithIgnoreCase(this string input, string value) public static bool EndsWithIgnoreCase(this string input, string value) { if (input == null || value == null) + { return false; + } return input.EndsWith(value, StringComparison.OrdinalIgnoreCase); } @@ -209,40 +220,48 @@ public static bool EndsWithIgnoreCase(this string input, string value) public static string MaskStart(this string input, int visibleCount, char maskChar = '*') { if (input.IsNullOrEmpty()) + { return string.Empty; + } if (visibleCount >= input.Length) + { return input; + } var maskLength = input.Length - visibleCount; return new string(maskChar, maskLength) + input[maskLength..]; } /// Parses as using invariant culture. Returns if the string is not a valid integer. /// The string to parse. - public static int? ToIntOrNull(this string input) - => int.TryParse(input, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null; + public static int? ToIntOrNull(this string input) => + int.TryParse(input, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) + ? v + : null; /// Parses as using invariant culture. Returns if the string is not a valid decimal number. /// The string to parse. - public static decimal? ToDecimalOrNull(this string input) - => decimal.TryParse(input, NumberStyles.Number, CultureInfo.InvariantCulture, out var v) ? v : null; + public static decimal? ToDecimalOrNull(this string input) => + decimal.TryParse(input, NumberStyles.Number, CultureInfo.InvariantCulture, out var v) + ? v + : null; /// Parses as . Returns if the string is not a valid date/time. /// The string to parse. - public static DateTime? ToDateTimeOrNull(this string input) - => DateTime.TryParse(input, out var v) ? v : null; + public static DateTime? ToDateTimeOrNull(this string input) => + DateTime.TryParse(input, out var v) ? v : null; /// Parses as . Returns if the string is not a valid GUID. /// The string to parse. - public static Guid? ToGuidOrNull(this string input) - => Guid.TryParse(input, out var v) ? v : null; + public static Guid? ToGuidOrNull(this string input) => + Guid.TryParse(input, out var v) ? v : null; /// /// Parses as . Returns if the string is not a valid boolean /// ("true" and "false" are accepted, case-insensitive; other values return ). /// /// The string to parse. - public static bool? ToBoolOrNull(this string input) - => bool.TryParse(input, out var v) ? v : null; + public static bool? ToBoolOrNull(this string input) => + bool.TryParse(input, out var v) ? v : null; /// Encodes as a standard Base64 string using UTF-8 encoding. /// The string to encode. @@ -250,7 +269,9 @@ public static string MaskStart(this string input, int visibleCount, char maskCha public static string Base64Encode(this string input) { if (input == null) + { return null; + } return Convert.ToBase64String(Encoding.UTF8.GetBytes(input)); } @@ -260,7 +281,9 @@ public static string Base64Encode(this string input) public static string Base64Decode(this string input) { if (input == null) + { return null; + } return Encoding.UTF8.GetString(Convert.FromBase64String(input)); } @@ -273,8 +296,11 @@ public static string Base64Decode(this string input) public static string ToBase64Url(this string input) { if (input == null) + { return null; - return Convert.ToBase64String(Encoding.UTF8.GetBytes(input)) + } + return Convert + .ToBase64String(Encoding.UTF8.GetBytes(input)) .Replace('+', '-') .Replace('/', '_') .TrimEnd('='); @@ -288,13 +314,15 @@ public static string ToBase64Url(this string input) public static string FromBase64Url(this string input) { if (input == null) + { return null; + } var padded = input.Replace('-', '+').Replace('_', '/'); padded = (padded.Length % 4) switch { 2 => padded + "==", 3 => padded + "=", - _ => padded + _ => padded, }; return Encoding.UTF8.GetString(Convert.FromBase64String(padded)); } @@ -302,14 +330,14 @@ public static string FromBase64Url(this string input) /// Converts to its UTF-8 byte representation. /// The string to encode. Returns an empty array when . /// UTF-8 encoded bytes. - public static byte[] ToUtf8Bytes(this string input) - => Encoding.UTF8.GetBytes(input ?? string.Empty); + public static byte[] ToUtf8Bytes(this string input) => + Encoding.UTF8.GetBytes(input ?? string.Empty); /// Converts to a seekable using UTF-8 encoding. /// The string to stream. Returns an empty stream when . /// A containing the UTF-8 bytes. - public static MemoryStream ToUtf8Stream(this string input) - => new(Encoding.UTF8.GetBytes(input ?? string.Empty)); + public static MemoryStream ToUtf8Stream(this string input) => + new(Encoding.UTF8.GetBytes(input ?? string.Empty)); /// /// Joins using as the delimiter. @@ -318,8 +346,8 @@ public static MemoryStream ToUtf8Stream(this string input) /// The separator string (this). A separator is treated as empty. /// The strings to join. /// The joined string. - public static string JoinWith(this string separator, IEnumerable values) - => string.Join(separator ?? string.Empty, values ?? []); + public static string JoinWith(this string separator, IEnumerable values) => + string.Join(separator ?? string.Empty, values ?? []); /// /// Splits on and removes empty entries. @@ -331,7 +359,9 @@ public static string JoinWith(this string separator, IEnumerable values) public static string[] SplitNonEmpty(this string input, params char[] separators) { if (input.IsNullOrEmpty()) + { return []; + } return input.Split(separators, StringSplitOptions.RemoveEmptyEntries); } @@ -341,7 +371,9 @@ public static string[] SplitNonEmpty(this string input, params char[] separators public static string RemoveWhitespace(this string input) { if (input == null) + { return string.Empty; + } return string.Concat(input.Where(c => !char.IsWhiteSpace(c))); } @@ -353,7 +385,9 @@ public static string RemoveWhitespace(this string input) public static string CollapseWhitespace(this string input) { if (input.IsNullOrEmpty()) + { return string.Empty; + } return CollapseRegex.Replace(input.Trim(), " "); } @@ -363,13 +397,20 @@ public static string CollapseWhitespace(this string input) /// The string to modify. Returns when . /// Replacement pairs: each OldValue is replaced by NewValue, applied sequentially. /// The resulting string after all replacements. - public static string ReplaceMany(this string input, IEnumerable<(string OldValue, string NewValue)> pairs) + public static string ReplaceMany( + this string input, + IEnumerable<(string OldValue, string NewValue)> pairs + ) { if (input == null) + { return string.Empty; + } var result = input; foreach (var (oldValue, newValue) in pairs ?? []) + { result = result.Replace(oldValue, newValue); + } return result; } @@ -382,31 +423,35 @@ public static string ReplaceMany(this string input, IEnumerable<(string OldValue public static string RemoveDiacritics(this string input) { if (input.IsNullOrEmpty()) + { return string.Empty; + } var normalized = input.Normalize(NormalizationForm.FormD); var sb = new StringBuilder(normalized.Length); foreach (var c in normalized) { if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark) + { sb.Append(c); + } } return sb.ToString().Normalize(NormalizationForm.FormC); } /// Returns if is non-empty and all characters are digits (0–9). /// The string to test. - public static bool IsNumeric(this string input) - => !input.IsNullOrEmpty() && input.All(char.IsDigit); + public static bool IsNumeric(this string input) => + !input.IsNullOrEmpty() && input.All(char.IsDigit); /// Returns if is non-empty and all characters are Unicode letters. /// The string to test. - public static bool IsAlpha(this string input) - => !input.IsNullOrEmpty() && input.All(char.IsLetter); + public static bool IsAlpha(this string input) => + !input.IsNullOrEmpty() && input.All(char.IsLetter); /// Returns if is non-empty and all characters are Unicode letters or digits. /// The string to test. - public static bool IsAlphaNumeric(this string input) - => !input.IsNullOrEmpty() && input.All(char.IsLetterOrDigit); + public static bool IsAlphaNumeric(this string input) => + !input.IsNullOrEmpty() && input.All(char.IsLetterOrDigit); /// Returns with prepended if it does not already start with it. /// The string to check. Returns when . @@ -416,7 +461,9 @@ public static string EnsurePrefix(this string input, string prefix) { ArgumentNullException.ThrowIfNull(prefix); if (input == null) + { return prefix; + } return input.StartsWith(prefix, StringComparison.Ordinal) ? input : prefix + input; } @@ -428,7 +475,9 @@ public static string EnsureSuffix(this string input, string suffix) { ArgumentNullException.ThrowIfNull(suffix); if (input == null) + { return suffix; + } return input.EndsWith(suffix, StringComparison.Ordinal) ? input : input + suffix; } @@ -440,10 +489,10 @@ public static string TrimPrefix(this string input, string prefix) { ArgumentNullException.ThrowIfNull(prefix); if (input == null) + { return string.Empty; - return input.StartsWith(prefix, StringComparison.Ordinal) - ? input[prefix.Length..] - : input; + } + return input.StartsWith(prefix, StringComparison.Ordinal) ? input[prefix.Length..] : input; } /// Removes from the end of if present. @@ -454,10 +503,10 @@ public static string TrimSuffix(this string input, string suffix) { ArgumentNullException.ThrowIfNull(suffix); if (input == null) + { return string.Empty; - return input.EndsWith(suffix, StringComparison.Ordinal) - ? input[..^suffix.Length] - : input; + } + return input.EndsWith(suffix, StringComparison.Ordinal) ? input[..^suffix.Length] : input; } /// @@ -469,7 +518,9 @@ public static string TrimSuffix(this string input, string suffix) public static string ToSlug(this string input) { if (input.IsNullOrEmpty()) + { return string.Empty; + } var clean = input.RemoveDiacritics().ToLowerInvariant(); var sb = new StringBuilder(clean.Length); var lastWasDash = false; @@ -487,7 +538,9 @@ public static string ToSlug(this string input) } } if (sb.Length > 0 && sb[^1] == '-') + { sb.Length--; + } return sb.ToString(); } @@ -500,10 +553,14 @@ public static string ToSlug(this string input) public static string ToTitleCase(this string input) { if (input.IsNullOrEmpty()) + { return string.Empty; + } var words = input.ToLowerInvariant().CollapseWhitespace().Split(' '); for (var i = 0; i < words.Length; i++) + { words[i] = char.ToUpperInvariant(words[i][0]) + words[i][1..]; + } return string.Join(" ", words); } } From 91317be6a58d53701c437337d183809f1938c0d6 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 18:56:05 -0400 Subject: [PATCH 17/21] editorconfig fixes --- .editorconfig | 1 + .../plans/2026-05-27-totitlecase.md | 100 ------------------ 2 files changed, 1 insertion(+), 100 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-27-totitlecase.md diff --git a/.editorconfig b/.editorconfig index 4b3e93c..ff84d5e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -39,6 +39,7 @@ indent_size = 4 tab_width = 4 # ---- .NET code style ---- +csharp_prefer_braces = true:warning # Prefer language keywords over BCL type names dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion diff --git a/docs/superpowers/plans/2026-05-27-totitlecase.md b/docs/superpowers/plans/2026-05-27-totitlecase.md deleted file mode 100644 index 00eeca2..0000000 --- a/docs/superpowers/plans/2026-05-27-totitlecase.md +++ /dev/null @@ -1,100 +0,0 @@ -# ToTitleCase Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `ToTitleCase` extension method to `StringExtensions` that lowercases the input, collapses whitespace, and capitalizes the first letter of each space-delimited word. - -**Architecture:** Single method added to the existing `StringExtensions` static class. Reuses the already-compiled `CollapseRegex` field in the same file. Follows the null-safe, `string.Empty`-on-null pattern used throughout the class. - -**Tech Stack:** C# 13 / net10.0, xUnit, FluentAssertions - ---- - -### Task 1: Write the failing test - -**Files:** -- Modify: `src/CSharpHelperExtensions.Test/StringExtensionTest.cs` - -- [ ] **Step 1: Add the failing test** - -Open `src/CSharpHelperExtensions.Test/StringExtensionTest.cs` and append the following fact inside the `StringExtensionTest` class (after the last closing `}` of the last `[Fact]`, before the class `}`): - -```csharp -[Fact] -public void Verify_ToTitleCase_CapitalizesFirstLetterOfEachWord() -{ - "hello world".ToTitleCase().Should().Be("Hello World"); - " hELLO wORLD ".ToTitleCase().Should().Be("Hello World"); - "it's a test".ToTitleCase().Should().Be("It's A Test"); - "SINGLE".ToTitleCase().Should().Be("Single"); - ((string)null).ToTitleCase().Should().Be(""); - "".ToTitleCase().Should().Be(""); - " ".ToTitleCase().Should().Be(""); -} -``` - -- [ ] **Step 2: Run the test to confirm it fails** - -```bash -dotnet test --filter "FullyQualifiedName~Verify_ToTitleCase" -``` - -Expected: build error — `'string' does not contain a definition for 'ToTitleCase'` - ---- - -### Task 2: Implement `ToTitleCase` - -**Files:** -- Modify: `src/CSharpHelperExtensions/StringExtensions.cs` - -- [ ] **Step 1: Add the method** - -Open `src/CSharpHelperExtensions/StringExtensions.cs`. Append the following method inside the `StringExtensions` class, after the closing brace of `ToSlug` and before the final class `}`: - -```csharp -/// -/// Converts to simple title case: lowercases the string, -/// collapses whitespace, then capitalizes the first letter of each word. -/// -/// The string to convert. Returns when or whitespace. -/// The title-cased string, e.g. "hello world""Hello World". -public static string ToTitleCase(this string input) -{ - if (input.IsNullOrEmpty()) return string.Empty; - var collapsed = CollapseRegex.Replace(input.Trim().ToLowerInvariant(), " "); - var words = collapsed.Split(' '); - for (var i = 0; i < words.Length; i++) - { - if (words[i].Length > 0) - words[i] = char.ToUpperInvariant(words[i][0]) + words[i][1..]; - } - return string.Join(" ", words); -} -``` - -- [ ] **Step 2: Run the test to confirm it passes** - -```bash -dotnet test --filter "FullyQualifiedName~Verify_ToTitleCase" -``` - -Expected output: -``` -Passed! - Failed: 0, Passed: 1, Skipped: 0 -``` - -- [ ] **Step 3: Run the full test suite to check for regressions** - -```bash -dotnet test -``` - -Expected: all tests pass, no failures. - -- [ ] **Step 4: Commit** - -```bash -git add src/CSharpHelperExtensions/StringExtensions.cs src/CSharpHelperExtensions.Test/StringExtensionTest.cs -git commit -m "feat: add ToTitleCase string extension" -``` From b454a2d6e22b9c9d0f0aa9f8bcbfe9200858b1a0 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 19:04:32 -0400 Subject: [PATCH 18/21] enumerable extension plan --- .../plans/2026-05-28-enumerable-extensions.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/superpowers/plans/2026-05-28-enumerable-extensions.md b/docs/superpowers/plans/2026-05-28-enumerable-extensions.md index 45637b9..3213b08 100644 --- a/docs/superpowers/plans/2026-05-28-enumerable-extensions.md +++ b/docs/superpowers/plans/2026-05-28-enumerable-extensions.md @@ -22,6 +22,7 @@ ### Task 1: `HasAny`, `OrEmpty`, `None()` — Collection presence shortcuts **Files:** + - Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` - Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` @@ -115,6 +116,7 @@ git commit -m "feat(enumerable): add HasAny, OrEmpty, None" ### Task 2: `WhereNotNull`, `AsReadOnlyList`, `ToHashSetSafe` — Materialization helpers **Files:** + - Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` - Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` @@ -207,6 +209,7 @@ git commit -m "feat(enumerable): add WhereNotNull, AsReadOnlyList, ToHashSetSafe ### Task 3: `Yield`, `JoinAsString`, `WithIndex` — Sequence transforms **Files:** + - Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` - Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` @@ -306,6 +309,7 @@ git commit -m "feat(enumerable): add Yield, JoinAsString, WithIndex" ### Task 4: `ToDictionarySafe` — Duplicate-key-safe dictionary **Files:** + - Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` - Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` @@ -386,6 +390,7 @@ git commit -m "feat(enumerable): add ToDictionarySafe" ### Task 5: `AddIf`, `AddRangeIf` — Conditional list mutation **Files:** + - Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` - Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` @@ -491,6 +496,7 @@ git commit -m "feat(enumerable): add AddIf, AddRangeIf" ### Task 6: `ConcatIf` — Conditional concatenation **Files:** + - Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` - Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` @@ -565,6 +571,7 @@ git commit -m "feat(enumerable): add ConcatIf" ### Task 7: `None(predicate)`, `IsSingle`, `IsSingle(predicate)`, `IndexOf` — Predicate queries **Files:** + - Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` - Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` @@ -717,6 +724,7 @@ git commit -m "feat(enumerable): add None(predicate), IsSingle, IndexOf" ### Task 8: `Partition`, `Batch` — Splitting and chunking **Files:** + - Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` - Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` @@ -832,6 +840,7 @@ git commit -m "feat(enumerable): add Partition, Batch" ### Task 9: `MinByOrDefault`, `MaxByOrDefault` — Min/Max with null-safe default **Files:** + - Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` - Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` @@ -917,12 +926,14 @@ git commit -m "feat(enumerable): add MinByOrDefault, MaxByOrDefault" ### Task 10: `SelectAsync`, `WhenAllList` — Async projection **Files:** + - Modify: `src/CSharpHelperExtensions/EnumerableExtensions.cs` (add `using System.Threading;`) - Modify: `src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs` (add `using System.Threading;`, `using System.Threading.Tasks;`) - [ ] **Step 1: Write the failing tests** Add these usings at the top of `EnumerableExtensionTest.cs` if not present: + ```csharp using System.Threading; using System.Threading.Tasks; From 1872ecc7c16796d110c25063299163a88384be73 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 19:18:24 -0400 Subject: [PATCH 19/21] update value extensions --- CLAUDE.md | 6 +- README.md | 9 + sample/value-extensions.ipynb | 486 ++++++++++++++++++ ...ExtensionTest.cs => ValueExtensionTest.cs} | 3 +- ...enericExtensions.cs => ValueExtensions.cs} | 23 +- 5 files changed, 508 insertions(+), 19 deletions(-) create mode 100644 sample/value-extensions.ipynb rename src/CSharpHelperExtensions.Test/{GenericExtensionTest.cs => ValueExtensionTest.cs} (98%) rename src/CSharpHelperExtensions/{GenericExtensions.cs => ValueExtensions.cs} (85%) diff --git a/CLAUDE.md b/CLAUDE.md index 307ee74..e3a6282 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,15 +53,15 @@ The library splits extensions across three namespaces — callers must import th | File | Namespace | Key types | |------|-----------|-----------| -| `GenericExtensions.cs` | `CSharpHelperExtensions` | `In`, `IsNullOrEmpty` (string), `IsBetween`, `ToJson` | +| `ValueExtensions.cs` | `CSharpHelperExtensions.Values` | `In`, `IsBetween`, `ToJson` | | `EnumerableExtensions.cs` | `CSharpHelperExtensions.Enumerable` | `IsNullOrEmpty`, `HasAny`, `None`, `CleanNullOrEmptyItems`, `WhereNotNull`, `ContainsOnly`, `AreEqual`, `ForEach`, `Reduce`, `SelectAsync`, `WhenAllList`, `Partition`, `Batch`, `MinByOrDefault`, `MaxByOrDefault`, `ToDictionarySafe`, `AddIf`, `AddRangeIf`, `ConcatIf`, `IsSingle`, `IndexOf`, `Yield`, `WithIndex`, `JoinAsString`, `AsReadOnlyList`, `ToHashSetSafe`, `OrEmpty` | | `StringExtensions.cs` | `CSharpHelperExtensions.Strings` | `IsNullOrEmpty`, `HasValue`, `OrEmpty`, `OrDefault`, `Truncate`, `Reverse`, `TrimToLower`, `TrimToUpper`, `ToTitleCase`, `ToSlug`, `MaskStart`, `EqualsIgnoreCase`, `ContainsIgnoreCase`, `StartsWithIgnoreCase`, `EndsWithIgnoreCase`, `EnsurePrefix`, `EnsureSuffix`, `TrimPrefix`, `TrimSuffix`, `SplitNonEmpty`, `JoinWith`, `ReplaceMany`, `RemoveWhitespace`, `CollapseWhitespace`, `RemoveDiacritics`, `IsNumeric`, `IsAlpha`, `IsAlphaNumeric`, `ToNullable`, `ToIntOrNull`, `ToDecimalOrNull`, `ToDateTimeOrNull`, `ToGuidOrNull`, `ToBoolOrNull`, `Base64Encode`, `Base64Decode`, `ToBase64Url`, `FromBase64Url`, `ToUtf8Bytes`, `ToUtf8Stream` | -`IsNullOrEmpty` exists in **both** `GenericExtensions` (for `string`) and `EnumerableExtensions` (for `IEnumerable`). Be careful about which namespace is imported. +`IsNullOrEmpty` exists in **both** `StringExtensions` (for `string`, namespace `CSharpHelperExtensions.Strings`) and `EnumerableExtensions` (for `IEnumerable`, namespace `CSharpHelperExtensions.Enumerable`). Be careful about which namespace is imported. ### `BetweenComparison` enum -Defined in `GenericExtensions.cs`, controls how `IsBetween` handles bounds: +Defined in `ValueExtensions.cs` (namespace `CSharpHelperExtensions.Values`), controls how `IsBetween` handles bounds: - `None` (default) — inclusive on both ends - `ExcludeBoth` — exclusive on both ends - `ExcludeLower` — excludes lower bound, includes upper diff --git a/README.md b/README.md index ccffed1..73e8694 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,14 @@ Don't Repeat Yourself Extensions A set of helper extension methods that are used very often when coding +## Namespaces + +| Namespace | Methods | +|---|---| +| `CSharpHelperExtensions.Values` | `In`, `IsBetween`, `ToJson` | +| `CSharpHelperExtensions.Enumerable` | All `IEnumerable` extensions | +| `CSharpHelperExtensions.Strings` | All `string` extensions | + 1. _**In() Method**_ Checks to see if a item is part of the quick list @@ -67,6 +75,7 @@ The item being compared needs to be comparable (IComparable) Usage ```c# +// using CSharpHelperExtensions.Values; decimal value = 3; decimal lower = 1; decimal upper = 3; diff --git a/sample/value-extensions.ipynb b/sample/value-extensions.ipynb new file mode 100644 index 0000000..6bb3f25 --- /dev/null +++ b/sample/value-extensions.ipynb @@ -0,0 +1,486 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a1b2c3d4", + "metadata": {}, + "source": [ + "# Value Extensions\n", + "\n", + "Runnable samples for every method in `CSharpHelperExtensions.Values`. \n", + "Run the **Setup** cell first, then any section independently.\n", + "\n", + "| Section | Methods |\n", + "|---|---|\n", + "| [1. In](#1-in) | `In` |\n", + "| [2. IsBetween](#2-isbetween) | `IsBetween` · `BetweenComparison` |\n", + "| [3. ToJson](#3-tojson) | `ToJson` |\n", + "| [4. Chaining Examples](#4-chaining-examples) | Composing value extensions |" + ] + }, + { + "cell_type": "markdown", + "id": "e5f6a7b8", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "> **Run this cell first.** It loads the compiled library and imports the required namespace.\n", + ">\n", + "> Build first if the DLL is missing: `dotnet build` from the repo root." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c9d0e1f2", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "#r \"../src/CSharpHelperExtensions/bin/Debug/net10.0/CSharpHelperExtensions.dll\"\n", + "using CSharpHelperExtensions.Values; // In, IsBetween, BetweenComparison, ToJson" + ] + }, + { + "cell_type": "markdown", + "id": "a3b4c5d6", + "metadata": {}, + "source": [ + "---\n", + "## 1. In\n", + "\n", + "| Method | Signature | Returns |\n", + "|---|---|---|\n", + "| `In` | `T, params T[] → bool` | `true` when `value` equals any item in the set (like SQL `IN`) |\n", + "\n", + "Returns `false` when no candidates are supplied. Equality uses the default comparer for `T`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e7f8a9b0", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// String membership — role check\n", + "display(\"admin\".In(\"admin\", \"superadmin\")); // True\n", + "display(\"guest\".In(\"admin\", \"superadmin\")); // False\n", + "display(\"superadmin\".In(\"admin\", \"superadmin\")); // True" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1d2e3f4", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Integer membership\n", + "display(3.In(1, 2, 3, 4)); // True\n", + "display(5.In(1, 2, 3, 4)); // False\n", + "display(1.In(1, 2, 3, 4)); // True (first element)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a5b6c7d8", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Empty set — always false\n", + "display(\"x\".In()); // False (no candidates supplied)\n", + "display(42.In()); // False" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e9f0a1b2", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Enum membership\n", + "enum HttpMethod { Get, Post, Put, Delete, Patch }\n", + "\n", + "var method = HttpMethod.Post;\n", + "\n", + "display(method.In(HttpMethod.Post, HttpMethod.Put, HttpMethod.Patch)); // True (mutating methods)\n", + "display(method.In(HttpMethod.Get, HttpMethod.Delete)); // False\n", + "display(HttpMethod.Get.In(HttpMethod.Get, HttpMethod.Post)); // True" + ] + }, + { + "cell_type": "markdown", + "id": "c3d4e5f6", + "metadata": {}, + "source": [ + "---\n", + "## 2. IsBetween\n", + "\n", + "| `BetweenComparison` mode | Behaviour |\n", + "|---|---|\n", + "| `None` (default) | Inclusive on both ends: `lower ≤ value ≤ upper` |\n", + "| `ExcludeBoth` | Exclusive on both ends: `lower < value < upper` |\n", + "| `ExcludeLower` | Exclusive lower, inclusive upper: `lower < value ≤ upper` |\n", + "| `ExcludeUpper` | Inclusive lower, exclusive upper: `lower ≤ value < upper` |\n", + "\n", + "Works on any type that implements `IComparable` — integers, `DateTime`, `string`, etc." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a7b8c9d0", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// BetweenComparison.None (default) — inclusive on both ends\n", + "display(5.IsBetween(1, 10)); // True (interior)\n", + "display(1.IsBetween(1, 10)); // True (lower bound included)\n", + "display(10.IsBetween(1, 10)); // True (upper bound included)\n", + "display(0.IsBetween(1, 10)); // False (below lower)\n", + "display(11.IsBetween(1, 10)); // False (above upper)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e1f2a3b4", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// BetweenComparison.ExcludeBoth — exclusive on both ends\n", + "display(5.IsBetween(1, 10, BetweenComparison.ExcludeBoth)); // True (interior)\n", + "display(1.IsBetween(1, 10, BetweenComparison.ExcludeBoth)); // False (lower bound excluded)\n", + "display(10.IsBetween(1, 10, BetweenComparison.ExcludeBoth)); // False (upper bound excluded)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5d6e7f8", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// BetweenComparison.ExcludeLower — exclusive lower, inclusive upper\n", + "display(5.IsBetween(1, 10, BetweenComparison.ExcludeLower)); // True\n", + "display(1.IsBetween(1, 10, BetweenComparison.ExcludeLower)); // False (lower excluded)\n", + "display(10.IsBetween(1, 10, BetweenComparison.ExcludeLower)); // True (upper included)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a9b0c1d2", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// BetweenComparison.ExcludeUpper — inclusive lower, exclusive upper\n", + "display(5.IsBetween(1, 10, BetweenComparison.ExcludeUpper)); // True\n", + "display(1.IsBetween(1, 10, BetweenComparison.ExcludeUpper)); // True (lower included)\n", + "display(10.IsBetween(1, 10, BetweenComparison.ExcludeUpper)); // False (upper excluded)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e3f4a5b6", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Works on any IComparable — DateTime and string\n", + "\n", + "// DateTime range check\n", + "var start = new DateTime(2024, 1, 1);\n", + "var end = new DateTime(2024, 12, 31);\n", + "var mid = new DateTime(2024, 6, 15);\n", + "\n", + "display(mid.IsBetween(start, end)); // True\n", + "display(start.IsBetween(start, end)); // True (inclusive)\n", + "display(start.IsBetween(start, end, BetweenComparison.ExcludeBoth)); // False (excluded)\n", + "\n", + "// String lexicographic range\n", + "display(\"mango\".IsBetween(\"apple\", \"orange\")); // True\n", + "display(\"zebra\".IsBetween(\"apple\", \"orange\")); // False (lexically after \"orange\")" + ] + }, + { + "cell_type": "markdown", + "id": "c7d8e9f0", + "metadata": {}, + "source": [ + "---\n", + "## 3. ToJson\n", + "\n", + "| Parameter | Type | Default | Notes |\n", + "|---|---|---|---|\n", + "| `value` | `T` | — | Object to serialize; returns `null` when this is `null` |\n", + "| `indentation` | `bool` | `false` | `true` → pretty-printed; `false` → compact single-line |\n", + "\n", + "Serializes via **Newtonsoft.Json**. Works on both reference types and value types." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1b2e3f4", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Compact output (default)\n", + "display(new { Name = \"Alice\", Age = 30 }.ToJson()); // {\"Name\":\"Alice\",\"Age\":30}\n", + "\n", + "// With a list\n", + "display(new { Tags = new[] { \"a\", \"b\", \"c\" }, Active = true }.ToJson());\n", + "// {\"Tags\":[\"a\",\"b\",\"c\"],\"Active\":true}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5d6f7a8", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Indented (pretty-printed) output\n", + "display(new { Name = \"Alice\", Age = 30 }.ToJson(indentation: true));\n", + "// {\n", + "// \"Name\": \"Alice\",\n", + "// \"Age\": 30\n", + "// }" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b9c0d1e2", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Value types — works directly on int, bool, DateTime, etc.\n", + "display(42.ToJson()); // 42\n", + "display(true.ToJson()); // true\n", + "display(3.14.ToJson()); // 3.14\n", + "display(DateTime.Parse(\"2024-06-15\").ToJson()); // \"2024-06-15T00:00:00\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f3a4b5c6", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Null-safe — returns null for null input (no exception)\n", + "display(((object)null).ToJson()); // null\n", + "display(((string)null).ToJson()); // null" + ] + }, + { + "cell_type": "markdown", + "id": "d7e8f9a0", + "metadata": {}, + "source": [ + "---\n", + "## 4. Chaining Examples\n", + "\n", + "Value extensions compose naturally. These pipelines show realistic scenarios that combine\n", + "multiple `ValueExtensions` methods together." + ] + }, + { + "cell_type": "markdown", + "id": "b1c2d3e4", + "metadata": {}, + "source": [ + "### Range guard before categorisation\n", + "`IsBetween` to validate a score is in a legal range, then categorise it" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f5a6b7c8", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Categorise a score only when it falls in the valid range [0, 100]\n", + "int[] scores = { -5, 0, 45, 75, 90, 101 };\n", + "\n", + "foreach (var score in scores)\n", + "{\n", + " if (!score.IsBetween(0, 100))\n", + " {\n", + " display($\"{score,4}: invalid score\");\n", + " continue;\n", + " }\n", + "\n", + " var grade = score.IsBetween(90, 100) ? \"A\" :\n", + " score.IsBetween(75, 89) ? \"B\" :\n", + " score.IsBetween(60, 74) ? \"C\" : \"F\";\n", + "\n", + " display($\"{score,4}: {grade}\");\n", + "}\n", + "// -5: invalid score\n", + "// 0: F\n", + "// 45: F\n", + "// 75: B\n", + "// 90: A\n", + "// 101: invalid score" + ] + }, + { + "cell_type": "markdown", + "id": "a9b0c1d3", + "metadata": {}, + "source": [ + "### Role check with conditional JSON serialisation\n", + "`In` to check access rights, `ToJson` to emit the audit record only when allowed" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5f6a7b9", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "var user = new { Name = \"Carol\", Role = \"editor\", LastLogin = \"2024-06-15\" };\n", + "\n", + "bool canWrite = user.Role.In(\"admin\", \"editor\"); // True\n", + "bool canDelete = user.Role.In(\"admin\", \"superadmin\"); // False\n", + "\n", + "display($\"Can write: {canWrite}\"); // True\n", + "display($\"Can delete: {canDelete}\"); // False\n", + "\n", + "// Only serialise an audit record for privileged roles\n", + "var auditJson = user.Role.In(\"admin\", \"editor\", \"superadmin\")\n", + " ? new { user.Name, user.Role, Action = \"login\" }.ToJson(indentation: true)\n", + " : null;\n", + "\n", + "display(auditJson);\n", + "// {\n", + "// \"Name\": \"Carol\",\n", + "// \"Role\": \"editor\",\n", + "// \"Action\": \"login\"\n", + "// }" + ] + }, + { + "cell_type": "markdown", + "id": "c3d4e5f7", + "metadata": {}, + "source": [ + "### Serialise only values within an allowed set\n", + "`In` as a filter guard, `ToJson` for the output payload" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a8b9c0d1", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Emit a JSON status event only for terminal states; ignore transient ones\n", + "string[] statuses = { \"pending\", \"running\", \"succeeded\", \"failed\", \"cancelled\" };\n", + "string[] terminalStates = { \"succeeded\", \"failed\", \"cancelled\" };\n", + "\n", + "foreach (var status in statuses)\n", + "{\n", + " var payload = status.In(terminalStates)\n", + " ? new { Status = status, Timestamp = \"2024-06-15T12:00:00Z\" }.ToJson()\n", + " : null;\n", + "\n", + " if (payload is not null)\n", + " display(payload);\n", + "}\n", + "// {\"Status\":\"succeeded\",\"Timestamp\":\"2024-06-15T12:00:00Z\"}\n", + "// {\"Status\":\"failed\",\"Timestamp\":\"2024-06-15T12:00:00Z\"}\n", + "// {\"Status\":\"cancelled\",\"Timestamp\":\"2024-06-15T12:00:00Z\"}" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".NET (C#)", + "language": "C#", + "name": ".net-csharp" + }, + "language_info": { + "name": "C#" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/CSharpHelperExtensions.Test/GenericExtensionTest.cs b/src/CSharpHelperExtensions.Test/ValueExtensionTest.cs similarity index 98% rename from src/CSharpHelperExtensions.Test/GenericExtensionTest.cs rename to src/CSharpHelperExtensions.Test/ValueExtensionTest.cs index 7d4c498..38ae3a8 100644 --- a/src/CSharpHelperExtensions.Test/GenericExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/ValueExtensionTest.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using CSharpHelperExtensions.Strings; +using CSharpHelperExtensions.Values; using Shouldly; using Xunit; @@ -11,7 +12,7 @@ public class Person public string LastName { get; set; } } - public class GenericExtensionsTest + public class ValueExtensionsTest { [Fact] public void Verify_In_Exists() diff --git a/src/CSharpHelperExtensions/GenericExtensions.cs b/src/CSharpHelperExtensions/ValueExtensions.cs similarity index 85% rename from src/CSharpHelperExtensions/GenericExtensions.cs rename to src/CSharpHelperExtensions/ValueExtensions.cs index b9e3f2f..42f7a4d 100644 --- a/src/CSharpHelperExtensions/GenericExtensions.cs +++ b/src/CSharpHelperExtensions/ValueExtensions.cs @@ -1,13 +1,11 @@ using System; -using System.Collections.Generic; -using System.ComponentModel; using System.Linq; using Newtonsoft.Json; -namespace CSharpHelperExtensions; +namespace CSharpHelperExtensions.Values; /// -/// Controls which bounds are included when using . +/// Controls which bounds are included when using . /// public enum BetweenComparison { @@ -20,7 +18,7 @@ public enum BetweenComparison /// Inclusive lower bound, exclusive upper: lower ≤ value < upper. ExcludeUpper } -public static class GenericExtensions +public static class ValueExtensions { /// @@ -55,7 +53,7 @@ public static bool IsBetween(this T value, T lower, T upper, BetweenCompariso BetweenComparison.ExcludeBoth => (value.CompareTo(lower) > 0) && (value.CompareTo(upper) < 0), BetweenComparison.ExcludeLower => (value.CompareTo(lower) > 0) && (value.CompareTo(upper) <= 0), BetweenComparison.ExcludeUpper => (value.CompareTo(lower) >= 0) && (value.CompareTo(upper) < 0), - _ => (lower.CompareTo(value) <= 0) && (value.CompareTo(upper) <= 0) + _ => value.CompareTo(lower) >= 0 && value.CompareTo(upper) <= 0 }; } @@ -65,7 +63,6 @@ public static bool IsBetween(this T value, T lower, T upper, BetweenCompariso /// /// The value to look for. /// One or more candidate values to match against. - /// The type of the value and candidates. /// /// Returns when no candidates are supplied. /// Equality is determined by the default equality comparer for . @@ -83,7 +80,7 @@ public static bool IsBetween(this T value, T lower, T upper, BetweenCompariso /// public static bool In(this T value, params T[] input) { - return input is { } && input.Contains(value); + return input.Contains(value); } /// @@ -96,10 +93,6 @@ public static bool In(this T value, params T[] input) /// When , the JSON output is pretty-printed with indentation. /// Defaults to (compact, single-line output). /// - /// - /// The type of the object to serialize. Must be a reference type (class). - /// Value types (structs) must be boxed to before calling this method. - /// /// /// A JSON string representing , /// or if is . @@ -113,12 +106,12 @@ public static bool In(this T value, params T[] input) /// // "Name": "Alice", /// // "Age": 30 /// // } - /// ((object)null).ToJson() // null + /// 42.ToJson() // 42 /// /// - public static string ToJson(this T value, bool indentation = false) where T : class + public static string ToJson(this T value, bool indentation = false) { var formatting = indentation ? Formatting.Indented : Formatting.None; - return value == null ? null : JsonConvert.SerializeObject(value, formatting); + return value is null ? null : JsonConvert.SerializeObject(value, formatting); } } From 955204509f711c8affa3927e09174b0c57a99bd1 Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 23:09:09 -0400 Subject: [PATCH 20/21] move the solution file --- CSharpHelperExtensions.slnx | 4 ---- src/CSharpHelperExtensions.slnx | 4 ++++ 2 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 CSharpHelperExtensions.slnx create mode 100644 src/CSharpHelperExtensions.slnx diff --git a/CSharpHelperExtensions.slnx b/CSharpHelperExtensions.slnx deleted file mode 100644 index 8e42b2b..0000000 --- a/CSharpHelperExtensions.slnx +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/CSharpHelperExtensions.slnx b/src/CSharpHelperExtensions.slnx new file mode 100644 index 0000000..854d15f --- /dev/null +++ b/src/CSharpHelperExtensions.slnx @@ -0,0 +1,4 @@ + + + + From ba1031bbe85aed7809e846fd17df22640a1896fc Mon Sep 17 00:00:00 2001 From: Bipin Radhakrishnan Date: Thu, 28 May 2026 23:15:32 -0400 Subject: [PATCH 21/21] fix the solution folder path --- .github/workflows/dotnet.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 84dc51c..baea726 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -18,8 +18,8 @@ jobs: with: dotnet-version: 10.0.x - name: Restore dependencies - run: dotnet restore + run: dotnet restore src/CSharpHelperExtensions.slnx - name: Build - run: dotnet build --no-restore + run: dotnet build src/CSharpHelperExtensions.slnx --no-restore - name: Test - run: dotnet test --no-build --verbosity normal + run: dotnet test src/CSharpHelperExtensions.slnx --no-build --verbosity normal