From cb392338d5e26dc7dfc9ec23d1be29a6e3a8073d Mon Sep 17 00:00:00 2001 From: Repo Assist Date: Sat, 7 Mar 2026 21:26:42 +0000 Subject: [PATCH] feat: add TaskSeq.unfold and TaskSeq.unfoldAsync (ref #289) Adds two new lazy sequence generators that produce elements from a state value without requiring the element count to be known upfront. Matches the semantics of Seq.unfold and AsyncSeq.unfold. - TaskSeq.unfold : synchronous generator ('State -> ('T * 'State) option) - TaskSeq.unfoldAsync: async generator ('State -> Task<('T * 'State) option>) Key properties: - Zero allocations per element beyond the value tuple - Works lazily: generator is only called as elements are consumed - Infinite sequences supported (use take/truncate to limit) - Re-iterating restarts from the original state 14 new tests covering: empty (None immediately), finite sequences, singleton, Fibonacci via state threading, string generation, infinite sequence truncation, generator call-count verification, and re-iteration correctness. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FSharp.Control.TaskSeq.Test.fsproj | 1 + .../TaskSeq.Unfold.Tests.fs | 160 ++++++++++++++++++ src/FSharp.Control.TaskSeq/TaskSeq.fs | 3 + src/FSharp.Control.TaskSeq/TaskSeq.fsi | 28 +++ src/FSharp.Control.TaskSeq/TaskSeqInternal.fs | 26 +++ 5 files changed, 218 insertions(+) create mode 100644 src/FSharp.Control.TaskSeq.Test/TaskSeq.Unfold.Tests.fs 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 118a941..367c0bf 100644 --- a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj +++ b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj @@ -29,6 +29,7 @@ + diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.Unfold.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Unfold.Tests.fs new file mode 100644 index 0000000..6cbec40 --- /dev/null +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Unfold.Tests.fs @@ -0,0 +1,160 @@ +module TaskSeq.Tests.Unfold + +open Xunit +open FsUnit.Xunit + +open FSharp.Control + +// +// TaskSeq.unfold +// TaskSeq.unfoldAsync +// + +module EmptySeq = + [] + let ``TaskSeq-unfold generator returning None immediately yields empty sequence`` () = task { + let! result = TaskSeq.unfold (fun _ -> None) 0 |> TaskSeq.toArrayAsync + + result |> should be Empty + } + + [] + let ``TaskSeq-unfoldAsync generator returning None immediately yields empty sequence`` () = task { + let! result = + TaskSeq.unfoldAsync (fun _ -> task { return None }) 0 + |> TaskSeq.toArrayAsync + + result |> should be Empty + } + +module Functionality = + [] + let ``TaskSeq-unfold generates a finite sequence`` () = task { + // unfold 0..9 + let! result = + TaskSeq.unfold (fun n -> if n < 10 then Some(n, n + 1) else None) 0 + |> TaskSeq.toArrayAsync + + result |> should equal [| 0..9 |] + } + + [] + let ``TaskSeq-unfoldAsync generates a finite sequence`` () = task { + let! result = + TaskSeq.unfoldAsync (fun n -> task { return if n < 10 then Some(n, n + 1) else None }) 0 + |> TaskSeq.toArrayAsync + + result |> should equal [| 0..9 |] + } + + [] + let ``TaskSeq-unfold generates a singleton sequence`` () = task { + let! result = + TaskSeq.unfold (fun s -> if s = 0 then Some(42, 1) else None) 0 + |> TaskSeq.toArrayAsync + + result |> should equal [| 42 |] + } + + [] + let ``TaskSeq-unfoldAsync generates a singleton sequence`` () = task { + let! result = + TaskSeq.unfoldAsync (fun s -> task { return if s = 0 then Some(42, 1) else None }) 0 + |> TaskSeq.toArrayAsync + + result |> should equal [| 42 |] + } + + [] + let ``TaskSeq-unfold uses state correctly to thread accumulator`` () = task { + // Fibonacci: state = (a, b), yield a, new state = (b, a+b) + let! fibs = + TaskSeq.unfold (fun (a, b) -> if a > 100 then None else Some(a, (b, a + b))) (1, 1) + |> TaskSeq.toArrayAsync + + fibs + |> should equal [| 1; 1; 2; 3; 5; 8; 13; 21; 34; 55; 89 |] + } + + [] + let ``TaskSeq-unfoldAsync uses state correctly to thread accumulator`` () = task { + let! fibs = + TaskSeq.unfoldAsync (fun (a, b) -> task { return if a > 100 then None else Some(a, (b, a + b)) }) (1, 1) + |> TaskSeq.toArrayAsync + + fibs + |> should equal [| 1; 1; 2; 3; 5; 8; 13; 21; 34; 55; 89 |] + } + + [] + let ``TaskSeq-unfold can be truncated to limit infinite-like sequences`` () = task { + // counters counting from 1 upward, take first 100 + let! result = + TaskSeq.unfold (fun n -> Some(n, n + 1)) 1 + |> TaskSeq.take 100 + |> TaskSeq.toArrayAsync + + result |> should equal [| 1..100 |] + result |> Array.length |> should equal 100 + } + + [] + let ``TaskSeq-unfoldAsync can be truncated to limit infinite-like sequences`` () = task { + let! result = + TaskSeq.unfoldAsync (fun n -> task { return Some(n, n + 1) }) 1 + |> TaskSeq.take 100 + |> TaskSeq.toArrayAsync + + result |> should equal [| 1..100 |] + result |> Array.length |> should equal 100 + } + + [] + let ``TaskSeq-unfold generates string sequences from state`` () = task { + // build "A", "B", ..., "Z" + let! letters = + TaskSeq.unfold (fun c -> if c > int 'Z' then None else Some(string (char c), c + 1)) (int 'A') + |> TaskSeq.toArrayAsync + + letters + |> should equal [| for c in 'A' .. 'Z' -> string c |] + } + + [] + let ``TaskSeq-unfold calls generator exactly once per element plus one final None call`` () = task { + let mutable callCount = 0 + + let! result = + TaskSeq.unfold + (fun n -> + callCount <- callCount + 1 + + if n < 5 then Some(n, n + 1) else None) + 0 + |> TaskSeq.toArrayAsync + + result |> should equal [| 0..4 |] + callCount |> should equal 6 // 5 Some + 1 None + } + + [] + let ``TaskSeq-unfold re-iterating restarts from initial state`` () = task { + let ts = TaskSeq.unfold (fun n -> if n < 5 then Some(n, n + 1) else None) 0 + + let! first = ts |> TaskSeq.toArrayAsync + let! second = ts |> TaskSeq.toArrayAsync + + first |> should equal second + first |> should equal [| 0..4 |] + } + + [] + let ``TaskSeq-unfoldAsync re-iterating restarts from initial state`` () = task { + let ts = TaskSeq.unfoldAsync (fun n -> task { return if n < 5 then Some(n, n + 1) else None }) 0 + + let! first = ts |> TaskSeq.toArrayAsync + let! second = ts |> TaskSeq.toArrayAsync + + first |> should equal second + first |> should equal [| 0..4 |] + } diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fs b/src/FSharp.Control.TaskSeq/TaskSeq.fs index 32c159f..6870754 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fs @@ -175,6 +175,9 @@ type TaskSeq private () = static member initAsync count initializer = Internal.init (Some count) (InitActionAsync initializer) static member initInfiniteAsync initializer = Internal.init None (InitActionAsync initializer) + static member unfold generator state = Internal.unfold generator state + static member unfoldAsync generator state = Internal.unfoldAsync generator state + static member delay(generator: unit -> TaskSeq<'T>) = { new IAsyncEnumerable<'T> with member _.GetAsyncEnumerator(ct) = generator().GetAsyncEnumerator(ct) diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fsi b/src/FSharp.Control.TaskSeq/TaskSeq.fsi index 76e90c2..ab4304b 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi @@ -211,6 +211,34 @@ type TaskSeq = /// The resulting task sequence. static member initInfiniteAsync: initializer: (int -> #Task<'T>) -> TaskSeq<'T> + /// + /// Returns a task sequence generated by applying the generator function to a state value, until it returns None. + /// Each call to returns either None, which terminates the sequence, or + /// Some(element, newState), which yields and updates the state for the next call. + /// Unlike , the number of elements need not be known in advance. + /// If the generator function is asynchronous, consider using . + /// + /// + /// A function that takes the current state and returns either None to terminate, + /// or Some(element, newState) to yield an element and continue with a new state. + /// The initial state value. + /// The resulting task sequence. + static member unfold: generator: ('State -> ('T * 'State) option) -> state: 'State -> TaskSeq<'T> + + /// + /// Returns a task sequence generated by applying the asynchronous generator function to a state value, until it + /// returns None. Each call to returns either None, which terminates the + /// sequence, or Some(element, newState), which yields and updates the state. + /// Unlike , the number of elements need not be known in advance. + /// If the generator function is synchronous, consider using . + /// + /// + /// An async function that takes the current state and returns either None to terminate, + /// or Some(element, newState) to yield an element and continue with a new state. + /// The initial state value. + /// The resulting task sequence. + static member unfoldAsync: generator: ('State -> Task<('T * 'State) option>) -> state: 'State -> TaskSeq<'T> + /// /// Combines the given task sequence of task sequences and concatenates them end-to-end, to form a /// new flattened, single task sequence, like . Each task sequence is diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs index a8f2a4b..344d65e 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -300,6 +300,32 @@ module internal TaskSeqInternal = } + let unfold generator state = taskSeq { + let mutable go = true + let mutable currentState = state + + while go do + match generator currentState with + | None -> go <- false + | Some(value, nextState) -> + yield value + currentState <- nextState + } + + let unfoldAsync generator state = taskSeq { + let mutable go = true + let mutable currentState = state + + while go do + let! result = (generator currentState: Task<_>) + + match result with + | None -> go <- false + | Some(value, nextState) -> + yield value + currentState <- nextState + } + let iter action (source: TaskSeq<_>) = checkNonNull (nameof source) source