diff --git a/release-notes.txt b/release-notes.txt index 72c765f..4a1d5ec 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,6 +2,7 @@ Release notes: Unreleased + - adds TaskSeq.foldWhile and TaskSeq.foldWhileAsync: fold with early termination via a (state, element) -> bool predicate (takeWhile-style, exclusive). When the predicate returns false, iteration halts without folding that element; no further elements are enumerated. - test: add SideEffects module and ImmTaskSeq variant tests to TaskSeq.ChunkBy.Tests.fs, improving coverage for chunkBy and chunkByAsync - fixes: `Async.bind` signature corrected from `(Async<'T> -> Async<'U>)` to `('T -> Async<'U>)` to match standard monadic bind semantics (same as `Task.bind`); the previous signature made the function effectively equivalent to direct application - refactor: simplify splitAt 'rest' taskSeq to use while!, removing redundant go2 mutable and manual MoveNextAsync pre-advance diff --git a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj index 0284354..e87084e 100644 --- a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj +++ b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj @@ -28,6 +28,7 @@ + diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldWhile.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldWhile.Tests.fs new file mode 100644 index 0000000..f8deadc --- /dev/null +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldWhile.Tests.fs @@ -0,0 +1,258 @@ +module TaskSeq.Tests.FoldWhile + +open Xunit +open FsUnit.Xunit + +open FSharp.Control + +// +// TaskSeq.foldWhile +// TaskSeq.foldWhileAsync +// +// Semantics match TaskSeq.takeWhile: the predicate is evaluated against (state, element) +// before that element is folded in. When the predicate returns false, iteration halts +// without folding that element, and no further elements are enumerated. +// + +module EmptySeq = + [] + let ``Null source is invalid`` () = + assertNullArg + <| fun () -> TaskSeq.foldWhile (fun _ _ -> true) (fun _ item -> item + 1) 0 null + + assertNullArg + <| fun () -> TaskSeq.foldWhileAsync (fun _ _ -> Task.fromResult true) (fun _ item -> Task.fromResult (item + 1)) 0 null + + [)>] + let ``TaskSeq-foldWhile returns initial state when empty`` variant = task { + let! result = + Gen.getEmptyVariant variant + |> TaskSeq.foldWhile (fun _ _ -> true) (fun _ item -> item + 1) -1 + + result |> should equal -1 + } + + [)>] + let ``TaskSeq-foldWhileAsync returns initial state when empty`` variant = task { + let! result = + Gen.getEmptyVariant variant + |> TaskSeq.foldWhileAsync (fun _ _ -> Task.fromResult true) (fun _ item -> Task.fromResult (item + 1)) -1 + + result |> should equal -1 + } + + [)>] + let ``TaskSeq-foldWhile does not call predicate or folder when empty`` variant = task { + let mutable predicateCalled = false + let mutable folderCalled = false + + let! _ = + Gen.getEmptyVariant variant + |> TaskSeq.foldWhile + (fun _ _ -> + predicateCalled <- true + true) + (fun state _ -> + folderCalled <- true + state) + 0 + + predicateCalled |> should be False + folderCalled |> should be False + } + + [)>] + let ``TaskSeq-foldWhileAsync does not call predicate or folder when empty`` variant = task { + let mutable predicateCalled = false + let mutable folderCalled = false + + let! _ = + Gen.getEmptyVariant variant + |> TaskSeq.foldWhileAsync + (fun _ _ -> task { + predicateCalled <- true + return true + }) + (fun state _ -> task { + folderCalled <- true + return state + }) + 0 + + predicateCalled |> should be False + folderCalled |> should be False + } + +module Functionality = + [] + let ``TaskSeq-foldWhile with always-true predicate behaves like fold`` () = task { + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldWhile (fun _ _ -> true) (fun acc item -> acc + item) 0 + + result |> should equal 15 + } + + [] + let ``TaskSeq-foldWhileAsync with always-true predicate behaves like foldAsync`` () = task { + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldWhileAsync (fun _ _ -> Task.fromResult true) (fun acc item -> Task.fromResult (acc + item)) 0 + + result |> should equal 15 + } + + [] + let ``TaskSeq-foldWhile is left-associative like fold`` () = task { + let! result = + TaskSeq.ofList [ "b"; "c"; "d" ] + |> TaskSeq.foldWhile (fun _ _ -> true) (fun acc item -> acc + item) "a" + + result |> should equal "abcd" + } + + [] + let ``TaskSeq-foldWhileAsync is left-associative like foldAsync`` () = task { + let! result = + TaskSeq.ofList [ "b"; "c"; "d" ] + |> TaskSeq.foldWhileAsync (fun _ _ -> Task.fromResult true) (fun acc item -> Task.fromResult (acc + item)) "a" + + result |> should equal "abcd" + } + +module Halt = + [] + let ``TaskSeq-foldWhile stops immediately when predicate is false on first element`` () = task { + let mutable predicateCalls = 0 + let mutable folderCalls = 0 + + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldWhile + (fun _ _ -> + predicateCalls <- predicateCalls + 1 + false) + (fun _ item -> + folderCalls <- folderCalls + 1 + item) + 0 + + result |> should equal 0 + predicateCalls |> should equal 1 + folderCalls |> should equal 0 + } + + [] + let ``TaskSeq-foldWhileAsync stops immediately when predicate is false on first element`` () = task { + let mutable predicateCalls = 0 + let mutable folderCalls = 0 + + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldWhileAsync + (fun _ _ -> task { + predicateCalls <- predicateCalls + 1 + return false + }) + (fun _ item -> task { + folderCalls <- folderCalls + 1 + return item + }) + 0 + + result |> should equal 0 + predicateCalls |> should equal 1 + folderCalls |> should equal 0 + } + + [] + let ``TaskSeq-foldWhile halts mid-sequence without folding the halting element`` () = task { + // Sum while adding the next element would keep the total <= 5. Once adding + // the element would overshoot, stop — that element is NOT folded in. + let mutable predicateCalls = 0 + let mutable folderCalls = 0 + + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldWhile + (fun acc item -> + predicateCalls <- predicateCalls + 1 + acc + item <= 5) + (fun acc item -> + folderCalls <- folderCalls + 1 + acc + item) + 0 + + // 1 (ok, total 1), 2 (ok, total 3), 3 (would make 6 > 5, stop) + result |> should equal 3 + predicateCalls |> should equal 3 + folderCalls |> should equal 2 + } + + [] + let ``TaskSeq-foldWhileAsync halts mid-sequence without folding the halting element`` () = task { + let mutable predicateCalls = 0 + let mutable folderCalls = 0 + + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldWhileAsync + (fun acc item -> task { + predicateCalls <- predicateCalls + 1 + return acc + item <= 5 + }) + (fun acc item -> task { + folderCalls <- folderCalls + 1 + return acc + item + }) + 0 + + result |> should equal 3 + predicateCalls |> should equal 3 + folderCalls |> should equal 2 + } + + [] + let ``TaskSeq-foldWhile does not enumerate past the halting element`` () = task { + // Source has a side effect per pulled element; halt on the 3rd pull. + let mutable pulled = 0 + + let source = taskSeq { + for i in 1..5 do + pulled <- pulled + 1 + yield i + } + + let! _ = + source + |> TaskSeq.foldWhile (fun _ item -> item < 3) (fun acc item -> acc + item) 0 + + // Pull 1 (ok), pull 2 (ok), pull 3 (predicate false, stop) — must not pull 4. + pulled |> should equal 3 + } + + [] + let ``TaskSeq-foldWhileAsync does not enumerate past the halting element`` () = task { + let mutable pulled = 0 + + let source = taskSeq { + for i in 1..5 do + pulled <- pulled + 1 + yield i + } + + let! _ = + source + |> TaskSeq.foldWhileAsync (fun _ item -> Task.fromResult (item < 3)) (fun acc item -> Task.fromResult (acc + item)) 0 + + pulled |> should equal 3 + } + + [] + let ``TaskSeq-foldWhile that never halts is equivalent to fold`` () = task { + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldWhile (fun _ item -> item <= 10) (fun acc item -> acc + item) 0 + + result |> should equal 15 + } diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fs b/src/FSharp.Control.TaskSeq/TaskSeq.fs index d3a94f9..6bc1387 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fs @@ -540,6 +540,10 @@ type TaskSeq private () = static member compareWithAsync comparer source1 source2 = Internal.compareWithAsync comparer source1 source2 static member fold folder state source = Internal.fold (FolderAction folder) state source static member foldAsync folder state source = Internal.fold (AsyncFolderAction folder) state source + static member foldWhile predicate folder state source = Internal.foldWhile predicate folder state source + + static member foldWhileAsync predicate folder state source = Internal.foldWhileAsync predicate folder state source + static member scan folder state source = Internal.scan (FolderAction folder) state source static member scanAsync folder state source = Internal.scan (AsyncFolderAction folder) state source static member reduce folder source = Internal.reduce (FolderAction folder) source diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fsi b/src/FSharp.Control.TaskSeq/TaskSeq.fsi index 1caf826..6f13363 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi @@ -1865,6 +1865,51 @@ type TaskSeq = static member foldAsync: folder: ('State -> 'T -> #Task<'State>) -> state: 'State -> source: TaskSeq<'T> -> Task<'State> + /// + /// Applies the function to each element in the task sequence, threading an + /// accumulator of type through the computation, for as long as + /// returns true. The predicate is evaluated against the current + /// state and next element before that element is folded in; once it returns false the element + /// is not folded, iteration stops, and no further elements of the input are enumerated. + /// If either function is asynchronous, consider using . + /// + /// + /// A function that, given the current state and next element, returns true to keep folding or false to stop. + /// A function that updates the state with each element from the sequence. + /// The initial state. + /// The input sequence. + /// The state object after iteration halted, or after the whole sequence was consumed. + /// Thrown when the input task sequence is null. + static member foldWhile: + predicate: ('State -> 'T -> bool) -> + folder: ('State -> 'T -> 'State) -> + state: 'State -> + source: TaskSeq<'T> -> + Task<'State> + + /// + /// Applies the asynchronous function to each element in the task sequence, + /// threading an accumulator of type through the computation, for as long as + /// the asynchronous returns true. The predicate is evaluated + /// against the current state and next element before that element is folded in; once it returns + /// false the element is not folded, iteration stops, and no further elements of the input are + /// enumerated. + /// If both functions are synchronous, consider using . + /// + /// + /// An async function that, given the current state and next element, returns true to keep folding or false to stop. + /// An async function that updates the state with each element from the sequence. + /// The initial state. + /// The input sequence. + /// The state object after iteration halted, or after the whole sequence was consumed. + /// Thrown when the input task sequence is null. + static member foldWhileAsync: + predicate: ('State -> 'T -> #Task) -> + folder: ('State -> 'T -> #Task<'State>) -> + state: 'State -> + source: TaskSeq<'T> -> + Task<'State> + /// /// Like , but returns the sequence of intermediate results and the final result. /// The first element of the output sequence is always the initial state. If the input task sequence diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs index 7e73fb4..0f6819b 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -409,6 +409,53 @@ module internal TaskSeqInternal = return result } + let foldWhile predicate folder initial (source: TaskSeq<_>) = + checkNonNull (nameof source) source + + task { + use e = source.GetAsyncEnumerator CancellationToken.None + let mutable result = initial + let mutable running = true + + while running do + let! hasNext = e.MoveNextAsync() + + if hasNext then + if predicate result e.Current then + result <- folder result e.Current + else + running <- false + else + running <- false + + return result + } + + let foldWhileAsync predicate folder initial (source: TaskSeq<_>) = + checkNonNull (nameof source) source + + task { + use e = source.GetAsyncEnumerator CancellationToken.None + let mutable result = initial + let mutable running = true + + while running do + let! hasNext = e.MoveNextAsync() + + if hasNext then + let! keepGoing = predicate result e.Current + + if keepGoing then + let! newState = folder result e.Current + result <- newState + else + running <- false + else + running <- false + + return result + } + let scan folder initial (source: TaskSeq<_>) = checkNonNull (nameof source) source