From a968548f8b8347195a66e66c3f82e1fc1e63459f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:20:33 +0000 Subject: [PATCH 1/2] feat: add fold, foldBack, find, tryFind, filter, choose, partition, scan, indexed, unzip, pairwise, groupBy, max, min to NonEmptyList (addresses #174) Add 14 missing standard List-equivalent functions to the NonEmptyList module: - fold/foldBack: thread accumulator through the list - find/tryFind: search for elements by predicate - filter/choose: return plain 'T list (result can be empty) - partition: split into two plain lists by predicate - scan: return NonEmptyList<'State> (always non-empty) - indexed: return NonEmptyList - unzip: split NonEmptyList<'T1 * 'T2> into two NonEmptyLists - pairwise: return ('T * 'T) list (may be empty for singleton) - groupBy: return NonEmptyList<'Key * NonEmptyList<'T>> - max/min: safe since list is always non-empty Functions that preserve non-emptiness return NonEmptyList. Functions that may produce empty results return plain 'T list. Add 16 property-based tests covering all new functions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/FSharpx.Collections/NonEmptyList.fs | 80 ++++++++ .../NonEmptyListTests.fs | 173 +++++++++++++++++- 2 files changed, 252 insertions(+), 1 deletion(-) diff --git a/src/FSharpx.Collections/NonEmptyList.fs b/src/FSharpx.Collections/NonEmptyList.fs index 1d41d52d..8988e941 100644 --- a/src/FSharpx.Collections/NonEmptyList.fs +++ b/src/FSharpx.Collections/NonEmptyList.fs @@ -165,3 +165,83 @@ module NonEmptyList = [] let minBy projection list = List.minBy projection list.List + + /// O(n). Returns the largest element of the non-empty list. + [] + let max list = + List.max list.List + + /// O(n). Returns the smallest element of the non-empty list. + [] + let min list = + List.min list.List + + /// O(n). Applies a function to each element of the collection, threading an accumulator argument. + [] + let fold (folder: 'State -> 'T -> 'State) (state: 'State) (list: NonEmptyList<'T>) = + List.fold folder state list.List + + /// O(n). Applies a function to each element of the collection from right to left, threading an accumulator argument. + [] + let foldBack (folder: 'T -> 'State -> 'State) (list: NonEmptyList<'T>) (state: 'State) = + List.foldBack folder list.List state + + /// O(n), worst case. Returns the first element for which the given function returns Some. + [] + let tryFind (predicate: 'T -> bool) (list: NonEmptyList<'T>) = + List.tryFind predicate list.List + + /// O(n), worst case. Returns the first element for which the given function returns true. + /// Raises KeyNotFoundException if no such element exists. + [] + let find (predicate: 'T -> bool) (list: NonEmptyList<'T>) = + List.find predicate list.List + + /// O(n). Returns a new list containing only the elements for which the given predicate returns true. + /// The result may be empty, so a plain 'T list is returned. + [] + let filter (predicate: 'T -> bool) (list: NonEmptyList<'T>) : 'T list = + List.filter predicate list.List + + /// O(n). Applies the given function to each element and returns a list of the values returned by + /// the function where the function returned Some. The result may be empty. + [] + let choose (mapping: 'T -> 'U option) (list: NonEmptyList<'T>) : 'U list = + List.choose mapping list.List + + /// O(n). Splits the collection into two lists; the first containing elements for which the given + /// predicate returns true, the second for which it returns false. Both parts may be empty. + [] + let partition (predicate: 'T -> bool) (list: NonEmptyList<'T>) : 'T list * 'T list = + List.partition predicate list.List + + /// O(n). Returns a NonEmptyList of each element paired with its index. + [] + let indexed(list: NonEmptyList<'T>) : NonEmptyList = + { List = List.indexed list.List } + + /// O(n). Splits a NonEmptyList of pairs into a pair of NonEmptyLists. + [] + let unzip(list: NonEmptyList<'T1 * 'T2>) : NonEmptyList<'T1> * NonEmptyList<'T2> = + let a, b = List.unzip list.List + { List = a }, { List = b } + + /// O(n). Returns a list of each element and its successor. The result may be empty for a singleton list. + [] + let pairwise(list: NonEmptyList<'T>) : ('T * 'T) list = + List.pairwise list.List + + /// O(n). Returns a NonEmptyList of states by threading an accumulator through the list. + /// The result always contains at least the initial state followed by the intermediate states. + [] + let scan (folder: 'State -> 'T -> 'State) (state: 'State) (list: NonEmptyList<'T>) : NonEmptyList<'State> = + { List = List.scan folder state list.List } + + /// O(n). Applies a key-generating function to each element and yields a NonEmptyList of + /// unique keys together with a NonEmptyList of all elements that match each key. + [] + let groupBy (projection: 'T -> 'Key) (list: NonEmptyList<'T>) : NonEmptyList<'Key * NonEmptyList<'T>> = + { List = + list.List + |> List.groupBy projection + |> List.map(fun (k, vs) -> k, { List = vs }) } diff --git a/tests/FSharpx.Collections.Tests/NonEmptyListTests.fs b/tests/FSharpx.Collections.Tests/NonEmptyListTests.fs index b77fbd78..a10f053b 100644 --- a/tests/FSharpx.Collections.Tests/NonEmptyListTests.fs +++ b/tests/FSharpx.Collections.Tests/NonEmptyListTests.fs @@ -402,4 +402,175 @@ module NonEmptyListTests = config10k "minBy with negation projection returns maximum element" (Prop.forAll(neListOfInt()) - <| fun nel -> NonEmptyList.minBy (fun x -> -x) nel = (nel |> NonEmptyList.toList |> List.max)) ] + <| fun nel -> NonEmptyList.minBy (fun x -> -x) nel = (nel |> NonEmptyList.toList |> List.max)) + + testPropertyWithConfig + config10k + "max returns maximum element" + (Prop.forAll(neListOfInt()) + <| fun nel -> NonEmptyList.max nel = (nel |> NonEmptyList.toList |> List.max)) + + testPropertyWithConfig + config10k + "min returns minimum element" + (Prop.forAll(neListOfInt()) + <| fun nel -> NonEmptyList.min nel = (nel |> NonEmptyList.toList |> List.min)) + + testPropertyWithConfig + config10k + "fold behaves like List.fold" + (Prop.forAll(neListOfInt()) + <| fun nel -> + let actual = NonEmptyList.fold (+) 0 nel + let expected = nel |> NonEmptyList.toList |> List.fold (+) 0 + actual = expected) + + testPropertyWithConfig + config10k + "foldBack behaves like List.foldBack" + (Prop.forAll(neListOfInt()) + <| fun nel -> + let actual = NonEmptyList.foldBack (fun x acc -> x - acc) nel 0 + + let expected = + nel |> NonEmptyList.toList |> List.foldBack(fun x acc -> x - acc) <| 0 + + actual = expected) + + testPropertyWithConfig + config10k + "tryFind returns Some when element exists" + (Prop.forAll(neListOfInt()) + <| fun nel -> + let list = NonEmptyList.toList nel + let predicate x = x % 2 = 0 + NonEmptyList.tryFind predicate nel = List.tryFind predicate list) + + testPropertyWithConfig config10k "find returns element when it exists" + <| fun (xs: int list) -> + if xs.IsEmpty then + true + else + let nel = NonEmptyList.create xs.Head xs.Tail + let target = xs.Head + + NonEmptyList.find (fun x -> x = target) nel = List.find (fun x -> x = target) xs + + testPropertyWithConfig config10k "find raises KeyNotFoundException when element not found" + <| fun (xs: int list) -> + if xs.IsEmpty then + true + else + let nel = NonEmptyList.create xs.Head xs.Tail + let sentinel = System.Int32.MinValue + + if List.contains sentinel xs then + true + else + Expect.throwsT "should raise" (fun () -> + NonEmptyList.find (fun x -> x = sentinel) nel |> ignore) + + true + + testPropertyWithConfig + config10k + "filter returns subset as plain list" + (Prop.forAll(neListOfInt()) + <| fun nel -> + let predicate x = x % 2 = 0 + let actual = NonEmptyList.filter predicate nel + let expected = nel |> NonEmptyList.toList |> List.filter predicate + actual = expected) + + testPropertyWithConfig + config10k + "choose returns mapped subset as plain list" + (Prop.forAll(neListOfInt()) + <| fun nel -> + let mapping x = + if x % 2 = 0 then Some(x * 2) else None + + let actual = NonEmptyList.choose mapping nel + let expected = nel |> NonEmptyList.toList |> List.choose mapping + actual = expected) + + testPropertyWithConfig + config10k + "partition splits into two plain lists" + (Prop.forAll(neListOfInt()) + <| fun nel -> + let predicate x = x % 2 = 0 + let trueList, falseList = NonEmptyList.partition predicate nel + + let expectedTrue, expectedFalse = + nel |> NonEmptyList.toList |> List.partition predicate + + trueList = expectedTrue && falseList = expectedFalse) + + testPropertyWithConfig + config10k + "indexed pairs each element with its index" + (Prop.forAll(NonEmptyListGen.NonEmptyList()) + <| fun nel -> + let actual = NonEmptyList.indexed nel |> NonEmptyList.toList + let expected = nel |> NonEmptyList.toList |> List.indexed + actual = expected) + + testPropertyWithConfig + config10k + "unzip splits into two NonEmptyLists" + (Prop.forAll(NonEmptyListGen.NonEmptyList()) + <| fun nel -> + let pairs = NonEmptyList.map (fun x -> x, x) nel + let a, b = NonEmptyList.unzip pairs + + NonEmptyList.toList a = NonEmptyList.toList nel + && NonEmptyList.toList b = NonEmptyList.toList nel) + + testPropertyWithConfig + config10k + "pairwise returns adjacent pairs as plain list" + (Prop.forAll(neListOfInt()) + <| fun nel -> + let actual = NonEmptyList.pairwise nel + let expected = nel |> NonEmptyList.toList |> List.pairwise + actual = expected) + + testPropertyWithConfig + config10k + "scan produces intermediate accumulator states" + (Prop.forAll(neListOfInt()) + <| fun nel -> + let actual = NonEmptyList.scan (+) 0 nel |> NonEmptyList.toList + let expected = nel |> NonEmptyList.toList |> List.scan (+) 0 + actual = expected) + + testPropertyWithConfig + config10k + "scan result is always non-empty" + (Prop.forAll(NonEmptyListGen.NonEmptyList()) + <| fun nel -> NonEmptyList.scan (fun _ _ -> 0) 0 nel |> NonEmptyList.length > 0) + + testPropertyWithConfig + config10k + "groupBy groups elements by key" + (Prop.forAll(neListOfInt()) + <| fun nel -> + let projection x = x % 3 + let groups = NonEmptyList.groupBy projection nel + + // verify all original elements appear in exactly one group + let flattened = + groups + |> NonEmptyList.toList + |> List.collect(fun (_, vs) -> NonEmptyList.toList vs) + |> List.sort + + let expected = nel |> NonEmptyList.toList |> List.sort + flattened = expected) + + testPropertyWithConfig + config10k + "groupBy result is always non-empty" + (Prop.forAll(NonEmptyListGen.NonEmptyList()) + <| fun nel -> NonEmptyList.groupBy id nel |> NonEmptyList.length > 0) ] From 0326eb4250f59de4e4ea9200814cead089b1d641 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 20:20:36 +0000 Subject: [PATCH 2/2] ci: trigger checks