Skip to content

Commit 468e875

Browse files
authored
Merge pull request #300 from fsprojects/repo-assist/feat-unfold-2026-03-80824e6b5ff14272
[Repo Assist] feat: add TaskSeq.unfold and TaskSeq.unfoldAsync (ref #289)
2 parents 0bdf1d6 + 53d4c28 commit 468e875

File tree

5 files changed

+218
-0
lines changed

5 files changed

+218
-0
lines changed

src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
<Compile Include="TaskSeq.Head.Tests.fs" />
3232
<Compile Include="TaskSeq.Indexed.Tests.fs" />
3333
<Compile Include="TaskSeq.Init.Tests.fs" />
34+
<Compile Include="TaskSeq.Unfold.Tests.fs" />
3435
<Compile Include="TaskSeq.InsertAt.Tests.fs" />
3536
<Compile Include="TaskSeq.IsEmpty.fs" />
3637
<Compile Include="TaskSeq.Item.Tests.fs" />
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
module TaskSeq.Tests.Unfold
2+
3+
open Xunit
4+
open FsUnit.Xunit
5+
6+
open FSharp.Control
7+
8+
//
9+
// TaskSeq.unfold
10+
// TaskSeq.unfoldAsync
11+
//
12+
13+
module EmptySeq =
14+
[<Fact>]
15+
let ``TaskSeq-unfold generator returning None immediately yields empty sequence`` () = task {
16+
let! result = TaskSeq.unfold (fun _ -> None) 0 |> TaskSeq.toArrayAsync
17+
18+
result |> should be Empty
19+
}
20+
21+
[<Fact>]
22+
let ``TaskSeq-unfoldAsync generator returning None immediately yields empty sequence`` () = task {
23+
let! result =
24+
TaskSeq.unfoldAsync (fun _ -> task { return None }) 0
25+
|> TaskSeq.toArrayAsync
26+
27+
result |> should be Empty
28+
}
29+
30+
module Functionality =
31+
[<Fact>]
32+
let ``TaskSeq-unfold generates a finite sequence`` () = task {
33+
// unfold 0..9
34+
let! result =
35+
TaskSeq.unfold (fun n -> if n < 10 then Some(n, n + 1) else None) 0
36+
|> TaskSeq.toArrayAsync
37+
38+
result |> should equal [| 0..9 |]
39+
}
40+
41+
[<Fact>]
42+
let ``TaskSeq-unfoldAsync generates a finite sequence`` () = task {
43+
let! result =
44+
TaskSeq.unfoldAsync (fun n -> task { return if n < 10 then Some(n, n + 1) else None }) 0
45+
|> TaskSeq.toArrayAsync
46+
47+
result |> should equal [| 0..9 |]
48+
}
49+
50+
[<Fact>]
51+
let ``TaskSeq-unfold generates a singleton sequence`` () = task {
52+
let! result =
53+
TaskSeq.unfold (fun s -> if s = 0 then Some(42, 1) else None) 0
54+
|> TaskSeq.toArrayAsync
55+
56+
result |> should equal [| 42 |]
57+
}
58+
59+
[<Fact>]
60+
let ``TaskSeq-unfoldAsync generates a singleton sequence`` () = task {
61+
let! result =
62+
TaskSeq.unfoldAsync (fun s -> task { return if s = 0 then Some(42, 1) else None }) 0
63+
|> TaskSeq.toArrayAsync
64+
65+
result |> should equal [| 42 |]
66+
}
67+
68+
[<Fact>]
69+
let ``TaskSeq-unfold uses state correctly to thread accumulator`` () = task {
70+
// Fibonacci: state = (a, b), yield a, new state = (b, a+b)
71+
let! fibs =
72+
TaskSeq.unfold (fun (a, b) -> if a > 100 then None else Some(a, (b, a + b))) (1, 1)
73+
|> TaskSeq.toArrayAsync
74+
75+
fibs
76+
|> should equal [| 1; 1; 2; 3; 5; 8; 13; 21; 34; 55; 89 |]
77+
}
78+
79+
[<Fact>]
80+
let ``TaskSeq-unfoldAsync uses state correctly to thread accumulator`` () = task {
81+
let! fibs =
82+
TaskSeq.unfoldAsync (fun (a, b) -> task { return if a > 100 then None else Some(a, (b, a + b)) }) (1, 1)
83+
|> TaskSeq.toArrayAsync
84+
85+
fibs
86+
|> should equal [| 1; 1; 2; 3; 5; 8; 13; 21; 34; 55; 89 |]
87+
}
88+
89+
[<Fact>]
90+
let ``TaskSeq-unfold can be truncated to limit infinite-like sequences`` () = task {
91+
// counters counting from 1 upward, take first 100
92+
let! result =
93+
TaskSeq.unfold (fun n -> Some(n, n + 1)) 1
94+
|> TaskSeq.take 100
95+
|> TaskSeq.toArrayAsync
96+
97+
result |> should equal [| 1..100 |]
98+
result |> Array.length |> should equal 100
99+
}
100+
101+
[<Fact>]
102+
let ``TaskSeq-unfoldAsync can be truncated to limit infinite-like sequences`` () = task {
103+
let! result =
104+
TaskSeq.unfoldAsync (fun n -> task { return Some(n, n + 1) }) 1
105+
|> TaskSeq.take 100
106+
|> TaskSeq.toArrayAsync
107+
108+
result |> should equal [| 1..100 |]
109+
result |> Array.length |> should equal 100
110+
}
111+
112+
[<Fact>]
113+
let ``TaskSeq-unfold generates string sequences from state`` () = task {
114+
// build "A", "B", ..., "Z"
115+
let! letters =
116+
TaskSeq.unfold (fun c -> if c > int 'Z' then None else Some(string (char c), c + 1)) (int 'A')
117+
|> TaskSeq.toArrayAsync
118+
119+
letters
120+
|> should equal [| for c in 'A' .. 'Z' -> string c |]
121+
}
122+
123+
[<Fact>]
124+
let ``TaskSeq-unfold calls generator exactly once per element plus one final None call`` () = task {
125+
let mutable callCount = 0
126+
127+
let! result =
128+
TaskSeq.unfold
129+
(fun n ->
130+
callCount <- callCount + 1
131+
132+
if n < 5 then Some(n, n + 1) else None)
133+
0
134+
|> TaskSeq.toArrayAsync
135+
136+
result |> should equal [| 0..4 |]
137+
callCount |> should equal 6 // 5 Some + 1 None
138+
}
139+
140+
[<Fact>]
141+
let ``TaskSeq-unfold re-iterating restarts from initial state`` () = task {
142+
let ts = TaskSeq.unfold (fun n -> if n < 5 then Some(n, n + 1) else None) 0
143+
144+
let! first = ts |> TaskSeq.toArrayAsync
145+
let! second = ts |> TaskSeq.toArrayAsync
146+
147+
first |> should equal second
148+
first |> should equal [| 0..4 |]
149+
}
150+
151+
[<Fact>]
152+
let ``TaskSeq-unfoldAsync re-iterating restarts from initial state`` () = task {
153+
let ts = TaskSeq.unfoldAsync (fun n -> task { return if n < 5 then Some(n, n + 1) else None }) 0
154+
155+
let! first = ts |> TaskSeq.toArrayAsync
156+
let! second = ts |> TaskSeq.toArrayAsync
157+
158+
first |> should equal second
159+
first |> should equal [| 0..4 |]
160+
}

src/FSharp.Control.TaskSeq/TaskSeq.fs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ type TaskSeq private () =
175175
static member initAsync count initializer = Internal.init (Some count) (InitActionAsync initializer)
176176
static member initInfiniteAsync initializer = Internal.init None (InitActionAsync initializer)
177177

178+
static member unfold generator state = Internal.unfold generator state
179+
static member unfoldAsync generator state = Internal.unfoldAsync generator state
180+
178181
static member delay(generator: unit -> TaskSeq<'T>) =
179182
{ new IAsyncEnumerable<'T> with
180183
member _.GetAsyncEnumerator(ct) = generator().GetAsyncEnumerator(ct)

src/FSharp.Control.TaskSeq/TaskSeq.fsi

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,34 @@ type TaskSeq =
211211
/// <returns>The resulting task sequence.</returns>
212212
static member initInfiniteAsync: initializer: (int -> #Task<'T>) -> TaskSeq<'T>
213213

214+
/// <summary>
215+
/// Returns a task sequence generated by applying the generator function to a state value, until it returns <c>None</c>.
216+
/// Each call to <paramref name="generator" /> returns either <c>None</c>, which terminates the sequence, or
217+
/// <c>Some(element, newState)</c>, which yields <paramref name="element" /> and updates the state for the next call.
218+
/// Unlike <see cref="TaskSeq.init" />, the number of elements need not be known in advance.
219+
/// If the generator function is asynchronous, consider using <see cref="TaskSeq.unfoldAsync" />.
220+
/// </summary>
221+
///
222+
/// <param name="generator">A function that takes the current state and returns either <c>None</c> to terminate,
223+
/// or <c>Some(element, newState)</c> to yield an element and continue with a new state.</param>
224+
/// <param name="state">The initial state value.</param>
225+
/// <returns>The resulting task sequence.</returns>
226+
static member unfold: generator: ('State -> ('T * 'State) option) -> state: 'State -> TaskSeq<'T>
227+
228+
/// <summary>
229+
/// Returns a task sequence generated by applying the asynchronous generator function to a state value, until it
230+
/// returns <c>None</c>. Each call to <paramref name="generator" /> returns either <c>None</c>, which terminates the
231+
/// sequence, or <c>Some(element, newState)</c>, which yields <paramref name="element" /> and updates the state.
232+
/// Unlike <see cref="TaskSeq.initAsync" />, the number of elements need not be known in advance.
233+
/// If the generator function is synchronous, consider using <see cref="TaskSeq.unfold" />.
234+
/// </summary>
235+
///
236+
/// <param name="generator">An async function that takes the current state and returns either <c>None</c> to terminate,
237+
/// or <c>Some(element, newState)</c> to yield an element and continue with a new state.</param>
238+
/// <param name="state">The initial state value.</param>
239+
/// <returns>The resulting task sequence.</returns>
240+
static member unfoldAsync: generator: ('State -> Task<('T * 'State) option>) -> state: 'State -> TaskSeq<'T>
241+
214242
/// <summary>
215243
/// Combines the given task sequence of task sequences and concatenates them end-to-end, to form a
216244
/// new flattened, single task sequence, like <paramref name="TaskSeq.collect id"/>. Each task sequence is

src/FSharp.Control.TaskSeq/TaskSeqInternal.fs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,32 @@ module internal TaskSeqInternal =
300300

301301
}
302302

303+
let unfold generator state = taskSeq {
304+
let mutable go = true
305+
let mutable currentState = state
306+
307+
while go do
308+
match generator currentState with
309+
| None -> go <- false
310+
| Some(value, nextState) ->
311+
yield value
312+
currentState <- nextState
313+
}
314+
315+
let unfoldAsync generator state = taskSeq {
316+
let mutable go = true
317+
let mutable currentState = state
318+
319+
while go do
320+
let! result = (generator currentState: Task<_>)
321+
322+
match result with
323+
| None -> go <- false
324+
| Some(value, nextState) ->
325+
yield value
326+
currentState <- nextState
327+
}
328+
303329
let iter action (source: TaskSeq<_>) =
304330
checkNonNull (nameof source) source
305331

0 commit comments

Comments
 (0)