Skip to content

Commit 43eff83

Browse files
authored
Merge pull request #296 from fsprojects/repo-assist/feat-scan-2026-03-8079b757d4337d53
[Repo Assist] feat: add TaskSeq.scan and TaskSeq.scanAsync (ref #289)
2 parents 468e875 + ffeb50e commit 43eff83

File tree

7 files changed

+228
-1
lines changed

7 files changed

+228
-1
lines changed

AGENTS.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,12 @@ All workflows are in `.github/workflows/`:
102102

103103
## Release Notes
104104

105-
When making changes, update the release notes and bump the version appropriately
105+
**Required**: Every PR that adds features, fixes bugs, or makes user-visible changes **must** include an update to `release-notes.txt`. Add a bullet under the appropriate version heading (currently `0.5.0`). The format is:
106+
107+
```
108+
0.5.0
109+
- adds TaskSeq.myFunction and TaskSeq.myFunctionAsync, #<issue>
110+
- fixes <description>, #<issue>
111+
```
112+
113+
If you are bumping to a new version, also update `Version.props`. PRs that touch library source (`src/FSharp.Control.TaskSeq/`) without updating `release-notes.txt` are incomplete.

release-notes.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ Release notes:
33

44
0.5.0
55
- update engineering to .NET 9/10
6+
- adds TaskSeq.scan and TaskSeq.scanAsync, #289
67
- adds TaskSeq.pairwise, #289
78

89
0.4.0

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
<Compile Include="TaskSeq.FindIndex.Tests.fs" />
2727
<Compile Include="TaskSeq.Find.Tests.fs" />
2828
<Compile Include="TaskSeq.Fold.Tests.fs" />
29+
<Compile Include="TaskSeq.Scan.Tests.fs" />
2930
<Compile Include="TaskSeq.Reduce.Tests.fs" />
3031
<Compile Include="TaskSeq.Forall.Tests.fs" />
3132
<Compile Include="TaskSeq.Head.Tests.fs" />
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
module TaskSeq.Tests.Scan
2+
3+
open Xunit
4+
open FsUnit.Xunit
5+
6+
open FSharp.Control
7+
8+
//
9+
// TaskSeq.scan
10+
// TaskSeq.scanAsync
11+
//
12+
13+
module EmptySeq =
14+
[<Fact>]
15+
let ``Null source is invalid`` () =
16+
assertNullArg
17+
<| fun () -> TaskSeq.scan (fun _ _ -> 42) 0 null
18+
19+
assertNullArg
20+
<| fun () -> TaskSeq.scanAsync (fun _ _ -> Task.fromResult 42) 0 null
21+
22+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
23+
let ``TaskSeq-scan on empty returns singleton initial state`` variant = task {
24+
let! result =
25+
Gen.getEmptyVariant variant
26+
|> TaskSeq.scan (fun acc _ -> acc + 1) 0
27+
|> TaskSeq.toListAsync
28+
29+
result |> should equal [ 0 ]
30+
}
31+
32+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
33+
let ``TaskSeq-scanAsync on empty returns singleton initial state`` variant = task {
34+
let! result =
35+
Gen.getEmptyVariant variant
36+
|> TaskSeq.scanAsync (fun acc _ -> task { return acc + 1 }) 0
37+
|> TaskSeq.toListAsync
38+
39+
result |> should equal [ 0 ]
40+
}
41+
42+
module Functionality =
43+
[<Fact>]
44+
let ``TaskSeq-scan yields initial state then each intermediate state`` () = task {
45+
let! result =
46+
TaskSeq.ofList [ 1; 2; 3; 4; 5 ]
47+
|> TaskSeq.scan (fun acc item -> acc + item) 0
48+
|> TaskSeq.toListAsync
49+
50+
// N=5 elements → N+1=6 output elements
51+
result |> should equal [ 0; 1; 3; 6; 10; 15 ]
52+
}
53+
54+
[<Fact>]
55+
let ``TaskSeq-scanAsync yields initial state then each intermediate state`` () = task {
56+
let! result =
57+
TaskSeq.ofList [ 1; 2; 3; 4; 5 ]
58+
|> TaskSeq.scanAsync (fun acc item -> task { return acc + item }) 0
59+
|> TaskSeq.toListAsync
60+
61+
result |> should equal [ 0; 1; 3; 6; 10; 15 ]
62+
}
63+
64+
[<Fact>]
65+
let ``TaskSeq-scan output length is input length plus one`` () = task {
66+
let input = TaskSeq.ofList [ 'a'; 'b'; 'c' ]
67+
68+
let! result =
69+
input
70+
|> TaskSeq.scan (fun acc c -> acc + string c) ""
71+
|> TaskSeq.toListAsync
72+
73+
result |> should equal [ ""; "a"; "ab"; "abc" ]
74+
}
75+
76+
[<Fact>]
77+
let ``TaskSeq-scanAsync output length is input length plus one`` () = task {
78+
let input = TaskSeq.ofList [ 'a'; 'b'; 'c' ]
79+
80+
let! result =
81+
input
82+
|> TaskSeq.scanAsync (fun acc c -> task { return acc + string c }) ""
83+
|> TaskSeq.toListAsync
84+
85+
result |> should equal [ ""; "a"; "ab"; "abc" ]
86+
}
87+
88+
[<Fact>]
89+
let ``TaskSeq-scan with single element returns two-element result`` () = task {
90+
let! result =
91+
TaskSeq.singleton 42
92+
|> TaskSeq.scan (fun acc item -> acc + item) 10
93+
|> TaskSeq.toListAsync
94+
95+
result |> should equal [ 10; 52 ]
96+
}
97+
98+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
99+
let ``TaskSeq-scan accumulates correctly across variants`` variant = task {
100+
// Input is 1..10; cumulative sums: 0, 1, 3, 6, 10, 15, 21, 28, 36, 45, 55
101+
let! result =
102+
Gen.getSeqImmutable variant
103+
|> TaskSeq.scan (fun acc item -> acc + item) 0
104+
|> TaskSeq.toListAsync
105+
106+
result
107+
|> should equal [ 0; 1; 3; 6; 10; 15; 21; 28; 36; 45; 55 ]
108+
}
109+
110+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
111+
let ``TaskSeq-scanAsync accumulates correctly across variants`` variant = task {
112+
let! result =
113+
Gen.getSeqImmutable variant
114+
|> TaskSeq.scanAsync (fun acc item -> task { return acc + item }) 0
115+
|> TaskSeq.toListAsync
116+
117+
result
118+
|> should equal [ 0; 1; 3; 6; 10; 15; 21; 28; 36; 45; 55 ]
119+
}
120+
121+
module SideEffects =
122+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
123+
let ``TaskSeq-scan second iteration accumulates from fresh start`` variant = task {
124+
let ts = Gen.getSeqWithSideEffect variant
125+
126+
let! first =
127+
ts
128+
|> TaskSeq.scan (fun acc item -> acc + item) 0
129+
|> TaskSeq.toListAsync
130+
131+
first
132+
|> should equal [ 0; 1; 3; 6; 10; 15; 21; 28; 36; 45; 55 ]
133+
134+
let! second =
135+
ts
136+
|> TaskSeq.scan (fun acc item -> acc + item) 0
137+
|> TaskSeq.toListAsync
138+
139+
// side-effect sequences yield next 10 items (11..20)
140+
second
141+
|> should equal [ 0; 11; 23; 36; 50; 65; 81; 98; 116; 135; 155 ]
142+
}
143+
144+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
145+
let ``TaskSeq-scanAsync second iteration accumulates from fresh start`` variant = task {
146+
let ts = Gen.getSeqWithSideEffect variant
147+
148+
let! first =
149+
ts
150+
|> TaskSeq.scanAsync (fun acc item -> task { return acc + item }) 0
151+
|> TaskSeq.toListAsync
152+
153+
first
154+
|> should equal [ 0; 1; 3; 6; 10; 15; 21; 28; 36; 45; 55 ]
155+
156+
let! second =
157+
ts
158+
|> TaskSeq.scanAsync (fun acc item -> task { return acc + item }) 0
159+
|> TaskSeq.toListAsync
160+
161+
second
162+
|> should equal [ 0; 11; 23; 36; 50; 65; 81; 98; 116; 135; 155 ]
163+
}

src/FSharp.Control.TaskSeq/TaskSeq.fs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,5 +410,7 @@ type TaskSeq private () =
410410
static member zip source1 source2 = Internal.zip source1 source2
411411
static member fold folder state source = Internal.fold (FolderAction folder) state source
412412
static member foldAsync folder state source = Internal.fold (AsyncFolderAction folder) state source
413+
static member scan folder state source = Internal.scan (FolderAction folder) state source
414+
static member scanAsync folder state source = Internal.scan (AsyncFolderAction folder) state source
413415
static member reduce folder source = Internal.reduce (FolderAction folder) source
414416
static member reduceAsync folder source = Internal.reduce (AsyncFolderAction folder) source

src/FSharp.Control.TaskSeq/TaskSeq.fsi

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,6 +1389,35 @@ type TaskSeq =
13891389
static member foldAsync:
13901390
folder: ('State -> 'T -> #Task<'State>) -> state: 'State -> source: TaskSeq<'T> -> Task<'State>
13911391

1392+
/// <summary>
1393+
/// Like <see cref="TaskSeq.fold" />, but returns the sequence of intermediate results and the final result.
1394+
/// The first element of the output sequence is always the initial state. If the input task sequence
1395+
/// has <c>N</c> elements, the output task sequence has <c>N + 1</c> elements.
1396+
/// If the folder function <paramref name="folder" /> is asynchronous, consider using <see cref="TaskSeq.scanAsync" />.
1397+
/// </summary>
1398+
///
1399+
/// <param name="folder">A function that updates the state with each element from the sequence.</param>
1400+
/// <param name="state">The initial state.</param>
1401+
/// <param name="source">The input sequence.</param>
1402+
/// <returns>A task sequence of states, starting with the initial state and applying the folder to each element.</returns>
1403+
/// <exception cref="T:ArgumentNullException">Thrown when the input task sequence is null.</exception>
1404+
static member scan: folder: ('State -> 'T -> 'State) -> state: 'State -> source: TaskSeq<'T> -> TaskSeq<'State>
1405+
1406+
/// <summary>
1407+
/// Like <see cref="TaskSeq.foldAsync" />, but returns the sequence of intermediate results and the final result.
1408+
/// The first element of the output sequence is always the initial state. If the input task sequence
1409+
/// has <c>N</c> elements, the output task sequence has <c>N + 1</c> elements.
1410+
/// If the folder function <paramref name="folder" /> is synchronous, consider using <see cref="TaskSeq.scan" />.
1411+
/// </summary>
1412+
///
1413+
/// <param name="folder">A function that updates the state with each element from the sequence.</param>
1414+
/// <param name="state">The initial state.</param>
1415+
/// <param name="source">The input sequence.</param>
1416+
/// <returns>A task sequence of states, starting with the initial state and applying the folder to each element.</returns>
1417+
/// <exception cref="T:ArgumentNullException">Thrown when the input task sequence is null.</exception>
1418+
static member scanAsync:
1419+
folder: ('State -> 'T -> #Task<'State>) -> state: 'State -> source: TaskSeq<'T> -> TaskSeq<'State>
1420+
13921421
/// <summary>
13931422
/// Applies the function <paramref name="folder" /> to each element of the task sequence, threading an accumulator
13941423
/// argument through the computation. The first element is used as the initial state. If the input function is

src/FSharp.Control.TaskSeq/TaskSeqInternal.fs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,29 @@ module internal TaskSeqInternal =
397397
return result
398398
}
399399

400+
let scan folder initial (source: TaskSeq<_>) =
401+
checkNonNull (nameof source) source
402+
403+
match folder with
404+
| FolderAction folder -> taskSeq {
405+
let mutable state = initial
406+
yield state
407+
408+
for item in source do
409+
state <- folder state item
410+
yield state
411+
}
412+
413+
| AsyncFolderAction folder -> taskSeq {
414+
let mutable state = initial
415+
yield state
416+
417+
for item in source do
418+
let! newState = folder state item
419+
state <- newState
420+
yield state
421+
}
422+
400423
let reduce folder (source: TaskSeq<_>) =
401424
checkNonNull (nameof source) source
402425

0 commit comments

Comments
 (0)