From 7bde2263039593c35d4e06001b99b2ac68937867 Mon Sep 17 00:00:00 2001 From: Onur Gumus Date: Tue, 21 Apr 2026 09:13:29 +0200 Subject: [PATCH 1/3] feat: add TaskSeq.foldUntil and foldUntilAsync with FoldStep<'State> DU Fold over a task sequence with early termination. The folder returns `Continue newState` to keep consuming, or `Halt newState` to stop immediately; in either case the state is updated. No elements past the one that caused Halt are enumerated from the input. Motivation: reaching for `fold`/`foldAsync` often ends with a mutable flag or a `match state with Decided -> state | _ -> ...` guard at the top of the folder when the caller wants to short-circuit on some condition discovered mid-stream. `tryPickAsync` handles the "find and halt" subcase but cannot also thread an accumulator. `foldUntil` fills the gap and removes the impedance mismatch. Includes: - FoldStep<'State> DU (Continue / Halt) in public API - Internal FoldUntilAction DU unifying sync/async folder dispatch - Public TaskSeq.foldUntil and TaskSeq.foldUntilAsync - XML docs on both overloads mirroring fold/foldAsync style - Release-notes entry - 22 tests covering: null source, empty sequence (no folder call, initial state preserved), all-Continue (equals fold), Halt-on-first (1 folder call, no further pulls), Halt mid-sequence (correct count, source not pulled past Halt), Halt-on-last (equals fold) --- release-notes.txt | 1 + .../FSharp.Control.TaskSeq.Test.fsproj | 1 + .../TaskSeq.FoldUntil.Tests.fs | 226 ++++++++++++++++++ src/FSharp.Control.TaskSeq/TaskSeq.fs | 4 + src/FSharp.Control.TaskSeq/TaskSeq.fsi | 36 +++ src/FSharp.Control.TaskSeq/TaskSeqInternal.fs | 51 ++++ 6 files changed, 319 insertions(+) create mode 100644 src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldUntil.Tests.fs diff --git a/release-notes.txt b/release-notes.txt index 72c765f..cfa8269 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,6 +2,7 @@ Release notes: Unreleased + - adds TaskSeq.foldUntil and TaskSeq.foldUntilAsync: fold with early termination via a FoldStep<'State> DU (Continue / Halt). The underlying sequence is not enumerated past the element that caused Halt. - 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..16759fb 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.FoldUntil.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldUntil.Tests.fs new file mode 100644 index 0000000..5923f2b --- /dev/null +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldUntil.Tests.fs @@ -0,0 +1,226 @@ +module TaskSeq.Tests.FoldUntil + +open Xunit +open FsUnit.Xunit + +open FSharp.Control + +// +// TaskSeq.foldUntil +// TaskSeq.foldUntilAsync +// + +module EmptySeq = + [] + let ``Null source is invalid`` () = + assertNullArg + <| fun () -> TaskSeq.foldUntil (fun _ _ -> Continue 42) 0 null + + assertNullArg + <| fun () -> TaskSeq.foldUntilAsync (fun _ _ -> Task.fromResult (Continue 42)) 0 null + + [)>] + let ``TaskSeq-foldUntil returns initial state when empty`` variant = task { + let! result = + Gen.getEmptyVariant variant + |> TaskSeq.foldUntil (fun _ item -> Continue(item + 1)) -1 + + result |> should equal -1 + } + + [)>] + let ``TaskSeq-foldUntilAsync returns initial state when empty`` variant = task { + let! result = + Gen.getEmptyVariant variant + |> TaskSeq.foldUntilAsync (fun _ item -> task { return Continue(item + 1) }) -1 + + result |> should equal -1 + } + + [)>] + let ``TaskSeq-foldUntil does not call folder when empty`` variant = task { + let mutable called = false + + let! _ = + Gen.getEmptyVariant variant + |> TaskSeq.foldUntil + (fun state _ -> + called <- true + Continue state) + 0 + + called |> should be False + } + + [)>] + let ``TaskSeq-foldUntilAsync does not call folder when empty`` variant = task { + let mutable called = false + + let! _ = + Gen.getEmptyVariant variant + |> TaskSeq.foldUntilAsync + (fun state _ -> task { + called <- true + return Continue state + }) + 0 + + called |> should be False + } + +module Functionality = + [] + let ``TaskSeq-foldUntil with all Continue behaves like fold`` () = task { + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldUntil (fun acc item -> Continue(acc + item)) 0 + + result |> should equal 15 + } + + [] + let ``TaskSeq-foldUntilAsync with all Continue behaves like foldAsync`` () = task { + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldUntilAsync (fun acc item -> task { return Continue(acc + item) }) 0 + + result |> should equal 15 + } + + [] + let ``TaskSeq-foldUntil is left-associative like fold`` () = task { + let! result = + TaskSeq.ofList [ "b"; "c"; "d" ] + |> TaskSeq.foldUntil (fun acc item -> Continue(acc + item)) "a" + + result |> should equal "abcd" + } + + [] + let ``TaskSeq-foldUntilAsync is left-associative like foldAsync`` () = task { + let! result = + TaskSeq.ofList [ "b"; "c"; "d" ] + |> TaskSeq.foldUntilAsync (fun acc item -> task { return Continue(acc + item) }) "a" + + result |> should equal "abcd" + } + +module Halt = + [] + let ``TaskSeq-foldUntil Halt on first element stops immediately`` () = task { + let mutable callCount = 0 + + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldUntil + (fun _ item -> + callCount <- callCount + 1 + Halt item) + 0 + + result |> should equal 1 + callCount |> should equal 1 + } + + [] + let ``TaskSeq-foldUntilAsync Halt on first element stops immediately`` () = task { + let mutable callCount = 0 + + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldUntilAsync + (fun _ item -> task { + callCount <- callCount + 1 + return Halt item + }) + 0 + + result |> should equal 1 + callCount |> should equal 1 + } + + [] + let ``TaskSeq-foldUntil halts mid-sequence, preserving halt state`` () = task { + // Sum until running total exceeds 5, then halt with the overshoot. + let mutable callCount = 0 + + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldUntil + (fun acc item -> + callCount <- callCount + 1 + let next = acc + item + if next > 5 then Halt next else Continue next) + 0 + + // 1, 3, 6 — halts on the third element (6 > 5) + result |> should equal 6 + callCount |> should equal 3 + } + + [] + let ``TaskSeq-foldUntilAsync halts mid-sequence, preserving halt state`` () = task { + let mutable callCount = 0 + + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldUntilAsync + (fun acc item -> task { + callCount <- callCount + 1 + let next = acc + item + return if next > 5 then Halt next else Continue next + }) + 0 + + result |> should equal 6 + callCount |> should equal 3 + } + + [] + let ``TaskSeq-foldUntil does not enumerate past the halt element`` () = task { + // Source has a side effect per pulled element; halt on the 2nd. + let mutable pulled = 0 + + let source = taskSeq { + for i in 1..5 do + pulled <- pulled + 1 + yield i + } + + let! _ = + source + |> TaskSeq.foldUntil (fun _ item -> if item = 2 then Halt item else Continue item) 0 + + // The source yielded 1 (Continue), then 2 (Halt) — we must not pull 3. + pulled |> should equal 2 + } + + [] + let ``TaskSeq-foldUntilAsync does not enumerate past the halt element`` () = task { + let mutable pulled = 0 + + let source = taskSeq { + for i in 1..5 do + pulled <- pulled + 1 + yield i + } + + let! _ = + source + |> TaskSeq.foldUntilAsync (fun _ item -> task { return if item = 2 then Halt item else Continue item }) 0 + + pulled |> should equal 2 + } + + [] + let ``TaskSeq-foldUntil on last-element Halt is equivalent to fold`` () = task { + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.foldUntil + (fun acc item -> + let next = acc + item + if item = 5 then Halt next else Continue next) + 0 + + result |> should equal 15 + } diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fs b/src/FSharp.Control.TaskSeq/TaskSeq.fs index d3a94f9..5cf1b3c 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 foldUntil folder state source = Internal.foldUntil (FoldUntilAction folder) state source + + static member foldUntilAsync folder state source = Internal.foldUntil (AsyncFoldUntilAction 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..31c88b8 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi @@ -1865,6 +1865,42 @@ 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, with the ability to stop + /// early. The folder returns Continue newState to keep consuming or Halt newState to + /// stop iteration immediately; in either case the state is updated. When the folder halts, no further + /// elements of the input are enumerated. + /// If the folder function is asynchronous, consider using + /// . + /// + /// + /// A function that updates the state and decides whether to continue or halt. + /// The initial state. + /// The input sequence. + /// The state object, either after the folder halts or after the whole sequence has been consumed. + /// Thrown when the input task sequence is null. + static member foldUntil: + folder: ('State -> 'T -> FoldStep<'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, with the ability + /// to stop early. The folder returns Continue newState to keep consuming or Halt newState + /// to stop iteration immediately; in either case the state is updated. When the folder halts, no further + /// elements of the input are enumerated. + /// If the folder function is synchronous, consider using + /// . + /// + /// + /// A function that updates the state and decides whether to continue or halt. + /// The initial state. + /// The input sequence. + /// The state object, either after the folder halts or after the whole sequence has been consumed. + /// Thrown when the input task sequence is null. + static member foldUntilAsync: + folder: ('State -> 'T -> #Task>) -> 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..5921010 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -34,6 +34,19 @@ type internal FolderAction<'T, 'State, 'TaskState when 'TaskState :> Task<'State | FolderAction of state_action: ('State -> 'T -> 'State) | AsyncFolderAction of async_state_action: ('State -> 'T -> 'TaskState) +/// The result of a single folder step in or +/// : Continue threads the new state and +/// keeps consuming, Halt records the final state and stops iteration. +[] +type FoldStep<'State> = + | Continue of continue_state: 'State + | Halt of halt_state: 'State + +[] +type internal FoldUntilAction<'T, 'State, 'TaskStep when 'TaskStep :> Task>> = + | FoldUntilAction of fold_until_action: ('State -> 'T -> FoldStep<'State>) + | AsyncFoldUntilAction of async_fold_until_action: ('State -> 'T -> 'TaskStep) + [] type internal ChooserAction<'T, 'U, 'TaskOption when 'TaskOption :> Task<'U option>> = | TryPick of try_pick: ('T -> 'U option) @@ -409,6 +422,44 @@ module internal TaskSeqInternal = return result } + let foldUntil folder initial (source: TaskSeq<_>) = + checkNonNull (nameof source) source + + task { + use e = source.GetAsyncEnumerator CancellationToken.None + let mutable result = initial + let mutable running = true + + match folder with + | FoldUntilAction folder -> + while running do + let! hasNext = e.MoveNextAsync() + + if hasNext then + match folder result e.Current with + | Continue s -> result <- s + | Halt s -> + result <- s + running <- false + else + running <- false + + | AsyncFoldUntilAction folder -> + while running do + let! hasNext = e.MoveNextAsync() + + if hasNext then + match! folder result e.Current with + | Continue s -> result <- s + | Halt s -> + result <- s + running <- false + else + running <- false + + return result + } + let scan folder initial (source: TaskSeq<_>) = checkNonNull (nameof source) source From bbf9125c39b30fa5e3ef0f6005fe7f658ab6f23b Mon Sep 17 00:00:00 2001 From: Onur Gumus Date: Tue, 21 Apr 2026 13:39:53 +0200 Subject: [PATCH 2/3] refactor: replace FoldStep DU with foldWhile predicate+folder API Reworks the early-termination fold added in 7bde226 to match the conventions of FSharp.Control.TaskSeq: bool-returning predicate + plain folder, and no new public DU. Every other early-termination API in the library (takeWhile, skipWhile, tryFind, forall, exists) uses this shape; every DU in the source is internal. FoldStep<'State> would have been the only public DU, which is the inconsistency @dsyme flagged on the PR. API: - TaskSeq.foldWhile : ('State -> 'T -> bool) -> ('State -> 'T -> 'State) -> 'State -> TaskSeq<'T> -> Task<'State> - TaskSeq.foldWhileAsync : ('State -> 'T -> #Task) -> ('State -> 'T -> #Task<'State>) -> 'State -> TaskSeq<'T> -> Task<'State> Semantics (match takeWhile, exclusive): the predicate is evaluated against (currentState, nextElement) before that element is folded. If false, iteration halts without folding that element and the source is not enumerated further. Removes: - public FoldStep<'State> DU - internal FoldUntilAction DU - TaskSeq.foldUntil / foldUntilAsync Tests: rewritten for exclusive semantics (halting element not folded, predicate-call / folder-call counts asserted separately). Full test suite green (5277 passed). --- release-notes.txt | 2 +- .../FSharp.Control.TaskSeq.Test.fsproj | 2 +- .../TaskSeq.FoldUntil.Tests.fs | 226 -------------- .../TaskSeq.FoldWhile.Tests.fs | 275 ++++++++++++++++++ src/FSharp.Control.TaskSeq/TaskSeq.fs | 4 +- src/FSharp.Control.TaskSeq/TaskSeq.fsi | 45 +-- src/FSharp.Control.TaskSeq/TaskSeqInternal.fs | 63 ++-- 7 files changed, 333 insertions(+), 284 deletions(-) delete mode 100644 src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldUntil.Tests.fs create mode 100644 src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldWhile.Tests.fs diff --git a/release-notes.txt b/release-notes.txt index cfa8269..4a1d5ec 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,7 +2,7 @@ Release notes: Unreleased - - adds TaskSeq.foldUntil and TaskSeq.foldUntilAsync: fold with early termination via a FoldStep<'State> DU (Continue / Halt). The underlying sequence is not enumerated past the element that caused Halt. + - 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 16759fb..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,7 +28,7 @@ - + diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldUntil.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldUntil.Tests.fs deleted file mode 100644 index 5923f2b..0000000 --- a/src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldUntil.Tests.fs +++ /dev/null @@ -1,226 +0,0 @@ -module TaskSeq.Tests.FoldUntil - -open Xunit -open FsUnit.Xunit - -open FSharp.Control - -// -// TaskSeq.foldUntil -// TaskSeq.foldUntilAsync -// - -module EmptySeq = - [] - let ``Null source is invalid`` () = - assertNullArg - <| fun () -> TaskSeq.foldUntil (fun _ _ -> Continue 42) 0 null - - assertNullArg - <| fun () -> TaskSeq.foldUntilAsync (fun _ _ -> Task.fromResult (Continue 42)) 0 null - - [)>] - let ``TaskSeq-foldUntil returns initial state when empty`` variant = task { - let! result = - Gen.getEmptyVariant variant - |> TaskSeq.foldUntil (fun _ item -> Continue(item + 1)) -1 - - result |> should equal -1 - } - - [)>] - let ``TaskSeq-foldUntilAsync returns initial state when empty`` variant = task { - let! result = - Gen.getEmptyVariant variant - |> TaskSeq.foldUntilAsync (fun _ item -> task { return Continue(item + 1) }) -1 - - result |> should equal -1 - } - - [)>] - let ``TaskSeq-foldUntil does not call folder when empty`` variant = task { - let mutable called = false - - let! _ = - Gen.getEmptyVariant variant - |> TaskSeq.foldUntil - (fun state _ -> - called <- true - Continue state) - 0 - - called |> should be False - } - - [)>] - let ``TaskSeq-foldUntilAsync does not call folder when empty`` variant = task { - let mutable called = false - - let! _ = - Gen.getEmptyVariant variant - |> TaskSeq.foldUntilAsync - (fun state _ -> task { - called <- true - return Continue state - }) - 0 - - called |> should be False - } - -module Functionality = - [] - let ``TaskSeq-foldUntil with all Continue behaves like fold`` () = task { - let! result = - TaskSeq.ofList [ 1; 2; 3; 4; 5 ] - |> TaskSeq.foldUntil (fun acc item -> Continue(acc + item)) 0 - - result |> should equal 15 - } - - [] - let ``TaskSeq-foldUntilAsync with all Continue behaves like foldAsync`` () = task { - let! result = - TaskSeq.ofList [ 1; 2; 3; 4; 5 ] - |> TaskSeq.foldUntilAsync (fun acc item -> task { return Continue(acc + item) }) 0 - - result |> should equal 15 - } - - [] - let ``TaskSeq-foldUntil is left-associative like fold`` () = task { - let! result = - TaskSeq.ofList [ "b"; "c"; "d" ] - |> TaskSeq.foldUntil (fun acc item -> Continue(acc + item)) "a" - - result |> should equal "abcd" - } - - [] - let ``TaskSeq-foldUntilAsync is left-associative like foldAsync`` () = task { - let! result = - TaskSeq.ofList [ "b"; "c"; "d" ] - |> TaskSeq.foldUntilAsync (fun acc item -> task { return Continue(acc + item) }) "a" - - result |> should equal "abcd" - } - -module Halt = - [] - let ``TaskSeq-foldUntil Halt on first element stops immediately`` () = task { - let mutable callCount = 0 - - let! result = - TaskSeq.ofList [ 1; 2; 3; 4; 5 ] - |> TaskSeq.foldUntil - (fun _ item -> - callCount <- callCount + 1 - Halt item) - 0 - - result |> should equal 1 - callCount |> should equal 1 - } - - [] - let ``TaskSeq-foldUntilAsync Halt on first element stops immediately`` () = task { - let mutable callCount = 0 - - let! result = - TaskSeq.ofList [ 1; 2; 3; 4; 5 ] - |> TaskSeq.foldUntilAsync - (fun _ item -> task { - callCount <- callCount + 1 - return Halt item - }) - 0 - - result |> should equal 1 - callCount |> should equal 1 - } - - [] - let ``TaskSeq-foldUntil halts mid-sequence, preserving halt state`` () = task { - // Sum until running total exceeds 5, then halt with the overshoot. - let mutable callCount = 0 - - let! result = - TaskSeq.ofList [ 1; 2; 3; 4; 5 ] - |> TaskSeq.foldUntil - (fun acc item -> - callCount <- callCount + 1 - let next = acc + item - if next > 5 then Halt next else Continue next) - 0 - - // 1, 3, 6 — halts on the third element (6 > 5) - result |> should equal 6 - callCount |> should equal 3 - } - - [] - let ``TaskSeq-foldUntilAsync halts mid-sequence, preserving halt state`` () = task { - let mutable callCount = 0 - - let! result = - TaskSeq.ofList [ 1; 2; 3; 4; 5 ] - |> TaskSeq.foldUntilAsync - (fun acc item -> task { - callCount <- callCount + 1 - let next = acc + item - return if next > 5 then Halt next else Continue next - }) - 0 - - result |> should equal 6 - callCount |> should equal 3 - } - - [] - let ``TaskSeq-foldUntil does not enumerate past the halt element`` () = task { - // Source has a side effect per pulled element; halt on the 2nd. - let mutable pulled = 0 - - let source = taskSeq { - for i in 1..5 do - pulled <- pulled + 1 - yield i - } - - let! _ = - source - |> TaskSeq.foldUntil (fun _ item -> if item = 2 then Halt item else Continue item) 0 - - // The source yielded 1 (Continue), then 2 (Halt) — we must not pull 3. - pulled |> should equal 2 - } - - [] - let ``TaskSeq-foldUntilAsync does not enumerate past the halt element`` () = task { - let mutable pulled = 0 - - let source = taskSeq { - for i in 1..5 do - pulled <- pulled + 1 - yield i - } - - let! _ = - source - |> TaskSeq.foldUntilAsync (fun _ item -> task { return if item = 2 then Halt item else Continue item }) 0 - - pulled |> should equal 2 - } - - [] - let ``TaskSeq-foldUntil on last-element Halt is equivalent to fold`` () = task { - let! result = - TaskSeq.ofList [ 1; 2; 3; 4; 5 ] - |> TaskSeq.foldUntil - (fun acc item -> - let next = acc + item - if item = 5 then Halt next else Continue next) - 0 - - result |> should equal 15 - } 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..07547e5 --- /dev/null +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldWhile.Tests.fs @@ -0,0 +1,275 @@ +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 5cf1b3c..6bc1387 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fs @@ -540,9 +540,9 @@ 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 foldUntil folder state source = Internal.foldUntil (FoldUntilAction folder) state source + static member foldWhile predicate folder state source = Internal.foldWhile predicate folder state source - static member foldUntilAsync folder state source = Internal.foldUntil (AsyncFoldUntilAction 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 diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fsi b/src/FSharp.Control.TaskSeq/TaskSeq.fsi index 31c88b8..324db4b 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi @@ -1867,39 +1867,44 @@ type TaskSeq = /// /// Applies the function to each element in the task sequence, threading an - /// accumulator of type through the computation, with the ability to stop - /// early. The folder returns Continue newState to keep consuming or Halt newState to - /// stop iteration immediately; in either case the state is updated. When the folder halts, no further - /// elements of the input are enumerated. - /// If the folder function is asynchronous, consider using - /// . + /// 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 updates the state and decides whether to continue or halt. + /// 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, either after the folder halts or after the whole sequence has been consumed. + /// The state object after iteration halted, or after the whole sequence was consumed. /// Thrown when the input task sequence is null. - static member foldUntil: - folder: ('State -> 'T -> FoldStep<'State>) -> state: 'State -> source: TaskSeq<'T> -> Task<'State> + 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, with the ability - /// to stop early. The folder returns Continue newState to keep consuming or Halt newState - /// to stop iteration immediately; in either case the state is updated. When the folder halts, no further - /// elements of the input are enumerated. - /// If the folder function is synchronous, consider using - /// . + /// 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 . /// /// - /// A function that updates the state and decides whether to continue or halt. + /// 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, either after the folder halts or after the whole sequence has been consumed. + /// The state object after iteration halted, or after the whole sequence was consumed. /// Thrown when the input task sequence is null. - static member foldUntilAsync: - folder: ('State -> 'T -> #Task>) -> state: 'State -> source: TaskSeq<'T> -> Task<'State> + 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. diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs index 5921010..8b50a00 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -34,19 +34,6 @@ type internal FolderAction<'T, 'State, 'TaskState when 'TaskState :> Task<'State | FolderAction of state_action: ('State -> 'T -> 'State) | AsyncFolderAction of async_state_action: ('State -> 'T -> 'TaskState) -/// The result of a single folder step in or -/// : Continue threads the new state and -/// keeps consuming, Halt records the final state and stops iteration. -[] -type FoldStep<'State> = - | Continue of continue_state: 'State - | Halt of halt_state: 'State - -[] -type internal FoldUntilAction<'T, 'State, 'TaskStep when 'TaskStep :> Task>> = - | FoldUntilAction of fold_until_action: ('State -> 'T -> FoldStep<'State>) - | AsyncFoldUntilAction of async_fold_until_action: ('State -> 'T -> 'TaskStep) - [] type internal ChooserAction<'T, 'U, 'TaskOption when 'TaskOption :> Task<'U option>> = | TryPick of try_pick: ('T -> 'U option) @@ -422,7 +409,7 @@ module internal TaskSeqInternal = return result } - let foldUntil folder initial (source: TaskSeq<_>) = + let foldWhile predicate folder initial (source: TaskSeq<_>) = checkNonNull (nameof source) source task { @@ -430,32 +417,40 @@ module internal TaskSeqInternal = let mutable result = initial let mutable running = true - match folder with - | FoldUntilAction folder -> - while running do - let! hasNext = e.MoveNextAsync() - - if hasNext then - match folder result e.Current with - | Continue s -> result <- s - | Halt s -> - result <- s - running <- false + 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 + } - | AsyncFoldUntilAction folder -> - while running do - let! hasNext = e.MoveNextAsync() + let foldWhileAsync predicate folder initial (source: TaskSeq<_>) = + checkNonNull (nameof source) source - if hasNext then - match! folder result e.Current with - | Continue s -> result <- s - | Halt s -> - result <- s - running <- false + 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 } From e83d194d5e403695e1cd697c4888757aee2e2489 Mon Sep 17 00:00:00 2001 From: Onur Gumus Date: Tue, 21 Apr 2026 14:12:27 +0200 Subject: [PATCH 3/3] style: run fantomas on foldWhile refactor --- .../TaskSeq.FoldWhile.Tests.fs | 27 ++++--------------- src/FSharp.Control.TaskSeq/TaskSeq.fsi | 8 ++++-- src/FSharp.Control.TaskSeq/TaskSeqInternal.fs | 1 + 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldWhile.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldWhile.Tests.fs index 07547e5..f8deadc 100644 --- a/src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldWhile.Tests.fs +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.FoldWhile.Tests.fs @@ -21,12 +21,7 @@ module EmptySeq = <| 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 + <| 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 { @@ -41,10 +36,7 @@ module EmptySeq = 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 + |> TaskSeq.foldWhileAsync (fun _ _ -> Task.fromResult true) (fun _ item -> Task.fromResult (item + 1)) -1 result |> should equal -1 } @@ -105,10 +97,7 @@ module Functionality = 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 + |> TaskSeq.foldWhileAsync (fun _ _ -> Task.fromResult true) (fun acc item -> Task.fromResult (acc + item)) 0 result |> should equal 15 } @@ -126,10 +115,7 @@ module Functionality = 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" + |> TaskSeq.foldWhileAsync (fun _ _ -> Task.fromResult true) (fun acc item -> Task.fromResult (acc + item)) "a" result |> should equal "abcd" } @@ -257,10 +243,7 @@ module Halt = let! _ = source - |> TaskSeq.foldWhileAsync - (fun _ item -> Task.fromResult (item < 3)) - (fun acc item -> Task.fromResult (acc + item)) - 0 + |> TaskSeq.foldWhileAsync (fun _ item -> Task.fromResult (item < 3)) (fun acc item -> Task.fromResult (acc + item)) 0 pulled |> should equal 3 } diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fsi b/src/FSharp.Control.TaskSeq/TaskSeq.fsi index 324db4b..6f13363 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi @@ -1883,7 +1883,9 @@ type TaskSeq = static member foldWhile: predicate: ('State -> 'T -> bool) -> folder: ('State -> 'T -> 'State) -> - state: 'State -> source: TaskSeq<'T> -> Task<'State> + state: 'State -> + source: TaskSeq<'T> -> + Task<'State> /// /// Applies the asynchronous function to each element in the task sequence, @@ -1904,7 +1906,9 @@ type TaskSeq = static member foldWhileAsync: predicate: ('State -> 'T -> #Task) -> folder: ('State -> 'T -> #Task<'State>) -> - state: 'State -> source: TaskSeq<'T> -> Task<'State> + state: 'State -> + source: TaskSeq<'T> -> + Task<'State> /// /// Like , but returns the sequence of intermediate results and the final result. diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs index 8b50a00..0f6819b 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -444,6 +444,7 @@ module internal TaskSeqInternal = if hasNext then let! keepGoing = predicate result e.Current + if keepGoing then let! newState = folder result e.Current result <- newState